actions/refs-not-closing — the gather is gh api graphql, which Forgejo does not serve at all #199

Closed
opened 2026-08-05 10:21:15 +00:00 by claude-bot-andresmgsl · 4 comments

Context

Part of #197. Blocked by #198, which lands the merge that first brings this
action onto the tree.

actions/refs-not-closing/ is new in upstream 0.6.0 (#218). It refuses a PR
whose Refs #N promise contradicts the closing-issue graph — a PR that says
it merely references an issue while actually being scheduled to close it.

Its entire gather is one GraphQL read, actions/refs-not-closing/run.sh:17:

facts="$(gh api graphql -f query='query($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) { pullRequest(number: $number) {
    body
    closingIssuesReferences(first: 100) { nodes { number } pageInfo { hasNextPage } }
  } } }' ...)"

Forgejo serves no GraphQL at all. /api/graphql on this instance returns
404, and lib/forge.sh's header records that a real forgejo-runner job even
arrives with GITHUB_GRAPHQL_URL set to the empty string. There is no
endpoint to translate this to; it has to be re-expressed, exactly as #188
re-expressed its own two GraphQL sites over REST plus a parser this repo owns.

#198 lands this action declaring CEREMONY_FORGE_CLIENT=gh, so
forge_preflight refuses loudly here rather than producing a verdict from an
unread graph. This issue removes the refusal by making the action portable.

Spec

1. The gather becomes REST, through the shim. Two reads, both already
available on both backends:

  • the PR body — forge_pr_view / forge_api "repos/$REPO/pulls/$N", field
    .body;
  • the PR's commits — forge_api --paginate "repos/$REPO/pulls/$N/commits",
    field .[].commit.message (verified present on this instance, HTTP 200).

2. The closing set is computed by lib/closes_references.sh, not by the
forge.
closes_references is this repo's closing-keyword parser, added by
#188 for precisely this reason. Run it over the body and over each commit
message
, union the results.

The commit half is not optional. refs-not-closing.sh's own header calls the
GraphQL graph "authoritative because it includes both closing keywords and
sidebar links". On Forgejo the two halves of that claim resolve differently:

  • Sidebar links — Forgejo has no equivalent concept; an issue is closed by
    a keyword, not by a manual link. So nothing is lost there.
  • Commit messages — Forgejo does honour closing keywords in commit
    messages. A body-only parse would therefore miss a PR that closes an issue
    from a commit subject, and would let exactly the contradiction this action
    exists to catch through. Hence both reads.

3. The hasNextPage refusal is kept in spirit, relocated. Upstream
refuses rather than issuing a partial verdict past 100 closing references.
The REST gather's equivalent failure is an incomplete paginated commit
read, so it must refuse on the same terms — reuse the forgejo backend's
existing x-total-count completeness proof rather than inventing a second
one. A partial read is a refusal, never a pass.

4. The decision stays network-free. refs-not-closing.sh keeps its
current signature — <body-file> [<closing-issue-number> ...] — so
test/refs-not-closing.test.sh keeps driving the incident matrix offline and
is not rewritten by this issue. Only run.sh changes.

5. The CEREMONY_FORGE_CLIENT=gh declaration from #198 is removed in the
same PR, and the action's preflight then passes on both forges.

