1- #
2- # Licensed to the Apache Software Foundation (ASF) under one
3- # or more contributor license agreements. See the NOTICE file
4- # distributed with this work for additional information
5- # regarding copyright ownership. The ASF licenses this file
6- # to you under the Apache License, Version 2.0 (the
7- # "License"); you may not use this file except in compliance
8- # with the License. You may obtain a copy of the License at
9- #
10- # http://www.apache.org/licenses/LICENSE-2.0
11- #
12- # Unless required by applicable law or agreed to in writing,
13- # software distributed under the License is distributed on an
14- # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15- # KIND, either express or implied. See the License for the
16- # specific language governing permissions and limitations
17- # under the License.
18- #
19-
201name : Pulsar Bot
212on :
223 issue_comment :
256permissions :
267 actions : write
278 contents : read
9+ pull-requests : read
10+ issues : read
2811
2912jobs :
3013 pulsarbot :
@@ -33,21 +16,13 @@ jobs:
3316 if : github.event_name == 'issue_comment' && contains(github.event.comment.body, '/pulsarbot')
3417 steps :
3518 - name : Execute pulsarbot command
36- id : pulsarbot
37- uses : actions/github-script@v7
19+ uses : actions/github-script@v8
3820 with :
3921 github-token : ${{ secrets.GITHUB_TOKEN }}
4022 script : |
41- // Supported commands:
42- // - /pulsarbot rerun
43- // Reruns all completed workflows with conclusions of failure/timed_out/skipped/cancelled
44- // If workflow is still running, cannot rerun whole workflow, just suggest using "/pulsarbot rerun jobname"
45- // - /pulsarbot rerun jobname
46- // Matches job.name by keyword, reruns matching jobs (regardless of current state, failures are logged)
47- // - /pulsarbot stop or /pulsarbot cancel
48- // Cancels all still running (queued/in_progress) workflow runs associated with the current PR
49- const commentBody = context.payload.comment.body.trim();
23+ const commentBody = (context.payload.comment?.body || '').trim();
5024 const prefix = '/pulsarbot';
25+
5126 if (!commentBody.startsWith(prefix)) {
5227 console.log('Not a pulsarbot command, skipping ...');
5328 return;
@@ -56,62 +31,71 @@ jobs:
5631 console.error('This comment is not on a Pull Request. pulsarbot only works on PRs.');
5732 return;
5833 }
34+
5935 const parts = commentBody.split(/\s+/);
6036 const sub = (parts[1] || '').toLowerCase();
6137 const arg = parts.length > 2 ? parts.slice(2).join(' ') : '';
38+
6239 const supported = ['rerun', 'stop', 'cancel', 'rerun-failure-checks'];
6340 if (!supported.includes(sub)) {
64- console.log(`Unsupported command '${sub}'. Supported: '/pulsarbot rerun [jobName?]', '/pulsarbot stop', '/pulsarbot cancel'.`);
41+ console.log(
42+ `Unsupported command '${sub}'. Supported: ${supported
43+ .map(cmd => `'/pulsarbot ${cmd}${cmd === 'rerun' ? ' [jobName?]' : ''}'`)
44+ .join(', ')}.`
45+ );
6546 return;
6647 }
48+
6749 const prNum = context.payload.issue.number;
50+
6851 // Get PR info
6952 let pr;
7053 try {
7154 ({ data: pr } = await github.rest.pulls.get({
7255 owner: context.repo.owner,
7356 repo: context.repo.repo,
74- pull_number: prNum
57+ pull_number: prNum,
7558 }));
7659 } catch (e) {
7760 console.error(`Failed to fetch PR #${prNum}: ${e.message}`);
7861 return;
7962 }
63+
8064 const headSha = pr.head.sha;
8165 const prBranch = pr.head.ref;
8266 const prUser = pr.user.login;
8367 const prUrl = pr.html_url;
68+
8469 console.log(`pulsarbot handling PR #${prNum} ${prUrl}`);
8570 console.log(`PR branch='${prBranch}', headSha='${headSha}', author='${prUser}'`);
8671 console.log(`Command parsed => sub='${sub}', arg='${arg || ''}'`);
87- // Fetch workflow runs in this repo triggered by this user on this branch, then filter by headSha
88- let page = 1;
89- const allRunsRaw = [];
90- while (true) {
91- const { data } = await github.rest.actions.listWorkflowRunsForRepo( {
72+
73+ // Most reliable: list workflow runs by head_sha (no guessing by actor/branch/event)
74+ const runsAtHeadRaw = await github.paginate(
75+ github.rest.actions.listWorkflowRunsForRepo,
76+ {
9277 owner: context.repo.owner,
9378 repo: context.repo.repo,
94- actor: prUser,
95- branch: prBranch,
79+ head_sha: headSha,
9680 per_page: 100,
97- page
98- });
99- const wr = data.workflow_runs || [];
100- if (wr.length === 0) break;
101- allRunsRaw.push(...wr);
102- if (wr.length < 100) break;
103- page++;
104- }
105- const runsAtHead = allRunsRaw.filter(r => r.head_sha === headSha);
81+ },
82+ );
83+ console.log(`DEBUG runs for head_sha=${headSha}: total_count=${runsAtHeadRaw.total_count}, returned=${(runsAtHeadRaw.workflow_runs||[]).length}`);
84+ const runsAtHead = runsAtHeadRaw.filter(r => r && typeof r === 'object');
85+
86+ console.log(`runsAtHead total=${runsAtHead.length} for head_sha=${headSha}`);
87+
10688 if (runsAtHead.length === 0) {
107- console.error(`No workflow runs found for head SHA ${headSha} on branch ${prBranch}.`);
89+ console.error(`No workflow runs found for head SHA ${headSha} (PR branch ${prBranch}) .`);
10890 return;
10991 }
92+
11093 // Only keep the latest run for each workflow_id
11194 runsAtHead.sort((a, b) => {
11295 if (a.workflow_id !== b.workflow_id) return a.workflow_id - b.workflow_id;
11396 return new Date(b.created_at) - new Date(a.created_at);
11497 });
98+
11599 const latestRuns = [];
116100 const seen = new Set();
117101 for (const r of runsAtHead) {
@@ -120,22 +104,24 @@ jobs:
120104 latestRuns.push(r);
121105 }
122106 }
107+
123108 function runKey(r) {
124109 return `[run_id=${r.id}] ${r.name || '(unnamed)'} | status=${r.status} | conclusion=${r.conclusion || '-'} | ${r.html_url}`;
125110 }
111+
126112 console.log('--- Latest workflow runs for this PR headSHA (one per workflow) ---');
127113 for (const r of latestRuns) console.log('- ' + runKey(r));
128- // Utility: list all jobs in a run
114+
129115 async function listAllJobs(runId) {
130- let jobs = [];
116+ const jobs = [];
131117 let p = 1;
132118 while (true) {
133119 const { data } = await github.rest.actions.listJobsForWorkflowRun({
134120 owner: context.repo.owner,
135121 repo: context.repo.repo,
136122 run_id: runId,
137123 per_page: 100,
138- page: p
124+ page: p,
139125 });
140126 const js = data.jobs || [];
141127 if (js.length === 0) break;
@@ -145,36 +131,31 @@ jobs:
145131 }
146132 return jobs;
147133 }
148- // Utility: rerun a single job
134+
149135 async function rerunJob(job, run) {
150136 try {
151- if (github.rest.actions.reRunJobForWorkflowRun) {
152- await github.rest.actions.reRunJobForWorkflowRun({
153- owner: context.repo.owner,
154- repo: context.repo.repo,
155- job_id: job.id
156- });
157- } else {
158- await github.request('POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun', {
159- owner: context.repo.owner,
160- repo: context.repo.repo,
161- job_id: job.id
162- });
163- }
137+ await github.rest.actions.reRunJobForWorkflowRun({
138+ owner: context.repo.owner,
139+ repo: context.repo.repo,
140+ job_id: job.id,
141+ });
164142 console.log(`Re-ran job '${job.name}' (job_id=${job.id}) in run '${run.name}' | ${run.html_url}`);
165143 return true;
166144 } catch (e) {
167145 console.log(`Failed to re-run job '${job.name}' (job_id=${job.id}) in run '${run.name}': ${e.message}`);
168146 return false;
169147 }
170148 }
171- // Command 1: /pulsarbot rerun
149+
150+ // Command 1: /pulsarbot rerun (or rerun-failure-checks)
172151 if ((sub === 'rerun' || sub === 'rerun-failure-checks') && !arg) {
173152 const targetConclusions = new Set(['failure', 'timed_out', 'cancelled', 'skipped']);
174- let fullRerunCount = 0;
153+ let rerunCount = 0;
175154 let skippedRunning = 0;
176155 let skippedConclusion = 0;
177- console.log('Mode: full workflow re-run for completed runs with conclusions in [failure,timed_out,cancelled,skipped].');
156+
157+ console.log('Mode: workflow re-run for completed runs with conclusions in [failure,timed_out,cancelled,skipped].');
158+
178159 for (const r of latestRuns) {
179160 if (r.status !== 'completed') {
180161 console.log(`Skip (still running) ${runKey(r)}. Cannot re-run whole workflow. Consider '/pulsarbot rerun <jobName>' for single job.`);
@@ -187,30 +168,34 @@ jobs:
187168 continue;
188169 }
189170 try {
190- await github.rest.actions.reRunWorkflow ({
171+ await github.rest.actions.reRunWorkflowFailedJobs ({
191172 owner: context.repo.owner,
192173 repo: context.repo.repo,
193- run_id: r.id
174+ run_id: r.id,
194175 });
195- console.log(`Triggered full re-run for ${runKey(r)}`);
196- fullRerunCount ++;
176+ console.log(`Triggered re-run for ${runKey(r)}`);
177+ rerunCount ++;
197178 } catch (e) {
198- console.log(`Failed to trigger full re-run for ${runKey(r)}: ${e.message}`);
179+ console.log(`Failed to trigger re-run for ${runKey(r)}: ${e.message}`);
199180 }
200181 }
201- if (fullRerunCount === 0) {
182+
183+ if (rerunCount === 0) {
202184 console.error(`No eligible workflow runs to re-run. Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
203185 } else {
204- console.log(`Finished. Triggered full re-run for ${fullRerunCount } workflow run(s). Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
186+ console.log(`Finished. Triggered re-run for ${rerunCount } workflow run(s). Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
205187 }
206188 return;
207189 }
190+
208191 // Command 2: /pulsarbot rerun jobname
209192 if (sub === 'rerun' && arg) {
210193 const keyword = arg.trim();
211194 console.log(`Mode: job-level re-run. keyword='${keyword}'`);
195+
212196 let matchedJobs = 0;
213197 let successJobs = 0;
198+
214199 for (const r of latestRuns) {
215200 let jobs = [];
216201 try {
@@ -219,6 +204,7 @@ jobs:
219204 console.log(`Failed to list jobs for ${runKey(r)}: ${e.message}`);
220205 continue;
221206 }
207+
222208 for (const j of jobs) {
223209 if (j.name && j.name.includes(keyword)) {
224210 matchedJobs++;
@@ -227,18 +213,22 @@ jobs:
227213 }
228214 }
229215 }
216+
230217 if (matchedJobs === 0) {
231218 console.error(`No jobs matched keyword '${keyword}' among latest runs for this PR head.`);
232219 } else {
233220 console.log(`Finished. Matched ${matchedJobs} job(s); successfully requested re-run for ${successJobs} job(s).`);
234221 }
235222 return;
236223 }
224+
237225 // Command 3: /pulsarbot stop or /pulsarbot cancel
238226 if (sub === 'stop' || sub === 'cancel') {
239227 console.log('Mode: cancel running workflow runs (queued/in_progress).');
228+
240229 let cancelCount = 0;
241230 let alreadyCompleted = 0;
231+
242232 for (const r of latestRuns) {
243233 if (r.status === 'completed') {
244234 console.log(`Skip (already completed) ${runKey(r)}`);
@@ -249,18 +239,19 @@ jobs:
249239 await github.rest.actions.cancelWorkflowRun({
250240 owner: context.repo.owner,
251241 repo: context.repo.repo,
252- run_id: r.id
242+ run_id: r.id,
253243 });
254244 console.log(`Cancel requested for ${runKey(r)}`);
255245 cancelCount++;
256246 } catch (e) {
257247 console.log(`Failed to cancel ${runKey(r)}: ${e.message}`);
258248 }
259249 }
250+
260251 if (cancelCount === 0) {
261252 console.error(`No running workflow runs to cancel. Already completed: ${alreadyCompleted}.`);
262253 } else {
263254 console.log(`Finished. Requested cancel for ${cancelCount} running workflow run(s). Already completed: ${alreadyCompleted}.`);
264255 }
265256 return;
266- }
257+ }
0 commit comments