Skip to content

fix(gitea): key the tea api error shape on the envelope, not on a non-empty message - #3600

Draft
max-sixty wants to merge 1 commit into
mainfrom
gitea-blank-error-message-shape
Draft

fix(gitea): key the tea api error shape on the envelope, not on a non-empty message#3600
max-sixty wants to merge 1 commit into
mainfrom
gitea-blank-error-message-shape

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

Gitea blanks a 500's message in production unless the token belongs to an admin (services/context/api.go: if setting.IsProd && !(ctx.Doer != nil && ctx.Doer.IsAdmin) { message = "" }), so the body arrives as {"message":"","url":"…/api/swagger"}. Both Gitea call sites required a non-empty message before they would call a response an error, so that body fell through to the data path:

  • wt list logged Failed to parse tea api pulls JSON — blaming an API change for an API error. (The CI cell was blank either way, so this was log-only.)
  • wt switch pr:<n> failed with Failed to parse Gitea API response for PR #N. This may indicate a Gitea API change. — the same misattribution, in the user's face.

The key

The discriminator is now the presence of the message key, which is the whole shape. tea api never reads the HTTP status — runApi copies the body to stdout and returns nil regardless — so the body is the only channel, and none of the resources read here carries a message: verified against gitea main for PullRequest, CombinedStatus, and CommitStatus.

url is deliberately not part of the key. The two mistakes aren't symmetric: an error body that omits url would read as the resource, which is the bug this key exists to prevent, while requiring it would only guard against a resource one day growing a message field. Gitea already ships error shapes without one (APIInvalidTopicsError is message plus invalidTopics), and CombinedStatus does carry url.

Shape

One parser — remote_ref::gitea::api_error_message — now serves both sites; the CI-status backend drops its duplicate struct, and the PR path checks the envelope before the resource parse, so "may indicate a Gitea API change" is reserved for a body that is neither. A blanked message reaches the user as an error that says why there's no detail:

✗ Gitea API error for PR #101 on owner/test-repo, but the response carried no message — Gitea hides 500 messages from non-admin tokens

The CI cell for a blanked 500 stays blank, unchanged: is_retriable_error is the one gate every backend uses to turn a failure into the error indicator, and an empty message isn't retriable. Only the misleading log line is gone.

Tests

Extended rather than duplicated: test_tea_api_error_reads_the_response_shape flips its blank-message assertion (that assertion was the bug), a new test_api_error_message_reads_the_response_shape pins the key against every shape both sides see, and switch_pr_gitea_blank_error_message snapshots the user-visible path alongside the existing 401/403/404 cases.

Noted, not done

tea's api command has an --include flag that writes the status line to stderr — a genuine structured channel that would beat shape-sniffing. It's in tea's main source but in no released changelog entry, so sending it would break every user whose tea predates it. It would also compose with this change rather than replace it, since the body still supplies the message text.

🤖 Generated with Claude Code

This was written by Claude Code on behalf of Maximilian

…-empty message

Gitea blanks a 500's message in production unless the token belongs to an
admin, so the body arrives as `{"message":"","url":"…"}`. Both Gitea call
sites required a non-empty message before they would call a response an
error, so that body fell through to the data path: `wt list` logged
"Failed to parse tea api pulls JSON", and `wt switch pr:<n>` told the user
"This may indicate a Gitea API change" — blaming an API change for an API
error.

The key is now the presence of the `message` key, which is the whole shape:
`tea api` never reads the HTTP status, and none of the resources read here
(a PR object, a PR array, a combined commit status) carries a `message`.
One parser, `remote_ref::gitea::api_error_message`, now serves both sites —
the CI-status backend drops its duplicate struct — and a blanked message
reaches the user as an error that says why there is no detail.
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.

1 participant