forge label create exits 22 without diagnostics #100

Closed
opened 2026-07-13 18:59:11 +01:00 by vnprc-agent · 6 comments
Contributor

User story: So that I can fix label setup failures without guessing, forge label create should report useful API or validation diagnostics whenever label creation is rejected.

Part of the "Forge / workspace CLI tooling" arc.


Bug

forge label create can fail with exit code 22 and print no error message, leaving the operator with no actionable cause.

Observed while trying to create a priority label on a repository with no existing labels:

forge -R <owner>/<repo> label create P0 -c '#FF0000' -d 'Maximum priority' -f

Result:

  • exit code: 22
  • stdout: empty
  • stderr: empty
  • label was not created

A prior variant without the leading # in the color also failed silently:

forge -R <owner>/<repo> label create P0 -c FF0000 -d 'Maximum priority' -f

Authentication was valid at the time, and forge label list still reported no labels afterward.

Expected behavior

If the Forgejo API rejects label creation, forge should print the HTTP status and response body, or a concise decoded error message. If the failure is client-side argument validation, it should report the invalid field.

At minimum, a failing forge label create should not exit nonzero with no diagnostics.

Acceptance criteria

  • Reproduce or add fixture coverage for a label-create API failure.
  • Ensure non-2xx responses from label create surface useful diagnostics.
  • Verify both accepted color formats, or document/enforce the supported format before making the API call.
  • forge label create ... failures include enough context to distinguish auth/permission, validation, route, and server errors.
**User story:** So that I can fix label setup failures without guessing, `forge label create` should report useful API or validation diagnostics whenever label creation is rejected. _Part of the "Forge / workspace CLI tooling" arc._ --- ## Bug `forge label create` can fail with exit code 22 and print no error message, leaving the operator with no actionable cause. Observed while trying to create a priority label on a repository with no existing labels: ```sh forge -R <owner>/<repo> label create P0 -c '#FF0000' -d 'Maximum priority' -f ``` Result: - exit code: `22` - stdout: empty - stderr: empty - label was not created A prior variant without the leading `#` in the color also failed silently: ```sh forge -R <owner>/<repo> label create P0 -c FF0000 -d 'Maximum priority' -f ``` Authentication was valid at the time, and `forge label list` still reported no labels afterward. ## Expected behavior If the Forgejo API rejects label creation, `forge` should print the HTTP status and response body, or a concise decoded error message. If the failure is client-side argument validation, it should report the invalid field. At minimum, a failing `forge label create` should not exit nonzero with no diagnostics. ## Acceptance criteria - Reproduce or add fixture coverage for a label-create API failure. - Ensure non-2xx responses from label create surface useful diagnostics. - Verify both accepted color formats, or document/enforce the supported format before making the API call. - `forge label create ...` failures include enough context to distinguish auth/permission, validation, route, and server errors.
Author
Contributor

Reproduced on the current forge, with one new observation: the failure is not universal. The same command, same token, same session, succeeds on some repositories and fails on others.

Exit 22, empty stdout, empty stderr, no label created:

forge -R allod/tools label create bug -c '#d73a4a' -d 'Something is broken'
forge -R allod/memory label create bug -c '#d73a4a' -d 'test'
forge -R allod/strategy label create bug -c '#d73a4a'
forge -R allod/tools label create bug

Exit 0, prints Label created: <id> bug, on repositories owned by the calling account rather than by the org. forge label delete <name> --yes also succeeds there, so the whole create/delete round trip works on those repositories.

The color argument is not the variable. The org-repo call fails with -c '#d73a4a', with a bare name and no flags at all, and with or without -d. forge auth status reports the credential as fine, and forge label list returns exit 0 on every repository tried, org or not, printing No labels in <repo> — no repository in this forge has a label defined.

On why nothing is printed: api() runs curl -sf, so 22 is curl's generic "HTTP >= 400" exit and both the status line and the response body are discarded before any handler could read them. That is upstream of every forge subcommand, not specific to label create.

