Fix IssueTracker duplicate-key crash on PR status refresh#180
Merged
dhilgaertner merged 1 commit intomainfrom Apr 16, 2026
Merged
Fix IssueTracker duplicate-key crash on PR status refresh#180dhilgaertner merged 1 commit intomainfrom
dhilgaertner merged 1 commit intomainfrom
Conversation
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>
dgershman
approved these changes
Apr 16, 2026
Collaborator
dgershman
left a comment
There was a problem hiding this comment.
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 staticfunctions are pure and side-effect-free
Concerns:
- None identified
Code Quality
Strengths:
- Root cause is correctly identified:
ghResult.viewerPRs + stalePRscan produce duplicate URLs (race between stale-PR follow-up and viewer query). TheDictionary(uniqueKeysWithValues:)call then traps on the duplicate key. - Fix applies dedup at the assembly site (
dedupedByURL) and retainsuniquingKeysWith:at the two downstream dictionary call sites as a belt-and-suspenders safety net — good defensive layering. mergePRRecordsstate-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.dedupedByURLpreserves insertion order via a separateorderarray — 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
uniquingKeysWithpath directly. ViewerPRandLinkedIssuecorrectly markedSendableto supportnonisolated staticusage.
Minor observations (non-blocking):
stateRankreturns 0 for unknown states — the>=comparison inmergePRRecordsmeans the left-hand side wins ties, which is fine since both records are equivalent in that case.Makefiletesttarget now depends on$(XCFW), ensuring the xcframework is built before tests run — good addition.- CI adds root-level
swift testafter 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
dgershman
approved these changes
Apr 20, 2026
Collaborator
dgershman
left a comment
There was a problem hiding this comment.
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 staticfunctions correctly operate onSendablevalue types with no actor-isolated state access
Concerns:
- None identified
Code Quality
Strengths:
- Root cause correctly identified:
viewerPRs + stalePRsconcatenation can produce duplicate URLs (stale-PR follow-up race or GitHub response anomaly), andDictionary(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
mergePRRecordscorrectly handles the asymmetry where stale-PR follow-up queries return minimal records (empty checks/reviews) while viewer queries return full records - Special handling for
mergeablefield (!= "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 dedupedByURLpreserves 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
uniquingKeysWithpath directly
Minor observations (non-blocking):
CrowTestsdepends on theCrowexecutable target — this works in SPM 6.0 but is worth noting if the project ever needs to support older toolchains- The
testMakefile 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.
5 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Swift/NativeDictionary.swift:792: Fatal error: Duplicate values for key: '…/pull/201'atDictionary(uniqueKeysWithValues:)inapplyPRStatuses/autoCompleteFinishedSessions.allKnownPRs = viewerPRs + stalePRscould surface the same URL twice (stale-PR follow-up race, or a GitHub response anomaly). The dictionary builders assumed uniqueness and trapped.MERGED > CLOSED > OPEN; empty winner fields backfilled from the loser) applied atallKnownPRsassembly, and switch the twoDictionarycall sites fromuniqueKeysWithValues:touniquingKeysWith: Self.mergePRRecordsas a belt-and-suspenders safety net.CrowTeststarget (Tests/CrowTests/) with 6 regression cases and wires root-levelswift testintomake testand CI.Test plan
make test— 265 tests across 9 suites pass, including 6 newIssueTracker PR dedupcasesmake build— clean buildFollow-ups (not in this PR)
[GhosttyApp] Unhandled action: tag=35log noise originates fromvendor/ghosttyand is unrelated to the crash — flag for a separate issue if wanted.🤖 Generated with Claude Code