Silent label no-op in edit_issue and create_issue -- the forgejo.ts fix was never ported to the Python client #18

Open
opened 2026-10-01 15:26:38 +02:00 by kade · 1 comment
Owner

Summary

~/src/forgejo-client carries the same silent label no-op that was found and fixed in the
TypeScript forgejo.ts on 2026-10-01. The archived brief scoped itself to
packages/coding-agent/src/core/tools/forgejo.ts; this Python client is a different repo and
was never in it. The companion zero-tests note for this repo has zero occurrences of the string
label — nobody has examined it.

Both defects share a shape: accept a labels argument, do nothing with it, return success.

1. edit_issue passes labels through raw

src/forgejo_client/modules/issues.py:189

if labels:
    data['labels'] = labels

This is a PATCH /repos/{owner}/{repo}/issues/{n}. Two independent problems:

  • Forgejo's issue-edit endpoint does not accept a labels field at all. Measured against
    git.sly.so on 2026-10-01: PATCH with labels as names returns 200 and changes nothing;
    the same request with numeric label IDs returns 200 and changes nothing.
  • Forgejo wants int64 label IDs, not names, so even a correct endpoint would get strings.

update_issue (:199-202) and close_issue (:204) both delegate here, so the entire update
surface inherits the no-op.

This is verbatim the defect the archived brief recorded as "a no-op that returns success" and
told the implementer to fix by either routing through POST /issues/{n}/labels or rejecting the
field outright. Both resolutions remain available here.

2. create_issue silently drops labels that do not resolve

src/forgejo_client/modules/issues.py:88

existing_labels = self.client.labels.get_labels()
label_ids = []
for label_name in labels:
    for label in existing_labels:
        if label.get('name') == label_name:
            label_ids.append(label.get('id'))
            break
if label_ids:
    data['labels'] = label_ids

The name→ID conversion works, but there is no else. A name that resolves to nothing is
dropped with no error and no warning. Two further holes in the same block:

  • If some names resolve, the request goes through with the resolvable subset — a partial
    success that looks complete.
  • If none resolve, data['labels'] is never set and the issue is created unlabelled.

This was observed live, not inferred. AGENTS.md §9 documents four pkg:* labels
(pkg:agent, pkg:ai, pkg:coding-agent, pkg:tui). kade/r had three of the four —
pkg:ai did not exist. Passing --label pkg:ai therefore succeeded and applied nothing. The
missing label was invisible precisely because the failure was silent; it was found only by
listing the repo's labels after the fact. pkg:ai has since been created (id 637).

This repo currently has no pkg:* labels at all, so any attempt to label an issue here with
the documented names will silently drop every one of them.

The comment directly above this block (issues.py:85-87) reads "For now, we'll skip labels to
avoid the API error / TODO: Implement label name to ID conversion"
— stale, and contradicted by
the twelve lines of working conversion code beneath it.

3. LabelsModule is complete and unreachable from the CLI

src/forgejo_client/modules/labels.py implements get_labels, create_label, update_label,
delete_label and ensure_labels. forgejo_client.cli exposes no labels subcommand, so
none of it is reachable from the CLI. ensure_labels is dead code.

This settles an open question from the archived brief, which asked whether label creation
belongs in the tool and concluded "my recommendation is that it belongs". For the Python client
it already exists and is simply not wired up.

Working path today, using the library directly:

from forgejo_client.client import ForgejoClient
from forgejo_client.config import ForgejoConfig
client = ForgejoClient(ForgejoConfig(
    base_url="https://git.sly.so",
    token=os.environ["FORGEJO_TOKEN"],
    repo_owner=owner, repo_name=repo,
    cache_enabled=False,
))
client.labels.create_label("pkg:ai", "1d76db", "Affected package: ai")

4. Duplicate label names in this repo

While enumerating labels: bug exists as both id 384 and id 212, and ci/cd as both 421 and
219. Name→ID resolution takes the first match in list order, so which label an issue receives is
not determined by its name. Worth deduplicating separately.

The working procedure, for reference

  1. GET /api/v1/repos/{owner}/{repo}/labels — enumerate to obtain numeric IDs.
  2. POST /api/v1/repos/{owner}/{repo}/issues/{n}/labels with {"labels":[id,...]} — additive.
    This is the only call that attaches a label to an existing issue.
  3. PUT on the same path — replace semantics.
  4. POST /api/v1/repos/{owner}/{repo}/labels with {name,color,description} — creates a label.

