Skip to content

Commit ae2c889

Browse files
Introduce /bkbot Command to Control CI Workflow Runs via PR Comments (#4673)
* Introduce /bkbot Command to Control CI Workflow Runs via PR Comments
1 parent 5154149 commit ae2c889

File tree

1 file changed

+298
-0
lines changed

1 file changed

+298
-0
lines changed

.github/workflows/ci-bkbot.yaml

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
#
18+
# Description:
19+
# This GitHub Actions workflow enables rerunning CI via PR/Issue comments using the /bkbot command.
20+
# Supported commands: /bkbot rerun [keyword]
21+
# - /bkbot rerun => Rerun the latest run of each workflow under the same head SHA, limited to runs with a conclusion of failure/cancelled/timed_out/skipped (entire run).
22+
# - /bkbot rerun <keyword> => Regardless of workflow/job status, fetch all jobs in the latest runs, match by name, and rerun each matching job.
23+
# Logging instructions:
24+
# - Jobs that are failed/cancelled/timed_out/skipped are scanned from all the latest workflow runs (including those in progress), thus jobs fail/skipped during progress can be captured.
25+
# Triggering condition: When a new comment is created containing /bkbot.
26+
27+
name: BookKeeper Bot
28+
on:
29+
issue_comment:
30+
types: [created]
31+
32+
permissions:
33+
actions: write
34+
contents: read
35+
36+
jobs:
37+
bkbot:
38+
runs-on: ubuntu-24.04
39+
timeout-minutes: 10
40+
if: github.event_name == 'issue_comment' && contains(github.event.comment.body, '/bkbot')
41+
steps:
42+
- name: Execute bkbot command
43+
uses: actions/github-script@v7
44+
with:
45+
github-token: ${{ secrets.GITHUB_TOKEN }}
46+
script: |
47+
// Supported commands:
48+
// - /bkbot rerun
49+
// Reruns all completed workflows with conclusions of failure/timed_out/skipped/cancelled
50+
// If workflow is still running, cannot rerun whole workflow, just suggest using "/bkbot rerun jobname"
51+
// - /bkbot rerun jobname
52+
// Matches job.name by keyword, reruns matching jobs (regardless of current state, failures are logged)
53+
// - /bkbot stop or /bkbot cancel
54+
// Cancels all still running (queued/in_progress) workflow runs associated with the current PR
55+
56+
const commentBody = context.payload.comment.body.trim();
57+
const prefix = '/bkbot';
58+
if (!commentBody.startsWith(prefix)) {
59+
console.log('Not a bkbot command, skipping ...');
60+
return;
61+
}
62+
63+
if (!context.payload.issue || !context.payload.issue.pull_request) {
64+
console.error('This comment is not on a Pull Request. bkbot only works on PRs.');
65+
return;
66+
}
67+
68+
const parts = commentBody.split(/\s+/);
69+
const sub = (parts[1] || '').toLowerCase();
70+
const arg = parts.length > 2 ? parts.slice(2).join(' ') : '';
71+
72+
const supported = ['rerun', 'stop', 'cancel'];
73+
if (!supported.includes(sub)) {
74+
console.log(`Unsupported command '${sub}'. Supported: '/bkbot rerun [jobName?]', '/bkbot stop', '/bkbot cancel'.`);
75+
return;
76+
}
77+
78+
const prNum = context.payload.issue.number;
79+
80+
// Get PR info
81+
let pr;
82+
try {
83+
({ data: pr } = await github.rest.pulls.get({
84+
owner: context.repo.owner,
85+
repo: context.repo.repo,
86+
pull_number: prNum
87+
}));
88+
} catch (e) {
89+
console.error(`Failed to fetch PR #${prNum}: ${e.message}`);
90+
return;
91+
}
92+
93+
const headSha = pr.head.sha;
94+
const prBranch = pr.head.ref;
95+
const prUser = (pr.head && pr.head.user && pr.head.user.login) ? pr.head.user.login : pr.user.login;
96+
const prUrl = pr.html_url;
97+
98+
console.log(`bkbot handling PR #${prNum} ${prUrl}`);
99+
console.log(`PR branch='${prBranch}', headSha='${headSha}', author='${prUser}'`);
100+
console.log(`Command parsed => sub='${sub}', arg='${arg || ''}'`);
101+
102+
// Fetch workflow runs in this repo triggered by this user on this branch, then filter by headSha
103+
let page = 1;
104+
const allRunsRaw = [];
105+
while (true) {
106+
const { data } = await github.rest.actions.listWorkflowRunsForRepo({
107+
owner: context.repo.owner,
108+
repo: context.repo.repo,
109+
actor: prUser,
110+
branch: prBranch,
111+
per_page: 100,
112+
page
113+
});
114+
const wr = data.workflow_runs || [];
115+
if (wr.length === 0) break;
116+
allRunsRaw.push(...wr);
117+
if (wr.length < 100) break;
118+
page++;
119+
}
120+
121+
const runsAtHead = allRunsRaw.filter(r => r.head_sha === headSha);
122+
if (runsAtHead.length === 0) {
123+
console.error(`No workflow runs found for head SHA ${headSha} on branch ${prBranch}.`);
124+
return;
125+
}
126+
127+
// Only keep the latest run for each workflow_id
128+
runsAtHead.sort((a, b) => {
129+
if (a.workflow_id !== b.workflow_id) return a.workflow_id - b.workflow_id;
130+
return new Date(b.created_at) - new Date(a.created_at);
131+
});
132+
const latestRuns = [];
133+
const seen = new Set();
134+
for (const r of runsAtHead) {
135+
if (!seen.has(r.workflow_id)) {
136+
seen.add(r.workflow_id);
137+
latestRuns.push(r);
138+
}
139+
}
140+
141+
function runKey(r) {
142+
return `[run_id=${r.id}] ${r.name || '(unnamed)'} | status=${r.status} | conclusion=${r.conclusion || '-'} | ${r.html_url}`;
143+
}
144+
145+
console.log('--- Latest workflow runs for this PR headSHA (one per workflow) ---');
146+
for (const r of latestRuns) console.log('- ' + runKey(r));
147+
148+
// Utility: list all jobs in a run
149+
async function listAllJobs(runId) {
150+
let jobs = [];
151+
let p = 1;
152+
while (true) {
153+
const { data } = await github.rest.actions.listJobsForWorkflowRun({
154+
owner: context.repo.owner,
155+
repo: context.repo.repo,
156+
run_id: runId,
157+
per_page: 100,
158+
page: p
159+
});
160+
const js = data.jobs || [];
161+
if (js.length === 0) break;
162+
jobs.push(...js);
163+
if (js.length < 100) break;
164+
p++;
165+
}
166+
return jobs;
167+
}
168+
169+
// Utility: rerun a single job
170+
async function rerunJob(job, run) {
171+
try {
172+
if (github.rest.actions.reRunJobForWorkflowRun) {
173+
await github.rest.actions.reRunJobForWorkflowRun({
174+
owner: context.repo.owner,
175+
repo: context.repo.repo,
176+
job_id: job.id
177+
});
178+
} else {
179+
await github.request('POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun', {
180+
owner: context.repo.owner,
181+
repo: context.repo.repo,
182+
job_id: job.id
183+
});
184+
}
185+
console.log(`Re-ran job '${job.name}' (job_id=${job.id}) in run '${run.name}' | ${run.html_url}`);
186+
return true;
187+
} catch (e) {
188+
console.log(`Failed to re-run job '${job.name}' (job_id=${job.id}) in run '${run.name}': ${e.message}`);
189+
return false;
190+
}
191+
}
192+
193+
// Command 1: /bkbot rerun
194+
if (sub === 'rerun' && !arg) {
195+
const targetConclusions = new Set(['failure', 'timed_out', 'cancelled', 'skipped']);
196+
let fullRerunCount = 0;
197+
let skippedRunning = 0;
198+
let skippedConclusion = 0;
199+
200+
console.log('Mode: full workflow re-run for completed runs with conclusions in [failure,timed_out,cancelled,skipped].');
201+
for (const r of latestRuns) {
202+
if (r.status !== 'completed') {
203+
console.log(`Skip (still running) ${runKey(r)}. Cannot re-run whole workflow. Consider '/bkbot rerun <jobName>' for single job.`);
204+
skippedRunning++;
205+
continue;
206+
}
207+
if (!targetConclusions.has(r.conclusion)) {
208+
console.log(`Skip (conclusion not eligible) ${runKey(r)}`);
209+
skippedConclusion++;
210+
continue;
211+
}
212+
try {
213+
await github.rest.actions.reRunWorkflow({
214+
owner: context.repo.owner,
215+
repo: context.repo.repo,
216+
run_id: r.id
217+
});
218+
console.log(`Triggered full re-run for ${runKey(r)}`);
219+
fullRerunCount++;
220+
} catch (e) {
221+
console.log(`Failed to trigger full re-run for ${runKey(r)}: ${e.message}`);
222+
}
223+
}
224+
225+
if (fullRerunCount === 0) {
226+
console.error(`No eligible workflow runs to re-run. Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
227+
} else {
228+
console.log(`Finished. Triggered full re-run for ${fullRerunCount} workflow run(s). Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
229+
}
230+
return;
231+
}
232+
233+
// Command 2: /bkbot rerun jobname
234+
if (sub === 'rerun' && arg) {
235+
const keyword = arg.trim();
236+
console.log(`Mode: job-level re-run. keyword='${keyword}'`);
237+
238+
let matchedJobs = 0;
239+
let successJobs = 0;
240+
241+
for (const r of latestRuns) {
242+
let jobs = [];
243+
try {
244+
jobs = await listAllJobs(r.id);
245+
} catch (e) {
246+
console.log(`Failed to list jobs for ${runKey(r)}: ${e.message}`);
247+
continue;
248+
}
249+
for (const j of jobs) {
250+
if (j.name && j.name.includes(keyword)) {
251+
matchedJobs++;
252+
const ok = await rerunJob(j, r);
253+
if (ok) successJobs++;
254+
}
255+
}
256+
}
257+
258+
if (matchedJobs === 0) {
259+
console.error(`No jobs matched keyword '${keyword}' among latest runs for this PR head.`);
260+
} else {
261+
console.log(`Finished. Matched ${matchedJobs} job(s); successfully requested re-run for ${successJobs} job(s).`);
262+
}
263+
return;
264+
}
265+
266+
// Command 3: /bkbot stop or /bkbot cancel
267+
if (sub === 'stop' || sub === 'cancel') {
268+
console.log('Mode: cancel running workflow runs (queued/in_progress).');
269+
270+
let cancelCount = 0;
271+
let alreadyCompleted = 0;
272+
273+
for (const r of latestRuns) {
274+
if (r.status === 'completed') {
275+
console.log(`Skip (already completed) ${runKey(r)}`);
276+
alreadyCompleted++;
277+
continue;
278+
}
279+
try {
280+
await github.rest.actions.cancelWorkflowRun({
281+
owner: context.repo.owner,
282+
repo: context.repo.repo,
283+
run_id: r.id
284+
});
285+
console.log(`Cancel requested for ${runKey(r)}`);
286+
cancelCount++;
287+
} catch (e) {
288+
console.log(`Failed to cancel ${runKey(r)}: ${e.message}`);
289+
}
290+
}
291+
292+
if (cancelCount === 0) {
293+
console.error(`No running workflow runs to cancel. Already completed: ${alreadyCompleted}.`);
294+
} else {
295+
console.log(`Finished. Requested cancel for ${cancelCount} running workflow run(s). Already completed: ${alreadyCompleted}.`);
296+
}
297+
return;
298+
}

0 commit comments

Comments
 (0)