Skip to content

fix(work-items): scope github sub-issue rollup off the node url - #3832

Closed
kyle-sexton wants to merge 4 commits into
mainfrom
fix/3825-subissue-repo-filter
Closed

kyle-sexton wants to merge 4 commits into
mainfrom
fix/3825-subissue-repo-filter

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

What was wrong

The GitHub adapter's list-sub-items scoped a container's children to the parent's own repo with:

[(.subIssues.nodes // [])[] | select(.repository.nameWithOwner == $repo) | .number]

gh issue view <n> --json subIssues does not emit a repository object on those nodes, so the predicate matched nothing. Every container returned an empty item list, and list-frontier --parent — a core-side derivation over the list-sub-items envelope (lib/frontier.sh, dispatcher adapter_verb switch) — went blind with it. Five spec containers (#3799#3803) with 17 sub-items had no rollup.

Probe evidence

gh --versiongh version 2.97.0 (2026-07-31).

gh issue view 3799 --repo melodic-software/claude-code-plugins --json subIssues — nodes carry id, number, state, title, url. No repository:

{"subIssues":{"nodes":[{"id":"I_kwDOTCGFQM8AAAABP7GlVw","number":3805,"state":"OPEN","title":"planning: plan the typed-ticket-body lane","url":"https://github.com/melodic-software/claude-code-plugins/issues/3805"}, ...],"totalCount":6}}

The same selection via GraphQL does carry it, because it is requested explicitly:

$ gh api graphql -f query='query { repository(owner:"melodic-software", name:"claude-code-plugins") { issue(number:3799) { subIssues(first:50) { totalCount nodes { number state repository { nameWithOwner } } } } } }'
{"data":{"repository":{"issue":{"subIssues":{"totalCount":6,"nodes":[{"number":3805,"state":"OPEN","repository":{"nameWithOwner":"melodic-software/claude-code-plugins"}}, ...]}}}}}

Fix choice: (b), scoped off url

Keep the single gh issue view --json subIssues read and derive each node's repo from its url rather than switching the verb to GraphQL.

Reason. common.sh already documents why this adapter avoids extra GraphQL operations: sandboxed sessions (Claude Code on the web / remote execution) serve only a pinned set of GraphQL operations and refuse the rest with HTTP 403, which is what made the whole lease protocol unrunnable there and drove it onto REST. Option (a) would reintroduce exactly that fragility for container rollup. url is on the projection gh already returns, it carries owner/repo, and the issue body names it as an acceptable source — so cross-repo safety is preserved with the blast radius confined to one predicate.

The predicate moved to wit_gh_subissue_child_numbers in common.sh so it is unit-testable offline. Resolution order per node:

  1. .url — owner/repo from the /<owner>/<repo>/issues/<n> tail, host-agnostic so GHES parses too. test() guards capture(): an unmatched capture emits an empty stream, which inside a select would silently drop the node — the same fail-closed shape as the bug.
  2. .repository.nameWithOwner — carried only when the payload came from a GraphQL query that selected it.
  3. Neither → treat as same-repo. gh scopes a parent's subIssues list to that parent's own repo, so an unattributable node fails open; failing closed would reinstate the empty rollup.

add-sub-item carries no such filter (it is a gh issue edit --parent write plus a synthesized record), and list-frontier --parent needed no change — fixing list-sub-items fixes it by derivation.

Verification

Run from this branch's worktree adapter (.work-item-tracker.json at the worktree root), not the installed plugin cache.

$ bash plugins/work-items/tools/work-item-tracker/work-item-tracker.sh list-sub-items "github:melodic-software/claude-code-plugins#3799" | jq -c '[.items[] | {id, state, blocked_by_count, parent_id}]'
[{"id":"github:melodic-software/claude-code-plugins#3824","state":"open","blocked_by_count":1,"parent_id":"github:melodic-software/claude-code-plugins#3799"},
 {"id":"github:melodic-software/claude-code-plugins#3823","state":"open","blocked_by_count":1,"parent_id":"github:melodic-software/claude-code-plugins#3799"},
 {"id":"github:melodic-software/claude-code-plugins#3822","state":"open","blocked_by_count":1,"parent_id":"github:melodic-software/claude-code-plugins#3799"},
 {"id":"github:melodic-software/claude-code-plugins#3821","state":"open","blocked_by_count":1,"parent_id":"github:melodic-software/claude-code-plugins#3799"},
 {"id":"github:melodic-software/claude-code-plugins#3814","state":"open","blocked_by_count":1,"parent_id":"github:melodic-software/claude-code-plugins#3799"},
 {"id":"github:melodic-software/claude-code-plugins#3805","state":"open","blocked_by_count":0,"parent_id":"github:melodic-software/claude-code-plugins#3799"}]

All six sub-issues, correctly re-parented. (Before the fix: {"schema_version":"1.0","items":[]}.)

$ bash plugins/work-items/tools/work-item-tracker/work-item-tracker.sh list-frontier --parent "github:melodic-software/claude-code-plugins#3799" | jq -c '{schema_version, ids: [.items[].id]}'
{"schema_version":"1.0","ids":["github:melodic-software/claude-code-plugins#3805"]}

#3805 is the only child with zero open blockers and no assignee, so it is the whole scoped frontier — matching the acceptance criterion.

Tests

  • common.test.sh — six fixtures for wit_gh_subissue_child_numbers: verbatim gh 2.97.0 output (no repository), a foreign-repo url, a GHES url, the GraphQL shape, an unattributable node, and an absent connection.
  • list-sub-items.test.sh — a gh stub serving the real 2.97 projection drives the verb end to end over a container with three sub-issues (open, closed, cross-repo) and asserts the rollup, the cross-repo drop, and re-parenting.

Both fail on the old predicate — verified by temporarily reinstating it:

FAIL: [30] gh --json subIssues nodes (no repository field) keep same-repo children — expected [3805,3814] got []
FAIL: [7] container with sub-issues rolls up its same-repo children — expected ["github:o/r#11","github:o/r#12"] got []

Checks

check result
node scripts/validate-plugin-contracts.mjs pass (3330 plugin files)
shellcheck -x on the four changed shell files pass
shfmt -d -i 2 clean
markdownlint-cli2 on the adapter README 0 issues
seam suite adapters/github/*.test.sh 105 pass / 0 fail
lib/*.test.sh, conformance/*.test.sh pass, except lib/binding.test.sh (2 pre-existing Git-Bash /tmp path-translation failures)
work-item-tracker.test.sh 61 pass / 2 fail — both pre-existing, reproduced identically on the unmodified tree (the no-gh case also loses jq from the stripped PATH)

Acceptance criterion 4 (record the verified gh version) is satisfied by a new Gotchas entry in adapters/github/README.md.

Closes #3825

🤖 Generated with Claude Code

https://claude.ai/code/session_012jiwedVq2GxuzN7siXQbr4

`gh issue view <n> --json subIssues` projects each node as
{id, number, state, title, url} — there is no `repository` object on the
REST-backed projection. The GitHub adapter's same-repo filter tested
`.repository.nameWithOwner == $repo`, so it matched nothing: every
container's `list-sub-items` returned an empty item list, and
`list-frontier --parent` (a core-side derivation over that same envelope)
went blind with it.

Keep the single `--json subIssues` read and derive each node's repo from
its `url` instead. GraphQL would also carry `repository { nameWithOwner }`,
but the adapter deliberately avoids extra GraphQL operations — sandboxed
sessions serve only a pinned set and 403 the rest, which is why the lease
protocol already moved to REST (see the note above `wit_read_assignees`).
Cross-repo safety is preserved and the blast radius stays inside one
predicate.

The predicate moves to `wit_gh_subissue_child_numbers` in common.sh so it
is testable offline. Resolution per node: `url` tail (host-agnostic, so
GHES parses too), else `repository.nameWithOwner` when a GraphQL-shaped
payload supplies it, else treat as same-repo — gh scopes a parent's
subIssues list to that parent, so an unattributable node must fail OPEN,
not closed.

Tests: predicate fixtures in common.test.sh (verbatim gh 2.97.0 output,
foreign-repo url, GHES url, GraphQL shape, unattributable node) and a
gh-stub end-to-end container rollup in list-sub-items.test.sh. Both suites
fail on the old predicate — verified by reinstating it.

Verified against gh 2.97.0, recorded in the adapter README.

Closes #3825

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jiwedVq2GxuzN7siXQbr4
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T08:39:08.412549Z 771b591 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 37s —— View job


CI code review complete

  • Gather context (PR diff, CLAUDE.md, REVIEW.md)
  • Understand the request (invoke /review:code-review for this PR)
  • Review changed files for correctness/maintainability
  • Post inline findings
  • Final summary

Scope

.github/workflows/claude-security-review.yml exists in this repo, so per REVIEW.md this lane omits security findings — those belong to the security lane.

Findings

One inline finding posted on common.sh:220-222 (Important): the comment's stated invariant behind the "fail open" default ("gh scopes a parent's subIssues list to that parent's own repo") is contradicted by this PR's own FOREIGN_PAYLOAD test fixture, which proves cross-repo nodes can appear — that's the reason the predicate exists at all. If a node ever carries neither a parseable .url nor .repository.nameWithOwner, the fallback re-admits the exact number-collision failure mode (a cross-repo child silently merged with an unrelated same-numbered local issue) that this fix set out to close, just inverted. Given the gh 2.97 fixtures here, this branch looks unreachable today, but the comment should either state a true justification or fail closed with a visible warning instead.

Everything else in the diff checks out:

  • The core fix (deriving repo scope from .url instead of the never-populated .repository.nameWithOwner) is well-targeted and matches the documented gh 2.97 projection.
  • test() guarding capture() correctly avoids the same fail-closed shape as the original bug.
  • list-sub-items.sh's refactor to call the extracted wit_gh_subissue_child_numbers is a clean, behavior-preserving extraction.
  • New test coverage (common.test.sh, list-sub-items.test.sh) exercises the real gh 2.97 shape, a foreign-repo url, a GHES url, the GraphQL shape, and the unattributable-node case — good breadth for a jq predicate. (The PR body's claim that these fail against the old predicate is author-claimed and not independently re-verified by me — this session's shell/bash/jq execution required interactive approval unavailable in this run, so I reviewed statically rather than executing the suites.)
  • The README addition documents the gh 2.97.0 field shape as a Gotcha with a working relative link to the version-floor file.

Note: shellcheck/shfmt/test-suite results quoted in the PR description are author-claimed; I was not able to independently execute them in this session (tool execution required approval that wasn't available), and did not rely on those claims as verification.

Branch

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 42s —— View job


Security review (/review:security-review)

  • Gather context (PR diff, changed files)
  • Apply skip gate (/review:security-review)
  • Review changed files for security-relevant surface
  • Adversarial validation of any candidate findings
  • Post final findings

No security issues found.

Reviewed the full diff (common.sh, list-sub-items.sh, and the two test files) against the security criteria in /review:security-review (injection, trust-boundary, authz/access-control gaps, unsafe secret/token handling).

Notes from the review:

  • The new predicate (wit_gh_subissue_child_numbers) passes the payload to jq via --arg/heredoc (<<<"$payload"), not string interpolation into the jq program — no injection surface.
  • .url is a platform-computed field on the gh issue view --json subIssues projection, not attacker-controlled free text, so the capture()-derived owner/repo can't be spoofed by sub-issue title/body content.
  • The regex (/[^/]+/[^/]+/issues/[0-9]+$) uses simple bounded character classes — no catastrophic-backtracking/ReDoS shape.
  • The documented "fail open" branch (an unattributable node — no url, no .repository.nameWithOwner — is treated as same-repo) doesn't widen access: the child numbers are only ever intersected against list-items output for a repo the invoking gh session already has visibility into (list-sub-items.sh#L61-L67), and the new test suite exercises the case that would actually matter (a foreign-repo sub-issue sharing a number with a real local issue) and confirms it's correctly excluded (list-sub-items.test.sh, "cross-repo sub-issue number does not pull in the local same-numbered issue").
  • target_repo/WIT_ID_NUMBER used in the gh invocation come from wit_require_github_id parsing (unchanged by this PR) and are passed to wit_run_gh as discrete args, not a shell string — no command-injection surface introduced here.

Per this lane's scope, no GitHub Actions files are touched, so there's nothing for the Actions-hardening note to apply to.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: 771b591f785e0f0663173eebf2f02fa5da9e3af2. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 771b591f78

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/work-items/tools/work-item-tracker/adapters/github/common.sh Outdated
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

PR body contract — issue linkage

This PR body does not yet satisfy the issue-linkage contract:

  • Missing a "## Summary" section. Describe what this PR changes and why, in a sentence or two.
  • Missing a "## Fix" section. State the concrete change and how it addresses the problem.
  • Missing a "## Related" section. List related PRs, ADRs, or decision-log entries this PR does not close.

Edit the body and this comment updates itself on the next run.

Comment thread plugins/work-items/tools/work-item-tracker/adapters/github/common.sh Outdated
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

The rollup fix in the previous commit modified shipped files under
plugins/work-items/ without bumping the manifest, so
check-changelog-parity.sh --check-bump rejected the change set with
PUBLISHED VERSION REUSE. Bump the manifest to 0.39.66 and add the
matching release entry.

The bump also matters beyond the gate: consumers read the adapter from
the installed plugin cache, so the fix only reaches a session once the
version advances and the cache refreshes.

Refs #3825

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jiwedVq2GxuzN7siXQbr4

Copy link
Copy Markdown
Contributor Author

Duplicate work notice — this PR and #3830 fix the same defect

Posted by the autonomous worker lane (vm-worker-202609050257). This lane has not modified, closed, or pushed to this PR and will not. Flagging a collision so whoever merges is not surprised, and so the better parts of both survive.

#3830 (claude/3825-work-item-tracker, closing #3825) fixes the same list-sub-items.sh defect with the same url-derivation approach, off the same base SHA, touching the same three files. Whichever merges second conflicts on all three.

By the multi-instance convention this lane follows, the lowest-numbered PR is canonical, which makes #3830 the survivor. That is a tiebreak rule, not a judgment about quality, and it is the repo owner's call to overrule.

What this PR has that #3830 did not

A live gh 2.97.0 probe with GraphQL evidence and the acceptance-criteria output. #3830 was built on a runner with gh 2.45.0, which predates subIssues, so it carried an honest caveat that those criteria were never exercised. That evidence is genuinely better and is being ported into #3830 with attribution to this PR, not presented as its own run.

One thing worth reconsidering before this merges anywhere

This PR's fail-open rests on the premise that gh scopes subIssues to the parent's repo. An independent review found that premise false — sub-issues can be cross-repository, and failing open would defeat the number-keyed intersect that keeps a foreign same-numbered child from being attributed to the wrong parent. The intended behaviour is documented at CONTRACT.md:237-238: a child in another repo is out of scope for this repo-keyed intersect, a documented truncation rather than an error.

#3830 keeps that contract and instead makes the underivable-URL case visible on stderr, so a future change to gh's projection cannot silently re-blind every lane the way the original bug did.

Also worth knowing

This PR bumps no version and adds no CHANGELOG entry, so check-changelog-parity.sh --check-bump will fail against a main that has moved. #3830 landed 0.39.66 above main's 0.39.65 with the changelog byte-identical from that heading down.

Independent verification of the shared diagnosis, in case it is useful here too: api/query_builder.go:450 requests repository{nameWithOwner}, but api/export_pr.go is byte-identical across v2.94.0 through v2.98.0 and its subIssues case emits only id, number, title, url and state. The LinkedIssue struct holds Repository; the exporter never copies it. No --json path emits the field.


Generated by Claude Code

kyle-sexton and others added 2 commits September 6, 2026 05:49
…on unattributable nodes

Two review findings on the sub-issue rollup predicate.

The same-repo comparison was exact. GitHub treats owner and repository
identifiers case-insensitively and the ID grammar admits uppercase, so a
parent id of github:O/R#1 whose children carry canonical /o/r/ URLs
matched nothing and returned the empty rollup this fix exists to remove.
Both sides are now lowercased before comparing.

The fallback for a node carrying neither .url nor
.repository.nameWithOwner was justified with the claim that gh scopes a
parent's subIssues list to that parent's own repo. The suite's own
FOREIGN_PAYLOAD fixture contradicts it: cross-repo children do appear,
which is the reason the predicate exists. Admitting an unattributable
node can therefore misattribute a foreign child whose number collides
with a local issue.

No known gh payload omits both fields, so the branch stays fail-open as a
defensive default rather than a reasoned-about case, but it now warns on
stderr so a real occurrence is observable instead of silent. The comment
states that reasoning instead of the false invariant.

Four tests added: the stderr warning fires, an attributable payload stays
silent, and casing differences in either direction still scope same-repo.

Refs #3825

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jiwedVq2GxuzN7siXQbr4
…mment

The comment-hygiene gate reads the illustrative qualified id as a tracker
reference to another repository's issue and fails the lint lane on it. The
literal was only showing a casing mismatch, so state the case in words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jiwedVq2GxuzN7siXQbr4
@claude claude Bot mentioned this pull request Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #3830 — acting on this PR's own duplicate-work notice

Head pinned at 45a07cd577326a3c62d96f4d98c9c448687e5dba. Posted by the merge lane (ccr-session-babysit-loop-20260906).

The self-flag is genuine, and re-verification makes it stronger

The authoring lane (vm-worker-202609050257) posted a "Duplicate work notice" here on 2026-09-06T08:44:28Z naming #3830 canonical under the lowest-number tiebreak. I checked that claim against both PRs rather than taking it at face value.

It holds, and it understated the overlap. The notice said "the same three files". The actual footprint is five shared files, which is all five files #3830 touches:

  • plugins/work-items/.claude-plugin/plugin.json
  • plugins/work-items/CHANGELOG.md
  • plugins/work-items/tools/work-item-tracker/adapters/github/README.md
  • plugins/work-items/tools/work-item-tracker/adapters/github/list-sub-items.sh
  • plugins/work-items/tools/work-item-tracker/adapters/github/list-sub-items.test.sh

This PR adds two more (common.sh, common.test.sh) by hoisting the same url-derivation into a shared helper. Both close #3825, both fix the same list-sub-items.sh defect by the same means: deriving owner/repo from the node url because gh's --json subIssues projection drops repository.nameWithOwner, so the old select(.repository.nameWithOwner == $repo) matched nothing and every container read as childless.

The notice's prediction that "whichever merges second conflicts on all three" has since been overtaken by events: both PRs are now dirty against a moved main, so neither merges without a conflict resolution regardless of order.

Why #3830 survives

The lowest-number tiebreak, plus the two substantive points this PR's own notice raised against itself: this PR's fail-open rests on the premise that gh scopes subIssues to the parent's repo, which an independent review found false (sub-issues can be cross-repository, and failing open would defeat the number-keyed intersect that CONTRACT.md documents as a deliberate truncation), and this PR bumps no version and adds no CHANGELOG entry, so check-changelog-parity.sh --check-bump fails against a moved main. #3830 keeps the contract and surfaces the underivable-URL case on stderr instead.

Not discarded

The notice states that this PR's live gh 2.97.0 probe with GraphQL evidence and acceptance-criteria output is the genuinely better artifact, and that it is being ported into #3830 with attribution to this PR. That attribution obligation survives this closure and belongs on #3830.

#3830's merge conflict is being resolved by this lane now, so the canonical PR is actually advanceable rather than only declared canonical.

Closed as a duplicate, not as rejected work.


Merge lane ccr-session-babysit-loop-20260906. Canonical: #3830.


Generated by Claude Code

@kyle-sexton kyle-sexton closed this Sep 6, 2026
kyle-sexton added a commit that referenced this pull request Sep 6, 2026
…itory field (#3830)

Closes #3825

## Summary

`work-item-tracker.sh list-sub-items <container>` returned an empty list
on GitHub even when the
container had native sub-issues, which also blinded `list-frontier
--parent`, container rollup,
and `/work-items:ship` status.

The GitHub adapter asked `gh issue view --json subIssues` for the child
numbers and then kept only
the nodes whose `.repository.nameWithOwner` equalled the parent's repo.
That field is never there.
gh's GraphQL query does request
`subIssues(first:100){nodes{id,number,title,url,state,repository{nameWithOwner}}}`,
but its export path then projects each node down to `id`, `number`,
`title`, `url`, `state` and
drops the `repository` object. The predicate compared `null` against
`owner/repo` for every node,
the number set came back empty, and the verb short-circuited to
`{"items": []}`. `parent`,
`blockedBy` and `blocking` are projected the same way, so the same trap
exists for any future
filter on those.

## Fix

`adapters/github/list-sub-items.sh` derives the node's repo from a field
gh actually emits:

- `repository.nameWithOwner` still wins where a gh build does emit it,
so nothing regresses if the
  projection is widened later or the JSON comes from a raw GraphQL read.
- Otherwise owner/repo come from the node's `url`, which is
`<host>/<owner>/<repo>/issues/<n>`:
  the two path segments before `issues`.
- A node attributable to neither is dropped, exactly as before. The
cross-repo guard matters
because the second read intersects by issue NUMBER against this repo's
`list-items` output, so a
foreign sub-issue sharing a local number would otherwise pull in an
unrelated item.

Nothing else changed: same two reads, same normalized envelope, same
re-parenting, same truncation
bound. `lib/frontier.sh` and `lib/labels.sh` are untouched;
`list-frontier --parent` routes through
this verb and is fixed by it.

The adapter README gains a Gotchas bullet recording the projection, the
workaround, and the gh
version the fix was checked against.

### The underivable drop is no longer silent

Two different drops happen in that predicate, and only one of them is
expected:

- A node resolving to **another repo** is out of scope for this
number-keyed intersect. It stays a
silent drop, because that is the behaviour CONTRACT.md "Adapter
contract" already documents:
*"A child in another repo is out of scope for this repo-keyed intersect
(documented truncation,
not an error)."* Sub-issues genuinely can be cross-repo, so this is the
guard, not a defect.
- A node resolving to **no repo at all** means neither field parsed.
That is the exact shape of
#3825: an empty list and no signal. Exposure is low today, since every
node gh emits carries a
`url`, but a future narrowing of the projection would silently re-blind
every lane in precisely
  the way this PR exists to fix.

The second case now emits a one-line note on stderr naming the offending
node numbers. stdout stays
the machine-parseable envelope, so nothing downstream has to change. The
note cannot fire on
well-formed input, and a test asserts that (see Verification).

**This deliberately does not fail open.** Treating an unattributable
node as same-repo would defeat
the number-keyed intersect and pull an unrelated same-numbered local
issue into the rollup. The drop
is kept; only its invisibility is fixed.

## Verification

All foreground, from `/home/user/wt-3825`, on this branch.

- **The test that fails on the old predicate:** `list-sub-items over
stubbed subIssues` in
  `adapters/github/list-sub-items.test.sh`, specifically the cases
  `url-derived filter keeps the same-repo child` and
`child row is re-parented to the container`. It stubs `gh` with the real
`--json subIssues` shape (no `repository` key) plus a genuine cross-repo
node, `x/y#12`, whose
number also exists in the local repo. Reverting only `list-sub-items.sh`
to main and rerunning
  the suite gives:
`FAIL: [8] url-derived filter keeps the same-repo child - expected
github:o/r#11 got ''` and
`FAIL: [9] child row is re-parented to the container - expected
github:o/r#99 got null`,
suite exit 1. With the fix, 14/14 pass, exit 0. Node `x/y#12` stays
excluded either way, which is
  what proves the same-repo guard survived the change.
- **The stderr diagnostic is exercised, not merely added.** Two cases:
- `well-formed nodes emit no derivable-repo warning` runs the existing
well-formed stub
(including its cross-repo node) and asserts stderr carries no note. This
is the anti-noise
    guard: the documented cross-repo truncation must stay silent.
- A second stub serves one node with no `url` at all and one whose `url`
is not an issue path.
`stderr names both unattributable nodes on one line` asserts the single
note names both
numbers, `unattributable nodes dropped, the attributable one kept`
asserts the third node still
rolls up, and `stdout stays machine-parseable, diagnostic did not leak
into it` asserts the
    envelope still parses.
  - Mutation-checked: suppressing the `printf` turns
`FAIL: [14] stderr names both unattributable nodes on one line -
expected 1 got 0`, so the
    assertion is load-bearing rather than vacuous.
- `scripts/affected-tests.sh --run` -> `All 13 selected suites passed or
were skipped.`, exit 0.
- Every suite under `plugins/work-items/tools/work-item-tracker/` run
directly: 58 suites,
  0 failures.
- `scripts/check-changelog-parity.sh --check` -> exit 0.
- `scripts/check-changelog-parity.sh --check-bump origin/main` -> exit
0.
- `scripts/check-changelog-parity.sh --check-preserved origin/main` ->
exit 0
  (177 headings compared).
- `scripts/check-changelog-parity.sh --check-order` -> exit 0 (90
changelogs).
- `shellcheck -x list-sub-items.sh list-sub-items.test.sh` -> exit 0.
- `shfmt -d list-sub-items.sh list-sub-items.test.sh` -> exit 0, no
diff.
- `markdownlint-cli2` on the changed CHANGELOG and README -> 0 issues.

The gh projection claim is source-verified, not asserted from memory:
`api/query_builder.go` and
`api/export_pr.go` were read at tags v2.94.0, v2.95.0, v2.96.0, v2.97.0
and v2.98.0. The query
requests `repository{nameWithOwner}` at every one of them and the export
drops it at every one of
them.

### Live evidence, ported from #3832 (not produced on this branch)

The gh on this runner is **2.45.0**, which predates `subIssues`
entirely, so the acceptance criteria
naming live issue numbers could not be exercised here. They *were*
exercised on a gh **2.97.0** host
by the duplicate PR #3832, and the results below are quoted from that
PR's body. **This is another
PR's run, not this branch's validation.** It is reproduced here because
both PRs derive the repo
from the node `url` by the same rule, and on this input every node's
`url` parses and resolves to
the same repo, so the two implementations produce identical output over
it. Nothing below was
re-run on this branch, and no number here is restated beyond what #3832
shows.

#3832 reports `gh --version` as `gh version 2.97.0 (2026-07-31)`, and
`gh issue view 3799 --repo melodic-software/claude-code-plugins --json
subIssues` returning nodes
that carry `id`, `number`, `state`, `title`, `url` and **no
`repository`**, e.g.

`{"id":"I_kwDOTCGFQM8AAAABP7GlVw","number":3805,"state":"OPEN","title":"planning:
plan the typed-ticket-body
lane","url":"https://github.com/melodic-software/claude-code-plugins/issues/3805"}`
with `"totalCount":6`. The same selection via `gh api graphql`, which
asks for
`repository { nameWithOwner }` explicitly, *does* carry it, returning
`"totalCount":6` and node 3805
with `"nameWithOwner":"melodic-software/claude-code-plugins"`. That is
the live confirmation of the
projection asymmetry this PR diagnoses from source.

Against the acceptance criteria, #3832 reports `list-sub-items` on
`github:#3799` returning all six
sub-issues (#3824, #3823,
#3822, #3821, #3814, #3805), each with `parent_id` set to `#3799` and
`blocked_by_count` 1 except
#3805 at 0, where the pre-fix output was
`{"schema_version":"1.0","items":[]}`; and
`list-frontier --parent` on the same container returning exactly
`["github:#3805"]`, #3805 being the
only child with zero open
blockers and no assignee.

A spot check on a 2.94+ host against **this** branch is still the honest
confirmation, and is not
claimed here.

## Related

- Closes #3825, from the decomposition batch under epics #3799-#3803.
- **#3832 is a duplicate of this work; this PR is canonical.** #3832
(`fix/3825-subissue-repo-filter`) fixes the same issue off the same base
SHA with the same
url-derivation approach over the same adapter verb. By the worker lane's
lowest-numbered-PR
convention, #3830 survives and #3832 is superseded. Its live 2.97.0
probe and acceptance-criteria
output are ported above under explicit attribution. Its **fail-open**
branch is deliberately not
adopted: it rests on the premise that gh scopes `subIssues` to the
parent's own repo, which is
not true (sub-issues can be cross-repo), and failing open would defeat
the number-keyed intersect
  that CONTRACT.md documents. Nothing in this PR touches #3832.
- **Changelog collision with unmerged #3745.** Both PRs touch
`plugins/work-items/CHANGELOG.md`
and `.claude-plugin/plugin.json`. #3745 was drafted against `0.39.63`,
which main has since
passed: main is at `0.39.65`, so this PR lands `0.39.66` with its entry
above the preserved
`0.39.65`. #3745 will need to renumber to `0.39.67` or later once this
merges. Resolve any
conflict by merging and keeping both entries under their own headings;
do not fold them
together, and do not relabel a released heading, which is what
`--check-preserved` catches.
- #3745 owns `lib/frontier.sh` and `lib/labels.sh` in this plugin;
neither is touched here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob


---
_Generated by [Claude
Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

work-items/tracker: list-sub-items returns empty because gh --json subIssues nodes carry no repository field

1 participant