From 42d83455cdfb48033dac5e5215af4894928e6354 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 8 Apr 2026 19:38:31 +0200 Subject: [PATCH 1/2] Merge suggest-reviewers into maintainer-approval as a single workflow The suggest-reviewers workflow posted a comment with reviewer suggestions based on git history. The maintainer-approval workflow checked approval status and set a commit status. This merges both into one workflow so every PR gets a single comment that shows both approval status and reviewer suggestions. Changes: - maintainer-approval.js now scores contributors via git history and posts a comment with approval status per ownership group, reviewer suggestions, and eligible reviewers. Uses delete-then-create for comments so they always appear at the bottom. - maintainer-approval.yml gets pull-requests:write, contents:read, fetch-depth:0 (full history for git log), and skips drafts. - Deleted suggest-reviewers.js and suggest-reviewers.yml. - Updated test-owners-scripts.yml to remove suggest-reviewers path. - Added 7 new tests for comment posting (marker, delete-then-create, per-group, single-domain, all-approved formats). Co-authored-by: Isaac --- .github/workflows/maintainer-approval.js | 456 +++++++++++++++-- .github/workflows/maintainer-approval.test.js | 151 +++++- .github/workflows/maintainer-approval.yml | 16 +- .github/workflows/suggest-reviewers.js | 461 ------------------ .github/workflows/suggest-reviewers.yml | 30 -- .github/workflows/test-owners-scripts.yml | 1 - 6 files changed, 561 insertions(+), 554 deletions(-) delete mode 100644 .github/workflows/suggest-reviewers.js delete mode 100644 .github/workflows/suggest-reviewers.yml diff --git a/.github/workflows/maintainer-approval.js b/.github/workflows/maintainer-approval.js index aedd979961..f0efdb037b 100644 --- a/.github/workflows/maintainer-approval.js +++ b/.github/workflows/maintainer-approval.js @@ -1,10 +1,14 @@ const path = require("path"); +const { execSync } = require("child_process"); const { parseOwnersFile, + findOwners, getMaintainers, getOwnershipGroups, } = require("../scripts/owners"); +// --- Approval helpers --- + /** * Check if an approver is a member of a GitHub team. * Requires org read access on the token; falls back to false if unavailable. @@ -19,10 +23,8 @@ async function isTeamMember(github, org, teamSlug, login, core) { return data.state === "active"; } catch (err) { if (err.status === 404) { - // User is genuinely not a member of this team. return false; } - // Permission denied or other error. Log it so it's visible. if (core) { core.warning( `Could not verify team membership for ${login} in ${org}/${teamSlug} ` + @@ -35,56 +37,376 @@ async function isTeamMember(github, org, teamSlug, login, core) { } /** - * Check if any approver matches an owner entry. - * Owner can be a plain login or an org/team ref (containing "/"). + * Find which approver (if any) satisfies a group's ownership requirement. + * Returns the login of the first matching approver, or null. */ -async function ownerHasApproval(owner, approverSet, github, org, core) { - if (owner.includes("/")) { - const teamSlug = owner.split("/")[1]; - for (const approver of approverSet) { - if (await isTeamMember(github, org, teamSlug, approver, core)) { - return true; +async function findGroupApprover(owners, approverLogins, github, org, core) { + const approverSet = new Set(approverLogins.map(l => l.toLowerCase())); + for (const owner of owners) { + if (owner.includes("/")) { + const teamSlug = owner.split("/")[1]; + for (const approver of approverLogins) { + if (await isTeamMember(github, org, teamSlug, approver, core)) { + return approver; + } } + } else if (approverSet.has(owner.toLowerCase())) { + return owner; } - return false; } - return approverSet.has(owner.toLowerCase()); + return null; } /** * Per-path approval check. Each ownership group needs at least one * approval from its owners. Files matching only "*" require a maintainer. + * Returns groups, approvedBy map, and coverage info. */ async function checkPerPathApproval(files, rulesWithTeams, approverLogins, github, org, core) { const groups = getOwnershipGroups(files.map(f => f.filename), rulesWithTeams); - const approverSet = new Set(approverLogins.map(l => l.toLowerCase())); + const approvedBy = new Map(); if (groups.has("*")) { + // Still check non-wildcard groups for comment building + for (const [pattern, { owners }] of groups) { + if (pattern === "*") continue; + const approver = await findGroupApprover(owners, approverLogins, github, org, core); + if (approver) approvedBy.set(pattern, approver); + } return { allCovered: false, hasWildcardFiles: true, wildcardFiles: groups.get("*").files, + groups, + approvedBy, }; } const uncovered = []; for (const [pattern, { owners }] of groups) { - let hasApproval = false; - for (const owner of owners) { - if (await ownerHasApproval(owner, approverSet, github, org, core)) { - hasApproval = true; - break; - } - } - if (!hasApproval) { + const approver = await findGroupApprover(owners, approverLogins, github, org, core); + if (approver) { + approvedBy.set(pattern, approver); + } else { uncovered.push({ pattern, owners }); } } - return { allCovered: uncovered.length === 0, uncovered }; + return { allCovered: uncovered.length === 0, uncovered, groups, approvedBy }; } +// --- Git history & scoring helpers --- + +const MENTION_REVIEWERS = true; +const OWNERS_LINK = "[OWNERS](.github/OWNERS)"; +const MARKER = ""; const STATUS_CONTEXT = "maintainer-approval"; +const loginCache = {}; + +function classifyFile(filepath, totalFiles) { + const base = path.basename(filepath); + if (base.startsWith("out.") || base === "output.txt") { + return 0.01 / Math.max(totalFiles, 1); + } + if (filepath.startsWith("acceptance/") || filepath.startsWith("integration/")) { + return 0.2; + } + if (filepath.endsWith("_test.go")) return 0.3; + return filepath.endsWith(".go") ? 1.0 : 0.5; +} + +function gitLog(filepath) { + try { + const out = execSync( + `git log -50 --no-merges --since="12 months ago" --format="%H|%an|%aI" -- "${filepath}"`, + { encoding: "utf-8" } + ); + const entries = []; + for (const line of out.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parts = trimmed.split("|", 3); + if (parts.length !== 3) continue; + const date = new Date(parts[2]); + if (isNaN(date.getTime())) continue; + entries.push({ sha: parts[0], name: parts[1], date }); + } + return entries; + } catch { + return []; + } +} + +async function resolveLogin(github, owner, repo, sha, authorName) { + if (authorName in loginCache) return loginCache[authorName]; + try { + const { data } = await github.rest.repos.getCommit({ owner, repo, ref: sha }); + const login = data.author?.login || null; + loginCache[authorName] = login; + return login; + } catch { + loginCache[authorName] = null; + return null; + } +} + +function parseOwnersForFiles(changedFiles, ownersPath) { + const rules = parseOwnersFile(ownersPath, { includeTeams: true }); + const allOwners = new Set(); + for (const filepath of changedFiles) { + for (const o of findOwners(filepath, rules)) allOwners.add(o); + } + return Array.from(allOwners).sort(); +} + +async function scoreContributors(files, prAuthor, now, github, owner, repo) { + const scores = {}; + const dirScores = {}; + let scoredCount = 0; + const authorLogin = (prAuthor || "").toLowerCase(); + const totalFiles = files.length; + + for (const filepath of files) { + const weight = classifyFile(filepath, totalFiles); + let history = gitLog(filepath); + if (history.length === 0) { + const parent = path.dirname(filepath); + if (parent && parent !== ".") { + history = gitLog(parent); + } + } + if (history.length === 0) continue; + + const topDir = path.dirname(filepath) || "."; + let fileContributed = false; + for (const { sha, name, date } of history) { + if (name.endsWith("[bot]")) continue; + const login = await resolveLogin(github, owner, repo, sha, name); + if (!login || login.toLowerCase() === authorLogin) continue; + const daysAgo = Math.max(0, (now - date) / 86400000); + const s = weight * Math.pow(0.5, daysAgo / 150); + scores[login] = (scores[login] || 0) + s; + if (!dirScores[login]) dirScores[login] = {}; + dirScores[login][topDir] = (dirScores[login][topDir] || 0) + s; + fileContributed = true; + } + if (fileContributed) scoredCount++; + } + return { scores, dirScores, scoredCount }; +} + +function topDirs(ds, n = 3) { + return Object.entries(ds || {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, n) + .map(([d]) => d); +} + +function fmtReviewer(login, dirs) { + const mention = MENTION_REVIEWERS ? `@${login}` : login; + const dirList = dirs.map((d) => `\`${d}/\``).join(", "); + return `- ${mention} -- recent work in ${dirList}`; +} + +function selectReviewers(ss) { + if (ss.length === 0) return []; + const out = [ss[0]]; + if (ss.length >= 2 && ss[0][1] < 1.5 * ss[1][1]) { + out.push(ss[1]); + if (ss.length >= 3 && ss[1][1] < 1.5 * ss[2][1]) { + out.push(ss[2]); + } + } + return out; +} + +function fmtEligible(owners) { + if (MENTION_REVIEWERS) return owners.map((o) => `@${o}`).join(", "); + return owners.join(", "); +} + +async function countRecentReviews(github, owner, repo, logins, days = 30) { + const since = new Date(Date.now() - days * 86400000) + .toISOString() + .slice(0, 10); + const counts = {}; + for (const login of logins) { + try { + const { data } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} reviewed-by:${login} is:pr created:>${since}`, + }); + counts[login] = data.total_count; + } catch { + // skip on error + } + } + return counts; +} + +async function selectRoundRobin(github, owner, repo, eligibleOwners, prAuthor) { + const candidates = eligibleOwners + .filter((o) => !o.includes("/") && o.toLowerCase() !== (prAuthor || "").toLowerCase()); + if (candidates.length === 0) return null; + const counts = await countRecentReviews(github, owner, repo, candidates); + if (Object.keys(counts).length === 0) { + return candidates[Math.floor(Math.random() * candidates.length)]; + } + return candidates.reduce((best, c) => + (counts[c] || 0) < (counts[best] || 0) ? c : best + ); +} + +// --- Comment builders --- + +function buildApprovedComment(description) { + const lines = [ + MARKER, + `## ${description}`, + "", + `See ${OWNERS_LINK} for ownership rules.`, + ]; + 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(`See ${OWNERS_LINK} for ownership rules.`); + return lines.join("\n") + "\n"; +} + +function buildPendingPerGroupComment(groups, scores, dirScores, approvedBy, maintainers, prAuthor) { + const authorLower = (prAuthor || "").toLowerCase(); + const lines = [MARKER, "## Approval status: pending", ""]; + + for (const [pattern, { owners, files }] of groups) { + if (pattern === "*") continue; + + const approver = approvedBy.get(pattern); + if (approver) { + lines.push(`### \`${pattern}\` - approved by @${approver}`); + } else { + lines.push(`### \`${pattern}\` - needs approval`); + } + lines.push(`Files: ${files.map(f => `\`${f}\``).join(", ")}`); + + const teams = owners.filter(o => o.includes("/")); + const individuals = owners.filter(o => !o.includes("/") && o.toLowerCase() !== authorLower); + + if (teams.length > 0) { + lines.push(`Teams: ${teams.map(t => `@${t}`).join(", ")}`); + } + + if (!approver && individuals.length > 0) { + const scored = individuals.map(o => [o, scores[o] || 0]).sort((a, b) => b[1] - a[1]); + if (scored[0][1] > 0) { + lines.push(`Suggested: @${scored[0][0]}`); + const rest = scored.slice(1).map(([o]) => o); + if (rest.length > 0) { + lines.push(`Also eligible: ${fmtEligible(rest)}`); + } + } else { + lines.push(`Eligible: ${fmtEligible(individuals)}`); + } + } + lines.push(""); + } + + const starGroup = groups.get("*"); + if (starGroup) { + lines.push("### General files (require maintainer)"); + lines.push(`Files: ${starGroup.files.map(f => `\`${f}\``).join(", ")}`); + + const maintainerSet = new Set(maintainers.map(m => m.toLowerCase())); + const maintainerScores = Object.entries(scores) + .filter(([login]) => + login.toLowerCase() !== authorLower && maintainerSet.has(login.toLowerCase()) + ) + .sort((a, b) => b[1] - a[1]); + + if (maintainerScores.length > 0 && maintainerScores[0][1] > 0) { + const [login] = maintainerScores[0]; + const dirs = topDirs(dirScores[login]); + lines.push("Based on git history:"); + lines.push(fmtReviewer(login, dirs)); + } else { + lines.push(`Pick a maintainer from ${OWNERS_LINK}.`); + } + lines.push(""); + } + + const maintainerList = maintainers + .filter(m => m.toLowerCase() !== authorLower) + .map(m => `@${m}`) + .join(", "); + + lines.push( + `Any maintainer (${maintainerList}) can approve all areas.`, + `See ${OWNERS_LINK} for ownership rules.` + ); + + return lines.join("\n") + "\n"; +} + +function buildSingleDomainPendingComment(sortedScores, dirScores, scoredCount, eligibleOwners, prAuthor, roundRobinReviewer) { + const reviewers = selectReviewers(sortedScores); + const suggestedLogins = new Set(reviewers.map(([login]) => login.toLowerCase())); + const eligible = eligibleOwners.filter( + o => o.toLowerCase() !== (prAuthor || "").toLowerCase() && !suggestedLogins.has(o.toLowerCase()) + ); + + const lines = [MARKER, "## Waiting for approval", ""]; + + if (reviewers.length > 0) { + lines.push("Based on git history, these people are best suited to review:", ""); + for (const [login] of reviewers) { + lines.push(fmtReviewer(login, topDirs(dirScores[login]))); + } + lines.push(""); + } else if (roundRobinReviewer) { + lines.push( + "Could not determine reviewers from git history.", + `Round-robin suggestion: @${roundRobinReviewer}`, + "" + ); + } + + if (eligible.length > 0) { + lines.push(`Eligible reviewers: ${fmtEligible(eligible)}`, ""); + } + + lines.push(`Suggestions based on git history. See ${OWNERS_LINK} for ownership rules.`); + return lines.join("\n") + "\n"; +} + +// --- Comment management --- + +async function postComment(github, owner, repo, prNumber, comment) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, + }); + const existing = comments.find(c => c.body && c.body.includes(MARKER)); + if (existing) { + await github.rest.issues.deleteComment({ + owner, repo, comment_id: existing.id, + }); + } + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: comment, + }); +} + +// --- Main --- + module.exports = async ({ github, context, core }) => { const ownersPath = path.join( process.env.GITHUB_WORKSPACE, @@ -102,6 +424,9 @@ module.exports = async ({ github, context, core }) => { } const { pull_request: pr } = context.payload; + const owner = context.repo.owner; + const repo = context.repo.repo; + const prNumber = context.issue.number; const authorLogin = pr?.user?.login; const sha = pr.head.sha; const statusParams = { @@ -117,26 +442,25 @@ module.exports = async ({ github, context, core }) => { pull_number: context.issue.number, }); - const maintainerApproved = reviews.some( + // Maintainer approval -> success with simple comment + const maintainerApproval = reviews.find( ({ state, user }) => state === "APPROVED" && user && maintainers.includes(user.login) ); - - if (maintainerApproved) { - const approver = reviews.find( - ({ state, user }) => - state === "APPROVED" && user && maintainers.includes(user.login) - ); - core.info(`Maintainer approval from @${approver.user.login}`); + if (maintainerApproval) { + const approver = maintainerApproval.user.login; + core.info(`Maintainer approval from @${approver}`); await github.rest.repos.createCommitStatus({ ...statusParams, state: "success", - description: `Approved by @${approver.user.login}`, + description: `Approved by @${approver}`, }); + await postComment(github, owner, repo, prNumber, + buildApprovedComment(`Approved by @${approver}`)); return; } - // If the PR author is a maintainer, any approval suffices (second pair of eyes). + // Maintainer-authored PR with any approval -> success if (authorLogin && maintainers.includes(authorLogin)) { const hasAnyApproval = reviews.some( ({ state, user }) => @@ -149,12 +473,13 @@ module.exports = async ({ github, context, core }) => { state: "success", description: "Approved (maintainer-authored PR)", }); + await postComment(github, owner, repo, prNumber, + buildApprovedComment("Approved (maintainer-authored PR)")); return; } } - // Gather approved logins (excluding the PR author, since GitHub prevents - // self-approval, but we filter defensively in case of API edge cases). + // Gather approved logins (excluding the PR author). const approverLogins = reviews .filter( ({ state, user }) => @@ -177,6 +502,7 @@ module.exports = async ({ github, context, core }) => { core ); + // Set commit status if (result.allCovered && approverLogins.length > 0) { core.info("All ownership groups have per-path approval."); await github.rest.repos.createCommitStatus({ @@ -184,10 +510,7 @@ module.exports = async ({ github, context, core }) => { state: "success", description: "All ownership groups approved", }); - return; - } - - if (result.hasWildcardFiles) { + } else if (result.hasWildcardFiles) { const fileList = result.wildcardFiles.join(", "); const msg = `Files need maintainer review: ${fileList}. ` + @@ -198,10 +521,7 @@ module.exports = async ({ github, context, core }) => { state: "pending", description: msg.length > 140 ? msg.slice(0, 137) + "..." : msg, }); - return; - } - - if (result.uncovered && result.uncovered.length > 0) { + } else if (result.uncovered && result.uncovered.length > 0) { const groupList = result.uncovered .map(({ pattern, owners }) => `${pattern} (needs: ${owners.join(", ")})`) .join("; "); @@ -214,14 +534,50 @@ module.exports = async ({ github, context, core }) => { state: "pending", description: msg.length > 140 ? msg.slice(0, 137) + "..." : msg, }); - return; + } else { + const msg = `Waiting for maintainer approval: ${maintainers.join(", ")}`; + core.info(msg); + await github.rest.repos.createCommitStatus({ + ...statusParams, + state: "pending", + description: msg.length > 140 ? msg.slice(0, 137) + "..." : msg, + }); } - const msg = `Waiting for maintainer approval: ${maintainers.join(", ")}`; - core.info(msg); - await github.rest.repos.createCommitStatus({ - ...statusParams, - state: "pending", - description: msg.length > 140 ? msg.slice(0, 137) + "..." : msg, - }); + // Score contributors via git history + const fileNames = files.map(f => f.filename); + const now = new Date(); + const { scores, dirScores, scoredCount } = await scoreContributors( + fileNames, + authorLogin, + now, + github, + owner, + repo + ); + const sortedScores = Object.entries(scores).sort((a, b) => b[1] - a[1]); + + // Build comment based on approval state and ownership groups + let comment; + const groups = result.groups; + + if (result.allCovered && approverLogins.length > 0) { + comment = buildAllGroupsApprovedComment(groups, result.approvedBy); + } else if (groups.size >= 2) { + comment = buildPendingPerGroupComment( + groups, scores, dirScores, result.approvedBy, maintainers, authorLogin + ); + } else { + const eligible = parseOwnersForFiles(fileNames, ownersPath); + let roundRobin = null; + if (selectReviewers(sortedScores).length === 0 && eligible.length > 0) { + roundRobin = await selectRoundRobin(github, owner, repo, eligible, authorLogin); + } + comment = buildSingleDomainPendingComment( + sortedScores, dirScores, scoredCount, eligible, authorLogin, roundRobin + ); + } + + core.info(comment); + await postComment(github, owner, repo, prNumber, comment); }; diff --git a/.github/workflows/maintainer-approval.test.js b/.github/workflows/maintainer-approval.test.js index da3f0967ea..3c38ddc10d 100644 --- a/.github/workflows/maintainer-approval.test.js +++ b/.github/workflows/maintainer-approval.test.js @@ -1,4 +1,4 @@ -const { describe, it, before, after, beforeEach } = require("node:test"); +const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); @@ -29,6 +29,7 @@ function makeContext({ author = "someuser", sha = "abc123", prNumber = 42 } = {} issue: { number: prNumber }, payload: { pull_request: { + number: prNumber, user: { login: author }, head: { sha }, }, @@ -53,16 +54,21 @@ function makeCore() { * @param {Array} opts.reviews - PR reviews to return * @param {Array} opts.files - PR files to return (objects with .filename) * @param {Object} opts.teamMembers - { teamSlug: [logins] } + * @param {Array} opts.existingComments - Existing PR comments to return */ -function makeGithub({ reviews = [], files = [], teamMembers = {} } = {}) { +function makeGithub({ reviews = [], files = [], teamMembers = {}, existingComments = [] } = {}) { const listReviews = Symbol("listReviews"); const listFiles = Symbol("listFiles"); + const listComments = Symbol("listComments"); const statuses = []; + const createdComments = []; + const deletedCommentIds = []; const github = { paginate: async (endpoint, _opts) => { if (endpoint === listReviews) return reviews; if (endpoint === listFiles) return files; + if (endpoint === listComments) return existingComments; return []; }, rest: { @@ -75,6 +81,15 @@ function makeGithub({ reviews = [], files = [], teamMembers = {} } = {}) { statuses.push(params); }, }, + issues: { + listComments, + deleteComment: async (params) => { + deletedCommentIds.push(params.comment_id); + }, + createComment: async (params) => { + createdComments.push(params); + }, + }, teams: { getMembershipForUserInOrg: async ({ team_slug, username }) => { if (teamMembers[team_slug]?.includes(username)) { @@ -87,6 +102,8 @@ function makeGithub({ reviews = [], files = [], teamMembers = {} } = {}) { }, }, _statuses: statuses, + _comments: createdComments, + _deletedCommentIds: deletedCommentIds, }; return github; } @@ -319,4 +336,134 @@ describe("maintainer-approval", () => { process.env.GITHUB_WORKSPACE = oldWorkspace; fs.rmSync(noWildcardDir, { recursive: true }); }); + + // --- Comment posting tests --- + + it("posts a comment with MARKER on every run", async () => { + const github = makeGithub({ + reviews: [], + files: [{ filename: "cmd/pipelines/foo.go" }], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._comments.length, 1); + assert.ok(github._comments[0].body.includes("")); + }); + + it("maintainer approval posts simple approved comment", async () => { + const github = makeGithub({ + reviews: [ + { state: "APPROVED", user: { login: "maintainer1" } }, + ], + files: [{ filename: "cmd/pipelines/foo.go" }], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._comments.length, 1); + assert.ok(github._comments[0].body.includes("Approved by @maintainer1")); + assert.ok(github._comments[0].body.includes("")); + }); + + it("deletes existing comment before posting new one", async () => { + const github = makeGithub({ + reviews: [], + files: [{ filename: "cmd/pipelines/foo.go" }], + existingComments: [ + { id: 999, body: "\nOld comment" }, + ], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._deletedCommentIds.length, 1); + assert.equal(github._deletedCommentIds[0], 999); + assert.equal(github._comments.length, 1); + assert.ok(github._comments[0].body.includes("")); + }); + + it("does not delete comments without the marker", async () => { + const github = makeGithub({ + reviews: [], + files: [{ filename: "cmd/pipelines/foo.go" }], + existingComments: [ + { id: 888, body: "Some unrelated comment" }, + ], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._deletedCommentIds.length, 0); + assert.equal(github._comments.length, 1); + }); + + it("pending single-domain comment includes waiting header", async () => { + const github = makeGithub({ + reviews: [], + files: [{ filename: "cmd/pipelines/foo.go" }], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._comments.length, 1); + assert.ok(github._comments[0].body.includes("## Waiting for approval")); + }); + + it("pending cross-domain comment includes per-group sections", async () => { + const github = makeGithub({ + reviews: [ + { state: "APPROVED", user: { login: "jefferycheng1" } }, + ], + files: [ + { filename: "cmd/pipelines/foo.go" }, + { filename: "bundle/config.go" }, + ], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._comments.length, 1); + const body = github._comments[0].body; + assert.ok(body.includes("## Approval status: pending")); + assert.ok(body.includes("`/cmd/pipelines/`")); + assert.ok(body.includes("`/bundle/`")); + assert.ok(body.includes("approved by @jefferycheng1")); + assert.ok(body.includes("needs approval")); + }); + + it("all groups approved comment shows per-group detail", async () => { + const github = makeGithub({ + reviews: [ + { state: "APPROVED", user: { login: "jefferycheng1" } }, + { state: "APPROVED", user: { login: "bundleowner" } }, + ], + files: [ + { filename: "cmd/pipelines/foo.go" }, + { filename: "bundle/config.go" }, + ], + }); + const core = makeCore(); + const context = makeContext(); + + await runModule({ github, context, core }); + + assert.equal(github._comments.length, 1); + const body = github._comments[0].body; + assert.ok(body.includes("## All ownership groups approved")); + assert.ok(body.includes("Approved by: @jefferycheng1")); + assert.ok(body.includes("Approved by: @bundleowner")); + }); }); diff --git a/.github/workflows/maintainer-approval.yml b/.github/workflows/maintainer-approval.yml index d48086f0dc..ffc113d61d 100644 --- a/.github/workflows/maintainer-approval.yml +++ b/.github/workflows/maintainer-approval.yml @@ -1,4 +1,4 @@ -name: Maintainer approval +name: PR approval on: pull_request_target: @@ -14,22 +14,18 @@ jobs: runs-on: group: databricks-deco-testing-runner-group labels: ubuntu-latest-deco + if: ${{ !github.event.pull_request.draft }} timeout-minutes: 5 - # Note: team membership resolution in per-path approval requires a token - # with org:read scope. The default GITHUB_TOKEN may lack this, causing - # team-based ownership checks to fall back to maintainer approval. permissions: - pull-requests: read + pull-requests: write statuses: write + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - sparse-checkout: | - .github/workflows - .github/scripts - .github/OWNERS - - name: Require core team approval + fetch-depth: 0 + - name: Check approval and suggest reviewers uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: retries: 3 diff --git a/.github/workflows/suggest-reviewers.js b/.github/workflows/suggest-reviewers.js deleted file mode 100644 index efac10a335..0000000000 --- a/.github/workflows/suggest-reviewers.js +++ /dev/null @@ -1,461 +0,0 @@ -const path = require("path"); -const { execSync } = require("child_process"); -const { - parseOwnersFile, - findOwners, - getMaintainers, - getOwnershipGroups, -} = require("../scripts/owners"); - -const MENTION_REVIEWERS = true; -const OWNERS_LINK = "[OWNERS](.github/OWNERS)"; -const MARKER = ""; - -const loginCache = {}; - -function classifyFile(filepath, totalFiles) { - const base = path.basename(filepath); - if (base.startsWith("out.") || base === "output.txt") { - return 0.01 / Math.max(totalFiles, 1); - } - if (filepath.startsWith("acceptance/") || filepath.startsWith("integration/")) { - return 0.2; - } - if (filepath.endsWith("_test.go")) return 0.3; - return filepath.endsWith(".go") ? 1.0 : 0.5; -} - -async function getChangedFiles(github, owner, repo, prNumber) { - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number: prNumber, - }); - return files.map((f) => f.filename); -} - -function gitLog(filepath) { - try { - const out = execSync( - `git log -50 --no-merges --since="12 months ago" --format="%H|%an|%aI" -- "${filepath}"`, - { encoding: "utf-8" } - ); - const entries = []; - for (const line of out.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parts = trimmed.split("|", 3); - if (parts.length !== 3) continue; - const date = new Date(parts[2]); - if (isNaN(date.getTime())) continue; - entries.push({ sha: parts[0], name: parts[1], date }); - } - return entries; - } catch { - return []; - } -} - -async function resolveLogin(github, owner, repo, sha, authorName) { - if (authorName in loginCache) return loginCache[authorName]; - try { - const { data } = await github.rest.repos.getCommit({ owner, repo, ref: sha }); - const login = data.author?.login || null; - loginCache[authorName] = login; - return login; - } catch { - loginCache[authorName] = null; - return null; - } -} - -function parseOwnersForFiles(changedFiles, ownersPath) { - const rules = parseOwnersFile(ownersPath, { includeTeams: true }); - const allOwners = new Set(); - for (const filepath of changedFiles) { - for (const o of findOwners(filepath, rules)) allOwners.add(o); - } - return Array.from(allOwners).sort(); -} - -async function scoreContributors(files, prAuthor, now, github, owner, repo) { - const scores = {}; - const dirScores = {}; - let scoredCount = 0; - const authorLogin = prAuthor.toLowerCase(); - const totalFiles = files.length; - - for (const filepath of files) { - const weight = classifyFile(filepath, totalFiles); - let history = gitLog(filepath); - if (history.length === 0) { - const parent = path.dirname(filepath); - if (parent && parent !== ".") { - history = gitLog(parent); - } - } - if (history.length === 0) continue; - - const topDir = path.dirname(filepath) || "."; - let fileContributed = false; - for (const { sha, name, date } of history) { - if (name.endsWith("[bot]")) continue; - const login = await resolveLogin(github, owner, repo, sha, name); - if (!login || login.toLowerCase() === authorLogin) continue; - const daysAgo = Math.max(0, (now - date) / 86400000); - const s = weight * Math.pow(0.5, daysAgo / 150); - scores[login] = (scores[login] || 0) + s; - if (!dirScores[login]) dirScores[login] = {}; - dirScores[login][topDir] = (dirScores[login][topDir] || 0) + s; - fileContributed = true; - } - if (fileContributed) scoredCount++; - } - return { scores, dirScores, scoredCount }; -} - -function topDirs(ds, n = 3) { - return Object.entries(ds || {}) - .sort((a, b) => b[1] - a[1]) - .slice(0, n) - .map(([d]) => d); -} - -function fmtReviewer(login, dirs) { - const mention = MENTION_REVIEWERS ? `@${login}` : login; - const dirList = dirs.map((d) => `\`${d}/\``).join(", "); - return `- ${mention} -- recent work in ${dirList}`; -} - -function selectReviewers(ss) { - if (ss.length === 0) return []; - const out = [ss[0]]; - if (ss.length >= 2 && ss[0][1] < 1.5 * ss[1][1]) { - out.push(ss[1]); - if (ss.length >= 3 && ss[1][1] < 1.5 * ss[2][1]) { - out.push(ss[2]); - } - } - return out; -} - -function computeConfidence(ss, scoredCount) { - if (ss.length === 0) return "low"; - if (ss.length === 1) return "high"; - if (ss.length === 2) { - if (ss[0][1] > 2 * ss[1][1]) return "high"; - if (ss[0][1] > 1.5 * ss[1][1]) return "medium"; - return "low"; - } - if (scoredCount < 3) return "low"; - if (ss[0][1] > 2 * ss[2][1]) return "high"; - if (ss[0][1] > 1.5 * ss[2][1]) return "medium"; - return "low"; -} - -function fmtEligible(owners) { - if (MENTION_REVIEWERS) return owners.map((o) => `@${o}`).join(", "); - return owners.join(", "); -} - -async function countRecentReviews(github, owner, repo, logins, days = 30) { - const since = new Date(Date.now() - days * 86400000) - .toISOString() - .slice(0, 10); - const counts = {}; - for (const login of logins) { - try { - const { data } = await github.rest.search.issuesAndPullRequests({ - q: `repo:${owner}/${repo} reviewed-by:${login} is:pr created:>${since}`, - }); - counts[login] = data.total_count; - } catch { - // skip on error - } - } - return counts; -} - -async function selectRoundRobin(github, owner, repo, eligibleOwners, prAuthor) { - const candidates = eligibleOwners - .filter((o) => !o.includes("/") && o.toLowerCase() !== prAuthor.toLowerCase()); - if (candidates.length === 0) return null; - const counts = await countRecentReviews(github, owner, repo, candidates); - if (Object.keys(counts).length === 0) { - return candidates[Math.floor(Math.random() * candidates.length)]; - } - return candidates.reduce((best, c) => - (counts[c] || 0) < (counts[best] || 0) ? c : best - ); -} - -function buildComment( - sortedScores, - dirScores, - totalFiles, - scoredCount, - eligibleOwners, - prAuthor, - roundRobinReviewer -) { - const reviewers = selectReviewers(sortedScores); - const suggestedLogins = new Set(reviewers.map(([login]) => login.toLowerCase())); - const eligible = eligibleOwners.filter( - (o) => - o.toLowerCase() !== prAuthor.toLowerCase() && - !suggestedLogins.has(o.toLowerCase()) - ); - - const lines = [MARKER]; - if (reviewers.length > 0) { - lines.push( - "## Suggested reviewers", - "", - "Based on git history of the changed files, these people are best suited to review:", - "" - ); - for (const [login] of reviewers) { - lines.push(fmtReviewer(login, topDirs(dirScores[login]))); - } - lines.push("", `Confidence: ${computeConfidence(sortedScores, scoredCount)}`); - if (eligible.length > 0) { - lines.push( - "", - "## Eligible reviewers", - "", - "Based on OWNERS, these people or teams could also review:", - "", - fmtEligible(eligible) - ); - } - } else if (eligible.length > 0) { - if (roundRobinReviewer) { - const mention = MENTION_REVIEWERS - ? `@${roundRobinReviewer}` - : roundRobinReviewer; - const remaining = eligible.filter( - (o) => o.toLowerCase() !== roundRobinReviewer.toLowerCase() - ); - lines.push( - "## Suggested reviewer", - "", - "Could not determine reviewers from git history.", - "Round-robin suggestion (based on recent review load):", - "", - `- ${mention}` - ); - if (remaining.length > 0) { - lines.push( - "", - "## Eligible reviewers", - "", - "Based on OWNERS, these people or teams could also review:", - "", - fmtEligible(remaining) - ); - } - } else { - lines.push( - "## Eligible reviewers", - "", - "Could not determine reviewers from git history. Based on OWNERS, these people or teams could review:", - "", - fmtEligible(eligible) - ); - } - } else { - lines.push( - "## Suggested reviewers", - "", - `Could not determine reviewers from git history. Please pick from ${OWNERS_LINK}.` - ); - } - - lines.push( - "", - `Suggestions based on git history of ${totalFiles} changed files ` + - `(${scoredCount} scored). ` + - `See ${OWNERS_LINK} for path-specific ownership rules.` - ); - return lines.join("\n") + "\n"; -} - -function buildPerGroupComment( - groups, - scores, - dirScores, - maintainers, - prAuthor, - totalFiles -) { - const authorLower = prAuthor.toLowerCase(); - const lines = [MARKER, "## Reviewers by ownership area", ""]; - lines.push( - "This PR crosses multiple ownership areas. Each area needs at least one", - "approval from its owners (or any maintainer can approve everything).", - "" - ); - - for (const [pattern, { owners, files }] of groups) { - if (pattern === "*") continue; - - lines.push(`### \`${pattern}\``); - lines.push(`Files: ${files.map((f) => `\`${f}\``).join(", ")}`); - - const teams = owners.filter((o) => o.includes("/")); - const individuals = owners.filter( - (o) => !o.includes("/") && o.toLowerCase() !== authorLower - ); - - if (teams.length > 0) { - lines.push(`Teams: ${teams.map((t) => `@${t}`).join(", ")}`); - } - - if (individuals.length > 0) { - const scored = individuals - .map((o) => [o, scores[o] || 0]) - .sort((a, b) => b[1] - a[1]); - - if (scored[0][1] > 0) { - const mention = MENTION_REVIEWERS ? `@${scored[0][0]}` : scored[0][0]; - lines.push(`Suggested: ${mention}`); - const rest = scored.slice(1).map(([o]) => o); - if (rest.length > 0) { - lines.push(`Also eligible: ${fmtEligible(rest)}`); - } - } else { - lines.push(`Eligible: ${fmtEligible(individuals)}`); - } - } - lines.push(""); - } - - const starGroup = groups.get("*"); - if (starGroup) { - lines.push("### General files (require maintainer)"); - lines.push(`Files: ${starGroup.files.map((f) => `\`${f}\``).join(", ")}`); - - // Only suggest maintainers for wildcard files, since only they can approve. - const maintainerSet = new Set(maintainers.map((m) => m.toLowerCase())); - const maintainerScores = Object.entries(scores) - .filter( - ([login]) => - login.toLowerCase() !== authorLower && - maintainerSet.has(login.toLowerCase()) - ) - .sort((a, b) => b[1] - a[1]); - - if (maintainerScores.length > 0 && maintainerScores[0][1] > 0) { - const [login] = maintainerScores[0]; - const dirs = topDirs(dirScores[login]); - lines.push("Based on git history:"); - lines.push(fmtReviewer(login, dirs)); - } else { - lines.push(`Pick a maintainer from ${OWNERS_LINK}.`); - } - lines.push(""); - } - - const maintainerList = maintainers - .filter((m) => m.toLowerCase() !== authorLower) - .map((m) => (MENTION_REVIEWERS ? `@${m}` : m)) - .join(", "); - - lines.push( - `Any maintainer (${maintainerList}) can approve all areas.`, - `See ${OWNERS_LINK} for ownership rules.` - ); - - return lines.join("\n") + "\n"; -} - -async function findExistingComment(github, owner, repo, prNumber) { - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: prNumber, - }); - const match = comments.find((c) => c.body && c.body.includes(MARKER)); - return match ? match.id : null; -} - -module.exports = async ({ github, context, core }) => { - const owner = context.repo.owner; - const repo = context.repo.repo; - const prNumber = context.payload.pull_request.number; - const prAuthor = context.payload.pull_request.user.login; - - const files = await getChangedFiles(github, owner, repo, prNumber); - if (files.length === 0) { - core.info("No changed files found."); - return; - } - - const now = new Date(); - const ownersPath = path.join( - process.env.GITHUB_WORKSPACE, - ".github", - "OWNERS" - ); - - const { scores, dirScores, scoredCount } = await scoreContributors( - files, - prAuthor, - now, - github, - owner, - repo - ); - const sortedScores = Object.entries(scores).sort((a, b) => b[1] - a[1]); - const eligible = parseOwnersForFiles(files, ownersPath); - - const rulesWithTeams = parseOwnersFile(ownersPath, { includeTeams: true }); - const groups = getOwnershipGroups(files, rulesWithTeams); - const maintainers = getMaintainers(rulesWithTeams); - - let comment; - if (groups.size >= 2) { - comment = buildPerGroupComment( - groups, - scores, - dirScores, - maintainers, - prAuthor, - files.length - ); - } else { - let roundRobin = null; - if (selectReviewers(sortedScores).length === 0 && eligible.length > 0) { - roundRobin = await selectRoundRobin(github, owner, repo, eligible, prAuthor); - } - - comment = buildComment( - sortedScores, - dirScores, - files.length, - scoredCount, - eligible, - prAuthor, - roundRobin - ); - } - - core.info(comment); - - const existingId = await findExistingComment(github, owner, repo, prNumber); - if (existingId) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existingId, - body: comment, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: comment, - }); - } -}; diff --git a/.github/workflows/suggest-reviewers.yml b/.github/workflows/suggest-reviewers.yml deleted file mode 100644 index 066bc6021b..0000000000 --- a/.github/workflows/suggest-reviewers.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: suggest-reviewers - -on: - pull_request: - types: [opened, ready_for_review] - -permissions: - contents: read - pull-requests: write - -jobs: - suggest-reviewers: - runs-on: - group: databricks-deco-testing-runner-group - labels: ubuntu-latest-deco - if: ${{ !github.event.pull_request.draft && !github.event.pull_request.head.repo.fork }} - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Suggest reviewers - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - retries: 3 - script: |- - const script = require('./.github/workflows/suggest-reviewers.js'); - await script({ context, github, core }); diff --git a/.github/workflows/test-owners-scripts.yml b/.github/workflows/test-owners-scripts.yml index bc09ed4347..14acbb6df0 100644 --- a/.github/workflows/test-owners-scripts.yml +++ b/.github/workflows/test-owners-scripts.yml @@ -6,7 +6,6 @@ on: - '.github/scripts/**' - '.github/workflows/maintainer-approval.js' - '.github/workflows/maintainer-approval.test.js' - - '.github/workflows/suggest-reviewers.js' - '.github/OWNERS' jobs: From 5174fefb8f3cbc0b2172d5f592d6722c13d29ad7 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 8 Apr 2026 19:49:23 +0200 Subject: [PATCH 2/2] Fix security, draft-PR, and comment-cleanup issues in maintainer-approval - Checkout base branch SHA to prevent PR-authored code execution on review events - Add ready_for_review to pull_request_target types so draft promotions trigger - Delete ALL matching comments (not just first) including legacy REVIEWER_SUGGESTION marker - Add concurrency group to prevent overlapping runs per PR Co-authored-by: Isaac --- .github/workflows/maintainer-approval.js | 10 +++++++--- .github/workflows/maintainer-approval.yml | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/maintainer-approval.js b/.github/workflows/maintainer-approval.js index f0efdb037b..13f4c6bfb6 100644 --- a/.github/workflows/maintainer-approval.js +++ b/.github/workflows/maintainer-approval.js @@ -390,14 +390,18 @@ function buildSingleDomainPendingComment(sortedScores, dirScores, scoredCount, e // --- Comment management --- +const LEGACY_MARKER = ""; + async function postComment(github, owner, repo, prNumber, comment) { const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, }); - const existing = comments.find(c => c.body && c.body.includes(MARKER)); - if (existing) { + const toDelete = 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: existing.id, + owner, repo, comment_id: c.id, }); } await github.rest.issues.createComment({ diff --git a/.github/workflows/maintainer-approval.yml b/.github/workflows/maintainer-approval.yml index ffc113d61d..b33fad48c5 100644 --- a/.github/workflows/maintainer-approval.yml +++ b/.github/workflows/maintainer-approval.yml @@ -2,9 +2,14 @@ name: PR approval on: pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted, dismissed] +concurrency: + group: pr-approval-${{ github.event.pull_request.number }} + cancel-in-progress: true + defaults: run: shell: bash @@ -23,6 +28,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false fetch-depth: 0 - name: Check approval and suggest reviewers