One adjacent observation from the same session: pushing an agent branch to one of the org repositories above is refused server-side with Forgejo: User permission denied for writing.

Reproduced on the current `forge`, with one new observation: the failure is not universal. The same command, same token, same session, succeeds on some repositories and fails on others. Exit 22, empty stdout, empty stderr, no label created: ```sh forge -R allod/tools label create bug -c '#d73a4a' -d 'Something is broken' forge -R allod/memory label create bug -c '#d73a4a' -d 'test' forge -R allod/strategy label create bug -c '#d73a4a' forge -R allod/tools label create bug ``` Exit 0, prints `Label created: <id> bug`, on repositories owned by the calling account rather than by the org. `forge label delete <name> --yes` also succeeds there, so the whole create/delete round trip works on those repositories. The color argument is not the variable. The org-repo call fails with `-c '#d73a4a'`, with a bare name and no flags at all, and with or without `-d`. `forge auth status` reports the credential as fine, and `forge label list` returns exit 0 on every repository tried, org or not, printing `No labels in <repo>` — no repository in this forge has a label defined. On why nothing is printed: `api()` runs `curl -sf`, so 22 is curl's generic "HTTP >= 400" exit and both the status line and the response body are discarded before any handler could read them. That is upstream of every `forge` subcommand, not specific to `label create`. One adjacent observation from the same session: pushing an agent branch to one of the org repositories above is refused server-side with `Forgejo: User permission denied for writing.`
Author
Contributor

Correcting the theory in the comment above. I said creation succeeds "on repositories owned by the calling account rather than by the org". That is not the boundary. It also succeeds on a repository in a personal namespace that the calling account does not own — same token, same session, exit 0, label created and deletable.

So the split is not repository ownership. It is which namespaces the token carries write scope for: none for the org namespace, write for the personal namespaces it is provisioned against. Which means the underlying 403 is correct behavior, not a bug. Label creation on an org repository is a human act at the forge, in the same category as branch protection, and no amount of fixing forge will change that.

That narrows this issue to what it actually is: a diagnostics defect, not an authorization one. The API call is being answered correctly and the answer is being thrown away. api() runs curl -sf, so -f suppresses the body and collapses every HTTP >= 400 into curl's generic exit 22 before any handler can read the status line. A correct, informative 403 Forbidden reaches the process and is discarded.

The fix is therefore upstream of every subcommand rather than in label create: stop passing -f, capture the status (-w '%{http_code}' or --fail-with-body), and report status plus response message on failure. Had the status been visible, the answer here would have been one line instead of a repository-by-repository bisect — which is the general cost of this bug, and the reason it is worth fixing at the api() layer rather than per command.

Correcting the theory in the comment above. I said creation succeeds "on repositories owned by the calling account rather than by the org". That is not the boundary. It also succeeds on a repository in a personal namespace that the calling account does not own — same token, same session, exit 0, label created and deletable. So the split is not repository ownership. It is which namespaces the token carries write scope for: none for the org namespace, write for the personal namespaces it is provisioned against. Which means the underlying `403` is correct behavior, not a bug. Label creation on an org repository is a human act at the forge, in the same category as branch protection, and no amount of fixing `forge` will change that. That narrows this issue to what it actually is: a diagnostics defect, not an authorization one. The API call is being answered correctly and the answer is being thrown away. `api()` runs `curl -sf`, so `-f` suppresses the body and collapses every HTTP >= 400 into curl's generic exit 22 before any handler can read the status line. A correct, informative `403 Forbidden` reaches the process and is discarded. The fix is therefore upstream of every subcommand rather than in `label create`: stop passing `-f`, capture the status (`-w '%{http_code}'` or `--fail-with-body`), and report status plus response message on failure. Had the status been visible, the answer here would have been one line instead of a repository-by-repository bisect — which is the general cost of this bug, and the reason it is worth fixing at the `api()` layer rather than per command.
Author
Contributor

