Add GitHub Project GraphQL Mutator and Weekly Digest Bot
Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
50
.github/workflows/digest-bot.yml
vendored
Normal file
50
.github/workflows/digest-bot.yml
vendored
Normal 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
290
bot/digest-bot.js
Normal 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,
|
||||||
|
};
|
||||||
130
bot/handlers/project-graphql-updater.js
Normal file
130
bot/handlers/project-graphql-updater.js
Normal 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
142
tests/digest-bot.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
193
tests/project-graphql-updater.test.js
Normal file
193
tests/project-graphql-updater.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user