mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-17 06:13:08 +08:00
Add discussion audit gate
This commit is contained in:
committed by
Affaan Mustafa
parent
0b6763463f
commit
6887f2952d
@@ -11,7 +11,7 @@ clean checkout.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| PR queue | Current | 0 open PRs across checked repos |
|
| PR queue | Current | 0 open PRs across checked repos |
|
||||||
| Issue queue | Current | 0 open issues 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 repo status | `git status --short --branch` | `## main...origin/main`; `?? docs/drafts/` remains unrelated |
|
||||||
| Main commit | `git rev-parse HEAD` | `c0f8c3bc813360f29e9f2b66bcae7e977cd03327` |
|
| Main commit | `git rev-parse HEAD` | `c0f8c3bc813360f29e9f2b66bcae7e977cd03327` |
|
||||||
| Main repo PRs/issues | GitHub connector and `gh` readback | 0 open PRs; 0 open issues |
|
| 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 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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-44 | Completion audit and readiness dashboard | In Progress |
|
||||||
| ITO-57 | Supply-chain intelligence and local protection loop | 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:
|
Still-open lanes:
|
||||||
|
|
||||||
@@ -91,8 +92,8 @@ Still-open lanes:
|
|||||||
|
|
||||||
- The checked GitHub queues are below the explicit target, so the next work
|
- The checked GitHub queues are below the explicit target, so the next work
|
||||||
should not spend more time closing nonexistent PRs/issues.
|
should not spend more time closing nonexistent PRs/issues.
|
||||||
- The discussion queue is current, but repeatability is weak. ITO-59 remains
|
- The discussion queue is current and repeatable through `discussion:audit`.
|
||||||
open until the sweep is scripted or documented as an operator command.
|
ITO-59 remains open only for recurring Linear/status synchronization.
|
||||||
- The Mini Shai-Hulud/TanStack protection pass is strong enough for current
|
- The Mini Shai-Hulud/TanStack protection pass is strong enough for current
|
||||||
local protection, but ITO-57 remains open until incident response and IOC
|
local protection, but ITO-57 remains open until incident response and IOC
|
||||||
updates become a durable workflow.
|
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
|
1. Build the ITO-44 completion dashboard into a repeatable command or generated
|
||||||
markdown artifact.
|
markdown artifact.
|
||||||
2. Productize the ITO-59 discussion queue sweep so the maintainer-touch and
|
2. Run `platform:audit` and `discussion:audit` from the final release commit
|
||||||
accepted-answer checks do not depend on manual GraphQL.
|
before recording publication evidence.
|
||||||
3. Continue ITO-57 by turning emergency hardening into documented incident
|
3. Continue ITO-57 by turning emergency hardening into documented incident
|
||||||
response and scanner update workflow.
|
response and scanner update workflow.
|
||||||
4. Resume release/publication lanes ITO-45, ITO-46, and ITO-56 only after the
|
4. Resume release/publication lanes ITO-45, ITO-46, and ITO-56 only after the
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Do Not Publish If
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"scripts/claw.js",
|
"scripts/claw.js",
|
||||||
"scripts/codex/merge-codex-config.js",
|
"scripts/codex/merge-codex-config.js",
|
||||||
"scripts/codex/merge-mcp-config.js",
|
"scripts/codex/merge-mcp-config.js",
|
||||||
|
"scripts/discussion-audit.js",
|
||||||
"scripts/doctor.js",
|
"scripts/doctor.js",
|
||||||
"scripts/ecc.js",
|
"scripts/ecc.js",
|
||||||
"scripts/gemini-adapt-agents.js",
|
"scripts/gemini-adapt-agents.js",
|
||||||
@@ -296,6 +297,7 @@
|
|||||||
"harness:audit": "node scripts/harness-audit.js",
|
"harness:audit": "node scripts/harness-audit.js",
|
||||||
"observability:ready": "node scripts/observability-readiness.js",
|
"observability:ready": "node scripts/observability-readiness.js",
|
||||||
"platform:audit": "node scripts/platform-audit.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",
|
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"claw": "node scripts/claw.js",
|
"claw": "node scripts/claw.js",
|
||||||
"orchestrate:status": "node scripts/orchestration-status.js",
|
"orchestrate:status": "node scripts/orchestration-status.js",
|
||||||
|
|||||||
350
scripts/discussion-audit.js
Normal file
350
scripts/discussion-audit.js
Normal file
@@ -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 <text|json|markdown>',
|
||||||
|
' Output format (default: text)',
|
||||||
|
' --json Alias for --format json',
|
||||||
|
' --markdown Alias for --format markdown',
|
||||||
|
' --write <path> Write json or markdown output to a file',
|
||||||
|
' --repo <owner/repo> GitHub repo to inspect; repeatable',
|
||||||
|
' --first <n> 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, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
141
scripts/lib/github-discussions.js
Normal file
141
scripts/lib/github-discussions.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -5,6 +5,10 @@ const fs = require('fs');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
|
const {
|
||||||
|
emptyDiscussionSummary,
|
||||||
|
fetchDiscussionSummary,
|
||||||
|
} = require('./lib/github-discussions');
|
||||||
|
|
||||||
const SCHEMA_VERSION = 'ecc.platform-audit.v1';
|
const SCHEMA_VERSION = 'ecc.platform-audit.v1';
|
||||||
const DEFAULT_REPOS = Object.freeze([
|
const DEFAULT_REPOS = Object.freeze([
|
||||||
@@ -19,9 +23,6 @@ const DEFAULT_THRESHOLDS = Object.freeze({
|
|||||||
maxOpenIssues: 20,
|
maxOpenIssues: 20,
|
||||||
maxDirtyFiles: 0,
|
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() {
|
function usage() {
|
||||||
console.log([
|
console.log([
|
||||||
'Usage: node scripts/platform-audit.js [options]',
|
'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) {
|
function fetchGithubRepo(repo, options) {
|
||||||
const prs = runGhJson([
|
const prs = runGhJson([
|
||||||
'pr',
|
'pr',
|
||||||
@@ -431,6 +381,7 @@ function buildGithubReport(options) {
|
|||||||
openPrs: 0,
|
openPrs: 0,
|
||||||
openIssues: 0,
|
openIssues: 0,
|
||||||
discussionsNeedingMaintainerTouch: 0,
|
discussionsNeedingMaintainerTouch: 0,
|
||||||
|
discussionsMissingAcceptedAnswer: 0,
|
||||||
dirtyPrs: 0,
|
dirtyPrs: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
},
|
},
|
||||||
@@ -446,12 +397,7 @@ function buildGithubReport(options) {
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
openPrs: 0,
|
openPrs: 0,
|
||||||
openIssues: 0,
|
openIssues: 0,
|
||||||
discussions: {
|
discussions: emptyDiscussionSummary(),
|
||||||
enabled: false,
|
|
||||||
totalCount: 0,
|
|
||||||
sampledCount: 0,
|
|
||||||
needingMaintainerTouch: [],
|
|
||||||
},
|
|
||||||
dirtyPrs: [],
|
dirtyPrs: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -464,6 +410,7 @@ function buildGithubReport(options) {
|
|||||||
openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0),
|
openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0),
|
||||||
openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0),
|
openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0),
|
||||||
discussionsNeedingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 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),
|
dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0),
|
||||||
errors: repoReports.filter(repo => repo.error).length,
|
errors: repoReports.filter(repo => repo.error).length,
|
||||||
},
|
},
|
||||||
@@ -482,9 +429,12 @@ function buildLocalEvidenceChecks(rootDir) {
|
|||||||
return [
|
return [
|
||||||
buildCheck(
|
buildCheck(
|
||||||
'platform-audit-cli-surface',
|
'platform-audit-cli-surface',
|
||||||
packageScripts['platform:audit'] === 'node scripts/platform-audit.js' ? 'pass' : 'fail',
|
packageScripts['platform:audit'] === 'node scripts/platform-audit.js'
|
||||||
'package.json exposes the platform audit command',
|
&& packageScripts['discussion:audit'] === 'node scripts/discussion-audit.js'
|
||||||
{ fix: 'Add "platform:audit": "node scripts/platform-audit.js" to package.json.' }
|
? 'pass'
|
||||||
|
: 'fail',
|
||||||
|
'package.json exposes the platform and discussion audit commands',
|
||||||
|
{ fix: 'Add platform:audit and discussion:audit commands to package.json.' }
|
||||||
),
|
),
|
||||||
buildCheck(
|
buildCheck(
|
||||||
'roadmap-linear-mirror',
|
'roadmap-linear-mirror',
|
||||||
@@ -567,6 +517,13 @@ function buildReport(options) {
|
|||||||
{ fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' }
|
{ 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(
|
checks.push(buildCheck(
|
||||||
'github-conflict-queue',
|
'github-conflict-queue',
|
||||||
github.totals.dirtyPrs === 0 ? 'pass' : 'fail',
|
github.totals.dirtyPrs === 0 ? 'pass' : 'fail',
|
||||||
@@ -611,6 +568,7 @@ function renderText(report) {
|
|||||||
`Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`,
|
`Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`,
|
||||||
`Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`,
|
`Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`,
|
||||||
`Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`,
|
`Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`,
|
||||||
|
`Answerable discussions missing accepted answer: ${report.github.totals.discussionsMissingAcceptedAnswer}`,
|
||||||
`Conflicting open PRs: ${report.github.totals.dirtyPrs}`,
|
`Conflicting open PRs: ${report.github.totals.dirtyPrs}`,
|
||||||
'',
|
'',
|
||||||
'Checks:',
|
'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 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'} |`,
|
`| 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'} |`,
|
`| 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'} |`,
|
`| 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'} |`,
|
`| Blocking dirty files | ${report.git.blockingDirtyCount} | ${report.thresholds.maxDirtyFiles} | ${report.git.blockingDirtyCount <= report.thresholds.maxDirtyFiles ? 'PASS' : 'FAIL'} |`,
|
||||||
'',
|
'',
|
||||||
'## Repositories',
|
'## 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) {
|
for (const repo of report.github.repos) {
|
||||||
lines.push(
|
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} |`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
258
tests/scripts/discussion-audit.test.js
Normal file
258
tests/scripts/discussion-audit.test.js
Normal file
@@ -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();
|
||||||
@@ -46,6 +46,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
|||||||
"scripts/ci/scan-supply-chain-iocs.js",
|
"scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"scripts/consult.js",
|
"scripts/consult.js",
|
||||||
"scripts/claw.js",
|
"scripts/claw.js",
|
||||||
|
"scripts/discussion-audit.js",
|
||||||
"scripts/doctor.js",
|
"scripts/doctor.js",
|
||||||
"scripts/status.js",
|
"scripts/status.js",
|
||||||
"scripts/sessions-cli.js",
|
"scripts/sessions-cli.js",
|
||||||
@@ -123,6 +124,7 @@ function main() {
|
|||||||
"scripts/catalog.js",
|
"scripts/catalog.js",
|
||||||
"scripts/ci/scan-supply-chain-iocs.js",
|
"scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"scripts/consult.js",
|
"scripts/consult.js",
|
||||||
|
"scripts/discussion-audit.js",
|
||||||
"scripts/work-items.js",
|
"scripts/work-items.js",
|
||||||
"scripts/platform-audit.js",
|
"scripts/platform-audit.js",
|
||||||
".gemini/GEMINI.md",
|
".gemini/GEMINI.md",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const path = require('path');
|
|||||||
const { execFileSync, spawnSync } = require('child_process');
|
const { execFileSync, spawnSync } = require('child_process');
|
||||||
|
|
||||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js');
|
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js');
|
||||||
|
const { DISCUSSION_QUERY } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions'));
|
||||||
|
|
||||||
function createTempDir(prefix) {
|
function createTempDir(prefix) {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
@@ -30,6 +31,7 @@ function seedRepo(rootDir, overrides = {}) {
|
|||||||
name: 'everything-claude-code',
|
name: 'everything-claude-code',
|
||||||
scripts: {
|
scripts: {
|
||||||
'platform:audit': 'node scripts/platform-audit.js',
|
'platform:audit': 'node scripts/platform-audit.js',
|
||||||
|
'discussion:audit': 'node scripts/discussion-audit.js',
|
||||||
'observability:ready': 'node scripts/observability-readiness.js',
|
'observability:ready': 'node scripts/observability-readiness.js',
|
||||||
'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
|
'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
|
||||||
'harness:audit': 'node scripts/harness-audit.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) {
|
function writeGhShim(rootDir, responses) {
|
||||||
const shimPath = path.join(rootDir, 'gh-shim.js');
|
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||||
fs.writeFileSync(shimPath, `
|
fs.writeFileSync(shimPath, `
|
||||||
@@ -237,7 +243,7 @@ function runTests() {
|
|||||||
const shimPath = writeGhShim(projectRoot, {
|
const shimPath = writeGhShim(projectRoot, {
|
||||||
'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [],
|
'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': [],
|
'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: {
|
data: {
|
||||||
repository: {
|
repository: {
|
||||||
hasDiscussionsEnabled: true,
|
hasDiscussionsEnabled: true,
|
||||||
@@ -250,6 +256,8 @@ function runTests() {
|
|||||||
url: 'https://github.com/example/discussions/73',
|
url: 'https://github.com/example/discussions/73',
|
||||||
updatedAt: '2026-05-15T00:00:00Z',
|
updatedAt: '2026-05-15T00:00:00Z',
|
||||||
authorAssociation: 'NONE',
|
authorAssociation: 'NONE',
|
||||||
|
category: { name: 'General', isAnswerable: false },
|
||||||
|
answer: null,
|
||||||
comments: { nodes: [{ authorAssociation: 'OWNER' }] }
|
comments: { nodes: [{ authorAssociation: 'OWNER' }] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -276,7 +284,9 @@ function runTests() {
|
|||||||
assert.strictEqual(parsed.github.totals.openPrs, 0);
|
assert.strictEqual(parsed.github.totals.openPrs, 0);
|
||||||
assert.strictEqual(parsed.github.totals.openIssues, 0);
|
assert.strictEqual(parsed.github.totals.openIssues, 0);
|
||||||
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 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-touch' && check.status === 'pass'));
|
||||||
|
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-answers' && check.status === 'pass'));
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(projectRoot);
|
cleanup(projectRoot);
|
||||||
}
|
}
|
||||||
@@ -299,7 +309,7 @@ function runTests() {
|
|||||||
const shimPath = writeGhShim(projectRoot, {
|
const shimPath = writeGhShim(projectRoot, {
|
||||||
'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs,
|
'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': [],
|
'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: {
|
data: {
|
||||||
repository: {
|
repository: {
|
||||||
hasDiscussionsEnabled: true,
|
hasDiscussionsEnabled: true,
|
||||||
@@ -312,6 +322,8 @@ function runTests() {
|
|||||||
url: 'https://github.com/example/discussions/1239',
|
url: 'https://github.com/example/discussions/1239',
|
||||||
updatedAt: '2026-05-15T00:00:00Z',
|
updatedAt: '2026-05-15T00:00:00Z',
|
||||||
authorAssociation: 'NONE',
|
authorAssociation: 'NONE',
|
||||||
|
category: { name: 'Q&A', isAnswerable: true },
|
||||||
|
answer: null,
|
||||||
comments: { nodes: [] }
|
comments: { nodes: [] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -336,7 +348,9 @@ function runTests() {
|
|||||||
assert.strictEqual(parsed.ready, false);
|
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-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-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.discussionsNeedingMaintainerTouch, 1);
|
||||||
|
assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 1);
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(projectRoot);
|
cleanup(projectRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user