diff --git a/Makefile b/Makefile index 78c6c58be..2eac73df3 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,33 @@ VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= +# Toolchain pin. macOS 26.4+ SDKs dropped the arm64-macos slice from +# libSystem.tbd, which zig 0.15.2 (pinned by Ghostty) can't link against +# (ziglang/zig#31658, fixed in zig 0.16). Xcode 26.5's Swift 6.3 also fails to +# compile the pinned swift-composable-architecture. Xcode 26.3 satisfies both, +# so resolve a developer dir and export it for every recipe: `tuist generate` +# builds Ghostty, and `xcodebuild` builds zmx (a pre-script) plus the app. +# Override with SUPACODE_DEVELOPER_DIR=... (set it empty to use the system +# default); it auto-disables on machines/CI without Xcode 26.3 installed. +SUPACODE_DEVELOPER_DIR ?= /Applications/Xcode_26.3.app/Contents/Developer +ifneq ($(wildcard $(SUPACODE_DEVELOPER_DIR)),) +export DEVELOPER_DIR := $(SUPACODE_DEVELOPER_DIR) +endif + +# Pretty-print xcodebuild output with xcbeautify when it's available (via mise +# or on PATH), otherwise pass the raw log through `cat`. xcbeautify is not +# declared in mise.toml, so the build must not hard-depend on it. +ifneq ($(shell mise which xcbeautify 2>/dev/null),) +XCBEAUTIFY := mise exec -- xcbeautify --disable-logging +XCBEAUTIFY_QUIET := mise exec -- xcbeautify --quiet --disable-logging +else ifneq ($(shell command -v xcbeautify 2>/dev/null),) +XCBEAUTIFY := xcbeautify --disable-logging +XCBEAUTIFY_QUIET := xcbeautify --quiet --disable-logging +else +XCBEAUTIFY := cat +XCBEAUTIFY_QUIET := cat +endif + .DEFAULT_GOAL := help .PHONY: build-ghostty-xcframework build-zmx generate-project generate-project-sources inspect-dependencies warm-cache build-app run-app install-dev-build archive export-archive format lint check test bump-version bump-and-release log-stream @@ -82,7 +109,7 @@ warm-cache: $(TUIST_INSTALL_STAMP) # Warm the full Tuist cacheable graph mise exec -- tuist cache warm --configuration $(TUIST_CACHE_CONFIGURATION) build-app: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Build the macOS app (Debug) - bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug build -skipMacroValidation 2>&1 | mise exec -- xcbeautify --disable-logging' + bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug build -skipMacroValidation 2>&1 | $(XCBEAUTIFY)' run-app: build-app # Build then launch (Debug) with log streaming @settings="$$(xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ @@ -108,14 +135,14 @@ install-dev-build: build-app # install dev build to /Applications archive: $(TUIST_SOURCE_RELEASE_GENERATION_STAMP) # Archive Release build for distribution mkdir -p build - bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Release -destination "generic/platform=macOS" -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcbeautify --quiet --disable-logging' + bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Release -destination "generic/platform=macOS" -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | $(XCBEAUTIFY_QUIET)' export-archive: # Export xarchive - bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcbeautify --quiet --disable-logging' + bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | $(XCBEAUTIFY_QUIET)' test: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Run all tests @if [ -t 1 ]; then \ - bash -o pipefail -c 'xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO 2>&1 | mise exec -- xcbeautify --disable-logging'; \ + bash -o pipefail -c 'xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO 2>&1 | $(XCBEAUTIFY)'; \ else \ xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO; \ fi diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 2dcebacd0..3b00c5b4b 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -53,6 +53,7 @@ public struct SettingsFeature { public var analyticsEnabled: Bool public var crashReportsEnabled: Bool public var githubIntegrationEnabled: Bool + public var gitlabIntegrationEnabled: Bool public var deleteBranchOnDeleteWorktree: Bool public var mergedWorktreeAction: MergedWorktreeAction? public var promptForWorktreeCreation: Bool @@ -95,6 +96,7 @@ public struct SettingsFeature { analyticsEnabled = settings.analyticsEnabled crashReportsEnabled = settings.crashReportsEnabled githubIntegrationEnabled = settings.githubIntegrationEnabled + gitlabIntegrationEnabled = settings.gitlabIntegrationEnabled deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree mergedWorktreeAction = settings.mergedWorktreeAction promptForWorktreeCreation = settings.promptForWorktreeCreation @@ -131,6 +133,7 @@ public struct SettingsFeature { analyticsEnabled: analyticsEnabled, crashReportsEnabled: crashReportsEnabled, githubIntegrationEnabled: githubIntegrationEnabled, + gitlabIntegrationEnabled: gitlabIntegrationEnabled, deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, mergedWorktreeAction: mergedWorktreeAction, promptForWorktreeCreation: promptForWorktreeCreation, @@ -271,6 +274,7 @@ public struct SettingsFeature { state.analyticsEnabled = normalizedSettings.analyticsEnabled state.crashReportsEnabled = normalizedSettings.crashReportsEnabled state.githubIntegrationEnabled = normalizedSettings.githubIntegrationEnabled + state.gitlabIntegrationEnabled = normalizedSettings.gitlabIntegrationEnabled state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree state.mergedWorktreeAction = normalizedSettings.mergedWorktreeAction state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation diff --git a/SupacodeSettingsFeature/Views/SettingsSection.swift b/SupacodeSettingsFeature/Views/SettingsSection.swift index f1019f171..ff4b111d0 100644 --- a/SupacodeSettingsFeature/Views/SettingsSection.swift +++ b/SupacodeSettingsFeature/Views/SettingsSection.swift @@ -7,7 +7,7 @@ public enum SettingsSection: Hashable { case developer case shortcuts case updates - case github + case forges case scripts case repository(String) case repositoryScripts(String) diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index f2ad322e2..adb7a89f2 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -39,6 +39,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var analyticsEnabled: Bool public var crashReportsEnabled: Bool public var githubIntegrationEnabled: Bool + public var gitlabIntegrationEnabled: Bool public var deleteBranchOnDeleteWorktree: Bool public var mergedWorktreeAction: MergedWorktreeAction? public var promptForWorktreeCreation: Bool @@ -80,6 +81,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { analyticsEnabled: true, crashReportsEnabled: true, githubIntegrationEnabled: true, + gitlabIntegrationEnabled: true, deleteBranchOnDeleteWorktree: true, mergedWorktreeAction: nil, promptForWorktreeCreation: true, @@ -114,6 +116,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { analyticsEnabled: Bool, crashReportsEnabled: Bool, githubIntegrationEnabled: Bool, + gitlabIntegrationEnabled: Bool = true, deleteBranchOnDeleteWorktree: Bool, mergedWorktreeAction: MergedWorktreeAction? = nil, promptForWorktreeCreation: Bool, @@ -146,6 +149,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.analyticsEnabled = analyticsEnabled self.crashReportsEnabled = crashReportsEnabled self.githubIntegrationEnabled = githubIntegrationEnabled + self.gitlabIntegrationEnabled = gitlabIntegrationEnabled self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree self.mergedWorktreeAction = mergedWorktreeAction self.promptForWorktreeCreation = promptForWorktreeCreation @@ -210,6 +214,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { githubIntegrationEnabled = try container.decodeIfPresent(Bool.self, forKey: .githubIntegrationEnabled) ?? Self.default.githubIntegrationEnabled + gitlabIntegrationEnabled = + try container.decodeIfPresent(Bool.self, forKey: .gitlabIntegrationEnabled) + ?? Self.default.gitlabIntegrationEnabled deleteBranchOnDeleteWorktree = try container.decodeIfPresent(Bool.self, forKey: .deleteBranchOnDeleteWorktree) ?? Self.default.deleteBranchOnDeleteWorktree diff --git a/scripts/build-ghostty.sh b/scripts/build-ghostty.sh index d667ad476..c4a13822b 100755 --- a/scripts/build-ghostty.sh +++ b/scripts/build-ghostty.sh @@ -78,6 +78,20 @@ if [ -f "${ghostty_fingerprint_path}" ] && fi cd "${ghostty_dir}" + +# macOS 26.4+ SDKs dropped plain `arm64-macos` from libSystem.tbd, leaving only +# `arm64e-macos`. Zig 0.15.2's Mach-O linker won't match the two, so every +# libSystem symbol comes up undefined when it compiles its own build runner +# (ziglang/zig#31658, fixed in 0.16 but Ghostty pins minimum 0.15.2). Build +# against an Xcode whose SDK still advertises arm64-macos. The dir must be a +# *full* Xcode (Command Line Tools lack the iOS SDK and the `metal` compiler the +# xcframework build needs). Respect a DEVELOPER_DIR the caller already chose +# (the Makefile exports one); only fall back to Xcode 26.3 when nothing is set, +# e.g. when this script is run directly. No-op when 26.3 is absent. +if [ -z "${DEVELOPER_DIR:-}" ] && [ -d "/Applications/Xcode_26.3.app/Contents/Developer" ]; then + export DEVELOPER_DIR="/Applications/Xcode_26.3.app/Contents/Developer" +fi + mise exec -- zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false --prefix "${ghostty_build_root}" --cache-dir "${ghostty_local_cache_dir}" --global-cache-dir "${ghostty_global_cache_dir}" rsync -a --delete "${ghostty_dir}/macos/GhosttyKit.xcframework/" "${xcframework_path}/" prepare_xcframework diff --git a/scripts/build-zmx.sh b/scripts/build-zmx.sh index 98fe52bb4..393d6f176 100755 --- a/scripts/build-zmx.sh +++ b/scripts/build-zmx.sh @@ -66,6 +66,15 @@ fi cd "${zmx_dir}" +# zig compiles its build runner for the native target, which won't link against +# macOS 26.4+ SDKs (arm64e-only libSystem.tbd; ziglang/zig#31658). Respect a +# DEVELOPER_DIR the caller already chose (the Makefile exports one); only fall +# back to Xcode 26.3 — whose SDK still has arm64-macos — when nothing is set, +# e.g. when this script is run directly. No-op when 26.3 is absent. +if [ -z "${DEVELOPER_DIR:-}" ] && [ -d "/Applications/Xcode_26.3.app/Contents/Developer" ]; then + export DEVELOPER_DIR="/Applications/Xcode_26.3.app/Contents/Developer" +fi + slice_paths=() for target in "${zmx_targets[@]}"; do slice_prefix="${zmx_build_root}/slices/${target}" diff --git a/supacode/Clients/Forge/Forge.swift b/supacode/Clients/Forge/Forge.swift new file mode 100644 index 000000000..d1db839a4 --- /dev/null +++ b/supacode/Clients/Forge/Forge.swift @@ -0,0 +1,17 @@ +import Foundation + +nonisolated enum Forge: String, Equatable, Hashable, Sendable, Codable, CaseIterable { + case github + case gitlab + + static func detect(host: String) -> Forge? { + let normalized = host.lowercased() + if normalized.contains("github") { + return .github + } + if normalized.contains("gitlab") { + return .gitlab + } + return nil + } +} diff --git a/supacode/Clients/Forge/ForgePullRequest.swift b/supacode/Clients/Forge/ForgePullRequest.swift new file mode 100644 index 000000000..62e67046c --- /dev/null +++ b/supacode/Clients/Forge/ForgePullRequest.swift @@ -0,0 +1,130 @@ +import Foundation + +// Forge-tagged pull/merge request. Views and reducers that don't care which forge the request came +// from read the shared projection (number, title, isDraft, ...). Views that need forge-specific +// fields pull out the underlying case via `.github` / `.gitlab`. +nonisolated enum ForgePullRequest: Equatable, Hashable, Sendable { + case github(GithubPullRequest) + case gitlab(GitLabMergeRequest) +} + +extension ForgePullRequest { + var forge: Forge { + switch self { + case .github: return .github + case .gitlab: return .gitlab + } + } + + // GitHub: PR number. GitLab: MR iid. + var number: Int { + switch self { + case .github(let pr): return pr.number + case .gitlab(let mr): return mr.iid + } + } + + var title: String { + switch self { + case .github(let pr): return pr.title + case .gitlab(let mr): return mr.title + } + } + + var url: String { + switch self { + case .github(let pr): return pr.url + case .gitlab(let mr): return mr.url + } + } + + var isDraft: Bool { + switch self { + case .github(let pr): return pr.isDraft + case .gitlab(let mr): return mr.isDraft + } + } + + var additions: Int { + switch self { + case .github(let pr): return pr.additions + case .gitlab(let mr): return mr.additions + } + } + + var deletions: Int { + switch self { + case .github(let pr): return pr.deletions + case .gitlab(let mr): return mr.deletions + } + } + + var updatedAt: Date? { + switch self { + case .github(let pr): return pr.updatedAt + case .gitlab(let mr): return mr.updatedAt + } + } + + var headRefName: String? { + switch self { + case .github(let pr): return pr.headRefName + case .gitlab(let mr): return mr.sourceBranch + } + } + + var baseRefName: String? { + switch self { + case .github(let pr): return pr.baseRefName + case .gitlab(let mr): return mr.targetBranch + } + } + + var authorLogin: String? { + switch self { + case .github(let pr): return pr.authorLogin + case .gitlab(let mr): return mr.authorUsername + } + } + + var isMerged: Bool { + switch self { + case .github(let pr): return pr.state == "MERGED" + case .gitlab(let mr): return mr.state == .merged + } + } + + var isOpen: Bool { + switch self { + case .github(let pr): return pr.state == "OPEN" + case .gitlab(let mr): return mr.state == .opened + } + } + + // Uppercase state string compatible with the existing `PullRequestBadgeStyle.style(state:...)` + // vocabulary ("OPEN" / "MERGED" / "CLOSED"). GitLab states are mapped to the nearest GitHub + // equivalent so the badge color picker keeps working without per-forge branching. + var displayStateBadge: String? { + switch self { + case .github(let pr): + return pr.state.uppercased() + case .gitlab(let mr): + switch mr.state { + case .opened: return "OPEN" + case .merged: return "MERGED" + case .closed, .locked: return "CLOSED" + case .unknown: return nil + } + } + } + + var github: GithubPullRequest? { + if case .github(let pr) = self { return pr } + return nil + } + + var gitlab: GitLabMergeRequest? { + if case .gitlab(let mr) = self { return mr } + return nil + } +} diff --git a/supacode/Clients/Github/GithubRemoteInfo.swift b/supacode/Clients/Forge/ForgeRemoteInfo.swift similarity index 54% rename from supacode/Clients/Github/GithubRemoteInfo.swift rename to supacode/Clients/Forge/ForgeRemoteInfo.swift index 5d855049a..985ca837d 100644 --- a/supacode/Clients/Github/GithubRemoteInfo.swift +++ b/supacode/Clients/Forge/ForgeRemoteInfo.swift @@ -1,6 +1,7 @@ import Foundation -struct GithubRemoteInfo: Equatable, Sendable { +struct ForgeRemoteInfo: Equatable, Sendable { + let forge: Forge let host: String let owner: String let repo: String diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index c8a4aadf4..0e584350e 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -652,7 +652,7 @@ struct GitClient { return FileManager.default.fileExists(atPath: lockURL.path(percentEncoded: false)) } - nonisolated func remoteInfo(for repositoryRoot: URL) async -> GithubRemoteInfo? { + nonisolated func remoteInfo(for repositoryRoot: URL) async -> ForgeRemoteInfo? { let path = repositoryRoot.path(percentEncoded: false) guard let remotesOutput = try? await runGit( @@ -682,7 +682,7 @@ struct GitClient { else { continue } - if let info = Self.parseGithubRemoteInfo(remoteURL) { + if let info = Self.parseRemoteInfo(remoteURL) { return info } } @@ -937,7 +937,7 @@ struct GitClient { return nil } - nonisolated static func parseGithubRemoteInfo(_ remoteURL: String) -> GithubRemoteInfo? { + nonisolated static func parseRemoteInfo(_ remoteURL: String) -> ForgeRemoteInfo? { let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil @@ -952,33 +952,33 @@ struct GitClient { guard hostParts.count == 2 else { return nil } - return parseGithubRemoteInfo(host: String(hostParts[0]), path: String(hostParts[1])) + return parseRemoteInfo(host: String(hostParts[0]), path: String(hostParts[1])) } guard let url = URL(string: trimmed), let host = url.host else { return nil } let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return parseGithubRemoteInfo(host: host, path: path) + return parseRemoteInfo(host: host, path: path) } - nonisolated private static func parseGithubRemoteInfo(host: String, path: String) -> GithubRemoteInfo? { - let normalizedHost = host.lowercased() - guard normalizedHost.contains("github") else { + nonisolated private static func parseRemoteInfo(host: String, path: String) -> ForgeRemoteInfo? { + guard let forge = Forge.detect(host: host) else { return nil } let components = path.split(separator: "/", omittingEmptySubsequences: true) guard components.count >= 2 else { return nil } - let owner = String(components[0]) - var repo = String(components[1]) - if repo.hasSuffix(".git") { - repo = String(repo.dropLast(4)) + var repoSegment = String(components.last!) + if repoSegment.hasSuffix(".git") { + repoSegment = String(repoSegment.dropLast(4)) } - guard !owner.isEmpty, !repo.isEmpty else { + let ownerSegments = components.dropLast().map(String.init) + let owner = ownerSegments.joined(separator: "/") + guard !owner.isEmpty, !repoSegment.isEmpty else { return nil } - return GithubRemoteInfo(host: host, owner: owner, repo: repo) + return ForgeRemoteInfo(forge: forge, host: host, owner: owner, repo: repoSegment) } } diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index b32878b92..7b9578dbe 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -44,11 +44,11 @@ extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? - var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? + var resolveRemoteInfo: @Sendable (URL) async -> ForgeRemoteInfo? var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] - var mergePullRequest: @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void - var closePullRequest: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void - var markPullRequestReady: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void + var mergePullRequest: @Sendable (URL, ForgeRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void + var closePullRequest: @Sendable (URL, ForgeRemoteInfo?, Int) async throws -> Void + var markPullRequestReady: @Sendable (URL, ForgeRemoteInfo?, Int) async throws -> Void var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void var failedRunLogs: @Sendable (URL, Int) async throws -> String var runLogs: @Sendable (URL, Int) async throws -> String @@ -245,7 +245,7 @@ nonisolated private struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable nonisolated private func resolveRemoteInfoFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL) async -> GithubRemoteInfo? { +) -> @Sendable (URL) async -> ForgeRemoteInfo? { { repoRoot in do { let output = try await runGh( @@ -266,7 +266,8 @@ nonisolated private func resolveRemoteInfoFetcher( guard !response.owner.login.isEmpty, !response.name.isEmpty else { return nil } - return GithubRemoteInfo( + return ForgeRemoteInfo( + forge: .github, host: host, owner: response.owner.login, repo: response.name @@ -288,7 +289,7 @@ nonisolated private func hostFromRepoViewURL(_ urlString: String?) -> String? { return host } -nonisolated private func repoSlug(for remote: GithubRemoteInfo) -> String { +nonisolated private func repoSlug(for remote: ForgeRemoteInfo) -> String { "\(remote.host)/\(remote.owner)/\(remote.repo)" } @@ -322,7 +323,7 @@ nonisolated private func batchPullRequestsFetcher( nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void { +) -> @Sendable (URL, ForgeRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void { { repoRoot, remote, pullRequestNumber, strategy in var arguments: [String] = ["pr", "merge", "\(pullRequestNumber)", "--\(strategy.ghArgument)"] if let remote { @@ -340,7 +341,7 @@ nonisolated private func mergePullRequestFetcher( nonisolated private func closePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { +) -> @Sendable (URL, ForgeRemoteInfo?, Int) async throws -> Void { { repoRoot, remote, pullRequestNumber in var arguments: [String] = ["pr", "close", "\(pullRequestNumber)"] if let remote { @@ -358,7 +359,7 @@ nonisolated private func closePullRequestFetcher( nonisolated private func markPullRequestReadyFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { +) -> @Sendable (URL, ForgeRemoteInfo?, Int) async throws -> Void { { repoRoot, remote, pullRequestNumber in var arguments: [String] = ["pr", "ready", "\(pullRequestNumber)"] if let remote { diff --git a/supacode/Clients/Gitlab/GitLabAuthStatus.swift b/supacode/Clients/Gitlab/GitLabAuthStatus.swift new file mode 100644 index 000000000..067911a49 --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabAuthStatus.swift @@ -0,0 +1,6 @@ +import Foundation + +struct GitLabAuthStatus: Equatable, Sendable { + let username: String + let host: String +} diff --git a/supacode/Clients/Gitlab/GitLabCLIClient.swift b/supacode/Clients/Gitlab/GitLabCLIClient.swift new file mode 100644 index 000000000..00462b458 --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabCLIClient.swift @@ -0,0 +1,363 @@ +import ComposableArchitecture +import Darwin +import Foundation +import SupacodeSettingsShared + +struct GitLabCLIClient: Sendable { + var resolveRemoteInfo: @Sendable (URL) async -> ForgeRemoteInfo? + var batchMergeRequests: @Sendable (String, String, String, [String]) async throws -> [String: GitLabMergeRequest] + var isAvailable: @Sendable () async -> Bool + var authStatus: @Sendable () async throws -> GitLabAuthStatus? +} + +extension GitLabCLIClient: DependencyKey { + static let liveValue = live() + + static func live(shell: ShellClient = .liveValue) -> GitLabCLIClient { + let resolver = GitLabCLIExecutableResolver() + return GitLabCLIClient( + resolveRemoteInfo: resolveRemoteInfoFetcher(shell: shell, resolver: resolver), + batchMergeRequests: batchMergeRequestsFetcher(shell: shell, resolver: resolver), + isAvailable: isAvailableFetcher(shell: shell, resolver: resolver), + authStatus: authStatusFetcher(shell: shell, resolver: resolver) + ) + } + + static let testValue = GitLabCLIClient( + resolveRemoteInfo: { _ in nil }, + batchMergeRequests: { _, _, _, _ in [:] }, + isAvailable: { true }, + authStatus: { GitLabAuthStatus(username: "testuser", host: "gitlab.com") } + ) +} + +extension DependencyValues { + var gitlabCLI: GitLabCLIClient { + get { self[GitLabCLIClient.self] } + set { self[GitLabCLIClient.self] = newValue } + } +} + +private actor GitLabCLIExecutableResolver { + private var cachedExecutableURL: URL? + private var inFlightResolution: Task? + + func executableURL(shell: ShellClient) async throws -> URL { + if let cachedExecutableURL { + return cachedExecutableURL + } + if let inFlightResolution { + return try await inFlightResolution.value + } + let resolutionTask = Task { + try await resolveExecutableURL(shell: shell) + } + inFlightResolution = resolutionTask + do { + let executableURL = try await resolutionTask.value + cachedExecutableURL = executableURL + inFlightResolution = nil + return executableURL + } catch { + inFlightResolution = nil + throw error + } + } + + func invalidate() { + cachedExecutableURL = nil + inFlightResolution?.cancel() + inFlightResolution = nil + } + + private func resolveExecutableURL(shell: ShellClient) async throws -> URL { + if let executableURL = await locateExecutableURL(shell: shell, useLoginShell: false) { + return executableURL + } + if let executableURL = await locateExecutableURL(shell: shell, useLoginShell: true) { + return executableURL + } + throw GitLabCLIError.unavailable + } + + private func locateExecutableURL(shell: ShellClient, useLoginShell: Bool) async -> URL? { + let whichURL = URL(fileURLWithPath: "/usr/bin/which") + do { + let output: String + if useLoginShell { + output = try await shell.runLogin(whichURL, ["glab"], nil, log: false).stdout + } else { + output = try await shell.run(whichURL, ["glab"], nil).stdout + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return URL(fileURLWithPath: trimmed) + } catch { + return nil + } + } +} + +// `glab repo view --output json` returns name + namespace + web_url. +private nonisolated struct GitLabRepoViewResponse: Decodable, Sendable { + let name: String? + let path: String? + let pathWithNamespace: String? + let webUrl: String? + + private enum CodingKeys: String, CodingKey { + case name + case path + case pathWithNamespace = "path_with_namespace" + case webUrl = "web_url" + } +} + +nonisolated private func resolveRemoteInfoFetcher( + shell: ShellClient, + resolver: GitLabCLIExecutableResolver +) -> @Sendable (URL) async -> ForgeRemoteInfo? { + { repoRoot in + do { + let output = try await runGlab( + shell: shell, + resolver: resolver, + arguments: ["repo", "view", "--output", "json"], + repoRoot: repoRoot + ) + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + let response = try JSONDecoder().decode(GitLabRepoViewResponse.self, from: Data(trimmed.utf8)) + guard let pathWithNamespace = response.pathWithNamespace, !pathWithNamespace.isEmpty else { + return nil + } + let host = hostFromWebURL(response.webUrl) ?? "gitlab.com" + let segments = pathWithNamespace.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard let repo = segments.last, segments.count >= 2 else { + return nil + } + let owner = segments.dropLast().joined(separator: "/") + return ForgeRemoteInfo(forge: .gitlab, host: host, owner: owner, repo: repo) + } catch { + return nil + } + } +} + +nonisolated private func hostFromWebURL(_ urlString: String?) -> String? { + guard let urlString, !urlString.isEmpty, + let url = URL(string: urlString), + let host = url.host, + !host.isEmpty + else { + return nil + } + return host +} + +nonisolated private let batchMergeRequestsPageSize = 50 + +nonisolated private func batchMergeRequestsFetcher( + shell: ShellClient, + resolver: GitLabCLIExecutableResolver +) -> @Sendable (String, String, String, [String]) async throws -> [String: GitLabMergeRequest] { + { host, owner, repo, branches in + let dedupedBranches = deduplicatedBranches(branches) + guard !dedupedBranches.isEmpty else { + return [:] + } + let fullPath = "\(owner)/\(repo)" + let (query, branchListLiteral) = makeBatchMergeRequestsQuery(branches: dedupedBranches) + let arguments = [ + "api", + "graphql", + "--hostname", + host, + "-f", + "query=\(query)", + "-f", + "fullPath=\(fullPath)", + "-f", + "sourceBranches=\(branchListLiteral)", + ] + let output = try await runGlab(shell: shell, resolver: resolver, arguments: arguments, repoRoot: nil) + guard !output.isEmpty else { + return [:] + } + let data = Data(output.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let response = try decoder.decode(GitLabGraphQLMergeRequestResponse.self, from: data) + return response.mergeRequestsBySourceBranch() + } +} + +// GitLab GraphQL: `Project.mergeRequests(sourceBranches: [String])` returns MRs grouped under the +// project. v1 fetches open MRs only — merged-state display is a v2 nicety. +nonisolated private func makeBatchMergeRequestsQuery( + branches: [String] +) -> (query: String, branchListLiteral: String) { + // GitLab `--field` doesn't natively encode arrays. The query takes `[String!]!` and we pass it as a + // literal JSON array string via `-f` (string), which glab will pass through as-is when the param + // type matches. Simpler than maintaining variable-by-variable encoding. + let escaped = branches.map { "\"\(escapeGraphQLString($0))\"" }.joined(separator: ",") + let branchListLiteral = "[\(escaped)]" + let query = """ + query($fullPath: ID!) { + project(fullPath: $fullPath) { + mergeRequests(sourceBranches: \(branchListLiteral), state: opened, sort: UPDATED_DESC, first: \(batchMergeRequestsPageSize)) { + nodes { + iid + title + state + draft + webUrl + updatedAt + sourceBranch + targetBranch + diffStatsSummary { + additions + deletions + } + author { + username + } + headPipeline { + status + } + } + } + } + } + """ + return (query, branchListLiteral) +} + +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 deduplicatedBranches(_ branches: [String]) -> [String] { + var seen = Set() + return branches.filter { !$0.isEmpty && seen.insert($0).inserted } +} + +nonisolated private func isAvailableFetcher( + shell: ShellClient, + resolver: GitLabCLIExecutableResolver +) -> @Sendable () async -> Bool { + { + do { + _ = try await runGlab( + shell: shell, + resolver: resolver, + arguments: ["--version"], + repoRoot: nil + ) + return true + } catch { + return false + } + } +} + +nonisolated private func authStatusFetcher( + shell: ShellClient, + resolver: GitLabCLIExecutableResolver +) -> @Sendable () async throws -> GitLabAuthStatus? { + { + // `glab auth status` prints `✓ Logged in to gitlab.com as USERNAME (...)` to stderr in + // human-readable form. There's no `--json` flag as of glab 1.x, so parse the textual output. + let output: String + do { + output = try await runGlab( + shell: shell, + resolver: resolver, + arguments: ["auth", "status"], + repoRoot: nil, + captureStderr: true + ) + } catch GitLabCLIError.commandFailed(let message) { + // glab returns exit 1 when not authenticated, with the message in stderr. Fall through and + // try to parse — if nothing matches we return nil. + output = message + } + return parseGlabAuthStatus(output) + } +} + +// Matches `Logged in to as ` (with optional preceding glyph). +nonisolated func parseGlabAuthStatus(_ output: String) -> GitLabAuthStatus? { + let pattern = #/Logged in to\s+([^\s]+)\s+as\s+([^\s\(]+)/# + guard let match = output.firstMatch(of: pattern) else { + return nil + } + let host = String(match.1) + let username = String(match.2) + guard !host.isEmpty, !username.isEmpty else { + return nil + } + return GitLabAuthStatus(username: username, host: host) +} + +nonisolated private func runGlab( + shell: ShellClient, + resolver: GitLabCLIExecutableResolver, + arguments: [String], + repoRoot: URL?, + captureStderr: Bool = false +) async throws -> String { + let command = (["glab"] + arguments).joined(separator: " ") + do { + let executableURL = try await resolver.executableURL(shell: shell) + do { + let result = try await shell.runLogin(executableURL, arguments, repoRoot, log: false) + return captureStderr && result.stdout.isEmpty ? result.stderr : result.stdout + } catch { + guard shouldRetryGlabExecution(after: error) else { + throw error + } + await resolver.invalidate() + let executableURL = try await resolver.executableURL(shell: shell) + let result = try await shell.runLogin(executableURL, arguments, repoRoot, log: false) + return captureStderr && result.stdout.isEmpty ? result.stderr : result.stdout + } + } catch let error as GitLabCLIError { + throw error + } catch { + if let shellError = error as? ShellClientError { + let message = shellError.errorDescription ?? "Command failed: \(command)" + throw GitLabCLIError.commandFailed(message) + } + throw GitLabCLIError.commandFailed(error.localizedDescription) + } +} + +nonisolated private func shouldRetryGlabExecution(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 +} diff --git a/supacode/Clients/Gitlab/GitLabCLIError.swift b/supacode/Clients/Gitlab/GitLabCLIError.swift new file mode 100644 index 000000000..b1a23c3cf --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabCLIError.swift @@ -0,0 +1,6 @@ +import Foundation + +enum GitLabCLIError: Error, Equatable { + case unavailable + case commandFailed(String) +} diff --git a/supacode/Clients/Gitlab/GitLabGraphQLMergeRequestResponse.swift b/supacode/Clients/Gitlab/GitLabGraphQLMergeRequestResponse.swift new file mode 100644 index 000000000..c119b9727 --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabGraphQLMergeRequestResponse.swift @@ -0,0 +1,84 @@ +import Foundation + +nonisolated struct GitLabGraphQLMergeRequestResponse: Decodable, Sendable { + let data: DataPayload + + nonisolated struct DataPayload: Decodable, Sendable { + let project: ProjectPayload? + } + + nonisolated struct ProjectPayload: Decodable, Sendable { + let mergeRequests: MergeRequestsConnection + } + + nonisolated struct MergeRequestsConnection: Decodable, Sendable { + let nodes: [MergeRequestNode] + } + + nonisolated struct MergeRequestNode: Decodable, Sendable { + let iid: String + let title: String + let state: String + let draft: Bool? + let webUrl: String + let updatedAt: Date? + let sourceBranch: String? + let targetBranch: String? + let diffStatsSummary: DiffStatsSummary? + let author: Author? + let headPipeline: Pipeline? + } + + nonisolated struct DiffStatsSummary: Decodable, Sendable { + let additions: Int? + let deletions: Int? + } + + nonisolated struct Author: Decodable, Sendable { + let username: String? + } + + nonisolated struct Pipeline: Decodable, Sendable { + let status: String? + } + + // Group MRs by their source branch. If multiple MRs share a branch (rare but possible when a + // closed/locked MR coexists with an open one), the most recently updated one wins. Caller has + // already filtered to `state: opened` in v1, so ties are unlikely in practice. + func mergeRequestsBySourceBranch() -> [String: GitLabMergeRequest] { + guard let project = data.project else { + return [:] + } + var byBranch: [String: GitLabMergeRequest] = [:] + for node in project.mergeRequests.nodes { + guard let branch = node.sourceBranch, !branch.isEmpty, + let iid = Int(node.iid) + else { + continue + } + let mergeRequest = GitLabMergeRequest( + iid: iid, + title: node.title, + state: GitLabMergeRequestState(rawGraphQL: node.state), + additions: node.diffStatsSummary?.additions ?? 0, + deletions: node.diffStatsSummary?.deletions ?? 0, + isDraft: node.draft ?? false, + updatedAt: node.updatedAt, + url: node.webUrl, + sourceBranch: node.sourceBranch, + targetBranch: node.targetBranch, + authorUsername: node.author?.username, + pipelineStatus: node.headPipeline?.status.map(GitLabPipelineStatus.init(rawGraphQL:)) + ) + if let existing = byBranch[branch], + let existingUpdated = existing.updatedAt, + let nodeUpdated = node.updatedAt, + existingUpdated > nodeUpdated + { + continue + } + byBranch[branch] = mergeRequest + } + return byBranch + } +} diff --git a/supacode/Clients/Gitlab/GitLabIntegrationClient.swift b/supacode/Clients/Gitlab/GitLabIntegrationClient.swift new file mode 100644 index 000000000..b3b82a9db --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabIntegrationClient.swift @@ -0,0 +1,81 @@ +import ComposableArchitecture +import SupacodeSettingsShared + +struct GitLabIntegrationClient: Sendable { + var isAvailable: @MainActor @Sendable () async -> Bool +} + +private actor GitLabIntegrationAvailabilityCache { + private struct Entry { + let value: Bool + let fetchedAt: ContinuousClock.Instant + } + + private let ttl: Duration + private let clock = ContinuousClock() + private var cachedEntry: Entry? + private var inFlightTask: Task? + + init(ttl: Duration) { + self.ttl = ttl + } + + func value(orFetch fetch: @Sendable @escaping () async -> Bool) async -> Bool { + let now = clock.now + if let cachedEntry, + cachedEntry.fetchedAt.duration(to: now) < ttl + { + return cachedEntry.value + } + if let inFlightTask { + return await inFlightTask.value + } + let task = Task { await fetch() } + inFlightTask = task + let value = await task.value + cachedEntry = Entry(value: value, fetchedAt: clock.now) + inFlightTask = nil + return value + } + + func clear() { + inFlightTask?.cancel() + inFlightTask = nil + cachedEntry = nil + } +} + +private let gitlabIntegrationAvailabilityCache = GitLabIntegrationAvailabilityCache( + ttl: .seconds(30) +) + +extension GitLabIntegrationClient: DependencyKey { + static let liveValue = GitLabIntegrationClient( + isAvailable: { + await gitlabIntegrationIsAvailable() + } + ) + static let testValue = GitLabIntegrationClient( + isAvailable: { true } + ) +} + +extension DependencyValues { + var gitlabIntegration: GitLabIntegrationClient { + get { self[GitLabIntegrationClient.self] } + set { self[GitLabIntegrationClient.self] = newValue } + } +} + +@MainActor +private func gitlabIntegrationIsAvailable() async -> Bool { + @Shared(.settingsFile) var settingsFile + @Dependency(GitLabCLIClient.self) var gitlabCLI + guard settingsFile.global.gitlabIntegrationEnabled else { + await gitlabIntegrationAvailabilityCache.clear() + return false + } + return await gitlabIntegrationAvailabilityCache.value { + await gitlabCLI.isAvailable() + } +} diff --git a/supacode/Clients/Gitlab/GitLabMergeRequest.swift b/supacode/Clients/Gitlab/GitLabMergeRequest.swift new file mode 100644 index 000000000..95910aaff --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabMergeRequest.swift @@ -0,0 +1,34 @@ +import Foundation + +nonisolated struct GitLabMergeRequest: Equatable, Hashable, Sendable { + let iid: Int + let title: String + let state: GitLabMergeRequestState + let additions: Int + let deletions: Int + let isDraft: Bool + let updatedAt: Date? + let url: String + let sourceBranch: String? + let targetBranch: String? + let authorUsername: String? + let pipelineStatus: GitLabPipelineStatus? +} + +nonisolated enum GitLabMergeRequestState: String, Equatable, Hashable, Sendable, Codable { + case opened + case closed + case merged + case locked + case unknown + + init(rawGraphQL: String) { + switch rawGraphQL.lowercased() { + case "opened": self = .opened + case "closed": self = .closed + case "merged": self = .merged + case "locked": self = .locked + default: self = .unknown + } + } +} diff --git a/supacode/Clients/Gitlab/GitLabPipelineStatus.swift b/supacode/Clients/Gitlab/GitLabPipelineStatus.swift new file mode 100644 index 000000000..d2aaca200 --- /dev/null +++ b/supacode/Clients/Gitlab/GitLabPipelineStatus.swift @@ -0,0 +1,46 @@ +import Foundation + +// GitLab pipeline statuses returned by `Pipeline.status` (GraphQL `PipelineStatusEnum`). +// Mapped to a common visual vocabulary for the check ring UI. +nonisolated enum GitLabPipelineStatus: String, Equatable, Hashable, Sendable, Codable { + case created + case waitingForResource = "waiting_for_resource" + case preparing + case pending + case running + case success + case failed + case canceled + case skipped + case manual + case scheduled + case unknown + + init(rawGraphQL: String) { + let normalized = rawGraphQL.lowercased() + switch normalized { + case "created": self = .created + case "waiting_for_resource": self = .waitingForResource + case "preparing": self = .preparing + case "pending": self = .pending + case "running": self = .running + case "success": self = .success + case "failed": self = .failed + case "canceled", "cancelled": self = .canceled + case "skipped": self = .skipped + case "manual": self = .manual + case "scheduled": self = .scheduled + default: self = .unknown + } + } + + var isInProgress: Bool { + switch self { + case .running, .pending, .preparing, .waitingForResource, .created, .scheduled: return true + case .success, .failed, .canceled, .skipped, .manual, .unknown: return false + } + } + + var isSuccess: Bool { self == .success } + var isFailure: Bool { self == .failed } +} diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index 3b7cec048..e8ee47914 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -50,7 +50,7 @@ struct GitClientDependency: Sendable { var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? var remoteNames: @Sendable (_ repoRoot: URL) async throws -> [String] var fetchRemote: @Sendable (_ remote: String, _ repoRoot: URL) async throws -> Void - var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> GithubRemoteInfo? + var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> ForgeRemoteInfo? } extension GitClientDependency: DependencyKey { diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 71ed2a681..40e04eb34 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -85,7 +85,10 @@ private struct WorktreeMainMenu: Commands { } .appKeyboardShortcut(openPR) .help("Open Pull Request (\(openPR?.display ?? "none"))") - .disabled(snapshot.selectedPullRequestURL == nil || !snapshot.githubIntegrationEnabled) + .disabled( + snapshot.selectedPullRequestURL == nil + || (!snapshot.githubIntegrationEnabled && !snapshot.gitlabIntegrationEnabled) + ) Divider() Button("Refresh Worktrees", systemImage: "arrow.clockwise") { store.send(.repositories(.refreshWorktrees)) diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 70c024c0c..d2339b887 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -12,6 +12,7 @@ import SupacodeSettingsShared struct WorktreeMenuSnapshot: Equatable { var shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] var githubIntegrationEnabled: Bool = true + var gitlabIntegrationEnabled: Bool = true var canCreateWorktree: Bool = false var canNavigateBackward: Bool = false var canNavigateForward: Bool = false @@ -30,6 +31,7 @@ extension AppFeature.State { return WorktreeMenuSnapshot( shortcutOverrides: settings.shortcutOverrides, githubIntegrationEnabled: settings.githubIntegrationEnabled, + gitlabIntegrationEnabled: settings.gitlabIntegrationEnabled, canCreateWorktree: repositories.canCreateWorktree, canNavigateBackward: repositories.canNavigateWorktreeHistoryBackward, canNavigateForward: repositories.canNavigateWorktreeHistoryForward, @@ -56,6 +58,9 @@ extension AppFeature.State { if old.githubIntegrationEnabled != new.githubIntegrationEnabled { diffs.append("githubIntegrationEnabled") } + if old.gitlabIntegrationEnabled != new.gitlabIntegrationEnabled { + diffs.append("gitlabIntegrationEnabled") + } if old.canCreateWorktree != new.canCreateWorktree { diffs.append("canCreateWorktree") } if old.canNavigateBackward != new.canNavigateBackward { diffs.append("canNavigateBackward") diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 858ffb324..220f7da1c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -434,8 +434,11 @@ struct AppFeature { await terminalClient.send(.refreshTabBarVisibility) }, .run { _ in + // Enable watcher when either forge integration is active; the per-repo dispatcher + // routes by remote-info forge, so it's safe to keep watching even if one forge is off. + let trackingEnabled = settings.githubIntegrationEnabled || settings.gitlabIntegrationEnabled await worktreeInfoWatcher.send( - .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) + .setPullRequestTrackingEnabled(trackingEnabled) ) }, .run { send in @@ -2082,7 +2085,7 @@ struct AppFeature { case .shortcuts: .shortcuts case .scripts: .scripts case .updates: .updates - case .github: .github + case .github: .forges } return .send(.settings(.setSelection(settingsSection))) } diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index f4cf29a93..fd1c82d4b 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -214,9 +214,12 @@ struct CommandPaletteFeature { items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) items.append(contentsOf: scriptItems(scripts: scripts, runningScriptIDs: runningScriptIDs)) } + // v1 GitLab: merge/close/ready/checks command-palette actions are GitHub-only. GitLab MRs surface + // in the sidebar and toolbar; the mutation surface lands in v2. if let selectedWorktreeID = repositories.selectedWorktreeID, let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), - let pullRequest = repositories.sidebarItems[id: selectedWorktreeID]?.pullRequest, + let forgePullRequest = repositories.sidebarItems[id: selectedWorktreeID]?.pullRequest, + let pullRequest = forgePullRequest.github, pullRequest.number > 0, pullRequest.state.uppercased() != "CLOSED" { diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index c73aa8551..2d32de8f2 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -369,7 +369,7 @@ extension RepositoriesFeature.Action { .archiveScriptCompleted, .deleteScriptCompleted, .scriptCompleted, .consumeSetupScript, .pinWorktree, .unpinWorktree, - .repositoryPullRequestsLoaded: + .repositoryPullRequestsLoaded, .repositoryMergeRequestsLoaded: return [.sidebarStructure, .selectedWorktreeSlice] // Selection changes only refresh the slice. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 9b9d02621..483ef189f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -32,7 +32,7 @@ private func resolveRemoteInfo( repositoryRootURL: URL, githubCLI: GithubCLIClient, gitClient: GitClientDependency -) async -> GithubRemoteInfo? { +) async -> ForgeRemoteInfo? { if let info = await githubCLI.resolveRemoteInfo(repositoryRootURL) { return info } @@ -383,6 +383,10 @@ struct RepositoriesFeature { repositoryID: Repository.ID, pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] ) + case repositoryMergeRequestsLoaded( + repositoryID: Repository.ID, + mergeRequestsByWorktreeID: [Worktree.ID: GitLabMergeRequest?] + ) case setGithubIntegrationEnabled(Bool) case setMergedWorktreeAction(MergedWorktreeAction?) case setAutoDeleteArchivedWorktreesAfterDays(AutoDeletePeriod?) @@ -462,6 +466,8 @@ struct RepositoriesFeature { @Dependency(GitClientDependency.self) private var gitClient @Dependency(GithubCLIClient.self) private var githubCLI @Dependency(GithubIntegrationClient.self) private var githubIntegration + @Dependency(GitLabCLIClient.self) private var gitlabCLI + @Dependency(GitLabIntegrationClient.self) private var gitlabIntegration @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence @Dependency(ShellClient.self) private var shellClient @Dependency(\.date.now) private var now @@ -2798,8 +2804,17 @@ struct RepositoriesFeature { } state.githubIntegrationAvailability = .checking let githubIntegration = githubIntegration + let gitlabIntegration = gitlabIntegration return .run { send in - let isAvailable = await githubIntegration.isAvailable() + // Any-forge availability gate: the per-repo dispatcher selects the right CLI at fetch + // time, so as long as one forge integration is installed + enabled, the refresh + // scheduler should let work through. A repo on the absent forge fails fast inside the + // batch effect and dispatches `.repositoryPullRequestRefreshCompleted`. + async let github = githubIntegration.isAvailable() + async let gitlab = gitlabIntegration.isAvailable() + let githubAvailable = await github + let gitlabAvailable = await gitlab + let isAvailable = githubAvailable || gitlabAvailable await send(.githubIntegrationAvailabilityUpdated(isAvailable)) } .cancellable(id: CancelID.githubIntegrationAvailability, cancelInFlight: true) @@ -2898,14 +2913,15 @@ struct RepositoriesFeature { continue } let pullRequest = pullRequestsByWorktreeID[worktreeID] ?? nil + let forgePullRequest = pullRequest.map(ForgePullRequest.github) let previousPullRequest = state.sidebarItems[id: worktreeID]?.pullRequest - let previousMerged = previousPullRequest?.state == "MERGED" - let nextMerged = pullRequest?.state == "MERGED" + let previousMerged = previousPullRequest?.isMerged == true + let nextMerged = forgePullRequest?.isMerged == true // Dispatch unconditionally so an identical-PR result still clears the row's watermark. rowEffects.append( state.updateWorktreePullRequestEffect( worktreeID: worktreeID, - pullRequest: pullRequest, + pullRequest: forgePullRequest, branchAtQueryTime: branchSnapshot[worktreeID], ) ) @@ -2935,6 +2951,35 @@ struct RepositoriesFeature { } return .merge(effects) + case .repositoryMergeRequestsLoaded(let repositoryID, let mergeRequestsByWorktreeID): + // GitLab refresh result. Auto-archive on merged-state transitions is v2; this handler only + // updates per-row state via `pullRequestChanged`. Watermark clearing mirrors the GitHub + // path: dispatch unconditionally for every queried-but-missing worktree too. + guard let repository = state.repositories[id: repositoryID] else { + return .none + } + let branchSnapshot = state.inFlightPullRequestBranchSnapshotsByRepositoryID[repositoryID] ?? [:] + let dispatchIDs = Set(branchSnapshot.keys).union(mergeRequestsByWorktreeID.keys) + var rowEffects: [Effect] = [] + for worktreeID in dispatchIDs.sorted() { + guard repository.worktrees[id: worktreeID] != nil else { + continue + } + let mergeRequest = mergeRequestsByWorktreeID[worktreeID] ?? nil + let forgePullRequest = mergeRequest.map(ForgePullRequest.gitlab) + rowEffects.append( + state.updateWorktreePullRequestEffect( + worktreeID: worktreeID, + pullRequest: forgePullRequest, + branchAtQueryTime: branchSnapshot[worktreeID], + ) + ) + } + guard !rowEffects.isEmpty else { + return .none + } + return .merge(rowEffects) + case .pullRequestAction(let worktreeID, let action): guard let worktree = state.worktree(for: worktreeID), let repositoryID = state.repositoryID(containing: worktreeID), @@ -2955,7 +3000,9 @@ struct RepositoriesFeature { worktreeIDs: repository.worktrees.map(\.id) ) let branchName = pullRequest.headRefName ?? worktree.name - let failingCheckDetailsURL = (pullRequest.statusCheckRollup?.checks ?? []).first { + // Status-check rollup details are GitHub-only; GitLab pipeline failing-job extraction + // is deferred to v2 alongside the corresponding command-palette actions. + let failingCheckDetailsURL = (pullRequest.github?.statusCheckRollup?.checks ?? []).first { $0.checkState == .failure && $0.detailsUrl != nil }?.detailsUrl switch action { @@ -3519,6 +3566,7 @@ struct RepositoriesFeature { ) -> Effect { let gitClient = gitClient let githubCLI = githubCLI + let gitlabCLI = gitlabCLI return .run { send in guard let remoteInfo = await resolveRemoteInfo( @@ -3531,22 +3579,42 @@ struct RepositoriesFeature { return } do { - let prsByBranch = try await githubCLI.batchPullRequests( - remoteInfo.host, - remoteInfo.owner, - remoteInfo.repo, - branches - ) - var pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] - for worktree in worktrees { - pullRequestsByWorktreeID[worktree.id] = prsByBranch[worktree.name] - } - await send( - .repositoryPullRequestsLoaded( - repositoryID: repositoryID, - pullRequestsByWorktreeID: pullRequestsByWorktreeID + switch remoteInfo.forge { + case .github: + let prsByBranch = try await githubCLI.batchPullRequests( + remoteInfo.host, + remoteInfo.owner, + remoteInfo.repo, + branches ) - ) + var pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] + for worktree in worktrees { + pullRequestsByWorktreeID[worktree.id] = prsByBranch[worktree.name] + } + await send( + .repositoryPullRequestsLoaded( + repositoryID: repositoryID, + pullRequestsByWorktreeID: pullRequestsByWorktreeID + ) + ) + case .gitlab: + let mrsByBranch = try await gitlabCLI.batchMergeRequests( + remoteInfo.host, + remoteInfo.owner, + remoteInfo.repo, + branches + ) + var mergeRequestsByWorktreeID: [Worktree.ID: GitLabMergeRequest?] = [:] + for worktree in worktrees { + mergeRequestsByWorktreeID[worktree.id] = mrsByBranch[worktree.name] + } + await send( + .repositoryMergeRequestsLoaded( + repositoryID: repositoryID, + mergeRequestsByWorktreeID: mergeRequestsByWorktreeID + ) + ) + } } catch { await send(.repositoryPullRequestRefreshCompleted(repositoryID)) return @@ -4166,7 +4234,7 @@ extension RepositoriesFeature.State { } func isWorktreeMerged(_ worktree: Worktree) -> Bool { - sidebarItems[id: worktree.id]?.pullRequest?.state == "MERGED" + sidebarItems[id: worktree.id]?.pullRequest?.isMerged == true } func orderedPinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { @@ -4652,7 +4720,7 @@ extension RepositoriesFeature.State { /// The row's own equality guard short-circuits the PR-value mutation. func updateWorktreePullRequestEffect( worktreeID: Worktree.ID, - pullRequest: GithubPullRequest?, + pullRequest: ForgePullRequest?, branchAtQueryTime: String? = nil, ) -> Effect { guard let row = sidebarItems[id: worktreeID] else { return .none } diff --git a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift index 98428943c..3258de333 100644 --- a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift +++ b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift @@ -74,7 +74,7 @@ struct SidebarItemFeature { var addedLines: Int? var removedLines: Int? - var pullRequest: GithubPullRequest? + var pullRequest: ForgePullRequest? /// Branch name at PR-query start; on result land, mismatched results are dropped. /// Invariant: non-nil iff a PR query is in flight; cleared by reconcile on branch rename. var pullRequestBranchAtQueryTime: String? @@ -112,7 +112,7 @@ struct SidebarItemFeature { case lifecycleChanged(State.Lifecycle) case diffStatsChanged(added: Int?, removed: Int?) case pullRequestQueryStarted(branch: String) - case pullRequestChanged(GithubPullRequest?, branchAtQueryTime: String) + case pullRequestChanged(ForgePullRequest?, branchAtQueryTime: String) case runningScriptStarted(id: UUID, tint: RepositoryColor) case runningScriptStopped(id: UUID) case agentSnapshotChanged([AgentPresenceFeature.AgentInstance], hasActivity: Bool) @@ -279,7 +279,7 @@ struct SelectedWorktreeSlice: Equatable, Sendable { let isMainWorktree: Bool let isPinned: Bool let lifecycle: SidebarItemFeature.State.Lifecycle - let pullRequest: GithubPullRequest? + let pullRequest: ForgePullRequest? let runningScripts: IdentifiedArrayOf init(_ row: SidebarItemFeature.State) { diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift index f6c8638a9..202e5c601 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift @@ -4,7 +4,7 @@ import SwiftUI struct ArchivedWorktreeRowView: View { let worktree: Worktree - let pullRequest: GithubPullRequest? + let pullRequest: ForgePullRequest? let onUnarchive: () -> Void let onDelete: () -> Void diff --git a/supacode/Features/Repositories/Views/GitLabMergeRequestStatusView.swift b/supacode/Features/Repositories/Views/GitLabMergeRequestStatusView.swift new file mode 100644 index 000000000..8082a6a89 --- /dev/null +++ b/supacode/Features/Repositories/Views/GitLabMergeRequestStatusView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +// v1 GitLab toolbar status: badge + title + pipeline ring. No merge / close / ready affordances. +struct GitLabMergeRequestStatusView: View { + let mergeRequest: GitLabMergeRequest + + var body: some View { + let badgeState = ForgePullRequest.gitlab(mergeRequest).displayStateBadge + let style = PullRequestBadgeStyle.style( + state: badgeState, + number: mergeRequest.iid, + isQueued: false + ) + Button { + guard let url = URL(string: mergeRequest.url) else { return } + NSWorkspace.shared.open(url) + } label: { + HStack(spacing: 6) { + if let style { + PullRequestBadgeView(text: style.text, color: style.color) + .layoutPriority(1) + } + if let pipeline = mergeRequest.pipelineStatus, pipeline != .unknown { + GitLabPipelineRingView(status: pipeline) + } + Text(mergeRequest.title) + .lineLimit(1) + .font(.caption) + } + } + .buttonStyle(.borderless) + .help("Open merge request !\(mergeRequest.iid) on GitLab") + } +} + +private struct GitLabPipelineRingView: View { + let status: GitLabPipelineStatus + + var body: some View { + Image(systemName: symbolName) + .foregroundStyle(color) + .font(.caption) + .accessibilityLabel(accessibilityLabel) + } + + private var symbolName: String { + if status.isSuccess { return "checkmark.circle.fill" } + if status.isFailure { return "xmark.circle.fill" } + if status.isInProgress { return "circle.dotted" } + return "circle" + } + + private var color: Color { + if status.isSuccess { return .green } + if status.isFailure { return .red } + if status.isInProgress { return .yellow } + return .secondary + } + + private var accessibilityLabel: String { + "Pipeline \(status.rawValue)" + } +} diff --git a/supacode/Features/Repositories/Views/SidebarItemView.swift b/supacode/Features/Repositories/Views/SidebarItemView.swift index 107923156..5f01b6410 100644 --- a/supacode/Features/Repositories/Views/SidebarItemView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemView.swift @@ -188,15 +188,26 @@ enum SidebarPullRequestIcon: Equatable { case merged case closed - static func resolve(_ pullRequest: GithubPullRequest?) -> Self { + static func resolve(_ pullRequest: ForgePullRequest?) -> Self { guard let pullRequest else { return .branch } - switch pullRequest.state.uppercased() { - case "MERGED": return .merged - case "CLOSED": return .closed - case "OPEN" where pullRequest.isDraft: return .draft - case "OPEN" where PullRequestMergeQueueStatus(pullRequest: pullRequest) != nil: return .queued - case "OPEN": return .open - default: return .branch + switch pullRequest { + case .github(let pr): + switch pr.state.uppercased() { + case "MERGED": return .merged + case "CLOSED": return .closed + case "OPEN" where pr.isDraft: return .draft + case "OPEN" where PullRequestMergeQueueStatus(pullRequest: pr) != nil: return .queued + case "OPEN": return .open + default: return .branch + } + case .gitlab(let mr): + switch mr.state { + case .merged: return .merged + case .closed, .locked: return .closed + case .opened where mr.isDraft: return .draft + case .opened: return .open + case .unknown: return .branch + } } } @@ -223,12 +234,22 @@ enum SidebarPullRequestIcon: Equatable { } } -private func resolveCheckBadgeState(_ pullRequest: GithubPullRequest?) -> SidebarCheckBadgeState? { - guard let checks = pullRequest?.statusCheckRollup?.checks, !checks.isEmpty else { return nil } - let breakdown = PullRequestCheckBreakdown(checks: checks) - if breakdown.failed > 0 { return .failing } - if breakdown.inProgress > 0 || breakdown.expected > 0 { return .inProgress } - return .passing +private func resolveCheckBadgeState(_ pullRequest: ForgePullRequest?) -> SidebarCheckBadgeState? { + guard let pullRequest else { return nil } + switch pullRequest { + case .github(let pr): + guard let checks = pr.statusCheckRollup?.checks, !checks.isEmpty else { return nil } + let breakdown = PullRequestCheckBreakdown(checks: checks) + if breakdown.failed > 0 { return .failing } + if breakdown.inProgress > 0 || breakdown.expected > 0 { return .inProgress } + return .passing + case .gitlab(let mr): + guard let status = mr.pipelineStatus else { return nil } + if status.isFailure { return .failing } + if status.isInProgress { return .inProgress } + if status.isSuccess { return .passing } + return nil + } } private struct TitleView: View, Equatable { @@ -298,7 +319,7 @@ private struct IconView: View { let isFolder: Bool let isMissing: Bool let branchName: String - let pullRequest: GithubPullRequest? + let pullRequest: ForgePullRequest? let showsPullRequestInfo: Bool let lifecycle: SidebarItemFeature.State.Lifecycle diff --git a/supacode/Features/Repositories/Views/ToolbarStatusView.swift b/supacode/Features/Repositories/Views/ToolbarStatusView.swift index 432e1772b..15cb148ab 100644 --- a/supacode/Features/Repositories/Views/ToolbarStatusView.swift +++ b/supacode/Features/Repositories/Views/ToolbarStatusView.swift @@ -2,7 +2,7 @@ import SwiftUI struct ToolbarStatusView: View { let toast: RepositoriesFeature.StatusToast? - let pullRequest: GithubPullRequest? + let pullRequest: ForgePullRequest? var body: some View { Group { @@ -27,7 +27,10 @@ struct ToolbarStatusView: View { } .transition(.opacity) case nil: - if let model = PullRequestStatusModel(pullRequest: pullRequest) { + if let pullRequest, case .gitlab(let mr) = pullRequest { + GitLabMergeRequestStatusView(mergeRequest: mr) + .transition(.opacity) + } else if let model = PullRequestStatusModel(pullRequest: pullRequest?.github) { PullRequestStatusButton(model: model) .transition(.opacity) } else { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index a5ccb6887..17ad0eb88 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -148,7 +148,14 @@ private struct RepositoryOwnerAvatar: View { enum GitHubOwnerAvatar { static func url(for rootURL: URL, gitClient: GitClientDependency) async -> URL? { guard let info = await gitClient.remoteInfo(rootURL) else { return nil } - return URL(string: "https://github.com/\(info.owner).png?size=64") + switch info.forge { + case .github: + return URL(string: "https://github.com/\(info.owner).png?size=64") + case .gitlab: + // GitLab serves group / user avatars at `/.png` on the active host. For subgroup + // namespaces (owner contains `/`), this 404s — the fallback is rendered when avatarURL stays nil. + return URL(string: "https://\(info.host)/\(info.owner).png?width=64") + } } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index d7c9d6d4a..7c7bb2144 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -374,7 +374,7 @@ struct WorktreeDetailView: View { // Folders have no git remote, so the PR payload is scoped to // `.git` — this makes "folder with a pull request" unrepresentable. enum Kind { - case git(pullRequest: GithubPullRequest?) + case git(pullRequest: ForgePullRequest?) case folder } @@ -392,7 +392,7 @@ struct WorktreeDetailView: View { if case .folder = kind { true } else { false } } - var pullRequest: GithubPullRequest? { + var pullRequest: ForgePullRequest? { if case .git(let pullRequest) = kind { pullRequest } else { nil } } diff --git a/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift b/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift index 89ffadec3..c1090e6f7 100644 --- a/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift +++ b/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift @@ -1,11 +1,11 @@ import SwiftUI struct WorktreePullRequestDisplay { - let pullRequest: GithubPullRequest? + let pullRequest: ForgePullRequest? let pullRequestState: String? let pullRequestBadgeStyle: (text: String, color: Color)? - init(worktreeName: String, pullRequest: GithubPullRequest?) { + init(worktreeName: String, pullRequest: ForgePullRequest?) { let matchesWorktree = if let pullRequest { pullRequest.headRefName == nil || pullRequest.headRefName == worktreeName @@ -13,9 +13,10 @@ struct WorktreePullRequestDisplay { false } let displayPullRequest = matchesWorktree ? pullRequest : nil - let pullRequestState = displayPullRequest?.state.uppercased() + let pullRequestState = displayPullRequest?.displayStateBadge let pullRequestNumber = displayPullRequest?.number - let isQueued = displayPullRequest.map { PullRequestMergeQueueStatus(pullRequest: $0) != nil } ?? false + // Merge-queue is GitHub-only; GitLab MRs never report a queued state in v1. + let isQueued = displayPullRequest?.github.map { PullRequestMergeQueueStatus(pullRequest: $0) != nil } ?? false self.pullRequest = displayPullRequest self.pullRequestState = pullRequestState self.pullRequestBadgeStyle = PullRequestBadgeStyle.style( @@ -33,9 +34,16 @@ struct WorktreePullRequestAccessoryView: View { if let pullRequestBadgeStyle = display.pullRequestBadgeStyle, let pullRequest = display.pullRequest { - PullRequestChecksPopoverButton( - pullRequest: pullRequest - ) { + switch pullRequest { + case .github(let githubPullRequest): + PullRequestChecksPopoverButton( + pullRequest: githubPullRequest + ) { + PullRequestBadgeView(text: pullRequestBadgeStyle.text, color: pullRequestBadgeStyle.color) + } + case .gitlab: + // v1 GitLab: render the badge without a check-rollup popover. Pipeline status display + // is a separate v1 surface (sidebar status dot / detail view). PullRequestBadgeView(text: pullRequestBadgeStyle.text, color: pullRequestBadgeStyle.color) } } diff --git a/supacode/Features/Settings/Views/ForgesSettingsView.swift b/supacode/Features/Settings/Views/ForgesSettingsView.swift new file mode 100644 index 000000000..10bbbe2fc --- /dev/null +++ b/supacode/Features/Settings/Views/ForgesSettingsView.swift @@ -0,0 +1,234 @@ +import ComposableArchitecture +import SupacodeSettingsFeature +import SupacodeSettingsShared +import SwiftUI + +@MainActor @Observable +final class ForgesSettingsViewModel { + enum Status: Equatable { + case loading + case unavailable + case outdated + case notAuthenticated + case authenticated(username: String, host: String) + case error(String) + } + + var statuses: [Forge: Status] = [.github: .loading, .gitlab: .loading] + + @ObservationIgnored @Dependency(GithubIntegrationClient.self) private var githubIntegration + @ObservationIgnored @Dependency(GithubCLIClient.self) private var githubCLI + @ObservationIgnored @Dependency(GitLabIntegrationClient.self) private var gitlabIntegration + @ObservationIgnored @Dependency(GitLabCLIClient.self) private var gitlabCLI + + func loadAll() async { + statuses[.github] = await githubStatus() + statuses[.gitlab] = await gitlabStatus() + } + + func reload(_ forge: Forge) async { + statuses[forge] = .loading + switch forge { + case .github: statuses[.github] = await githubStatus() + case .gitlab: statuses[.gitlab] = await gitlabStatus() + } + } + + private func githubStatus() async -> Status { + guard await githubIntegration.isAvailable() else { return .unavailable } + do { + if let status = try await githubCLI.authStatus() { + return .authenticated(username: status.username, host: status.host) + } + return .notAuthenticated + } catch let error as GithubCLIError { + switch error { + case .outdated: return .outdated + case .unavailable: return .unavailable + case .gatewayTimeout: return .error(error.errorDescription ?? "GitHub returned a gateway timeout.") + case .commandFailed(let message): return .error(message) + } + } catch { + return .error(error.localizedDescription) + } + } + + private func gitlabStatus() async -> Status { + guard await gitlabIntegration.isAvailable() else { return .unavailable } + do { + if let status = try await gitlabCLI.authStatus() { + return .authenticated(username: status.username, host: status.host) + } + return .notAuthenticated + } catch let error as GitLabCLIError { + switch error { + case .unavailable: return .unavailable + case .commandFailed(let message): return .error(message) + } + } catch { + return .error(error.localizedDescription) + } + } +} + +struct ForgesSettingsView: View { + @Bindable var store: StoreOf + @State private var viewModel = ForgesSettingsViewModel() + + var body: some View { + Form { + Section { + ForgeIntegrationRow( + forge: .github, + enabled: $store.githubIntegrationEnabled, + status: viewModel.statuses[.github] ?? .loading + ) + ForgeIntegrationRow( + forge: .gitlab, + enabled: $store.gitlabIntegrationEnabled, + status: viewModel.statuses[.gitlab] ?? .loading + ) + } header: { + Text("Forges") + } footer: { + Text("Each forge surfaces pull/merge request status for worktrees on its remotes. The CLI must be installed and authenticated.") + } + + Section { + Picker(selection: $store.pullRequestMergeStrategy) { + ForEach(PullRequestMergeStrategy.allCases) { strategy in + Text(strategy.title) + .tag(strategy) + } + } label: { + Text("Merge strategy") + Text("Default strategy when merging pull requests from the command palette.") + } + Picker(selection: $store.mergedWorktreeAction) { + Text("Do nothing").tag(MergedWorktreeAction?.none) + ForEach(MergedWorktreeAction.allCases) { action in + Text(action.title).tag(MergedWorktreeAction?.some(action)) + } + } label: { + Text("When a pull request is merged") + switch store.mergedWorktreeAction { + case .archive: + Text("Archives the worktree when its pull request is merged.") + case .delete: + Text("Follows the \"Delete local branch with worktree\" option in Worktrees settings.") + case nil: + EmptyView() + } + } + } header: { + Text("Pull Requests") + } footer: { + Text("Merge actions are available for GitHub today; GitLab merge request actions are coming in a future release.") + } + } + .formStyle(.grouped) + .padding(.top, -20) + .padding(.leading, -8) + .padding(.trailing, -6) + .navigationTitle("Forges") + .task { + await viewModel.loadAll() + } + .onChange(of: store.githubIntegrationEnabled) { _, _ in + Task { await viewModel.reload(.github) } + } + .onChange(of: store.gitlabIntegrationEnabled) { _, _ in + Task { await viewModel.reload(.gitlab) } + } + } +} + +private struct ForgeIntegrationRow: View { + let forge: Forge + @Binding var enabled: Bool + let status: ForgesSettingsViewModel.Status + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: $enabled) { + Text(forge.displayName) + Text("Enable \(forge.displayName) integration.") + } + statusContent + .font(.callout) + } + } + + @ViewBuilder + private var statusContent: some View { + switch status { + case .loading: + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Checking \(forge.cliName)\u{2026}").foregroundStyle(.secondary) + } + + case .unavailable: + HStack(spacing: 6) { + Label("\(forge.cliName) not found", systemImage: "xmark.circle") + .foregroundStyle(.secondary) + Button("Get \(forge.cliName) \u{2197}") { + NSWorkspace.shared.open(forge.cliInstallURL) + } + .buttonStyle(.link) + } + + case .outdated: + HStack(spacing: 6) { + Label("\(forge.cliName) is outdated", systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + Button("Update \(forge.cliName) \u{2197}") { + NSWorkspace.shared.open(forge.cliInstallURL) + } + .buttonStyle(.link) + } + + case .notAuthenticated: + Label("Not authenticated \u{2014} run `\(forge.authLoginCommand)`", systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + + case .authenticated(let username, let host): + Label("Signed in as \(username) \u{00B7} \(host)", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + + case .error(let message): + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } +} + +private extension Forge { + var displayName: String { + switch self { + case .github: return "GitHub" + case .gitlab: return "GitLab" + } + } + + var cliName: String { + switch self { + case .github: return "gh" + case .gitlab: return "glab" + } + } + + var authLoginCommand: String { + switch self { + case .github: return "gh auth login" + case .gitlab: return "glab auth login" + } + } + + var cliInstallURL: URL { + switch self { + case .github: return URL(string: "https://cli.github.com")! + case .gitlab: return URL(string: "https://gitlab.com/gitlab-org/cli")! + } + } +} diff --git a/supacode/Features/Settings/Views/GithubSettingsView.swift b/supacode/Features/Settings/Views/GithubSettingsView.swift deleted file mode 100644 index 4dc653394..000000000 --- a/supacode/Features/Settings/Views/GithubSettingsView.swift +++ /dev/null @@ -1,195 +0,0 @@ -import ComposableArchitecture -import SupacodeSettingsFeature -import SupacodeSettingsShared -import SwiftUI - -@MainActor @Observable -final class GithubSettingsViewModel { - enum State: Equatable { - case loading - case unavailable - case outdated - case notAuthenticated - case authenticated(username: String, host: String) - case error(String) - } - - var state: State = .loading - - @ObservationIgnored - @Dependency(GithubIntegrationClient.self) private var githubIntegration - - @ObservationIgnored - @Dependency(GithubCLIClient.self) private var githubCLI - - func load() async { - state = .loading - let isAvailable = await githubIntegration.isAvailable() - guard isAvailable else { - state = .unavailable - return - } - - do { - if let status = try await githubCLI.authStatus() { - state = .authenticated(username: status.username, host: status.host) - } else { - state = .notAuthenticated - } - } catch let error as GithubCLIError { - switch error { - case .outdated: - state = .outdated - case .unavailable: - state = .unavailable - case .gatewayTimeout: - state = .error(error.localizedDescription ?? "GitHub returned a gateway timeout.") - case .commandFailed(let message): - state = .error(message) - } - } catch { - state = .error(error.localizedDescription) - } - } -} - -struct GithubSettingsView: View { - @Bindable var store: StoreOf - @State private var viewModel = GithubSettingsViewModel() - - var body: some View { - Form { - Section { - Toggle(isOn: $store.githubIntegrationEnabled) { - Text("Enable GitHub Integration") - Text("Pull request checks and merge actions in the command palette.") - } - } - Section("GitHub CLI") { - switch viewModel.state { - case .loading: - LabeledContent("Checking GitHub CLI…") { - ProgressView().controlSize(.small) - } - - case .unavailable: - Label { - VStack(alignment: .leading, spacing: 2) { - Text("GitHub CLI not found") - Text("Install `gh` to enable pull request checks.") - .foregroundStyle(.secondary) - .font(.callout) - } - } icon: { - Image(systemName: "xmark.circle") - .foregroundStyle(.red) - .accessibilityHidden(true) - } - - case .notAuthenticated: - Label { - VStack(alignment: .leading, spacing: 2) { - Text("Not authenticated") - Text("Run `gh auth login` in a terminal to authenticate.") - .foregroundStyle(.secondary) - .font(.callout) - } - } icon: { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - .accessibilityHidden(true) - } - - case .outdated: - Label { - VStack(alignment: .leading, spacing: 2) { - Text("GitHub CLI outdated") - Text("Update to the latest version for full support.") - .foregroundStyle(.secondary) - .font(.callout) - } - } icon: { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - .accessibilityHidden(true) - } - - case .authenticated(let username, let host): - LabeledContent("Signed in as") { - Text(username) - } - LabeledContent("Host") { - Text(host) - } - - case .error(let message): - Label { - VStack(alignment: .leading, spacing: 2) { - Text("Error checking status") - Text(message) - .foregroundStyle(.secondary) - .font(.callout) - } - } icon: { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.red) - .accessibilityHidden(true) - } - } - - switch viewModel.state { - case .unavailable: - Button("Get GitHub CLI") { - NSWorkspace.shared.open(URL(string: "https://cli.github.com")!) - } - case .outdated: - Button("Update GitHub CLI") { - NSWorkspace.shared.open(URL(string: "https://cli.github.com")!) - } - default: - EmptyView() - } - } - Section("Pull Requests") { - Picker(selection: $store.pullRequestMergeStrategy) { - ForEach(PullRequestMergeStrategy.allCases) { strategy in - Text(strategy.title) - .tag(strategy) - } - } label: { - Text("Merge strategy") - Text("Default strategy when merging PRs from the command palette.") - } - Picker(selection: $store.mergedWorktreeAction) { - Text("Do nothing").tag(MergedWorktreeAction?.none) - ForEach(MergedWorktreeAction.allCases) { action in - Text(action.title).tag(MergedWorktreeAction?.some(action)) - } - } label: { - Text("When a pull request is merged") - switch store.mergedWorktreeAction { - case .archive: - Text("Archives the worktree when its pull request is merged.") - case .delete: - Text("Follows the \"Delete local branch with worktree\" option in Worktrees settings.") - case nil: - EmptyView() - } - } - } - } - .formStyle(.grouped) - .padding(.top, -20) - .padding(.leading, -8) - .padding(.trailing, -6) - .navigationTitle("GitHub") - .task { - await viewModel.load() - } - .onChange(of: store.githubIntegrationEnabled) { _, _ in - Task { - await viewModel.load() - } - } - } -} diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 28d02854d..8bc0e81f3 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -88,8 +88,8 @@ private struct SettingsSidebarView: View { .tag(SettingsSection.worktree) Label("Developer", systemImage: "hammer") .tag(SettingsSection.developer) - Label("GitHub", image: "github-mark") - .tag(SettingsSection.github) + Label("Forges", systemImage: "arrow.triangle.branch") + .tag(SettingsSection.forges) Label("Shortcuts", systemImage: "keyboard") .tag(SettingsSection.shortcuts) Label("Global Scripts", systemImage: "terminal") @@ -165,8 +165,8 @@ private struct SettingsDetailView: View { KeyboardShortcutsSettingsView(store: settingsStore) case .updates: UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) - case .github: - GithubSettingsView(store: settingsStore) + case .forges: + ForgesSettingsView(store: settingsStore) case .scripts: GlobalScriptsSettingsView(store: settingsStore) .navigationTitle("Global Scripts") diff --git a/supacode/Support/CustomDump+Extensions.swift b/supacode/Support/CustomDump+Extensions.swift index 532cbb783..036dba06a 100644 --- a/supacode/Support/CustomDump+Extensions.swift +++ b/supacode/Support/CustomDump+Extensions.swift @@ -79,3 +79,23 @@ extension GithubPullRequestStatusCheckRollup: CustomDumpRepresentable { checks.count } } + +extension GitLabMergeRequest: CustomDumpRepresentable { + var customDumpValue: Any { + ( + iid: iid, + state: state, + isDraft: isDraft, + pipelineStatus: pipelineStatus as Any + ) + } +} + +extension ForgePullRequest: CustomDumpRepresentable { + var customDumpValue: Any { + switch self { + case .github(let pr): return ("github", pr.customDumpValue) + case .gitlab(let mr): return ("gitlab", mr.customDumpValue) + } + } +} diff --git a/supacodeTests/GitLabCLIClientTests.swift b/supacodeTests/GitLabCLIClientTests.swift new file mode 100644 index 000000000..f277bb54b --- /dev/null +++ b/supacodeTests/GitLabCLIClientTests.swift @@ -0,0 +1,184 @@ +import Foundation +import Testing + +@testable import supacode + +struct GitLabAuthStatusParsingTests { + @Test func parsesGitlabComLoggedInLine() { + let output = """ + gitlab.com + ✓ Logged in to gitlab.com as octouser (api) + ✓ Token: *** + """ + let status = parseGlabAuthStatus(output) + #expect(status == GitLabAuthStatus(username: "octouser", host: "gitlab.com")) + } + + @Test func parsesSelfHostedLoggedInLine() { + let output = "✓ Logged in to gitlab.acme.com as team-bot (api, write_repository)" + let status = parseGlabAuthStatus(output) + #expect(status == GitLabAuthStatus(username: "team-bot", host: "gitlab.acme.com")) + } + + @Test func returnsNilWhenNotAuthenticated() { + let output = "No tokens configured. Run `glab auth login` to get started." + let status = parseGlabAuthStatus(output) + #expect(status == nil) + } +} + +struct GitLabMergeRequestResponseTests { + private func decodePayload(_ json: String) throws -> GitLabGraphQLMergeRequestResponse { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GitLabGraphQLMergeRequestResponse.self, from: Data(json.utf8)) + } + + @Test func decodesOpenMergeRequestKeyedBySourceBranch() throws { + let json = """ + { + "data": { + "project": { + "mergeRequests": { + "nodes": [ + { + "iid": "42", + "title": "Add login flow", + "state": "opened", + "draft": false, + "webUrl": "https://gitlab.com/g/p/-/merge_requests/42", + "updatedAt": "2026-05-27T10:00:00Z", + "sourceBranch": "feature/login", + "targetBranch": "main", + "diffStatsSummary": { "additions": 12, "deletions": 3 }, + "author": { "username": "octouser" }, + "headPipeline": { "status": "RUNNING" } + } + ] + } + } + } + } + """ + let response = try decodePayload(json) + let byBranch = response.mergeRequestsBySourceBranch() + let mr = try #require(byBranch["feature/login"]) + #expect(mr.iid == 42) + #expect(mr.title == "Add login flow") + #expect(mr.state == .opened) + #expect(mr.isDraft == false) + #expect(mr.additions == 12) + #expect(mr.deletions == 3) + #expect(mr.authorUsername == "octouser") + #expect(mr.pipelineStatus == .running) + #expect(mr.url == "https://gitlab.com/g/p/-/merge_requests/42") + #expect(mr.targetBranch == "main") + } + + @Test func dropsNodesWithoutSourceBranch() throws { + let json = """ + { + "data": { + "project": { + "mergeRequests": { + "nodes": [ + { + "iid": "1", + "title": "Detached", + "state": "opened", + "webUrl": "https://gitlab.com/g/p/-/merge_requests/1", + "sourceBranch": null, + "targetBranch": "main" + } + ] + } + } + } + } + """ + let response = try decodePayload(json) + #expect(response.mergeRequestsBySourceBranch().isEmpty) + } + + @Test func handlesMissingProject() throws { + let json = #"{"data":{"project":null}}"# + let response = try decodePayload(json) + #expect(response.mergeRequestsBySourceBranch().isEmpty) + } + + @Test func keepsMostRecentlyUpdatedOnBranchCollision() throws { + let json = """ + { + "data": { + "project": { + "mergeRequests": { + "nodes": [ + { + "iid": "1", + "title": "Older", + "state": "opened", + "webUrl": "https://gitlab.com/g/p/-/merge_requests/1", + "updatedAt": "2026-05-20T10:00:00Z", + "sourceBranch": "feature/x", + "targetBranch": "main" + }, + { + "iid": "2", + "title": "Newer", + "state": "opened", + "webUrl": "https://gitlab.com/g/p/-/merge_requests/2", + "updatedAt": "2026-05-27T10:00:00Z", + "sourceBranch": "feature/x", + "targetBranch": "main" + } + ] + } + } + } + } + """ + let response = try decodePayload(json) + let mr = try #require(response.mergeRequestsBySourceBranch()["feature/x"]) + #expect(mr.iid == 2) + #expect(mr.title == "Newer") + } +} + +struct GitLabPipelineStatusTests { + @Test func mapsKnownStates() { + #expect(GitLabPipelineStatus(rawGraphQL: "RUNNING") == .running) + #expect(GitLabPipelineStatus(rawGraphQL: "success") == .success) + #expect(GitLabPipelineStatus(rawGraphQL: "FAILED") == .failed) + #expect(GitLabPipelineStatus(rawGraphQL: "CANCELLED") == .canceled) + #expect(GitLabPipelineStatus(rawGraphQL: "canceled") == .canceled) + #expect(GitLabPipelineStatus(rawGraphQL: "WAITING_FOR_RESOURCE") == .waitingForResource) + } + + @Test func unknownStateFallsThrough() { + #expect(GitLabPipelineStatus(rawGraphQL: "totally-new-state") == .unknown) + } + + @Test func inProgressCoversPendingAndRunning() { + #expect(GitLabPipelineStatus.running.isInProgress) + #expect(GitLabPipelineStatus.pending.isInProgress) + #expect(GitLabPipelineStatus.scheduled.isInProgress) + #expect(!GitLabPipelineStatus.success.isInProgress) + #expect(!GitLabPipelineStatus.failed.isInProgress) + } +} + +struct ForgeDetectionTests { + @Test func detectsGithubHost() { + #expect(Forge.detect(host: "github.com") == .github) + #expect(Forge.detect(host: "github.acme.com") == .github) + } + + @Test func detectsGitlabHost() { + #expect(Forge.detect(host: "gitlab.com") == .gitlab) + #expect(Forge.detect(host: "gitlab.acme.com") == .gitlab) + } + + @Test func returnsNilForUnknown() { + #expect(Forge.detect(host: "bitbucket.org") == nil) + } +} diff --git a/supacodeTests/GitRemoteInfoTests.swift b/supacodeTests/GitRemoteInfoTests.swift index a07bff5e5..bf7f35ddf 100644 --- a/supacodeTests/GitRemoteInfoTests.swift +++ b/supacodeTests/GitRemoteInfoTests.swift @@ -4,28 +4,50 @@ import Testing @testable import supacode struct GitRemoteInfoTests { - @Test func parseSSHRemote() { - let info = GitClient.parseGithubRemoteInfo("git@github.com:octo/repo.git") - #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) + @Test func parseGithubSSHRemote() { + let info = GitClient.parseRemoteInfo("git@github.com:octo/repo.git") + #expect(info == ForgeRemoteInfo(forge: .github, host: "github.com", owner: "octo", repo: "repo")) } - @Test func parseSSHURLRemote() { - let info = GitClient.parseGithubRemoteInfo("ssh://git@github.com/octo/repo.git") - #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) + @Test func parseGithubSSHURLRemote() { + let info = GitClient.parseRemoteInfo("ssh://git@github.com/octo/repo.git") + #expect(info == ForgeRemoteInfo(forge: .github, host: "github.com", owner: "octo", repo: "repo")) } - @Test func parseHTTPSRemote() { - let info = GitClient.parseGithubRemoteInfo("https://github.com/octo/repo") - #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) + @Test func parseGithubHTTPSRemote() { + let info = GitClient.parseRemoteInfo("https://github.com/octo/repo") + #expect(info == ForgeRemoteInfo(forge: .github, host: "github.com", owner: "octo", repo: "repo")) } - @Test func parseEnterpriseRemote() { - let info = GitClient.parseGithubRemoteInfo("git@github.acme.com:team/repo.git") - #expect(info == GithubRemoteInfo(host: "github.acme.com", owner: "team", repo: "repo")) + @Test func parseGithubEnterpriseRemote() { + let info = GitClient.parseRemoteInfo("git@github.acme.com:team/repo.git") + #expect(info == ForgeRemoteInfo(forge: .github, host: "github.acme.com", owner: "team", repo: "repo")) } - @Test func rejectsNonGithubRemote() { - let info = GitClient.parseGithubRemoteInfo("https://gitlab.com/group/repo.git") + @Test func parseGitlabHTTPSRemote() { + let info = GitClient.parseRemoteInfo("https://gitlab.com/group/repo.git") + #expect(info == ForgeRemoteInfo(forge: .gitlab, host: "gitlab.com", owner: "group", repo: "repo")) + } + + @Test func parseGitlabSSHRemote() { + let info = GitClient.parseRemoteInfo("git@gitlab.com:group/repo.git") + #expect(info == ForgeRemoteInfo(forge: .gitlab, host: "gitlab.com", owner: "group", repo: "repo")) + } + + @Test func parseGitlabSubgroupRemote() { + let info = GitClient.parseRemoteInfo("git@gitlab.com:group/subgroup/project.git") + #expect( + info == ForgeRemoteInfo(forge: .gitlab, host: "gitlab.com", owner: "group/subgroup", repo: "project") + ) + } + + @Test func parseGitlabSelfHostedRemote() { + let info = GitClient.parseRemoteInfo("https://gitlab.acme.com/team/proj") + #expect(info == ForgeRemoteInfo(forge: .gitlab, host: "gitlab.acme.com", owner: "team", repo: "proj")) + } + + @Test func rejectsUnknownForgeRemote() { + let info = GitClient.parseRemoteInfo("https://bitbucket.org/team/repo.git") #expect(info == nil) } } diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index de5973792..79cbd55dc 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -386,7 +386,7 @@ struct GithubCLIClientTests { let info = await client.resolveRemoteInfo(URL(fileURLWithPath: "/tmp/repo")) - #expect(info == GithubRemoteInfo(host: "github.com", owner: "upstream-org", repo: "upstream-repo")) + #expect(info == ForgeRemoteInfo(forge: .github, host: "github.com", owner: "upstream-org", repo: "upstream-repo")) } @Test func resolveRemoteInfoReturnsNilWhenGhFails() async { @@ -426,7 +426,7 @@ struct GithubCLIClientTests { } ) let client = GithubCLIClient.live(shell: shell) - let remote = GithubRemoteInfo(host: "github.com", owner: "upstream-org", repo: "upstream-repo") + let remote = ForgeRemoteInfo(forge: .github, host: "github.com", owner: "upstream-org", repo: "upstream-repo") try await client.mergePullRequest(URL(fileURLWithPath: "/tmp/fork"), remote, 42, .squash) @@ -479,7 +479,7 @@ struct GithubCLIClientTests { } ) let client = GithubCLIClient.live(shell: shell) - let remote = GithubRemoteInfo(host: "ghe.acme.com", owner: "team", repo: "repo") + let remote = ForgeRemoteInfo(forge: .github, host: "ghe.acme.com", owner: "team", repo: "repo") try await client.closePullRequest(URL(fileURLWithPath: "/tmp/fork"), remote, 7) @@ -504,7 +504,7 @@ struct GithubCLIClientTests { } ) let client = GithubCLIClient.live(shell: shell) - let remote = GithubRemoteInfo(host: "github.com", owner: "owner", repo: "repo") + let remote = ForgeRemoteInfo(forge: .github, host: "github.com", owner: "owner", repo: "repo") try await client.markPullRequestReady(URL(fileURLWithPath: "/tmp/fork"), remote, 13) diff --git a/supacodeTests/RepositoriesFeatureSidebarTests.swift b/supacodeTests/RepositoriesFeatureSidebarTests.swift index 35a57b37b..91b461042 100644 --- a/supacodeTests/RepositoriesFeatureSidebarTests.swift +++ b/supacodeTests/RepositoriesFeatureSidebarTests.swift @@ -173,7 +173,7 @@ struct RepositoriesFeatureSidebarTests { mergeQueueEntry: nil ) var state = RepositoriesFeature.State(reconciledRepositories: [repository]) - state.sidebarItems[id: worktreeID]?.pullRequest = pullRequest + state.sidebarItems[id: worktreeID]?.pullRequest = .github(pullRequest) state.sidebarItems[id: worktreeID]?.pullRequestBranchAtQueryTime = "feature" state.inFlightPullRequestBranchSnapshotsByRepositoryID[repoID] = [worktreeID: "feature"] @@ -191,7 +191,7 @@ struct RepositoriesFeatureSidebarTests { $0.sidebarItems[id: worktreeID]?.pullRequestBranchAtQueryTime = nil } await store.finish() - #expect(store.state.sidebarItems[id: worktreeID]?.pullRequest == pullRequest) + #expect(store.state.sidebarItems[id: worktreeID]?.pullRequest == .github(pullRequest)) } @Test func pullRequestsLoadedClearsWatermarkForQueriedButMissingWorktree() async { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index d926f1f43..1c97ce4cb 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -4313,7 +4313,7 @@ struct RepositoriesFeatureTests { .buckets[.archived]?.items[featureWorktree.id]?.archivedAt == fixedDate ) #expect( - store.state.sidebarItems[id: featureWorktree.id]?.pullRequest == mergedPullRequest + store.state.sidebarItems[id: featureWorktree.id]?.pullRequest == .github(mergedPullRequest) ) } @@ -4336,7 +4336,7 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.sidebarItems) { - $0.sidebarItems[id: mainWorktree.id]?.pullRequest = mergedPullRequest + $0.sidebarItems[id: mainWorktree.id]?.pullRequest = .github(mergedPullRequest) } await store.finish() } @@ -4394,7 +4394,7 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.sidebarItems) { - $0.sidebarItems[id: featureWorktree.id]?.pullRequest = mergedPullRequest + $0.sidebarItems[id: featureWorktree.id]?.pullRequest = .github(mergedPullRequest) } await store.finish() } @@ -4431,7 +4431,7 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.sidebarItems) { - $0.sidebarItems[id: featureWorktree.id]?.pullRequest = mergedPullRequest + $0.sidebarItems[id: featureWorktree.id]?.pullRequest = .github(mergedPullRequest) } await store.finish() } @@ -4461,7 +4461,7 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.sidebarItems) { - $0.sidebarItems[id: featureWorktree.id]?.pullRequest = mergedPullRequest + $0.sidebarItems[id: featureWorktree.id]?.pullRequest = .github(mergedPullRequest) } await store.finish() } @@ -4491,7 +4491,7 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.sidebarItems) { - $0.sidebarItems[id: featureWorktree.id]?.pullRequest = mergedPullRequest + $0.sidebarItems[id: featureWorktree.id]?.pullRequest = .github(mergedPullRequest) } await store.finish() } @@ -4544,7 +4544,7 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.sidebarItems) { - $0.sidebarItems[id: featureWorktree.id]?.pullRequest = refreshedPullRequest + $0.sidebarItems[id: featureWorktree.id]?.pullRequest = .github(refreshedPullRequest) } await store.finish() } @@ -4639,19 +4639,19 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available - let batchCalls = LockIsolated<[GithubRemoteInfo]>([]) + let batchCalls = LockIsolated<[ForgeRemoteInfo]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.githubCLI.resolveRemoteInfo = { _ in - GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + ForgeRemoteInfo(forge: .github, host: "github.com", owner: "upstream", repo: "project") } $0.gitClient.remoteInfo = { _ in Issue.record("gitClient.remoteInfo should be the fallback, not the first choice") - return GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") + return ForgeRemoteInfo(forge: .github, host: "github.com", owner: "fork", repo: "project") } $0.githubCLI.batchPullRequests = { host, owner, repo, _ in - batchCalls.withValue { $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) } + batchCalls.withValue { $0.append(ForgeRemoteInfo(forge: .github, host: host, owner: owner, repo: repo)) } return [:] } } @@ -4668,7 +4668,7 @@ struct RepositoriesFeatureTests { await store.receive(\.repositoryPullRequestRefreshCompleted) await store.finish() - #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project")]) + #expect(batchCalls.value == [ForgeRemoteInfo(forge: .github, host: "github.com", owner: "upstream", repo: "project")]) } @Test func worktreeInfoEventRepositoryPullRequestRefreshFallsBackToGitRemote() async { @@ -4682,16 +4682,16 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available - let batchCalls = LockIsolated<[GithubRemoteInfo]>([]) + let batchCalls = LockIsolated<[ForgeRemoteInfo]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.gitClient.remoteInfo = { _ in - GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") + ForgeRemoteInfo(forge: .github, host: "github.com", owner: "fork", repo: "project") } $0.githubCLI.batchPullRequests = { host, owner, repo, _ in - batchCalls.withValue { $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) } + batchCalls.withValue { $0.append(ForgeRemoteInfo(forge: .github, host: host, owner: owner, repo: repo)) } return [:] } } @@ -4708,7 +4708,7 @@ struct RepositoriesFeatureTests { await store.receive(\.repositoryPullRequestRefreshCompleted) await store.finish() - #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project")]) + #expect(batchCalls.value == [ForgeRemoteInfo(forge: .github, host: "github.com", owner: "fork", repo: "project")]) } @Test func pullRequestActionMergePassesResolvedRemoteToGh() async { @@ -4726,13 +4726,13 @@ struct RepositoriesFeatureTests { state.reconcileSidebarForTesting() state.setWorktreeInfoForTesting( id: featureWorktree.id, addedLines: nil, removedLines: nil, pullRequest: openPullRequest) - let recordedRemote = LockIsolated(nil) + let recordedRemote = LockIsolated(nil) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in - GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + ForgeRemoteInfo(forge: .github, host: "github.com", owner: "upstream", repo: "project") } $0.githubCLI.mergePullRequest = { _, remote, _, _ in recordedRemote.withValue { $0 = remote } @@ -4746,7 +4746,76 @@ struct RepositoriesFeatureTests { await store.receive(\.worktreeInfoEvent) await store.finish() - #expect(recordedRemote.value == GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project")) + #expect(recordedRemote.value == ForgeRemoteInfo(forge: .github, host: "github.com", owner: "upstream", repo: "project")) + } + + @Test func worktreeInfoEventRepositoryPullRequestRefreshRoutesGitlabRemoteToGlabBatch() async { + let repoRoot = "/tmp/gitlab-repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + let glabBatchCalls = LockIsolated<[ForgeRemoteInfo]>([]) + let ghBatchCalls = LockIsolated(0) + let mergeRequest = GitLabMergeRequest( + iid: 7, + title: "Add login flow", + state: .opened, + additions: 12, + deletions: 3, + isDraft: false, + updatedAt: nil, + url: "https://gitlab.com/group/project/-/merge_requests/7", + sourceBranch: featureWorktree.name, + targetBranch: "main", + authorUsername: "octouser", + pipelineStatus: .running + ) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.gitClient.remoteInfo = { _ in + ForgeRemoteInfo(forge: .gitlab, host: "gitlab.com", owner: "group", repo: "project") + } + $0.githubCLI.batchPullRequests = { _, _, _, _ in + ghBatchCalls.withValue { $0 += 1 } + return [:] + } + $0.gitlabCLI.batchMergeRequests = { host, owner, repo, _ in + glabBatchCalls.withValue { + $0.append(ForgeRemoteInfo(forge: .gitlab, host: host, owner: owner, repo: repo)) + } + return [featureWorktree.name: mergeRequest] + } + } + store.exhaustivity = .off + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) + ) + ) + await store.receive(\.repositoryPullRequestRefreshCompleted) + await store.finish() + + #expect( + glabBatchCalls.value == [ + ForgeRemoteInfo(forge: .gitlab, host: "gitlab.com", owner: "group", repo: "project") + ] + ) + #expect(ghBatchCalls.value == 0) + #expect( + store.state.sidebarItems[id: featureWorktree.id]?.pullRequest == .gitlab(mergeRequest) + ) } @Test func pullRequestActionMergeFallsBackToGitRemoteWhenGhResolverReturnsNil() async { @@ -4764,14 +4833,14 @@ struct RepositoriesFeatureTests { state.reconcileSidebarForTesting() state.setWorktreeInfoForTesting( id: featureWorktree.id, addedLines: nil, removedLines: nil, pullRequest: openPullRequest) - let recordedRemote = LockIsolated(nil) + let recordedRemote = LockIsolated(nil) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.gitClient.remoteInfo = { _ in - GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") + ForgeRemoteInfo(forge: .github, host: "github.com", owner: "fork", repo: "project") } $0.githubCLI.mergePullRequest = { _, remote, _, _ in recordedRemote.withValue { $0 = remote } @@ -4785,7 +4854,7 @@ struct RepositoriesFeatureTests { await store.receive(\.worktreeInfoEvent) await store.finish() - #expect(recordedRemote.value == GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project")) + #expect(recordedRemote.value == ForgeRemoteInfo(forge: .github, host: "github.com", owner: "fork", repo: "project")) } @Test func worktreeInfoEventRepositoryPullRequestRefreshMarksInFlightThenCompletes() async { @@ -5157,7 +5226,7 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in GithubRemoteInfo(host: "github.com", owner: "o", repo: "r") } + $0.gitClient.remoteInfo = { _ in ForgeRemoteInfo(forge: .github, host: "github.com", owner: "o", repo: "r") } $0.githubCLI.batchPullRequests = { _, _, _, _ in [featureName: pullRequest] } } @@ -5188,7 +5257,7 @@ struct RepositoriesFeatureTests { $0.sidebarItems[id: mainWorktree.id]?.pullRequestBranchAtQueryTime = nil } await store.receive(\.sidebarItems[id: featureWorktree.id].pullRequestChanged) { - $0.sidebarItems[id: featureWorktree.id]?.pullRequest = pullRequest + $0.sidebarItems[id: featureWorktree.id]?.pullRequest = .github(pullRequest) $0.sidebarItems[id: featureWorktree.id]?.pullRequestBranchAtQueryTime = nil } await store.receive(\.repositoryPullRequestRefreshCompleted) { diff --git a/supacodeTests/RepositoriesSidebarTestHelpers.swift b/supacodeTests/RepositoriesSidebarTestHelpers.swift index 84e1c79a5..e4f7d3e58 100644 --- a/supacodeTests/RepositoriesSidebarTestHelpers.swift +++ b/supacodeTests/RepositoriesSidebarTestHelpers.swift @@ -50,6 +50,6 @@ extension RepositoriesFeature.State { ) { sidebarItems[id: id]?.addedLines = addedLines sidebarItems[id: id]?.removedLines = removedLines - sidebarItems[id: id]?.pullRequest = pullRequest + sidebarItems[id: id]?.pullRequest = pullRequest.map(ForgePullRequest.github) } } diff --git a/supacodeTests/SidebarItemFeatureTests.swift b/supacodeTests/SidebarItemFeatureTests.swift index 32c7222a4..bf67d7e65 100644 --- a/supacodeTests/SidebarItemFeatureTests.swift +++ b/supacodeTests/SidebarItemFeatureTests.swift @@ -189,7 +189,7 @@ struct SidebarItemFeatureTests { statusCheckRollup: nil, mergeQueueEntry: nil ) - state.pullRequest = livePR + state.pullRequest = .github(livePR) let store = TestStore(initialState: state) { SidebarItemFeature() } @@ -213,8 +213,8 @@ struct SidebarItemFeatureTests { mergeQueueEntry: nil ) // Late stale result must not replace the live PR. - await store.send(.pullRequestChanged(stalePR, branchAtQueryTime: "feature/x")) - #expect(store.state.pullRequest == livePR) + await store.send(.pullRequestChanged(.github(stalePR), branchAtQueryTime: "feature/x")) + #expect(store.state.pullRequest == .github(livePR)) } @Test func pullRequestChangedClearsWatermarkOnSuccessAndOnIdenticalReissue() async { @@ -246,15 +246,15 @@ struct SidebarItemFeatureTests { $0.pullRequestBranchAtQueryTime = "feature" } // Success path: PR is written and watermark cleared. - await store.send(.pullRequestChanged(pullRequest, branchAtQueryTime: "feature")) { - $0.pullRequest = pullRequest + await store.send(.pullRequestChanged(.github(pullRequest), branchAtQueryTime: "feature")) { + $0.pullRequest = .github(pullRequest) $0.pullRequestBranchAtQueryTime = nil } // Identical-payload reissue with a re-armed watermark: PR unchanged, watermark still cleared. await store.send(.pullRequestQueryStarted(branch: "feature")) { $0.pullRequestBranchAtQueryTime = "feature" } - await store.send(.pullRequestChanged(pullRequest, branchAtQueryTime: "feature")) { + await store.send(.pullRequestChanged(.github(pullRequest), branchAtQueryTime: "feature")) { $0.pullRequestBranchAtQueryTime = nil } }