Tasks

  • Replace run.sh's GraphQL gather with the two REST reads through the
    shim.
  • Union closes_references over the body and over every commit message.
  • Carry the completeness refusal onto the paginated commit read.
  • Remove the CEREMONY_FORGE_CLIENT=gh declaration #198 added.
  • Remove .github/workflows/refs-guard.yml's
    if: github.server_url == 'https://github.com' gate. #198 added it so a
    guard that could only refuse would not stand red on every PR on this
    forge; once the gather is REST the job must run here and produce
    verdicts again. Deleting the declaration without deleting the gate
    leaves the action portable and never scheduled — a guard that passes
    by never running, which is this repo's blind-sweep shape wearing a
    different hat (@kimi-reviewer-andresmgsl, #198).
  • Extend test/refs-not-closing.test.sh with the gather-level cases
    below; leave the existing offline matrix untouched.
  • Add a changelog.d/ fragment.

Acceptance criteria

  • actions/refs-not-closing/run.sh contains no gh invocation and no
    GraphQL.
  • The action produces the same verdict on both backends for the same PR —
    proven by one fixture driven through each.
  • A PR whose closing keyword appears only in a commit message, not in
    the body, is detected — the case a body-only port would miss.
  • An incomplete commit read refuses with a named reason and a non-zero
    exit; it never returns a verdict.
  • refs-not-closing.sh is unchanged, and
    test/refs-not-closing.test.sh's existing cases pass untouched.
  • test/run.sh ends failed 0; shellcheck- and actionlint-clean.

Test plan

  • The existing offline matrix in test/refs-not-closing.test.sh, unchanged
    and green — proof the decision layer was not disturbed.
  • Must fail before, pass after: a PR body carrying Refs #7 with
    Closes #7 present only in a commit subject. Body-only parsing calls this
    clean; the union must refuse it.
  • Must refuse: a stubbed commit read whose completeness proof fails —
    assert non-zero exit and a reason naming the read, and assert no verdict
    was emitted.
  • Must not regress: an ordinary Closes #N PR with no Refs, which the
    action must leave untouched on both backends.
  • Re-confirm /api/graphql 404s on this instance, so the reason this port
    exists is evidenced by the instance rather than by this issue's prose.

Dependencies

Part of #197. Blocked by #198.

Dependency discharged 2026-08-05 (triage). Blocked by #198 was about the MERGE, and it landed: !204 merged as 790c4d2, and upstream 0.6.0 is now an ancestor of main. #198 itself stays open only for its post-merge evidence (#5667), which this issue does not wait on. The blocked label may lag: on this forge the sweep cannot remove labels at all until #192 lands, which is the whole of that issue.

## Context Part of #197. `Blocked by #198`, which lands the merge that first brings this action onto the tree. `actions/refs-not-closing/` is new in upstream 0.6.0 (#218). It refuses a PR whose `Refs #N` promise contradicts the closing-issue graph — a PR that says it merely *references* an issue while actually being scheduled to close it. Its entire gather is one GraphQL read, `actions/refs-not-closing/run.sh:17`: ``` facts="$(gh api graphql -f query='query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { body closingIssuesReferences(first: 100) { nodes { number } pageInfo { hasNextPage } } } } }' ...)" ``` **Forgejo serves no GraphQL at all.** `/api/graphql` on this instance returns 404, and `lib/forge.sh`'s header records that a real forgejo-runner job even arrives with `GITHUB_GRAPHQL_URL` set to the empty string. There is no endpoint to translate this to; it has to be re-expressed, exactly as #188 re-expressed its own two GraphQL sites over REST plus a parser this repo owns. #198 lands this action declaring `CEREMONY_FORGE_CLIENT=gh`, so `forge_preflight` refuses loudly here rather than producing a verdict from an unread graph. This issue removes the refusal by making the action portable. ## Spec **1. The gather becomes REST, through the shim.** Two reads, both already available on both backends: - the PR body — `forge_pr_view` / `forge_api "repos/$REPO/pulls/$N"`, field `.body`; - the PR's commits — `forge_api --paginate "repos/$REPO/pulls/$N/commits"`, field `.[].commit.message` (verified present on this instance, HTTP 200). **2. The closing set is computed by `lib/closes_references.sh`, not by the forge.** `closes_references` is this repo's closing-keyword parser, added by #188 for precisely this reason. Run it over the body **and over each commit message**, union the results. The commit half is not optional. `refs-not-closing.sh`'s own header calls the GraphQL graph "authoritative because it includes both closing keywords and sidebar links". On Forgejo the two halves of that claim resolve differently: - **Sidebar links** — Forgejo has no equivalent concept; an issue is closed by a keyword, not by a manual link. So nothing is lost there. - **Commit messages** — Forgejo *does* honour closing keywords in commit messages. A body-only parse would therefore miss a PR that closes an issue from a commit subject, and would let exactly the contradiction this action exists to catch through. Hence both reads. **3. The `hasNextPage` refusal is kept in spirit, relocated.** Upstream refuses rather than issuing a partial verdict past 100 closing references. The REST gather's equivalent failure is an incomplete paginated commit read, so it must refuse on the same terms — reuse the forgejo backend's existing `x-total-count` completeness proof rather than inventing a second one. A partial read is a refusal, never a pass. **4. The decision stays network-free.** `refs-not-closing.sh` keeps its current signature — `<body-file> [<closing-issue-number> ...]` — so `test/refs-not-closing.test.sh` keeps driving the incident matrix offline and is not rewritten by this issue. Only `run.sh` changes. **5. The `CEREMONY_FORGE_CLIENT=gh` declaration from #198 is removed** in the same PR, and the action's preflight then passes on both forges. ## Tasks - [ ] Replace `run.sh`'s GraphQL gather with the two REST reads through the shim. - [ ] Union `closes_references` over the body and over every commit message. - [ ] Carry the completeness refusal onto the paginated commit read. - [ ] Remove the `CEREMONY_FORGE_CLIENT=gh` declaration #198 added. - [ ] Remove `.github/workflows/refs-guard.yml`'s `if: github.server_url == 'https://github.com'` gate. #198 added it so a guard that could only refuse would not stand red on every PR on this forge; once the gather is REST the job must run here and produce verdicts again. Deleting the declaration without deleting the gate leaves the action portable and never scheduled — a guard that passes by never running, which is this repo's blind-sweep shape wearing a different hat (@kimi-reviewer-andresmgsl, #198). - [ ] Extend `test/refs-not-closing.test.sh` with the gather-level cases below; leave the existing offline matrix untouched. - [ ] Add a `changelog.d/` fragment. ## Acceptance criteria - [ ] `actions/refs-not-closing/run.sh` contains no `gh` invocation and no GraphQL. - [ ] The action produces the same verdict on both backends for the same PR — proven by one fixture driven through each. - [ ] A PR whose closing keyword appears **only in a commit message**, not in the body, is detected — the case a body-only port would miss. - [ ] An incomplete commit read refuses with a named reason and a non-zero exit; it never returns a verdict. - [ ] `refs-not-closing.sh` is unchanged, and `test/refs-not-closing.test.sh`'s existing cases pass untouched. - [ ] `test/run.sh` ends `failed 0`; shellcheck- and actionlint-clean. ## Test plan - The existing offline matrix in `test/refs-not-closing.test.sh`, unchanged and green — proof the decision layer was not disturbed. - **Must fail before, pass after:** a PR body carrying `Refs #7` with `Closes #7` present only in a commit subject. Body-only parsing calls this clean; the union must refuse it. - **Must refuse:** a stubbed commit read whose completeness proof fails — assert non-zero exit and a reason naming the read, and assert no verdict was emitted. - **Must not regress:** an ordinary `Closes #N` PR with no `Refs`, which the action must leave untouched on both backends. - Re-confirm `/api/graphql` 404s on this instance, so the reason this port exists is evidenced by the instance rather than by this issue's prose. ## Dependencies `Part of #197`. `Blocked by #198`. > **Dependency discharged 2026-08-05 (triage).** `Blocked by #198` was about the MERGE, and it landed: !204 merged as `790c4d2`, and upstream 0.6.0 is now an ancestor of `main`. #198 itself stays open only for its post-merge evidence (#5667), which this issue does not wait on. The `blocked` label may lag: on this forge the sweep cannot remove labels at all until #192 lands, which is the whole of that issue.
forgejo-actions added the
needs-triage
label 2026-08-05 10:21:28 +00:00
claude-bot-andresmgsl added
blocked
bug
scope:guards
and removed
needs-triage
labels 2026-08-05 10:22:33 +00:00

This issue's Blocked by declarations parse to: {#198}

That is the exact set this sweep gates on — what the machine read, never a
judgment about whether it is what you meant. The parse unions every clause it
finds, so a sentence like no longer blocked by #9 contributes #9 like
any other; over-retaining is the deliberate direction of error, because a stale
blocked is a triage comment away and a false ready sends a builder into
work that cannot merge. If this set names something you did not declare, or
omits something you did, edit the declaration — the next sweep echoes the
correction.

Comment only: nothing on this path writes a label. The marker carries the set
itself, so a parse unchanged since the last echo never re-posts.

<!-- issueflow:blockers-parsed-198-a5ec78197834 --> This issue's `Blocked by` declarations parse to: {#198} That is the exact set this sweep gates on — what the machine read, never a judgment about whether it is what you meant. The parse unions every clause it finds, so a sentence like `no longer blocked by #9` contributes `#9` like any other; over-retaining is the deliberate direction of error, because a stale `blocked` is a triage comment away and a false `ready` sends a builder into work that cannot merge. If this set names something you did not declare, or omits something you did, edit the declaration — the next sweep echoes the correction. *Comment only: nothing on this path writes a label. The marker carries the set itself, so a parse unchanged since the last echo never re-posts.*

Every issue named by Blocked by is closed. The sweep is moving this issue to ready.

<!-- issueflow:blockers-cleared --> Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.
forgejo-actions added
ready
and removed
blocked
labels 2026-08-05 16:33:08 +00:00
claude-bot-andresmgsl self-assigned this 2026-08-05 17:01:24 +00:00
claude-bot-andresmgsl added
claimed
and removed
ready
labels 2026-08-05 17:01:25 +00:00

The Refs-linked PR merged with these acceptance criteria still unchecked:

  • Replace run.sh's GraphQL gather with the two REST reads through the
  • Union closes_references over the body and over every commit message.
  • Carry the completeness refusal onto the paginated commit read.
  • Remove the CEREMONY_FORGE_CLIENT=gh declaration #198 added.
  • Remove .github/workflows/refs-guard.yml's
  • Extend test/refs-not-closing.test.sh with the gather-level cases
  • Add a changelog.d/ fragment.
  • actions/refs-not-closing/run.sh contains no gh invocation and no
  • The action produces the same verdict on both backends for the same PR —
  • A PR whose closing keyword appears only in a commit message, not in
  • An incomplete commit read refuses with a named reason and a non-zero
  • refs-not-closing.sh is unchanged, and
  • test/run.sh ends failed 0; shellcheck- and actionlint-clean.

The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition.

<!-- issueflow:post-merge-transition-pr-214 --> The Refs-linked PR merged with these acceptance criteria still unchecked: - [ ] Replace `run.sh`'s GraphQL gather with the two REST reads through the - [ ] Union `closes_references` over the body and over every commit message. - [ ] Carry the completeness refusal onto the paginated commit read. - [ ] Remove the `CEREMONY_FORGE_CLIENT=gh` declaration #198 added. - [ ] Remove `.github/workflows/refs-guard.yml`'s - [ ] Extend `test/refs-not-closing.test.sh` with the gather-level cases - [ ] Add a `changelog.d/` fragment. - [ ] `actions/refs-not-closing/run.sh` contains no `gh` invocation and no - [ ] The action produces the same verdict on both backends for the same PR — - [ ] A PR whose closing keyword appears **only in a commit message**, not in - [ ] An incomplete commit read refuses with a named reason and a non-zero - [ ] `refs-not-closing.sh` is unchanged, and - [ ] `test/run.sh` ends `failed 0`; shellcheck- and actionlint-clean. The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition.
forgejo-actions added
post-merge
and removed
claimed
labels 2026-08-05 17:58:09 +00:00
claude-bot-andresmgsl was unassigned by forgejo-actions 2026-08-05 17:58:10 +00:00
Author
Member

Post-merge evidence — the guard's first live verdict on this forge

Run 513, refs-guard.yml, event pull_request, on !213's head
ed5ce89 — the job ran and succeeded where every prior run on this forge
was skipped. From its log:

⭐ Run Main ./actions/refs-not-closing
refs-not-closing: no Refs target appears in GitHub's closing-issue graph
✅  Success - Main ./actions/refs-not-closing

That is the ported gather executing end to end on a live PR of this
repository: two REST reads through the shim (body + paginated commits),
closes_references unioned over both, and a real pass verdict for a PR
whose body declares Refs #205 and whose commits close nothing — the correct
answer, produced rather than refused.

For the criteria the transition comment lists as unchecked: the build-side
items were all verified in !214's review (both-backend fixture parity, the
commit-only detection case, the completeness refusal, no gh/GraphQL — codex
and kimi at head). Run 513 supplies the live half: the action produces
verdicts on this forge.

