refactor: apply code-review findings to github-native coordination

scripts/github-coordination.js:
- parseArgs: replace 13-entry if/else chain with BOOL_FLAGS/VALUE_FLAGS
  lookup maps; shrinks from 119 to ~45 lines
- Extract dispatchCommand(options, ctx) and formatOutput(payload, options)
  from main(); main() shrinks to ~20 lines

scripts/lib/github-coordination.js:
- Split 1041-line monolith into 6 focused sub-modules under
  scripts/lib/github-coordination/ (policy, parsing, gh-api, state,
  actions, store); index becomes a thin re-export (~55 lines)
- Document ECC_GH_SHIM trust boundary in runGh() (gh-api.js)
- Document applyClaim() read→check→write race condition (actions.js)

tests/lib/github-coordination.test.js:
- Refactor runTests() to data-driven DESCRIPTORS array + runGroup()
  helper; runTests() shrinks to ~10 lines
- Add 5 new edge-case tests: normalizeRepo('') and normalizeRepo('   ')
  throw, desiredLabelsForState for blocked/ready statuses, and
  buildIssueStateFromAction for validate action (15 → 20 tests)

tests/scripts/github-coordination.test.js:
- Replace console.log in test runner with process.stdout.write

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Victor Casado 2026-06-11 14:05:42 -04:00
parent 64470f4307
commit d4486a7a29
10 changed files with 1513 additions and 1429 deletions

View File

