Merge branch origin/codex/add-emoji-based-automation-for-github-projects into main
This commit is contained in:
16
app/agent-templates/broadcast-agent.md
Normal file
16
app/agent-templates/broadcast-agent.md
Normal 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.
|
||||||
16
app/agent-templates/builder-agent.md
Normal file
16
app/agent-templates/builder-agent.md
Normal 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.”
|
||||||
16
app/agent-templates/guardian-agent.md
Normal file
16
app/agent-templates/guardian-agent.md
Normal 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.
|
||||||
16
app/agent-templates/planner-agent.md
Normal file
16
app/agent-templates/planner-agent.md
Normal 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.
|
||||||
16
app/agent-templates/scribe-agent.md
Normal file
16
app/agent-templates/scribe-agent.md
Normal 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: `<1–3 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
94
config/project-config.yml
Normal 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
15
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
109
src/automation/emoji-agent-router.js
Normal file
109
src/automation/emoji-agent-router.js
Normal 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,
|
||||||
|
};
|
||||||
284
src/automation/project-status-sync.js
Normal file
284
src/automation/project-status-sync.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user