Merge pull request #19 from modelcontextprotocol/claude/dependabot-auto-approve
slash-commands: add hardened Dependabot auto-approve mode
This commit is contained in:
commit
2c9cb4f4db
3 changed files with 318 additions and 6 deletions
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
Reusable composite action that implements Prow-style `/lgtm`, `/hold`, 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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
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. |
|
||||
| **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. |
|
||||
|
|
@ -154,6 +203,9 @@ jobs:
|
|||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
# auto-merge-method: squash # default; also: merge, rebase
|
||||
# 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
|
||||
|
|
@ -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. |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
| 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) |
|
||||
|
||||
## 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.
|
||||
- **Review provenance** — the review body always names the real approver
|
||||
("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.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ description: >-
|
|||
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
|
||||
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:
|
||||
github-token:
|
||||
|
|
@ -110,6 +111,29 @@ inputs:
|
|||
if no file matches.
|
||||
required: false
|
||||
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:
|
||||
result:
|
||||
|
|
@ -117,7 +141,7 @@ outputs:
|
|||
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
|
||||
stageblog-disabled, dependabot-approved, dependabot-skipped, noop
|
||||
value: ${{ steps.handle.outputs.result }}
|
||||
actor:
|
||||
description: Login of the user who triggered the action (commenter)
|
||||
|
|
@ -146,6 +170,8 @@ runs:
|
|||
PROJECT_GATE_LABEL: ${{ inputs.project-gate-label }}
|
||||
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
|
||||
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
|
||||
DEPENDABOT_AUTO_APPROVE: ${{ inputs.dependabot-auto-approve }}
|
||||
DEPENDABOT_ALLOWED_FILES: ${{ inputs.dependabot-allowed-files }}
|
||||
with:
|
||||
github-token: ${{ inputs.github-token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -21,7 +21,15 @@ module.exports = async ({ github, context, core }) => {
|
|||
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);
|
||||
|
|
@ -205,7 +213,7 @@ module.exports = async ({ github, context, core }) => {
|
|||
});
|
||||
for (const r of reviews) {
|
||||
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 {
|
||||
await github.rest.pulls.dismissReview({
|
||||
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 ---------------------------------------------------
|
||||
const ev = context.eventName;
|
||||
|
||||
|
|
@ -370,6 +557,43 @@ module.exports = async ({ github, context, core }) => {
|
|||
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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue