Skip to content

Fix IssueTracker duplicate-key crash on PR status refresh#180

Merged
dhilgaertner merged 1 commit intomainfrom
feature/crow-prstatus-duplicate-key-crash
Apr 16, 2026
Merged

Fix IssueTracker duplicate-key crash on PR status refresh#180
dhilgaertner merged 1 commit intomainfrom
feature/crow-prstatus-duplicate-key-crash

Conversation

@dhilgaertner
Copy link
Copy Markdown
Contributor

@dhilgaertner dhilgaertner commented Apr 16, 2026

Summary

  • Crash: Swift/NativeDictionary.swift:792: Fatal error: Duplicate values for key: '…/pull/201' at Dictionary(uniqueKeysWithValues:) in applyPRStatuses / autoCompleteFinishedSessions.
  • allKnownPRs = viewerPRs + stalePRs could surface the same URL twice (stale-PR follow-up race, or a GitHub response anomaly). The dictionary builders assumed uniqueness and trapped.
  • Replace with a state-ranked merge (MERGED > CLOSED > OPEN; empty winner fields backfilled from the loser) applied at allKnownPRs assembly, and switch the two Dictionary call sites from uniqueKeysWithValues: to uniquingKeysWith: Self.mergePRRecords as a belt-and-suspenders safety net.
  • Adds a CrowTests target (Tests/CrowTests/) with 6 regression cases and wires root-level swift test into make test and CI.

Test plan

  • make test — 265 tests across 9 suites pass, including 6 new IssueTracker PR dedup cases
  • make build — clean build
  • Manual: launch app with a session whose PR link matches a viewer-returned PR; confirm badge renders and no crash on first refresh tick

Follow-ups (not in this PR)

  • [GhosttyApp] Unhandled action: tag=35 log noise originates from vendor/ghostty and is unrelated to the crash — flag for a separate issue if wanted.
  • Opt-in crash telemetry to surface the next trap without relying on user logs is a separate design decision.

🤖 Generated with Claude Code

allKnownPRs could contain the same URL twice (viewer query + stale-PR
follow-up overlap, or a GitHub response anomaly), causing
Dictionary(uniqueKeysWithValues:) to trap on startup. Replace with a
state-ranked merge (MERGED > CLOSED > OPEN; empty winner fields
backfilled from the loser) applied at assembly time, and switch the
two Dictionary call sites to uniquingKeysWith: as a safety net.

Adds a CrowTests target with 6 regression cases and wires root-level
swift test into make test and CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dhilgaertner dhilgaertner requested a review from dgershman as a code owner April 16, 2026 17:09
Copy link
Copy Markdown
Collaborator

@dgershman dgershman left a comment

Choose a reason for hiding this comment

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

Code & Security Review

Critical Issues

None found.

Security Review

Strengths:

  • No user-supplied input reaches the dedup logic — all data originates from GitHub API responses
  • No secrets, credentials, or injection surfaces introduced
  • nonisolated static functions are pure and side-effect-free

Concerns:

  • None identified

Code Quality

Strengths:

  • Root cause is correctly identified: ghResult.viewerPRs + stalePRs can produce duplicate URLs (race between stale-PR follow-up and viewer query). The Dictionary(uniqueKeysWithValues:) call then traps on the duplicate key.
  • Fix applies dedup at the assembly site (dedupedByURL) and retains uniquingKeysWith: at the two downstream dictionary call sites as a belt-and-suspenders safety net — good defensive layering.
  • mergePRRecords state-rank merge (MERGED > CLOSED > OPEN) with empty-field backfill is well-reasoned: the stale-PR query returns minimal fields, so the richer OPEN record's checks/reviews are preserved when the winner is MERGED.
  • dedupedByURL preserves insertion order via a separate order array — deterministic downstream iteration.
  • Test suite covers: exact duplicates, state precedence regardless of order, backfill of empty fields from loser, three-way merge, order preservation, and the dictionary uniquingKeysWith path directly.
  • ViewerPR and LinkedIssue correctly marked Sendable to support nonisolated static usage.

Minor observations (non-blocking):

  • stateRank returns 0 for unknown states — the >= comparison in mergePRRecords means the left-hand side wins ties, which is fine since both records are equivalent in that case.
  • Makefile test target now depends on $(XCFW), ensuring the xcframework is built before tests run — good addition.
  • CI adds root-level swift test after the per-package loop — matches the Makefile change.

Summary Table

Priority Issue
🟢 Consider adding a doc comment on ViewerPR's Sendable conformance explaining it's needed for nonisolated static dedup helpers

Recommendation: Approve — the fix is well-scoped, correctly addresses the crash, has good defensive layering, and ships with solid regression tests.

🤖 Generated with Claude Code

@dhilgaertner dhilgaertner merged commit 0be909d into main Apr 16, 2026
2 checks passed
@dhilgaertner dhilgaertner deleted the feature/crow-prstatus-duplicate-key-crash branch April 16, 2026 17:56
Copy link
Copy Markdown
Collaborator

@dgershman dgershman left a comment

Choose a reason for hiding this comment

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

Code & Security Review

Critical Issues

None.

Security Review

Strengths:

  • No external input surfaces affected — all data flows from GitHub's GraphQL API responses, which are already validated during JSON parsing
  • No new shell invocations or command construction
  • nonisolated static functions correctly operate on Sendable value types with no actor-isolated state access

Concerns:

  • None identified

Code Quality

Strengths:

  • Root cause correctly identified: viewerPRs + stalePRs concatenation can produce duplicate URLs (stale-PR follow-up race or GitHub response anomaly), and Dictionary(uniqueKeysWithValues:) traps on duplicates
  • State-ranked merge (MERGED > CLOSED > OPEN) is the right precedence — a PR that transitioned mid-refresh gets its terminal state
  • Backfill logic in mergePRRecords correctly handles the asymmetry where stale-PR follow-up queries return minimal records (empty checks/reviews) while viewer queries return full records
  • Special handling for mergeable field (!= "UNKNOWN" vs .isEmpty) correctly accounts for its sentinel default — good attention to detail
  • Belt-and-suspenders: dedup at assembly site and uniquingKeysWith: at both Dictionary call sites means a future refactor can't regress one without the other catching it
  • dedupedByURL preserves first-seen order for deterministic downstream iteration
  • Tests cover the important cases: exact duplicates, state precedence regardless of input order, backfill from loser to winner, three-way merge, order preservation, and the Dictionary uniquingKeysWith path directly

Minor observations (non-blocking):

  • CrowTests depends on the Crow executable target — this works in SPM 6.0 but is worth noting if the project ever needs to support older toolchains
  • The test Makefile target now depends on $(XCFW), which correctly ensures GhosttyKit is built before running root-level tests that transitively link it

Summary Table

Priority Issue
Green Test target depends on executable target (SPM 6.0 feature) — fine for this project's minimum toolchain

Recommendation: Approve — the fix is correct, well-tested (6 regression cases), and the defense-in-depth approach at both the assembly and consumption sites is solid.

dhilgaertner added a commit that referenced this pull request Apr 22, 2026
Closes #181.

## Summary

- `autoCompleteFinishedSessions` used set-difference against
`openIssues`, so a silently-partial GraphQL response (rate-limit, parse
miss, stale-PR fetch error) flipped every candidate session to
completed. Replace the two absence-based fallbacks with
positive-evidence rules: PR-linked sessions need `MERGED`/`CLOSED` in
`prsByURL`; issue-only sessions need the ticket URL in
`closedIssueURLs`.
- `fetchStalePRStates` now returns `[ViewerPR]?` (nil on shell/parse
failure) and `refresh()` threads `prDataComplete` + `closedIssueURLs`
into both auto-complete paths. When the stale-PR fetch errors, PR-linked
completion is skipped.
- Belt-and-suspenders floor guard: if any candidate has a ticket URL and
`openIssueURLs` is empty, skip the cycle with a warning.
- Decision logic extracted into pure `nonisolated static` helpers
(`decideSessionCompletions`, `decideReviewCompletions`) so it's covered
by unit tests without a shell/Process abstraction (matches the
`dedupedByURL` / `mergePRRecords` pattern from #180). The same guards
apply symmetrically to review-session completion.

## Test plan

- [x] `make build` — clean compile
- [x] `swift test --filter IssueTracker` — 20/20 pass (6 existing dedup
+ 14 new)
- [x] `swift test` (full suite) — all pass
- [x] Manual: with the app running, force `runConsolidatedGitHubQuery`
to return an empty `data.openIssues.nodes` and confirm no active
sessions flip + log line `skipping auto-complete — openIssues empty with
N candidate sessions`
- [x] Manual: confirm the positive path still works — merge a PR linked
to an active session and observe it auto-complete on the next refresh
cycle

## Behavior changes worth calling out

- Sessions whose linked issue was closed **more than 24h ago** (outside
the `closed:>=` window in the consolidated query) will no longer
auto-complete — the user marks them manually. Matches the ticket's
"positive evidence only" requirement.
- Review sessions now only auto-complete when the PR is `MERGED` or
`CLOSED`. Previously, "no longer in the review-request queue for any
reason" triggered completion (e.g., reviewer unassigned with PR still
open). Users will manually complete those cases.

Recovery for the three sessions flipped on 2026-04-16 is unchanged from
the ticket:

```
crow set-status --session 27664893-E932-4AB3-97D6-7C8A6F997829 active
crow set-status --session EC8B5E7F-25A6-4E9A-A321-5087B01D32CA active
crow set-status --session 1D802104-9921-49EA-81DC-2D42F4AD2C6D active
```

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

Co-authored-by: Claude Opus 4.7 (1M context) <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.

2 participants