Skip to content

Commit 4346165

Browse files
liangyepianzhouxiangyinglhotari
authored
[improve][ci] update pulsarbot to use github-script based implementation (apache#24940)
Co-authored-by: xiangying <mengxiangying@xiaohongshu.com> Co-authored-by: Lari Hotari <lhotari@apache.org>
1 parent a373258 commit 4346165

1 file changed

Lines changed: 245 additions & 4 deletions

File tree

.github/workflows/ci-pulsarbot.yaml

Lines changed: 245 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ on:
2525
permissions:
2626
actions: write
2727
contents: read
28+
pull-requests: read
29+
issues: read
2830

2931
jobs:
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

Comments
 (0)