native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451import ComposableArchitectureimport Darwinimport Foundationimport ProwlCLIShared
// A login shell sources `.zprofile` / `.zlogin` before `gh` runs, so a banner or version-manager// line can prepend to captured stdout and corrupt the JSON.enum GithubCLIOutput { // No JSON found at all: the likely cause is shell startup output polluting stdout. nonisolated static let noPayloadMessage = String( localized: "Could not read GitHub CLI output. Your shell startup files may be printing extra output to stdout." )
// Valid JSON was found but failed to decode: the likely cause is an incompatible gh version. nonisolated static let undecodableMessage = String( localized: "Could not parse GitHub CLI output. The installed GitHub CLI version may be incompatible." )
nonisolated private static let logger = ProwlLogger("GithubCLI")
// Every balanced top-level JSON value ({...} or [...]) in `output`, in source order. An opener that // never balances (a stray brace from shell noise) is skipped, so leading noise cannot swallow a real // payload that follows. nonisolated static func balancedJSONSpans(in output: String) -> [Substring] { var spans: [Substring] = [] var searchStart = output.startIndex while let start = output[searchStart...].firstIndex(where: { $0 == "{" || $0 == "[" }) { let opener = output[start] let closer: Character = opener == "{" ? "}" : "]" var depth = 0 var inString = false var escaped = false var matchedEnd: String.Index? var index = start scan: while index < output.endIndex { let character = output[index] switch character { case _ where inString && escaped: escaped = false case "\\" where inString: escaped = true case "\"" where inString: inString = false case _ where inString: break case "\"": inString = true case opener: depth += 1 case closer: depth -= 1 guard depth == 0 else { break } matchedEnd = output.index(after: index) break scan default: break } index = output.index(after: index) } guard let end = matchedEnd else { // Unbalanced opener (stray bracket in noise): skip it and keep scanning. searchStart = output.index(after: start) continue } spans.append(output[start..<end]) searchStart = end } return spans }
// Decodes the JSON payload from possibly noisy gh stdout, surfacing a readable error (with the raw // output logged) instead of an opaque DecodingError. Throws when no payload is found at all. nonisolated static func decode<T: Decodable>( _ type: T.Type, from output: String, decoder: JSONDecoder = JSONDecoder() ) throws -> T { guard let value = try decodeIfPresent(T.self, from: output, decoder: decoder) else { logDecodeFailure(output) throw GithubCLIError.commandFailed(noPayloadMessage) } return value }
// Returns nil when `output` carries no JSON payload at all (e.g. `gh run list` with no runs), and // throws only when a payload is present but cannot be decoded. gh prints its JSON after any leading // shell noise, so the last decodable span wins. nonisolated static func decodeIfPresent<T: Decodable>( _ type: T.Type, from output: String, decoder: JSONDecoder = JSONDecoder() ) throws -> T? { let spans = balancedJSONSpans(in: output) guard !spans.isEmpty else { return nil } var lastDecodingError: Error? var sawValidJSON = false for span in spans.reversed() { let data = Data(span.utf8) do { return try decoder.decode(T.self, from: data) } catch { lastDecodingError = error if (try? JSONSerialization.jsonObject(with: data)) != nil { sawValidJSON = true } } } let detail = lastDecodingError.map { "\($0)" } ?? "unknown" logger.warning("Failed to decode GitHub CLI JSON output: \(snapshot(of: output)) error=\(detail)") // Valid JSON that failed to decode points at gh schema drift; otherwise the brackets were noise. throw GithubCLIError.commandFailed(sawValidJSON ? undecodableMessage : noPayloadMessage) }
// True when gh's output holds a complete GraphQL answer: an HTTP success status when `--include` // printed one, and a body whose top-level `data` is a non-empty object. gh exits 1 whenever the // body also lists `errors`, so the exit code alone cannot tell a partial answer from a failure. nonisolated static func carriesGraphQLData(_ output: String) -> Bool { if let status = GithubRateLimitClassifier.responseStatus(in: output), !(200..<300).contains(status) { return false } guard let span = balancedJSONSpans(in: output).last, let object = try? JSONSerialization.jsonObject(with: Data(span.utf8)) as? [String: Any], let data = object["data"] as? [String: Any] else { return false } return !data.isEmpty }
// Logs a length-capped snapshot of the offending output so the user can retrieve it from the log // stream without flooding it with a large banner. nonisolated static func logDecodeFailure(_ output: String) { logger.warning("Failed to read GitHub CLI JSON output: \(snapshot(of: output))") }
// Caps the logged output so a large banner does not flood the log stream. nonisolated private static func snapshot(of output: String) -> String { let limit = 500 let prefix = output.prefix(limit) return prefix.endIndex == output.endIndex ? String(prefix) : "\(prefix)… (truncated)" }}
enum GithubAuthStatusParsing { // `hosts` is an unordered dictionary, so prefer github.com and otherwise sort the keys: the result // is deterministic even when more than one host has an active account. nonisolated static func activeAccount( in response: GithubAuthStatusResponse ) -> (host: String, login: String)? { let orderedHosts = response.hosts.keys.sorted { lhs, rhs in if lhs == "github.com" { return true } if rhs == "github.com" { return false } return lhs < rhs } let snapshot = GithubAuthStatusSnapshot(response: response) for host in orderedHosts { if let active = snapshot.activeAccount(on: host) { return (host, active.login) } } return nil }}
actor GithubAccountSwitchLock { static let shared = GithubAccountSwitchLock()
private var lockedHosts: Set<String> = [] private var waitersByHost: [String: [CheckedContinuation<Void, Never>]] = [:]
func acquire(host: String) async { if !lockedHosts.contains(host) { lockedHosts.insert(host) return } await withCheckedContinuation { continuation in waitersByHost[host, default: []].append(continuation) } }
func release(host: String) { guard var waiters = waitersByHost[host], !waiters.isEmpty else { lockedHosts.remove(host) return } let next = waiters.removeFirst() waitersByHost[host] = waiters.isEmpty ? nil : waiters next.resume() }}
struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? var latestRun: @Sendable (URL, String, GithubAccountOverride?) async throws -> GithubWorkflowRun? var batchPullRequests: @Sendable (String, String, String, [String], Set<String>, GithubAccountOverride?) async throws -> [String: GithubPullRequest] var batchPullRequestsAcrossRepositories: @Sendable (String, [CrossRepoPullRequestRequest], GithubAccountOverride?) async throws -> CrossRepoPullRequestResult var mergePullRequest: @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy, GithubAccountOverride?) async throws -> Void var closePullRequest: @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void var markPullRequestReady: @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void var rerunFailedJobs: @Sendable (URL, Int, GithubAccountOverride?) async throws -> Void var failedRunLogs: @Sendable (URL, Int, GithubAccountOverride?) async throws -> String var runLogs: @Sendable (URL, Int, GithubAccountOverride?) async throws -> String var isAvailable: @Sendable () async -> Bool var authStatusSnapshot: @Sendable () async throws -> GithubAuthStatusSnapshot var authStatus: @Sendable () async throws -> GithubAuthStatus? var rateLimitRetryTimes: @Sendable () async -> AsyncStream<Date?>}
extension GithubCLIClient: DependencyKey { static let liveValue = live()
static func live( shell: ShellClient = .liveValue, fallbackExecutableURLs: [URL] = GithubCLIExecutableResolver.defaultFallbackExecutableURLs(), rateLimitGate gate: GithubRateLimitGate = .shared ) -> GithubCLIClient { let resolver = GithubCLIExecutableResolver(fallbackExecutableURLs: fallbackExecutableURLs) return GithubCLIClient( defaultBranch: defaultBranchFetcher(shell: shell, resolver: resolver, gate: gate), resolveRemoteInfo: resolveRemoteInfoFetcher(shell: shell, resolver: resolver, gate: gate), latestRun: latestRunFetcher(shell: shell, resolver: resolver, gate: gate), batchPullRequests: batchPullRequestsFetcher(shell: shell, resolver: resolver, gate: gate), batchPullRequestsAcrossRepositories: batchPullRequestsAcrossRepositoriesFetcher( shell: shell, resolver: resolver, gate: gate), mergePullRequest: mergePullRequestFetcher(shell: shell, resolver: resolver, gate: gate), closePullRequest: closePullRequestFetcher(shell: shell, resolver: resolver, gate: gate), markPullRequestReady: markPullRequestReadyFetcher(shell: shell, resolver: resolver, gate: gate), rerunFailedJobs: rerunFailedJobsFetcher(shell: shell, resolver: resolver, gate: gate), failedRunLogs: failedRunLogsFetcher(shell: shell, resolver: resolver, gate: gate), runLogs: runLogsFetcher(shell: shell, resolver: resolver, gate: gate), isAvailable: isAvailableFetcher(shell: shell, resolver: resolver), authStatusSnapshot: authStatusSnapshotFetcher(shell: shell, resolver: resolver, gate: gate), authStatus: authStatusFetcher(shell: shell, resolver: resolver, gate: gate), rateLimitRetryTimes: { await gate.retryTimes() } ) }
static let testValue = GithubCLIClient( defaultBranch: { _ in "main" }, resolveRemoteInfo: { _ in nil }, latestRun: { _, _, _ in nil }, batchPullRequests: { _, _, _, _, _, _ in [:] }, batchPullRequestsAcrossRepositories: { _, _, _ in CrossRepoPullRequestResult() }, mergePullRequest: { _, _, _, _, _ in }, closePullRequest: { _, _, _, _ in }, markPullRequestReady: { _, _, _, _ in }, rerunFailedJobs: { _, _, _ in }, failedRunLogs: { _, _, _ in "" }, runLogs: { _, _, _ in "" }, isAvailable: { true }, authStatusSnapshot: { GithubAuthStatusSnapshot( hosts: [ "github.com": [ GithubAuthAccountStatus( host: "github.com", login: "testuser", active: true, state: "success", gitProtocol: "ssh", scopes: nil, tokenSource: nil ) ] ] ) }, authStatus: { GithubAuthStatus(username: "testuser", host: "github.com") }, rateLimitRetryTimes: { AsyncStream { $0.finish() } } )}
extension DependencyValues { var githubCLI: GithubCLIClient { get { self[GithubCLIClient.self] } set { self[GithubCLIClient.self] = newValue } }}
nonisolated private func defaultBranchFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL) async throws -> String { { repoRoot in let output = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: ["repo", "view", "--json", "defaultBranchRef"], repoRoot: repoRoot ) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let response = try GithubCLIOutput.decode(GithubRepoViewResponse.self, from: output, decoder: decoder) return response.defaultBranchRef.name }}
nonisolated private func resolveRemoteInfoFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL) async -> GithubRemoteInfo? { { repoRoot in do { let output = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: ["repo", "view", "--json", "owner,name,url"], repoRoot: repoRoot ) guard let response = try GithubCLIOutput.decodeIfPresent(GithubRepoViewRemoteInfoResponse.self, from: output) else { return nil } return response.remoteInfo } catch { return nil } }}
nonisolated private func latestRunFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, String, GithubAccountOverride?) async throws -> GithubWorkflowRun? { { repoRoot, branch, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, accountOverride: accountOverride ) { let output = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "run", "list", "--branch", branch, "--limit", "1", "--json", "databaseId,workflowName,name,displayTitle,status,conclusion,createdAt,updatedAt", ], repoRoot: repoRoot ) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 // nil payload means no runs; a present-but-undecodable payload still throws. let runs = try GithubCLIOutput.decodeIfPresent([GithubWorkflowRun].self, from: output, decoder: decoder) return runs?.first } }}
nonisolated private func batchPullRequestsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (String, String, String, [String], Set<String>, GithubAccountOverride?) async throws -> [String: GithubPullRequest]{ { host, owner, repo, branches, detailBranches, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, host: host, accountOverride: accountOverride ) { let dedupedBranches = deduplicatedBranches(branches) guard !dedupedBranches.isEmpty else { return [:] } let request = GithubPullRequestsRequest(host: host, owner: owner, repo: repo, detailBranches: detailBranches) let chunks = makeBranchChunks( dedupedBranches, chunkSize: batchPullRequestsChunkSize ) let chunkResults = try await loadPullRequestChunks( shell: shell, resolver: resolver, gate: gate, request: request, chunks: chunks ) return mergePullRequestChunkResults( chunkResults, chunkCount: chunks.count ) } }}
// Repositories per cross-repository query. One query runs at a time: GitHub's secondary limits// count concurrent requests and server time per account, and the account is shared.nonisolated let crossRepoBatchAliasLimit = 15nonisolated private let crossRepoBatchMaxConcurrentRequests = 1
nonisolated private struct CrossRepoChunkOutcome: Sendable { let successByRepo: [RepoKey: [String: GithubPullRequest]] let failedRepos: [RepoKey: GithubCLIError]}
nonisolated private func batchPullRequestsAcrossRepositoriesFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (String, [CrossRepoPullRequestRequest], GithubAccountOverride?) async throws -> CrossRepoPullRequestResult{ { host, requests, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, host: host, accountOverride: accountOverride ) { let cleaned = sanitizeCrossRepoRequests(requests) guard !cleaned.isEmpty else { return CrossRepoPullRequestResult() } let chunks = makeCrossRepoChunks(cleaned, chunkSize: crossRepoBatchAliasLimit) let outcomes = try await loadCrossRepoChunks( shell: shell, resolver: resolver, gate: gate, host: host, chunks: chunks ) return mergeCrossRepoChunkResults(outcomes) } }}
nonisolated private func sanitizeCrossRepoRequests( _ requests: [CrossRepoPullRequestRequest]) -> [CrossRepoPullRequestRequest] { var sanitized: [CrossRepoPullRequestRequest] = [] for request in requests { var seen = Set<String>() let branches = request.branches.compactMap { value -> String? in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } return trimmed } guard !branches.isEmpty else { continue } sanitized.append( CrossRepoPullRequestRequest( owner: request.owner, repo: request.repo, branches: branches, allowedHeadRepositories: request.allowedHeadRepositories, detailBranches: Set(request.detailBranches.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }) ) ) } return sanitized}
nonisolated private func makeCrossRepoChunks( _ requests: [CrossRepoPullRequestRequest], chunkSize: Int) -> [[CrossRepoPullRequestRequest]] { guard !requests.isEmpty else { return [] } var chunks: [[CrossRepoPullRequestRequest]] = [] var index = 0 while index < requests.count { let end = min(index + chunkSize, requests.count) chunks.append(Array(requests[index..<end])) index = end } return chunks}
nonisolated private func loadCrossRepoChunks( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, host: String, chunks: [[CrossRepoPullRequestRequest]]) async throws -> [CrossRepoChunkOutcome] { try await withThrowingTaskGroup(of: (Int, CrossRepoChunkOutcome).self) { group in var nextChunkIndex = 0 let initialCount = min(crossRepoBatchMaxConcurrentRequests, chunks.count) while nextChunkIndex < initialCount { let chunkIndex = nextChunkIndex let chunk = chunks[chunkIndex] group.addTask { let outcome = try await fetchCrossRepoChunk( shell: shell, resolver: resolver, gate: gate, host: host, chunk: chunk ) return (chunkIndex, outcome) } nextChunkIndex += 1 } var resultsByIndex: [Int: CrossRepoChunkOutcome] = [:] while let (chunkIndex, outcome) = try await group.next() { resultsByIndex[chunkIndex] = outcome if nextChunkIndex < chunks.count { let candidateIndex = nextChunkIndex let candidateChunk = chunks[candidateIndex] group.addTask { let outcome = try await fetchCrossRepoChunk( shell: shell, resolver: resolver, gate: gate, host: host, chunk: candidateChunk ) return (candidateIndex, outcome) } nextChunkIndex += 1 } } return (0..<chunks.count).compactMap { resultsByIndex[$0] } }}
nonisolated private func mergeCrossRepoChunkResults( _ outcomes: [CrossRepoChunkOutcome]) -> CrossRepoPullRequestResult { var success: [RepoKey: [String: GithubPullRequest]] = [:] var failed: [RepoKey: GithubCLIError] = [:] for outcome in outcomes { for (key, prs) in outcome.successByRepo { success[key] = prs } for (key, error) in outcome.failedRepos { failed[key] = error } } return CrossRepoPullRequestResult(successByRepo: success, failedRepos: failed)}
nonisolated private func fetchCrossRepoChunk( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, host: String, chunk: [CrossRepoPullRequestRequest]) async throws -> CrossRepoChunkOutcome { let plan = makeCrossRepoBatchQuery(requests: chunk) let output = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "api", "graphql", "--include", "--hostname", host, "-f", "query=\(plan.query)", ], repoRoot: nil, acceptsGraphQLErrors: true ) guard !output.isEmpty else { var failed: [RepoKey: GithubCLIError] = [:] for key in plan.repoAliases.values { failed[key] = .commandFailed("Empty response from gh api graphql") } return CrossRepoChunkOutcome(successByRepo: [:], failedRepos: failed) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let response = try GithubCLIOutput.decode(CrossRepoPullRequestResponse.self, from: output, decoder: decoder)
let errorsByAlias = response.errorsByAlias() var success: [RepoKey: [String: GithubPullRequest]] = [:] var failed: [RepoKey: GithubCLIError] = [:] for (alias, key) in plan.repoAliases { if let error = errorsByAlias[alias] { failed[key] = .graphQLError( type: error.type, message: "GraphQL error for \(key.owner)/\(key.repo): \(error.message)" ) continue } guard let payload = response.repositories[alias] else { failed[key] = .commandFailed("Missing response payload for \(key.owner)/\(key.repo)") continue } let branchAliasMap = plan.branchAliasesByRepo[alias] ?? [:] let prs = rankCrossRepoPullRequests( pullRequestsByAlias: payload.pullRequestsByAlias, aliasMap: branchAliasMap, allowedHeadRepositories: plan.allowedHeadRepositoriesByRepoAlias[alias] ?? [key] ) success[key] = prs } return CrossRepoChunkOutcome(successByRepo: success, failedRepos: failed)}
nonisolated private struct CrossRepoBatchQueryPlan: Sendable { let query: String let repoAliases: [String: RepoKey] let branchAliasesByRepo: [String: [String: String]] let allowedHeadRepositoriesByRepoAlias: [String: Set<RepoKey>]}
nonisolated private func makeCrossRepoBatchQuery( requests: [CrossRepoPullRequestRequest]) -> CrossRepoBatchQueryPlan { var repoAliases: [String: RepoKey] = [:] var branchAliasesByRepo: [String: [String: String]] = [:] var allowedHeadRepositoriesByRepoAlias: [String: Set<RepoKey>] = [:] var repoBlocks: [String] = [] let orderBy = "orderBy: {field: UPDATED_AT, direction: DESC}" for (repoIndex, request) in requests.enumerated() { let repoAlias = "r\(repoIndex)" repoAliases[repoAlias] = RepoKey(owner: request.owner, repo: request.repo) allowedHeadRepositoriesByRepoAlias[repoAlias] = request.allowedHeadRepositories var branchAliasMap: [String: String] = [:] var selections: [String] = [] for (branchIndex, branch) in request.branches.enumerated() { let branchAlias = "\(repoAlias)_b\(branchIndex)" branchAliasMap[branchAlias] = branch let escapedBranch = escapeGraphQLString(branch) let pullRequestsArgs = "first: 5, states: [OPEN, MERGED, CLOSED], headRefName: \"\(escapedBranch)\", \(orderBy)" let selection = """ \(branchAlias): pullRequests(\(pullRequestsArgs)) { nodes { \(pullRequestNodeFields(includeCheckDetails: request.detailBranches.contains(branch))) } } """ selections.append(selection) } branchAliasesByRepo[repoAlias] = branchAliasMap let escapedOwner = escapeGraphQLString(request.owner) let escapedRepo = escapeGraphQLString(request.repo) let block = """ \(repoAlias): repository(owner: \"\(escapedOwner)\", name: \"\(escapedRepo)\") { \(selections.joined(separator: "\n")) } """ repoBlocks.append(block) } let query = """ query { \(repoBlocks.joined(separator: "\n")) } """ return CrossRepoBatchQueryPlan( query: query, repoAliases: repoAliases, branchAliasesByRepo: branchAliasesByRepo, allowedHeadRepositoriesByRepoAlias: allowedHeadRepositoriesByRepoAlias )}
// The pull request fields every refresh reads. Each check is listed only when asked for: per-state// counts carry the badge and merge readiness, while a list of up to 100 contexts per pull request// is most of what a batched query costs GitHub to resolve.nonisolated func pullRequestNodeFields(includeCheckDetails: Bool) -> String { let checkDetails = includeCheckDetails ? """ nodes { ... on CheckRun { name status conclusion startedAt completedAt detailsUrl } ... on StatusContext { context state targetUrl createdAt } } """ : "" return """ number title state additions deletions isDraft reviewDecision mergeable mergeStateStatus url updatedAt headRefName baseRefName commits { totalCount } author { login } headRepository { name owner { login } } mergeQueueEntry { position estimatedTimeToMerge state } statusCheckRollup { contexts\(includeCheckDetails ? "(first: 100)" : "") { checkRunCountsByState { state count } statusContextCountsByState { state count } \(checkDetails) } } """}
nonisolated private func rankCrossRepoPullRequests( pullRequestsByAlias: [String: GithubGraphQLPullRequestResponse.PullRequestConnection], aliasMap: [String: String], allowedHeadRepositories: Set<RepoKey>) -> [String: GithubPullRequest] { var results: [String: GithubPullRequest] = [:] for (alias, connection) in pullRequestsByAlias { guard let branch = aliasMap[alias] else { continue } if let node = connection.bestMatchingPullRequest(allowedHeadRepositories: allowedHeadRepositories) { results[branch] = node.pullRequest } } return results}
nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy, GithubAccountOverride?) async throws -> Void { { repoRoot, remoteInfo, pullRequestNumber, strategy, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, host: remoteInfo.host, accountOverride: accountOverride ) { _ = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "pr", "merge", "\(pullRequestNumber)", "--\(strategy.ghArgument)", ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } }}
nonisolated private func closePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void { { repoRoot, remoteInfo, pullRequestNumber, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, host: remoteInfo.host, accountOverride: accountOverride ) { _ = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "pr", "close", "\(pullRequestNumber)", ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } }}
nonisolated private func markPullRequestReadyFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void { { repoRoot, remoteInfo, pullRequestNumber, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, host: remoteInfo.host, accountOverride: accountOverride ) { _ = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "pr", "ready", "\(pullRequestNumber)", ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } }}
nonisolated private func repoArgument(_ remoteInfo: GithubRemoteInfo) -> [String] { ["--repo", "\(remoteInfo.host)/\(remoteInfo.owner)/\(remoteInfo.repo)"]}
nonisolated private func rerunFailedJobsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, Int, GithubAccountOverride?) async throws -> Void { { repoRoot, runID, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, accountOverride: accountOverride ) { _ = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "run", "rerun", "\(runID)", "--failed", ], repoRoot: repoRoot ) } }}
nonisolated private func failedRunLogsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, Int, GithubAccountOverride?) async throws -> String { { repoRoot, runID, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, accountOverride: accountOverride ) { try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "run", "view", "\(runID)", "--log-failed", ], repoRoot: repoRoot ) } }}
nonisolated private func runLogsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable (URL, Int, GithubAccountOverride?) async throws -> String { { repoRoot, runID, accountOverride in try await withExpectedGithubAccount( shell: shell, resolver: resolver, gate: gate, accountOverride: accountOverride ) { try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "run", "view", "\(runID)", "--log", ], repoRoot: repoRoot ) } }}
nonisolated private func isAvailableFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver) -> @Sendable () async -> Bool { { do { _ = try await runLocalGh( shell: shell, resolver: resolver, arguments: ["--version"], repoRoot: nil ) return true } catch { return false } }}
nonisolated private func authStatusFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable () async throws -> GithubAuthStatus? { { let response = try await loadAuthStatusResponse(shell: shell, resolver: resolver, gate: gate) guard let active = GithubAuthStatusParsing.activeAccount(in: response) else { return nil } return GithubAuthStatus(username: active.login, host: active.host) }}
nonisolated private func authStatusSnapshotFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate) -> @Sendable () async throws -> GithubAuthStatusSnapshot { { let response = try await loadAuthStatusResponse(shell: shell, resolver: resolver, gate: gate) return GithubAuthStatusSnapshot(response: response) }}
nonisolated private func loadAuthStatusResponse( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, host: String? = nil, activeOnly: Bool = false) async throws -> GithubAuthStatusResponse { var arguments = ["auth", "status"] if activeOnly { arguments.append("--active") } if let host { arguments.append(contentsOf: ["--hostname", host]) } arguments.append(contentsOf: ["--json", "hosts"]) let output = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: arguments, repoRoot: nil ) return try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: output)}
nonisolated private func withExpectedGithubAccount<Value>( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, host: String? = nil, accountOverride: GithubAccountOverride?, operation: () async throws -> Value) async throws -> Value { guard let accountOverride = accountOverride?.normalized else { return try await operation() } if let host, host != accountOverride.host { throw GithubCLIError.commandFailed( String( localized: """ This repository uses \(host), but its GitHub account override is configured for \ \(accountOverride.host)/\(accountOverride.login). Update the repository's GitHub identity setting. """ ) ) } await GithubAccountSwitchLock.shared.acquire(host: accountOverride.host) let previousLogin: String? do { previousLogin = try await activeGithubLogin( shell: shell, resolver: resolver, gate: gate, host: accountOverride.host ) if previousLogin != accountOverride.login { try await switchGithubAccount( shell: shell, resolver: resolver, accountOverride: accountOverride ) } } catch { await GithubAccountSwitchLock.shared.release(host: accountOverride.host) throw error } do { let value = try await operation() await restoreGithubAccountIfNeeded( shell: shell, resolver: resolver, host: accountOverride.host, previousLogin: previousLogin, targetLogin: accountOverride.login ) await GithubAccountSwitchLock.shared.release(host: accountOverride.host) return value } catch { await restoreGithubAccountIfNeeded( shell: shell, resolver: resolver, host: accountOverride.host, previousLogin: previousLogin, targetLogin: accountOverride.login ) await GithubAccountSwitchLock.shared.release(host: accountOverride.host) throw error }}
nonisolated private func activeGithubLogin( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, host: String) async throws -> String? { let response = try await loadAuthStatusResponse( shell: shell, resolver: resolver, gate: gate, host: host, activeOnly: true ) let snapshot = GithubAuthStatusSnapshot(response: response) return snapshot.activeAccount(on: host)?.login}
nonisolated private func switchGithubAccount( shell: ShellClient, resolver: GithubCLIExecutableResolver, accountOverride: GithubAccountOverride) async throws { do { _ = try await runLocalGh( shell: shell, resolver: resolver, arguments: [ "auth", "switch", "--hostname", accountOverride.host, "--user", accountOverride.login, ], repoRoot: nil ) } catch { throw GithubCLIError.commandFailed( String( localized: """ Prowl is configured to use \(accountOverride.login) on \(accountOverride.host), \ but gh could not switch to that account. Run \ `gh auth switch --hostname \(accountOverride.host) --user \(accountOverride.login)` and try again. """ ) ) }}
nonisolated private func restoreGithubAccountIfNeeded( shell: ShellClient, resolver: GithubCLIExecutableResolver, host: String, previousLogin: String?, targetLogin: String) async { guard let previousLogin, previousLogin != targetLogin else { return } do { _ = try await runLocalGh( shell: shell, resolver: resolver, arguments: [ "auth", "switch", "--hostname", host, "--user", previousLogin, ], repoRoot: nil ) } catch { ProwlLogger("GithubCLI").warning( "Failed to restore gh active account for \(host) to \(previousLogin): \(error.localizedDescription)" ) }}
nonisolated private func deduplicatedBranches(_ branches: [String]) -> [String] { var seen = Set<String>() return branches.filter { !$0.isEmpty && seen.insert($0).inserted }}
nonisolated let batchPullRequestsChunkSize = 25nonisolated private let batchPullRequestsMaxConcurrentRequests = 1
nonisolated private func makeBranchChunks( _ branches: [String], chunkSize: Int) -> [[String]] { guard !branches.isEmpty else { return [] }
var chunks: [[String]] = [] var index = 0 while index < branches.count { let end = min(index + chunkSize, branches.count) chunks.append(Array(branches[index..<end])) index = end }
return chunks}
nonisolated private func loadPullRequestChunks( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, request: GithubPullRequestsRequest, chunks: [[String]]) async throws -> [Int: [String: GithubPullRequest]] { try await withThrowingTaskGroup( of: (Int, [String: GithubPullRequest]).self ) { group in var nextChunkIndex = 0 let initialCount = min(batchPullRequestsMaxConcurrentRequests, chunks.count) while nextChunkIndex < initialCount { let chunkIndex = nextChunkIndex let chunk = chunks[chunkIndex] group.addTask { let prsByBranch = try await fetchPullRequestsChunk( shell: shell, resolver: resolver, gate: gate, request: request, chunk: chunk ) return (chunkIndex, prsByBranch) } nextChunkIndex += 1 }
var resultsByChunkIndex: [Int: [String: GithubPullRequest]] = [:] while let (chunkIndex, prsByBranch) = try await group.next() { resultsByChunkIndex[chunkIndex] = prsByBranch if nextChunkIndex < chunks.count { let candidateIndex = nextChunkIndex let candidateChunk = chunks[candidateIndex] group.addTask { let prsByBranch = try await fetchPullRequestsChunk( shell: shell, resolver: resolver, gate: gate, request: request, chunk: candidateChunk ) return (candidateIndex, prsByBranch) } nextChunkIndex += 1 } }
return resultsByChunkIndex }}
nonisolated private func mergePullRequestChunkResults( _ chunkResults: [Int: [String: GithubPullRequest]], chunkCount: Int) -> [String: GithubPullRequest] { var results: [String: GithubPullRequest] = [:] for chunkIndex in 0..<chunkCount { guard let prsByBranch = chunkResults[chunkIndex] else { continue } results.merge(prsByBranch) { _, new in new } } return results}
nonisolated private func fetchPullRequestsChunk( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, request: GithubPullRequestsRequest, chunk: [String]) async throws -> [String: GithubPullRequest] { let (query, aliasMap) = makeBatchPullRequestsQuery(branches: chunk, detailBranches: request.detailBranches) let output = try await runGh( shell: shell, resolver: resolver, gate: gate, arguments: [ "api", "graphql", "--include", "--hostname", request.host, "-f", "query=\(query)", "-f", "owner=\(request.owner)", "-f", "repo=\(request.repo)", ], repoRoot: nil, acceptsGraphQLErrors: true ) guard !output.isEmpty else { return [:] }
let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let response = try GithubCLIOutput.decode(GithubGraphQLPullRequestResponse.self, from: output, decoder: decoder) if let repositoryError = response.repositoryError { throw repositoryError } let prsByBranch = response.pullRequestsByBranch( aliasMap: aliasMap, owner: request.owner, repo: request.repo ) return prsByBranch}
nonisolated private func makeBatchPullRequestsQuery( branches: [String], detailBranches: Set<String>) -> (query: String, aliasMap: [String: String]) { var aliasMap: [String: String] = [:] var selections: [String] = [] for (index, branch) in branches.enumerated() { let alias = "branch\(index)" aliasMap[alias] = branch let escapedBranch = escapeGraphQLString(branch) let orderBy = "orderBy: {field: UPDATED_AT, direction: DESC}" let selection = """ \(alias): pullRequests(first: 5, states: [OPEN, MERGED, CLOSED], headRefName: \"\(escapedBranch)\", \(orderBy)) { nodes { \(pullRequestNodeFields(includeCheckDetails: detailBranches.contains(branch))) } } """ selections.append(selection) } let selectionBlock = selections.joined(separator: "\n") let query = """ query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { \(selectionBlock) } } """ return (query, aliasMap)}
nonisolated private func escapeGraphQLString(_ value: String) -> String { value .replacing("\\", with: "\\\\") .replacing("\"", with: "\\\"") .replacing("\n", with: "\\n") .replacing("\r", with: "\\r") .replacing("\t", with: "\\t")}
nonisolated private func isOutdatedGitHubCLI(_ error: ShellClientError) -> Bool { let combined = "\(error.stdout)\n\(error.stderr)".lowercased() if combined.contains("unknown flag: --json") { return true } if combined.contains("unknown shorthand flag") && combined.contains("json") { return true } return false}
// `acceptsGraphQLErrors` keeps the output of a `gh api graphql` call that exited 1 only because the// answer lists GraphQL errors next to its data; the caller then routes each error to its repository.nonisolated private func runGh( shell: ShellClient, resolver: GithubCLIExecutableResolver, gate: GithubRateLimitGate, arguments: [String], repoRoot: URL?, acceptsGraphQLErrors: Bool = false) async throws -> String { let command = (["gh"] + arguments).joined(separator: " ") let ticket = try await gate.admit() if Task.isCancelled { await gate.abandon(ticket) throw CancellationError() } let stdout: String do { stdout = try await launchGh(shell: shell, resolver: resolver, arguments: arguments, repoRoot: repoRoot) } catch { if error is CancellationError || Task.isCancelled { await gate.abandon(ticket) throw githubCLIError(from: error, command: command) } let shellError = error as? ShellClientError let signal = shellError.flatMap { GithubRateLimitClassifier.classify(stdout: $0.stdout, stderr: $0.stderr, succeeded: false) } let answered = shellError.flatMap { GithubRateLimitClassifier.responseStatus(in: $0.stdout) } != nil if let retryAt = await gate.record(ticket, signal: signal, answered: answered) { throw GithubCLIError.rateLimited(retryAt: retryAt) } if acceptsGraphQLErrors, let shellError, GithubCLIOutput.carriesGraphQLData(shellError.stdout) { return shellError.stdout } throw githubCLIError(from: error, command: command) } let signal = GithubRateLimitClassifier.classify(stdout: stdout, stderr: "", succeeded: true) if let retryAt = await gate.record(ticket, signal: signal, answered: true) { throw GithubCLIError.rateLimited(retryAt: retryAt) } return stdout}
// For gh commands that never leave the machine, such as `--version` and `auth switch`, so they keep// working while GitHub is refusing the account.nonisolated private func runLocalGh( shell: ShellClient, resolver: GithubCLIExecutableResolver, arguments: [String], repoRoot: URL?) async throws -> String { do { return try await launchGh(shell: shell, resolver: resolver, arguments: arguments, repoRoot: repoRoot) } catch { throw githubCLIError(from: error, command: (["gh"] + arguments).joined(separator: " ")) }}
nonisolated private func launchGh( shell: ShellClient, resolver: GithubCLIExecutableResolver, arguments: [String], repoRoot: URL?) async throws -> String { let executableURL = try await resolver.executableURL(shell: shell) do { return try await shell.runLogin(executableURL, arguments, repoRoot, log: false).stdout } catch { guard shouldRetryGhExecution(after: error) else { throw error } await resolver.invalidate() let executableURL = try await resolver.executableURL(shell: shell) return try await shell.runLogin(executableURL, arguments, repoRoot, log: false).stdout }}
nonisolated private func githubCLIError(from error: Error, command: String) -> Error { if let error = error as? GithubCLIError { return error } if let shellError = error as? ShellClientError { if isOutdatedGitHubCLI(shellError) { return GithubCLIError.outdated } let message = shellError.errorDescription ?? String(localized: "Command failed: \(command)") return GithubCLIError.commandFailed(message) } return GithubCLIError.commandFailed(error.localizedDescription)}
nonisolated private func shouldRetryGhExecution(after error: Error) -> Bool { if let shellError = error as? ShellClientError { let combined = "\(shellError.stdout)\n\(shellError.stderr)".lowercased() if combined.contains("no such file or directory") || combined.contains("command not found") { return true } if shellError.exitCode == 127 { return true } } let nsError = error as NSError if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileNoSuchFileError { return true } if nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT) { return true } return false}