Skip to content

Commit 161dd0a

Browse files
author
xiangying
committed
PulsarBot
1 parent fefe771 commit 161dd0a

File tree

1 file changed

+229
-2
lines changed

1 file changed

+229
-2
lines changed

.github/workflows/ci-pulsarbot.yaml

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)