native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Resolve PR repo via `gh repo view` so fork clones target upstream (#261)

* Resolve PR repo via `gh repo view` so fork clones target upstream (#255)

`GitClient.remoteInfo` preferred the `origin` remote, so in fork
workflows Supacode queried the fork instead of the upstream repo
that actually hosts the PR. The worktree badge/CI ring never lit up
and `Merge PR` / `Close PR` / `Mark ready for review` from the command
palette ran `gh pr …` against the fork, where the PR does not exist.

- `GithubCLIClient.resolveRemoteInfo` shells `gh repo view --json
owner,name,url` in `repoRoot` and reuses `gh`'s own default-repo
resolution. The reducer prefers this result and only falls back to
`gitClient.remoteInfo` when `gh` is unavailable.
- `mergePullRequest` / `closePullRequest` / `markPullRequestReady`
now take the resolved `GithubRemoteInfo` and pass `--repo
HOST/OWNER/REPO`, so mutations run against the repository the
refresh pipeline just queried.
- CI/workflow calls (`gh run list`, rerun failed jobs, view run
logs) still target `repoRoot` — workflow runs live on the remote
that received the push, which in fork workflows is the fork.

* Include merged PRs from deleted forks in branch resolution

`pullRequestsByBranch` excluded every node with `headRepository:
null`, which GitHub returns when the PR's source fork has been
deleted — a routine outcome after a fork PR is merged and the
author removes their fork. The local branch still matches the
PR's `headRefName`, so Supacode should surface it.

Tier the candidates instead of the previous two-bucket split:

1. upstream PRs (head repo == queried repo),
2. fork PRs with an intact `headRepository` and
`baseRefName != branch` (guards against "user:main → main"),
3. deleted-fork PRs (`headRepository == nil`, same
`baseRefName` guard) — only consulted when tiers 1 and 2 are
empty so we never prefer a stub over a PR with verifiable
provenance.

The existing `skipsNilHeadRepositoryInForkFallback` still passes
because the intact-fork node lands in tier 2 and wins. New
`includesMergedPRWithDeletedForkHead` covers the regression this
uncovered (observed on PR #248 in `supabitapp/supacode`).

authored by

Stefano Bertagno and committed by
GitHub
a4cdad9e 644bd468

+587 -48
+90 -28
supacode/Clients/Github/GithubCLIClient.swift
··· 44 44 struct GithubCLIClient: Sendable { 45 45 var defaultBranch: @Sendable (URL) async throws -> String 46 46 var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? 47 + var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? 47 48 var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] 48 - var mergePullRequest: @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void 49 - var closePullRequest: @Sendable (URL, Int) async throws -> Void 50 - var markPullRequestReady: @Sendable (URL, Int) async throws -> Void 49 + var mergePullRequest: @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void 50 + var closePullRequest: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void 51 + var markPullRequestReady: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void 51 52 var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void 52 53 var failedRunLogs: @Sendable (URL, Int) async throws -> String 53 54 var runLogs: @Sendable (URL, Int) async throws -> String ··· 63 64 return GithubCLIClient( 64 65 defaultBranch: defaultBranchFetcher(shell: shell, resolver: resolver), 65 66 latestRun: latestRunFetcher(shell: shell, resolver: resolver), 67 + resolveRemoteInfo: resolveRemoteInfoFetcher(shell: shell, resolver: resolver), 66 68 batchPullRequests: batchPullRequestsFetcher(shell: shell, resolver: resolver), 67 69 mergePullRequest: mergePullRequestFetcher(shell: shell, resolver: resolver), 68 70 closePullRequest: closePullRequestFetcher(shell: shell, resolver: resolver), ··· 78 80 static let testValue = GithubCLIClient( 79 81 defaultBranch: { _ in "main" }, 80 82 latestRun: { _, _ in nil }, 83 + resolveRemoteInfo: { _ in nil }, 81 84 batchPullRequests: { _, _, _, _ in [:] }, 82 - mergePullRequest: { _, _, _ in }, 83 - closePullRequest: { _, _ in }, 84 - markPullRequestReady: { _, _ in }, 85 + mergePullRequest: { _, _, _, _ in }, 86 + closePullRequest: { _, _, _ in }, 87 + markPullRequestReady: { _, _, _ in }, 85 88 rerunFailedJobs: { _, _ in }, 86 89 failedRunLogs: { _, _ in "" }, 87 90 runLogs: { _, _ in "" }, ··· 229 232 } 230 233 } 231 234 235 + nonisolated private struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { 236 + let name: String 237 + let owner: Owner 238 + let url: String? 239 + 240 + nonisolated struct Owner: Decodable, Sendable { 241 + let login: String 242 + } 243 + } 244 + 245 + nonisolated private func resolveRemoteInfoFetcher( 246 + shell: ShellClient, 247 + resolver: GithubCLIExecutableResolver 248 + ) -> @Sendable (URL) async -> GithubRemoteInfo? { 249 + { repoRoot in 250 + do { 251 + let output = try await runGh( 252 + shell: shell, 253 + resolver: resolver, 254 + arguments: ["repo", "view", "--json", "owner,name,url"], 255 + repoRoot: repoRoot 256 + ) 257 + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) 258 + guard !trimmed.isEmpty else { 259 + return nil 260 + } 261 + let response = try JSONDecoder().decode( 262 + GithubRepoViewRemoteInfoResponse.self, 263 + from: Data(trimmed.utf8) 264 + ) 265 + let host = hostFromRepoViewURL(response.url) ?? "github.com" 266 + guard !response.owner.login.isEmpty, !response.name.isEmpty else { 267 + return nil 268 + } 269 + return GithubRemoteInfo( 270 + host: host, 271 + owner: response.owner.login, 272 + repo: response.name 273 + ) 274 + } catch { 275 + return nil 276 + } 277 + } 278 + } 279 + 280 + nonisolated private func hostFromRepoViewURL(_ urlString: String?) -> String? { 281 + guard let urlString, !urlString.isEmpty, 282 + let url = URL(string: urlString), 283 + let host = url.host, 284 + !host.isEmpty 285 + else { 286 + return nil 287 + } 288 + return host 289 + } 290 + 291 + nonisolated private func repoSlug(for remote: GithubRemoteInfo) -> String { 292 + "\(remote.host)/\(remote.owner)/\(remote.repo)" 293 + } 294 + 232 295 nonisolated private func batchPullRequestsFetcher( 233 296 shell: ShellClient, 234 297 resolver: GithubCLIExecutableResolver ··· 259 322 nonisolated private func mergePullRequestFetcher( 260 323 shell: ShellClient, 261 324 resolver: GithubCLIExecutableResolver 262 - ) -> @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void { 263 - { repoRoot, pullRequestNumber, strategy in 325 + ) -> @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void { 326 + { repoRoot, remote, pullRequestNumber, strategy in 327 + var arguments: [String] = ["pr", "merge", "\(pullRequestNumber)", "--\(strategy.ghArgument)"] 328 + if let remote { 329 + arguments.append(contentsOf: ["--repo", repoSlug(for: remote)]) 330 + } 264 331 _ = try await runGh( 265 332 shell: shell, 266 333 resolver: resolver, 267 - arguments: [ 268 - "pr", 269 - "merge", 270 - "\(pullRequestNumber)", 271 - "--\(strategy.ghArgument)", 272 - ], 334 + arguments: arguments, 273 335 repoRoot: repoRoot 274 336 ) 275 337 } ··· 278 340 nonisolated private func closePullRequestFetcher( 279 341 shell: ShellClient, 280 342 resolver: GithubCLIExecutableResolver 281 - ) -> @Sendable (URL, Int) async throws -> Void { 282 - { repoRoot, pullRequestNumber in 343 + ) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { 344 + { repoRoot, remote, pullRequestNumber in 345 + var arguments: [String] = ["pr", "close", "\(pullRequestNumber)"] 346 + if let remote { 347 + arguments.append(contentsOf: ["--repo", repoSlug(for: remote)]) 348 + } 283 349 _ = try await runGh( 284 350 shell: shell, 285 351 resolver: resolver, 286 - arguments: [ 287 - "pr", 288 - "close", 289 - "\(pullRequestNumber)", 290 - ], 352 + arguments: arguments, 291 353 repoRoot: repoRoot 292 354 ) 293 355 } ··· 296 358 nonisolated private func markPullRequestReadyFetcher( 297 359 shell: ShellClient, 298 360 resolver: GithubCLIExecutableResolver 299 - ) -> @Sendable (URL, Int) async throws -> Void { 300 - { repoRoot, pullRequestNumber in 361 + ) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { 362 + { repoRoot, remote, pullRequestNumber in 363 + var arguments: [String] = ["pr", "ready", "\(pullRequestNumber)"] 364 + if let remote { 365 + arguments.append(contentsOf: ["--repo", repoSlug(for: remote)]) 366 + } 301 367 _ = try await runGh( 302 368 shell: shell, 303 369 resolver: resolver, 304 - arguments: [ 305 - "pr", 306 - "ready", 307 - "\(pullRequestNumber)", 308 - ], 370 + arguments: arguments, 309 371 repoRoot: repoRoot 310 372 ) 311 373 }
+31 -14
supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift
··· 15 15 guard let branch = aliasMap[alias] else { 16 16 continue 17 17 } 18 - // Prefer PRs from the same repo (matching owner/name). 18 + // Tier 1 — PRs whose head ref lives in the queried repo. 19 + // The caller already resolved the query target, so an exact 20 + // head match is the most trustworthy signal. 19 21 let upstreamCandidates = connection.nodes.filter { $0.matches(owner: normalizedOwner, repo: normalizedRepo) } 20 - // Fall back to fork PRs. The GraphQL query fetches by headRefName, 21 - // so a fork PR like "user:main → main" appears when querying for 22 - // "main" even though the local branch is the PR's target, not its 23 - // source. Skip these by requiring baseRefName to differ from the 24 - // local branch name (nil baseRefName is treated as unknown and 25 - // excluded conservatively). 26 - let candidates = 27 - upstreamCandidates.isEmpty 28 - ? connection.nodes.filter { 29 - $0.headRepository != nil 30 - && $0.baseRefName.map { $0.lowercased() != branch.lowercased() } ?? false 31 - } 32 - : upstreamCandidates 22 + // Tier 2 — fork PRs with an intact head repository. The 23 + // GraphQL query fetches by headRefName, so a fork PR like 24 + // "user:main → main" appears when querying for "main" even 25 + // though the local branch is the PR's target, not its source. 26 + // The `baseRefName != branch` guard excludes that case (nil 27 + // baseRefName is treated as unknown and excluded conservatively). 28 + let forkCandidates = connection.nodes.filter { 29 + $0.headRepository != nil 30 + && $0.baseRefName.map { $0.lowercased() != branch.lowercased() } ?? false 31 + } 32 + // Tier 3 — PRs whose head repository has been deleted 33 + // (GitHub returns `headRepository: null`). Common after a 34 + // fork PR is merged and the fork is removed; the PR itself 35 + // is still the correct match for the local branch. Only 36 + // consulted when Tiers 1 and 2 are empty so a deleted-fork 37 + // entry never outranks one with verifiable provenance. 38 + let deletedForkCandidates = connection.nodes.filter { 39 + $0.headRepository == nil 40 + && $0.baseRefName.map { $0.lowercased() != branch.lowercased() } ?? false 41 + } 42 + let candidates: [PullRequestNode] 43 + if !upstreamCandidates.isEmpty { 44 + candidates = upstreamCandidates 45 + } else if !forkCandidates.isEmpty { 46 + candidates = forkCandidates 47 + } else { 48 + candidates = deletedForkCandidates 49 + } 33 50 if let node = candidates.max(by: { left, right in 34 51 let leftRank = left.stateRank 35 52 let rightRank = right.stateRank
+45 -4
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 22 22 23 23 nonisolated let repositoriesLogger = SupaLogger("Repositories") 24 24 private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 25 + 26 + // Resolve `(host, owner, repo)` for a repository root. `gh repo 27 + // view` honours the user's default-repo resolution (fork → 28 + // upstream), so it wins when available. The git remote parser is 29 + // the fallback for when `gh` is unavailable or unauthenticated. 30 + @Sendable 31 + private func resolveRemoteInfo( 32 + repositoryRootURL: URL, 33 + githubCLI: GithubCLIClient, 34 + gitClient: GitClientDependency 35 + ) async -> GithubRemoteInfo? { 36 + if let info = await githubCLI.resolveRemoteInfo(repositoryRootURL) { 37 + return info 38 + } 39 + return await gitClient.remoteInfo(repositoryRootURL) 40 + } 41 + 25 42 private nonisolated let worktreeCreationProgressLineLimit = 200 26 43 private nonisolated let worktreeCreationProgressUpdateStride = 20 27 44 ··· 2746 2763 2747 2764 case .markReadyForReview: 2748 2765 let githubCLI = githubCLI 2766 + let gitClient = gitClient 2749 2767 let githubIntegration = githubIntegration 2750 2768 return .run { send in 2751 2769 guard await githubIntegration.isAvailable() else { ··· 2757 2775 ) 2758 2776 return 2759 2777 } 2778 + let remote = await resolveRemoteInfo( 2779 + repositoryRootURL: repoRoot, 2780 + githubCLI: githubCLI, 2781 + gitClient: gitClient 2782 + ) 2760 2783 await send(.showToast(.inProgress("Marking PR ready…"))) 2761 2784 do { 2762 - try await githubCLI.markPullRequestReady(worktreeRoot, pullRequest.number) 2785 + try await githubCLI.markPullRequestReady(worktreeRoot, remote, pullRequest.number) 2763 2786 await send(.showToast(.success("Pull request marked ready"))) 2764 2787 await send(.delayedPullRequestRefresh(worktreeID)) 2765 2788 } catch { ··· 2775 2798 2776 2799 case .merge: 2777 2800 let githubCLI = githubCLI 2801 + let gitClient = gitClient 2778 2802 let githubIntegration = githubIntegration 2779 2803 return .run { send in 2780 2804 guard await githubIntegration.isAvailable() else { ··· 2790 2814 @Shared(.settingsFile) var settingsFile 2791 2815 let strategy = 2792 2816 repositorySettings.pullRequestMergeStrategy ?? settingsFile.global.pullRequestMergeStrategy 2817 + let remote = await resolveRemoteInfo( 2818 + repositoryRootURL: repoRoot, 2819 + githubCLI: githubCLI, 2820 + gitClient: gitClient 2821 + ) 2793 2822 await send(.showToast(.inProgress("Merging pull request…"))) 2794 2823 do { 2795 - try await githubCLI.mergePullRequest(worktreeRoot, pullRequest.number, strategy) 2824 + try await githubCLI.mergePullRequest(worktreeRoot, remote, pullRequest.number, strategy) 2796 2825 await send(.showToast(.success("Pull request merged"))) 2797 2826 await send(.worktreeInfoEvent(pullRequestRefresh)) 2798 2827 await send(.delayedPullRequestRefresh(worktreeID)) ··· 2809 2838 2810 2839 case .close: 2811 2840 let githubCLI = githubCLI 2841 + let gitClient = gitClient 2812 2842 let githubIntegration = githubIntegration 2813 2843 return .run { send in 2814 2844 guard await githubIntegration.isAvailable() else { ··· 2820 2850 ) 2821 2851 return 2822 2852 } 2853 + let remote = await resolveRemoteInfo( 2854 + repositoryRootURL: repoRoot, 2855 + githubCLI: githubCLI, 2856 + gitClient: gitClient 2857 + ) 2823 2858 await send(.showToast(.inProgress("Closing pull request…"))) 2824 2859 do { 2825 - try await githubCLI.closePullRequest(worktreeRoot, pullRequest.number) 2860 + try await githubCLI.closePullRequest(worktreeRoot, remote, pullRequest.number) 2826 2861 await send(.showToast(.success("Pull request closed"))) 2827 2862 await send(.worktreeInfoEvent(pullRequestRefresh)) 2828 2863 await send(.delayedPullRequestRefresh(worktreeID)) ··· 3118 3153 let gitClient = gitClient 3119 3154 let githubCLI = githubCLI 3120 3155 return .run { send in 3121 - guard let remoteInfo = await gitClient.remoteInfo(repositoryRootURL) else { 3156 + guard 3157 + let remoteInfo = await resolveRemoteInfo( 3158 + repositoryRootURL: repositoryRootURL, 3159 + githubCLI: githubCLI, 3160 + gitClient: gitClient 3161 + ) 3162 + else { 3122 3163 await send(.repositoryPullRequestRefreshCompleted(repositoryID)) 3123 3164 return 3124 3165 }
+105
supacodeTests/GithubBatchPullRequestsTests.swift
··· 329 329 #expect(prs["feature-a"]?.title == "Merged Newer") 330 330 } 331 331 332 + @Test func intactForkPRWinsOverNewerDeletedForkPR() throws { 333 + // Tier 2 (intact fork) must outrank Tier 3 (deleted fork) even 334 + // when the Tier 3 candidate is MERGED and more recent — a 335 + // deleted-fork entry should never outrank one with verifiable 336 + // provenance. 337 + let json = """ 338 + { 339 + "data": { 340 + "repository": { 341 + "branch0": { 342 + "nodes": [ 343 + { 344 + "number": 50, 345 + "title": "Deleted Fork PR", 346 + "state": "MERGED", 347 + "additions": 1, 348 + "deletions": 0, 349 + "isDraft": false, 350 + "reviewDecision": null, 351 + "updatedAt": "2026-04-10T00:00:00Z", 352 + "url": "https://github.com/octo/repo/pull/50", 353 + "headRefName": "feature-a", 354 + "baseRefName": "main", 355 + "headRepository": null 356 + }, 357 + { 358 + "number": 51, 359 + "title": "Intact Fork PR", 360 + "state": "OPEN", 361 + "additions": 2, 362 + "deletions": 1, 363 + "isDraft": false, 364 + "reviewDecision": null, 365 + "updatedAt": "2026-01-01T00:00:00Z", 366 + "url": "https://github.com/fork/repo/pull/51", 367 + "headRefName": "feature-a", 368 + "baseRefName": "main", 369 + "headRepository": { 370 + "name": "repo", 371 + "owner": { "login": "fork" } 372 + } 373 + } 374 + ] 375 + } 376 + } 377 + } 378 + } 379 + """ 380 + let data = Data(json.utf8) 381 + let decoder = JSONDecoder() 382 + decoder.dateDecodingStrategy = .iso8601 383 + let response = try decoder.decode(GithubGraphQLPullRequestResponse.self, from: data) 384 + let prs = response.pullRequestsByBranch( 385 + aliasMap: ["branch0": "feature-a"], 386 + owner: "octo", 387 + repo: "repo" 388 + ) 389 + #expect(prs["feature-a"]?.number == 51) 390 + } 391 + 392 + @Test func includesMergedPRWithDeletedForkHead() throws { 393 + // Merged PRs whose source fork has been deleted return 394 + // headRepository: null from GitHub. When no intact-fork or 395 + // upstream candidate exists, the deleted-fork PR is the 396 + // correct match for the local branch (baseRefName still 397 + // differs, so the "user:main → main" confusion is absent). 398 + let json = """ 399 + { 400 + "data": { 401 + "repository": { 402 + "branch0": { 403 + "nodes": [ 404 + { 405 + "number": 248, 406 + "title": "Add RubyMine editor option", 407 + "state": "MERGED", 408 + "additions": 30, 409 + "deletions": 0, 410 + "isDraft": false, 411 + "reviewDecision": null, 412 + "updatedAt": "2026-04-15T00:00:00Z", 413 + "url": "https://github.com/supabitapp/supacode/pull/248", 414 + "headRefName": "feat/add-support-for-rubymine", 415 + "baseRefName": "main", 416 + "headRepository": null 417 + } 418 + ] 419 + } 420 + } 421 + } 422 + } 423 + """ 424 + let data = Data(json.utf8) 425 + let decoder = JSONDecoder() 426 + decoder.dateDecodingStrategy = .iso8601 427 + let response = try decoder.decode(GithubGraphQLPullRequestResponse.self, from: data) 428 + let prs = response.pullRequestsByBranch( 429 + aliasMap: ["branch0": "feat/add-support-for-rubymine"], 430 + owner: "supabitapp", 431 + repo: "supacode" 432 + ) 433 + #expect(prs["feat/add-support-for-rubymine"]?.number == 248) 434 + #expect(prs["feat/add-support-for-rubymine"]?.state == "MERGED") 435 + } 436 + 332 437 @Test func excludesForkPRWithNilBaseRefName() throws { 333 438 // When baseRefName is nil the filter cannot determine whether the 334 439 // local branch is the PR's target, so the PR is excluded conservatively.
+150
supacodeTests/GithubCLIClientTests.swift
··· 1 + import ConcurrencyExtras 1 2 import Foundation 2 3 import Testing 3 4 ··· 178 179 #expect(snapshot.ghCallCount == 2) 179 180 #expect(snapshot.whichCallCount == 1) 180 181 #expect(snapshot.loginCallCount == 2) 182 + } 183 + 184 + @Test func resolveRemoteInfoUsesGhRepoViewAndParsesHost() async { 185 + let shell = ShellClient( 186 + run: { executableURL, _, _ in 187 + if executableURL.lastPathComponent == "which" { 188 + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) 189 + } 190 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 191 + }, 192 + runLoginImpl: { executableURL, arguments, _, _ in 193 + guard executableURL.lastPathComponent == "gh" else { 194 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 195 + } 196 + #expect(arguments == ["repo", "view", "--json", "owner,name,url"]) 197 + let stdout = """ 198 + {"name":"upstream-repo","owner":{"login":"upstream-org"},\ 199 + "url":"https://github.com/upstream-org/upstream-repo"} 200 + """ 201 + return ShellOutput(stdout: stdout, stderr: "", exitCode: 0) 202 + } 203 + ) 204 + let client = GithubCLIClient.live(shell: shell) 205 + 206 + let info = await client.resolveRemoteInfo(URL(fileURLWithPath: "/tmp/repo")) 207 + 208 + #expect(info == GithubRemoteInfo(host: "github.com", owner: "upstream-org", repo: "upstream-repo")) 209 + } 210 + 211 + @Test func resolveRemoteInfoReturnsNilWhenGhFails() async { 212 + let shell = ShellClient( 213 + run: { executableURL, _, _ in 214 + if executableURL.lastPathComponent == "which" { 215 + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) 216 + } 217 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 218 + }, 219 + runLoginImpl: { _, _, _, _ in 220 + throw ShellClientError(command: "gh repo view", stdout: "", stderr: "nope", exitCode: 1) 221 + } 222 + ) 223 + let client = GithubCLIClient.live(shell: shell) 224 + 225 + let info = await client.resolveRemoteInfo(URL(fileURLWithPath: "/tmp/repo")) 226 + 227 + #expect(info == nil) 228 + } 229 + 230 + @Test func mergePullRequestForwardsRepoSlugWhenRemoteProvided() async throws { 231 + let recordedArguments = LockIsolated<[[String]]>([]) 232 + let shell = ShellClient( 233 + run: { executableURL, _, _ in 234 + if executableURL.lastPathComponent == "which" { 235 + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) 236 + } 237 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 238 + }, 239 + runLoginImpl: { executableURL, arguments, _, _ in 240 + guard executableURL.lastPathComponent == "gh" else { 241 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 242 + } 243 + recordedArguments.withValue { $0.append(arguments) } 244 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 245 + } 246 + ) 247 + let client = GithubCLIClient.live(shell: shell) 248 + let remote = GithubRemoteInfo(host: "github.com", owner: "upstream-org", repo: "upstream-repo") 249 + 250 + try await client.mergePullRequest(URL(fileURLWithPath: "/tmp/fork"), remote, 42, .squash) 251 + 252 + #expect( 253 + recordedArguments.value == [ 254 + ["pr", "merge", "42", "--squash", "--repo", "github.com/upstream-org/upstream-repo"] 255 + ] 256 + ) 257 + } 258 + 259 + @Test func mergePullRequestOmitsRepoFlagWhenRemoteMissing() async throws { 260 + let recordedArguments = LockIsolated<[[String]]>([]) 261 + let shell = ShellClient( 262 + run: { executableURL, _, _ in 263 + if executableURL.lastPathComponent == "which" { 264 + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) 265 + } 266 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 267 + }, 268 + runLoginImpl: { executableURL, arguments, _, _ in 269 + guard executableURL.lastPathComponent == "gh" else { 270 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 271 + } 272 + recordedArguments.withValue { $0.append(arguments) } 273 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 274 + } 275 + ) 276 + let client = GithubCLIClient.live(shell: shell) 277 + 278 + try await client.mergePullRequest(URL(fileURLWithPath: "/tmp/fork"), nil, 42, .squash) 279 + 280 + #expect(recordedArguments.value == [["pr", "merge", "42", "--squash"]]) 281 + } 282 + 283 + @Test func closePullRequestForwardsRepoSlug() async throws { 284 + let recordedArguments = LockIsolated<[[String]]>([]) 285 + let shell = ShellClient( 286 + run: { executableURL, _, _ in 287 + if executableURL.lastPathComponent == "which" { 288 + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) 289 + } 290 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 291 + }, 292 + runLoginImpl: { executableURL, arguments, _, _ in 293 + guard executableURL.lastPathComponent == "gh" else { 294 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 295 + } 296 + recordedArguments.withValue { $0.append(arguments) } 297 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 298 + } 299 + ) 300 + let client = GithubCLIClient.live(shell: shell) 301 + let remote = GithubRemoteInfo(host: "ghe.acme.com", owner: "team", repo: "repo") 302 + 303 + try await client.closePullRequest(URL(fileURLWithPath: "/tmp/fork"), remote, 7) 304 + 305 + #expect(recordedArguments.value == [["pr", "close", "7", "--repo", "ghe.acme.com/team/repo"]]) 306 + } 307 + 308 + @Test func markPullRequestReadyForwardsRepoSlug() async throws { 309 + let recordedArguments = LockIsolated<[[String]]>([]) 310 + let shell = ShellClient( 311 + run: { executableURL, _, _ in 312 + if executableURL.lastPathComponent == "which" { 313 + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) 314 + } 315 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 316 + }, 317 + runLoginImpl: { executableURL, arguments, _, _ in 318 + guard executableURL.lastPathComponent == "gh" else { 319 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 320 + } 321 + recordedArguments.withValue { $0.append(arguments) } 322 + return ShellOutput(stdout: "", stderr: "", exitCode: 0) 323 + } 324 + ) 325 + let client = GithubCLIClient.live(shell: shell) 326 + let remote = GithubRemoteInfo(host: "github.com", owner: "owner", repo: "repo") 327 + 328 + try await client.markPullRequestReady(URL(fileURLWithPath: "/tmp/fork"), remote, 13) 329 + 330 + #expect(recordedArguments.value == [["pr", "ready", "13", "--repo", "github.com/owner/repo"]]) 181 331 } 182 332 183 333 @Test func executableResolutionIsSingleFlightAndReused() async {
+166 -2
supacodeTests/RepositoriesFeatureTests.swift
··· 3713 3713 RepositoriesFeature() 3714 3714 } withDependencies: { 3715 3715 $0.githubIntegration.isAvailable = { true } 3716 - $0.githubCLI.mergePullRequest = { _, number, _ in 3716 + $0.githubCLI.mergePullRequest = { _, _, number, _ in 3717 3717 mergedNumbers.withValue { $0.append(number) } 3718 3718 } 3719 3719 } ··· 3755 3755 RepositoriesFeature() 3756 3756 } withDependencies: { 3757 3757 $0.githubIntegration.isAvailable = { true } 3758 - $0.githubCLI.closePullRequest = { _, number in 3758 + $0.githubCLI.closePullRequest = { _, _, number in 3759 3759 closedNumbers.withValue { $0.append(number) } 3760 3760 } 3761 3761 } ··· 3771 3771 await store.receive(\.worktreeInfoEvent) 3772 3772 #expect(closedNumbers.value == [12]) 3773 3773 await store.finish() 3774 + } 3775 + 3776 + @Test func worktreeInfoEventRepositoryPullRequestRefreshPrefersGhResolvedRemote() async { 3777 + let repoRoot = "/tmp/repo" 3778 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3779 + let featureWorktree = makeWorktree( 3780 + id: "\(repoRoot)/feature", 3781 + name: "feature", 3782 + repoRoot: repoRoot 3783 + ) 3784 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3785 + var initialState = makeState(repositories: [repository]) 3786 + initialState.githubIntegrationAvailability = .available 3787 + let batchCalls = LockIsolated<[GithubRemoteInfo]>([]) 3788 + let store = TestStore(initialState: initialState) { 3789 + RepositoriesFeature() 3790 + } withDependencies: { 3791 + $0.githubCLI.resolveRemoteInfo = { _ in 3792 + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") 3793 + } 3794 + $0.gitClient.remoteInfo = { _ in 3795 + Issue.record("gitClient.remoteInfo should be the fallback, not the first choice") 3796 + return GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") 3797 + } 3798 + $0.githubCLI.batchPullRequests = { host, owner, repo, _ in 3799 + batchCalls.withValue { $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) } 3800 + return [:] 3801 + } 3802 + } 3803 + store.exhaustivity = .off 3804 + 3805 + await store.send( 3806 + .worktreeInfoEvent( 3807 + .repositoryPullRequestRefresh( 3808 + repositoryRootURL: URL(fileURLWithPath: repoRoot), 3809 + worktreeIDs: [mainWorktree.id, featureWorktree.id] 3810 + ) 3811 + ) 3812 + ) 3813 + await store.receive(\.repositoryPullRequestRefreshCompleted) 3814 + await store.finish() 3815 + 3816 + #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project")]) 3817 + } 3818 + 3819 + @Test func worktreeInfoEventRepositoryPullRequestRefreshFallsBackToGitRemote() async { 3820 + let repoRoot = "/tmp/repo" 3821 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3822 + let featureWorktree = makeWorktree( 3823 + id: "\(repoRoot)/feature", 3824 + name: "feature", 3825 + repoRoot: repoRoot 3826 + ) 3827 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3828 + var initialState = makeState(repositories: [repository]) 3829 + initialState.githubIntegrationAvailability = .available 3830 + let batchCalls = LockIsolated<[GithubRemoteInfo]>([]) 3831 + let store = TestStore(initialState: initialState) { 3832 + RepositoriesFeature() 3833 + } withDependencies: { 3834 + $0.githubCLI.resolveRemoteInfo = { _ in nil } 3835 + $0.gitClient.remoteInfo = { _ in 3836 + GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") 3837 + } 3838 + $0.githubCLI.batchPullRequests = { host, owner, repo, _ in 3839 + batchCalls.withValue { $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) } 3840 + return [:] 3841 + } 3842 + } 3843 + store.exhaustivity = .off 3844 + 3845 + await store.send( 3846 + .worktreeInfoEvent( 3847 + .repositoryPullRequestRefresh( 3848 + repositoryRootURL: URL(fileURLWithPath: repoRoot), 3849 + worktreeIDs: [mainWorktree.id, featureWorktree.id] 3850 + ) 3851 + ) 3852 + ) 3853 + await store.receive(\.repositoryPullRequestRefreshCompleted) 3854 + await store.finish() 3855 + 3856 + #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project")]) 3857 + } 3858 + 3859 + @Test func pullRequestActionMergePassesResolvedRemoteToGh() async { 3860 + let repoRoot = "/tmp/repo" 3861 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3862 + let featureWorktree = makeWorktree( 3863 + id: "\(repoRoot)/feature", 3864 + name: "feature", 3865 + repoRoot: repoRoot 3866 + ) 3867 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3868 + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 88) 3869 + var state = makeState(repositories: [repository]) 3870 + state.githubIntegrationAvailability = .disabled 3871 + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 3872 + addedLines: nil, 3873 + removedLines: nil, 3874 + pullRequest: openPullRequest 3875 + ) 3876 + let recordedRemote = LockIsolated<GithubRemoteInfo?>(nil) 3877 + let store = TestStore(initialState: state) { 3878 + RepositoriesFeature() 3879 + } withDependencies: { 3880 + $0.githubIntegration.isAvailable = { true } 3881 + $0.githubCLI.resolveRemoteInfo = { _ in 3882 + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") 3883 + } 3884 + $0.githubCLI.mergePullRequest = { _, remote, _, _ in 3885 + recordedRemote.withValue { $0 = remote } 3886 + } 3887 + } 3888 + store.exhaustivity = .off 3889 + 3890 + await store.send(.pullRequestAction(featureWorktree.id, .merge)) 3891 + await store.receive(\.showToast) 3892 + await store.receive(\.showToast) 3893 + await store.receive(\.worktreeInfoEvent) 3894 + await store.finish() 3895 + 3896 + #expect(recordedRemote.value == GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project")) 3897 + } 3898 + 3899 + @Test func pullRequestActionMergeFallsBackToGitRemoteWhenGhResolverReturnsNil() async { 3900 + let repoRoot = "/tmp/repo" 3901 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3902 + let featureWorktree = makeWorktree( 3903 + id: "\(repoRoot)/feature", 3904 + name: "feature", 3905 + repoRoot: repoRoot 3906 + ) 3907 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3908 + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 88) 3909 + var state = makeState(repositories: [repository]) 3910 + state.githubIntegrationAvailability = .disabled 3911 + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 3912 + addedLines: nil, 3913 + removedLines: nil, 3914 + pullRequest: openPullRequest 3915 + ) 3916 + let recordedRemote = LockIsolated<GithubRemoteInfo?>(nil) 3917 + let store = TestStore(initialState: state) { 3918 + RepositoriesFeature() 3919 + } withDependencies: { 3920 + $0.githubIntegration.isAvailable = { true } 3921 + $0.githubCLI.resolveRemoteInfo = { _ in nil } 3922 + $0.gitClient.remoteInfo = { _ in 3923 + GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") 3924 + } 3925 + $0.githubCLI.mergePullRequest = { _, remote, _, _ in 3926 + recordedRemote.withValue { $0 = remote } 3927 + } 3928 + } 3929 + store.exhaustivity = .off 3930 + 3931 + await store.send(.pullRequestAction(featureWorktree.id, .merge)) 3932 + await store.receive(\.showToast) 3933 + await store.receive(\.showToast) 3934 + await store.receive(\.worktreeInfoEvent) 3935 + await store.finish() 3936 + 3937 + #expect(recordedRemote.value == GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project")) 3774 3938 } 3775 3939 3776 3940 @Test func worktreeInfoEventRepositoryPullRequestRefreshMarksInFlightThenCompletes() async {