mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 08:06:30 +00:00
234 lines
8.2 KiB
JavaScript
234 lines
8.2 KiB
JavaScript
// Closes stale issues that never received outside engagement.
|
|
//
|
|
// Used by .github/workflows/stale-close.yml through actions/github-script, and
|
|
// runnable locally as a read-only dry run:
|
|
//
|
|
// node .github/scripts/stale-close.cjs [--repo owner/name] [--out report.md] [--min-age-days 30]
|
|
//
|
|
// Locally the token comes from GITHUB_TOKEN or `gh auth token`. The local
|
|
// runner never writes to GitHub.
|
|
|
|
const MARKER = '<!-- herdr:stale-close -->';
|
|
const EXTRA_TEAM = ['akbash-bot', 'kangal-bot'];
|
|
const PAGE_SIZE = 25;
|
|
const CLOSE_COMMENT = [
|
|
MARKER,
|
|
"We weren't able to reproduce this, and it hasn't had further reports or activity, so it may already be fixed or no longer relevant. Closing as stale.",
|
|
'',
|
|
'If you still see this on the latest release, please open a new issue with current details.',
|
|
].join('\n');
|
|
|
|
const SKIP_REASONS = {
|
|
reactions: 'has reactions',
|
|
outside_comment: 'outside comment',
|
|
linked_pr: 'linked open or merged PR',
|
|
too_many_comments: 'too many comments to evaluate (>100)',
|
|
};
|
|
|
|
const ISSUES_QUERY = `
|
|
query($owner: String!, $repo: String!, $cursor: String, $pageSize: Int!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
issues(states: OPEN, first: $pageSize, after: $cursor, orderBy: {field: CREATED_AT, direction: ASC}) {
|
|
pageInfo { hasNextPage endCursor }
|
|
nodes {
|
|
id
|
|
number
|
|
title
|
|
url
|
|
createdAt
|
|
author { login __typename }
|
|
labels(first: 20) { nodes { name } }
|
|
reactions { totalCount }
|
|
comments(first: 100) {
|
|
totalCount
|
|
nodes { author { login __typename } }
|
|
}
|
|
closedByPullRequestsReferences(first: 10, includeClosedPrs: false) {
|
|
nodes { number state }
|
|
}
|
|
timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
|
nodes {
|
|
__typename
|
|
... on CrossReferencedEvent { source { __typename ... on PullRequest { number state } } }
|
|
... on ConnectedEvent { subject { __typename ... on PullRequest { number state } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
rateLimit { cost remaining }
|
|
}`;
|
|
|
|
const MAINTAINERS_QUERY = `
|
|
query($owner: String!, $repo: String!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
object(expression: "HEAD:.github/MAINTAINERS") { ... on Blob { text } }
|
|
}
|
|
}`;
|
|
|
|
const CLOSE_MUTATION = `
|
|
mutation($id: ID!, $body: String!) {
|
|
addComment(input: {subjectId: $id, body: $body}) { clientMutationId }
|
|
closeIssue(input: {issueId: $id, stateReason: NOT_PLANNED}) { issue { number state } }
|
|
}`;
|
|
|
|
function parseUserList(content) {
|
|
return content
|
|
.split('\n')
|
|
.map(line => line.trim().toLowerCase())
|
|
.filter(line => line && !line.startsWith('#'));
|
|
}
|
|
|
|
function isTeam(team, author) {
|
|
if (!author) return false;
|
|
const login = author.login.toLowerCase();
|
|
return author.__typename === 'Bot' || login.endsWith('[bot]') || team.has(login);
|
|
}
|
|
|
|
function classify(issue, team) {
|
|
if (issue.reactions.totalCount > 0) return 'reactions';
|
|
if (issue.comments.totalCount > issue.comments.nodes.length) return 'too_many_comments';
|
|
const authorLogin = issue.author?.login.toLowerCase();
|
|
const outside = issue.comments.nodes.some(comment =>
|
|
!isTeam(team, comment.author) &&
|
|
!(comment.author && comment.author.login.toLowerCase() === authorLogin));
|
|
if (outside) return 'outside_comment';
|
|
const prs = [
|
|
...issue.closedByPullRequestsReferences.nodes,
|
|
...issue.timelineItems.nodes.map(item => item.source ?? item.subject),
|
|
].filter(pr => pr && pr.state);
|
|
if (prs.some(pr => pr.state === 'OPEN' || pr.state === 'MERGED')) return 'linked_pr';
|
|
return null;
|
|
}
|
|
|
|
function escapeCell(text) {
|
|
return String(text).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
}
|
|
|
|
function renderReport({ repo, dryRun, minAgeDays, candidates, wouldClose, skipped, team }) {
|
|
const verb = dryRun ? 'Would close' : 'Closed';
|
|
const lines = [
|
|
`# Stale issue close (${dryRun ? 'dry run' : 'live'})`,
|
|
'',
|
|
`Repository: \`${repo}\` · minimum age: ${minAgeDays} days · team: ${[...team].sort().join(', ')} + bots`,
|
|
'',
|
|
`- Candidates (open issues older than ${minAgeDays} days): ${candidates}`,
|
|
`- ${verb}: ${wouldClose.length}`,
|
|
];
|
|
for (const [key, label] of Object.entries(SKIP_REASONS)) {
|
|
lines.push(`- Skipped, ${label}: ${skipped[key]}`);
|
|
}
|
|
lines.push('', `## ${verb}`, '');
|
|
if (wouldClose.length === 0) {
|
|
lines.push('None.');
|
|
} else {
|
|
lines.push('| # | Title | Age (days) | Labels | Author |', '|---|---|---|---|---|');
|
|
for (const issue of wouldClose) {
|
|
lines.push(`| [#${issue.number}](${issue.url}) | ${escapeCell(issue.title)} | ${issue.ageDays} | ${escapeCell(issue.labels.join(', '))} | ${escapeCell(issue.author)} |`);
|
|
}
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
async function run({ graphql, owner, repo, dryRun, minAgeDays = 30, log = console.log, now = Date.now() }) {
|
|
const maintainers = await graphql(MAINTAINERS_QUERY, { owner, repo });
|
|
const text = maintainers.repository.object?.text;
|
|
if (typeof text !== 'string') throw new Error('Could not read .github/MAINTAINERS');
|
|
const team = new Set([...parseUserList(text), ...EXTRA_TEAM]);
|
|
|
|
const cutoff = now - minAgeDays * 24 * 60 * 60 * 1000;
|
|
const wouldClose = [];
|
|
const skipped = Object.fromEntries(Object.keys(SKIP_REASONS).map(key => [key, 0]));
|
|
let candidates = 0;
|
|
let cursor = null;
|
|
let done = false;
|
|
|
|
while (!done) {
|
|
const data = await graphql(ISSUES_QUERY, { owner, repo, cursor, pageSize: PAGE_SIZE });
|
|
const { nodes, pageInfo } = data.repository.issues;
|
|
for (const issue of nodes) {
|
|
const created = Date.parse(issue.createdAt);
|
|
if (created >= cutoff) {
|
|
done = true;
|
|
break;
|
|
}
|
|
candidates += 1;
|
|
const reason = classify(issue, team);
|
|
if (reason) {
|
|
skipped[reason] += 1;
|
|
continue;
|
|
}
|
|
wouldClose.push({
|
|
id: issue.id,
|
|
number: issue.number,
|
|
title: issue.title,
|
|
url: issue.url,
|
|
ageDays: Math.floor((now - created) / (24 * 60 * 60 * 1000)),
|
|
labels: issue.labels.nodes.map(label => label.name),
|
|
author: issue.author?.login ?? 'ghost',
|
|
});
|
|
}
|
|
if (!pageInfo.hasNextPage) done = true;
|
|
cursor = pageInfo.endCursor;
|
|
}
|
|
|
|
if (!dryRun) {
|
|
for (const issue of wouldClose) {
|
|
await graphql(CLOSE_MUTATION, { id: issue.id, body: CLOSE_COMMENT });
|
|
log(`Closed #${issue.number}`);
|
|
}
|
|
}
|
|
|
|
log(`candidates=${candidates} close=${wouldClose.length} skipped=${JSON.stringify(skipped)} dryRun=${dryRun}`);
|
|
const report = renderReport({
|
|
repo: `${owner}/${repo}`, dryRun, minAgeDays, candidates, wouldClose, skipped, team,
|
|
});
|
|
return { candidates, wouldClose, skipped, report };
|
|
}
|
|
|
|
module.exports = { run, classify, parseUserList, CLOSE_COMMENT, MARKER };
|
|
|
|
async function main() {
|
|
const { execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
const option = (name, fallback) => {
|
|
const index = args.indexOf(name);
|
|
return index === -1 ? fallback : args[index + 1];
|
|
};
|
|
const [owner, repo] = option('--repo', 'herdrdev/herdr').split('/');
|
|
const out = option('--out', null);
|
|
const minAgeDays = Number(option('--min-age-days', '30'));
|
|
const token = process.env.GITHUB_TOKEN ||
|
|
execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim();
|
|
|
|
const graphql = async (query, variables) => {
|
|
if (query.trimStart().startsWith('mutation')) {
|
|
throw new Error('Local runner is read-only');
|
|
}
|
|
const response = await fetch('https://api.github.com/graphql', {
|
|
method: 'POST',
|
|
headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, variables }),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok || body.errors) {
|
|
throw new Error(`GraphQL error: ${JSON.stringify(body.errors ?? body)}`);
|
|
}
|
|
return body.data;
|
|
};
|
|
|
|
const result = await run({
|
|
graphql, owner, repo, dryRun: true, minAgeDays, log: msg => console.error(msg),
|
|
});
|
|
if (out) fs.writeFileSync(out, result.report);
|
|
else process.stdout.write(result.report);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(error => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|