Skip to content

Commit fb37547

Browse files
authored
Minimize comment noise in maintainer-approval workflow (#4926)
## Why The maintainer-approval GitHub Action deletes and recreates its PR comment on every push, review change, and reopen event. Each delete+create cycle sends notifications to all participants, creating unnecessary noise. Approval state comments ("Approved by @user") are also redundant since the commit status already conveys that information. ## Changes Before: every workflow run deletes the old comment and posts a new one, even if nothing changed. Approval events post dedicated "Approved by" comments. Now: the workflow edits the existing comment in place (no notification), skips the edit entirely when the body hasn't changed, and stops posting approval comments. On approval, any stale pending comment is cleaned up so the PR doesn't show a misleading "Waiting for approval" alongside a green status. Specifically: - Replaced `postComment` (delete+create) with `upsertComment` (find, compare, edit-in-place) - Removed `buildApprovedComment` and `buildAllGroupsApprovedComment` (commit status is sufficient) - All three success paths (maintainer approval, maintainer-authored, all groups approved) now only set commit status and clean up stale marker comments - Added `deleteMarkerComments` helper for success-path cleanup ## Test plan - [x] All 20 unit tests pass (`node --test .github/workflows/maintainer-approval.test.js`) - [x] Tests cover: create when none exists, edit in place, skip when unchanged, duplicate cleanup, stale comment cleanup on approval, no-comment on all success paths This pull request was AI-assisted by Isaac.
1 parent 0ea36ad commit fb37547

2 files changed

Lines changed: 149 additions & 86 deletions

File tree

.github/workflows/maintainer-approval.js

Lines changed: 53 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -258,32 +258,6 @@ async function selectRoundRobin(github, owner, repo, eligibleOwners, prAuthor) {
258258

259259
// --- Comment builders ---
260260

261-
function buildApprovedComment(description) {
262-
const lines = [
263-
MARKER,
264-
`## ${description}`,
265-
"",
266-
`<sub>See ${OWNERS_LINK} for ownership rules.</sub>`,
267-
];
268-
return lines.join("\n") + "\n";
269-
}
270-
271-
function buildAllGroupsApprovedComment(groups, approvedBy) {
272-
const lines = [MARKER, "## All ownership groups approved", ""];
273-
for (const [pattern, { files }] of groups) {
274-
if (pattern === "*") continue;
275-
lines.push(`### \`${pattern}\` - approved`);
276-
lines.push(`Files: ${files.map(f => `\`${f}\``).join(", ")}`);
277-
const approver = approvedBy.get(pattern);
278-
if (approver) {
279-
lines.push(`Approved by: @${approver}`);
280-
}
281-
lines.push("");
282-
}
283-
lines.push(`<sub>See ${OWNERS_LINK} for ownership rules.</sub>`);
284-
return lines.join("\n") + "\n";
285-
}
286-
287261
function buildPendingPerGroupComment(groups, scores, dirScores, approvedBy, maintainers, prAuthor) {
288262
const authorLower = (prAuthor || "").toLowerCase();
289263
const lines = [MARKER, "## Approval status: pending", ""];
@@ -392,20 +366,56 @@ function buildSingleDomainPendingComment(sortedScores, dirScores, scoredCount, e
392366

393367
const LEGACY_MARKER = "<!-- REVIEWER_SUGGESTION -->";
394368

395-
async function postComment(github, owner, repo, prNumber, comment) {
369+
/**
370+
* Delete all marker and legacy marker comments from the PR.
371+
* Used on success paths to clean up stale pending comments.
372+
*/
373+
async function deleteMarkerComments(github, owner, repo, prNumber) {
396374
const comments = await github.paginate(github.rest.issues.listComments, {
397375
owner, repo, issue_number: prNumber,
398376
});
399-
const toDelete = comments.filter(c =>
377+
for (const c of comments) {
378+
if (c.body && (c.body.includes(MARKER) || c.body.includes(LEGACY_MARKER))) {
379+
await github.rest.issues.deleteComment({
380+
owner, repo, comment_id: c.id,
381+
});
382+
}
383+
}
384+
}
385+
386+
/**
387+
* Create or edit the marker comment. Skips the edit if the body is unchanged.
388+
* Cleans up duplicate or legacy marker comments, keeping only the first one.
389+
*/
390+
async function upsertComment(github, owner, repo, prNumber, newBody) {
391+
const comments = await github.paginate(github.rest.issues.listComments, {
392+
owner, repo, issue_number: prNumber,
393+
});
394+
const markerComments = comments.filter(c =>
400395
c.body && (c.body.includes(MARKER) || c.body.includes(LEGACY_MARKER))
401396
);
402-
for (const c of toDelete) {
403-
await github.rest.issues.deleteComment({
404-
owner, repo, comment_id: c.id,
397+
398+
if (markerComments.length > 0) {
399+
const existing = markerComments[0];
400+
401+
// Clean up duplicates (legacy or accidental), keep the first.
402+
for (const c of markerComments.slice(1)) {
403+
await github.rest.issues.deleteComment({
404+
owner, repo, comment_id: c.id,
405+
});
406+
}
407+
408+
// Skip if body is unchanged.
409+
if (existing.body === newBody) return;
410+
411+
await github.rest.issues.updateComment({
412+
owner, repo, comment_id: existing.id, body: newBody,
405413
});
414+
return;
406415
}
416+
407417
await github.rest.issues.createComment({
408-
owner, repo, issue_number: prNumber, body: comment,
418+
owner, repo, issue_number: prNumber, body: newBody,
409419
});
410420
}
411421

@@ -459,8 +469,7 @@ module.exports = async ({ github, context, core }) => {
459469
state: "success",
460470
description: `Approved by @${approver}`,
461471
});
462-
await postComment(github, owner, repo, prNumber,
463-
buildApprovedComment(`Approved by @${approver}`));
472+
await deleteMarkerComments(github, owner, repo, prNumber);
464473
return;
465474
}
466475

@@ -477,8 +486,7 @@ module.exports = async ({ github, context, core }) => {
477486
state: "success",
478487
description: "Approved (maintainer-authored PR)",
479488
});
480-
await postComment(github, owner, repo, prNumber,
481-
buildApprovedComment("Approved (maintainer-authored PR)"));
489+
await deleteMarkerComments(github, owner, repo, prNumber);
482490
return;
483491
}
484492
}
@@ -506,15 +514,19 @@ module.exports = async ({ github, context, core }) => {
506514
core
507515
);
508516

509-
// Set commit status
517+
// Set commit status. Approved PRs return early (commit status is sufficient).
510518
if (result.allCovered && approverLogins.length > 0) {
511519
core.info("All ownership groups have per-path approval.");
512520
await github.rest.repos.createCommitStatus({
513521
...statusParams,
514522
state: "success",
515523
description: "All ownership groups approved",
516524
});
517-
} else if (result.hasWildcardFiles) {
525+
await deleteMarkerComments(github, owner, repo, prNumber);
526+
return;
527+
}
528+
529+
if (result.hasWildcardFiles) {
518530
const fileList = result.wildcardFiles.join(", ");
519531
const msg =
520532
`Files need maintainer review: ${fileList}. ` +
@@ -561,13 +573,11 @@ module.exports = async ({ github, context, core }) => {
561573
);
562574
const sortedScores = Object.entries(scores).sort((a, b) => b[1] - a[1]);
563575

564-
// Build comment based on approval state and ownership groups
576+
// Build pending comment with reviewer suggestions.
565577
let comment;
566578
const groups = result.groups;
567579

568-
if (result.allCovered && approverLogins.length > 0) {
569-
comment = buildAllGroupsApprovedComment(groups, result.approvedBy);
570-
} else if (groups.size >= 2) {
580+
if (groups.size >= 2) {
571581
comment = buildPendingPerGroupComment(
572582
groups, scores, dirScores, result.approvedBy, maintainers, authorLogin
573583
);
@@ -583,5 +593,5 @@ module.exports = async ({ github, context, core }) => {
583593
}
584594

585595
core.info(comment);
586-
await postComment(github, owner, repo, prNumber, comment);
596+
await upsertComment(github, owner, repo, prNumber, comment);
587597
};

0 commit comments

Comments
 (0)