Merge branch origin/codex/add-emoji-based-automation-for-github-projects into main

This commit is contained in:
Alexa Amundson
2025-11-25 13:39:09 -06:00
10 changed files with 583 additions and 2 deletions

View File

@@ -0,0 +1,16 @@
# 📢 broadcast-agent — Cross-channel Updates
**Trigger:** React with 📢 to broadcast updates across Slack/Discord/email.
**Project moves:**
- Status → In Progress (emoji config `statusOption: in_progress`)
- Team/Owner can be set to comms or community leads
**Reply template:**
- Audience: `<channels or roles>`
- Message: `<short announcement>`
- Links: `<PR/issue dashboards>`
- Next broadcast: `<timestamp or event>`
- Owner: `<who will post>`
**Notes:** Pair with 🧾 or ✅ to attach docs or final release notes.

View File

@@ -0,0 +1,16 @@
# 🤖 builder-agent — Build, Deploy, Ship
**Trigger:** React with 🤖 when implementation or deployment should start; ✅ marks completion.
**Project moves:**
- Status → In Progress (emoji config `statusOption: in_progress`)
- Owner field can be overridden to the deployment runner account
**Reply template:**
- Build plan: `<steps or pipeline name>`
- Environments: `<dev/stage/prod>`
- Checks: `<tests or smoke tasks>`
- Ship log link: `<URL to run/artefact>`
- Rollback plan: `<1-2 lines>`
**Notes:** Add ✅ when shipped to auto-close and move to “Shipped.”

View File

@@ -0,0 +1,16 @@
# 🛟 guardian-agent — Escalation & Monitoring
**Trigger:** React with 🛟 for escalation or ❌ when work is blocked/regressed.
**Project moves:**
- Status → Blocked
- Team/Owner reassigned per emoji configuration
**Reply template:**
- Alert summary: `<what failed and where>`
- Blast radius: `<impact surface>`
- Immediate actions: `<containment steps>`
- Requested owners: `<people/teams>`
- Next check-in: `<timestamp>`
**Notes:** Do not close the issue. Use ✅ once the regression is resolved and verified.

View File

@@ -0,0 +1,16 @@
# 🧠 planner-agent — Estimation & Sprint Intake
**Trigger:** React with 🧠 when an issue needs scoping, story-pointing, or sprint planning.
**Project moves:**
- Status → In Review (see config `statusOption: in_review`)
- Sprint / Team fields set from defaults unless overridden in emoji config
**Reply template:**
- Scope summary: `<two-sentence recap>`
- Estimate: `<S|M|L or points>`
- Risks/assumptions: `<bullets>`
- Sprint target: `<current sprint name>`
- Owners: `<engineer or squad>`
**Notes:** Keep the issue open; the ✅ reaction will close and ship.

View File

@@ -0,0 +1,16 @@
# 🧾 scribe-agent — Docs & Release Notes
**Trigger:** React with 🧾 to request documentation, or ✅ to finalize release notes post-ship.
**Project moves:**
- Status → In Review (emoji config `statusOption: in_review`)
- Owner field can be updated to the doc maintainer
**Reply template:**
- Change summary: `<13 bullets>`
- User impact: `<who is affected>`
- Docs: `<links to README/handbook/pages>`
- Release notes draft: `<concise paragraph>`
- Follow-ups: `<tickets or TODOs>`
**Notes:** Keep responses concise and link PRs/issues for traceability.

94
config/project-config.yml Normal file
View File

@@ -0,0 +1,94 @@
# Shared emoji automation config for GitHub Projects v2.
# Keep IDs in sync with the target project configuration. All IDs below are
# examples and should be replaced with the real IDs for your organization.
defaults:
projectId: "PVT_default_project"
fields:
status: "PVTF_STATUS_FIELD"
team: "PVTF_TEAM_FIELD"
owner: "PVTF_OWNER_FIELD"
sprint: "PVTF_SPRINT_FIELD"
statusOptions:
backlog: "option_backlog"
in_progress: "option_in_progress"
in_review: "option_in_review"
blocked: "option_blocked"
shipped: "option_shipped"
emojiActions:
"✅":
statusOption: shipped
close: true
agents:
- builder-agent
- scribe-agent
"❌":
statusOption: blocked
close: false
agents:
- guardian-agent
"🛟":
statusOption: blocked
escalate: true
agents:
- guardian-agent
"🧠":
statusOption: in_review
agents:
- planner-agent
"🤖":
statusOption: in_progress
agents:
- builder-agent
"🧾":
statusOption: in_review
agents:
- scribe-agent
"📢":
statusOption: in_progress
agents:
- broadcast-agent
defaultFieldValues:
team: "Core"
sprint: "Current"
defaultAgentRouting:
planner-agent: ["🧠"]
builder-agent: ["🤖", "✅"]
scribe-agent: ["🧾", "✅"]
guardian-agent: ["🛟", "❌"]
broadcast-agent: ["📢"]
repos:
"open-sorcerers/blackroad-os":
projectId: "PVT_blackroad_os"
fields:
status: "PVTF_status_custom"
owner: "PVTF_owner_custom"
statusOptions:
shipped: "option_shipped_custom"
blocked: "option_blocked_custom"
in_review: "option_review_custom"
emojiActions:
"✅":
statusOption: shipped
close: true
fields:
owner: "automation-bot"
team: "DX"
agents:
- builder-agent
- scribe-agent
- broadcast-agent
"❌":
statusOption: blocked
close: false
agents:
- guardian-agent
"🛟":
statusOption: blocked
escalate: true
fields:
team: "Support"
agents:
- guardian-agent

