Skip to content

Fail PR checks closed when the provider read fails - #2085

Merged
ppXD merged 1 commit into
mainfrom
fix/fail-gitlab-ci-checks-closed
Oct 7, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/fail-gitlab-ci-checks-closed

Conversation

@ppXD

@ppXD ppXD commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • git.fetch_pr_checks is the CI gate that workflows wire into If/else ahead of git.merge_pr. GitLab's ListChecksAsync turned every pipelines/jobs read error into an empty list: 429, 5xx, 403, 404, a dropped connection, or a status NGitLab cannot parse. The node reads an empty list as allPassed=true, so a provider hiccup merged a red merge request. Every read failure now goes through ExternalCallResilience (typed ProviderApiException, 5xx retried) and fails the node. GitHub had the same problem for a 401 on check runs and for a PR without a head commit; both fail now too.

  • GitLab answers 200 [] on the MR pipelines list to a credential that may not read pipelines. That read uses the connection credential, while the merge uses the actor's. In GitLabRepositoryProvider.ConfirmNoPipeline, an empty list now means "no checks" only when one of these holds:

    • CI is disabled for the project.
    • Otherwise, the project pipeline list is readable (it returns 403 to a credential without read_pipeline), and the MR names no head pipeline the list left out, such as a fork's.
  • The latest pipeline's status sets a minimum for its jobs, using its own mapping (MapPipelineStatus):

    • Only success passes.
    • manual (blocked on a manual job) and scheduled (waiting on a delayed job) are Pending.
    • skipped and canceled are Cancelled.

    Before, all three went through the job mapping to Skipped, and the gate opened on MRs that GitLab itself would hold.

  • ExternalCallResilience.IsTransient now retries NGitLab's network failures (WebException with no response, HttpIOException) the same way it retries Octokit's HttpRequestException. A single TCP reset no longer fails the gate run. NGitLab writes already go through ExecuteNonIdempotentAsync, which checks whether an earlier attempt landed before sending again.

Test plan

  • Unit GitLabListChecksTests:
    • 429, 503, 403 and 404 refusals.
    • Dropped connection: HttpIOException after MaxAttempts tries. Refused connection: WebException after the full backoff.
    • A failed pipeline whose jobs read errors, and an unparsable status.
    • Newest-pipeline selection (78 over 77).
    • Empty list: no pipeline, CI off, blind credential (403), fork head pipeline.
    • Floor rows for manual, scheduled and skipped, and a theory covering every pipeline status.
  • Unit GitHubListChecksTests: a 429 row, and a dropped-connection row asserting MaxAttempts attempts.
  • Unit ExternalCallResilienceTests: WebException with no response and HttpIOException count as transient.
  • Unit GitLabWriteRetryTests: a LandsThenConnectionDrops row on all six NGitLab writes, each landing exactly once.
  • Integration PullRequestChecksGateFlowTests, through the real engine, Postgres and a stub GitLab:
    • A blind credential or a fork head pipeline fails the run before the merge.
    • Blocked, delayed, skipped and newest-failed pipelines hold the merge.
  • The five new integration behaviour rows fail on the pre-fix provider.
  • Mutation: 9 of 9 mutants killed (oldest pipeline, job-status mapping, skipped passes, no readability probe, no head-pipeline check, no CI-off exemption, IsTransient without either network type, GitHub swallowing 429 and drops).
  • Full unit suite: 12383 passed. Provider, PR and webhook integration suites: 209 passed.

git.fetch_pr_checks is the CI gate a workflow wires into If/else ahead
of git.merge_pr. GitLab's ListChecksAsync caught every exception while
reading pipelines or jobs (429, 5xx, 403, 404, a dropped connection, a
status NGitLab cannot parse) and returned an empty list, which the node
summarises as allPassed=true. A provider hiccup merged a merge request
whose CI nobody saw. The catch also sat inside the resilience lambda,
so a 5xx was never retried and nothing was typed as a
ProviderApiException.

Every failure now leaves through ExternalCallResilience and fails the
node. An empty pipeline list is not taken at its word either: GitLab
answers [] to a credential that may not read pipelines, and this read
uses the connection's credential while the merge uses the actor's. []
means "no checks" only when CI is off for the project, or when the
project's own pipeline list (which refuses such a credential with 403)
is readable and the merge request names no head pipeline the list left
out, as a fork's can be.

The latest pipeline's status floors its jobs, mapped on its own terms:
only success passes. A pipeline blocked on a manual job or waiting on a
delayed one is pending, and a skipped one is not a success, as GitLab's
merge check holds by default. Read as job statuses all three were
"skipped" and opened the gate on a merge request GitLab would hold.

NGitLab reports a lost connection as a WebException or HttpIOException,
which the wrapper did not treat as transient, so one TCP reset failed
the gate run outright. Both are retried now, like Octokit's
HttpRequestException; writes already probe before they re-send.

GitHub had the same shape for a 401 on check runs and for a pull
request without a head commit; both fail now too.
@ppXD
ppXD merged commit 26d8044 into main Oct 7, 2026
7 checks passed
@ppXD
ppXD deleted the fix/fail-gitlab-ci-checks-closed branch October 7, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant