Merge branch origin/copilot/add-emoji-github-automation into main

This commit is contained in:
Alexa Amundson
2025-11-25 13:45:42 -06:00
12 changed files with 3440 additions and 0 deletions

37
.github/workflows/weekly-digest.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: 📊 Weekly Emoji Digest
on:
schedule:
# Run every Monday at 9:00 AM UTC
- cron: "0 9 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (don't post comment)"
required: false
default: "false"
jobs:
generate-digest:
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: 🧬 Checkout Repo
uses: actions/checkout@v4
- name: 🧠 Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: 📦 Install Dependencies
run: npm ci
- name: 📊 Generate Weekly Digest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
run: node bot/run-digest.js

382
bot/emoji-agent-router.js Normal file
View File

@@ -0,0 +1,382 @@
// bot/emoji-agent-router.js
// 🔁 Routes emoji to math, status, or escalation handlers
const {
countEmojis,
generateHeatmap,
generateMarkdownReport,
reactionToCategory,
} = require("./emoji-heatmap");
const {
emojiToStatus,
reactionToStatus,
createProjectStatusSync,
} = require("./project-status-sync");
/**
* Route types for emoji handling
*/
const ROUTE_TYPES = {
MATH: "math",
STATUS: "status",
ESCALATION: "escalation",
NOTIFICATION: "notification",
IGNORE: "ignore",
};
/**
* Escalation emojis that trigger special handling
*/
const ESCALATION_EMOJIS = ["🛟", "🚨", "🔥"];
/**
* Emojis that trigger status updates
*/
const STATUS_EMOJIS = ["✅", "🟡", "⬜", "❌", "🔁", "🤔"];
/**
* Reactions that trigger status updates
*/
const STATUS_REACTIONS = [
"rocket",
"hooray",
"eyes",
"-1",
"confused",
];
/**
* Determine the route type for an emoji
* @param {string} emoji - The emoji to route
* @returns {string} - Route type
*/
function determineEmojiRoute(emoji) {
if (ESCALATION_EMOJIS.includes(emoji)) {
return ROUTE_TYPES.ESCALATION;
}
if (STATUS_EMOJIS.includes(emoji)) {
return ROUTE_TYPES.STATUS;
}
return ROUTE_TYPES.IGNORE;
}
/**
* Determine the route type for a reaction
* @param {string} reaction - GitHub reaction name
* @returns {string} - Route type
*/
function determineReactionRoute(reaction) {
if (STATUS_REACTIONS.includes(reaction)) {
return ROUTE_TYPES.STATUS;
}
if (reaction === "rotating_light") {
return ROUTE_TYPES.ESCALATION;
}
return ROUTE_TYPES.IGNORE;
}
/**
* Route context containing all relevant information
* @typedef {Object} RouteContext
* @property {string} type - Route type
* @property {string} emoji - The triggering emoji
* @property {string|null} status - Status name if applicable
* @property {boolean} isEscalation - Whether this is an escalation
* @property {string|null} category - Emoji category
*/
/**
* Create a route context for an emoji
* @param {string} emoji - The emoji
* @returns {RouteContext} - Route context
*/
function createEmojiRouteContext(emoji) {
const type = determineEmojiRoute(emoji);
const status = emojiToStatus(emoji);
return {
type,
emoji,
status,
isEscalation: type === ROUTE_TYPES.ESCALATION,
category: null, // Direct emojis don't map to categories
};
}
/**
* Create a route context for a reaction
* @param {string} reaction - GitHub reaction name
* @returns {RouteContext} - Route context
*/
function createReactionRouteContext(reaction) {
const type = determineReactionRoute(reaction);
const status = reactionToStatus(reaction);
const category = reactionToCategory(reaction);
return {
type,
emoji: reaction, // Store reaction name
status,
isEscalation: type === ROUTE_TYPES.ESCALATION,
category,
};
}
/**
* Handler result
* @typedef {Object} HandlerResult
* @property {boolean} handled - Whether the event was handled
* @property {string} action - Action taken
* @property {Object|null} data - Additional data
*/
/**
* Create an emoji agent router
* @param {Object} options - Router options
* @param {Object} options.octokit - GitHub Octokit instance
* @param {string} options.projectId - Default project ID
* @param {Function} options.onEscalation - Escalation callback
* @param {Function} options.onStatusUpdate - Status update callback
* @param {Function} options.onMathRequest - Math calculation callback
* @returns {Object} - Router methods
*/
function createEmojiAgentRouter(options = {}) {
const {
octokit,
projectId,
onEscalation,
onStatusUpdate,
onMathRequest,
} = options;
// Create project sync if octokit is available
const projectSync = octokit
? createProjectStatusSync(octokit)
: null;
/**
* Handle an escalation event
* @param {RouteContext} context - Route context
* @param {Object} eventData - GitHub event data
* @returns {Promise<HandlerResult>} - Handler result
*/
async function handleEscalation(context, eventData) {
const result = {
handled: true,
action: "escalation_triggered",
data: {
emoji: context.emoji,
issueNumber: eventData.issueNumber,
owner: eventData.owner,
repo: eventData.repo,
},
};
if (onEscalation) {
try {
await onEscalation(context, eventData);
} catch (error) {
console.error(`⚠️ Escalation callback error: ${error.message}`);
result.data.callbackError = error.message;
}
}
console.log(
`🛟 Escalation triggered for issue #${eventData.issueNumber}`
);
return result;
}
/**
* Handle a status update event
* @param {RouteContext} context - Route context
* @param {Object} eventData - GitHub event data
* @returns {Promise<HandlerResult>} - Handler result
*/
async function handleStatusUpdate(context, eventData) {
const result = {
handled: true,
action: "status_update",
data: {
emoji: context.emoji,
status: context.status,
issueNumber: eventData.issueNumber,
},
};
// Update project status if sync is available
if (projectSync && projectId && context.status) {
try {
const syncResult = await projectSync.syncIssueStatusFromEmoji({
owner: eventData.owner,
repo: eventData.repo,
issueNumber: eventData.issueNumber,
emoji: context.emoji,
projectId,
});
result.data.syncResult = syncResult;
} catch (error) {
result.data.syncError = error.message;
}
}
if (onStatusUpdate) {
try {
await onStatusUpdate(context, eventData);
} catch (error) {
console.error(`⚠️ Status update callback error: ${error.message}`);
result.data.callbackError = error.message;
}
}
console.log(
`📊 Status update: ${context.status} for issue #${eventData.issueNumber}`
);
return result;
}
/**
* Handle a math/calculation request
* @param {string} text - Text containing emojis
* @param {Object} options - Options for calculation
* @returns {HandlerResult} - Handler result
*/
function handleMathRequest(text, options = {}) {
const counts = countEmojis(text);
const heatmap = generateHeatmap(counts);
const report = options.generateReport
? generateMarkdownReport(heatmap, options.title)
: null;
const result = {
handled: true,
action: "math_calculation",
data: {
counts,
heatmap,
report,
},
};
if (onMathRequest) {
onMathRequest(result.data);
}
return result;
}
/**
* Route and handle an emoji event
* @param {string} emoji - The emoji
* @param {Object} eventData - GitHub event data
* @returns {Promise<HandlerResult>} - Handler result
*/
async function routeEmoji(emoji, eventData) {
const context = createEmojiRouteContext(emoji);
switch (context.type) {
case ROUTE_TYPES.ESCALATION:
return handleEscalation(context, eventData);
case ROUTE_TYPES.STATUS:
return handleStatusUpdate(context, eventData);
default:
return {
handled: false,
action: "ignored",
data: { emoji, reason: "No handler for emoji" },
};
}
}
/**
* Route and handle a reaction event
* @param {string} reaction - GitHub reaction name
* @param {Object} eventData - GitHub event data
* @returns {Promise<HandlerResult>} - Handler result
*/
async function routeReaction(reaction, eventData) {
const context = createReactionRouteContext(reaction);
switch (context.type) {
case ROUTE_TYPES.ESCALATION:
return handleEscalation(context, eventData);
case ROUTE_TYPES.STATUS:
return handleStatusUpdate(context, eventData);
default:
return {
handled: false,
action: "ignored",
data: { reaction, reason: "No handler for reaction" },
};
}
}
/**
* Process a batch of emojis for math calculations
* @param {Array<string>} texts - Array of text to analyze
* @param {Object} options - Options
* @returns {HandlerResult} - Handler result with aggregated data
*/
function processBatchMath(texts, options = {}) {
const allCounts = texts.map((text) => countEmojis(text));
const aggregated = allCounts.reduce(
(acc, counts) => {
for (const key of Object.keys(acc)) {
acc[key] += counts[key] || 0;
}
return acc;
},
{
completed: 0,
blocked: 0,
escalation: 0,
inProgress: 0,
review: 0,
notStarted: 0,
total: 0,
}
);
const heatmap = generateHeatmap(aggregated);
const report = options.generateReport
? generateMarkdownReport(heatmap, options.title || "Batch Analysis")
: null;
return {
handled: true,
action: "batch_math_calculation",
data: {
itemCount: texts.length,
aggregatedCounts: aggregated,
heatmap,
report,
},
};
}
return {
routeEmoji,
routeReaction,
handleMathRequest,
processBatchMath,
handleEscalation,
handleStatusUpdate,
createEmojiRouteContext,
createReactionRouteContext,
};
}
module.exports = {
ROUTE_TYPES,
ESCALATION_EMOJIS,
STATUS_EMOJIS,
STATUS_REACTIONS,
determineEmojiRoute,
determineReactionRoute,
createEmojiRouteContext,
createReactionRouteContext,
createEmojiAgentRouter,
};

189
bot/emoji-heatmap.js Normal file
View File