forgejo.ts:308-311 states the constraint in the harness tool: "Label operations cannot route
through forgejo-cli: its parser exposes only issues create|update|list|comment, search,
wiki sync and cache, so there is no argv that reaches /issues/{index}/labels."

Definition of done

  • edit_issue either resolves names→IDs and routes through POST /issues/{n}/labels, or
    rejects labels with an error naming that route. Accepting the field and doing nothing is
    not an acceptable resolution.
  • create_issue raises on any unresolvable name, listing the unknown names and the ones that
    do exist. forgejo.ts already has this as throwUnknownLabels() — port it. The partial case
    must not be a silent success either.
  • A labels CLI subcommand exposing the existing LabelsModule.
  • Stale comment at issues.py:85-87 corrected.

Tests

This client has no test suite. The archived zero-tests note covers a different defect — a
src/ directory shadowing the installed package — so "no tests" is not solved here.

Per the anti-vacuity rule that the TS brief was held to: a test asserting only that
POST /issues/{n}/labels was called passes against the broken code, because the original bug
was a different endpoint being called successfully. The test that catches it asserts that an
unresolvable label name produces an error and sends no request at all. Same shape as
fails on an unresolvable label name and sends no mutation at all in the TS suite.

Cross-references

  • Archived brief that fixed the TypeScript side:
    ~/.todos/archive/ACTIONABLE-2026-09-30-forge-forgejo-label-support.md (verified 2026-10-01;
    forgejo.ts is 1208 lines with 67 occurrences of labels)
  • Zero-tests note for this repo: ~/.todos/archive/ACTIONABLE-2026-09-28-forgejo-client-zero-tests-ran-src-shadowing.md
  • Full write-up with measurements:
    ~/.todos/ACTIONABLE-2026-10-01-forgejo-client-label-no-op-and-the-missing-pkg-ai-label.md
  • Live instance of the drop: kade/r shipped three of four documented pkg:* labels because
    the fourth's absence was invisible.
