Reconciler can never clear Status/Blocked when every blocker is in another repo #11

Open
opened 2026-09-08 08:34:22 +00:00 by claude · 4 comments
Member

sync_blocked_label.py cannot take Status/Blocked off an issue whose blockers all live in another repo. It adds correctly and it never removes, so the label sticks forever and the backlog sweep keeps reporting the issue as blocked after it is free.

The natural experiment, 2026-09-08

Two issues had every blocker closed within minutes of each other. The reconciler runs on a */15 cron in each repo and reported success on every run.

issue blockers where label after 4 scheduled runs
cfbypass#1 its own sub-issues, all closed same repo removed automatically at ~07:44
weblib-archive#30 cfbypass#1, cfbypass#33, both closed cross repo still Status/Blocked at 08:30

Runs 1171/1176/1179/1182 (07:45, 08:00, 08:15, 08:30) all green. HANDS_OFF does not apply — #30 carries Kind/Feature, Priority/Low, Status/Blocked, Type/Epic, and neither Status/On Hold nor Status/Abandoned. I removed the label by hand to unstick the board, so the reproduction is gone from #30; re-create it by adding a cross-repo dependency that is already closed.

Two candidate mechanisms, both consistent, not yet distinguished

The Actions token is scoped to the repo it runs in, so it cannot read cfbypass while running in weblib-archive. Either:

  1. Gitea omits the cross-repo entries from repos/{repo}/issues/{n}/dependencies for a token that cannot see the other repo. deps then comes back empty and the function takes its "no edges, no opinion" path and continues — never touching the label. I think this is the likelier one, because it also explains the silence: an empty list produces no log line at all.
  2. The entries are returned without a resolvable state. Then d.get("state") != "closed" is true, every cross-repo blocker counts as open forever, and the label is re-asserted rather than removed.

They are easy to tell apart: log len(deps) and the raw entries for one known cross-repo case under the Actions token. Worth doing before fixing, since the two need different fixes — 1 needs a token that can see the other repo, 2 needs the state resolved by querying the blocker's own repo directly.

Why this is the fail-safe from #19 biting back

This is not a regression, it is the deliberate trade from cfbypass#19. Before that fix the rule was "only a positively open blocker blocks", and an unreadable cross-repo entry read as not-open — so the runner took the label off while cfbypass#1 was still open. The fix inverted it to "only a positively closed blocker is done", which is the right direction: a false "unblocked" sends someone to work on something that cannot be worked on, while a false "blocked" only hides available work.

But the inverted rule has no exit. Under mechanism 2 a cross-repo blocker can never be confirmed closed, so the label is permanent. #19 fixed the dangerous half and left the stuck half, and nothing measured the stuck half because the only cross-repo blockers at the time were still open.

Why it matters beyond one label

The backlog sweep in docs/UNSUPERVISED.md keys on Status/Blocked and on the dependency graph. A permanently-labelled issue is one that never comes back as workable — the exact failure bit called out on 2026-09-07: "they were unblocked/etc. and you should have started working on them". weblib-archive#30 is the largest item on the board and it would have stayed invisible.

Note the cross-repo case is not an edge case here: weblib-archive#30 → cfbypass#1, #29 → weblib-fs#17, #57 → #30. The dependency graph in this org is mostly cross-repo.

Adjacent

Whatever the fix, it wants a test, and #9 is that nothing runs this repo's tests at all. The reporting side of the same problem: a job that does nothing still reports success, which is how this survived four green runs.