@@ -0,0 +1,189 @@
// bot/emoji-heatmap.js
// 🧠 Emoji Stats Engine: counts ✅, ❌, 🛟 and calculates percentages
/**
* Emoji categories for tracking
*/
const EMOJI_CATEGORIES = {
completed: ["✅", "🎉", "🚀", "👀"],
blocked: ["❌", "🔴", "🛑"],
escalation: ["🛟", "🚨", "🔥"],
inProgress: ["🟡", "🔄", "🔁"],
review: ["🤔", "👁️", "📝"],
notStarted: ["⬜", "📋"],
};
/**
* Maps reaction names to emojis
*/
const REACTION_TO_EMOJI = {
"+1": "👍",
"-1": "👎",
laugh: "😄",
hooray: "🎉",
confused: "😕",
heart: "❤️",
rocket: "🚀",
eyes: "👀",
};
/**
* Count emojis in a text string
* @param {string} text - The text to analyze
* @returns {Object} - Emoji counts by category
*/
function countEmojis(text) {
const counts = {
completed: 0,
blocked: 0,
escalation: 0,
inProgress: 0,
review: 0,
notStarted: 0,
total: 0,
};
if (!text) return counts;
for (const [category, emojis] of Object.entries(EMOJI_CATEGORIES)) {
for (const emoji of emojis) {
const regex = new RegExp(emoji, "g");
const matches = text.match(regex);
if (matches) {
counts[category] += matches.length;
counts.total += matches.length;
}
}
}
return counts;
}
/**
* Calculate percentage complete from emoji counts
* @param {Object} counts - Emoji counts object
* @returns {number} - Percentage complete (0-100)
*/
function calculatePercentComplete(counts) {
const total =
counts.completed +
counts.blocked +
counts.inProgress +
counts.review +
counts.notStarted;
if (total === 0) return 0;
return Math.round((counts.completed / total) * 100);
}
/**
* Generate a heatmap summary from emoji counts
* @param {Object} counts - Emoji counts object
* @returns {Object} - Heatmap summary with percentages and status
*/
function generateHeatmap(counts) {
const total =
counts.completed +
counts.blocked +
counts.inProgress +
counts.review +
counts.notStarted || 1;
return {
percentComplete: calculatePercentComplete(counts),
percentBlocked: Math.round((counts.blocked / total) * 100),
percentInProgress: Math.round((counts.inProgress / total) * 100),
percentReview: Math.round((counts.review / total) * 100),
percentNotStarted: Math.round((counts.notStarted / total) * 100),
escalations: counts.escalation,
totalItems: total,
};
}
/**
* Aggregate emoji counts from multiple sources
* @param {Array<Object>} countsList - Array of emoji count objects
* @returns {Object} - Aggregated counts
*/
function aggregateCounts(countsList) {
const aggregated = {
completed: 0,
blocked: 0,
escalation: 0,
inProgress: 0,
review: 0,
notStarted: 0,
total: 0,
};
for (const counts of countsList) {
for (const key of Object.keys(aggregated)) {
aggregated[key] += counts[key] || 0;
}
}
return aggregated;
}
/**
* Generate a markdown report from heatmap data
* @param {Object} heatmap - Heatmap summary object
* @param {string} title - Report title
* @returns {string} - Markdown formatted report
*/
function generateMarkdownReport(heatmap, title = "Emoji Heatmap Report") {
const progressBar = (percent) => {
const filled = Math.round(percent / 10);
const empty = 10 - filled;
return "█".repeat(filled) + "░".repeat(empty);
};
return `## 📊 ${title}
| Status | Count | Progress |
|--------|-------|----------|
| ✅ Complete | ${heatmap.percentComplete}% | ${progressBar(heatmap.percentComplete)} |
| 🟡 In Progress | ${heatmap.percentInProgress}% | ${progressBar(heatmap.percentInProgress)} |
| 🤔 Review | ${heatmap.percentReview}% | ${progressBar(heatmap.percentReview)} |
| ❌ Blocked | ${heatmap.percentBlocked}% | ${progressBar(heatmap.percentBlocked)} |
| ⬜ Not Started | ${heatmap.percentNotStarted}% | ${progressBar(heatmap.percentNotStarted)} |
### 🛟 Escalations: ${heatmap.escalations}
### 📦 Total Items: ${heatmap.totalItems}
`;
}
/**
* Convert GitHub reaction to emoji category
* @param {string} reaction - GitHub reaction name
* @returns {string|null} - Category name or null
*/
function reactionToCategory(reaction) {
const emoji = REACTION_TO_EMOJI[reaction];
if (!emoji) return null;
for (const [category, emojis] of Object.entries(EMOJI_CATEGORIES)) {
if (emojis.includes(emoji)) {
return category;
}
}
// Special mappings for reactions
if (reaction === "rocket" || reaction === "hooray") return "completed";
if (reaction === "-1" || reaction === "confused") return "blocked";
if (reaction === "eyes") return "review";
return null;
}
module.exports = {
EMOJI_CATEGORIES,
REACTION_TO_EMOJI,
countEmojis,
calculatePercentComplete,
generateHeatmap,
aggregateCounts,
generateMarkdownReport,
reactionToCategory,
};

View File

@@ -0,0 +1,424 @@
// bot/graphql-mutation-handler.js
// 🔧 GitHub GraphQL Mutation Handler for updating project cards
/**
* GraphQL mutation to update project item field value
*/
const UPDATE_PROJECT_ITEM_FIELD = `
mutation UpdateProjectV2ItemFieldValue(
$projectId: ID!
$itemId: ID!
$fieldId: ID!
$value: ProjectV2FieldValue!
) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: $value
}
) {
projectV2Item {
id
}
}
}
`;
/**
* GraphQL mutation to add item to project
*/
const ADD_ITEM_TO_PROJECT = `
mutation AddProjectV2ItemById($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item {
id
}
}
}
`;
/**
* GraphQL query to get project by number
*/
const GET_PROJECT_BY_NUMBER = `
query GetProjectByNumber($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
title
fields(first: 50) {
nodes {
... on ProjectV2Field {
id
name
dataType
}
... on ProjectV2SingleSelectField {
id
name
dataType
options {
id
name
}
}
}
}
}
}
}
`;
/**
* GraphQL query to get project by number (user-owned)
*/
const GET_USER_PROJECT_BY_NUMBER = `
query GetUserProjectByNumber($owner: String!, $number: Int!) {
user(login: $owner) {
projectV2(number: $number) {
id
title
fields(first: 50) {
nodes {
... on ProjectV2Field {
id
name
dataType
}
... on ProjectV2SingleSelectField {
id
name
dataType
options {
id
name
}
}
}
}
}
}
}
`;
/**
* GraphQL query to get issue details including project items
*/
const GET_ISSUE_WITH_PROJECT_ITEMS = `
query GetIssueWithProjectItems($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
id
title
state
projectItems(first: 20) {
nodes {
id
project {
id
number
title
}
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
field {
... on ProjectV2SingleSelectField {
id
name
}
}
optionId
name
}
}
}
}
}
}
}
}
`;
/**
* GraphQL query to list reactions on an issue
*/
const GET_ISSUE_REACTIONS = `
query GetIssueReactions($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
id
reactions(first: 100) {
nodes {
content
user {
login
}
}
}
}
}
}
`;
/**
* GraphQL mutation to add a comment to an issue
*/
const ADD_ISSUE_COMMENT = `
mutation AddIssueComment($subjectId: ID!, $body: String!) {
addComment(input: { subjectId: $subjectId, body: $body }) {
commentEdge {
node {
id
body
createdAt
}
}
}
}
`;
/**
* Create a GraphQL mutation handler
* @param {Object} octokit - GitHub Octokit instance with graphql
* @returns {Object} - Handler methods
*/
function createGraphQLMutationHandler(octokit) {
/**
* Get project details by number
* @param {string} owner - Organization or user login
* @param {number} projectNumber - Project number
* @param {boolean} isOrg - Whether owner is an organization
* @returns {Promise<Object>} - Project details
*/
async function getProject(owner, projectNumber, isOrg = true) {
const query = isOrg ? GET_PROJECT_BY_NUMBER : GET_USER_PROJECT_BY_NUMBER;
const result = await octokit.graphql(query, {
owner,
number: projectNumber,
});
const project = isOrg
? result.organization?.projectV2
: result.user?.projectV2;
if (!project) {
throw new Error(`Project #${projectNumber} not found for ${owner}`);
}
return project;
}
/**
* Find a status field option by name
* @param {Object} project - Project object
* @param {string} statusName - Status option name
* @returns {Object|null} - Field and option details
*/
function findStatusOption(project, statusName) {
const statusField = project.fields.nodes.find(
(f) => f.name === "Status" && f.options
);
if (!statusField) {
return null;
}
const option = statusField.options.find(
(o) => o.name.toLowerCase() === statusName.toLowerCase()
);
if (!option) {
return null;
}
return {
fieldId: statusField.id,
fieldName: statusField.name,
optionId: option.id,
optionName: option.name,
};
}
/**
* Get issue details with project items
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} issueNumber - Issue number
* @returns {Promise<Object>} - Issue details
*/
async function getIssueWithProjects(owner, repo, issueNumber) {
const result = await octokit.graphql(GET_ISSUE_WITH_PROJECT_ITEMS, {
owner,
repo,
number: issueNumber,
});
return result.repository?.issue;
}
/**
* Get reactions on an issue
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} issueNumber - Issue number
* @returns {Promise<Array>} - Array of reactions
*/
async function getIssueReactions(owner, repo, issueNumber) {
const result = await octokit.graphql(GET_ISSUE_REACTIONS, {
owner,
repo,
number: issueNumber,
});
return result.repository?.issue?.reactions?.nodes || [];
}
/**
* Update a project item's status field
* @param {Object} options - Update options
* @returns {Promise<Object>} - Update result
*/
async function updateProjectItemStatus({
projectId,
itemId,
fieldId,
optionId,
}) {
const result = await octokit.graphql(UPDATE_PROJECT_ITEM_FIELD, {
projectId,
itemId,
fieldId,
value: { singleSelectOptionId: optionId },
});
return {
success: true,
itemId: result.updateProjectV2ItemFieldValue.projectV2Item.id,
};
}
/**
* Add an issue to a project
* @param {string} projectId - Project node ID
* @param {string} contentId - Issue node ID
* @returns {Promise<Object>} - Added item details
*/
async function addIssueToProject(projectId, contentId) {
const result = await octokit.graphql(ADD_ITEM_TO_PROJECT, {
projectId,
contentId,
});
return {
success: true,
itemId: result.addProjectV2ItemById.item.id,
};
}
/**
* Add a comment to an issue
* @param {string} issueId - Issue node ID
* @param {string} body - Comment body
* @returns {Promise<Object>} - Comment details
*/
async function addIssueComment(issueId, body) {
const result = await octokit.graphql(ADD_ISSUE_COMMENT, {
subjectId: issueId,
body,
});
return {
success: true,
comment: result.addComment.commentEdge.node,
};
}
/**
* Update issue status in project based on emoji
* @param {Object} options - Options
* @returns {Promise<Object>} - Update result
*/
async function updateIssueStatusByEmoji({
owner,
repo,
issueNumber,
statusName,
projectNumber,
isOrg = true,
}) {
// Get project details
const project = await getProject(owner, projectNumber, isOrg);
// Find status option
const statusOption = findStatusOption(project, statusName);
if (!statusOption) {
throw new Error(`Status "${statusName}" not found in project`);
}
// Get issue with project items
const issue = await getIssueWithProjects(owner, repo, issueNumber);
if (!issue) {
throw new Error(`Issue #${issueNumber} not found`);
}
// Find the project item for this project
let projectItem = issue.projectItems.nodes.find(
(item) => item.project.id === project.id
);
// If issue is not in project, add it
if (!projectItem) {
const addResult = await addIssueToProject(project.id, issue.id);
// Re-fetch to get the item details
const updatedIssue = await getIssueWithProjects(owner, repo, issueNumber);
projectItem = updatedIssue.projectItems.nodes.find(
(item) => item.project.id === project.id
);
}
if (!projectItem) {
throw new Error("Failed to add issue to project");
}
// Update the status
const updateResult = await updateProjectItemStatus({
projectId: project.id,
itemId: projectItem.id,
fieldId: statusOption.fieldId,
optionId: statusOption.optionId,
});
return {
success: true,
issueNumber,
projectNumber,
status: statusOption.optionName,
itemId: updateResult.itemId,
};
}
return {
getProject,
findStatusOption,
getIssueWithProjects,
getIssueReactions,
updateProjectItemStatus,
addIssueToProject,
addIssueComment,
updateIssueStatusByEmoji,
};
}
module.exports = {
UPDATE_PROJECT_ITEM_FIELD,
ADD_ITEM_TO_PROJECT,
GET_PROJECT_BY_NUMBER,
GET_USER_PROJECT_BY_NUMBER,
GET_ISSUE_WITH_PROJECT_ITEMS,
GET_ISSUE_REACTIONS,
ADD_ISSUE_COMMENT,
createGraphQLMutationHandler,
};

