Skip to content

fix(ci-status): classify tea failures by shape; report azure-devops extension - #3597

Merged
max-sixty merged 1 commit into
mainfrom
fix-uninstall-exec-context-adjacency
Jul 25, 2026
Merged

fix(ci-status): classify tea failures by shape; report azure-devops extension#3597
max-sixty merged 1 commit into
mainfrom
fix-uninstall-exec-context-adjacency

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

Two follow-ups from #3595, both in code that talks to a forge CLI.

Gitea CI status had the same exit-0 blind spot as the switch path

tea api never reads the HTTP status — it copies the response body to stdout and exits 0 — so the !output.status.success() gate in src/commands/list/ci_status/gitea.rs never fired for a 4xx/5xx. #3595 fixed this for wt switch pr:<n>; the CI-status backend is the second instance of the same bug.

The commit-status path was the quieter one: every field of GiteaCombinedStatus carries #[serde(default)], so Gitea's APIError body deserialized as {state: "", total_count: 0} — an error indistinguishable from "this commit has no statuses". The PR-list path was noisier but less harmful: the error body failed to parse and logged Failed to parse tea api pulls JSON, blaming an API change for what was an API error.

tea_api_error now reads the response shape — the APIError body, whose message feeds the existing retriable sniff — and a non-zero exit keeps meaning what it always meant, tea itself failing (transport error, no login). A proxy's HTML error page stays unclassified, as before: sniffing arbitrary bodies is the string-heuristic pile this removes.

wt config show --full reports the azure-devops extension

The whole az repos command group ships in that extension, so without it CI status stays blank however well az is authenticated — a persistent, user-fixable condition, which is what this surface is for. azure_devops_extension_installed (added in #3595) answers it in JSON:

✓ az installed & authenticated
▲ azure-devops extension not installed; run az extension add --name azure-devops

An az that can't answer counts as installed, so an unanswered check never becomes an accusation. The wt switch pr:<n> bail now uses the same sentence — one wording for one condition, which is the one-line change to switch_pr_azure_extension_not_installed.snap.

Testing

The two new Gitea tests were checked against the unfixed source: reverting gitea.rs alone fails both, so they pin the fix rather than describe it. The two pre-existing retriable tests kept their exit-1 shape but lost their fabricated payload — exit 1 alongside 429 Too Many Requests is the combination tea can't produce — and now carry a real transport error, which is the case that does exit non-zero. config show --full gets a case per extension state.

This was written by Claude Code on behalf of max

…xtension

`tea api` copies the response body to stdout and exits 0, so the
`!output.status.success()` gate in the Gitea CI-status backend never fired
for an HTTP error. The error body reached `parse_json` as data — and every
field of `GiteaCombinedStatus` defaults, so a 500 deserialized as "no
statuses" and painted a blank cell. `tea_api_error` now reads the response
shape; a non-zero exit stays what it always meant, `tea` itself failing.

`wt config show --full` reports the azure-devops extension alongside az's
install and auth state: the whole `az repos` command group ships in it, so
without it CI status stays blank however well az is authenticated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@max-sixty
max-sixty merged commit 5844444 into main Jul 25, 2026
39 checks passed
@max-sixty
max-sixty deleted the fix-uninstall-exec-context-adjacency branch July 25, 2026 18:54
max-sixty added a commit that referenced this pull request Jul 25, 2026
Follow-up to #3600, independent of it (no shared files).

GitHub and GitLab rewrote forge API failures into messages of our own,
discarding what the CLI said. GitLab picked its three by prose —
`error_text.starts_with("404")` — the pattern #3595/#3597 removed
elsewhere.

## Before / after

A GitLab MR that doesn't exist:

```
- ✗ MR !9999 not found
+ ✗ glab api failed for MR !9999
+ ▎ glab: 404 Not found (HTTP 404)
```

A GitHub PR that doesn't exist — the one message kept, now with gh's
verdict under it rather than instead of it:

```
  ✗ PR #999999 not found on max-sixty/worktrunk (gh default). Check that `gh repo set-default` points to the correct repository.
+ ▎ gh: Not Found (HTTP 404)
```

## The rule, now written down

`src/git/remote_ref/mod.rs` gets an **Error Messages** section: forward
the CLI's own line, and write our own only where the CLI *structurally
cannot* report the condition. Two cases qualify — GitHub's 404 (it
answers about an owner/repo *we* chose, from `gh repo set-default` or a
remote; gh can't know that) and Azure's missing `azure-devops` extension
(a precondition `az extension list` answers, which `az` never names).
Reading more nicely than the CLI doesn't qualify.

`cli_api_error` and `cli_api_error_details` gained docstrings for the
mechanism: our context as the headline, the CLI's words in the gutter,
so a provider with something to add passes it as `message` rather than
bailing.

## Evidence

Measured, not assumed — both CLIs render an API failure the same way:

| | stdout | stderr |
|---|---|---|
| `gh api …/pulls/999999` | `{"message":"Not Found",…,"status":"404"}` |
`gh: Not Found (HTTP 404)` |
| `glab api …/merge_requests/…` | `{"message":"401 Unauthorized"}` |
`glab: 401 Unauthorized (HTTP 401)` |

`cli_api_error_details` prefers stderr, so the gutter gets the human
line, not the JSON. That also surfaced a test bug: **the glab mocks
never set stderr**, so they'd have enshrined output real glab doesn't
produce. Fixed to match the probe.

`gh` puts the status in a structured field, so its 404 arm keys on
`status`. `glab` puts it only inside the message text — nothing to key
on, which is why prose-matching was the only way to keep those arms.

## What this gives up

A bad or expired token no longer gets "run gh auth login" / "run glab
auth login"; it gets `gh: Bad credentials (HTTP 401)`. The far more
common case — no auth configured at all — is unaffected: gh prints its
own "please run: gh auth login" and always did fall through to
forwarding. Happy to restore the GitHub 401 arm (it's structurally
keyable); GitLab's can't come back without prose-matching.

## Testing

`cargo run -- hook pre-merge --yes` → 4572 passed. Six snapshots
reviewed individually before accepting (five updated, one new). Both
paths also driven through the **real** `gh` binary (a live 404 and a
live 401), not only mocks. Clippy clean with `--features
shell-integration-tests`.

Coverage: the GitHub 404 arm and the forwarding fallthrough each have a
test, on both forges. `codecov/patch` also flagged one line —
re-indenting the 404 block pulled a pre-existing gap into the patch, and
it turned out to be real: the `gh repo set-default` half of the hint,
the fork workflow the message exists for, had never been tested.
`test_switch_pr_not_found_gh_default` closes it.

> _This was written by Claude Code on behalf of Maximilian Roos_
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.

2 participants