192 lines
7.3 KiB
YAML
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).`);
|