@ -55,25 +55,37 @@ function readValue(args, index, flagName) {
return 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) { function parseArgs(argv) {
const args = argv.slice(2); const args = argv.slice(2);
const parsed = { const parsed = {
command: null, command: null, actor: null, branch: null, configPath: null,
actor: null, dbPath: null, dryRun: false, help: false, homeDir: null,
branch: null, issueNumber: null, json: false, limit: 100, repo: null,
configPath: null, validation: null, review: null, status: null, projectState: 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: [], positionals: [],
}; };
@ -81,94 +93,21 @@ function parseArgs(argv) {
parsed.command = args.shift(); parsed.command = args.shift();
} }
for (let index = 0; index < args.length; index += 1) { for (let i = 0; i < args.length; i += 1) {
const arg = args[index]; const arg = args[i];
if (BOOL_FLAGS.has(arg)) {
if (arg === '--help' || arg === '-h') { BOOL_FLAGS.get(arg)(parsed);
parsed.help = true; } else if (VALUE_FLAGS.has(arg)) {
continue; VALUE_FLAGS.get(arg)(parsed, readValue(args, i, arg));
} i += 1;
if (arg === '--json') { } else if (!arg.startsWith('-')) {
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); parsed.positionals.push(arg);
continue; } else {
}
throw new Error(`Unknown argument: ${arg}`); throw new Error(`Unknown argument: ${arg}`);
} }
if (!parsed.command) {
parsed.command = 'sync';
} }
if (!parsed.command) parsed.command = 'sync';
if (!parsed.issueNumber && parsed.positionals.length > 0) { if (!parsed.issueNumber && parsed.positionals.length > 0) {
parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]); parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]);
} }
@ -176,81 +115,44 @@ function parseArgs(argv) {
return parsed; return parsed;
} }
async function main() { function dispatchCommand(options, ctx) {
let store = null; const { store, policy, rootDir } = ctx;
const base = { configPath: options.configPath, dryRun: options.dryRun };
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.command === 'claim') {
if (!options.issueNumber) throw new Error('Missing issue number.'); if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyClaim(options.repo, options.issueNumber, { return applyClaim(options.repo, options.issueNumber, {
actor: options.actor, ...base, actor: options.actor, branch: options.branch, owner: options.actor,
branch: options.branch, projectState: options.projectState, review: options.review,
configPath: options.configPath, status: options.status, validation: options.validation,
dryRun: options.dryRun, }, { store, policy, rootDir });
owner: options.actor, }
projectState: options.projectState, if (options.command === 'sync') {
review: options.review, return applySync(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir });
status: options.status, }
validation: options.validation, if (options.command === 'validate') {
}, { 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.'); if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyValidate(options.repo, options.issueNumber, { return applyValidate(options.repo, options.issueNumber, base, { store, policy, rootDir });
configPath: options.configPath, }
dryRun: options.dryRun, if (options.command === 'publish') {
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'publish') {
if (!options.issueNumber) throw new Error('Missing issue number.'); if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyPublish(options.repo, options.issueNumber, { return applyPublish(options.repo, options.issueNumber, base, { store, policy, rootDir });
configPath: options.configPath, }
dryRun: options.dryRun, if (options.command === 'review') {
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'review') {
if (!options.issueNumber) throw new Error('Missing issue number.'); if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyReview(options.repo, options.issueNumber, { return applyReview(options.repo, options.issueNumber, { ...base, review: options.review }, { store, policy, rootDir });
configPath: options.configPath, }
dryRun: options.dryRun, if (options.command === 'unblock') {
review: options.review, return applyUnblock(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir });
}, { store, policy, rootDir: process.cwd() }); }
} else if (options.command === 'unblock') { if (options.command === 'decompose') {
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.'); if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyDecompose(options.repo, options.issueNumber, { return applyDecompose(options.repo, options.issueNumber, base, { store, policy, rootDir });
configPath: options.configPath, }
dryRun: options.dryRun,
}, { store, policy, rootDir: process.cwd() });
} else {
throw new Error(`Unknown command: ${options.command}`); throw new Error(`Unknown command: ${options.command}`);
} }
function formatOutput(payload, options) {
if (options.json) { if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
} else if (options.command === 'sync' || options.command === 'unblock') { } else if (options.command === 'sync' || options.command === 'unblock') {
@ -258,13 +160,28 @@ async function main() {
} else { } else {
process.stdout.write(formatSummary(payload)); 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 <owner/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) { } catch (error) {
console.error(`Error: ${error.message}`); console.error(`Error: ${error.message}`);
process.exit(1); process.exit(1);
} finally { } finally {
if (store) { if (store) store.close();
store.close();
}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,357 @@
'use strict';
const { loadPolicy } = require('./policy');
const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing');
const { getIssue, listIssues, editIssue, commentIssue } = require('./gh-api');
const {
assertIssueClaimable,
buildIssueComment,
buildIssueStateFromAction,
desiredLabelsForState,
getCoordinationState,
summarizeStateForOutput,
syncIssueLabels,
verifyDependenciesClosed,
} = require('./state');
const { upsertCoordinationWorkItem } = require('./store');
const { extractIssueReferences, extractTasks } = require('./parsing');
// applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write
// (editIssue) sequence that is NOT atomic. Two concurrent callers can both read
// an unclaimed issue, pass the check, and both succeed — resulting in a
// double-claim. Until a store.acquireLock(repo, issueNumber) API is available,
// callers should prevent races via external serialization (e.g. a GitHub
// branch-protection rule that allows only one actor at a time, or a
// serialized job queue).
function applyClaim(repo, issueNumber, options = {}, context = {}) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issue = getIssue(repo, issueNumber, options);
const currentState = getCoordinationState(issue, policy);
assertIssueClaimable(issue, currentState);
const nextState = buildIssueStateFromAction(issue, currentState, 'claim', {
owner: options.actor || options.owner || currentState.owner || issue.author?.login || null,
branch: options.branch || currentState.branch || null,
status: options.status || 'claimed',
validation: options.validation || currentState.validation || 'pending',
review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'),
projectState: options.projectState || 'in-progress',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
const body = mergeIssueBody(issue, nextState, policy);
if (!options.dryRun) {
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: [],
}, options);
commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options);
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy });
}
return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy);
}
function applySync(repo, options = {}, context = {}) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 });
const syncedAt = new Date().toISOString();
const results = [];
for (const issue of issues) {
const currentState = getCoordinationState(issue, policy);
const nextState = buildIssueStateFromAction(issue, currentState, 'sync', {
status: currentState.status,
validation: currentState.validation,
review: currentState.review,
projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
const body = mergeIssueBody(issue, nextState, policy);
const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options);
if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) {
editIssue(repo, issue.number, {
body,
addLabels: labelPlan.addLabels,
removeLabels: labelPlan.removeLabels,
}, options);
}
const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy });
results.push({
...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy),
syncedAt,
labelPlan,
snapshot: snapshot || null,
});
}
return {
repo,
syncedAt,
count: results.length,
items: results,
};
}
function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = existingIssue || getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options);
const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number));
const validations = [];
if (policy.validation.required && state.validation !== 'passed') {
validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` });
} else {
validations.push({ check: 'validation-status', ok: true, detail: state.validation });
}
if (missingDependencies.length > 0) {
validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') });
} else {
validations.push({ check: 'dependencies', ok: true, detail: 'closed' });
}
const ok = validations.every(entry => entry.ok);
const nextState = buildIssueStateFromAction(issue, state, 'validate', {
status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status,
validation: ok ? 'passed' : 'failed',
projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: [],
}, options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy });
}
return {
...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy),
ok,
validations,
missingDependencies,
};
}
function applyPublish(repo, issueNumber, options = {}, context = {}) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue);
if (!validation.ok) {
throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`);
}
const nextState = buildIssueStateFromAction(issue, state, 'publish', {
status: 'published',
validation: 'passed',
review: state.review === 'changes-requested' ? state.review : 'approved',
projectState: 'done',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: [],
}, options);
commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, {
validation: 'passed',
}), options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy });
}
return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy);
}
function applyReview(repo, issueNumber, options = {}, context = {}) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const reviewState = options.review || 'approved';
const nextState = buildIssueStateFromAction(issue, state, 'review', {
status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked',
review: reviewState,
projectState: reviewState === 'approved' ? 'ready' : 'blocked',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: [],
}, options);
commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, {
review: reviewState,
}), options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy });
}
return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy);
}
function applyDecompose(repo, issueNumber, options = {}, context = {}) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const tasks = extractTasks(issue.body);
const dependencies = extractIssueReferences(issue.body);
const nextState = buildIssueStateFromAction(issue, state, 'decompose', {
tasks,
dependencies,
status: tasks.some(task => !task.done) ? 'claimed' : state.status,
projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: [],
}, options);
commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, {
taskCount: String(tasks.length),
dependencyCount: String(dependencies.length),
}), options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy });
}
return {
...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy),
tasks,
dependencyCount: dependencies.length,
};
}
function applyUnblock(repo, options = {}, context = {}) {
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 });
const results = [];
for (const issue of issues) {
const state = getCoordinationState(issue, policy);
if (state.status !== 'blocked') {
continue;
}
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues);
if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) {
continue;
}
const nextState = buildIssueStateFromAction(issue, state, 'unblock', {
status: 'ready',
projectState: 'ready',
validation: state.validation === 'failed' ? 'pending' : state.validation,
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issue.number, {
body,
addLabels: trackedIssue.labels,
removeLabels: [],
}, options);
commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, {
dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none',
}), options);
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy });
}
results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy));
}
return {
repo,
count: results.length,
items: results,
};
}
function formatSummary(payload) {
const lines = [
`${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`,
`Repo: ${payload.repo}`,
`Status: ${payload.status}`,
`Owner: ${payload.owner || '(unassigned)'}`,
`Branch: ${payload.branch || '(none)'}`,
`Validation: ${payload.validation || 'pending'}`,
`Review: ${payload.review || 'not-requested'}`,
];
if (payload.tasks && payload.tasks.length > 0) {
lines.push(`Tasks: ${payload.tasks.length}`);
}
if (payload.dependencies && payload.dependencies.length > 0) {
lines.push(`Dependencies: ${payload.dependencies.join(', ')}`);
}
return `${lines.join('\n')}\n`;
}
function formatCollection(payload) {
const lines = [
`Repo: ${payload.repo}`,
`Items: ${payload.count}`,
];
for (const item of payload.items || []) {
lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`);
}
return `${lines.join('\n')}\n`;
}
module.exports = {
applyClaim,
applyDecompose,
applyPublish,
applyReview,
applySync,
applyUnblock,
applyValidate,
formatCollection,
formatSummary,
};

View File

@ -0,0 +1,175 @@
'use strict';
const { spawnSync } = require('child_process');
function normalizeRepo(repo) {
const parts = String(repo || '').split('/').filter(Boolean);
if (parts.length !== 2) {
throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`);
}
const [owner, name] = parts;
return { owner, name };
}
function normalizeIssueNumber(value) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid issue number: ${value}`);
}
return parsed;
}
function normalizeLabelValue(label) {
if (typeof label === 'string') {
return label.trim();
}
if (label && typeof label === 'object') {
return String(label.name || label.label || '').trim();
}
return '';
}
function normalizeLabels(labels) {
return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort();
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
if (result.error) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
}
return result.stdout || '';
}
// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real
// `gh` binary and command/commandArgs execute an arbitrary script via
// process.execPath. This variable MUST only be set in trusted, isolated test
// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in
// production — doing so allows arbitrary script execution under the caller's
// privileges.
function runGh(args, options = {}) {
const shimPath = process.env.ECC_GH_SHIM;
const command = shimPath ? process.execPath : 'gh';
const commandArgs = shimPath ? [shimPath, ...args] : args;
const env = { ...process.env };
if (!options.useEnvGithubToken) {
delete env.GITHUB_TOKEN;
}
return runCommand(command, commandArgs, { cwd: options.cwd, env });
}
function runGhJson(args, options = {}) {
try {
return JSON.parse(runGh(args, options) || 'null');
} catch (error) {
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
}
}
function getIssue(repo, issueNumber, options = {}) {
const { owner, name } = normalizeRepo(repo);
const json = runGhJson([
'issue',
'view',
String(issueNumber),
'--repo',
`${owner}/${name}`,
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options);
if (!json) {
throw new Error(`Unable to load issue #${issueNumber} from ${repo}`);
}
return json;
}
function listIssues(repo, options = {}) {
const { owner, name } = normalizeRepo(repo);
const limit = Number.isFinite(options.limit) ? options.limit : 100;
const state = options.state || 'all';
return runGhJson([
'issue',
'list',
'--repo',
`${owner}/${name}`,
'--state',
state,
'--limit',
String(limit),
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options) || [];
}
function editIssue(repo, issueNumber, options = {}) {
const { owner, name } = normalizeRepo(repo);
const args = [
'issue',
'edit',
String(issueNumber),
'--repo',
`${owner}/${name}`,
];
if (options.body !== undefined) {
args.push('--body', options.body);
}
for (const label of options.addLabels || []) {
args.push('--add-label', label);
}
for (const label of options.removeLabels || []) {
args.push('--remove-label', label);
}
if (options.title) {
args.push('--title', options.title);
}
if (options.assignee) {
args.push('--add-assignee', options.assignee);
}
return runGh(args, options);
}
function commentIssue(repo, issueNumber, body, options = {}) {
const { owner, name } = normalizeRepo(repo);
return runGh([
'issue',
'comment',
String(issueNumber),
'--repo',
`${owner}/${name}`,
'--body',
body,
], options);
}
module.exports = {
commentIssue,
editIssue,
getIssue,
listIssues,
normalizeIssueNumber,
normalizeLabels,
normalizeRepo,
runGh,
runGhJson,
};

View File

@ -0,0 +1,141 @@
'use strict';
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION, DEFAULT_SECTION_MARKER } = require('./policy');
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeBodyForComparison(body) {
return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED');
}
function extractCoordinationState(body, policy = DEFAULT_POLICY) {
const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
const regex = new RegExp(
`<!--\\s*${marker}:start\\s*-->\\s*` +
'```json\\s*([\\s\\S]*?)\\s*```' +
`\\s*<!--\\s*${marker}:end\\s*-->`,
'm'
);
const match = String(body || '').match(regex);
if (!match) {
return null;
}
try {
const parsed = JSON.parse(match[1]);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (_error) {
return null;
}
}
function extractIssueReferences(text) {
const refs = new Set();
const source = String(text || '');
for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) {
refs.add(Number.parseInt(match[1], 10));
}
return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b);
}
function extractTasks(body) {
const lines = String(body || '').split(/\r?\n/);
const tasks = [];
let inTasks = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) {
inTasks = true;
continue;
}
if (inTasks && /^#{2,3}\s+\S/.test(line)) {
break;
}
if (inTasks) {
const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i);
if (taskMatch) {
tasks.push({
title: taskMatch[2].trim(),
done: taskMatch[1].toLowerCase() === 'x',
});
}
}
}
return tasks;
}
function parseStringList(value) {
if (!value) {
return [];
}
return String(value)
.split(',')
.map(part => part.trim())
.filter(Boolean);
}
function renderCoordinationState(state, policy = DEFAULT_POLICY) {
const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER;
const payload = {
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
kind: state.kind || 'epic',
status: state.status || 'available',
owner: state.owner || null,
branch: state.branch || null,
validation: state.validation || 'pending',
review: state.review || 'not-requested',
project: state.project || { state: 'backlog', fields: {} },
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
tasks: Array.isArray(state.tasks) ? state.tasks : [],
labels: Array.isArray(state.labels) ? state.labels : [],
lastAction: state.lastAction || 'sync',
lastActionAt: state.lastActionAt || new Date().toISOString(),
lastSyncAt: state.lastSyncAt || new Date().toISOString(),
notes: state.notes || null,
};
return [
`<!-- ${marker}:start -->`,
'```json',
JSON.stringify(payload, null, 2),
'```',
`<!-- ${marker}:end -->`,
].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*${markerEscaped}:start\\s*-->[\\s\\S]*?<!--\\s*${markerEscaped}:end\\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,
};