Fix prepared, awaiting relay — the calling account cannot push to this repository, so it is a commit on agent/forge-api-error-diagnostics rather than a PR.

Before and after, on the exact command from the report:

$ forge -R allod/tools label create bug-test -c '#d73a4a' -d 'probe'      # before
exit 22, no output

$ forge -R allod/tools label create bug-test -c '#d73a4a' -d 'probe'      # after
forge: POST /repos/allod/tools/labels failed: HTTP 403: user should have a permission to write to a repo
exit 22

The change is at the api() layer, so every subcommand gains it rather than label create alone. -f is dropped, the status is appended to the body with -w '\n%{http_code}' and split back off, and one line goes to stderr naming the method, path, status, and the API's own message. Exit 22 is preserved for HTTP errors so existing callers see no contract change — including the one caller that deliberately tolerates failure — while a genuine transport failure now returns curl's real code instead of being flattened.

Two things worth knowing for whoever reviews it. Some endpoints reject with an empty body: POST /repos/{owner}/{repo}/issues/{n}/labels returns a bare 403, so there the output is just HTTP 403 with no trailing message. And the mock curl in the test harness needed a central status append, because real curl honours -w on every request while the mock only did so for the verification endpoint — a per-route append silently starved every caller that splits the status back off, which is worth remembering before adding routes.

Covered by a new tests/forge/api-errors.sh, verified to fail against the old api(): with curl -sf restored, a 403 renders as Issue #403: null with a jq type error, because the error body was being consumed as data. All nine forge suites pass with the change.

This also settles the authorization question the earlier comments circled: the 403 is correct behavior, not a defect. The token carries issue-write scope; the account simply has push: false on org repositories, and label writes need repository write. Nothing in forge can or should change that.

Fix prepared, awaiting relay — the calling account cannot push to this repository, so it is a commit on `agent/forge-api-error-diagnostics` rather than a PR. Before and after, on the exact command from the report: ``` $ forge -R allod/tools label create bug-test -c '#d73a4a' -d 'probe' # before exit 22, no output $ forge -R allod/tools label create bug-test -c '#d73a4a' -d 'probe' # after forge: POST /repos/allod/tools/labels failed: HTTP 403: user should have a permission to write to a repo exit 22 ``` The change is at the `api()` layer, so every subcommand gains it rather than `label create` alone. `-f` is dropped, the status is appended to the body with `-w '\n%{http_code}'` and split back off, and one line goes to stderr naming the method, path, status, and the API's own `message`. Exit 22 is preserved for HTTP errors so existing callers see no contract change — including the one caller that deliberately tolerates failure — while a genuine transport failure now returns curl's real code instead of being flattened. Two things worth knowing for whoever reviews it. Some endpoints reject with an empty body: `POST /repos/{owner}/{repo}/issues/{n}/labels` returns a bare 403, so there the output is just `HTTP 403` with no trailing message. And the mock curl in the test harness needed a central status append, because real curl honours `-w` on every request while the mock only did so for the verification endpoint — a per-route append silently starved every caller that splits the status back off, which is worth remembering before adding routes. Covered by a new `tests/forge/api-errors.sh`, verified to fail against the old `api()`: with `curl -sf` restored, a 403 renders as `Issue #403: null` with a jq type error, because the error body was being consumed as data. All nine forge suites pass with the change. This also settles the authorization question the earlier comments circled: the 403 is correct behavior, not a defect. The token carries issue-write scope; the account simply has `push: false` on org repositories, and label writes need repository write. Nothing in `forge` can or should change that.
Author
Contributor

Read-only review pass done on the prepared branch; findings and resolutions below. Follow-up commit a0f9c97 sits on top of 983b96f on agent/forge-api-error-diagnostics. No blockers were found in the original commit.

The review verified byte-identical stdout, stderr, and exit codes across 148 command invocations of the eight pre-existing suites, probed the body/status split against ten body shapes, and mutation-tested the new fixture. Worth recording what that established: the split is correct on empty, multi-line, trailing-newline-or-not, and numeric-final-line bodies; the token still never reaches argv; and exit 22 survives every caller style including the one that deliberately tolerates failure.

