From 64470f4307098593c076259e3d1d988494522c4e Mon Sep 17 00:00:00 2001 From: Victor Casado Date: Thu, 11 Jun 2026 12:58:11 -0400 Subject: [PATCH] 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); +});