diff --git a/Muxy/Services/RemoteServerDelegate.swift b/Muxy/Services/RemoteServerDelegate.swift index 39176c76..75681189 100644 --- a/Muxy/Services/RemoteServerDelegate.swift +++ b/Muxy/Services/RemoteServerDelegate.swift @@ -496,6 +496,27 @@ final class RemoteServerDelegate: MuxyRemoteServerDelegate { return VCSCreatePRResultDTO(url: info.url, number: info.number) } + func vcsMergePullRequest( + projectID: UUID, + number: Int, + method: VCSMergeMethodDTO, + deleteBranch: Bool + ) async throws { + let repoPath = try repoPath(projectID: projectID) + let mergeMethod: GitRepositoryService.PRMergeMethod = switch method { + case .merge: .merge + case .squash: .squash + case .rebase: .rebase + } + try await gitService.mergePullRequest( + repoPath: repoPath, + number: number, + method: mergeMethod, + deleteBranch: deleteBranch + ) + notifyRepoDidChange(repoPath: repoPath) + } + func vcsAddWorktree( projectID: UUID, name: String, @@ -585,15 +606,7 @@ final class RemoteServerDelegate: MuxyRemoteServerDelegate { private static func toStatusDTO(_ state: VCSTabState) -> VCSStatusDTO? { guard let branch = state.branchName else { return nil } - let pullRequest: VCSPullRequestDTO? = state.pullRequestInfo.map { info in - VCSPullRequestDTO( - url: info.url, - number: info.number, - state: info.state.rawValue, - isDraft: info.isDraft, - baseBranch: info.baseBranch - ) - } + let pullRequest = state.pullRequestInfo.map(Self.toPullRequestDTO) return VCSStatusDTO( branch: branch, aheadCount: state.aheadBehind.ahead, @@ -606,6 +619,34 @@ final class RemoteServerDelegate: MuxyRemoteServerDelegate { ) } + private static func toPullRequestDTO(_ info: GitRepositoryService.PRInfo) -> VCSPullRequestDTO { + VCSPullRequestDTO( + url: info.url, + number: info.number, + state: info.state.rawValue, + isDraft: info.isDraft, + baseBranch: info.baseBranch, + mergeable: info.mergeable, + mergeStateStatus: info.mergeStateStatus.rawValue, + checks: VCSPRChecksDTO( + status: Self.checksStatusString(info.checks.status), + passing: info.checks.passing, + failing: info.checks.failing, + pending: info.checks.pending, + total: info.checks.total + ) + ) + } + + private static func checksStatusString(_ status: GitRepositoryService.PRChecksStatus) -> String { + switch status { + case .none: "none" + case .pending: "pending" + case .success: "success" + case .failure: "failure" + } + } + private static func toFileDTO(_ file: GitStatusFile, staged: Bool) -> GitFileDTO { let statusChar = staged ? file.xStatus : file.yStatus let isUntracked = file.xStatus == "?" && file.yStatus == "?" diff --git a/MuxyServer/MuxyRemoteServer.swift b/MuxyServer/MuxyRemoteServer.swift index cda2d468..8caa95f0 100644 --- a/MuxyServer/MuxyRemoteServer.swift +++ b/MuxyServer/MuxyRemoteServer.swift @@ -62,6 +62,7 @@ public protocol MuxyRemoteServerDelegate: AnyObject { func vcsSwitchBranch(projectID: UUID, branch: String) async throws func vcsCreateBranch(projectID: UUID, name: String) async throws func vcsCreatePR(projectID: UUID, title: String, body: String, baseBranch: String?, draft: Bool) async throws -> VCSCreatePRResultDTO + func vcsMergePullRequest(projectID: UUID, number: Int, method: VCSMergeMethodDTO, deleteBranch: Bool) async throws func vcsAddWorktree(projectID: UUID, name: String, branch: String, createBranch: Bool) async throws -> WorktreeDTO func vcsRemoveWorktree(projectID: UUID, worktreeID: UUID) async throws func getProjectLogo(projectID: UUID) -> ProjectLogoDTO? @@ -576,6 +577,22 @@ public final class MuxyRemoteServer: @unchecked Sendable { return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) } + case .vcsMergePullRequest: + guard case let .vcsMergePullRequest(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsMergePullRequest( + projectID: params.projectID, + number: params.number, + method: params.method, + deleteBranch: params.deleteBranch + ) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + case .vcsAddWorktree: guard case let .vcsAddWorktree(params) = request.params else { return MuxyResponse(id: request.id, error: .invalidParams) diff --git a/MuxyShared/MuxyProtocol.swift b/MuxyShared/MuxyProtocol.swift index 1d4bad07..056c199b 100644 --- a/MuxyShared/MuxyProtocol.swift +++ b/MuxyShared/MuxyProtocol.swift @@ -90,6 +90,7 @@ public enum MuxyMethod: String, Codable, Sendable { case vcsSwitchBranch case vcsCreateBranch case vcsCreatePR + case vcsMergePullRequest case vcsAddWorktree case vcsRemoveWorktree case getProjectLogo @@ -131,6 +132,7 @@ public enum MuxyParams: Codable, Sendable { case vcsSwitchBranch(VCSSwitchBranchParams) case vcsCreateBranch(VCSCreateBranchParams) case vcsCreatePR(VCSCreatePRParams) + case vcsMergePullRequest(VCSMergePullRequestParams) case vcsAddWorktree(VCSAddWorktreeParams) case vcsRemoveWorktree(VCSRemoveWorktreeParams) case getProjectLogo(GetProjectLogoParams) @@ -178,6 +180,7 @@ public enum MuxyParams: Codable, Sendable { case "vcsSwitchBranch": self = try .vcsSwitchBranch(container.decode(VCSSwitchBranchParams.self, forKey: .value)) case "vcsCreateBranch": self = try .vcsCreateBranch(container.decode(VCSCreateBranchParams.self, forKey: .value)) case "vcsCreatePR": self = try .vcsCreatePR(container.decode(VCSCreatePRParams.self, forKey: .value)) + case "vcsMergePullRequest": self = try .vcsMergePullRequest(container.decode(VCSMergePullRequestParams.self, forKey: .value)) case "vcsAddWorktree": self = try .vcsAddWorktree(container.decode(VCSAddWorktreeParams.self, forKey: .value)) case "vcsRemoveWorktree": self = try .vcsRemoveWorktree(container.decode(VCSRemoveWorktreeParams.self, forKey: .value)) case "getProjectLogo": self = try .getProjectLogo(container.decode(GetProjectLogoParams.self, forKey: .value)) @@ -253,6 +256,8 @@ public enum MuxyParams: Codable, Sendable { try container.encode(v, forKey: .value) case let .vcsCreatePR(v): try container.encode("vcsCreatePR", forKey: .type) try container.encode(v, forKey: .value) + case let .vcsMergePullRequest(v): try container.encode("vcsMergePullRequest", forKey: .type) + try container.encode(v, forKey: .value) case let .vcsAddWorktree(v): try container.encode("vcsAddWorktree", forKey: .type) try container.encode(v, forKey: .value) case let .vcsRemoveWorktree(v): try container.encode("vcsRemoveWorktree", forKey: .type) diff --git a/MuxyShared/ProtocolParams.swift b/MuxyShared/ProtocolParams.swift index 7a54f376..8bca52f3 100644 --- a/MuxyShared/ProtocolParams.swift +++ b/MuxyShared/ProtocolParams.swift @@ -459,6 +459,25 @@ public struct VCSCreatePRParams: Codable, Sendable { } } +public enum VCSMergeMethodDTO: String, Codable, Sendable { + case merge + case squash + case rebase +} + +public struct VCSMergePullRequestParams: Codable, Sendable { + public let projectID: UUID + public let number: Int + public let method: VCSMergeMethodDTO + public let deleteBranch: Bool + public init(projectID: UUID, number: Int, method: VCSMergeMethodDTO, deleteBranch: Bool) { + self.projectID = projectID + self.number = number + self.method = method + self.deleteBranch = deleteBranch + } +} + public struct VCSAddWorktreeParams: Codable, Sendable { public let projectID: UUID public let name: String diff --git a/MuxyShared/VCSStatusDTO.swift b/MuxyShared/VCSStatusDTO.swift index de505a11..84dc4abd 100644 --- a/MuxyShared/VCSStatusDTO.swift +++ b/MuxyShared/VCSStatusDTO.swift @@ -60,13 +60,44 @@ public struct VCSPullRequestDTO: Codable, Sendable, Hashable { public let state: String public let isDraft: Bool public let baseBranch: String + public let mergeable: Bool? + public let mergeStateStatus: String + public let checks: VCSPRChecksDTO - public init(url: String, number: Int, state: String, isDraft: Bool, baseBranch: String) { + public init( + url: String, + number: Int, + state: String, + isDraft: Bool, + baseBranch: String, + mergeable: Bool? = nil, + mergeStateStatus: String = "UNKNOWN", + checks: VCSPRChecksDTO = VCSPRChecksDTO(status: "none", passing: 0, failing: 0, pending: 0, total: 0) + ) { self.url = url self.number = number self.state = state self.isDraft = isDraft self.baseBranch = baseBranch + self.mergeable = mergeable + self.mergeStateStatus = mergeStateStatus + self.checks = checks + } +} + +public struct VCSPRChecksDTO: Codable, Sendable, Hashable { + public let status: String + public let passing: Int + public let failing: Int + public let pending: Int + public let total: Int + + public init(status: String, passing: Int, failing: Int, pending: Int, total: Int) { + self.status = status + self.passing = passing + self.failing = failing + self.pending = pending + self.total = total } } diff --git a/Tests/MuxyTests/Remote/MuxyRemoteServerRoutingTests.swift b/Tests/MuxyTests/Remote/MuxyRemoteServerRoutingTests.swift index d9156da7..c0780f20 100644 --- a/Tests/MuxyTests/Remote/MuxyRemoteServerRoutingTests.swift +++ b/Tests/MuxyTests/Remote/MuxyRemoteServerRoutingTests.swift @@ -23,6 +23,7 @@ private final class MockDelegate: MuxyRemoteServerDelegate { var vcsSwitchBranchCalls: [(projectID: UUID, branch: String)] = [] var vcsCreateBranchCalls: [(projectID: UUID, name: String)] = [] var vcsCreatePRCalls: [(projectID: UUID, title: String, body: String, baseBranch: String?, draft: Bool)] = [] + var vcsMergePullRequestCalls: [(projectID: UUID, number: Int, method: VCSMergeMethodDTO, deleteBranch: Bool)] = [] var vcsAddWorktreeCalls: [(projectID: UUID, name: String, branch: String, createBranch: Bool)] = [] var vcsRemoveWorktreeCalls: [(projectID: UUID, worktreeID: UUID)] = [] @@ -146,6 +147,15 @@ private final class MockDelegate: MuxyRemoteServerDelegate { return stubCreatePRResult } + func vcsMergePullRequest( + projectID: UUID, + number: Int, + method: VCSMergeMethodDTO, + deleteBranch: Bool + ) async throws { + vcsMergePullRequestCalls.append((projectID, number, method, deleteBranch)) + } + func vcsAddWorktree( projectID: UUID, name: String, @@ -510,6 +520,35 @@ struct MuxyRemoteServerRoutingTests { #expect(result.number == delegate.stubCreatePRResult.number) } + @Test("vcs merge pull request route forwards params") + func vcsMergePullRequestRoute() async { + let (server, delegate) = makeServer() + let projectID = UUID() + + let response = await server.processRequest( + MuxyRequest( + id: "8m", + method: .vcsMergePullRequest, + params: .vcsMergePullRequest(VCSMergePullRequestParams( + projectID: projectID, + number: 42, + method: .squash, + deleteBranch: true + )) + ), + clientID: authedClient(on: server) + ) + + #expect(delegate.vcsMergePullRequestCalls.first?.projectID == projectID) + #expect(delegate.vcsMergePullRequestCalls.first?.number == 42) + #expect(delegate.vcsMergePullRequestCalls.first?.method == .squash) + #expect(delegate.vcsMergePullRequestCalls.first?.deleteBranch == true) + guard case .ok = response.result else { + Issue.record("expected ok result") + return + } + } + @Test("vcs worktree routes forward input and output") func vcsWorktreeRoutes() async { let (server, delegate) = makeServer() diff --git a/docs/features/remote-server/methods.md b/docs/features/remote-server/methods.md index 31694bec..4897d422 100644 --- a/docs/features/remote-server/methods.md +++ b/docs/features/remote-server/methods.md @@ -67,6 +67,7 @@ Notes: | `vcsSwitchBranch` | `projectID`, `branch` | `ok` | | `vcsCreateBranch` | `projectID`, `name` | `ok` | | `vcsCreatePR` | `projectID`, `title`, `body`, `baseBranch`, `draft` | `vcsPRCreated` | +| `vcsMergePullRequest` | `projectID`, `number`, `method`, `deleteBranch` | `ok` | | `vcsAddWorktree` | `projectID`, `name`, `branch`, `createBranch` | `worktrees` | | `vcsRemoveWorktree` | `projectID`, `worktreeID` | `ok` |