341
bot/project-status-sync.js Normal file
View File

@@ -0,0 +1,341 @@
// bot/project-status-sync.js
// 📊 Pushes emoji updates into GitHub Projects using GraphQL API
const {
EMOJI_CATEGORIES,
reactionToCategory,
} = require("./emoji-heatmap");
/**
* Status field value mappings from emoji-bot-config.yml
*/
const STATUS_MAPPING = {
"✅": "Done",
"🟡": "In Progress",
"⬜": "Not Started",
"❌": "Blocked",
"🔁": "Rework",
"🤔": "Needs Review",
"🛟": "Escalation",
};
/**
* Reverse mapping from category to status
*/
const CATEGORY_TO_STATUS = {
completed: "Done",
inProgress: "In Progress",
notStarted: "Not Started",
blocked: "Blocked",
review: "Needs Review",
escalation: "Escalation",
};
/**
* GraphQL mutation to update a project item field
*/
const UPDATE_PROJECT_FIELD_MUTATION = `
mutation UpdateProjectV2ItemFieldValue(
$projectId: ID!
$itemId: ID!
$fieldId: ID!
$value: ProjectV2FieldValue!
) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: $value
}
) {
projectV2Item {
id
}
}
}
`;
/**
* GraphQL query to get project details including field IDs
*/
const GET_PROJECT_FIELDS_QUERY = `
query GetProjectFields($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
id
title
fields(first: 20) {
nodes {
... on ProjectV2Field {
id
name
}
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
`;
/**
* GraphQL query to get issue's project item ID
*/
const GET_ISSUE_PROJECT_ITEM_QUERY = `
query GetIssueProjectItem($owner: String!, $repo: String!, $issueNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
id
projectItems(first: 10) {
nodes {
id
project {
id
}
}
}
}
}
}
`;
/**
* Find the status field and value ID for a given status name
* @param {Array} fields - Project fields array
* @param {string} statusName - The status name to find (e.g., "Done")
* @returns {Object|null} - Field and value IDs or null
*/
function findStatusFieldValue(fields, statusName) {
const statusField = fields.find(
(f) =>
f.name === "Status" &&
f.options // Single select field
);
if (!statusField) return null;
const option = statusField.options.find(
(o) => o.name.toLowerCase() === statusName.toLowerCase()
);
if (!option) return null;
return {
fieldId: statusField.id,
valueId: option.id,
fieldName: statusField.name,
valueName: option.name,
};
}
/**
* Convert emoji to status name
* @param {string} emoji - The emoji character
* @returns {string|null} - Status name or null
*/
function emojiToStatus(emoji) {
return STATUS_MAPPING[emoji] || null;
}
/**
* Convert GitHub reaction to status name
* @param {string} reaction - GitHub reaction name
* @returns {string|null} - Status name or null
*/
function reactionToStatus(reaction) {
const category = reactionToCategory(reaction);
return category ? CATEGORY_TO_STATUS[category] : null;
}
/**
* Create a project status sync handler
* @param {Object} octokit - GitHub Octokit instance
* @param {Object} options - Options
* @param {number} options.cacheTTL - Cache TTL in milliseconds (default: 5 minutes)
* @returns {Object} - Handler methods
*/
function createProjectStatusSync(octokit, options = {}) {
const cacheTTL = options.cacheTTL || 5 * 60 * 1000; // Default 5 minutes
let cachedProjectFields = null;
let cachedProjectId = null;
let cacheTimestamp = null;
/**
* Clear the project fields cache
*/
function clearCache() {
cachedProjectFields = null;
cachedProjectId = null;
cacheTimestamp = null;
}
/**
* Fetch and cache project fields
* @param {string} projectId - GitHub Project node ID
* @param {boolean} forceRefresh - Force refresh the cache
* @returns {Promise<Array>} - Project fields
*/
async function getProjectFields(projectId, forceRefresh = false) {
const now = Date.now();
const cacheExpired = cacheTimestamp && (now - cacheTimestamp) > cacheTTL;
if (!forceRefresh && !cacheExpired && cachedProjectId === projectId && cachedProjectFields) {
return cachedProjectFields;
}
const result = await octokit.graphql(GET_PROJECT_FIELDS_QUERY, {
projectId,
});
cachedProjectId = projectId;
cachedProjectFields = result.node?.fields?.nodes || [];
cacheTimestamp = now;
return cachedProjectFields;
}
/**
* Get issue's project item ID
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} issueNumber - Issue number
* @param {string} projectId - Target project ID
* @returns {Promise<string|null>} - Project item ID or null
*/
async function getIssueProjectItemId(owner, repo, issueNumber, projectId) {
const result = await octokit.graphql(GET_ISSUE_PROJECT_ITEM_QUERY, {
owner,
repo,
issueNumber,
});
const items = result.repository?.issue?.projectItems?.nodes || [];
const item = items.find((i) => i.project.id === projectId);
return item?.id || null;
}
/**
* Update project item status
* @param {Object} options - Update options
* @returns {Promise<Object>} - Update result
*/
async function updateProjectItemStatus({
projectId,
itemId,
statusName,
}) {
const fields = await getProjectFields(projectId);
const fieldValue = findStatusFieldValue(fields, statusName);
if (!fieldValue) {
throw new Error(
`Status "${statusName}" not found in project fields`
);
}
const result = await octokit.graphql(UPDATE_PROJECT_FIELD_MUTATION, {
projectId,
itemId,
fieldId: fieldValue.fieldId,
value: { singleSelectOptionId: fieldValue.valueId },
});
return {
success: true,
itemId: result.updateProjectV2ItemFieldValue.projectV2Item.id,
status: fieldValue.valueName,
};
}
/**
* Sync issue status based on emoji
* @param {Object} options - Sync options
* @returns {Promise<Object>} - Sync result
*/
async function syncIssueStatusFromEmoji({
owner,
repo,
issueNumber,
emoji,
projectId,
}) {
const statusName = emojiToStatus(emoji);
if (!statusName) {
return { success: false, reason: "No status mapping for emoji" };
}
const itemId = await getIssueProjectItemId(
owner,
repo,
issueNumber,
projectId
);
if (!itemId) {
return { success: false, reason: "Issue not in project" };
}
return updateProjectItemStatus({ projectId, itemId, statusName });
}
/**
* Sync issue status based on reaction
* @param {Object} options - Sync options
* @returns {Promise<Object>} - Sync result
*/
async function syncIssueStatusFromReaction({
owner,
repo,
issueNumber,
reaction,
projectId,
}) {
const statusName = reactionToStatus(reaction);
if (!statusName) {
return { success: false, reason: "No status mapping for reaction" };
}
const itemId = await getIssueProjectItemId(
owner,
repo,
issueNumber,
projectId
);
if (!itemId) {
return { success: false, reason: "Issue not in project" };
}
return updateProjectItemStatus({ projectId, itemId, statusName });
}
return {
getProjectFields,
getIssueProjectItemId,
updateProjectItemStatus,
syncIssueStatusFromEmoji,
syncIssueStatusFromReaction,
findStatusFieldValue,
clearCache,
};
}
module.exports = {
STATUS_MAPPING,
CATEGORY_TO_STATUS,
UPDATE_PROJECT_FIELD_MUTATION,
GET_PROJECT_FIELDS_QUERY,
GET_ISSUE_PROJECT_ITEM_QUERY,
emojiToStatus,
reactionToStatus,
findStatusFieldValue,
createProjectStatusSync,
};

90
bot/run-digest.js Normal file
View File

