diff --git a/README.md b/README.md index 618a3d7d..3d5e5b80 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ This repo is the raw code only. The guides explain everything. - **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system. - **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone. - **ECC 2.0 alpha is in-tree** — the Rust control-plane prototype in `ecc2/` now builds locally and exposes `dashboard`, `start`, `sessions`, `status`, `stop`, `resume`, and `daemon` commands. It is usable as an alpha, not yet a general release. -- **Operator status snapshots** — `ecc status --markdown --write status.md` turns the local state store into a portable handoff covering readiness, active sessions, skill-run health, install health, pending governance events, and linked work items from Linear/GitHub/handoffs. Use `ecc work-items upsert ...` to add or update those linked work items from the CLI, and `ecc status --exit-code` to fail automation when readiness needs attention. +- **Operator status snapshots** — `ecc status --markdown --write status.md` turns the local state store into a portable handoff covering readiness, active sessions, skill-run health, install health, pending governance events, and linked work items from Linear/GitHub/handoffs. Use `ecc work-items upsert ...` for manual entries, `ecc work-items sync-github --repo owner/repo` for PR/issue queue state, and `ecc status --exit-code` to fail automation when readiness needs attention. - **Ecosystem hardening** — AgentShield, ECC Tools cost controls, billing portal work, and website refreshes continue to ship around the core plugin instead of drifting into separate silos. ### v1.9.0 — Selective Install & Language Expansion (Mar 2026) diff --git a/scripts/ecc.js b/scripts/ecc.js index ce15736a..5fbeac97 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -118,6 +118,7 @@ Examples: ecc sessions ecc sessions session-active --json ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked + ecc work-items sync-github --repo affaan-m/everything-claude-code ecc session-inspect claude:latest ecc loop-status --json ecc uninstall --target antigravity --dry-run diff --git a/scripts/work-items.js b/scripts/work-items.js index 86a58361..4add7f04 100644 --- a/scripts/work-items.js +++ b/scripts/work-items.js @@ -2,10 +2,12 @@ 'use strict'; const os = require('os'); +const { spawnSync } = require('child_process'); const { createStateStore } = require('./lib/state-store'); const VALUE_FLAGS = new Set([ '--db', + '--github-repo', '--id', '--limit', '--metadata-json', @@ -29,6 +31,7 @@ Usage: node scripts/work-items.js show [--db ] [--json] node scripts/work-items.js upsert [] --title [options] [--json] node scripts/work-items.js close <id> [--status done] [--db <path>] [--json] + node scripts/work-items.js sync-github --repo <owner/repo> [--db <path>] [--json] Track Linear, GitHub, handoff, and manual roadmap items in the ECC SQLite state store so "ecc status" can include linked work and blocked operator follow-up. @@ -42,7 +45,8 @@ Options: --url <url> Optional source URL --owner <owner> Optional owner label --repo-root <path> Optional repo root to associate with this item - --repo <path> Alias for --repo-root + --repo <path> GitHub repo for sync-github, otherwise alias for --repo-root + --github-repo <owner/repo> Explicit GitHub repo for sync-github --session-id <id> Optional ECC session id --session <id> Alias for --session-id --metadata-json <json> Optional JSON metadata payload @@ -54,11 +58,13 @@ Options: function assignOption(options, flag, value) { if (flag === '--db') options.dbPath = value; + else if (flag === '--github-repo') options.githubRepo = value; else if (flag === '--id') options.id = value; else if (flag === '--limit') options.limit = value; else if (flag === '--metadata-json') options.metadataJson = value; else if (flag === '--owner') options.owner = value; else if (flag === '--priority') options.priority = value; + else if (flag === '--repo' && options.command === 'sync-github') options.githubRepo = value; else if (flag === '--repo' || flag === '--repo-root') options.repoRoot = value; else if (flag === '--session' || flag === '--session-id') options.sessionId = value; else if (flag === '--source') options.source = value; @@ -131,6 +137,192 @@ function normalizeLimit(value) { return parsed; } +function runGhJson(args) { + const shimPath = process.env.ECC_GH_SHIM; + const command = shimPath ? process.execPath : 'gh'; + const commandArgs = shimPath ? [shimPath, ...args] : args; + const displayCommand = shimPath ? `node ${shimPath} ${args.join(' ')}` : `gh ${args.join(' ')}`; + const result = spawnSync(command, commandArgs, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw new Error(`Failed to run gh: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`${displayCommand} failed: ${(result.stderr || result.stdout || '').trim()}`); + } + + try { + return JSON.parse(result.stdout || '[]'); + } catch (error) { + throw new Error(`${displayCommand} returned invalid JSON: ${error.message}`); + } +} + +function slugifyWorkItemSegment(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unknown'; +} + +function githubWorkItemId(repo, type, number) { + return `github-${slugifyWorkItemSegment(repo)}-${type}-${number}`; +} + +function githubPrStatus(pr) { + if (pr.isDraft || pr.mergeStateStatus === 'DIRTY') { + return 'blocked'; + } + + return 'needs-review'; +} + +function githubAuthorLogin(item) { + return item && item.author && item.author.login ? item.author.login : null; +} + +function buildGithubPrWorkItem(repo, pr, options = {}) { + return { + id: githubWorkItemId(repo, 'pr', pr.number), + source: 'github-pr', + sourceId: String(pr.number), + title: `PR #${pr.number}: ${pr.title}`, + status: githubPrStatus(pr), + priority: pr.isDraft || pr.mergeStateStatus === 'DIRTY' ? 'high' : 'normal', + url: pr.url || null, + owner: githubAuthorLogin(pr), + repoRoot: options.repoRoot || process.cwd(), + sessionId: options.sessionId || null, + metadata: { + repo, + type: 'pull_request', + mergeStateStatus: pr.mergeStateStatus || null, + isDraft: Boolean(pr.isDraft), + headRefName: pr.headRefName || null, + sourceUpdatedAt: pr.updatedAt || null, + syncedBy: 'ecc-work-items-sync-github', + }, + }; +} + +function buildGithubIssueWorkItem(repo, issue, options = {}) { + return { + id: githubWorkItemId(repo, 'issue', issue.number), + source: 'github-issue', + sourceId: String(issue.number), + title: `Issue #${issue.number}: ${issue.title}`, + status: 'needs-review', + priority: 'normal', + url: issue.url || null, + owner: githubAuthorLogin(issue), + repoRoot: options.repoRoot || process.cwd(), + sessionId: options.sessionId || null, + metadata: { + repo, + type: 'issue', + labels: Array.isArray(issue.labels) ? issue.labels.map(label => label.name || label).filter(Boolean) : [], + sourceUpdatedAt: issue.updatedAt || null, + syncedBy: 'ecc-work-items-sync-github', + }, + }; +} + +function closeStaleGithubItems(store, repo, activeIds, options = {}) { + const payload = store.listWorkItems({ limit: options.limit || 10000 }); + const closed = []; + for (const item of payload.items) { + if (!item.metadata || item.metadata.syncedBy !== 'ecc-work-items-sync-github') { + continue; + } + if (item.metadata.repo !== repo || activeIds.has(item.id)) { + continue; + } + if (item.status === 'closed' || item.status === 'done') { + continue; + } + closed.push(store.upsertWorkItem({ + ...item, + status: 'closed', + updatedAt: new Date().toISOString(), + metadata: { + ...item.metadata, + sourceClosedAt: new Date().toISOString(), + }, + })); + } + return closed; +} + +function syncGithubWorkItems(store, options) { + const repo = options.githubRepo; + if (!repo) { + throw new Error('Missing GitHub repo. Pass --repo <owner/repo>.'); + } + + const limit = normalizeLimit(options.limit); + const prs = runGhJson([ + 'pr', + 'list', + '--repo', + repo, + '--state', + 'open', + '--limit', + String(limit), + '--json', + 'number,title,author,url,updatedAt,mergeStateStatus,isDraft,headRefName', + ]); + const issues = runGhJson([ + 'issue', + 'list', + '--repo', + repo, + '--state', + 'open', + '--limit', + String(limit), + '--json', + 'number,title,author,url,updatedAt,labels', + ]); + + const syncedAt = new Date().toISOString(); + const activeIds = new Set(); + const items = []; + for (const pr of prs) { + const payload = buildGithubPrWorkItem(repo, pr, options); + activeIds.add(payload.id); + items.push(store.upsertWorkItem({ + ...payload, + createdAt: undefined, + updatedAt: syncedAt, + })); + } + for (const issue of issues) { + const payload = buildGithubIssueWorkItem(repo, issue, options); + activeIds.add(payload.id); + items.push(store.upsertWorkItem({ + ...payload, + createdAt: undefined, + updatedAt: syncedAt, + })); + } + + const closedItems = closeStaleGithubItems(store, repo, activeIds, { limit: Math.max(limit * 4, 1000) }); + return { + repo, + syncedAt, + prCount: prs.length, + issueCount: issues.length, + closedCount: closedItems.length, + items, + closedItems, + }; +} + function buildUpsertPayload(options, existing = null) { const id = resolveWorkItemId(options); if (!id) { @@ -194,6 +386,20 @@ function printWorkItemList(payload) { } } +function printGithubSyncResult(payload) { + console.log(`GitHub sync: ${payload.repo}`); + console.log(` Open PRs: ${payload.prCount}`); + console.log(` Open issues: ${payload.issueCount}`); + console.log(` Closed stale items: ${payload.closedCount}`); + if (payload.items.length === 0 && payload.closedItems.length === 0) { + console.log(' Work items changed: none'); + return; + } + for (const item of [...payload.items, ...payload.closedItems]) { + console.log(` - ${item.id} ${item.status}: ${item.title}`); + } +} + async function main() { let store = null; @@ -269,6 +475,16 @@ async function main() { return; } + if (options.command === 'sync-github') { + const payload = syncGithubWorkItems(store, options); + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + printGithubSyncResult(payload); + } + return; + } + throw new Error(`Unknown command: ${options.command}`); } catch (error) { console.error(`Error: ${error.message}`); @@ -286,6 +502,9 @@ if (require.main === module) { module.exports = { buildUpsertPayload, + buildGithubIssueWorkItem, + buildGithubPrWorkItem, main, parseArgs, + syncGithubWorkItems, }; diff --git a/tests/lib/state-store.test.js b/tests/lib/state-store.test.js index 79fea25b..57b9ef3e 100644 --- a/tests/lib/state-store.test.js +++ b/tests/lib/state-store.test.js @@ -49,6 +49,59 @@ function runNode(scriptPath, args = [], options = {}) { }); } +function createGhShim(binDir) { + fs.mkdirSync(binDir, { recursive: true }); + const shimJs = path.join(binDir, 'gh.js'); + fs.writeFileSync(shimJs, ` +const mode = process.env.ECC_FAKE_GH_MODE || 'open'; +const args = process.argv.slice(2); +function write(payload) { + process.stdout.write(JSON.stringify(payload)); +} +if (args[0] === 'pr' && args[1] === 'list') { + if (mode === 'empty') write([]); + else write([ + { + number: 3, + title: 'Conflicting queue cleanup', + author: { login: 'contributor-a' }, + url: 'https://github.com/affaan-m/everything-claude-code/pull/3', + updatedAt: '2026-05-11T10:00:00Z', + mergeStateStatus: 'DIRTY', + isDraft: false, + headRefName: 'fix/conflict' + }, + { + number: 4, + title: 'Clean docs update', + author: { login: 'contributor-b' }, + url: 'https://github.com/affaan-m/everything-claude-code/pull/4', + updatedAt: '2026-05-11T11:00:00Z', + mergeStateStatus: 'CLEAN', + isDraft: false, + headRefName: 'docs/clean' + } + ]); +} else if (args[0] === 'issue' && args[1] === 'list') { + if (mode === 'empty') write([]); + else write([ + { + number: 9, + title: 'Track release blocker', + author: { login: 'reporter' }, + url: 'https://github.com/affaan-m/everything-claude-code/issues/9', + updatedAt: '2026-05-11T12:00:00Z', + labels: [{ name: 'release' }] + } + ]); +} else { + process.stderr.write('unexpected gh args: ' + args.join(' ')); + process.exit(2); +} +`, 'utf8'); + return shimJs; +} + function parseJson(stdout) { return JSON.parse(stdout.trim()); } @@ -796,6 +849,75 @@ async function runTests() { } })) passed += 1; else failed += 1; + if (await test('work-items CLI syncs GitHub PRs and issues into readiness', async () => { + const testDir = createTempDir('ecc-work-items-github-'); + const dbPath = path.join(testDir, 'state.db'); + const binDir = path.join(testDir, 'bin'); + const repo = 'affaan-m/everything-claude-code'; + + try { + const env = { + ECC_GH_SHIM: createGhShim(binDir), + }; + + const syncResult = runNode(WORK_ITEMS_SCRIPT, [ + 'sync-github', + '--repo', + repo, + '--db', + dbPath, + '--limit', + '10', + '--json', + ], { cwd: testDir, env }); + assert.strictEqual(syncResult.status, 0, syncResult.stderr); + const syncPayload = parseJson(syncResult.stdout); + assert.strictEqual(syncPayload.repo, repo); + assert.strictEqual(syncPayload.prCount, 2); + assert.strictEqual(syncPayload.issueCount, 1); + assert.strictEqual(syncPayload.closedCount, 0); + assert.strictEqual(syncPayload.items.length, 3); + assert.strictEqual(syncPayload.items[0].id, 'github-affaan-m-everything-claude-code-pr-3'); + assert.strictEqual(syncPayload.items[0].status, 'blocked'); + assert.strictEqual(syncPayload.items[1].status, 'needs-review'); + assert.strictEqual(syncPayload.items[2].metadata.labels[0], 'release'); + + const statusResult = runNode(STATUS_SCRIPT, ['--db', dbPath, '--json', '--exit-code']); + assert.strictEqual(statusResult.status, 2, statusResult.stderr); + const statusPayload = parseJson(statusResult.stdout); + assert.strictEqual(statusPayload.readiness.blockedWorkItems, 3); + + const closeResult = runNode(WORK_ITEMS_SCRIPT, [ + 'sync-github', + '--repo', + repo, + '--db', + dbPath, + '--json', + ], { + cwd: testDir, + env: { + ...env, + ECC_FAKE_GH_MODE: 'empty', + }, + }); + assert.strictEqual(closeResult.status, 0, closeResult.stderr); + const closePayload = parseJson(closeResult.stdout); + assert.strictEqual(closePayload.prCount, 0); + assert.strictEqual(closePayload.issueCount, 0); + assert.strictEqual(closePayload.closedCount, 3); + assert.ok(closePayload.closedItems.every(item => item.status === 'closed')); + + const cleanStatusResult = runNode(STATUS_SCRIPT, ['--db', dbPath, '--json', '--exit-code']); + assert.strictEqual(cleanStatusResult.status, 0, cleanStatusResult.stderr); + const cleanStatusPayload = parseJson(cleanStatusResult.stdout); + assert.strictEqual(cleanStatusPayload.readiness.blockedWorkItems, 0); + assert.strictEqual(cleanStatusPayload.workItems.closedCount, 3); + } finally { + cleanupTempDir(testDir); + } + })) passed += 1; else failed += 1; + if (await test('sessions CLI supports list and detail views in human-readable and --json output', async () => { const testDir = createTempDir('ecc-state-cli-'); const dbPath = path.join(testDir, 'state.db');