Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/desktop/src/components/forge/CIChecksBadge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,14 @@
const projectState = $derived(uiState.project(projectId));
const isDone = $derived(!projectState.branchesToPoll.current.includes(branchName));

// Do not create a checks monitor if pull request is merged or from a fork.
// For more information about unavailability of check-runs for forked repos,
// see GitHub docs at:
// Do not create a checks monitor if the pull request is merged, or if it is
// from a fork on a forge where fork checks are unavailable. GitHub does not
// expose check-runs for forked repos, see:
// https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#list-check-runs-in-a-check-suite
const enabled = $derived(!isFork && !isMerged); // Deduplication.
// GitLab resolves fork MR pipelines through the MR itself, so forks are fine there.
const isGitLab = $derived(forgeInfo?.name === "gitlab");
const forkBlocksChecks = $derived(Boolean(isFork) && !isGitLab);
const enabled = $derived(!forkBlocksChecks && !isMerged); // Deduplication.

// Backs polling off while the checks query is failing (offline, rate-limited,
// repo access lost) and restores the schedule on recovery. A transient 422
Expand All @@ -79,7 +82,7 @@

const checksTagInfo: StatusInfo = $derived.by(() => {
const checks = checksQuery?.response;
if (isFork) {
if (forkBlocksChecks) {
return {
style: "gray",
icon: undefined,
Expand Down
61 changes: 52 additions & 9 deletions crates/but-forge/src/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,20 +143,49 @@ fn ci_checks_for_ref(
let pipelines = std::thread::spawn(move || -> anyhow::Result<_> {
let runtime = tokio::runtime::Runtime::new()
.map_err(|err| anyhow::anyhow!("Failed to create tokio runtime: {err}"))?;
runtime.block_on(gl.list_pipeline_jobs_for_ref(project_id, &reference))
runtime.block_on(async {
let jobs = gl
.list_pipeline_jobs_for_ref(project_id.clone(), &reference)
.await?;
if !jobs.is_empty() {
return Ok(Some(jobs));
}
// MR-only pipelines (detached / merged-results) and fork MRs
// never have a branch pipeline; fall back to the head
// pipeline of the branch's open MR.
match gl
.list_pipeline_jobs_for_open_mr(project_id, &reference)
.await
{
Ok(Some(mr_jobs)) => Ok(Some(mr_jobs)),
// No open MR or no head pipeline: authoritatively no checks.
Ok(None) => Ok(Some(Vec::new())),
Err(err) => {
// Missing MR read access behaves like the ref path's
// 403/404: show "no checks" without wiping the cache.
let status = err
.downcast_ref::<but_gitlab::HttpStatusError>()
.map(|http| http.status.as_u16());
if matches!(status, Some(403 | 404)) {
Ok(None)
} else {
Err(err)
}
}
}
})
})
.join()
.map_err(|e| anyhow::anyhow!("Failed to join thread: {e:?}"))??;
Ok(Some(
pipelines
.into_iter()
.map(|pipeline| {
let mut ci_check = CiCheck::from(pipeline);
Ok(pipelines.map(|jobs| {
jobs.into_iter()
.map(|job| {
let mut ci_check = CiCheck::from(job);
ci_check.reference = reference_for_checks.to_string();
ci_check
})
.collect(),
))
.collect()
}))
}
ForgeName::Bitbucket => {
let preferred_account = preferred_forge_user
Expand Down Expand Up @@ -353,7 +382,7 @@ impl From<but_gitlab::GitLabPipelineJob> for CiCheck {
output: CiOutput::default(),
started_at,
status,
head_sha: String::new(),
head_sha: job.pipeline.sha.clone().unwrap_or_default(),
url: job_url.clone(),
html_url: job_url.clone(),
details_url: pipeline_url,
Expand Down Expand Up @@ -576,6 +605,7 @@ mod tests {
id: 7,
web_url: None,
status: None,
sha: None,
},
}
}
Expand Down Expand Up @@ -625,6 +655,19 @@ mod tests {
));
}

#[test]
fn gitlab_jobs_carry_the_pipeline_sha_as_head_sha() {
let mut job = job("success", Some("https://example.com/job"));
job.pipeline.sha = Some("deadbeef".into());

let check = CiCheck::from(job);

assert_eq!(
check.head_sha, "deadbeef",
"the pipeline SHA identifies which commit the cached checks belong to"
);
}

#[test]
fn maps_canceling_jobs_to_in_progress_status() {
let check = CiCheck::from(job("canceling", Some("https://example.com/job")));
Expand Down
Loading