15
package-lock.json generated
View File

@@ -15,7 +15,8 @@
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"yaml": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -5215,6 +5216,18 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yn": { "node_modules/yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@@ -20,7 +20,8 @@
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"yaml": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",

View File

@@ -0,0 +1,109 @@
const { applyReactionUpdate, loadProjectConfig, resolveRepoConfig } = require("./project-status-sync");
const builtInAgents = {
"planner-agent": async (context) => ({
nextSteps: [
"Gather acceptance criteria",
"Estimate story points",
"Update sprint field in project card",
],
context,
}),
"builder-agent": async (context) => ({
nextSteps: [
"Kick off deployment pipeline",
"Post ship log once complete",
],
context,
}),
"scribe-agent": async (context) => ({
nextSteps: [
"Draft release notes",
"Refresh docs with latest acceptance results",
],
context,
}),
"guardian-agent": async (context) => ({
nextSteps: [
"Watch for regressions",
"Escalate to humans if errors repeat",
],
context,
}),
"broadcast-agent": async (context) => ({
nextSteps: [
"Post update to Slack/Discord",
"Publish digest back to the issue thread",
],
context,
}),
};
function resolveEmojiAgents(emoji, repoConfig, config) {
const action = repoConfig.emojiActions?.[emoji];
const mappedAgents = action?.agents || [];
const defaultRouting = config.defaultAgentRouting || {};
const fromDefaults = Object.entries(defaultRouting)
.filter(([, emojis]) => emojis.includes(emoji))
.map(([agent]) => agent);
const combined = new Set([...fromDefaults, ...mappedAgents]);
return Array.from(combined);
}
async function routeReactionToAgents(payload, options = {}) {
const { reaction, repository, issue, pull_request, comment } = payload;
const emoji = reaction?.content;
const repoFullName = repository?.full_name;
const contentNodeId = issue?.node_id || pull_request?.node_id || comment?.node_id;
const issueNumber = issue?.number || pull_request?.number;
if (!emoji || !repoFullName || !contentNodeId) {
return { dispatched: false, reason: "Missing required reaction data" };
}
const config = loadProjectConfig(options.configPath);
const repoConfig = resolveRepoConfig(repoFullName, config);
const agents = resolveEmojiAgents(emoji, repoConfig, config);
const statusResult = await applyReactionUpdate({
emoji,
repoFullName,
contentNodeId,
issueNumber,
configPath: options.configPath,
fieldValues: options.fieldValues,
});
const agentResults = {};
for (const agent of agents) {
const handler = options.agentHandlers?.[agent] || builtInAgents[agent];
if (!handler) {
agentResults[agent] = { skipped: true, reason: "No handler registered" };
continue;
}
agentResults[agent] = await handler({
emoji,
repoFullName,
issueNumber,
projectId: statusResult.projectId,
projectItemId: statusResult.itemId,
commentNodeId: comment?.node_id,
escalate: statusResult.escalate,
});
}
return {
dispatched: true,
agents,
statusResult,
agentResults,
};
}
module.exports = {
routeReactionToAgents,
resolveEmojiAgents,
};

View File

