From 7527cbebf8e04dc6e3ea87c083f3dab2f4e02070 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Thu, 8 Oct 2026 06:46:28 +0200 Subject: [PATCH] Read merge requests and pipelines from GitLab A repository whose base remote is on gitlab.com, or on a host glab is signed in to, or whose settings say git.forge = "gitlab", now reads its merge request, pipeline jobs and failed job logs through glab api. Any GitHub remote, and anything uncertain, stays on GitHub. GitLab's answers fill the existing PullRequest and CheckRun, and what GitHub has no word for travels in blockers, which the GitHub decoder never sets. A merge request is found by source branch, which survives the branch being deleted, so no column is added and a GitLab number is never written where GitHub would read it. --- Sources/Bloom/State/Bridges.swift | 5 +- .../State/WorkspaceModel+PullRequest.swift | 5 +- Sources/Bloom/State/WorkspaceModel.swift | 4 + .../Bloom/Views/Inspector/GitHubSignIn.swift | 5 + .../BloomCore/Bridge/WorkspaceMergeTool.swift | 4 +- .../GitHub/CheckFailureHandoff.swift | 26 +- Sources/BloomCore/GitHub/Forge.swift | 31 +++ Sources/BloomCore/GitHub/ForgeClient.swift | 25 +- Sources/BloomCore/GitHub/ForgeRouting.swift | 143 +++++++++++ Sources/BloomCore/GitHub/MergeBlocker.swift | 68 ++++++ .../BloomCore/GitHub/PullRequestNumber.swift | 3 +- .../BloomCore/GitHub/PullRequestStatus.swift | 25 +- Sources/BloomCore/GitLab/GitLab.swift | 227 ++++++++++++++++++ Sources/BloomCore/GitLab/GitLabDecoding.swift | 159 ++++++++++++ Sources/BloomCore/GitLab/GitLabProject.swift | 31 +++ Sources/BloomCore/Model/Models.swift | 10 +- Sources/BloomCore/Model/WorkspaceStatus.swift | 3 +- Sources/BloomCore/Persistence/Settings.swift | 5 + .../Workspace/WorkspaceStartContext.swift | 2 +- Tests/BloomCoreTests/ForgeClientTests.swift | 15 +- Tests/BloomCoreTests/ForgeRoutingTests.swift | 93 +++++++ Tests/BloomCoreTests/GitLabCommandTests.swift | 107 +++++++++ .../BloomCoreTests/GitLabDecodingTests.swift | 137 +++++++++++ .../fixtures/gitlab/jobs-failed-pipeline.json | 200 +++++++++++++++ .../gitlab/mr-draft-conflicting-fork.json | 31 +++ Tests/fixtures/gitlab/mr-list-open.json | 87 +++++++ Tests/fixtures/gitlab/mr-merged.json | 31 +++ Tests/fixtures/gitlab/mr-not-approved.json | 31 +++ .../gitlab/mr-unresolved-discussions.json | 31 +++ 29 files changed, 1504 insertions(+), 40 deletions(-) create mode 100644 Sources/BloomCore/GitHub/Forge.swift create mode 100644 Sources/BloomCore/GitHub/ForgeRouting.swift create mode 100644 Sources/BloomCore/GitHub/MergeBlocker.swift create mode 100644 Sources/BloomCore/GitLab/GitLab.swift create mode 100644 Sources/BloomCore/GitLab/GitLabDecoding.swift create mode 100644 Sources/BloomCore/GitLab/GitLabProject.swift create mode 100644 Tests/BloomCoreTests/ForgeRoutingTests.swift create mode 100644 Tests/BloomCoreTests/GitLabCommandTests.swift create mode 100644 Tests/BloomCoreTests/GitLabDecodingTests.swift create mode 100644 Tests/fixtures/gitlab/jobs-failed-pipeline.json create mode 100644 Tests/fixtures/gitlab/mr-draft-conflicting-fork.json create mode 100644 Tests/fixtures/gitlab/mr-list-open.json create mode 100644 Tests/fixtures/gitlab/mr-merged.json create mode 100644 Tests/fixtures/gitlab/mr-not-approved.json create mode 100644 Tests/fixtures/gitlab/mr-unresolved-discussions.json diff --git a/Sources/Bloom/State/Bridges.swift b/Sources/Bloom/State/Bridges.swift index a490c8c3e..ace900d88 100644 --- a/Sources/Bloom/State/Bridges.swift +++ b/Sources/Bloom/State/Bridges.swift @@ -12,6 +12,9 @@ enum GitHubBridge { try await DirectPullRequestCreation.create(for: workspace) } static func readPullRequest(for workspace: Workspace, maxAge: Duration = .zero) async -> PullRequestRead { + guard await ForgeResolver.forge(for: workspace.path) == .gitHub else { + return await GitLab.readPullRequest(for: workspace, maxAge: maxAge) + } let availability = await GitHubAvailability.shared.check() if availability == .notInstalled { return .unavailable(GitHubReadFailure(reason: .unavailable, message: "Install the GitHub CLI to refresh pull requests.")) @@ -19,7 +22,7 @@ enum GitHubBridge { guard availability == .ready else { return .unavailable(GitHubReadFailure(reason: .authentication, message: "Connect GitHub to refresh pull requests.")) } - return await ForgeResolver.client(for: workspace.path).readPullRequest(for: workspace, maxAge: maxAge) + return await GitHub.readPullRequest(for: workspace, maxAge: maxAge) } /// - Parameter maxAge: how old an answer from the last `gh pr view` may be and still be used. diff --git a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift index 97878e273..6f7a28318 100644 --- a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift +++ b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift @@ -15,7 +15,10 @@ extension WorkspaceModel { await checkFailures.send(run, in: self) } - var gitHubReadiness: GitHubAvailability.State { GitHubAvailability.shared.state } + /// GitLab reports its own access through the read, so gh's state says nothing about it. + var gitHubReadiness: GitHubAvailability.State { + forge == .gitLab ? .ready : GitHubAvailability.shared.state + } func continueAfterMerge(_ pullRequest: PullRequest) async -> AppModel.ContinuationOutcome { await app.continueAfterMerge(workspace, pullRequest: pullRequest) diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index 904b55292..1b107cbbc 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -203,6 +203,8 @@ final class WorkspaceModel { } var isLoadingPullRequest = false + /// Which forge this workspace's repository answered to on the last refresh. + var forge: Forge = .gitHub /// Whether any refresh has come back for this workspace this launch, whatever it said. /// /// Not the same question as `pullRequest != nil`, and that is the whole point: "this branch @@ -2454,6 +2456,8 @@ final class WorkspaceModel { ) { isLoadingPullRequest = true } + // Before the read is awaited, so the supersession guard below still has nothing after it. + forge = await ForgeResolver.forge(for: asked.path) let read = await task.value diff --git a/Sources/Bloom/Views/Inspector/GitHubSignIn.swift b/Sources/Bloom/Views/Inspector/GitHubSignIn.swift index b4499faf7..60d886114 100644 --- a/Sources/Bloom/Views/Inspector/GitHubSignIn.swift +++ b/Sources/Bloom/Views/Inspector/GitHubSignIn.swift @@ -49,6 +49,11 @@ final class GitHubSignIn { /// cannot be undone may be handed to this. func run(directory: String, action: @escaping @MainActor () -> Void) { Task { + // A GitLab project never needs gh, and says what glab needs through its own reads. + guard await ForgeResolver.forge(for: directory) == .gitHub else { + action() + return + } let state = await GitHubAvailability.shared.check() guard state != .ready else { action() diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index bf5ff0050..03905d191 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -392,7 +392,7 @@ public struct WorkspaceMergeTool: BridgeToolHandling { ) else { // gh answered and there is nothing, but only if gh could answer at all. Asked // second because it costs a subprocess and the common case never needs it. - let access = await forge.access() + let access = await forge.access(in: workspace.path) return access == .ready ? .noPullRequest : .unavailable(access) } let local = try? await Git.localWork(worktree: workspace.path) @@ -400,7 +400,7 @@ public struct WorkspaceMergeTool: BridgeToolHandling { } catch { // gh threw. Which of the two it is decides the advice, so it is asked rather than // assumed: an unusable gh is permanent and a failed call usually is not. - let access = await forge.access() + let access = await forge.access(in: workspace.path) return access == .ready ? .failed(plainly(error)) : .unavailable(access) } } diff --git a/Sources/BloomCore/GitHub/CheckFailureHandoff.swift b/Sources/BloomCore/GitHub/CheckFailureHandoff.swift index 1ee9829ba..ae366a1ef 100644 --- a/Sources/BloomCore/GitHub/CheckFailureHandoff.swift +++ b/Sources/BloomCore/GitHub/CheckFailureHandoff.swift @@ -25,17 +25,22 @@ public enum CheckFailureHandoff { public var runID: String /// The job inside that run, when the URL named one. public var jobID: String? + /// The GitLab project the job ran in, which for a merge request from a fork is the fork. + /// Nil on GitHub, whose run ids are found through the repository gh is pointed at. + public var project: String? - public init(runID: String, jobID: String? = nil) { + public init(runID: String, jobID: String? = nil, project: String? = nil) { self.runID = runID self.jobID = jobID + self.project = project } } public static func logTarget(detailsURL: String?) -> LogTarget? { - guard let detailsURL, let url = URL(string: detailsURL), - let host = url.host?.lowercased(), host == "github.com" || host.hasSuffix(".github.com") - else { return nil } + guard let detailsURL, let url = URL(string: detailsURL), let host = url.host?.lowercased() else { + return nil + } + guard host == "github.com" || host.hasSuffix(".github.com") else { return gitLabJob(url) } let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) guard let runs = parts.firstIndex(of: "runs"), runs > 0, parts[runs - 1] == "actions", @@ -55,6 +60,19 @@ public enum CheckFailureHandoff { return LogTarget(runID: runID, jobID: jobID) } + /// A GitLab job page, `https:////-/jobs/`. The job is its own run. + private static func gitLabJob(_ url: URL) -> LogTarget? { + let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard let separator = parts.firstIndex(of: "-"), separator >= 2, + parts.indices.contains(separator + 2), parts[separator + 1] == "jobs" + else { return nil } + let jobID = parts[separator + 2] + guard !jobID.isEmpty, jobID.allSatisfy(\.isNumber) else { return nil } + return LogTarget( + runID: jobID, jobID: jobID, project: parts[.. String { + switch self { + case .gitHub: "#\(number)" + case .gitLab: "!\(number)" + } + } +} diff --git a/Sources/BloomCore/GitHub/ForgeClient.swift b/Sources/BloomCore/GitHub/ForgeClient.swift index 2d610276b..79dcae209 100644 --- a/Sources/BloomCore/GitHub/ForgeClient.swift +++ b/Sources/BloomCore/GitHub/ForgeClient.swift @@ -3,8 +3,7 @@ import Foundation /// The pull request operations more than one forge answers. Each requirement takes only what its /// callers pass, so every default stays on `GitHub` rather than being restated here. public protocol ForgeClient { - static func access() async -> GitHubAccess - static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead + static func access(in directory: String) async -> GitHubAccess static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? static func checks(for workspace: Workspace) async throws -> [CheckRun]? @@ -18,14 +17,11 @@ public protocol ForgeClient { /// `GitHub` as a `ForgeClient`. It only forwards. public enum GitHubForge: ForgeClient { - public static func access() async -> GitHubAccess { + /// gh signs in once for every repository, so the directory changes nothing here. + public static func access(in directory: String) async -> GitHubAccess { await GitHub.access() } - public static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead { - await GitHub.readPullRequest(for: workspace, maxAge: maxAge) - } - public static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? { try await GitHub.pullRequest(for: workspace, maxAge: maxAge) } @@ -45,7 +41,9 @@ public enum GitHubForge: ForgeClient { public static func checkRunLog( _ target: CheckFailureHandoff.LogTarget, worktree: String ) async throws -> String { - try await GitHub.checkRunLog(target, worktree: worktree) + // A GitLab job's page, on a repository GitHub reports for: not a run gh can find. + guard target.project == nil else { throw GitHubError("This check's log is not on GitHub.") } + return try await GitHub.checkRunLog(target, worktree: worktree) } public static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] { @@ -64,14 +62,3 @@ public enum GitHubForge: ForgeClient { try await GitHub.checkoutPullRequest(number: number, into: worktree, localBranch: localBranch) } } - -/// Which forge a repository's pull requests are asked of. -/// -/// Every repository answers GitHub, which is the whole of what Bloom speaks today. The directory -/// is taken now so that callers already say which repository they mean when a second answer -/// arrives. -public enum ForgeResolver { - public static func client(for directory: String) async -> any ForgeClient.Type { - GitHubForge.self - } -} diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift new file mode 100644 index 000000000..3a5b3c552 --- /dev/null +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -0,0 +1,143 @@ +import Foundation + +/// Which forge a repository's pull requests are asked of: GitLab only on positive evidence, +/// GitHub otherwise. A GitHub remote anywhere wins, and nobody is asked. `git.forge` in a +/// settings file overrules detection. +public enum ForgeRouting { + public enum Decision: Sendable, Equatable { + case forge(Forge) + /// Neither GitHub nor gitlab.com: GitLab only if glab is signed in to the host. + case askGlab(host: String) + } + + public static func decide(declared: Forge?, baseRemoteURL: String?, remoteURLs: [String]) -> Decision { + if let declared { return .forge(declared) } + guard let base = GitHub.repositoryHost(baseRemoteURL) else { return .forge(.gitHub) } + if isGitHub(base) || remoteURLs.compactMap({ GitHub.repositoryHost($0) }).contains(where: isGitHub) { + return .forge(.gitHub) + } + if isGitLabCom(base) { return .forge(.gitLab) } + return .askGlab(host: base) + } + + /// Any host naming GitHub, so Enterprise hosts and SSH aliases like `github-work` are never + /// probed with glab. + static func isGitHub(_ host: String) -> Bool { + host.contains("github") + } + + static func isGitLabCom(_ host: String) -> Bool { + host == "gitlab.com" || host.hasSuffix(".gitlab.com") + } +} + +/// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes. +public enum ForgeResolver { + private static let resolutions = ForgeResolutions() + + public static func client(for directory: String) async -> any ForgeClient.Type { + switch await forge(for: directory) { + case .gitHub: GitHubForge.self + case .gitLab: GitLab.self + } + } + + public static func forge(for directory: String) async -> Forge { + let paths = Git.repositoryPaths(in: directory) + let checkout = mainCheckout(paths) ?? directory + let configPath = paths.map { ($0.commonDirectory as NSString).appendingPathComponent("config") } + let fingerprint = fingerprint(of: [configPath].compactMap { $0 } + SettingsLoader.candidatePaths(repo: checkout)) + if let known = await resolutions.forge(for: directory, fingerprint: fingerprint) { return known } + + let forge = await resolve(directory: directory, checkout: checkout, configPath: configPath) + await resolutions.store(forge, for: directory, fingerprint: fingerprint) + return forge + } + + private static func resolve(directory: String, checkout: String, configPath: String?) async -> Forge { + if let declared = SettingsLoader.load(repo: checkout).forge { return declared } + // Settled from the config file's text where possible, so a GitHub user pays no process. + let written = configPath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) } ?? "" + let hosts = remoteURLs(in: written).compactMap { GitHub.repositoryHost($0) } + if hosts.contains(where: ForgeRouting.isGitHub) { return .gitHub } + if !hosts.contains(where: ForgeRouting.isGitLabCom), GlabSignIn.isInstalled() == false { return .gitHub } + + let config = (try? await Git.repositoryConfiguration(in: directory)) ?? [:] + let context = try? await Git.repositoryContext(in: directory) + let remotes = config.filter { $0.key.hasPrefix("remote.") && $0.key.hasSuffix(".url") }.map(\.value) + switch ForgeRouting.decide(declared: nil, baseRemoteURL: context?.baseRemoteURL, remoteURLs: remotes) { + case .forge(let decided): return decided + case .askGlab(let host): return await GlabSignIn.shared.isSignedIn(to: host) ? .gitLab : .gitHub + } + } + + /// The `url = ` values of a git config file, without git. Includes and `insteadOf` are not + /// followed; anything they hide reaches the full resolution instead. + static func remoteURLs(in config: String) -> [String] { + config.split(whereSeparator: \.isNewline).compactMap { line in + let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2, parts[0].lowercased() == "url" else { return nil } + return parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + /// The project's own checkout, where `settings.local.toml` lives. + static func mainCheckout(_ paths: GitRepositoryPaths?) -> String? { + guard let common = paths?.commonDirectory, (common as NSString).lastPathComponent == ".git" else { return nil } + return (common as NSString).deletingLastPathComponent + } + + private static func fingerprint(of paths: [String]) -> [Date?] { + paths.map { (try? FileManager.default.attributesOfItem(atPath: $0))?[.modificationDate] as? Date } + } +} + +private actor ForgeResolutions { + private var entries: [String: (fingerprint: [Date?], forge: Forge)] = [:] + + func forge(for directory: String, fingerprint: [Date?]) -> Forge? { + guard let entry = entries[directory], entry.fingerprint == fingerprint else { return nil } + return entry.forge + } + + func store(_ forge: Forge, for directory: String, fingerprint: [Date?]) { + entries[directory] = (fingerprint, forge) + } +} + +/// Whether glab is signed in to a host. One probe per host at a time; a yes is kept for the +/// launch, a no only for a few minutes, because a VPN that was down comes back. +actor GlabSignIn { + static let shared = GlabSignIn() + private static let timeout = Duration.seconds(10) + private static let refusalLifetime = Duration.seconds(300) + + private var answers: [String: (signedIn: Bool, at: ContinuousClock.Instant)] = [:] + private var probes: [String: Task] = [:] + + static func isInstalled() -> Bool { + GitLab.commandOverride != nil || Shell.which("glab") != nil + } + + func isSignedIn(to host: String) async -> Bool { + if let known = answers[host], known.signedIn || known.at.duration(to: .now) < Self.refusalLifetime { + return known.signedIn + } + if let running = probes[host] { return await running.value } + let probe = Task { await Self.probe(host) } + probes[host] = probe + let answer = await probe.value + probes[host] = nil + answers[host] = (answer, .now) + return answer + } + + private static func probe(_ host: String) async -> Bool { + let arguments = ["auth", "status", "--hostname", host] + if let override = GitLab.commandOverride { + return (try? await override(arguments, nil))?.ok ?? false + } + guard Shell.which("glab") != nil else { return false } + return (try? await Shell.run("glab", arguments, timeout: timeout))?.ok ?? false + } +} diff --git a/Sources/BloomCore/GitHub/MergeBlocker.swift b/Sources/BloomCore/GitHub/MergeBlocker.swift new file mode 100644 index 000000000..b8aff7018 --- /dev/null +++ b/Sources/BloomCore/GitHub/MergeBlocker.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Why GitLab will refuse a merge request that is open, not a draft and not conflicting, read +/// from `detailed_merge_status`. The GitHub decoder never fills it. +public enum MergeBlocker: Sendable, Hashable, Codable { + case approvalRequired + case changesRequested + case unresolvedDiscussions + case needsRebase + case pipelineMustPass + case pipelineRunning + case blockedByMergeRequest + case notBefore + case checking + /// A project rule in GitLab's words. An unknown status lands here: a refusal, never mergeable. + case policy(String) + + /// Nil for `mergeable` and for what `PullRequest` already says (conflict, draft, not open). + public init?(detailedMergeStatus status: String) { + switch status { + case "mergeable", "conflict", "draft_status", "not_open", "": return nil + case "not_approved": self = .approvalRequired + case "requested_changes": self = .changesRequested + case "discussions_not_resolved": self = .unresolvedDiscussions + case "need_rebase": self = .needsRebase + case "ci_must_pass", "status_checks_must_pass", "security_policy_pipeline_check": + self = .pipelineMustPass + case "ci_still_running": self = .pipelineRunning + case "merge_request_blocked": self = .blockedByMergeRequest + case "merge_time": self = .notBefore + case "checking", "unchecked", "preparing", "approvals_syncing": self = .checking + default: self = .policy(status) + } + } + + /// The strip's headline. + public var text: String { + switch self { + case .approvalRequired: "Waiting for approval" + case .changesRequested: "Changes requested" + case .unresolvedDiscussions: "Unresolved threads" + case .needsRebase: "Needs rebase" + case .pipelineMustPass: "Pipeline must pass" + case .pipelineRunning: "Pipeline running" + case .blockedByMergeRequest: "Blocked by another merge request" + case .notBefore: "Scheduled" + case .checking: "Checking mergeability" + case .policy: "Blocked by GitLab" + } + } + + /// Why the merge button is off, for its tooltip. + public var reason: String { + switch self { + case .approvalRequired: "This merge request needs an approval first." + case .changesRequested: "A reviewer has requested changes." + case .unresolvedDiscussions: "Resolve the open threads first." + case .needsRebase: "Rebase this branch onto its target first." + case .pipelineMustPass: "The pipeline has to succeed before this can merge." + case .pipelineRunning: "Wait for the pipeline to finish." + case .blockedByMergeRequest: "Another merge request has to merge first." + case .notBefore: "This merge request is set to merge later." + case .checking: "GitLab is still working out whether this can merge." + case .policy(let status): + "GitLab will not merge this yet: \(status.replacingOccurrences(of: "_", with: " "))." + } + } +} diff --git a/Sources/BloomCore/GitHub/PullRequestNumber.swift b/Sources/BloomCore/GitHub/PullRequestNumber.swift index 7da6e5a38..6eea3be57 100644 --- a/Sources/BloomCore/GitHub/PullRequestNumber.swift +++ b/Sources/BloomCore/GitHub/PullRequestNumber.swift @@ -28,7 +28,8 @@ public enum PullRequestNumber { // Zero is a real value rather than a hypothetical: `GitHub.decodePullRequest` reads a // payload with no `number` in it as 0, which an older gh produces, and a 0 written here // would be asked about by `gh pr view 0` on every poll for the rest of the row's life. - guard let found, found.number > 0, found.number != recorded else { return nil } + // GitHub's only: GitLab needs no recorded number, and an iid here would be read as one. + guard let found, found.forge == .gitHub, found.number > 0, found.number != recorded else { return nil } return found.number } diff --git a/Sources/BloomCore/GitHub/PullRequestStatus.swift b/Sources/BloomCore/GitHub/PullRequestStatus.swift index 741bb1ec2..8cdc9665f 100644 --- a/Sources/BloomCore/GitHub/PullRequestStatus.swift +++ b/Sources/BloomCore/GitHub/PullRequestStatus.swift @@ -202,7 +202,7 @@ public extension PullRequest { tone: .merged, text: "Merged", canMerge: false, - blockedReason: "This pull request is already merged." + blockedReason: "This \(forge.request) is already merged." ) } if isClosed { @@ -210,7 +210,7 @@ public extension PullRequest { tone: .neutral, text: "Closed", canMerge: false, - blockedReason: "This pull request was closed without merging." + blockedReason: "This \(forge.request) was closed without merging." ) } // Conflicts outrank the check rollup: green checks on a branch that cannot be applied to @@ -236,10 +236,19 @@ public extension PullRequest { text: "Draft", detail: checksDetail, canMerge: false, - blockedReason: "This pull request is still a draft.", + blockedReason: "This \(forge.request) is still a draft.", remedy: .markReadyForReview ) } + if let blocker = blockers.first { + return PullRequestStatus( + tone: blocker == .checking ? .neutral : .warning, + text: blocker.text, + detail: checksDetail, + canMerge: false, + blockedReason: blocker.reason + ) + } return PullRequestStatus( tone: openTone, text: openHeadline, detail: checksDetail, canMerge: true @@ -247,16 +256,16 @@ public extension PullRequest { } func mergeConfirmationTitle(base: String) -> String { - "Merge #\(number) into \(base)?" + "Merge \(forge.reference(number)) into \(base)?" } var mergeConfirmationMessage: String { - "Your agent will merge this pull request in the chat, where you can follow its progress." + "Your agent will merge this \(forge.request) in the chat, where you can follow its progress." } func mergeBranchDeletionMessage(deletesBranch: Bool) -> String? { guard deletesBranch, !branch.isEmpty else { return nil } - return "After merging, \(branch) will be deleted on GitHub. Your local branch stays." + return "After merging, \(branch) will be deleted on \(forge.name). Your local branch stays." } /// These warnings precede the explanation so a merge of older work or a failing check @@ -265,7 +274,7 @@ public extension PullRequest { var warnings: [String] = [] if let local, local.isAhead { warnings.append( - "GitHub does not have everything in this worktree: " + "\(forge.name) does not have everything in this worktree: " + Self.localDetail(local) + ". None of that is part of what is merged, and the agent is told to leave it" + " alone rather than commit it first." @@ -273,7 +282,7 @@ public extension PullRequest { } if checks == .failing { warnings.append(checksSummary) } if checks == .unavailable { - warnings.append("Bloom could not read this pull request's checks, so it cannot say whether they passed.") + warnings.append("Bloom could not read this \(forge.request)'s checks, so it cannot say whether they passed.") } if hasConflicts { warnings.append("This branch conflicts with \(base).") } return warnings diff --git a/Sources/BloomCore/GitLab/GitLab.swift b/Sources/BloomCore/GitLab/GitLab.swift new file mode 100644 index 000000000..e9f1d0419 --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLab.swift @@ -0,0 +1,227 @@ +import Foundation + +/// The glab boundary, read as `PullRequest` and `CheckRun`. +/// +/// Reads use `glab api` on the versioned REST API with the host and project named, rather than +/// glab's own JSON and remote guessing. No cache yet: a read is two or three small calls. +public enum GitLab: ForgeClient { + @TaskLocal static var commandOverride: (@Sendable ([String], String?) async throws -> ShellResult)? + + private static let readTimeout = Duration.seconds(20) + /// Checking out fetches objects, so it gets what `gh pr checkout` gets. + private static let checkoutTimeout = Duration.seconds(120) + private static let logTimeout = Duration.seconds(90) + private static let candidateLimit = 20 + private static let jobLimit = 100 + private static let listLimit = 30 + + public static func access(in directory: String) async -> GitHubAccess { + guard commandOverride != nil || Shell.which("glab") != nil else { return .notInstalled } + guard let project = await project(in: directory), + let result = try? await run(["auth", "status", "--hostname", project.host], cwd: nil) + else { return .signedOut } + return result.ok ? .ready : .signedOut + } + + public static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead { + guard commandOverride != nil || Shell.which("glab") != nil else { + return .unavailable(GitHubReadFailure( + reason: .unavailable, message: "Install the GitLab CLI to refresh merge requests." + )) + } + do { return .current(try await pullRequest(for: workspace, maxAge: maxAge)) } catch { + let failure = GitHubReadFailure.classify(error) + let refused = failure.reason == .authentication || ((error as? ShellError)?.stderr.contains("401") ?? false) + return .unavailable(refused ? GitHubReadFailure( + reason: .authentication, message: "Connect GitLab again to refresh merge requests." + ) : failure) + } + } + + public static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? { + try await snapshot(for: workspace)?.pullRequest + } + + public static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? { + guard let project = await project(in: worktree) else { return nil } + let candidates = try await mergeRequests(withSource: branch, in: project, worktree: worktree) + .filter { $0.sourceProjectId == $0.targetProjectId } + let chosen = candidates.first { $0.state == "opened" } ?? candidates.first + guard let iid = chosen?.iid else { return nil } + return try await snapshot(number: iid, in: project, worktree: worktree).pullRequest + } + + public static func checks(for workspace: Workspace) async throws -> [CheckRun]? { + try await snapshot(for: workspace)?.runs ?? [] + } + + public static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { + guard pullRequest.isOpen, pullRequest.isDraft else { + throw GitHubError("This merge request is no longer an open draft.") + } + guard let project = await project(in: worktree) else { throw notAProject } + try await check(["mr", "update", String(pullRequest.number), "--ready", "-R", project.webURL], cwd: worktree) + } + + public static func checkRunLog(_ target: CheckFailureHandoff.LogTarget, worktree: String) async throws -> String { + guard let jobID = target.jobID, let projectPath = target.project else { + throw GitHubError("Bloom could not tell which GitLab job this is.") + } + guard let project = await project(in: worktree) else { throw notAProject } + let jobProject = GitLabProject(remote: "https://\(project.host)/\(projectPath)") ?? project + let result = try await run( + api(project.host, "projects/\(jobProject.apiID)/jobs/\(jobID)/trace"), cwd: worktree, timeout: logTimeout + ) + guard result.ok, !result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GitHubError(result.stderr.isEmpty ? "glab returned no log for this job" : result.stderr) + } + return result.stdout + } + + public static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] { + guard let project = await project(in: repoPath) else { return [] } + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests?state=opened&per_page=\(listLimit)"), + cwd: repoPath + ) + let listings = try GitLabDecoding.mergeRequests(from: Data(result.stdout.utf8)).map(GitLabDecoding.listing) + return WorkspaceCheckoutPlan.offered(listings) + } + + public static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing { + guard number > 0 else { throw GitHubError("\(number) is not a merge request number") } + guard let project = await project(in: repoPath) else { throw notAProject } + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests/\(number)"), cwd: repoPath + ) + return GitLabDecoding.listing(try GitLabDecoding.mergeRequest(from: Data(result.stdout.utf8))) + } + + public static func repositorySlug(repoPath: String) async -> String? { + await project(in: repoPath)?.path + } + + public static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws { + guard number > 0 else { throw GitHubError("\(number) is not a merge request number") } + guard Git.isValidBranchName(localBranch) else { + throw GitHubError("'\(localBranch)' is not a valid branch name") + } + guard let project = await project(in: worktree) else { throw notAProject } + try await check( + ["mr", "checkout", String(number), "--branch", localBranch, "-R", project.webURL], + cwd: worktree, timeout: checkoutTimeout + ) + } + + // MARK: Reading one merge request + + struct Snapshot { + let pullRequest: PullRequest + let runs: [CheckRun] + } + + /// The newest merge request from the workspace's head that had not ended before it existed. + /// GitLab filters on the recorded source branch, so this survives the branch being deleted. + static func snapshot(for workspace: Workspace) async throws -> Snapshot? { + guard let project = await project(in: workspace.path) else { return nil } + let head = await GitHub.headBranch(of: workspace) + let checkedOut = await checkedOutMergeRequest(branch: head, worktree: workspace.path) + let candidates = try await mergeRequests(withSource: head, in: project, worktree: workspace.path) + // A fork's merge request with the same branch name is somebody else's, unless this + // worktree was checked out from it. + let matches = candidates.compactMap { candidate -> PullRequestHeadMatch? in + guard let iid = candidate.iid, + candidate.sourceProjectId == candidate.targetProjectId || iid == checkedOut + else { return nil } + let ended = GitLabDecoding.pullRequest(candidate, runs: []).closedAt + return PullRequestHeadMatch(number: iid, closedAt: ended) + } + guard let chosen = PullRequestOwnership.choose( + from: matches, startedAt: workspace.createdAt, checkedOutAs: checkedOut + ) else { return nil } + return try await snapshot(number: chosen, in: project, worktree: workspace.path) + } + + private static func snapshot(number: Int, in project: GitLabProject, worktree: String) async throws -> Snapshot { + let detail = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests/\(number)"), cwd: worktree + ) + let payload = try GitLabDecoding.mergeRequest(from: Data(detail.stdout.utf8)) + guard let pipeline = payload.headPipeline else { + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: []), runs: []) + } + let pipelineProject = pipeline.projectId.map(String.init) ?? project.apiID + // An unreadable pipeline (a private fork) means unknown checks, not no merge request. + guard let jobs = try? await check( + api(project.host, "projects/\(pipelineProject)/pipelines/\(pipeline.id)/jobs?per_page=\(jobLimit)"), + cwd: worktree + ), let runs = try? GitLabDecoding.jobs(from: Data(jobs.stdout.utf8)) else { + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: nil), runs: []) + } + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: runs), runs: runs) + } + + private static func mergeRequests( + withSource branch: String, in project: GitLabProject, worktree: String + ) async throws -> [GitLabDecoding.MergeRequestPayload] { + guard Git.isValidBranchName(branch) else { return [] } + let query = "source_branch=\(queryValue(branch))&state=all&order_by=created_at&sort=desc" + + "&per_page=\(candidateLimit)" + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests?\(query)"), cwd: worktree + ) + return try GitLabDecoding.mergeRequests(from: Data(result.stdout.utf8)) + } + + /// The merge request `glab mr checkout` wrote into the branch's config, if it did. + private static func checkedOutMergeRequest(branch: String, worktree: String) async -> Int? { + guard Git.isValidBranchName(branch), + let result = try? await Shell.run("git", ["config", "--get", "branch.\(branch).merge"], cwd: worktree), + result.ok + else { return nil } + let reference = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "refs/merge-requests/" + let suffix = "/head" + guard reference.hasPrefix(prefix), reference.hasSuffix(suffix) else { return nil } + return Int(reference.dropFirst(prefix.count).dropLast(suffix.count)) + } + + // MARK: Running glab + + static func project(in directory: String) async -> GitLabProject? { + let context = try? await Git.repositoryContext(in: directory) + return GitLabProject(remote: context?.baseRemoteURL) + } + + private static let notAProject = GitHubError("This repository has no GitLab remote Bloom can read.") + + private static func api(_ host: String, _ endpoint: String) -> [String] { + ["api", "--hostname", host, endpoint] + } + + private static func queryValue(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))) ?? value + } + + @discardableResult + private static func check( + _ arguments: [String], cwd: String?, timeout: Duration = readTimeout + ) async throws -> ShellResult { + let result = try await run(arguments, cwd: cwd, timeout: timeout) + guard result.ok else { + throw ShellError( + command: "glab " + arguments.joined(separator: " "), + status: result.status, + stderr: result.stderr.isEmpty ? result.stdout : result.stderr + ) + } + return result + } + + private static func run( + _ arguments: [String], cwd: String?, timeout: Duration = readTimeout + ) async throws -> ShellResult { + if let commandOverride { return try await commandOverride(arguments, cwd) } + return try await Shell.run("glab", arguments, cwd: cwd, timeout: timeout) + } +} diff --git a/Sources/BloomCore/GitLab/GitLabDecoding.swift b/Sources/BloomCore/GitLab/GitLabDecoding.swift new file mode 100644 index 000000000..2762505d1 --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabDecoding.swift @@ -0,0 +1,159 @@ +import Foundation + +/// GitLab's REST answers in GitHub's vocabulary (`opened` is `OPEN`, a job is a check run), so +/// everything downstream is shared. What GitHub has no word for goes in `blockers`. +enum GitLabDecoding { + struct MergeRequestPayload: Decodable { + struct Pipeline: Decodable { + let id: Int + /// The project the pipeline ran in, which for a merge request from a fork is the fork. + let projectId: Int? + } + + struct Author: Decodable { + let username: String? + } + + let iid: Int? + let title: String? + let webUrl: String? + let state: String? + let draft: Bool? + let hasConflicts: Bool? + let detailedMergeStatus: String? + let sourceBranch: String? + let targetBranch: String? + /// The source branch's head commit, which a direct merge passes as `--sha`. + let sha: String? + let mergedAt: String? + let closedAt: String? + let sourceProjectId: Int? + let targetProjectId: Int? + let headPipeline: Pipeline? + let author: Author? + } + + struct JobPayload: Decodable { + let id: Int? + let name: String? + let stage: String? + let status: String? + let allowFailure: Bool? + let webUrl: String? + let startedAt: String? + let finishedAt: String? + } + + static func mergeRequest(from data: Data) throws -> MergeRequestPayload { + try decode(MergeRequestPayload.self, from: data) + } + + static func mergeRequests(from data: Data) throws -> [MergeRequestPayload] { + try decode([MergeRequestPayload].self, from: data) + } + + static func jobs(from data: Data) throws -> [CheckRun] { + try decode([JobPayload].self, from: data).map(checkRun) + } + + /// `runs` is nil when the pipeline's jobs could not be read. + static func pullRequest(_ payload: MergeRequestPayload, runs: [CheckRun]?) -> PullRequest { + let detailed = payload.detailedMergeStatus ?? "" + let state = state(payload.state) + let (checks, summary) = runs.map(GitHub.rollup) ?? (.unavailable, GitHub.checksUnavailableSummary) + return PullRequest( + number: payload.iid ?? 0, + title: payload.title ?? "", + url: payload.webUrl ?? "", + state: state, + isDraft: payload.draft ?? false, + mergeable: payload.hasConflicts == true || detailed == "conflict" ? "CONFLICTING" : nil, + checks: checks, + checksSummary: summary, + reviewDecision: reviewDecision(detailed), + branch: payload.sourceBranch ?? "", + baseBranch: payload.targetBranch, + headRevision: payload.sha, + // GitLab leaves `closed_at` null on a merge; ownership needs it set, as GitHub does. + closedAt: date(payload.mergedAt) ?? date(payload.closedAt), + forge: .gitLab, + blockers: state == "OPEN" ? MergeBlocker(detailedMergeStatus: detailed).map { [$0] } ?? [] : [] + ) + } + + static func listing(_ payload: MergeRequestPayload) -> PullRequestListing { + let isFork = payload.sourceProjectId != nil && payload.sourceProjectId != payload.targetProjectId + let author = payload.author?.username ?? "" + return PullRequestListing( + number: payload.iid ?? 0, + title: payload.title ?? "", + author: author, + headRefName: payload.sourceBranch ?? "", + baseRefName: payload.targetBranch ?? "", + isDraft: payload.draft ?? false, + state: state(payload.state), + isCrossRepository: isFork, + // Usually the fork's namespace; only the local branch name uses it. + headRepositoryOwner: isFork ? author : nil + ) + } + + static func state(_ value: String?) -> String { + switch value { + case "opened": "OPEN" + case "merged": "MERGED" + case "closed", "locked": "CLOSED" + default: value?.uppercased() ?? "UNKNOWN" + } + } + + private static func reviewDecision(_ detailed: String) -> String? { + switch detailed { + case "not_approved": "REVIEW_REQUIRED" + case "requested_changes": "CHANGES_REQUESTED" + default: nil + } + } + + private static func checkRun(_ job: JobPayload) -> CheckRun { + let (status, conclusion) = outcome(job.status) + return CheckRun( + name: job.name ?? "Job", + status: status, + conclusion: conclusion, + detailsURL: job.webUrl, + startedAt: date(job.startedAt), + completedAt: date(job.finishedAt), + workflowName: job.stage, + isRequired: !(job.allowFailure ?? false) + ) + } + + private static func outcome(_ status: String?) -> (status: String, conclusion: String?) { + switch status { + case "success": ("COMPLETED", "SUCCESS") + case "failed": ("COMPLETED", "FAILURE") + case "canceled", "canceling": ("COMPLETED", "CANCELLED") + case "skipped", "manual": ("COMPLETED", "SKIPPED") + case "running": ("IN_PROGRESS", nil) + default: ("QUEUED", nil) + } + } + + private static func date(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(value)) + ?? (try? Date.ISO8601FormatStyle(includingFractionalSeconds: false).parse(value)) + } + + private static func decode(_ type: T.Type, from data: Data) throws -> T { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + return try decoder.decode(type, from: data) + } catch { + let raw = String(decoding: data.suffix(1_024), as: UTF8.self) + throw GitHubError("Could not decode GitLab JSON: \(error). Raw JSON tail: \(raw)") + } + } +} diff --git a/Sources/BloomCore/GitLab/GitLabProject.swift b/Sources/BloomCore/GitLab/GitLabProject.swift new file mode 100644 index 000000000..882e5d646 --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabProject.swift @@ -0,0 +1,31 @@ +import Foundation + +/// A GitLab project from a remote: the host and the whole path, since groups nest. +struct GitLabProject: Sendable, Hashable { + let host: String + let path: String + + init?(remote: String?) { + guard let remote, let host = GitHub.repositoryHost(remote) else { return nil } + let rawPath: String + if let url = URL(string: remote), url.host != nil { + rawPath = url.path + } else if let colon = remote.firstIndex(of: ":") { + rawPath = String(remote[remote.index(after: colon)...]) + } else { return nil } + + var segments = rawPath.split(separator: "/").map(String.init) + if let last = segments.last, last.hasSuffix(".git") { segments[segments.count - 1] = String(last.dropLast(4)) } + guard segments.count >= 2, segments.allSatisfy({ !$0.isEmpty && !$0.hasPrefix("-") }) else { return nil } + self.host = host + self.path = segments.joined(separator: "/") + } + + /// The project as the REST API takes it in a path: `group%2Fsubgroup%2Fapp`. + var apiID: String { + path.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))) ?? path + } + + /// What `glab -R` is given, so glab never has to guess the project from the remotes. + var webURL: String { "https://\(host)/\(path)" } +} diff --git a/Sources/BloomCore/Model/Models.swift b/Sources/BloomCore/Model/Models.swift index bce6939d8..c7d1ce1aa 100644 --- a/Sources/BloomCore/Model/Models.swift +++ b/Sources/BloomCore/Model/Models.swift @@ -671,6 +671,10 @@ public struct PullRequest: Sendable, Hashable, Codable { /// A pull request that ended before the workspace asking about it was created belongs to an /// earlier life of that name. See `PullRequestOwnership`. public var closedAt: Date? + /// Which server answered. Every value built before there was a second forge is GitHub's. + public var forge: Forge + /// Why the server will refuse a merge that nothing else here explains. Always empty on GitHub. + public var blockers: [MergeBlocker] public init( number: Int, @@ -685,7 +689,9 @@ public struct PullRequest: Sendable, Hashable, Codable { branch: String = "", baseBranch: String? = nil, headRevision: String? = nil, - closedAt: Date? = nil + closedAt: Date? = nil, + forge: Forge = .gitHub, + blockers: [MergeBlocker] = [] ) { self.number = number self.title = title @@ -700,6 +706,8 @@ public struct PullRequest: Sendable, Hashable, Codable { self.baseBranch = baseBranch self.headRevision = headRevision self.closedAt = closedAt + self.forge = forge + self.blockers = blockers } } diff --git a/Sources/BloomCore/Model/WorkspaceStatus.swift b/Sources/BloomCore/Model/WorkspaceStatus.swift index ff0c66416..f157cddb7 100644 --- a/Sources/BloomCore/Model/WorkspaceStatus.swift +++ b/Sources/BloomCore/Model/WorkspaceStatus.swift @@ -186,7 +186,8 @@ public enum WorkspaceStatus: String, Sendable, Hashable, CaseIterable, Codable { } guard describesPullRequest, let pullRequest else { return label } - var text = "\(label), pull request #\(pullRequest.number)" + let forge = pullRequest.forge + var text = "\(label), \(forge.request) \(forge.reference(pullRequest.number))" if let detail = detail(pullRequest: pullRequest) { text += ": \(detail)" } return text } diff --git a/Sources/BloomCore/Persistence/Settings.swift b/Sources/BloomCore/Persistence/Settings.swift index 4cef0b6ff..2b6da2bc0 100644 --- a/Sources/BloomCore/Persistence/Settings.swift +++ b/Sources/BloomCore/Persistence/Settings.swift @@ -100,6 +100,8 @@ public struct RepoSettings: Sendable, Hashable { /// `http://localhost:$BLOOM_PORT/admin`. A workspace whose setup script wrote an address of /// its own beats this. See `WorkspaceBrowserURL`, which holds both and the order between them. public var browserURL: String? + /// `git.forge`, overruling detection. See `ForgeRouting`. + public var forge: Forge? /// Set by a file inside the repository. Ranks ABOVE the app-level defaults, because pinning /// a model in a project's own settings is a deliberate statement about that project. public var defaultModel: String? @@ -323,6 +325,9 @@ public enum SettingsLoader { break } } + if let forge = toml["git.forge"]?.stringValue.flatMap(Forge.init(rawValue:)) { + settings.forge = forge + } if let delete = toml["git.delete_branch_on_archive"]?.boolValue { settings.deleteBranchOnArchive = delete note(.deleteBranchOnArchive) diff --git a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift index 149e6cf66..4386f3003 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift @@ -180,7 +180,7 @@ public struct WorkspaceCheckoutOptions: Sendable { } let forge = await ForgeResolver.client(for: repoPath) - let access = await forge.access() + let access = await forge.access(in: repoPath) guard access == .ready else { return options(access: access) } do { diff --git a/Tests/BloomCoreTests/ForgeClientTests.swift b/Tests/BloomCoreTests/ForgeClientTests.swift index f92f2ec3b..5cdecdc62 100644 --- a/Tests/BloomCoreTests/ForgeClientTests.swift +++ b/Tests/BloomCoreTests/ForgeClientTests.swift @@ -4,12 +4,25 @@ import Testing @Suite("Forge client", .scratchDirectory) struct ForgeClientTests { - @Test("every repository is asked through GitHub") + @Test("a directory that is not a repository is asked through GitHub") func resolvesGitHub() async { let client = await ForgeResolver.client(for: TestScratch.unique("forge")) #expect(ObjectIdentifier(client) == ObjectIdentifier(GitHubForge.self)) } + @Test("a GitLab job's page is never handed to gh") + func gitLabJobOnGitHub() async { + let target = CheckFailureHandoff.LogTarget(runID: "1", jobID: "1", project: "group/app") + await #expect(throws: GitHubError.self) { + try await GitHub.$commandOverride.withValue({ _, _ in + Issue.record("gh should not be asked for a GitLab job") + return ShellResult(status: 0, stdout: "log", stderr: "") + }) { + try await GitHubForge.checkRunLog(target, worktree: TestScratch.unique("forge-log")) + } + } + } + @Test("the GitHub client runs the gh command GitHub runs") func forwards() async throws { let worktree = TestScratch.unique("forge-forward") diff --git a/Tests/BloomCoreTests/ForgeRoutingTests.swift b/Tests/BloomCoreTests/ForgeRoutingTests.swift new file mode 100644 index 000000000..bbc5f9327 --- /dev/null +++ b/Tests/BloomCoreTests/ForgeRoutingTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Forge routing", .scratchDirectory) +struct ForgeRoutingTests { + private static let cases: [(declared: Forge?, base: String?, remotes: [String], decision: ForgeRouting.Decision)] = [ + (nil, "git@github.com:acme/app.git", [], .forge(.gitHub)), + (nil, "git@gitlab.com:group/app.git", ["git@github.com:acme/app.git"], .forge(.gitHub)), + (nil, "git@gitlab.com:group/sub/app.git", [], .forge(.gitLab)), + (nil, "https://gitlab.example.com/group/app.git", [], .askGlab(host: "gitlab.example.com")), + (nil, "git@ghe.corp.example:acme/app.git", [], .askGlab(host: "ghe.corp.example")), + (nil, "git@github-work:acme/app.git", [], .forge(.gitHub)), + (nil, "https://github.corp.example/acme/app.git", [], .forge(.gitHub)), + (nil, nil, [], .forge(.gitHub)), + (.gitLab, "git@github.com:acme/app.git", [], .forge(.gitLab)), + (.gitHub, "git@gitlab.com:group/app.git", [], .forge(.gitHub)), + ] + + @Test("GitLab only on positive evidence, GitHub everywhere else", arguments: cases) + func decide(declared: Forge?, base: String?, remotes: [String], decision: ForgeRouting.Decision) { + #expect(ForgeRouting.decide(declared: declared, baseRemoteURL: base, remoteURLs: remotes) == decision) + } + + @Test("a host glab is not signed in to stays on GitHub") + func unsignedHost() async { + let host = "gitlab-\(UUID().uuidString).example" + let signedIn = await GitLab.$commandOverride.withValue({ _, _ in + ShellResult(status: 1, stdout: "", stderr: "not logged in") + }) { + await GlabSignIn.shared.isSignedIn(to: host) + } + #expect(signedIn == false) + } + + @Test("a host glab is signed in to is GitLab's") + func signedHost() async { + let host = "gitlab-\(UUID().uuidString).example" + let signedIn = await GitLab.$commandOverride.withValue({ arguments, _ in + ShellResult(status: arguments == ["auth", "status", "--hostname", host] ? 0 : 1, stdout: "", stderr: "") + }) { + await GlabSignIn.shared.isSignedIn(to: host) + } + #expect(signedIn) + } + + @Test("a repository resolves from its remotes, and again when they change", .tags(.git)) + func resolvesRepository() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/sub/app.git"], cwd: repo.path) + #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) + + try await Shell.check("git", ["remote", "add", "mirror", "git@github.com:acme/app.git"], cwd: repo.path) + #expect(await ForgeResolver.forge(for: repo.path) == .gitHub) + + try repo.write(".bloom/settings.toml", "[git]\nforge = \"gitlab\"\n") + #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) + } + + @Test("remote addresses are read from git's config file without git") + func remoteURLs() { + let config = "[remote \"origin\"]\n\turl = git@github.com:acme/app.git\n\tpushurl = x\n[remote \"up\"]\n\turl=\"https://gitlab.com/g/a\"\n" + #expect(ForgeResolver.remoteURLs(in: config) == ["git@github.com:acme/app.git", "https://gitlab.com/g/a"]) + } + + @Test("a project's settings can name its forge") + func declared() throws { + let repo = TestScratch.unique("forge-settings") + try FileManager.default.createDirectory(atPath: "\(repo)/.bloom", withIntermediateDirectories: true) + try "[git]\nforge = \"gitlab\"\n".write(toFile: "\(repo)/.bloom/settings.toml", atomically: true, encoding: .utf8) + #expect(SettingsLoader.load(repo: repo).forge == .gitLab) + } + + @Test("a GitLab remote names its whole namespace", arguments: [ + (remote: "git@gitlab.com:group/sub/app.git", host: "gitlab.com", path: "group/sub/app", apiID: "group%2Fsub%2Fapp"), + (remote: "https://gitlab.example.com/group/app", host: "gitlab.example.com", path: "group/app", apiID: "group%2Fapp"), + (remote: "ssh://git@gitlab.example.com:2222/group/app.git", host: "gitlab.example.com", path: "group/app", apiID: "group%2Fapp"), + ]) + func project(remote: String, host: String, path: String, apiID: String) throws { + let project = try #require(GitLabProject(remote: remote)) + #expect(project.host == host) + #expect(project.path == path) + #expect(project.apiID == apiID) + #expect(project.webURL == "https://\(host)/\(path)") + } + + @Test("a remote with one segment is no project") + func noProject() { + #expect(GitLabProject(remote: "https://gitlab.com/group") == nil) + #expect(GitLabProject(remote: nil) == nil) + } +} diff --git a/Tests/BloomCoreTests/GitLabCommandTests.swift b/Tests/BloomCoreTests/GitLabCommandTests.swift new file mode 100644 index 000000000..72f287313 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabCommandTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab commands", .tags(.git), .scratchDirectory) +struct GitLabCommandTests { + @Test("a branch's merge request is found by source branch, then read with its pipeline's jobs") + func readsByBranch() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/sub/app.git"], cwd: repo.path) + let detail = try fixture("mr-not-approved.json") + let jobs = try fixture("jobs-failed-pipeline.json") + let log = GlabLog() + + let found = try await GitLab.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + let endpoint = arguments.last ?? "" + if endpoint.contains("/jobs") { return ShellResult(status: 0, stdout: jobs, stderr: "") } + if endpoint.contains("source_branch=") { return ShellResult(status: 0, stdout: "[\(detail)]", stderr: "") } + return ShellResult(status: 0, stdout: detail, stderr: "") + }) { + try await GitLab.pullRequest(forBranch: "feature/x", worktree: repo.path) + } + + #expect(found?.number == 4021) + #expect(found?.checks == .failing) + #expect(await log.commands == [ + ["api", "--hostname", "gitlab.com", + "projects/group%2Fsub%2Fapp/merge_requests?source_branch=feature%2Fx&state=all&order_by=created_at&sort=desc&per_page=20"], + ["api", "--hostname", "gitlab.com", "projects/group%2Fsub%2Fapp/merge_requests/4021"], + ["api", "--hostname", "gitlab.com", "projects/34675721/pipelines/2923303483/jobs?per_page=100"], + ]) + } + + @Test("a fork's merge request on the same branch name is not this branch's") + func forkIsNotOurs() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:gitlab-org/cli.git"], cwd: repo.path) + let fork = try fixture("mr-draft-conflicting-fork.json") + let log = GlabLog() + + let found = try await GitLab.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + return ShellResult(status: 0, stdout: "[\(fork)]", stderr: "") + }) { + try await GitLab.pullRequest(forBranch: "8591-iteration-list-total", worktree: repo.path) + } + #expect(found == nil) + #expect(await log.commands.count == 1) + } + + @Test("a pipeline whose jobs cannot be read leaves the checks unknown, not the merge request") + func unreadableJobs() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:gitlab-org/cli.git"], cwd: repo.path) + let detail = try fixture("mr-not-approved.json") + + let found = try await GitLab.$commandOverride.withValue({ arguments, _ in + let endpoint = arguments.last ?? "" + if endpoint.contains("/jobs") { return ShellResult(status: 1, stdout: "", stderr: "403 Forbidden") } + if endpoint.contains("source_branch=") { return ShellResult(status: 0, stdout: "[\(detail)]", stderr: "") } + return ShellResult(status: 0, stdout: detail, stderr: "") + }) { + try await GitLab.pullRequest(forBranch: "feature", worktree: repo.path) + } + #expect(found?.number == 4021) + #expect(found?.checks == .unavailable) + } + + @Test("marking ready and checking out name the project rather than letting glab guess") + func actionsNameTheProject() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "https://gitlab.example.com/group/app.git"], cwd: repo.path) + let draft = PullRequest(number: 7, title: "", url: "", state: "OPEN", isDraft: true, forge: .gitLab) + let log = GlabLog() + + try await GitLab.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + return ShellResult(status: 0, stdout: "", stderr: "") + }) { + try await GitLab.markReadyForReview(draft, worktree: repo.path) + try await GitLab.checkoutPullRequest(number: 7, into: repo.path, localBranch: "review/7") + } + + #expect(await log.commands == [ + ["mr", "update", "7", "--ready", "-R", "https://gitlab.example.com/group/app"], + ["mr", "checkout", "7", "--branch", "review/7", "-R", "https://gitlab.example.com/group/app"], + ]) + } + + private func fixture(_ name: String) throws -> String { + let url = try #require(bloomFixtureURL("gitlab/\(name)")) + return try String(contentsOf: url, encoding: .utf8) + } +} + +private actor GlabLog { + private(set) var commands: [[String]] = [] + + func append(_ arguments: [String]) { + commands.append(arguments) + } +} diff --git a/Tests/BloomCoreTests/GitLabDecodingTests.swift b/Tests/BloomCoreTests/GitLabDecodingTests.swift new file mode 100644 index 000000000..61d6e031a --- /dev/null +++ b/Tests/BloomCoreTests/GitLabDecodingTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import BloomCore + +/// Against real REST answers from gitlab.com (`gitlab-org/cli`, 2026-10-08), trimmed to the +/// fields Bloom reads and with authors anonymised. See `Tests/fixtures/gitlab`. +@Suite("GitLab decoding") +struct GitLabDecodingTests { + @Test("an open merge request waiting for approval, with a failed pipeline") + func waitingForApproval() throws { + let pullRequest = try decode("mr-not-approved.json", jobs: "jobs-failed-pipeline.json") + #expect(pullRequest.number == 4021) + #expect(pullRequest.state == "OPEN") + #expect(pullRequest.forge == .gitLab) + #expect(pullRequest.branch == "phikai/8524-validate-api-repo-urls") + #expect(pullRequest.baseBranch == "main") + #expect(pullRequest.headRevision == "d7be98ed462c3011d7fd47a92f0dd1257ec6fdbb") + #expect(pullRequest.reviewDecision == "REVIEW_REQUIRED") + #expect(pullRequest.blockers == [.approvalRequired]) + #expect(pullRequest.checks == .failing) + #expect(pullRequest.checksSummary == "1 required check failed") + #expect(pullRequest.status.text == "Waiting for approval") + #expect(pullRequest.status.canMerge == false) + #expect(pullRequest.status.blockedReason == "This merge request needs an approval first.") + } + + @Test("a conflict outranks the draft, as it does on GitHub") + func draftAndConflicting() throws { + let pullRequest = try decode("mr-draft-conflicting-fork.json") + #expect(pullRequest.isDraft) + #expect(pullRequest.hasConflicts) + #expect(pullRequest.blockers.isEmpty) + #expect(pullRequest.status.text == "Merge conflicts") + } + + @Test("a merged merge request ended when it merged") + func merged() throws { + let pullRequest = try decode("mr-merged.json") + #expect(pullRequest.isMerged) + #expect(pullRequest.closedAt != nil) + #expect(pullRequest.blockers.isEmpty) + #expect(pullRequest.status.blockedReason == "This merge request is already merged.") + #expect(WorkspaceStatus.merged.summary(pullRequest: pullRequest) == "Merged, merge request !4015: No checks") + } + + @Test("unresolved threads block the merge") + func unresolvedThreads() throws { + let pullRequest = try decode("mr-unresolved-discussions.json") + #expect(pullRequest.blockers == [.unresolvedDiscussions]) + #expect(pullRequest.status.text == "Unresolved threads") + } + + @Test("jobs become check runs, required unless allowed to fail") + func jobs() throws { + let runs = try GitLabDecoding.jobs(from: fixture("jobs-failed-pipeline.json")) + #expect(runs.count == 18) + let failed = try #require(runs.first { $0.name == "tests:unit" }) + #expect(failed.status == "COMPLETED") + #expect(failed.conclusion == "FAILURE") + #expect(failed.isRequired) + #expect(failed.workflowName == "test") + let manual = try #require(runs.first { $0.name == "review-docs-deploy" }) + #expect(manual.conclusion == "SKIPPED") + #expect(manual.isRequired == false) + } + + @Test("a merge request from a fork is listed as one") + func listing() throws { + let payloads = try GitLabDecoding.mergeRequests(from: fixture("mr-list-open.json")) + let first = GitLabDecoding.listing(try #require(payloads.first)) + #expect(first.number == 4038) + #expect(first.headRefName == "anishisbusy/repo-create-topics") + #expect(first.baseRefName == "main") + #expect(first.isCrossRepository) + #expect(first.headRepositoryOwner == "contributor-1") + #expect(first.state == "OPEN") + } + + private static let statuses: [(status: String, blocker: MergeBlocker?)] = [ + (status: "mergeable", blocker: nil), + (status: "conflict", blocker: nil), + (status: "draft_status", blocker: nil), + (status: "not_open", blocker: nil), + (status: "not_approved", blocker: .approvalRequired), + (status: "requested_changes", blocker: .changesRequested), + (status: "need_rebase", blocker: .needsRebase), + (status: "ci_must_pass", blocker: .pipelineMustPass), + (status: "ci_still_running", blocker: .pipelineRunning), + (status: "merge_request_blocked", blocker: .blockedByMergeRequest), + (status: "merge_time", blocker: .notBefore), + (status: "unchecked", blocker: .checking), + (status: "jira_association_missing", blocker: .policy("jira_association_missing")), + (status: "something_new", blocker: .policy("something_new")), + ] + + @Test("every detailed merge status has a meaning, and an unknown one refuses", arguments: statuses) + func blockers(status: String, blocker: MergeBlocker?) { + #expect(MergeBlocker(detailedMergeStatus: status) == blocker) + } + + @Test("a GitHub pull request never carries a blocker") + func gitHubHasNone() throws { + let pullRequest = try GitHub.decodePullRequest(from: Data( + #"{"number":1,"title":"PR","url":"https://github.com/a/b/pull/1","state":"OPEN","mergeStateStatus":"BLOCKED"}"#.utf8 + )) + #expect(pullRequest.forge == .gitHub) + #expect(pullRequest.blockers.isEmpty) + } + + @Test("a GitLab number is never written into the GitHub column") + func numberNotRecorded() { + let gitLab = PullRequest(number: 12, title: "", url: "", state: "OPEN", forge: .gitLab) + let gitHub = PullRequest(number: 12, title: "", url: "", state: "OPEN") + #expect(PullRequestNumber.toRecord(found: gitLab, recorded: nil) == nil) + #expect(PullRequestNumber.toRecord(found: gitHub, recorded: nil) == 12) + } + + @Test("a GitLab job page names its job and the project it ran in") + func jobLogTarget() { + let target = CheckFailureHandoff.logTarget(detailsURL: "https://gitlab.com/group/sub/app/-/jobs/17015172335") + #expect(target == CheckFailureHandoff.LogTarget( + runID: "17015172335", jobID: "17015172335", project: "group/sub/app" + )) + #expect(CheckFailureHandoff.logTarget(detailsURL: "https://gitlab.com/group/app/-/pipelines/1") == nil) + } + + private func decode(_ name: String, jobs: String? = nil) throws -> PullRequest { + let payload = try GitLabDecoding.mergeRequest(from: fixture(name)) + let runs = try jobs.map { try GitLabDecoding.jobs(from: fixture($0)) } ?? [] + return GitLabDecoding.pullRequest(payload, runs: runs) + } + + private func fixture(_ name: String) throws -> Data { + let url = try #require(bloomFixtureURL("gitlab/\(name)")) + return try Data(contentsOf: url) + } +} diff --git a/Tests/fixtures/gitlab/jobs-failed-pipeline.json b/Tests/fixtures/gitlab/jobs-failed-pipeline.json new file mode 100644 index 000000000..7339f456c --- /dev/null +++ b/Tests/fixtures/gitlab/jobs-failed-pipeline.json @@ -0,0 +1,200 @@ +[ + { + "id": 17015172335, + "name": "lint_commit", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015172335", + "created_at": "2026-10-07T22:35:58.281Z", + "started_at": "2026-10-07T22:35:58.546Z", + "finished_at": "2026-10-07T22:36:24.924Z" + }, + { + "id": 17015105576, + "name": "review-docs-cleanup", + "stage": "review", + "status": "manual", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105576", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": null, + "finished_at": null + }, + { + "id": 17015105575, + "name": "review-docs-deploy", + "stage": "review", + "status": "manual", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105575", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": null, + "finished_at": null + }, + { + "id": 17015105574, + "name": "tests:coverage", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105574", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:35:49.786Z", + "finished_at": "2026-10-07T22:36:28.692Z" + }, + { + "id": 17015105573, + "name": "tests:integration", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105573", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.278Z", + "finished_at": "2026-10-07T22:33:47.991Z" + }, + { + "id": 17015105572, + "name": "tests:unit", + "stage": "test", + "status": "failed", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105572", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.178Z", + "finished_at": "2026-10-07T22:35:47.931Z" + }, + { + "id": 17015105571, + "name": "lint", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105571", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.151Z", + "finished_at": "2026-10-07T22:38:57.151Z" + }, + { + "id": 17015105570, + "name": "lint:comments", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105570", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.081Z", + "finished_at": "2026-10-07T22:32:05.621Z" + }, + { + "id": 17015105569, + "name": "docs hugo_build", + "stage": "documentation", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105569", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.971Z", + "finished_at": "2026-10-07T22:32:04.282Z" + }, + { + "id": 17015105568, + "name": "check_docs_markdown", + "stage": "documentation", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105568", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.952Z", + "finished_at": "2026-10-07T22:31:55.658Z" + }, + { + "id": 17015105567, + "name": "check_docs_update", + "stage": "documentation", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105567", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.905Z", + "finished_at": "2026-10-07T22:33:35.901Z" + }, + { + "id": 17015105566, + "name": "check_args", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105566", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.837Z", + "finished_at": "2026-10-07T22:31:48.038Z" + }, + { + "id": 17015105565, + "name": "check_embed", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105565", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.752Z", + "finished_at": "2026-10-07T22:31:48.027Z" + }, + { + "id": 17015105563, + "name": "check_go_generated_code", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105563", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.719Z", + "finished_at": "2026-10-07T22:32:31.164Z" + }, + { + "id": 17015105562, + "name": "dependency-scanning-profile-0", + "stage": "application-security-testing", + "status": "success", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105562", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.650Z", + "finished_at": "2026-10-07T22:31:55.534Z" + }, + { + "id": 17015105561, + "name": "govulncheck", + "stage": "application-security-testing", + "status": "manual", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105561", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": null, + "finished_at": null + }, + { + "id": 17015105560, + "name": "secret_detection", + "stage": "application-security-testing", + "status": "success", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105560", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.466Z", + "finished_at": "2026-10-07T22:31:51.248Z" + }, + { + "id": 17015105559, + "name": "gitlab-advanced-sast", + "stage": "application-security-testing", + "status": "success", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105559", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.476Z", + "finished_at": "2026-10-07T22:33:35.126Z" + } +] diff --git a/Tests/fixtures/gitlab/mr-draft-conflicting-fork.json b/Tests/fixtures/gitlab/mr-draft-conflicting-fork.json new file mode 100644 index 000000000..57fec2c03 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-draft-conflicting-fork.json @@ -0,0 +1,31 @@ +{ + "iid": 4020, + "title": "Draft: fix(iteration): list header shows the total count, not the page count", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4020", + "state": "opened", + "draft": true, + "has_conflicts": true, + "detailed_merge_status": "draft_status", + "merge_status": "cannot_be_merged", + "source_branch": "8591-iteration-list-total", + "target_branch": "main", + "merged_at": null, + "closed_at": null, + "sha": "f144edffb4240d739d84a54a66bfb78d0cfa54c6", + "source_project_id": 45049979, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2923072371, + "iid": 1567, + "project_id": 45049979, + "status": "success", + "sha": "f144edffb4240d739d84a54a66bfb78d0cfa54c6", + "ref": "8591-iteration-list-total", + "web_url": "https://gitlab.com/gitlab-community/gitlab-org/cli/-/pipelines/2923072371" + }, + "references": { + "short": "!4020", + "relative": "!4020", + "full": "gitlab-org/cli!4020" + } +} diff --git a/Tests/fixtures/gitlab/mr-list-open.json b/Tests/fixtures/gitlab/mr-list-open.json new file mode 100644 index 000000000..57faf0a74 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-list-open.json @@ -0,0 +1,87 @@ +[ + { + "iid": 4038, + "title": "fix(repo): send topics instead of deprecated tag_list", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4038", + "state": "opened", + "draft": false, + "source_branch": "anishisbusy/repo-create-topics", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "discussions_not_resolved", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-1" + } + }, + { + "iid": 4037, + "title": "fix(repo): correct default visibility in repo create help", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4037", + "state": "opened", + "draft": false, + "source_branch": "anishisbusy/repo-create-visibility-help", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "discussions_not_resolved", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-2" + } + }, + { + "iid": 4035, + "title": "Draft: refactor(issue): reuse cmdutils issue URL parsing", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4035", + "state": "opened", + "draft": true, + "source_branch": "8398-unify-issue-url-parsing", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "draft_status", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-3" + } + }, + { + "iid": 4034, + "title": "Draft: feat(ssh-key): add --replace flag to ssh-key add", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4034", + "state": "opened", + "draft": true, + "source_branch": "8577-ssh-key-add-replace", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "draft_status", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-4" + } + }, + { + "iid": 4033, + "title": "Draft: fix(stack): track the current stack per worktree", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4033", + "state": "opened", + "draft": true, + "source_branch": "8329-isolate-current-stack-per-worktree", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "draft_status", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-5" + } + } +] diff --git a/Tests/fixtures/gitlab/mr-merged.json b/Tests/fixtures/gitlab/mr-merged.json new file mode 100644 index 000000000..bb0ead3cb --- /dev/null +++ b/Tests/fixtures/gitlab/mr-merged.json @@ -0,0 +1,31 @@ +{ + "iid": 4015, + "title": "fix(iteration): show the real total in list header", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4015", + "state": "merged", + "draft": false, + "has_conflicts": false, + "detailed_merge_status": "not_open", + "merge_status": "can_be_merged", + "source_branch": "unstable-code/fix-iteration-list-total", + "target_branch": "main", + "merged_at": "2026-10-07T16:41:00.770Z", + "closed_at": null, + "sha": "a4f0330b1b8cfb0a3392696524005025d3342099", + "source_project_id": 45049979, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2923247089, + "iid": 14865, + "project_id": 34675721, + "status": "success", + "sha": "d5f45439bd471880c6bfe4dd391a24ea2bbcf777", + "ref": "refs/merge-requests/4015/train", + "web_url": "https://gitlab.com/gitlab-org/cli/-/pipelines/2923247089" + }, + "references": { + "short": "!4015", + "relative": "!4015", + "full": "gitlab-org/cli!4015" + } +} diff --git a/Tests/fixtures/gitlab/mr-not-approved.json b/Tests/fixtures/gitlab/mr-not-approved.json new file mode 100644 index 000000000..f6e34f296 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-not-approved.json @@ -0,0 +1,31 @@ +{ + "iid": 4021, + "title": "fix(git): validate API-supplied repository URLs before running git", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4021", + "state": "opened", + "draft": false, + "has_conflicts": false, + "detailed_merge_status": "not_approved", + "merge_status": "can_be_merged", + "source_branch": "phikai/8524-validate-api-repo-urls", + "target_branch": "main", + "merged_at": null, + "closed_at": null, + "sha": "d7be98ed462c3011d7fd47a92f0dd1257ec6fdbb", + "source_project_id": 34675721, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2923303483, + "iid": 14872, + "project_id": 34675721, + "status": "success", + "sha": "bd1f12c63234365990db79745a8ba4022fb306ea", + "ref": "refs/merge-requests/4021/merge", + "web_url": "https://gitlab.com/gitlab-org/cli/-/pipelines/2923303483" + }, + "references": { + "short": "!4021", + "relative": "!4021", + "full": "gitlab-org/cli!4021" + } +} diff --git a/Tests/fixtures/gitlab/mr-unresolved-discussions.json b/Tests/fixtures/gitlab/mr-unresolved-discussions.json new file mode 100644 index 000000000..495e0f285 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-unresolved-discussions.json @@ -0,0 +1,31 @@ +{ + "iid": 4038, + "title": "fix(repo): send topics instead of deprecated tag_list", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4038", + "state": "opened", + "draft": false, + "has_conflicts": false, + "detailed_merge_status": "discussions_not_resolved", + "merge_status": "can_be_merged", + "source_branch": "anishisbusy/repo-create-topics", + "target_branch": "main", + "merged_at": null, + "closed_at": null, + "sha": "b4feaccdd217d199ed18d15d3acd4447bbf677b3", + "source_project_id": 45049979, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2924840836, + "iid": 1584, + "project_id": 45049979, + "status": "success", + "sha": "b4feaccdd217d199ed18d15d3acd4447bbf677b3", + "ref": "anishisbusy/repo-create-topics", + "web_url": "https://gitlab.com/gitlab-community/gitlab-org/cli/-/pipelines/2924840836" + }, + "references": { + "short": "!4038", + "relative": "!4038", + "full": "gitlab-org/cli!4038" + } +}