diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1fba8a1e..457d3749 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 67 agents, 269 skills, 85 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 67 agents, 269 skills, 92 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.0.0", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aa2ee522..c0a5a9cf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.0.0", - "description": "Harness-native ECC plugin for engineering teams - 67 agents, 269 skills, 85 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 67 agents, 269 skills, 92 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/AGENTS.md b/AGENTS.md index 76f22032..86f46e14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 67 specialized agents, 269 skills, 85 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 67 specialized agents, 269 skills, 92 commands, and automated hook workflows for software development. **Version:** 2.0.0 @@ -153,7 +153,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 67 specialized subagents skills/ — 269 workflow skills and domain knowledge -commands/ — 85 slash commands +commands/ — 92 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) scripts/ — Cross-platform Node.js utilities diff --git a/README.md b/README.md index 2bfaed38..c954acb7 100644 --- a/README.md +++ b/README.md @@ -428,7 +428,7 @@ If you stacked methods, clean up in this order: /plugin list ecc@ecc ``` -**That's it!** You now have access to 67 agents, 269 skills, and 85 legacy command shims. +**That's it!** You now have access to 67 agents, 269 skills, and 92 legacy command shims. ### Dashboard GUI @@ -1516,7 +1516,7 @@ The configuration is automatically detected from `.opencode/opencode.json`. | Feature | Claude Code | OpenCode | Status | |---------|---------------------|----------|--------| | Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** | -| Commands | PASS: 85 commands | PASS: 35 commands | **Claude Code leads** | +| Commands | PASS: 92 commands | PASS: 35 commands | **Claude Code leads** | | Skills | PASS: 269 skills | PASS: 37 skills | **Claude Code leads** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | @@ -1677,7 +1677,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | GitHub Copilot | |---------|-----------------------|------------|-----------|----------|----------------| | **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A | -| **Commands** | 85 | Shared | Instruction-based | 35 | 5 prompts | +| **Commands** | 92 | Shared | Instruction-based | 35 | 5 prompts | | **Skills** | 269 | Shared | 10 (native format) | 37 | Via instructions | | **Hook Events** | 8 types | 15 types | None yet | 11 types | None | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A | diff --git a/README.zh-CN.md b/README.zh-CN.md index 919687e8..1f226bb6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 67 个代理、269 个技能和 85 个命令。 +**完成!** 你现在可以使用 67 个代理、269 个技能和 92 个命令。 ### multi-* 命令需要额外配置 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 73da3a2c..056287b4 100644 --- a/docs/COMMAND-REGISTRY.json +++ b/docs/COMMAND-REGISTRY.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "totalCommands": 85, + "totalCommands": 92, "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", @@ -941,11 +1007,11 @@ "statistics": { "byType": { "build": 2, - "general": 7, + "general": 10, "orchestration": 11, "planning": 2, "refactoring": 1, - "review": 9, + "review": 13, "testing": 53 }, "topAgents": [ @@ -995,6 +1061,14 @@ "skill": "continuous-learning-v2", "count": 6 }, + { + "skill": "orch-add-feature", + "count": 4 + }, + { + "skill": "orch-change-feature", + "count": 4 + }, { "skill": "tdd-workflow", "count": 4 @@ -1007,14 +1081,6 @@ "skill": "flutter-dart-code-review", "count": 3 }, - { - "skill": "orch-add-feature", - "count": 3 - }, - { - "skill": "orch-change-feature", - "count": 3 - }, { "skill": "orch-fix-defect", "count": 3 diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index fa9dc801..4e457ae5 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、269 项技能、85 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、269 项技能、92 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.0.0 @@ -148,7 +148,7 @@ ``` agents/ — 67 个专业子代理 skills/ — 269 个工作流技能和领域知识 -commands/ — 85 个斜杠命令 +commands/ — 92 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) scripts/ — 跨平台 Node.js 实用工具 diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 5492a612..d348f008 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -228,7 +228,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 67 个智能体、269 项技能和 85 个命令了。 +**搞定!** 你现在可以使用 67 个智能体、269 项技能和 92 个命令了。 *** @@ -1141,7 +1141,7 @@ opencode | 功能特性 | Claude Code | OpenCode | 状态 | |---------|---------------|----------|--------| | 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** | -| 命令 | PASS: 85 个 | PASS: 35 个 | **Claude Code 领先** | +| 命令 | PASS: 92 个 | PASS: 35 个 | **Claude Code 领先** | | 技能 | PASS: 269 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | @@ -1249,7 +1249,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 | 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode | |---------|-----------------------|------------|-----------|----------| | **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | -| **命令** | 85 | 共享 | 基于指令 | 35 | +| **命令** | 92 | 共享 | 基于指令 | 35 | | **技能** | 269 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | diff --git a/scripts/github-coordination.js b/scripts/github-coordination.js new file mode 100644 index 00000000..3907a212 --- /dev/null +++ b/scripts/github-coordination.js @@ -0,0 +1,196 @@ +#!/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; +} + +// 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, + positionals: [], + }; + + if (args.length > 0 && !args[0].startsWith('-')) { + parsed.command = args.shift(); + } + + 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); + } else { + 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; +} + +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 .'); + + const policy = loadPolicy(process.cwd(), options.configPath); + store = await openStore({ + dbPath: options.dbPath, + homeDir: options.homeDir || process.env.HOME || os.homedir(), + }); + + 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 (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..a388e791 --- /dev/null +++ b/scripts/lib/github-coordination.js @@ -0,0 +1,57 @@ +'use strict'; + +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: 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..06cd0d38 --- /dev/null +++ b/scripts/lib/github-coordination/actions.js @@ -0,0 +1,385 @@ +'use strict'; + +const { loadPolicy } = require('./policy'); +const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing'); +const { getIssue, listIssues, editIssue, commentIssue, normalizeLabels } = require('./gh-api'); +const { + assertIssueClaimable, + buildIssueComment, + buildIssueStateFromAction, + desiredLabelsForState, + getCoordinationState, + summarizeStateForOutput, + syncIssueLabels, + verifyDependenciesClosed, +} = require('./state'); +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)}`); + } +} + +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. 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); + 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: staleCoordinationLabels(issue, trackedIssue.labels, policy), + }, 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 = {}) { + 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 }); + 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); + + 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 }); + } + 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) { + 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); + 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 (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 ? '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: staleCoordinationLabels(issue, trackedIssue.labels, policy), + }, 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 = {}) { + 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); + 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(', ')}`); + } + + 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', + 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: staleCoordinationLabels(issue, trackedIssue.labels, policy), + }, 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 = {}) { + 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); + 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: staleCoordinationLabels(issue, trackedIssue.labels, policy), + }, 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 = {}) { + 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); + 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: staleCoordinationLabels(issue, trackedIssue.labels, policy), + }, 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 = {}) { + 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 }); + 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: staleCoordinationLabels(issue, trackedIssue.labels, policy), + }, 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..d7669cdb --- /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.stripGithubToken) { + 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..08bc3b54 --- /dev/null +++ b/scripts/lib/github-coordination/parsing.js @@ -0,0 +1,143 @@ +'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*:\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) { + throw new SyntaxError( + `Malformed coordination JSON in body: ${error.message} — raw: ${match[1].slice(0, 120)}` + ); + } +} + +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..dc0b0d54 --- /dev/null +++ b/scripts/lib/github-coordination/policy.js @@ -0,0 +1,101 @@ +'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}`); + } + 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, ...labels }, + review: { ...DEFAULT_POLICY.review, ...review }, + validation: { ...DEFAULT_POLICY.validation, ...validation }, + branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel }, + project: { + ...DEFAULT_POLICY.project, + ...project, + fieldNames: { ...DEFAULT_POLICY.project.fieldNames, ...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..3f094dc1 --- /dev/null +++ b/scripts/lib/github-coordination/state.js @@ -0,0 +1,252 @@ +'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 + 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), + ...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') { + throw new Error(`Issue #${issue.number} is already claimed by ${state.owner || 'unknown'}`); + } +} + +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/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..a983580f --- /dev/null +++ b/tests/lib/github-coordination.test.js @@ -0,0 +1,307 @@ +'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 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 throws SyntaxError when JSON block is malformed', + fn: () => { + const body = [ + '', + '```json', + '{ not valid json }', + '```', + '', + ].join('\n'); + assert.throws(() => extractCoordinationState(body), SyntaxError); + }, + }, + + // 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'); + + const counters = { passed: 0, failed: 0 }; + const groups = [...new Set(DESCRIPTORS.map(d => d.group))]; + + for (const group of groups) { + await runGroup(group, DESCRIPTORS, counters); + } + + 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 new file mode 100644 index 00000000..aca4edbf --- /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(); + process.stdout.write(` PASS ${name}\n`); + return true; + } catch (error) { + process.stdout.write(` FAIL ${name}\n`); + process.stdout.write(` Error: ${error.message}\n`); + return false; + } +} + +async function readStore(dbPath) { + const store = await createStateStore({ dbPath }); + try { + return store.listWorkItems({ limit: 20 }); + } finally { + store.close(); + } +} + +async function runTests() { + process.stdout.write('\n=== Testing github-coordination.js ===\n\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++; + + process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(error => { + console.error(error); + process.exit(1); +});