diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts index 15bf482f7..b52a2d461 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -16,18 +16,30 @@ export class GitCommandError extends Schema.TaggedErrorClass()( } /** - * GitHubCliError - GitHub CLI execution or authentication failed. + * GitHostingCliError - Git hosting CLI (gh, glab, etc.) execution or authentication failed. */ -export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), -}) { +export class GitHostingCliError extends Schema.TaggedErrorClass()( + "GitHostingCliError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { override get message(): string { - return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + return `Git hosting CLI failed in ${this.operation}: ${this.detail}`; } } +/** + * @deprecated Use GitHostingCliError instead. Kept for backwards compatibility. + */ +export type GitHubCliError = GitHostingCliError; +/** + * @deprecated Use GitHostingCliError instead. Kept for backwards compatibility. + */ +export const GitHubCliError = GitHostingCliError; + /** * TextGenerationError - Commit or PR text generation failed. */ @@ -63,5 +75,5 @@ export class GitManagerError extends Schema.TaggedErrorClass()( export type GitManagerServiceError = | GitManagerError | GitCommandError - | GitHubCliError + | GitHostingCliError | TextGenerationError; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93f..e1333bb96 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -369,7 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { const prompt = [ - "You write GitHub pull request content.", + "You write pull request / merge request content.", "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 3fa0ef1f0..f59040f8b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -757,6 +757,8 @@ const makeGitCore = Effect.gen(function* () { statusDetails(input.cwd).pipe( Effect.map((details) => ({ branch: details.branch, + hostingPlatform: "github" as const, + hostingCliAuthenticated: null, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, diff --git a/apps/server/src/git/Layers/GitHostingCliDispatcher.ts b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts new file mode 100644 index 000000000..ad176629a --- /dev/null +++ b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts @@ -0,0 +1,531 @@ +/** + * GitHostingCliDispatcher - Selects the correct hosting CLI (gh or glab) + * based on the repository's origin remote URL. + * + * For GitHub repositories, delegates to the existing GitHubCli service + * (which owns Schema-validated parsing and error normalization). + * For GitLab repositories, implements glab CLI calls inline. + * + * Detection runs `git remote get-url origin` and matches the hostname. + * Falls back to GitHub for unknown or missing remotes, preserving full + * backwards compatibility. + */ +import { spawnSync } from "node:child_process"; + +import { Effect, Layer } from "effect"; + +import { runProcess } from "../../processRunner"; +import { GitHostingCliError } from "../Errors.ts"; +import { GitHubCli } from "../Services/GitHubCli.ts"; +import { + GitHostingCli, + type GitHostingCliShape, + type PullRequestSummary, + type RepositoryCloneUrls, +} from "../Services/GitHostingCli.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +type HostingProvider = "github" | "gitlab"; + +// ── Provider detection with per-repo caching ────────────────────────── + +const providerCache = new Map(); + +// ── Auth status cache with TTL ──────────────────────────────────────── + +const AUTH_STATUS_TTL_MS = 60_000; + +interface AuthCacheEntry { + value: boolean | null; + expiresAt: number; +} + +const authStatusCache = new Map(); + +/** + * Check whether the hosting CLI is authenticated by running + * `gh auth status` or `glab auth status`. + * + * Uses a per-provider in-memory cache with 60s TTL to avoid + * running the check on every status poll. + */ +function checkHostingAuthStatus(provider: HostingProvider): boolean | null { + const now = Date.now(); + const cached = authStatusCache.get(provider); + if (cached && now < cached.expiresAt) { + return cached.value; + } + + let value: boolean | null = null; + try { + const cmd = provider === "github" ? "gh" : "glab"; + const result = spawnSync(cmd, ["auth", "status"], { + encoding: "utf-8", + timeout: 5_000, + // Merge stderr into stdout — both CLIs write status output to stderr. + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + // CLI not found or timed out. + value = null; + } else { + value = result.status === 0; + } + } catch { + value = null; + } + + authStatusCache.set(provider, { value, expiresAt: now + AUTH_STATUS_TTL_MS }); + return value; +} + +/** + * Detect the hosting provider from the origin remote URL. + * + * Uses synchronous spawn to keep the detection simple. Results are + * cached per `cwd` so we only run `git remote get-url origin` once + * per repository path. + * + * Returns "github" as the default when detection is inconclusive. + */ +function detectHostingProvider(cwd: string): HostingProvider { + const cached = providerCache.get(cwd); + if (cached !== undefined) { + return cached; + } + + let provider: HostingProvider = "github"; + try { + const result = spawnSync("git", ["remote", "get-url", "origin"], { + cwd, + encoding: "utf-8", + timeout: 5_000, + }); + + if (result.status === 0 && result.stdout) { + const url = result.stdout.trim().toLowerCase(); + try { + const hostname = new URL(url.replace(/^git@([^:]+):/, "https://$1/")).hostname; + if (hostname === "gitlab.com" || hostname.endsWith(".gitlab.com")) { + provider = "gitlab"; + } + } catch { + // Fall through to default + } + } + } catch { + // Fall through to default + } + + providerCache.set(cwd, provider); + return provider; +} + +// ── GitLab helpers ──────────────────────────────────────────────────── + +function normalizeGitLabError(operation: string, error: unknown): GitHostingCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: glab")) { + return new GitHostingCliError({ + operation, + detail: "GitLab CLI (`glab`) is required but not available on PATH.", + cause: error, + }); + } + const lower = error.message.toLowerCase(); + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("glab auth login") || + lower.includes("401") + ) { + return new GitHostingCliError({ + operation, + detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + cause: error, + }); + } + if ( + lower.includes("merge request not found") || + lower.includes("404 not found") || + lower.includes("no merge requests found") + ) { + return new GitHostingCliError({ + operation, + detail: "Merge request not found. Check the MR number or URL and try again.", + cause: error, + }); + } + return new GitHostingCliError({ + operation, + detail: `GitLab CLI command failed: ${error.message}`, + cause: error, + }); + } + return new GitHostingCliError({ operation, detail: "GitLab CLI command failed.", cause: error }); +} + +function normalizeGitLabState(state: string | null | undefined): "open" | "closed" | "merged" { + if (!state) return "open"; + const lower = state.toLowerCase(); + if (lower === "merged") return "merged"; + if (lower === "closed") return "closed"; + return "open"; +} + +function parseGitLabMrList(raw: string): ReadonlyArray { + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + const parsed: unknown = JSON.parse(trimmed); + if (!Array.isArray(parsed)) throw new Error("GitLab CLI returned non-array JSON."); + const result: PullRequestSummary[] = []; + for (const entry of parsed) { + if (!entry || typeof entry !== "object") continue; + const r = entry as Record; + if ( + typeof r.iid !== "number" || + !Number.isInteger(r.iid) || + r.iid <= 0 || + typeof r.title !== "string" || + typeof r.web_url !== "string" || + typeof r.source_branch !== "string" || + typeof r.target_branch !== "string" + ) + continue; + + const isCrossRepository = + typeof r.source_project_id === "number" && + typeof r.target_project_id === "number" && + r.source_project_id !== r.target_project_id; + + result.push({ + number: r.iid, + title: r.title, + url: r.web_url, + baseRefName: r.target_branch, + headRefName: r.source_branch, + state: normalizeGitLabState(r.state as string | null | undefined), + updatedAt: typeof r.updated_at === "string" && r.updated_at.length > 0 ? r.updated_at : null, + isCrossRepository, + }); + } + return result; +} + +function parseGitLabMrView(raw: string): PullRequestSummary { + const trimmed = raw.trim(); + const parsed = JSON.parse(trimmed) as Record; + if ( + typeof parsed.iid !== "number" || + typeof parsed.title !== "string" || + typeof parsed.web_url !== "string" || + typeof parsed.source_branch !== "string" || + typeof parsed.target_branch !== "string" + ) { + throw new Error("GitLab CLI returned invalid MR JSON."); + } + + const isCrossRepository = + typeof parsed.source_project_id === "number" && + typeof parsed.target_project_id === "number" && + parsed.source_project_id !== parsed.target_project_id; + + return { + number: parsed.iid, + title: parsed.title, + url: parsed.web_url, + baseRefName: parsed.target_branch, + headRefName: parsed.source_branch, + state: normalizeGitLabState(parsed.state as string | null | undefined), + isCrossRepository, + }; +} + +function resolveGlabStateArgs(state: "open" | "closed" | "merged" | "all"): string[] { + switch (state) { + case "all": + return ["--all"]; + case "closed": + return ["--closed"]; + case "merged": + return ["--merged"]; + case "open": + return []; + } +} + +// ── Dispatcher ──────────────────────────────────────────────────────── + +const makeGitHostingCliDispatcher = Effect.gen(function* () { + const gitHubCli = yield* GitHubCli; + + const service: GitHostingCliShape = { + execute: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.execute(input); + } + return Effect.tryPromise({ + try: () => + runProcess("glab", input.args, { + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitLabError("execute", error), + }); + }, + + listOpenPullRequests: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.listOpenPullRequests(input); + } + return Effect.tryPromise({ + try: () => + runProcess( + "glab", + [ + "mr", + "list", + "--source-branch", + input.headSelector, + "--per-page", + String(input.limit ?? 1), + "--output", + "json", + ], + { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }, + ), + catch: (error) => normalizeGitLabError("listOpenPullRequests", error), + }).pipe( + Effect.map((result) => result.stdout), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseGitLabMrList(raw), + catch: (error: unknown) => + new GitHostingCliError({ + operation: "listOpenPullRequests", + detail: + error instanceof Error + ? `GitLab CLI returned invalid MR list JSON: ${error.message}` + : "GitLab CLI returned invalid MR list JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ); + }, + + listPullRequests: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.listPullRequests(input); + } + const stateArgs = resolveGlabStateArgs(input.state); + return Effect.tryPromise({ + try: () => + runProcess( + "glab", + [ + "mr", + "list", + "--source-branch", + input.headSelector, + ...stateArgs, + "--per-page", + String(input.limit ?? 20), + "--output", + "json", + ], + { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }, + ), + catch: (error) => normalizeGitLabError("listPullRequests", error), + }).pipe( + Effect.map((result) => result.stdout), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseGitLabMrList(raw), + catch: (error: unknown) => + new GitHostingCliError({ + operation: "listPullRequests", + detail: + error instanceof Error + ? `GitLab CLI returned invalid MR list JSON: ${error.message}` + : "GitLab CLI returned invalid MR list JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ); + }, + + getPullRequest: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.getPullRequest(input); + } + return Effect.tryPromise({ + try: () => + runProcess("glab", ["mr", "view", input.reference, "--output", "json"], { + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitLabError("getPullRequest", error), + }).pipe( + Effect.map((result) => result.stdout), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseGitLabMrView(raw), + catch: (error: unknown) => + new GitHostingCliError({ + operation: "getPullRequest", + detail: + error instanceof Error + ? `GitLab CLI returned invalid MR JSON: ${error.message}` + : "GitLab CLI returned invalid MR JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ); + }, + + getRepositoryCloneUrls: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.getRepositoryCloneUrls(input); + } + + // GitLab's glab CLI does not have a direct equivalent of `gh repo view` + // that returns clone URLs for an arbitrary repository. For cross-repo + // forks, the MR response already carries the source project info. We + // construct a best-effort response from the repository identifier. + return Effect.tryPromise({ + try: () => + runProcess("glab", ["repo", "view", input.repository, "--output", "json"], { + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitLabError("getRepositoryCloneUrls", error), + }).pipe( + Effect.flatMap((result) => + Effect.try({ + try: () => { + const parsed = JSON.parse(result.stdout.trim()) as Record; + const httpUrl = + typeof parsed.http_url_to_repo === "string" ? parsed.http_url_to_repo : ""; + const sshUrl = + typeof parsed.ssh_url_to_repo === "string" ? parsed.ssh_url_to_repo : ""; + const pathWithNamespace = + typeof parsed.path_with_namespace === "string" + ? parsed.path_with_namespace + : input.repository; + return { + nameWithOwner: pathWithNamespace, + url: httpUrl, + sshUrl, + } satisfies RepositoryCloneUrls; + }, + catch: (error: unknown) => + new GitHostingCliError({ + operation: "getRepositoryCloneUrls", + detail: + error instanceof Error + ? `GitLab CLI returned invalid repo JSON: ${error.message}` + : "GitLab CLI returned invalid repo JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ); + }, + + createPullRequest: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.createPullRequest(input); + } + return Effect.tryPromise({ + try: async () => { + const { promises: fsp } = await import("node:fs"); + const body = await fsp.readFile(input.bodyFile, "utf-8"); + return runProcess( + "glab", + [ + "mr", + "create", + "--target-branch", + input.baseBranch, + "--source-branch", + input.headSelector, + "--title", + input.title, + "--description", + body, + "--yes", + ], + { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }, + ); + }, + catch: (error) => normalizeGitLabError("createPullRequest", error), + }).pipe(Effect.asVoid); + }, + + getDefaultBranch: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.getDefaultBranch(input); + } + return Effect.tryPromise({ + try: () => + runProcess("glab", ["repo", "view", "--output", "json"], { + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitLabError("getDefaultBranch", error), + }).pipe( + Effect.flatMap((value) => + Effect.try({ + try: () => { + const parsed = JSON.parse(value.stdout.trim()) as Record; + const defaultBranch = parsed.default_branch; + return typeof defaultBranch === "string" && defaultBranch.length > 0 + ? defaultBranch + : null; + }, + catch: () => + new GitHostingCliError({ + operation: "getDefaultBranch", + detail: "GitLab CLI returned invalid repo view JSON.", + }), + }), + ), + ); + }, + + checkoutPullRequest: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "github") { + return gitHubCli.checkoutPullRequest(input); + } + return Effect.tryPromise({ + try: () => + runProcess("glab", ["mr", "checkout", input.reference], { + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitLabError("checkoutPullRequest", error), + }).pipe(Effect.asVoid); + }, + + getHostingPlatform: (cwd) => detectHostingProvider(cwd), + + checkAuthStatus: (cwd) => checkHostingAuthStatus(detectHostingProvider(cwd)), + }; + + return service; +}); + +export const GitHostingCliLive = Layer.effect(GitHostingCli, makeGitHostingCliDispatcher); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 80ce43659..c73d5d584 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -86,6 +86,7 @@ const RawGitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.optional(Schema.NullOr(Schema.String)), isCrossRepository: Schema.optional(Schema.Boolean), headRepository: Schema.optional( Schema.NullOr( @@ -125,6 +126,7 @@ function normalizePullRequestSummary( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizePullRequestState(raw), + updatedAt: raw.updatedAt ?? null, ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } : {}), @@ -146,7 +148,11 @@ function normalizeRepositoryCloneUrls( function decodeGitHubJson( raw: string, schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + operation: + | "listOpenPullRequests" + | "listPullRequests" + | "getPullRequest" + | "getRepositoryCloneUrls", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -203,6 +209,35 @@ const makeGitHubCli = Effect.sync(() => { ), Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), ), + listPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : decodeGitHubJson( + raw, + Schema.Array(RawGitHubPullRequestSchema), + "listPullRequests", + "GitHub CLI returned invalid PR list JSON.", + ), + ), + Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), + ), getPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index cc80eda23..1df1c540a 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -7,13 +7,13 @@ import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; -import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; +import { GitCommandError, GitHostingCliError, TextGenerationError } from "../Errors.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; import { - type GitHubCliShape, - type GitHubPullRequestSummary, - GitHubCli, -} from "../Services/GitHubCli.ts"; + type GitHostingCliShape, + type PullRequestSummary, + GitHostingCli, +} from "../Services/GitHostingCli.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; @@ -37,7 +37,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCliError; + failWith?: GitHostingCliError; } interface FakeGitTextGeneration { @@ -75,18 +75,18 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { if (result.status === 0) { return; } - throw new GitHubCliError({ + throw new GitHostingCliError({ operation: "execute", detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, }); } -function isGitHubCliError(error: unknown): error is GitHubCliError { +function isGitHostingCliError(error: unknown): error is GitHostingCliError { return ( typeof error === "object" && error !== null && "_tag" in error && - (error as { _tag?: unknown })._tag === "GitHubCliError" + (error as { _tag?: unknown })._tag === "GitHostingCliError" ); } @@ -207,13 +207,13 @@ function createTextGeneration(overrides: Partial = {}): T } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCliShape; + service: GitHostingCliShape; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; const ghCalls: string[] = []; - const execute: GitHubCliShape["execute"] = (input) => { + const execute: GitHostingCliShape["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -315,9 +315,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }; }, catch: (error) => - isGitHubCliError(error) + isGitHostingCliError(error) ? error - : new GitHubCliError({ + : new GitHostingCliError({ operation: "execute", detail: error instanceof Error @@ -333,7 +333,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCliError({ + new GitHostingCliError({ operation: "execute", detail: `Unexpected repository lookup: ${repository}`, }), @@ -362,7 +362,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCliError({ + new GitHostingCliError({ operation: "execute", detail: `Unexpected gh command: ${args.join(" ")}`, }), @@ -371,6 +371,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { + getHostingPlatform: (_cwd: string) => "github" as const, + checkAuthStatus: (_cwd: string) => true, execute, listOpenPullRequests: (input) => execute({ @@ -388,9 +390,25 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as ReadonlyArray, - ), + Effect.map((result) => JSON.parse(result.stdout) as ReadonlyArray), + ), + listPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as ReadonlyArray), ), createPullRequest: (input) => execute({ @@ -428,7 +446,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], - }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), + }).pipe(Effect.map((result) => JSON.parse(result.stdout) as PullRequestSummary)), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, @@ -471,7 +489,7 @@ function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; }) { - const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const { service: gitHostingCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); const gitCoreLayer = GitCoreLive.pipe( @@ -480,7 +498,7 @@ function makeManager(input?: { ); const managerLayer = Layer.mergeAll( - Layer.succeed(GitHubCli, gitHubCli), + Layer.succeed(GitHostingCli, gitHostingCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, NodeServices.layer, @@ -687,7 +705,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ + failWith: new GitHostingCliError({ operation: "execute", detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), @@ -1330,7 +1348,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ + failWith: new GitHostingCliError({ operation: "execute", detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), @@ -1359,7 +1377,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ + failWith: new GitHostingCliError({ operation: "execute", detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }), diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 97760e2d3..aef782de2 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -11,7 +11,7 @@ import { import { GitManagerError } from "../Errors.ts"; import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; -import { GitHubCli } from "../Services/GitHubCli.ts"; +import { GitHostingCli } from "../Services/GitHostingCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; interface OpenPrInfo { @@ -117,57 +117,6 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } -function parsePullRequestList(raw: unknown): PullRequestInfo[] { - if (!Array.isArray(raw)) return []; - - const parsed: PullRequestInfo[] = []; - for (const entry of raw) { - if (!entry || typeof entry !== "object") continue; - const record = entry as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - const state = record.state; - const mergedAt = record.mergedAt; - const updatedAt = record.updatedAt; - if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { - continue; - } - if ( - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - - let normalizedState: "open" | "closed" | "merged"; - if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { - normalizedState = "merged"; - } else if (state === "OPEN" || state === undefined || state === null) { - normalizedState = "open"; - } else if (state === "CLOSED") { - normalizedState = "closed"; - } else { - continue; - } - - parsed.push({ - number, - title, - url, - baseRefName, - headRefName, - state: normalizedState, - updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, - }); - } - return parsed; -} - function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -334,7 +283,7 @@ function toPullRequestHeadRemoteInfo(pr: { export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; - const gitHubCli = yield* GitHubCli; + const gitHostingCli = yield* GitHostingCli; const textGeneration = yield* TextGeneration; const configurePullRequestHeadUpstream = ( @@ -348,7 +297,7 @@ export const makeGitManager = Effect.gen(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* gitHostingCli.getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -395,7 +344,7 @@ export const makeGitManager = Effect.gen(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* gitHostingCli.getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -529,7 +478,7 @@ export const makeGitManager = Effect.gen(function* () { const findOpenPr = (cwd: string, headSelectors: ReadonlyArray) => Effect.gen(function* () { for (const headSelector of headSelectors) { - const pullRequests = yield* gitHubCli.listOpenPullRequests({ + const pullRequests = yield* gitHostingCli.listOpenPullRequests({ cwd, headSelector, limit: 1, @@ -558,37 +507,25 @@ export const makeGitManager = Effect.gen(function* () { const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { - const stdout = yield* gitHubCli - .execute({ - cwd, - args: [ - "pr", - "list", - "--head", - headSelector, - "--state", - "all", - "--limit", - "20", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", - ], - }) - .pipe(Effect.map((result) => result.stdout)); - - const raw = stdout.trim(); - if (raw.length === 0) { - continue; - } - - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), + const pullRequests = yield* gitHostingCli.listPullRequests({ + cwd, + headSelector, + state: "all", + limit: 20, }); - for (const pr of parsePullRequestList(parsedJson)) { - parsedByNumber.set(pr.number, pr); + for (const pr of pullRequests) { + if (pr.state) { + parsedByNumber.set(pr.number, { + number: pr.number, + title: pr.title, + url: pr.url, + baseRefName: pr.baseRefName, + headRefName: pr.headRefName, + state: pr.state, + updatedAt: pr.updatedAt ?? null, + }); + } } } @@ -622,7 +559,7 @@ export const makeGitManager = Effect.gen(function* () { } } - const defaultFromGh = yield* gitHubCli + const defaultFromGh = yield* gitHostingCli .getDefaultBranch({ cwd }) .pipe(Effect.catch(() => Effect.succeed(null))); if (defaultFromGh) { @@ -755,7 +692,7 @@ export const makeGitManager = Effect.gen(function* () { gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), ), ); - yield* gitHubCli + yield* gitHostingCli .createPullRequest({ cwd, baseBranch, @@ -801,6 +738,8 @@ export const makeGitManager = Effect.gen(function* () { return { branch: details.branch, + hostingPlatform: gitHostingCli.getHostingPlatform(input.cwd), + hostingCliAuthenticated: gitHostingCli.checkAuthStatus(input.cwd), hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, @@ -812,7 +751,7 @@ export const makeGitManager = Effect.gen(function* () { const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced( function* (input) { - const pullRequest = yield* gitHubCli + const pullRequest = yield* gitHostingCli .getPullRequest({ cwd: input.cwd, reference: normalizePullRequestReference(input.reference), @@ -827,14 +766,14 @@ export const makeGitManager = Effect.gen(function* () { function* (input) { const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ + const pullRequestSummary = yield* gitHostingCli.getPullRequest({ cwd: input.cwd, reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ + yield* gitHostingCli.checkoutPullRequest({ cwd: input.cwd, reference: normalizedReference, force: true, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 502ac349d..bc9481b80 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -24,7 +24,10 @@ import type { import type { GitCommandError } from "../Errors.ts"; -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails extends Omit< + GitStatusResult, + "pr" | "hostingPlatform" | "hostingCliAuthenticated" +> { upstreamRef: string | null; } diff --git a/apps/server/src/git/Services/GitHostingCli.ts b/apps/server/src/git/Services/GitHostingCli.ts new file mode 100644 index 000000000..b1ee4dcc5 --- /dev/null +++ b/apps/server/src/git/Services/GitHostingCli.ts @@ -0,0 +1,134 @@ +/** + * GitHostingCli - Effect service contract for git hosting provider CLI interactions. + * + * Provides a provider-agnostic interface for pull/merge request operations, + * backed by hosting-specific CLIs (e.g. GitHub `gh`, GitLab `glab`). + * + * @module GitHostingCli + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { GitHostingPlatform } from "@t3tools/contracts"; + +import type { ProcessRunResult } from "../../processRunner"; +import type { GitHostingCliError } from "../Errors.ts"; + +export interface PullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: string | null; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface RepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +/** + * GitHostingCliShape - Service API for executing git hosting CLI commands. + * + * Each method is intentionally hosting-agnostic so that GitHub (`gh`) and + * GitLab (`glab`) implementations can be swapped transparently. + */ +export interface GitHostingCliShape { + /** + * Execute a hosting CLI command and return full process output. + */ + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + /** + * List open pull/merge requests for a head branch. + */ + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHostingCliError>; + + /** + * List pull/merge requests across all states for a head branch. + * Used to find the latest PR/MR (open, closed, or merged) for status display. + */ + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitHostingCliError>; + + /** + * Resolve a pull/merge request by URL, number, or branch-ish identifier. + */ + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + /** + * Resolve clone URLs for a repository. + */ + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + /** + * Create a pull/merge request from branch context and body file. + */ + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + /** + * Resolve repository default branch through hosting metadata. + */ + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + /** + * Checkout a pull/merge request into the current repository worktree. + */ + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + + /** + * Return the detected hosting platform for the given repository. + * Synchronous — the result is cached after first detection. + */ + readonly getHostingPlatform: (cwd: string) => GitHostingPlatform; + + /** + * Check whether the hosting CLI (gh/glab) is authenticated. + * Synchronous with short-lived caching. Returns `true` when authenticated, + * `false` when not, or `null` when the status cannot be determined + * (e.g. CLI not installed, timed out). + */ + readonly checkAuthStatus: (cwd: string) => boolean | null; +} + +/** + * GitHostingCli - Service tag for git hosting CLI operations. + */ +export class GitHostingCli extends ServiceMap.Service()( + "t3/git/Services/GitHostingCli", +) {} diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index f10339af4..3504afb3e 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -18,6 +18,7 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: string | null; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; @@ -51,6 +52,16 @@ export interface GitHubCliShape { readonly limit?: number; }) => Effect.Effect, GitHubCliError>; + /** + * List pull requests across all states for a head branch. + */ + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + /** * Resolve a pull request by URL, number, or branch-ish identifier. */ diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96..c33f7dc8b 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -31,6 +31,7 @@ import { KeybindingsLive } from "./keybindings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { GitHostingCliLive } from "./git/Layers/GitHostingCliDispatcher"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; @@ -115,9 +116,11 @@ export function makeServerRuntimeServicesLayer() { ), ); + const gitHostingCliLayer = GitHostingCliLive.pipe(Layer.provide(GitHubCliLive)); + const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(gitCoreLayer), - Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(gitHostingCliLayer), Layer.provideMerge(textGenerationLayer), ); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a31..cfef0b40f 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1673,6 +1673,8 @@ describe("WebSocket Server", () => { it("supports git.status over websocket", async () => { const statusResult = { branch: "feature/test", + hostingPlatform: "github" as const, + hostingCliAuthenticated: true, hasWorkingTreeChanges: true, workingTree: { files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..83f9df7a4 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -34,6 +34,7 @@ import { Exit, FileSystem, Layer, + Option, Path, Ref, Result, @@ -892,6 +893,28 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } }); + /** + * Extract a user-facing error message from an Effect Cause. + * + * Prefers the `.message` of known tagged errors (which are written to be + * human-readable) over `Cause.pretty` (which includes fiber metadata and + * nested cause chains that are useful for debugging but not for end-users). + */ + function formatCauseForClient(cause: Cause.Cause): string { + const failure = Cause.findErrorOption(cause); + if (Option.isSome(failure)) { + const error = failure.value; + if (error instanceof Error) { + return error.message; + } + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + return String(error); + } + return Cause.pretty(cause); + } + const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { const sendWsResponse = (response: WsResponseMessage) => encodeWsResponse(response).pipe( @@ -919,7 +942,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (Exit.isFailure(result)) { return yield* sendWsResponse({ id: request.success.id, - error: { message: Cause.pretty(result.cause) }, + error: { message: formatCauseForClient(result.cause) }, }); } diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 44ad29efa..4a3ff0ded 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "vitest"; import { buildGitActionProgressStages, buildMenuItems, + prLabel, requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, @@ -13,6 +14,8 @@ import { function status(overrides: Partial = {}): GitStatusResult { return { branch: "feature/test", + hostingPlatform: "github", + hostingCliAuthenticated: null, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -1015,3 +1018,258 @@ describe("resolveAutoFeatureBranchName", () => { assert.equal(branch, "feature/update"); }); }); + +describe("prLabel", () => { + it("returns PR for github", () => { + assert.equal(prLabel("github"), "PR"); + }); + + it("returns MR for gitlab", () => { + assert.equal(prLabel("gitlab"), "MR"); + }); +}); + +describe("GitLab platform: MR terminology", () => { + const gitlab = "gitlab" as const; + + it("resolveQuickAction uses MR in labels for gitlab", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + gitlab, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & MR", + }); + }); + + it("resolveQuickAction uses View MR for gitlab when open", () => { + const quick = resolveQuickAction( + status({ + pr: { + number: 10, + title: "Open MR", + url: "https://gitlab.com/org/repo/-/merge_requests/10", + baseBranch: "main", + headBranch: "feature/test", + state: "open", + }, + }), + false, + false, + true, + gitlab, + ); + assert.deepInclude(quick, { kind: "open_pr", label: "View MR", disabled: false }); + }); + + it("resolveQuickAction uses Push & create MR for gitlab", () => { + const quick = resolveQuickAction( + status({ aheadCount: 2, pr: null }), + false, + false, + true, + gitlab, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Push & create MR", + }); + }); + + it("buildMenuItems uses MR labels for gitlab", () => { + const items = buildMenuItems( + status({ + pr: { + number: 11, + title: "Existing MR", + url: "https://gitlab.com/org/repo/-/merge_requests/11", + baseBranch: "main", + headBranch: "feature/test", + state: "open", + }, + }), + false, + true, + gitlab, + ); + const prItem = items.find((item) => item.id === "pr"); + assert.equal(prItem?.label, "View MR"); + }); + + it("buildMenuItems uses Create MR for gitlab when no open MR", () => { + const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false, true, gitlab); + const prItem = items.find((item) => item.id === "pr"); + assert.equal(prItem?.label, "Create MR"); + }); + + it("summarizeGitResult uses MR for gitlab", () => { + const result = summarizeGitResult( + { + action: "commit_push_pr", + branch: { status: "skipped_not_requested" }, + commit: { status: "created", commitSha: "abc123", subject: "test" }, + push: { status: "pushed", branch: "foo" }, + pr: { status: "created", number: 42, title: "feat: gitlab support" }, + }, + gitlab, + ); + assert.equal(result.title, "Created MR #42"); + }); + + it("buildGitActionProgressStages uses MR for gitlab", () => { + const stages = buildGitActionProgressStages({ + action: "commit_push_pr", + hasCustomCommitMessage: false, + hasWorkingTreeChanges: true, + platform: gitlab, + }); + assert.include(stages[stages.length - 1], "Creating MR..."); + }); + + it("resolveDefaultBranchActionDialogCopy uses MR for gitlab", () => { + const copy = resolveDefaultBranchActionDialogCopy({ + action: "commit_push_pr", + branchName: "main", + includesCommit: true, + platform: gitlab, + }); + assert.equal(copy.title, "Commit, push & create MR from default branch?"); + assert.include(copy.description, "create a MR"); + assert.equal(copy.continueLabel, "Commit, push & create MR"); + }); +}); + +describe("Hosting CLI authentication", () => { + it("resolveQuickAction downgrades to commit & push when CLI is unauthenticated", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + "github", + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Commit & push", + disabled: false, + }); + }); + + it("resolveQuickAction downgrades push & create PR to push when CLI is unauthenticated", () => { + const quick = resolveQuickAction( + status({ aheadCount: 1 }), + false, + false, + true, + "github", + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Push", + disabled: false, + }); + }); + + it("resolveQuickAction does not downgrade when CLI is authenticated", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + "github", + true, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & PR", + disabled: false, + }); + }); + + it("resolveQuickAction does not downgrade when auth status is null (unknown)", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + "github", + null, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & PR", + disabled: false, + }); + }); + + it("buildMenuItems disables Create PR when CLI is unauthenticated", () => { + const items = buildMenuItems( + status({ aheadCount: 1, hasWorkingTreeChanges: false }), + false, + true, + "github", + false, + ); + const prItem = items.find((i) => i.id === "pr"); + assert.ok(prItem); + assert.equal(prItem!.label, "Create PR"); + assert.isTrue(prItem!.disabled); + }); + + it("buildMenuItems enables Create PR when CLI is authenticated", () => { + const items = buildMenuItems( + status({ aheadCount: 1, hasWorkingTreeChanges: false }), + false, + true, + "github", + true, + ); + const prItem = items.find((i) => i.id === "pr"); + assert.ok(prItem); + assert.equal(prItem!.label, "Create PR"); + assert.isFalse(prItem!.disabled); + }); + + it("buildMenuItems disables Create MR on gitlab when CLI is unauthenticated", () => { + const items = buildMenuItems( + status({ aheadCount: 1, hasWorkingTreeChanges: false }), + false, + true, + "gitlab", + false, + ); + const prItem = items.find((i) => i.id === "pr"); + assert.ok(prItem); + assert.equal(prItem!.label, "Create MR"); + assert.isTrue(prItem!.disabled); + }); + + it("resolveQuickAction downgrades MR to push on gitlab when CLI is unauthenticated", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + "gitlab", + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Commit & push", + disabled: false, + }); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8f7f023ef..0084ca63c 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,4 +1,5 @@ import type { + GitHostingPlatform, GitRunStackedActionResult, GitStackedAction, GitStatusResult, @@ -6,6 +7,14 @@ import type { export type GitActionIconName = "commit" | "push" | "pr"; +/** + * Returns the short label for a pull/merge request based on the hosting platform. + * GitHub uses "PR", GitLab uses "MR". + */ +export function prLabel(platform: GitHostingPlatform): string { + return platform === "gitlab" ? "MR" : "PR"; +} + export type GitDialogAction = "commit" | "push" | "create_pr"; export interface GitActionMenuItem { @@ -58,7 +67,9 @@ export function buildGitActionProgressStages(input: { forcePushOnly?: boolean; pushTarget?: string; featureBranch?: boolean; + platform?: GitHostingPlatform; }): string[] { + const label = prLabel(input.platform ?? "github"); const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; const shouldIncludeCommitStages = !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); @@ -74,19 +85,23 @@ export function buildGitActionProgressStages(input: { if (input.action === "commit_push") { return [...branchStages, ...commitStages, pushStage]; } - return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; + return [...branchStages, ...commitStages, pushStage, `Creating ${label}...`]; } const withDescription = (title: string, description: string | undefined) => description ? { title, description } : { title }; -export function summarizeGitResult(result: GitRunStackedActionResult): { +export function summarizeGitResult( + result: GitRunStackedActionResult, + platform: GitHostingPlatform = "github", +): { title: string; description?: string; } { if (result.pr.status === "created" || result.pr.status === "opened_existing") { + const label = prLabel(platform); const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; + const title = `${result.pr.status === "created" ? `Created ${label}` : `Opened ${label}`}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } @@ -114,14 +129,18 @@ export function buildMenuItems( gitStatus: GitStatusResult | null, isBusy: boolean, hasOriginRemote = true, + platform: GitHostingPlatform = "github", + hostingCliAuthenticated: boolean | null = null, ): GitActionMenuItem[] { if (!gitStatus) return []; + const label = prLabel(platform); const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; + const isCliUnauthenticated = hostingCliAuthenticated === false; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && @@ -132,6 +151,7 @@ export function buildMenuItems( (gitStatus.hasUpstream || canPushWithoutUpstream); const canCreatePr = !isBusy && + !isCliUnauthenticated && hasBranch && !hasChanges && !hasOpenPr && @@ -160,14 +180,14 @@ export function buildMenuItems( hasOpenPr ? { id: "pr", - label: "View PR", + label: `View ${label}`, disabled: !canOpenPr, icon: "pr", kind: "open_pr", } : { id: "pr", - label: "Create PR", + label: `Create ${label}`, disabled: !canCreatePr, icon: "pr", kind: "open_dialog", @@ -181,7 +201,12 @@ export function resolveQuickAction( isBusy: boolean, isDefaultBranch = false, hasOriginRemote = true, + platform: GitHostingPlatform = "github", + hostingCliAuthenticated: boolean | null = null, ): GitQuickAction { + const label = prLabel(platform); + const isCliUnauthenticated = hostingCliAuthenticated === false; + if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; } @@ -201,13 +226,15 @@ export function resolveQuickAction( const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; + // When the CLI is not authenticated, skip PR/MR steps and downgrade to push-only. + const skipPr = isCliUnauthenticated; if (!hasBranch) { return { label: "Commit", disabled: true, kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", + hint: `Create and checkout a branch before pushing or opening a ${label}.`, }; } @@ -215,11 +242,11 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream && !hasOriginRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultBranch || skipPr) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Commit, push & PR", + label: `Commit, push & ${label}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -229,18 +256,18 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream) { if (!hasOriginRemote) { if (hasOpenPr && !isAhead) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${label}`, disabled: false, kind: "open_pr" }; } return { label: "Push", disabled: true, kind: "show_hint", - hint: 'Add an "origin" remote before pushing or creating a PR.', + hint: `Add an "origin" remote before pushing or creating a ${label}.`, }; } if (!isAhead) { if (hasOpenPr) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${label}`, disabled: false, kind: "open_pr" }; } return { label: "Push", @@ -249,11 +276,11 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultBranch || skipPr) { return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Push & create PR", + label: `Push & create ${label}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -278,11 +305,11 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultBranch || skipPr) { return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Push & create PR", + label: `Push & create ${label}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -290,7 +317,7 @@ export function resolveQuickAction( } if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${label}`, disabled: false, kind: "open_pr" }; } return { @@ -313,7 +340,9 @@ export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; + platform?: GitHostingPlatform; }): DefaultBranchActionDialogCopy { + const label = prLabel(input.platform ?? "github"); const branchLabel = input.branchName; const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; @@ -334,15 +363,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: "Commit, push & create PR from default branch?", - description: `This action will commit, push, and create a PR${suffix}`, - continueLabel: `Commit, push & create PR`, + title: `Commit, push & create ${label} from default branch?`, + description: `This action will commit, push, and create a ${label}${suffix}`, + continueLabel: `Commit, push & create ${label}`, }; } return { - title: "Push & create PR from default branch?", - description: `This action will push local commits and create a PR${suffix}`, - continueLabel: "Push & create PR", + title: `Push & create ${label} from default branch?`, + description: `This action will push local commits and create a ${label}${suffix}`, + continueLabel: `Push & create ${label}`, }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 379fb5a84..d685dbed7 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,8 +1,13 @@ -import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; +import type { + GitHostingPlatform, + GitStackedAction, + GitStatusResult, + ThreadId, +} from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; -import { GitHubIcon } from "./Icons"; +import { GitHubIcon, GitLabIcon } from "./Icons"; import { buildGitActionProgressStages, buildMenuItems, @@ -10,6 +15,7 @@ import { type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, + prLabel, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveQuickAction, @@ -64,21 +70,27 @@ function getMenuActionDisabledReason({ gitStatus, isBusy, hasOriginRemote, + platform, + hostingCliAuthenticated, }: { item: GitActionMenuItem; gitStatus: GitStatusResult | null; isBusy: boolean; hasOriginRemote: boolean; + platform: GitHostingPlatform; + hostingCliAuthenticated: boolean | null; }): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; + const label = prLabel(platform); const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; + const cliName = platform === "gitlab" ? "glab" : "gh"; if (item.id === "commit") { if (!hasChanges) { @@ -107,44 +119,74 @@ function getMenuActionDisabledReason({ } if (hasOpenPr) { - return "View PR is currently unavailable."; + return `View ${label} is currently unavailable.`; + } + if (hostingCliAuthenticated === false) { + return `Not authenticated with \`${cliName}\`. Run \`${cliName} auth login\` to enable ${label} creation.`; } if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; + return `Detached HEAD: checkout a branch before creating a ${label}.`; } if (hasChanges) { - return "Commit local changes before creating a PR."; + return `Commit local changes before creating a ${label}.`; } if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before creating a PR.'; + return `Add an "origin" remote before creating a ${label}.`; } if (!isAhead) { - return "No local commits to include in a PR."; + return `No local commits to include in a ${label}.`; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before creating a PR."; + return `Branch is behind upstream. Pull/rebase before creating a ${label}.`; } - return "Create PR is currently unavailable."; + return `Create ${label} is currently unavailable.`; } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; -function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { +function HostingPlatformIcon({ + platform, + className, +}: { + platform: GitHostingPlatform; + className?: string; +}) { + return platform === "gitlab" ? ( + + ) : ( + + ); +} + +function GitActionItemIcon({ + icon, + platform, +}: { + icon: GitActionIconName; + platform: GitHostingPlatform; +}) { if (icon === "commit") return ; if (icon === "push") return ; - return ; + return ; } -function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { +function GitQuickActionIcon({ + quickAction, + platform, +}: { + quickAction: GitQuickAction; + platform: GitHostingPlatform; +}) { const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_pr") + return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "commit_push") return ; - return ; + return ; } if (quickAction.label === "Commit") return ; return ; @@ -167,6 +209,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions // Default to true while loading so we don't flash init controls. const isRepo = branchList?.isRepo ?? true; const hasOriginRemote = branchList?.hasOriginRemote ?? false; + const hostingPlatform: GitHostingPlatform = gitStatus?.hostingPlatform ?? "github"; + const hostingCliAuthenticated: boolean | null = gitStatus?.hostingCliAuthenticated ?? null; const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; const isGitStatusOutOfSync = !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; @@ -197,13 +241,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [branchList?.branches, gitStatusForActions?.branch]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isGitActionRunning], + () => + buildMenuItems( + gitStatusForActions, + isGitActionRunning, + hasOriginRemote, + hostingPlatform, + hostingCliAuthenticated, + ), + [ + gitStatusForActions, + hasOriginRemote, + hostingCliAuthenticated, + hostingPlatform, + isGitActionRunning, + ], ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], + resolveQuickAction( + gitStatusForActions, + isGitActionRunning, + isDefaultBranch, + hasOriginRemote, + hostingPlatform, + hostingCliAuthenticated, + ), + [ + gitStatusForActions, + hasOriginRemote, + hostingCliAuthenticated, + hostingPlatform, + isDefaultBranch, + isGitActionRunning, + ], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -213,6 +284,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, + platform: hostingPlatform, }) : null; @@ -226,11 +298,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } + const label = prLabel(hostingPlatform); const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", - title: "No open PR found.", + title: `No open ${label} found.`, data: threadToastData, }); return; @@ -238,12 +311,17 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions void api.shell.openExternal(prUrl).catch((err) => { toastManager.add({ type: "error", - title: "Unable to open PR link", + title: `Unable to open ${label} link`, description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); }); - }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); + }, [ + gitStatusForActions?.pr?.state, + gitStatusForActions?.pr?.url, + hostingPlatform, + threadToastData, + ]); const runGitActionWithToast = useCallback( async ({ @@ -299,6 +377,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, featureBranch, + platform: hostingPlatform, }); const resolvedProgressToastId = progressToastId ?? @@ -342,7 +421,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions try { const result = await promise; stopProgressUpdates(); - const resultToast = summarizeGitResult(result); + const resultToast = summarizeGitResult(result, hostingPlatform); const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; @@ -363,6 +442,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions toastManager.close(resolvedProgressToastId); }; + const actionLabel = prLabel(hostingPlatform); toastManager.update(resolvedProgressToastId, { type: "success", title: resultToast.title, @@ -390,7 +470,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions : shouldOfferOpenPrCta ? { actionProps: { - children: "View PR", + children: `View ${actionLabel}`, onClick: () => { const api = readNativeApi(); if (!api) return; @@ -402,7 +482,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions : shouldOfferCreatePrCta ? { actionProps: { - children: "Create PR", + children: `Create ${actionLabel}`, onClick: () => { closeResultToast(); void runGitActionWithToast({ @@ -428,6 +508,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ + hostingPlatform, isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, @@ -621,7 +702,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions /> } > - + {quickAction.label} @@ -637,7 +718,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions disabled={isGitActionRunning || quickAction.disabled} onClick={runQuickAction} > - + {quickAction.label} @@ -662,6 +743,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions gitStatus: gitStatusForActions, isBusy: isGitActionRunning, hasOriginRemote, + platform: hostingPlatform, + hostingCliAuthenticated, }); if (item.disabled && disabledReason) { return ( @@ -672,7 +755,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions render={} > - + {item.label} @@ -691,14 +774,15 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions openDialogForMenuItem(item); }} > - + {item.label} ); })} {gitStatusForActions?.branch === null && (

- Detached HEAD: create and checkout a branch to enable push and PR actions. + Detached HEAD: create and checkout a branch to enable push and{" "} + {prLabel(hostingPlatform)} actions.

)} {gitStatusForActions && diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 4e1a586d6..0bf5a66d9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -14,6 +14,15 @@ export const GitHubIcon: Icon = (props) => ( ); +export const GitLabIcon: Icon = (props) => ( + + + +); + export const CursorIcon: Icon = (props) => ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..216414a3f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -112,13 +112,14 @@ interface TerminalStatusIndicator { } interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; + label: string; colorClass: string; tooltip: string; url: string; } type ThreadPr = GitStatusResult["pr"]; +type HostingPlatform = GitStatusResult["hostingPlatform"]; function terminalStatusFromRunningIds( runningTerminalIds: string[], @@ -133,30 +134,34 @@ function terminalStatusFromRunningIds( }; } -function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { +function prStatusIndicator( + pr: ThreadPr, + platform: HostingPlatform = "github", +): PrStatusIndicator | null { if (!pr) return null; + const label = platform === "gitlab" ? "MR" : "PR"; if (pr.state === "open") { return { - label: "PR open", + label: `${label} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, + tooltip: `#${pr.number} ${label} open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { - label: "PR closed", + label: `${label} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, + tooltip: `#${pr.number} ${label} closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { - label: "PR merged", + label: `${label} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, + tooltip: `#${pr.number} ${label} merged: ${pr.title}`, url: pr.url, }; } @@ -355,7 +360,7 @@ export default function Sidebar() { refetchInterval: 60_000, })), }); - const prByThreadId = useMemo(() => { + const { prByThreadId, platformByThreadId } = useMemo(() => { const statusByCwd = new Map(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; @@ -366,37 +371,45 @@ export default function Sidebar() { } } - const map = new Map(); + const prMap = new Map(); + const platformMap = new Map(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); + prMap.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); + if (status?.hostingPlatform) { + platformMap.set(target.threadId, status.hostingPlatform); + } } - return map; + return { prByThreadId: prMap, platformByThreadId: platformMap }; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); + const openPrLink = useCallback( + (event: React.MouseEvent, prUrl: string, platform: HostingPlatform = "github") => { + event.preventDefault(); + event.stopPropagation(); - const api = readNativeApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - }); - return; - } + const api = readNativeApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } - void api.shell.openExternal(prUrl).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: error instanceof Error ? error.message : "An error occurred.", + const label = platform === "gitlab" ? "MR" : "PR"; + void api.shell.openExternal(prUrl).catch((error) => { + toastManager.add({ + type: "error", + title: `Unable to open ${label} link`, + description: error instanceof Error ? error.message : "An error occurred.", + }); }); - }); - }, []); + }, + [], + ); const handleNewThread = useCallback( ( @@ -1513,8 +1526,11 @@ export default function Sidebar() { hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, }); + const threadPlatform = + platformByThreadId.get(thread.id) ?? "github"; const prStatus = prStatusIndicator( prByThreadId.get(thread.id) ?? null, + threadPlatform, ); const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id) @@ -1588,7 +1604,7 @@ export default function Sidebar() { aria-label={prStatus.tooltip} className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} onClick={(event) => { - openPrLink(event, prStatus.url); + openPrLink(event, prStatus.url, threadPlatform); }} > diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 60bb7b12f..7b9336039 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -20,4 +20,22 @@ describe("parsePullRequestReference", () => { it("rejects non-pull-request input", () => { expect(parsePullRequestReference("feature/my-branch")).toBeNull(); }); + + it("accepts GitLab merge request URLs", () => { + expect(parsePullRequestReference("https://gitlab.com/group/project/-/merge_requests/42")).toBe( + "https://gitlab.com/group/project/-/merge_requests/42", + ); + }); + + it("accepts GitLab merge request URLs with subgroups", () => { + expect( + parsePullRequestReference("https://gitlab.com/org/sub/project/-/merge_requests/99"), + ).toBe("https://gitlab.com/org/sub/project/-/merge_requests/99"); + }); + + it("accepts self-hosted GitLab merge request URLs", () => { + expect( + parsePullRequestReference("https://gitlab.example.com/team/repo/-/merge_requests/7"), + ).toBe("https://gitlab.example.com/team/repo/-/merge_requests/7"); + }); }); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index ecaf916b7..11f025044 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,5 +1,7 @@ const GITHUB_PULL_REQUEST_URL_PATTERN = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +const GITLAB_MERGE_REQUEST_URL_PATTERN = + /^https:\/\/[^/\s]+\/(?:[^/\s]+\/)+(?:-\/)?merge_requests\/(\d+)(?:[/?#].*)?$/i; const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; export function parsePullRequestReference(input: string): string | null { @@ -8,8 +10,13 @@ export function parsePullRequestReference(input: string): string | null { return null; } - const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed); - if (urlMatch?.[1]) { + const githubUrlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed); + if (githubUrlMatch?.[1]) { + return trimmed; + } + + const gitlabUrlMatch = GITLAB_MERGE_REQUEST_URL_PATTERN.exec(trimmed); + if (gitlabUrlMatch?.[1]) { return trimmed; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 34ab11b16..f779e63d2 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -5,6 +5,9 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; // Domain Types +export const GitHostingPlatform = Schema.Literals(["github", "gitlab"]); +export type GitHostingPlatform = typeof GitHostingPlatform.Type; + export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); export type GitStackedAction = typeof GitStackedAction.Type; const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); @@ -127,6 +130,8 @@ const GitStatusPr = Schema.Struct({ export const GitStatusResult = Schema.Struct({ branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hostingPlatform: GitHostingPlatform, + hostingCliAuthenticated: Schema.NullOr(Schema.Boolean), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array(