Merge pull request #19 from modelcontextprotocol/claude/dependabot-auto-approve

slash-commands: add hardened Dependabot auto-approve mode
This commit is contained in:
Den Delimarsky 2026-07-06 11:09:01 -07:00 • committed by GitHub
commit 2c9cb4f4db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 318 additions and 6 deletions

View file

@ -2,7 +2,8 @@
Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and
`/stageblog` slash commands on pull requests, gated by team membership and `/stageblog` slash commands on pull requests, gated by team membership and
CODEOWNERS. CODEOWNERS. Optionally auto-approves narrowly-scoped Dependabot PRs
(`dependabot-auto-approve`).
## What it does ## What it does
@ -42,6 +43,54 @@ must match `^/cmd\b`). Unauthorized attempts, or a PR author trying to `/lgtm`
their own PR, receive a 👎 reaction with no further noise. Successful commands their own PR, receive a 👎 reaction with no further noise. Successful commands
receive a 👍 reaction. receive a 👍 reaction.
### Dependabot auto-approve (opt-in)
When `dependabot-auto-approve: dev-patch-minor` is set, the action evaluates
Dependabot PRs on `pull_request_target` (`opened`/`reopened`) and on every
`check_suite` completion for a `dependabot/` branch, and — when the PR passes
**all** of the gates below — adds the `accepted` label, submits a bot APPROVE
review (marker `<!-- slash-commands-dependabot-auto -->`), and enables
auto-merge, exactly like `/lgtm`. Any gate failure exits **without approving**
(fail closed) and logs the reason to the action log only:
1. **Real Dependabot** — PR author login is `dependabot[bot]` **and** the
user id is `49699333` (the Dependabot app's stable id). A lookalike
account can never match.
2. **Same-repo head** — the head branch lives in the base repo (never a
fork) and starts with `dependabot/`.
3. **Trusted commits** — every commit on the PR has a **verified signature**
and is authored by `dependabot[bot]`.
4. **File allow-list** — the changed files are a subset of
`dependabot-allowed-files` (exact paths; default
`package.json,package-lock.json`).
5. **Dependency scope** — Dependabot's structured commit-message metadata
(`updated-dependencies:` block) must show **every** updated dependency as
`dependency-type: direct:development` with
`update-type: version-update:semver-patch` or `…semver-minor`. Missing or
unparseable metadata never approves. (The metadata is trustworthy at this
point because gate 3 proved the commits are dependabot-authored and
signed.)
6. **CI green** — every check run on the head SHA is completed with
conclusion `success`/`neutral`/`skipped`, at least one **non-skipped**
check exists, 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 **in the action** so approval
itself is withheld until CI passes, even when branch protection has no
required status checks.
7. **PR state** — open, not a draft, no merge conflicts, and not carrying
the hold label.
Push invalidation applies to these approvals too: any push to the PR (human
or Dependabot rebase) dismisses the auto-approval, removes the label, and
disables auto-merge. If **Dependabot** pushed (e.g. a rebase), the PR is
re-evaluated when its checks complete and can be re-approved. If a **human**
pushed, gate 3 fails permanently for that PR — every subsequent evaluation
sees a non-dependabot commit — so the PR can only proceed via a maintainer's
`/lgtm`. That one-way trapdoor is intentional.
Enabling the mode requires two extra caller-workflow triggers — see
[Caller workflow requirements](#caller-workflow-requirements).
### Merge gate ### Merge gate
The merge gate is **GitHub's native required-reviews rule** plus The merge gate is **GitHub's native required-reviews rule** plus
@ -117,7 +166,7 @@ After creating:
| | | | | |
|---|---| |---|---|
| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`) | | **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`). With `dependabot-auto-approve` enabled, also `pull_request_target` types `opened`, `reopened` + `check_suite` (types: `completed`) — gate the `check_suite` job on `startsWith(github.event.check_suite.head_branch, 'dependabot/')` to avoid a run per suite repo-wide. |
| **Permissions** | `contents: read` is sufficient — the App token handles all writes. | | **Permissions** | `contents: read` is sufficient — the App token handles all writes. |
| **Fork-PR safety** | No special guard needed — `issue_comment` and `pull_request_target` both run in the **base** repo's context with the base workflow definition, so fork authors cannot modify the logic. CODEOWNERS is also fetched from the PR's **base** ref, never the head. | | **Fork-PR safety** | No special guard needed — `issue_comment` and `pull_request_target` both run in the **base** repo's context with the base workflow definition, so fork authors cannot modify the logic. CODEOWNERS is also fetched from the PR's **base** ref, never the head. |
| **No checkout** | The action calls GitHub API only. Do not `actions/checkout` PR code. | | **No checkout** | The action calls GitHub API only. Do not `actions/checkout` PR code. |
@ -154,6 +203,9 @@ jobs:
github-token: ${{ steps.app-token.outputs.token }} github-token: ${{ steps.app-token.outputs.token }}
# auto-merge-method: squash # default; also: merge, rebase # auto-merge-method: squash # default; also: merge, rebase
# stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog # stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog
# dependabot-auto-approve: dev-patch-minor # uncomment to enable — also add
# pull_request_target types [opened, reopened] and check_suite [completed]
# triggers above (see Caller workflow requirements)
``` ```
**Branch protection settings** for this flow: enable _Require a pull request **Branch protection settings** for this flow: enable _Require a pull request
@ -227,12 +279,14 @@ jobs:
| `project-gate-label` | | _(empty)_ | If set, only update the project status when the PR has this label (e.g. `SEP`). Empty = update for every accepted PR. Label removal via `remove-labels-on-accept` is not gated — it always runs. | | `project-gate-label` | | _(empty)_ | If set, only update the project status when the PR has this label (e.g. `SEP`). Empty = update for every accepted PR. Label removal via `remove-labels-on-accept` is not gated — it always runs. |
| `stageblog-workflow` | | _(empty)_ | Workflow file name (e.g. `stage-blog.yml`) to dispatch when `/stageblog` is invoked. Empty = command disabled. The workflow must accept `pr_number` and `head_sha` string inputs. | | `stageblog-workflow` | | _(empty)_ | Workflow file name (e.g. `stage-blog.yml`) to dispatch when `/stageblog` is invoked. Empty = command disabled. The workflow must accept `pr_number` and `head_sha` string inputs. |
| `stageblog-paths` | | `blog/**` | Comma-separated CODEOWNERS-style glob patterns. `/stageblog` is refused if no changed file matches. | | `stageblog-paths` | | `blog/**` | Comma-separated CODEOWNERS-style glob patterns. `/stageblog` is refused if no changed file matches. |
| `dependabot-auto-approve` | | _(empty)_ | Dependabot auto-approve policy. Empty = disabled. `dev-patch-minor` = approve + label + auto-merge Dependabot PRs that pass **all** gates (see [Dependabot auto-approve](#dependabot-auto-approve-opt-in)). Any other value disables the mode (fail closed). Requires the extra caller triggers listed above. |
| `dependabot-allowed-files` | | `package.json,package-lock.json` | Comma-separated **exact** file paths a Dependabot PR may touch to qualify for auto-approve. Any other changed file blocks auto-approval. |
## Outputs ## Outputs
| Output | Description | | Output | Description |
|---|---| |---|---|
| `result` | One of: `lgtm-added`, `lgtm-forced`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `invalidate-skipped`, `unauthorized`, `force-unauthorized`, `self-lgtm-blocked`, `stageblog-dispatched`, `stageblog-not-blog`, `stageblog-disabled`, `noop` | | `result` | One of: `lgtm-added`, `lgtm-forced`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `invalidate-skipped`, `unauthorized`, `force-unauthorized`, `self-lgtm-blocked`, `stageblog-dispatched`, `stageblog-not-blog`, `stageblog-disabled`, `dependabot-approved`, `dependabot-skipped`, `noop` |
| `actor` | Login of the commenter (empty for non-comment triggers) | | `actor` | Login of the commenter (empty for non-comment triggers) |
## CODEOWNERS pattern support ## CODEOWNERS pattern support
@ -274,3 +328,11 @@ Last-match-wins per file, consistent with GitHub's native CODEOWNERS semantics.
author cannot push new commits between the maintainer's review and the build. author cannot push new commits between the maintainer's review and the build.
- **Review provenance** — the review body always names the real approver - **Review provenance** — the review body always names the real approver
("on behalf of @login"), even though GitHub shows the review as App-authored. ("on behalf of @login"), even though GitHub shows the review as App-authored.
- **Dependabot auto-approve** — off by default; every gate fails closed. The
author check pins the Dependabot app's numeric user id, not just the login;
fork heads are rejected outright; commit trust requires verified signatures
on dependabot-authored commits; the file allow-list is exact-path; missing
update metadata never approves; and approval waits for green CI inside the
action, so it holds even without required status checks in branch
protection. A human push onto a dependabot branch permanently disqualifies
the PR from auto-approval (gate 3) — only `/lgtm` can accept it after that.

View file

@ -4,7 +4,8 @@ description: >-
can approve via /lgtm (adds an accepted label, submits a bot APPROVE review, can approve via /lgtm (adds an accepted label, submits a bot APPROVE review,
and enables auto-merge). Core maintainers or CODEOWNERS can block via /hold and enables auto-merge). Core maintainers or CODEOWNERS can block via /hold
or trigger a blog preview staging workflow via /stageblog. Approval is or trigger a blog preview staging workflow via /stageblog. Approval is
invalidated when new commits are pushed. invalidated when new commits are pushed. Optionally auto-approves
narrowly-scoped Dependabot PRs (dependabot-auto-approve).
inputs: inputs:
github-token: github-token:
@ -110,6 +111,29 @@ inputs:
if no file matches. if no file matches.
required: false required: false
default: 'blog/**' default: 'blog/**'
dependabot-auto-approve:
description: >-
Opt-in Dependabot auto-approve policy. Empty (default) = disabled.
'dev-patch-minor' approves + labels + enables auto-merge on Dependabot
PRs where ALL of these hold: author is the real dependabot[bot] app
(login and user id), same-repo dependabot/ head branch, every commit is
signature-verified and dependabot-authored, changed files are a subset
of dependabot-allowed-files, every updated dependency is
direct:development with a semver-patch/minor update (per Dependabot's
commit-message metadata; missing metadata never approves), all check
runs on the head SHA are green with at least one non-skipped check, and
the PR is open, non-draft, conflict-free, and not on hold. Requires the
caller workflow to also trigger on pull_request_target
[opened, reopened] and check_suite [completed]. Any other value
disables the mode (fail closed).
required: false
default: ''
dependabot-allowed-files:
description: >-
Comma-separated exact file paths a Dependabot PR may touch to qualify
for auto-approve. A PR changing any other file is never auto-approved.
required: false
default: 'package.json,package-lock.json'
outputs: outputs:
result: result:
@ -117,7 +141,7 @@ outputs:
One of: lgtm-added, lgtm-forced, lgtm-removed, hold-added, hold-removed, One of: lgtm-added, lgtm-forced, lgtm-removed, hold-added, hold-removed,
invalidated, invalidate-skipped, unauthorized, force-unauthorized, invalidated, invalidate-skipped, unauthorized, force-unauthorized,
self-lgtm-blocked, stageblog-dispatched, stageblog-not-blog, self-lgtm-blocked, stageblog-dispatched, stageblog-not-blog,
stageblog-disabled, noop stageblog-disabled, dependabot-approved, dependabot-skipped, noop
value: ${{ steps.handle.outputs.result }} value: ${{ steps.handle.outputs.result }}
actor: actor:
description: Login of the user who triggered the action (commenter) description: Login of the user who triggered the action (commenter)
@ -146,6 +170,8 @@ runs:
PROJECT_GATE_LABEL: ${{ inputs.project-gate-label }} PROJECT_GATE_LABEL: ${{ inputs.project-gate-label }}
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
DEPENDABOT_AUTO_APPROVE: ${{ inputs.dependabot-auto-approve }}
DEPENDABOT_ALLOWED_FILES: ${{ inputs.dependabot-allowed-files }}
with: with:
github-token: ${{ inputs.github-token }} github-token: ${{ inputs.github-token }}
script: | script: |

View file

@ -21,7 +21,15 @@ module.exports = async ({ github, context, core }) => {
const projectStatusField = process.env.PROJECT_STATUS_FIELD; const projectStatusField = process.env.PROJECT_STATUS_FIELD;
const projectAcceptedOption = process.env.PROJECT_ACCEPTED_OPTION; const projectAcceptedOption = process.env.PROJECT_ACCEPTED_OPTION;
const projectGateLabel = process.env.PROJECT_GATE_LABEL; 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 -->'; 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) { function setResult(result, actor) {
core.setOutput('result', result); core.setOutput('result', result);
@ -205,7 +213,7 @@ module.exports = async ({ github, context, core }) => {
}); });
for (const r of reviews) { for (const r of reviews) {
if (r.state !== 'APPROVED') continue; if (r.state !== 'APPROVED') continue;
if (!r.body || !r.body.includes(reviewMarker)) continue; if (!r.body || !botReviewMarkers.some(mk => r.body.includes(mk))) continue;
try { try {
await github.rest.pulls.dismissReview({ await github.rest.pulls.dismissReview({
owner, repo, pull_number: prNumber, review_id: r.id, owner, repo, pull_number: prNumber, review_id: r.id,
@ -338,6 +346,185 @@ module.exports = async ({ github, context, core }) => {
} }
} }
// --- 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 --------------------------------------------------- // --- Dispatch ---------------------------------------------------
const ev = context.eventName; const ev = context.eventName;
@ -370,6 +557,43 @@ module.exports = async ({ github, context, core }) => {
return setResult('invalidated'); 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 // Comment commands
if (ev !== 'issue_comment') return setResult('noop'); if (ev !== 'issue_comment') return setResult('noop');
if (!context.payload.issue.pull_request) return setResult('noop'); if (!context.payload.issue.pull_request) return setResult('noop');