Files
2026-08-27 21:09:14 +00:00

192 lines
7.3 KiB
YAML

name: Comment Cop
# Flags multi-line code comments added in src/ by claude-labeled PRs and
# asks for them to be deleted. Groups containing a SAFETY: marker are
# skipped. Runs entirely against the GitHub API (no checkout of PR code).
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
pull-requests: write
jobs:
comment-cop:
if: >
github.repository == 'oven-sh/bun' &&
contains(github.event.pull_request.labels.*.name, 'claude') &&
(github.event.action != 'labeled' || github.event.label.name == 'claude')
runs-on: ubuntu-latest
timeout-minutes: 5
concurrency:
group: comment-cop-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- name: Scan diff for added multi-line comments
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const crypto = require('crypto');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pull_number = context.payload.pull_request.number;
const headSha = context.payload.pull_request.head.sha;
const SRC_EXT = /\.(rs|c|cc|cpp|h|hpp|m|mm|ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
const MIN_LINES = 2;
function isCommentLine(line) {
const t = line.trimStart();
if (t.startsWith('//')) return true;
if (t.startsWith('/*')) return true;
if (t === '*' || t === '*/' || t.startsWith('* ')) return true;
return false;
}
function groupsFromPatch(path, patch) {
const out = [];
let newLine = 0;
let cur = null;
const flush = () => {
if (cur && cur.lines.length >= MIN_LINES) {
const text = cur.lines.join('\n');
if (!/SAFETY:/.test(text)) out.push({ path, start: cur.start, end: cur.end, text });
}
cur = null;
};
for (const raw of patch.split('\n')) {
if (raw.startsWith('@@')) {
flush();
const m = /\+(\d+)/.exec(raw);
newLine = m ? parseInt(m[1], 10) : 1;
} else if (raw.startsWith('+')) {
const content = raw.slice(1);
if (isCommentLine(content)) {
if (cur) {
cur.end = newLine;
cur.lines.push(content);
} else {
cur = { start: newLine, end: newLine, lines: [content] };
}
} else {
flush();
}
newLine++;
} else if (raw.startsWith('-')) {
flush();
} else if (raw.startsWith('\\')) {
// "\ No newline at end of file"
} else {
// context line (leading space, or blank)
flush();
newLine++;
}
}
flush();
return out;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number, per_page: 100,
});
const groups = [];
for (const f of files) {
if (f.status === 'removed') continue;
if (!f.filename.startsWith('src/')) continue;
if (!SRC_EXT.test(f.filename)) continue;
if (!f.patch) continue;
for (const g of groupsFromPatch(f.filename, f.patch)) groups.push(g);
}
const keyFor = g => `${g.path}:${crypto.createHash('sha256').update(g.text).digest('hex').slice(0, 12)}`;
const presentKeys = new Set(groups.map(keyFor));
// Fetch existing comment-cop review threads (for dedup + auto-resolve).
const threads = [];
{
const q = `
query($owner: String!, $repo: String!, $pr: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { id isResolved comments(first: 1) { nodes { body } } }
}
}
}
}`;
let after = null;
for (;;) {
const res = await github.graphql(q, { owner, repo, pr: pull_number, after });
const page = res.repository.pullRequest.reviewThreads;
for (const t of page.nodes) threads.push(t);
if (!page.pageInfo.hasNextPage) break;
after = page.pageInfo.endCursor;
}
}
const seenKeys = new Set();
const toResolve = [];
for (const t of threads) {
const body = t.comments.nodes[0]?.body || '';
const m = /<!-- comment-cop:([^>\s]+) -->/.exec(body);
if (!m) continue;
const key = m[1];
seenKeys.add(key);
if (!t.isResolved && !presentKeys.has(key)) toResolve.push(t.id);
}
// Auto-resolve threads whose flagged block is gone from the current diff.
if (toResolve.length > 0) {
const mut = `
mutation($id: ID!) {
resolveReviewThread(input: { threadId: $id }) { thread { id } }
}`;
for (const id of toResolve) {
try {
await github.graphql(mut, { id });
} catch (e) {
core.warning(`resolveReviewThread failed for ${id}: ${e.message}`);
}
}
core.info(`Resolved ${toResolve.length} stale comment-cop thread(s).`);
}
// Post new line comments for groups not already flagged.
const fresh = groups.filter(g => !seenKeys.has(keyFor(g)));
if (fresh.length === 0) {
core.info(`No new comment groups to flag (${groups.length} present, all already flagged).`);
return;
}
const bodyFor = g =>
`<!-- comment-cop:${keyFor(g)} -->\n` +
`If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code\n\n`;
let posted = 0;
for (const g of fresh) {
const params = {
owner, repo, pull_number,
commit_id: headSha,
path: g.path,
line: g.end,
side: 'RIGHT',
body: bodyFor(g),
};
if (g.start < g.end) {
params.start_line = g.start;
params.start_side = 'RIGHT';
}
try {
await github.rest.pulls.createReviewComment(params);
posted++;
} catch (e) {
core.warning(`createReviewComment failed for ${g.path}:${g.start}-${g.end}: ${e.message}`);
}
}
core.info(`Posted ${posted} review comment(s).`);