Add GitHub Project GraphQL Mutator and Weekly Digest Bot

Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-24 23:02:05 +00:00
parent 97f115f597
commit 5f44392142
5 changed files with 805 additions and 0 deletions

50
.github/workflows/digest-bot.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: 📊 Weekly Emoji Digest Bot
on:
schedule:
# Run every Monday at 9:00 AM UTC
- cron: "0 9 * * 1"
workflow_dispatch:
# Allow manual triggering for testing
inputs:
days_to_look_back:
description: "Number of days to look back for reactions"
required: false
default: "7"
post_comment:
description: "Post digest as comment on tracking issue"
required: false
default: "true"
jobs:
generate-digest:
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: 🧬 Checkout Repository
uses: actions/checkout@v3
- name: 🧠 Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: 📊 Run Digest Bot
run: node bot/digest-bot.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
DAYS_TO_LOOK_BACK: ${{ github.event.inputs.days_to_look_back || '7' }}
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
DIGEST_ISSUE_NUMBER: ${{ vars.DIGEST_ISSUE_NUMBER || '1' }}
- name: 📤 Upload Digest Artifact
uses: actions/upload-artifact@v3
with:
name: weekly-digest
path: digest.md
retention-days: 30

290
bot/digest-bot.js Normal file
View File

@@ -0,0 +1,290 @@
// bot/digest-bot.js
/**
* Weekly Emoji Digest Bot
* Calculates emoji reaction statistics and generates a markdown digest.
* Runs via GitHub Actions every Monday.
*/
const fs = require("fs");
/**
* Parses reaction data and calculates statistics.
* @param {Array} reactions - Array of reaction objects with content field
* @returns {Object} - Statistics object
*/
function calculateEmojiStats(reactions) {
const emojiCounts = {};
const agentCounts = {};
const blockedIssues = {};
// Count reactions by emoji type
for (const reaction of reactions) {
const emoji = reaction.content;
emojiCounts[emoji] = (emojiCounts[emoji] || 0) + 1;
// Track agent activity if available
if (reaction.user) {
agentCounts[reaction.user] = (agentCounts[reaction.user] || 0) + 1;
}
// Track blocked issues
if (emoji === "-1" || emoji === "confused") {
const issueKey = reaction.issue || "unknown";
blockedIssues[issueKey] = (blockedIssues[issueKey] || 0) + 1;
}
}
const total = reactions.length;
// Map internal reaction names to display emojis
const emojiMap = {
"+1": "✅",
hooray: "✅",
rocket: "✅",
"-1": "❌",
confused: "❌",
eyes: "👀",
heart: "❤️",
laugh: "😄",
// Custom mappings for project status
rotating_light: "🛟",
thinking_face: "🤔",
};
// Calculate percentages and format
const stats = Object.entries(emojiCounts)
.map(([emoji, count]) => ({
emoji: emojiMap[emoji] || emoji,
rawEmoji: emoji,
count,
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
// Find most active agent
const mostActiveAgent = Object.entries(agentCounts).sort(
(a, b) => b[1] - a[1]
)[0];
// Find most blocked issue
const mostBlocked = Object.entries(blockedIssues).sort(
(a, b) => b[1] - a[1]
)[0];
return {
total,
stats,
mostActiveAgent: mostActiveAgent ? mostActiveAgent[0] : null,
mostBlocked: mostBlocked ? mostBlocked[0] : null,
blockedCount: stats
.filter((s) => s.emoji === "❌")
.reduce((acc, s) => acc + s.count, 0),
escalationCount: stats
.filter((s) => s.rawEmoji === "rotating_light")
.reduce((acc, s) => acc + s.count, 0),
reviewCount: stats
.filter((s) => s.rawEmoji === "thinking_face")
.reduce((acc, s) => acc + s.count, 0),
};
}
/**
* Generates a markdown digest from the statistics.
* @param {Object} stats - Statistics object from calculateEmojiStats
* @param {Date} date - Date for the digest header
* @returns {string} - Markdown formatted digest
*/
function generateDigestMarkdown(stats, date = new Date()) {
const dateStr = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
let markdown = `# 📊 Weekly Agent Emoji Digest (${dateStr})\n\n`;
markdown += `| Emoji | Count | % |\n`;
markdown += `|-------|-------|----|\n`;
// Show top reactions (limit to 10)
const topStats = stats.stats.slice(0, 10);
for (const stat of topStats) {
markdown += `| ${stat.emoji} | ${stat.count} | ${stat.percentage}% |\n`;
}
markdown += `\n`;
// Add summary metrics
if (stats.mostActiveAgent) {
markdown += `🔥 Most active agent: \`${stats.mostActiveAgent}\`\n`;
}
if (stats.mostBlocked) {
markdown += `🛑 Most blocked: \`${stats.mostBlocked}\`\n`;
}
if (stats.escalationCount > 0) {
markdown += `🛟 Escalations: ${stats.escalationCount} cases\n`;
}
if (stats.reviewCount > 0) {
markdown += `🤔 Review queue: ${stats.reviewCount} issues\n`;
}
return markdown;
}
/**
* Fetches reactions from GitHub API for a repository.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} days - Number of days to look back
* @returns {Promise<Array>} - Array of reactions
*/
async function fetchReactions(owner, repo, days = 7) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
const since = new Date();
since.setDate(since.getDate() - days);
// Fetch recent issues
const issuesResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues?state=all&since=${since.toISOString()}&per_page=100`,
{
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
},
}
);
if (!issuesResponse.ok) {
throw new Error(`Failed to fetch issues: ${issuesResponse.status}`);
}
const issues = await issuesResponse.json();
const allReactions = [];
// Fetch reactions for each issue
for (const issue of issues) {
const reactionsResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues/${issue.number}/reactions`,
{
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
},
}
);
if (reactionsResponse.ok) {
const reactions = await reactionsResponse.json();
for (const reaction of reactions) {
allReactions.push({
content: reaction.content,
user: reaction.user?.login,
issue: issue.title,
created_at: reaction.created_at,
});
}
}
}
// Filter reactions to only include those from the specified time period
const sinceTimestamp = since.getTime();
return allReactions.filter(
(r) => new Date(r.created_at).getTime() >= sinceTimestamp
);
}
/**
* Posts a comment to a GitHub issue.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} issueNumber - Issue number to comment on
* @param {string} body - Comment body
*/
async function postDigestComment(owner, repo, issueNumber, body) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
{
method: "POST",
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to post comment: ${response.status} - ${error}`);
}
console.log("✅ Digest comment posted successfully");
return response.json();
}
/**
* Main function to run the digest bot.
*/
async function main() {
const owner = process.env.REPO_OWNER || "BlackRoad-OS";
const repo = process.env.REPO_NAME || "blackroad-os";
const digestIssueNumber = parseInt(process.env.DIGEST_ISSUE_NUMBER || "1", 10);
const daysToLookBack = parseInt(process.env.DAYS_TO_LOOK_BACK || "7", 10);
console.log("📊 Starting Weekly Emoji Digest Bot...");
console.log(`Repository: ${owner}/${repo}`);
console.log(`Looking back ${daysToLookBack} days`);
try {
// Fetch reactions
const reactions = await fetchReactions(owner, repo, daysToLookBack);
console.log(`Found ${reactions.length} reactions`);
// Calculate statistics
const stats = calculateEmojiStats(reactions);
// Generate markdown
const digest = generateDigestMarkdown(stats);
console.log("\n--- Generated Digest ---");
console.log(digest);
// Post to tracking issue if configured
if (digestIssueNumber && process.env.POST_COMMENT === "true") {
await postDigestComment(owner, repo, digestIssueNumber, digest);
}
// Also save to file for reference
const outputPath = process.env.OUTPUT_PATH || "./digest.md";
fs.writeFileSync(outputPath, digest);
console.log(`\n✅ Digest saved to ${outputPath}`);
} catch (error) {
console.error("❌ Error running digest bot:", error.message);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
main();
}
module.exports = {
calculateEmojiStats,
generateDigestMarkdown,
fetchReactions,
postDigestComment,
};

View File

@@ -0,0 +1,130 @@
// bot/handlers/project-graphql-updater.js
/**
* Updates a GitHub Project V2 field value via GraphQL.
* Used to change the status of linked project cards when reactions are triggered.
*/
async function updateProjectField({
issueNodeId,
projectId,
fieldId,
valueId,
}) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
const mutation = `
mutation UpdateProjectV2ItemFieldValue($input: UpdateProjectV2ItemFieldValueInput!) {
updateProjectV2ItemFieldValue(input: $input) {
projectV2Item {
id
}
}
}
`;
const variables = {
input: {
projectId,
itemId: issueNodeId,
fieldId,
value: { singleSelectOptionId: 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 error = await response.text();
throw new Error(`GraphQL request failed: ${response.status} - ${error}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
console.log("✅ Project field updated:", result.data);
return result.data;
}
/**
* Fetches the Node ID for an issue, project details, and field options.
* Useful for obtaining the required IDs before calling updateProjectField.
*/
async function fetchProjectMetadata({ owner, repo, issueNumber, projectNumber }) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
const query = `
query FetchProjectMetadata($owner: String!, $repo: String!, $issueNumber: Int!, $projectNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
id
}
}
user(login: $owner) {
projectV2(number: $projectNumber) {
id
fields(first: 20) {
nodes {
... on ProjectV2FieldCommon {
id
name
}
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
`;
const variables = { owner, repo, issueNumber, 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 error = await response.text();
throw new Error(`GraphQL request failed: ${response.status} - ${error}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
return result.data;
}
module.exports = {
updateProjectField,
fetchProjectMetadata,
};

142
tests/digest-bot.test.js Normal file
View File

@@ -0,0 +1,142 @@
// tests/digest-bot.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
// We'll test the pure functions that don't require network access
const {
calculateEmojiStats,
generateDigestMarkdown,
} = require("../bot/digest-bot.js");
describe("digest-bot", () => {
describe("calculateEmojiStats", () => {
it("should calculate correct emoji counts and percentages", () => {
const reactions = [
{ content: "+1", user: "agent-1", issue: "Issue A" },
{ content: "+1", user: "agent-2", issue: "Issue A" },
{ content: "+1", user: "agent-1", issue: "Issue B" },
{ content: "-1", user: "agent-3", issue: "Issue C" },
{ content: "confused", user: "agent-3", issue: "Issue D" },
{ content: "eyes", user: "agent-1", issue: "Issue E" },
];
const stats = calculateEmojiStats(reactions);
expect(stats.total).toBe(6);
expect(stats.stats.length).toBe(4);
// +1 should be most common with 50%
const plusOne = stats.stats.find((s) => s.rawEmoji === "+1");
expect(plusOne.count).toBe(3);
expect(plusOne.percentage).toBe(50);
expect(plusOne.emoji).toBe("✅");
// -1 should have 1 count
const minusOne = stats.stats.find((s) => s.rawEmoji === "-1");
expect(minusOne.count).toBe(1);
expect(minusOne.emoji).toBe("❌");
});
it("should identify most active agent", () => {
const reactions = [
{ content: "+1", user: "scribe-agent", issue: "Issue A" },
{ content: "+1", user: "scribe-agent", issue: "Issue B" },
{ content: "+1", user: "scribe-agent", issue: "Issue C" },
{ content: "-1", user: "builder-agent", issue: "Issue D" },
];
const stats = calculateEmojiStats(reactions);
expect(stats.mostActiveAgent).toBe("scribe-agent");
});
it("should identify most blocked issue", () => {
const reactions = [
{ content: "-1", user: "agent-1", issue: "blackroad-os-api" },
{ content: "-1", user: "agent-2", issue: "blackroad-os-api" },
{ content: "confused", user: "agent-3", issue: "blackroad-os-api" },
{ content: "-1", user: "agent-4", issue: "other-issue" },
];
const stats = calculateEmojiStats(reactions);
expect(stats.mostBlocked).toBe("blackroad-os-api");
expect(stats.blockedCount).toBe(4);
});
it("should handle empty reactions array", () => {
const stats = calculateEmojiStats([]);
expect(stats.total).toBe(0);
expect(stats.stats.length).toBe(0);
expect(stats.mostActiveAgent).toBeNull();
expect(stats.mostBlocked).toBeNull();
});
it("should count escalations and review requests", () => {
const reactions = [
{ content: "rotating_light", user: "agent-1", issue: "Issue A" },
{ content: "rotating_light", user: "agent-2", issue: "Issue B" },
{ content: "thinking_face", user: "agent-3", issue: "Issue C" },
];
const stats = calculateEmojiStats(reactions);
expect(stats.escalationCount).toBe(2);
expect(stats.reviewCount).toBe(1);
});
});
describe("generateDigestMarkdown", () => {
it("should generate valid markdown table", () => {
const stats = {
total: 10,
stats: [
{ emoji: "✅", rawEmoji: "+1", count: 5, percentage: 50 },
{ emoji: "❌", rawEmoji: "-1", count: 3, percentage: 30 },
{ emoji: "🛟", rawEmoji: "rotating_light", count: 2, percentage: 20 },
],
mostActiveAgent: "@scribe-agent",
mostBlocked: "blackroad-os-api",
blockedCount: 3,
escalationCount: 2,
reviewCount: 1,
};
const testDate = new Date("2025-11-24");
const markdown = generateDigestMarkdown(stats, testDate);
expect(markdown).toContain("# 📊 Weekly Agent Emoji Digest");
expect(markdown).toContain("Nov 24, 2025");
expect(markdown).toContain("| Emoji | Count | % |");
expect(markdown).toContain("| ✅ | 5 | 50% |");
expect(markdown).toContain("| ❌ | 3 | 30% |");
expect(markdown).toContain("| 🛟 | 2 | 20% |");
expect(markdown).toContain("🔥 Most active agent: `@scribe-agent`");
expect(markdown).toContain("🛑 Most blocked: `blackroad-os-api`");
expect(markdown).toContain("🛟 Escalations: 2 cases");
expect(markdown).toContain("🤔 Review queue: 1 issues");
});
it("should handle empty stats gracefully", () => {
const stats = {
total: 0,
stats: [],
mostActiveAgent: null,
mostBlocked: null,
blockedCount: 0,
escalationCount: 0,
reviewCount: 0,
};
const testDate = new Date("2025-11-24");
const markdown = generateDigestMarkdown(stats, testDate);
expect(markdown).toContain("# 📊 Weekly Agent Emoji Digest");
expect(markdown).toContain("| Emoji | Count | % |");
// Should not contain agent/blocked info when null
expect(markdown).not.toContain("Most active agent");
expect(markdown).not.toContain("Most blocked");
});
});
});

View File

@@ -0,0 +1,193 @@
// tests/project-graphql-updater.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const {
updateProjectField,
fetchProjectMetadata,
} = require("../bot/handlers/project-graphql-updater.js");
describe("project-graphql-updater", () => {
const originalEnv = process.env;
const mockFetch = vi.fn();
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv, GITHUB_TOKEN: "test-token" };
global.fetch = mockFetch;
mockFetch.mockReset();
});
afterEach(() => {
process.env = originalEnv;
});
describe("updateProjectField", () => {
it("should throw error when GITHUB_TOKEN is not set", async () => {
delete process.env.GITHUB_TOKEN;
await expect(
updateProjectField({
issueNodeId: "issue-123",
projectId: "project-456",
fieldId: "field-789",
valueId: "value-abc",
})
).rejects.toThrow("GITHUB_TOKEN environment variable is required");
});
it("should make GraphQL mutation request with correct parameters", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: {
updateProjectV2ItemFieldValue: {
projectV2Item: { id: "item-123" },
},
},
}),
});
const result = await updateProjectField({
issueNodeId: "issue-123",
projectId: "project-456",
fieldId: "field-789",
valueId: "value-abc",
});
expect(mockFetch).toHaveBeenCalledWith(
"https://api.github.com/graphql",
expect.objectContaining({
method: "POST",
headers: {
Authorization: "bearer test-token",
"Content-Type": "application/json",
},
})
);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.variables.input).toEqual({
projectId: "project-456",
itemId: "issue-123",
fieldId: "field-789",
value: { singleSelectOptionId: "value-abc" },
});
expect(result.updateProjectV2ItemFieldValue.projectV2Item.id).toBe(
"item-123"
);
});
it("should throw error on HTTP failure", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve("Unauthorized"),
});
await expect(
updateProjectField({
issueNodeId: "issue-123",
projectId: "project-456",
fieldId: "field-789",
valueId: "value-abc",
})
).rejects.toThrow("GraphQL request failed: 401 - Unauthorized");
});
it("should throw error on GraphQL errors", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
errors: [{ message: "Field not found" }],
}),
});
await expect(
updateProjectField({
issueNodeId: "issue-123",
projectId: "project-456",
fieldId: "field-789",
valueId: "value-abc",
})
).rejects.toThrow("GraphQL errors:");
});
});
describe("fetchProjectMetadata", () => {
it("should throw error when GITHUB_TOKEN is not set", async () => {
delete process.env.GITHUB_TOKEN;
await expect(
fetchProjectMetadata({
owner: "BlackRoad-OS",
repo: "blackroad-os",
issueNumber: 1,
projectNumber: 1,
})
).rejects.toThrow("GITHUB_TOKEN environment variable is required");
});
it("should fetch project metadata with correct query", async () => {
const mockResponse = {
repository: {
issue: { id: "issue-node-id-123" },
},
user: {
projectV2: {
id: "project-id-456",
fields: {
nodes: [
{
id: "field-id-789",
name: "Status",
options: [
{ id: "opt-done", name: "Done" },
{ id: "opt-blocked", name: "Blocked" },
],
},
],
},
},
},
};
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockResponse }),
});
const result = await fetchProjectMetadata({
owner: "BlackRoad-OS",
repo: "blackroad-os",
issueNumber: 1,
projectNumber: 1,
});
expect(mockFetch).toHaveBeenCalledWith(
"https://api.github.com/graphql",
expect.objectContaining({
method: "POST",
headers: {
Authorization: "bearer test-token",
"Content-Type": "application/json",
},
})
);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.variables).toEqual({
owner: "BlackRoad-OS",
repo: "blackroad-os",
issueNumber: 1,
projectNumber: 1,
});
expect(result.repository.issue.id).toBe("issue-node-id-123");
expect(result.user.projectV2.id).toBe("project-id-456");
});
});
});