diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index d6004b8..54c2c23 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -470,7 +470,15 @@ reconcile_opened_issue() { # The stand-downs return 0 explicitly: a bare return carries the failed # test's status, which under execution is live `set -e` — and it killed the # run on every triage-authored mint, before one issue was reconciled (#91). - jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return 0 + # `.pull_request == null`, NOT `has("pull_request") | not` (#188). The key's + # PRESENCE is a GitHub-only discriminator: GitHub omits it on a plain issue, + # Forgejo emits it on every entry and sets it to null. Measured on the list + # endpoints, 2026-08-02 — GitHub 0 of 9 issues carried the key, Forgejo 10 of + # 10 did. So the old test selected ZERO issues here and the sweep printed + # "reconciled." over an untouched board: the blind sweep again, one layer in. + # The null test is true for an issue on both forges (absent key reads as + # null) and false for a PR on both. + jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || return 0 author="$(jq -r '.user.login' <<<"$ISSUE_JSON")" is_triage_actor "$author" && triage=true labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" @@ -543,10 +551,10 @@ main() { local n for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \ - --jq '.[] | select(has("pull_request") | not) | .number'); do + --jq '.[] | select(.pull_request == null) | .number'); do ( ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")" - jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 + jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0 ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" reconcile_issue "$n" ) || log "#$n: reconcile failed — continuing with the remaining issues" diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 83eee06..858d1cd 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -674,6 +674,30 @@ check "...and performs the release edit from the executable path" 0 "" \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ "$ARRIVAL/fixtures/edits" +# -- the issue/PR discriminator is not `has("pull_request")` --------------- +# Measured on the two list endpoints, 2026-08-02 (#188): +# +# GitHub plain issues OMIT the key -> 0 of 9 carried it +# Forgejo every entry HAS the key -> 10 of 10, valued null on issues +# +# So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and +# the sweep printed "reconciled." over an untouched board — the blind sweep +# again, one layer in, and invisible because the log looks identical to a +# legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live +# board, not by any unit test. `.pull_request == null` is true for an issue on +# both forges (an absent key reads as null) and false for a PR on both. +disc() { jq -e "$1" >/dev/null 2>&1 && echo issue || echo pr; } +check "github-shaped issue (key absent) reads as an issue" 0 "issue" \ + bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr' +check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \ + bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr' +check "a PR reads as a PR on either shape" 0 "pr" \ + bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr' +# The old test, kept as the must-fail: it disagrees with the new one on the +# forgejo shape, which is exactly the bug. +check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \ + bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr' + # -- the OPEN-pull gather, at main() granularity ---------------------------- # The closed/merged half above proves one REST path; this proves the other, # which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->