Acted on:

  • The exit-code contract was untested. run_fail asserts only a non-zero exit, so degrading return 22 to return 1 left all nine suites green — the load-bearing half of what the docs promise was unvalidated. Added direct exit-code assertions plus mock fixtures for a transport failure and a redirect, then confirmed by mutation that each of the three now fails when broken.
  • Only 4xx and 5xx were treated as failures. Every other non-2xx passed through as success while the status sat in hand. Concretely, an http:// base URL against an https-only forge draws a 308 with an empty body, and the old test reported that as No labels in <repo> with exit 0 — a confidently wrong answer, which is worse than the silence this issue is about. Now any non-2xx reports. Redirects are included deliberately: no -L is passed, so a 3xx body is never the requested resource.
  • verify_token_http had the same defect this change removes. Its curl lacked the rc guard, so under set -euo pipefail an unreachable host produced completely empty output — auth status against a nonexistent host printed nothing at all and exited 6. It now reports curl exit 6.
  • The branch deletion suppressed its own diagnosis. That call needs repo write, making it the most likely to be refused, and it discarded stderr. Now only stdout is dropped, so the refusal reason reaches the operator alongside the existing warning.
  • The error line could be forged or misread. A message containing a newline produced two stderr lines, control bytes reached the terminal raw, the .message path was untruncated while the raw-body fallback was capped, and a JSON null body rendered as HTTP 404: null as though the API had said "null". The text is now flattened to one line, stripped of control bytes, truncated uniformly, and a null is treated as absent.
  • Documentation overclaimed. Three corrections: it is not always one line's worth of API message, the fallback is raw body rather than anything the forge said, and 22 does not mean "the forge refused it" so much as "the forge answered and the answer was not a success."
  • Two harness nits. The fixture glob issues/403* also captured four-digit issue numbers, and a comment claimed routes set a non-200 status when the documented 204 routes do not.

Not acted on, recorded instead:

  • The mock's auth gate fires on zero of 104 requests across the suite, so the discriminator change is covered by nothing. Review instrumentation confirmed the gate decisions are an identical multiset before and after, and that under a sabotaged auth header it fires on the same 4 of 13 requests, so this is a pre-existing coverage gap rather than a regression. Two residual fragilities for whoever extends the harness: the gate is keyed on the URL, so a future api GET /user would bypass it silently, and the mock models auth failure as a transport failure, which leaves the HTTP 401 path untestable.
  • The pm scripts run the same curl -sf pattern with the token in argv. Outside this issue's scope and filed separately as issue #133, along with a second defect in the same lines: 2>/dev/null || true means a failed page silently truncates the data, which turns the integrity check into a no-op that still exits 0.

shellcheck is not installed on the machine this was prepared on, so no lint pass was run. bash -n is clean on all three shell files and all nine suites pass.

