Merge commit '9c9b256c5532ac51de6c8c98d3fdd31628ee8e5a'

This commit is contained in:
Alexa Amundson
2025-11-25 13:58:48 -06:00
6 changed files with 954 additions and 0 deletions

View File

@@ -10,16 +10,28 @@ on:
inputs: inputs:
digest_issue: digest_issue:
description: 'Issue number to post digest to (overrides default)' description: 'Issue number to post digest to (overrides default)'
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 required: false
type: number type: number
jobs: jobs:
weekly-digest: weekly-digest:
digest-bot:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
issues: write issues: write
contents: read contents: read
steps: steps:
- name: 🧬 Checkout Repo - name: 🧬 Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -28,6 +40,10 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: 🧠 Set up Node
uses: actions/setup-node@v4
with:
node-version: 18
- name: 📊 Run Weekly Digest Bot - name: 📊 Run Weekly Digest Bot
env: env:
@@ -35,3 +51,27 @@ jobs:
DIGEST_ISSUE: ${{ inputs.digest_issue || '' }} DIGEST_ISSUE: ${{ inputs.digest_issue || '' }}
run: node digest.js run: node digest.js
working-directory: ./bot working-directory: ./bot
DIGEST_ISSUE_NUMBER: ${{ inputs.digest_issue_number }}
run: |
node -e "
const { runWeeklyDigest } = require('./bot/handlers/digest-bot.js');
const repoEnv = process.env.GITHUB_REPOSITORY;
if (!repoEnv || !repoEnv.includes('/')) {
console.error('❌ GITHUB_REPOSITORY environment variable is not set or invalid');
process.exit(1);
}
const [owner, repo] = repoEnv.split('/');
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);
});
"

314
bot/handlers/digest-bot.js Normal file
View File

@@ -0,0 +1,314 @@
// 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.
* @param {string} owner - Repository owner (username or organization)
* @param {string} repo - Repository name
* @param {string} since - ISO 8601 date string to filter issues updated since this date
*/
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,
};

View File

@@ -0,0 +1,177 @@
// 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.
* Supports both user and organization projects.
* @param {string} owner - The user or organization login
* @param {number} projectNumber - The project number
* @param {boolean} isOrg - Whether the owner is an organization (default: false)
*/
async function getProjectFields(owner, projectNumber, isOrg = false) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is not set");
}
const ownerType = isOrg ? "organization" : "user";
const query = `
query GetProjectFields($owner: String!, $projectNumber: Int!) {
${ownerType}(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[ownerType].projectV2;
}
module.exports = {
updateProjectField,
getIssueNodeId,
getProjectFields,
};

View File

@@ -4,6 +4,18 @@ 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");
// 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 +27,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 +87,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
View 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");
});
});
});

View File

@@ -0,0 +1,169 @@
// 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 for user 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, false);
expect(result).toEqual(mockProject);
});
it("should fetch project fields for organization successfully", async () => {
const mockProject = {
id: "org-project-123",
fields: {
nodes: [
{ id: "field-1", name: "Status", options: [{ id: "opt-1", name: "Done" }] },
],
},
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
organization: {
projectV2: mockProject,
},
},
}),
});
const { getProjectFields } = await import("../bot/handlers/project-graphql-updater.js");
const result = await getProjectFields("BlackRoad-OS", 1, true);
expect(result).toEqual(mockProject);
});
});
});