@@ -0,0 +1,90 @@
// bot/run-digest.js
// 📊 Runner script for weekly emoji digest
const { createWeeklyEmojiDigest } = require("./weekly-emoji-digest");
async function main() {
// Check for required environment variables
const token = process.env.GITHUB_TOKEN;
const dryRun = process.env.DRY_RUN === "true";
// Support GitHub Enterprise with GITHUB_API_URL, default to public GitHub
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
const graphqlUrl = `${apiUrl}/graphql`;
if (!token) {
console.error("❌ GITHUB_TOKEN environment variable is required");
process.exit(1);
}
// Parse repository from GITHUB_REPOSITORY env var
const repo = process.env.GITHUB_REPOSITORY;
if (!repo) {
console.error("❌ GITHUB_REPOSITORY environment variable is required");
process.exit(1);
}
const [owner, repoName] = repo.split("/");
console.log(`📊 Generating weekly emoji digest for ${owner}/${repoName}`);
console.log(`🔧 Dry run: ${dryRun}`);
console.log(`🌐 API URL: ${apiUrl}`);
// Create a minimal octokit-like client
const octokit = {
graphql: async (query, variables) => {
const response = await fetch(graphqlUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "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: ${result.errors.map((e) => e.message).join(", ")}`
);
}
return result.data;
},
};
const digest = createWeeklyEmojiDigest(octokit);
try {
if (dryRun) {
// Just generate and print the digest
const data = await digest.generateWeeklyDigest(owner, repoName);
console.log("\n📝 Generated Digest:\n");
console.log(data.markdown);
console.log("\n✅ Dry run complete - no comment posted");
} else {
// Generate and post the digest
const result = await digest.generateAndPostDigest(owner, repoName);
if (result.success) {
console.log(`✅ Digest posted to issue #${result.issueNumber}`);
console.log(`📝 Comment ID: ${result.commentId}`);
} else {
console.log(`⚠️ Could not post digest: ${result.reason}`);
console.log("\n📝 Generated Digest:\n");
console.log(result.digest.markdown);
}
}
} catch (error) {
console.error("❌ Error generating digest:", error.message);
process.exit(1);
}
}
main();

495
bot/weekly-emoji-digest.js Normal file
View File

@@ -0,0 +1,495 @@
// bot/weekly-emoji-digest.js
// 📅 Weekly Auto-Generated Emoji Digest - Posts agent heatmap reports
const {
countEmojis,
generateHeatmap,
aggregateCounts,
generateMarkdownReport,
} = require("./emoji-heatmap");
const { ADD_ISSUE_COMMENT } = require("./graphql-mutation-handler");
/**
* GraphQL query to get repository issues with reactions
*/
const GET_REPO_ISSUES_WITH_REACTIONS = `
query GetRepoIssuesWithReactions(
$owner: String!
$repo: String!
$after: String
$since: DateTime
) {
repository(owner: $owner, name: $repo) {
issues(first: 50, after: $after, filterBy: { since: $since }) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
body
reactions(first: 100) {
nodes {
content
}
}
comments(first: 50) {
nodes {
body
reactions(first: 50) {
nodes {
content
}
}
}
}
labels(first: 10) {
nodes {
name
}
}
assignees(first: 5) {
nodes {
login
}
}
}
}
}
}
`;
/**
* GraphQL query to find the digest issue
*/
const FIND_DIGEST_ISSUE = `
query FindDigestIssue($owner: String!, $repo: String!, $title: String!) {
repository(owner: $owner, name: $repo) {
issues(
first: 1
filterBy: { labels: ["emoji-digest"] }
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
id
number
title
}
}
}
}
`;
/**
* Map GitHub reaction content to emoji for counting
*/
const REACTION_CONTENT_TO_EMOJI = {
THUMBS_UP: "✅",
THUMBS_DOWN: "❌",
LAUGH: "😄",
HOORAY: "✅",
CONFUSED: "❌",
HEART: "❤️",
ROCKET: "✅",
EYES: "🤔",
};
/**
* Agent labels to track
*/
const AGENT_LABELS = [
"builder-agent",
"planner-agent",
"guardian-agent",
"observer-agent",
"human",
];
/**
* Count reactions and convert to emoji counts
* @param {Array} reactions - Array of reaction objects
* @returns {Object} - Emoji counts
*/
function countReactions(reactions) {
const counts = {
completed: 0,
blocked: 0,
escalation: 0,
inProgress: 0,
review: 0,
notStarted: 0,
total: 0,
};
for (const reaction of reactions) {
const emoji = REACTION_CONTENT_TO_EMOJI[reaction.content];
if (emoji) {
const textCounts = countEmojis(emoji);
for (const key of Object.keys(counts)) {
counts[key] += textCounts[key] || 0;
}
}
}
return counts;
}
/**
* Extract all emojis and reactions from an issue
* @param {Object} issue - Issue object from GraphQL
* @returns {Object} - Aggregated emoji counts
*/
function extractIssueEmojis(issue) {
const allCounts = [];
// Count emojis in issue body
if (issue.body) {
allCounts.push(countEmojis(issue.body));
}
// Count reactions on issue
if (issue.reactions?.nodes) {
allCounts.push(countReactions(issue.reactions.nodes));
}
// Count emojis and reactions in comments
if (issue.comments?.nodes) {
for (const comment of issue.comments.nodes) {
if (comment.body) {
allCounts.push(countEmojis(comment.body));
}
if (comment.reactions?.nodes) {
allCounts.push(countReactions(comment.reactions.nodes));
}
}
}
return aggregateCounts(allCounts);
}
/**
* Group issues by agent based on labels
* @param {Array} issues - Array of issue objects
* @returns {Object} - Issues grouped by agent
*/
function groupIssuesByAgent(issues) {
const groups = {};
// Initialize groups for known agents
for (const agent of AGENT_LABELS) {
groups[agent] = [];
}
groups.unassigned = [];
for (const issue of issues) {
const labels = issue.labels?.nodes?.map((l) => l.name) || [];
const agentLabel = labels.find((l) => AGENT_LABELS.includes(l));
if (agentLabel) {
groups[agentLabel].push(issue);
} else {
groups.unassigned.push(issue);
}
}
return groups;
}
/**
* Generate agent-specific heatmap data
* @param {Object} groups - Issues grouped by agent
* @returns {Object} - Heatmap data per agent
*/
function generateAgentHeatmaps(groups) {
const heatmaps = {};
for (const [agent, issues] of Object.entries(groups)) {
if (issues.length === 0) continue;
const counts = aggregateCounts(
issues.map((issue) => extractIssueEmojis(issue))
);
heatmaps[agent] = {
issueCount: issues.length,
counts,
heatmap: generateHeatmap(counts),
};
}
return heatmaps;
}
/**
* Generate weekly digest markdown report
* @param {Object} data - Digest data
* @returns {string} - Markdown report
*/
function generateDigestMarkdown(data) {
const {
repoName,
weekStart,
weekEnd,
totalIssues,
overallHeatmap,
agentHeatmaps,
topEscalations,
} = data;
const formatDate = (date) =>
date.toISOString().split("T")[0];
let markdown = `# 📊 Weekly Emoji Digest
**Repository:** ${repoName}
**Period:** ${formatDate(weekStart)}${formatDate(weekEnd)}
**Total Issues Analyzed:** ${totalIssues}
---
## 🌡️ Overall Status Heatmap
${generateMarkdownReport(overallHeatmap, "Repository Summary")}
---
## 🤖 Agent Performance
`;
// Add per-agent heatmaps
for (const [agent, data] of Object.entries(agentHeatmaps)) {
const agentEmoji =
agent === "builder-agent"
? "🏗️"
: agent === "planner-agent"
? "📋"
: agent === "guardian-agent"
? "🛡️"
: agent === "observer-agent"
? "👁️"
: agent === "human"
? "🧑"
: "📦";
markdown += `### ${agentEmoji} ${agent}
- **Issues:** ${data.issueCount}
- **% Complete:** ${data.heatmap.percentComplete}%
- **Escalations:** ${data.heatmap.escalations}
- **Blocked:** ${data.heatmap.percentBlocked}%
`;
}
// Add escalations section if any
if (topEscalations && topEscalations.length > 0) {
markdown += `---
## 🛟 Active Escalations
| Issue | Title | Assigned To |
|-------|-------|-------------|
`;
for (const esc of topEscalations) {
const assignees =
esc.assignees?.nodes?.map((a) => `@${a.login}`).join(", ") ||
"Unassigned";
markdown += `| #${esc.number} | ${esc.title} | ${assignees} |\n`;
}
}
markdown += `
---
## 📈 Emoji Legend
| Emoji | Meaning |
|-------|---------|
| ✅ | Completed |
| 🟡 | In Progress |
| ❌ | Blocked |
| 🤔 | Needs Review |
| 🛟 | Escalation |
| ⬜ | Not Started |
---
*Generated automatically by Emoji Bot 🤖*
*Next digest: ${formatDate(new Date(weekEnd.getTime() + 7 * 24 * 60 * 60 * 1000))}*
`;
return markdown;
}
/**
* Create a weekly emoji digest generator
* @param {Object} octokit - GitHub Octokit instance
* @returns {Object} - Digest methods
*/
function createWeeklyEmojiDigest(octokit) {
/**
* Fetch all issues from the past week
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<Array>} - Array of issues
*/
async function fetchWeeklyIssues(owner, repo) {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const allIssues = [];
let after = null;
let hasNextPage = true;
while (hasNextPage) {
const result = await octokit.graphql(GET_REPO_ISSUES_WITH_REACTIONS, {
owner,
repo,
after,
since: oneWeekAgo.toISOString(),
});
const issues = result.repository?.issues?.nodes || [];
allIssues.push(...issues);
hasNextPage = result.repository?.issues?.pageInfo?.hasNextPage || false;
after = result.repository?.issues?.pageInfo?.endCursor;
}
return allIssues;
}
/**
* Find or create digest issue
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<Object>} - Digest issue
*/
async function findDigestIssue(owner, repo) {
const result = await octokit.graphql(FIND_DIGEST_ISSUE, {
owner,
repo,
title: "emoji-digest",
});
return result.repository?.issues?.nodes?.[0] || null;
}
/**
* Post digest comment to issue
* @param {string} issueId - Issue node ID
* @param {string} markdown - Markdown content
* @returns {Promise<Object>} - Comment result
*/
async function postDigestComment(issueId, markdown) {
const result = await octokit.graphql(ADD_ISSUE_COMMENT, {
subjectId: issueId,
body: markdown,
});
return result.addComment.commentEdge.node;
}
/**
* Generate weekly digest for a repository
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<Object>} - Digest data
*/
async function generateWeeklyDigest(owner, repo) {
const issues = await fetchWeeklyIssues(owner, repo);
const weekEnd = new Date();
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() - 7);
// Extract emoji counts from all issues once (optimization: reuse for escalation check)
const issuesWithCounts = issues.map((issue) => ({
issue,
counts: extractIssueEmojis(issue),
}));
const allCounts = issuesWithCounts.map(({ counts }) => counts);
const overallCounts = aggregateCounts(allCounts);
const overallHeatmap = generateHeatmap(overallCounts);
// Group by agent and generate per-agent heatmaps
const agentGroups = groupIssuesByAgent(issues);
const agentHeatmaps = generateAgentHeatmaps(agentGroups);
// Find issues with escalations (reuse pre-computed counts)
const topEscalations = issuesWithCounts
.filter(({ counts }) => counts.escalation > 0)
.map(({ issue }) => issue)
.slice(0, 5);
const digestData = {
repoName: `${owner}/${repo}`,
weekStart,
weekEnd,
totalIssues: issues.length,
overallCounts,
overallHeatmap,
agentHeatmaps,
topEscalations,
};
digestData.markdown = generateDigestMarkdown(digestData);
return digestData;
}
/**
* Generate and post weekly digest
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<Object>} - Result
*/
async function generateAndPostDigest(owner, repo) {
// Generate digest
const digestData = await generateWeeklyDigest(owner, repo);
// Find digest issue
const digestIssue = await findDigestIssue(owner, repo);
if (!digestIssue) {
return {
success: false,
reason: "No emoji-digest issue found. Create an issue with 'emoji-digest' label.",
digest: digestData,
};
}
// Post comment
const comment = await postDigestComment(digestIssue.id, digestData.markdown);
return {
success: true,
issueNumber: digestIssue.number,
commentId: comment.id,
digest: digestData,
};
}
return {
fetchWeeklyIssues,
findDigestIssue,
postDigestComment,
generateWeeklyDigest,
generateAndPostDigest,
};
}
module.exports = {
GET_REPO_ISSUES_WITH_REACTIONS,
FIND_DIGEST_ISSUE,
REACTION_CONTENT_TO_EMOJI,
AGENT_LABELS,
countReactions,
extractIssueEmojis,
groupIssuesByAgent,
generateAgentHeatmaps,
generateDigestMarkdown,
createWeeklyEmojiDigest,
};