View File

@ -0,0 +1,107 @@
'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG_FILE = 'github-native-coordination.json';
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', 'config', DEFAULT_CONFIG_FILE);
const DEFAULT_SECTION_MARKER = 'ecc-coordination';
const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1';
const DEFAULT_LABELS = Object.freeze({
epic: 'epic',
available: 'coordination:available',
claimed: 'coordination:claimed',
ready: 'coordination:ready',
blocked: 'coordination:blocked',
validated: 'coordination:validated',
reviewRequested: 'coordination:review-requested',
reviewApproved: 'coordination:review-approved',
reviewChangesRequested: 'coordination:review-changes-requested',
published: 'coordination:published',
synced: 'coordination:synced',
});
const DEFAULT_POLICY = Object.freeze({
schemaVersion: DEFAULT_SCHEMA_VERSION,
sectionMarker: DEFAULT_SECTION_MARKER,
labels: DEFAULT_LABELS,
review: {
required: true,
defaultMode: 'required',
},
validation: {
required: true,
},
branchModel: {
epicOnly: true,
taskBranches: false,
},
project: {
enabled: false,
fieldNames: {
status: 'Status',
owner: 'Owner',
branch: 'Branch',
validation: 'Validation',
review: 'Review',
},
},
});
function loadPolicy(rootDir = process.cwd(), configPath = null) {
const resolvedPath = configPath
? path.resolve(configPath)
: path.join(rootDir, 'config', DEFAULT_CONFIG_FILE);
if (!fs.existsSync(resolvedPath)) {
return {
...DEFAULT_POLICY,
sourcePath: null,
};
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
} catch (error) {
throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`);
}
return {
...DEFAULT_POLICY,
...parsed,
labels: {
...DEFAULT_LABELS,
...(parsed.labels || {}),
},
review: {
...DEFAULT_POLICY.review,
...(parsed.review || {}),
},
validation: {
...DEFAULT_POLICY.validation,
...(parsed.validation || {}),
},
branchModel: {
...DEFAULT_POLICY.branchModel,
...(parsed.branchModel || {}),
},
project: {
...DEFAULT_POLICY.project,
...(parsed.project || {}),
fieldNames: {
...DEFAULT_POLICY.project.fieldNames,
...((parsed.project || {}).fieldNames || {}),
},
},
sourcePath: resolvedPath,
};
}
module.exports = {
DEFAULT_CONFIG_FILE,
DEFAULT_CONFIG_PATH,
DEFAULT_LABELS,
DEFAULT_POLICY,
DEFAULT_SCHEMA_VERSION,
DEFAULT_SECTION_MARKER,
loadPolicy,
};

View File

@ -0,0 +1,246 @@
'use strict';
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION } = require('./policy');
const { extractIssueReferences, extractTasks } = require('./parsing');
const { normalizeLabels, listIssues, editIssue } = require('./gh-api');
function slugifySegment(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown';
}
function defaultCoordinationState(issue, policy = DEFAULT_POLICY) {
return {
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
kind: 'epic',
status: 'available',
owner: issue && issue.author && issue.author.login ? issue.author.login : null,
branch: null,
validation: 'pending',
review: 'not-requested',
project: {
state: 'backlog',
fields: {},
},
dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''),
tasks: extractTasks(issue && issue.body ? issue.body : ''),
labels: normalizeLabels(issue && issue.labels),
lastAction: 'sync',
lastActionAt: new Date().toISOString(),
lastSyncAt: new Date().toISOString(),
notes: null,
};
}
function getCoordinationState(issue, policy = DEFAULT_POLICY) {
const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order
const existing = extractCoordinationState(issue && issue.body, policy);
if (existing) {
return {
...defaultCoordinationState(issue, policy),
...existing,
project: {
...defaultCoordinationState(issue, policy).project,
...(existing.project || {}),
},
tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''),
dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''),
labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels),
};
}
return defaultCoordinationState(issue, policy);
}
function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) {
const now = new Date().toISOString();
const next = {
...currentState,
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
kind: 'epic',
lastAction: action,
lastActionAt: now,
lastSyncAt: now,
labels: normalizeLabels(issue.labels),
dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body),
tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body),
};
if (options.owner !== undefined) next.owner = options.owner;
if (options.branch !== undefined) next.branch = options.branch;
if (options.validation !== undefined) next.validation = options.validation;
if (options.review !== undefined) next.review = options.review;
if (options.status !== undefined) next.status = options.status;
if (options.projectState !== undefined) {
next.project = { ...(next.project || {}), state: options.projectState };
}
if (options.notes !== undefined) next.notes = options.notes;
if (options.tasks !== undefined) next.tasks = options.tasks;
if (options.dependencies !== undefined) next.dependencies = options.dependencies;
return next;
}
function desiredLabelsForState(state, policy = DEFAULT_POLICY) {
const labels = [];
const known = policy.labels || DEFAULT_POLICY.labels;
labels.push(known.epic);
labels.push(known.synced);
if (state.status === 'available') labels.push(known.available);
if (state.status === 'claimed') labels.push(known.claimed);
if (state.status === 'ready') labels.push(known.ready);
if (state.status === 'blocked') labels.push(known.blocked);
if (state.validation === 'passed') labels.push(known.validated);
if (state.review === 'requested') labels.push(known.reviewRequested);
if (state.review === 'approved') labels.push(known.reviewApproved);
if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested);
if (state.status === 'published') labels.push(known.published);
return Array.from(new Set(labels.filter(Boolean))).sort();
}
function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) {
const desired = new Set(desiredLabelsForState(state, policy));
const current = new Set(normalizeLabels(issue.labels));
const addLabels = Array.from(desired).filter(label => !current.has(label));
const removeLabels = Array.from(current).filter(label => {
if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) {
return false;
}
return !desired.has(label);
});
if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) {
return { addLabels, removeLabels };
}
if (addLabels.length > 0 || removeLabels.length > 0) {
editIssue(repo, issue.number, { ...options, addLabels, removeLabels });
}
return { addLabels, removeLabels };
}
function findIssueByNumber(issues, issueNumber) {
return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null;
}
function buildIssueComment(action, repo, issueNumber, state, extra = {}) {
const summary = [
`ECC coordination ${action}`,
`Repo: ${repo}`,
`Issue: #${issueNumber}`,
`Status: ${state.status}`,
`Owner: ${state.owner || '(unassigned)'}`,
`Branch: ${state.branch || '(none)'}`,
`Validation: ${state.validation || 'pending'}`,
`Review: ${state.review || 'not-requested'}`,
];
for (const [key, value] of Object.entries(extra)) {
summary.push(`${key}: ${value}`);
}
summary.push('', 'This comment is part of the append-only coordination audit trail.');
return summary.join('\n');
}
function mapStateToWorkItemStatus(state) {
switch (state) {
case 'blocked':
return 'blocked';
case 'published':
return 'done';
case 'validated':
case 'reviewing':
case 'claimed':
case 'ready':
return 'in-progress';
case 'changes-requested':
return 'needs-review';
case 'available':
default:
return 'open';
}
}
function summarizeProjectProjection(state, policy = DEFAULT_POLICY) {
return {
enabled: Boolean(policy.project && policy.project.enabled),
state: state.project && state.project.state ? state.project.state : 'backlog',
fields: {
...(state.project && state.project.fields ? state.project.fields : {}),
},
};
}
function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) {
return {
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
repo,
issueNumber: issue.number,
issueUrl: issue.url || null,
issueTitle: issue.title,
action,
status: state.status,
owner: state.owner || null,
branch: state.branch || null,
validation: state.validation || 'pending',
review: state.review || 'not-requested',
project: summarizeProjectProjection(state, policy),
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
tasks: Array.isArray(state.tasks) ? state.tasks : [],
labels: normalizeLabels(issue.labels),
workItemId: `github-${slugifySegment(repo)}-epic-${issue.number}`,
lastActionAt: state.lastActionAt || null,
lastSyncAt: state.lastSyncAt || null,
};
}
function assertIssueClaimable(issue, state) {
if (String(issue.state || '').toLowerCase() !== 'open') {
throw new Error(`Issue #${issue.number} is not open`);
}
if (state.status === 'claimed' && state.owner) {
throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`);
}
}
function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) {
if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) {
return [];
}
const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 });
const closed = [];
for (const dependencyNumber of dependencyNumbers) {
const issue = findIssueByNumber(issueList, dependencyNumber);
if (!issue) {
process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`);
} else if (String(issue.state || '').toLowerCase() === 'closed') {
closed.push(dependencyNumber);
}
}
return closed;
}
module.exports = {
assertIssueClaimable,
buildIssueComment,
buildIssueStateFromAction,
defaultCoordinationState,
desiredLabelsForState,
findIssueByNumber,
getCoordinationState,
mapStateToWorkItemStatus,
slugifySegment,
summarizeProjectProjection,
summarizeStateForOutput,
syncIssueLabels,
verifyDependenciesClosed,
};