## Summary `~/src/forgejo-client` carries the same silent label no-op that was found and fixed in the TypeScript `forgejo.ts` on 2026-10-01. The archived brief scoped itself to `packages/coding-agent/src/core/tools/forgejo.ts`; this Python client is a different repo and was never in it. The companion zero-tests note for this repo has zero occurrences of the string `label` — nobody has examined it. Both defects share a shape: accept a `labels` argument, do nothing with it, return success. ## 1. `edit_issue` passes labels through raw `src/forgejo_client/modules/issues.py:189` ```python if labels: data['labels'] = labels ``` This is a `PATCH /repos/{owner}/{repo}/issues/{n}`. Two independent problems: - Forgejo's issue-edit endpoint **does not accept a `labels` field at all**. Measured against `git.sly.so` on 2026-10-01: `PATCH` with labels as names returns **200** and changes nothing; the same request with numeric label IDs returns **200** and changes nothing. - Forgejo wants **int64 label IDs**, not names, so even a correct endpoint would get strings. `update_issue` (`:199-202`) and `close_issue` (`:204`) both delegate here, so the entire update surface inherits the no-op. This is verbatim the defect the archived brief recorded as *"a no-op that returns success"* and told the implementer to fix by either routing through `POST /issues/{n}/labels` or rejecting the field outright. Both resolutions remain available here. ## 2. `create_issue` silently drops labels that do not resolve `src/forgejo_client/modules/issues.py:88` ```python existing_labels = self.client.labels.get_labels() label_ids = [] for label_name in labels: for label in existing_labels: if label.get('name') == label_name: label_ids.append(label.get('id')) break if label_ids: data['labels'] = label_ids ``` The name→ID conversion works, but there is **no `else`**. A name that resolves to nothing is dropped with no error and no warning. Two further holes in the same block: - If *some* names resolve, the request goes through with the resolvable subset — a partial success that looks complete. - If *none* resolve, `data['labels']` is never set and the issue is created unlabelled. **This was observed live, not inferred.** `AGENTS.md` §9 documents four `pkg:*` labels (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`). `kade/r` had three of the four — `pkg:ai` did not exist. Passing `--label pkg:ai` therefore succeeded and applied nothing. The missing label was invisible precisely because the failure was silent; it was found only by listing the repo's labels after the fact. `pkg:ai` has since been created (id 637). This repo currently has **no `pkg:*` labels at all**, so any attempt to label an issue here with the documented names will silently drop every one of them. The comment directly above this block (`issues.py:85-87`) reads *"For now, we'll skip labels to avoid the API error / TODO: Implement label name to ID conversion"* — stale, and contradicted by the twelve lines of working conversion code beneath it. ## 3. `LabelsModule` is complete and unreachable from the CLI `src/forgejo_client/modules/labels.py` implements `get_labels`, `create_label`, `update_label`, `delete_label` and `ensure_labels`. `forgejo_client.cli` exposes **no `labels` subcommand**, so none of it is reachable from the CLI. `ensure_labels` is dead code. This settles an open question from the archived brief, which asked whether label *creation* belongs in the tool and concluded *"my recommendation is that it belongs"*. For the Python client it already exists and is simply not wired up. Working path today, using the library directly: ```python from forgejo_client.client import ForgejoClient from forgejo_client.config import ForgejoConfig client = ForgejoClient(ForgejoConfig( base_url="https://git.sly.so", token=os.environ["FORGEJO_TOKEN"], repo_owner=owner, repo_name=repo, cache_enabled=False, )) client.labels.create_label("pkg:ai", "1d76db", "Affected package: ai") ``` ## 4. Duplicate label names in this repo While enumerating labels: **`bug` exists as both id 384 and id 212**, and `ci/cd` as both 421 and 219. Name→ID resolution takes the first match in list order, so which label an issue receives is not determined by its name. Worth deduplicating separately. ## The working procedure, for reference 1. `GET /api/v1/repos/{owner}/{repo}/labels` — enumerate to obtain numeric IDs. 2. `POST /api/v1/repos/{owner}/{repo}/issues/{n}/labels` with `{"labels":[id,...]}` — **additive**. This is the only call that attaches a label to an existing issue. 3. `PUT` on the same path — replace semantics. 4. `POST /api/v1/repos/{owner}/{repo}/labels` with `{name,color,description}` — creates a label. `forgejo.ts:308-311` states the constraint in the harness tool: *"Label operations cannot route through forgejo-cli: its parser exposes only `issues create|update|list|comment`, `search`, `wiki sync` and `cache`, so there is no argv that reaches `/issues/{index}/labels`."* ## Definition of done - `edit_issue` either resolves names→IDs and routes through `POST /issues/{n}/labels`, or **rejects** `labels` with an error naming that route. Accepting the field and doing nothing is not an acceptable resolution. - `create_issue` raises on **any** unresolvable name, listing the unknown names and the ones that do exist. `forgejo.ts` already has this as `throwUnknownLabels()` — port it. The partial case must not be a silent success either. - A `labels` CLI subcommand exposing the existing `LabelsModule`. - Stale comment at `issues.py:85-87` corrected. ## Tests This client has **no test suite**. The archived zero-tests note covers a *different* defect — a `src/` directory shadowing the installed package — so "no tests" is not solved here. Per the anti-vacuity rule that the TS brief was held to: a test asserting only that `POST /issues/{n}/labels` was called **passes against the broken code**, because the original bug was a *different* endpoint being called successfully. The test that catches it asserts that an unresolvable label name **produces an error and sends no request at all**. Same shape as `fails on an unresolvable label name and sends no mutation at all` in the TS suite. ## Cross-references - Archived brief that fixed the TypeScript side: `~/.todos/archive/ACTIONABLE-2026-09-30-forge-forgejo-label-support.md` (verified 2026-10-01; `forgejo.ts` is 1208 lines with 67 occurrences of `labels`) - Zero-tests note for this repo: `~/.todos/archive/ACTIONABLE-2026-09-28-forgejo-client-zero-tests-ran-src-shadowing.md` - Full write-up with measurements: `~/.todos/ACTIONABLE-2026-10-01-forgejo-client-label-no-op-and-the-missing-pkg-ai-label.md` - Live instance of the drop: `kade/r` shipped three of four documented `pkg:*` labels because the fourth's absence was invisible.
Author
Owner