View File

@@ -0,0 +1,264 @@
// tests/emoji-agent-router.test.js
import { describe, expect, it, vi } from "vitest";
import {
ROUTE_TYPES,
ESCALATION_EMOJIS,
STATUS_EMOJIS,
determineEmojiRoute,
determineReactionRoute,
createEmojiRouteContext,
createReactionRouteContext,
createEmojiAgentRouter,
} from "../bot/emoji-agent-router";
describe("emoji-agent-router", () => {
describe("ROUTE_TYPES", () => {
it("should have all required route types", () => {
expect(ROUTE_TYPES.MATH).toBe("math");
expect(ROUTE_TYPES.STATUS).toBe("status");
expect(ROUTE_TYPES.ESCALATION).toBe("escalation");
expect(ROUTE_TYPES.NOTIFICATION).toBe("notification");
expect(ROUTE_TYPES.IGNORE).toBe("ignore");
});
});
describe("ESCALATION_EMOJIS", () => {
it("should contain escalation emojis", () => {
expect(ESCALATION_EMOJIS).toContain("🛟");
expect(ESCALATION_EMOJIS).toContain("🚨");
expect(ESCALATION_EMOJIS).toContain("🔥");
});
});
describe("STATUS_EMOJIS", () => {
it("should contain status emojis", () => {
expect(STATUS_EMOJIS).toContain("✅");
expect(STATUS_EMOJIS).toContain("🟡");
expect(STATUS_EMOJIS).toContain("❌");
expect(STATUS_EMOJIS).toContain("🤔");
});
});
describe("determineEmojiRoute", () => {
it("should route escalation emojis", () => {
expect(determineEmojiRoute("🛟")).toBe(ROUTE_TYPES.ESCALATION);
expect(determineEmojiRoute("🚨")).toBe(ROUTE_TYPES.ESCALATION);
expect(determineEmojiRoute("🔥")).toBe(ROUTE_TYPES.ESCALATION);
});
it("should route status emojis", () => {
expect(determineEmojiRoute("✅")).toBe(ROUTE_TYPES.STATUS);
expect(determineEmojiRoute("❌")).toBe(ROUTE_TYPES.STATUS);
expect(determineEmojiRoute("🟡")).toBe(ROUTE_TYPES.STATUS);
});
it("should ignore unknown emojis", () => {
expect(determineEmojiRoute("🍕")).toBe(ROUTE_TYPES.IGNORE);
expect(determineEmojiRoute("🎈")).toBe(ROUTE_TYPES.IGNORE);
});
});
describe("determineReactionRoute", () => {
it("should route status reactions", () => {
expect(determineReactionRoute("rocket")).toBe(ROUTE_TYPES.STATUS);
expect(determineReactionRoute("hooray")).toBe(ROUTE_TYPES.STATUS);
expect(determineReactionRoute("-1")).toBe(ROUTE_TYPES.STATUS);
});
it("should route escalation reactions", () => {
expect(determineReactionRoute("rotating_light")).toBe(ROUTE_TYPES.ESCALATION);
});
it("should ignore unknown reactions", () => {
expect(determineReactionRoute("unknown")).toBe(ROUTE_TYPES.IGNORE);
expect(determineReactionRoute("+1")).toBe(ROUTE_TYPES.IGNORE);
});
});
describe("createEmojiRouteContext", () => {
it("should create context for status emoji", () => {
const context = createEmojiRouteContext("✅");
expect(context.type).toBe(ROUTE_TYPES.STATUS);
expect(context.emoji).toBe("✅");
expect(context.status).toBe("Done");
expect(context.isEscalation).toBe(false);
});
it("should create context for escalation emoji", () => {
const context = createEmojiRouteContext("🛟");
expect(context.type).toBe(ROUTE_TYPES.ESCALATION);
expect(context.isEscalation).toBe(true);
expect(context.status).toBe("Escalation");
});
});
describe("createReactionRouteContext", () => {
it("should create context for status reaction", () => {
const context = createReactionRouteContext("rocket");
expect(context.type).toBe(ROUTE_TYPES.STATUS);
expect(context.emoji).toBe("rocket");
expect(context.status).toBe("Done");
expect(context.category).toBe("completed");
});
it("should create context for blocked reaction", () => {
const context = createReactionRouteContext("-1");
expect(context.type).toBe(ROUTE_TYPES.STATUS);
expect(context.status).toBe("Blocked");
expect(context.category).toBe("blocked");
});
});
describe("createEmojiAgentRouter", () => {
it("should create router with all methods", () => {
const router = createEmojiAgentRouter({});
expect(router).toHaveProperty("routeEmoji");
expect(router).toHaveProperty("routeReaction");
expect(router).toHaveProperty("handleMathRequest");
expect(router).toHaveProperty("processBatchMath");
});
describe("routeEmoji", () => {
it("should route status emoji and call callback", async () => {
const onStatusUpdate = vi.fn();
const router = createEmojiAgentRouter({ onStatusUpdate });
const result = await router.routeEmoji("✅", {
owner: "test",
repo: "repo",
issueNumber: 1,
});
expect(result.handled).toBe(true);
expect(result.action).toBe("status_update");
expect(onStatusUpdate).toHaveBeenCalled();
});
it("should route escalation emoji and call callback", async () => {
const onEscalation = vi.fn();
const router = createEmojiAgentRouter({ onEscalation });
const result = await router.routeEmoji("🛟", {
owner: "test",
repo: "repo",
issueNumber: 1,
});
expect(result.handled).toBe(true);
expect(result.action).toBe("escalation_triggered");
expect(onEscalation).toHaveBeenCalled();
});
it("should ignore unknown emojis", async () => {
const router = createEmojiAgentRouter({});
const result = await router.routeEmoji("🍕", {
owner: "test",
repo: "repo",
issueNumber: 1,
});
expect(result.handled).toBe(false);
expect(result.action).toBe("ignored");
});
});
describe("routeReaction", () => {
it("should route status reaction", async () => {
const router = createEmojiAgentRouter({});
const result = await router.routeReaction("rocket", {
owner: "test",
repo: "repo",
issueNumber: 1,
});
expect(result.handled).toBe(true);
expect(result.action).toBe("status_update");
});
it("should ignore unknown reactions", async () => {
const router = createEmojiAgentRouter({});
const result = await router.routeReaction("unknown", {
owner: "test",
repo: "repo",
issueNumber: 1,
});
expect(result.handled).toBe(false);
});
});
describe("handleMathRequest", () => {
it("should calculate emoji counts", () => {
const router = createEmojiAgentRouter({});
const result = router.handleMathRequest("✅✅❌🟡");
expect(result.handled).toBe(true);
expect(result.action).toBe("math_calculation");
expect(result.data.counts.completed).toBe(2);
expect(result.data.counts.blocked).toBe(1);
expect(result.data.counts.inProgress).toBe(1);
});
it("should generate heatmap", () => {
const router = createEmojiAgentRouter({});
const result = router.handleMathRequest("✅✅✅✅❌");
expect(result.data.heatmap.percentComplete).toBe(80);
expect(result.data.heatmap.percentBlocked).toBe(20);
});
it("should generate markdown report when requested", () => {
const router = createEmojiAgentRouter({});
const result = router.handleMathRequest("✅❌", {
generateReport: true,
title: "Test Report",
});
expect(result.data.report).toContain("Test Report");
expect(result.data.report).toContain("Complete");
});
it("should call onMathRequest callback", () => {
const onMathRequest = vi.fn();
const router = createEmojiAgentRouter({ onMathRequest });
router.handleMathRequest("✅❌");
expect(onMathRequest).toHaveBeenCalled();
});
});
describe("processBatchMath", () => {
it("should aggregate counts from multiple texts", () => {
const router = createEmojiAgentRouter({});
const result = router.processBatchMath(["✅✅", "❌❌", "🟡"]);
expect(result.handled).toBe(true);
expect(result.action).toBe("batch_math_calculation");
expect(result.data.itemCount).toBe(3);
expect(result.data.aggregatedCounts.completed).toBe(2);
expect(result.data.aggregatedCounts.blocked).toBe(2);
expect(result.data.aggregatedCounts.inProgress).toBe(1);
});
it("should generate batch report when requested", () => {
const router = createEmojiAgentRouter({});
const result = router.processBatchMath(["✅", "❌"], {
generateReport: true,
title: "Batch Report",
});
expect(result.data.report).toContain("Batch Report");
});
});
});
});

