Add GitHub Project GraphQL mutator and Weekly Digest Bot
Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
51
.github/workflows/digest-bot.yml
vendored
Normal file
51
.github/workflows/digest-bot.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: 📊 Weekly Emoji Digest Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Runs every Monday at 9:00 AM UTC
|
||||||
|
- cron: "0 9 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
digest_issue_number:
|
||||||
|
description: "Issue number to post the digest comment (optional)"
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
digest-bot:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🧬 Checkout Repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: 🧠 Set up Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: 📊 Run Weekly Digest Bot
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DIGEST_ISSUE_NUMBER: ${{ inputs.digest_issue_number }}
|
||||||
|
run: |
|
||||||
|
node -e "
|
||||||
|
const { runWeeklyDigest } = require('./bot/handlers/digest-bot.js');
|
||||||
|
|
||||||
|
const owner = process.env.GITHUB_REPOSITORY.split('/')[0];
|
||||||
|
const repo = process.env.GITHUB_REPOSITORY.split('/')[1];
|
||||||
|
const digestIssueNumber = process.env.DIGEST_ISSUE_NUMBER ? parseInt(process.env.DIGEST_ISSUE_NUMBER) : null;
|
||||||
|
|
||||||
|
runWeeklyDigest(owner, repo, digestIssueNumber)
|
||||||
|
.then(result => {
|
||||||
|
console.log('📊 Digest generation complete!');
|
||||||
|
console.log('Total reactions:', result.stats.total);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('❌ Digest generation failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
"
|
||||||
311
bot/handlers/digest-bot.js
Normal file
311
bot/handlers/digest-bot.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
// bot/handlers/digest-bot.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekly Emoji Digest Bot
|
||||||
|
* Calculates reaction statistics and generates a markdown digest report.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches issues with reaction data from the repository.
|
||||||
|
*/
|
||||||
|
async function fetchIssuesWithReactions(owner, repo, since) {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetIssuesWithReactions($owner: String!, $repo: String!, $since: DateTime) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
issues(first: 100, filterBy: { since: $since }, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
||||||
|
nodes {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
labels(first: 10) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reactions(first: 100) {
|
||||||
|
nodes {
|
||||||
|
content
|
||||||
|
user {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments(first: 50) {
|
||||||
|
nodes {
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
reactions(first: 100) {
|
||||||
|
nodes {
|
||||||
|
content
|
||||||
|
user {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const variables = { owner, repo, since };
|
||||||
|
|
||||||
|
const response = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`GitHub GraphQL request failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.repository.issues.nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps GitHub reaction content to emoji characters.
|
||||||
|
*/
|
||||||
|
const REACTION_EMOJI_MAP = {
|
||||||
|
THUMBS_UP: "👍",
|
||||||
|
THUMBS_DOWN: "👎",
|
||||||
|
LAUGH: "😄",
|
||||||
|
HOORAY: "🎉",
|
||||||
|
CONFUSED: "😕",
|
||||||
|
HEART: "❤️",
|
||||||
|
ROCKET: "🚀",
|
||||||
|
EYES: "👀",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps emoji statuses used in the system.
|
||||||
|
*/
|
||||||
|
const STATUS_EMOJI_MAP = {
|
||||||
|
"✅": "Done",
|
||||||
|
"❌": "Blocked",
|
||||||
|
"🛟": "Escalation",
|
||||||
|
"🤔": "Needs Review",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates reaction statistics from issues data.
|
||||||
|
*/
|
||||||
|
function calculateReactionStats(issues) {
|
||||||
|
const stats = {
|
||||||
|
total: 0,
|
||||||
|
byEmoji: {},
|
||||||
|
byAgent: {},
|
||||||
|
blockedIssues: [],
|
||||||
|
escalations: [],
|
||||||
|
reviewQueue: [],
|
||||||
|
mostActiveAgent: null,
|
||||||
|
mostBlockedRepo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentCounts = {};
|
||||||
|
const issueLabels = {};
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
// Process issue reactions
|
||||||
|
for (const reaction of issue.reactions?.nodes || []) {
|
||||||
|
stats.total++;
|
||||||
|
const emoji = REACTION_EMOJI_MAP[reaction.content] || reaction.content;
|
||||||
|
stats.byEmoji[emoji] = (stats.byEmoji[emoji] || 0) + 1;
|
||||||
|
|
||||||
|
// Track agent activity
|
||||||
|
const agent = reaction.user?.login || "unknown";
|
||||||
|
agentCounts[agent] = (agentCounts[agent] || 0) + 1;
|
||||||
|
|
||||||
|
// Track blocked/escalation/review based on reaction type
|
||||||
|
if (reaction.content === "THUMBS_DOWN" || reaction.content === "CONFUSED") {
|
||||||
|
if (!stats.blockedIssues.includes(issue.number)) {
|
||||||
|
stats.blockedIssues.push(issue.number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process comment reactions
|
||||||
|
for (const comment of issue.comments?.nodes || []) {
|
||||||
|
for (const reaction of comment.reactions?.nodes || []) {
|
||||||
|
stats.total++;
|
||||||
|
const emoji = REACTION_EMOJI_MAP[reaction.content] || reaction.content;
|
||||||
|
stats.byEmoji[emoji] = (stats.byEmoji[emoji] || 0) + 1;
|
||||||
|
|
||||||
|
const agent = reaction.user?.login || "unknown";
|
||||||
|
agentCounts[agent] = (agentCounts[agent] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track issues by label for most blocked detection
|
||||||
|
for (const label of issue.labels?.nodes || []) {
|
||||||
|
issueLabels[label.name] = (issueLabels[label.name] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find most active agent
|
||||||
|
let maxAgentCount = 0;
|
||||||
|
for (const [agent, count] of Object.entries(agentCounts)) {
|
||||||
|
if (count > maxAgentCount) {
|
||||||
|
maxAgentCount = count;
|
||||||
|
stats.mostActiveAgent = agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find most blocked (labeled) area
|
||||||
|
let maxLabelCount = 0;
|
||||||
|
for (const [label, count] of Object.entries(issueLabels)) {
|
||||||
|
if (count > maxLabelCount) {
|
||||||
|
maxLabelCount = count;
|
||||||
|
stats.mostBlockedRepo = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.byAgent = agentCounts;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a markdown digest from the calculated statistics.
|
||||||
|
*/
|
||||||
|
function generateDigestMarkdown(stats, dateStr) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
lines.push(`# 📊 Weekly Agent Emoji Digest (${dateStr})`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("| Emoji | Count | % |");
|
||||||
|
lines.push("|-------|-------|---|");
|
||||||
|
|
||||||
|
// Sort emojis by count descending
|
||||||
|
const sortedEmojis = Object.entries(stats.byEmoji)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10); // Top 10
|
||||||
|
|
||||||
|
for (const [emoji, count] of sortedEmojis) {
|
||||||
|
const percentage = stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
|
||||||
|
lines.push(`| ${emoji} | ${count} | ${percentage}% |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`**Total Reactions:** 🧮 ${stats.total}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (stats.mostActiveAgent) {
|
||||||
|
lines.push(`🔥 Most active agent: \`@${stats.mostActiveAgent}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.mostBlockedRepo) {
|
||||||
|
lines.push(`🛑 Most active label: \`${stats.mostBlockedRepo}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.blockedIssues.length > 0) {
|
||||||
|
lines.push(`❌ Blocked issues: ${stats.blockedIssues.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.escalations.length > 0) {
|
||||||
|
lines.push(`🛟 Escalations: ${stats.escalations.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.reviewQueue.length > 0) {
|
||||||
|
lines.push(`🤔 Review queue: ${stats.reviewQueue.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a comment to a GitHub issue.
|
||||||
|
*/
|
||||||
|
async function postDigestComment(owner, repo, issueNumber, body) {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`GitHub API request failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`✅ Digest comment posted: ${data.html_url}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to generate and post the weekly digest.
|
||||||
|
*/
|
||||||
|
async function runWeeklyDigest(owner, repo, digestIssueNumber) {
|
||||||
|
// Calculate date range for the past week
|
||||||
|
const now = new Date();
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const dateStr = now.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 Generating weekly digest for ${owner}/${repo}...`);
|
||||||
|
console.log(`📅 Date range: ${weekAgo.toISOString()} to ${now.toISOString()}`);
|
||||||
|
|
||||||
|
// Fetch issues with reactions from the past week
|
||||||
|
const issues = await fetchIssuesWithReactions(owner, repo, weekAgo.toISOString());
|
||||||
|
console.log(`📦 Fetched ${issues.length} issues`);
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const stats = calculateReactionStats(issues);
|
||||||
|
console.log(`🧮 Total reactions: ${stats.total}`);
|
||||||
|
|
||||||
|
// Generate markdown digest
|
||||||
|
const markdown = generateDigestMarkdown(stats, dateStr);
|
||||||
|
console.log(`📝 Generated digest:\n${markdown}`);
|
||||||
|
|
||||||
|
// Post comment if digest issue number is provided
|
||||||
|
if (digestIssueNumber) {
|
||||||
|
await postDigestComment(owner, repo, digestIssueNumber, markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stats, markdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchIssuesWithReactions,
|
||||||
|
calculateReactionStats,
|
||||||
|
generateDigestMarkdown,
|
||||||
|
postDigestComment,
|
||||||
|
runWeeklyDigest,
|
||||||
|
REACTION_EMOJI_MAP,
|
||||||
|
STATUS_EMOJI_MAP,
|
||||||
|
};
|
||||||
172
bot/handlers/project-graphql-updater.js
Normal file
172
bot/handlers/project-graphql-updater.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// bot/handlers/project-graphql-updater.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a GitHub Project V2 item field value via GraphQL.
|
||||||
|
* Used to change status of project cards when emoji reactions are received.
|
||||||
|
*/
|
||||||
|
async function updateProjectField({
|
||||||
|
issueNodeId,
|
||||||
|
projectId,
|
||||||
|
fieldId,
|
||||||
|
valueId,
|
||||||
|
}) {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateProjectField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $valueId: String!) {
|
||||||
|
updateProjectV2ItemFieldValue(input: {
|
||||||
|
projectId: $projectId,
|
||||||
|
itemId: $itemId,
|
||||||
|
fieldId: $fieldId,
|
||||||
|
value: { singleSelectOptionId: $valueId }
|
||||||
|
}) {
|
||||||
|
projectV2Item {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
projectId,
|
||||||
|
itemId: issueNodeId,
|
||||||
|
fieldId,
|
||||||
|
valueId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query: mutation, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`GitHub GraphQL request failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Project field updated:", data.data.updateProjectV2ItemFieldValue.projectV2Item.id);
|
||||||
|
return data.data.updateProjectV2ItemFieldValue.projectV2Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the Node ID of an issue by its number.
|
||||||
|
*/
|
||||||
|
async function getIssueNodeId(owner, repo, issueNumber) {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetIssueNodeId($owner: String!, $repo: String!, $number: Int!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
issue(number: $number) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const variables = { owner, repo, number: issueNumber };
|
||||||
|
|
||||||
|
const response = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`GitHub GraphQL request failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.repository.issue.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches project fields and their options.
|
||||||
|
*/
|
||||||
|
async function getProjectFields(owner, projectNumber) {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetProjectFields($owner: String!, $projectNumber: Int!) {
|
||||||
|
user(login: $owner) {
|
||||||
|
projectV2(number: $projectNumber) {
|
||||||
|
id
|
||||||
|
fields(first: 20) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2Field {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const variables = { owner, projectNumber };
|
||||||
|
|
||||||
|
const response = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`GitHub GraphQL request failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.user.projectV2;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
updateProjectField,
|
||||||
|
getIssueNodeId,
|
||||||
|
getProjectFields,
|
||||||
|
};
|
||||||
56
bot/index.js
56
bot/index.js
@@ -4,6 +4,21 @@ console.log("🤖 Emoji Bot Activated");
|
|||||||
|
|
||||||
const event = process.env.GITHUB_EVENT_PATH;
|
const event = process.env.GITHUB_EVENT_PATH;
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const { updateProjectField, getIssueNodeId } = require("./handlers/project-graphql-updater");
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
const configPath = process.env.EMOJI_BOT_CONFIG || "../emoji-bot-config.yml";
|
||||||
|
|
||||||
|
// Mapping of reaction content to status updates
|
||||||
|
const REACTION_TO_STATUS = {
|
||||||
|
eyes: "Done",
|
||||||
|
hooray: "Done",
|
||||||
|
rocket: "Done",
|
||||||
|
"+1": "Done",
|
||||||
|
"-1": "Blocked",
|
||||||
|
confused: "Blocked",
|
||||||
|
thinking_face: "Needs Review",
|
||||||
|
};
|
||||||
|
|
||||||
if (!event || !fs.existsSync(event)) {
|
if (!event || !fs.existsSync(event)) {
|
||||||
console.error("🚫 No GitHub event payload found.");
|
console.error("🚫 No GitHub event payload found.");
|
||||||
@@ -15,15 +30,51 @@ console.log("📦 Event Payload:", JSON.stringify(payload, null, 2));
|
|||||||
|
|
||||||
const reaction = payload.reaction?.content || "";
|
const reaction = payload.reaction?.content || "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the GitHub Project card status based on reaction.
|
||||||
|
* Requires PROJECT_ID, STATUS_FIELD_ID, and status option IDs to be set.
|
||||||
|
*/
|
||||||
|
async function handleProjectUpdate(statusName) {
|
||||||
|
const projectId = process.env.PROJECT_ID;
|
||||||
|
const fieldId = process.env.STATUS_FIELD_ID;
|
||||||
|
const statusOptions = process.env.STATUS_OPTIONS ? JSON.parse(process.env.STATUS_OPTIONS) : {};
|
||||||
|
const valueId = statusOptions[statusName];
|
||||||
|
|
||||||
|
if (!projectId || !fieldId || !valueId) {
|
||||||
|
console.log("⚠️ Project update skipped: missing PROJECT_ID, STATUS_FIELD_ID, or STATUS_OPTIONS");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumber = payload.issue?.number;
|
||||||
|
const owner = payload.repository?.owner?.login;
|
||||||
|
const repo = payload.repository?.name;
|
||||||
|
|
||||||
|
if (!issueNumber || !owner || !repo) {
|
||||||
|
console.log("⚠️ Project update skipped: missing issue/repo information");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issueNodeId = await getIssueNodeId(owner, repo, issueNumber);
|
||||||
|
await updateProjectField({ issueNodeId, projectId, fieldId, valueId });
|
||||||
|
console.log(`🎯 Project status updated to: ${statusName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to update project:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (reaction) {
|
if (reaction) {
|
||||||
console.log(`🧠 Reaction received: ${reaction}`);
|
console.log(`🧠 Reaction received: ${reaction}`);
|
||||||
|
|
||||||
|
const statusName = REACTION_TO_STATUS[reaction];
|
||||||
|
|
||||||
switch (reaction) {
|
switch (reaction) {
|
||||||
case "eyes":
|
case "eyes":
|
||||||
console.log("👀 Mark as Done");
|
console.log("👀 Mark as Done");
|
||||||
break;
|
break;
|
||||||
case "hooray":
|
case "hooray":
|
||||||
case "rocket":
|
case "rocket":
|
||||||
|
case "+1":
|
||||||
console.log("✅ Mark as Done");
|
console.log("✅ Mark as Done");
|
||||||
break;
|
break;
|
||||||
case "-1":
|
case "-1":
|
||||||
@@ -39,6 +90,11 @@ if (reaction) {
|
|||||||
default:
|
default:
|
||||||
console.log("🪞 No mapping for this reaction.");
|
console.log("🪞 No mapping for this reaction.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger project update if status mapping exists
|
||||||
|
if (statusName) {
|
||||||
|
handleProjectUpdate(statusName).catch(console.error);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("💬 Comment ignored — no emoji trigger detected.");
|
console.log("💬 Comment ignored — no emoji trigger detected.");
|
||||||
}
|
}
|
||||||
|
|||||||
201
tests/digest-bot.test.js
Normal file
201
tests/digest-bot.test.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// tests/digest-bot.test.js
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
describe("digest-bot", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...originalEnv, GITHUB_TOKEN: "test-token" };
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateReactionStats", () => {
|
||||||
|
it("should calculate statistics from issues with reactions", async () => {
|
||||||
|
const { calculateReactionStats } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
const mockIssues = [
|
||||||
|
{
|
||||||
|
number: 1,
|
||||||
|
title: "Test Issue 1",
|
||||||
|
reactions: {
|
||||||
|
nodes: [
|
||||||
|
{ content: "THUMBS_UP", user: { login: "agent-1" } },
|
||||||
|
{ content: "THUMBS_UP", user: { login: "agent-2" } },
|
||||||
|
{ content: "HEART", user: { login: "agent-1" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
comments: { nodes: [] },
|
||||||
|
labels: { nodes: [{ name: "bug" }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 2,
|
||||||
|
title: "Test Issue 2",
|
||||||
|
reactions: {
|
||||||
|
nodes: [
|
||||||
|
{ content: "THUMBS_DOWN", user: { login: "agent-1" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
comments: { nodes: [] },
|
||||||
|
labels: { nodes: [{ name: "bug" }] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = calculateReactionStats(mockIssues);
|
||||||
|
|
||||||
|
expect(stats.total).toBe(4);
|
||||||
|
expect(stats.byEmoji["👍"]).toBe(2);
|
||||||
|
expect(stats.byEmoji["❤️"]).toBe(1);
|
||||||
|
expect(stats.byEmoji["👎"]).toBe(1);
|
||||||
|
expect(stats.mostActiveAgent).toBe("agent-1");
|
||||||
|
expect(stats.blockedIssues).toContain(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty issues array", async () => {
|
||||||
|
const { calculateReactionStats } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
const stats = calculateReactionStats([]);
|
||||||
|
|
||||||
|
expect(stats.total).toBe(0);
|
||||||
|
expect(stats.byEmoji).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count comment reactions", async () => {
|
||||||
|
const { calculateReactionStats } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
const mockIssues = [
|
||||||
|
{
|
||||||
|
number: 1,
|
||||||
|
title: "Test Issue",
|
||||||
|
reactions: { nodes: [] },
|
||||||
|
comments: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
author: { login: "commenter" },
|
||||||
|
reactions: {
|
||||||
|
nodes: [
|
||||||
|
{ content: "ROCKET", user: { login: "agent-1" } },
|
||||||
|
{ content: "EYES", user: { login: "agent-2" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
labels: { nodes: [] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = calculateReactionStats(mockIssues);
|
||||||
|
|
||||||
|
expect(stats.total).toBe(2);
|
||||||
|
expect(stats.byEmoji["🚀"]).toBe(1);
|
||||||
|
expect(stats.byEmoji["👀"]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateDigestMarkdown", () => {
|
||||||
|
it("should generate valid markdown digest", async () => {
|
||||||
|
const { generateDigestMarkdown } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: 100,
|
||||||
|
byEmoji: {
|
||||||
|
"👍": 50,
|
||||||
|
"❤️": 30,
|
||||||
|
"🚀": 20,
|
||||||
|
},
|
||||||
|
byAgent: { "agent-1": 60, "agent-2": 40 },
|
||||||
|
blockedIssues: [1, 2],
|
||||||
|
escalations: [],
|
||||||
|
reviewQueue: [],
|
||||||
|
mostActiveAgent: "agent-1",
|
||||||
|
mostBlockedRepo: "bug",
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = generateDigestMarkdown(stats, "Nov 24, 2025");
|
||||||
|
|
||||||
|
expect(markdown).toContain("# 📊 Weekly Agent Emoji Digest (Nov 24, 2025)");
|
||||||
|
expect(markdown).toContain("| 👍 | 50 | 50% |");
|
||||||
|
expect(markdown).toContain("| ❤️ | 30 | 30% |");
|
||||||
|
expect(markdown).toContain("| 🚀 | 20 | 20% |");
|
||||||
|
expect(markdown).toContain("**Total Reactions:** 🧮 100");
|
||||||
|
expect(markdown).toContain("🔥 Most active agent: `@agent-1`");
|
||||||
|
expect(markdown).toContain("❌ Blocked issues: 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero total reactions", async () => {
|
||||||
|
const { generateDigestMarkdown } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: 0,
|
||||||
|
byEmoji: {},
|
||||||
|
byAgent: {},
|
||||||
|
blockedIssues: [],
|
||||||
|
escalations: [],
|
||||||
|
reviewQueue: [],
|
||||||
|
mostActiveAgent: null,
|
||||||
|
mostBlockedRepo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = generateDigestMarkdown(stats, "Nov 24, 2025");
|
||||||
|
|
||||||
|
expect(markdown).toContain("# 📊 Weekly Agent Emoji Digest (Nov 24, 2025)");
|
||||||
|
expect(markdown).toContain("**Total Reactions:** 🧮 0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("REACTION_EMOJI_MAP", () => {
|
||||||
|
it("should have all expected reaction mappings", async () => {
|
||||||
|
const { REACTION_EMOJI_MAP } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
expect(REACTION_EMOJI_MAP.THUMBS_UP).toBe("👍");
|
||||||
|
expect(REACTION_EMOJI_MAP.THUMBS_DOWN).toBe("👎");
|
||||||
|
expect(REACTION_EMOJI_MAP.HEART).toBe("❤️");
|
||||||
|
expect(REACTION_EMOJI_MAP.ROCKET).toBe("🚀");
|
||||||
|
expect(REACTION_EMOJI_MAP.EYES).toBe("👀");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("postDigestComment", () => {
|
||||||
|
it("should post comment to GitHub issue", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
id: 123,
|
||||||
|
html_url: "https://github.com/test/test/issues/1#issuecomment-123",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { postDigestComment } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
const result = await postDigestComment("owner", "repo", 1, "Test digest");
|
||||||
|
|
||||||
|
expect(result.id).toBe(123);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.github.com/repos/owner/repo/issues/1/comments",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body: "Test digest" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when GITHUB_TOKEN is not set", async () => {
|
||||||
|
process.env.GITHUB_TOKEN = "";
|
||||||
|
|
||||||
|
const { postDigestComment } = await import("../bot/handlers/digest-bot.js");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
postDigestComment("owner", "repo", 1, "Test digest")
|
||||||
|
).rejects.toThrow("GITHUB_TOKEN environment variable is not set");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
tests/project-graphql-updater.test.js
Normal file
141
tests/project-graphql-updater.test.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// tests/project-graphql-updater.test.js
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
describe("project-graphql-updater", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...originalEnv, GITHUB_TOKEN: "test-token" };
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProjectField", () => {
|
||||||
|
it("should update project field successfully", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
updateProjectV2ItemFieldValue: {
|
||||||
|
projectV2Item: { id: "item-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { updateProjectField } = await import("../bot/handlers/project-graphql-updater.js");
|
||||||
|
|
||||||
|
const result = await updateProjectField({
|
||||||
|
issueNodeId: "issue-node-id",
|
||||||
|
projectId: "project-123",
|
||||||
|
fieldId: "field-456",
|
||||||
|
valueId: "value-789",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "item-123" });
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.github.com/graphql",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: "Bearer test-token",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when GITHUB_TOKEN is not set", async () => {
|
||||||
|
process.env.GITHUB_TOKEN = "";
|
||||||
|
|
||||||
|
const { updateProjectField } = await import("../bot/handlers/project-graphql-updater.js");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateProjectField({
|
||||||
|
issueNodeId: "issue-node-id",
|
||||||
|
projectId: "project-123",
|
||||||
|
fieldId: "field-456",
|
||||||
|
valueId: "value-789",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("GITHUB_TOKEN environment variable is not set");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error on GraphQL errors", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
errors: [{ message: "Test error" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { updateProjectField } = await import("../bot/handlers/project-graphql-updater.js");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateProjectField({
|
||||||
|
issueNodeId: "issue-node-id",
|
||||||
|
projectId: "project-123",
|
||||||
|
fieldId: "field-456",
|
||||||
|
valueId: "value-789",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("GraphQL errors");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getIssueNodeId", () => {
|
||||||
|
it("should fetch issue node ID successfully", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
repository: {
|
||||||
|
issue: { id: "issue-node-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getIssueNodeId } = await import("../bot/handlers/project-graphql-updater.js");
|
||||||
|
|
||||||
|
const result = await getIssueNodeId("BlackRoad-OS", "blackroad-os", 1);
|
||||||
|
|
||||||
|
expect(result).toBe("issue-node-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getProjectFields", () => {
|
||||||
|
it("should fetch project fields successfully", async () => {
|
||||||
|
const mockProject = {
|
||||||
|
id: "project-123",
|
||||||
|
fields: {
|
||||||
|
nodes: [
|
||||||
|
{ id: "field-1", name: "Status", options: [{ id: "opt-1", name: "Done" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
projectV2: mockProject,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getProjectFields } = await import("../bot/handlers/project-graphql-updater.js");
|
||||||
|
|
||||||
|
const result = await getProjectFields("BlackRoad-OS", 1);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockProject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user