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" + } +}