Read-only review pass done on the prepared branch; findings and resolutions below. Follow-up commit `a0f9c97` sits on top of `983b96f` on `agent/forge-api-error-diagnostics`. No blockers were found in the original commit. The review verified byte-identical stdout, stderr, and exit codes across 148 command invocations of the eight pre-existing suites, probed the body/status split against ten body shapes, and mutation-tested the new fixture. Worth recording what that established: the split is correct on empty, multi-line, trailing-newline-or-not, and numeric-final-line bodies; the token still never reaches argv; and exit 22 survives every caller style including the one that deliberately tolerates failure. Acted on: - **The exit-code contract was untested.** `run_fail` asserts only a non-zero exit, so degrading `return 22` to `return 1` left all nine suites green — the load-bearing half of what the docs promise was unvalidated. Added direct exit-code assertions plus mock fixtures for a transport failure and a redirect, then confirmed by mutation that each of the three now fails when broken. - **Only 4xx and 5xx were treated as failures.** Every other non-2xx passed through as success while the status sat in hand. Concretely, an `http://` base URL against an https-only forge draws a 308 with an empty body, and the old test reported that as `No labels in <repo>` with exit 0 — a confidently wrong answer, which is worse than the silence this issue is about. Now any non-2xx reports. Redirects are included deliberately: no `-L` is passed, so a 3xx body is never the requested resource. - **`verify_token_http` had the same defect this change removes.** Its `curl` lacked the `rc` guard, so under `set -euo pipefail` an unreachable host produced completely empty output — `auth status` against a nonexistent host printed nothing at all and exited 6. It now reports `curl exit 6`. - **The branch deletion suppressed its own diagnosis.** That call needs repo write, making it the most likely to be refused, and it discarded stderr. Now only stdout is dropped, so the refusal reason reaches the operator alongside the existing warning. - **The error line could be forged or misread.** A `message` containing a newline produced two stderr lines, control bytes reached the terminal raw, the `.message` path was untruncated while the raw-body fallback was capped, and a JSON `null` body rendered as `HTTP 404: null` as though the API had said "null". The text is now flattened to one line, stripped of control bytes, truncated uniformly, and a null is treated as absent. - **Documentation overclaimed.** Three corrections: it is not always one line's worth of API message, the fallback is raw body rather than anything the forge said, and 22 does not mean "the forge refused it" so much as "the forge answered and the answer was not a success." - **Two harness nits.** The fixture glob `issues/403*` also captured four-digit issue numbers, and a comment claimed routes set a non-200 status when the documented 204 routes do not. Not acted on, recorded instead: - The mock's auth gate fires on zero of 104 requests across the suite, so the discriminator change is covered by nothing. Review instrumentation confirmed the gate decisions are an identical multiset before and after, and that under a sabotaged auth header it fires on the same 4 of 13 requests, so this is a pre-existing coverage gap rather than a regression. Two residual fragilities for whoever extends the harness: the gate is keyed on the URL, so a future `api GET /user` would bypass it silently, and the mock models auth failure as a transport failure, which leaves the HTTP 401 path untestable. - The `pm` scripts run the same `curl -sf` pattern with the token in argv. Outside this issue's scope and filed separately as issue #133, along with a second defect in the same lines: `2>/dev/null || true` means a failed page silently truncates the data, which turns the integrity check into a no-op that still exits 0. `shellcheck` is not installed on the machine this was prepared on, so no lint pass was run. `bash -n` is clean on all three shell files and all nine suites pass.
Owner

is this fixed now? can we close this issue?

is this fixed now? can we close this issue?
Member

The Go forge that is now authoritative satisfies this issue's acceptance criteria. Non-2xx responses report the method, path, HTTP status, and a sanitized API message or response-body fallback while preserving exit 22; transport failures keep their own mapped exit code. Color handling accepts and normalizes both #FF0000 and FF0000, and rejects malformed values before the request.

The retained tests pin exact API-error output, redirects, empty and malformed response bodies, transport failures, both accepted color forms, and invalid color validation (cmd/forge/harness_test.go and cmd/forge/api_errors_test.go). PR #157 removed the old Bash implementation that still contained the silent-failure path, after the Go package had passed live deployment witnesses on both allod-dev and nexus.

The Go `forge` that is now authoritative satisfies this issue's acceptance criteria. Non-2xx responses report the method, path, HTTP status, and a sanitized API message or response-body fallback while preserving exit 22; transport failures keep their own mapped exit code. Color handling accepts and normalizes both `#FF0000` and `FF0000`, and rejects malformed values before the request. The retained tests pin exact API-error output, redirects, empty and malformed response bodies, transport failures, both accepted color forms, and invalid color validation (`cmd/forge/harness_test.go` and `cmd/forge/api_errors_test.go`). PR #157 removed the old Bash implementation that still contained the silent-failure path, after the Go package had passed live deployment witnesses on both `allod-dev` and `nexus`.
Sign in to join this conversation.
No description provided.