Progress update — the two silent no-ops are fixed

~/src/forgejo-client main, uncommitted. cli.py and tests/test_cli.py in the
working tree are another agent's in-flight work and are not touched here.

Definition of done, item by item

edit_issue rejects labels — done. Raises NotImplementedError naming
POST {issues_url}/{n}/labels. update_issue and close_issue inherit it; both are
thin delegates and never had a working label path.

I took the reject branch rather than routing through POST /issues/{n}/labels
because rejecting cannot rot: if Forgejo ever starts honouring the field, the error
becomes visibly wrong and gets fixed. Silently relabelling every caller would have
changed behaviour for anyone currently relying on the no-op.

create_issue raises on unknown names — done, with the partial case covered too.
New _resolve_label_ids raises on:

  • an unknown name, listing the unknown names and the available ones;
  • a duplicated name, reporting every id sharing it, instead of taking whichever
    the API returned first;
  • a partially-resolvable request — ['bug', 'ghost'] does not create the issue.

Stale comment at issues.py:85-87 — done. Removed.

One addition the original report missed

The resolver reads labels with use_cache=False, and this is load-bearing rather
than tidy. cache_ttl is 3600s. A label created minutes ago would resolve as
unknown, so the new check would fail spuriously and every caller would learn to
retry around it — converting a silent drop into a loud wrong answer.

ensure_labels had the same latent problem: a stale read reports a label that exists
as missing and then attempts to create it a second time. It now reads uncached too.

Tests — 7 regressions, and proof they are not vacuous

tests/test_issues.py::TestLabelResolution, following the anti-vacuity style already
established by TestIssueReadCaching in the same file.

The first attempt at proving they guard anything passed against the pre-fix code,
which is the failure mode this discipline exists to catch. PYTHONPATH did not win:
conftest.py:20 does sys.path.insert(0, .../src), putting the fixed source ahead of
it, and --confcutdir did not stop that. Building a self-contained copy whose own
conftest.py pointed at the pre-fix issues.py gave the honest answer:

6 failed, 9 passed

The seventh passes by design — it asserts edit_issue still edits title/body/state,
i.e. that the fix did not break the fields that do work.

Full suite: 285 passed, 1 skipped, no regressions.

The repository's own label set was part of the bug

kade/forgejo-client had 55 labels and none of the four pkg:*, so any
--label pkg:* there dropped every one. It also had seven duplicate names, which
is precisely the condition the new ambiguity check exists to catch:

bug (384/212), enhancement (385/171), documentation (387/214), ci/cd
(390/220), dependencies (391/223), feature (386/213), tracking (394/215).

Ids 212–223 were a bulk import with templated <name> issues descriptions; 384–394
is a curated set with real descriptions and GitHub-standard colours. Migrated to the
curated set, four pkg:* created (638–641), each removal gated on the canonical
already being attached to that issue. A POST /issues/{n}/labels is additive and does
not remove the loser — the per-issue assertion caught that before any label object
was deleted.

Result: 55 → 52 labels, 17 → 17 issues, no issue lost a label, zero duplicates.

pyproject.toml has an empty [tool.ruff] section, yet issues.py at HEAD had 63 violations

Worth knowing before assuming a lint failure is yours. issues.py and labels.py are
now clean under EXE001,I001,FA100,E,W,F — the set the harness edit tool enforces —
and under --select ALL apart from two deliberate exceptions: the optional-sklearn
imports, which must stay function-local or the package will not import without sklearn,
and the FBT001/FBT002 boolean-positional-argument warnings, which would require
breaking the public signature of get_issues, find_similar_issues, search_issues
and search_with_filters. Every in-repo caller already passes those by keyword, so it
is a one-line change if breaking external callers is acceptable.

Still open

  • A labels CLI subcommand wrapping the existing, complete LabelsModule, so
    ensure_labels is reachable and label creation stops being a manual step.
  • delete_label passes a label name to DELETE .../labels/{name}. Not verified
    against Forgejo and deliberately not changed on a guess.

Full write-up: ~/.todos/ACTIONABLE-2026-10-01-forgejo-client-label-no-op-and-the-missing-pkg-ai-label.md