`sync_blocked_label.py` cannot take `Status/Blocked` **off** an issue whose blockers all live in another repo. It adds correctly and it never removes, so the label sticks forever and the backlog sweep keeps reporting the issue as blocked after it is free. ## The natural experiment, 2026-09-08 Two issues had every blocker closed within minutes of each other. The reconciler runs on a `*/15` cron in each repo and reported **success** on every run. | issue | blockers | where | label after 4 scheduled runs | |---|---|---|---| | cfbypass#1 | its own sub-issues, all closed | **same repo** | **removed automatically** at ~07:44 | | weblib-archive#30 | cfbypass#1, cfbypass#33, both closed | **cross repo** | **still `Status/Blocked`** at 08:30 | Runs 1171/1176/1179/1182 (07:45, 08:00, 08:15, 08:30) all green. `HANDS_OFF` does not apply — #30 carries `Kind/Feature`, `Priority/Low`, `Status/Blocked`, `Type/Epic`, and neither `Status/On Hold` nor `Status/Abandoned`. I removed the label by hand to unstick the board, so **the reproduction is gone from #30**; re-create it by adding a cross-repo dependency that is already closed. ## Two candidate mechanisms, both consistent, not yet distinguished The Actions token is scoped to the repo it runs in, so it cannot read cfbypass while running in weblib-archive. Either: 1. **Gitea omits the cross-repo entries** from `repos/{repo}/issues/{n}/dependencies` for a token that cannot see the other repo. `deps` then comes back empty and the function takes its "no edges, no opinion" path and `continue`s — never touching the label. **I think this is the likelier one**, because it also explains the silence: an empty list produces no log line at all. 2. **The entries are returned without a resolvable `state`.** Then `d.get("state") != "closed"` is true, every cross-repo blocker counts as open forever, and the label is re-asserted rather than removed. They are easy to tell apart: log `len(deps)` and the raw entries for one known cross-repo case under the Actions token. Worth doing before fixing, since the two need different fixes — 1 needs a token that can see the other repo, 2 needs the state resolved by querying the blocker's own repo directly. ## Why this is the fail-safe from #19 biting back This is not a regression, it is the deliberate trade from cfbypass#19. Before that fix the rule was "only a positively *open* blocker blocks", and an unreadable cross-repo entry read as not-open — so the runner **took the label off while cfbypass#1 was still open**. The fix inverted it to "only a positively *closed* blocker is done", which is the right direction: a false "unblocked" sends someone to work on something that cannot be worked on, while a false "blocked" only hides available work. But the inverted rule has no exit. Under mechanism 2 a cross-repo blocker can never be confirmed closed, so the label is permanent. #19 fixed the dangerous half and left the stuck half, and nothing measured the stuck half because the only cross-repo blockers at the time were still open. ## Why it matters beyond one label The backlog sweep in `docs/UNSUPERVISED.md` keys on `Status/Blocked` and on the dependency graph. A permanently-labelled issue is one that never comes back as workable — the exact failure bit called out on 2026-09-07: *"they were unblocked/etc. and you should have started working on them"*. weblib-archive#30 is the largest item on the board and it would have stayed invisible. Note the cross-repo case is not an edge case here: weblib-archive#30 → cfbypass#1, #29 → weblib-fs#17, #57 → #30. The dependency graph in this org is mostly cross-repo. ## Adjacent Whatever the fix, it wants a test, and #9 is that nothing runs this repo's tests at all. The reporting side of the same problem: a job that does nothing still reports success, which is how this survived four green runs.
claude added the
Priority
High
2
Type
Bug
labels 2026-09-08 08:34:35 +00:00
Author
Member

Nothing needed from you here beyond the decision already asked in #13

Mechanism established (not mechanism 1, and not quite the plain mechanism 2
from the original framing, but the sharper version): under a real Actions
token scoped to weblib-archive, a cross-repo dependency entry is never
omitted (len(deps) is never 0), but comes back as Gitea's Go zero-value
Issue stub -- id: 0, state: "", repository: null. The old
state != "closed" check read that blank state as "still open" forever,
which is why the label could never come off, and -- found live on
weblib-archive#30, 2026-09-09 -- why it started getting reasserted over a
hand-removal every 15 minutes.

Fix, tests (12, offline, proved by fault injection), and the full evidence
trail are in #13. That PR also carries a decision for bit: whether to add
a broader-scoped credential so cross-repo state can be positively confirmed,
rather than only ever reported as unknown. Probed directly: the
repo-scoped token gets 404 on GET repos/weblib/cfbypass/issues/1 and on the
repo itself, so this is a hard wall, not a permissions nuance -- and not
something to work around with a wider token without that being bit's call.

Also noticed while chasing this, not fixed in #13: main in both
weblib-archive and cfbypass is currently running each repo's own
pre-migration local copy of this script, which can no longer find
Status/Blocked at all since labels moved to the org -- every scheduled run
on main prints skipped: no 'Status/Blocked' label in this repo and
touches nothing, for any issue. Filing separately per repo.

## Nothing needed from you here beyond the decision already asked in #13 Mechanism established (not mechanism 1, and not quite the plain mechanism 2 from the original framing, but the sharper version): under a real Actions token scoped to weblib-archive, a cross-repo dependency entry is never omitted (`len(deps)` is never 0), but comes back as Gitea's Go zero-value `Issue` stub -- `id: 0, state: "", repository: null`. The old `state != "closed"` check read that blank state as "still open" forever, which is why the label could never come off, and -- found live on weblib-archive#30, 2026-09-09 -- why it started getting *reasserted* over a hand-removal every 15 minutes. Fix, tests (12, offline, proved by fault injection), and the full evidence trail are in **#13**. That PR also carries a decision for bit: whether to add a broader-scoped credential so cross-repo state can be positively confirmed, rather than only ever reported as `unknown`. Probed directly: the repo-scoped token gets 404 on `GET repos/weblib/cfbypass/issues/1` and on the repo itself, so this is a hard wall, not a permissions nuance -- and not something to work around with a wider token without that being bit's call. Also noticed while chasing this, not fixed in #13: `main` in both weblib-archive and cfbypass is currently running each repo's own pre-migration local copy of this script, which can no longer find `Status/Blocked` at all since labels moved to the org -- every scheduled run on `main` prints `skipped: no 'Status/Blocked' label in this repo` and touches nothing, for any issue. Filing separately per repo.
Author
Member

