From 64470f4307098593c076259e3d1d988494522c4e Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 12:58:11 -0400 Subject: [PATCH 1/6] feat: add github-native coordination (epic-* commands + scripts + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GitHub-native coordination layer on top of ECC: Commands (7 new slash commands): - epic-claim, epic-sync, epic-validate, epic-publish - epic-review, epic-unblock, epic-decompose Scripts: - scripts/github-coordination.js — CLI entry point - scripts/lib/github-coordination.js — core library (state machine, gh API wrappers) - scripts/status.js — coordination status reporter Config: - config/github-native-coordination.json — labels, review policy, validation gates Tests: - tests/lib/github-coordination.test.js — 15 unit tests for pure functions - tests/scripts/github-coordination.test.js — integration/CLI test suite Registry: - docs/COMMAND-REGISTRY.json — adds 7 epic-* entries, totalCommands 84 → 91 No encoding changes, no prp-* modifications, no Windows shims. Co-Authored-By: Claude Sonnet 4.6 --- commands/epic-claim.md | 26 + commands/epic-decompose.md | 23 + commands/epic-publish.md | 23 + commands/epic-review.md | 23 + commands/epic-sync.md | 23 + commands/epic-unblock.md | 22 + commands/epic-validate.md | 22 + config/github-native-coordination.json | 38 + docs/COMMAND-REGISTRY.json | 68 +- scripts/github-coordination.js | 279 ++++++ scripts/lib/github-coordination.js | 1040 +++++++++++++++++++++ scripts/status.js | 102 ++ tests/lib/github-coordination.test.js | 249 +++++ tests/scripts/github-coordination.test.js | 238 +++++ 14 files changed, 2175 insertions(+), 1 deletion(-) create mode 100644 commands/epic-claim.md create mode 100644 commands/epic-decompose.md create mode 100644 commands/epic-publish.md create mode 100644 commands/epic-review.md create mode 100644 commands/epic-sync.md create mode 100644 commands/epic-unblock.md create mode 100644 commands/epic-validate.md create mode 100644 config/github-native-coordination.json create mode 100644 scripts/github-coordination.js create mode 100644 scripts/lib/github-coordination.js create mode 100644 tests/lib/github-coordination.test.js create mode 100644 tests/scripts/github-coordination.test.js diff --git a/commands/epic-claim.md b/commands/epic-claim.md new file mode 100644 index 00000000..85c64b16 --- /dev/null +++ b/commands/epic-claim.md @@ -0,0 +1,26 @@ +--- +description: Claim an epic issue, stamp coordination state, and sync local ownership. +--- + +# /epic-claim + +Claim one epic issue as the source of truth for a unit of work. + +Use the coordination script: + +```bash +node scripts/github-coordination.js claim --repo --actor +``` + +What this does: + +1. Loads the issue body and coordination block. +2. Marks the epic as claimed in GitHub issue state. +3. Updates labels and the local SQLite cache. +4. Appends an audit comment for the claim. + +Compatibility aliases: + +- `/orch-add-feature` +- `/orch-change-feature` +- `/prp-implement` diff --git a/commands/epic-decompose.md b/commands/epic-decompose.md new file mode 100644 index 00000000..5a1af14a --- /dev/null +++ b/commands/epic-decompose.md @@ -0,0 +1,23 @@ +--- +description: Break an epic into task children without creating task branches. +--- + +# /epic-decompose + +Reconcile the task breakdown for one epic issue. + +```bash +node scripts/github-coordination.js decompose --repo +``` + +What this does: + +1. Reads the epic issue body for task checklists and dependency references. +2. Stores the decomposition in the coordination block. +3. Leaves task branches out of the workflow. +4. Appends a concise audit comment. + +Compatibility aliases: + +- `/plan` +- `/prp-plan` diff --git a/commands/epic-publish.md b/commands/epic-publish.md new file mode 100644 index 00000000..675ac3d9 --- /dev/null +++ b/commands/epic-publish.md @@ -0,0 +1,23 @@ +--- +description: Publish a validated epic update back to the issue and local cache. +--- + +# /epic-publish + +Publish a validated coordination update to GitHub. + +```bash +node scripts/github-coordination.js publish --repo +``` + +What this does: + +1. Re-validates the epic before publishing. +2. Updates the coordination block in the issue body. +3. Appends a concise publish comment. +4. Records the final local snapshot. + +Compatibility aliases: + +- `/pr` +- `/prp-pr` diff --git a/commands/epic-review.md b/commands/epic-review.md new file mode 100644 index 00000000..378b6ab6 --- /dev/null +++ b/commands/epic-review.md @@ -0,0 +1,23 @@ +--- +description: Mark epic review requested, approved, or changes requested. +--- + +# /epic-review + +Coordinate review state for an epic issue. + +```bash +node scripts/github-coordination.js review --repo --review approved +``` + +What this does: + +1. Updates the review state in the coordination block. +2. Syncs review labels to GitHub. +3. Records the review outcome in an audit comment. +4. Keeps the local cache aligned with the issue body. + +Compatibility aliases: + +- `/review-pr` +- `/code-review` diff --git a/commands/epic-sync.md b/commands/epic-sync.md new file mode 100644 index 00000000..8ea55da7 --- /dev/null +++ b/commands/epic-sync.md @@ -0,0 +1,23 @@ +--- +description: Sync epic issue bodies, labels, and local coordination snapshots from GitHub. +--- + +# /epic-sync + +Run a deterministic sync for epic issues. + +```bash +node scripts/github-coordination.js sync --repo +``` + +What this does: + +1. Reads issue bodies as the canonical epic state. +2. Reconciles the coordination block with labels. +3. Writes a fresh local snapshot for each epic issue. +4. Keeps the SQLite cache aligned with GitHub. + +Compatibility aliases: + +- `/projects` +- `/work-items sync-github` diff --git a/commands/epic-unblock.md b/commands/epic-unblock.md new file mode 100644 index 00000000..7895c661 --- /dev/null +++ b/commands/epic-unblock.md @@ -0,0 +1,22 @@ +--- +description: Sweep blocked epic issues and reopen anything whose dependencies are closed. +--- + +# /epic-unblock + +Sweep blocked epics whose declared dependencies are complete. + +```bash +node scripts/github-coordination.js unblock --repo +``` + +What this does: + +1. Scans epic issues in the repository. +2. Checks each blocked epic's dependency list. +3. Moves fully unblocked epics to ready. +4. Updates labels, comments, and local snapshots. + +Compatibility aliases: + +- `/loop-status` diff --git a/commands/epic-validate.md b/commands/epic-validate.md new file mode 100644 index 00000000..73192e8f --- /dev/null +++ b/commands/epic-validate.md @@ -0,0 +1,22 @@ +--- +description: Validate epic readiness, dependencies, and coordination policy. +--- + +# /epic-validate + +Validate a single epic issue before publishing or review handoff. + +```bash +node scripts/github-coordination.js validate --repo +``` + +What this checks: + +1. Coordination state exists and is parseable. +2. Validation state is satisfied by policy. +3. Declared dependencies are closed. +4. The epic is ready for the next workflow stage. + +Compatibility aliases: + +- `/quality-gate` diff --git a/config/github-native-coordination.json b/config/github-native-coordination.json new file mode 100644 index 00000000..4dfc6824 --- /dev/null +++ b/config/github-native-coordination.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": "ecc.github.coordination.v1", + "sectionMarker": "ecc-coordination", + "labels": { + "epic": "epic", + "available": "coordination:available", + "claimed": "coordination:claimed", + "ready": "coordination:ready", + "blocked": "coordination:blocked", + "validated": "coordination:validated", + "reviewRequested": "coordination:review-requested", + "reviewApproved": "coordination:review-approved", + "reviewChangesRequested": "coordination:review-changes-requested", + "published": "coordination:published", + "synced": "coordination:synced" + }, + "review": { + "required": true, + "defaultMode": "required" + }, + "validation": { + "required": true + }, + "branchModel": { + "epicOnly": true, + "taskBranches": false + }, + "project": { + "enabled": false, + "fieldNames": { + "status": "Status", + "owner": "Owner", + "branch": "Branch", + "validation": "Validation", + "review": "Review" + } + } +} diff --git a/docs/COMMAND-REGISTRY.json b/docs/COMMAND-REGISTRY.json index 2ac66a02..c52b1a2f 100644 --- a/docs/COMMAND-REGISTRY.json +++ b/docs/COMMAND-REGISTRY.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "totalCommands": 84, + "totalCommands": 91, "commands": [ { "command": "aside", @@ -111,6 +111,72 @@ ], "path": "commands/ecc-guide.md" }, + { + "command": "epic-claim", + "description": "Claim an epic issue, stamp coordination state, and sync local ownership.", + "type": "review", + "primaryAgents": [], + "allAgents": [], + "skills": [ + "orch-add-feature", + "orch-change-feature" + ], + "path": "commands/epic-claim.md" + }, + { + "command": "epic-decompose", + "description": "Break an epic into task children without creating task branches.", + "type": "review", + "primaryAgents": [], + "allAgents": [], + "skills": [], + "path": "commands/epic-decompose.md" + }, + { + "command": "epic-publish", + "description": "Publish a validated epic update back to the issue and local cache.", + "type": "general", + "primaryAgents": [], + "allAgents": [], + "skills": [], + "path": "commands/epic-publish.md" + }, + { + "command": "epic-review", + "description": "Mark epic review requested, approved, or changes requested.", + "type": "review", + "primaryAgents": [], + "allAgents": [], + "skills": [], + "path": "commands/epic-review.md" + }, + { + "command": "epic-sync", + "description": "Sync epic issue bodies, labels, and local coordination snapshots from GitHub.", + "type": "general", + "primaryAgents": [], + "allAgents": [], + "skills": [], + "path": "commands/epic-sync.md" + }, + { + "command": "epic-unblock", + "description": "Sweep blocked epic issues and reopen anything whose dependencies are closed.", + "type": "general", + "primaryAgents": [], + "allAgents": [], + "skills": [], + "path": "commands/epic-unblock.md" + }, + { + "command": "epic-validate", + "description": "Validate epic readiness, dependencies, and coordination policy.", + "type": "review", + "primaryAgents": [], + "allAgents": [], + "skills": [], + "path": "commands/epic-validate.md" + }, { "command": "evolve", "description": "Analyze instincts and suggest or generate evolved structures", diff --git a/scripts/github-coordination.js b/scripts/github-coordination.js new file mode 100644 index 00000000..ddc065bd --- /dev/null +++ b/scripts/github-coordination.js @@ -0,0 +1,279 @@ +#!/usr/bin/env node +'use strict'; + +const os = require('os'); + +const { + applyClaim, + applyDecompose, + applyPublish, + applyReview, + applySync, + applyUnblock, + applyValidate, + formatCollection, + formatSummary, + loadPolicy, + normalizeIssueNumber, + openStore, +} = require('./lib/github-coordination'); + +function usage(exitCode = 0) { + console.log([ + 'Usage: node scripts/github-coordination.js [options]', + '', + 'Commands:', + ' claim Claim an epic issue and stamp coordination state', + ' sync Sync epic issue bodies, labels, and local snapshots', + ' validate Validate epic readiness and dependency status', + ' publish Publish a validated epic update/comment', + ' review Mark review requested/approved/blocked', + ' unblock Sweep blocked epics whose dependencies are closed', + ' decompose Reconcile epic task breakdown from issue body', + '', + 'Options:', + ' --repo GitHub repository', + ' --issue Issue number for actions that target one issue', + ' --actor Claim owner / coordination actor', + ' --branch Epic branch name to stamp into the coordination body', + ' --config Optional coordination policy config', + ' --db SQLite state store path', + ' --home Override home directory used by the state store', + ' --limit Limit issues scanned by sync/unblock', + ' --dry-run Preview changes without modifying GitHub or state', + ' --json Emit machine-readable JSON', + ' --help, -h Show this help', + ].join('\n')); + process.exit(exitCode); +} + +function readValue(args, index, flagName) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flagName} requires a value`); + } + return value; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + command: null, + actor: null, + branch: null, + configPath: null, + dbPath: null, + dryRun: false, + help: false, + homeDir: null, + issueNumber: null, + json: false, + limit: 100, + repo: null, + validation: null, + review: null, + status: null, + projectState: null, + positionals: [], + }; + + if (args.length > 0 && !args[0].startsWith('-')) { + parsed.command = args.shift(); + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--help' || arg === '-h') { + parsed.help = true; + continue; + } + if (arg === '--json') { + parsed.json = true; + continue; + } + if (arg === '--dry-run') { + parsed.dryRun = true; + continue; + } + if (arg === '--repo') { + parsed.repo = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--issue') { + parsed.issueNumber = normalizeIssueNumber(readValue(args, index, arg)); + index += 1; + continue; + } + if (arg === '--actor') { + parsed.actor = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--branch') { + parsed.branch = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--config') { + parsed.configPath = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--db') { + parsed.dbPath = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--home') { + parsed.homeDir = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--limit') { + parsed.limit = normalizeIssueNumber(readValue(args, index, arg)); + index += 1; + continue; + } + if (arg === '--validation') { + parsed.validation = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--review') { + parsed.review = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--status') { + parsed.status = readValue(args, index, arg); + index += 1; + continue; + } + if (arg === '--project-state') { + parsed.projectState = readValue(args, index, arg); + index += 1; + continue; + } + + if (!arg.startsWith('-')) { + parsed.positionals.push(arg); + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!parsed.command) { + parsed.command = 'sync'; + } + + if (!parsed.issueNumber && parsed.positionals.length > 0) { + parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]); + } + + return parsed; +} + +async function main() { + let store = null; + + try { + const options = parseArgs(process.argv); + if (options.help) { + usage(0); + } + + if (!options.repo) { + throw new Error('Missing --repo .'); + } + + const policy = loadPolicy(process.cwd(), options.configPath); + store = await openStore({ + dbPath: options.dbPath, + homeDir: options.homeDir || process.env.HOME || os.homedir(), + }); + + let payload; + + if (options.command === 'claim') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + payload = applyClaim(options.repo, options.issueNumber, { + actor: options.actor, + branch: options.branch, + configPath: options.configPath, + dryRun: options.dryRun, + owner: options.actor, + projectState: options.projectState, + review: options.review, + status: options.status, + validation: options.validation, + }, { store, policy, rootDir: process.cwd() }); + } else if (options.command === 'sync') { + payload = applySync(options.repo, { + configPath: options.configPath, + dryRun: options.dryRun, + limit: options.limit, + }, { store, policy, rootDir: process.cwd() }); + } else if (options.command === 'validate') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + payload = applyValidate(options.repo, options.issueNumber, { + configPath: options.configPath, + dryRun: options.dryRun, + }, { store, policy, rootDir: process.cwd() }); + } else if (options.command === 'publish') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + payload = applyPublish(options.repo, options.issueNumber, { + configPath: options.configPath, + dryRun: options.dryRun, + }, { store, policy, rootDir: process.cwd() }); + } else if (options.command === 'review') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + payload = applyReview(options.repo, options.issueNumber, { + configPath: options.configPath, + dryRun: options.dryRun, + review: options.review, + }, { store, policy, rootDir: process.cwd() }); + } else if (options.command === 'unblock') { + payload = applyUnblock(options.repo, { + configPath: options.configPath, + dryRun: options.dryRun, + limit: options.limit, + }, { store, policy, rootDir: process.cwd() }); + } else if (options.command === 'decompose') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + payload = applyDecompose(options.repo, options.issueNumber, { + configPath: options.configPath, + dryRun: options.dryRun, + }, { store, policy, rootDir: process.cwd() }); + } else { + throw new Error(`Unknown command: ${options.command}`); + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else if (options.command === 'sync' || options.command === 'unblock') { + process.stdout.write(formatCollection(payload)); + } else { + process.stdout.write(formatSummary(payload)); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } finally { + if (store) { + store.close(); + } + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + main, + parseArgs, + usage, +}; diff --git a/scripts/lib/github-coordination.js b/scripts/lib/github-coordination.js new file mode 100644 index 00000000..340b93cb --- /dev/null +++ b/scripts/lib/github-coordination.js @@ -0,0 +1,1040 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { createStateStore } = require('./state-store'); + +const DEFAULT_CONFIG_FILE = 'github-native-coordination.json'; +const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', 'config', DEFAULT_CONFIG_FILE); +const DEFAULT_SECTION_MARKER = 'ecc-coordination'; +const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1'; +const DEFAULT_LABELS = Object.freeze({ + epic: 'epic', + available: 'coordination:available', + claimed: 'coordination:claimed', + ready: 'coordination:ready', + blocked: 'coordination:blocked', + validated: 'coordination:validated', + reviewRequested: 'coordination:review-requested', + reviewApproved: 'coordination:review-approved', + reviewChangesRequested: 'coordination:review-changes-requested', + published: 'coordination:published', + synced: 'coordination:synced', +}); +const DEFAULT_POLICY = Object.freeze({ + schemaVersion: DEFAULT_SCHEMA_VERSION, + sectionMarker: DEFAULT_SECTION_MARKER, + labels: DEFAULT_LABELS, + review: { + required: true, + defaultMode: 'required', + }, + validation: { + required: true, + }, + branchModel: { + epicOnly: true, + taskBranches: false, + }, + project: { + enabled: false, + fieldNames: { + status: 'Status', + owner: 'Owner', + branch: 'Branch', + validation: 'Validation', + review: 'Review', + }, + }, +}); + +function normalizeRepo(repo) { + const parts = String(repo || '').split('/').filter(Boolean); + if (parts.length !== 2) { + throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`); + } + const [owner, name] = parts; + return { owner, name }; +} + +function slugifySegment(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unknown'; +} + +function epicWorkItemId(repo, issueNumber) { + return `github-${slugifySegment(repo)}-epic-${issueNumber}`; +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env || process.env, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`); + } + + return result.stdout || ''; +} + +function runGh(args, options = {}) { + const shimPath = process.env.ECC_GH_SHIM; + const command = shimPath ? process.execPath : 'gh'; + const commandArgs = shimPath ? [shimPath, ...args] : args; + const env = { ...process.env }; + + if (!options.useEnvGithubToken) { + delete env.GITHUB_TOKEN; + } + + return runCommand(command, commandArgs, { cwd: options.cwd, env }); +} + +function runGhJson(args, options = {}) { + try { + return JSON.parse(runGh(args, options) || 'null'); + } catch (error) { + throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); + } +} + +function normalizeLabelValue(label) { + if (typeof label === 'string') { + return label.trim(); + } + if (label && typeof label === 'object') { + return String(label.name || label.label || '').trim(); + } + return ''; +} + +function normalizeLabels(labels) { + return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort(); +} + +function normalizeIssueNumber(value) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid issue number: ${value}`); + } + return parsed; +} + +function loadPolicy(rootDir = process.cwd(), configPath = null) { + const resolvedPath = configPath + ? path.resolve(configPath) + : path.join(rootDir, 'config', DEFAULT_CONFIG_FILE); + + if (!fs.existsSync(resolvedPath)) { + return { + ...DEFAULT_POLICY, + sourcePath: null, + }; + } + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`); + } + return { + ...DEFAULT_POLICY, + ...parsed, + labels: { + ...DEFAULT_LABELS, + ...(parsed.labels || {}), + }, + review: { + ...DEFAULT_POLICY.review, + ...(parsed.review || {}), + }, + validation: { + ...DEFAULT_POLICY.validation, + ...(parsed.validation || {}), + }, + branchModel: { + ...DEFAULT_POLICY.branchModel, + ...(parsed.branchModel || {}), + }, + project: { + ...DEFAULT_POLICY.project, + ...(parsed.project || {}), + fieldNames: { + ...DEFAULT_POLICY.project.fieldNames, + ...((parsed.project || {}).fieldNames || {}), + }, + }, + sourcePath: resolvedPath, + }; +} + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function normalizeBodyForComparison(body) { + return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED'); +} + +function extractCoordinationState(body, policy = DEFAULT_POLICY) { + const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); + const regex = new RegExp( + `\\s*` + + '```json\\s*([\\s\\S]*?)\\s*```' + + `\\s*`, + 'm' + ); + const match = String(body || '').match(regex); + + if (!match) { + return null; + } + + try { + const parsed = JSON.parse(match[1]); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_error) { + return null; + } +} + +function extractIssueReferences(text) { + const refs = new Set(); + const source = String(text || ''); + for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) { + refs.add(Number.parseInt(match[1], 10)); + } + return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b); +} + +function extractTasks(body) { + const lines = String(body || '').split(/\r?\n/); + const tasks = []; + let inTasks = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) { + inTasks = true; + continue; + } + if (inTasks && /^#{2,3}\s+\S/.test(line)) { + break; + } + if (inTasks) { + const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i); + if (taskMatch) { + tasks.push({ + title: taskMatch[2].trim(), + done: taskMatch[1].toLowerCase() === 'x', + }); + } + } + } + + return tasks; +} + +function parseStringList(value) { + if (!value) { + return []; + } + + return String(value) + .split(',') + .map(part => part.trim()) + .filter(Boolean); +} + +function defaultCoordinationState(issue, policy = DEFAULT_POLICY) { + return { + schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: 'epic', + status: 'available', + owner: issue && issue.author && issue.author.login ? issue.author.login : null, + branch: null, + validation: 'pending', + review: 'not-requested', + project: { + state: 'backlog', + fields: {}, + }, + dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''), + tasks: extractTasks(issue && issue.body ? issue.body : ''), + labels: normalizeLabels(issue && issue.labels), + lastAction: 'sync', + lastActionAt: new Date().toISOString(), + lastSyncAt: new Date().toISOString(), + notes: null, + }; +} + +function getCoordinationState(issue, policy = DEFAULT_POLICY) { + const existing = extractCoordinationState(issue && issue.body, policy); + if (existing) { + return { + ...defaultCoordinationState(issue, policy), + ...existing, + project: { + ...defaultCoordinationState(issue, policy).project, + ...(existing.project || {}), + }, + tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''), + dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''), + labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels), + }; + } + return defaultCoordinationState(issue, policy); +} + +function renderCoordinationState(state, policy = DEFAULT_POLICY) { + const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER; + const payload = { + schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: state.kind || 'epic', + status: state.status || 'available', + owner: state.owner || null, + branch: state.branch || null, + validation: state.validation || 'pending', + review: state.review || 'not-requested', + project: state.project || { state: 'backlog', fields: {} }, + dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], + tasks: Array.isArray(state.tasks) ? state.tasks : [], + labels: Array.isArray(state.labels) ? state.labels : [], + lastAction: state.lastAction || 'sync', + lastActionAt: state.lastActionAt || new Date().toISOString(), + lastSyncAt: state.lastSyncAt || new Date().toISOString(), + notes: state.notes || null, + }; + + return [ + ``, + '```json', + JSON.stringify(payload, null, 2), + '```', + ``, + ].join('\n'); +} + +function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) { + const body = String(issue.body || ''); + const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); + const rendered = renderCoordinationState(nextState, policy); + const regex = new RegExp( + `\\n?[\\s\\S]*?\\n?`, + 'm' + ); + + if (regex.test(body)) { + return body.replace(regex, `\n${rendered}\n`).trim() + '\n'; + } + + const trimmed = body.trimEnd(); + if (!trimmed) { + return `${rendered}\n`; + } + return `${trimmed}\n\n${rendered}\n`; +} + +function buildIssueComment(action, repo, issueNumber, state, extra = {}) { + const summary = [ + `ECC coordination ${action}`, + `Repo: ${repo}`, + `Issue: #${issueNumber}`, + `Status: ${state.status}`, + `Owner: ${state.owner || '(unassigned)'}`, + `Branch: ${state.branch || '(none)'}`, + `Validation: ${state.validation || 'pending'}`, + `Review: ${state.review || 'not-requested'}`, + ]; + + for (const [key, value] of Object.entries(extra)) { + summary.push(`${key}: ${value}`); + } + + summary.push('', 'This comment is part of the append-only coordination audit trail.'); + return summary.join('\n'); +} + +function mapStateToWorkItemStatus(state) { + switch (state) { + case 'blocked': + return 'blocked'; + case 'published': + return 'done'; + case 'validated': + case 'reviewing': + case 'claimed': + case 'ready': + return 'in-progress'; + case 'changes-requested': + return 'needs-review'; + case 'available': + default: + return 'open'; + } +} + +function summarizeProjectProjection(state, policy = DEFAULT_POLICY) { + return { + enabled: Boolean(policy.project && policy.project.enabled), + state: state.project && state.project.state ? state.project.state : 'backlog', + fields: { + ...(state.project && state.project.fields ? state.project.fields : {}), + }, + }; +} + +function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) { + if (!store) { + return null; + } + + const now = new Date().toISOString(); + const metadata = { + schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION, + repo, + issueNumber: issue.number, + issueUrl: issue.url || null, + issueTitle: issue.title || null, + labels: normalizeLabels(issue.labels), + coordination: state, + projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY), + action, + actionAt: now, + syncedBy: 'ecc-github-coordination', + }; + + return store.upsertWorkItem({ + id: epicWorkItemId(repo, issue.number), + source: 'github-epic', + sourceId: String(issue.number), + title: `Epic #${issue.number}: ${issue.title}`, + status: mapStateToWorkItemStatus(state.status), + priority: state.status === 'blocked' ? 'high' : 'normal', + url: issue.url || null, + owner: state.owner || (issue.author && issue.author.login) || null, + repoRoot: options.repoRoot || process.cwd(), + sessionId: options.sessionId || null, + metadata, + updatedAt: now, + }); +} + +function getIssue(repo, issueNumber, options = {}) { + const { owner, name } = normalizeRepo(repo); + const json = runGhJson([ + 'issue', + 'view', + String(issueNumber), + '--repo', + `${owner}/${name}`, + '--json', + 'number,title,body,url,state,labels,author,updatedAt,assignees', + ], options); + + if (!json) { + throw new Error(`Unable to load issue #${issueNumber} from ${repo}`); + } + + return json; +} + +function listIssues(repo, options = {}) { + const { owner, name } = normalizeRepo(repo); + const limit = Number.isFinite(options.limit) ? options.limit : 100; + const state = options.state || 'all'; + return runGhJson([ + 'issue', + 'list', + '--repo', + `${owner}/${name}`, + '--state', + state, + '--limit', + String(limit), + '--json', + 'number,title,body,url,state,labels,author,updatedAt,assignees', + ], options) || []; +} + +function editIssue(repo, issueNumber, options = {}) { + const { owner, name } = normalizeRepo(repo); + const args = [ + 'issue', + 'edit', + String(issueNumber), + '--repo', + `${owner}/${name}`, + ]; + + if (options.body !== undefined) { + args.push('--body', options.body); + } + + for (const label of options.addLabels || []) { + args.push('--add-label', label); + } + + for (const label of options.removeLabels || []) { + args.push('--remove-label', label); + } + + if (options.title) { + args.push('--title', options.title); + } + + if (options.assignee) { + args.push('--add-assignee', options.assignee); + } + + return runGh(args, options); +} + +function commentIssue(repo, issueNumber, body, options = {}) { + const { owner, name } = normalizeRepo(repo); + return runGh([ + 'issue', + 'comment', + String(issueNumber), + '--repo', + `${owner}/${name}`, + '--body', + body, + ], options); +} + +function findIssueByNumber(issues, issueNumber) { + return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null; +} + +function desiredLabelsForState(state, policy = DEFAULT_POLICY) { + const labels = []; + const known = policy.labels || DEFAULT_LABELS; + + labels.push(known.epic); + labels.push(known.synced); + + if (state.status === 'available') labels.push(known.available); + if (state.status === 'claimed') labels.push(known.claimed); + if (state.status === 'ready') labels.push(known.ready); + if (state.status === 'blocked') labels.push(known.blocked); + if (state.validation === 'passed') labels.push(known.validated); + if (state.review === 'requested') labels.push(known.reviewRequested); + if (state.review === 'approved') labels.push(known.reviewApproved); + if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested); + if (state.status === 'published') labels.push(known.published); + + return Array.from(new Set(labels.filter(Boolean))).sort(); +} + +function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) { + const desired = new Set(desiredLabelsForState(state, policy)); + const current = new Set(normalizeLabels(issue.labels)); + const addLabels = Array.from(desired).filter(label => !current.has(label)); + const removeLabels = Array.from(current).filter(label => { + if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) { + return false; + } + return !desired.has(label); + }); + + if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) { + return { addLabels, removeLabels }; + } + + if (addLabels.length > 0 || removeLabels.length > 0) { + editIssue(repo, issue.number, { ...options, addLabels, removeLabels }); + } + + return { addLabels, removeLabels }; +} + +function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) { + const now = new Date().toISOString(); + const next = { + ...currentState, + schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: 'epic', + lastAction: action, + lastActionAt: now, + lastSyncAt: now, + labels: normalizeLabels(issue.labels), + dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body), + tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body), + }; + + if (options.owner !== undefined) { + next.owner = options.owner; + } + if (options.branch !== undefined) { + next.branch = options.branch; + } + if (options.validation !== undefined) { + next.validation = options.validation; + } + if (options.review !== undefined) { + next.review = options.review; + } + if (options.status !== undefined) { + next.status = options.status; + } + if (options.projectState !== undefined) { + next.project = { + ...(next.project || {}), + state: options.projectState, + }; + } + if (options.notes !== undefined) { + next.notes = options.notes; + } + if (options.tasks !== undefined) { + next.tasks = options.tasks; + } + if (options.dependencies !== undefined) { + next.dependencies = options.dependencies; + } + + return next; +} + +function assertIssueClaimable(issue, state) { + if (String(issue.state || '').toLowerCase() !== 'open') { + throw new Error(`Issue #${issue.number} is not open`); + } + + if (state.status === 'claimed' && state.owner) { + throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`); + } +} + +function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) { + if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) { + return []; + } + + const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 }); + const closed = []; + for (const dependencyNumber of dependencyNumbers) { + const issue = findIssueByNumber(issueList, dependencyNumber); + if (!issue) { + process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`); + } else if (String(issue.state || '').toLowerCase() === 'closed') { + closed.push(dependencyNumber); + } + } + + return closed; +} + +function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) { + return { + schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + repo, + issueNumber: issue.number, + issueUrl: issue.url || null, + issueTitle: issue.title, + action, + status: state.status, + owner: state.owner || null, + branch: state.branch || null, + validation: state.validation || 'pending', + review: state.review || 'not-requested', + project: summarizeProjectProjection(state, policy), + dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], + tasks: Array.isArray(state.tasks) ? state.tasks : [], + labels: normalizeLabels(issue.labels), + workItemId: epicWorkItemId(repo, issue.number), + lastActionAt: state.lastActionAt || null, + lastSyncAt: state.lastSyncAt || null, + }; +} + +function applyClaim(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issue = getIssue(repo, issueNumber, options); + const currentState = getCoordinationState(issue, policy); + + assertIssueClaimable(issue, currentState); + + const nextState = buildIssueStateFromAction(issue, currentState, 'claim', { + owner: options.actor || options.owner || currentState.owner || issue.author?.login || null, + branch: options.branch || currentState.branch || null, + status: options.status || 'claimed', + validation: options.validation || currentState.validation || 'pending', + review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'), + projectState: options.projectState || 'in-progress', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + const body = mergeIssueBody(issue, nextState, policy); + if (!options.dryRun) { + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options); + upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy); +} + +function applySync(repo, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 }); + const syncedAt = new Date().toISOString(); + const results = []; + + for (const issue of issues) { + const currentState = getCoordinationState(issue, policy); + const nextState = buildIssueStateFromAction(issue, currentState, 'sync', { + status: currentState.status, + validation: currentState.validation, + review: currentState.review, + projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + const body = mergeIssueBody(issue, nextState, policy); + const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); + + if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) { + editIssue(repo, issue.number, { + body, + addLabels: labelPlan.addLabels, + removeLabels: labelPlan.removeLabels, + }, options); + } + + const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); + results.push({ + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy), + syncedAt, + labelPlan, + snapshot: snapshot || null, + }); + } + + return { + repo, + syncedAt, + count: results.length, + items: results, + }; +} + +function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = existingIssue || getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; + const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options); + const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number)); + const validations = []; + + if (policy.validation.required && state.validation !== 'passed') { + validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` }); + } else { + validations.push({ check: 'validation-status', ok: true, detail: state.validation }); + } + + if (missingDependencies.length > 0) { + validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') }); + } else { + validations.push({ check: 'dependencies', ok: true, detail: 'closed' }); + } + + const ok = validations.every(entry => entry.ok); + const nextState = buildIssueStateFromAction(issue, state, 'validate', { + status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status, + validation: ok ? 'passed' : 'failed', + projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy }); + } + + return { + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy), + ok, + validations, + missingDependencies, + }; +} + +function applyPublish(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue); + + if (!validation.ok) { + throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`); + } + + const nextState = buildIssueStateFromAction(issue, state, 'publish', { + status: 'published', + validation: 'passed', + review: state.review === 'changes-requested' ? state.review : 'approved', + projectState: 'done', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, { + validation: 'passed', + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy); +} + +function applyReview(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const reviewState = options.review || 'approved'; + const nextState = buildIssueStateFromAction(issue, state, 'review', { + status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked', + review: reviewState, + projectState: reviewState === 'approved' ? 'ready' : 'blocked', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, { + review: reviewState, + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy); +} + +function applyDecompose(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const tasks = extractTasks(issue.body); + const dependencies = extractIssueReferences(issue.body); + const nextState = buildIssueStateFromAction(issue, state, 'decompose', { + tasks, + dependencies, + status: tasks.some(task => !task.done) ? 'claimed' : state.status, + projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, { + taskCount: String(tasks.length), + dependencyCount: String(dependencies.length), + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy }); + } + + return { + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy), + tasks, + dependencyCount: dependencies.length, + }; +} + +function applyUnblock(repo, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 }); + const results = []; + + for (const issue of issues) { + const state = getCoordinationState(issue, policy); + if (state.status !== 'blocked') { + continue; + } + + const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; + const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues); + if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) { + continue; + } + + const nextState = buildIssueStateFromAction(issue, state, 'unblock', { + status: 'ready', + projectState: 'ready', + validation: state.validation === 'failed' ? 'pending' : state.validation, + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issue.number, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, { + dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none', + }), options); + upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy }); + } + + results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy)); + } + + return { + repo, + count: results.length, + items: results, + }; +} + +function formatSummary(payload) { + const lines = [ + `${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`, + `Repo: ${payload.repo}`, + `Status: ${payload.status}`, + `Owner: ${payload.owner || '(unassigned)'}`, + `Branch: ${payload.branch || '(none)'}`, + `Validation: ${payload.validation || 'pending'}`, + `Review: ${payload.review || 'not-requested'}`, + ]; + if (payload.tasks && payload.tasks.length > 0) { + lines.push(`Tasks: ${payload.tasks.length}`); + } + if (payload.dependencies && payload.dependencies.length > 0) { + lines.push(`Dependencies: ${payload.dependencies.join(', ')}`); + } + return `${lines.join('\n')}\n`; +} + +function formatCollection(payload) { + const lines = [ + `Repo: ${payload.repo}`, + `Items: ${payload.count}`, + ]; + for (const item of payload.items || []) { + lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`); + } + return `${lines.join('\n')}\n`; +} + +async function openStore(options = {}) { + if (options.dbPath === false) { + return null; + } + + return createStateStore({ + dbPath: options.dbPath, + homeDir: options.homeDir || process.env.HOME || os.homedir(), + }); +} + +module.exports = { + DEFAULT_CONFIG_FILE, + DEFAULT_CONFIG_PATH, + DEFAULT_POLICY, + DEFAULT_SCHEMA_VERSION, + applyClaim, + applyDecompose, + applyPublish, + applyReview, + applySync, + applyUnblock, + applyValidate, + buildIssueComment, + buildIssueStateFromAction, + commentIssue, + defaultCoordinationState, + desiredLabelsForState, + editIssue, + epicWorkItemId, + extractCoordinationState, + extractIssueReferences, + extractTasks, + formatCollection, + formatSummary, + getCoordinationState, + getIssue, + listIssues, + loadPolicy, + mapStateToWorkItemStatus, + mergeIssueBody, + normalizeIssueNumber, + normalizeLabels, + normalizeRepo, + openStore, + renderCoordinationState, + runGh, + runGhJson, + slugifySegment, + syncIssueLabels, + summarizeStateForOutput, + upsertCoordinationWorkItem, + verifyDependenciesClosed, +}; diff --git a/scripts/status.js b/scripts/status.js index e97b8d38..523738bb 100644 --- a/scripts/status.js +++ b/scripts/status.js @@ -165,6 +165,74 @@ function printWorkItems(section) { } } +function summarizeGithubCoordination(workItems) { + const epicItems = workItems.items.filter(item => item.source === 'github-epic'); + const summary = { + totalCount: epicItems.length, + availableCount: 0, + claimedCount: 0, + readyCount: 0, + blockedCount: 0, + validatedCount: 0, + publishedCount: 0, + recent: epicItems.slice(0, 10), + }; + + for (const item of epicItems) { + const state = item.metadata && item.metadata.coordination ? item.metadata.coordination.status : item.status; + switch (state) { + case 'available': + summary.availableCount += 1; + break; + case 'claimed': + summary.claimedCount += 1; + break; + case 'ready': + summary.readyCount += 1; + break; + case 'blocked': + summary.blockedCount += 1; + break; + case 'validated': + summary.validatedCount += 1; + break; + case 'published': + summary.publishedCount += 1; + break; + default: + summary.availableCount += 1; + break; + } + } + + return summary; +} + +function printGithubCoordination(section) { + console.log(`GitHub epic coordination: ${section.totalCount} tracked`); + if (section.totalCount === 0) { + console.log(' - none'); + return; + } + + console.log(` Available: ${section.availableCount}`); + console.log(` Claimed: ${section.claimedCount}`); + console.log(` Ready: ${section.readyCount}`); + console.log(` Blocked: ${section.blockedCount}`); + console.log(` Validated: ${section.validatedCount}`); + console.log(` Published: ${section.publishedCount}`); + + for (const item of section.recent) { + console.log(` - ${item.source}/${item.sourceId || item.id} ${item.status}: ${item.title}`); + if (item.metadata && item.metadata.coordination) { + const coordination = item.metadata.coordination; + console.log(` Epic status: ${coordination.status}`); + console.log(` Owner: ${coordination.owner || '(unassigned)'}`); + console.log(` Branch: ${coordination.branch || '(none)'}`); + } + } +} + function printReadiness(section) { console.log(`Readiness: ${section.status}`); console.log(` Attention items: ${section.attentionCount}`); @@ -188,6 +256,10 @@ function printHuman(payload) { console.log(); printGovernance(payload.governance); console.log(); + if (payload.githubCoordination) { + printGithubCoordination(payload.githubCoordination); + console.log(); + } printWorkItems(payload.workItems); } @@ -318,6 +390,35 @@ function renderMarkdown(payload) { } } + if (payload.githubCoordination) { + lines.push( + '', + '## GitHub Epic Coordination', + '', + `Tracked: ${payload.githubCoordination.totalCount}`, + `Available: ${payload.githubCoordination.availableCount}`, + `Claimed: ${payload.githubCoordination.claimedCount}`, + `Ready: ${payload.githubCoordination.readyCount}`, + `Blocked: ${payload.githubCoordination.blockedCount}`, + `Validated: ${payload.githubCoordination.validatedCount}`, + `Published: ${payload.githubCoordination.publishedCount}` + ); + + if (payload.githubCoordination.recent.length === 0) { + lines.push('', '- none'); + } else { + lines.push('', 'Recent epics:'); + for (const item of payload.githubCoordination.recent) { + lines.push(`- ${formatCode(item.source)} ${formatCode(item.sourceId || item.id)} ${item.status}: ${item.title}`); + if (item.metadata && item.metadata.coordination) { + lines.push(` - Epic status: ${item.metadata.coordination.status}`); + lines.push(` - Owner: ${item.metadata.coordination.owner || '(unassigned)'}`); + lines.push(` - Branch: ${item.metadata.coordination.branch || '(none)'}`); + } + } + } + } + return `${lines.join('\n')}\n`; } @@ -350,6 +451,7 @@ async function main() { workItemLimit: options.limit, }), }; + payload.githubCoordination = summarizeGithubCoordination(payload.workItems); if (options.json) { const output = `${JSON.stringify(payload, null, 2)}\n`; diff --git a/tests/lib/github-coordination.test.js b/tests/lib/github-coordination.test.js new file mode 100644 index 00000000..561d85ff --- /dev/null +++ b/tests/lib/github-coordination.test.js @@ -0,0 +1,249 @@ +'use strict'; + +const assert = require('assert'); + +const { + normalizeRepo, + extractCoordinationState, + buildIssueStateFromAction, + desiredLabelsForState, + extractTasks, + renderCoordinationState, + DEFAULT_POLICY, +} = require('../../scripts/lib/github-coordination'); + +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +async function runTests() { + console.log('\n=== Testing github-coordination ===\n'); + + let passed = 0; + let failed = 0; + + // normalizeRepo + + if (await test('normalizeRepo returns { owner, name } for "owner/repo"', () => { + const result = normalizeRepo('acme/my-repo'); + assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' }); + })) passed += 1; else failed += 1; + + if (await test('normalizeRepo throws on "owner/repo/extra"', () => { + assert.throws( + () => normalizeRepo('owner/repo/extra'), + /Invalid repo format/ + ); + })) passed += 1; else failed += 1; + + if (await test('normalizeRepo throws on bare string with no slash', () => { + assert.throws( + () => normalizeRepo('justowner'), + /Invalid repo format/ + ); + })) passed += 1; else failed += 1; + + // extractCoordinationState + + if (await test('extractCoordinationState returns null for body with no coordination section', () => { + const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.'); + assert.strictEqual(result, null); + })) passed += 1; else failed += 1; + + if (await test('extractCoordinationState returns parsed state from a proper coordination JSON block', () => { + const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' }; + const body = [ + '', + '```json', + JSON.stringify(state, null, 2), + '```', + '', + ].join('\n'); + const result = extractCoordinationState(body); + assert.ok(result !== null); + assert.strictEqual(result.status, 'available'); + assert.strictEqual(result.kind, 'epic'); + assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1'); + })) passed += 1; else failed += 1; + + if (await test('extractCoordinationState returns null when JSON block is malformed', () => { + const body = [ + '', + '```json', + '{ not valid json }', + '```', + '', + ].join('\n'); + const result = extractCoordinationState(body); + assert.strictEqual(result, null); + })) passed += 1; else failed += 1; + + // buildIssueStateFromAction + + if (await test('buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', () => { + const issue = { number: 1, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'available', + owner: null, + branch: null, + validation: 'pending', + review: 'not-requested', + dependencies: [], + tasks: [], + }; + const before = new Date(); + const result = buildIssueStateFromAction(issue, currentState, 'claim', { + owner: 'alice', + branch: 'feat/my-branch', + status: 'claimed', + }); + const after = new Date(); + + assert.strictEqual(result.status, 'claimed'); + assert.strictEqual(result.owner, 'alice'); + assert.strictEqual(result.branch, 'feat/my-branch'); + assert.strictEqual(result.lastAction, 'claim'); + assert.ok(result.lastActionAt); + const actionAt = new Date(result.lastActionAt); + assert.ok(actionAt >= before && actionAt <= after); + })) passed += 1; else failed += 1; + + if (await test('buildIssueStateFromAction with "unblock" action preserves owner from existing state', () => { + const issue = { number: 2, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'blocked', + owner: 'bob', + branch: 'feat/blocked-branch', + validation: 'pending', + review: 'not-requested', + dependencies: [], + tasks: [], + }; + const result = buildIssueStateFromAction(issue, currentState, 'unblock', { + status: 'ready', + }); + + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.owner, 'bob'); + assert.strictEqual(result.branch, 'feat/blocked-branch'); + assert.strictEqual(result.lastAction, 'unblock'); + })) passed += 1; else failed += 1; + + // desiredLabelsForState + + if (await test('desiredLabelsForState for status "available" includes "coordination:available"', () => { + const labels = desiredLabelsForState({ status: 'available' }); + assert.ok(Array.isArray(labels)); + assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`); + })) passed += 1; else failed += 1; + + if (await test('desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"', () => { + const labels = desiredLabelsForState({ status: 'claimed' }); + assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`); + assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); + })) passed += 1; else failed += 1; + + // extractTasks + + if (await test('extractTasks returns empty array when body has no Tasks section', () => { + const body = 'Some issue without any task list.'; + const tasks = extractTasks(body); + assert.deepStrictEqual(tasks, []); + })) passed += 1; else failed += 1; + + if (await test('extractTasks parses completed and open checkboxes under ## Tasks heading', () => { + const body = [ + '## Tasks', + '- [x] Done task', + '- [ ] Open task', + '- [x] Another done task', + ].join('\n'); + const tasks = extractTasks(body); + const completed = tasks.filter(t => t.done); + const open = tasks.filter(t => !t.done); + assert.strictEqual(tasks.length, 3); + assert.strictEqual(completed.length, 2); + assert.strictEqual(open.length, 1); + assert.strictEqual(open[0].title, 'Open task'); + })) passed += 1; else failed += 1; + + if (await test('extractTasks stops parsing at next heading after task section', () => { + const body = [ + '## Tasks', + '- [x] First task', + '## Notes', + '- [ ] This is not a task', + ].join('\n'); + const tasks = extractTasks(body); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].title, 'First task'); + })) passed += 1; else failed += 1; + + // renderCoordinationState + + if (await test('renderCoordinationState returns a string containing the section marker', () => { + const state = { + schemaVersion: 'ecc.github.coordination.v1', + kind: 'epic', + status: 'available', + owner: null, + branch: null, + validation: 'pending', + review: 'not-requested', + project: { state: 'backlog', fields: {} }, + dependencies: [], + tasks: [], + labels: [], + lastAction: 'sync', + lastActionAt: '2026-01-01T00:00:00.000Z', + lastSyncAt: '2026-01-01T00:00:00.000Z', + notes: null, + }; + const rendered = renderCoordinationState(state); + assert.ok(typeof rendered === 'string'); + assert.ok(rendered.includes(''), 'Missing start marker'); + assert.ok(rendered.includes(''), 'Missing end marker'); + assert.ok(rendered.includes('```json'), 'Missing json code fence'); + })) passed += 1; else failed += 1; + + if (await test('renderCoordinationState output round-trips through extractCoordinationState', () => { + const state = { + schemaVersion: 'ecc.github.coordination.v1', + kind: 'epic', + status: 'claimed', + owner: 'carol', + branch: 'feat/my-feature', + validation: 'pending', + review: 'requested', + project: { state: 'in-progress', fields: {} }, + dependencies: [5, 6], + tasks: [{ title: 'Write tests', done: false }], + labels: ['coordination:claimed'], + lastAction: 'claim', + lastActionAt: '2026-01-01T00:00:00.000Z', + lastSyncAt: '2026-01-01T00:00:00.000Z', + notes: null, + }; + const rendered = renderCoordinationState(state); + const extracted = extractCoordinationState(rendered); + assert.ok(extracted !== null); + assert.strictEqual(extracted.status, 'claimed'); + assert.strictEqual(extracted.owner, 'carol'); + assert.deepStrictEqual(extracted.dependencies, [5, 6]); + })) passed += 1; else failed += 1; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/github-coordination.test.js b/tests/scripts/github-coordination.test.js new file mode 100644 index 00000000..0a7bfb99 --- /dev/null +++ b/tests/scripts/github-coordination.test.js @@ -0,0 +1,238 @@ +/** + * Tests for scripts/github-coordination.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const { createStateStore } = require('../../scripts/lib/state-store'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'github-coordination.js'); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeGhShim(rootDir, responses) { + const shimPath = path.join(rootDir, 'gh-shim.js'); + const logPath = path.join(rootDir, 'gh-calls.jsonl'); + fs.writeFileSync(shimPath, ` +const fs = require('fs'); +const responses = ${JSON.stringify(responses)}; +const args = process.argv.slice(2); +const key = args.join(' '); +const logPath = process.env.ECC_GH_SHIM_LOG; +if (logPath) { + fs.appendFileSync(logPath, JSON.stringify({ args }, null, 0) + '\\n'); +} +if (Object.prototype.hasOwnProperty.call(responses, key)) { + process.stdout.write(JSON.stringify(responses[key])); + process.exit(0); +} +if (args[0] === 'issue' && (args[1] === 'edit' || args[1] === 'comment')) { + process.stdout.write('{}'); + process.exit(0); +} +console.error('Unexpected gh args: ' + key); +process.exit(3); +`); + return { shimPath, logPath }; +} + +function run(args = [], options = {}) { + return spawnSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + env: { + ...process.env, + ...(options.env || {}), + }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }); +} + +function parseJson(stdout) { + return JSON.parse(stdout.trim()); +} + +async function test(name, fn) { + try { + await fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +async function readStore(dbPath) { + const store = await createStateStore({ dbPath }); + try { + return store.listWorkItems({ limit: 20 }); + } finally { + store.close(); + } +} + +async function runTests() { + console.log('\n=== Testing github-coordination.js ===\n'); + + let passed = 0; + let failed = 0; + + if (await test('claims an epic issue, updates GitHub state, and caches a work item', async () => { + const rootDir = createTempDir('github-coordination-claim-'); + const dbPath = path.join(rootDir, 'state.db'); + + try { + const epicBody = [ + '# Ship GitHub-native coordination', + '', + 'We want deterministic epic state.', + '', + '## Tasks', + '- [ ] Claim the epic', + '- [ ] Validate the epic', + ].join('\n'); + const issueView = { + number: 12, + title: 'Ship GitHub-native coordination', + body: epicBody, + url: 'https://github.com/affaan-m/ECC/issues/12', + state: 'OPEN', + labels: [{ name: 'epic' }], + author: { login: 'maintainer' }, + updatedAt: '2026-06-01T12:00:00Z', + }; + const shim = writeGhShim(rootDir, { + 'issue view 12 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': issueView, + }); + + const result = run(['claim', '12', '--repo', 'affaan-m/ECC', '--actor', 'codex', '--db', dbPath, '--json'], { + cwd: rootDir, + env: { + ECC_GH_SHIM: shim.shimPath, + ECC_GH_SHIM_LOG: shim.logPath, + }, + }); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.status, 'claimed'); + assert.strictEqual(payload.owner, 'codex'); + assert.strictEqual(payload.project.state, 'in-progress'); + + const logEntries = fs.readFileSync(shim.logPath, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line)); + assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit')); + assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment')); + + const stored = await readStore(dbPath); + const epicItem = stored.items.find(item => item.source === 'github-epic'); + assert.ok(epicItem, 'expected github epic work item'); + assert.strictEqual(epicItem.status, 'in-progress'); + assert.strictEqual(epicItem.metadata.coordination.status, 'claimed'); + assert.strictEqual(epicItem.metadata.coordination.owner, 'codex'); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (await test('unblocks an epic when dependencies are closed', async () => { + const rootDir = createTempDir('github-coordination-unblock-'); + const dbPath = path.join(rootDir, 'state.db'); + + try { + const blockedBody = [ + '# Release readiness', + '', + 'Dependencies: #2', + '', + '', + '```json', + JSON.stringify({ + schemaVersion: 'ecc.github.coordination.v1', + kind: 'epic', + status: 'blocked', + owner: 'codex', + branch: 'feat/release-readiness', + validation: 'pending', + review: 'requested', + project: { state: 'blocked', fields: {} }, + dependencies: [2], + tasks: [{ title: 'Check release checklist', done: false }], + labels: ['epic', 'coordination:blocked'], + lastAction: 'claim', + lastActionAt: '2026-06-01T13:00:00Z', + lastSyncAt: '2026-06-01T13:00:00Z', + notes: null, + }, null, 2), + '```', + '', + ].join('\n'); + const openIssue = { + number: 1, + title: 'Release readiness', + body: blockedBody, + url: 'https://github.com/affaan-m/ECC/issues/1', + state: 'OPEN', + labels: [{ name: 'epic' }, { name: 'coordination:blocked' }], + author: { login: 'codex' }, + updatedAt: '2026-06-01T13:00:00Z', + }; + const closedDependency = { + number: 2, + title: 'Release prerequisite', + body: '# Release prerequisite', + url: 'https://github.com/affaan-m/ECC/issues/2', + state: 'CLOSED', + labels: [{ name: 'blocked-by-release' }], + author: { login: 'maintainer' }, + updatedAt: '2026-06-01T10:00:00Z', + }; + const shim = writeGhShim(rootDir, { + 'issue list --repo affaan-m/ECC --state all --limit 100 --json number,title,body,url,state,labels,author,updatedAt,assignees': [openIssue, closedDependency], + 'issue view 1 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': openIssue, + }); + + const result = run(['unblock', '--repo', 'affaan-m/ECC', '--db', dbPath, '--json'], { + cwd: rootDir, + env: { + ECC_GH_SHIM: shim.shimPath, + ECC_GH_SHIM_LOG: shim.logPath, + }, + }); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.count, 1); + assert.strictEqual(payload.items[0].status, 'ready'); + + const logEntries = fs.readFileSync(shim.logPath, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line)); + assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit')); + assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment')); + + const stored = await readStore(dbPath); + const epicItem = stored.items.find(item => item.source === 'github-epic'); + assert.ok(epicItem, 'expected github epic work item'); + assert.strictEqual(epicItem.metadata.coordination.status, 'ready'); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(error => { + console.error(error); + process.exit(1); +}); From d4486a7a29dab19eae2b225239972163cc059bce Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 14:05:42 -0400 Subject: [PATCH 2/6] refactor: apply code-review findings to github-native coordination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/github-coordination.js: - parseArgs: replace 13-entry if/else chain with BOOL_FLAGS/VALUE_FLAGS lookup maps; shrinks from 119 to ~45 lines - Extract dispatchCommand(options, ctx) and formatOutput(payload, options) from main(); main() shrinks to ~20 lines scripts/lib/github-coordination.js: - Split 1041-line monolith into 6 focused sub-modules under scripts/lib/github-coordination/ (policy, parsing, gh-api, state, actions, store); index becomes a thin re-export (~55 lines) - Document ECC_GH_SHIM trust boundary in runGh() (gh-api.js) - Document applyClaim() read→check→write race condition (actions.js) tests/lib/github-coordination.test.js: - Refactor runTests() to data-driven DESCRIPTORS array + runGroup() helper; runTests() shrinks to ~10 lines - Add 5 new edge-case tests: normalizeRepo('') and normalizeRepo(' ') throw, desiredLabelsForState for blocked/ready statuses, and buildIssueStateFromAction for validate action (15 → 20 tests) tests/scripts/github-coordination.test.js: - Replace console.log in test runner with process.stdout.write Co-Authored-By: Claude Sonnet 4.6 --- scripts/github-coordination.js | 265 ++--- scripts/lib/github-coordination.js | 1087 +------------------- scripts/lib/github-coordination/actions.js | 357 +++++++ scripts/lib/github-coordination/gh-api.js | 175 ++++ scripts/lib/github-coordination/parsing.js | 141 +++ scripts/lib/github-coordination/policy.js | 107 ++ scripts/lib/github-coordination/state.js | 246 +++++ scripts/lib/github-coordination/store.js | 65 ++ tests/lib/github-coordination.test.js | 489 +++++---- tests/scripts/github-coordination.test.js | 10 +- 10 files changed, 1513 insertions(+), 1429 deletions(-) create mode 100644 scripts/lib/github-coordination/actions.js create mode 100644 scripts/lib/github-coordination/gh-api.js create mode 100644 scripts/lib/github-coordination/parsing.js create mode 100644 scripts/lib/github-coordination/policy.js create mode 100644 scripts/lib/github-coordination/state.js create mode 100644 scripts/lib/github-coordination/store.js diff --git a/scripts/github-coordination.js b/scripts/github-coordination.js index ddc065bd..3907a212 100644 --- a/scripts/github-coordination.js +++ b/scripts/github-coordination.js @@ -55,25 +55,37 @@ function readValue(args, index, flagName) { return value; } +// Boolean flags: map flag string → setter(parsed) +const BOOL_FLAGS = new Map([ + ['--help', p => { p.help = true; }], + ['-h', p => { p.help = true; }], + ['--json', p => { p.json = true; }], + ['--dry-run', p => { p.dryRun = true; }], +]); + +// Value flags: map flag string → setter(parsed, value) +const VALUE_FLAGS = new Map([ + ['--repo', (p, v) => { p.repo = v; }], + ['--actor', (p, v) => { p.actor = v; }], + ['--branch', (p, v) => { p.branch = v; }], + ['--config', (p, v) => { p.configPath = v; }], + ['--db', (p, v) => { p.dbPath = v; }], + ['--home', (p, v) => { p.homeDir = v; }], + ['--validation', (p, v) => { p.validation = v; }], + ['--review', (p, v) => { p.review = v; }], + ['--status', (p, v) => { p.status = v; }], + ['--project-state', (p, v) => { p.projectState = v; }], + ['--issue', (p, v) => { p.issueNumber = normalizeIssueNumber(v); }], + ['--limit', (p, v) => { p.limit = normalizeIssueNumber(v); }], +]); + function parseArgs(argv) { const args = argv.slice(2); const parsed = { - command: null, - actor: null, - branch: null, - configPath: null, - dbPath: null, - dryRun: false, - help: false, - homeDir: null, - issueNumber: null, - json: false, - limit: 100, - repo: null, - validation: null, - review: null, - status: null, - projectState: null, + command: null, actor: null, branch: null, configPath: null, + dbPath: null, dryRun: false, help: false, homeDir: null, + issueNumber: null, json: false, limit: 100, repo: null, + validation: null, review: null, status: null, projectState: null, positionals: [], }; @@ -81,94 +93,21 @@ function parseArgs(argv) { parsed.command = args.shift(); } - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - - if (arg === '--help' || arg === '-h') { - parsed.help = true; - continue; - } - if (arg === '--json') { - parsed.json = true; - continue; - } - if (arg === '--dry-run') { - parsed.dryRun = true; - continue; - } - if (arg === '--repo') { - parsed.repo = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--issue') { - parsed.issueNumber = normalizeIssueNumber(readValue(args, index, arg)); - index += 1; - continue; - } - if (arg === '--actor') { - parsed.actor = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--branch') { - parsed.branch = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--config') { - parsed.configPath = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--db') { - parsed.dbPath = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--home') { - parsed.homeDir = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--limit') { - parsed.limit = normalizeIssueNumber(readValue(args, index, arg)); - index += 1; - continue; - } - if (arg === '--validation') { - parsed.validation = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--review') { - parsed.review = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--status') { - parsed.status = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--project-state') { - parsed.projectState = readValue(args, index, arg); - index += 1; - continue; - } - - if (!arg.startsWith('-')) { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (BOOL_FLAGS.has(arg)) { + BOOL_FLAGS.get(arg)(parsed); + } else if (VALUE_FLAGS.has(arg)) { + VALUE_FLAGS.get(arg)(parsed, readValue(args, i, arg)); + i += 1; + } else if (!arg.startsWith('-')) { parsed.positionals.push(arg); - continue; + } else { + throw new Error(`Unknown argument: ${arg}`); } - - throw new Error(`Unknown argument: ${arg}`); - } - - if (!parsed.command) { - parsed.command = 'sync'; } + if (!parsed.command) parsed.command = 'sync'; if (!parsed.issueNumber && parsed.positionals.length > 0) { parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]); } @@ -176,18 +115,59 @@ function parseArgs(argv) { return parsed; } +function dispatchCommand(options, ctx) { + const { store, policy, rootDir } = ctx; + const base = { configPath: options.configPath, dryRun: options.dryRun }; + + if (options.command === 'claim') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyClaim(options.repo, options.issueNumber, { + ...base, actor: options.actor, branch: options.branch, owner: options.actor, + projectState: options.projectState, review: options.review, + status: options.status, validation: options.validation, + }, { store, policy, rootDir }); + } + if (options.command === 'sync') { + return applySync(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir }); + } + if (options.command === 'validate') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyValidate(options.repo, options.issueNumber, base, { store, policy, rootDir }); + } + if (options.command === 'publish') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyPublish(options.repo, options.issueNumber, base, { store, policy, rootDir }); + } + if (options.command === 'review') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyReview(options.repo, options.issueNumber, { ...base, review: options.review }, { store, policy, rootDir }); + } + if (options.command === 'unblock') { + return applyUnblock(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir }); + } + if (options.command === 'decompose') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyDecompose(options.repo, options.issueNumber, base, { store, policy, rootDir }); + } + throw new Error(`Unknown command: ${options.command}`); +} + +function formatOutput(payload, options) { + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else if (options.command === 'sync' || options.command === 'unblock') { + process.stdout.write(formatCollection(payload)); + } else { + process.stdout.write(formatSummary(payload)); + } +} + async function main() { let store = null; - try { const options = parseArgs(process.argv); - if (options.help) { - usage(0); - } - - if (!options.repo) { - throw new Error('Missing --repo .'); - } + if (options.help) usage(0); + if (!options.repo) throw new Error('Missing --repo .'); const policy = loadPolicy(process.cwd(), options.configPath); store = await openStore({ @@ -195,76 +175,13 @@ async function main() { homeDir: options.homeDir || process.env.HOME || os.homedir(), }); - let payload; - - if (options.command === 'claim') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyClaim(options.repo, options.issueNumber, { - actor: options.actor, - branch: options.branch, - configPath: options.configPath, - dryRun: options.dryRun, - owner: options.actor, - projectState: options.projectState, - review: options.review, - status: options.status, - validation: options.validation, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'sync') { - payload = applySync(options.repo, { - configPath: options.configPath, - dryRun: options.dryRun, - limit: options.limit, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'validate') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyValidate(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'publish') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyPublish(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'review') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyReview(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - review: options.review, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'unblock') { - payload = applyUnblock(options.repo, { - configPath: options.configPath, - dryRun: options.dryRun, - limit: options.limit, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'decompose') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyDecompose(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - }, { store, policy, rootDir: process.cwd() }); - } else { - throw new Error(`Unknown command: ${options.command}`); - } - - if (options.json) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else if (options.command === 'sync' || options.command === 'unblock') { - process.stdout.write(formatCollection(payload)); - } else { - process.stdout.write(formatSummary(payload)); - } + const payload = dispatchCommand(options, { store, policy, rootDir: process.cwd() }); + formatOutput(payload, options); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } finally { - if (store) { - store.close(); - } + if (store) store.close(); } } diff --git a/scripts/lib/github-coordination.js b/scripts/lib/github-coordination.js index 340b93cb..a388e791 100644 --- a/scripts/lib/github-coordination.js +++ b/scripts/lib/github-coordination.js @@ -1,1040 +1,57 @@ 'use strict'; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { spawnSync } = require('child_process'); - -const { createStateStore } = require('./state-store'); - -const DEFAULT_CONFIG_FILE = 'github-native-coordination.json'; -const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', 'config', DEFAULT_CONFIG_FILE); -const DEFAULT_SECTION_MARKER = 'ecc-coordination'; -const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1'; -const DEFAULT_LABELS = Object.freeze({ - epic: 'epic', - available: 'coordination:available', - claimed: 'coordination:claimed', - ready: 'coordination:ready', - blocked: 'coordination:blocked', - validated: 'coordination:validated', - reviewRequested: 'coordination:review-requested', - reviewApproved: 'coordination:review-approved', - reviewChangesRequested: 'coordination:review-changes-requested', - published: 'coordination:published', - synced: 'coordination:synced', -}); -const DEFAULT_POLICY = Object.freeze({ - schemaVersion: DEFAULT_SCHEMA_VERSION, - sectionMarker: DEFAULT_SECTION_MARKER, - labels: DEFAULT_LABELS, - review: { - required: true, - defaultMode: 'required', - }, - validation: { - required: true, - }, - branchModel: { - epicOnly: true, - taskBranches: false, - }, - project: { - enabled: false, - fieldNames: { - status: 'Status', - owner: 'Owner', - branch: 'Branch', - validation: 'Validation', - review: 'Review', - }, - }, -}); - -function normalizeRepo(repo) { - const parts = String(repo || '').split('/').filter(Boolean); - if (parts.length !== 2) { - throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`); - } - const [owner, name] = parts; - return { owner, name }; -} - -function slugifySegment(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'unknown'; -} - -function epicWorkItemId(repo, issueNumber) { - return `github-${slugifySegment(repo)}-epic-${issueNumber}`; -} - -function runCommand(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd, - env: options.env || process.env, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - }); - - if (result.error) { - throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); - } - - if (result.status !== 0) { - throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`); - } - - return result.stdout || ''; -} - -function runGh(args, options = {}) { - const shimPath = process.env.ECC_GH_SHIM; - const command = shimPath ? process.execPath : 'gh'; - const commandArgs = shimPath ? [shimPath, ...args] : args; - const env = { ...process.env }; - - if (!options.useEnvGithubToken) { - delete env.GITHUB_TOKEN; - } - - return runCommand(command, commandArgs, { cwd: options.cwd, env }); -} - -function runGhJson(args, options = {}) { - try { - return JSON.parse(runGh(args, options) || 'null'); - } catch (error) { - throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); - } -} - -function normalizeLabelValue(label) { - if (typeof label === 'string') { - return label.trim(); - } - if (label && typeof label === 'object') { - return String(label.name || label.label || '').trim(); - } - return ''; -} - -function normalizeLabels(labels) { - return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort(); -} - -function normalizeIssueNumber(value) { - const parsed = Number.parseInt(String(value), 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`Invalid issue number: ${value}`); - } - return parsed; -} - -function loadPolicy(rootDir = process.cwd(), configPath = null) { - const resolvedPath = configPath - ? path.resolve(configPath) - : path.join(rootDir, 'config', DEFAULT_CONFIG_FILE); - - if (!fs.existsSync(resolvedPath)) { - return { - ...DEFAULT_POLICY, - sourcePath: null, - }; - } - - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); - } catch (error) { - throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`); - } - return { - ...DEFAULT_POLICY, - ...parsed, - labels: { - ...DEFAULT_LABELS, - ...(parsed.labels || {}), - }, - review: { - ...DEFAULT_POLICY.review, - ...(parsed.review || {}), - }, - validation: { - ...DEFAULT_POLICY.validation, - ...(parsed.validation || {}), - }, - branchModel: { - ...DEFAULT_POLICY.branchModel, - ...(parsed.branchModel || {}), - }, - project: { - ...DEFAULT_POLICY.project, - ...(parsed.project || {}), - fieldNames: { - ...DEFAULT_POLICY.project.fieldNames, - ...((parsed.project || {}).fieldNames || {}), - }, - }, - sourcePath: resolvedPath, - }; -} - -function escapeRegExp(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function normalizeBodyForComparison(body) { - return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED'); -} - -function extractCoordinationState(body, policy = DEFAULT_POLICY) { - const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); - const regex = new RegExp( - `\\s*` + - '```json\\s*([\\s\\S]*?)\\s*```' + - `\\s*`, - 'm' - ); - const match = String(body || '').match(regex); - - if (!match) { - return null; - } - - try { - const parsed = JSON.parse(match[1]); - return parsed && typeof parsed === 'object' ? parsed : null; - } catch (_error) { - return null; - } -} - -function extractIssueReferences(text) { - const refs = new Set(); - const source = String(text || ''); - for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) { - refs.add(Number.parseInt(match[1], 10)); - } - return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b); -} - -function extractTasks(body) { - const lines = String(body || '').split(/\r?\n/); - const tasks = []; - let inTasks = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) { - inTasks = true; - continue; - } - if (inTasks && /^#{2,3}\s+\S/.test(line)) { - break; - } - if (inTasks) { - const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i); - if (taskMatch) { - tasks.push({ - title: taskMatch[2].trim(), - done: taskMatch[1].toLowerCase() === 'x', - }); - } - } - } - - return tasks; -} - -function parseStringList(value) { - if (!value) { - return []; - } - - return String(value) - .split(',') - .map(part => part.trim()) - .filter(Boolean); -} - -function defaultCoordinationState(issue, policy = DEFAULT_POLICY) { - return { - schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - kind: 'epic', - status: 'available', - owner: issue && issue.author && issue.author.login ? issue.author.login : null, - branch: null, - validation: 'pending', - review: 'not-requested', - project: { - state: 'backlog', - fields: {}, - }, - dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''), - tasks: extractTasks(issue && issue.body ? issue.body : ''), - labels: normalizeLabels(issue && issue.labels), - lastAction: 'sync', - lastActionAt: new Date().toISOString(), - lastSyncAt: new Date().toISOString(), - notes: null, - }; -} - -function getCoordinationState(issue, policy = DEFAULT_POLICY) { - const existing = extractCoordinationState(issue && issue.body, policy); - if (existing) { - return { - ...defaultCoordinationState(issue, policy), - ...existing, - project: { - ...defaultCoordinationState(issue, policy).project, - ...(existing.project || {}), - }, - tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''), - dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''), - labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels), - }; - } - return defaultCoordinationState(issue, policy); -} - -function renderCoordinationState(state, policy = DEFAULT_POLICY) { - const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER; - const payload = { - schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - kind: state.kind || 'epic', - status: state.status || 'available', - owner: state.owner || null, - branch: state.branch || null, - validation: state.validation || 'pending', - review: state.review || 'not-requested', - project: state.project || { state: 'backlog', fields: {} }, - dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], - tasks: Array.isArray(state.tasks) ? state.tasks : [], - labels: Array.isArray(state.labels) ? state.labels : [], - lastAction: state.lastAction || 'sync', - lastActionAt: state.lastActionAt || new Date().toISOString(), - lastSyncAt: state.lastSyncAt || new Date().toISOString(), - notes: state.notes || null, - }; - - return [ - ``, - '```json', - JSON.stringify(payload, null, 2), - '```', - ``, - ].join('\n'); -} - -function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) { - const body = String(issue.body || ''); - const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); - const rendered = renderCoordinationState(nextState, policy); - const regex = new RegExp( - `\\n?[\\s\\S]*?\\n?`, - 'm' - ); - - if (regex.test(body)) { - return body.replace(regex, `\n${rendered}\n`).trim() + '\n'; - } - - const trimmed = body.trimEnd(); - if (!trimmed) { - return `${rendered}\n`; - } - return `${trimmed}\n\n${rendered}\n`; -} - -function buildIssueComment(action, repo, issueNumber, state, extra = {}) { - const summary = [ - `ECC coordination ${action}`, - `Repo: ${repo}`, - `Issue: #${issueNumber}`, - `Status: ${state.status}`, - `Owner: ${state.owner || '(unassigned)'}`, - `Branch: ${state.branch || '(none)'}`, - `Validation: ${state.validation || 'pending'}`, - `Review: ${state.review || 'not-requested'}`, - ]; - - for (const [key, value] of Object.entries(extra)) { - summary.push(`${key}: ${value}`); - } - - summary.push('', 'This comment is part of the append-only coordination audit trail.'); - return summary.join('\n'); -} - -function mapStateToWorkItemStatus(state) { - switch (state) { - case 'blocked': - return 'blocked'; - case 'published': - return 'done'; - case 'validated': - case 'reviewing': - case 'claimed': - case 'ready': - return 'in-progress'; - case 'changes-requested': - return 'needs-review'; - case 'available': - default: - return 'open'; - } -} - -function summarizeProjectProjection(state, policy = DEFAULT_POLICY) { - return { - enabled: Boolean(policy.project && policy.project.enabled), - state: state.project && state.project.state ? state.project.state : 'backlog', - fields: { - ...(state.project && state.project.fields ? state.project.fields : {}), - }, - }; -} - -function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) { - if (!store) { - return null; - } - - const now = new Date().toISOString(); - const metadata = { - schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION, - repo, - issueNumber: issue.number, - issueUrl: issue.url || null, - issueTitle: issue.title || null, - labels: normalizeLabels(issue.labels), - coordination: state, - projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY), - action, - actionAt: now, - syncedBy: 'ecc-github-coordination', - }; - - return store.upsertWorkItem({ - id: epicWorkItemId(repo, issue.number), - source: 'github-epic', - sourceId: String(issue.number), - title: `Epic #${issue.number}: ${issue.title}`, - status: mapStateToWorkItemStatus(state.status), - priority: state.status === 'blocked' ? 'high' : 'normal', - url: issue.url || null, - owner: state.owner || (issue.author && issue.author.login) || null, - repoRoot: options.repoRoot || process.cwd(), - sessionId: options.sessionId || null, - metadata, - updatedAt: now, - }); -} - -function getIssue(repo, issueNumber, options = {}) { - const { owner, name } = normalizeRepo(repo); - const json = runGhJson([ - 'issue', - 'view', - String(issueNumber), - '--repo', - `${owner}/${name}`, - '--json', - 'number,title,body,url,state,labels,author,updatedAt,assignees', - ], options); - - if (!json) { - throw new Error(`Unable to load issue #${issueNumber} from ${repo}`); - } - - return json; -} - -function listIssues(repo, options = {}) { - const { owner, name } = normalizeRepo(repo); - const limit = Number.isFinite(options.limit) ? options.limit : 100; - const state = options.state || 'all'; - return runGhJson([ - 'issue', - 'list', - '--repo', - `${owner}/${name}`, - '--state', - state, - '--limit', - String(limit), - '--json', - 'number,title,body,url,state,labels,author,updatedAt,assignees', - ], options) || []; -} - -function editIssue(repo, issueNumber, options = {}) { - const { owner, name } = normalizeRepo(repo); - const args = [ - 'issue', - 'edit', - String(issueNumber), - '--repo', - `${owner}/${name}`, - ]; - - if (options.body !== undefined) { - args.push('--body', options.body); - } - - for (const label of options.addLabels || []) { - args.push('--add-label', label); - } - - for (const label of options.removeLabels || []) { - args.push('--remove-label', label); - } - - if (options.title) { - args.push('--title', options.title); - } - - if (options.assignee) { - args.push('--add-assignee', options.assignee); - } - - return runGh(args, options); -} - -function commentIssue(repo, issueNumber, body, options = {}) { - const { owner, name } = normalizeRepo(repo); - return runGh([ - 'issue', - 'comment', - String(issueNumber), - '--repo', - `${owner}/${name}`, - '--body', - body, - ], options); -} - -function findIssueByNumber(issues, issueNumber) { - return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null; -} - -function desiredLabelsForState(state, policy = DEFAULT_POLICY) { - const labels = []; - const known = policy.labels || DEFAULT_LABELS; - - labels.push(known.epic); - labels.push(known.synced); - - if (state.status === 'available') labels.push(known.available); - if (state.status === 'claimed') labels.push(known.claimed); - if (state.status === 'ready') labels.push(known.ready); - if (state.status === 'blocked') labels.push(known.blocked); - if (state.validation === 'passed') labels.push(known.validated); - if (state.review === 'requested') labels.push(known.reviewRequested); - if (state.review === 'approved') labels.push(known.reviewApproved); - if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested); - if (state.status === 'published') labels.push(known.published); - - return Array.from(new Set(labels.filter(Boolean))).sort(); -} - -function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) { - const desired = new Set(desiredLabelsForState(state, policy)); - const current = new Set(normalizeLabels(issue.labels)); - const addLabels = Array.from(desired).filter(label => !current.has(label)); - const removeLabels = Array.from(current).filter(label => { - if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) { - return false; - } - return !desired.has(label); - }); - - if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) { - return { addLabels, removeLabels }; - } - - if (addLabels.length > 0 || removeLabels.length > 0) { - editIssue(repo, issue.number, { ...options, addLabels, removeLabels }); - } - - return { addLabels, removeLabels }; -} - -function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) { - const now = new Date().toISOString(); - const next = { - ...currentState, - schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - kind: 'epic', - lastAction: action, - lastActionAt: now, - lastSyncAt: now, - labels: normalizeLabels(issue.labels), - dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body), - tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body), - }; - - if (options.owner !== undefined) { - next.owner = options.owner; - } - if (options.branch !== undefined) { - next.branch = options.branch; - } - if (options.validation !== undefined) { - next.validation = options.validation; - } - if (options.review !== undefined) { - next.review = options.review; - } - if (options.status !== undefined) { - next.status = options.status; - } - if (options.projectState !== undefined) { - next.project = { - ...(next.project || {}), - state: options.projectState, - }; - } - if (options.notes !== undefined) { - next.notes = options.notes; - } - if (options.tasks !== undefined) { - next.tasks = options.tasks; - } - if (options.dependencies !== undefined) { - next.dependencies = options.dependencies; - } - - return next; -} - -function assertIssueClaimable(issue, state) { - if (String(issue.state || '').toLowerCase() !== 'open') { - throw new Error(`Issue #${issue.number} is not open`); - } - - if (state.status === 'claimed' && state.owner) { - throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`); - } -} - -function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) { - if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) { - return []; - } - - const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 }); - const closed = []; - for (const dependencyNumber of dependencyNumbers) { - const issue = findIssueByNumber(issueList, dependencyNumber); - if (!issue) { - process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`); - } else if (String(issue.state || '').toLowerCase() === 'closed') { - closed.push(dependencyNumber); - } - } - - return closed; -} - -function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) { - return { - schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - repo, - issueNumber: issue.number, - issueUrl: issue.url || null, - issueTitle: issue.title, - action, - status: state.status, - owner: state.owner || null, - branch: state.branch || null, - validation: state.validation || 'pending', - review: state.review || 'not-requested', - project: summarizeProjectProjection(state, policy), - dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], - tasks: Array.isArray(state.tasks) ? state.tasks : [], - labels: normalizeLabels(issue.labels), - workItemId: epicWorkItemId(repo, issue.number), - lastActionAt: state.lastActionAt || null, - lastSyncAt: state.lastSyncAt || null, - }; -} - -function applyClaim(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const store = context.store || null; - const issue = getIssue(repo, issueNumber, options); - const currentState = getCoordinationState(issue, policy); - - assertIssueClaimable(issue, currentState); - - const nextState = buildIssueStateFromAction(issue, currentState, 'claim', { - owner: options.actor || options.owner || currentState.owner || issue.author?.login || null, - branch: options.branch || currentState.branch || null, - status: options.status || 'claimed', - validation: options.validation || currentState.validation || 'pending', - review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'), - projectState: options.projectState || 'in-progress', - }, policy); - - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - const body = mergeIssueBody(issue, nextState, policy); - if (!options.dryRun) { - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options); - upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy }); - } - - return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy); -} - -function applySync(repo, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const store = context.store || null; - const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 }); - const syncedAt = new Date().toISOString(); - const results = []; - - for (const issue of issues) { - const currentState = getCoordinationState(issue, policy); - const nextState = buildIssueStateFromAction(issue, currentState, 'sync', { - status: currentState.status, - validation: currentState.validation, - review: currentState.review, - projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog', - }, policy); - - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - const body = mergeIssueBody(issue, nextState, policy); - const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); - - if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) { - editIssue(repo, issue.number, { - body, - addLabels: labelPlan.addLabels, - removeLabels: labelPlan.removeLabels, - }, options); - } - - const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); - results.push({ - ...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy), - syncedAt, - labelPlan, - snapshot: snapshot || null, - }); - } - - return { - repo, - syncedAt, - count: results.length, - items: results, - }; -} - -function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = existingIssue || getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; - const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options); - const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number)); - const validations = []; - - if (policy.validation.required && state.validation !== 'passed') { - validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` }); - } else { - validations.push({ check: 'validation-status', ok: true, detail: state.validation }); - } - - if (missingDependencies.length > 0) { - validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') }); - } else { - validations.push({ check: 'dependencies', ok: true, detail: 'closed' }); - } - - const ok = validations.every(entry => entry.ok); - const nextState = buildIssueStateFromAction(issue, state, 'validate', { - status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status, - validation: ok ? 'passed' : 'failed', - projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy }); - } - - return { - ...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy), - ok, - validations, - missingDependencies, - }; -} - -function applyPublish(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue); - - if (!validation.ok) { - throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`); - } - - const nextState = buildIssueStateFromAction(issue, state, 'publish', { - status: 'published', - validation: 'passed', - review: state.review === 'changes-requested' ? state.review : 'approved', - projectState: 'done', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, { - validation: 'passed', - }), options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy }); - } - - return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy); -} - -function applyReview(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const reviewState = options.review || 'approved'; - const nextState = buildIssueStateFromAction(issue, state, 'review', { - status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked', - review: reviewState, - projectState: reviewState === 'approved' ? 'ready' : 'blocked', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, { - review: reviewState, - }), options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy }); - } - - return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy); -} - -function applyDecompose(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const tasks = extractTasks(issue.body); - const dependencies = extractIssueReferences(issue.body); - const nextState = buildIssueStateFromAction(issue, state, 'decompose', { - tasks, - dependencies, - status: tasks.some(task => !task.done) ? 'claimed' : state.status, - projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, { - taskCount: String(tasks.length), - dependencyCount: String(dependencies.length), - }), options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy }); - } - - return { - ...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy), - tasks, - dependencyCount: dependencies.length, - }; -} - -function applyUnblock(repo, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const store = context.store || null; - const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 }); - const results = []; - - for (const issue of issues) { - const state = getCoordinationState(issue, policy); - if (state.status !== 'blocked') { - continue; - } - - const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; - const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues); - if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) { - continue; - } - - const nextState = buildIssueStateFromAction(issue, state, 'unblock', { - status: 'ready', - projectState: 'ready', - validation: state.validation === 'failed' ? 'pending' : state.validation, - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issue.number, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, { - dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none', - }), options); - upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy }); - } - - results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy)); - } - - return { - repo, - count: results.length, - items: results, - }; -} - -function formatSummary(payload) { - const lines = [ - `${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`, - `Repo: ${payload.repo}`, - `Status: ${payload.status}`, - `Owner: ${payload.owner || '(unassigned)'}`, - `Branch: ${payload.branch || '(none)'}`, - `Validation: ${payload.validation || 'pending'}`, - `Review: ${payload.review || 'not-requested'}`, - ]; - if (payload.tasks && payload.tasks.length > 0) { - lines.push(`Tasks: ${payload.tasks.length}`); - } - if (payload.dependencies && payload.dependencies.length > 0) { - lines.push(`Dependencies: ${payload.dependencies.join(', ')}`); - } - return `${lines.join('\n')}\n`; -} - -function formatCollection(payload) { - const lines = [ - `Repo: ${payload.repo}`, - `Items: ${payload.count}`, - ]; - for (const item of payload.items || []) { - lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`); - } - return `${lines.join('\n')}\n`; -} - -async function openStore(options = {}) { - if (options.dbPath === false) { - return null; - } - - return createStateStore({ - dbPath: options.dbPath, - homeDir: options.homeDir || process.env.HOME || os.homedir(), - }); -} +const policy = require('./github-coordination/policy'); +const parsing = require('./github-coordination/parsing'); +const ghApi = require('./github-coordination/gh-api'); +const state = require('./github-coordination/state'); +const actions = require('./github-coordination/actions'); +const store = require('./github-coordination/store'); module.exports = { - DEFAULT_CONFIG_FILE, - DEFAULT_CONFIG_PATH, - DEFAULT_POLICY, - DEFAULT_SCHEMA_VERSION, - applyClaim, - applyDecompose, - applyPublish, - applyReview, - applySync, - applyUnblock, - applyValidate, - buildIssueComment, - buildIssueStateFromAction, - commentIssue, - defaultCoordinationState, - desiredLabelsForState, - editIssue, - epicWorkItemId, - extractCoordinationState, - extractIssueReferences, - extractTasks, - formatCollection, - formatSummary, - getCoordinationState, - getIssue, - listIssues, - loadPolicy, - mapStateToWorkItemStatus, - mergeIssueBody, - normalizeIssueNumber, - normalizeLabels, - normalizeRepo, - openStore, - renderCoordinationState, - runGh, - runGhJson, - slugifySegment, - syncIssueLabels, - summarizeStateForOutput, - upsertCoordinationWorkItem, - verifyDependenciesClosed, + DEFAULT_CONFIG_FILE: policy.DEFAULT_CONFIG_FILE, + DEFAULT_CONFIG_PATH: policy.DEFAULT_CONFIG_PATH, + DEFAULT_POLICY: policy.DEFAULT_POLICY, + DEFAULT_SCHEMA_VERSION: policy.DEFAULT_SCHEMA_VERSION, + loadPolicy: policy.loadPolicy, + + extractCoordinationState: parsing.extractCoordinationState, + extractIssueReferences: parsing.extractIssueReferences, + extractTasks: parsing.extractTasks, + mergeIssueBody: parsing.mergeIssueBody, + renderCoordinationState: parsing.renderCoordinationState, + + commentIssue: ghApi.commentIssue, + editIssue: ghApi.editIssue, + getIssue: ghApi.getIssue, + listIssues: ghApi.listIssues, + normalizeIssueNumber: ghApi.normalizeIssueNumber, + normalizeLabels: ghApi.normalizeLabels, + normalizeRepo: ghApi.normalizeRepo, + runGh: ghApi.runGh, + runGhJson: ghApi.runGhJson, + + buildIssueComment: state.buildIssueComment, + buildIssueStateFromAction: state.buildIssueStateFromAction, + defaultCoordinationState: state.defaultCoordinationState, + desiredLabelsForState: state.desiredLabelsForState, + getCoordinationState: state.getCoordinationState, + mapStateToWorkItemStatus: state.mapStateToWorkItemStatus, + slugifySegment: state.slugifySegment, + summarizeStateForOutput: state.summarizeStateForOutput, + syncIssueLabels: state.syncIssueLabels, + verifyDependenciesClosed: state.verifyDependenciesClosed, + + applyClaim: actions.applyClaim, + applyDecompose: actions.applyDecompose, + applyPublish: actions.applyPublish, + applyReview: actions.applyReview, + applySync: actions.applySync, + applyUnblock: actions.applyUnblock, + applyValidate: actions.applyValidate, + formatCollection: actions.formatCollection, + formatSummary: actions.formatSummary, + + epicWorkItemId: store.epicWorkItemId, + openStore: store.openStore, + upsertCoordinationWorkItem: store.upsertCoordinationWorkItem, }; diff --git a/scripts/lib/github-coordination/actions.js b/scripts/lib/github-coordination/actions.js new file mode 100644 index 00000000..6ad73b5e --- /dev/null +++ b/scripts/lib/github-coordination/actions.js @@ -0,0 +1,357 @@ +'use strict'; + +const { loadPolicy } = require('./policy'); +const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing'); +const { getIssue, listIssues, editIssue, commentIssue } = require('./gh-api'); +const { + assertIssueClaimable, + buildIssueComment, + buildIssueStateFromAction, + desiredLabelsForState, + getCoordinationState, + summarizeStateForOutput, + syncIssueLabels, + verifyDependenciesClosed, +} = require('./state'); +const { upsertCoordinationWorkItem } = require('./store'); +const { extractIssueReferences, extractTasks } = require('./parsing'); + +// applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write +// (editIssue) sequence that is NOT atomic. Two concurrent callers can both read +// an unclaimed issue, pass the check, and both succeed — resulting in a +// double-claim. Until a store.acquireLock(repo, issueNumber) API is available, +// callers should prevent races via external serialization (e.g. a GitHub +// branch-protection rule that allows only one actor at a time, or a +// serialized job queue). +function applyClaim(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issue = getIssue(repo, issueNumber, options); + const currentState = getCoordinationState(issue, policy); + + assertIssueClaimable(issue, currentState); + + const nextState = buildIssueStateFromAction(issue, currentState, 'claim', { + owner: options.actor || options.owner || currentState.owner || issue.author?.login || null, + branch: options.branch || currentState.branch || null, + status: options.status || 'claimed', + validation: options.validation || currentState.validation || 'pending', + review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'), + projectState: options.projectState || 'in-progress', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + const body = mergeIssueBody(issue, nextState, policy); + if (!options.dryRun) { + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options); + upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy); +} + +function applySync(repo, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 }); + const syncedAt = new Date().toISOString(); + const results = []; + + for (const issue of issues) { + const currentState = getCoordinationState(issue, policy); + const nextState = buildIssueStateFromAction(issue, currentState, 'sync', { + status: currentState.status, + validation: currentState.validation, + review: currentState.review, + projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + const body = mergeIssueBody(issue, nextState, policy); + const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); + + if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) { + editIssue(repo, issue.number, { + body, + addLabels: labelPlan.addLabels, + removeLabels: labelPlan.removeLabels, + }, options); + } + + const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); + results.push({ + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy), + syncedAt, + labelPlan, + snapshot: snapshot || null, + }); + } + + return { + repo, + syncedAt, + count: results.length, + items: results, + }; +} + +function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = existingIssue || getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; + const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options); + const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number)); + const validations = []; + + if (policy.validation.required && state.validation !== 'passed') { + validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` }); + } else { + validations.push({ check: 'validation-status', ok: true, detail: state.validation }); + } + + if (missingDependencies.length > 0) { + validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') }); + } else { + validations.push({ check: 'dependencies', ok: true, detail: 'closed' }); + } + + const ok = validations.every(entry => entry.ok); + const nextState = buildIssueStateFromAction(issue, state, 'validate', { + status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status, + validation: ok ? 'passed' : 'failed', + projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy }); + } + + return { + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy), + ok, + validations, + missingDependencies, + }; +} + +function applyPublish(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue); + + if (!validation.ok) { + throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`); + } + + const nextState = buildIssueStateFromAction(issue, state, 'publish', { + status: 'published', + validation: 'passed', + review: state.review === 'changes-requested' ? state.review : 'approved', + projectState: 'done', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, { + validation: 'passed', + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy); +} + +function applyReview(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const reviewState = options.review || 'approved'; + const nextState = buildIssueStateFromAction(issue, state, 'review', { + status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked', + review: reviewState, + projectState: reviewState === 'approved' ? 'ready' : 'blocked', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, { + review: reviewState, + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy); +} + +function applyDecompose(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const tasks = extractTasks(issue.body); + const dependencies = extractIssueReferences(issue.body); + const nextState = buildIssueStateFromAction(issue, state, 'decompose', { + tasks, + dependencies, + status: tasks.some(task => !task.done) ? 'claimed' : state.status, + projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, { + taskCount: String(tasks.length), + dependencyCount: String(dependencies.length), + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy }); + } + + return { + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy), + tasks, + dependencyCount: dependencies.length, + }; +} + +function applyUnblock(repo, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 }); + const results = []; + + for (const issue of issues) { + const state = getCoordinationState(issue, policy); + if (state.status !== 'blocked') { + continue; + } + + const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; + const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues); + if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) { + continue; + } + + const nextState = buildIssueStateFromAction(issue, state, 'unblock', { + status: 'ready', + projectState: 'ready', + validation: state.validation === 'failed' ? 'pending' : state.validation, + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issue.number, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, { + dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none', + }), options); + upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy }); + } + + results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy)); + } + + return { + repo, + count: results.length, + items: results, + }; +} + +function formatSummary(payload) { + const lines = [ + `${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`, + `Repo: ${payload.repo}`, + `Status: ${payload.status}`, + `Owner: ${payload.owner || '(unassigned)'}`, + `Branch: ${payload.branch || '(none)'}`, + `Validation: ${payload.validation || 'pending'}`, + `Review: ${payload.review || 'not-requested'}`, + ]; + if (payload.tasks && payload.tasks.length > 0) { + lines.push(`Tasks: ${payload.tasks.length}`); + } + if (payload.dependencies && payload.dependencies.length > 0) { + lines.push(`Dependencies: ${payload.dependencies.join(', ')}`); + } + return `${lines.join('\n')}\n`; +} + +function formatCollection(payload) { + const lines = [ + `Repo: ${payload.repo}`, + `Items: ${payload.count}`, + ]; + for (const item of payload.items || []) { + lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`); + } + return `${lines.join('\n')}\n`; +} + +module.exports = { + applyClaim, + applyDecompose, + applyPublish, + applyReview, + applySync, + applyUnblock, + applyValidate, + formatCollection, + formatSummary, +}; diff --git a/scripts/lib/github-coordination/gh-api.js b/scripts/lib/github-coordination/gh-api.js new file mode 100644 index 00000000..98f15560 --- /dev/null +++ b/scripts/lib/github-coordination/gh-api.js @@ -0,0 +1,175 @@ +'use strict'; + +const { spawnSync } = require('child_process'); + +function normalizeRepo(repo) { + const parts = String(repo || '').split('/').filter(Boolean); + if (parts.length !== 2) { + throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`); + } + const [owner, name] = parts; + return { owner, name }; +} + +function normalizeIssueNumber(value) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid issue number: ${value}`); + } + return parsed; +} + +function normalizeLabelValue(label) { + if (typeof label === 'string') { + return label.trim(); + } + if (label && typeof label === 'object') { + return String(label.name || label.label || '').trim(); + } + return ''; +} + +function normalizeLabels(labels) { + return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort(); +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env || process.env, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`); + } + + return result.stdout || ''; +} + +// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real +// `gh` binary and command/commandArgs execute an arbitrary script via +// process.execPath. This variable MUST only be set in trusted, isolated test +// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in +// production — doing so allows arbitrary script execution under the caller's +// privileges. +function runGh(args, options = {}) { + const shimPath = process.env.ECC_GH_SHIM; + const command = shimPath ? process.execPath : 'gh'; + const commandArgs = shimPath ? [shimPath, ...args] : args; + const env = { ...process.env }; + + if (!options.useEnvGithubToken) { + delete env.GITHUB_TOKEN; + } + + return runCommand(command, commandArgs, { cwd: options.cwd, env }); +} + +function runGhJson(args, options = {}) { + try { + return JSON.parse(runGh(args, options) || 'null'); + } catch (error) { + throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); + } +} + +function getIssue(repo, issueNumber, options = {}) { + const { owner, name } = normalizeRepo(repo); + const json = runGhJson([ + 'issue', + 'view', + String(issueNumber), + '--repo', + `${owner}/${name}`, + '--json', + 'number,title,body,url,state,labels,author,updatedAt,assignees', + ], options); + + if (!json) { + throw new Error(`Unable to load issue #${issueNumber} from ${repo}`); + } + + return json; +} + +function listIssues(repo, options = {}) { + const { owner, name } = normalizeRepo(repo); + const limit = Number.isFinite(options.limit) ? options.limit : 100; + const state = options.state || 'all'; + return runGhJson([ + 'issue', + 'list', + '--repo', + `${owner}/${name}`, + '--state', + state, + '--limit', + String(limit), + '--json', + 'number,title,body,url,state,labels,author,updatedAt,assignees', + ], options) || []; +} + +function editIssue(repo, issueNumber, options = {}) { + const { owner, name } = normalizeRepo(repo); + const args = [ + 'issue', + 'edit', + String(issueNumber), + '--repo', + `${owner}/${name}`, + ]; + + if (options.body !== undefined) { + args.push('--body', options.body); + } + + for (const label of options.addLabels || []) { + args.push('--add-label', label); + } + + for (const label of options.removeLabels || []) { + args.push('--remove-label', label); + } + + if (options.title) { + args.push('--title', options.title); + } + + if (options.assignee) { + args.push('--add-assignee', options.assignee); + } + + return runGh(args, options); +} + +function commentIssue(repo, issueNumber, body, options = {}) { + const { owner, name } = normalizeRepo(repo); + return runGh([ + 'issue', + 'comment', + String(issueNumber), + '--repo', + `${owner}/${name}`, + '--body', + body, + ], options); +} + +module.exports = { + commentIssue, + editIssue, + getIssue, + listIssues, + normalizeIssueNumber, + normalizeLabels, + normalizeRepo, + runGh, + runGhJson, +}; diff --git a/scripts/lib/github-coordination/parsing.js b/scripts/lib/github-coordination/parsing.js new file mode 100644 index 00000000..3b148d21 --- /dev/null +++ b/scripts/lib/github-coordination/parsing.js @@ -0,0 +1,141 @@ +'use strict'; + +const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION, DEFAULT_SECTION_MARKER } = require('./policy'); + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function normalizeBodyForComparison(body) { + return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED'); +} + +function extractCoordinationState(body, policy = DEFAULT_POLICY) { + const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); + const regex = new RegExp( + `\\s*` + + '```json\\s*([\\s\\S]*?)\\s*```' + + `\\s*`, + 'm' + ); + const match = String(body || '').match(regex); + + if (!match) { + return null; + } + + try { + const parsed = JSON.parse(match[1]); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_error) { + return null; + } +} + +function extractIssueReferences(text) { + const refs = new Set(); + const source = String(text || ''); + for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) { + refs.add(Number.parseInt(match[1], 10)); + } + return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b); +} + +function extractTasks(body) { + const lines = String(body || '').split(/\r?\n/); + const tasks = []; + let inTasks = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) { + inTasks = true; + continue; + } + if (inTasks && /^#{2,3}\s+\S/.test(line)) { + break; + } + if (inTasks) { + const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i); + if (taskMatch) { + tasks.push({ + title: taskMatch[2].trim(), + done: taskMatch[1].toLowerCase() === 'x', + }); + } + } + } + + return tasks; +} + +function parseStringList(value) { + if (!value) { + return []; + } + return String(value) + .split(',') + .map(part => part.trim()) + .filter(Boolean); +} + +function renderCoordinationState(state, policy = DEFAULT_POLICY) { + const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER; + const payload = { + schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: state.kind || 'epic', + status: state.status || 'available', + owner: state.owner || null, + branch: state.branch || null, + validation: state.validation || 'pending', + review: state.review || 'not-requested', + project: state.project || { state: 'backlog', fields: {} }, + dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], + tasks: Array.isArray(state.tasks) ? state.tasks : [], + labels: Array.isArray(state.labels) ? state.labels : [], + lastAction: state.lastAction || 'sync', + lastActionAt: state.lastActionAt || new Date().toISOString(), + lastSyncAt: state.lastSyncAt || new Date().toISOString(), + notes: state.notes || null, + }; + + return [ + ``, + '```json', + JSON.stringify(payload, null, 2), + '```', + ``, + ].join('\n'); +} + +function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) { + const body = String(issue.body || ''); + const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); + const rendered = renderCoordinationState(nextState, policy); + const regex = new RegExp( + `\\n?[\\s\\S]*?\\n?`, + 'm' + ); + + if (regex.test(body)) { + return body.replace(regex, `\n${rendered}\n`).trim() + '\n'; + } + + const trimmed = body.trimEnd(); + if (!trimmed) { + return `${rendered}\n`; + } + + return `${trimmed}\n\n${rendered}\n`; +} + +module.exports = { + escapeRegExp, + extractCoordinationState, + extractIssueReferences, + extractTasks, + mergeIssueBody, + normalizeBodyForComparison, + parseStringList, + renderCoordinationState, +}; diff --git a/scripts/lib/github-coordination/policy.js b/scripts/lib/github-coordination/policy.js new file mode 100644 index 00000000..72fc7cab --- /dev/null +++ b/scripts/lib/github-coordination/policy.js @@ -0,0 +1,107 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_CONFIG_FILE = 'github-native-coordination.json'; +const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', 'config', DEFAULT_CONFIG_FILE); +const DEFAULT_SECTION_MARKER = 'ecc-coordination'; +const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1'; +const DEFAULT_LABELS = Object.freeze({ + epic: 'epic', + available: 'coordination:available', + claimed: 'coordination:claimed', + ready: 'coordination:ready', + blocked: 'coordination:blocked', + validated: 'coordination:validated', + reviewRequested: 'coordination:review-requested', + reviewApproved: 'coordination:review-approved', + reviewChangesRequested: 'coordination:review-changes-requested', + published: 'coordination:published', + synced: 'coordination:synced', +}); +const DEFAULT_POLICY = Object.freeze({ + schemaVersion: DEFAULT_SCHEMA_VERSION, + sectionMarker: DEFAULT_SECTION_MARKER, + labels: DEFAULT_LABELS, + review: { + required: true, + defaultMode: 'required', + }, + validation: { + required: true, + }, + branchModel: { + epicOnly: true, + taskBranches: false, + }, + project: { + enabled: false, + fieldNames: { + status: 'Status', + owner: 'Owner', + branch: 'Branch', + validation: 'Validation', + review: 'Review', + }, + }, +}); + +function loadPolicy(rootDir = process.cwd(), configPath = null) { + const resolvedPath = configPath + ? path.resolve(configPath) + : path.join(rootDir, 'config', DEFAULT_CONFIG_FILE); + + if (!fs.existsSync(resolvedPath)) { + return { + ...DEFAULT_POLICY, + sourcePath: null, + }; + } + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`); + } + return { + ...DEFAULT_POLICY, + ...parsed, + labels: { + ...DEFAULT_LABELS, + ...(parsed.labels || {}), + }, + review: { + ...DEFAULT_POLICY.review, + ...(parsed.review || {}), + }, + validation: { + ...DEFAULT_POLICY.validation, + ...(parsed.validation || {}), + }, + branchModel: { + ...DEFAULT_POLICY.branchModel, + ...(parsed.branchModel || {}), + }, + project: { + ...DEFAULT_POLICY.project, + ...(parsed.project || {}), + fieldNames: { + ...DEFAULT_POLICY.project.fieldNames, + ...((parsed.project || {}).fieldNames || {}), + }, + }, + sourcePath: resolvedPath, + }; +} + +module.exports = { + DEFAULT_CONFIG_FILE, + DEFAULT_CONFIG_PATH, + DEFAULT_LABELS, + DEFAULT_POLICY, + DEFAULT_SCHEMA_VERSION, + DEFAULT_SECTION_MARKER, + loadPolicy, +}; diff --git a/scripts/lib/github-coordination/state.js b/scripts/lib/github-coordination/state.js new file mode 100644 index 00000000..04e8c747 --- /dev/null +++ b/scripts/lib/github-coordination/state.js @@ -0,0 +1,246 @@ +'use strict'; + +const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION } = require('./policy'); +const { extractIssueReferences, extractTasks } = require('./parsing'); +const { normalizeLabels, listIssues, editIssue } = require('./gh-api'); + +function slugifySegment(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unknown'; +} + +function defaultCoordinationState(issue, policy = DEFAULT_POLICY) { + return { + schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: 'epic', + status: 'available', + owner: issue && issue.author && issue.author.login ? issue.author.login : null, + branch: null, + validation: 'pending', + review: 'not-requested', + project: { + state: 'backlog', + fields: {}, + }, + dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''), + tasks: extractTasks(issue && issue.body ? issue.body : ''), + labels: normalizeLabels(issue && issue.labels), + lastAction: 'sync', + lastActionAt: new Date().toISOString(), + lastSyncAt: new Date().toISOString(), + notes: null, + }; +} + +function getCoordinationState(issue, policy = DEFAULT_POLICY) { + const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order + const existing = extractCoordinationState(issue && issue.body, policy); + if (existing) { + return { + ...defaultCoordinationState(issue, policy), + ...existing, + project: { + ...defaultCoordinationState(issue, policy).project, + ...(existing.project || {}), + }, + tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''), + dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''), + labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels), + }; + } + return defaultCoordinationState(issue, policy); +} + +function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) { + const now = new Date().toISOString(); + const next = { + ...currentState, + schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: 'epic', + lastAction: action, + lastActionAt: now, + lastSyncAt: now, + labels: normalizeLabels(issue.labels), + dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body), + tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body), + }; + + if (options.owner !== undefined) next.owner = options.owner; + if (options.branch !== undefined) next.branch = options.branch; + if (options.validation !== undefined) next.validation = options.validation; + if (options.review !== undefined) next.review = options.review; + if (options.status !== undefined) next.status = options.status; + if (options.projectState !== undefined) { + next.project = { ...(next.project || {}), state: options.projectState }; + } + if (options.notes !== undefined) next.notes = options.notes; + if (options.tasks !== undefined) next.tasks = options.tasks; + if (options.dependencies !== undefined) next.dependencies = options.dependencies; + + return next; +} + +function desiredLabelsForState(state, policy = DEFAULT_POLICY) { + const labels = []; + const known = policy.labels || DEFAULT_POLICY.labels; + + labels.push(known.epic); + labels.push(known.synced); + + if (state.status === 'available') labels.push(known.available); + if (state.status === 'claimed') labels.push(known.claimed); + if (state.status === 'ready') labels.push(known.ready); + if (state.status === 'blocked') labels.push(known.blocked); + if (state.validation === 'passed') labels.push(known.validated); + if (state.review === 'requested') labels.push(known.reviewRequested); + if (state.review === 'approved') labels.push(known.reviewApproved); + if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested); + if (state.status === 'published') labels.push(known.published); + + return Array.from(new Set(labels.filter(Boolean))).sort(); +} + +function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) { + const desired = new Set(desiredLabelsForState(state, policy)); + const current = new Set(normalizeLabels(issue.labels)); + const addLabels = Array.from(desired).filter(label => !current.has(label)); + const removeLabels = Array.from(current).filter(label => { + if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) { + return false; + } + return !desired.has(label); + }); + + if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) { + return { addLabels, removeLabels }; + } + + if (addLabels.length > 0 || removeLabels.length > 0) { + editIssue(repo, issue.number, { ...options, addLabels, removeLabels }); + } + + return { addLabels, removeLabels }; +} + +function findIssueByNumber(issues, issueNumber) { + return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null; +} + +function buildIssueComment(action, repo, issueNumber, state, extra = {}) { + const summary = [ + `ECC coordination ${action}`, + `Repo: ${repo}`, + `Issue: #${issueNumber}`, + `Status: ${state.status}`, + `Owner: ${state.owner || '(unassigned)'}`, + `Branch: ${state.branch || '(none)'}`, + `Validation: ${state.validation || 'pending'}`, + `Review: ${state.review || 'not-requested'}`, + ]; + + for (const [key, value] of Object.entries(extra)) { + summary.push(`${key}: ${value}`); + } + + summary.push('', 'This comment is part of the append-only coordination audit trail.'); + return summary.join('\n'); +} + +function mapStateToWorkItemStatus(state) { + switch (state) { + case 'blocked': + return 'blocked'; + case 'published': + return 'done'; + case 'validated': + case 'reviewing': + case 'claimed': + case 'ready': + return 'in-progress'; + case 'changes-requested': + return 'needs-review'; + case 'available': + default: + return 'open'; + } +} + +function summarizeProjectProjection(state, policy = DEFAULT_POLICY) { + return { + enabled: Boolean(policy.project && policy.project.enabled), + state: state.project && state.project.state ? state.project.state : 'backlog', + fields: { + ...(state.project && state.project.fields ? state.project.fields : {}), + }, + }; +} + +function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) { + return { + schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + repo, + issueNumber: issue.number, + issueUrl: issue.url || null, + issueTitle: issue.title, + action, + status: state.status, + owner: state.owner || null, + branch: state.branch || null, + validation: state.validation || 'pending', + review: state.review || 'not-requested', + project: summarizeProjectProjection(state, policy), + dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], + tasks: Array.isArray(state.tasks) ? state.tasks : [], + labels: normalizeLabels(issue.labels), + workItemId: `github-${slugifySegment(repo)}-epic-${issue.number}`, + lastActionAt: state.lastActionAt || null, + lastSyncAt: state.lastSyncAt || null, + }; +} + +function assertIssueClaimable(issue, state) { + if (String(issue.state || '').toLowerCase() !== 'open') { + throw new Error(`Issue #${issue.number} is not open`); + } + + if (state.status === 'claimed' && state.owner) { + throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`); + } +} + +function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) { + if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) { + return []; + } + + const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 }); + const closed = []; + for (const dependencyNumber of dependencyNumbers) { + const issue = findIssueByNumber(issueList, dependencyNumber); + if (!issue) { + process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`); + } else if (String(issue.state || '').toLowerCase() === 'closed') { + closed.push(dependencyNumber); + } + } + + return closed; +} + +module.exports = { + assertIssueClaimable, + buildIssueComment, + buildIssueStateFromAction, + defaultCoordinationState, + desiredLabelsForState, + findIssueByNumber, + getCoordinationState, + mapStateToWorkItemStatus, + slugifySegment, + summarizeProjectProjection, + summarizeStateForOutput, + syncIssueLabels, + verifyDependenciesClosed, +}; diff --git a/scripts/lib/github-coordination/store.js b/scripts/lib/github-coordination/store.js new file mode 100644 index 00000000..cc337102 --- /dev/null +++ b/scripts/lib/github-coordination/store.js @@ -0,0 +1,65 @@ +'use strict'; + +const os = require('os'); + +const { createStateStore } = require('../state-store'); +const { DEFAULT_SCHEMA_VERSION, DEFAULT_POLICY } = require('./policy'); +const { normalizeLabels } = require('./gh-api'); +const { slugifySegment, mapStateToWorkItemStatus, summarizeProjectProjection } = require('./state'); + +function epicWorkItemId(repo, issueNumber) { + return `github-${slugifySegment(repo)}-epic-${issueNumber}`; +} + +function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) { + if (!store) { + return null; + } + + const now = new Date().toISOString(); + const metadata = { + schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION, + repo, + issueNumber: issue.number, + issueUrl: issue.url || null, + issueTitle: issue.title || null, + labels: normalizeLabels(issue.labels), + coordination: state, + projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY), + action, + actionAt: now, + syncedBy: 'ecc-github-coordination', + }; + + return store.upsertWorkItem({ + id: epicWorkItemId(repo, issue.number), + source: 'github-epic', + sourceId: String(issue.number), + title: `Epic #${issue.number}: ${issue.title}`, + status: mapStateToWorkItemStatus(state.status), + priority: state.status === 'blocked' ? 'high' : 'normal', + url: issue.url || null, + owner: state.owner || (issue.author && issue.author.login) || null, + repoRoot: options.repoRoot || process.cwd(), + sessionId: options.sessionId || null, + metadata, + updatedAt: now, + }); +} + +async function openStore(options = {}) { + if (options.dbPath === false) { + return null; + } + + return createStateStore({ + dbPath: options.dbPath, + homeDir: options.homeDir || process.env.HOME || os.homedir(), + }); +} + +module.exports = { + epicWorkItemId, + openStore, + upsertCoordinationWorkItem, +}; diff --git a/tests/lib/github-coordination.test.js b/tests/lib/github-coordination.test.js index 561d85ff..c71a4115 100644 --- a/tests/lib/github-coordination.test.js +++ b/tests/lib/github-coordination.test.js @@ -24,226 +24,285 @@ async function test(name, fn) { } } +async function runGroup(group, descriptors, counters) { + for (const { name, fn } of descriptors.filter(d => d.group === group)) { + if (await test(name, fn)) counters.passed += 1; + else counters.failed += 1; + } +} + +const DESCRIPTORS = [ + // normalizeRepo + { + group: 'normalizeRepo', + name: 'normalizeRepo returns { owner, name } for "owner/repo"', + fn: () => { + const result = normalizeRepo('acme/my-repo'); + assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' }); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on "owner/repo/extra"', + fn: () => { + assert.throws(() => normalizeRepo('owner/repo/extra'), /Invalid repo format/); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on bare string with no slash', + fn: () => { + assert.throws(() => normalizeRepo('justowner'), /Invalid repo format/); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on empty string', + fn: () => { + assert.throws(() => normalizeRepo(''), /Invalid repo format/); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on whitespace-only string', + fn: () => { + assert.throws(() => normalizeRepo(' '), /Invalid repo format/); + }, + }, + + // extractCoordinationState + { + group: 'extractCoordinationState', + name: 'extractCoordinationState returns null for body with no coordination section', + fn: () => { + const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.'); + assert.strictEqual(result, null); + }, + }, + { + group: 'extractCoordinationState', + name: 'extractCoordinationState returns parsed state from a proper coordination JSON block', + fn: () => { + const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' }; + const body = [ + '', + '```json', + JSON.stringify(state, null, 2), + '```', + '', + ].join('\n'); + const result = extractCoordinationState(body); + assert.ok(result !== null); + assert.strictEqual(result.status, 'available'); + assert.strictEqual(result.kind, 'epic'); + assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1'); + }, + }, + { + group: 'extractCoordinationState', + name: 'extractCoordinationState returns null when JSON block is malformed', + fn: () => { + const body = [ + '', + '```json', + '{ not valid json }', + '```', + '', + ].join('\n'); + const result = extractCoordinationState(body); + assert.strictEqual(result, null); + }, + }, + + // buildIssueStateFromAction + { + group: 'buildIssueStateFromAction', + name: 'buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', + fn: () => { + const issue = { number: 1, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'available', owner: null, branch: null, + validation: 'pending', review: 'not-requested', dependencies: [], tasks: [], + }; + const before = new Date(); + const result = buildIssueStateFromAction(issue, currentState, 'claim', { + owner: 'alice', branch: 'feat/my-branch', status: 'claimed', + }); + const after = new Date(); + + assert.strictEqual(result.status, 'claimed'); + assert.strictEqual(result.owner, 'alice'); + assert.strictEqual(result.branch, 'feat/my-branch'); + assert.strictEqual(result.lastAction, 'claim'); + assert.ok(result.lastActionAt); + const actionAt = new Date(result.lastActionAt); + assert.ok(actionAt >= before && actionAt <= after); + }, + }, + { + group: 'buildIssueStateFromAction', + name: 'buildIssueStateFromAction with "unblock" action preserves owner from existing state', + fn: () => { + const issue = { number: 2, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'blocked', owner: 'bob', branch: 'feat/blocked-branch', + validation: 'pending', review: 'not-requested', dependencies: [], tasks: [], + }; + const result = buildIssueStateFromAction(issue, currentState, 'unblock', { status: 'ready' }); + + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.owner, 'bob'); + assert.strictEqual(result.branch, 'feat/blocked-branch'); + assert.strictEqual(result.lastAction, 'unblock'); + }, + }, + { + group: 'buildIssueStateFromAction', + name: 'buildIssueStateFromAction with "validate" action sets status "validated" and validation "passed"', + fn: () => { + const issue = { number: 3, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'claimed', owner: 'carol', branch: 'feat/new', + validation: 'pending', review: 'not-requested', dependencies: [], tasks: [], + }; + const result = buildIssueStateFromAction(issue, currentState, 'validate', { + status: 'validated', validation: 'passed', + }); + + assert.strictEqual(result.status, 'validated'); + assert.strictEqual(result.validation, 'passed'); + assert.strictEqual(result.lastAction, 'validate'); + assert.strictEqual(result.owner, 'carol'); + }, + }, + + // desiredLabelsForState + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "available" includes "coordination:available"', + fn: () => { + const labels = desiredLabelsForState({ status: 'available' }); + assert.ok(Array.isArray(labels)); + assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`); + }, + }, + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"', + fn: () => { + const labels = desiredLabelsForState({ status: 'claimed' }); + assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`); + assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); + }, + }, + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "blocked" includes "coordination:blocked"', + fn: () => { + const labels = desiredLabelsForState({ status: 'blocked' }); + assert.ok(labels.includes('coordination:blocked'), `Expected coordination:blocked in [${labels.join(', ')}]`); + assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); + }, + }, + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "ready" includes "coordination:ready"', + fn: () => { + const labels = desiredLabelsForState({ status: 'ready' }); + assert.ok(labels.includes('coordination:ready'), `Expected coordination:ready in [${labels.join(', ')}]`); + }, + }, + + // extractTasks + { + group: 'extractTasks', + name: 'extractTasks returns empty array when body has no Tasks section', + fn: () => { + const tasks = extractTasks('Some issue without any task list.'); + assert.deepStrictEqual(tasks, []); + }, + }, + { + group: 'extractTasks', + name: 'extractTasks parses completed and open checkboxes under ## Tasks heading', + fn: () => { + const body = ['## Tasks', '- [x] Done task', '- [ ] Open task', '- [x] Another done task'].join('\n'); + const tasks = extractTasks(body); + const completed = tasks.filter(t => t.done); + const open = tasks.filter(t => !t.done); + assert.strictEqual(tasks.length, 3); + assert.strictEqual(completed.length, 2); + assert.strictEqual(open.length, 1); + assert.strictEqual(open[0].title, 'Open task'); + }, + }, + { + group: 'extractTasks', + name: 'extractTasks stops parsing at next heading after task section', + fn: () => { + const body = ['## Tasks', '- [x] First task', '## Notes', '- [ ] This is not a task'].join('\n'); + const tasks = extractTasks(body); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].title, 'First task'); + }, + }, + + // renderCoordinationState + { + group: 'renderCoordinationState', + name: 'renderCoordinationState returns a string containing the section marker', + fn: () => { + const state = { + schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available', + owner: null, branch: null, validation: 'pending', review: 'not-requested', + project: { state: 'backlog', fields: {} }, dependencies: [], tasks: [], labels: [], + lastAction: 'sync', lastActionAt: '2026-01-01T00:00:00.000Z', + lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null, + }; + const rendered = renderCoordinationState(state); + assert.ok(typeof rendered === 'string'); + assert.ok(rendered.includes(''), 'Missing start marker'); + assert.ok(rendered.includes(''), 'Missing end marker'); + assert.ok(rendered.includes('```json'), 'Missing json code fence'); + }, + }, + { + group: 'renderCoordinationState', + name: 'renderCoordinationState output round-trips through extractCoordinationState', + fn: () => { + const state = { + schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'claimed', + owner: 'carol', branch: 'feat/my-feature', validation: 'pending', review: 'requested', + project: { state: 'in-progress', fields: {} }, dependencies: [5, 6], + tasks: [{ title: 'Write tests', done: false }], labels: ['coordination:claimed'], + lastAction: 'claim', lastActionAt: '2026-01-01T00:00:00.000Z', + lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null, + }; + const rendered = renderCoordinationState(state); + const extracted = extractCoordinationState(rendered); + assert.ok(extracted !== null); + assert.strictEqual(extracted.status, 'claimed'); + assert.strictEqual(extracted.owner, 'carol'); + assert.deepStrictEqual(extracted.dependencies, [5, 6]); + }, + }, +]; + async function runTests() { console.log('\n=== Testing github-coordination ===\n'); - let passed = 0; - let failed = 0; + const counters = { passed: 0, failed: 0 }; + const groups = [...new Set(DESCRIPTORS.map(d => d.group))]; - // normalizeRepo + for (const group of groups) { + await runGroup(group, DESCRIPTORS, counters); + } - if (await test('normalizeRepo returns { owner, name } for "owner/repo"', () => { - const result = normalizeRepo('acme/my-repo'); - assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' }); - })) passed += 1; else failed += 1; - - if (await test('normalizeRepo throws on "owner/repo/extra"', () => { - assert.throws( - () => normalizeRepo('owner/repo/extra'), - /Invalid repo format/ - ); - })) passed += 1; else failed += 1; - - if (await test('normalizeRepo throws on bare string with no slash', () => { - assert.throws( - () => normalizeRepo('justowner'), - /Invalid repo format/ - ); - })) passed += 1; else failed += 1; - - // extractCoordinationState - - if (await test('extractCoordinationState returns null for body with no coordination section', () => { - const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.'); - assert.strictEqual(result, null); - })) passed += 1; else failed += 1; - - if (await test('extractCoordinationState returns parsed state from a proper coordination JSON block', () => { - const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' }; - const body = [ - '', - '```json', - JSON.stringify(state, null, 2), - '```', - '', - ].join('\n'); - const result = extractCoordinationState(body); - assert.ok(result !== null); - assert.strictEqual(result.status, 'available'); - assert.strictEqual(result.kind, 'epic'); - assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1'); - })) passed += 1; else failed += 1; - - if (await test('extractCoordinationState returns null when JSON block is malformed', () => { - const body = [ - '', - '```json', - '{ not valid json }', - '```', - '', - ].join('\n'); - const result = extractCoordinationState(body); - assert.strictEqual(result, null); - })) passed += 1; else failed += 1; - - // buildIssueStateFromAction - - if (await test('buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', () => { - const issue = { number: 1, body: '', labels: [] }; - const currentState = { - schemaVersion: DEFAULT_POLICY.schemaVersion, - status: 'available', - owner: null, - branch: null, - validation: 'pending', - review: 'not-requested', - dependencies: [], - tasks: [], - }; - const before = new Date(); - const result = buildIssueStateFromAction(issue, currentState, 'claim', { - owner: 'alice', - branch: 'feat/my-branch', - status: 'claimed', - }); - const after = new Date(); - - assert.strictEqual(result.status, 'claimed'); - assert.strictEqual(result.owner, 'alice'); - assert.strictEqual(result.branch, 'feat/my-branch'); - assert.strictEqual(result.lastAction, 'claim'); - assert.ok(result.lastActionAt); - const actionAt = new Date(result.lastActionAt); - assert.ok(actionAt >= before && actionAt <= after); - })) passed += 1; else failed += 1; - - if (await test('buildIssueStateFromAction with "unblock" action preserves owner from existing state', () => { - const issue = { number: 2, body: '', labels: [] }; - const currentState = { - schemaVersion: DEFAULT_POLICY.schemaVersion, - status: 'blocked', - owner: 'bob', - branch: 'feat/blocked-branch', - validation: 'pending', - review: 'not-requested', - dependencies: [], - tasks: [], - }; - const result = buildIssueStateFromAction(issue, currentState, 'unblock', { - status: 'ready', - }); - - assert.strictEqual(result.status, 'ready'); - assert.strictEqual(result.owner, 'bob'); - assert.strictEqual(result.branch, 'feat/blocked-branch'); - assert.strictEqual(result.lastAction, 'unblock'); - })) passed += 1; else failed += 1; - - // desiredLabelsForState - - if (await test('desiredLabelsForState for status "available" includes "coordination:available"', () => { - const labels = desiredLabelsForState({ status: 'available' }); - assert.ok(Array.isArray(labels)); - assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`); - })) passed += 1; else failed += 1; - - if (await test('desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"', () => { - const labels = desiredLabelsForState({ status: 'claimed' }); - assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`); - assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); - })) passed += 1; else failed += 1; - - // extractTasks - - if (await test('extractTasks returns empty array when body has no Tasks section', () => { - const body = 'Some issue without any task list.'; - const tasks = extractTasks(body); - assert.deepStrictEqual(tasks, []); - })) passed += 1; else failed += 1; - - if (await test('extractTasks parses completed and open checkboxes under ## Tasks heading', () => { - const body = [ - '## Tasks', - '- [x] Done task', - '- [ ] Open task', - '- [x] Another done task', - ].join('\n'); - const tasks = extractTasks(body); - const completed = tasks.filter(t => t.done); - const open = tasks.filter(t => !t.done); - assert.strictEqual(tasks.length, 3); - assert.strictEqual(completed.length, 2); - assert.strictEqual(open.length, 1); - assert.strictEqual(open[0].title, 'Open task'); - })) passed += 1; else failed += 1; - - if (await test('extractTasks stops parsing at next heading after task section', () => { - const body = [ - '## Tasks', - '- [x] First task', - '## Notes', - '- [ ] This is not a task', - ].join('\n'); - const tasks = extractTasks(body); - assert.strictEqual(tasks.length, 1); - assert.strictEqual(tasks[0].title, 'First task'); - })) passed += 1; else failed += 1; - - // renderCoordinationState - - if (await test('renderCoordinationState returns a string containing the section marker', () => { - const state = { - schemaVersion: 'ecc.github.coordination.v1', - kind: 'epic', - status: 'available', - owner: null, - branch: null, - validation: 'pending', - review: 'not-requested', - project: { state: 'backlog', fields: {} }, - dependencies: [], - tasks: [], - labels: [], - lastAction: 'sync', - lastActionAt: '2026-01-01T00:00:00.000Z', - lastSyncAt: '2026-01-01T00:00:00.000Z', - notes: null, - }; - const rendered = renderCoordinationState(state); - assert.ok(typeof rendered === 'string'); - assert.ok(rendered.includes(''), 'Missing start marker'); - assert.ok(rendered.includes(''), 'Missing end marker'); - assert.ok(rendered.includes('```json'), 'Missing json code fence'); - })) passed += 1; else failed += 1; - - if (await test('renderCoordinationState output round-trips through extractCoordinationState', () => { - const state = { - schemaVersion: 'ecc.github.coordination.v1', - kind: 'epic', - status: 'claimed', - owner: 'carol', - branch: 'feat/my-feature', - validation: 'pending', - review: 'requested', - project: { state: 'in-progress', fields: {} }, - dependencies: [5, 6], - tasks: [{ title: 'Write tests', done: false }], - labels: ['coordination:claimed'], - lastAction: 'claim', - lastActionAt: '2026-01-01T00:00:00.000Z', - lastSyncAt: '2026-01-01T00:00:00.000Z', - notes: null, - }; - const rendered = renderCoordinationState(state); - const extracted = extractCoordinationState(rendered); - assert.ok(extracted !== null); - assert.strictEqual(extracted.status, 'claimed'); - assert.strictEqual(extracted.owner, 'carol'); - assert.deepStrictEqual(extracted.dependencies, [5, 6]); - })) passed += 1; else failed += 1; - - console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); - process.exit(failed > 0 ? 1 : 0); + console.log(`\nResults: Passed: ${counters.passed}, Failed: ${counters.failed}`); + process.exit(counters.failed > 0 ? 1 : 0); } runTests(); diff --git a/tests/scripts/github-coordination.test.js b/tests/scripts/github-coordination.test.js index 0a7bfb99..aca4edbf 100644 --- a/tests/scripts/github-coordination.test.js +++ b/tests/scripts/github-coordination.test.js @@ -66,11 +66,11 @@ function parseJson(stdout) { async function test(name, fn) { try { await fn(); - console.log(` PASS ${name}`); + process.stdout.write(` PASS ${name}\n`); return true; } catch (error) { - console.log(` FAIL ${name}`); - console.log(` Error: ${error.message}`); + process.stdout.write(` FAIL ${name}\n`); + process.stdout.write(` Error: ${error.message}\n`); return false; } } @@ -85,7 +85,7 @@ async function readStore(dbPath) { } async function runTests() { - console.log('\n=== Testing github-coordination.js ===\n'); + process.stdout.write('\n=== Testing github-coordination.js ===\n\n'); let passed = 0; let failed = 0; @@ -228,7 +228,7 @@ async function runTests() { } })) passed++; else failed++; - console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); process.exit(failed > 0 ? 1 : 0); } From 33f2219307cd2dd74fd37f19401c28233699a3f5 Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 14:25:58 -0400 Subject: [PATCH 3/6] fix: address second round of code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions.js: - Add assertValidRepo/assertValidIssueNumber guards at the top of all action handlers (applyClaim, applySync, applyValidate, applyPublish, applyReview, applyDecompose, applyUnblock) for fast-fail validation - applyValidate: fix status transition — set 'validated' unconditionally when ok=true instead of preserving 'blocked' (was inconsistent with projectState becoming 'ready') gh-api.js: - runGh: preserve GITHUB_TOKEN by default; only delete when caller explicitly sets options.stripGithubToken=true (was deleting by default, breaking CI) parsing.js: - extractCoordinationState: throw SyntaxError on malformed JSON instead of silently returning null — lets callers distinguish bad JSON from absent marker - normalizeBodyForComparison: fix regex to match JSON-quoted form "lastSyncAt": ... instead of bare lastSyncAt: ... policy.js: - loadPolicy: validate that parsed JSON is a plain object before spreading; coerce nested fields (labels, review, validation, branchModel, project, fieldNames) to objects before merging state.js: - assertIssueClaimable: block re-claim on status alone (not status AND owner) to prevent {status:'claimed', owner:null} bypass; use state.owner || 'unknown' in error message - getCoordinationState: catch SyntaxError from extractCoordinationState, log warning to stderr, fall back to default state tests/lib: - Update malformed-JSON test to expect SyntaxError throw instead of null Co-Authored-By: Claude Sonnet 4.6 --- scripts/lib/github-coordination/actions.js | 26 +++++++++++++++- scripts/lib/github-coordination/gh-api.js | 2 +- scripts/lib/github-coordination/parsing.js | 8 +++-- scripts/lib/github-coordination/policy.js | 36 +++++++++------------- scripts/lib/github-coordination/state.js | 12 ++++++-- tests/lib/github-coordination.test.js | 5 ++- 6 files changed, 57 insertions(+), 32 deletions(-) diff --git a/scripts/lib/github-coordination/actions.js b/scripts/lib/github-coordination/actions.js index 6ad73b5e..2b00b249 100644 --- a/scripts/lib/github-coordination/actions.js +++ b/scripts/lib/github-coordination/actions.js @@ -16,6 +16,18 @@ const { const { upsertCoordinationWorkItem } = require('./store'); const { extractIssueReferences, extractTasks } = require('./parsing'); +function assertValidRepo(repo) { + if (typeof repo !== 'string' || !repo.trim()) { + throw new Error(`invalid repo: expected non-empty string, got ${JSON.stringify(repo)}`); + } +} + +function assertValidIssueNumber(issueNumber) { + if (!Number.isFinite(issueNumber) || issueNumber <= 0 || !Number.isInteger(issueNumber)) { + throw new Error(`invalid issueNumber: expected positive integer, got ${JSON.stringify(issueNumber)}`); + } +} + // applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write // (editIssue) sequence that is NOT atomic. Two concurrent callers can both read // an unclaimed issue, pass the check, and both succeed — resulting in a @@ -24,6 +36,8 @@ const { extractIssueReferences, extractTasks } = require('./parsing'); // branch-protection rule that allows only one actor at a time, or a // serialized job queue). function applyClaim(repo, issueNumber, options = {}, context = {}) { + assertValidRepo(repo); + assertValidIssueNumber(issueNumber); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const store = context.store || null; const issue = getIssue(repo, issueNumber, options); @@ -59,6 +73,7 @@ function applyClaim(repo, issueNumber, options = {}, context = {}) { } function applySync(repo, options = {}, context = {}) { + assertValidRepo(repo); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const store = context.store || null; const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 }); @@ -107,6 +122,8 @@ function applySync(repo, options = {}, context = {}) { } function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) { + assertValidRepo(repo); + assertValidIssueNumber(issueNumber); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const issue = existingIssue || getIssue(repo, issueNumber, options); const state = getCoordinationState(issue, policy); @@ -129,7 +146,7 @@ function applyValidate(repo, issueNumber, options = {}, context = {}, existingIs const ok = validations.every(entry => entry.ok); const nextState = buildIssueStateFromAction(issue, state, 'validate', { - status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status, + status: ok ? 'validated' : state.status, validation: ok ? 'passed' : 'failed', projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog', }, policy); @@ -157,6 +174,8 @@ function applyValidate(repo, issueNumber, options = {}, context = {}, existingIs } function applyPublish(repo, issueNumber, options = {}, context = {}) { + assertValidRepo(repo); + assertValidIssueNumber(issueNumber); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const issue = getIssue(repo, issueNumber, options); const state = getCoordinationState(issue, policy); @@ -194,6 +213,8 @@ function applyPublish(repo, issueNumber, options = {}, context = {}) { } function applyReview(repo, issueNumber, options = {}, context = {}) { + assertValidRepo(repo); + assertValidIssueNumber(issueNumber); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const issue = getIssue(repo, issueNumber, options); const state = getCoordinationState(issue, policy); @@ -225,6 +246,8 @@ function applyReview(repo, issueNumber, options = {}, context = {}) { } function applyDecompose(repo, issueNumber, options = {}, context = {}) { + assertValidRepo(repo); + assertValidIssueNumber(issueNumber); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const issue = getIssue(repo, issueNumber, options); const state = getCoordinationState(issue, policy); @@ -264,6 +287,7 @@ function applyDecompose(repo, issueNumber, options = {}, context = {}) { } function applyUnblock(repo, options = {}, context = {}) { + assertValidRepo(repo); const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); const store = context.store || null; const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 }); diff --git a/scripts/lib/github-coordination/gh-api.js b/scripts/lib/github-coordination/gh-api.js index 98f15560..d7669cdb 100644 --- a/scripts/lib/github-coordination/gh-api.js +++ b/scripts/lib/github-coordination/gh-api.js @@ -64,7 +64,7 @@ function runGh(args, options = {}) { const commandArgs = shimPath ? [shimPath, ...args] : args; const env = { ...process.env }; - if (!options.useEnvGithubToken) { + if (options.stripGithubToken) { delete env.GITHUB_TOKEN; } diff --git a/scripts/lib/github-coordination/parsing.js b/scripts/lib/github-coordination/parsing.js index 3b148d21..08bc3b54 100644 --- a/scripts/lib/github-coordination/parsing.js +++ b/scripts/lib/github-coordination/parsing.js @@ -7,7 +7,7 @@ function escapeRegExp(str) { } function normalizeBodyForComparison(body) { - return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED'); + return (body || '').replace(/"lastSyncAt"\s*:\s*[^,\}\n]+/g, '"lastSyncAt": NORMALIZED'); } function extractCoordinationState(body, policy = DEFAULT_POLICY) { @@ -27,8 +27,10 @@ function extractCoordinationState(body, policy = DEFAULT_POLICY) { try { const parsed = JSON.parse(match[1]); return parsed && typeof parsed === 'object' ? parsed : null; - } catch (_error) { - return null; + } catch (error) { + throw new SyntaxError( + `Malformed coordination JSON in body: ${error.message} — raw: ${match[1].slice(0, 120)}` + ); } } diff --git a/scripts/lib/github-coordination/policy.js b/scripts/lib/github-coordination/policy.js index 72fc7cab..dc0b0d54 100644 --- a/scripts/lib/github-coordination/policy.js +++ b/scripts/lib/github-coordination/policy.js @@ -65,32 +65,26 @@ function loadPolicy(rootDir = process.cwd(), configPath = null) { } catch (error) { throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`); } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Policy file ${resolvedPath} must contain a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`); + } + const labels = typeof parsed.labels === 'object' && parsed.labels !== null && !Array.isArray(parsed.labels) ? parsed.labels : {}; + const review = typeof parsed.review === 'object' && parsed.review !== null && !Array.isArray(parsed.review) ? parsed.review : {}; + const validation = typeof parsed.validation === 'object' && parsed.validation !== null && !Array.isArray(parsed.validation) ? parsed.validation : {}; + const branchModel = typeof parsed.branchModel === 'object' && parsed.branchModel !== null && !Array.isArray(parsed.branchModel) ? parsed.branchModel : {}; + const project = typeof parsed.project === 'object' && parsed.project !== null && !Array.isArray(parsed.project) ? parsed.project : {}; + const fieldNames = typeof project.fieldNames === 'object' && project.fieldNames !== null && !Array.isArray(project.fieldNames) ? project.fieldNames : {}; return { ...DEFAULT_POLICY, ...parsed, - labels: { - ...DEFAULT_LABELS, - ...(parsed.labels || {}), - }, - review: { - ...DEFAULT_POLICY.review, - ...(parsed.review || {}), - }, - validation: { - ...DEFAULT_POLICY.validation, - ...(parsed.validation || {}), - }, - branchModel: { - ...DEFAULT_POLICY.branchModel, - ...(parsed.branchModel || {}), - }, + labels: { ...DEFAULT_LABELS, ...labels }, + review: { ...DEFAULT_POLICY.review, ...review }, + validation: { ...DEFAULT_POLICY.validation, ...validation }, + branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel }, project: { ...DEFAULT_POLICY.project, - ...(parsed.project || {}), - fieldNames: { - ...DEFAULT_POLICY.project.fieldNames, - ...((parsed.project || {}).fieldNames || {}), - }, + ...project, + fieldNames: { ...DEFAULT_POLICY.project.fieldNames, ...fieldNames }, }, sourcePath: resolvedPath, }; diff --git a/scripts/lib/github-coordination/state.js b/scripts/lib/github-coordination/state.js index 04e8c747..3f094dc1 100644 --- a/scripts/lib/github-coordination/state.js +++ b/scripts/lib/github-coordination/state.js @@ -36,7 +36,13 @@ function defaultCoordinationState(issue, policy = DEFAULT_POLICY) { function getCoordinationState(issue, policy = DEFAULT_POLICY) { const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order - const existing = extractCoordinationState(issue && issue.body, policy); + let existing; + try { + existing = extractCoordinationState(issue && issue.body, policy); + } catch (error) { + process.stderr.write(`[github-coordination] Warning: ${error.message} (issue #${issue && issue.number})\n`); + existing = null; + } if (existing) { return { ...defaultCoordinationState(issue, policy), @@ -205,8 +211,8 @@ function assertIssueClaimable(issue, state) { throw new Error(`Issue #${issue.number} is not open`); } - if (state.status === 'claimed' && state.owner) { - throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`); + if (state.status === 'claimed') { + throw new Error(`Issue #${issue.number} is already claimed by ${state.owner || 'unknown'}`); } } diff --git a/tests/lib/github-coordination.test.js b/tests/lib/github-coordination.test.js index c71a4115..a983580f 100644 --- a/tests/lib/github-coordination.test.js +++ b/tests/lib/github-coordination.test.js @@ -100,7 +100,7 @@ const DESCRIPTORS = [ }, { group: 'extractCoordinationState', - name: 'extractCoordinationState returns null when JSON block is malformed', + name: 'extractCoordinationState throws SyntaxError when JSON block is malformed', fn: () => { const body = [ '', @@ -109,8 +109,7 @@ const DESCRIPTORS = [ '```', '', ].join('\n'); - const result = extractCoordinationState(body); - assert.strictEqual(result, null); + assert.throws(() => extractCoordinationState(body), SyntaxError); }, }, From 273b82c8baf98e42cbf17135cf8bab878f1e703c Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 14:54:06 -0400 Subject: [PATCH 4/6] fix: address code-review findings in github-coordination actions - Remove circular validation-status check in applyValidate that prevented fresh claims (validation='pending') from ever reaching 'passed' - Add staleCoordinationLabels helper to compute coordination:* labels to remove on state transitions; replaces hardcoded removeLabels:[] across all six editIssue call sites - Fix duplicate label writes in applySync: syncIssueLabels already calls editIssue for labels, so the follow-up editIssue now only updates body - Skip acquireLock finding: store.acquireLock does not exist; comment updated to explain why the fix was not applied Co-Authored-By: Claude Sonnet 4.6 --- scripts/lib/github-coordination/actions.js | 44 +++++++++++----------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/scripts/lib/github-coordination/actions.js b/scripts/lib/github-coordination/actions.js index 2b00b249..0682d8c3 100644 --- a/scripts/lib/github-coordination/actions.js +++ b/scripts/lib/github-coordination/actions.js @@ -2,7 +2,7 @@ const { loadPolicy } = require('./policy'); const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing'); -const { getIssue, listIssues, editIssue, commentIssue } = require('./gh-api'); +const { getIssue, listIssues, editIssue, commentIssue, normalizeLabels } = require('./gh-api'); const { assertIssueClaimable, buildIssueComment, @@ -28,13 +28,21 @@ function assertValidIssueNumber(issueNumber) { } } +function staleCoordinationLabels(issue, nextLabels, policy) { + const epicLabel = policy.labels && policy.labels.epic; + return normalizeLabels(issue.labels).filter(l => + (l.startsWith('coordination:') || l === epicLabel) && !nextLabels.includes(l) + ); +} + // applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write // (editIssue) sequence that is NOT atomic. Two concurrent callers can both read // an unclaimed issue, pass the check, and both succeed — resulting in a -// double-claim. Until a store.acquireLock(repo, issueNumber) API is available, -// callers should prevent races via external serialization (e.g. a GitHub -// branch-protection rule that allows only one actor at a time, or a -// serialized job queue). +// double-claim. A code-review finding suggested fixing this via +// context.store.acquireLock(repo, issueNumber), but that API does not exist in +// store.js; adding a call to it would throw at runtime. Left as-is until a +// locking primitive is available — callers should prevent races via external +// serialization (e.g. a serialized job queue or GitHub branch-protection rule). function applyClaim(repo, issueNumber, options = {}, context = {}) { assertValidRepo(repo); assertValidIssueNumber(issueNumber); @@ -63,7 +71,7 @@ function applyClaim(repo, issueNumber, options = {}, context = {}) { editIssue(repo, issueNumber, { body, addLabels: trackedIssue.labels, - removeLabels: [], + removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy), }, options); commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options); upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy }); @@ -96,12 +104,8 @@ function applySync(repo, options = {}, context = {}) { const body = mergeIssueBody(issue, nextState, policy); const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); - if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) { - editIssue(repo, issue.number, { - body, - addLabels: labelPlan.addLabels, - removeLabels: labelPlan.removeLabels, - }, options); + if (!options.dryRun && normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body)) { + editIssue(repo, issue.number, { body }, options); } const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); @@ -132,12 +136,6 @@ function applyValidate(repo, issueNumber, options = {}, context = {}, existingIs const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number)); const validations = []; - if (policy.validation.required && state.validation !== 'passed') { - validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` }); - } else { - validations.push({ check: 'validation-status', ok: true, detail: state.validation }); - } - if (missingDependencies.length > 0) { validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') }); } else { @@ -160,7 +158,7 @@ function applyValidate(repo, issueNumber, options = {}, context = {}, existingIs editIssue(repo, issueNumber, { body, addLabels: trackedIssue.labels, - removeLabels: [], + removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy), }, options); upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy }); } @@ -201,7 +199,7 @@ function applyPublish(repo, issueNumber, options = {}, context = {}) { editIssue(repo, issueNumber, { body, addLabels: trackedIssue.labels, - removeLabels: [], + removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy), }, options); commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, { validation: 'passed', @@ -234,7 +232,7 @@ function applyReview(repo, issueNumber, options = {}, context = {}) { editIssue(repo, issueNumber, { body, addLabels: trackedIssue.labels, - removeLabels: [], + removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy), }, options); commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, { review: reviewState, @@ -270,7 +268,7 @@ function applyDecompose(repo, issueNumber, options = {}, context = {}) { editIssue(repo, issueNumber, { body, addLabels: trackedIssue.labels, - removeLabels: [], + removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy), }, options); commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, { taskCount: String(tasks.length), @@ -320,7 +318,7 @@ function applyUnblock(repo, options = {}, context = {}) { editIssue(repo, issue.number, { body, addLabels: trackedIssue.labels, - removeLabels: [], + removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy), }, options); commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, { dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none', From 573ebe091890da08ea3691a4e9faafaa73b12569 Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 15:00:34 -0400 Subject: [PATCH 5/6] fix: enforce policy.review.required gate in applyPublish applyPublish was forcing review='approved' for any state that wasn't 'changes-requested', bypassing policy.review.required entirely. Add a guard that throws before buildIssueStateFromAction when review approval is required but not yet granted. Co-Authored-By: Claude Sonnet 4.6 --- scripts/lib/github-coordination/actions.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/lib/github-coordination/actions.js b/scripts/lib/github-coordination/actions.js index 0682d8c3..4548c4ad 100644 --- a/scripts/lib/github-coordination/actions.js +++ b/scripts/lib/github-coordination/actions.js @@ -183,6 +183,10 @@ function applyPublish(repo, issueNumber, options = {}, context = {}) { throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`); } + if (policy.review && policy.review.required && state.review !== 'approved') { + throw new Error(`Issue #${issueNumber} cannot be published: review approval required (current: ${state.review})`); + } + const nextState = buildIssueStateFromAction(issue, state, 'publish', { status: 'published', validation: 'passed', From af0cf0d7c8f2bf65afa31af29a84f042c0141b0c Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 15:06:34 -0400 Subject: [PATCH 6/6] fix: guard upsertCoordinationWorkItem behind dryRun check in applySync The store write was unconditional, persisting work items even during dry runs. Move it inside the !dryRun block alongside editIssue and initialize snapshot to null beforehand so results.push still receives snapshot: null for dry runs. Co-Authored-By: Claude Sonnet 4.6 --- scripts/lib/github-coordination/actions.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/lib/github-coordination/actions.js b/scripts/lib/github-coordination/actions.js index 4548c4ad..06cd0d38 100644 --- a/scripts/lib/github-coordination/actions.js +++ b/scripts/lib/github-coordination/actions.js @@ -104,11 +104,13 @@ function applySync(repo, options = {}, context = {}) { const body = mergeIssueBody(issue, nextState, policy); const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); - if (!options.dryRun && normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body)) { - editIssue(repo, issue.number, { body }, options); + let snapshot = null; + if (!options.dryRun) { + if (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body)) { + editIssue(repo, issue.number, { body }, options); + } + snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); } - - const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); results.push({ ...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy), syncedAt,