## Progress update — the two silent no-ops are fixed `~/src/forgejo-client` `main`, uncommitted. `cli.py` and `tests/test_cli.py` in the working tree are another agent's in-flight work and are not touched here. ### Definition of done, item by item **`edit_issue` rejects `labels` — done.** Raises `NotImplementedError` naming `POST {issues_url}/{n}/labels`. `update_issue` and `close_issue` inherit it; both are thin delegates and never had a working label path. I took the *reject* branch rather than routing through `POST /issues/{n}/labels` because rejecting cannot rot: if Forgejo ever starts honouring the field, the error becomes visibly wrong and gets fixed. Silently relabelling every caller would have changed behaviour for anyone currently relying on the no-op. **`create_issue` raises on unknown names — done**, with the partial case covered too. New `_resolve_label_ids` raises on: - an unknown name, listing the unknown names and the available ones; - a **duplicated name**, reporting every id sharing it, instead of taking whichever the API returned first; - a partially-resolvable request — `['bug', 'ghost']` does not create the issue. **Stale comment at `issues.py:85-87` — done.** Removed. ### One addition the original report missed The resolver reads labels with `use_cache=False`, and this is load-bearing rather than tidy. `cache_ttl` is **3600s**. A label created minutes ago would resolve as *unknown*, so the new check would fail spuriously and every caller would learn to retry around it — converting a silent drop into a loud wrong answer. `ensure_labels` had the same latent problem: a stale read reports a label that exists as missing and then attempts to create it a second time. It now reads uncached too. ### Tests — 7 regressions, and proof they are not vacuous `tests/test_issues.py::TestLabelResolution`, following the anti-vacuity style already established by `TestIssueReadCaching` in the same file. The first attempt at proving they guard anything **passed against the pre-fix code**, which is the failure mode this discipline exists to catch. `PYTHONPATH` did not win: `conftest.py:20` does `sys.path.insert(0, .../src)`, putting the fixed source ahead of it, and `--confcutdir` did not stop that. Building a self-contained copy whose own `conftest.py` pointed at the pre-fix `issues.py` gave the honest answer: ``` 6 failed, 9 passed ``` The seventh passes by design — it asserts `edit_issue` still edits title/body/state, i.e. that the fix did not break the fields that do work. Full suite: **285 passed, 1 skipped**, no regressions. ### The repository's own label set was part of the bug `kade/forgejo-client` had **55 labels and none of the four `pkg:*`**, so any `--label pkg:*` there dropped every one. It also had **seven duplicate names**, which is precisely the condition the new ambiguity check exists to catch: `bug` (384/212), `enhancement` (385/171), `documentation` (387/214), `ci/cd` (390/220), `dependencies` (391/223), `feature` (386/213), `tracking` (394/215). Ids 212–223 were a bulk import with templated `<name> issues` descriptions; 384–394 is a curated set with real descriptions and GitHub-standard colours. Migrated to the curated set, four `pkg:*` created (638–641), each removal gated on the canonical already being attached to that issue. A `POST /issues/{n}/labels` is additive and does *not* remove the loser — the per-issue assertion caught that before any label object was deleted. Result: 55 → 52 labels, 17 → 17 issues, **no issue lost a label**, zero duplicates. ### `pyproject.toml` has an empty `[tool.ruff]` section, yet `issues.py` at HEAD had 63 violations Worth knowing before assuming a lint failure is yours. `issues.py` and `labels.py` are now clean under `EXE001,I001,FA100,E,W,F` — the set the harness edit tool enforces — and under `--select ALL` apart from two deliberate exceptions: the optional-`sklearn` imports, which must stay function-local or the package will not import without sklearn, and the `FBT001`/`FBT002` boolean-positional-argument warnings, which would require breaking the public signature of `get_issues`, `find_similar_issues`, `search_issues` and `search_with_filters`. Every in-repo caller already passes those by keyword, so it is a one-line change if breaking external callers is acceptable. ### Still open - A `labels` CLI subcommand wrapping the existing, complete `LabelsModule`, so `ensure_labels` is reachable and label creation stops being a manual step. - `delete_label` passes a label *name* to `DELETE .../labels/{name}`. Not verified against Forgejo and deliberately not changed on a guess. Full write-up: `~/.todos/ACTIONABLE-2026-10-01-forgejo-client-label-no-op-and-the-missing-pkg-ai-label.md`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kade/forgejo-client#18
No description provided.