341 lines
16 KiB
JavaScript
341 lines
16 KiB
JavaScript
// Reviewer SLA sweep: nudge + escalate open PRs and issues that a MAINTAINER has
|
|
// been sitting on for more than SLA_DAYS *working* days without replying.
|
|
//
|
|
// Runs on a schedule from the trusted default branch (see review-sla.yml), so it
|
|
// reads no PR-authored code and just talks to the issues/PRs API. For each open,
|
|
// non-draft item:
|
|
// - PRs: the "assigned person" is any maintainer in requested_reviewers (GitHub
|
|
// drops them from that list the moment they submit a review, so being in it
|
|
// means "still owes a review"). The clock starts at their latest
|
|
// `review_requested` event (fallback: PR opened). If >= SLA_DAYS working days
|
|
// have elapsed AND they've posted no comment or review since, the SLA is
|
|
// breached: re-ping them in one comment and add ONE second reviewer (lowest
|
|
// open-review load among the area owners in .github/areas.json, mirrored as
|
|
// an assignee like auto-assign-reviewer.js does).
|
|
// - Issues: the "assigned person" is any maintainer assignee; clock starts at
|
|
// their latest `assigned` event. Breach -> re-ping + add one second assignee
|
|
// from the owners of the area(s) whose comp:* label the issue carries.
|
|
//
|
|
// Ownership comes from .github/areas.json -- the single source of truth shared
|
|
// with auto-assign-reviewer.js and issue-triage.yml (it replaced the old
|
|
// .github/reviewers + .github/ISSUE_ASSIGNEES files). `owners_paused` is ignored.
|
|
//
|
|
// "Working days" = weekdays (Mon-Fri) in UTC. Reply = ANY comment or review by the
|
|
// assignee since the clock started.
|
|
//
|
|
// Escalate-once, two independent guards so the bot never spams:
|
|
// 1. a one-shot LABEL, and
|
|
// 2. the MARKER hidden in the reminder comment -- checked as a fallback so that
|
|
// even if the label write fails after the comment lands, the next sweep still
|
|
// sees the marker and skips.
|
|
// The second reviewer/assignee is added FIRST (best-effort); the comment is then
|
|
// worded to match what actually happened (so it can't claim "Adding @X" when the
|
|
// add 422'd), and the label is written last. If the comment itself fails nothing
|
|
// user-visible was posted, so we skip the label and let the next sweep retry.
|
|
//
|
|
// ponytail: one escalation per item. Per-reviewer re-escalation or a weekly
|
|
// re-ping would need per-nudge timestamp state instead of the label+marker pair --
|
|
// add that only if a single nudge proves too weak.
|
|
|
|
const fs = require("fs");
|
|
|
|
const SLA_DAYS = 5; // working days
|
|
const LABEL = "review-sla-escalated";
|
|
const MARKER = "<!-- review-sla-bot -->"; // idempotency fallback if the label write fails
|
|
const CANONICAL_REPO = "omnigent-ai/omnigent";
|
|
// Max escalations per sweep. Bounds the day-one blast against an existing stale
|
|
// backlog (and any future surge): the backlog drains a chunk per weekday instead
|
|
// of nudging everything at once. PRs are processed before issues.
|
|
// ponytail: single global cap; split into per-kind caps if issue nudges starving
|
|
// behind a large PR backlog ever matters.
|
|
const MAX_ESCALATIONS_PER_RUN = 30;
|
|
|
|
// --- Pure helpers (exported for the offline test; no network) --------------
|
|
|
|
// Weekdays strictly after `from`'s date, through `to`'s date, in UTC. So a review
|
|
// requested on a Monday first counts as 5 working days the following Monday.
|
|
// ponytail: weekends only, no holiday calendar -- add one if the SLA needs it.
|
|
function workingDaysBetween(from, to) {
|
|
const cur = new Date(from);
|
|
cur.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(to);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
let count = 0;
|
|
while (cur < end) {
|
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
const d = cur.getUTCDay();
|
|
if (d !== 0 && d !== 6) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// Latest ISO timestamp per (lowercased) login for a given timeline event type.
|
|
function latestByUser(timeline, eventName, getLogin) {
|
|
const out = {};
|
|
for (const e of timeline || []) {
|
|
if (e.event !== eventName) continue;
|
|
const login = getLogin(e);
|
|
if (!login || !e.created_at) continue;
|
|
const lc = login.toLowerCase();
|
|
if (!out[lc] || new Date(e.created_at) > new Date(out[lc])) out[lc] = e.created_at;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Did `login` post any comment/review after `sinceIso`?
|
|
function repliedSince(login, sinceIso, comments, reviews, reviewComments) {
|
|
const since = new Date(sinceIso).getTime();
|
|
const lc = login.toLowerCase();
|
|
const by = (u) => (u || "").toLowerCase() === lc;
|
|
const after = (t) => t && new Date(t).getTime() > since;
|
|
return (
|
|
(comments || []).some((c) => by(c.user && c.user.login) && after(c.created_at)) ||
|
|
(reviews || []).some((r) => by(r.user && r.user.login) && after(r.submitted_at)) ||
|
|
(reviewComments || []).some((rc) => by(rc.user && rc.user.login) && after(rc.created_at))
|
|
);
|
|
}
|
|
|
|
// Have we already posted a reminder here? (idempotency fallback for a failed label)
|
|
function alreadyNudged(comments) {
|
|
return (comments || []).some((c) => (c.body || "").includes(MARKER));
|
|
}
|
|
|
|
// Breached maintainer targets for one item, given the reply signals. Shared by the
|
|
// PR and issue paths (issues pass [] for reviews/reviewComments).
|
|
function breachedTargets({ targets, clockStartByUser, openedAt, now, comments, reviews, reviewComments }) {
|
|
const out = [];
|
|
for (const t of targets) {
|
|
// Fallback to openedAt when there's no explicit request/assign event for
|
|
// this login (e.g. a CODEOWNERS/team expansion, or a timeline pagination
|
|
// edge). That can over-count elapsed time slightly -- acceptable, and never
|
|
// fires for the normal auto-assigned path which always emits the event.
|
|
const since = clockStartByUser[t.toLowerCase()] || openedAt;
|
|
if (workingDaysBetween(since, now) < SLA_DAYS) continue;
|
|
if (repliedSince(t, since, comments, reviews, reviewComments)) continue;
|
|
out.push(t);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Parse .github/areas.json (same shape auto-assign-reviewer.js reads) into:
|
|
// rules - [{ prefix, owners }] in document order (last match wins per file)
|
|
// pool - Map lc->original of every owner (the full candidate set)
|
|
// labelOwners - Map "comp:x" -> Set of owners, for routing an issue by its label
|
|
// `owners_paused` is intentionally ignored. `text` is injectable for tests.
|
|
function parseAreas(text) {
|
|
const areas = JSON.parse(text).areas || [];
|
|
const rules = [];
|
|
const pool = new Map();
|
|
const labelOwners = new Map();
|
|
for (const area of areas) {
|
|
const owners = area.owners || [];
|
|
owners.forEach((o) => pool.set(o.toLowerCase(), o));
|
|
for (const p of area.paths || []) rules.push({ prefix: p.replace(/^\//, ""), owners });
|
|
if (area.label) {
|
|
const set = labelOwners.get(area.label) || new Set();
|
|
owners.forEach((o) => set.add(o));
|
|
labelOwners.set(area.label, set);
|
|
}
|
|
}
|
|
return { rules, pool, labelOwners };
|
|
}
|
|
|
|
// Count currently-open review requests per (lc) login -- the stateless fairness
|
|
// signal auto-assign-reviewer.js also uses.
|
|
function buildLoad(openPRs) {
|
|
const load = new Map();
|
|
for (const p of openPRs)
|
|
for (const r of p.requested_reviewers || []) {
|
|
const l = (r.login || "").toLowerCase();
|
|
load.set(l, (load.get(l) || 0) + 1);
|
|
}
|
|
return load;
|
|
}
|
|
|
|
// Pick the lowest-load of a candidate list, random tie-break within a load tier.
|
|
function lowestLoad(candidates, load) {
|
|
if (!candidates.length) return null;
|
|
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
|
|
const byTier = {};
|
|
for (const u of candidates) (byTier[loadOf(u)] ||= []).push(u);
|
|
const lowest = byTier[Math.min(...Object.keys(byTier).map(Number))];
|
|
return lowest[Math.floor(Math.random() * lowest.length)];
|
|
}
|
|
|
|
// One lowest-load area owner for the PR's files, else lowest from the full pool;
|
|
// never anyone already on the PR.
|
|
function pickSecondReviewer({ files, rules, pool, load, exclude }) {
|
|
const areaOwners = new Map();
|
|
for (const f of files) {
|
|
let match = null;
|
|
for (const r of rules) if (f.startsWith(r.prefix)) match = r; // last wins
|
|
if (match) match.owners.forEach((o) => areaOwners.set(o.toLowerCase(), o));
|
|
}
|
|
const base = areaOwners.size ? areaOwners : pool;
|
|
return lowestLoad([...base.values()].filter((u) => !exclude.has(u.toLowerCase())), load);
|
|
}
|
|
|
|
// One second assignee from the owners of the issue's comp:* area(s), else the full
|
|
// pool; never anyone already assigned.
|
|
// ponytail: tie-break reuses the PR open-review `load` -- a proxy for issues (there
|
|
// is no per-assignee open-issue count), so this only approximates issue fairness.
|
|
// Tally open-issue assignee counts here if that starts to matter.
|
|
function pickSecondAssignee({ labels, labelOwners, pool, load, exclude }) {
|
|
const owners = new Set();
|
|
for (const l of labels) for (const o of labelOwners.get(l) || []) owners.add(o);
|
|
const base = owners.size ? owners : new Set(pool.values());
|
|
return lowestLoad([...base].filter((u) => !exclude.has(u.toLowerCase())), load);
|
|
}
|
|
|
|
// --- Orchestrator ----------------------------------------------------------
|
|
|
|
async function run({ github, context, core }) {
|
|
const { owner, repo } = context.repo;
|
|
if (`${owner}/${repo}` !== CANONICAL_REPO) {
|
|
core.info(`Not ${CANONICAL_REPO}; skipping.`);
|
|
return;
|
|
}
|
|
const now = new Date();
|
|
|
|
const maintainers = new Set(
|
|
fs.readFileSync(".github/MAINTAINER", "utf8")
|
|
.split("\n").map((l) => l.replace(/#.*/, "").trim().toLowerCase()).filter(Boolean)
|
|
);
|
|
// REVIEWER_AREAS_FILE lets the unit test pin a fixture; defaults to the real file.
|
|
const areasFile = process.env.REVIEWER_AREAS_FILE || ".github/areas.json";
|
|
const { rules, pool, labelOwners } = parseAreas(fs.readFileSync(areasFile, "utf8"));
|
|
|
|
const hasLabel = (item) => (item.labels || []).some((l) => (l.name || l) === LABEL);
|
|
const escalated = [];
|
|
const capReached = () => escalated.length >= MAX_ESCALATIONS_PER_RUN;
|
|
|
|
// Escalate one item once. Add the second reviewer/assignee FIRST (best-effort,
|
|
// returns the login it actually added or null), so the comment states the true
|
|
// outcome; then post the marked comment; then lock the LABEL. If the comment
|
|
// fails, nothing was posted -> skip the label and retry next sweep.
|
|
const escalateOnce = async (number, breached, kind, addSecond, secondCandidate) => {
|
|
let added = null;
|
|
if (secondCandidate) {
|
|
try {
|
|
added = (await addSecond()) ? secondCandidate : null;
|
|
} catch (e) {
|
|
core.warning(`#${number}: could not add second ${kind} @${secondCandidate}: ${e.message}`);
|
|
}
|
|
}
|
|
const noun = kind === "reviewer" ? "review" : "a response";
|
|
const body =
|
|
`${MARKER}\n⏰ **${kind === "reviewer" ? "Reviewer" : "Response"} SLA** — this ${kind === "reviewer" ? "PR" : "issue"} ` +
|
|
`has been awaiting ${noun} from ${breached.map((u) => "@" + u).join(", ")} for more than ${SLA_DAYS} working days.` +
|
|
(added ? ` Adding @${added} as a second ${kind}.` : "");
|
|
try {
|
|
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
|
|
} catch (e) {
|
|
core.warning(`#${number}: reminder comment failed, will retry next run: ${e.message}`);
|
|
return;
|
|
}
|
|
try {
|
|
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
|
|
} catch (e) {
|
|
core.warning(`#${number}: could not add ${LABEL} label (marker still guards re-nudge): ${e.message}`);
|
|
}
|
|
escalated.push(`${kind === "reviewer" ? "PR" : "issue"} #${number} (re-pinged ${breached.join(", ")}${added ? `, +@${added}` : ""})`);
|
|
};
|
|
|
|
// ----- PRs: awaiting a maintainer's review -----
|
|
const openPRs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
|
|
const load = buildLoad(openPRs);
|
|
// Count each second reviewer/assignee we add during THIS sweep against the load
|
|
// map, so successive picks rotate instead of dogpiling the current lowest-load
|
|
// maintainer -- without it, one sweep hands nearly every escalation to one person.
|
|
const bumpLoad = (u) => load.set(u.toLowerCase(), (load.get(u.toLowerCase()) || 0) + 1);
|
|
|
|
for (const pr of openPRs) {
|
|
if (capReached()) break;
|
|
if (pr.draft || hasLabel(pr)) continue;
|
|
const targets = (pr.requested_reviewers || []).map((r) => r.login).filter((l) => maintainers.has(l.toLowerCase()));
|
|
if (!targets.length) continue;
|
|
|
|
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: pr.number, per_page: 100 });
|
|
const requestedAt = latestByUser(timeline, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login);
|
|
|
|
// Cheap staleness prefilter before fetching reply signals.
|
|
const stale = targets.filter((t) => workingDaysBetween(requestedAt[t.toLowerCase()] || pr.created_at, now) >= SLA_DAYS);
|
|
if (!stale.length) continue;
|
|
|
|
const [comments, reviews, reviewComments] = await Promise.all([
|
|
github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }),
|
|
github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 }),
|
|
github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 }),
|
|
]);
|
|
if (alreadyNudged(comments)) continue; // label may have failed to write; marker still guards
|
|
const breached = breachedTargets({
|
|
targets: stale, clockStartByUser: requestedAt, openedAt: pr.created_at, now, comments, reviews, reviewComments,
|
|
});
|
|
if (!breached.length) continue;
|
|
|
|
const files = (await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 })).map((f) => f.filename);
|
|
const onPr = new Set(
|
|
[pr.user && pr.user.login, ...targets, ...(pr.assignees || []).map((a) => a.login), ...(pr.requested_reviewers || []).map((r) => r.login)]
|
|
.filter(Boolean).map((s) => s.toLowerCase())
|
|
);
|
|
const second = pickSecondReviewer({ files, rules, pool, load, exclude: onPr });
|
|
|
|
await escalateOnce(pr.number, breached, "reviewer", async () => {
|
|
await github.rest.pulls.requestReviewers({ owner, repo, pull_number: pr.number, reviewers: [second] });
|
|
// Mirror as assignee for UI filterability, matching auto-assign-reviewer.js.
|
|
await github.rest.issues.addAssignees({ owner, repo, issue_number: pr.number, assignees: [second] });
|
|
bumpLoad(second);
|
|
return true;
|
|
}, second);
|
|
}
|
|
|
|
// ----- Issues: awaiting a maintainer assignee -----
|
|
const openIssues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 100 });
|
|
for (const issue of openIssues) {
|
|
if (capReached()) break;
|
|
if (issue.pull_request || hasLabel(issue)) continue; // listForRepo also returns PRs
|
|
const targets = (issue.assignees || []).map((a) => a.login).filter((l) => maintainers.has(l.toLowerCase()));
|
|
if (!targets.length) continue;
|
|
|
|
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: issue.number, per_page: 100 });
|
|
const assignedAt = latestByUser(timeline, "assigned", (e) => e.assignee && e.assignee.login);
|
|
|
|
const stale = targets.filter((t) => workingDaysBetween(assignedAt[t.toLowerCase()] || issue.created_at, now) >= SLA_DAYS);
|
|
if (!stale.length) continue;
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 });
|
|
if (alreadyNudged(comments)) continue;
|
|
const breached = breachedTargets({
|
|
targets: stale, clockStartByUser: assignedAt, openedAt: issue.created_at, now, comments, reviews: [], reviewComments: [],
|
|
});
|
|
if (!breached.length) continue;
|
|
|
|
const labels = (issue.labels || []).map((l) => l.name || l).filter((n) => n.startsWith("comp:"));
|
|
const onIssue = new Set((issue.assignees || []).map((a) => a.login.toLowerCase()));
|
|
const second = pickSecondAssignee({ labels, labelOwners, pool, load, exclude: onIssue });
|
|
|
|
await escalateOnce(issue.number, breached, "assignee", async () => {
|
|
await github.rest.issues.addAssignees({ owner, repo, issue_number: issue.number, assignees: [second] });
|
|
bumpLoad(second);
|
|
return true;
|
|
}, second);
|
|
}
|
|
|
|
core.info(escalated.length ? `Escalated ${escalated.length}: ${escalated.join("; ")}.` : "No SLA breaches; nothing to escalate.");
|
|
}
|
|
|
|
module.exports = run;
|
|
// Exported for the offline unit test.
|
|
module.exports.workingDaysBetween = workingDaysBetween;
|
|
module.exports.latestByUser = latestByUser;
|
|
module.exports.repliedSince = repliedSince;
|
|
module.exports.alreadyNudged = alreadyNudged;
|
|
module.exports.breachedTargets = breachedTargets;
|
|
module.exports.parseAreas = parseAreas;
|
|
module.exports.pickSecondReviewer = pickSecondReviewer;
|
|
module.exports.pickSecondAssignee = pickSecondAssignee;
|
|
module.exports.SLA_DAYS = SLA_DAYS;
|
|
module.exports.LABEL = LABEL;
|
|
module.exports.MARKER = MARKER;
|
|
module.exports.MAX_ESCALATIONS_PER_RUN = MAX_ESCALATIONS_PER_RUN;
|