View File

@ -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,
};

View File

@ -24,41 +24,65 @@ async function test(name, fn) {
} }
} }
async function runTests() { async function runGroup(group, descriptors, counters) {
console.log('\n=== Testing github-coordination ===\n'); for (const { name, fn } of descriptors.filter(d => d.group === group)) {
if (await test(name, fn)) counters.passed += 1;
let passed = 0; else counters.failed += 1;
let failed = 0; }
}
const DESCRIPTORS = [
// normalizeRepo // normalizeRepo
{
if (await test('normalizeRepo returns { owner, name } for "owner/repo"', () => { group: 'normalizeRepo',
name: 'normalizeRepo returns { owner, name } for "owner/repo"',
fn: () => {
const result = normalizeRepo('acme/my-repo'); const result = normalizeRepo('acme/my-repo');
assert.deepStrictEqual(result, { owner: 'acme', name: '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( group: 'normalizeRepo',
() => normalizeRepo('owner/repo/extra'), name: 'normalizeRepo throws on "owner/repo/extra"',
/Invalid repo format/ fn: () => {
); 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( group: 'normalizeRepo',
() => normalizeRepo('justowner'), name: 'normalizeRepo throws on bare string with no slash',
/Invalid repo format/ fn: () => {
); assert.throws(() => normalizeRepo('justowner'), /Invalid repo format/);
})) passed += 1; else failed += 1; },
},
{
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 // extractCoordinationState
{
if (await test('extractCoordinationState returns null for body with no coordination section', () => { group: 'extractCoordinationState',
name: 'extractCoordinationState returns null for body with no coordination section',
fn: () => {
const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.'); const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.');
assert.strictEqual(result, null); assert.strictEqual(result, null);
})) passed += 1; else failed += 1; },
},
if (await test('extractCoordinationState returns parsed state from a proper coordination JSON block', () => { {
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 state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' };
const body = [ const body = [
'<!-- ecc-coordination:start -->', '<!-- ecc-coordination:start -->',
@ -72,9 +96,12 @@ async function runTests() {
assert.strictEqual(result.status, 'available'); assert.strictEqual(result.status, 'available');
assert.strictEqual(result.kind, 'epic'); assert.strictEqual(result.kind, 'epic');
assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1'); assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1');
})) passed += 1; else failed += 1; },
},
if (await test('extractCoordinationState returns null when JSON block is malformed', () => { {
group: 'extractCoordinationState',
name: 'extractCoordinationState returns null when JSON block is malformed',
fn: () => {
const body = [ const body = [
'<!-- ecc-coordination:start -->', '<!-- ecc-coordination:start -->',
'```json', '```json',
@ -84,27 +111,23 @@ async function runTests() {
].join('\n'); ].join('\n');
const result = extractCoordinationState(body); const result = extractCoordinationState(body);
assert.strictEqual(result, null); assert.strictEqual(result, null);
})) passed += 1; else failed += 1; },
},
// buildIssueStateFromAction // buildIssueStateFromAction
{
if (await test('buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', () => { group: 'buildIssueStateFromAction',
name: 'buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt',
fn: () => {
const issue = { number: 1, body: '', labels: [] }; const issue = { number: 1, body: '', labels: [] };
const currentState = { const currentState = {
schemaVersion: DEFAULT_POLICY.schemaVersion, schemaVersion: DEFAULT_POLICY.schemaVersion,
status: 'available', status: 'available', owner: null, branch: null,
owner: null, validation: 'pending', review: 'not-requested', dependencies: [], tasks: [],
branch: null,
validation: 'pending',
review: 'not-requested',
dependencies: [],
tasks: [],
}; };
const before = new Date(); const before = new Date();
const result = buildIssueStateFromAction(issue, currentState, 'claim', { const result = buildIssueStateFromAction(issue, currentState, 'claim', {
owner: 'alice', owner: 'alice', branch: 'feat/my-branch', status: 'claimed',
branch: 'feat/my-branch',
status: 'claimed',
}); });
const after = new Date(); const after = new Date();
@ -115,59 +138,98 @@ async function runTests() {
assert.ok(result.lastActionAt); assert.ok(result.lastActionAt);
const actionAt = new Date(result.lastActionAt); const actionAt = new Date(result.lastActionAt);
assert.ok(actionAt >= before && actionAt <= after); assert.ok(actionAt >= before && actionAt <= after);
})) passed += 1; else failed += 1; },
},
if (await test('buildIssueStateFromAction with "unblock" action preserves owner from existing state', () => { {
group: 'buildIssueStateFromAction',
name: 'buildIssueStateFromAction with "unblock" action preserves owner from existing state',
fn: () => {
const issue = { number: 2, body: '', labels: [] }; const issue = { number: 2, body: '', labels: [] };
const currentState = { const currentState = {
schemaVersion: DEFAULT_POLICY.schemaVersion, schemaVersion: DEFAULT_POLICY.schemaVersion,
status: 'blocked', status: 'blocked', owner: 'bob', branch: 'feat/blocked-branch',
owner: 'bob', validation: 'pending', review: 'not-requested', dependencies: [], tasks: [],
branch: 'feat/blocked-branch',
validation: 'pending',
review: 'not-requested',
dependencies: [],
tasks: [],
}; };
const result = buildIssueStateFromAction(issue, currentState, 'unblock', { const result = buildIssueStateFromAction(issue, currentState, 'unblock', { status: 'ready' });
status: 'ready',
});
assert.strictEqual(result.status, 'ready'); assert.strictEqual(result.status, 'ready');
assert.strictEqual(result.owner, 'bob'); assert.strictEqual(result.owner, 'bob');
assert.strictEqual(result.branch, 'feat/blocked-branch'); assert.strictEqual(result.branch, 'feat/blocked-branch');
assert.strictEqual(result.lastAction, 'unblock'); assert.strictEqual(result.lastAction, 'unblock');
})) passed += 1; else failed += 1; },
},
{
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 // desiredLabelsForState
{
if (await test('desiredLabelsForState for status "available" includes "coordination:available"', () => { group: 'desiredLabelsForState',
name: 'desiredLabelsForState for status "available" includes "coordination:available"',
fn: () => {
const labels = desiredLabelsForState({ status: 'available' }); const labels = desiredLabelsForState({ status: 'available' });
assert.ok(Array.isArray(labels)); assert.ok(Array.isArray(labels));
assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`); 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"', () => { {
group: 'desiredLabelsForState',
name: 'desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"',
fn: () => {
const labels = desiredLabelsForState({ status: 'claimed' }); const labels = desiredLabelsForState({ status: 'claimed' });
assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`); 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(', ')}]`); assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`);
})) passed += 1; else failed += 1; },
},
{
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 // extractTasks
{
if (await test('extractTasks returns empty array when body has no Tasks section', () => { group: 'extractTasks',
const body = 'Some issue without any task list.'; name: 'extractTasks returns empty array when body has no Tasks section',
const tasks = extractTasks(body); fn: () => {
const tasks = extractTasks('Some issue without any task list.');
assert.deepStrictEqual(tasks, []); assert.deepStrictEqual(tasks, []);
})) passed += 1; else failed += 1; },
},
if (await test('extractTasks parses completed and open checkboxes under ## Tasks heading', () => { {
const body = [ group: 'extractTasks',
'## Tasks', name: 'extractTasks parses completed and open checkboxes under ## Tasks heading',
'- [x] Done task', fn: () => {
'- [ ] Open task', const body = ['## Tasks', '- [x] Done task', '- [ ] Open task', '- [x] Another done task'].join('\n');
'- [x] Another done task',
].join('\n');
const tasks = extractTasks(body); const tasks = extractTasks(body);
const completed = tasks.filter(t => t.done); const completed = tasks.filter(t => t.done);
const open = tasks.filter(t => !t.done); const open = tasks.filter(t => !t.done);
@ -175,64 +237,49 @@ async function runTests() {
assert.strictEqual(completed.length, 2); assert.strictEqual(completed.length, 2);
assert.strictEqual(open.length, 1); assert.strictEqual(open.length, 1);
assert.strictEqual(open[0].title, 'Open task'); 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 = [ group: 'extractTasks',
'## Tasks', name: 'extractTasks stops parsing at next heading after task section',
'- [x] First task', fn: () => {
'## Notes', const body = ['## Tasks', '- [x] First task', '## Notes', '- [ ] This is not a task'].join('\n');
'- [ ] This is not a task',
].join('\n');
const tasks = extractTasks(body); const tasks = extractTasks(body);
assert.strictEqual(tasks.length, 1); assert.strictEqual(tasks.length, 1);
assert.strictEqual(tasks[0].title, 'First task'); assert.strictEqual(tasks[0].title, 'First task');
})) passed += 1; else failed += 1; },
},
// renderCoordinationState // renderCoordinationState
{
if (await test('renderCoordinationState returns a string containing the section marker', () => { group: 'renderCoordinationState',
name: 'renderCoordinationState returns a string containing the section marker',
fn: () => {
const state = { const state = {
schemaVersion: 'ecc.github.coordination.v1', schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available',
kind: 'epic', owner: null, branch: null, validation: 'pending', review: 'not-requested',
status: 'available', project: { state: 'backlog', fields: {} }, dependencies: [], tasks: [], labels: [],
owner: null, lastAction: 'sync', lastActionAt: '2026-01-01T00:00:00.000Z',
branch: null, lastSyncAt: '2026-01-01T00:00:00.000Z', notes: 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); const rendered = renderCoordinationState(state);
assert.ok(typeof rendered === 'string'); assert.ok(typeof rendered === 'string');
assert.ok(rendered.includes('<!-- ecc-coordination:start -->'), 'Missing start marker'); assert.ok(rendered.includes('<!-- ecc-coordination:start -->'), 'Missing start marker');
assert.ok(rendered.includes('<!-- ecc-coordination:end -->'), 'Missing end marker'); assert.ok(rendered.includes('<!-- ecc-coordination:end -->'), 'Missing end marker');
assert.ok(rendered.includes('```json'), 'Missing json code fence'); assert.ok(rendered.includes('```json'), 'Missing json code fence');
})) passed += 1; else failed += 1; },
},
if (await test('renderCoordinationState output round-trips through extractCoordinationState', () => { {
group: 'renderCoordinationState',
name: 'renderCoordinationState output round-trips through extractCoordinationState',
fn: () => {
const state = { const state = {
schemaVersion: 'ecc.github.coordination.v1', schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'claimed',
kind: 'epic', owner: 'carol', branch: 'feat/my-feature', validation: 'pending', review: 'requested',
status: 'claimed', project: { state: 'in-progress', fields: {} }, dependencies: [5, 6],
owner: 'carol', tasks: [{ title: 'Write tests', done: false }], labels: ['coordination:claimed'],
branch: 'feat/my-feature', lastAction: 'claim', lastActionAt: '2026-01-01T00:00:00.000Z',
validation: 'pending', lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null,
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 rendered = renderCoordinationState(state);
const extracted = extractCoordinationState(rendered); const extracted = extractCoordinationState(rendered);
@ -240,10 +287,22 @@ async function runTests() {
assert.strictEqual(extracted.status, 'claimed'); assert.strictEqual(extracted.status, 'claimed');
assert.strictEqual(extracted.owner, 'carol'); assert.strictEqual(extracted.owner, 'carol');
assert.deepStrictEqual(extracted.dependencies, [5, 6]); assert.deepStrictEqual(extracted.dependencies, [5, 6]);
})) passed += 1; else failed += 1; },
},
];
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); async function runTests() {
process.exit(failed > 0 ? 1 : 0); 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(); runTests();

View File

@ -66,11 +66,11 @@ function parseJson(stdout) {
async function test(name, fn) { async function test(name, fn) {
try { try {
await fn(); await fn();
console.log(` PASS ${name}`); process.stdout.write(` PASS ${name}\n`);
return true; return true;
} catch (error) { } catch (error) {
console.log(` FAIL ${name}`); process.stdout.write(` FAIL ${name}\n`);
console.log(` Error: ${error.message}`); process.stdout.write(` Error: ${error.message}\n`);
return false; return false;
} }
} }
@ -85,7 +85,7 @@ async function readStore(dbPath) {
} }
async function runTests() { async function runTests() {
console.log('\n=== Testing github-coordination.js ===\n'); process.stdout.write('\n=== Testing github-coordination.js ===\n\n');
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
@ -228,7 +228,7 @@ async function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);
} }