219
tests/emoji-heatmap.test.js Normal file
View File

@@ -0,0 +1,219 @@
// tests/emoji-heatmap.test.js
import { describe, expect, it } from "vitest";
import {
countEmojis,
calculatePercentComplete,
generateHeatmap,
aggregateCounts,
generateMarkdownReport,
reactionToCategory,
EMOJI_CATEGORIES,
} from "../bot/emoji-heatmap";
describe("emoji-heatmap", () => {
describe("countEmojis", () => {
it("should count completed emojis", () => {
const text = "✅ Task done ✅ Another done";
const counts = countEmojis(text);
expect(counts.completed).toBe(2);
});
it("should count blocked emojis", () => {
const text = "❌ Blocked ❌❌";
const counts = countEmojis(text);
expect(counts.blocked).toBe(3);
});
it("should count escalation emojis", () => {
const text = "🛟 Help needed! 🚨 Alert";
const counts = countEmojis(text);
expect(counts.escalation).toBe(2);
});
it("should count multiple categories", () => {
const text = "✅ Done ❌ Blocked 🟡 In progress";
const counts = countEmojis(text);
expect(counts.completed).toBe(1);
expect(counts.blocked).toBe(1);
expect(counts.inProgress).toBe(1);
});
it("should handle empty text", () => {
const counts = countEmojis("");
expect(counts.total).toBe(0);
});
it("should handle null/undefined", () => {
expect(countEmojis(null).total).toBe(0);
expect(countEmojis(undefined).total).toBe(0);
});
it("should count review emojis", () => {
const text = "🤔 Needs review";
const counts = countEmojis(text);
expect(counts.review).toBe(1);
});
});
describe("calculatePercentComplete", () => {
it("should calculate 0% when nothing is done", () => {
const counts = {
completed: 0,
blocked: 2,
inProgress: 3,
review: 0,
notStarted: 5,
};
expect(calculatePercentComplete(counts)).toBe(0);
});
it("should calculate 100% when all done", () => {
const counts = {
completed: 10,
blocked: 0,
inProgress: 0,
review: 0,
notStarted: 0,
};
expect(calculatePercentComplete(counts)).toBe(100);
});
it("should calculate 50% correctly", () => {
const counts = {
completed: 5,
blocked: 0,
inProgress: 0,
review: 0,
notStarted: 5,
};
expect(calculatePercentComplete(counts)).toBe(50);
});
it("should handle empty counts", () => {
const counts = {
completed: 0,
blocked: 0,
inProgress: 0,
review: 0,
notStarted: 0,
};
expect(calculatePercentComplete(counts)).toBe(0);
});
});
describe("generateHeatmap", () => {
it("should generate heatmap with percentages", () => {
const counts = {
completed: 4,
blocked: 1,
inProgress: 2,
review: 1,
notStarted: 2,
escalation: 3,
};
const heatmap = generateHeatmap(counts);
expect(heatmap.percentComplete).toBe(40);
expect(heatmap.percentBlocked).toBe(10);
expect(heatmap.escalations).toBe(3);
expect(heatmap.totalItems).toBe(10);
});
it("should handle zero totals", () => {
const counts = {
completed: 0,
blocked: 0,
inProgress: 0,
review: 0,
notStarted: 0,
escalation: 0,
};
const heatmap = generateHeatmap(counts);
expect(heatmap.percentComplete).toBe(0);
});
});
describe("aggregateCounts", () => {
it("should aggregate multiple count objects", () => {
const counts1 = { completed: 2, blocked: 1, total: 3 };
const counts2 = { completed: 3, blocked: 2, total: 5 };
const aggregated = aggregateCounts([counts1, counts2]);
expect(aggregated.completed).toBe(5);
expect(aggregated.blocked).toBe(3);
expect(aggregated.total).toBe(8);
});
it("should handle empty array", () => {
const aggregated = aggregateCounts([]);
expect(aggregated.completed).toBe(0);
expect(aggregated.total).toBe(0);
});
});
describe("generateMarkdownReport", () => {
it("should generate valid markdown", () => {
const heatmap = {
percentComplete: 50,
percentBlocked: 10,
percentInProgress: 20,
percentReview: 10,
percentNotStarted: 10,
escalations: 2,
totalItems: 10,
};
const report = generateMarkdownReport(heatmap, "Test Report");
expect(report).toContain("## 📊 Test Report");
expect(report).toContain("50%");
expect(report).toContain("Escalations: 2");
expect(report).toContain("Total Items: 10");
});
});
describe("reactionToCategory", () => {
it("should map rocket to completed", () => {
expect(reactionToCategory("rocket")).toBe("completed");
});
it("should map hooray to completed", () => {
expect(reactionToCategory("hooray")).toBe("completed");
});
it("should map -1 to blocked", () => {
expect(reactionToCategory("-1")).toBe("blocked");
});
it("should map confused to blocked", () => {
expect(reactionToCategory("confused")).toBe("blocked");
});
it("should map eyes to completed (via special mapping)", () => {
// Eyes emoji is mapped to "completed" via the special reaction mapping
// because it often indicates acknowledgment of completion
expect(reactionToCategory("eyes")).toBe("completed");
});
it("should return null for unknown reactions", () => {
expect(reactionToCategory("unknown")).toBe(null);
});
});
describe("EMOJI_CATEGORIES", () => {
it("should have all required categories", () => {
expect(EMOJI_CATEGORIES).toHaveProperty("completed");
expect(EMOJI_CATEGORIES).toHaveProperty("blocked");
expect(EMOJI_CATEGORIES).toHaveProperty("escalation");
expect(EMOJI_CATEGORIES).toHaveProperty("inProgress");
expect(EMOJI_CATEGORIES).toHaveProperty("review");
expect(EMOJI_CATEGORIES).toHaveProperty("notStarted");
});
it("should have arrays for each category", () => {
for (const category of Object.values(EMOJI_CATEGORIES)) {
expect(Array.isArray(category)).toBe(true);
expect(category.length).toBeGreaterThan(0);
}
});
});
});

View File

