From 5a5a560f589f1dc0354d4388c8b84baeb381a296 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 4 Jun 2026 15:39:37 -0400 Subject: [PATCH 1/8] Add CLA Assistant retry workflow --- .github/workflows/retry-cla-assistant.yml | 55 ++++ Cargo.lock | 1 + tools/ci/Cargo.toml | 3 +- tools/ci/src/main.rs | 7 + tools/ci/src/retry_cla_assistant.rs | 354 ++++++++++++++++++++++ 5 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/retry-cla-assistant.yml create mode 100644 tools/ci/src/retry_cla_assistant.rs diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml new file mode 100644 index 00000000000..76036e7dd3d --- /dev/null +++ b/.github/workflows/retry-cla-assistant.yml @@ -0,0 +1,55 @@ +name: Retry CLA Assistant + +# CLA Assistant publishes `license/cla` as a commit status, not a check run. +# If its webhook handler misses a PR update, GitHub branch protection can wait +# forever even after every real CI check has passed. This workflow nudges CLA +# Assistant only when that status is the sole remaining non-green signal. +# +# SECURITY: This workflow uses pull_request_target so it can inspect PR status +# for forks, but it must never check out, build, or execute code from the PR. + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + schedule: + - cron: "7,22,37,52 * * * *" + workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to check" + required: true + type: number + +permissions: + actions: read + checks: read + contents: read + pull-requests: read + statuses: read + +concurrency: + group: retry-cla-assistant-${{ github.event.pull_request.number || github.event.inputs.pr_number || github.run_id }} + cancel-in-progress: false + +jobs: + retry-cla: + name: Retry CLA Assistant if it is the only blocker + runs-on: ubuntu-latest + + steps: + - name: Check out trusted base code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - uses: dsherret/rust-toolchain-file@v1 + + - uses: Swatinem/rust-cache@v2 + with: + cache-all-crates: true + + - name: Recheck CLA Assistant + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + run: cargo ci retry-cla-assistant diff --git a/Cargo.lock b/Cargo.lock index 3d23f530b1e..389abf7ca16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -900,6 +900,7 @@ dependencies = [ "log", "regex", "reqwest 0.12.24", + "serde", "serde_json", "spacetimedb-guard", "tempfile", diff --git a/tools/ci/Cargo.toml b/tools/ci/Cargo.toml index 972a0bf9d61..deb9b7f4ee7 100644 --- a/tools/ci/Cargo.toml +++ b/tools/ci/Cargo.toml @@ -9,7 +9,8 @@ anyhow.workspace = true chrono = { workspace = true, features=["clock"] } clap.workspace = true regex.workspace = true -reqwest = { workspace = true, features = ["blocking"] } +reqwest = { workspace = true, features = ["blocking", "json"] } +serde.workspace = true serde_json.workspace = true duct.workspace = true tempfile.workspace = true diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index eb27190cbb0..2276be04c9f 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -14,6 +14,7 @@ const README_PATH: &str = "tools/ci/README.md"; mod ci_docs; mod keynote_bench; +mod retry_cla_assistant; mod smoketest; mod util; @@ -367,6 +368,8 @@ enum CiCmd { VersionUpgradeCheck, /// Builds the docs site. Docs, + /// Retries CLA Assistant if `license/cla` is the only remaining PR blocker. + RetryClaAssistant(retry_cla_assistant::RetryClaAssistantArgs), } fn run_all_clap_subcommands(skips: &[String]) -> Result<()> { @@ -772,6 +775,10 @@ fn main() -> Result<()> { run_docs_build()?; } + Some(CiCmd::RetryClaAssistant(args)) => { + retry_cla_assistant::run(args)?; + } + None => run_all_clap_subcommands(&cli.skip)?, } diff --git a/tools/ci/src/retry_cla_assistant.rs b/tools/ci/src/retry_cla_assistant.rs new file mode 100644 index 00000000000..7733d6aab33 --- /dev/null +++ b/tools/ci/src/retry_cla_assistant.rs @@ -0,0 +1,354 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use clap::Args; +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::Value; + +const CLA_CONTEXT: &str = "license/cla"; +const MIN_HEAD_AGE: Duration = Duration::from_secs(10 * 60); +const POLL_ATTEMPTS: usize = 6; +const POLL_DELAY: Duration = Duration::from_secs(30); + +#[derive(Args)] +pub(crate) struct RetryClaAssistantArgs { + /// Pull request number. If omitted, GitHub Actions event payloads are used. + #[arg(long)] + pub(crate) pr_number: Option, + + /// Repository in `owner/name` form. Defaults to GITHUB_REPOSITORY. + #[arg(long)] + pub(crate) repo: Option, +} + +pub(crate) fn run(args: RetryClaAssistantArgs) -> Result<()> { + let repo = args + .repo + .or_else(|| env::var("GITHUB_REPOSITORY").ok()) + .context("repo is required via --repo or GITHUB_REPOSITORY")?; + let (owner, repo_name) = repo + .split_once('/') + .ok_or_else(|| anyhow!("repo must be in owner/name form, got {repo:?}"))?; + let token = env::var("GITHUB_TOKEN").context("GITHUB_TOKEN is required")?; + let client = GithubClient::new(token)?; + + let pr_numbers = candidate_pull_requests(&client, owner, repo_name, args.pr_number)?; + if pr_numbers.is_empty() { + println!("No pull request associated with this event; skipping."); + return Ok(()); + } + + for pr_number in pr_numbers { + retry_for_pr(&client, owner, repo_name, pr_number)?; + } + + Ok(()) +} + +fn candidate_pull_requests(client: &GithubClient, owner: &str, repo: &str, pr_number: Option) -> Result> { + if let Some(pr_number) = pr_number { + return Ok(vec![pr_number]); + } + + if let Ok(input_pr_number) = env::var("INPUT_PR_NUMBER") { + if !input_pr_number.trim().is_empty() { + return Ok(vec![input_pr_number.parse().with_context(|| { + format!("INPUT_PR_NUMBER must be numeric, got {input_pr_number:?}") + })?]); + } + } + + let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or_default(); + if event_name == "schedule" { + return client.list_open_pull_requests(owner, repo); + } + + let event_path = match env::var("GITHUB_EVENT_PATH") { + Ok(path) => path, + Err(_) => return Ok(Vec::new()), + }; + let event: Value = serde_json::from_str( + &fs::read_to_string(&event_path) + .with_context(|| format!("failed to read GitHub event payload {event_path}"))?, + )?; + + let mut pr_numbers = BTreeSet::new(); + match event_name.as_str() { + "pull_request" | "pull_request_target" => { + if let Some(number) = event.pointer("/pull_request/number").and_then(Value::as_u64) { + pr_numbers.insert(number); + } + } + "workflow_dispatch" => {} + other => println!("Unsupported event {other}; skipping."), + } + + Ok(pr_numbers.into_iter().collect()) +} + +fn retry_for_pr(client: &GithubClient, owner: &str, repo: &str, pr_number: u64) -> Result<()> { + println!("Inspecting PR #{pr_number}"); + + let pr: PullRequest = client.github_get(&format!("/repos/{owner}/{repo}/pulls/{pr_number}"))?; + if pr.state != "open" { + println!("PR #{pr_number} is {}; skipping.", pr.state); + return Ok(()); + } + if pr.draft { + println!("PR #{pr_number} is draft; skipping."); + return Ok(()); + } + if pr.base.ref_name != "master" { + println!("PR #{pr_number} targets {}, not master; skipping.", pr.base.ref_name); + return Ok(()); + } + + let sha = pr.head.sha; + let commit: CommitResponse = client.github_get(&format!("/repos/{owner}/{repo}/commits/{sha}"))?; + let committed_at = commit + .commit + .committer + .date + .or(commit.commit.author.date) + .context("commit payload did not contain an author or committer date")?; + let committed_at = DateTime::parse_from_rfc3339(&committed_at) + .context("commit date was not RFC3339")? + .with_timezone(&Utc); + let head_age = Utc::now() + .signed_duration_since(committed_at) + .to_std() + .unwrap_or_default(); + if head_age < MIN_HEAD_AGE { + println!("PR #{pr_number} head is too new ({}s); skipping.", head_age.as_secs()); + return Ok(()); + } + + let check_runs = client.list_check_runs(owner, repo, &sha)?; + let statuses = client.list_statuses(owner, repo, &sha)?; + + let latest_statuses = latest_status_by_context(statuses); + if latest_statuses + .get(CLA_CONTEXT) + .is_some_and(|status| status.state == "success") + { + println!("PR #{pr_number} already has {CLA_CONTEXT}=success."); + return Ok(()); + } + + if check_runs.is_empty() { + println!("PR #{pr_number} has no check runs yet; skipping."); + return Ok(()); + } + + let blocking_check_runs: Vec<_> = check_runs.iter().filter(|run| !check_run_is_green(run)).collect(); + if !blocking_check_runs.is_empty() { + println!("PR #{pr_number} still has non-green check runs:"); + for run in blocking_check_runs { + println!( + "- {}: status={}, conclusion={}", + run.name, + run.status, + run.conclusion.as_deref().unwrap_or("none") + ); + } + return Ok(()); + } + + let blocking_statuses: Vec<_> = latest_statuses + .values() + .filter(|status| status.context != CLA_CONTEXT) + .filter(|status| status.state != "success") + .collect(); + if !blocking_statuses.is_empty() { + println!("PR #{pr_number} still has non-green commit statuses:"); + for status in blocking_statuses { + println!("- {}: {}", status.context, status.state); + } + return Ok(()); + } + + if let Some(cla_status) = latest_statuses.get(CLA_CONTEXT) { + if !matches!(cla_status.state.as_str(), "pending" | "failure" | "error") { + println!( + "PR #{pr_number} has unexpected {CLA_CONTEXT} state {}; skipping.", + cla_status.state + ); + return Ok(()); + } + } + + let reason = latest_statuses.get(CLA_CONTEXT).map_or_else( + || format!("{CLA_CONTEXT} is missing"), + |status| format!("{CLA_CONTEXT} is {}", status.state), + ); + println!("Retrying CLA Assistant for PR #{pr_number}: {reason}"); + client.recheck_cla(owner, repo, pr_number)?; + + for attempt in 1..=POLL_ATTEMPTS { + std::thread::sleep(POLL_DELAY); + let statuses = latest_status_by_context(client.list_statuses(owner, repo, &sha)?); + let cla_state = statuses + .get(CLA_CONTEXT) + .map(|status| status.state.as_str()) + .unwrap_or("missing"); + println!("Poll {attempt}/{POLL_ATTEMPTS}: {CLA_CONTEXT}={cla_state}"); + if cla_state == "success" { + println!("CLA Assistant posted {CLA_CONTEXT}=success for PR #{pr_number}."); + return Ok(()); + } + } + + println!("::warning::CLA Assistant did not post {CLA_CONTEXT}=success for PR #{pr_number} after retry."); + Ok(()) +} + +fn check_run_is_green(run: &CheckRun) -> bool { + run.status == "completed" && matches!(run.conclusion.as_deref(), Some("success" | "skipped" | "neutral")) +} + +fn latest_status_by_context(statuses: Vec) -> BTreeMap { + // GitHub returns combined statuses newest-first, so keep the first context. + let mut result = BTreeMap::new(); + for status in statuses { + result.entry(status.context.clone()).or_insert(status); + } + result +} + +struct GithubClient { + http: Client, +} + +impl GithubClient { + fn new(token: String) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("clockworklabs-ci")); + headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).context("invalid GitHub token header")?, + ); + Ok(Self { + http: Client::builder().default_headers(headers).build()?, + }) + } + + fn github_get(&self, path: &str) -> Result { + let url = format!("https://api.github.com{path}"); + let response = self.http.get(&url).send()?; + if !response.status().is_success() { + bail!("GET {url} failed with HTTP {}", response.status()); + } + Ok(response.json()?) + } + + fn list_check_runs(&self, owner: &str, repo: &str, sha: &str) -> Result> { + let path = format!("/repos/{owner}/{repo}/commits/{sha}/check-runs"); + let response: CheckRunsResponse = self.github_get(&path)?; + Ok(response.check_runs) + } + + fn list_statuses(&self, owner: &str, repo: &str, sha: &str) -> Result> { + let path = format!("/repos/{owner}/{repo}/commits/{sha}/status"); + let response: CombinedStatusResponse = self.github_get(&path)?; + Ok(response.statuses) + } + + fn list_open_pull_requests(&self, owner: &str, repo: &str) -> Result> { + let mut page = 1; + let mut pr_numbers = Vec::new(); + + loop { + let path = format!("/repos/{owner}/{repo}/pulls?state=open&base=master&per_page=100&page={page}"); + let prs: Vec = self.github_get(&path)?; + let is_last_page = prs.len() < 100; + pr_numbers.extend(prs.into_iter().map(|pr| pr.number)); + if is_last_page { + break; + } + page += 1; + } + + Ok(pr_numbers) + } + + fn recheck_cla(&self, owner: &str, repo: &str, pr_number: u64) -> Result<()> { + let url = format!("https://cla-assistant.io/check/{owner}/{repo}?pullRequest={pr_number}"); + let response = self + .http + .get(&url) + .header(ACCEPT, HeaderValue::from_static("text/plain, */*")) + .send()?; + println!("CLA Assistant recheck response: HTTP {}", response.status()); + if !response.status().is_success() { + bail!("CLA Assistant recheck failed with HTTP {}", response.status()); + } + Ok(()) + } +} + +#[derive(Deserialize)] +struct PullRequest { + state: String, + draft: bool, + head: PullRequestRef, + base: PullRequestRef, +} + +#[derive(Deserialize)] +struct PullRequestSummary { + number: u64, +} + +#[derive(Deserialize)] +struct PullRequestRef { + sha: String, + #[serde(rename = "ref")] + ref_name: String, +} + +#[derive(Deserialize)] +struct CommitResponse { + commit: Commit, +} + +#[derive(Deserialize)] +struct Commit { + author: CommitPerson, + committer: CommitPerson, +} + +#[derive(Deserialize)] +struct CommitPerson { + date: Option, +} + +#[derive(Deserialize)] +struct CheckRunsResponse { + check_runs: Vec, +} + +#[derive(Deserialize)] +struct CheckRun { + name: String, + status: String, + conclusion: Option, +} + +#[derive(Deserialize)] +struct CombinedStatusResponse { + statuses: Vec, +} + +#[derive(Clone, Deserialize)] +struct CommitStatus { + context: String, + state: String, +} From 535f388053bd4a1a432329027407b88957139077 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:53:02 -0700 Subject: [PATCH 2/8] Apply suggestion from @bfops Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- .github/workflows/retry-cla-assistant.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index 76036e7dd3d..eb0f6ab481d 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -44,10 +44,6 @@ jobs: - uses: dsherret/rust-toolchain-file@v1 - - uses: Swatinem/rust-cache@v2 - with: - cache-all-crates: true - - name: Recheck CLA Assistant env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From d7b45215d78255a4830c4327dd949b305800c62c Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:53:10 -0700 Subject: [PATCH 3/8] Apply suggestion from @bfops Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- .github/workflows/retry-cla-assistant.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index eb0f6ab481d..5b78c89b2be 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -13,12 +13,6 @@ on: types: [opened, reopened, synchronize, ready_for_review] schedule: - cron: "7,22,37,52 * * * *" - workflow_dispatch: - inputs: - pr_number: - description: "Pull request number to check" - required: true - type: number permissions: actions: read From c8413c2cc76c46f576ffab29dd33001e4093b526 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:53:42 -0700 Subject: [PATCH 4/8] Apply suggestion from @bfops Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- .github/workflows/retry-cla-assistant.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index 5b78c89b2be..f28a9af1fb2 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -21,10 +21,6 @@ permissions: pull-requests: read statuses: read -concurrency: - group: retry-cla-assistant-${{ github.event.pull_request.number || github.event.inputs.pr_number || github.run_id }} - cancel-in-progress: false - jobs: retry-cla: name: Retry CLA Assistant if it is the only blocker From c300b1535e33bde1a53108531981d636378ef257 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 4 Jun 2026 15:54:59 -0400 Subject: [PATCH 5/8] Run CLA retry after any workflow completion --- .github/workflows/retry-cla-assistant.yml | 2 ++ tools/ci/src/retry_cla_assistant.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index f28a9af1fb2..e41142915c0 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -11,6 +11,8 @@ name: Retry CLA Assistant on: pull_request_target: types: [opened, reopened, synchronize, ready_for_review] + workflow_run: + types: [completed] schedule: - cron: "7,22,37,52 * * * *" diff --git a/tools/ci/src/retry_cla_assistant.rs b/tools/ci/src/retry_cla_assistant.rs index 7733d6aab33..3efb1c21e59 100644 --- a/tools/ci/src/retry_cla_assistant.rs +++ b/tools/ci/src/retry_cla_assistant.rs @@ -86,6 +86,19 @@ fn candidate_pull_requests(client: &GithubClient, owner: &str, repo: &str, pr_nu pr_numbers.insert(number); } } + "workflow_run" => { + if event.pointer("/workflow_run/name").and_then(Value::as_str) == Some("Retry CLA Assistant") { + println!("Ignoring completion of this workflow."); + return Ok(Vec::new()); + } + if let Some(prs) = event.pointer("/workflow_run/pull_requests").and_then(Value::as_array) { + for pr in prs { + if let Some(number) = pr.get("number").and_then(Value::as_u64) { + pr_numbers.insert(number); + } + } + } + } "workflow_dispatch" => {} other => println!("Unsupported event {other}; skipping."), } From 1ec4a2dacb4c8c956f57e3ee307e1e7992c7c55e Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 4 Jun 2026 15:56:18 -0400 Subject: [PATCH 6/8] Clarify CLA retry checkout safety comment --- .github/workflows/retry-cla-assistant.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index e41142915c0..dd19d1f51d8 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -6,7 +6,8 @@ name: Retry CLA Assistant # Assistant only when that status is the sole remaining non-green signal. # # SECURITY: This workflow uses pull_request_target so it can inspect PR status -# for forks, but it must never check out, build, or execute code from the PR. +# for forks. It checks out trusted default-branch code only; it must never check +# out, build, or execute code from the PR head. on: pull_request_target: From e458f9195473a268b2ba5de9924a04bb379eca7a Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 4 Jun 2026 15:58:10 -0400 Subject: [PATCH 7/8] Restore manual CLA retry dispatch --- .github/workflows/retry-cla-assistant.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index dd19d1f51d8..cbad8eb9299 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -16,6 +16,12 @@ on: types: [completed] schedule: - cron: "7,22,37,52 * * * *" + workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to check" + required: true + type: number permissions: actions: read From d18031938c2838bf6028b895101cc558c326ef0a Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 4 Jun 2026 16:04:27 -0400 Subject: [PATCH 8/8] Pass CLA retry PR numbers from workflow --- .github/workflows/retry-cla-assistant.yml | 41 +++++++++- tools/ci/src/retry_cla_assistant.rs | 97 +---------------------- 2 files changed, 43 insertions(+), 95 deletions(-) diff --git a/.github/workflows/retry-cla-assistant.yml b/.github/workflows/retry-cla-assistant.yml index cbad8eb9299..df5718b3090 100644 --- a/.github/workflows/retry-cla-assistant.yml +++ b/.github/workflows/retry-cla-assistant.yml @@ -43,8 +43,45 @@ jobs: - uses: dsherret/rust-toolchain-file@v1 + - name: Collect pull requests to check + id: prs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr_numbers="${RUNNER_TEMP}/cla-pr-numbers" + + case "${GITHUB_EVENT_NAME}" in + pull_request_target) + jq -r '.pull_request.number' "${GITHUB_EVENT_PATH}" > "${pr_numbers}" + ;; + workflow_run) + if jq -e '.workflow_run.name == "Retry CLA Assistant"' "${GITHUB_EVENT_PATH}" > /dev/null; then + : > "${pr_numbers}" + else + jq -r '.workflow_run.pull_requests[].number' "${GITHUB_EVENT_PATH}" > "${pr_numbers}" + fi + ;; + schedule) + gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&base=master&per_page=100" --jq '.[].number' > "${pr_numbers}" + ;; + workflow_dispatch) + jq -r '.inputs.pr_number' "${GITHUB_EVENT_PATH}" > "${pr_numbers}" + ;; + *) + echo "unsupported event ${GITHUB_EVENT_NAME}" >&2 + exit 1 + ;; + esac + + sort -n -u "${pr_numbers}" -o "${pr_numbers}" + echo "path=${pr_numbers}" >> "${GITHUB_OUTPUT}" + - name: Recheck CLA Assistant env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - INPUT_PR_NUMBER: ${{ inputs.pr_number }} - run: cargo ci retry-cla-assistant + run: | + while read -r pr_number; do + if [ -n "${pr_number}" ]; then + cargo ci retry-cla-assistant --pr-number "${pr_number}" + fi + done < "${{ steps.prs.outputs.path }}" diff --git a/tools/ci/src/retry_cla_assistant.rs b/tools/ci/src/retry_cla_assistant.rs index 3efb1c21e59..a768cbc1123 100644 --- a/tools/ci/src/retry_cla_assistant.rs +++ b/tools/ci/src/retry_cla_assistant.rs @@ -1,6 +1,5 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::env; -use std::fs; use std::time::Duration; use anyhow::{anyhow, bail, Context, Result}; @@ -10,7 +9,6 @@ use reqwest::blocking::Client; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; use serde::de::DeserializeOwned; use serde::Deserialize; -use serde_json::Value; const CLA_CONTEXT: &str = "license/cla"; const MIN_HEAD_AGE: Duration = Duration::from_secs(10 * 60); @@ -19,9 +17,9 @@ const POLL_DELAY: Duration = Duration::from_secs(30); #[derive(Args)] pub(crate) struct RetryClaAssistantArgs { - /// Pull request number. If omitted, GitHub Actions event payloads are used. + /// Pull request number to check. #[arg(long)] - pub(crate) pr_number: Option, + pub(crate) pr_number: u64, /// Repository in `owner/name` form. Defaults to GITHUB_REPOSITORY. #[arg(long)] @@ -39,71 +37,7 @@ pub(crate) fn run(args: RetryClaAssistantArgs) -> Result<()> { let token = env::var("GITHUB_TOKEN").context("GITHUB_TOKEN is required")?; let client = GithubClient::new(token)?; - let pr_numbers = candidate_pull_requests(&client, owner, repo_name, args.pr_number)?; - if pr_numbers.is_empty() { - println!("No pull request associated with this event; skipping."); - return Ok(()); - } - - for pr_number in pr_numbers { - retry_for_pr(&client, owner, repo_name, pr_number)?; - } - - Ok(()) -} - -fn candidate_pull_requests(client: &GithubClient, owner: &str, repo: &str, pr_number: Option) -> Result> { - if let Some(pr_number) = pr_number { - return Ok(vec![pr_number]); - } - - if let Ok(input_pr_number) = env::var("INPUT_PR_NUMBER") { - if !input_pr_number.trim().is_empty() { - return Ok(vec![input_pr_number.parse().with_context(|| { - format!("INPUT_PR_NUMBER must be numeric, got {input_pr_number:?}") - })?]); - } - } - - let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or_default(); - if event_name == "schedule" { - return client.list_open_pull_requests(owner, repo); - } - - let event_path = match env::var("GITHUB_EVENT_PATH") { - Ok(path) => path, - Err(_) => return Ok(Vec::new()), - }; - let event: Value = serde_json::from_str( - &fs::read_to_string(&event_path) - .with_context(|| format!("failed to read GitHub event payload {event_path}"))?, - )?; - - let mut pr_numbers = BTreeSet::new(); - match event_name.as_str() { - "pull_request" | "pull_request_target" => { - if let Some(number) = event.pointer("/pull_request/number").and_then(Value::as_u64) { - pr_numbers.insert(number); - } - } - "workflow_run" => { - if event.pointer("/workflow_run/name").and_then(Value::as_str) == Some("Retry CLA Assistant") { - println!("Ignoring completion of this workflow."); - return Ok(Vec::new()); - } - if let Some(prs) = event.pointer("/workflow_run/pull_requests").and_then(Value::as_array) { - for pr in prs { - if let Some(number) = pr.get("number").and_then(Value::as_u64) { - pr_numbers.insert(number); - } - } - } - } - "workflow_dispatch" => {} - other => println!("Unsupported event {other}; skipping."), - } - - Ok(pr_numbers.into_iter().collect()) + retry_for_pr(&client, owner, repo_name, args.pr_number) } fn retry_for_pr(client: &GithubClient, owner: &str, repo: &str, pr_number: u64) -> Result<()> { @@ -274,24 +208,6 @@ impl GithubClient { Ok(response.statuses) } - fn list_open_pull_requests(&self, owner: &str, repo: &str) -> Result> { - let mut page = 1; - let mut pr_numbers = Vec::new(); - - loop { - let path = format!("/repos/{owner}/{repo}/pulls?state=open&base=master&per_page=100&page={page}"); - let prs: Vec = self.github_get(&path)?; - let is_last_page = prs.len() < 100; - pr_numbers.extend(prs.into_iter().map(|pr| pr.number)); - if is_last_page { - break; - } - page += 1; - } - - Ok(pr_numbers) - } - fn recheck_cla(&self, owner: &str, repo: &str, pr_number: u64) -> Result<()> { let url = format!("https://cla-assistant.io/check/{owner}/{repo}?pullRequest={pr_number}"); let response = self @@ -315,11 +231,6 @@ struct PullRequest { base: PullRequestRef, } -#[derive(Deserialize)] -struct PullRequestSummary { - number: u64, -} - #[derive(Deserialize)] struct PullRequestRef { sha: String,