diff --git a/.github/workflows/ci-pulsarbot.yaml b/.github/workflows/ci-pulsarbot.yaml index 5052eb41f1f91..031cea76700cc 100644 --- a/.github/workflows/ci-pulsarbot.yaml +++ b/.github/workflows/ci-pulsarbot.yaml @@ -34,6 +34,233 @@ jobs: steps: - name: Execute pulsarbot command id: pulsarbot - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: apache/pulsar-test-infra/pulsarbot@master + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Supported commands: + // - /pulsarbot rerun + // Reruns all completed workflows with conclusions of failure/timed_out/skipped/cancelled + // If workflow is still running, cannot rerun whole workflow, just suggest using "/pulsarbot rerun jobname" + // - /pulsarbot rerun jobname + // Matches job.name by keyword, reruns matching jobs (regardless of current state, failures are logged) + // - /pulsarbot stop or /pulsarbot cancel + // Cancels all still running (queued/in_progress) workflow runs associated with the current PR + const commentBody = context.payload.comment.body.trim(); + const prefix = '/pulsarbot'; + if (!commentBody.startsWith(prefix)) { + console.log('Not a pulsarbot command, skipping ...'); + return; + } + if (!context.payload.issue || !context.payload.issue.pull_request) { + console.error('This comment is not on a Pull Request. pulsarbot only works on PRs.'); + return; + } + const parts = commentBody.split(/\s+/); + const sub = (parts[1] || '').toLowerCase(); + const arg = parts.length > 2 ? parts.slice(2).join(' ') : ''; + const supported = ['rerun', 'stop', 'cancel', 'rerun-failure-checks']; + if (!supported.includes(sub)) { + console.log(`Unsupported command '${sub}'. Supported: '/pulsarbot rerun [jobName?]', '/pulsarbot stop', '/pulsarbot cancel'.`); + return; + } + const prNum = context.payload.issue.number; + // Get PR info + let pr; + try { + ({ data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNum + })); + } catch (e) { + console.error(`Failed to fetch PR #${prNum}: ${e.message}`); + return; + } + const headSha = pr.head.sha; + const prBranch = pr.head.ref; + const prUser = pr.user.login; + const prUrl = pr.html_url; + console.log(`pulsarbot handling PR #${prNum} ${prUrl}`); + console.log(`PR branch='${prBranch}', headSha='${headSha}', author='${prUser}'`); + console.log(`Command parsed => sub='${sub}', arg='${arg || ''}'`); + // Fetch workflow runs in this repo triggered by this user on this branch, then filter by headSha + let page = 1; + const allRunsRaw = []; + while (true) { + const { data } = await github.rest.actions.listWorkflowRunsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + actor: prUser, + branch: prBranch, + per_page: 100, + page + }); + const wr = data.workflow_runs || []; + if (wr.length === 0) break; + allRunsRaw.push(...wr); + if (wr.length < 100) break; + page++; + } + const runsAtHead = allRunsRaw.filter(r => r.head_sha === headSha); + if (runsAtHead.length === 0) { + console.error(`No workflow runs found for head SHA ${headSha} on branch ${prBranch}.`); + return; + } + // Only keep the latest run for each workflow_id + runsAtHead.sort((a, b) => { + if (a.workflow_id !== b.workflow_id) return a.workflow_id - b.workflow_id; + return new Date(b.created_at) - new Date(a.created_at); + }); + const latestRuns = []; + const seen = new Set(); + for (const r of runsAtHead) { + if (!seen.has(r.workflow_id)) { + seen.add(r.workflow_id); + latestRuns.push(r); + } + } + function runKey(r) { + return `[run_id=${r.id}] ${r.name || '(unnamed)'} | status=${r.status} | conclusion=${r.conclusion || '-'} | ${r.html_url}`; + } + console.log('--- Latest workflow runs for this PR headSHA (one per workflow) ---'); + for (const r of latestRuns) console.log('- ' + runKey(r)); + // Utility: list all jobs in a run + async function listAllJobs(runId) { + let jobs = []; + let p = 1; + while (true) { + const { data } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + per_page: 100, + page: p + }); + const js = data.jobs || []; + if (js.length === 0) break; + jobs.push(...js); + if (js.length < 100) break; + p++; + } + return jobs; + } + // Utility: rerun a single job + async function rerunJob(job, run) { + try { + if (github.rest.actions.reRunJobForWorkflowRun) { + await github.rest.actions.reRunJobForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + job_id: job.id + }); + } else { + await github.request('POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun', { + owner: context.repo.owner, + repo: context.repo.repo, + job_id: job.id + }); + } + console.log(`Re-ran job '${job.name}' (job_id=${job.id}) in run '${run.name}' | ${run.html_url}`); + return true; + } catch (e) { + console.log(`Failed to re-run job '${job.name}' (job_id=${job.id}) in run '${run.name}': ${e.message}`); + return false; + } + } + // Command 1: /pulsarbot rerun + if ((sub === 'rerun' || sub === 'rerun-failure-checks') && !arg) { + const targetConclusions = new Set(['failure', 'timed_out', 'cancelled', 'skipped']); + let fullRerunCount = 0; + let skippedRunning = 0; + let skippedConclusion = 0; + console.log('Mode: full workflow re-run for completed runs with conclusions in [failure,timed_out,cancelled,skipped].'); + for (const r of latestRuns) { + if (r.status !== 'completed') { + console.log(`Skip (still running) ${runKey(r)}. Cannot re-run whole workflow. Consider '/pulsarbot rerun ' for single job.`); + skippedRunning++; + continue; + } + if (!targetConclusions.has(r.conclusion)) { + console.log(`Skip (conclusion not eligible) ${runKey(r)}`); + skippedConclusion++; + continue; + } + try { + await github.rest.actions.reRunWorkflow({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: r.id + }); + console.log(`Triggered full re-run for ${runKey(r)}`); + fullRerunCount++; + } catch (e) { + console.log(`Failed to trigger full re-run for ${runKey(r)}: ${e.message}`); + } + } + if (fullRerunCount === 0) { + console.error(`No eligible workflow runs to re-run. Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`); + } else { + console.log(`Finished. Triggered full re-run for ${fullRerunCount} workflow run(s). Skipped running=${skippedRunning}, skipped by conclusion=${skippedConclusion}.`); + } + return; + } + // Command 2: /pulsarbot rerun jobname + if (sub === 'rerun' && arg) { + const keyword = arg.trim(); + console.log(`Mode: job-level re-run. keyword='${keyword}'`); + let matchedJobs = 0; + let successJobs = 0; + for (const r of latestRuns) { + let jobs = []; + try { + jobs = await listAllJobs(r.id); + } catch (e) { + console.log(`Failed to list jobs for ${runKey(r)}: ${e.message}`); + continue; + } + for (const j of jobs) { + if (j.name && j.name.includes(keyword)) { + matchedJobs++; + const ok = await rerunJob(j, r); + if (ok) successJobs++; + } + } + } + if (matchedJobs === 0) { + console.error(`No jobs matched keyword '${keyword}' among latest runs for this PR head.`); + } else { + console.log(`Finished. Matched ${matchedJobs} job(s); successfully requested re-run for ${successJobs} job(s).`); + } + return; + } + // Command 3: /pulsarbot stop or /pulsarbot cancel + if (sub === 'stop' || sub === 'cancel') { + console.log('Mode: cancel running workflow runs (queued/in_progress).'); + let cancelCount = 0; + let alreadyCompleted = 0; + for (const r of latestRuns) { + if (r.status === 'completed') { + console.log(`Skip (already completed) ${runKey(r)}`); + alreadyCompleted++; + continue; + } + try { + await github.rest.actions.cancelWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: r.id + }); + console.log(`Cancel requested for ${runKey(r)}`); + cancelCount++; + } catch (e) { + console.log(`Failed to cancel ${runKey(r)}: ${e.message}`); + } + } + if (cancelCount === 0) { + console.error(`No running workflow runs to cancel. Already completed: ${alreadyCompleted}.`); + } else { + console.log(`Finished. Requested cancel for ${cancelCount} running workflow run(s). Already completed: ${alreadyCompleted}.`); + } + return; + } \ No newline at end of file