Merge commit 'd04dbc7d8c1bf3a6a968a67394512cbf16e24543'
This commit is contained in:
237
bot/agent-math-utils.js
Normal file
237
bot/agent-math-utils.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// bot/agent-math-utils.js
|
||||||
|
// Utility functions for tracking agent activity and emoji heatmaps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track agent trigger counts
|
||||||
|
*/
|
||||||
|
class AgentTracker {
|
||||||
|
constructor() {
|
||||||
|
this.triggers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an agent trigger
|
||||||
|
* @param {string} agentName - The name of the agent triggered
|
||||||
|
*/
|
||||||
|
recordTrigger(agentName) {
|
||||||
|
if (!this.triggers[agentName]) {
|
||||||
|
this.triggers[agentName] = 0;
|
||||||
|
}
|
||||||
|
this.triggers[agentName]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trigger count for a specific agent
|
||||||
|
* @param {string} agentName - The name of the agent
|
||||||
|
* @returns {number} - The trigger count
|
||||||
|
*/
|
||||||
|
getTriggerCount(agentName) {
|
||||||
|
return this.triggers[agentName] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all trigger counts
|
||||||
|
* @returns {Object} - All agent trigger counts
|
||||||
|
*/
|
||||||
|
getAllTriggers() {
|
||||||
|
return { ...this.triggers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all trigger counts
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.triggers = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track emoji usage and generate heatmaps
|
||||||
|
*/
|
||||||
|
class EmojiHeatmap {
|
||||||
|
constructor() {
|
||||||
|
this.counts = {};
|
||||||
|
this.total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an emoji occurrence
|
||||||
|
* @param {string} emoji - The emoji character
|
||||||
|
* @param {number} [count=1] - Number of occurrences to add
|
||||||
|
*/
|
||||||
|
record(emoji, count = 1) {
|
||||||
|
if (!this.counts[emoji]) {
|
||||||
|
this.counts[emoji] = 0;
|
||||||
|
}
|
||||||
|
this.counts[emoji] += count;
|
||||||
|
this.total += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count for a specific emoji
|
||||||
|
* @param {string} emoji - The emoji character
|
||||||
|
* @returns {number} - The count
|
||||||
|
*/
|
||||||
|
getCount(emoji) {
|
||||||
|
return this.counts[emoji] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get percentage for a specific emoji
|
||||||
|
* @param {string} emoji - The emoji character
|
||||||
|
* @returns {number} - The percentage (0-100)
|
||||||
|
*/
|
||||||
|
getPercentage(emoji) {
|
||||||
|
if (this.total === 0) return 0;
|
||||||
|
return ((this.counts[emoji] || 0) / this.total) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all emoji counts
|
||||||
|
* @returns {Object} - All emoji counts
|
||||||
|
*/
|
||||||
|
getAllCounts() {
|
||||||
|
return { ...this.counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get percentage breakdown for all emojis
|
||||||
|
* @returns {Object} - Emoji to percentage mapping
|
||||||
|
*/
|
||||||
|
getPercentageBreakdown() {
|
||||||
|
const breakdown = {};
|
||||||
|
for (const emoji of Object.keys(this.counts)) {
|
||||||
|
breakdown[emoji] = this.getPercentage(emoji);
|
||||||
|
}
|
||||||
|
return breakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count
|
||||||
|
* @returns {number} - Total emoji count
|
||||||
|
*/
|
||||||
|
getTotal() {
|
||||||
|
return this.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset heatmap
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.counts = {};
|
||||||
|
this.total = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a visual progress bar using emojis
|
||||||
|
* @param {Object} options - Progress bar options
|
||||||
|
* @param {number} options.completed - Number of completed items
|
||||||
|
* @param {number} options.inProgress - Number of in-progress items
|
||||||
|
* @param {number} options.total - Total number of items
|
||||||
|
* @param {number} [options.width=10] - Width of progress bar in characters
|
||||||
|
* @returns {string} - Visual progress bar
|
||||||
|
*/
|
||||||
|
function generateProgressBar({ completed, inProgress, total, width = 10 }) {
|
||||||
|
if (total === 0) return "⬜".repeat(width);
|
||||||
|
|
||||||
|
// Clamp values to prevent overflow when completed + inProgress > total
|
||||||
|
const safeCompleted = Math.min(completed, total);
|
||||||
|
const safeInProgress = Math.min(inProgress, total - safeCompleted);
|
||||||
|
|
||||||
|
const completedRatio = safeCompleted / total;
|
||||||
|
const inProgressRatio = safeInProgress / total;
|
||||||
|
|
||||||
|
const completedSlots = Math.round(completedRatio * width);
|
||||||
|
const inProgressSlots = Math.round(inProgressRatio * width);
|
||||||
|
const remainingSlots = width - completedSlots - inProgressSlots;
|
||||||
|
|
||||||
|
return (
|
||||||
|
"✅".repeat(completedSlots) +
|
||||||
|
"🟡".repeat(inProgressSlots) +
|
||||||
|
"⬜".repeat(Math.max(0, remainingSlots))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate sprint progress statistics
|
||||||
|
* @param {Array<Object>} items - Array of sprint items with status
|
||||||
|
* @returns {Object} - Sprint statistics
|
||||||
|
*/
|
||||||
|
function calculateSprintProgress(items) {
|
||||||
|
const stats = {
|
||||||
|
total: items.length,
|
||||||
|
done: 0,
|
||||||
|
inProgress: 0,
|
||||||
|
notStarted: 0,
|
||||||
|
blocked: 0,
|
||||||
|
needsReview: 0,
|
||||||
|
escalation: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const status = item.status?.toLowerCase();
|
||||||
|
switch (status) {
|
||||||
|
case "done":
|
||||||
|
stats.done++;
|
||||||
|
break;
|
||||||
|
case "in progress":
|
||||||
|
stats.inProgress++;
|
||||||
|
break;
|
||||||
|
case "not started":
|
||||||
|
stats.notStarted++;
|
||||||
|
break;
|
||||||
|
case "blocked":
|
||||||
|
stats.blocked++;
|
||||||
|
break;
|
||||||
|
case "needs review":
|
||||||
|
stats.needsReview++;
|
||||||
|
break;
|
||||||
|
case "escalation":
|
||||||
|
stats.escalation++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stats.notStarted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.completionPercentage =
|
||||||
|
stats.total > 0 ? (stats.done / stats.total) * 100 : 0;
|
||||||
|
stats.progressBar = generateProgressBar({
|
||||||
|
completed: stats.done,
|
||||||
|
inProgress: stats.inProgress,
|
||||||
|
total: stats.total
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format emoji statistics as a human-readable report
|
||||||
|
* @param {EmojiHeatmap} heatmap - The emoji heatmap instance
|
||||||
|
* @returns {string} - Formatted report
|
||||||
|
*/
|
||||||
|
function formatHeatmapReport(heatmap) {
|
||||||
|
const breakdown = heatmap.getPercentageBreakdown();
|
||||||
|
const lines = ["📊 Emoji Heatmap Report", ""];
|
||||||
|
|
||||||
|
const entries = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
for (const [emoji, percentage] of entries) {
|
||||||
|
const count = heatmap.getCount(emoji);
|
||||||
|
lines.push(`${emoji} ${percentage.toFixed(1)}% (${count} occurrences)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Total: ${heatmap.getTotal()} emojis tracked`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AgentTracker,
|
||||||
|
EmojiHeatmap,
|
||||||
|
generateProgressBar,
|
||||||
|
calculateSprintProgress,
|
||||||
|
formatHeatmapReport
|
||||||
|
};
|
||||||
@@ -366,6 +366,123 @@ function createEmojiAgentRouter(options = {}) {
|
|||||||
handleStatusUpdate,
|
handleStatusUpdate,
|
||||||
createEmojiRouteContext,
|
createEmojiRouteContext,
|
||||||
createReactionRouteContext,
|
createReactionRouteContext,
|
||||||
|
// Routes emoji reactions to appropriate agent handlers
|
||||||
|
|
||||||
|
const AGENT_ROUTES = {
|
||||||
|
// Status change reactions
|
||||||
|
"✅": { agent: "status-agent", action: "mark_done" },
|
||||||
|
"🟡": { agent: "status-agent", action: "mark_in_progress" },
|
||||||
|
"⬜": { agent: "status-agent", action: "mark_not_started" },
|
||||||
|
"❌": { agent: "status-agent", action: "mark_blocked" },
|
||||||
|
"🔁": { agent: "status-agent", action: "mark_rework" },
|
||||||
|
|
||||||
|
// Special agent triggers
|
||||||
|
"🤔": { agent: "review-agent", action: "request_review" },
|
||||||
|
"🛟": { agent: "guardian-agent", action: "escalate" },
|
||||||
|
"🤖": { agent: "auto-assign-agent", action: "auto_assign" },
|
||||||
|
"🧍♀️": { agent: "assignment-agent", action: "assign_human" },
|
||||||
|
"👥": { agent: "team-agent", action: "tag_team" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reaction name to emoji mapping for GitHub reactions
|
||||||
|
const REACTION_TO_EMOJI = {
|
||||||
|
"+1": "👍",
|
||||||
|
"-1": "👎",
|
||||||
|
laugh: "😄",
|
||||||
|
hooray: "🎉",
|
||||||
|
confused: "😕",
|
||||||
|
heart: "❤️",
|
||||||
|
rocket: "🚀",
|
||||||
|
eyes: "👀"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reaction name to agent routing
|
||||||
|
const REACTION_ROUTES = {
|
||||||
|
hooray: { agent: "status-agent", action: "mark_done" },
|
||||||
|
rocket: { agent: "status-agent", action: "mark_done" },
|
||||||
|
eyes: { agent: "status-agent", action: "mark_in_progress" },
|
||||||
|
"+1": { agent: "status-agent", action: "approve" },
|
||||||
|
"-1": { agent: "status-agent", action: "mark_blocked" },
|
||||||
|
confused: { agent: "status-agent", action: "mark_blocked" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route an emoji to the appropriate agent
|
||||||
|
* @param {string} emoji - The emoji character
|
||||||
|
* @returns {Object|null} - Agent routing info or null if not found
|
||||||
|
*/
|
||||||
|
function routeEmoji(emoji) {
|
||||||
|
return AGENT_ROUTES[emoji] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a reaction name to the appropriate agent
|
||||||
|
* @param {string} reaction - The reaction name (e.g., "rocket")
|
||||||
|
* @returns {Object|null} - Agent routing info or null if not found
|
||||||
|
*/
|
||||||
|
function routeReaction(reaction) {
|
||||||
|
return REACTION_ROUTES[reaction] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered emoji routes
|
||||||
|
* @returns {Object} - All emoji routes
|
||||||
|
*/
|
||||||
|
function getEmojiRoutes() {
|
||||||
|
return { ...AGENT_ROUTES };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered reaction routes
|
||||||
|
* @returns {Object} - All reaction routes
|
||||||
|
*/
|
||||||
|
function getReactionRoutes() {
|
||||||
|
return { ...REACTION_ROUTES };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a reaction name to its emoji representation
|
||||||
|
* @param {string} reaction - The reaction name
|
||||||
|
* @returns {string|null} - The emoji or null if not found
|
||||||
|
*/
|
||||||
|
function reactionToEmoji(reaction) {
|
||||||
|
return REACTION_TO_EMOJI[reaction] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming reaction and return handling instructions
|
||||||
|
* @param {Object} options - Processing options
|
||||||
|
* @param {string} options.reaction - The reaction name
|
||||||
|
* @param {Object} options.payload - The event payload
|
||||||
|
* @returns {Object} - Processing result with routing info
|
||||||
|
*/
|
||||||
|
function processReaction({ reaction, payload } = {}) {
|
||||||
|
if (!reaction || typeof reaction !== "string") {
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
reason: "Invalid or missing reaction parameter"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = routeReaction(reaction);
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
reason: `No route for reaction: ${reaction}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const safePayload = payload || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
agent: route.agent,
|
||||||
|
action: route.action,
|
||||||
|
reaction,
|
||||||
|
emoji: reactionToEmoji(reaction),
|
||||||
|
issueNumber: safePayload.issue?.number || safePayload.pull_request?.number,
|
||||||
|
repository: safePayload.repository?.full_name
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,4 +496,12 @@ module.exports = {
|
|||||||
createEmojiRouteContext,
|
createEmojiRouteContext,
|
||||||
createReactionRouteContext,
|
createReactionRouteContext,
|
||||||
createEmojiAgentRouter,
|
createEmojiAgentRouter,
|
||||||
|
routeEmoji,
|
||||||
|
routeReaction,
|
||||||
|
getEmojiRoutes,
|
||||||
|
getReactionRoutes,
|
||||||
|
reactionToEmoji,
|
||||||
|
processReaction,
|
||||||
|
AGENT_ROUTES,
|
||||||
|
REACTION_ROUTES
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,4 +63,88 @@ module.exports = async function syncStatus(reaction, issueNumber, repo) {
|
|||||||
console.log(
|
console.log(
|
||||||
`Would update project card for issue #${issueNumber} in ${repo} to status ${status}`
|
`Would update project card for issue #${issueNumber} in ${repo} to status ${status}`
|
||||||
);
|
);
|
||||||
|
// bot/handlers/project-status-sync.js
|
||||||
|
// Handler for syncing project statuses based on emoji reactions
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
"✅": "Done",
|
||||||
|
"🟡": "In Progress",
|
||||||
|
"⬜": "Not Started",
|
||||||
|
"❌": "Blocked",
|
||||||
|
"🔁": "Rework",
|
||||||
|
"🤔": "Needs Review",
|
||||||
|
"🛟": "Escalation"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map emoji to project status
|
||||||
|
* @param {string} emoji - The emoji character
|
||||||
|
* @returns {string|null} - The corresponding status or null if not found
|
||||||
|
*/
|
||||||
|
function mapEmojiToStatus(emoji) {
|
||||||
|
return STATUS_MAP[emoji] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map reaction name to project status
|
||||||
|
* @param {string} reaction - The reaction name (e.g., "rocket", "eyes")
|
||||||
|
* @returns {string|null} - The corresponding status or null if not found
|
||||||
|
*/
|
||||||
|
function mapReactionToStatus(reaction) {
|
||||||
|
const REACTION_MAP = {
|
||||||
|
hooray: "Done",
|
||||||
|
rocket: "Done",
|
||||||
|
eyes: "In Progress",
|
||||||
|
"+1": "Done",
|
||||||
|
"-1": "Blocked",
|
||||||
|
confused: "Blocked",
|
||||||
|
thinking_face: "Needs Review",
|
||||||
|
rotating_light: "Escalation"
|
||||||
|
};
|
||||||
|
return REACTION_MAP[reaction] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all status mappings
|
||||||
|
* @returns {Object} - All emoji to status mappings
|
||||||
|
*/
|
||||||
|
function getStatusMappings() {
|
||||||
|
return { ...STATUS_MAP };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync project status based on reaction
|
||||||
|
* @param {Object} options - Sync options
|
||||||
|
* @param {string} options.reaction - The reaction name
|
||||||
|
* @param {string|number} options.issueId - The issue or PR ID
|
||||||
|
* @param {string|number} options.projectId - The project ID
|
||||||
|
* @returns {Object} - Sync result
|
||||||
|
*/
|
||||||
|
async function syncProjectStatus({ reaction, issueId, projectId }) {
|
||||||
|
const status = mapReactionToStatus(reaction);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `No status mapping for reaction: ${reaction}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// This would be implemented with actual GitHub GraphQL API calls
|
||||||
|
// For now, return the intended status change
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
issueId,
|
||||||
|
projectId,
|
||||||
|
newStatus: status,
|
||||||
|
reaction,
|
||||||
|
message: `Status would be updated to: ${status}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapEmojiToStatus,
|
||||||
|
mapReactionToStatus,
|
||||||
|
getStatusMappings,
|
||||||
|
syncProjectStatus
|
||||||
};
|
};
|
||||||
|
|||||||
255
bot/project-status-service.js
Normal file
255
bot/project-status-service.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// bot/project-status-service.js
|
||||||
|
// Service for interacting with GitHub Projects via GraphQL API
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub GraphQL API client for Project status management
|
||||||
|
*/
|
||||||
|
class ProjectStatusService {
|
||||||
|
/**
|
||||||
|
* Create a new ProjectStatusService
|
||||||
|
* @param {Object} options - Service options
|
||||||
|
* @param {string} options.token - GitHub API token
|
||||||
|
* @param {string} [options.apiUrl] - GitHub GraphQL API URL
|
||||||
|
*/
|
||||||
|
constructor({ token, apiUrl = "https://api.github.com/graphql" }) {
|
||||||
|
this.token = token;
|
||||||
|
this.apiUrl = apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL query
|
||||||
|
* @param {string} query - The GraphQL query
|
||||||
|
* @param {Object} [variables] - Query variables
|
||||||
|
* @returns {Promise<Object>} - Query result
|
||||||
|
*/
|
||||||
|
async graphql(query, variables = {}) {
|
||||||
|
if (!query || typeof query !== "string") {
|
||||||
|
throw new Error("GraphQL query must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GraphQL request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project by ID
|
||||||
|
* @param {string} projectId - The project node ID
|
||||||
|
* @returns {Promise<Object>} - Project data
|
||||||
|
*/
|
||||||
|
async getProject(projectId) {
|
||||||
|
const query = `
|
||||||
|
query GetProject($projectId: ID!) {
|
||||||
|
node(id: $projectId) {
|
||||||
|
... on ProjectV2 {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
number
|
||||||
|
fields(first: 20) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return this.graphql(query, { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Status field and its options from a project
|
||||||
|
* @param {string} projectId - The project node ID
|
||||||
|
* @returns {Promise<Object|null>} - Status field data or null
|
||||||
|
*/
|
||||||
|
async getStatusField(projectId) {
|
||||||
|
const data = await this.getProject(projectId);
|
||||||
|
const project = data?.node;
|
||||||
|
|
||||||
|
if (!project?.fields?.nodes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusField = project.fields.nodes.find(
|
||||||
|
(field) => field.name === "Status"
|
||||||
|
);
|
||||||
|
|
||||||
|
return statusField || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project item by issue/PR node ID
|
||||||
|
* @param {string} projectId - The project node ID
|
||||||
|
* @param {string} contentId - The issue or PR node ID
|
||||||
|
* @returns {Promise<Object|null>} - Project item data or null
|
||||||
|
*/
|
||||||
|
async getProjectItem(projectId, contentId) {
|
||||||
|
const query = `
|
||||||
|
query GetProjectItem($projectId: ID!) {
|
||||||
|
node(id: $projectId) {
|
||||||
|
... on ProjectV2 {
|
||||||
|
items(first: 100) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
content {
|
||||||
|
... on Issue {
|
||||||
|
id
|
||||||
|
number
|
||||||
|
title
|
||||||
|
}
|
||||||
|
... on PullRequest {
|
||||||
|
id
|
||||||
|
number
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fieldValues(first: 10) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2ItemFieldSingleSelectValue {
|
||||||
|
name
|
||||||
|
field {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await this.graphql(query, { projectId });
|
||||||
|
const items = data?.node?.items?.nodes || [];
|
||||||
|
|
||||||
|
return items.find((item) => item.content?.id === contentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project item status
|
||||||
|
* @param {Object} options - Update options
|
||||||
|
* @param {string} options.projectId - The project node ID
|
||||||
|
* @param {string} options.itemId - The project item ID
|
||||||
|
* @param {string} options.fieldId - The status field ID
|
||||||
|
* @param {string} options.optionId - The status option ID
|
||||||
|
* @returns {Promise<Object>} - Update result
|
||||||
|
*/
|
||||||
|
async updateItemStatus({ projectId, itemId, fieldId, optionId }) {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateProjectItemField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
|
||||||
|
updateProjectV2ItemFieldValue(input: {
|
||||||
|
projectId: $projectId
|
||||||
|
itemId: $itemId
|
||||||
|
fieldId: $fieldId
|
||||||
|
value: $value
|
||||||
|
}) {
|
||||||
|
projectV2Item {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return this.graphql(mutation, {
|
||||||
|
projectId,
|
||||||
|
itemId,
|
||||||
|
fieldId,
|
||||||
|
value: { singleSelectOptionId: optionId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set status for an issue/PR by finding and updating its project card
|
||||||
|
* @param {Object} options - Options
|
||||||
|
* @param {string} options.projectId - The project node ID
|
||||||
|
* @param {string} options.contentId - The issue or PR node ID
|
||||||
|
* @param {string} options.statusName - The status name to set
|
||||||
|
* @returns {Promise<Object>} - Result
|
||||||
|
*/
|
||||||
|
async setStatus({ projectId, contentId, statusName }) {
|
||||||
|
// Get status field and options
|
||||||
|
const statusField = await this.getStatusField(projectId);
|
||||||
|
if (!statusField) {
|
||||||
|
return { success: false, error: "Status field not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the option ID for the requested status
|
||||||
|
const option = statusField.options.find(
|
||||||
|
(opt) => opt.name.toLowerCase() === statusName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (!option) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Status option "${statusName}" not found`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the project item for this content
|
||||||
|
const item = await this.getProjectItem(projectId, contentId);
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, error: "Project item not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the status
|
||||||
|
await this.updateItemStatus({
|
||||||
|
projectId,
|
||||||
|
itemId: item.id,
|
||||||
|
fieldId: statusField.id,
|
||||||
|
optionId: option.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
itemId: item.id,
|
||||||
|
newStatus: statusName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ProjectStatusService from environment variables
|
||||||
|
* @returns {ProjectStatusService} - Service instance
|
||||||
|
* @throws {Error} - If GITHUB_TOKEN environment variable is not set
|
||||||
|
*/
|
||||||
|
function createFromEnv() {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"GITHUB_TOKEN environment variable is required for ProjectStatusService"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new ProjectStatusService({ token });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ProjectStatusService,
|
||||||
|
createFromEnv
|
||||||
|
};
|
||||||
@@ -36,3 +36,147 @@ Notes
|
|||||||
-----
|
-----
|
||||||
- The bot relies on `@actions/github` and the built-in `GITHUB_TOKEN`
|
- The bot relies on `@actions/github` and the built-in `GITHUB_TOKEN`
|
||||||
- Templates include a footer marker so duplicate comments are easy to find
|
- Templates include a footer marker so duplicate comments are easy to find
|
||||||
|
================================================================================
|
||||||
|
EMOJI-BOT SPECIFICATION
|
||||||
|
BlackRoad OS Intelligence Suite
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
OVERVIEW
|
||||||
|
--------
|
||||||
|
Emoji-Bot is an intelligent automation bot that uses emoji reactions and
|
||||||
|
patterns to orchestrate GitHub project management workflows. It bridges
|
||||||
|
human-readable emoji cues with automated agent actions and project status
|
||||||
|
updates.
|
||||||
|
|
||||||
|
ARCHITECTURE
|
||||||
|
------------
|
||||||
|
The bot consists of the following core components:
|
||||||
|
|
||||||
|
1. index.js
|
||||||
|
- Entry point for the GitHub Actions workflow
|
||||||
|
- Parses event payloads and extracts reaction data
|
||||||
|
- Logs processing activity
|
||||||
|
|
||||||
|
2. emoji-agent-router.js
|
||||||
|
- Routes emoji/reactions to appropriate agent handlers
|
||||||
|
- Maintains mapping tables for emoji → agent routing
|
||||||
|
- Supports both Unicode emoji and GitHub reaction names
|
||||||
|
|
||||||
|
3. handlers/project-status-sync.js
|
||||||
|
- Maps reactions to project status values
|
||||||
|
- Synchronizes status changes with GitHub Projects
|
||||||
|
- Provides status mapping utilities
|
||||||
|
|
||||||
|
4. project-status-service.js
|
||||||
|
- GitHub GraphQL API client for Projects V2
|
||||||
|
- Reads project structure (fields, options)
|
||||||
|
- Updates project item statuses
|
||||||
|
|
||||||
|
5. agent-math-utils.js
|
||||||
|
- Tracks agent trigger statistics
|
||||||
|
- Generates emoji heatmaps
|
||||||
|
- Calculates sprint progress metrics
|
||||||
|
- Renders visual progress bars
|
||||||
|
|
||||||
|
EMOJI MAPPINGS
|
||||||
|
--------------
|
||||||
|
Status Emojis:
|
||||||
|
✅ → Done
|
||||||
|
🟡 → In Progress
|
||||||
|
⬜ → Not Started
|
||||||
|
❌ → Blocked
|
||||||
|
🔁 → Rework
|
||||||
|
🤔 → Needs Review
|
||||||
|
🛟 → Escalation
|
||||||
|
|
||||||
|
Agent Triggers:
|
||||||
|
🤖 → Auto-assign to agent
|
||||||
|
🧍♀️ → Assign to human
|
||||||
|
👥 → Tag team
|
||||||
|
|
||||||
|
GitHub Reaction Mappings:
|
||||||
|
:rocket: / :hooray: → Done
|
||||||
|
:eyes: → In Progress
|
||||||
|
:-1: / :confused: → Blocked
|
||||||
|
:thinking_face: → Needs Review
|
||||||
|
:rotating_light: → Escalation
|
||||||
|
|
||||||
|
WORKFLOW
|
||||||
|
--------
|
||||||
|
1. User reacts to issue/PR comment with an emoji
|
||||||
|
2. GitHub triggers the emoji-bot workflow
|
||||||
|
3. Bot parses the reaction from the event payload
|
||||||
|
4. emoji-agent-router determines the appropriate handler
|
||||||
|
5. project-status-sync maps reaction to status
|
||||||
|
6. project-status-service updates the GitHub Project card
|
||||||
|
7. Bot posts a comment using the appropriate template
|
||||||
|
|
||||||
|
COMMENT TEMPLATES
|
||||||
|
-----------------
|
||||||
|
Location: bot/comments/
|
||||||
|
|
||||||
|
- in-progress.md : Posted when work begins
|
||||||
|
- completed.md : Posted when task is done
|
||||||
|
- blocked.md : Posted when progress is halted
|
||||||
|
- review-requested.md : Posted when review is needed
|
||||||
|
- escalation.md : Posted for critical issues
|
||||||
|
|
||||||
|
CONFIGURATION FILES
|
||||||
|
-------------------
|
||||||
|
- emoji-bot-config.yml : Emoji mappings and agent triggers
|
||||||
|
- project-config.yml : GitHub Project settings and automation rules
|
||||||
|
|
||||||
|
ANALYTICS
|
||||||
|
---------
|
||||||
|
The agent-math-utils module provides:
|
||||||
|
|
||||||
|
1. Agent Tracker
|
||||||
|
- Records how many times each agent is triggered
|
||||||
|
- Supports per-agent and aggregate reporting
|
||||||
|
|
||||||
|
2. Emoji Heatmap
|
||||||
|
- Tracks emoji usage frequency
|
||||||
|
- Calculates percentage distributions
|
||||||
|
- Example output:
|
||||||
|
✅ 45.0% (45 occurrences)
|
||||||
|
❌ 30.0% (30 occurrences)
|
||||||
|
🟡 25.0% (25 occurrences)
|
||||||
|
|
||||||
|
3. Sprint Progress Bar
|
||||||
|
- Visual representation of sprint status
|
||||||
|
- Format: ✅✅✅🟡🟡⬜⬜⬜⬜⬜
|
||||||
|
- Configurable width
|
||||||
|
|
||||||
|
TESTING
|
||||||
|
-------
|
||||||
|
Tests are located in tests/reaction.test.ts and cover:
|
||||||
|
|
||||||
|
- Reaction to status mapping
|
||||||
|
- Emoji routing
|
||||||
|
- Agent tracking
|
||||||
|
- Heatmap calculations
|
||||||
|
- Progress bar generation
|
||||||
|
|
||||||
|
GITHUB ACTIONS WORKFLOW
|
||||||
|
-----------------------
|
||||||
|
Location: .github/workflows/emoji-bot.yml
|
||||||
|
|
||||||
|
Triggers on:
|
||||||
|
- issue_comment created events
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Checkout repository
|
||||||
|
2. Set up Node.js 18
|
||||||
|
3. Run bot/index.js
|
||||||
|
|
||||||
|
FUTURE ENHANCEMENTS
|
||||||
|
-------------------
|
||||||
|
- Real-time Slack notifications
|
||||||
|
- Cross-org project synchronization
|
||||||
|
- LUCIDIA AI integration for intelligent routing
|
||||||
|
- Custom reaction vocabulary per repository
|
||||||
|
- Sprint velocity predictions
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
© BlackRoad OS - All Rights Reserved
|
||||||
|
================================================================================
|
||||||
|
|||||||
@@ -15,3 +15,114 @@ projects:
|
|||||||
"🟡": "In Progress"
|
"🟡": "In Progress"
|
||||||
"⬜": "Not Started"
|
"⬜": "Not Started"
|
||||||
"❌": "Blocked"
|
"❌": "Blocked"
|
||||||
|
# project-config.yml
|
||||||
|
# Configuration for GitHub Projects integration
|
||||||
|
|
||||||
|
# Default project settings
|
||||||
|
default_project:
|
||||||
|
# Organization and project number
|
||||||
|
organization: "blackroad-os"
|
||||||
|
project_number: 1
|
||||||
|
|
||||||
|
# Node ID for GraphQL API (set after project creation)
|
||||||
|
# project_id: "PVT_kwDOxxxxxx"
|
||||||
|
|
||||||
|
# Status field configuration
|
||||||
|
status_field:
|
||||||
|
name: "Status"
|
||||||
|
options:
|
||||||
|
- id: "not_started"
|
||||||
|
name: "Not Started"
|
||||||
|
emoji: "⬜"
|
||||||
|
- id: "in_progress"
|
||||||
|
name: "In Progress"
|
||||||
|
emoji: "🟡"
|
||||||
|
- id: "done"
|
||||||
|
name: "Done"
|
||||||
|
emoji: "✅"
|
||||||
|
- id: "blocked"
|
||||||
|
name: "Blocked"
|
||||||
|
emoji: "❌"
|
||||||
|
- id: "needs_review"
|
||||||
|
name: "Needs Review"
|
||||||
|
emoji: "🤔"
|
||||||
|
- id: "rework"
|
||||||
|
name: "Rework"
|
||||||
|
emoji: "🔁"
|
||||||
|
- id: "escalation"
|
||||||
|
name: "Escalation"
|
||||||
|
emoji: "🛟"
|
||||||
|
|
||||||
|
# Custom fields for sprint tracking
|
||||||
|
custom_fields:
|
||||||
|
- name: "Sprint"
|
||||||
|
type: "iteration"
|
||||||
|
- name: "Priority"
|
||||||
|
type: "single_select"
|
||||||
|
options:
|
||||||
|
- "🔴 Critical"
|
||||||
|
- "🟠 High"
|
||||||
|
- "🟡 Medium"
|
||||||
|
- "🟢 Low"
|
||||||
|
- name: "Assigned Agent"
|
||||||
|
type: "single_select"
|
||||||
|
options:
|
||||||
|
- "🤖 builder-agent"
|
||||||
|
- "📝 scribe-agent"
|
||||||
|
- "👁️ guardian-agent"
|
||||||
|
- "📋 planner-agent"
|
||||||
|
- "🧍♀️ Human"
|
||||||
|
|
||||||
|
# Automation rules
|
||||||
|
automation:
|
||||||
|
# Auto-assign agent based on labels
|
||||||
|
auto_assign:
|
||||||
|
enabled: true
|
||||||
|
rules:
|
||||||
|
- label: "documentation"
|
||||||
|
agent: "scribe-agent"
|
||||||
|
- label: "bug"
|
||||||
|
agent: "builder-agent"
|
||||||
|
- label: "security"
|
||||||
|
agent: "guardian-agent"
|
||||||
|
- label: "planning"
|
||||||
|
agent: "planner-agent"
|
||||||
|
|
||||||
|
# Auto-status transitions
|
||||||
|
status_transitions:
|
||||||
|
enabled: true
|
||||||
|
rules:
|
||||||
|
- trigger: "pr_opened"
|
||||||
|
from: "Not Started"
|
||||||
|
to: "In Progress"
|
||||||
|
- trigger: "review_requested"
|
||||||
|
from: "In Progress"
|
||||||
|
to: "Needs Review"
|
||||||
|
- trigger: "pr_merged"
|
||||||
|
from: "*"
|
||||||
|
to: "Done"
|
||||||
|
- trigger: "issue_closed"
|
||||||
|
from: "*"
|
||||||
|
to: "Done"
|
||||||
|
|
||||||
|
# Notification settings
|
||||||
|
notifications:
|
||||||
|
# Slack integration (if configured)
|
||||||
|
slack:
|
||||||
|
enabled: false
|
||||||
|
channel: "#blackroad-status"
|
||||||
|
|
||||||
|
# Comment notifications
|
||||||
|
comments:
|
||||||
|
on_status_change: true
|
||||||
|
on_escalation: true
|
||||||
|
on_assignment: true
|
||||||
|
|
||||||
|
# Sprint tracking configuration
|
||||||
|
sprint:
|
||||||
|
# Sprint duration in weeks
|
||||||
|
duration_weeks: 2
|
||||||
|
# Goal calculation
|
||||||
|
velocity_tracking: true
|
||||||
|
# Progress bar width
|
||||||
|
progress_bar_width: 10
|
||||||
|
|||||||
489
tests/reaction.test.ts
Normal file
489
tests/reaction.test.ts
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
// Import bot modules - using require since they are CommonJS
|
||||||
|
const projectStatusSync = require("../bot/handlers/project-status-sync.js");
|
||||||
|
const emojiAgentRouter = require("../bot/emoji-agent-router.js");
|
||||||
|
const agentMathUtils = require("../bot/agent-math-utils.js");
|
||||||
|
const projectStatusService = require("../bot/project-status-service.js");
|
||||||
|
|
||||||
|
describe("project-status-sync", () => {
|
||||||
|
describe("mapEmojiToStatus", () => {
|
||||||
|
it("maps ✅ to Done", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("✅")).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 🟡 to In Progress", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("🟡")).toBe("In Progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps ⬜ to Not Started", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("⬜")).toBe("Not Started");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps ❌ to Blocked", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("❌")).toBe("Blocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 🔁 to Rework", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("🔁")).toBe("Rework");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 🤔 to Needs Review", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("🤔")).toBe("Needs Review");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 🛟 to Escalation", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("🛟")).toBe("Escalation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown emoji", () => {
|
||||||
|
expect(projectStatusSync.mapEmojiToStatus("🎉")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapReactionToStatus", () => {
|
||||||
|
it("maps hooray to Done", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("hooray")).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps rocket to Done", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("rocket")).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps eyes to In Progress", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("eyes")).toBe("In Progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps -1 to Blocked", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("-1")).toBe("Blocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps confused to Blocked", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("confused")).toBe("Blocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps thinking_face to Needs Review", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("thinking_face")).toBe(
|
||||||
|
"Needs Review"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps rotating_light to Escalation", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("rotating_light")).toBe(
|
||||||
|
"Escalation"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown reaction", () => {
|
||||||
|
expect(projectStatusSync.mapReactionToStatus("heart")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncProjectStatus", () => {
|
||||||
|
it("returns success for valid reaction", async () => {
|
||||||
|
const result = await projectStatusSync.syncProjectStatus({
|
||||||
|
reaction: "rocket",
|
||||||
|
issueId: 123,
|
||||||
|
projectId: 456
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.newStatus).toBe("Done");
|
||||||
|
expect(result.issueId).toBe(123);
|
||||||
|
expect(result.projectId).toBe(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns failure for unknown reaction", async () => {
|
||||||
|
const result = await projectStatusSync.syncProjectStatus({
|
||||||
|
reaction: "unknown",
|
||||||
|
issueId: 123,
|
||||||
|
projectId: 456
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain("No status mapping");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emoji-agent-router", () => {
|
||||||
|
describe("routeEmoji", () => {
|
||||||
|
it("routes ✅ to status-agent with mark_done action", () => {
|
||||||
|
const route = emojiAgentRouter.routeEmoji("✅");
|
||||||
|
expect(route).toEqual({ agent: "status-agent", action: "mark_done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes 🛟 to guardian-agent with escalate action", () => {
|
||||||
|
const route = emojiAgentRouter.routeEmoji("🛟");
|
||||||
|
expect(route).toEqual({ agent: "guardian-agent", action: "escalate" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes 🤖 to auto-assign-agent", () => {
|
||||||
|
const route = emojiAgentRouter.routeEmoji("🤖");
|
||||||
|
expect(route).toEqual({ agent: "auto-assign-agent", action: "auto_assign" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unrouted emoji", () => {
|
||||||
|
expect(emojiAgentRouter.routeEmoji("🎉")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeReaction", () => {
|
||||||
|
it("routes rocket to status-agent with mark_done action", () => {
|
||||||
|
const route = emojiAgentRouter.routeReaction("rocket");
|
||||||
|
expect(route).toEqual({ agent: "status-agent", action: "mark_done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes eyes to status-agent with mark_in_progress action", () => {
|
||||||
|
const route = emojiAgentRouter.routeReaction("eyes");
|
||||||
|
expect(route).toEqual({
|
||||||
|
agent: "status-agent",
|
||||||
|
action: "mark_in_progress"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes confused to status-agent with mark_blocked action", () => {
|
||||||
|
const route = emojiAgentRouter.routeReaction("confused");
|
||||||
|
expect(route).toEqual({ agent: "status-agent", action: "mark_blocked" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unrouted reaction", () => {
|
||||||
|
expect(emojiAgentRouter.routeReaction("heart")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reactionToEmoji", () => {
|
||||||
|
it("converts +1 to 👍", () => {
|
||||||
|
expect(emojiAgentRouter.reactionToEmoji("+1")).toBe("👍");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts rocket to 🚀", () => {
|
||||||
|
expect(emojiAgentRouter.reactionToEmoji("rocket")).toBe("🚀");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown reaction", () => {
|
||||||
|
expect(emojiAgentRouter.reactionToEmoji("unknown")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processReaction", () => {
|
||||||
|
it("processes valid reaction with payload", () => {
|
||||||
|
const result = emojiAgentRouter.processReaction({
|
||||||
|
reaction: "rocket",
|
||||||
|
payload: {
|
||||||
|
issue: { number: 42 },
|
||||||
|
repository: { full_name: "org/repo" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(result.agent).toBe("status-agent");
|
||||||
|
expect(result.action).toBe("mark_done");
|
||||||
|
expect(result.issueNumber).toBe(42);
|
||||||
|
expect(result.repository).toBe("org/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unhandled for unknown reaction", () => {
|
||||||
|
const result = emojiAgentRouter.processReaction({
|
||||||
|
reaction: "unknown",
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.handled).toBe(false);
|
||||||
|
expect(result.reason).toContain("No route");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unhandled for missing reaction parameter", () => {
|
||||||
|
const result = emojiAgentRouter.processReaction({
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.handled).toBe(false);
|
||||||
|
expect(result.reason).toContain("Invalid or missing reaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined options gracefully", () => {
|
||||||
|
const result = emojiAgentRouter.processReaction();
|
||||||
|
|
||||||
|
expect(result.handled).toBe(false);
|
||||||
|
expect(result.reason).toContain("Invalid or missing reaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing payload gracefully", () => {
|
||||||
|
const result = emojiAgentRouter.processReaction({
|
||||||
|
reaction: "rocket"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(result.agent).toBe("status-agent");
|
||||||
|
expect(result.issueNumber).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agent-math-utils", () => {
|
||||||
|
describe("AgentTracker", () => {
|
||||||
|
let tracker: InstanceType<typeof agentMathUtils.AgentTracker>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tracker = new agentMathUtils.AgentTracker();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero triggers", () => {
|
||||||
|
expect(tracker.getTriggerCount("test-agent")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records and retrieves triggers", () => {
|
||||||
|
tracker.recordTrigger("test-agent");
|
||||||
|
tracker.recordTrigger("test-agent");
|
||||||
|
tracker.recordTrigger("other-agent");
|
||||||
|
|
||||||
|
expect(tracker.getTriggerCount("test-agent")).toBe(2);
|
||||||
|
expect(tracker.getTriggerCount("other-agent")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all triggers", () => {
|
||||||
|
tracker.recordTrigger("agent-a");
|
||||||
|
tracker.recordTrigger("agent-b");
|
||||||
|
|
||||||
|
const all = tracker.getAllTriggers();
|
||||||
|
expect(all).toEqual({ "agent-a": 1, "agent-b": 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets triggers", () => {
|
||||||
|
tracker.recordTrigger("test-agent");
|
||||||
|
tracker.reset();
|
||||||
|
|
||||||
|
expect(tracker.getTriggerCount("test-agent")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EmojiHeatmap", () => {
|
||||||
|
let heatmap: InstanceType<typeof agentMathUtils.EmojiHeatmap>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
heatmap = new agentMathUtils.EmojiHeatmap();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero total", () => {
|
||||||
|
expect(heatmap.getTotal()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records emoji occurrences", () => {
|
||||||
|
heatmap.record("✅");
|
||||||
|
heatmap.record("✅", 2);
|
||||||
|
heatmap.record("❌");
|
||||||
|
|
||||||
|
expect(heatmap.getCount("✅")).toBe(3);
|
||||||
|
expect(heatmap.getCount("❌")).toBe(1);
|
||||||
|
expect(heatmap.getTotal()).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates percentages", () => {
|
||||||
|
heatmap.record("✅", 75);
|
||||||
|
heatmap.record("❌", 25);
|
||||||
|
|
||||||
|
expect(heatmap.getPercentage("✅")).toBe(75);
|
||||||
|
expect(heatmap.getPercentage("❌")).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 percentage when empty", () => {
|
||||||
|
expect(heatmap.getPercentage("✅")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns percentage breakdown", () => {
|
||||||
|
heatmap.record("✅", 50);
|
||||||
|
heatmap.record("❌", 50);
|
||||||
|
|
||||||
|
const breakdown = heatmap.getPercentageBreakdown();
|
||||||
|
expect(breakdown["✅"]).toBe(50);
|
||||||
|
expect(breakdown["❌"]).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets heatmap", () => {
|
||||||
|
heatmap.record("✅", 10);
|
||||||
|
heatmap.reset();
|
||||||
|
|
||||||
|
expect(heatmap.getTotal()).toBe(0);
|
||||||
|
expect(heatmap.getCount("✅")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateProgressBar", () => {
|
||||||
|
it("generates empty bar for zero total", () => {
|
||||||
|
const bar = agentMathUtils.generateProgressBar({
|
||||||
|
completed: 0,
|
||||||
|
inProgress: 0,
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
expect(bar).toBe("⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates full completed bar", () => {
|
||||||
|
const bar = agentMathUtils.generateProgressBar({
|
||||||
|
completed: 10,
|
||||||
|
inProgress: 0,
|
||||||
|
total: 10
|
||||||
|
});
|
||||||
|
expect(bar).toBe("✅✅✅✅✅✅✅✅✅✅");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates mixed progress bar", () => {
|
||||||
|
const bar = agentMathUtils.generateProgressBar({
|
||||||
|
completed: 5,
|
||||||
|
inProgress: 2,
|
||||||
|
total: 10
|
||||||
|
});
|
||||||
|
expect(bar).toBe("✅✅✅✅✅🟡🟡⬜⬜⬜");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects custom width", () => {
|
||||||
|
const bar = agentMathUtils.generateProgressBar({
|
||||||
|
completed: 4,
|
||||||
|
inProgress: 0,
|
||||||
|
total: 8,
|
||||||
|
width: 8
|
||||||
|
});
|
||||||
|
expect(bar).toBe("✅✅✅✅⬜⬜⬜⬜");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles overflow when completed + inProgress > total", () => {
|
||||||
|
const bar = agentMathUtils.generateProgressBar({
|
||||||
|
completed: 8,
|
||||||
|
inProgress: 5,
|
||||||
|
total: 10,
|
||||||
|
width: 10
|
||||||
|
});
|
||||||
|
// Should clamp values and produce a valid bar without invalid characters
|
||||||
|
expect(bar).not.toContain("undefined");
|
||||||
|
// The bar should contain only valid emoji characters
|
||||||
|
expect(bar).toMatch(/^[✅🟡⬜]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps completed items that exceed total", () => {
|
||||||
|
const bar = agentMathUtils.generateProgressBar({
|
||||||
|
completed: 15,
|
||||||
|
inProgress: 0,
|
||||||
|
total: 10,
|
||||||
|
width: 10
|
||||||
|
});
|
||||||
|
// Should show all completed since completed >= total
|
||||||
|
expect(bar).toBe("✅✅✅✅✅✅✅✅✅✅");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateSprintProgress", () => {
|
||||||
|
it("calculates progress for sprint items", () => {
|
||||||
|
const items = [
|
||||||
|
{ status: "Done" },
|
||||||
|
{ status: "Done" },
|
||||||
|
{ status: "In Progress" },
|
||||||
|
{ status: "Blocked" },
|
||||||
|
{ status: "Not Started" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = agentMathUtils.calculateSprintProgress(items);
|
||||||
|
|
||||||
|
expect(stats.total).toBe(5);
|
||||||
|
expect(stats.done).toBe(2);
|
||||||
|
expect(stats.inProgress).toBe(1);
|
||||||
|
expect(stats.blocked).toBe(1);
|
||||||
|
expect(stats.notStarted).toBe(1);
|
||||||
|
expect(stats.completionPercentage).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zero completion for empty sprint", () => {
|
||||||
|
const stats = agentMathUtils.calculateSprintProgress([]);
|
||||||
|
|
||||||
|
expect(stats.total).toBe(0);
|
||||||
|
expect(stats.completionPercentage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes progress bar", () => {
|
||||||
|
const items = [{ status: "Done" }, { status: "Done" }];
|
||||||
|
const stats = agentMathUtils.calculateSprintProgress(items);
|
||||||
|
|
||||||
|
expect(stats.progressBar).toContain("✅");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatHeatmapReport", () => {
|
||||||
|
it("formats heatmap as report", () => {
|
||||||
|
const heatmap = new agentMathUtils.EmojiHeatmap();
|
||||||
|
heatmap.record("✅", 45);
|
||||||
|
heatmap.record("❌", 30);
|
||||||
|
heatmap.record("🟡", 25);
|
||||||
|
|
||||||
|
const report = agentMathUtils.formatHeatmapReport(heatmap);
|
||||||
|
|
||||||
|
expect(report).toContain("📊 Emoji Heatmap Report");
|
||||||
|
expect(report).toContain("✅ 45.0%");
|
||||||
|
expect(report).toContain("Total: 100 emojis tracked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project-status-service", () => {
|
||||||
|
describe("ProjectStatusService", () => {
|
||||||
|
it("creates service with token", () => {
|
||||||
|
const service = new projectStatusService.ProjectStatusService({
|
||||||
|
token: "test-token"
|
||||||
|
});
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service.token).toBe("test-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom API URL when provided", () => {
|
||||||
|
const service = new projectStatusService.ProjectStatusService({
|
||||||
|
token: "test-token",
|
||||||
|
apiUrl: "https://custom.api/graphql"
|
||||||
|
});
|
||||||
|
expect(service.apiUrl).toBe("https://custom.api/graphql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error for invalid query", async () => {
|
||||||
|
const service = new projectStatusService.ProjectStatusService({
|
||||||
|
token: "test-token"
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.graphql(null)).rejects.toThrow(
|
||||||
|
"GraphQL query must be a non-empty string"
|
||||||
|
);
|
||||||
|
await expect(service.graphql("")).rejects.toThrow(
|
||||||
|
"GraphQL query must be a non-empty string"
|
||||||
|
);
|
||||||
|
await expect(service.graphql(123 as any)).rejects.toThrow(
|
||||||
|
"GraphQL query must be a non-empty string"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFromEnv", () => {
|
||||||
|
const originalEnv = process.env.GITHUB_TOKEN;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.GITHUB_TOKEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv) {
|
||||||
|
process.env.GITHUB_TOKEN = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.GITHUB_TOKEN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when GITHUB_TOKEN is not set", () => {
|
||||||
|
expect(() => projectStatusService.createFromEnv()).toThrow(
|
||||||
|
"GITHUB_TOKEN environment variable is required"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates service when GITHUB_TOKEN is set", () => {
|
||||||
|
process.env.GITHUB_TOKEN = "test-token";
|
||||||
|
const service = projectStatusService.createFromEnv();
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service.token).toBe("test-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user