mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-16 08:26:52 +08:00
feat: add github-native coordination (epic-* commands + scripts + tests)
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 <noreply@anthropic.com>
This commit is contained in:
parent
fec84fcf19
commit
64470f4307
26
commands/epic-claim.md
Normal file
26
commands/epic-claim.md
Normal file
@ -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 <issue-number> --repo <owner/repo> --actor <login>
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
23
commands/epic-decompose.md
Normal file
23
commands/epic-decompose.md
Normal file
@ -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 <issue-number> --repo <owner/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`
|
||||||
23
commands/epic-publish.md
Normal file
23
commands/epic-publish.md
Normal file
@ -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 <issue-number> --repo <owner/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`
|
||||||
23
commands/epic-review.md
Normal file
23
commands/epic-review.md
Normal file
@ -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 <issue-number> --repo <owner/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`
|
||||||
23
commands/epic-sync.md
Normal file
23
commands/epic-sync.md
Normal file
@ -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 <owner/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`
|
||||||
22
commands/epic-unblock.md
Normal file
22
commands/epic-unblock.md
Normal file
@ -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 <owner/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`
|
||||||
22
commands/epic-validate.md
Normal file
22
commands/epic-validate.md
Normal file
@ -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 <issue-number> --repo <owner/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`
|
||||||
38
config/github-native-coordination.json
Normal file
38
config/github-native-coordination.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"totalCommands": 84,
|
"totalCommands": 91,
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "aside",
|
"command": "aside",
|
||||||
@ -111,6 +111,72 @@
|
|||||||
],
|
],
|
||||||
"path": "commands/ecc-guide.md"
|
"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",
|
"command": "evolve",
|
||||||
"description": "Analyze instincts and suggest or generate evolved structures",
|
"description": "Analyze instincts and suggest or generate evolved structures",
|
||||||
|
|||||||
279
scripts/github-coordination.js
Normal file
279
scripts/github-coordination.js
Normal file
@ -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 <command> [options]',
|
||||||
|
'',
|
||||||
|
'Commands:',
|
||||||
|
' claim <issue-number> Claim an epic issue and stamp coordination state',
|
||||||
|
' sync Sync epic issue bodies, labels, and local snapshots',
|
||||||
|
' validate <issue-number> Validate epic readiness and dependency status',
|
||||||
|
' publish <issue-number> Publish a validated epic update/comment',
|
||||||
|
' review <issue-number> Mark review requested/approved/blocked',
|
||||||
|
' unblock Sweep blocked epics whose dependencies are closed',
|
||||||
|
' decompose <issue-number> Reconcile epic task breakdown from issue body',
|
||||||
|
'',
|
||||||
|
'Options:',
|
||||||
|
' --repo <owner/repo> GitHub repository',
|
||||||
|
' --issue <number> Issue number for actions that target one issue',
|
||||||
|
' --actor <login> Claim owner / coordination actor',
|
||||||
|
' --branch <name> Epic branch name to stamp into the coordination body',
|
||||||
|
' --config <path> Optional coordination policy config',
|
||||||
|
' --db <path> SQLite state store path',
|
||||||
|
' --home <dir> Override home directory used by the state store',
|
||||||
|
' --limit <n> 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 <owner/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,
|
||||||
|
};
|
||||||
1040
scripts/lib/github-coordination.js
Normal file
1040
scripts/lib/github-coordination.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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) {
|
function printReadiness(section) {
|
||||||
console.log(`Readiness: ${section.status}`);
|
console.log(`Readiness: ${section.status}`);
|
||||||
console.log(` Attention items: ${section.attentionCount}`);
|
console.log(` Attention items: ${section.attentionCount}`);
|
||||||
@ -188,6 +256,10 @@ function printHuman(payload) {
|
|||||||
console.log();
|
console.log();
|
||||||
printGovernance(payload.governance);
|
printGovernance(payload.governance);
|
||||||
console.log();
|
console.log();
|
||||||
|
if (payload.githubCoordination) {
|
||||||
|
printGithubCoordination(payload.githubCoordination);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
printWorkItems(payload.workItems);
|
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`;
|
return `${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,6 +451,7 @@ async function main() {
|
|||||||
workItemLimit: options.limit,
|
workItemLimit: options.limit,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
payload.githubCoordination = summarizeGithubCoordination(payload.workItems);
|
||||||
|
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
const output = `${JSON.stringify(payload, null, 2)}\n`;
|
const output = `${JSON.stringify(payload, null, 2)}\n`;
|
||||||
|
|||||||
249
tests/lib/github-coordination.test.js
Normal file
249
tests/lib/github-coordination.test.js
Normal file
@ -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 = [
|
||||||
|
'<!-- ecc-coordination:start -->',
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
'```',
|
||||||
|
'<!-- ecc-coordination:end -->',
|
||||||
|
].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 = [
|
||||||
|
'<!-- ecc-coordination:start -->',
|
||||||
|
'```json',
|
||||||
|
'{ not valid json }',
|
||||||
|
'```',
|
||||||
|
'<!-- ecc-coordination:end -->',
|
||||||
|
].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('<!-- ecc-coordination:start -->'), 'Missing start marker');
|
||||||
|
assert.ok(rendered.includes('<!-- ecc-coordination:end -->'), '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();
|
||||||
238
tests/scripts/github-coordination.test.js
Normal file
238
tests/scripts/github-coordination.test.js
Normal file
@ -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',
|
||||||
|
'',
|
||||||
|
'<!-- ecc-coordination:start -->',
|
||||||
|
'```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),
|
||||||
|
'```',
|
||||||
|
'<!-- ecc-coordination:end -->',
|
||||||
|
].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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user