// 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 = ''; // 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 = ''; 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); };