@@ -0,0 +1,398 @@
// tests/graphql-mutation-handler.test.js
import { describe, expect, it, vi } from "vitest";
import {
UPDATE_PROJECT_ITEM_FIELD,
ADD_ITEM_TO_PROJECT,
GET_PROJECT_BY_NUMBER,
GET_USER_PROJECT_BY_NUMBER,
GET_ISSUE_WITH_PROJECT_ITEMS,
GET_ISSUE_REACTIONS,
ADD_ISSUE_COMMENT,
createGraphQLMutationHandler,
} from "../bot/graphql-mutation-handler";
describe("graphql-mutation-handler", () => {
describe("GraphQL queries and mutations", () => {
it("should export UPDATE_PROJECT_ITEM_FIELD mutation", () => {
expect(UPDATE_PROJECT_ITEM_FIELD).toContain("mutation");
expect(UPDATE_PROJECT_ITEM_FIELD).toContain("updateProjectV2ItemFieldValue");
});
it("should export ADD_ITEM_TO_PROJECT mutation", () => {
expect(ADD_ITEM_TO_PROJECT).toContain("mutation");
expect(ADD_ITEM_TO_PROJECT).toContain("addProjectV2ItemById");
});
it("should export GET_PROJECT_BY_NUMBER query", () => {
expect(GET_PROJECT_BY_NUMBER).toContain("query");
expect(GET_PROJECT_BY_NUMBER).toContain("organization");
expect(GET_PROJECT_BY_NUMBER).toContain("projectV2");
});
it("should export GET_USER_PROJECT_BY_NUMBER query", () => {
expect(GET_USER_PROJECT_BY_NUMBER).toContain("query");
expect(GET_USER_PROJECT_BY_NUMBER).toContain("user");
});
it("should export GET_ISSUE_WITH_PROJECT_ITEMS query", () => {
expect(GET_ISSUE_WITH_PROJECT_ITEMS).toContain("query");
expect(GET_ISSUE_WITH_PROJECT_ITEMS).toContain("projectItems");
});
it("should export GET_ISSUE_REACTIONS query", () => {
expect(GET_ISSUE_REACTIONS).toContain("query");
expect(GET_ISSUE_REACTIONS).toContain("reactions");
});
it("should export ADD_ISSUE_COMMENT mutation", () => {
expect(ADD_ISSUE_COMMENT).toContain("mutation");
expect(ADD_ISSUE_COMMENT).toContain("addComment");
});
});
describe("createGraphQLMutationHandler", () => {
it("should create handler with all methods", () => {
const mockOctokit = { graphql: vi.fn() };
const handler = createGraphQLMutationHandler(mockOctokit);
expect(handler).toHaveProperty("getProject");
expect(handler).toHaveProperty("findStatusOption");
expect(handler).toHaveProperty("getIssueWithProjects");
expect(handler).toHaveProperty("getIssueReactions");
expect(handler).toHaveProperty("updateProjectItemStatus");
expect(handler).toHaveProperty("addIssueToProject");
expect(handler).toHaveProperty("addIssueComment");
expect(handler).toHaveProperty("updateIssueStatusByEmoji");
});
describe("getProject", () => {
it("should fetch organization project", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
organization: {
projectV2: {
id: "proj1",
title: "Test Project",
fields: { nodes: [] },
},
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const project = await handler.getProject("org", 1, true);
expect(project.id).toBe("proj1");
expect(project.title).toBe("Test Project");
});
it("should fetch user project", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
user: {
projectV2: {
id: "proj2",
title: "User Project",
fields: { nodes: [] },
},
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const project = await handler.getProject("user", 1, false);
expect(project.id).toBe("proj2");
expect(project.title).toBe("User Project");
});
it("should throw error if project not found", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
organization: { projectV2: null },
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
await expect(handler.getProject("org", 999, true)).rejects.toThrow(
"Project #999 not found"
);
});
});
describe("findStatusOption", () => {
it("should find status field and option", () => {
const mockOctokit = { graphql: vi.fn() };
const handler = createGraphQLMutationHandler(mockOctokit);
const project = {
fields: {
nodes: [
{
id: "f1",
name: "Status",
options: [
{ id: "o1", name: "Done" },
{ id: "o2", name: "In Progress" },
],
},
],
},
};
const result = handler.findStatusOption(project, "Done");
expect(result.fieldId).toBe("f1");
expect(result.optionId).toBe("o1");
expect(result.optionName).toBe("Done");
});
it("should return null if no Status field", () => {
const mockOctokit = { graphql: vi.fn() };
const handler = createGraphQLMutationHandler(mockOctokit);
const project = {
fields: { nodes: [{ id: "f1", name: "Title" }] },
};
expect(handler.findStatusOption(project, "Done")).toBe(null);
});
it("should find status case-insensitively", () => {
const mockOctokit = { graphql: vi.fn() };
const handler = createGraphQLMutationHandler(mockOctokit);
const project = {
fields: {
nodes: [
{
id: "f1",
name: "Status",
options: [{ id: "o1", name: "Done" }],
},
],
},
};
const result = handler.findStatusOption(project, "done");
expect(result.optionId).toBe("o1");
});
});
describe("getIssueWithProjects", () => {
it("should fetch issue with project items", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
repository: {
issue: {
id: "issue1",
title: "Test Issue",
projectItems: { nodes: [] },
},
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const issue = await handler.getIssueWithProjects("owner", "repo", 1);
expect(issue.id).toBe("issue1");
expect(issue.title).toBe("Test Issue");
});
});
describe("getIssueReactions", () => {
it("should fetch issue reactions", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
repository: {
issue: {
id: "issue1",
reactions: {
nodes: [
{ content: "THUMBS_UP", user: { login: "user1" } },
{ content: "ROCKET", user: { login: "user2" } },
],
},
},
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const reactions = await handler.getIssueReactions("owner", "repo", 1);
expect(reactions).toHaveLength(2);
expect(reactions[0].content).toBe("THUMBS_UP");
});
});
describe("updateProjectItemStatus", () => {
it("should update project item status", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
updateProjectV2ItemFieldValue: {
projectV2Item: { id: "item1" },
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const result = await handler.updateProjectItemStatus({
projectId: "proj1",
itemId: "item1",
fieldId: "field1",
optionId: "opt1",
});
expect(result.success).toBe(true);
expect(result.itemId).toBe("item1");
});
});
describe("addIssueToProject", () => {
it("should add issue to project", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
addProjectV2ItemById: {
item: { id: "newItem1" },
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const result = await handler.addIssueToProject("proj1", "issue1");
expect(result.success).toBe(true);
expect(result.itemId).toBe("newItem1");
});
});
describe("addIssueComment", () => {
it("should add comment to issue", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
addComment: {
commentEdge: {
node: {
id: "comment1",
body: "Test comment",
createdAt: "2024-01-01T00:00:00Z",
},
},
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const result = await handler.addIssueComment("issue1", "Test comment");
expect(result.success).toBe(true);
expect(result.comment.id).toBe("comment1");
expect(result.comment.body).toBe("Test comment");
});
});
describe("updateIssueStatusByEmoji", () => {
it("should update issue status in project", async () => {
const mockOctokit = {
graphql: vi
.fn()
.mockResolvedValueOnce({
// getProject
organization: {
projectV2: {
id: "proj1",
title: "Test Project",
fields: {
nodes: [
{
id: "f1",
name: "Status",
options: [
{ id: "o1", name: "Done" },
{ id: "o2", name: "In Progress" },
],
},
],
},
},
},
})
.mockResolvedValueOnce({
// getIssueWithProjects
repository: {
issue: {
id: "issue1",
title: "Test Issue",
projectItems: {
nodes: [
{
id: "item1",
project: { id: "proj1" },
},
],
},
},
},
})
.mockResolvedValueOnce({
// updateProjectItemStatus
updateProjectV2ItemFieldValue: {
projectV2Item: { id: "item1" },
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
const result = await handler.updateIssueStatusByEmoji({
owner: "org",
repo: "repo",
issueNumber: 1,
statusName: "Done",
projectNumber: 1,
isOrg: true,
});
expect(result.success).toBe(true);
expect(result.status).toBe("Done");
expect(result.issueNumber).toBe(1);
});
it("should throw error if status not found in project", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
organization: {
projectV2: {
id: "proj1",
title: "Test Project",
fields: {
nodes: [
{
id: "f1",
name: "Status",
options: [{ id: "o1", name: "Done" }],
},
],
},
},
},
}),
};
const handler = createGraphQLMutationHandler(mockOctokit);
await expect(
handler.updateIssueStatusByEmoji({
owner: "org",
repo: "repo",
issueNumber: 1,
statusName: "Unknown Status",
projectNumber: 1,
isOrg: true,
})
).rejects.toThrow('Status "Unknown Status" not found in project');
});
});
});
});

View File

@@ -0,0 +1,225 @@
// tests/project-status-sync.test.js
import { describe, expect, it, vi } from "vitest";
import {
STATUS_MAPPING,
CATEGORY_TO_STATUS,
emojiToStatus,
reactionToStatus,
findStatusFieldValue,
createProjectStatusSync,
} from "../bot/project-status-sync";
describe("project-status-sync", () => {
describe("STATUS_MAPPING", () => {
it("should map ✅ to Done", () => {
expect(STATUS_MAPPING["✅"]).toBe("Done");
});
it("should map ❌ to Blocked", () => {
expect(STATUS_MAPPING["❌"]).toBe("Blocked");
});
it("should map 🟡 to In Progress", () => {
expect(STATUS_MAPPING["🟡"]).toBe("In Progress");
});
it("should map 🛟 to Escalation", () => {
expect(STATUS_MAPPING["🛟"]).toBe("Escalation");
});
it("should have all status mappings", () => {
expect(Object.keys(STATUS_MAPPING)).toHaveLength(7);
});
});
describe("CATEGORY_TO_STATUS", () => {
it("should map completed to Done", () => {
expect(CATEGORY_TO_STATUS.completed).toBe("Done");
});
it("should map blocked to Blocked", () => {
expect(CATEGORY_TO_STATUS.blocked).toBe("Blocked");
});
it("should map review to Needs Review", () => {
expect(CATEGORY_TO_STATUS.review).toBe("Needs Review");
});
});
describe("emojiToStatus", () => {
it("should convert ✅ to Done", () => {
expect(emojiToStatus("✅")).toBe("Done");
});
it("should convert ❌ to Blocked", () => {
expect(emojiToStatus("❌")).toBe("Blocked");
});
it("should return null for unknown emoji", () => {
expect(emojiToStatus("🍕")).toBe(null);
});
});
describe("reactionToStatus", () => {
it("should convert rocket to Done", () => {
expect(reactionToStatus("rocket")).toBe("Done");
});
it("should convert -1 to Blocked", () => {
expect(reactionToStatus("-1")).toBe("Blocked");
});
it("should convert eyes to Done (completed category)", () => {
// Eyes reaction maps to completed category which becomes Done status
expect(reactionToStatus("eyes")).toBe("Done");
});
it("should return null for unknown reaction", () => {
expect(reactionToStatus("unknown")).toBe(null);
});
});
describe("findStatusFieldValue", () => {
const mockFields = [
{
id: "field1",
name: "Title",
},
{
id: "field2",
name: "Status",
options: [
{ id: "opt1", name: "Done" },
{ id: "opt2", name: "In Progress" },
{ id: "opt3", name: "Blocked" },
],
},
];
it("should find status field and value", () => {
const result = findStatusFieldValue(mockFields, "Done");
expect(result).toEqual({
fieldId: "field2",
valueId: "opt1",
fieldName: "Status",
valueName: "Done",
});
});
it("should find status case-insensitively", () => {
const result = findStatusFieldValue(mockFields, "done");
expect(result.valueId).toBe("opt1");
});
it("should return null if status not found", () => {
const result = findStatusFieldValue(mockFields, "Unknown");
expect(result).toBe(null);
});
it("should return null if no Status field", () => {
const fields = [{ id: "field1", name: "Title" }];
const result = findStatusFieldValue(fields, "Done");
expect(result).toBe(null);
});
});
describe("createProjectStatusSync", () => {
it("should create sync handler with methods", () => {
const mockOctokit = {
graphql: vi.fn(),
};
const sync = createProjectStatusSync(mockOctokit);
expect(sync).toHaveProperty("getProjectFields");
expect(sync).toHaveProperty("getIssueProjectItemId");
expect(sync).toHaveProperty("updateProjectItemStatus");
expect(sync).toHaveProperty("syncIssueStatusFromEmoji");
expect(sync).toHaveProperty("syncIssueStatusFromReaction");
expect(sync).toHaveProperty("clearCache");
});
it("should cache project fields", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
node: {
fields: {
nodes: [
{ id: "f1", name: "Title" },
{
id: "f2",
name: "Status",
options: [{ id: "o1", name: "Done" }],
},
],
},
},
}),
};
const sync = createProjectStatusSync(mockOctokit);
// Call twice with same project ID
await sync.getProjectFields("project1");
await sync.getProjectFields("project1");
// Should only call API once (cached)
expect(mockOctokit.graphql).toHaveBeenCalledTimes(1);
});
it("should fetch new fields for different project", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
node: {
fields: {
nodes: [],
},
},
}),
};
const sync = createProjectStatusSync(mockOctokit);
await sync.getProjectFields("project1");
await sync.getProjectFields("project2");
expect(mockOctokit.graphql).toHaveBeenCalledTimes(2);
});
describe("syncIssueStatusFromEmoji", () => {
it("should return failure if no status mapping", async () => {
const mockOctokit = { graphql: vi.fn() };
const sync = createProjectStatusSync(mockOctokit);
const result = await sync.syncIssueStatusFromEmoji({
owner: "test",
repo: "repo",
issueNumber: 1,
emoji: "🍕",
projectId: "proj1",
});
expect(result.success).toBe(false);
expect(result.reason).toBe("No status mapping for emoji");
});
});
describe("syncIssueStatusFromReaction", () => {
it("should return failure if no status mapping", async () => {
const mockOctokit = { graphql: vi.fn() };
const sync = createProjectStatusSync(mockOctokit);
const result = await sync.syncIssueStatusFromReaction({
owner: "test",
repo: "repo",
issueNumber: 1,
reaction: "unknown",
projectId: "proj1",
});
expect(result.success).toBe(false);
expect(result.reason).toBe("No status mapping for reaction");
});
});
});
});

View File

