Add discussion audit gate

This commit is contained in:
Affaan Mustafa
2026-05-15 16:17:47 -04:00
committed by Affaan Mustafa
parent 0b6763463f
commit 6887f2952d
9 changed files with 805 additions and 78 deletions

View File

@@ -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} |`
);
}