Skip to content

Commit 9e32055

Browse files
committed
refactor: split logic into src/api.js, keep run() in index.js
Move all reusable functions (getConfig, getPRDetails, enqueue, isMergeQueueEnabled, checkEligibility, processPR, getPRNumbers) into src/api.js. src/index.js retains run() and the entry point invocation. Also adds the branch input to action.yml, README, and test coverage.
1 parent 54ed7ac commit 9e32055

5 files changed

Lines changed: 307 additions & 259 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ jobs:
5050
5151
Then add the `enqueue-pullrequest` label to any PR you want automatically enqueued once it's ready.
5252

53+
To process only PRs from a specific branch (useful for `schedule` or `workflow_dispatch` triggers), set `branch`:
54+
55+
```yaml
56+
- uses: waheedahmed/enqueue-pullrequest@v1
57+
with:
58+
github-token: ${{ secrets.GITHUB_TOKEN }}
59+
branch: "feature/my-branch"
60+
```
61+
5362
To enqueue **all** open PRs without requiring a label, set `label` to an empty string:
5463

5564
```yaml
@@ -66,6 +75,7 @@ To enqueue **all** open PRs without requiring a label, set `label` to an empty s
6675
|-------|-------------|---------|
6776
| `github-token` | Token for GitHub API access. Needs `pull-requests: write`. | `${{ github.token }}` |
6877
| `label` | Label that triggers enqueuing. Set to `""` to process every open PR regardless of labels. | `enqueue-pullrequest` |
78+
| `branch` | Head branch name to filter PRs by for broad events (`schedule`, `workflow_dispatch`, `push`, etc.). Leave empty to process all open PRs. | `""` |
6979
| `skip-labels` | Comma-separated blocking labels (e.g. `wip,do-not-merge`). | `""` |
7080
| `base-branches` | Comma-separated list of allowed base branches. Empty = all branches. | `""` |
7181
| `skip-drafts` | Skip draft pull requests. | `true` |

action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ inputs:
1919
required: false
2020
default: "enqueue-pullrequest"
2121

22+
branch:
23+
description: >
24+
Head branch name used to filter pull requests for broad events
25+
(schedule, workflow_dispatch, push, etc.). When provided only PRs whose
26+
head branch matches this value are processed. Leave empty to process all
27+
open PRs.
28+
required: false
29+
default: ""
30+
2231
skip-labels:
2332
description: >
2433
Comma-separated list of labels that prevent a PR from being added to the

src/__tests__/index.test.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe("enqueue-pullrequest action", () => {
4545
const defaults = {
4646
"github-token": "ghs_test",
4747
"label": "enqueue-pullrequest",
48+
"branch": "",
4849
"skip-labels": "",
4950
"base-branches": "",
5051
"skip-drafts": "true",
@@ -410,9 +411,10 @@ describe("enqueue-pullrequest action", () => {
410411

411412
await runAction();
412413

414+
// no head filter — scans all open PRs
413415
expect(mockOctokit.paginate).toHaveBeenCalledWith(
414416
mockOctokit.rest.pulls.list,
415-
expect.objectContaining({ state: "open" })
417+
expect.not.objectContaining({ head: expect.anything() })
416418
);
417419
expect(mockOctokit.graphql).toHaveBeenCalledTimes(6);
418420
});
@@ -433,6 +435,27 @@ describe("enqueue-pullrequest action", () => {
433435
expect(core.setFailed).not.toHaveBeenCalled();
434436
});
435437

438+
test("branch input filters PRs by head branch for broad events", async () => {
439+
setupInputs({ branch: "feature/my-branch" });
440+
github.context = {
441+
eventName: "workflow_dispatch",
442+
payload: {},
443+
repo: { owner: "acme", repo: "my-repo" },
444+
};
445+
mockOctokit.paginate.mockResolvedValue([{ number: 42 }]);
446+
mockOctokit.graphql
447+
.mockResolvedValueOnce({ repository: { pullRequest: makePRPayload() } })
448+
.mockResolvedValueOnce(mqEnabled)
449+
.mockResolvedValueOnce(mockEnqueue());
450+
451+
await runAction();
452+
453+
expect(mockOctokit.paginate).toHaveBeenCalledWith(
454+
mockOctokit.rest.pulls.list,
455+
expect.objectContaining({ head: "acme:feature/my-branch" })
456+
);
457+
});
458+
436459
test("pull_request event with missing PR number logs warning", async () => {
437460
setupInputs();
438461
github.context = {

src/api.js

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"use strict";
2+
3+
const core = require("@actions/core");
4+
5+
// ─── Configuration ────────────────────────────────────────────────────────────
6+
7+
function getConfig() {
8+
return {
9+
token: core.getInput("github-token", { required: true }),
10+
label: core.getInput("label").trim(),
11+
branch: core.getInput("branch").trim(),
12+
skipLabels: core
13+
.getInput("skip-labels")
14+
.split(",")
15+
.map((l) => l.trim())
16+
.filter(Boolean),
17+
baseBranches: core
18+
.getInput("base-branches")
19+
.split(",")
20+
.map((b) => b.trim())
21+
.filter(Boolean),
22+
skipDrafts: core.getInput("skip-drafts") !== "false",
23+
requiredApprovals: parseInt(core.getInput("required-approvals"), 10) || 0,
24+
};
25+
}
26+
27+
// ─── GitHub GraphQL helpers ───────────────────────────────────────────────────
28+
29+
/** Returns the GraphQL node ID and current merge-queue state for a PR. */
30+
async function getPRDetails(octokit, owner, repo, prNumber) {
31+
const { repository } = await octokit.graphql(
32+
`
33+
query PRDetails($owner: String!, $repo: String!, $number: Int!) {
34+
repository(owner: $owner, name: $repo) {
35+
pullRequest(number: $number) {
36+
id
37+
isDraft
38+
state
39+
title
40+
baseRefName
41+
labels(first: 20) {
42+
nodes { name }
43+
}
44+
reviews(states: [APPROVED], last: 10) {
45+
totalCount
46+
}
47+
mergeQueueEntry {
48+
id
49+
state
50+
position
51+
}
52+
}
53+
}
54+
}
55+
`,
56+
{ owner, repo, number: prNumber }
57+
);
58+
return repository?.pullRequest ?? null;
59+
}
60+
61+
/** Adds a PR (by GraphQL node ID) to the merge queue. */
62+
async function enqueue(octokit, prNodeId) {
63+
const result = await octokit.graphql(
64+
`
65+
mutation Enqueue($prId: ID!) {
66+
enqueuePullRequest(input: { pullRequestId: $prId }) {
67+
mergeQueueEntry {
68+
id
69+
state
70+
position
71+
}
72+
}
73+
}
74+
`,
75+
{ prId: prNodeId }
76+
);
77+
return result.enqueuePullRequest?.mergeQueueEntry ?? null;
78+
}
79+
80+
/** Returns true if the merge queue is enabled for the given branch. */
81+
async function isMergeQueueEnabled(octokit, owner, repo, branch) {
82+
const { repository } = await octokit.graphql(
83+
`
84+
query MergeQueueEnabled($owner: String!, $repo: String!, $branch: String!) {
85+
repository(owner: $owner, name: $repo) {
86+
mergeQueue(branch: $branch) {
87+
id
88+
}
89+
}
90+
}
91+
`,
92+
{ owner, repo, branch }
93+
);
94+
return repository?.mergeQueue != null;
95+
}
96+
97+
// ─── Eligibility checks ───────────────────────────────────────────────────────
98+
99+
/**
100+
* Returns `{ eligible: true }` or `{ eligible: false, reason: string }`.
101+
* Uses data already fetched via GraphQL to avoid extra round-trips.
102+
*/
103+
function checkEligibility(pr, config) {
104+
if (pr.state !== "OPEN") {
105+
return { eligible: false, reason: `PR is ${pr.state.toLowerCase()}` };
106+
}
107+
108+
if (config.skipDrafts && pr.isDraft) {
109+
return { eligible: false, reason: "PR is a draft" };
110+
}
111+
112+
const labels = pr.labels.nodes.map((l) => l.name);
113+
114+
if (config.label && !labels.includes(config.label)) {
115+
return {
116+
eligible: false,
117+
reason: `Missing required label "${config.label}"`,
118+
};
119+
}
120+
121+
for (const skip of config.skipLabels) {
122+
if (labels.includes(skip)) {
123+
return { eligible: false, reason: `Has blocking label "${skip}"` };
124+
}
125+
}
126+
127+
if (
128+
config.baseBranches.length > 0 &&
129+
!config.baseBranches.includes(pr.baseRefName)
130+
) {
131+
return {
132+
eligible: false,
133+
reason: `Base branch "${pr.baseRefName}" not in allowed list`,
134+
};
135+
}
136+
137+
if (config.requiredApprovals > 0) {
138+
const approvals = pr.reviews?.totalCount ?? 0;
139+
if (approvals < config.requiredApprovals) {
140+
return {
141+
eligible: false,
142+
reason: `Insufficient approvals: ${approvals}/${config.requiredApprovals}`,
143+
};
144+
}
145+
}
146+
147+
if (pr.mergeQueueEntry) {
148+
return {
149+
eligible: false,
150+
reason: `Already in merge queue (position ${pr.mergeQueueEntry.position}, state ${pr.mergeQueueEntry.state})`,
151+
};
152+
}
153+
154+
return { eligible: true };
155+
}
156+
157+
// ─── Per-PR processing ────────────────────────────────────────────────────────
158+
159+
async function processPR(octokit, owner, repo, prNumber, config) {
160+
core.info(`\n── PR #${prNumber} ──`);
161+
162+
let pr;
163+
try {
164+
pr = await getPRDetails(octokit, owner, repo, prNumber);
165+
} catch (err) {
166+
core.warning(`Could not fetch details for PR #${prNumber}: ${err.message}`);
167+
return;
168+
}
169+
170+
if (!pr) {
171+
core.warning(`PR #${prNumber} not found`);
172+
return;
173+
}
174+
175+
core.info(`Title: ${pr.title}`);
176+
core.info(`Base: ${pr.baseRefName}`);
177+
178+
const { eligible, reason } = checkEligibility(pr, config);
179+
180+
if (!eligible) {
181+
core.info(`Skipping: ${reason}`);
182+
return;
183+
}
184+
185+
core.info("Eligible — checking merge queue is enabled…");
186+
187+
const mqEnabled = await isMergeQueueEnabled(octokit, owner, repo, pr.baseRefName);
188+
if (!mqEnabled) {
189+
core.setFailed(
190+
`Merge queue is not enabled for branch "${pr.baseRefName}". Enable it under Settings → Branches → branch protection rules.`
191+
);
192+
return;
193+
}
194+
195+
core.info("Adding to merge queue…");
196+
197+
try {
198+
const entry = await enqueue(octokit, pr.id);
199+
if (entry) {
200+
core.info(
201+
`Added to merge queue: position=${entry.position}, state=${entry.state}`
202+
);
203+
} else {
204+
core.info("Added to merge queue (no entry details returned)");
205+
}
206+
} catch (err) {
207+
core.setFailed(`Failed to enqueue PR #${prNumber}: ${err.message}`);
208+
}
209+
}
210+
211+
// ─── Event routing ────────────────────────────────────────────────────────────
212+
213+
/**
214+
* Returns the list of PR numbers to inspect for the current event.
215+
* For events that carry a specific PR we use only that one; for broader events
216+
* (schedule, workflow_dispatch, push, etc.) we list open PRs, optionally
217+
* filtered to a specific head branch via the `branch` input.
218+
*/
219+
async function getPRNumbers(octokit, owner, repo, context, config) {
220+
const { eventName, payload } = context;
221+
222+
// Events that identify a single PR directly
223+
const singlePREvents = new Set([
224+
"pull_request",
225+
"pull_request_target",
226+
"pull_request_review",
227+
"pull_request_review_comment",
228+
]);
229+
230+
if (singlePREvents.has(eventName)) {
231+
const number = payload.pull_request?.number;
232+
if (!number) {
233+
core.warning(`Event ${eventName} carried no pull_request number`);
234+
return [];
235+
}
236+
return [number];
237+
}
238+
239+
// For check_run / check_suite the PR list is embedded in the payload
240+
if (eventName === "check_run") {
241+
return (payload.check_run?.pull_requests ?? []).map((pr) => pr.number);
242+
}
243+
244+
if (eventName === "check_suite") {
245+
return (payload.check_suite?.pull_requests ?? []).map((pr) => pr.number);
246+
}
247+
248+
// For all other events use the branch input to narrow the search when provided.
249+
const listParams = { owner, repo, state: "open", per_page: 100 };
250+
251+
if (config.branch) {
252+
core.info(`Fetching open PRs with head branch "${config.branch}"`);
253+
listParams.head = `${owner}:${config.branch}`;
254+
} else {
255+
core.info(`Scanning all open pull requests in ${owner}/${repo}`);
256+
}
257+
258+
const prs = await octokit.paginate(octokit.rest.pulls.list, listParams);
259+
return prs.map((pr) => pr.number);
260+
}
261+
262+
module.exports = { getConfig, getPRNumbers, processPR };

0 commit comments

Comments
 (0)