@@ -34,6 +34,233 @@ jobs:
3434 steps :
3535 - name : Execute pulsarbot command
3636 id : pulsarbot
37- env :
37+ uses : actions/github-script@v7
38+ with :
3839 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
39- uses : apache/pulsar-test-infra/pulsarbot@master
40+ 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();
50+ const prefix = '/pulsarbot';
51+ if (!commentBody.startsWith(prefix)) {
52+ console.log('Not a pulsarbot command, skipping ...');
53+ return;
54+ }
55+ if (!context.payload.issue || !context.payload.issue.pull_request) {
56+ console.error('This comment is not on a Pull Request. pulsarbot only works on PRs.');
57+ return;
58+ }
59+ const parts = commentBody.split(/\s+/);
60+ const sub = (parts[1] || '').toLowerCase();
61+ const arg = parts.length > 2 ? parts.slice(2).join(' ') : '';
62+ const supported = ['rerun', 'stop', 'cancel'];
63+ if (!supported.includes(sub)) {
64+ console.log(`Unsupported command '${sub}'. Supported: '/pulsarbot rerun [jobName?]', '/pulsarbot stop', '/pulsarbot cancel'.`);
65+ return;
66+ }
67+ const prNum = context.payload.issue.number;
68+ // Get PR info
69+ let pr;
70+ try {
71+ ({ data: pr } = await github.rest.pulls.get({
72+ owner: context.repo.owner,
73+ repo: context.repo.repo,
74+ pull_number: prNum
75+ }));
76+ } catch (e) {
77+ console.error(`Failed to fetch PR #${prNum}: ${e.message}`);
78+ return;
79+ }
80+ const headSha = pr.head.sha;
81+ const prBranch = pr.head.ref;
82+ const prUser = (pr.head && pr.head.user && pr.head.user.login) ? pr.head.user.login : pr.user.login;
83+ const prUrl = pr.html_url;
84+ console.log(`pulsarbot handling PR #${prNum} ${prUrl}`);
85+ console.log(`PR branch='${prBranch}', headSha='${headSha}', author='${prUser}'`);
86+ 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({
92+ owner: context.repo.owner,
93+ repo: context.repo.repo,
94+ actor: prUser,
95+ branch: prBranch,
96+ 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);
106+ if (runsAtHead.length === 0) {
107+ console.error(`No workflow runs found for head SHA ${headSha} on branch ${prBranch}.`);
108+ return;
109+ }
110+ // Only keep the latest run for each workflow_id
111+ runsAtHead.sort((a, b) => {
112+ if (a.workflow_id !== b.workflow_id) return a.workflow_id - b.workflow_id;
113+ return new Date(b.created_at) - new Date(a.created_at);
114+ });
115+ const latestRuns = [];
116+ const seen = new Set();
117+ for (const r of runsAtHead) {
118+ if (!seen.has(r.workflow_id)) {
119+ seen.add(r.workflow_id);
120+ latestRuns.push(r);
121+ }
122+ }
123+ function runKey(r) {
124+ return `[run_id=${r.id}] ${r.name || '(unnamed)'} | status=${r.status} | conclusion=${r.conclusion || '-'} | ${r.html_url}`;
125+ }
126+ console.log('--- Latest workflow runs for this PR headSHA (one per workflow) ---');
127+ for (const r of latestRuns) console.log('- ' + runKey(r));
128+ // Utility: list all jobs in a run
129+ async function listAllJobs(runId) {
130+ let jobs = [];
131+ let p = 1;
132+ while (true) {
133+ const { data } = await github.rest.actions.listJobsForWorkflowRun({
134+ owner: context.repo.owner,
135+ repo: context.repo.repo,
136+ run_id: runId,
137+ per_page: 100,
138+ page: p
139+ });
140+ const js = data.jobs || [];
141+ if (js.length === 0) break;
142+ jobs.push(...js);
143+ if (js.length < 100) break;
144+ p++;
145+ }
146+ return jobs;
147+ }
148+ // Utility: rerun a single job
149+ async function rerunJob(job, run) {
150+ 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+ }
164+ console.log(`Re-ran job '${job.name}' (job_id=${job.id}) in run '${run.name}' | ${run.html_url}`);
165+ return true;
166+ } catch (e) {
167+ console.log(`Failed to re-run job '${job.name}' (job_id=${job.id}) in run '${run.name}': ${e.message}`);
168+ return false;
169+ }
170+ }
171+ // Command 1: /pulsarbot rerun
172+ if (sub === 'rerun' && !arg) {
173+ const targetConclusions = new Set(['failure', 'timed_out', 'cancelled', 'skipped']);
174+ let fullRerunCount = 0;
175+ let skippedRunning = 0;
176+ let skippedConclusion = 0;
177+ console.log('Mode: full workflow re-run for completed runs with conclusions in [failure,timed_out,cancelled,skipped].');
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.reRunWorkflow({
191+ owner: context.repo.owner,
192+ repo: context.repo.repo,
193+ run_id: r.id
194+ });
195+ console.log(`Triggered full re-run for ${runKey(r)}`);
196+ fullRerunCount++;
197+ } catch (e) {
198+ console.log(`Failed to trigger full re-run for ${runKey(r)}: ${e.message}`);
199+ }
200+ }
201+ if (fullRerunCount === 0) {
202+ console.error(`No eligible workflow runs to re-run. Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
203+ } else {
204+ console.log(`Finished. Triggered full re-run for ${fullRerunCount} workflow run(s). Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`);
205+ }
206+ return;
207+ }
208+ // Command 2: /pulsarbot rerun jobname
209+ if (sub === 'rerun' && arg) {
210+ const keyword = arg.trim();
211+ console.log(`Mode: job-level re-run. keyword='${keyword}'`);
212+ let matchedJobs = 0;
213+ let successJobs = 0;
214+ for (const r of latestRuns) {
215+ let jobs = [];
216+ try {
217+ jobs = await listAllJobs(r.id);
218+ } catch (e) {
219+ console.log(`Failed to list jobs for ${runKey(r)}: ${e.message}`);
220+ continue;
221+ }
222+ for (const j of jobs) {
223+ if (j.name && j.name.includes(keyword)) {
224+ matchedJobs++;
225+ const ok = await rerunJob(j, r);
226+ if (ok) successJobs++;
227+ }
228+ }
229+ }
230+ if (matchedJobs === 0) {
231+ console.error(`No jobs matched keyword '${keyword}' among latest runs for this PR head.`);
232+ } else {
233+ console.log(`Finished. Matched ${matchedJobs} job(s); successfully requested re-run for ${successJobs} job(s).`);
234+ }
235+ return;
236+ }
237+ // Command 3: /pulsarbot stop or /pulsarbot cancel
238+ if (sub === 'stop' || sub === 'cancel') {
239+ console.log('Mode: cancel running workflow runs (queued/in_progress).');
240+ let cancelCount = 0;
241+ let alreadyCompleted = 0;
242+ for (const r of latestRuns) {
243+ if (r.status === 'completed') {
244+ console.log(`Skip (already completed) ${runKey(r)}`);
245+ alreadyCompleted++;
246+ continue;
247+ }
248+ try {
249+ await github.rest.actions.cancelWorkflowRun({
250+ owner: context.repo.owner,
251+ repo: context.repo.repo,
252+ run_id: r.id
253+ });
254+ console.log(`Cancel requested for ${runKey(r)}`);
255+ cancelCount++;
256+ } catch (e) {
257+ console.log(`Failed to cancel ${runKey(r)}: ${e.message}`);
258+ }
259+ }
260+ if (cancelCount === 0) {
261+ console.error(`No running workflow runs to cancel. Already completed: ${alreadyCompleted}.`);
262+ } else {
263+ console.log(`Finished. Requested cancel for ${cancelCount} running workflow run(s). Already completed: ${alreadyCompleted}.`);
264+ }
265+ return;
266+ }
0 commit comments