Merge branch origin/copilot/add-emoji-github-automation into main
This commit is contained in:
37
.github/workflows/weekly-digest.yml
vendored
Normal file
37
.github/workflows/weekly-digest.yml
vendored
Normal 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
382
bot/emoji-agent-router.js
Normal 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
189
bot/emoji-heatmap.js
Normal 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,
|
||||||
|
};
|
||||||
424
bot/graphql-mutation-handler.js
Normal file
424
bot/graphql-mutation-handler.js
Normal 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
341
bot/project-status-sync.js
Normal 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
90
bot/run-digest.js
Normal 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
495
bot/weekly-emoji-digest.js
Normal 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,
|
||||||
|
};
|
||||||
264
tests/emoji-agent-router.test.js
Normal file
264
tests/emoji-agent-router.test.js
Normal 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
219
tests/emoji-heatmap.test.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
398
tests/graphql-mutation-handler.test.js
Normal file
398
tests/graphql-mutation-handler.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
225
tests/project-status-sync.test.js
Normal file
225
tests/project-status-sync.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
376
tests/weekly-emoji-digest.test.js
Normal file
376
tests/weekly-emoji-digest.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user