native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
44 kB · 1126 lines
Swift
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127import AppKitimport ComposableArchitectureimport Foundation
private let unresolvedGithubRepositoryMessage = String( localized: """ Prowl could not determine which GitHub repository owns this pull request. \ Check the repository remote and try again. """)
extension RepositoriesFeature { static func resolveGithubRemoteInfo( repositoryRootURL: URL, githubCLI: GithubCLIClient, gitClient: GitClientDependency ) async -> GithubRemoteInfo? { // Parsing the local git remote URL is ~20x faster than spawning `gh repo view` // (no gh subprocess, no network round trip). The batched GraphQL query that // follows is itself authoritative about whether the repo exists on GitHub, so // gh is only useful as a fallback for non-standard remotes that the regex in // GitClient cannot parse. if let remoteInfo = await gitClient.remoteInfo(repositoryRootURL) { return remoteInfo } return await githubCLI.resolveRemoteInfo(repositoryRootURL) }
static func resolveGithubRemoteInfos( repositoryRootURL: URL, githubCLI: GithubCLIClient, gitClient: GitClientDependency ) async -> [GithubRemoteInfo] { let remoteInfos = await gitClient.githubRemoteInfos(repositoryRootURL) if !remoteInfos.isEmpty { return remoteInfos } if let remoteInfo = await githubCLI.resolveRemoteInfo(repositoryRootURL) { return [remoteInfo] } return [] }
static func resolveGithubRemoteInfo( for pullRequest: GithubPullRequest, repositoryRootURL: URL, githubCLI: GithubCLIClient, gitClient: GitClientDependency ) async -> GithubRemoteInfo? { if let remoteInfo = GitClient.parseGithubRemoteInfo(pullRequest.url) { return remoteInfo } return await resolveGithubRemoteInfo( repositoryRootURL: repositoryRootURL, githubCLI: githubCLI, gitClient: gitClient ) }}
extension RepositoriesFeature { // swiftlint:disable:next cyclomatic_complexity function_body_length func reduceGithubIntegration( state: inout State, action: GithubIntegrationAction ) -> Effect<Action> { switch action { case .delayedPullRequestRefresh(let worktreeID): guard let worktree = state.worktree(for: worktreeID), let repositoryID = state.repositoryID(containing: worktreeID), let repository = state.repositories[id: repositoryID] else { return .none } // An action just changed this pull request; its refresh must not wait for the cadence. state.pullRequestRefreshForcedWorktreeIDs.insert(worktreeID) let repositoryRootURL = worktree.repositoryRootURL let worktreeIDs = repository.worktrees.map(\.id) return .run { send in try? await ContinuousClock().sleep(for: .seconds(2)) await send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs ) ) ) } .cancellable(id: CancelID.delayedPRRefresh(worktreeID), cancelInFlight: true)
case .repositoryPullRequestRefreshRequested(let repositoryRootURL, let worktreeIDs): @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings guard repositorySettings.fetchesPullRequestState else { return .none } let selectedWorktreeID = state.selectedWorktreeID let checkpoints = state.pullRequestRefreshCheckpointByWorktreeID let forced = state.pullRequestRefreshForcedWorktreeIDs let currentDate = worktreeIDs.contains { checkpoints[$0] != nil && !forced.contains($0) } ? now : nil let worktrees = worktreeIDs.compactMap { state.worktree(for: $0) }.filter { worktree in guard let currentDate, !forced.contains(worktree.id) else { return true } return PullRequestRefreshCadence.isDue( branch: worktree.name, checkpoint: checkpoints[worktree.id], now: currentDate, isSelected: worktree.id == selectedWorktreeID ) } guard let firstWorktree = worktrees.first, let repositoryID = state.repositoryID(containing: firstWorktree.id) else { return .none } let dueWorktreeIDs = worktrees.map(\.id) var seen = Set<String>() let branches = worktrees .map(\.name) .filter { !$0.isEmpty && seen.insert($0).inserted } guard !branches.isEmpty else { return .none } switch state.githubIntegrationAvailability { case .available: if state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) { queuePullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: dueWorktreeIDs, refreshesByRepositoryID: &state.queuedPullRequestRefreshByRepositoryID ) return .none } state.inFlightPullRequestRefreshRepositoryIDs.insert(repositoryID) let sentMarks = state.pullRequestRefreshForcedWorktreeIDs.intersection(dueWorktreeIDs) state.pullRequestRefreshForcedWorktreeIDs.subtract(sentMarks) if !sentMarks.isEmpty { state.sentPullRequestRefreshMarks[repositoryID, default: []].formUnion(sentMarks) } // Only the selected worktree's pull request lists each check; the rest carry counts. let detailBranches = worktrees.filter { $0.id == state.selectedWorktreeID }.map(\.name) return enqueueBatchedPullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktrees: worktrees, branches: branches, detailBranches: detailBranches ) case .unknown: queuePullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: dueWorktreeIDs, refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID ) return .send(.githubIntegration(.refreshGithubIntegrationAvailability)) case .checking: queuePullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: dueWorktreeIDs, refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID ) return .none case .unavailable: queuePullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: dueWorktreeIDs, refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID ) return .none case .disabled: return .none }
case .refreshGithubIntegrationAvailability: guard state.githubIntegrationAvailability != .checking, state.githubIntegrationAvailability != .disabled else { return .none } state.githubIntegrationAvailability = .checking let githubIntegration = githubIntegration return .run { send in let isAvailable = await githubIntegration.isAvailable() await send(.githubIntegration(.githubIntegrationAvailabilityUpdated(isAvailable))) } .cancellable(id: CancelID.githubIntegrationAvailability, cancelInFlight: true)
case .githubIntegrationAvailabilityUpdated(let isAvailable): guard state.githubIntegrationAvailability != .disabled else { return .none } state.githubIntegrationAvailability = isAvailable ? .available : .unavailable guard isAvailable else { for (repositoryID, queued) in state.queuedPullRequestRefreshByRepositoryID { queuePullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: queued.repositoryRootURL, worktreeIDs: queued.worktreeIDs, refreshesByRepositoryID: &state.pendingPullRequestRefreshByRepositoryID ) } state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() state.restoreUnansweredPullRequestRefreshMarks() clearAllPullRequestRefreshTracking(state: &state) return .run { send in while !Task.isCancelled { try? await clock.sleep(for: githubIntegrationRecoveryInterval) guard !Task.isCancelled else { return } await send(.githubIntegration(.refreshGithubIntegrationAvailability)) } } .cancellable(id: CancelID.githubIntegrationRecovery, cancelInFlight: true) } let pendingRefreshes = state.pendingPullRequestRefreshByRepositoryID.values.sorted { $0.repositoryRootURL.path(percentEncoded: false) < $1.repositoryRootURL.path(percentEncoded: false) } state.pendingPullRequestRefreshByRepositoryID.removeAll() return .merge( .cancel(id: CancelID.githubIntegrationRecovery), .merge( pendingRefreshes.map { pending in .send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: pending.repositoryRootURL, worktreeIDs: pending.worktreeIDs ) ) ) } ) )
case .repositoryPullRequestRefreshCompleted(let repositoryID): state.restoreUnansweredPullRequestRefreshMarks(of: repositoryID) state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) guard state.githubIntegrationAvailability == .available, let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( forKey: repositoryID ) else { return .none } return .send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: pending.repositoryRootURL, worktreeIDs: pending.worktreeIDs ) ) )
case .pullRequestRefreshBatchCountResolved(let repositoryID, let count, let remotePriorities): guard state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) else { return .none } state.prRefreshBatchCountsByRepositoryID[repositoryID] = max(1, count) state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] = remotePriorities state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) return .none
case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): guard let repository = state.repositories[id: repositoryID] else { return .none } var mergedWorktreeIDs: [Worktree.ID] = [] for worktreeID in pullRequestsByWorktreeID.keys.sorted() { guard let worktree = repository.worktrees[id: worktreeID] else { continue } let loadedPullRequest = pullRequestsByWorktreeID[worktreeID] ?? nil let displayedPullRequest = Self.displayPullRequest(loadedPullRequest, for: worktree) let previousPullRequest = state.worktreeInfoByID[worktreeID]?.pullRequest let pullRequest = Self.preservingMergeableState( displayedPullRequest, previous: previousPullRequest ) guard previousPullRequest != pullRequest else { continue } let previousMerged = previousPullRequest?.state == "MERGED" let nextMerged = pullRequest?.state == "MERGED" updateWorktreePullRequest( worktreeID: worktreeID, pullRequest: pullRequest, state: &state ) if state.mergedWorktreeAction != nil, !previousMerged, nextMerged, !state.isMainWorktree(worktree), !state.isWorktreeArchived(worktreeID), !state.deletingWorktreeIDs.contains(worktreeID) { mergedWorktreeIDs.append(worktreeID) } } guard !mergedWorktreeIDs.isEmpty else { return .none } switch state.mergedWorktreeAction { case .archive: return .merge( mergedWorktreeIDs.map { worktreeID in .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktreeID, repositoryID))) } ) case .delete: @Shared(.settingsFile) var settingsFile return .merge( mergedWorktreeIDs.map { worktreeID in let shouldDeleteBranch = settingsFile.global.deleteBranchOnAutomaticCleanup && state.prowlCreatedWorktreeIDs.contains(worktreeID) return .send( .worktreeLifecycle( .deleteWorktreeConfirmed( worktreeID, repositoryID, deleteBranch: shouldDeleteBranch )) ) } ) case nil: return .none }
case .pullRequestAction(let worktreeID, let action): guard let worktree = state.worktree(for: worktreeID), let repositoryID = state.repositoryID(containing: worktreeID), let repository = state.repositories[id: repositoryID] else { return .send( .presentAlert( title: String(localized: "Repository not available"), message: String(localized: "Prowl could not find the selected repository.") ) ) } let repoRoot = worktree.repositoryRootURL let worktreeRoot = worktree.workingDirectory let optionalPullRequest = state.worktreeInfo(for: worktreeID)?.pullRequest if case .openOnCodeHost = action { let gitClient = gitClient let openURLClient = openURLClient let pullRequestURL = optionalPullRequest.flatMap { Self.validWebURL($0.url) } return .run { send in if let pullRequestURL { await openURLClient.open(pullRequestURL) return } guard let repositoryURL = await gitClient.repositoryWebURL(repoRoot) else { await send( .presentAlert( title: String(localized: "Repository URL not available"), message: String(localized: "Prowl could not determine a code host URL for this repository.") ) ) return } await openURLClient.open(repositoryURL) } } guard let pullRequest = optionalPullRequest else { return .send( .presentAlert( title: String(localized: "Pull request not available"), message: String(localized: "Prowl could not find a pull request for this worktree.") ) ) } let pullRequestRefresh = WorktreeInfoWatcherClient.Event.repositoryPullRequestRefresh( repositoryRootURL: repoRoot, worktreeIDs: repository.worktrees.map(\.id) ) let branchName = pullRequest.headRefName ?? worktree.name let failingCheckDetailsURL = (pullRequest.statusCheckRollup?.checks ?? []).first { $0.checkState == .failure && $0.detailsUrl != nil }?.detailsUrl switch action { case .openOnCodeHost: return .none
case .copyFailingJobURL: guard let failingCheckDetailsURL, !failingCheckDetailsURL.isEmpty else { return .send( .presentAlert( title: String(localized: "Failing check not found"), message: String(localized: "Prowl could not find a failing check URL.") ) ) } return .run { send in await MainActor.run { NSPasteboard.general.clearContents() NSPasteboard.general.setString(failingCheckDetailsURL, forType: .string) } await send(.showToast(.success(String(localized: "Failing job URL copied")))) }
case .openFailingCheckDetails: guard let failingCheckDetailsURL, let url = URL(string: failingCheckDetailsURL) else { return .send( .presentAlert( title: String(localized: "Failing check not found"), message: String(localized: "Prowl could not find a failing check with details.") ) ) } return .run { @MainActor _ in NSWorkspace.shared.open(url) }
case .markReadyForReview: let githubCLI = githubCLI let githubIntegration = githubIntegration let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( .presentAlert( title: String(localized: "GitHub integration unavailable"), message: String(localized: "Enable GitHub integration to mark a pull request as ready.") ) ) return } await send(.showToast(.inProgress(String(localized: "Marking PR ready…")))) do { @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient ) else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "GitHub repository not resolved"), message: unresolvedGithubRepositoryMessage ) ) return } try await githubCLI.markPullRequestReady( worktreeRoot, remoteInfo, pullRequest.number, repositorySettings.githubAccountOverride ) await send(.showToast(.success(String(localized: "Pull request marked ready")))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { await send(.dismissToast) await send( .presentAlert( title: String(localized: "Failed to mark pull request ready"), message: error.localizedDescription ) ) } }
case .merge: let githubCLI = githubCLI let githubIntegration = githubIntegration let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( .presentAlert( title: String(localized: "GitHub integration unavailable"), message: String(localized: "Enable GitHub integration to merge a pull request.") ) ) return } @Shared(.repositorySettings(repoRoot)) var repositorySettings @Shared(.settingsFile) var settingsFile let strategy = repositorySettings.pullRequestMergeStrategy ?? settingsFile.global.pullRequestMergeStrategy await send(.showToast(.inProgress(String(localized: "Merging pull request…")))) do { guard let remoteInfo = await Self.resolveGithubRemoteInfo( for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient ) else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "GitHub repository not resolved"), message: unresolvedGithubRepositoryMessage ) ) return } try await githubCLI.mergePullRequest( worktreeRoot, remoteInfo, pullRequest.number, strategy, repositorySettings.githubAccountOverride ) await send(.showToast(.success(String(localized: "Pull request merged")))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { await send(.dismissToast) await send( .presentAlert( title: String(localized: "Failed to merge pull request"), message: error.localizedDescription ) ) } }
case .close: let githubCLI = githubCLI let githubIntegration = githubIntegration let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( .presentAlert( title: String(localized: "GitHub integration unavailable"), message: String(localized: "Enable GitHub integration to close a pull request.") ) ) return } await send(.showToast(.inProgress(String(localized: "Closing pull request…")))) do { @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient ) else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "GitHub repository not resolved"), message: unresolvedGithubRepositoryMessage ) ) return } try await githubCLI.closePullRequest( worktreeRoot, remoteInfo, pullRequest.number, repositorySettings.githubAccountOverride ) await send(.showToast(.success(String(localized: "Pull request closed")))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { await send(.dismissToast) await send( .presentAlert( title: String(localized: "Failed to close pull request"), message: error.localizedDescription ) ) } }
case .copyCiFailureLogs: let githubCLI = githubCLI let githubIntegration = githubIntegration return .run { send in guard await githubIntegration.isAvailable() else { await send( .presentAlert( title: String(localized: "GitHub integration unavailable"), message: String(localized: "Enable GitHub integration to copy CI failure logs.") ) ) return } guard !branchName.isEmpty else { await send( .presentAlert( title: String(localized: "Branch name unavailable"), message: String(localized: "Prowl could not determine the pull request branch.") ) ) return } await send(.showToast(.inProgress(String(localized: "Fetching CI logs…")))) do { @Shared(.repositorySettings(repoRoot)) var repositorySettings let accountOverride = repositorySettings.githubAccountOverride guard let run = try await githubCLI.latestRun(worktreeRoot, branchName, accountOverride) else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "No workflow runs found"), message: String(localized: "Prowl could not find any workflow runs for this branch.") ) ) return } guard run.conclusion?.lowercased() == "failure" else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "No failing workflow run"), message: String(localized: "Prowl could not find a failing workflow run to copy logs from.") ) ) return } let failedLogs = try await githubCLI.failedRunLogs(worktreeRoot, run.databaseId, accountOverride) let logs = if failedLogs.isEmpty { try await githubCLI.runLogs(worktreeRoot, run.databaseId, accountOverride) } else { failedLogs } guard !logs.isEmpty else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "No CI logs available"), message: String(localized: "The workflow run failed but produced no logs.") ) ) return } await MainActor.run { NSPasteboard.general.clearContents() NSPasteboard.general.setString(logs, forType: .string) } await send(.showToast(.success(String(localized: "CI failure logs copied")))) } catch { await send(.dismissToast) await send( .presentAlert( title: String(localized: "Failed to copy CI failure logs"), message: error.localizedDescription ) ) } }
case .rerunFailedJobs: let githubCLI = githubCLI let githubIntegration = githubIntegration return .run { send in guard await githubIntegration.isAvailable() else { await send( .presentAlert( title: String(localized: "GitHub integration unavailable"), message: String(localized: "Enable GitHub integration to re-run failed jobs.") ) ) return } guard !branchName.isEmpty else { await send( .presentAlert( title: String(localized: "Branch name unavailable"), message: String(localized: "Prowl could not determine the pull request branch.") ) ) return } await send(.showToast(.inProgress(String(localized: "Re-running failed jobs…")))) do { @Shared(.repositorySettings(repoRoot)) var repositorySettings let accountOverride = repositorySettings.githubAccountOverride guard let run = try await githubCLI.latestRun(worktreeRoot, branchName, accountOverride) else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "No workflow runs found"), message: String(localized: "Prowl could not find any workflow runs for this branch.") ) ) return } guard run.conclusion?.lowercased() == "failure" else { await send(.dismissToast) await send( .presentAlert( title: String(localized: "No failing workflow run"), message: String(localized: "Prowl could not find a failing workflow run to re-run.") ) ) return } try await githubCLI.rerunFailedJobs(worktreeRoot, run.databaseId, accountOverride) await send(.showToast(.success(String(localized: "Failed jobs re-run started")))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { await send(.dismissToast) await send( .presentAlert( title: String(localized: "Failed to re-run failed jobs"), message: error.localizedDescription ) ) } } }
case .setGithubIntegrationEnabled(let isEnabled): if isEnabled { state.githubIntegrationAvailability = .unknown state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() state.restoreUnansweredPullRequestRefreshMarks() clearAllPullRequestRefreshTracking(state: &state) return .merge( .cancel(id: CancelID.githubIntegrationRecovery), .send(.githubIntegration(.refreshGithubIntegrationAvailability)) ) } state.githubIntegrationAvailability = .disabled state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() state.restoreUnansweredPullRequestRefreshMarks() clearAllPullRequestRefreshTracking(state: &state) let worktreeIDs = Array(state.worktreeInfoByID.keys) for worktreeID in worktreeIDs { updateWorktreePullRequest( worktreeID: worktreeID, pullRequest: nil, state: &state ) } return .merge( .cancel(id: CancelID.githubIntegrationAvailability), .cancel(id: CancelID.githubIntegrationRecovery) )
case .setMergedWorktreeAction(let action): state.mergedWorktreeAction = action return .none
case .pullRequestRefreshBatchOutcome(let outcome): return reduceBatchOutcome(state: &state, outcome: outcome)
case .rateLimitRetryTimeChanged(let retryAt): state.githubRateLimitedUntil = retryAt return .none } }
private func reduceBatchOutcome( state: inout State, outcome: PullRequestRefreshCoordinator.Outcome ) -> Effect<Action> { switch outcome { case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch, let confirmedNoPrBranches, let isPartial): guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) return .none } // A host whose repositories did not all answer leaves the branch status on that host unknown, // like a host whose batch failed: its found pull requests show, but nothing is confirmed absent // and no checkpoint is recorded. if isPartial { state.prRefreshFailedBatchRepositoryIDs.insert(repositoryID) } mergePullRequestRefreshResults( repositoryID: repositoryID, prsByBranch: prsByBranch, confirmedNoPrBranches: confirmedNoPrBranches, state: &state ) guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { return .none } let mergedPRsByBranch = state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] let hadFailedBatch = state.prRefreshFailedBatchRepositoryIDs.remove(repositoryID) != nil let accumulatedConfirmedNoPrBranches = state.prRefreshNoPrBranchesByID.removeValue( forKey: repositoryID ) ?? [] // A failed host batch means branch status on that host is unknown, even when // it arrived before this final refreshed outcome — suppress confirmed clears. let confirmedNoPrBranches = hadFailedBatch ? [] : accumulatedConfirmedNoPrBranches state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) // A branch's status is settled only when every host batch came back complete: a found pull // request or a confirmed absence then records a checkpoint from the answer and drops its mark. // Otherwise the branch stays due, and a sent mark goes back on completion. if !hadFailedBatch { recordAnsweredPullRequestRefresh( repositoryID: repositoryID, worktreeIDs: worktreeIDs, prsByBranch: mergedPRsByBranch, confirmedNoPrBranches: confirmedNoPrBranches, state: &state ) } let prsByWorktreeID = pullRequestsByWorktreeID( repository: repository, worktreeIDs: worktreeIDs, prsByBranch: mergedPRsByBranch, confirmedNoPrBranches: confirmedNoPrBranches ) return .merge( .send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repositoryID, pullRequestsByWorktreeID: prsByWorktreeID ) ) ), .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) ) case .failed(let repositoryID, let worktreeIDs, _): state.prRefreshFailedBatchRepositoryIDs.insert(repositoryID) guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { return .none } let mergedPRsByBranch = state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] state.prRefreshFailedBatchRepositoryIDs.remove(repositoryID) _ = state.prRefreshNoPrBranchesByID.removeValue(forKey: repositoryID) state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) guard !mergedPRsByBranch.isEmpty, let repository = state.repositories[id: repositoryID] else { return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) } return .merge( .send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repositoryID, pullRequestsByWorktreeID: pullRequestsByWorktreeID( repository: repository, worktreeIDs: worktreeIDs, prsByBranch: mergedPRsByBranch, confirmedNoPrBranches: [] ) ) ) ), .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) ) } }
private func mergePullRequestRefreshResults( repositoryID: Repository.ID, prsByBranch: [String: GithubPullRequest], confirmedNoPrBranches: Set<String>, state: inout State ) { var merged = state.prRefreshResultsByRepositoryID[repositoryID] ?? [:] var resultPriorities = state.prRefreshResultPrioritiesByRepositoryID[repositoryID] ?? [:] let remotePriorities = state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] ?? [:] // Host batches race independently. Use the returned PR URL to recover its // source repo, then compare against the original remote order before replacing. for (branch, pullRequest) in prsByBranch { let priority = remotePriority(for: pullRequest, remotePriorities: remotePriorities) if merged[branch] == nil || priority < (resultPriorities[branch] ?? .max) { merged[branch] = pullRequest resultPriorities[branch] = priority } } // Accumulate confirmed-no-PR branches. Only clear when all repos for a // branch succeeded and none returned a PR — partial failures leave the // branch out of confirmedNoPrBranches so existing state is preserved. var existingConfirmed = state.prRefreshNoPrBranchesByID[repositoryID] ?? [] existingConfirmed.formUnion(confirmedNoPrBranches) // Remove any confirmed-no-PR entries that now have a PR (priority-based // merge may have resolved a later host's PR over an earlier "no PR"). existingConfirmed.subtract(merged.keys) state.prRefreshNoPrBranchesByID[repositoryID] = existingConfirmed state.prRefreshResultsByRepositoryID[repositoryID] = merged state.prRefreshResultPrioritiesByRepositoryID[repositoryID] = resultPriorities }
private func remotePriority( for pullRequest: GithubPullRequest, remotePriorities: [String: Int] ) -> Int { guard let remoteInfo = GitClient.parseGithubRemoteInfo(pullRequest.url) else { return .max } return remotePriorities[Self.pullRequestRefreshRemotePriorityKey(remoteInfo)] ?? .max }
private func clearPullRequestRefreshTracking( repositoryID: Repository.ID, state: inout State ) { state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) state.prRefreshNoPrBranchesByID.removeValue(forKey: repositoryID) state.prRefreshFailedBatchRepositoryIDs.remove(repositoryID) state.prRefreshRemotePrioritiesByRepositoryID.removeValue(forKey: repositoryID) state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) }
private func clearAllPullRequestRefreshTracking(state: inout State) { state.prRefreshBatchCountsByRepositoryID.removeAll() state.prRefreshResultsByRepositoryID.removeAll() state.prRefreshNoPrBranchesByID.removeAll() state.prRefreshFailedBatchRepositoryIDs.removeAll() state.prRefreshRemotePrioritiesByRepositoryID.removeAll() state.prRefreshResultPrioritiesByRepositoryID.removeAll() }
private func recordAnsweredPullRequestRefresh( repositoryID: Repository.ID, worktreeIDs: [Worktree.ID], prsByBranch: [String: GithubPullRequest], confirmedNoPrBranches: Set<String>, state: inout State ) { let answered = worktreeIDs.compactMap { worktreeID -> (id: Worktree.ID, branch: String)? in guard let branch = state.worktree(for: worktreeID)?.name, prsByBranch[branch] != nil || confirmedNoPrBranches.contains(branch) else { return nil } return (worktreeID, branch) } guard !answered.isEmpty else { return } // The checkpoint reads the answer as GitHub gave it; the display may keep an earlier mergeability. let answeredAt = now for (worktreeID, branch) in answered { state.pullRequestRefreshCheckpointByWorktreeID[worktreeID] = PullRequestRefreshCadence.Checkpoint( branch: branch, pullRequest: prsByBranch[branch], answeredAt: answeredAt ) state.sentPullRequestRefreshMarks[repositoryID]?.remove(worktreeID) } }
private func consumePullRequestRefreshBatch( repositoryID: Repository.ID, state: inout State ) -> Bool { let remaining = (state.prRefreshBatchCountsByRepositoryID[repositoryID] ?? 1) - 1 guard remaining > 0 else { state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) return true } state.prRefreshBatchCountsByRepositoryID[repositoryID] = remaining return false }
private func pullRequestsByWorktreeID( repository: Repository, worktreeIDs: [Worktree.ID], prsByBranch: [String: GithubPullRequest], confirmedNoPrBranches: Set<String> ) -> [Worktree.ID: GithubPullRequest?] { var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] for worktreeID in worktreeIDs { guard let worktree = repository.worktrees[id: worktreeID] else { continue } if let pullRequest = prsByBranch[worktree.name] { prsByWorktreeID[worktreeID] = pullRequest } else if confirmedNoPrBranches.contains(worktree.name) { // All repos confirmed no PR for this branch — explicitly clear. A nil // literal through the subscript would remove the key instead of storing // an explicit nil, so downstream would never see the clear. prsByWorktreeID.updateValue(nil, forKey: worktreeID) } // Otherwise: unknown status (partial failure) — omit to preserve existing. } return prsByWorktreeID }
func enqueueBatchedPullRequestRefresh( repositoryID: Repository.ID, repositoryRootURL: URL, worktrees: [Worktree], branches: [String], detailBranches: [String] = [] ) -> Effect<Action> { let worktreeIDs = worktrees.map(\.id) let coordinatorClient = pullRequestRefreshCoordinator let githubCLI = self.githubCLI let gitClient = self.gitClient return .run { send in let remoteInfos = await RepositoriesFeature.resolveGithubRemoteInfos( repositoryRootURL: repositoryRootURL, githubCLI: githubCLI, gitClient: gitClient ) guard !remoteInfos.isEmpty else { // No GitHub remote configured for this repository — preserve existing PR // values rather than clearing them, which would cause a flicker during // refresh cycles. await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings let remoteInfosByHost = Dictionary(grouping: remoteInfos, by: \.host) await send( .githubIntegration( .pullRequestRefreshBatchCountResolved( repositoryID: repositoryID, count: remoteInfosByHost.count, remotePriorities: Self.pullRequestRefreshRemotePriorities(remoteInfos) ) ) ) for (host, hostRemoteInfos) in remoteInfosByHost { coordinatorClient.enqueue( PullRequestRefreshCoordinator.Request( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, host: host, repositories: hostRemoteInfos, accountOverride: repositorySettings.githubAccountOverride, branches: branches, worktreeIDs: worktreeIDs, detailBranches: detailBranches ) ) } } }
nonisolated private static func displayPullRequest( _ pullRequest: GithubPullRequest?, for worktree: Worktree ) -> GithubPullRequest? { if worktree.isMain, pullRequest?.state.uppercased() == "MERGED" { return nil } return pullRequest }
nonisolated private static func preservingMergeableState( _ pullRequest: GithubPullRequest?, previous: GithubPullRequest? ) -> GithubPullRequest? { guard var pullRequest else { return pullRequest } let newMergeable = pullRequest.mergeable?.uppercased() let previousMergeable = previous?.mergeable?.uppercased() let isUnknown = newMergeable == "UNKNOWN" let hasKnownPrevious = previousMergeable != nil && previousMergeable != "UNKNOWN" if isUnknown && hasKnownPrevious, let previous { pullRequest.mergeable = previous.mergeable pullRequest.mergeStateStatus = previous.mergeStateStatus } return pullRequest }
nonisolated private static func pullRequestRefreshRemotePriorities( _ remoteInfos: [GithubRemoteInfo] ) -> [String: Int] { var priorities: [String: Int] = [:] for (index, remoteInfo) in remoteInfos.enumerated() { let key = pullRequestRefreshRemotePriorityKey(remoteInfo) if priorities[key] == nil { priorities[key] = index } } return priorities }
nonisolated private static func pullRequestRefreshRemotePriorityKey( _ remoteInfo: GithubRemoteInfo ) -> String { [ remoteInfo.host.lowercased(), remoteInfo.owner.lowercased(), remoteInfo.repo.lowercased(), ].joined(separator: "/") }
nonisolated private static func validWebURL(_ raw: String) -> URL? { guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), url.host != nil else { return nil } return url }
var githubIntegrationReducer: some ReducerOf<Self> { Reduce { state, action in guard case .githubIntegration(let action) = action else { return .none } return reduceGithubIntegration(state: &state, action: action) } }}