One caveat named so it is not rediscovered: the verdict line's prose says
"GitHub's closing-issue graph" — wording from refs-not-closing.sh, which
#199's spec deliberately left unchanged. The gather behind it is the REST
port; only the sentence is stale. If triage wants the sentence updated it is a
one-line follow-up, not evidence of the old path running.

@andres — with this, every criterion on this issue has either review or live
evidence. Close is triage's; I am not closing it.

## Post-merge evidence — the guard's first live verdict on this forge **Run 513**, `refs-guard.yml`, event `pull_request`, on !213's head `ed5ce89` — the job **ran and succeeded** where every prior run on this forge was `skipped`. From its log: ```text ⭐ Run Main ./actions/refs-not-closing refs-not-closing: no Refs target appears in GitHub's closing-issue graph ✅ Success - Main ./actions/refs-not-closing ``` That is the ported gather executing end to end on a live PR of this repository: two REST reads through the shim (body + paginated commits), `closes_references` unioned over both, and a real **pass** verdict for a PR whose body declares `Refs #205` and whose commits close nothing — the correct answer, produced rather than refused. For the criteria the transition comment lists as unchecked: the build-side items were all verified in !214's review (both-backend fixture parity, the commit-only detection case, the completeness refusal, no `gh`/GraphQL — codex and kimi at head). Run 513 supplies the live half: **the action produces verdicts on this forge.** One caveat named so it is not rediscovered: the verdict line's prose says "GitHub's closing-issue graph" — wording from `refs-not-closing.sh`, which #199's spec deliberately left unchanged. The gather behind it is the REST port; only the sentence is stale. If triage wants the sentence updated it is a one-line follow-up, not evidence of the old path running. @andres — with this, every criterion on this issue has either review or live evidence. Close is triage's; I am not closing it.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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: heavy-duty/ceremony#199
No description provided.