2525permissions :
2626 actions : write
2727 contents : read
28+ pull-requests : read
29+ issues : read
2830
2931jobs :
3032 pulsarbot :
@@ -33,7 +35,246 @@ jobs:
3335 if : github.event_name == 'issue_comment' && contains(github.event.comment.body, '/pulsarbot')
3436 steps :
3537 - name : Execute pulsarbot command
36- id : pulsarbot
37- env :
38- GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
39- uses : apache/pulsar-test-infra/pulsarbot@master
38+ uses : actions/github-script@v8
39+ with :
40+ github-token : ${{ secrets.GITHUB_TOKEN }}
41+ script : |
42+ const commentBody = (context.payload.comment?.body || '').trim();
43+ const prefix = '/pulsarbot';
44+
45+ if (!commentBody.startsWith(prefix)) {
46+ console.log('Not a pulsarbot command, skipping ...');
47+ return;
48+ }
49+ if (!context.payload.issue || !context.payload.issue.pull_request) {
50+ console.error('This comment is not on a Pull Request. pulsarbot only works on PRs.');
51+ return;
52+ }
53+
54+ const parts = commentBody.split(/\s+/);
55+ const sub = (parts[1] || '').toLowerCase();
56+ const arg = parts.length > 2 ? parts.slice(2).join(' ') : '';
57+
58+ const supported = ['rerun', 'stop', 'cancel', 'rerun-failure-checks'];
59+ if (!supported.includes(sub)) {
60+ console.log(
61+ `Unsupported command '${sub}'. Supported: ${supported
62+ .map(cmd => `'/pulsarbot ${cmd}${cmd === 'rerun' ? ' [jobName?]' : ''}'`)
63+ .join(', ')}.`
64+ );
65+ return;
66+ }
67+
68+ const prNum = context.payload.issue.number;
69+
70+ // Get PR info
71+ let pr;
72+ try {
73+ ({ data: pr } = await github.rest.pulls.get({
74+ owner: context.repo.owner,
75+ repo: context.repo.repo,
76+ pull_number: prNum,
77+ }));
78+ } catch (e) {
79+ console.error(`Failed to fetch PR #${prNum}: ${e.message}`);
80+ return;
81+ }
82+
83+ const headSha = pr.head.sha;
84+ const prBranch = pr.head.ref;
85+ const prUser = pr.user.login;
86+ const prUrl = pr.html_url;
87+
88+ console.log(`pulsarbot handling PR #${prNum} ${prUrl}`);
89+ console.log(`PR branch='${prBranch}', headSha='${headSha}', author='${prUser}'`);
90+ console.log(`Command parsed => sub='${sub}', arg='${arg || ''}'`);
91+
92+ // Most reliable: list workflow runs by head_sha (no guessing by actor/branch/event)
93+ const runsAtHead = await github.paginate(
94+ github.rest.actions.listWorkflowRunsForRepo,
95+ {
96+ owner: context.repo.owner,
97+ repo: context.repo.repo,
98+ head_sha: headSha,
99+ per_page: 100,
100+ },
101+ );
102+ console.log(`runsAtHead total=${runsAtHead.length} for head_sha=${headSha}`);
103+
104+ if (runsAtHead.length === 0) {
105+ console.error(`No workflow runs found for head SHA ${headSha} (PR branch ${prBranch}).`);
106+ return;
107+ }
108+
109+ // Only keep the latest run for each workflow_id
110+ runsAtHead.sort((a, b) => {
111+ const aw = String(a.workflow_id);
112+ const bw = String(b.workflow_id);
113+ if (aw !== bw) {
114+ if (aw.length < bw.length) return -1;
115+ if (aw.length > bw.length) return 1;
116+ return aw < bw ? -1 : 1;
117+ }
118+ const at = new Date(a.created_at).getTime();
119+ const bt = new Date(b.created_at).getTime();
120+ if (bt > at) return 1;
121+ if (bt < at) return -1;
122+ return 0;
123+ });
124+
125+ const latestRuns = [];
126+ const seen = new Set();
127+ for (const r of runsAtHead) {
128+ if (!seen.has(r.workflow_id)) {
129+ seen.add(r.workflow_id);
130+ latestRuns.push(r);
131+ }
132+ }
133+
134+ function runKey(r) {
135+ return `[run_id=${r.id}] ${r.name || '(unnamed)'} | status=${r.status} | conclusion=${r.conclusion || '-'} | ${r.html_url}`;
136+ }
137+
138+ console.log('--- Latest workflow runs for this PR headSHA (one per workflow) ---');
139+ for (const r of latestRuns) console.log('- ' + runKey(r));
140+
141+ async function listAllJobs(runId) {
142+ const jobs = await github.paginate(
143+ github.rest.actions.listJobsForWorkflowRun,
144+ {
145+ owner: context.repo.owner,
146+ repo: context.repo.repo,
147+ run_id: runId,
148+ per_page: 100,
149+ },
150+ );
151+ return jobs;
152+ }
153+
154+ async function rerunJob(job, run) {
155+ try {
156+ await github.rest.actions.reRunJobForWorkflowRun({
157+ owner: context.repo.owner,
158+ repo: context.repo.repo,
159+ job_id: job.id,
160+ });
161+ console.log(`Re-ran job '${job.name}' (job_id=${job.id}) in run '${run.name}' | ${run.html_url}`);
162+ return true;
163+ } catch (e) {
164+ console.log(`Failed to re-run job '${job.name}' (job_id=${job.id}) in run '${run.name}': ${e.message}`);
165+ return false;
166+ }
167+ }
168+
169+ // Command 1: /pulsarbot rerun (or rerun-failure-checks)
170+ if ((sub === 'rerun' || sub === 'rerun-failure-checks') && !arg) {
171+ const targetConclusions = new Set(['failure', 'timed_out', 'cancelled', 'skipped']);
172+ let rerunCount = 0;
173+ let skippedRunning = 0;
174+ let skippedConclusion = 0;
175+
176+ console.log('Mode: workflow re-run for completed runs with conclusions in [failure,timed_out,cancelled,skipped].');
177+
178+ for (const r of latestRuns) {
179+ if (r.status !== 'completed') {
180+ console.log(`Skip (still running) ${runKey(r)}. Cannot re-run whole workflow. Consider '/pulsarbot rerun <jobName>' for single job.`);
181+ skippedRunning++;
182+ continue;
183+ }
184+ if (!targetConclusions.has(r.conclusion)) {
185+ console.log(`Skip (conclusion not eligible) ${runKey(r)}`);
186+ skippedConclusion++;
187+ continue;
188+ }
189+ try {
190+ await github.rest.actions.reRunWorkflowFailedJobs({
191+ owner: context.repo.owner,
192+ repo: context.repo.repo,
193+ run_id: r.id,
194+ });
195+ console.log(`Triggered re-run for ${runKey(r)}`);
196+ rerunCount++;
197+ } catch (e) {
198+ console.log(`Failed to trigger re-run for ${runKey(r)}: ${e.message}`);
199+ }
200+ }
201+
202+ if (rerunCount === 0) {
203+ console.error(`No eligible workflow runs to re-run. Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
204+ } else {
205+ console.log(`Finished. Triggered re-run for ${rerunCount} workflow run(s). Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
206+ }
207+ return;
208+ }
209+
210+ // Command 2: /pulsarbot rerun jobname
211+ if (sub === 'rerun' && arg) {
212+ const keyword = arg.trim();
213+ console.log(`Mode: job-level re-run. keyword='${keyword}'`);
214+
215+ let matchedJobs = 0;
216+ let successJobs = 0;
217+
218+ for (const r of latestRuns) {
219+ console.log(`Inspecting jobs in run for workflow '${r.name}' | ${r.html_url}`);
220+
221+ let jobs = [];
222+ try {
223+ jobs = await listAllJobs(r.id);
224+ } catch (e) {
225+ console.log(`Failed to list jobs for ${runKey(r)}: ${e.message}`);
226+ continue;
227+ }
228+
229+ for (const j of jobs) {
230+ console.log(`Inspecting job '${j.name}' (job_id=${j.id})`);
231+ if (j.name && j.name.includes(keyword)) {
232+ matchedJobs++;
233+ console.log(`Matched job '${j.name}'. Rerunning...`);
234+ const ok = await rerunJob(j, r);
235+ if (ok) successJobs++;
236+ }
237+ }
238+ }
239+
240+ if (matchedJobs === 0) {
241+ console.error(`No jobs matched keyword '${keyword}' among latest runs for this PR head.`);
242+ } else {
243+ console.log(`Finished. Matched ${matchedJobs} job(s); successfully requested re-run for ${successJobs} job(s).`);
244+ }
245+ return;
246+ }
247+
248+ // Command 3 : /pulsarbot stop or /pulsarbot cancel
249+ if (sub === 'stop' || sub === 'cancel') {
250+ console.log('Mode : cancel running workflow runs (queued/in_progress).');
251+
252+ let cancelCount = 0;
253+ let alreadyCompleted = 0;
254+
255+ for (const r of latestRuns) {
256+ if (r.status === 'completed') {
257+ console.log(`Skip (already completed) ${runKey(r)}`);
258+ alreadyCompleted++;
259+ continue;
260+ }
261+ try {
262+ await github.rest.actions.cancelWorkflowRun({
263+ owner : context.repo.owner,
264+ repo : context.repo.repo,
265+ run_id : r.id,
266+ });
267+ console.log(`Cancel requested for ${runKey(r)}`);
268+ cancelCount++;
269+ } catch (e) {
270+ console.log(`Failed to cancel ${runKey(r)} : ${e.message}`);
271+ }
272+ }
273+
274+ if (cancelCount === 0) {
275+ console.error(`No running workflow runs to cancel. Already completed : ${alreadyCompleted}.`);
276+ } else {
277+ console.log(`Finished. Requested cancel for ${cancelCount} running workflow run(s). Already completed : ${alreadyCompleted}.`);
278+ }
279+ return;
280+ }
0 commit comments