Opt-in via new 'dependabot-auto-approve' input (mode 'dev-patch-minor'). When enabled, Dependabot PRs are evaluated on pull_request_target opened/reopened and on check_suite completed for dependabot/ branches, and approved + labeled + auto-merged only when ALL gates hold: 1. Author is the real dependabot[bot] app (login AND user id 49699333) 2. Same-repo head on a dependabot/ branch (never forks) 3. Every commit signature-verified and dependabot-authored 4. Changed files a subset of 'dependabot-allowed-files' (exact paths) 5. Every updated dependency direct:development with semver-patch/minor update per Dependabot's commit metadata (missing metadata = no approve) 6. CI green on head SHA: all check runs success/neutral/skipped with at least one non-skipped check, combined status success if any exist 7. PR open, non-draft, conflict-free, not on hold Every gate fails closed, logging the reason to the action log only. Approvals use a distinct marker (slash-commands-dependabot-auto) that push invalidation now dismisses alongside /lgtm approvals; after a human push, gate 3 fails permanently so only /lgtm can accept the PR. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ygNtiNcAaqTRnQjHCDkVS
725 lines
30 KiB
JavaScript
725 lines
30 KiB
JavaScript
// Handler for /lgtm, /hold, and /stageblog slash commands. Called by actions/github-script
|
|
// via `require(github.action_path + '/main.js')({ github, context, core })`.
|
|
// See action.yml for input wiring via env vars.
|
|
|
|
module.exports = async ({ github, context, core }) => {
|
|
const { owner, repo } = context.repo;
|
|
const approvedLabel = process.env.APPROVED_LABEL;
|
|
const holdLabel = process.env.HOLD_LABEL;
|
|
const alwaysAllowTeams = process.env.ALWAYS_ALLOW_TEAMS
|
|
.split(',').map(s => s.trim()).filter(Boolean);
|
|
const forceAllowTeams = process.env.FORCE_ALLOW_TEAMS
|
|
.split(',').map(s => s.trim()).filter(Boolean);
|
|
const codeownersPath = process.env.CODEOWNERS_PATH;
|
|
const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true';
|
|
const submitReview = process.env.SUBMIT_REVIEW === 'true';
|
|
const autoMerge = process.env.ENABLE_AUTO_MERGE === 'true';
|
|
const autoMergeMethod = process.env.AUTO_MERGE_METHOD.toUpperCase();
|
|
const removeLabelsOnAccept = process.env.REMOVE_LABELS_ON_ACCEPT
|
|
.split(',').map(s => s.trim()).filter(Boolean);
|
|
const projectNumber = parseInt(process.env.PROJECT_NUMBER, 10) || 0;
|
|
const projectStatusField = process.env.PROJECT_STATUS_FIELD;
|
|
const projectAcceptedOption = process.env.PROJECT_ACCEPTED_OPTION;
|
|
const projectGateLabel = process.env.PROJECT_GATE_LABEL;
|
|
const dependabotAutoApprove = process.env.DEPENDABOT_AUTO_APPROVE.trim();
|
|
const dependabotAllowedFiles = process.env.DEPENDABOT_ALLOWED_FILES
|
|
.split(',').map(s => s.trim()).filter(Boolean);
|
|
const reviewMarker = '<!-- slash-commands-lgtm -->';
|
|
// Marker for reviews submitted by the dependabot auto-approve mode. Kept
|
|
// distinct from the /lgtm marker so logs/audits can tell them apart; push
|
|
// invalidation dismisses reviews carrying either marker.
|
|
const dependabotMarker = '<!-- slash-commands-dependabot-auto -->';
|
|
const botReviewMarkers = [reviewMarker, dependabotMarker];
|
|
|
|
function setResult(result, actor) {
|
|
core.setOutput('result', result);
|
|
core.setOutput('actor', actor || '');
|
|
core.info(`Result: ${result}${actor ? ` (actor: ${actor})` : ''}`);
|
|
}
|
|
|
|
// --- CODEOWNERS pattern → regex ---------------------------------
|
|
// Supports: leading-/ anchoring, trailing-/ directory match,
|
|
// `**` (any depth), `*` (single segment), `?`. No `!` negation.
|
|
function patternToRegex(pattern) {
|
|
let p = pattern;
|
|
const anchored = p.startsWith('/');
|
|
if (anchored) p = p.slice(1);
|
|
const dirMatch = p.endsWith('/');
|
|
if (dirMatch) p = p.slice(0, -1);
|
|
// Escape regex meta chars except our glob tokens * and ?
|
|
let re = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
// Handle globs. Use placeholders so single-* replacement doesn't
|
|
// catch ** sequences. `**/` = zero or more directories.
|
|
re = re.replace(/\*\*\//g, '\u0001');
|
|
re = re.replace(/\*\*/g, '\u0000');
|
|
re = re.replace(/\*/g, '[^/]*');
|
|
re = re.replace(/\?/g, '[^/]');
|
|
re = re.replace(/\u0001/g, '(?:.*/)?');
|
|
re = re.replace(/\u0000/g, '.*');
|
|
// Anchoring: leading `/` → match from repo root; otherwise
|
|
// match at any path depth (start or after a `/`).
|
|
const prefix = anchored ? '^' : '(^|/)';
|
|
// Trailing `/` → directory prefix (match anything under it).
|
|
// Otherwise must match to end of string.
|
|
const suffix = dirMatch ? '/.*$' : '$';
|
|
return new RegExp(prefix + re + suffix);
|
|
}
|
|
|
|
function parseCodeowners(content) {
|
|
const rules = [];
|
|
for (const raw of content.split('\n')) {
|
|
const line = raw.replace(/#.*$/, '').trim();
|
|
if (!line) continue;
|
|
const parts = line.split(/\s+/);
|
|
const pattern = parts[0];
|
|
const owners = parts.slice(1);
|
|
if (!owners.length) continue;
|
|
rules.push({ regex: patternToRegex(pattern), owners });
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
function ownersForFile(rules, file) {
|
|
// Last match wins.
|
|
let owners = null;
|
|
for (const r of rules) {
|
|
if (r.regex.test(file)) owners = r.owners;
|
|
}
|
|
return owners || [];
|
|
}
|
|
|
|
// --- Auth helpers -----------------------------------------------
|
|
const teamMembershipCache = new Map();
|
|
async function isTeamMember(user, teamSlug) {
|
|
const key = `${teamSlug}:${user}`;
|
|
if (teamMembershipCache.has(key)) return teamMembershipCache.get(key);
|
|
let ok;
|
|
try {
|
|
const { data } = await github.rest.teams.getMembershipForUserInOrg({
|
|
org: owner, team_slug: teamSlug, username: user,
|
|
});
|
|
ok = data.state === 'active';
|
|
} catch (e) {
|
|
if (e.status === 404) {
|
|
ok = false;
|
|
} else if (e.status === 403) {
|
|
core.warning(`Team membership check for @${owner}/${teamSlug} returned 403 — the provided github-token likely lacks Organization Members:read. Treating as not-a-member.`);
|
|
ok = false;
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
teamMembershipCache.set(key, ok);
|
|
return ok;
|
|
}
|
|
|
|
async function isInAlwaysAllowTeam(user) {
|
|
for (const team of alwaysAllowTeams) {
|
|
if (await isTeamMember(user, team)) {
|
|
core.info(`@${user} authorized via always-allow team @${owner}/${team}`);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function lastLabeledBy(prNumber, labelName) {
|
|
const events = await github.paginate(github.rest.issues.listEvents, {
|
|
owner, repo, issue_number: prNumber, per_page: 100,
|
|
});
|
|
let actor = null;
|
|
for (const e of events) {
|
|
if (e.event === 'labeled' && e.label?.name === labelName) actor = e.actor;
|
|
}
|
|
return actor;
|
|
}
|
|
|
|
async function isAuthorized(user, pr) {
|
|
// 1. always-allow teams
|
|
if (await isInAlwaysAllowTeam(user)) return true;
|
|
// 2. CODEOWNERS of the PR's changed files (from BASE ref)
|
|
let content;
|
|
try {
|
|
const { data } = await github.rest.repos.getContent({
|
|
owner, repo, path: codeownersPath, ref: pr.base.ref,
|
|
});
|
|
content = Buffer.from(data.content, data.encoding).toString('utf8');
|
|
} catch (e) {
|
|
if (e.status === 404) {
|
|
core.info(`No CODEOWNERS found at ${codeownersPath} on ${pr.base.ref}`);
|
|
return false;
|
|
}
|
|
throw e;
|
|
}
|
|
const rules = parseCodeowners(content);
|
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
owner, repo, pull_number: pr.number, per_page: 100,
|
|
});
|
|
const ownerSet = new Set();
|
|
for (const f of files) {
|
|
for (const o of ownersForFile(rules, f.filename)) ownerSet.add(o);
|
|
}
|
|
core.info(`CODEOWNERS for this PR's files: ${[...ownerSet].join(', ') || '(none)'}`);
|
|
for (const o of ownerSet) {
|
|
if (o.includes('/')) {
|
|
// @org/team — we require the org to match our repo's org
|
|
const [oOrg, oTeam] = o.replace(/^@/, '').split('/');
|
|
if (oOrg.toLowerCase() !== owner.toLowerCase()) continue;
|
|
if (await isTeamMember(user, oTeam)) {
|
|
core.info(`@${user} authorized via CODEOWNER team ${o}`);
|
|
return true;
|
|
}
|
|
} else {
|
|
// @user
|
|
if (o.replace(/^@/, '').toLowerCase() === user.toLowerCase()) {
|
|
core.info(`@${user} authorized via CODEOWNER user entry`);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Label & review helpers -------------------------------------
|
|
async function addLabel(prNumber, label) {
|
|
await github.rest.issues.addLabels({
|
|
owner, repo, issue_number: prNumber, labels: [label],
|
|
});
|
|
}
|
|
async function removeLabel(prNumber, label) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner, repo, issue_number: prNumber, name: label,
|
|
});
|
|
} catch (e) {
|
|
if (e.status !== 404) throw e;
|
|
}
|
|
}
|
|
async function react(commentId, content) {
|
|
await github.rest.reactions.createForIssueComment({
|
|
owner, repo, comment_id: commentId, content,
|
|
});
|
|
}
|
|
async function submitApproval(prNumber, onBehalfOf, forced) {
|
|
const via = forced ? '/lgtm force' : '/lgtm';
|
|
await github.rest.pulls.createReview({
|
|
owner, repo, pull_number: prNumber, event: 'APPROVE',
|
|
body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`${via}\`.`,
|
|
});
|
|
}
|
|
async function dismissBotApprovals(prNumber, reason) {
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
|
owner, repo, pull_number: prNumber, per_page: 100,
|
|
});
|
|
for (const r of reviews) {
|
|
if (r.state !== 'APPROVED') continue;
|
|
if (!r.body || !botReviewMarkers.some(mk => r.body.includes(mk))) continue;
|
|
try {
|
|
await github.rest.pulls.dismissReview({
|
|
owner, repo, pull_number: prNumber, review_id: r.id,
|
|
message: reason,
|
|
});
|
|
} catch (e) {
|
|
core.warning(`Failed to dismiss review ${r.id}: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
async function enableAutoMerge(prNodeId, prNumber) {
|
|
if (!autoMerge) return;
|
|
async function mergeDirect(reason) {
|
|
core.info(`${reason} — merging directly`);
|
|
try {
|
|
await github.rest.pulls.merge({
|
|
owner, repo, pull_number: prNumber,
|
|
merge_method: autoMergeMethod.toLowerCase(),
|
|
});
|
|
core.info('Merged');
|
|
} catch (e) {
|
|
core.warning(`Direct merge failed: ${e.message}. If branch protection requires code-owner review, add this GitHub App to the ruleset bypass list.`);
|
|
}
|
|
}
|
|
try {
|
|
const result = await github.graphql(
|
|
`mutation($id: ID!, $m: PullRequestMergeMethod!) {
|
|
enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: $m}) {
|
|
pullRequest { autoMergeRequest { enabledAt } reviewDecision }
|
|
}
|
|
}`,
|
|
{ id: prNodeId, m: autoMergeMethod },
|
|
);
|
|
core.info(`Auto-merge enabled (${autoMergeMethod})`);
|
|
// Auto-merge does not inherit the ruleset-bypass privileges of the
|
|
// actor who enabled it — it only fires once the PR is clean for
|
|
// everyone. If we submitted an approval and reviewDecision is still
|
|
// REVIEW_REQUIRED, the bot's approval did not satisfy
|
|
// require_code_owner_review (GitHub Apps cannot be team members), so
|
|
// auto-merge will never fire. Merge directly instead — a direct merge
|
|
// DOES use the bot's bypass.
|
|
const decision = result.enablePullRequestAutoMerge.pullRequest.reviewDecision;
|
|
if (submitReview && decision === 'REVIEW_REQUIRED') {
|
|
return mergeDirect('Bot approval did not satisfy review requirement');
|
|
}
|
|
} catch (e) {
|
|
const msg = e.message || String(e);
|
|
// enablePullRequestAutoMerge rejects PRs that are already mergeable
|
|
// ("clean status") because there is nothing to wait for.
|
|
if (/clean status/i.test(msg)) {
|
|
return mergeDirect('PR already mergeable');
|
|
}
|
|
core.warning(`Could not enable auto-merge: ${msg}`);
|
|
}
|
|
}
|
|
async function disableAutoMerge(prNodeId) {
|
|
if (!autoMerge) return;
|
|
try {
|
|
await github.graphql(
|
|
`mutation($id: ID!) {
|
|
disablePullRequestAutoMerge(input: {pullRequestId: $id}) {
|
|
pullRequest { id }
|
|
}
|
|
}`,
|
|
{ id: prNodeId },
|
|
);
|
|
core.info('Auto-merge disabled');
|
|
} catch (e) {
|
|
core.info(`Auto-merge disable skipped: ${e.message}`);
|
|
}
|
|
}
|
|
async function onAccept(pr) {
|
|
for (const label of removeLabelsOnAccept) {
|
|
await removeLabel(pr.number, label);
|
|
}
|
|
if (!projectNumber) return;
|
|
if (projectGateLabel && !pr.labels.some(l => l.name === projectGateLabel)) {
|
|
core.info(`Skipping project update — PR lacks '${projectGateLabel}' label`);
|
|
return;
|
|
}
|
|
try {
|
|
const lookup = await github.graphql(
|
|
`query($org: String!, $num: Int!, $field: String!) {
|
|
organization(login: $org) {
|
|
projectV2(number: $num) {
|
|
id
|
|
field(name: $field) {
|
|
... on ProjectV2SingleSelectField { id options { id name } }
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
{ org: owner, num: projectNumber, field: projectStatusField },
|
|
);
|
|
const project = lookup.organization?.projectV2;
|
|
if (!project) {
|
|
return core.warning(`Project #${projectNumber} not found in org ${owner}`);
|
|
}
|
|
const field = project.field;
|
|
if (!field?.id) {
|
|
return core.warning(`Field '${projectStatusField}' not found (or not single-select) in project #${projectNumber}`);
|
|
}
|
|
const option = field.options.find(o => o.name === projectAcceptedOption);
|
|
if (!option) {
|
|
return core.warning(`Option '${projectAcceptedOption}' not found in field '${projectStatusField}'`);
|
|
}
|
|
// addProjectV2ItemById is idempotent — returns the existing item if
|
|
// the PR is already in the project.
|
|
const add = await github.graphql(
|
|
`mutation($proj: ID!, $pr: ID!) {
|
|
addProjectV2ItemById(input: {projectId: $proj, contentId: $pr}) {
|
|
item { id }
|
|
}
|
|
}`,
|
|
{ proj: project.id, pr: pr.node_id },
|
|
);
|
|
const itemId = add.addProjectV2ItemById.item.id;
|
|
await github.graphql(
|
|
`mutation($proj: ID!, $item: ID!, $field: ID!, $opt: String!) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $proj, itemId: $item, fieldId: $field,
|
|
value: { singleSelectOptionId: $opt }
|
|
}) { projectV2Item { id } }
|
|
}`,
|
|
{ proj: project.id, item: itemId, field: field.id, opt: option.id },
|
|
);
|
|
core.info(`Moved PR to '${projectAcceptedOption}' in project #${projectNumber}`);
|
|
} catch (e) {
|
|
core.warning(`Project update failed: ${e.message}. Ensure the GitHub App has Organization Projects:write permission.`);
|
|
}
|
|
}
|
|
|
|
// --- Dependabot auto-approve -------------------------------------
|
|
// Opt-in via `dependabot-auto-approve`. Approves, labels, and enables
|
|
// auto-merge on Dependabot PRs — but ONLY when every gate below holds.
|
|
// Any gate failure exits without approving (fail closed), logging why.
|
|
|
|
// The real Dependabot app's login and stable numeric user id. Checking the
|
|
// id means a lookalike account (e.g. a renamed user squatting a similar
|
|
// login) can never match.
|
|
const DEPENDABOT_LOGIN = 'dependabot[bot]';
|
|
const DEPENDABOT_USER_ID = 49699333;
|
|
|
|
// Parse Dependabot's structured metadata block from a commit message:
|
|
// updated-dependencies:
|
|
// - dependency-name: foo
|
|
// dependency-type: direct:development
|
|
// update-type: version-update:semver-patch
|
|
// Returns one entry per updated dependency; fields are undefined when the
|
|
// corresponding line is absent. Empty array = no metadata block found.
|
|
function parseDependabotMetadata(message) {
|
|
const entries = [];
|
|
let inBlock = false;
|
|
let current = null;
|
|
for (const raw of message.split('\n')) {
|
|
const line = raw.replace(/\r$/, '');
|
|
if (!inBlock) {
|
|
if (/^updated-dependencies:\s*$/.test(line)) inBlock = true;
|
|
continue;
|
|
}
|
|
const name = line.match(/^-\s+dependency-name:\s*["']?(.+?)["']?\s*$/);
|
|
if (name) {
|
|
current = { name: name[1] };
|
|
entries.push(current);
|
|
continue;
|
|
}
|
|
// Indented `key: value` lines belong to the current entry. Record the
|
|
// two keys we gate on; tolerate others (e.g. dependency-group).
|
|
const field = current && line.match(/^\s+([a-z-]+):\s*["']?(.+?)["']?\s*$/);
|
|
if (field) {
|
|
if (field[1] === 'dependency-type') current.dependencyType = field[2];
|
|
if (field[1] === 'update-type') current.updateType = field[2];
|
|
continue;
|
|
}
|
|
// Anything else (the `...` terminator, blank line, trailer) ends the block.
|
|
inBlock = false;
|
|
current = null;
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// Evaluate one PR against the auto-approve policy. Returns true only if it
|
|
// was approved on this call.
|
|
async function handleDependabotAutoApprove(prNumber) {
|
|
const skip = (reason) => {
|
|
core.info(`dependabot-auto-approve: PR #${prNumber} not approved — ${reason}`);
|
|
return false;
|
|
};
|
|
|
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
|
|
|
// PR must be open, non-draft, without merge conflicts, and not held.
|
|
if (pr.state !== 'open') return skip('PR is not open');
|
|
if (pr.draft) return skip('PR is a draft');
|
|
if (pr.mergeable_state === 'dirty') return skip('PR has merge conflicts');
|
|
if (pr.labels.some(l => l.name === holdLabel)) return skip(`PR has the '${holdLabel}' label`);
|
|
|
|
// 1. Author must be the real Dependabot app — login AND user id.
|
|
if (pr.user.login !== DEPENDABOT_LOGIN || pr.user.id !== DEPENDABOT_USER_ID) {
|
|
return skip(`author is @${pr.user.login} (id ${pr.user.id}), not ${DEPENDABOT_LOGIN} (id ${DEPENDABOT_USER_ID})`);
|
|
}
|
|
|
|
// 2. Same-repo head on a dependabot/ branch — never fork PRs.
|
|
if (pr.head.repo?.full_name !== pr.base.repo.full_name) {
|
|
return skip('head repo is not the base repo');
|
|
}
|
|
if (!pr.head.ref.startsWith('dependabot/')) {
|
|
return skip(`head ref '${pr.head.ref}' is not a dependabot/ branch`);
|
|
}
|
|
|
|
// 3. Every commit must carry a verified signature and be authored by
|
|
// dependabot. Once a human pushes to the branch this gate fails for the
|
|
// rest of the PR's life — by design (fail closed); push invalidation
|
|
// dismisses the old approval and a maintainer must /lgtm from then on.
|
|
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
|
owner, repo, pull_number: prNumber, per_page: 100,
|
|
});
|
|
if (!commits.length) return skip('PR has no commits');
|
|
for (const c of commits) {
|
|
if (c.commit?.verification?.verified !== true) {
|
|
return skip(`commit ${c.sha.slice(0, 7)} does not have a verified signature`);
|
|
}
|
|
if (c.author?.login !== DEPENDABOT_LOGIN) {
|
|
return skip(`commit ${c.sha.slice(0, 7)} authored by @${c.author?.login || '(unknown)'}, not ${DEPENDABOT_LOGIN}`);
|
|
}
|
|
}
|
|
|
|
// 4. Changed files must be a subset of the allow-list (exact paths).
|
|
// Renames are checked on both sides.
|
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
owner, repo, pull_number: prNumber, per_page: 100,
|
|
});
|
|
for (const f of files) {
|
|
if (!dependabotAllowedFiles.includes(f.filename)) {
|
|
return skip(`changed file '${f.filename}' is not in dependabot-allowed-files`);
|
|
}
|
|
if (f.previous_filename && !dependabotAllowedFiles.includes(f.previous_filename)) {
|
|
return skip(`renamed file '${f.previous_filename}' is not in dependabot-allowed-files`);
|
|
}
|
|
}
|
|
|
|
// 5. Scope: under 'dev-patch-minor', every updated dependency (from the
|
|
// structured metadata in the commit messages — trustworthy here because
|
|
// gate 3 proved the messages are dependabot-authored and signed) must be
|
|
// direct:development with a semver-patch or semver-minor update. Missing
|
|
// or unparseable metadata never approves.
|
|
const deps = [];
|
|
for (const c of commits) deps.push(...parseDependabotMetadata(c.commit.message));
|
|
if (!deps.length) return skip('no updated-dependencies metadata found in commit messages');
|
|
for (const d of deps) {
|
|
if (d.dependencyType !== 'direct:development') {
|
|
return skip(`'${d.name}' has dependency-type '${d.dependencyType || '(missing)'}' — mode dev-patch-minor requires direct:development`);
|
|
}
|
|
if (!['version-update:semver-patch', 'version-update:semver-minor'].includes(d.updateType)) {
|
|
return skip(`'${d.name}' has update-type '${d.updateType || '(missing)'}' — mode dev-patch-minor requires semver-patch or semver-minor`);
|
|
}
|
|
}
|
|
|
|
// 6. CI must be green on the current head SHA: every check run completed
|
|
// with a passing conclusion, at least one non-skipped check exists (no
|
|
// approval without CI evidence), and the combined commit status — if any
|
|
// statuses exist — is success. Pending checks exit silently; the next
|
|
// check_suite completion re-evaluates. This gate lives here rather than
|
|
// in branch protection so approval itself is withheld until CI passes.
|
|
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
|
|
owner, repo, ref: pr.head.sha, per_page: 100,
|
|
});
|
|
const passing = ['success', 'neutral', 'skipped'];
|
|
for (const run of checkRuns) {
|
|
if (run.status !== 'completed') {
|
|
return skip(`check '${run.name}' is still ${run.status}`);
|
|
}
|
|
if (!passing.includes(run.conclusion)) {
|
|
return skip(`check '${run.name}' concluded '${run.conclusion}'`);
|
|
}
|
|
}
|
|
if (!checkRuns.some(r => r.conclusion !== 'skipped')) {
|
|
return skip('no non-skipped check runs on the head SHA');
|
|
}
|
|
const { data: combined } = await github.rest.repos.getCombinedStatusForRef({
|
|
owner, repo, ref: pr.head.sha,
|
|
});
|
|
if (combined.total_count > 0 && combined.state !== 'success') {
|
|
return skip(`combined commit status is '${combined.state}'`);
|
|
}
|
|
|
|
// Idempotency: concurrent check_suite completions can race — skip if an
|
|
// active (non-dismissed) auto-approval already exists.
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
|
owner, repo, pull_number: prNumber, per_page: 100,
|
|
});
|
|
if (reviews.some(r => r.state === 'APPROVED' && r.body?.includes(dependabotMarker))) {
|
|
return skip('already auto-approved');
|
|
}
|
|
|
|
// All gates passed — mirror the /lgtm accept path with the distinct
|
|
// marker, so push invalidation dismisses this approval too.
|
|
await addLabel(prNumber, approvedLabel);
|
|
await onAccept(pr);
|
|
await github.rest.pulls.createReview({
|
|
owner, repo, pull_number: prNumber, event: 'APPROVE',
|
|
body: `${dependabotMarker}\nAutomatically approved under the \`${dependabotAutoApprove}\` Dependabot policy: `
|
|
+ 'all commits are dependabot-authored with verified signatures, changed files are within '
|
|
+ `\`${dependabotAllowedFiles.join(', ')}\`, every update is \`direct:development\` `
|
|
+ 'semver-patch/minor, and CI is green.',
|
|
});
|
|
await enableAutoMerge(pr.node_id, prNumber);
|
|
core.info(`dependabot-auto-approve: PR #${prNumber} approved`);
|
|
return true;
|
|
}
|
|
|
|
// --- Dispatch ---------------------------------------------------
|
|
const ev = context.eventName;
|
|
|
|
// Invalidation on push
|
|
if (ev === 'pull_request_target' && context.payload.action === 'synchronize') {
|
|
if (!invalidateOnPush) return setResult('noop');
|
|
const pr = context.payload.pull_request;
|
|
const hasApproved = pr.labels.some(l => l.name === approvedLabel);
|
|
if (!hasApproved) return setResult('noop');
|
|
// If a core/lead maintainer applied the label directly (not the bot via
|
|
// /lgtm), treat it as a sticky governance decision rather than a per-SHA
|
|
// approval. Check the union of always-allow + force-allow teams so lead
|
|
// maintainers are covered even when always-allow-teams is narrower.
|
|
const labeler = await lastLabeledBy(pr.number, approvedLabel);
|
|
if (labeler && labeler.type !== 'Bot') {
|
|
for (const team of [...alwaysAllowTeams, ...forceAllowTeams]) {
|
|
if (await isTeamMember(labeler.login, team)) {
|
|
core.info(`'${approvedLabel}' was applied manually by @${labeler.login} (member of @${owner}/${team}); skipping push invalidation.`);
|
|
return setResult('invalidate-skipped', labeler.login);
|
|
}
|
|
}
|
|
}
|
|
await removeLabel(pr.number, approvedLabel);
|
|
await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.');
|
|
await disableAutoMerge(pr.node_id);
|
|
await github.rest.issues.createComment({
|
|
owner, repo, issue_number: pr.number,
|
|
body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`,
|
|
});
|
|
return setResult('invalidated');
|
|
}
|
|
|
|
// Dependabot auto-approve — evaluated when a PR opens/reopens and whenever
|
|
// a check suite completes on a dependabot branch (the pass that usually
|
|
// approves, since CI is rarely finished at open time).
|
|
const isDependabotEvent =
|
|
(ev === 'pull_request_target' && ['opened', 'reopened'].includes(context.payload.action)) ||
|
|
(ev === 'check_suite' && context.payload.action === 'completed');
|
|
if (isDependabotEvent) {
|
|
if (!dependabotAutoApprove) return setResult('noop');
|
|
if (dependabotAutoApprove !== 'dev-patch-minor') {
|
|
core.warning(`Unknown dependabot-auto-approve mode '${dependabotAutoApprove}' — auto-approve disabled (fail closed).`);
|
|
return setResult('noop');
|
|
}
|
|
const prNumbers = [];
|
|
if (ev === 'check_suite') {
|
|
const suite = context.payload.check_suite;
|
|
const branch = suite.head_branch || '';
|
|
// Only same-repo dependabot/ branches can ever pass the gates — skip
|
|
// the API round-trips entirely for everything else.
|
|
if (!branch.startsWith('dependabot/')) return setResult('noop');
|
|
for (const p of suite.pull_requests || []) prNumbers.push(p.number);
|
|
if (!prNumbers.length) {
|
|
// pull_requests can be empty on some deliveries — resolve by branch.
|
|
const { data: prs } = await github.rest.pulls.list({
|
|
owner, repo, state: 'open', head: `${owner}:${branch}`, per_page: 10,
|
|
});
|
|
for (const p of prs) prNumbers.push(p.number);
|
|
}
|
|
} else {
|
|
prNumbers.push(context.payload.pull_request.number);
|
|
}
|
|
let approvedCount = 0;
|
|
for (const n of prNumbers) {
|
|
if (await handleDependabotAutoApprove(n)) approvedCount++;
|
|
}
|
|
return setResult(approvedCount ? 'dependabot-approved' : 'dependabot-skipped');
|
|
}
|
|
|
|
// Comment commands
|
|
if (ev !== 'issue_comment') return setResult('noop');
|
|
if (!context.payload.issue.pull_request) return setResult('noop');
|
|
|
|
const commentBody = context.payload.comment.body || '';
|
|
const commentId = context.payload.comment.id;
|
|
const actor = context.payload.comment.user.login;
|
|
const firstLine = commentBody.split('\n')[0].trim();
|
|
const m = firstLine.match(/^\/(lgtm|hold|unhold|stageblog)\b\s*(cancel|force)?\s*$/i);
|
|
if (!m) return setResult('noop', actor);
|
|
|
|
const cmd = m[1].toLowerCase();
|
|
const arg = (m[2] || '').toLowerCase();
|
|
const cancel = arg === 'cancel' || cmd === 'unhold';
|
|
// `force` is only meaningful for /lgtm — reject `/hold force` etc. as noop
|
|
// rather than silently treating it as the base command.
|
|
if (arg === 'force' && cmd !== 'lgtm') return setResult('noop', actor);
|
|
const force = arg === 'force';
|
|
|
|
const prNumber = context.payload.issue.number;
|
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
|
if (pr.state !== 'open') return setResult('noop', actor);
|
|
|
|
// /lgtm force — restricted to force-allow-teams. Bypasses the self-approval
|
|
// guard and the CODEOWNERS path entirely; only team membership grants this.
|
|
if (cmd === 'lgtm' && force) {
|
|
let allowed = false;
|
|
for (const team of forceAllowTeams) {
|
|
if (await isTeamMember(actor, team)) { allowed = true; break; }
|
|
}
|
|
if (!allowed) {
|
|
await react(commentId, '-1');
|
|
return setResult('force-unauthorized', actor);
|
|
}
|
|
await addLabel(prNumber, approvedLabel);
|
|
await onAccept(pr);
|
|
if (submitReview) await submitApproval(prNumber, actor, true);
|
|
await react(commentId, '+1');
|
|
await enableAutoMerge(pr.node_id, prNumber);
|
|
return setResult('lgtm-forced', actor);
|
|
}
|
|
|
|
// Self-approval guard (only for /lgtm, not /hold)
|
|
if (cmd === 'lgtm' && !cancel && actor.toLowerCase() === pr.user.login.toLowerCase()) {
|
|
await react(commentId, '-1');
|
|
return setResult('self-lgtm-blocked', actor);
|
|
}
|
|
|
|
// Auth check. /lgtm is restricted to always-allow-teams (core maintainers)
|
|
// only — CODEOWNERS does not grant it. /hold and /stageblog still accept
|
|
// CODEOWNERS of the PR's changed files.
|
|
const authorized = cmd === 'lgtm'
|
|
? await isInAlwaysAllowTeam(actor)
|
|
: await isAuthorized(actor, pr);
|
|
if (!authorized) {
|
|
await react(commentId, '-1');
|
|
return setResult('unauthorized', actor);
|
|
}
|
|
|
|
// /stageblog — dispatch a caller-defined workflow with pinned head SHA
|
|
if (cmd === 'stageblog') {
|
|
const stageblogWorkflow = process.env.STAGEBLOG_WORKFLOW;
|
|
if (!stageblogWorkflow) {
|
|
await react(commentId, 'confused');
|
|
return setResult('stageblog-disabled', actor);
|
|
}
|
|
// cancel is meaningless here — just ignore silently
|
|
if (cancel) return setResult('noop', actor);
|
|
|
|
// Ack early — listFiles pagination + workflow dispatch can take a few
|
|
// seconds and the commenter otherwise gets no feedback until then.
|
|
await react(commentId, 'eyes');
|
|
|
|
// Gate: PR must touch blog paths
|
|
const blogRegexes = process.env.STAGEBLOG_PATHS
|
|
.split(',').map(s => s.trim()).filter(Boolean).map(patternToRegex);
|
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
owner, repo, pull_number: prNumber, per_page: 100,
|
|
});
|
|
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
|
|
if (!touchesBlog) {
|
|
await react(commentId, '-1');
|
|
await github.rest.issues.createComment({
|
|
owner, repo, issue_number: prNumber,
|
|
body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`,
|
|
});
|
|
return setResult('stageblog-not-blog', actor);
|
|
}
|
|
|
|
// Dispatch — pin head SHA at time of comment
|
|
const headSha = pr.head.sha;
|
|
await github.rest.actions.createWorkflowDispatch({
|
|
owner, repo, workflow_id: stageblogWorkflow, ref: pr.base.ref,
|
|
inputs: { pr_number: String(prNumber), head_sha: headSha },
|
|
});
|
|
await react(commentId, 'rocket');
|
|
await github.rest.issues.createComment({
|
|
owner, repo, issue_number: prNumber,
|
|
body: `Blog staging triggered by @${actor} for \`${headSha.slice(0, 7)}\`. Watch the [**${stageblogWorkflow}** workflow](../actions/workflows/${stageblogWorkflow}) for the preview link.`,
|
|
});
|
|
return setResult('stageblog-dispatched', actor);
|
|
}
|
|
|
|
// Execute
|
|
if (cmd === 'lgtm' && !cancel) {
|
|
await addLabel(prNumber, approvedLabel);
|
|
await onAccept(pr);
|
|
if (submitReview) await submitApproval(prNumber, actor);
|
|
await react(commentId, '+1');
|
|
await enableAutoMerge(pr.node_id, prNumber);
|
|
return setResult('lgtm-added', actor);
|
|
}
|
|
if (cmd === 'lgtm' && cancel) {
|
|
await removeLabel(prNumber, approvedLabel);
|
|
await dismissBotApprovals(prNumber, `@${actor} cancelled approval via /lgtm cancel.`);
|
|
await disableAutoMerge(pr.node_id);
|
|
await react(commentId, '+1');
|
|
return setResult('lgtm-removed', actor);
|
|
}
|
|
if ((cmd === 'hold' && !cancel)) {
|
|
await addLabel(prNumber, holdLabel);
|
|
await react(commentId, '+1');
|
|
return setResult('hold-added', actor);
|
|
}
|
|
// /hold cancel or /unhold
|
|
await removeLabel(prNumber, holdLabel);
|
|
await react(commentId, '+1');
|
|
return setResult('hold-removed', actor);
|
|
};
|