diff --git a/scripts/github-coordination.js b/scripts/github-coordination.js index ddc065bd..3907a212 100644 --- a/scripts/github-coordination.js +++ b/scripts/github-coordination.js @@ -55,25 +55,37 @@ function readValue(args, index, flagName) { return value; } +// Boolean flags: map flag string → setter(parsed) +const BOOL_FLAGS = new Map([ + ['--help', p => { p.help = true; }], + ['-h', p => { p.help = true; }], + ['--json', p => { p.json = true; }], + ['--dry-run', p => { p.dryRun = true; }], +]); + +// Value flags: map flag string → setter(parsed, value) +const VALUE_FLAGS = new Map([ + ['--repo', (p, v) => { p.repo = v; }], + ['--actor', (p, v) => { p.actor = v; }], + ['--branch', (p, v) => { p.branch = v; }], + ['--config', (p, v) => { p.configPath = v; }], + ['--db', (p, v) => { p.dbPath = v; }], + ['--home', (p, v) => { p.homeDir = v; }], + ['--validation', (p, v) => { p.validation = v; }], + ['--review', (p, v) => { p.review = v; }], + ['--status', (p, v) => { p.status = v; }], + ['--project-state', (p, v) => { p.projectState = v; }], + ['--issue', (p, v) => { p.issueNumber = normalizeIssueNumber(v); }], + ['--limit', (p, v) => { p.limit = normalizeIssueNumber(v); }], +]); + function parseArgs(argv) { const args = argv.slice(2); const parsed = { - command: null, - actor: null, - branch: null, - configPath: null, - dbPath: null, - dryRun: false, - help: false, - homeDir: null, - issueNumber: null, - json: false, - limit: 100, - repo: null, - validation: null, - review: null, - status: null, - projectState: null, + command: null, actor: null, branch: null, configPath: null, + dbPath: null, dryRun: false, help: false, homeDir: null, + issueNumber: null, json: false, limit: 100, repo: null, + validation: null, review: null, status: null, projectState: null, positionals: [], }; @@ -81,94 +93,21 @@ function parseArgs(argv) { parsed.command = args.shift(); } - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - - if (arg === '--help' || arg === '-h') { - parsed.help = true; - continue; - } - if (arg === '--json') { - parsed.json = true; - continue; - } - if (arg === '--dry-run') { - parsed.dryRun = true; - continue; - } - if (arg === '--repo') { - parsed.repo = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--issue') { - parsed.issueNumber = normalizeIssueNumber(readValue(args, index, arg)); - index += 1; - continue; - } - if (arg === '--actor') { - parsed.actor = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--branch') { - parsed.branch = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--config') { - parsed.configPath = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--db') { - parsed.dbPath = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--home') { - parsed.homeDir = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--limit') { - parsed.limit = normalizeIssueNumber(readValue(args, index, arg)); - index += 1; - continue; - } - if (arg === '--validation') { - parsed.validation = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--review') { - parsed.review = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--status') { - parsed.status = readValue(args, index, arg); - index += 1; - continue; - } - if (arg === '--project-state') { - parsed.projectState = readValue(args, index, arg); - index += 1; - continue; - } - - if (!arg.startsWith('-')) { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (BOOL_FLAGS.has(arg)) { + BOOL_FLAGS.get(arg)(parsed); + } else if (VALUE_FLAGS.has(arg)) { + VALUE_FLAGS.get(arg)(parsed, readValue(args, i, arg)); + i += 1; + } else if (!arg.startsWith('-')) { parsed.positionals.push(arg); - continue; + } else { + throw new Error(`Unknown argument: ${arg}`); } - - throw new Error(`Unknown argument: ${arg}`); - } - - if (!parsed.command) { - parsed.command = 'sync'; } + if (!parsed.command) parsed.command = 'sync'; if (!parsed.issueNumber && parsed.positionals.length > 0) { parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]); } @@ -176,18 +115,59 @@ function parseArgs(argv) { return parsed; } +function dispatchCommand(options, ctx) { + const { store, policy, rootDir } = ctx; + const base = { configPath: options.configPath, dryRun: options.dryRun }; + + if (options.command === 'claim') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyClaim(options.repo, options.issueNumber, { + ...base, actor: options.actor, branch: options.branch, owner: options.actor, + projectState: options.projectState, review: options.review, + status: options.status, validation: options.validation, + }, { store, policy, rootDir }); + } + if (options.command === 'sync') { + return applySync(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir }); + } + if (options.command === 'validate') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyValidate(options.repo, options.issueNumber, base, { store, policy, rootDir }); + } + if (options.command === 'publish') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyPublish(options.repo, options.issueNumber, base, { store, policy, rootDir }); + } + if (options.command === 'review') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyReview(options.repo, options.issueNumber, { ...base, review: options.review }, { store, policy, rootDir }); + } + if (options.command === 'unblock') { + return applyUnblock(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir }); + } + if (options.command === 'decompose') { + if (!options.issueNumber) throw new Error('Missing issue number.'); + return applyDecompose(options.repo, options.issueNumber, base, { store, policy, rootDir }); + } + throw new Error(`Unknown command: ${options.command}`); +} + +function formatOutput(payload, options) { + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else if (options.command === 'sync' || options.command === 'unblock') { + process.stdout.write(formatCollection(payload)); + } else { + process.stdout.write(formatSummary(payload)); + } +} + async function main() { let store = null; - try { const options = parseArgs(process.argv); - if (options.help) { - usage(0); - } - - if (!options.repo) { - throw new Error('Missing --repo .'); - } + if (options.help) usage(0); + if (!options.repo) throw new Error('Missing --repo .'); const policy = loadPolicy(process.cwd(), options.configPath); store = await openStore({ @@ -195,76 +175,13 @@ async function main() { homeDir: options.homeDir || process.env.HOME || os.homedir(), }); - let payload; - - if (options.command === 'claim') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyClaim(options.repo, options.issueNumber, { - actor: options.actor, - branch: options.branch, - configPath: options.configPath, - dryRun: options.dryRun, - owner: options.actor, - projectState: options.projectState, - review: options.review, - status: options.status, - validation: options.validation, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'sync') { - payload = applySync(options.repo, { - configPath: options.configPath, - dryRun: options.dryRun, - limit: options.limit, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'validate') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyValidate(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'publish') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyPublish(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'review') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyReview(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - review: options.review, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'unblock') { - payload = applyUnblock(options.repo, { - configPath: options.configPath, - dryRun: options.dryRun, - limit: options.limit, - }, { store, policy, rootDir: process.cwd() }); - } else if (options.command === 'decompose') { - if (!options.issueNumber) throw new Error('Missing issue number.'); - payload = applyDecompose(options.repo, options.issueNumber, { - configPath: options.configPath, - dryRun: options.dryRun, - }, { store, policy, rootDir: process.cwd() }); - } else { - throw new Error(`Unknown command: ${options.command}`); - } - - if (options.json) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else if (options.command === 'sync' || options.command === 'unblock') { - process.stdout.write(formatCollection(payload)); - } else { - process.stdout.write(formatSummary(payload)); - } + const payload = dispatchCommand(options, { store, policy, rootDir: process.cwd() }); + formatOutput(payload, options); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } finally { - if (store) { - store.close(); - } + if (store) store.close(); } } diff --git a/scripts/lib/github-coordination.js b/scripts/lib/github-coordination.js index 340b93cb..a388e791 100644 --- a/scripts/lib/github-coordination.js +++ b/scripts/lib/github-coordination.js @@ -1,1040 +1,57 @@ 'use strict'; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { spawnSync } = require('child_process'); - -const { createStateStore } = require('./state-store'); - -const DEFAULT_CONFIG_FILE = 'github-native-coordination.json'; -const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', 'config', DEFAULT_CONFIG_FILE); -const DEFAULT_SECTION_MARKER = 'ecc-coordination'; -const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1'; -const DEFAULT_LABELS = Object.freeze({ - epic: 'epic', - available: 'coordination:available', - claimed: 'coordination:claimed', - ready: 'coordination:ready', - blocked: 'coordination:blocked', - validated: 'coordination:validated', - reviewRequested: 'coordination:review-requested', - reviewApproved: 'coordination:review-approved', - reviewChangesRequested: 'coordination:review-changes-requested', - published: 'coordination:published', - synced: 'coordination:synced', -}); -const DEFAULT_POLICY = Object.freeze({ - schemaVersion: DEFAULT_SCHEMA_VERSION, - sectionMarker: DEFAULT_SECTION_MARKER, - labels: DEFAULT_LABELS, - review: { - required: true, - defaultMode: 'required', - }, - validation: { - required: true, - }, - branchModel: { - epicOnly: true, - taskBranches: false, - }, - project: { - enabled: false, - fieldNames: { - status: 'Status', - owner: 'Owner', - branch: 'Branch', - validation: 'Validation', - review: 'Review', - }, - }, -}); - -function normalizeRepo(repo) { - const parts = String(repo || '').split('/').filter(Boolean); - if (parts.length !== 2) { - throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`); - } - const [owner, name] = parts; - return { owner, name }; -} - -function slugifySegment(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'unknown'; -} - -function epicWorkItemId(repo, issueNumber) { - return `github-${slugifySegment(repo)}-epic-${issueNumber}`; -} - -function runCommand(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd, - env: options.env || process.env, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - }); - - if (result.error) { - throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); - } - - if (result.status !== 0) { - throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`); - } - - return result.stdout || ''; -} - -function runGh(args, options = {}) { - const shimPath = process.env.ECC_GH_SHIM; - const command = shimPath ? process.execPath : 'gh'; - const commandArgs = shimPath ? [shimPath, ...args] : args; - const env = { ...process.env }; - - if (!options.useEnvGithubToken) { - delete env.GITHUB_TOKEN; - } - - return runCommand(command, commandArgs, { cwd: options.cwd, env }); -} - -function runGhJson(args, options = {}) { - try { - return JSON.parse(runGh(args, options) || 'null'); - } catch (error) { - throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); - } -} - -function normalizeLabelValue(label) { - if (typeof label === 'string') { - return label.trim(); - } - if (label && typeof label === 'object') { - return String(label.name || label.label || '').trim(); - } - return ''; -} - -function normalizeLabels(labels) { - return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort(); -} - -function normalizeIssueNumber(value) { - const parsed = Number.parseInt(String(value), 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`Invalid issue number: ${value}`); - } - return parsed; -} - -function loadPolicy(rootDir = process.cwd(), configPath = null) { - const resolvedPath = configPath - ? path.resolve(configPath) - : path.join(rootDir, 'config', DEFAULT_CONFIG_FILE); - - if (!fs.existsSync(resolvedPath)) { - return { - ...DEFAULT_POLICY, - sourcePath: null, - }; - } - - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); - } catch (error) { - throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`); - } - return { - ...DEFAULT_POLICY, - ...parsed, - labels: { - ...DEFAULT_LABELS, - ...(parsed.labels || {}), - }, - review: { - ...DEFAULT_POLICY.review, - ...(parsed.review || {}), - }, - validation: { - ...DEFAULT_POLICY.validation, - ...(parsed.validation || {}), - }, - branchModel: { - ...DEFAULT_POLICY.branchModel, - ...(parsed.branchModel || {}), - }, - project: { - ...DEFAULT_POLICY.project, - ...(parsed.project || {}), - fieldNames: { - ...DEFAULT_POLICY.project.fieldNames, - ...((parsed.project || {}).fieldNames || {}), - }, - }, - sourcePath: resolvedPath, - }; -} - -function escapeRegExp(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function normalizeBodyForComparison(body) { - return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED'); -} - -function extractCoordinationState(body, policy = DEFAULT_POLICY) { - const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); - const regex = new RegExp( - `\\s*` + - '```json\\s*([\\s\\S]*?)\\s*```' + - `\\s*`, - 'm' - ); - const match = String(body || '').match(regex); - - if (!match) { - return null; - } - - try { - const parsed = JSON.parse(match[1]); - return parsed && typeof parsed === 'object' ? parsed : null; - } catch (_error) { - return null; - } -} - -function extractIssueReferences(text) { - const refs = new Set(); - const source = String(text || ''); - for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) { - refs.add(Number.parseInt(match[1], 10)); - } - return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b); -} - -function extractTasks(body) { - const lines = String(body || '').split(/\r?\n/); - const tasks = []; - let inTasks = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) { - inTasks = true; - continue; - } - if (inTasks && /^#{2,3}\s+\S/.test(line)) { - break; - } - if (inTasks) { - const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i); - if (taskMatch) { - tasks.push({ - title: taskMatch[2].trim(), - done: taskMatch[1].toLowerCase() === 'x', - }); - } - } - } - - return tasks; -} - -function parseStringList(value) { - if (!value) { - return []; - } - - return String(value) - .split(',') - .map(part => part.trim()) - .filter(Boolean); -} - -function defaultCoordinationState(issue, policy = DEFAULT_POLICY) { - return { - schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - kind: 'epic', - status: 'available', - owner: issue && issue.author && issue.author.login ? issue.author.login : null, - branch: null, - validation: 'pending', - review: 'not-requested', - project: { - state: 'backlog', - fields: {}, - }, - dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''), - tasks: extractTasks(issue && issue.body ? issue.body : ''), - labels: normalizeLabels(issue && issue.labels), - lastAction: 'sync', - lastActionAt: new Date().toISOString(), - lastSyncAt: new Date().toISOString(), - notes: null, - }; -} - -function getCoordinationState(issue, policy = DEFAULT_POLICY) { - const existing = extractCoordinationState(issue && issue.body, policy); - if (existing) { - return { - ...defaultCoordinationState(issue, policy), - ...existing, - project: { - ...defaultCoordinationState(issue, policy).project, - ...(existing.project || {}), - }, - tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''), - dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''), - labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels), - }; - } - return defaultCoordinationState(issue, policy); -} - -function renderCoordinationState(state, policy = DEFAULT_POLICY) { - const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER; - const payload = { - schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - kind: state.kind || 'epic', - status: state.status || 'available', - owner: state.owner || null, - branch: state.branch || null, - validation: state.validation || 'pending', - review: state.review || 'not-requested', - project: state.project || { state: 'backlog', fields: {} }, - dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], - tasks: Array.isArray(state.tasks) ? state.tasks : [], - labels: Array.isArray(state.labels) ? state.labels : [], - lastAction: state.lastAction || 'sync', - lastActionAt: state.lastActionAt || new Date().toISOString(), - lastSyncAt: state.lastSyncAt || new Date().toISOString(), - notes: state.notes || null, - }; - - return [ - ``, - '```json', - JSON.stringify(payload, null, 2), - '```', - ``, - ].join('\n'); -} - -function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) { - const body = String(issue.body || ''); - const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); - const rendered = renderCoordinationState(nextState, policy); - const regex = new RegExp( - `\\n?[\\s\\S]*?\\n?`, - 'm' - ); - - if (regex.test(body)) { - return body.replace(regex, `\n${rendered}\n`).trim() + '\n'; - } - - const trimmed = body.trimEnd(); - if (!trimmed) { - return `${rendered}\n`; - } - return `${trimmed}\n\n${rendered}\n`; -} - -function buildIssueComment(action, repo, issueNumber, state, extra = {}) { - const summary = [ - `ECC coordination ${action}`, - `Repo: ${repo}`, - `Issue: #${issueNumber}`, - `Status: ${state.status}`, - `Owner: ${state.owner || '(unassigned)'}`, - `Branch: ${state.branch || '(none)'}`, - `Validation: ${state.validation || 'pending'}`, - `Review: ${state.review || 'not-requested'}`, - ]; - - for (const [key, value] of Object.entries(extra)) { - summary.push(`${key}: ${value}`); - } - - summary.push('', 'This comment is part of the append-only coordination audit trail.'); - return summary.join('\n'); -} - -function mapStateToWorkItemStatus(state) { - switch (state) { - case 'blocked': - return 'blocked'; - case 'published': - return 'done'; - case 'validated': - case 'reviewing': - case 'claimed': - case 'ready': - return 'in-progress'; - case 'changes-requested': - return 'needs-review'; - case 'available': - default: - return 'open'; - } -} - -function summarizeProjectProjection(state, policy = DEFAULT_POLICY) { - return { - enabled: Boolean(policy.project && policy.project.enabled), - state: state.project && state.project.state ? state.project.state : 'backlog', - fields: { - ...(state.project && state.project.fields ? state.project.fields : {}), - }, - }; -} - -function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) { - if (!store) { - return null; - } - - const now = new Date().toISOString(); - const metadata = { - schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION, - repo, - issueNumber: issue.number, - issueUrl: issue.url || null, - issueTitle: issue.title || null, - labels: normalizeLabels(issue.labels), - coordination: state, - projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY), - action, - actionAt: now, - syncedBy: 'ecc-github-coordination', - }; - - return store.upsertWorkItem({ - id: epicWorkItemId(repo, issue.number), - source: 'github-epic', - sourceId: String(issue.number), - title: `Epic #${issue.number}: ${issue.title}`, - status: mapStateToWorkItemStatus(state.status), - priority: state.status === 'blocked' ? 'high' : 'normal', - url: issue.url || null, - owner: state.owner || (issue.author && issue.author.login) || null, - repoRoot: options.repoRoot || process.cwd(), - sessionId: options.sessionId || null, - metadata, - updatedAt: now, - }); -} - -function getIssue(repo, issueNumber, options = {}) { - const { owner, name } = normalizeRepo(repo); - const json = runGhJson([ - 'issue', - 'view', - String(issueNumber), - '--repo', - `${owner}/${name}`, - '--json', - 'number,title,body,url,state,labels,author,updatedAt,assignees', - ], options); - - if (!json) { - throw new Error(`Unable to load issue #${issueNumber} from ${repo}`); - } - - return json; -} - -function listIssues(repo, options = {}) { - const { owner, name } = normalizeRepo(repo); - const limit = Number.isFinite(options.limit) ? options.limit : 100; - const state = options.state || 'all'; - return runGhJson([ - 'issue', - 'list', - '--repo', - `${owner}/${name}`, - '--state', - state, - '--limit', - String(limit), - '--json', - 'number,title,body,url,state,labels,author,updatedAt,assignees', - ], options) || []; -} - -function editIssue(repo, issueNumber, options = {}) { - const { owner, name } = normalizeRepo(repo); - const args = [ - 'issue', - 'edit', - String(issueNumber), - '--repo', - `${owner}/${name}`, - ]; - - if (options.body !== undefined) { - args.push('--body', options.body); - } - - for (const label of options.addLabels || []) { - args.push('--add-label', label); - } - - for (const label of options.removeLabels || []) { - args.push('--remove-label', label); - } - - if (options.title) { - args.push('--title', options.title); - } - - if (options.assignee) { - args.push('--add-assignee', options.assignee); - } - - return runGh(args, options); -} - -function commentIssue(repo, issueNumber, body, options = {}) { - const { owner, name } = normalizeRepo(repo); - return runGh([ - 'issue', - 'comment', - String(issueNumber), - '--repo', - `${owner}/${name}`, - '--body', - body, - ], options); -} - -function findIssueByNumber(issues, issueNumber) { - return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null; -} - -function desiredLabelsForState(state, policy = DEFAULT_POLICY) { - const labels = []; - const known = policy.labels || DEFAULT_LABELS; - - labels.push(known.epic); - labels.push(known.synced); - - if (state.status === 'available') labels.push(known.available); - if (state.status === 'claimed') labels.push(known.claimed); - if (state.status === 'ready') labels.push(known.ready); - if (state.status === 'blocked') labels.push(known.blocked); - if (state.validation === 'passed') labels.push(known.validated); - if (state.review === 'requested') labels.push(known.reviewRequested); - if (state.review === 'approved') labels.push(known.reviewApproved); - if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested); - if (state.status === 'published') labels.push(known.published); - - return Array.from(new Set(labels.filter(Boolean))).sort(); -} - -function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) { - const desired = new Set(desiredLabelsForState(state, policy)); - const current = new Set(normalizeLabels(issue.labels)); - const addLabels = Array.from(desired).filter(label => !current.has(label)); - const removeLabels = Array.from(current).filter(label => { - if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) { - return false; - } - return !desired.has(label); - }); - - if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) { - return { addLabels, removeLabels }; - } - - if (addLabels.length > 0 || removeLabels.length > 0) { - editIssue(repo, issue.number, { ...options, addLabels, removeLabels }); - } - - return { addLabels, removeLabels }; -} - -function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) { - const now = new Date().toISOString(); - const next = { - ...currentState, - schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - kind: 'epic', - lastAction: action, - lastActionAt: now, - lastSyncAt: now, - labels: normalizeLabels(issue.labels), - dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body), - tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body), - }; - - if (options.owner !== undefined) { - next.owner = options.owner; - } - if (options.branch !== undefined) { - next.branch = options.branch; - } - if (options.validation !== undefined) { - next.validation = options.validation; - } - if (options.review !== undefined) { - next.review = options.review; - } - if (options.status !== undefined) { - next.status = options.status; - } - if (options.projectState !== undefined) { - next.project = { - ...(next.project || {}), - state: options.projectState, - }; - } - if (options.notes !== undefined) { - next.notes = options.notes; - } - if (options.tasks !== undefined) { - next.tasks = options.tasks; - } - if (options.dependencies !== undefined) { - next.dependencies = options.dependencies; - } - - return next; -} - -function assertIssueClaimable(issue, state) { - if (String(issue.state || '').toLowerCase() !== 'open') { - throw new Error(`Issue #${issue.number} is not open`); - } - - if (state.status === 'claimed' && state.owner) { - throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`); - } -} - -function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) { - if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) { - return []; - } - - const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 }); - const closed = []; - for (const dependencyNumber of dependencyNumbers) { - const issue = findIssueByNumber(issueList, dependencyNumber); - if (!issue) { - process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`); - } else if (String(issue.state || '').toLowerCase() === 'closed') { - closed.push(dependencyNumber); - } - } - - return closed; -} - -function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) { - return { - schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, - repo, - issueNumber: issue.number, - issueUrl: issue.url || null, - issueTitle: issue.title, - action, - status: state.status, - owner: state.owner || null, - branch: state.branch || null, - validation: state.validation || 'pending', - review: state.review || 'not-requested', - project: summarizeProjectProjection(state, policy), - dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], - tasks: Array.isArray(state.tasks) ? state.tasks : [], - labels: normalizeLabels(issue.labels), - workItemId: epicWorkItemId(repo, issue.number), - lastActionAt: state.lastActionAt || null, - lastSyncAt: state.lastSyncAt || null, - }; -} - -function applyClaim(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const store = context.store || null; - const issue = getIssue(repo, issueNumber, options); - const currentState = getCoordinationState(issue, policy); - - assertIssueClaimable(issue, currentState); - - const nextState = buildIssueStateFromAction(issue, currentState, 'claim', { - owner: options.actor || options.owner || currentState.owner || issue.author?.login || null, - branch: options.branch || currentState.branch || null, - status: options.status || 'claimed', - validation: options.validation || currentState.validation || 'pending', - review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'), - projectState: options.projectState || 'in-progress', - }, policy); - - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - const body = mergeIssueBody(issue, nextState, policy); - if (!options.dryRun) { - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options); - upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy }); - } - - return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy); -} - -function applySync(repo, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const store = context.store || null; - const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 }); - const syncedAt = new Date().toISOString(); - const results = []; - - for (const issue of issues) { - const currentState = getCoordinationState(issue, policy); - const nextState = buildIssueStateFromAction(issue, currentState, 'sync', { - status: currentState.status, - validation: currentState.validation, - review: currentState.review, - projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog', - }, policy); - - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - const body = mergeIssueBody(issue, nextState, policy); - const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); - - if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) { - editIssue(repo, issue.number, { - body, - addLabels: labelPlan.addLabels, - removeLabels: labelPlan.removeLabels, - }, options); - } - - const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); - results.push({ - ...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy), - syncedAt, - labelPlan, - snapshot: snapshot || null, - }); - } - - return { - repo, - syncedAt, - count: results.length, - items: results, - }; -} - -function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = existingIssue || getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; - const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options); - const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number)); - const validations = []; - - if (policy.validation.required && state.validation !== 'passed') { - validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` }); - } else { - validations.push({ check: 'validation-status', ok: true, detail: state.validation }); - } - - if (missingDependencies.length > 0) { - validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') }); - } else { - validations.push({ check: 'dependencies', ok: true, detail: 'closed' }); - } - - const ok = validations.every(entry => entry.ok); - const nextState = buildIssueStateFromAction(issue, state, 'validate', { - status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status, - validation: ok ? 'passed' : 'failed', - projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy }); - } - - return { - ...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy), - ok, - validations, - missingDependencies, - }; -} - -function applyPublish(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue); - - if (!validation.ok) { - throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`); - } - - const nextState = buildIssueStateFromAction(issue, state, 'publish', { - status: 'published', - validation: 'passed', - review: state.review === 'changes-requested' ? state.review : 'approved', - projectState: 'done', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, { - validation: 'passed', - }), options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy }); - } - - return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy); -} - -function applyReview(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const reviewState = options.review || 'approved'; - const nextState = buildIssueStateFromAction(issue, state, 'review', { - status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked', - review: reviewState, - projectState: reviewState === 'approved' ? 'ready' : 'blocked', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, { - review: reviewState, - }), options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy }); - } - - return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy); -} - -function applyDecompose(repo, issueNumber, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const issue = getIssue(repo, issueNumber, options); - const state = getCoordinationState(issue, policy); - const tasks = extractTasks(issue.body); - const dependencies = extractIssueReferences(issue.body); - const nextState = buildIssueStateFromAction(issue, state, 'decompose', { - tasks, - dependencies, - status: tasks.some(task => !task.done) ? 'claimed' : state.status, - projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog', - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issueNumber, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, { - taskCount: String(tasks.length), - dependencyCount: String(dependencies.length), - }), options); - upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy }); - } - - return { - ...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy), - tasks, - dependencyCount: dependencies.length, - }; -} - -function applyUnblock(repo, options = {}, context = {}) { - const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); - const store = context.store || null; - const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 }); - const results = []; - - for (const issue of issues) { - const state = getCoordinationState(issue, policy); - if (state.status !== 'blocked') { - continue; - } - - const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; - const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues); - if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) { - continue; - } - - const nextState = buildIssueStateFromAction(issue, state, 'unblock', { - status: 'ready', - projectState: 'ready', - validation: state.validation === 'failed' ? 'pending' : state.validation, - }, policy); - const trackedIssue = { - ...issue, - labels: desiredLabelsForState(nextState, policy), - }; - - if (!options.dryRun) { - const body = mergeIssueBody(issue, nextState, policy); - editIssue(repo, issue.number, { - body, - addLabels: trackedIssue.labels, - removeLabels: [], - }, options); - commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, { - dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none', - }), options); - upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy }); - } - - results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy)); - } - - return { - repo, - count: results.length, - items: results, - }; -} - -function formatSummary(payload) { - const lines = [ - `${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`, - `Repo: ${payload.repo}`, - `Status: ${payload.status}`, - `Owner: ${payload.owner || '(unassigned)'}`, - `Branch: ${payload.branch || '(none)'}`, - `Validation: ${payload.validation || 'pending'}`, - `Review: ${payload.review || 'not-requested'}`, - ]; - if (payload.tasks && payload.tasks.length > 0) { - lines.push(`Tasks: ${payload.tasks.length}`); - } - if (payload.dependencies && payload.dependencies.length > 0) { - lines.push(`Dependencies: ${payload.dependencies.join(', ')}`); - } - return `${lines.join('\n')}\n`; -} - -function formatCollection(payload) { - const lines = [ - `Repo: ${payload.repo}`, - `Items: ${payload.count}`, - ]; - for (const item of payload.items || []) { - lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`); - } - return `${lines.join('\n')}\n`; -} - -async function openStore(options = {}) { - if (options.dbPath === false) { - return null; - } - - return createStateStore({ - dbPath: options.dbPath, - homeDir: options.homeDir || process.env.HOME || os.homedir(), - }); -} +const policy = require('./github-coordination/policy'); +const parsing = require('./github-coordination/parsing'); +const ghApi = require('./github-coordination/gh-api'); +const state = require('./github-coordination/state'); +const actions = require('./github-coordination/actions'); +const store = require('./github-coordination/store'); module.exports = { - DEFAULT_CONFIG_FILE, - DEFAULT_CONFIG_PATH, - DEFAULT_POLICY, - DEFAULT_SCHEMA_VERSION, - applyClaim, - applyDecompose, - applyPublish, - applyReview, - applySync, - applyUnblock, - applyValidate, - buildIssueComment, - buildIssueStateFromAction, - commentIssue, - defaultCoordinationState, - desiredLabelsForState, - editIssue, - epicWorkItemId, - extractCoordinationState, - extractIssueReferences, - extractTasks, - formatCollection, - formatSummary, - getCoordinationState, - getIssue, - listIssues, - loadPolicy, - mapStateToWorkItemStatus, - mergeIssueBody, - normalizeIssueNumber, - normalizeLabels, - normalizeRepo, - openStore, - renderCoordinationState, - runGh, - runGhJson, - slugifySegment, - syncIssueLabels, - summarizeStateForOutput, - upsertCoordinationWorkItem, - verifyDependenciesClosed, + DEFAULT_CONFIG_FILE: policy.DEFAULT_CONFIG_FILE, + DEFAULT_CONFIG_PATH: policy.DEFAULT_CONFIG_PATH, + DEFAULT_POLICY: policy.DEFAULT_POLICY, + DEFAULT_SCHEMA_VERSION: policy.DEFAULT_SCHEMA_VERSION, + loadPolicy: policy.loadPolicy, + + extractCoordinationState: parsing.extractCoordinationState, + extractIssueReferences: parsing.extractIssueReferences, + extractTasks: parsing.extractTasks, + mergeIssueBody: parsing.mergeIssueBody, + renderCoordinationState: parsing.renderCoordinationState, + + commentIssue: ghApi.commentIssue, + editIssue: ghApi.editIssue, + getIssue: ghApi.getIssue, + listIssues: ghApi.listIssues, + normalizeIssueNumber: ghApi.normalizeIssueNumber, + normalizeLabels: ghApi.normalizeLabels, + normalizeRepo: ghApi.normalizeRepo, + runGh: ghApi.runGh, + runGhJson: ghApi.runGhJson, + + buildIssueComment: state.buildIssueComment, + buildIssueStateFromAction: state.buildIssueStateFromAction, + defaultCoordinationState: state.defaultCoordinationState, + desiredLabelsForState: state.desiredLabelsForState, + getCoordinationState: state.getCoordinationState, + mapStateToWorkItemStatus: state.mapStateToWorkItemStatus, + slugifySegment: state.slugifySegment, + summarizeStateForOutput: state.summarizeStateForOutput, + syncIssueLabels: state.syncIssueLabels, + verifyDependenciesClosed: state.verifyDependenciesClosed, + + applyClaim: actions.applyClaim, + applyDecompose: actions.applyDecompose, + applyPublish: actions.applyPublish, + applyReview: actions.applyReview, + applySync: actions.applySync, + applyUnblock: actions.applyUnblock, + applyValidate: actions.applyValidate, + formatCollection: actions.formatCollection, + formatSummary: actions.formatSummary, + + epicWorkItemId: store.epicWorkItemId, + openStore: store.openStore, + upsertCoordinationWorkItem: store.upsertCoordinationWorkItem, }; diff --git a/scripts/lib/github-coordination/actions.js b/scripts/lib/github-coordination/actions.js new file mode 100644 index 00000000..6ad73b5e --- /dev/null +++ b/scripts/lib/github-coordination/actions.js @@ -0,0 +1,357 @@ +'use strict'; + +const { loadPolicy } = require('./policy'); +const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing'); +const { getIssue, listIssues, editIssue, commentIssue } = require('./gh-api'); +const { + assertIssueClaimable, + buildIssueComment, + buildIssueStateFromAction, + desiredLabelsForState, + getCoordinationState, + summarizeStateForOutput, + syncIssueLabels, + verifyDependenciesClosed, +} = require('./state'); +const { upsertCoordinationWorkItem } = require('./store'); +const { extractIssueReferences, extractTasks } = require('./parsing'); + +// applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write +// (editIssue) sequence that is NOT atomic. Two concurrent callers can both read +// an unclaimed issue, pass the check, and both succeed — resulting in a +// double-claim. Until a store.acquireLock(repo, issueNumber) API is available, +// callers should prevent races via external serialization (e.g. a GitHub +// branch-protection rule that allows only one actor at a time, or a +// serialized job queue). +function applyClaim(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issue = getIssue(repo, issueNumber, options); + const currentState = getCoordinationState(issue, policy); + + assertIssueClaimable(issue, currentState); + + const nextState = buildIssueStateFromAction(issue, currentState, 'claim', { + owner: options.actor || options.owner || currentState.owner || issue.author?.login || null, + branch: options.branch || currentState.branch || null, + status: options.status || 'claimed', + validation: options.validation || currentState.validation || 'pending', + review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'), + projectState: options.projectState || 'in-progress', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + const body = mergeIssueBody(issue, nextState, policy); + if (!options.dryRun) { + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options); + upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy); +} + +function applySync(repo, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 }); + const syncedAt = new Date().toISOString(); + const results = []; + + for (const issue of issues) { + const currentState = getCoordinationState(issue, policy); + const nextState = buildIssueStateFromAction(issue, currentState, 'sync', { + status: currentState.status, + validation: currentState.validation, + review: currentState.review, + projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + const body = mergeIssueBody(issue, nextState, policy); + const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options); + + if (!options.dryRun && (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body) || labelPlan.addLabels.length > 0 || labelPlan.removeLabels.length > 0)) { + editIssue(repo, issue.number, { + body, + addLabels: labelPlan.addLabels, + removeLabels: labelPlan.removeLabels, + }, options); + } + + const snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy }); + results.push({ + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy), + syncedAt, + labelPlan, + snapshot: snapshot || null, + }); + } + + return { + repo, + syncedAt, + count: results.length, + items: results, + }; +} + +function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = existingIssue || getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; + const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options); + const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number)); + const validations = []; + + if (policy.validation.required && state.validation !== 'passed') { + validations.push({ check: 'validation-status', ok: false, detail: `validation=${state.validation}` }); + } else { + validations.push({ check: 'validation-status', ok: true, detail: state.validation }); + } + + if (missingDependencies.length > 0) { + validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') }); + } else { + validations.push({ check: 'dependencies', ok: true, detail: 'closed' }); + } + + const ok = validations.every(entry => entry.ok); + const nextState = buildIssueStateFromAction(issue, state, 'validate', { + status: ok ? (state.status === 'blocked' ? 'blocked' : 'validated') : state.status, + validation: ok ? 'passed' : 'failed', + projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy }); + } + + return { + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy), + ok, + validations, + missingDependencies, + }; +} + +function applyPublish(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue); + + if (!validation.ok) { + throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`); + } + + const nextState = buildIssueStateFromAction(issue, state, 'publish', { + status: 'published', + validation: 'passed', + review: state.review === 'changes-requested' ? state.review : 'approved', + projectState: 'done', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, { + validation: 'passed', + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy); +} + +function applyReview(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const reviewState = options.review || 'approved'; + const nextState = buildIssueStateFromAction(issue, state, 'review', { + status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked', + review: reviewState, + projectState: reviewState === 'approved' ? 'ready' : 'blocked', + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, { + review: reviewState, + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy }); + } + + return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy); +} + +function applyDecompose(repo, issueNumber, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const issue = getIssue(repo, issueNumber, options); + const state = getCoordinationState(issue, policy); + const tasks = extractTasks(issue.body); + const dependencies = extractIssueReferences(issue.body); + const nextState = buildIssueStateFromAction(issue, state, 'decompose', { + tasks, + dependencies, + status: tasks.some(task => !task.done) ? 'claimed' : state.status, + projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog', + }, policy); + + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issueNumber, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, { + taskCount: String(tasks.length), + dependencyCount: String(dependencies.length), + }), options); + upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy }); + } + + return { + ...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy), + tasks, + dependencyCount: dependencies.length, + }; +} + +function applyUnblock(repo, options = {}, context = {}) { + const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath); + const store = context.store || null; + const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 }); + const results = []; + + for (const issue of issues) { + const state = getCoordinationState(issue, policy); + if (state.status !== 'blocked') { + continue; + } + + const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : []; + const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues); + if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) { + continue; + } + + const nextState = buildIssueStateFromAction(issue, state, 'unblock', { + status: 'ready', + projectState: 'ready', + validation: state.validation === 'failed' ? 'pending' : state.validation, + }, policy); + const trackedIssue = { + ...issue, + labels: desiredLabelsForState(nextState, policy), + }; + + if (!options.dryRun) { + const body = mergeIssueBody(issue, nextState, policy); + editIssue(repo, issue.number, { + body, + addLabels: trackedIssue.labels, + removeLabels: [], + }, options); + commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, { + dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none', + }), options); + upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy }); + } + + results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy)); + } + + return { + repo, + count: results.length, + items: results, + }; +} + +function formatSummary(payload) { + const lines = [ + `${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`, + `Repo: ${payload.repo}`, + `Status: ${payload.status}`, + `Owner: ${payload.owner || '(unassigned)'}`, + `Branch: ${payload.branch || '(none)'}`, + `Validation: ${payload.validation || 'pending'}`, + `Review: ${payload.review || 'not-requested'}`, + ]; + if (payload.tasks && payload.tasks.length > 0) { + lines.push(`Tasks: ${payload.tasks.length}`); + } + if (payload.dependencies && payload.dependencies.length > 0) { + lines.push(`Dependencies: ${payload.dependencies.join(', ')}`); + } + return `${lines.join('\n')}\n`; +} + +function formatCollection(payload) { + const lines = [ + `Repo: ${payload.repo}`, + `Items: ${payload.count}`, + ]; + for (const item of payload.items || []) { + lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`); + } + return `${lines.join('\n')}\n`; +} + +module.exports = { + applyClaim, + applyDecompose, + applyPublish, + applyReview, + applySync, + applyUnblock, + applyValidate, + formatCollection, + formatSummary, +}; diff --git a/scripts/lib/github-coordination/gh-api.js b/scripts/lib/github-coordination/gh-api.js new file mode 100644 index 00000000..98f15560 --- /dev/null +++ b/scripts/lib/github-coordination/gh-api.js @@ -0,0 +1,175 @@ +'use strict'; + +const { spawnSync } = require('child_process'); + +function normalizeRepo(repo) { + const parts = String(repo || '').split('/').filter(Boolean); + if (parts.length !== 2) { + throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`); + } + const [owner, name] = parts; + return { owner, name }; +} + +function normalizeIssueNumber(value) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid issue number: ${value}`); + } + return parsed; +} + +function normalizeLabelValue(label) { + if (typeof label === 'string') { + return label.trim(); + } + if (label && typeof label === 'object') { + return String(label.name || label.label || '').trim(); + } + return ''; +} + +function normalizeLabels(labels) { + return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort(); +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env || process.env, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`); + } + + return result.stdout || ''; +} + +// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real +// `gh` binary and command/commandArgs execute an arbitrary script via +// process.execPath. This variable MUST only be set in trusted, isolated test +// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in +// production — doing so allows arbitrary script execution under the caller's +// privileges. +function runGh(args, options = {}) { + const shimPath = process.env.ECC_GH_SHIM; + const command = shimPath ? process.execPath : 'gh'; + const commandArgs = shimPath ? [shimPath, ...args] : args; + const env = { ...process.env }; + + if (!options.useEnvGithubToken) { + delete env.GITHUB_TOKEN; + } + + return runCommand(command, commandArgs, { cwd: options.cwd, env }); +} + +function runGhJson(args, options = {}) { + try { + return JSON.parse(runGh(args, options) || 'null'); + } catch (error) { + throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); + } +} + +function getIssue(repo, issueNumber, options = {}) { + const { owner, name } = normalizeRepo(repo); + const json = runGhJson([ + 'issue', + 'view', + String(issueNumber), + '--repo', + `${owner}/${name}`, + '--json', + 'number,title,body,url,state,labels,author,updatedAt,assignees', + ], options); + + if (!json) { + throw new Error(`Unable to load issue #${issueNumber} from ${repo}`); + } + + return json; +} + +function listIssues(repo, options = {}) { + const { owner, name } = normalizeRepo(repo); + const limit = Number.isFinite(options.limit) ? options.limit : 100; + const state = options.state || 'all'; + return runGhJson([ + 'issue', + 'list', + '--repo', + `${owner}/${name}`, + '--state', + state, + '--limit', + String(limit), + '--json', + 'number,title,body,url,state,labels,author,updatedAt,assignees', + ], options) || []; +} + +function editIssue(repo, issueNumber, options = {}) { + const { owner, name } = normalizeRepo(repo); + const args = [ + 'issue', + 'edit', + String(issueNumber), + '--repo', + `${owner}/${name}`, + ]; + + if (options.body !== undefined) { + args.push('--body', options.body); + } + + for (const label of options.addLabels || []) { + args.push('--add-label', label); + } + + for (const label of options.removeLabels || []) { + args.push('--remove-label', label); + } + + if (options.title) { + args.push('--title', options.title); + } + + if (options.assignee) { + args.push('--add-assignee', options.assignee); + } + + return runGh(args, options); +} + +function commentIssue(repo, issueNumber, body, options = {}) { + const { owner, name } = normalizeRepo(repo); + return runGh([ + 'issue', + 'comment', + String(issueNumber), + '--repo', + `${owner}/${name}`, + '--body', + body, + ], options); +} + +module.exports = { + commentIssue, + editIssue, + getIssue, + listIssues, + normalizeIssueNumber, + normalizeLabels, + normalizeRepo, + runGh, + runGhJson, +}; diff --git a/scripts/lib/github-coordination/parsing.js b/scripts/lib/github-coordination/parsing.js new file mode 100644 index 00000000..3b148d21 --- /dev/null +++ b/scripts/lib/github-coordination/parsing.js @@ -0,0 +1,141 @@ +'use strict'; + +const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION, DEFAULT_SECTION_MARKER } = require('./policy'); + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function normalizeBodyForComparison(body) { + return (body || '').replace(/lastSyncAt:\s*[^\n]+/g, 'lastSyncAt: NORMALIZED'); +} + +function extractCoordinationState(body, policy = DEFAULT_POLICY) { + const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); + const regex = new RegExp( + `\\s*` + + '```json\\s*([\\s\\S]*?)\\s*```' + + `\\s*`, + 'm' + ); + const match = String(body || '').match(regex); + + if (!match) { + return null; + } + + try { + const parsed = JSON.parse(match[1]); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_error) { + return null; + } +} + +function extractIssueReferences(text) { + const refs = new Set(); + const source = String(text || ''); + for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) { + refs.add(Number.parseInt(match[1], 10)); + } + return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b); +} + +function extractTasks(body) { + const lines = String(body || '').split(/\r?\n/); + const tasks = []; + let inTasks = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) { + inTasks = true; + continue; + } + if (inTasks && /^#{2,3}\s+\S/.test(line)) { + break; + } + if (inTasks) { + const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i); + if (taskMatch) { + tasks.push({ + title: taskMatch[2].trim(), + done: taskMatch[1].toLowerCase() === 'x', + }); + } + } + } + + return tasks; +} + +function parseStringList(value) { + if (!value) { + return []; + } + return String(value) + .split(',') + .map(part => part.trim()) + .filter(Boolean); +} + +function renderCoordinationState(state, policy = DEFAULT_POLICY) { + const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER; + const payload = { + schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: state.kind || 'epic', + status: state.status || 'available', + owner: state.owner || null, + branch: state.branch || null, + validation: state.validation || 'pending', + review: state.review || 'not-requested', + project: state.project || { state: 'backlog', fields: {} }, + dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], + tasks: Array.isArray(state.tasks) ? state.tasks : [], + labels: Array.isArray(state.labels) ? state.labels : [], + lastAction: state.lastAction || 'sync', + lastActionAt: state.lastActionAt || new Date().toISOString(), + lastSyncAt: state.lastSyncAt || new Date().toISOString(), + notes: state.notes || null, + }; + + return [ + ``, + '```json', + JSON.stringify(payload, null, 2), + '```', + ``, + ].join('\n'); +} + +function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) { + const body = String(issue.body || ''); + const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER); + const rendered = renderCoordinationState(nextState, policy); + const regex = new RegExp( + `\\n?[\\s\\S]*?\\n?`, + 'm' + ); + + if (regex.test(body)) { + return body.replace(regex, `\n${rendered}\n`).trim() + '\n'; + } + + const trimmed = body.trimEnd(); + if (!trimmed) { + return `${rendered}\n`; + } + + return `${trimmed}\n\n${rendered}\n`; +} + +module.exports = { + escapeRegExp, + extractCoordinationState, + extractIssueReferences, + extractTasks, + mergeIssueBody, + normalizeBodyForComparison, + parseStringList, + renderCoordinationState, +}; diff --git a/scripts/lib/github-coordination/policy.js b/scripts/lib/github-coordination/policy.js new file mode 100644 index 00000000..72fc7cab --- /dev/null +++ b/scripts/lib/github-coordination/policy.js @@ -0,0 +1,107 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_CONFIG_FILE = 'github-native-coordination.json'; +const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', 'config', DEFAULT_CONFIG_FILE); +const DEFAULT_SECTION_MARKER = 'ecc-coordination'; +const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1'; +const DEFAULT_LABELS = Object.freeze({ + epic: 'epic', + available: 'coordination:available', + claimed: 'coordination:claimed', + ready: 'coordination:ready', + blocked: 'coordination:blocked', + validated: 'coordination:validated', + reviewRequested: 'coordination:review-requested', + reviewApproved: 'coordination:review-approved', + reviewChangesRequested: 'coordination:review-changes-requested', + published: 'coordination:published', + synced: 'coordination:synced', +}); +const DEFAULT_POLICY = Object.freeze({ + schemaVersion: DEFAULT_SCHEMA_VERSION, + sectionMarker: DEFAULT_SECTION_MARKER, + labels: DEFAULT_LABELS, + review: { + required: true, + defaultMode: 'required', + }, + validation: { + required: true, + }, + branchModel: { + epicOnly: true, + taskBranches: false, + }, + project: { + enabled: false, + fieldNames: { + status: 'Status', + owner: 'Owner', + branch: 'Branch', + validation: 'Validation', + review: 'Review', + }, + }, +}); + +function loadPolicy(rootDir = process.cwd(), configPath = null) { + const resolvedPath = configPath + ? path.resolve(configPath) + : path.join(rootDir, 'config', DEFAULT_CONFIG_FILE); + + if (!fs.existsSync(resolvedPath)) { + return { + ...DEFAULT_POLICY, + sourcePath: null, + }; + } + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`); + } + return { + ...DEFAULT_POLICY, + ...parsed, + labels: { + ...DEFAULT_LABELS, + ...(parsed.labels || {}), + }, + review: { + ...DEFAULT_POLICY.review, + ...(parsed.review || {}), + }, + validation: { + ...DEFAULT_POLICY.validation, + ...(parsed.validation || {}), + }, + branchModel: { + ...DEFAULT_POLICY.branchModel, + ...(parsed.branchModel || {}), + }, + project: { + ...DEFAULT_POLICY.project, + ...(parsed.project || {}), + fieldNames: { + ...DEFAULT_POLICY.project.fieldNames, + ...((parsed.project || {}).fieldNames || {}), + }, + }, + sourcePath: resolvedPath, + }; +} + +module.exports = { + DEFAULT_CONFIG_FILE, + DEFAULT_CONFIG_PATH, + DEFAULT_LABELS, + DEFAULT_POLICY, + DEFAULT_SCHEMA_VERSION, + DEFAULT_SECTION_MARKER, + loadPolicy, +}; diff --git a/scripts/lib/github-coordination/state.js b/scripts/lib/github-coordination/state.js new file mode 100644 index 00000000..04e8c747 --- /dev/null +++ b/scripts/lib/github-coordination/state.js @@ -0,0 +1,246 @@ +'use strict'; + +const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION } = require('./policy'); +const { extractIssueReferences, extractTasks } = require('./parsing'); +const { normalizeLabels, listIssues, editIssue } = require('./gh-api'); + +function slugifySegment(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unknown'; +} + +function defaultCoordinationState(issue, policy = DEFAULT_POLICY) { + return { + schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: 'epic', + status: 'available', + owner: issue && issue.author && issue.author.login ? issue.author.login : null, + branch: null, + validation: 'pending', + review: 'not-requested', + project: { + state: 'backlog', + fields: {}, + }, + dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''), + tasks: extractTasks(issue && issue.body ? issue.body : ''), + labels: normalizeLabels(issue && issue.labels), + lastAction: 'sync', + lastActionAt: new Date().toISOString(), + lastSyncAt: new Date().toISOString(), + notes: null, + }; +} + +function getCoordinationState(issue, policy = DEFAULT_POLICY) { + const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order + const existing = extractCoordinationState(issue && issue.body, policy); + if (existing) { + return { + ...defaultCoordinationState(issue, policy), + ...existing, + project: { + ...defaultCoordinationState(issue, policy).project, + ...(existing.project || {}), + }, + tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''), + dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''), + labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels), + }; + } + return defaultCoordinationState(issue, policy); +} + +function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) { + const now = new Date().toISOString(); + const next = { + ...currentState, + schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + kind: 'epic', + lastAction: action, + lastActionAt: now, + lastSyncAt: now, + labels: normalizeLabels(issue.labels), + dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body), + tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body), + }; + + if (options.owner !== undefined) next.owner = options.owner; + if (options.branch !== undefined) next.branch = options.branch; + if (options.validation !== undefined) next.validation = options.validation; + if (options.review !== undefined) next.review = options.review; + if (options.status !== undefined) next.status = options.status; + if (options.projectState !== undefined) { + next.project = { ...(next.project || {}), state: options.projectState }; + } + if (options.notes !== undefined) next.notes = options.notes; + if (options.tasks !== undefined) next.tasks = options.tasks; + if (options.dependencies !== undefined) next.dependencies = options.dependencies; + + return next; +} + +function desiredLabelsForState(state, policy = DEFAULT_POLICY) { + const labels = []; + const known = policy.labels || DEFAULT_POLICY.labels; + + labels.push(known.epic); + labels.push(known.synced); + + if (state.status === 'available') labels.push(known.available); + if (state.status === 'claimed') labels.push(known.claimed); + if (state.status === 'ready') labels.push(known.ready); + if (state.status === 'blocked') labels.push(known.blocked); + if (state.validation === 'passed') labels.push(known.validated); + if (state.review === 'requested') labels.push(known.reviewRequested); + if (state.review === 'approved') labels.push(known.reviewApproved); + if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested); + if (state.status === 'published') labels.push(known.published); + + return Array.from(new Set(labels.filter(Boolean))).sort(); +} + +function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) { + const desired = new Set(desiredLabelsForState(state, policy)); + const current = new Set(normalizeLabels(issue.labels)); + const addLabels = Array.from(desired).filter(label => !current.has(label)); + const removeLabels = Array.from(current).filter(label => { + if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) { + return false; + } + return !desired.has(label); + }); + + if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) { + return { addLabels, removeLabels }; + } + + if (addLabels.length > 0 || removeLabels.length > 0) { + editIssue(repo, issue.number, { ...options, addLabels, removeLabels }); + } + + return { addLabels, removeLabels }; +} + +function findIssueByNumber(issues, issueNumber) { + return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null; +} + +function buildIssueComment(action, repo, issueNumber, state, extra = {}) { + const summary = [ + `ECC coordination ${action}`, + `Repo: ${repo}`, + `Issue: #${issueNumber}`, + `Status: ${state.status}`, + `Owner: ${state.owner || '(unassigned)'}`, + `Branch: ${state.branch || '(none)'}`, + `Validation: ${state.validation || 'pending'}`, + `Review: ${state.review || 'not-requested'}`, + ]; + + for (const [key, value] of Object.entries(extra)) { + summary.push(`${key}: ${value}`); + } + + summary.push('', 'This comment is part of the append-only coordination audit trail.'); + return summary.join('\n'); +} + +function mapStateToWorkItemStatus(state) { + switch (state) { + case 'blocked': + return 'blocked'; + case 'published': + return 'done'; + case 'validated': + case 'reviewing': + case 'claimed': + case 'ready': + return 'in-progress'; + case 'changes-requested': + return 'needs-review'; + case 'available': + default: + return 'open'; + } +} + +function summarizeProjectProjection(state, policy = DEFAULT_POLICY) { + return { + enabled: Boolean(policy.project && policy.project.enabled), + state: state.project && state.project.state ? state.project.state : 'backlog', + fields: { + ...(state.project && state.project.fields ? state.project.fields : {}), + }, + }; +} + +function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) { + return { + schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION, + repo, + issueNumber: issue.number, + issueUrl: issue.url || null, + issueTitle: issue.title, + action, + status: state.status, + owner: state.owner || null, + branch: state.branch || null, + validation: state.validation || 'pending', + review: state.review || 'not-requested', + project: summarizeProjectProjection(state, policy), + dependencies: Array.isArray(state.dependencies) ? state.dependencies : [], + tasks: Array.isArray(state.tasks) ? state.tasks : [], + labels: normalizeLabels(issue.labels), + workItemId: `github-${slugifySegment(repo)}-epic-${issue.number}`, + lastActionAt: state.lastActionAt || null, + lastSyncAt: state.lastSyncAt || null, + }; +} + +function assertIssueClaimable(issue, state) { + if (String(issue.state || '').toLowerCase() !== 'open') { + throw new Error(`Issue #${issue.number} is not open`); + } + + if (state.status === 'claimed' && state.owner) { + throw new Error(`Issue #${issue.number} is already claimed by ${state.owner}`); + } +} + +function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) { + if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) { + return []; + } + + const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 }); + const closed = []; + for (const dependencyNumber of dependencyNumbers) { + const issue = findIssueByNumber(issueList, dependencyNumber); + if (!issue) { + process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`); + } else if (String(issue.state || '').toLowerCase() === 'closed') { + closed.push(dependencyNumber); + } + } + + return closed; +} + +module.exports = { + assertIssueClaimable, + buildIssueComment, + buildIssueStateFromAction, + defaultCoordinationState, + desiredLabelsForState, + findIssueByNumber, + getCoordinationState, + mapStateToWorkItemStatus, + slugifySegment, + summarizeProjectProjection, + summarizeStateForOutput, + syncIssueLabels, + verifyDependenciesClosed, +}; diff --git a/scripts/lib/github-coordination/store.js b/scripts/lib/github-coordination/store.js new file mode 100644 index 00000000..cc337102 --- /dev/null +++ b/scripts/lib/github-coordination/store.js @@ -0,0 +1,65 @@ +'use strict'; + +const os = require('os'); + +const { createStateStore } = require('../state-store'); +const { DEFAULT_SCHEMA_VERSION, DEFAULT_POLICY } = require('./policy'); +const { normalizeLabels } = require('./gh-api'); +const { slugifySegment, mapStateToWorkItemStatus, summarizeProjectProjection } = require('./state'); + +function epicWorkItemId(repo, issueNumber) { + return `github-${slugifySegment(repo)}-epic-${issueNumber}`; +} + +function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) { + if (!store) { + return null; + } + + const now = new Date().toISOString(); + const metadata = { + schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION, + repo, + issueNumber: issue.number, + issueUrl: issue.url || null, + issueTitle: issue.title || null, + labels: normalizeLabels(issue.labels), + coordination: state, + projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY), + action, + actionAt: now, + syncedBy: 'ecc-github-coordination', + }; + + return store.upsertWorkItem({ + id: epicWorkItemId(repo, issue.number), + source: 'github-epic', + sourceId: String(issue.number), + title: `Epic #${issue.number}: ${issue.title}`, + status: mapStateToWorkItemStatus(state.status), + priority: state.status === 'blocked' ? 'high' : 'normal', + url: issue.url || null, + owner: state.owner || (issue.author && issue.author.login) || null, + repoRoot: options.repoRoot || process.cwd(), + sessionId: options.sessionId || null, + metadata, + updatedAt: now, + }); +} + +async function openStore(options = {}) { + if (options.dbPath === false) { + return null; + } + + return createStateStore({ + dbPath: options.dbPath, + homeDir: options.homeDir || process.env.HOME || os.homedir(), + }); +} + +module.exports = { + epicWorkItemId, + openStore, + upsertCoordinationWorkItem, +}; diff --git a/tests/lib/github-coordination.test.js b/tests/lib/github-coordination.test.js index 561d85ff..c71a4115 100644 --- a/tests/lib/github-coordination.test.js +++ b/tests/lib/github-coordination.test.js @@ -24,226 +24,285 @@ async function test(name, fn) { } } +async function runGroup(group, descriptors, counters) { + for (const { name, fn } of descriptors.filter(d => d.group === group)) { + if (await test(name, fn)) counters.passed += 1; + else counters.failed += 1; + } +} + +const DESCRIPTORS = [ + // normalizeRepo + { + group: 'normalizeRepo', + name: 'normalizeRepo returns { owner, name } for "owner/repo"', + fn: () => { + const result = normalizeRepo('acme/my-repo'); + assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' }); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on "owner/repo/extra"', + fn: () => { + assert.throws(() => normalizeRepo('owner/repo/extra'), /Invalid repo format/); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on bare string with no slash', + fn: () => { + assert.throws(() => normalizeRepo('justowner'), /Invalid repo format/); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on empty string', + fn: () => { + assert.throws(() => normalizeRepo(''), /Invalid repo format/); + }, + }, + { + group: 'normalizeRepo', + name: 'normalizeRepo throws on whitespace-only string', + fn: () => { + assert.throws(() => normalizeRepo(' '), /Invalid repo format/); + }, + }, + + // extractCoordinationState + { + group: 'extractCoordinationState', + name: 'extractCoordinationState returns null for body with no coordination section', + fn: () => { + const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.'); + assert.strictEqual(result, null); + }, + }, + { + group: 'extractCoordinationState', + name: 'extractCoordinationState returns parsed state from a proper coordination JSON block', + fn: () => { + const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' }; + const body = [ + '', + '```json', + JSON.stringify(state, null, 2), + '```', + '', + ].join('\n'); + const result = extractCoordinationState(body); + assert.ok(result !== null); + assert.strictEqual(result.status, 'available'); + assert.strictEqual(result.kind, 'epic'); + assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1'); + }, + }, + { + group: 'extractCoordinationState', + name: 'extractCoordinationState returns null when JSON block is malformed', + fn: () => { + const body = [ + '', + '```json', + '{ not valid json }', + '```', + '', + ].join('\n'); + const result = extractCoordinationState(body); + assert.strictEqual(result, null); + }, + }, + + // buildIssueStateFromAction + { + group: 'buildIssueStateFromAction', + name: 'buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', + fn: () => { + const issue = { number: 1, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'available', owner: null, branch: null, + validation: 'pending', review: 'not-requested', dependencies: [], tasks: [], + }; + const before = new Date(); + const result = buildIssueStateFromAction(issue, currentState, 'claim', { + owner: 'alice', branch: 'feat/my-branch', status: 'claimed', + }); + const after = new Date(); + + assert.strictEqual(result.status, 'claimed'); + assert.strictEqual(result.owner, 'alice'); + assert.strictEqual(result.branch, 'feat/my-branch'); + assert.strictEqual(result.lastAction, 'claim'); + assert.ok(result.lastActionAt); + const actionAt = new Date(result.lastActionAt); + assert.ok(actionAt >= before && actionAt <= after); + }, + }, + { + group: 'buildIssueStateFromAction', + name: 'buildIssueStateFromAction with "unblock" action preserves owner from existing state', + fn: () => { + const issue = { number: 2, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'blocked', owner: 'bob', branch: 'feat/blocked-branch', + validation: 'pending', review: 'not-requested', dependencies: [], tasks: [], + }; + const result = buildIssueStateFromAction(issue, currentState, 'unblock', { status: 'ready' }); + + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.owner, 'bob'); + assert.strictEqual(result.branch, 'feat/blocked-branch'); + assert.strictEqual(result.lastAction, 'unblock'); + }, + }, + { + group: 'buildIssueStateFromAction', + name: 'buildIssueStateFromAction with "validate" action sets status "validated" and validation "passed"', + fn: () => { + const issue = { number: 3, body: '', labels: [] }; + const currentState = { + schemaVersion: DEFAULT_POLICY.schemaVersion, + status: 'claimed', owner: 'carol', branch: 'feat/new', + validation: 'pending', review: 'not-requested', dependencies: [], tasks: [], + }; + const result = buildIssueStateFromAction(issue, currentState, 'validate', { + status: 'validated', validation: 'passed', + }); + + assert.strictEqual(result.status, 'validated'); + assert.strictEqual(result.validation, 'passed'); + assert.strictEqual(result.lastAction, 'validate'); + assert.strictEqual(result.owner, 'carol'); + }, + }, + + // desiredLabelsForState + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "available" includes "coordination:available"', + fn: () => { + const labels = desiredLabelsForState({ status: 'available' }); + assert.ok(Array.isArray(labels)); + assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`); + }, + }, + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"', + fn: () => { + const labels = desiredLabelsForState({ status: 'claimed' }); + assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`); + assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); + }, + }, + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "blocked" includes "coordination:blocked"', + fn: () => { + const labels = desiredLabelsForState({ status: 'blocked' }); + assert.ok(labels.includes('coordination:blocked'), `Expected coordination:blocked in [${labels.join(', ')}]`); + assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); + }, + }, + { + group: 'desiredLabelsForState', + name: 'desiredLabelsForState for status "ready" includes "coordination:ready"', + fn: () => { + const labels = desiredLabelsForState({ status: 'ready' }); + assert.ok(labels.includes('coordination:ready'), `Expected coordination:ready in [${labels.join(', ')}]`); + }, + }, + + // extractTasks + { + group: 'extractTasks', + name: 'extractTasks returns empty array when body has no Tasks section', + fn: () => { + const tasks = extractTasks('Some issue without any task list.'); + assert.deepStrictEqual(tasks, []); + }, + }, + { + group: 'extractTasks', + name: 'extractTasks parses completed and open checkboxes under ## Tasks heading', + fn: () => { + const body = ['## Tasks', '- [x] Done task', '- [ ] Open task', '- [x] Another done task'].join('\n'); + const tasks = extractTasks(body); + const completed = tasks.filter(t => t.done); + const open = tasks.filter(t => !t.done); + assert.strictEqual(tasks.length, 3); + assert.strictEqual(completed.length, 2); + assert.strictEqual(open.length, 1); + assert.strictEqual(open[0].title, 'Open task'); + }, + }, + { + group: 'extractTasks', + name: 'extractTasks stops parsing at next heading after task section', + fn: () => { + const body = ['## Tasks', '- [x] First task', '## Notes', '- [ ] This is not a task'].join('\n'); + const tasks = extractTasks(body); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].title, 'First task'); + }, + }, + + // renderCoordinationState + { + group: 'renderCoordinationState', + name: 'renderCoordinationState returns a string containing the section marker', + fn: () => { + const state = { + schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available', + owner: null, branch: null, validation: 'pending', review: 'not-requested', + project: { state: 'backlog', fields: {} }, dependencies: [], tasks: [], labels: [], + lastAction: 'sync', lastActionAt: '2026-01-01T00:00:00.000Z', + lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null, + }; + const rendered = renderCoordinationState(state); + assert.ok(typeof rendered === 'string'); + assert.ok(rendered.includes(''), 'Missing start marker'); + assert.ok(rendered.includes(''), 'Missing end marker'); + assert.ok(rendered.includes('```json'), 'Missing json code fence'); + }, + }, + { + group: 'renderCoordinationState', + name: 'renderCoordinationState output round-trips through extractCoordinationState', + fn: () => { + const state = { + schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'claimed', + owner: 'carol', branch: 'feat/my-feature', validation: 'pending', review: 'requested', + project: { state: 'in-progress', fields: {} }, dependencies: [5, 6], + tasks: [{ title: 'Write tests', done: false }], labels: ['coordination:claimed'], + lastAction: 'claim', lastActionAt: '2026-01-01T00:00:00.000Z', + lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null, + }; + const rendered = renderCoordinationState(state); + const extracted = extractCoordinationState(rendered); + assert.ok(extracted !== null); + assert.strictEqual(extracted.status, 'claimed'); + assert.strictEqual(extracted.owner, 'carol'); + assert.deepStrictEqual(extracted.dependencies, [5, 6]); + }, + }, +]; + async function runTests() { console.log('\n=== Testing github-coordination ===\n'); - let passed = 0; - let failed = 0; + const counters = { passed: 0, failed: 0 }; + const groups = [...new Set(DESCRIPTORS.map(d => d.group))]; - // normalizeRepo + for (const group of groups) { + await runGroup(group, DESCRIPTORS, counters); + } - if (await test('normalizeRepo returns { owner, name } for "owner/repo"', () => { - const result = normalizeRepo('acme/my-repo'); - assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' }); - })) passed += 1; else failed += 1; - - if (await test('normalizeRepo throws on "owner/repo/extra"', () => { - assert.throws( - () => normalizeRepo('owner/repo/extra'), - /Invalid repo format/ - ); - })) passed += 1; else failed += 1; - - if (await test('normalizeRepo throws on bare string with no slash', () => { - assert.throws( - () => normalizeRepo('justowner'), - /Invalid repo format/ - ); - })) passed += 1; else failed += 1; - - // extractCoordinationState - - if (await test('extractCoordinationState returns null for body with no coordination section', () => { - const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.'); - assert.strictEqual(result, null); - })) passed += 1; else failed += 1; - - if (await test('extractCoordinationState returns parsed state from a proper coordination JSON block', () => { - const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' }; - const body = [ - '', - '```json', - JSON.stringify(state, null, 2), - '```', - '', - ].join('\n'); - const result = extractCoordinationState(body); - assert.ok(result !== null); - assert.strictEqual(result.status, 'available'); - assert.strictEqual(result.kind, 'epic'); - assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1'); - })) passed += 1; else failed += 1; - - if (await test('extractCoordinationState returns null when JSON block is malformed', () => { - const body = [ - '', - '```json', - '{ not valid json }', - '```', - '', - ].join('\n'); - const result = extractCoordinationState(body); - assert.strictEqual(result, null); - })) passed += 1; else failed += 1; - - // buildIssueStateFromAction - - if (await test('buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', () => { - const issue = { number: 1, body: '', labels: [] }; - const currentState = { - schemaVersion: DEFAULT_POLICY.schemaVersion, - status: 'available', - owner: null, - branch: null, - validation: 'pending', - review: 'not-requested', - dependencies: [], - tasks: [], - }; - const before = new Date(); - const result = buildIssueStateFromAction(issue, currentState, 'claim', { - owner: 'alice', - branch: 'feat/my-branch', - status: 'claimed', - }); - const after = new Date(); - - assert.strictEqual(result.status, 'claimed'); - assert.strictEqual(result.owner, 'alice'); - assert.strictEqual(result.branch, 'feat/my-branch'); - assert.strictEqual(result.lastAction, 'claim'); - assert.ok(result.lastActionAt); - const actionAt = new Date(result.lastActionAt); - assert.ok(actionAt >= before && actionAt <= after); - })) passed += 1; else failed += 1; - - if (await test('buildIssueStateFromAction with "unblock" action preserves owner from existing state', () => { - const issue = { number: 2, body: '', labels: [] }; - const currentState = { - schemaVersion: DEFAULT_POLICY.schemaVersion, - status: 'blocked', - owner: 'bob', - branch: 'feat/blocked-branch', - validation: 'pending', - review: 'not-requested', - dependencies: [], - tasks: [], - }; - const result = buildIssueStateFromAction(issue, currentState, 'unblock', { - status: 'ready', - }); - - assert.strictEqual(result.status, 'ready'); - assert.strictEqual(result.owner, 'bob'); - assert.strictEqual(result.branch, 'feat/blocked-branch'); - assert.strictEqual(result.lastAction, 'unblock'); - })) passed += 1; else failed += 1; - - // desiredLabelsForState - - if (await test('desiredLabelsForState for status "available" includes "coordination:available"', () => { - const labels = desiredLabelsForState({ status: 'available' }); - assert.ok(Array.isArray(labels)); - assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`); - })) passed += 1; else failed += 1; - - if (await test('desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"', () => { - const labels = desiredLabelsForState({ status: 'claimed' }); - assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`); - assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`); - })) passed += 1; else failed += 1; - - // extractTasks - - if (await test('extractTasks returns empty array when body has no Tasks section', () => { - const body = 'Some issue without any task list.'; - const tasks = extractTasks(body); - assert.deepStrictEqual(tasks, []); - })) passed += 1; else failed += 1; - - if (await test('extractTasks parses completed and open checkboxes under ## Tasks heading', () => { - const body = [ - '## Tasks', - '- [x] Done task', - '- [ ] Open task', - '- [x] Another done task', - ].join('\n'); - const tasks = extractTasks(body); - const completed = tasks.filter(t => t.done); - const open = tasks.filter(t => !t.done); - assert.strictEqual(tasks.length, 3); - assert.strictEqual(completed.length, 2); - assert.strictEqual(open.length, 1); - assert.strictEqual(open[0].title, 'Open task'); - })) passed += 1; else failed += 1; - - if (await test('extractTasks stops parsing at next heading after task section', () => { - const body = [ - '## Tasks', - '- [x] First task', - '## Notes', - '- [ ] This is not a task', - ].join('\n'); - const tasks = extractTasks(body); - assert.strictEqual(tasks.length, 1); - assert.strictEqual(tasks[0].title, 'First task'); - })) passed += 1; else failed += 1; - - // renderCoordinationState - - if (await test('renderCoordinationState returns a string containing the section marker', () => { - const state = { - schemaVersion: 'ecc.github.coordination.v1', - kind: 'epic', - status: 'available', - owner: null, - branch: null, - validation: 'pending', - review: 'not-requested', - project: { state: 'backlog', fields: {} }, - dependencies: [], - tasks: [], - labels: [], - lastAction: 'sync', - lastActionAt: '2026-01-01T00:00:00.000Z', - lastSyncAt: '2026-01-01T00:00:00.000Z', - notes: null, - }; - const rendered = renderCoordinationState(state); - assert.ok(typeof rendered === 'string'); - assert.ok(rendered.includes(''), 'Missing start marker'); - assert.ok(rendered.includes(''), 'Missing end marker'); - assert.ok(rendered.includes('```json'), 'Missing json code fence'); - })) passed += 1; else failed += 1; - - if (await test('renderCoordinationState output round-trips through extractCoordinationState', () => { - const state = { - schemaVersion: 'ecc.github.coordination.v1', - kind: 'epic', - status: 'claimed', - owner: 'carol', - branch: 'feat/my-feature', - validation: 'pending', - review: 'requested', - project: { state: 'in-progress', fields: {} }, - dependencies: [5, 6], - tasks: [{ title: 'Write tests', done: false }], - labels: ['coordination:claimed'], - lastAction: 'claim', - lastActionAt: '2026-01-01T00:00:00.000Z', - lastSyncAt: '2026-01-01T00:00:00.000Z', - notes: null, - }; - const rendered = renderCoordinationState(state); - const extracted = extractCoordinationState(rendered); - assert.ok(extracted !== null); - assert.strictEqual(extracted.status, 'claimed'); - assert.strictEqual(extracted.owner, 'carol'); - assert.deepStrictEqual(extracted.dependencies, [5, 6]); - })) passed += 1; else failed += 1; - - console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); - process.exit(failed > 0 ? 1 : 0); + console.log(`\nResults: Passed: ${counters.passed}, Failed: ${counters.failed}`); + process.exit(counters.failed > 0 ? 1 : 0); } runTests(); diff --git a/tests/scripts/github-coordination.test.js b/tests/scripts/github-coordination.test.js index 0a7bfb99..aca4edbf 100644 --- a/tests/scripts/github-coordination.test.js +++ b/tests/scripts/github-coordination.test.js @@ -66,11 +66,11 @@ function parseJson(stdout) { async function test(name, fn) { try { await fn(); - console.log(` PASS ${name}`); + process.stdout.write(` PASS ${name}\n`); return true; } catch (error) { - console.log(` FAIL ${name}`); - console.log(` Error: ${error.message}`); + process.stdout.write(` FAIL ${name}\n`); + process.stdout.write(` Error: ${error.message}\n`); return false; } } @@ -85,7 +85,7 @@ async function readStore(dbPath) { } async function runTests() { - console.log('\n=== Testing github-coordination.js ===\n'); + process.stdout.write('\n=== Testing github-coordination.js ===\n\n'); let passed = 0; let failed = 0; @@ -228,7 +228,7 @@ async function runTests() { } })) passed++; else failed++; - console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); process.exit(failed > 0 ? 1 : 0); }