@@ -0,0 +1,376 @@
// tests/weekly-emoji-digest.test.js
import { describe, expect, it, vi } from "vitest";
import {
REACTION_CONTENT_TO_EMOJI,
AGENT_LABELS,
countReactions,
extractIssueEmojis,
groupIssuesByAgent,
generateAgentHeatmaps,
generateDigestMarkdown,
createWeeklyEmojiDigest,
} from "../bot/weekly-emoji-digest";
describe("weekly-emoji-digest", () => {
describe("REACTION_CONTENT_TO_EMOJI", () => {
it("should map THUMBS_UP to completed emoji", () => {
expect(REACTION_CONTENT_TO_EMOJI.THUMBS_UP).toBe("✅");
});
it("should map THUMBS_DOWN to blocked emoji", () => {
expect(REACTION_CONTENT_TO_EMOJI.THUMBS_DOWN).toBe("❌");
});
it("should map ROCKET to completed emoji", () => {
expect(REACTION_CONTENT_TO_EMOJI.ROCKET).toBe("✅");
});
it("should map EYES to review emoji", () => {
expect(REACTION_CONTENT_TO_EMOJI.EYES).toBe("🤔");
});
});
describe("AGENT_LABELS", () => {
it("should contain expected agent labels", () => {
expect(AGENT_LABELS).toContain("builder-agent");
expect(AGENT_LABELS).toContain("planner-agent");
expect(AGENT_LABELS).toContain("guardian-agent");
expect(AGENT_LABELS).toContain("observer-agent");
expect(AGENT_LABELS).toContain("human");
});
});
describe("countReactions", () => {
it("should count THUMBS_UP as completed", () => {
const reactions = [{ content: "THUMBS_UP" }, { content: "THUMBS_UP" }];
const counts = countReactions(reactions);
expect(counts.completed).toBe(2);
});
it("should count THUMBS_DOWN as blocked", () => {
const reactions = [{ content: "THUMBS_DOWN" }];
const counts = countReactions(reactions);
expect(counts.blocked).toBe(1);
});
it("should count mixed reactions", () => {
const reactions = [
{ content: "THUMBS_UP" },
{ content: "THUMBS_DOWN" },
{ content: "ROCKET" },
{ content: "EYES" },
];
const counts = countReactions(reactions);
expect(counts.completed).toBe(2); // THUMBS_UP + ROCKET
expect(counts.blocked).toBe(1);
expect(counts.review).toBe(1);
});
it("should handle empty reactions", () => {
const counts = countReactions([]);
expect(counts.total).toBe(0);
});
it("should ignore unknown reactions", () => {
const reactions = [{ content: "UNKNOWN" }];
const counts = countReactions(reactions);
expect(counts.total).toBe(0);
});
});
describe("extractIssueEmojis", () => {
it("should count emojis in issue body", () => {
const issue = {
body: "✅ Task completed ✅ Another done",
};
const counts = extractIssueEmojis(issue);
expect(counts.completed).toBe(2);
});
it("should count reactions on issue", () => {
const issue = {
body: "",
reactions: {
nodes: [{ content: "THUMBS_UP" }, { content: "ROCKET" }],
},
};
const counts = extractIssueEmojis(issue);
expect(counts.completed).toBe(2);
});
it("should count emojis in comments", () => {
const issue = {
body: "",
comments: {
nodes: [{ body: "✅ Done" }, { body: "❌ Blocked" }],
},
};
const counts = extractIssueEmojis(issue);
expect(counts.completed).toBe(1);
expect(counts.blocked).toBe(1);
});
it("should count reactions on comments", () => {
const issue = {
body: "",
comments: {
nodes: [
{
body: "",
reactions: {
nodes: [{ content: "THUMBS_UP" }],
},
},
],
},
};
const counts = extractIssueEmojis(issue);
expect(counts.completed).toBe(1);
});
it("should aggregate all counts", () => {
const issue = {
body: "✅ Main task",
reactions: {
nodes: [{ content: "ROCKET" }],
},
comments: {
nodes: [
{
body: "✅ Subtask done",
reactions: {
nodes: [{ content: "THUMBS_UP" }],
},
},
],
},
};
const counts = extractIssueEmojis(issue);
expect(counts.completed).toBe(4); // 1 in body + 1 rocket + 1 in comment + 1 thumbs up
});
});
describe("groupIssuesByAgent", () => {
it("should group issues by agent labels", () => {
const issues = [
{ labels: { nodes: [{ name: "builder-agent" }] } },
{ labels: { nodes: [{ name: "builder-agent" }] } },
{ labels: { nodes: [{ name: "planner-agent" }] } },
];
const groups = groupIssuesByAgent(issues);
expect(groups["builder-agent"]).toHaveLength(2);
expect(groups["planner-agent"]).toHaveLength(1);
});
it("should put unlabeled issues in unassigned", () => {
const issues = [
{ labels: { nodes: [] } },
{ labels: { nodes: [{ name: "other-label" }] } },
];
const groups = groupIssuesByAgent(issues);
expect(groups.unassigned).toHaveLength(2);
});
it("should handle issues without labels", () => {
const issues = [{}];
const groups = groupIssuesByAgent(issues);
expect(groups.unassigned).toHaveLength(1);
});
it("should initialize all agent groups", () => {
const groups = groupIssuesByAgent([]);
expect(groups["builder-agent"]).toEqual([]);
expect(groups["planner-agent"]).toEqual([]);
expect(groups["guardian-agent"]).toEqual([]);
expect(groups["observer-agent"]).toEqual([]);
expect(groups.human).toEqual([]);
});
});
describe("generateAgentHeatmaps", () => {
it("should generate heatmaps for agents with issues", () => {
const groups = {
"builder-agent": [{ body: "✅✅❌" }],
"planner-agent": [],
};
const heatmaps = generateAgentHeatmaps(groups);
expect(heatmaps["builder-agent"]).toBeDefined();
expect(heatmaps["builder-agent"].issueCount).toBe(1);
expect(heatmaps["planner-agent"]).toBeUndefined();
});
it("should calculate correct percentages", () => {
const groups = {
"builder-agent": [{ body: "✅✅✅✅❌" }],
};
const heatmaps = generateAgentHeatmaps(groups);
expect(heatmaps["builder-agent"].heatmap.percentComplete).toBe(80);
expect(heatmaps["builder-agent"].heatmap.percentBlocked).toBe(20);
});
});
describe("generateDigestMarkdown", () => {
it("should generate valid markdown report", () => {
const data = {
repoName: "test/repo",
weekStart: new Date("2024-01-01"),
weekEnd: new Date("2024-01-08"),
totalIssues: 10,
overallHeatmap: {
percentComplete: 50,
percentBlocked: 10,
percentInProgress: 20,
percentReview: 10,
percentNotStarted: 10,
escalations: 2,
totalItems: 10,
},
agentHeatmaps: {
"builder-agent": {
issueCount: 5,
heatmap: {
percentComplete: 60,
escalations: 1,
percentBlocked: 10,
},
},
},
topEscalations: [
{
number: 123,
title: "Critical bug",
assignees: { nodes: [{ login: "user1" }] },
},
],
};
const markdown = generateDigestMarkdown(data);
expect(markdown).toContain("Weekly Emoji Digest");
expect(markdown).toContain("test/repo");
expect(markdown).toContain("2024-01-01");
expect(markdown).toContain("**Total Issues Analyzed:** 10");
expect(markdown).toContain("builder-agent");
expect(markdown).toContain("#123");
expect(markdown).toContain("Critical bug");
expect(markdown).toContain("@user1");
});
it("should handle empty escalations", () => {
const data = {
repoName: "test/repo",
weekStart: new Date("2024-01-01"),
weekEnd: new Date("2024-01-08"),
totalIssues: 5,
overallHeatmap: {
percentComplete: 100,
percentBlocked: 0,
percentInProgress: 0,
percentReview: 0,
percentNotStarted: 0,
escalations: 0,
totalItems: 5,
},
agentHeatmaps: {},
topEscalations: [],
};
const markdown = generateDigestMarkdown(data);
expect(markdown).not.toContain("Active Escalations");
});
it("should include emoji legend", () => {
const data = {
repoName: "test/repo",
weekStart: new Date(),
weekEnd: new Date(),
totalIssues: 0,
overallHeatmap: {
percentComplete: 0,
percentBlocked: 0,
percentInProgress: 0,
percentReview: 0,
percentNotStarted: 0,
escalations: 0,
totalItems: 0,
},
agentHeatmaps: {},
topEscalations: [],
};
const markdown = generateDigestMarkdown(data);
expect(markdown).toContain("Emoji Legend");
expect(markdown).toContain("Completed");
expect(markdown).toContain("Blocked");
});
});
describe("createWeeklyEmojiDigest", () => {
it("should create digest handler with methods", () => {
const mockOctokit = { graphql: vi.fn() };
const digest = createWeeklyEmojiDigest(mockOctokit);
expect(digest).toHaveProperty("fetchWeeklyIssues");
expect(digest).toHaveProperty("findDigestIssue");
expect(digest).toHaveProperty("postDigestComment");
expect(digest).toHaveProperty("generateWeeklyDigest");
expect(digest).toHaveProperty("generateAndPostDigest");
});
describe("fetchWeeklyIssues", () => {
it("should fetch issues with pagination", async () => {
const mockOctokit = {
graphql: vi
.fn()
.mockResolvedValueOnce({
repository: {
issues: {
pageInfo: { hasNextPage: true, endCursor: "cursor1" },
nodes: [{ number: 1 }],
},
},
})
.mockResolvedValueOnce({
repository: {
issues: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{ number: 2 }],
},
},
}),
};
const digest = createWeeklyEmojiDigest(mockOctokit);
const issues = await digest.fetchWeeklyIssues("owner", "repo");
expect(issues).toHaveLength(2);
expect(mockOctokit.graphql).toHaveBeenCalledTimes(2);
});
});
describe("generateAndPostDigest", () => {
it("should return failure if no digest issue found", async () => {
const mockOctokit = {
graphql: vi.fn().mockResolvedValue({
repository: {
issues: {
pageInfo: { hasNextPage: false },
nodes: [],
},
},
}),
};
const digest = createWeeklyEmojiDigest(mockOctokit);
const result = await digest.generateAndPostDigest("owner", "repo");
expect(result.success).toBe(false);
expect(result.reason).toContain("No emoji-digest issue found");
});
});
});
});