native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #217 from onevcat/codex/broaden-code-host-opening-fallback

[codex] Broaden code host opening fallback

authored by

Wei Wang and committed by
GitHub
dff34e1d 9039c764

+487 -78
+1 -1
supacode/App/AppShortcuts.swift
··· 328 328 ), 329 329 .init( 330 330 id: CommandID.openPullRequest, 331 - title: "Open Pull Request", 331 + title: "Open on Code Host", 332 332 scope: .configurableAppAction, 333 333 shortcut: openPullRequest 334 334 ),
+46 -12
supacode/Clients/Git/GitClient.swift
··· 503 503 } 504 504 } 505 505 506 + nonisolated func repositoryWebURL(for repositoryRoot: URL) async -> URL? { 507 + await remoteWebInfo(for: repositoryRoot)?.repositoryURL 508 + } 509 + 506 510 nonisolated func remoteInfo(for repositoryRoot: URL) async -> GithubRemoteInfo? { 511 + guard let remoteWebInfo = await remoteWebInfo(for: repositoryRoot) else { 512 + return nil 513 + } 514 + return Self.parseGithubRemoteInfo(remoteWebInfo) 515 + } 516 + 517 + nonisolated private func remoteWebInfo(for repositoryRoot: URL) async -> GitRemoteWebInfo? { 507 518 let path = repositoryRoot.path(percentEncoded: false) 508 519 guard 509 520 let remotesOutput = try? await runGit( ··· 533 544 else { 534 545 continue 535 546 } 536 - if let info = Self.parseGithubRemoteInfo(remoteURL) { 547 + if let info = Self.parseRepositoryWebInfo(remoteURL) { 537 548 return info 538 549 } 539 550 } ··· 857 868 return nil 858 869 } 859 870 860 - nonisolated static func parseGithubRemoteInfo(_ remoteURL: String) -> GithubRemoteInfo? { 871 + nonisolated static func parseRepositoryWebInfo(_ remoteURL: String) -> GitRemoteWebInfo? { 861 872 let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) 862 873 guard !trimmed.isEmpty else { 863 874 return nil ··· 872 883 guard hostParts.count == 2 else { 873 884 return nil 874 885 } 875 - return parseGithubRemoteInfo(host: String(hostParts[0]), path: String(hostParts[1])) 886 + return parseRepositoryWebInfo(host: String(hostParts[0]), port: nil, path: String(hostParts[1])) 876 887 } 877 888 guard let url = URL(string: trimmed), let host = url.host else { 878 889 return nil 879 890 } 880 891 let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) 881 - return parseGithubRemoteInfo(host: host, path: path) 892 + return parseRepositoryWebInfo(host: host, port: url.port, path: path) 893 + } 894 + 895 + nonisolated static func parseGithubRemoteInfo(_ remoteURL: String) -> GithubRemoteInfo? { 896 + guard let remoteWebInfo = parseRepositoryWebInfo(remoteURL) else { 897 + return nil 898 + } 899 + return parseGithubRemoteInfo(remoteWebInfo) 900 + } 901 + 902 + nonisolated private static func parseRepositoryWebInfo( 903 + host: String, 904 + port: Int?, 905 + path: String 906 + ) -> GitRemoteWebInfo? { 907 + let components = path.split(separator: "/", omittingEmptySubsequences: true) 908 + guard components.count >= 2 else { 909 + return nil 910 + } 911 + var repositoryPath = components.map(String.init).joined(separator: "/") 912 + if repositoryPath.hasSuffix(".git") { 913 + repositoryPath = String(repositoryPath.dropLast(4)) 914 + } 915 + guard !repositoryPath.isEmpty else { 916 + return nil 917 + } 918 + return GitRemoteWebInfo(host: host, repositoryPath: repositoryPath, port: port) 882 919 } 883 920 884 - nonisolated private static func parseGithubRemoteInfo(host: String, path: String) -> GithubRemoteInfo? { 885 - let normalizedHost = host.lowercased() 921 + nonisolated private static func parseGithubRemoteInfo(_ remoteWebInfo: GitRemoteWebInfo) -> GithubRemoteInfo? { 922 + let normalizedHost = remoteWebInfo.host.lowercased() 886 923 guard normalizedHost.contains("github") else { 887 924 return nil 888 925 } 889 - let components = path.split(separator: "/", omittingEmptySubsequences: true) 926 + let components = remoteWebInfo.repositoryPath.split(separator: "/", omittingEmptySubsequences: true) 890 927 guard components.count >= 2 else { 891 928 return nil 892 929 } 893 930 let owner = String(components[0]) 894 - var repo = String(components[1]) 895 - if repo.hasSuffix(".git") { 896 - repo = String(repo.dropLast(4)) 897 - } 931 + let repo = String(components[1]) 898 932 guard !owner.isEmpty, !repo.isEmpty else { 899 933 return nil 900 934 } 901 - return GithubRemoteInfo(host: host, owner: owner, repo: repo) 935 + return GithubRemoteInfo(host: remoteWebInfo.host, owner: owner, repo: repo) 902 936 } 903 937 904 938 }
+22
supacode/Clients/Git/GitRemoteWebInfo.swift
··· 1 + import Foundation 2 + 3 + struct GitRemoteWebInfo: Equatable, Sendable { 4 + let host: String 5 + let repositoryPath: String 6 + let port: Int? 7 + 8 + nonisolated init(host: String, repositoryPath: String, port: Int? = nil) { 9 + self.host = host 10 + self.repositoryPath = repositoryPath 11 + self.port = port 12 + } 13 + 14 + nonisolated var repositoryURL: URL? { 15 + var components = URLComponents() 16 + components.scheme = "https" 17 + components.host = host 18 + components.port = port 19 + components.path = "/\(repositoryPath)" 20 + return components.url 21 + } 22 + }
+4
supacode/Clients/Repositories/GitClientDependency.swift
··· 36 36 var branchName: @Sendable (URL) async -> String? 37 37 var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? 38 38 var renameBranch: @Sendable (_ worktreeURL: URL, _ branchName: String) async throws -> Void 39 + var repositoryWebURL: @Sendable (_ repositoryRoot: URL) async -> URL? 39 40 var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> GithubRemoteInfo? 40 41 var remoteNames: @Sendable (_ repoRoot: URL) async throws -> [String] 41 42 var fetchRemote: @Sendable (_ remote: String, _ repoRoot: URL) async throws -> Void ··· 83 84 lineChanges: { await GitClient().lineChanges(at: $0) }, 84 85 renameBranch: { worktreeURL, branchName in 85 86 try await GitClient().renameBranch(in: worktreeURL, to: branchName) 87 + }, 88 + repositoryWebURL: { repositoryRoot in 89 + await GitClient().repositoryWebURL(for: repositoryRoot) 86 90 }, 87 91 remoteInfo: { repositoryRoot in 88 92 await GitClient().remoteInfo(for: repositoryRoot)
+22
supacode/Clients/Workspace/OpenURLClient.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import Foundation 4 + 5 + struct OpenURLClient { 6 + var open: @MainActor @Sendable (_ url: URL) -> Void 7 + } 8 + 9 + extension OpenURLClient: DependencyKey { 10 + static let liveValue = OpenURLClient { url in 11 + NSWorkspace.shared.open(url) 12 + } 13 + 14 + static let testValue = OpenURLClient { _ in } 15 + } 16 + 17 + extension DependencyValues { 18 + var openURLClient: OpenURLClient { 19 + get { self[OpenURLClient.self] } 20 + set { self[OpenURLClient.self] = newValue } 21 + } 22 + }
+36 -27
supacode/Commands/WorktreeCommands.swift
··· 20 20 let repositories = store.repositories 21 21 let hasActiveWorktree = repositories.worktree(for: repositories.selectedWorktreeID) != nil 22 22 let orderedRows = visibleHotkeyWorktreeRows ?? repositories.orderedWorktreeRows() 23 - let pullRequestURL = selectedPullRequestURL 24 - let githubIntegrationEnabled = store.settings.githubIntegrationEnabled 23 + let codeHostWorktreeID = selectedCodeHostWorktreeID 25 24 let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 26 25 let customCommands = store.selectedCustomCommands 27 26 CommandMenu("Worktrees") { ··· 74 73 .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.openWorktree))) 75 74 .help(helpText(title: "Open Worktree", commandID: AppShortcuts.CommandID.openWorktree)) 76 75 .disabled(openSelectedWorktreeAction == nil) 77 - Button("Open Pull Request on GitHub") { 78 - if let pullRequestURL { 79 - NSWorkspace.shared.open(pullRequestURL) 76 + Button("Open on Code Host") { 77 + if let codeHostWorktreeID { 78 + store.send(.repositories(.githubIntegration(.pullRequestAction(codeHostWorktreeID, .openOnCodeHost)))) 80 79 } 81 80 } 82 81 .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.openPullRequest))) 83 - .help(helpText(title: "Open Pull Request on GitHub", commandID: AppShortcuts.CommandID.openPullRequest)) 84 - .disabled(pullRequestURL == nil || !githubIntegrationEnabled) 82 + .help(helpText(title: "Open on Code Host", commandID: AppShortcuts.CommandID.openPullRequest)) 83 + .disabled(codeHostWorktreeID == nil) 85 84 Button("New Worktree", systemImage: "plus") { 86 85 store.send(.repositories(.worktreeCreation(.createRandomWorktree))) 87 86 } ··· 130 129 AppShortcuts.worktreeSelectionCommandIDs 131 130 } 132 131 133 - private var selectedPullRequestURL: URL? { 132 + private var selectedCodeHostWorktreeID: Worktree.ID? { 134 133 let repositories = store.repositories 135 134 guard let selectedWorktreeID = repositories.selectedWorktreeID else { return nil } 136 - let pullRequest = repositories.worktreeInfoByID[selectedWorktreeID]?.pullRequest 137 - return pullRequest.flatMap { URL(string: $0.url) } 135 + guard 136 + let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), 137 + repositories.repositories[id: repositoryID]?.capabilities.supportsCodeHost == true 138 + else { 139 + return nil 140 + } 141 + return selectedWorktreeID 138 142 } 139 143 140 144 private func keyboardShortcut(for commandID: String) -> KeyboardShortcut? { ··· 178 182 for repoID in repositories.orderedRepositoryIDs() { 179 183 guard let repo = reposByID[repoID] else { continue } 180 184 if repo.kind == .plain { 181 - entries.append(WorktreeMenuEntry( 182 - kind: .plainFolder(id: repo.id, name: repo.name), 183 - shortcutCommandID: nil 184 - )) 185 + entries.append( 186 + WorktreeMenuEntry( 187 + kind: .plainFolder(id: repo.id, name: repo.name), 188 + shortcutCommandID: nil 189 + )) 185 190 } else { 186 191 for row in repositories.worktreeRows(in: repo) { 187 - entries.append(WorktreeMenuEntry( 188 - kind: .worktree(row), 189 - shortcutCommandID: shortcutByWorktreeID[row.id] 190 - )) 192 + entries.append( 193 + WorktreeMenuEntry( 194 + kind: .worktree(row), 195 + shortcutCommandID: shortcutByWorktreeID[row.id] 196 + )) 191 197 } 192 198 } 193 199 } ··· 203 209 Button(title) { 204 210 store.send(.repositories(.selectWorktree(row.id))) 205 211 } 206 - .modifier(KeyboardShortcutModifier( 207 - shortcut: entry.shortcutCommandID.flatMap { keyboardShortcut(for: $0) } 208 - )) 209 - .help({ 210 - if let commandID = entry.shortcutCommandID, let shortcut = shortcutDisplay(for: commandID) { 211 - return "Switch to \(title) (\(shortcut))" 212 - } 213 - return "Switch to \(title)" 214 - }()) 212 + .modifier( 213 + KeyboardShortcutModifier( 214 + shortcut: entry.shortcutCommandID.flatMap { keyboardShortcut(for: $0) } 215 + ) 216 + ) 217 + .help( 218 + { 219 + if let commandID = entry.shortcutCommandID, let shortcut = shortcutDisplay(for: commandID) { 220 + return "Switch to \(title) (\(shortcut))" 221 + } 222 + return "Switch to \(title)" 223 + }()) 215 224 case .plainFolder(let repoID, let name): 216 225 Button(name) { 217 226 store.send(.repositories(.selectRepository(repoID)))
+3
supacode/Domain/Repository.swift
··· 11 11 let supportsWorktrees: Bool 12 12 let supportsBranchOperations: Bool 13 13 let supportsPullRequests: Bool 14 + let supportsCodeHost: Bool 14 15 let supportsDiff: Bool 15 16 let supportsGitStatus: Bool 16 17 let supportsRunnableFolderActions: Bool ··· 20 21 supportsWorktrees: true, 21 22 supportsBranchOperations: true, 22 23 supportsPullRequests: true, 24 + supportsCodeHost: true, 23 25 supportsDiff: true, 24 26 supportsGitStatus: true, 25 27 supportsRunnableFolderActions: true, ··· 30 32 supportsWorktrees: false, 31 33 supportsBranchOperations: false, 32 34 supportsPullRequests: false, 35 + supportsCodeHost: false, 33 36 supportsDiff: false, 34 37 supportsGitStatus: false, 35 38 supportsRunnableFolderActions: true,
+1 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 910 910 } 911 911 912 912 case .commandPalette(.delegate(.openPullRequest(let worktreeID))): 913 - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openOnGithub)))) 913 + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openOnCodeHost)))) 914 914 915 915 case .commandPalette(.delegate(.markPullRequestReady(let worktreeID))): 916 916 return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .markReadyForReview))))
+41 -15
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 236 236 ) 237 237 items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) 238 238 } 239 - if let selectedWorktreeID = repositories.selectedWorktreeID, 240 - let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), 241 - repositories.repositories[id: repositoryID]?.capabilities.supportsPullRequests == true, 242 - let pullRequest = repositories.worktreeInfo(for: selectedWorktreeID)?.pullRequest, 243 - pullRequest.number > 0, 244 - pullRequest.state.uppercased() != "CLOSED" 245 - { 246 - let pullRequestActions = pullRequestItems( 247 - pullRequest: pullRequest, 248 - worktreeID: selectedWorktreeID, 249 - repositoryID: repositoryID 250 - ) 251 - items.append(contentsOf: pullRequestActions) 252 - } 239 + items.append(contentsOf: selectedCodeHostItems(from: repositories)) 253 240 #if DEBUG 254 241 items.append(contentsOf: debugToastItems()) 255 242 #endif ··· 282 269 } 283 270 return ids 284 271 } 272 + } 273 + 274 + private func selectedCodeHostItems( 275 + from repositories: RepositoriesFeature.State 276 + ) -> [CommandPaletteItem] { 277 + guard 278 + let selectedWorktreeID = repositories.selectedWorktreeID, 279 + let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), 280 + let repository = repositories.repositories[id: repositoryID] 281 + else { 282 + return [] 283 + } 284 + 285 + let pullRequest = repositories.worktreeInfo(for: selectedWorktreeID)?.pullRequest 286 + if repository.capabilities.supportsPullRequests, 287 + let pullRequest, 288 + pullRequest.number > 0, 289 + pullRequest.state.uppercased() != "CLOSED" 290 + { 291 + return pullRequestItems( 292 + pullRequest: pullRequest, 293 + worktreeID: selectedWorktreeID, 294 + repositoryID: repositoryID 295 + ) 296 + } 297 + 298 + guard repository.capabilities.supportsCodeHost else { 299 + return [] 300 + } 301 + 302 + return [ 303 + CommandPaletteItem( 304 + id: CommandPaletteItemID.pullRequestOpen(repositoryID), 305 + title: "Open Repository on Code Host", 306 + subtitle: repository.name, 307 + kind: .openPullRequest(selectedWorktreeID), 308 + priorityTier: 2 309 + ), 310 + ] 285 311 } 286 312 287 313 private func pullRequestItems( ··· 361 387 var items: [CommandPaletteItem] = [ 362 388 CommandPaletteItem( 363 389 id: CommandPaletteItemID.pullRequestOpen(repositoryID), 364 - title: "Open PR on GitHub", 390 + title: "Open Pull Request on GitHub", 365 391 subtitle: pullRequest.title, 366 392 kind: .openPullRequest(worktreeID), 367 393 priorityTier: 2
+1 -1
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 520 520 case .archiveWorktree: 521 521 base = "Archive \(row.title)" 522 522 case .openPullRequest: 523 - base = "Open pull request on GitHub" 523 + base = "Open on Code Host" 524 524 case .markPullRequestReady: 525 525 base = "Mark pull request ready for review" 526 526 case .mergePullRequest:
+46 -16
supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift
··· 226 226 case .pullRequestAction(let worktreeID, let action): 227 227 guard let worktree = state.worktree(for: worktreeID), 228 228 let repositoryID = state.repositoryID(containing: worktreeID), 229 - let repository = state.repositories[id: repositoryID], 230 - let pullRequest = state.worktreeInfo(for: worktreeID)?.pullRequest 229 + let repository = state.repositories[id: repositoryID] 231 230 else { 232 231 return .send( 233 232 .presentAlert( 233 + title: "Repository not available", 234 + message: "Prowl could not find the selected repository." 235 + ) 236 + ) 237 + } 238 + let repoRoot = worktree.repositoryRootURL 239 + let worktreeRoot = worktree.workingDirectory 240 + let optionalPullRequest = state.worktreeInfo(for: worktreeID)?.pullRequest 241 + if case .openOnCodeHost = action { 242 + let gitClient = gitClient 243 + let openURLClient = openURLClient 244 + let pullRequestURL = optionalPullRequest.flatMap { Self.validWebURL($0.url) } 245 + return .run { send in 246 + if let pullRequestURL { 247 + await openURLClient.open(pullRequestURL) 248 + return 249 + } 250 + guard let repositoryURL = await gitClient.repositoryWebURL(repoRoot) else { 251 + await send( 252 + .presentAlert( 253 + title: "Repository URL not available", 254 + message: "Prowl could not determine a code host URL for this repository." 255 + ) 256 + ) 257 + return 258 + } 259 + await openURLClient.open(repositoryURL) 260 + } 261 + } 262 + guard let pullRequest = optionalPullRequest else { 263 + return .send( 264 + .presentAlert( 234 265 title: "Pull request not available", 235 266 message: "Prowl could not find a pull request for this worktree." 236 267 ) 237 268 ) 238 269 } 239 - let repoRoot = worktree.repositoryRootURL 240 - let worktreeRoot = worktree.workingDirectory 241 270 let pullRequestRefresh = WorktreeInfoWatcherClient.Event.repositoryPullRequestRefresh( 242 271 repositoryRootURL: repoRoot, 243 272 worktreeIDs: repository.worktrees.map(\.id) ··· 247 276 $0.checkState == .failure && $0.detailsUrl != nil 248 277 }?.detailsUrl 249 278 switch action { 250 - case .openOnGithub: 251 - guard let url = URL(string: pullRequest.url) else { 252 - return .send( 253 - .presentAlert( 254 - title: "Invalid pull request URL", 255 - message: "Prowl could not open the pull request URL." 256 - ) 257 - ) 258 - } 259 - return .run { @MainActor _ in 260 - NSWorkspace.shared.open(url) 261 - } 279 + case .openOnCodeHost: 280 + return .none 262 281 263 282 case .copyFailingJobURL: 264 283 guard let failingCheckDetailsURL, !failingCheckDetailsURL.isEmpty else { ··· 550 569 state.mergedWorktreeAction = action 551 570 return .none 552 571 } 572 + } 573 + 574 + nonisolated private static func validWebURL(_ raw: String) -> URL? { 575 + guard let url = URL(string: raw), 576 + let scheme = url.scheme?.lowercased(), 577 + ["http", "https"].contains(scheme), 578 + url.host != nil 579 + else { 580 + return nil 581 + } 582 + return url 553 583 } 554 584 555 585 var githubIntegrationReducer: some ReducerOf<Self> {
+2 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 332 332 } 333 333 334 334 enum PullRequestAction: Equatable { 335 - case openOnGithub 335 + case openOnCodeHost 336 336 case markReadyForReview 337 337 case merge 338 338 case close ··· 355 355 @Dependency(GitClientDependency.self) var gitClient 356 356 @Dependency(GithubCLIClient.self) var githubCLI 357 357 @Dependency(GithubIntegrationClient.self) var githubIntegration 358 + @Dependency(OpenURLClient.self) var openURLClient 358 359 @Dependency(RepositoryPersistenceClient.self) var repositoryPersistence 359 360 @Dependency(ShellClient.self) var shellClient 360 361 @Dependency(\.date.now) var now
+19
supacodeTests/CommandPaletteFeatureTests.swift
··· 179 179 #expect(items.contains(where: { $0.id == "global.new-worktree" }) == false) 180 180 } 181 181 182 + @Test func commandPaletteItems_showsCodeHostActionWithoutPullRequest() { 183 + let rootPath = "/tmp/repo" 184 + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) 185 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 186 + var state = RepositoriesFeature.State(repositories: [repository]) 187 + state.selection = .worktree(worktree.id) 188 + 189 + let items = CommandPaletteFeature.commandPaletteItems(from: state) 190 + let openItem = items.first { 191 + if case .openPullRequest(let worktreeID) = $0.kind { 192 + return worktreeID == worktree.id 193 + } 194 + return false 195 + } 196 + 197 + #expect(openItem?.title == "Open Repository on Code Host") 198 + #expect(openItem?.subtitle == repository.name) 199 + } 200 + 182 201 @Test func emptyQueryHidesGhosttyCommands() { 183 202 let ghosttyItem = CommandPaletteItem( 184 203 id: "ghostty.goto_split:right|Focus Split Right",
+23
supacodeTests/GitRemoteInfoTests.swift
··· 4 4 @testable import supacode 5 5 6 6 struct GitRemoteInfoTests { 7 + @Test func parseRepositoryWebInfoFromGitHubRemote() { 8 + let info = GitClient.parseRepositoryWebInfo("git@github.com:octo/repo.git") 9 + #expect(info == GitRemoteWebInfo(host: "github.com", repositoryPath: "octo/repo")) 10 + #expect(info?.repositoryURL == URL(string: "https://github.com/octo/repo")) 11 + } 12 + 13 + @Test func parseRepositoryWebInfoFromGitLabSubgroupRemote() { 14 + let info = GitClient.parseRepositoryWebInfo("https://gitlab.com/group/subgroup/repo.git") 15 + #expect(info == GitRemoteWebInfo(host: "gitlab.com", repositoryPath: "group/subgroup/repo")) 16 + #expect(info?.repositoryURL == URL(string: "https://gitlab.com/group/subgroup/repo")) 17 + } 18 + 19 + @Test func parseRepositoryWebInfoPreservesCustomPortAndPathPrefix() { 20 + let info = GitClient.parseRepositoryWebInfo("ssh://git@git.example.com:8443/scm/platform/repo.git") 21 + #expect(info == GitRemoteWebInfo(host: "git.example.com", repositoryPath: "scm/platform/repo", port: 8443)) 22 + #expect(info?.repositoryURL == URL(string: "https://git.example.com:8443/scm/platform/repo")) 23 + } 24 + 25 + @Test func parseRepositoryWebInfoRejectsUnparseableRemote() { 26 + let info = GitClient.parseRepositoryWebInfo("/tmp/local-only/repo.git") 27 + #expect(info == nil) 28 + } 29 + 7 30 @Test func parseSSHRemote() { 8 31 let info = GitClient.parseGithubRemoteInfo("git@github.com:octo/repo.git") 9 32 #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo"))
+78
supacodeTests/GitRepositoryWebURLIntegrationTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct GitRepositoryWebURLIntegrationTests { 7 + @Test func returnsNilWhenRepositoryHasNoRemote() async throws { 8 + let repoURL = try makeTemporaryRepo(namePrefix: "supacode-weburl-no-remote") 9 + defer { try? FileManager.default.removeItem(at: repoURL) } 10 + 11 + let url = await GitClient().repositoryWebURL(for: repoURL) 12 + 13 + #expect(url == nil) 14 + } 15 + 16 + @Test func returnsNilWhenRemoteCannotBeParsed() async throws { 17 + let repoURL = try makeTemporaryRepo(namePrefix: "supacode-weburl-unparseable") 18 + defer { try? FileManager.default.removeItem(at: repoURL) } 19 + 20 + try runGit([ 21 + "-C", repoURL.path(percentEncoded: false), 22 + "remote", "add", "origin", "/tmp/local-only/repo.git", 23 + ]) 24 + 25 + let url = await GitClient().repositoryWebURL(for: repoURL) 26 + 27 + #expect(url == nil) 28 + } 29 + 30 + @Test func preservesCustomPortAndPathPrefixFromRemote() async throws { 31 + let repoURL = try makeTemporaryRepo(namePrefix: "supacode-weburl-port-prefix") 32 + defer { try? FileManager.default.removeItem(at: repoURL) } 33 + 34 + try runGit([ 35 + "-C", repoURL.path(percentEncoded: false), 36 + "remote", "add", "origin", "ssh://git@git.example.com:8443/scm/platform/repo.git", 37 + ]) 38 + 39 + let url = await GitClient().repositoryWebURL(for: repoURL) 40 + 41 + #expect(url == URL(string: "https://git.example.com:8443/scm/platform/repo")) 42 + } 43 + } 44 + 45 + private struct GitCommandError: Error { 46 + let output: String 47 + } 48 + 49 + private func makeTemporaryRepo(namePrefix: String) throws -> URL { 50 + let tempRoot = URL(filePath: "/tmp", directoryHint: .isDirectory) 51 + let repoURL = tempRoot.appending( 52 + path: "\(namePrefix)-\(UUID().uuidString)", 53 + directoryHint: URL.DirectoryHint.isDirectory 54 + ) 55 + try runGit(["init", repoURL.path(percentEncoded: false)]) 56 + return repoURL 57 + } 58 + 59 + @discardableResult 60 + private func runGit(_ arguments: [String]) throws -> String { 61 + let process = Process() 62 + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") 63 + process.arguments = arguments 64 + var environment = ProcessInfo.processInfo.environment 65 + environment["GIT_CONFIG_GLOBAL"] = "/dev/null" 66 + process.environment = environment 67 + let pipe = Pipe() 68 + process.standardOutput = pipe 69 + process.standardError = pipe 70 + try process.run() 71 + process.waitUntilExit() 72 + let data = pipe.fileHandleForReading.readDataToEndOfFile() 73 + let output = String(data: data, encoding: .utf8) ?? "" 74 + if process.terminationStatus != 0 { 75 + throw GitCommandError(output: output) 76 + } 77 + return output 78 + }
+142 -4
supacodeTests/RepositoriesFeatureTests.swift
··· 3104 3104 await store.finish() 3105 3105 } 3106 3106 3107 + @Test func pullRequestActionOpenOnCodeHostOpensPullRequestURLWhenAvailable() async { 3108 + let repoRoot = "/tmp/repo" 3109 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3110 + let featureWorktree = makeWorktree( 3111 + id: "\(repoRoot)/feature", 3112 + name: "feature", 3113 + repoRoot: repoRoot 3114 + ) 3115 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3116 + let pullRequest = makePullRequest( 3117 + state: "OPEN", 3118 + headRefName: featureWorktree.name, 3119 + number: 12, 3120 + url: "https://github.com/octo/repo/pull/12" 3121 + ) 3122 + var state = makeState(repositories: [repository]) 3123 + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 3124 + addedLines: nil, 3125 + removedLines: nil, 3126 + pullRequest: pullRequest 3127 + ) 3128 + let openedURLs = LockIsolated<[URL]>([]) 3129 + let store = TestStore(initialState: state) { 3130 + RepositoriesFeature() 3131 + } withDependencies: { 3132 + $0.gitClient.repositoryWebURL = { _ in 3133 + Issue.record("repositoryWebURL should not be requested when a pull request URL exists") 3134 + return nil 3135 + } 3136 + $0.openURLClient.open = { url in 3137 + openedURLs.withValue { $0.append(url) } 3138 + } 3139 + } 3140 + 3141 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) 3142 + await store.finish() 3143 + 3144 + #expect(openedURLs.value == [URL(string: "https://github.com/octo/repo/pull/12")!]) 3145 + } 3146 + 3147 + @Test func pullRequestActionOpenOnCodeHostFallsBackToRepositoryURL() async { 3148 + let repoRoot = "/tmp/repo" 3149 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3150 + let featureWorktree = makeWorktree( 3151 + id: "\(repoRoot)/feature", 3152 + name: "feature", 3153 + repoRoot: repoRoot 3154 + ) 3155 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3156 + let repositoryURL = URL(string: "https://gitlab.com/group/subgroup/repo")! 3157 + let openedURLs = LockIsolated<[URL]>([]) 3158 + let store = TestStore(initialState: makeState(repositories: [repository])) { 3159 + RepositoriesFeature() 3160 + } withDependencies: { 3161 + $0.gitClient.repositoryWebURL = { _ in 3162 + repositoryURL 3163 + } 3164 + $0.openURLClient.open = { url in 3165 + openedURLs.withValue { $0.append(url) } 3166 + } 3167 + } 3168 + 3169 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) 3170 + await store.finish() 3171 + 3172 + #expect(openedURLs.value == [repositoryURL]) 3173 + } 3174 + 3175 + @Test func pullRequestActionOpenOnCodeHostFallsBackWhenPullRequestURLIsInvalid() async { 3176 + let repoRoot = "/tmp/repo" 3177 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3178 + let featureWorktree = makeWorktree( 3179 + id: "\(repoRoot)/feature", 3180 + name: "feature", 3181 + repoRoot: repoRoot 3182 + ) 3183 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3184 + let pullRequest = makePullRequest( 3185 + state: "OPEN", 3186 + headRefName: featureWorktree.name, 3187 + number: 12, 3188 + url: "/octo/repo/pull/12" 3189 + ) 3190 + var state = makeState(repositories: [repository]) 3191 + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 3192 + addedLines: nil, 3193 + removedLines: nil, 3194 + pullRequest: pullRequest 3195 + ) 3196 + let repositoryURL = URL(string: "https://git.example.com/scm/repo")! 3197 + let openedURLs = LockIsolated<[URL]>([]) 3198 + let store = TestStore(initialState: state) { 3199 + RepositoriesFeature() 3200 + } withDependencies: { 3201 + $0.gitClient.repositoryWebURL = { _ in 3202 + repositoryURL 3203 + } 3204 + $0.openURLClient.open = { url in 3205 + openedURLs.withValue { $0.append(url) } 3206 + } 3207 + } 3208 + 3209 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) 3210 + await store.finish() 3211 + 3212 + #expect(openedURLs.value == [repositoryURL]) 3213 + } 3214 + 3215 + @Test func pullRequestActionOpenOnCodeHostShowsAlertWhenRepositoryURLUnavailable() async { 3216 + let repoRoot = "/tmp/repo" 3217 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3218 + let featureWorktree = makeWorktree( 3219 + id: "\(repoRoot)/feature", 3220 + name: "feature", 3221 + repoRoot: repoRoot 3222 + ) 3223 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3224 + let store = TestStore(initialState: makeState(repositories: [repository])) { 3225 + RepositoriesFeature() 3226 + } withDependencies: { 3227 + $0.gitClient.repositoryWebURL = { _ in nil } 3228 + } 3229 + 3230 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) 3231 + await store.receive(\.presentAlert) { 3232 + $0.alert = AlertState<RepositoriesFeature.Alert> { 3233 + TextState("Repository URL not available") 3234 + } actions: { 3235 + ButtonState(role: .cancel) { 3236 + TextState("OK") 3237 + } 3238 + } message: { 3239 + TextState("Prowl could not determine a code host URL for this repository.") 3240 + } 3241 + } 3242 + } 3243 + 3107 3244 @Test func worktreeInfoEventRepositoryPullRequestRefreshMarksInFlightThenCompletes() async { 3108 3245 let repoRoot = "/tmp/repo" 3109 3246 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 3493 3630 let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3494 3631 var state = makeState(repositories: [repository]) 3495 3632 state.archivedWorktrees = [ 3496 - ArchivedWorktree(id: worktree.id, archivedAt: .distantPast), 3633 + ArchivedWorktree(id: worktree.id, archivedAt: .distantPast) 3497 3634 ] 3498 3635 state.archivedAutoDeletePeriod = nil 3499 3636 let store = TestStore(initialState: state) { ··· 3513 3650 let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3514 3651 var state = makeState(repositories: [repository]) 3515 3652 state.archivedWorktrees = [ 3516 - ArchivedWorktree(id: mainWorktree.id, archivedAt: .distantPast), 3653 + ArchivedWorktree(id: mainWorktree.id, archivedAt: .distantPast) 3517 3654 ] 3518 3655 state.archivedAutoDeletePeriod = .oneDay 3519 3656 let store = TestStore(initialState: state) { ··· 3787 3924 private func makePullRequest( 3788 3925 state: String, 3789 3926 headRefName: String? = nil, 3790 - number: Int = 1 3927 + number: Int = 1, 3928 + url: String? = nil 3791 3929 ) -> GithubPullRequest { 3792 3930 GithubPullRequest( 3793 3931 number: number, ··· 3800 3938 mergeable: nil, 3801 3939 mergeStateStatus: nil, 3802 3940 updatedAt: nil, 3803 - url: "https://example.com/pull/\(number)", 3941 + url: url ?? "https://example.com/pull/\(number)", 3804 3942 headRefName: headRefName, 3805 3943 baseRefName: "main", 3806 3944 commitsCount: 1,