Commit Graph
6 Commits
Author SHA1 Message Date
claudeandbit 05e8103eb0 Treat an unresolvable cross-repo blocker as unknown, not as open forever
Closes weblib-ci#11 in part: the reconciler could add Status/Blocked but
never remove it when every blocker lived in another repo, and, found live
on weblib-archive#30 (2026-09-09), it went further and *reasserted* the
label over a human's hand-removal every 15 minutes.

Established which of the two candidate mechanisms in #11 was real by
instrumenting the script and running it under a real Gitea Actions
token scoped to weblib-archive, against weblib-archive#68 (a throwaway
issue depending on the already-closed weblib/cfbypass#1) and the live
weblib-archive#30: `deps` is never empty for a cross-repo edge (rules out
"Gitea omits the entry"), but every cross-repo entry comes back as Go's
zero-value Issue struct -- id 0, state "", repository null -- rather than
being omitted or erroring. The old `d.get("state") != "closed"` read that
blank state as "still open", which is why the label could never come off,
and why it kept getting silently reapplied.

`is_resolved()` keys on `id == 0` rather than on the empty `state`, since
`id` cannot legitimately be zero for a real issue and a future Gitea could
start populating `state` while still stubbing the rest of the entry.
`reconcile()` now keeps the existing add/remove behaviour whenever every
blocker resolves (same-repo dependencies always do; pinned by
TestSameRepoUnchanged so this cannot regress), but when a blocker cannot
be resolved and none of the resolvable ones are positively open, it
changes nothing and logs the issue number instead of guessing -- keeping
the asymmetry from cfbypass#19 (a false "unblocked" sends someone at work
that cannot be done; a false "blocked" only hides work) rather than
inverting it.

Also probed whether the repo-scoped token could resolve a cross-repo
blocker by querying the other repo directly instead of via
`/dependencies`: `GET repos/weblib/cfbypass/issues/1` and
`GET repos/weblib/cfbypass` both 404 under weblib-archive's own Actions
token, while the same call against its own repo succeeds. So this token
cannot see cfbypass at all; making cross-repo state resolvable would need
a broader-scoped credential, which is a real blast-radius decision left
for bit rather than assumed here. The "unknown -> leave alone, log it"
behaviour in this PR fixes the stuck/reasserted label either way, with or
without that credential ever existing.

12 new tests in test_sync_blocked_label.py, offline against a local
http.server fake. Proved by fault injection: reverting sync_blocked_label.py
to its pre-fix form (keeping the new tests) fails exactly the 5 tests in
TestUnresolvableCrossRepoBlocker -- one of them reproducing the live
reassert bug verbatim (`[(30, 'add', 'blocked by #0, #0')]`) -- while every
other test still passes; restoring the fix (diffed byte-identical against
the pre-revert copy) turns the suite green again.

  python3 test_sync_blocked_label.py   # 12 tests, OK
  python3 test_report_job_log.py       # 22 tests, OK (unaffected)

Co-authored-by: bit <bit@das-labor.org>
2026-09-09 15:48:45 +00:00
claudeandbit 028c162874 Take the job's outcome as an argument, not as an assumption
`report_job_log.py` hardcoded the word *failed* into the comment header. Every
caller guards the step with `if: failure()`, so it was true by construction --
until an unguarded probe in weblib-viewer#10 ran it on a job that passed, and
the successful run posted a comment reading as a failure report. Anyone
scrolling that PR would conclude the probe had failed.

The interesting uses of this script are exactly the ones that want
`if: always()`: a probe, or a job whose *output* is the point rather than its
exit code. Those all lied in the header.

`--status` now supplies the outcome. **The default is `failed`**, which is what
`if: failure()` means, so the four consuming repos are untouched -- a change in
required arguments would have broken all of them at once, since they take this
script from `@main`. `JOB_STATUS` in the environment does the same, matching how
every other argument here already reads its default from the Actions
environment.

`${{ job.status }}` yields `success`/`failure`/`cancelled`/`skipped` while a
human writing the flag reaches for `passed`/`failed`, so both spellings are
accepted and the expression can be passed straight through. An **unrecognised**
status goes into the header verbatim rather than being rejected: `argparse`'s
`choices=` would exit 2 on a value the table has not heard of, and the log --
the whole reason this script exists -- would never be posted. A reporter must
not become the thing that reports nothing.

The "no log file" note was status-dependent too; it claimed the step "failed
before the build started" regardless.

## Verified

`test_report_job_log.py`, new here: stdlib only and offline, posting to an
`http.server` on localhost that keeps what it is sent, so each test reads the
comment back. An exit status of 0 proves nothing -- the script deliberately
swallows HTTP errors so a failure to report cannot mask the failure being
reported. 21 tests, 0 skipped, 1.2s. Three of them drive the CLI as a
subprocess with only environment variables set, the way a workflow does.

Each check was shown to fire by injecting the fault and reverting it:

| injected fault | result |
|---|---|
| header hardcodes `failed` again (the original bug) | 10 failures |
| `DEFAULT_STATUS = "passed"` (would break the four callers) | 8 failures |
| unknown status raises, as `choices=` would | 2 errors |
| missing-log note keeps the failure wording | 1 failure |
| a stray `%` in the `--status` help text | 1 failure |

All five reverted; the file's checksum matches the pre-injection copy.

Also drops a tracked `__pycache__/report_job_log.cpython-313.pyc` and adds a
`.gitignore`. It was committed by accident in 061d8b2 and importing the module
from the tests rewrites it, so it would otherwise show up in every future diff
as stale bytecode of a file that had already changed.

Closes #8

Co-authored-by: bit <bit@das-labor.org>
2026-09-08 07:57:23 +00:00
claudeandbit 67b133a115 README: say what the reconciler actually does now
Two behaviours landed in #4 that the one-line description did not mention, and
both are the kind of thing someone debugging would want to know before reading
the source:

  * the label is resolved from the repo *or the organisation* - labels moved to
    the org on 2026-09-07, and resolving from the repo alone is what silently
    turned the whole script into a no-op
  * an issue marked Status/On Hold or Status/Abandoned is left entirely alone,
    neither labelled nor unlabelled

Co-authored-by: bit <bit@das-labor.org>
2026-09-07 16:15:21 +00:00
claudeandbit d41e4ca2c6 Record how to consume this, measured
The seed commit said `uses:` did not work. That was measured while this repo
was private and is no longer true: with the full URL it works, and the action's
outputs.path reaches the caller and can run the tools.

The bare owner/repo form still fails - it resolves against the instance default
actions URL, not this host - so the README says which to use and why, with the
per-form results.

Co-authored-by: bit <bit@das-labor.org>
2026-09-07 11:30:00 +00:00
claudeandbit 061d8b266c Seed the shared CI tools
Split out of the four repos per weblib-archive#44. All three files were
byte-identical across every repo at this moment, which will not stay true --
they converged only because four twin PRs landed within hours today, and
report_job_log.py had already drifted once before that.

Taken from weblib-archive, verified identical to every other copy first:

  with-nixpkgs.sh       ca43fa20  (cfbypass, archive, fs)
  report_job_log.py     aaef8f62  (cfbypass, archive)
  sync_blocked_label.py e6ddb21d  (all four)

action.yml is included so the `uses:` question can be re-measured now the repo
is public; it did not work while private.

Co-authored-by: bit <bit@das-labor.org>
2026-09-07 11:25:33 +00:00
bit fb388546c5 Initial commit 2026-09-07 10:54:05 +00:00