diff --git a/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md b/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md index 57710a3d..2442b1e2 100644 --- a/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md +++ b/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md @@ -11,7 +11,7 @@ clean checkout. | --- | --- | --- | | PR queue | Current | 0 open PRs across checked repos | | Issue queue | Current | 0 open issues across checked repos | -| Discussions | Current | 58 main-repo discussions; 0 need maintainer touch | +| Discussions | Current | 58 main-repo discussions; 0 need maintainer touch; 0 answerable discussions missing accepted answers | | Local worktree | Current with caveat | `main...origin/main`; unrelated `?? docs/drafts/` ignored | | Security sweep | Current with follow-up | IOC scan, audits, and package-manager hardening completed | | Linear roadmap | Current with follow-up | `ECC Platform Roadmap`, ITO-44 through ITO-59 | @@ -26,7 +26,8 @@ Run these from `everything-claude-code` unless a row says otherwise. | Evidence | Command | 2026-05-15 result | | --- | --- | --- | -| Platform audit | `node scripts/platform-audit.js --json --allow-untracked docs/drafts/` | `ready: true`; open PRs 0/20; open issues 0/20; discussions needing maintainer touch 0; blocking dirty files 0 | +| Platform audit | `node scripts/platform-audit.js --json --allow-untracked docs/drafts/` | `ready: true`; open PRs 0/20; open issues 0/20; discussions needing maintainer touch 0; answerable discussions missing accepted answers 0; blocking dirty files 0 | +| Discussion audit | `node scripts/discussion-audit.js --json --repo affaan-m/everything-claude-code` | `ready: true`; 58 discussions sampled; 0 need maintainer touch; 0 answerable discussions missing accepted answers | | Main repo status | `git status --short --branch` | `## main...origin/main`; `?? docs/drafts/` remains unrelated | | Main commit | `git rev-parse HEAD` | `c0f8c3bc813360f29e9f2b66bcae7e977cd03327` | | Main repo PRs/issues | GitHub connector and `gh` readback | 0 open PRs; 0 open issues | @@ -45,7 +46,7 @@ Run these from `everything-claude-code` unless a row says otherwise. | --- | --- | --- | --- | | Keep PRs under 20 | `scripts/platform-audit.js`; live GitHub readback | Current | Repeat before release | | Keep issues under 20 | `scripts/platform-audit.js`; live GitHub readback | Current | Repeat before release | -| Respond and manage discussions | Discussion GraphQL sweep; #1923 answer marked | Current | Automate repeatable queue scanner | +| Respond and manage discussions | `scripts/discussion-audit.js`; #1923 answer marked | Current | Repeat before release | | ECC 2.0 preview pack ready | `preview-pack-manifest.md`; `publication-readiness.md` | In progress | Final publish evidence still pending | | Include Hermes specialized skills | `docs/HERMES-SETUP.md`; `skills/hermes-imports/SKILL.md` | In progress | Final preview-pack smoke still pending | | Name-change and availability path | `naming-and-publication-matrix.md`; ITO-46 | In progress | Final name/package/channel choice not approved | @@ -71,7 +72,7 @@ Active issue state after this pass: | --- | --- | --- | | ITO-44 | Completion audit and readiness dashboard | In Progress | | ITO-57 | Supply-chain intelligence and local protection loop | In Progress | -| ITO-59 | Discussions and public response queue | In Progress | +| ITO-59 | Discussions and public response queue | Current; Linear status sync pending | Still-open lanes: @@ -91,8 +92,8 @@ Still-open lanes: - The checked GitHub queues are below the explicit target, so the next work should not spend more time closing nonexistent PRs/issues. -- The discussion queue is current, but repeatability is weak. ITO-59 remains - open until the sweep is scripted or documented as an operator command. +- The discussion queue is current and repeatable through `discussion:audit`. + ITO-59 remains open only for recurring Linear/status synchronization. - The Mini Shai-Hulud/TanStack protection pass is strong enough for current local protection, but ITO-57 remains open until incident response and IOC updates become a durable workflow. @@ -104,8 +105,8 @@ Still-open lanes: 1. Build the ITO-44 completion dashboard into a repeatable command or generated markdown artifact. -2. Productize the ITO-59 discussion queue sweep so the maintainer-touch and - accepted-answer checks do not depend on manual GraphQL. +2. Run `platform:audit` and `discussion:audit` from the final release commit + before recording publication evidence. 3. Continue ITO-57 by turning emergency hardening into documented incident response and scanner update workflow. 4. Resume release/publication lanes ITO-45, ITO-46, and ITO-56 only after the diff --git a/docs/releases/2.0.0-rc.1/publication-readiness.md b/docs/releases/2.0.0-rc.1/publication-readiness.md index e1a7f312..709429f2 100644 --- a/docs/releases/2.0.0-rc.1/publication-readiness.md +++ b/docs/releases/2.0.0-rc.1/publication-readiness.md @@ -72,9 +72,9 @@ Record the exact commit SHA and command output before any publication action: | Release surface | `node tests/docs/ecc2-release-surface.test.js` | 0 failures | `publication-evidence-2026-05-13.md`: 18/18 passed | | Optional Rust surface | `cd ecc2 && cargo test` | 0 failures or explicit deferral | `publication-evidence-2026-05-15.md`: 462/462 passed, existing warnings only after PR #1935 current-dir guard | | Queue baseline | `gh pr list` / `gh issue list` across trunk, AgentShield, JARVIS, ECC Tools, and ECC website | Under 20 open PRs and under 20 open issues | `publication-evidence-2026-05-15.md`: platform audit ready, 0 open PRs and 0 open issues across checked repos | -| Discussion baseline | GraphQL discussion count and maintainer-touch sweep | No unmanaged active discussion queue | `publication-evidence-2026-05-15.md`: 58 trunk discussions, 0 without maintainer touch; other tracked repos disabled or 0 | +| Discussion baseline | `node scripts/discussion-audit.js --json` | No unmanaged active discussion queue and no answerable Q&A missing an accepted answer | `publication-evidence-2026-05-15.md`: 58 trunk discussions, 0 without maintainer touch; other tracked repos disabled or 0 | | Linear roadmap | Linear project and issue readback | Detailed roadmap exists with release, security, AgentShield, ECC Tools, legacy, and observability lanes | `publication-evidence-2026-05-15.md`: project and 16 issue lanes recorded | -| Operator readiness dashboard | `node scripts/platform-audit.js --json --allow-untracked docs/drafts/` plus prompt-to-artifact audit | Current queue state mapped to macro-goal deliverables and incomplete gaps | `operator-readiness-dashboard-2026-05-15.md`: live status, command evidence, Linear state, and next work order | +| Operator readiness dashboard | `node scripts/platform-audit.js --json --allow-untracked docs/drafts/` plus `node scripts/discussion-audit.js --json` | Current queue state mapped to macro-goal deliverables and incomplete gaps | `operator-readiness-dashboard-2026-05-15.md`: live status, command evidence, Linear state, and next work order | ## Do Not Publish If diff --git a/package.json b/package.json index 207c81bc..a3df59dd 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "scripts/claw.js", "scripts/codex/merge-codex-config.js", "scripts/codex/merge-mcp-config.js", + "scripts/discussion-audit.js", "scripts/doctor.js", "scripts/ecc.js", "scripts/gemini-adapt-agents.js", @@ -296,6 +297,7 @@ "harness:audit": "node scripts/harness-audit.js", "observability:ready": "node scripts/observability-readiness.js", "platform:audit": "node scripts/platform-audit.js", + "discussion:audit": "node scripts/discussion-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", "claw": "node scripts/claw.js", "orchestrate:status": "node scripts/orchestration-status.js", diff --git a/scripts/discussion-audit.js b/scripts/discussion-audit.js new file mode 100644 index 00000000..b8b89e2b --- /dev/null +++ b/scripts/discussion-audit.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + DEFAULT_DISCUSSION_FIRST, + emptyDiscussionSummary, + fetchDiscussionSummary, +} = require('./lib/github-discussions'); + +const SCHEMA_VERSION = 'ecc.discussion-audit.v1'; +const DEFAULT_REPOS = Object.freeze([ + 'affaan-m/everything-claude-code', + 'affaan-m/agentshield', + 'affaan-m/JARVIS', + 'ECC-Tools/ECC-Tools', + 'ECC-Tools/ECC-website', +]); + +function usage() { + console.log([ + 'Usage: node scripts/discussion-audit.js [options]', + '', + 'Audit GitHub discussions for maintainer touch and accepted-answer gaps.', + '', + 'Options:', + ' --format ', + ' Output format (default: text)', + ' --json Alias for --format json', + ' --markdown Alias for --format markdown', + ' --write Write json or markdown output to a file', + ' --repo GitHub repo to inspect; repeatable', + ' --first Discussions to sample per repo (default: 100)', + ' --use-env-github-token Keep GITHUB_TOKEN when invoking gh', + ' --exit-code Return 2 when the audit is not ready', + ' --help, -h Show this help', + ].join('\n')); +} + +function readValue(args, index, flagName) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flagName} requires a value`); + } + return value; +} + +function parseIntegerFlag(value, flagName) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid ${flagName}: ${value}`); + } + return parsed; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + exitCode: false, + first: DEFAULT_DISCUSSION_FIRST, + format: 'text', + help: false, + repos: [], + useEnvGithubToken: false, + writePath: null, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--help' || arg === '-h') { + parsed.help = true; + continue; + } + + if (arg === '--format') { + parsed.format = readValue(args, index, arg).toLowerCase(); + index += 1; + continue; + } + + if (arg.startsWith('--format=')) { + parsed.format = arg.slice('--format='.length).toLowerCase(); + continue; + } + + if (arg === '--json') { + parsed.format = 'json'; + continue; + } + + if (arg === '--markdown') { + parsed.format = 'markdown'; + continue; + } + + if (arg === '--write') { + parsed.writePath = path.resolve(readValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--write=')) { + parsed.writePath = path.resolve(arg.slice('--write='.length)); + continue; + } + + if (arg === '--repo') { + parsed.repos.push(readValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--repo=')) { + parsed.repos.push(arg.slice('--repo='.length)); + continue; + } + + if (arg === '--first') { + parsed.first = parseIntegerFlag(readValue(args, index, arg), arg); + index += 1; + continue; + } + + if (arg.startsWith('--first=')) { + parsed.first = parseIntegerFlag(arg.slice('--first='.length), '--first'); + continue; + } + + if (arg === '--use-env-github-token') { + parsed.useEnvGithubToken = true; + continue; + } + + if (arg === '--exit-code') { + parsed.exitCode = true; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!['text', 'json', 'markdown'].includes(parsed.format)) { + throw new Error(`Invalid format: ${parsed.format}. Use text, json, or markdown.`); + } + + if (parsed.writePath && parsed.format === 'text') { + throw new Error('--write requires --json, --markdown, or --format json|markdown'); + } + + return parsed; +} + +function buildReport(options) { + const repos = options.repos.length > 0 ? options.repos : DEFAULT_REPOS; + const repoReports = repos.map(repo => { + try { + return { + repo, + discussions: fetchDiscussionSummary(repo, options), + }; + } catch (error) { + return { + repo, + error: error.message, + discussions: emptyDiscussionSummary(), + }; + } + }); + + const totals = { + repos: repoReports.length, + totalDiscussions: repoReports.reduce((sum, repo) => sum + repo.discussions.totalCount, 0), + sampledDiscussions: repoReports.reduce((sum, repo) => sum + repo.discussions.sampledCount, 0), + needingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0), + missingAcceptedAnswer: repoReports.reduce((sum, repo) => sum + repo.discussions.answerableWithoutAcceptedAnswer.length, 0), + errors: repoReports.filter(repo => repo.error).length, + }; + + const checks = [ + { + id: 'discussion-fetch', + status: totals.errors === 0 ? 'pass' : 'fail', + summary: `GitHub discussion fetch errors: ${totals.errors}`, + fix: 'Re-run with working gh authentication or ECC_GH_SHIM for deterministic tests.', + }, + { + id: 'discussion-maintainer-touch', + status: totals.needingMaintainerTouch === 0 ? 'pass' : 'fail', + summary: `discussions needing maintainer touch: ${totals.needingMaintainerTouch}`, + fix: 'Respond to or route discussions without maintainer touch.', + }, + { + id: 'discussion-accepted-answers', + status: totals.missingAcceptedAnswer === 0 ? 'pass' : 'fail', + summary: `answerable discussions missing accepted answer: ${totals.missingAcceptedAnswer}`, + fix: 'Mark an accepted answer or route Q&A discussions that still need resolution.', + }, + ]; + const topActions = checks + .filter(check => check.status === 'fail') + .map(check => ({ + id: check.id, + summary: check.summary, + fix: check.fix, + })); + + return { + schema_version: SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + ready: topActions.length === 0, + sampleFirst: options.first, + repos: repoReports, + totals, + checks, + top_actions: topActions, + }; +} + +function markdownEscape(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, '
'); +} + +function renderText(report) { + const lines = [ + `ECC Discussion Audit: ${report.ready ? 'ready' : 'attention required'}`, + `Generated: ${report.generatedAt}`, + `Repos: ${report.totals.repos}`, + `Discussions sampled: ${report.totals.sampledDiscussions}/${report.totals.totalDiscussions}`, + `Needs maintainer touch: ${report.totals.needingMaintainerTouch}`, + `Missing accepted answers: ${report.totals.missingAcceptedAnswer}`, + `Fetch errors: ${report.totals.errors}`, + '', + 'Checks:', + ]; + + for (const check of report.checks) { + lines.push(` ${check.status.toUpperCase()} ${check.id}: ${check.summary}`); + } + + lines.push('', 'Top actions:'); + if (report.top_actions.length === 0) { + lines.push(' none'); + } else { + for (const action of report.top_actions) { + lines.push(` - ${action.id}: ${action.fix}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function renderMarkdown(report) { + const lines = [ + '# ECC Discussion Audit', + '', + `Generated: ${report.generatedAt}`, + `Status: ${report.ready ? 'ready' : 'attention required'}`, + '', + '## Summary', + '', + '| Surface | Count | Target | Status |', + '| --- | ---: | ---: | --- |', + `| Fetch errors | ${report.totals.errors} | 0 | ${report.totals.errors === 0 ? 'PASS' : 'FAIL'} |`, + `| Discussions needing maintainer touch | ${report.totals.needingMaintainerTouch} | 0 | ${report.totals.needingMaintainerTouch === 0 ? 'PASS' : 'FAIL'} |`, + `| Answerable discussions missing accepted answer | ${report.totals.missingAcceptedAnswer} | 0 | ${report.totals.missingAcceptedAnswer === 0 ? 'PASS' : 'FAIL'} |`, + '', + '## Repositories', + '', + '| Repository | Total | Sampled | Needs maintainer | Missing answers |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + + for (const repo of report.repos) { + lines.push( + `| \`${markdownEscape(repo.repo)}\` | ${repo.discussions.totalCount} | ${repo.discussions.sampledCount} | ${repo.discussions.needingMaintainerTouch.length} | ${repo.discussions.answerableWithoutAcceptedAnswer.length} |` + ); + } + + lines.push('', '## Top Actions', ''); + if (report.top_actions.length === 0) { + lines.push('- none'); + } else { + for (const action of report.top_actions) { + lines.push(`- \`${markdownEscape(action.id)}\`: ${markdownEscape(action.fix)}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function writeOutput(writePath, output) { + fs.mkdirSync(path.dirname(writePath), { recursive: true }); + fs.writeFileSync(writePath, output, 'utf8'); +} + +function renderReport(report, format) { + if (format === 'json') { + return `${JSON.stringify(report, null, 2)}\n`; + } + + if (format === 'markdown') { + return renderMarkdown(report); + } + + return renderText(report); +} + +function main() { + let options; + try { + options = parseArgs(process.argv); + } catch (error) { + console.error(error.message); + process.exit(1); + } + + if (options.help) { + usage(); + return; + } + + const report = buildReport(options); + const output = renderReport(report, options.format); + + if (options.writePath) { + writeOutput(options.writePath, output); + } + + process.stdout.write(output); + + if (options.exitCode && !report.ready) { + process.exit(2); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildReport, + parseArgs, + renderMarkdown, + renderReport, + renderText, +}; diff --git a/scripts/lib/github-discussions.js b/scripts/lib/github-discussions.js new file mode 100644 index 00000000..b9c2c6fc --- /dev/null +++ b/scripts/lib/github-discussions.js @@ -0,0 +1,141 @@ +'use strict'; + +const { spawnSync } = require('child_process'); + +const DEFAULT_DISCUSSION_FIRST = 100; +const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); +const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation category { name isAnswerable } answer { url authorAssociation } comments(first: 20) { nodes { authorAssociation } } } } } }'; + +function splitRepo(repo) { + const [owner, name] = String(repo || '').split('/'); + if (!owner || !name) { + throw new Error(`Invalid repo: ${repo}`); + } + return { owner, name }; +} + +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 runGhJson(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; + } + + const stdout = runCommand(command, commandArgs, { env }); + try { + return JSON.parse(stdout || 'null'); + } catch (error) { + throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); + } +} + +function discussionNeedsMaintainerTouch(discussion) { + if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) { + return false; + } + + if ( + discussion.answer + && MAINTAINER_ASSOCIATIONS.has(discussion.answer.authorAssociation) + ) { + return false; + } + + const comments = discussion.comments && Array.isArray(discussion.comments.nodes) + ? discussion.comments.nodes + : []; + return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation)); +} + +function discussionNeedsAcceptedAnswer(discussion) { + return Boolean( + discussion + && discussion.category + && discussion.category.isAnswerable + && !discussion.answer + ); +} + +function summarizeDiscussion(discussion) { + return { + number: discussion.number, + title: discussion.title, + url: discussion.url, + updatedAt: discussion.updatedAt, + category: discussion.category ? discussion.category.name : null, + }; +} + +function fetchDiscussionSummary(repo, options = {}) { + const { owner, name } = splitRepo(repo); + const first = Number.isFinite(options.first) ? options.first : DEFAULT_DISCUSSION_FIRST; + const payload = runGhJson([ + 'api', + 'graphql', + '-f', + `owner=${owner}`, + '-f', + `name=${name}`, + '-F', + `first=${first}`, + '-f', + `query=${DISCUSSION_QUERY}`, + ], options); + const repository = payload && payload.data && payload.data.repository; + const discussions = repository && repository.discussions; + const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : []; + const needingTouch = nodes.filter(discussionNeedsMaintainerTouch); + const missingAcceptedAnswer = nodes.filter(discussionNeedsAcceptedAnswer); + + return { + enabled: Boolean(repository && repository.hasDiscussionsEnabled), + totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0, + sampledCount: nodes.length, + needingMaintainerTouch: needingTouch.map(summarizeDiscussion), + answerableWithoutAcceptedAnswer: missingAcceptedAnswer.map(summarizeDiscussion), + }; +} + +function emptyDiscussionSummary() { + return { + enabled: false, + totalCount: 0, + sampledCount: 0, + needingMaintainerTouch: [], + answerableWithoutAcceptedAnswer: [], + }; +} + +module.exports = { + DEFAULT_DISCUSSION_FIRST, + DISCUSSION_QUERY, + MAINTAINER_ASSOCIATIONS, + discussionNeedsAcceptedAnswer, + discussionNeedsMaintainerTouch, + emptyDiscussionSummary, + fetchDiscussionSummary, + splitRepo, + summarizeDiscussion, +}; diff --git a/scripts/platform-audit.js b/scripts/platform-audit.js index ea164dbc..5a3e9ca7 100644 --- a/scripts/platform-audit.js +++ b/scripts/platform-audit.js @@ -5,6 +5,10 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); +const { + emptyDiscussionSummary, + fetchDiscussionSummary, +} = require('./lib/github-discussions'); const SCHEMA_VERSION = 'ecc.platform-audit.v1'; const DEFAULT_REPOS = Object.freeze([ @@ -19,9 +23,6 @@ const DEFAULT_THRESHOLDS = Object.freeze({ maxOpenIssues: 20, maxDirtyFiles: 0, }); -const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); -const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }'; - function usage() { console.log([ 'Usage: node scripts/platform-audit.js [options]', @@ -333,57 +334,6 @@ function inspectGit(rootDir, options) { } } -function discussionNeedsMaintainerTouch(discussion) { - if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) { - return false; - } - - const comments = discussion.comments && Array.isArray(discussion.comments.nodes) - ? discussion.comments.nodes - : []; - return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation)); -} - -function splitRepo(repo) { - const [owner, name] = String(repo || '').split('/'); - if (!owner || !name) { - throw new Error(`Invalid repo: ${repo}`); - } - return { owner, name }; -} - -function fetchDiscussionSummary(repo, options) { - const { owner, name } = splitRepo(repo); - const payload = runGhJson([ - 'api', - 'graphql', - '-f', - `owner=${owner}`, - '-f', - `name=${name}`, - '-F', - 'first=100', - '-f', - `query=${DISCUSSION_QUERY}`, - ], options); - const repository = payload && payload.data && payload.data.repository; - const discussions = repository && repository.discussions; - const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : []; - const needingTouch = nodes.filter(discussionNeedsMaintainerTouch); - - return { - enabled: Boolean(repository && repository.hasDiscussionsEnabled), - totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0, - sampledCount: nodes.length, - needingMaintainerTouch: needingTouch.map(discussion => ({ - number: discussion.number, - title: discussion.title, - url: discussion.url, - updatedAt: discussion.updatedAt, - })), - }; -} - function fetchGithubRepo(repo, options) { const prs = runGhJson([ 'pr', @@ -431,6 +381,7 @@ function buildGithubReport(options) { openPrs: 0, openIssues: 0, discussionsNeedingMaintainerTouch: 0, + discussionsMissingAcceptedAnswer: 0, dirtyPrs: 0, errors: 0, }, @@ -446,12 +397,7 @@ function buildGithubReport(options) { error: error.message, openPrs: 0, openIssues: 0, - discussions: { - enabled: false, - totalCount: 0, - sampledCount: 0, - needingMaintainerTouch: [], - }, + discussions: emptyDiscussionSummary(), dirtyPrs: [], }; } @@ -464,6 +410,7 @@ function buildGithubReport(options) { openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0), openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0), discussionsNeedingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0), + discussionsMissingAcceptedAnswer: repoReports.reduce((sum, repo) => sum + repo.discussions.answerableWithoutAcceptedAnswer.length, 0), dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0), errors: repoReports.filter(repo => repo.error).length, }, @@ -482,9 +429,12 @@ function buildLocalEvidenceChecks(rootDir) { return [ buildCheck( 'platform-audit-cli-surface', - packageScripts['platform:audit'] === 'node scripts/platform-audit.js' ? 'pass' : 'fail', - 'package.json exposes the platform audit command', - { fix: 'Add "platform:audit": "node scripts/platform-audit.js" to package.json.' } + packageScripts['platform:audit'] === 'node scripts/platform-audit.js' + && packageScripts['discussion:audit'] === 'node scripts/discussion-audit.js' + ? 'pass' + : 'fail', + 'package.json exposes the platform and discussion audit commands', + { fix: 'Add platform:audit and discussion:audit commands to package.json.' } ), buildCheck( 'roadmap-linear-mirror', @@ -567,6 +517,13 @@ function buildReport(options) { { fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' } )); + checks.push(buildCheck( + 'github-discussion-answers', + github.totals.discussionsMissingAcceptedAnswer === 0 ? 'pass' : 'fail', + `answerable discussions missing accepted answer: ${github.totals.discussionsMissingAcceptedAnswer}`, + { fix: 'Mark an accepted answer or route Q&A discussions that still need resolution.' } + )); + checks.push(buildCheck( 'github-conflict-queue', github.totals.dirtyPrs === 0 ? 'pass' : 'fail', @@ -611,6 +568,7 @@ function renderText(report) { `Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`, `Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`, `Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`, + `Answerable discussions missing accepted answer: ${report.github.totals.discussionsMissingAcceptedAnswer}`, `Conflicting open PRs: ${report.github.totals.dirtyPrs}`, '', 'Checks:', @@ -666,18 +624,19 @@ function renderMarkdown(report) { `| Open PRs | ${report.github.totals.openPrs} | ${report.thresholds.maxOpenPrs} | ${report.github.totals.openPrs <= report.thresholds.maxOpenPrs ? 'PASS' : 'FAIL'} |`, `| Open issues | ${report.github.totals.openIssues} | ${report.thresholds.maxOpenIssues} | ${report.github.totals.openIssues <= report.thresholds.maxOpenIssues ? 'PASS' : 'FAIL'} |`, `| Discussions needing maintainer touch | ${report.github.totals.discussionsNeedingMaintainerTouch} | 0 | ${report.github.totals.discussionsNeedingMaintainerTouch === 0 ? 'PASS' : 'FAIL'} |`, + `| Answerable discussions missing accepted answer | ${report.github.totals.discussionsMissingAcceptedAnswer} | 0 | ${report.github.totals.discussionsMissingAcceptedAnswer === 0 ? 'PASS' : 'FAIL'} |`, `| Conflicting open PRs | ${report.github.totals.dirtyPrs} | 0 | ${report.github.totals.dirtyPrs === 0 ? 'PASS' : 'FAIL'} |`, `| Blocking dirty files | ${report.git.blockingDirtyCount} | ${report.thresholds.maxDirtyFiles} | ${report.git.blockingDirtyCount <= report.thresholds.maxDirtyFiles ? 'PASS' : 'FAIL'} |`, '', '## Repositories', '', - '| Repository | PRs | Issues | Discussions sampled | Needs maintainer | Dirty PRs |', - '| --- | ---: | ---: | ---: | ---: | ---: |', + '| Repository | PRs | Issues | Discussions sampled | Needs maintainer | Missing answers | Dirty PRs |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', ]; for (const repo of report.github.repos) { lines.push( - `| \`${markdownEscape(repo.repo)}\` | ${repo.openPrs || 0} | ${repo.openIssues || 0} | ${repo.discussions ? repo.discussions.sampledCount : 0} | ${repo.discussions ? repo.discussions.needingMaintainerTouch.length : 0} | ${repo.dirtyPrs ? repo.dirtyPrs.length : 0} |` + `| \`${markdownEscape(repo.repo)}\` | ${repo.openPrs || 0} | ${repo.openIssues || 0} | ${repo.discussions ? repo.discussions.sampledCount : 0} | ${repo.discussions ? repo.discussions.needingMaintainerTouch.length : 0} | ${repo.discussions ? repo.discussions.answerableWithoutAcceptedAnswer.length : 0} | ${repo.dirtyPrs ? repo.dirtyPrs.length : 0} |` ); } diff --git a/tests/scripts/discussion-audit.test.js b/tests/scripts/discussion-audit.test.js new file mode 100644 index 00000000..fb26c2ee --- /dev/null +++ b/tests/scripts/discussion-audit.test.js @@ -0,0 +1,258 @@ +/** + * Tests for scripts/discussion-audit.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'discussion-audit.js'); +const { DISCUSSION_QUERY } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions')); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function discussionGhKey(owner, name, first = 100) { + return `api graphql -f owner=${owner} -f name=${name} -F first=${first} -f query=${DISCUSSION_QUERY}`; +} + +function writeGhShim(rootDir, responses) { + const shimPath = path.join(rootDir, 'gh-shim.js'); + fs.writeFileSync(shimPath, ` +const responses = ${JSON.stringify(responses)}; +const args = process.argv.slice(2); +const key = args.join(' '); +if (process.env.GITHUB_TOKEN) { + console.error('GITHUB_TOKEN should be unset by default'); + process.exit(42); +} +if (!Object.prototype.hasOwnProperty.call(responses, key)) { + console.error('Unexpected gh args: ' + key); + process.exit(3); +} +process.stdout.write(JSON.stringify(responses[key])); +`); + return shimPath; +} + +function run(args = [], options = {}) { + const env = { + ...process.env, + ...(options.env || {}) + }; + + return execFileSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + env, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +} + +function runProcess(args = [], options = {}) { + const env = { + ...process.env, + ...(options.env || {}) + }; + + return spawnSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + env, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +} + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing discussion-audit.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('passes when discussions have maintainer touch and accepted answers', () => { + const rootDir = createTempDir('discussion-audit-pass-'); + + try { + const shimPath = writeGhShim(rootDir, { + [discussionGhKey('affaan-m', 'everything-claude-code')]: { + data: { + repository: { + hasDiscussionsEnabled: true, + discussions: { + totalCount: 2, + nodes: [ + { + number: 1923, + title: 'Does Continuous Learning v2 work with VS Code Claude Code?', + url: 'https://github.com/example/discussions/1923', + updatedAt: '2026-05-15T19:08:52Z', + authorAssociation: 'NONE', + category: { name: 'Q&A', isAnswerable: true }, + answer: { url: 'https://github.com/example/discussions/1923#discussioncomment-1', authorAssociation: 'OWNER' }, + comments: { nodes: [] } + }, + { + number: 73, + title: 'Compacting during workflow', + url: 'https://github.com/example/discussions/73', + updatedAt: '2026-05-15T00:00:00Z', + authorAssociation: 'NONE', + category: { name: 'General', isAnswerable: false }, + answer: null, + comments: { nodes: [{ authorAssociation: 'MEMBER' }] } + } + ] + } + } + } + } + }); + + const parsed = JSON.parse(run([ + '--json', + '--repo', + 'affaan-m/everything-claude-code' + ], { + cwd: rootDir, + env: { + ECC_GH_SHIM: shimPath, + GITHUB_TOKEN: 'must-be-removed' + } + })); + + assert.strictEqual(parsed.ready, true); + assert.strictEqual(parsed.totals.needingMaintainerTouch, 0); + assert.strictEqual(parsed.totals.missingAcceptedAnswer, 0); + assert.ok(parsed.checks.some(check => check.id === 'discussion-accepted-answers' && check.status === 'pass')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('fails when Q&A lacks accepted answer and maintainer touch', () => { + const rootDir = createTempDir('discussion-audit-fail-'); + + try { + const shimPath = writeGhShim(rootDir, { + [discussionGhKey('affaan-m', 'everything-claude-code')]: { + data: { + repository: { + hasDiscussionsEnabled: true, + discussions: { + totalCount: 1, + nodes: [ + { + number: 1239, + title: 'Losing context', + url: 'https://github.com/example/discussions/1239', + updatedAt: '2026-05-15T00:00:00Z', + authorAssociation: 'NONE', + category: { name: 'Q&A', isAnswerable: true }, + answer: null, + comments: { nodes: [] } + } + ] + } + } + } + } + }); + + const result = runProcess([ + '--json', + '--repo', + 'affaan-m/everything-claude-code', + '--exit-code' + ], { + cwd: rootDir, + env: { ECC_GH_SHIM: shimPath } + }); + const parsed = JSON.parse(result.stdout); + + assert.strictEqual(result.status, 2); + assert.strictEqual(parsed.ready, false); + assert.strictEqual(parsed.totals.needingMaintainerTouch, 1); + assert.strictEqual(parsed.totals.missingAcceptedAnswer, 1); + assert.ok(parsed.top_actions.some(action => action.id === 'discussion-maintainer-touch')); + assert.ok(parsed.top_actions.some(action => action.id === 'discussion-accepted-answers')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('writes markdown output as a durable operator artifact', () => { + const rootDir = createTempDir('discussion-audit-markdown-'); + const outputPath = path.join(rootDir, 'artifacts', 'discussion-audit.md'); + + try { + const shimPath = writeGhShim(rootDir, { + [discussionGhKey('affaan-m', 'everything-claude-code')]: { + data: { + repository: { + hasDiscussionsEnabled: true, + discussions: { totalCount: 0, nodes: [] } + } + } + } + }); + const stdout = run([ + '--markdown', + '--write', + outputPath, + '--repo', + 'affaan-m/everything-claude-code' + ], { + cwd: rootDir, + env: { ECC_GH_SHIM: shimPath } + }); + const written = fs.readFileSync(outputPath, 'utf8'); + + assert.strictEqual(stdout, written); + assert.ok(written.includes('# ECC Discussion Audit')); + assert.ok(written.includes('Answerable discussions missing accepted answer')); + assert.ok(written.includes('- none')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('cli help and invalid args exit cleanly', () => { + const help = runProcess(['--help']); + assert.strictEqual(help.status, 0); + assert.ok(help.stdout.includes('Usage: node scripts/discussion-audit.js')); + + const invalid = runProcess(['--format', 'xml']); + assert.strictEqual(invalid.status, 1); + assert.ok(invalid.stderr.includes('Invalid format')); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests(); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 6f9f53d1..e4649e92 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -46,6 +46,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/ci/scan-supply-chain-iocs.js", "scripts/consult.js", "scripts/claw.js", + "scripts/discussion-audit.js", "scripts/doctor.js", "scripts/status.js", "scripts/sessions-cli.js", @@ -123,6 +124,7 @@ function main() { "scripts/catalog.js", "scripts/ci/scan-supply-chain-iocs.js", "scripts/consult.js", + "scripts/discussion-audit.js", "scripts/work-items.js", "scripts/platform-audit.js", ".gemini/GEMINI.md", diff --git a/tests/scripts/platform-audit.test.js b/tests/scripts/platform-audit.test.js index 7bd269ef..350b7cd6 100644 --- a/tests/scripts/platform-audit.test.js +++ b/tests/scripts/platform-audit.test.js @@ -9,6 +9,7 @@ const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js'); +const { DISCUSSION_QUERY } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions')); function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -30,6 +31,7 @@ function seedRepo(rootDir, overrides = {}) { name: 'everything-claude-code', scripts: { 'platform:audit': 'node scripts/platform-audit.js', + 'discussion:audit': 'node scripts/discussion-audit.js', 'observability:ready': 'node scripts/observability-readiness.js', 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js', 'harness:audit': 'node scripts/harness-audit.js' @@ -78,6 +80,10 @@ function seedRepo(rootDir, overrides = {}) { } } +function discussionGhKey(owner, name, first = 100) { + return `api graphql -f owner=${owner} -f name=${name} -F first=${first} -f query=${DISCUSSION_QUERY}`; +} + function writeGhShim(rootDir, responses) { const shimPath = path.join(rootDir, 'gh-shim.js'); fs.writeFileSync(shimPath, ` @@ -237,7 +243,7 @@ function runTests() { const shimPath = writeGhShim(projectRoot, { 'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [], 'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [], - 'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': { + [discussionGhKey('affaan-m', 'everything-claude-code')]: { data: { repository: { hasDiscussionsEnabled: true, @@ -250,6 +256,8 @@ function runTests() { url: 'https://github.com/example/discussions/73', updatedAt: '2026-05-15T00:00:00Z', authorAssociation: 'NONE', + category: { name: 'General', isAnswerable: false }, + answer: null, comments: { nodes: [{ authorAssociation: 'OWNER' }] } } ] @@ -276,7 +284,9 @@ function runTests() { assert.strictEqual(parsed.github.totals.openPrs, 0); assert.strictEqual(parsed.github.totals.openIssues, 0); assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0); + assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 0); assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass')); + assert.ok(parsed.checks.some(check => check.id === 'github-discussion-answers' && check.status === 'pass')); } finally { cleanup(projectRoot); } @@ -299,7 +309,7 @@ function runTests() { const shimPath = writeGhShim(projectRoot, { 'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs, 'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [], - 'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': { + [discussionGhKey('affaan-m', 'everything-claude-code')]: { data: { repository: { hasDiscussionsEnabled: true, @@ -312,6 +322,8 @@ function runTests() { url: 'https://github.com/example/discussions/1239', updatedAt: '2026-05-15T00:00:00Z', authorAssociation: 'NONE', + category: { name: 'Q&A', isAnswerable: true }, + answer: null, comments: { nodes: [] } } ] @@ -336,7 +348,9 @@ function runTests() { assert.strictEqual(parsed.ready, false); assert.ok(parsed.top_actions.some(action => action.id === 'github-open-pr-budget')); assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-touch')); + assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-answers')); assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1); + assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 1); } finally { cleanup(projectRoot); }