Update: PR #13 mitigates this, does not close it

Full evidence and the acceptance test are in #13's description now
(rewritten after a first pass got the acceptance criterion wrong -- see the
PR for the corrected version). Short version: do not close this issue when
#13 merges.
#13 stops the reconciler from silently re-asserting a
cross-repo Status/Blocked over a hand-removal (confirmed live on
weblib-archive#30, 2026-09-09) and makes it log the issue number instead of
guessing -- but weblib-archive's own scoped Actions token genuinely cannot
resolve a cross-repo blocker's state (GET repos/weblib/cfbypass/issues/1 ->
404 under that token), so the label still cannot be positively cleared. This
issue's title is still true after #13 merges; #13 just replaces "silently
wrong" with "loudly unknown".

The remaining decision -- whether to add a broader-scoped credential so
cross-repo state can be positively confirmed -- is posed in #13 for bit.

The two follow-up issues mentioned in my last comment are filed:
weblib-archive#70 and cfbypass#35, both assigned to bit (each repo's main
runs a pre-org-label-migration local copy of this script and has been a
complete no-op, in both directions, since labels moved to the org).

## Update: PR #13 mitigates this, does not close it Full evidence and the acceptance test are in **#13**'s description now (rewritten after a first pass got the acceptance criterion wrong -- see the PR for the corrected version). Short version: **do not close this issue when #13 merges.** #13 stops the reconciler from silently re-asserting a cross-repo `Status/Blocked` over a hand-removal (confirmed live on weblib-archive#30, 2026-09-09) and makes it log the issue number instead of guessing -- but weblib-archive's own scoped Actions token genuinely cannot resolve a cross-repo blocker's state (`GET repos/weblib/cfbypass/issues/1` -> 404 under that token), so the label still cannot be positively cleared. This issue's title is still true after #13 merges; #13 just replaces "silently wrong" with "loudly unknown". The remaining decision -- whether to add a broader-scoped credential so cross-repo state *can* be positively confirmed -- is posed in #13 for bit. The two follow-up issues mentioned in my last comment are filed: weblib-archive#70 and cfbypass#35, both assigned to bit (each repo's `main` runs a pre-org-label-migration local copy of this script and has been a complete no-op, in both directions, since labels moved to the org).
bit closed this issue 2026-09-09 16:35:13 +00:00
Author
Member

Reopening: this closed by keyword accident, not by decision

#13 is merged and that is correct, but this issue is not fixed and both the PR and its description say so.

What closed it: the PR title was "Mitigate the stuck/reasserted cross-repo Status/Blocked (does not fully fix #11)". Gitea's merge commit carries the PR title, and its keyword matcher reads fix #11 out of that string — the "does not fully" in front of it is not parsed. The close is timestamped 16:35:13, identical to the merge's commit_ref, which is the signature of an automatic close rather than a deliberate one.

So the phrasing chosen specifically to be honest about scope is what triggered the closure. Worth knowing generally: a negation does not defuse a closing keyword. Removing Closes from the body, as was done here deliberately, does not help if the title still contains fix #N, close #N, resolve #N or any of their inflections. Keep the issue number out of the title, or refer to it as "weblib-ci#11" / "issue 11" with no verb in front.

What actually remains open

The reconciler no longer guesses: an unresolvable cross-repo blocker is reported as unknown and no label is touched. That replaces silently wrong with loudly unknown, which is the improvement #13 shipped.

It does not let the label be cleared. weblib-archive's Actions token cannot read cfbypass at all (repos/weblib/cfbypass/issues/1 → 404), so weblib-archive#30's Status/Blocked is still stuck and will stay stuck. Clearing it needs a credential that can read across repos — a decision for @bit, posed in #13 and not taken unilaterally.

Reopening so that decision does not get lost. @bit, if you would rather track it elsewhere and close this, please do — I am restoring the state #13 intended, not overriding you.

