Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 53 additions & 43 deletions .github/workflows/maintainer-approval.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,32 +258,6 @@ async function selectRoundRobin(github, owner, repo, eligibleOwners, prAuthor) {

// --- Comment builders ---

function buildApprovedComment(description) {
const lines = [
MARKER,
`## ${description}`,
"",
`<sub>See ${OWNERS_LINK} for ownership rules.</sub>`,
];
return lines.join("\n") + "\n";
}

function buildAllGroupsApprovedComment(groups, approvedBy) {
const lines = [MARKER, "## All ownership groups approved", ""];
for (const [pattern, { files }] of groups) {
if (pattern === "*") continue;
lines.push(`### \`${pattern}\` - approved`);
lines.push(`Files: ${files.map(f => `\`${f}\``).join(", ")}`);
const approver = approvedBy.get(pattern);
if (approver) {
lines.push(`Approved by: @${approver}`);
}
lines.push("");
}
lines.push(`<sub>See ${OWNERS_LINK} for ownership rules.</sub>`);
return lines.join("\n") + "\n";
}

function buildPendingPerGroupComment(groups, scores, dirScores, approvedBy, maintainers, prAuthor) {
const authorLower = (prAuthor || "").toLowerCase();
const lines = [MARKER, "## Approval status: pending", ""];
Expand Down Expand Up @@ -392,20 +366,56 @@ function buildSingleDomainPendingComment(sortedScores, dirScores, scoredCount, e

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

async function postComment(github, owner, repo, prNumber, comment) {
/**
* Delete all marker and legacy marker comments from the PR.
* Used on success paths to clean up stale pending comments.
*/
async function deleteMarkerComments(github, owner, repo, prNumber) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber,
});
const toDelete = comments.filter(c =>
for (const c of comments) {
if (c.body && (c.body.includes(MARKER) || c.body.includes(LEGACY_MARKER))) {
await github.rest.issues.deleteComment({
owner, repo, comment_id: c.id,
});
}
}
}

/**
* Create or edit the marker comment. Skips the edit if the body is unchanged.
* Cleans up duplicate or legacy marker comments, keeping only the first one.
*/
async function upsertComment(github, owner, repo, prNumber, newBody) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber,
});
const markerComments = comments.filter(c =>
c.body && (c.body.includes(MARKER) || c.body.includes(LEGACY_MARKER))
);
for (const c of toDelete) {
await github.rest.issues.deleteComment({
owner, repo, comment_id: c.id,

if (markerComments.length > 0) {
const existing = markerComments[0];

// Clean up duplicates (legacy or accidental), keep the first.
for (const c of markerComments.slice(1)) {
await github.rest.issues.deleteComment({
owner, repo, comment_id: c.id,
});
}

// Skip if body is unchanged.
if (existing.body === newBody) return;

await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: newBody,
});
return;
}

await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: comment,
owner, repo, issue_number: prNumber, body: newBody,
});
}

Expand Down Expand Up @@ -459,8 +469,7 @@ module.exports = async ({ github, context, core }) => {
state: "success",
description: `Approved by @${approver}`,
});
await postComment(github, owner, repo, prNumber,
buildApprovedComment(`Approved by @${approver}`));
await deleteMarkerComments(github, owner, repo, prNumber);
return;
}

Expand All @@ -477,8 +486,7 @@ module.exports = async ({ github, context, core }) => {
state: "success",
description: "Approved (maintainer-authored PR)",
});
await postComment(github, owner, repo, prNumber,
buildApprovedComment("Approved (maintainer-authored PR)"));
await deleteMarkerComments(github, owner, repo, prNumber);
return;
}
}
Expand Down Expand Up @@ -506,15 +514,19 @@ module.exports = async ({ github, context, core }) => {
core
);

// Set commit status
// Set commit status. Approved PRs return early (commit status is sufficient).
if (result.allCovered && approverLogins.length > 0) {
core.info("All ownership groups have per-path approval.");
await github.rest.repos.createCommitStatus({
...statusParams,
state: "success",
description: "All ownership groups approved",
});
} else if (result.hasWildcardFiles) {
await deleteMarkerComments(github, owner, repo, prNumber);
return;
}

if (result.hasWildcardFiles) {
const fileList = result.wildcardFiles.join(", ");
const msg =
`Files need maintainer review: ${fileList}. ` +
Expand Down Expand Up @@ -561,13 +573,11 @@ module.exports = async ({ github, context, core }) => {
);
const sortedScores = Object.entries(scores).sort((a, b) => b[1] - a[1]);

// Build comment based on approval state and ownership groups
// Build pending comment with reviewer suggestions.
let comment;
const groups = result.groups;

if (result.allCovered && approverLogins.length > 0) {
comment = buildAllGroupsApprovedComment(groups, result.approvedBy);
} else if (groups.size >= 2) {
if (groups.size >= 2) {
comment = buildPendingPerGroupComment(
groups, scores, dirScores, result.approvedBy, maintainers, authorLogin
);
Expand All @@ -583,5 +593,5 @@ module.exports = async ({ github, context, core }) => {
}

core.info(comment);
await postComment(github, owner, repo, prNumber, comment);
await upsertComment(github, owner, repo, prNumber, comment);
};
Loading
Loading