diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index b086c12..6db4bbf 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -230,9 +230,16 @@ final class IssueTracker { // for every viewer (which routinely returned 100 PRs / ~86 KB). let openPRURLs = Set(ghResult.viewerPRs.map(\.url)) let staleCandidateURLs = collectStalePRURLs(excluding: openPRURLs) - let stalePRs = staleCandidateURLs.isEmpty + // nil here means the follow-up fetch errored (rate limit, exit != 0, + // parse failure). We thread that through to auto-complete so + // "PR missing from payload" doesn't get treated as "PR is closed" + // on a degraded response. An empty list with no candidate URLs is + // a real empty success. + let staleFetchResult: [ViewerPR]? = staleCandidateURLs.isEmpty ? [] : await fetchStalePRStates(urls: staleCandidateURLs) + let stalePRs = staleFetchResult ?? [] + let prDataComplete = staleFetchResult != nil let allKnownPRs = Self.dedupedByURL(ghResult.viewerPRs + stalePRs) applyPRStatuses(viewerPRs: allKnownPRs) @@ -261,9 +268,15 @@ final class IssueTracker { syncInReviewSessions(issues: allIssues) autoCompleteFinishedSessions( openIssues: allIssues.filter { $0.state == "open" }, - viewerPRs: allKnownPRs + closedIssueURLs: Set(ghResult.closedIssues.map(\.url)), + viewerPRs: allKnownPRs, + prDataComplete: prDataComplete + ) + autoCompleteFinishedReviews( + openReviewPRURLs: Set(reviews.map(\.url)), + prsByURL: Dictionary(allKnownPRs.map { ($0.url, $0) }, uniquingKeysWith: Self.mergePRRecords), + prDataComplete: prDataComplete ) - autoCompleteFinishedReviews(openReviewPRURLs: Set(reviews.map(\.url))) clearRateLimitWarning() } @@ -529,7 +542,9 @@ final class IssueTracker { /// open viewer set (typically merged or closed). Returns minimal `ViewerPR` /// records — only `state`, `url`, repo, and branch refs are populated; /// checks/reviews are left empty since they're moot for closed PRs. - private func fetchStalePRStates(urls: [String]) async -> [ViewerPR] { + /// Returns `nil` if the shell call or response parse fails, so callers + /// can distinguish a partial fetch from a successful empty result. + private func fetchStalePRStates(urls: [String]) async -> [ViewerPR]? { // Parse each URL into (owner, repo, number); skip any we can't parse. var parsed: [(url: String, owner: String, repo: String, number: Int)] = [] for url in urls { @@ -569,17 +584,17 @@ final class IssueTracker { let result = await shellWithStatus(args: args) if result.exitCode != 0 { - if handleGraphQLRateLimit(stderr: result.stderr) { return [] } + if handleGraphQLRateLimit(stderr: result.stderr) { return nil } print("[IssueTracker] Stale-PR follow-up failed (exit \(result.exitCode)): \(result.stderr.prefix(200))") - return [] + return nil } return parseStalePRResponse(result.stdout, count: parsed.count) } - private func parseStalePRResponse(_ output: String, count: Int) -> [ViewerPR] { + private func parseStalePRResponse(_ output: String, count: Int) -> [ViewerPR]? { guard let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let dataObj = json["data"] as? [String: Any] else { return [] } + let dataObj = json["data"] as? [String: Any] else { return nil } if let rl = parseRateLimit(dataObj["rateLimit"] as? [String: Any]) { appState.githubRateLimit = rl @@ -1005,64 +1020,184 @@ final class IssueTracker { } } - /// Check active sessions whose linked ticket is no longer in the open issues - /// list. If the session has a PR link, use the merged/closed state from the - /// payload (open PRs come from the viewer query, merged/closed PRs from the - /// stale-PR follow-up). Open PRs hold off completion in case the issue was - /// closed accidentally. - private func autoCompleteFinishedSessions(openIssues: [AssignedIssue], viewerPRs: [ViewerPR]) { - let openIssueURLs = Set(openIssues.map(\.url)) - let prsByURL = Dictionary(viewerPRs.map { ($0.url, $0) }, uniquingKeysWith: Self.mergePRRecords) + /// Decision returned by `decideSessionCompletions` — carries both the + /// session to complete and a short reason used in the log line emitted + /// by the adapter. + struct CompletionDecision: Equatable { + let sessionID: UUID + let reason: String + } - let candidateSessions = appState.sessions.filter { - $0.id != AppState.managerSessionID && - ($0.status == .active || $0.status == .paused || $0.status == .inReview) + /// Result of a completion-decision pass. `floorGuardTriggered` is `true` + /// when the decider refused to complete anything because the fetched + /// open-issue set was empty while candidate sessions had ticket URLs — + /// a strong indicator that the underlying GraphQL response was partial + /// or errored. Surfaced so the adapter can log a warning and tests can + /// assert the guard fired. + struct CompletionResult: Equatable { + let completions: [CompletionDecision] + let floorGuardTriggered: Bool + } + + /// Decide which candidate sessions should be auto-completed based on + /// the current refresh payload. Requires positive evidence of closure: + /// a PR-linked session needs its PR in `prsByURL` with state `MERGED` + /// or `CLOSED`; an issue-only session needs its ticket URL in + /// `closedIssueURLs`. Missing-from-open is no longer sufficient. + /// + /// `prDataComplete` is `false` when the stale-PR follow-up errored + /// (rate-limited, non-zero exit, parse failure). In that case, PR-linked + /// completions are skipped entirely to avoid completing on stale data. + nonisolated static func decideSessionCompletions( + candidateSessions: [Session], + linksBySessionID: [UUID: [SessionLink]], + openIssueURLs: Set, + closedIssueURLs: Set, + prsByURL: [String: ViewerPR], + prDataComplete: Bool + ) -> CompletionResult { + let withTickets = candidateSessions.filter { $0.ticketURL != nil } + + // Floor guard: if we have candidates but openIssueURLs is empty, the + // consolidated query likely returned partial data. Skip this cycle. + // This is belt-and-suspenders — the positive-evidence rules below + // already refuse to complete without a MERGED/CLOSED PR or a + // closedIssueURLs hit — but the guard catches future regressions + // that reintroduce an absence-based path. + if !withTickets.isEmpty && openIssueURLs.isEmpty { + return CompletionResult(completions: [], floorGuardTriggered: true) } - for session in candidateSessions { + + var decisions: [CompletionDecision] = [] + for session in withTickets { guard let ticketURL = session.ticketURL else { continue } if openIssueURLs.contains(ticketURL) { continue } - let sessionLinks = appState.links(for: session.id) + let sessionLinks = linksBySessionID[session.id] ?? [] if let prLink = sessionLinks.first(where: { $0.linkType == .pr }) { - if let pr = prsByURL[prLink.url] { - switch pr.state { - case "MERGED": - print("[IssueTracker] Session '\(session.name)' — PR merged, marking completed") - appState.onCompleteSession?(session.id) - case "CLOSED": - print("[IssueTracker] Session '\(session.name)' — PR closed, marking completed") - appState.onCompleteSession?(session.id) - default: - // OPEN: hold off (issue may have been closed accidentally). - break - } - } else { - // PR not in any payload (deleted, no access, or the - // follow-up query failed) — fall back to absence == done. - print("[IssueTracker] Session '\(session.name)' — PR no longer open, marking completed") - appState.onCompleteSession?(session.id) + guard prDataComplete else { continue } + guard let pr = prsByURL[prLink.url] else { continue } + switch pr.state { + case "MERGED": + decisions.append(CompletionDecision(sessionID: session.id, reason: "PR merged")) + case "CLOSED": + decisions.append(CompletionDecision(sessionID: session.id, reason: "PR closed")) + default: + break } continue } - // No PR link — issue isn't open, so it was closed directly. if session.provider == .github || session.provider == nil { - print("[IssueTracker] Session '\(session.name)' — issue closed, marking completed") - appState.onCompleteSession?(session.id) + if closedIssueURLs.contains(ticketURL) { + decisions.append(CompletionDecision(sessionID: session.id, reason: "issue closed")) + } } } + return CompletionResult(completions: decisions, floorGuardTriggered: false) } - /// Auto-complete review sessions whose PR is no longer in the open review - /// search — which implies it was merged, closed, or review-dismissed. - private func autoCompleteFinishedReviews(openReviewPRURLs: Set) { - let activeReviews = appState.sessions.filter { $0.kind == .review && $0.status == .active } - for session in activeReviews { - let sessionLinks = appState.links(for: session.id) + /// Decide which review sessions should be auto-completed. Requires the + /// PR to be present in `prsByURL` with state `MERGED` or `CLOSED` — the + /// old "missing from open review queue == done" rule was unsafe under + /// partial fetches. + nonisolated static func decideReviewCompletions( + reviewSessions: [Session], + linksBySessionID: [UUID: [SessionLink]], + openReviewPRURLs: Set, + prsByURL: [String: ViewerPR], + prDataComplete: Bool + ) -> [CompletionDecision] { + guard prDataComplete else { return [] } + + var decisions: [CompletionDecision] = [] + for session in reviewSessions { + let sessionLinks = linksBySessionID[session.id] ?? [] guard let prLink = sessionLinks.first(where: { $0.linkType == .pr }) else { continue } if openReviewPRURLs.contains(prLink.url) { continue } - print("[IssueTracker] Review session '\(session.name)' — PR no longer open for review, marking completed") - appState.onCompleteSession?(session.id) + guard let pr = prsByURL[prLink.url] else { continue } + switch pr.state { + case "MERGED": + decisions.append(CompletionDecision(sessionID: session.id, reason: "PR merged")) + case "CLOSED": + decisions.append(CompletionDecision(sessionID: session.id, reason: "PR closed")) + default: + break + } + } + return decisions + } + + /// Check active sessions whose linked ticket is no longer in the open + /// issues list. Delegates to `decideSessionCompletions` so the decision + /// logic is covered by unit tests without a shell/Process abstraction. + private func autoCompleteFinishedSessions( + openIssues: [AssignedIssue], + closedIssueURLs: Set, + viewerPRs: [ViewerPR], + prDataComplete: Bool + ) { + let openIssueURLs = Set(openIssues.map(\.url)) + let prsByURL = Dictionary(viewerPRs.map { ($0.url, $0) }, uniquingKeysWith: Self.mergePRRecords) + + let candidateSessions = appState.sessions.filter { + $0.id != AppState.managerSessionID && + ($0.status == .active || $0.status == .paused || $0.status == .inReview) + } + var linksBySessionID: [UUID: [SessionLink]] = [:] + for session in candidateSessions { + linksBySessionID[session.id] = appState.links(for: session.id) + } + + let result = Self.decideSessionCompletions( + candidateSessions: candidateSessions, + linksBySessionID: linksBySessionID, + openIssueURLs: openIssueURLs, + closedIssueURLs: closedIssueURLs, + prsByURL: prsByURL, + prDataComplete: prDataComplete + ) + + if result.floorGuardTriggered { + let count = candidateSessions.filter { $0.ticketURL != nil }.count + print("[IssueTracker] skipping auto-complete — openIssues empty with \(count) candidate sessions (likely partial fetch)") + return + } + + let sessionsByID = Dictionary(uniqueKeysWithValues: candidateSessions.map { ($0.id, $0) }) + for decision in result.completions { + let name = sessionsByID[decision.sessionID]?.name ?? decision.sessionID.uuidString + print("[IssueTracker] Session '\(name)' — \(decision.reason), marking completed") + appState.onCompleteSession?(decision.sessionID) + } + } + + /// Auto-complete review sessions whose PR has been merged or closed. + /// Delegates to `decideReviewCompletions` for testability. + private func autoCompleteFinishedReviews( + openReviewPRURLs: Set, + prsByURL: [String: ViewerPR], + prDataComplete: Bool + ) { + let activeReviews = appState.sessions.filter { $0.kind == .review && $0.status == .active } + var linksBySessionID: [UUID: [SessionLink]] = [:] + for session in activeReviews { + linksBySessionID[session.id] = appState.links(for: session.id) + } + + let decisions = Self.decideReviewCompletions( + reviewSessions: activeReviews, + linksBySessionID: linksBySessionID, + openReviewPRURLs: openReviewPRURLs, + prsByURL: prsByURL, + prDataComplete: prDataComplete + ) + + let sessionsByID = Dictionary(uniqueKeysWithValues: activeReviews.map { ($0.id, $0) }) + for decision in decisions { + let name = sessionsByID[decision.sessionID]?.name ?? decision.sessionID.uuidString + print("[IssueTracker] Review session '\(name)' — \(decision.reason), marking completed") + appState.onCompleteSession?(decision.sessionID) } } diff --git a/Tests/CrowTests/IssueTrackerCompletionTests.swift b/Tests/CrowTests/IssueTrackerCompletionTests.swift new file mode 100644 index 0000000..e8f0336 --- /dev/null +++ b/Tests/CrowTests/IssueTrackerCompletionTests.swift @@ -0,0 +1,308 @@ +import Foundation +import Testing +import CrowCore +@testable import Crow + +@Suite("IssueTracker completion decisions") +struct IssueTrackerCompletionTests { + + // MARK: - Fixtures + + private func makeSession( + id: UUID = UUID(), + name: String = "session", + status: SessionStatus = .active, + kind: SessionKind = .work, + ticketURL: String? = nil, + provider: Provider? = .github + ) -> Session { + Session( + id: id, + name: name, + status: status, + kind: kind, + ticketURL: ticketURL, + provider: provider + ) + } + + private func prLink(sessionID: UUID, url: String) -> SessionLink { + SessionLink(sessionID: sessionID, label: "PR", url: url, linkType: .pr) + } + + private func makeViewerPR( + url: String, + state: String, + number: Int = 1 + ) -> IssueTracker.ViewerPR { + IssueTracker.ViewerPR( + number: number, + url: url, + state: state, + mergeable: "UNKNOWN", + reviewDecision: "", + isDraft: false, + headRefName: "", + baseRefName: "", + repoNameWithOwner: "", + linkedIssueReferences: [], + checksState: "", + failedCheckNames: [], + latestReviewStates: [] + ) + } + + // MARK: - Floor guard (the crow#181 regression) + + @Test + func emptyOpenIssuesWithCandidatesTriggersFloorGuard() { + // The bug: a degraded consolidated-GraphQL response produced an + // empty openIssues list. Every candidate session with a ticket URL + // fell through the "absence == done" branches and was marked + // completed. With the guard, we must refuse to complete anything. + let session = makeSession( + name: "citadel-134-sensor-framework", + ticketURL: "https://github.com/foo/citadel/issues/134" + ) + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [:], + openIssueURLs: [], + closedIssueURLs: [], + prsByURL: [:], + prDataComplete: true + ) + #expect(result.floorGuardTriggered == true) + #expect(result.completions.isEmpty) + } + + @Test + func floorGuardDoesNotFireWhenNoCandidatesHaveTickets() { + // If no candidate sessions have ticket URLs, an empty openIssues + // set is just a user with no assigned issues — not a partial fetch. + let session = makeSession(ticketURL: nil) + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [:], + openIssueURLs: [], + closedIssueURLs: [], + prsByURL: [:], + prDataComplete: true + ) + #expect(result.floorGuardTriggered == false) + #expect(result.completions.isEmpty) + } + + // MARK: - Positive-evidence rules + + @Test + func mergedPRCompletes() { + let session = makeSession(ticketURL: "https://github.com/foo/bar/issues/1") + let prURL = "https://github.com/foo/bar/pull/2" + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [session.id: [prLink(sessionID: session.id, url: prURL)]], + openIssueURLs: ["https://github.com/foo/bar/issues/other"], + closedIssueURLs: [], + prsByURL: [prURL: makeViewerPR(url: prURL, state: "MERGED")], + prDataComplete: true + ) + #expect(result.floorGuardTriggered == false) + #expect(result.completions == [ + IssueTracker.CompletionDecision(sessionID: session.id, reason: "PR merged") + ]) + } + + @Test + func closedPRCompletes() { + let session = makeSession(ticketURL: "https://github.com/foo/bar/issues/1") + let prURL = "https://github.com/foo/bar/pull/2" + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [session.id: [prLink(sessionID: session.id, url: prURL)]], + openIssueURLs: ["https://github.com/foo/bar/issues/other"], + closedIssueURLs: [], + prsByURL: [prURL: makeViewerPR(url: prURL, state: "CLOSED")], + prDataComplete: true + ) + #expect(result.completions == [ + IssueTracker.CompletionDecision(sessionID: session.id, reason: "PR closed") + ]) + } + + @Test + func openPRHoldsOff() { + let session = makeSession(ticketURL: "https://github.com/foo/bar/issues/1") + let prURL = "https://github.com/foo/bar/pull/2" + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [session.id: [prLink(sessionID: session.id, url: prURL)]], + openIssueURLs: ["https://github.com/foo/bar/issues/other"], + closedIssueURLs: [], + prsByURL: [prURL: makeViewerPR(url: prURL, state: "OPEN")], + prDataComplete: true + ) + #expect(result.completions.isEmpty) + } + + @Test + func partialStalePRFetchDoesNotComplete() { + // prDataComplete == false means the stale-PR follow-up errored. + // A PR-linked session must not be completed — we don't have trust + // in the PR state, even for MERGED/CLOSED records that happen to + // appear (they may be stale from a prior cycle, but conservatively + // we refuse to act on this cycle's incomplete data). + let session = makeSession(ticketURL: "https://github.com/foo/bar/issues/1") + let prURL = "https://github.com/foo/bar/pull/2" + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [session.id: [prLink(sessionID: session.id, url: prURL)]], + openIssueURLs: ["https://github.com/foo/bar/issues/other"], + closedIssueURLs: [], + prsByURL: [:], + prDataComplete: false + ) + #expect(result.completions.isEmpty) + } + + @Test + func prMissingFromPayloadDoesNotComplete() { + // Even with prDataComplete == true, a PR that's simply absent from + // the payload (deleted, no access, or the viewer lost visibility) + // is not positive evidence of closure. The old code treated this + // as "PR no longer open, marking completed" — removed. + let session = makeSession(ticketURL: "https://github.com/foo/bar/issues/1") + let prURL = "https://github.com/foo/bar/pull/2" + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [session.id: [prLink(sessionID: session.id, url: prURL)]], + openIssueURLs: ["https://github.com/foo/bar/issues/other"], + closedIssueURLs: [], + prsByURL: [:], + prDataComplete: true + ) + #expect(result.completions.isEmpty) + } + + @Test + func issueOnlyCompletesViaClosedIssueURL() { + let ticketURL = "https://github.com/foo/bar/issues/1" + let session = makeSession(ticketURL: ticketURL) + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [:], + openIssueURLs: ["https://github.com/foo/bar/issues/other"], + closedIssueURLs: [ticketURL], + prsByURL: [:], + prDataComplete: true + ) + #expect(result.completions == [ + IssueTracker.CompletionDecision(sessionID: session.id, reason: "issue closed") + ]) + } + + @Test + func issueOnlyStaysActiveWhenMissingFromBothSets() { + // This is the primary bug scenario: the issue isn't in openIssues + // but also isn't in the recent closed set (either because the + // fetch was partial, or the closure is older than 24h). Under the + // old code, this session would be marked completed; now it must + // stay active. + let session = makeSession( + name: "citadel-98-cost-tracking-pricing", + ticketURL: "https://github.com/foo/citadel/issues/98" + ) + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [:], + openIssueURLs: ["https://github.com/foo/citadel/issues/other"], + closedIssueURLs: [], + prsByURL: [:], + prDataComplete: true + ) + #expect(result.floorGuardTriggered == false) + #expect(result.completions.isEmpty) + } + + @Test + func sessionStillInOpenIssuesIsSkipped() { + let ticketURL = "https://github.com/foo/bar/issues/1" + let session = makeSession(ticketURL: ticketURL) + let result = IssueTracker.decideSessionCompletions( + candidateSessions: [session], + linksBySessionID: [:], + openIssueURLs: [ticketURL], + closedIssueURLs: [ticketURL], + prsByURL: [:], + prDataComplete: true + ) + // Still open trumps closed — a session whose ticket is in the open + // set must not be completed regardless of other payload contents. + #expect(result.completions.isEmpty) + } + + // MARK: - Review completion + + @Test + func reviewPRMergedCompletes() { + let reviewSession = makeSession( + kind: .review, + ticketURL: nil + ) + let prURL = "https://github.com/foo/bar/pull/9" + let decisions = IssueTracker.decideReviewCompletions( + reviewSessions: [reviewSession], + linksBySessionID: [reviewSession.id: [prLink(sessionID: reviewSession.id, url: prURL)]], + openReviewPRURLs: [], + prsByURL: [prURL: makeViewerPR(url: prURL, state: "MERGED")], + prDataComplete: true + ) + #expect(decisions == [ + IssueTracker.CompletionDecision(sessionID: reviewSession.id, reason: "PR merged") + ]) + } + + @Test + func reviewStillInOpenReviewQueueIsSkipped() { + let reviewSession = makeSession(kind: .review) + let prURL = "https://github.com/foo/bar/pull/9" + let decisions = IssueTracker.decideReviewCompletions( + reviewSessions: [reviewSession], + linksBySessionID: [reviewSession.id: [prLink(sessionID: reviewSession.id, url: prURL)]], + openReviewPRURLs: [prURL], + prsByURL: [prURL: makeViewerPR(url: prURL, state: "MERGED")], + prDataComplete: true + ) + #expect(decisions.isEmpty) + } + + @Test + func reviewDoesNotCompleteWhenPRDataIncomplete() { + let reviewSession = makeSession(kind: .review) + let prURL = "https://github.com/foo/bar/pull/9" + let decisions = IssueTracker.decideReviewCompletions( + reviewSessions: [reviewSession], + linksBySessionID: [reviewSession.id: [prLink(sessionID: reviewSession.id, url: prURL)]], + openReviewPRURLs: [], + prsByURL: [prURL: makeViewerPR(url: prURL, state: "MERGED")], + prDataComplete: false + ) + #expect(decisions.isEmpty) + } + + @Test + func reviewMissingFromPayloadDoesNotComplete() { + // Old code: "PR no longer open for review, marking completed". + // New rule: without a MERGED/CLOSED record in prsByURL, don't act. + let reviewSession = makeSession(kind: .review) + let prURL = "https://github.com/foo/bar/pull/9" + let decisions = IssueTracker.decideReviewCompletions( + reviewSessions: [reviewSession], + linksBySessionID: [reviewSession.id: [prLink(sessionID: reviewSession.id, url: prURL)]], + openReviewPRURLs: [], + prsByURL: [:], + prDataComplete: true + ) + #expect(decisions.isEmpty) + } +}