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

@@ -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,
};