@@ -0,0 +1,284 @@
const fs = require("fs");
const path = require("path");
const YAML = require("yaml");
const DEFAULT_CONFIG_PATH = path.resolve(__dirname, "../../config/project-config.yml");
function loadProjectConfig(configPath = DEFAULT_CONFIG_PATH) {
const resolvedPath = configPath || DEFAULT_CONFIG_PATH;
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Project config not found at ${resolvedPath}`);
}
const raw = fs.readFileSync(resolvedPath, "utf8");
return YAML.parse(raw) || {};
}
function mergeConfig(defaults, override = {}) {
return {
...defaults,
fields: { ...(defaults.fields || {}), ...(override.fields || {}) },
statusOptions: {
...(defaults.statusOptions || {}),
...(override.statusOptions || {}),
},
emojiActions: { ...(defaults.emojiActions || {}), ...(override.emojiActions || {}) },
defaultFieldValues: {
...(defaults.defaultFieldValues || {}),
...(override.defaultFieldValues || {}),
},
};
}
function resolveRepoConfig(repoFullName, config) {
const defaults = config?.defaults || {};
const repoOverrides = config?.repos?.[repoFullName] || {};
return mergeConfig(defaults, repoOverrides);
}
async function callGitHubGraphQL(token, query, variables = {}) {
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 text = await response.text();
throw new Error(`GitHub GraphQL error: ${response.status} ${text}`);
}
const payload = await response.json();
if (payload.errors?.length) {
const messages = payload.errors.map((e) => e.message).join("; ");
throw new Error(`GitHub GraphQL errors: ${messages}`);
}
return payload.data;
}
async function closeGitHubContent(token, repoFullName, issueNumber) {
const response = await fetch(`https://api.github.com/repos/${repoFullName}/issues/${issueNumber}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "emoji-automation-bot",
},
body: JSON.stringify({ state: "closed" }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to close ${repoFullName}#${issueNumber}: ${response.status} ${text}`);
}
}
async function getProjectItemForContent(token, projectId, contentId) {
const query = `
query($contentId: ID!) {
node(id: $contentId) {
... on Issue {
projectItems(first: 20) {
nodes {
id
project { id }
}
}
}
... on PullRequest {
projectItems(first: 20) {
nodes {
id
project { id }
}
}
}
}
}
`;
const data = await callGitHubGraphQL(token, query, { contentId });
const items = data?.node?.projectItems?.nodes || [];
const match = items.find((item) => item.project?.id === projectId);
return match?.id;
}
async function addContentToProject(token, projectId, contentId) {
const mutation = `
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item { id }
}
}
`;
const data = await callGitHubGraphQL(token, mutation, { projectId, contentId });
return data?.addProjectV2ItemById?.item?.id;
}
async function ensureProjectItem(token, projectId, contentId) {
const existing = await getProjectItemForContent(token, projectId, contentId);
if (existing) return existing;
return addContentToProject(token, projectId, contentId);
}
async function updateSingleSelectField(token, projectId, itemId, fieldId, optionId) {
if (!fieldId || !optionId) return null;
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item { id }
}
}
`;
await callGitHubGraphQL(token, mutation, {
projectId,
itemId,
fieldId,
optionId,
});
return { fieldId, optionId };
}
async function updateTextField(token, projectId, itemId, fieldId, value) {
if (!fieldId || typeof value !== "string" || !value.length) return null;
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { text: $text }
}
) {
projectV2Item { id }
}
}
`;
await callGitHubGraphQL(token, mutation, {
projectId,
itemId,
fieldId,
text: value,
});
return { fieldId, value };
}
async function syncProjectFields({
token,
projectId,
itemId,
repoConfig,
action,
fieldValues = {},
}) {
const updates = [];
const statusOptionId = action.statusOption
? repoConfig.statusOptions?.[action.statusOption]
: undefined;
if (statusOptionId && repoConfig.fields?.status) {
const update = await updateSingleSelectField(
token,
projectId,
itemId,
repoConfig.fields.status,
statusOptionId,
);
if (update) updates.push(update);
}
const textFields = { ...repoConfig.defaultFieldValues, ...(action.fields || {}), ...fieldValues };
const textTargets = [
{ key: "team", fieldId: repoConfig.fields?.team },
{ key: "owner", fieldId: repoConfig.fields?.owner },
{ key: "sprint", fieldId: repoConfig.fields?.sprint },
];
for (const target of textTargets) {
const value = textFields[target.key];
if (!value) continue;
const update = await updateTextField(token, projectId, itemId, target.fieldId, value);
if (update) updates.push(update);
}
return updates;
}
async function applyReactionUpdate({
emoji,
repoFullName,
contentNodeId,
issueNumber,
configPath,
fieldValues,
}) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN must be set for GitHub automation");
}
const config = loadProjectConfig(configPath);
const repoConfig = resolveRepoConfig(repoFullName, config);
const action = repoConfig.emojiActions?.[emoji];
if (!action) {
return { applied: false, reason: `No emoji logic for ${emoji}` };
}
const projectId = action.projectId || repoConfig.projectId;
if (!projectId) {
throw new Error(`Missing projectId for ${repoFullName}`);
}
const itemId = await ensureProjectItem(token, projectId, contentNodeId);
const updates = await syncProjectFields({
token,
projectId,
itemId,
repoConfig,
action,
fieldValues,
});
if (action.close && issueNumber) {
await closeGitHubContent(token, repoFullName, issueNumber);
}
return {
applied: true,
projectId,
itemId,
updates,
agents: action.agents || [],
closed: Boolean(action.close && issueNumber),
escalate: Boolean(action.escalate),
};
}
module.exports = {
applyReactionUpdate,
ensureProjectItem,
loadProjectConfig,
resolveRepoConfig,
syncProjectFields,
};