## Reopening: this closed by keyword accident, not by decision #13 is merged and that is correct, but **this issue is not fixed** and both the PR and its description say so. What closed it: the PR title was *"Mitigate the stuck/reasserted cross-repo Status/Blocked (**does not fully fix #11**)"*. Gitea's merge commit carries the PR title, and its keyword matcher reads `fix #11` out of that string — **the "does not fully" in front of it is not parsed.** The close is timestamped 16:35:13, identical to the merge's `commit_ref`, which is the signature of an automatic close rather than a deliberate one. So the phrasing chosen specifically to be honest about scope is what triggered the closure. Worth knowing generally: **a negation does not defuse a closing keyword.** Removing `Closes` from the body, as was done here deliberately, does not help if the *title* still contains `fix #N`, `close #N`, `resolve #N` or any of their inflections. Keep the issue number out of the title, or refer to it as "weblib-ci#11" / "issue 11" with no verb in front. ### What actually remains open The reconciler no longer guesses: an unresolvable cross-repo blocker is reported as **unknown** and no label is touched. That replaces *silently wrong* with *loudly unknown*, which is the improvement #13 shipped. It does **not** let the label be cleared. weblib-archive's Actions token cannot read cfbypass at all (`repos/weblib/cfbypass/issues/1` → 404), so weblib-archive#30's `Status/Blocked` is still stuck and will stay stuck. Clearing it needs a credential that can read across repos — **a decision for @bit**, posed in #13 and not taken unilaterally. Reopening so that decision does not get lost. @bit, if you would rather track it elsewhere and close this, please do — I am restoring the state #13 intended, not overriding you.
claude reopened this issue 2026-09-09 16:36:07 +00:00
Author
Member

Refinement: it is not "cross-repo", it is "invisible to this token"

Live evidence from today, which narrows this issue's real scope.

weblib-viewer#25 was created with a single dependency on fdroid-repo-ci#12, a different repo — and the reconciler correctly added Status/Blocked. Under the merged fix, an unresolvable blocker yields "unknown, change nothing", so an add means the entry resolved properly.

Why it resolved: fdroid-repo-ci is public. A repo-scoped Actions token can read a public repo, so the dependency comes back fully populated (id=204, state="open") and the ordinary open-blocker path applies.

Confirmed for all six:

repo private
fdroid-repo-ci, weblib-ci no
cfbypass, weblib-fs, weblib-archive, weblib-viewer yes

So the zero-value stub is a visibility artifact, not a cross-repo one:

  • blocker in a public repo → resolves under any token → reconciler works normally, cross-repo or not
  • blocker in a private repo the token is not scoped to → zero-value stub → unknown

That explains the whole pattern without new hypotheses. weblib-archive#30 is blocked by cfbypass (private) → stuck. weblib-viewer#25 is blocked by fdroid-repo-ci (public) → works.

This issue's title is therefore too broad — "every blocker is in another repo" is not the condition; "every blocker is in a repo this token cannot see" is. The credential decision is unchanged, but it is narrower than it looked: it only has to cover the four private repos, and only for blockers among them.

No action needed from @bit beyond the credential decision already posed.

## Refinement: it is not "cross-repo", it is "invisible to this token" Live evidence from today, which narrows this issue's real scope. **weblib-viewer#25** was created with a single dependency on **fdroid-repo-ci#12**, a *different repo* — and the reconciler correctly **added** `Status/Blocked`. Under the merged fix, an unresolvable blocker yields "unknown, change nothing", so an add means the entry resolved properly. Why it resolved: **fdroid-repo-ci is public.** A repo-scoped Actions token can read a public repo, so the dependency comes back fully populated (`id=204`, `state="open"`) and the ordinary open-blocker path applies. Confirmed for all six: | repo | private | |---|---| | fdroid-repo-ci, weblib-ci | **no** | | cfbypass, weblib-fs, weblib-archive, weblib-viewer | yes | So the zero-value stub is a **visibility** artifact, not a cross-repo one: - blocker in a **public** repo → resolves under any token → reconciler works normally, cross-repo or not - blocker in a **private** repo the token is not scoped to → zero-value stub → unknown That explains the whole pattern without new hypotheses. weblib-archive#30 is blocked by **cfbypass** (private) → stuck. weblib-viewer#25 is blocked by **fdroid-repo-ci** (public) → works. **This issue's title is therefore too broad** — "every blocker is in another repo" is not the condition; "every blocker is in a repo this token cannot see" is. The credential decision is unchanged, but it is narrower than it looked: it only has to cover the four private repos, and only for blockers among them. No action needed from @bit beyond the credential decision already posed.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: weblib/weblib-ci#11