From e3791909f0b9ffb98d3adf3ffb572efad930e283 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Mon, 9 Mar 2026 12:38:40 +1100 Subject: [PATCH 1/6] feat(server): add GitLab glab CLI support for merge request operations Introduce a provider-agnostic git hosting abstraction that automatically detects whether a repository is hosted on GitHub or GitLab (by inspecting the origin remote URL) and dispatches PR/MR operations to the correct CLI. Architecture changes: - Add GitHostingCli service contract (generic interface replacing the GitHub-specific GitHubCli service) - Add GitHostingCliDispatcher layer that detects github.com vs gitlab.com from the origin remote URL and routes to gh or glab accordingly - Rename GitHubCliError to GitHostingCliError (deprecated alias preserved for backwards compatibility) - Update GitManager to consume the generic GitHostingCli service - Remove standalone GitHubCli/GitLabCli layer files (logic consolidated into the dispatcher) GitLab support includes: - glab mr list (with --source-branch, --output json) - glab mr create (with --target-branch, --source-branch, --description) - glab repo view --output json (for default branch detection) - Authentication and CLI-not-found error normalization Closes #535 Closes #191 --- apps/server/src/git/Errors.ts | 28 +- .../src/git/Layers/CodexTextGeneration.ts | 2 +- .../src/git/Layers/GitHostingCliDispatcher.ts | 322 ++++++++++++++++++ apps/server/src/git/Layers/GitHubCli.ts | 178 ---------- apps/server/src/git/Layers/GitManager.test.ts | 30 +- apps/server/src/git/Layers/GitManager.ts | 12 +- apps/server/src/git/Services/GitHostingCli.ts | 72 ++++ apps/server/src/git/Services/GitHubCli.ts | 68 ---- apps/server/src/serverLayers.ts | 4 +- 9 files changed, 438 insertions(+), 278 deletions(-) create mode 100644 apps/server/src/git/Layers/GitHostingCliDispatcher.ts delete mode 100644 apps/server/src/git/Layers/GitHubCli.ts create mode 100644 apps/server/src/git/Services/GitHostingCli.ts delete mode 100644 apps/server/src/git/Services/GitHubCli.ts 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/GitHostingCliDispatcher.ts b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts new file mode 100644 index 000000000..f8400d693 --- /dev/null +++ b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts @@ -0,0 +1,322 @@ +/** + * GitHostingCliDispatcher - Selects the correct hosting CLI (gh or glab) + * based on the repository's origin remote URL. + * + * Detects the hosting provider by running `git remote get-url origin` and + * matching known patterns (github.com → gh, gitlab.com → glab). Falls back + * to GitHub CLI for unknown or missing remotes, preserving backwards + * compatibility. + */ +import { spawnSync } from "node:child_process"; + +import { Effect, Layer } from "effect"; + +import { runProcess } from "../../processRunner"; +import { GitHostingCliError } from "../Errors.ts"; +import { GitHostingCli, type GitHostingCliShape } from "../Services/GitHostingCli.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +type HostingProvider = "github" | "gitlab"; + +/** + * Detect the hosting provider from the origin remote URL. + * + * Uses synchronous spawn to keep the detection simple and cache-friendly. + * Returns "github" as the default when detection is inconclusive. + */ +function detectHostingProvider(cwd: string): HostingProvider { + try { + const result = spawnSync("git", ["remote", "get-url", "origin"], { + cwd, + encoding: "utf-8", + timeout: 5_000, + }); + + if (result.status !== 0 || !result.stdout) { + return "github"; + } + + const url = result.stdout.trim().toLowerCase(); + if (url.includes("gitlab")) { + return "gitlab"; + } + + return "github"; + } catch { + return "github"; + } +} + +// ── GitHub implementation (inline, no extra import needed) ───────────── + +function normalizeGitHubError(operation: string, error: unknown): GitHostingCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: gh")) { + return new GitHostingCliError({ + operation, + detail: "GitHub CLI (`gh`) 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("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHostingCliError({ + operation, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + return new GitHostingCliError({ + operation, + detail: `GitHub CLI command failed: ${error.message}`, + cause: error, + }); + } + return new GitHostingCliError({ operation, detail: "GitHub CLI command failed.", cause: error }); +} + +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, + }); + } + 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 parseGitHubPrList(raw: string): ReadonlyArray<{ + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; +}> { + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + const parsed: unknown = JSON.parse(trimmed); + if (!Array.isArray(parsed)) throw new Error("GitHub CLI returned non-array JSON."); + const result: Array<{ number: number; title: string; url: string; baseRefName: string; headRefName: string }> = []; + for (const entry of parsed) { + if (!entry || typeof entry !== "object") continue; + const r = entry as Record; + if ( + typeof r.number !== "number" || !Number.isInteger(r.number) || r.number <= 0 || + typeof r.title !== "string" || typeof r.url !== "string" || + typeof r.baseRefName !== "string" || typeof r.headRefName !== "string" + ) continue; + result.push({ number: r.number, title: r.title, url: r.url, baseRefName: r.baseRefName, headRefName: r.headRefName }); + } + return result; +} + +function parseGitLabMrList(raw: string): ReadonlyArray<{ + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; +}> { + 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: Array<{ number: number; title: string; url: string; baseRefName: string; headRefName: string }> = []; + 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; + result.push({ number: r.iid, title: r.title, url: r.web_url, baseRefName: r.target_branch, headRefName: r.source_branch }); + } + return result; +} + +const makeGitHostingCliDispatcher = Effect.sync(() => { + const service: GitHostingCliShape = { + execute: (input) => { + const provider = detectHostingProvider(input.cwd); + const binary = provider === "gitlab" ? "glab" : "gh"; + const normalizeError = provider === "gitlab" ? normalizeGitLabError : normalizeGitHubError; + return Effect.tryPromise({ + try: () => + runProcess(binary, input.args, { + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeError("execute", error), + }); + }, + + listOpenPullRequests: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "gitlab") { + return Effect.tryPromise({ + try: () => + runProcess("glab", [ + "mr", "list", + "--source-branch", input.headBranch, + "--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 } : {}), + }), + }), + ), + ); + } + + return Effect.tryPromise({ + try: () => + runProcess("gh", [ + "pr", "list", + "--head", input.headBranch, + "--state", "open", + "--limit", String(input.limit ?? 1), + "--json", "number,title,url,baseRefName,headRefName", + ], { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }), + catch: (error) => normalizeGitHubError("listOpenPullRequests", error), + }).pipe( + Effect.map((result) => result.stdout), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseGitHubPrList(raw), + catch: (error: unknown) => + new GitHostingCliError({ + operation: "listOpenPullRequests", + detail: error instanceof Error + ? `GitHub CLI returned invalid PR list JSON: ${error.message}` + : "GitHub CLI returned invalid PR list JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ); + }, + + createPullRequest: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "gitlab") { + 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.headBranch, + "--title", input.title, + "--description", body, + "--yes", + ], { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }); + }, + catch: (error) => normalizeGitLabError("createPullRequest", error), + }).pipe(Effect.asVoid); + } + + return Effect.tryPromise({ + try: () => + runProcess("gh", [ + "pr", "create", + "--base", input.baseBranch, + "--head", input.headBranch, + "--title", input.title, + "--body-file", input.bodyFile, + ], { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }), + catch: (error) => normalizeGitHubError("createPullRequest", error), + }).pipe(Effect.asVoid); + }, + + getDefaultBranch: (input) => { + const provider = detectHostingProvider(input.cwd); + if (provider === "gitlab") { + 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.", + }), + }), + ), + ); + } + + return Effect.tryPromise({ + try: () => + runProcess("gh", [ + "repo", "view", + "--json", "defaultBranchRef", + "--jq", ".defaultBranchRef.name", + ], { cwd: input.cwd, timeoutMs: DEFAULT_TIMEOUT_MS }), + catch: (error) => normalizeGitHubError("getDefaultBranch", error), + }).pipe( + Effect.map((value) => { + const trimmed = value.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ); + }, + }; + + 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 deleted file mode 100644 index d0e6ebef6..000000000 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { Effect, Layer } from "effect"; - -import { runProcess } from "../../processRunner"; -import { GitHubCliError } from "../Errors.ts"; -import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; - -function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: gh")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) 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("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: `GitHub CLI command failed: ${error.message}`, - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: "GitHub CLI command failed.", - cause: error, - }); -} - -function parseOpenPullRequests(raw: string): ReadonlyArray<{ - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; -}> { - const trimmed = raw.trim(); - if (trimmed.length === 0) return []; - - const parsed: unknown = JSON.parse(trimmed); - if (!Array.isArray(parsed)) { - throw new Error("GitHub CLI returned non-array JSON."); - } - - const result: Array<{ - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; - }> = []; - for (const entry of parsed) { - 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; - if ( - typeof number !== "number" || - !Number.isInteger(number) || - number <= 0 || - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - result.push({ - number, - title, - url, - baseRefName, - headRefName, - }); - } - - return result; -} - -const makeGitHubCli = Effect.sync(() => { - const execute: GitHubCliShape["execute"] = (input) => - Effect.tryPromise({ - try: () => - runProcess("gh", input.args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }), - catch: (error) => normalizeGitHubCliError("execute", error), - }); - - const service = { - execute, - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headBranch, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName", - ], - }).pipe( - Effect.map((result) => result.stdout), - Effect.flatMap((raw) => - Effect.try({ - try: () => parseOpenPullRequests(raw), - catch: (error: unknown) => - new GitHubCliError({ - operation: "listOpenPullRequests", - detail: - error instanceof Error - ? `GitHub CLI returned invalid PR list JSON: ${error.message}` - : "GitHub CLI returned invalid PR list JSON.", - ...(error !== undefined ? { cause: error } : {}), - }), - }), - ), - ), - createPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "create", - "--base", - input.baseBranch, - "--head", - input.headBranch, - "--title", - input.title, - "--body-file", - input.bodyFile, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], - }).pipe( - Effect.map((value) => { - const trimmed = value.stdout.trim(); - return trimmed.length > 0 ? trimmed : null; - }), - ), - } satisfies GitHubCliShape; - - return service; -}); - -export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index d8a3753bb..2619ced1c 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -6,13 +6,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"; @@ -23,7 +23,7 @@ interface FakeGhScenario { prListSequence?: string[]; createdPrUrl?: string; defaultBranch?: string; - failWith?: GitHubCliError; + failWith?: GitHostingCliError; } interface FakeGitTextGeneration { @@ -166,13 +166,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(" ")); @@ -223,7 +223,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCliError({ + new GitHostingCliError({ operation: "execute", detail: `Unexpected gh command: ${args.join(" ")}`, }), @@ -250,7 +250,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ], }).pipe( Effect.map( - (result) => JSON.parse(result.stdout) as ReadonlyArray, + (result) => JSON.parse(result.stdout) as ReadonlyArray, ), ), createPullRequest: (input) => @@ -300,7 +300,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( @@ -309,7 +309,7 @@ function makeManager(input?: { ); const managerLayer = Layer.mergeAll( - Layer.succeed(GitHubCli, gitHubCli), + Layer.succeed(GitHostingCli, gitHostingCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, NodeServices.layer, @@ -458,7 +458,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.", }), @@ -878,7 +878,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.", }), @@ -907,7 +907,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 c4a29e15d..5c0f5c81f 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -6,7 +6,7 @@ import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tool 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 { @@ -177,7 +177,7 @@ function toStatusPr(pr: PullRequestInfo): { export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; - const gitHubCli = yield* GitHubCli; + const gitHostingCli = yield* GitHostingCli; const textGeneration = yield* TextGeneration; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -185,7 +185,7 @@ export const makeGitManager = Effect.gen(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const findOpenPr = (cwd: string, branch: string) => - gitHubCli + gitHostingCli .listOpenPullRequests({ cwd, headBranch: branch, @@ -211,7 +211,7 @@ export const makeGitManager = Effect.gen(function* () { const findLatestPr = (cwd: string, branch: string) => Effect.gen(function* () { - const stdout = yield* gitHubCli + const stdout = yield* gitHostingCli .execute({ cwd, args: [ @@ -265,7 +265,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) { @@ -393,7 +393,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, diff --git a/apps/server/src/git/Services/GitHostingCli.ts b/apps/server/src/git/Services/GitHostingCli.ts new file mode 100644 index 000000000..d191374c7 --- /dev/null +++ b/apps/server/src/git/Services/GitHostingCli.ts @@ -0,0 +1,72 @@ +/** + * 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 { 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; +} + +/** + * 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 headBranch: string; + readonly limit?: number; + }) => Effect.Effect, GitHostingCliError>; + + /** + * Create a pull/merge request from branch context and body file. + */ + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headBranch: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + /** + * Resolve repository default branch through hosting metadata. + */ + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; +} + +/** + * 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 deleted file mode 100644 index db0ed95aa..000000000 --- a/apps/server/src/git/Services/GitHubCli.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * GitHubCli - Effect service contract for `gh` process interactions. - * - * Provides thin command execution helpers used by Git workflow orchestration. - * - * @module GitHubCli - */ -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { ProcessRunResult } from "../../processRunner"; -import type { GitHubCliError } from "../Errors.ts"; - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; -} - -/** - * GitHubCliShape - Service API for executing GitHub CLI commands. - */ -export interface GitHubCliShape { - /** - * Execute a GitHub CLI command and return full process output. - */ - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - /** - * List open pull requests for a head branch. - */ - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headBranch: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; - - /** - * Create a pull request from branch context and body file. - */ - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headBranch: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - /** - * Resolve repository default branch through GitHub metadata. - */ - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; -} - -/** - * GitHubCli - Service tag for GitHub CLI process execution. - */ -export class GitHubCli extends ServiceMap.Service()( - "t3/git/Services/GitHubCli", -) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b..c71f9a659 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -29,7 +29,7 @@ import { TerminalManagerLive } from "./terminal/Layers/Manager"; 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,7 +115,7 @@ export function makeServerRuntimeServicesLayer() { const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(gitCoreLayer), - Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(GitHostingCliLive), Layer.provideMerge(textGenerationLayer), ); From 6a8d6c4ecd51a544340cc611231869aa85c63b1b Mon Sep 17 00:00:00 2001 From: BinBandit Date: Mon, 9 Mar 2026 12:50:59 +1100 Subject: [PATCH 2/6] refactor(githosting): robustly detect hosting provider by parsing URLs and handling git@ syntax --- .../server/src/git/Layers/GitHostingCliDispatcher.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/git/Layers/GitHostingCliDispatcher.ts b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts index f8400d693..9e3a4be3f 100644 --- a/apps/server/src/git/Layers/GitHostingCliDispatcher.ts +++ b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts @@ -38,11 +38,15 @@ function detectHostingProvider(cwd: string): HostingProvider { } const url = result.stdout.trim().toLowerCase(); - if (url.includes("gitlab")) { - return "gitlab"; + try { + const hostname = new URL(url.replace(/^git@([^:]+):/, "https://$1/")).hostname; + if (hostname === "gitlab.com" || hostname.endsWith(".gitlab.com")) { + return "gitlab" + } + } catch { + // Fall through to default } - - return "github"; + return "github" } catch { return "github"; } From ac3b8fbb1f7fad485c6069ee7772d5a7311f9ae1 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 09:28:57 +1100 Subject: [PATCH 3/6] Reconcile GitLab hosting support with main branch cross-repository PR changes Merge main's cross-repository PR detection (headSelector, BranchHeadContext, resolveRemoteRepositoryContext) into the GitLab hosting abstraction layer. Restore all GitLab-specific changes (Icons, platform-aware UI labels, MR URL parsing) that were stashed during the merge. --- apps/server/src/git/Layers/GitCore.ts | 2 +- apps/server/src/git/Layers/GitManager.test.ts | 2 +- apps/server/src/git/Layers/GitManager.ts | 2 +- apps/server/src/git/Services/GitHostingCli.ts | 1 - apps/server/src/wsServer.test.ts | 2 +- .../GitActionsControl.logic.test.ts | 127 ++++++++++++++++++ .../src/components/GitActionsControl.logic.ts | 60 ++++++--- apps/web/src/components/GitActionsControl.tsx | 100 ++++++++++---- apps/web/src/components/Icons.tsx | 9 ++ apps/web/src/components/Sidebar.tsx | 78 ++++++----- apps/web/src/pullRequestReference.test.ts | 18 +++ apps/web/src/pullRequestReference.ts | 11 +- packages/contracts/src/git.ts | 8 +- 13 files changed, 330 insertions(+), 90 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index f4a85c90b..0c2500146 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -757,13 +757,13 @@ const makeGitCore = Effect.gen(function* () { statusDetails(input.cwd).pipe( Effect.map((details) => ({ branch: details.branch, + hostingPlatform: "github" as const, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, pr: null, - hostingPlatform: "github" as const, })), ); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index c914f2aaa..6a1af7674 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -371,6 +371,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { + getHostingPlatform: (_cwd: string) => "github" as const, execute, listOpenPullRequests: (input) => execute({ @@ -437,7 +438,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), - getHostingPlatform: () => "github" as const, }, ghCalls, }; diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index f8ed19ef4..a6fa13ef4 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -801,13 +801,13 @@ export const makeGitManager = Effect.gen(function* () { return { branch: details.branch, + hostingPlatform: gitHostingCli.getHostingPlatform(input.cwd), hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, pr, - hostingPlatform: gitHostingCli.getHostingPlatform(input.cwd), }; }); diff --git a/apps/server/src/git/Services/GitHostingCli.ts b/apps/server/src/git/Services/GitHostingCli.ts index dd3f0319f..00a7c6ce9 100644 --- a/apps/server/src/git/Services/GitHostingCli.ts +++ b/apps/server/src/git/Services/GitHostingCli.ts @@ -8,7 +8,6 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; - import type { GitHostingPlatform } from "@t3tools/contracts"; import type { ProcessRunResult } from "../../processRunner"; diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index da3fb76bd..e9085da08 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1625,6 +1625,7 @@ describe("WebSocket Server", () => { it("supports git.status over websocket", async () => { const statusResult = { branch: "feature/test", + hostingPlatform: "github" as const, hasWorkingTreeChanges: true, workingTree: { files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], @@ -1635,7 +1636,6 @@ describe("WebSocket Server", () => { aheadCount: 0, behindCount: 0, pr: null, - hostingPlatform: "github" as const, }; const status = vi.fn(() => Effect.succeed(statusResult)); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 44ad29efa..9a0f5bca4 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,7 @@ import { function status(overrides: Partial = {}): GitStatusResult { return { branch: "feature/test", + hostingPlatform: "github", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -1015,3 +1017,128 @@ 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"); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8f7f023ef..92e859187 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,9 +129,11 @@ export function buildMenuItems( gitStatus: GitStatusResult | null, isBusy: boolean, hasOriginRemote = true, + platform: GitHostingPlatform = "github", ): GitActionMenuItem[] { if (!gitStatus) return []; + const label = prLabel(platform); const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; @@ -160,14 +177,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 +198,10 @@ export function resolveQuickAction( isBusy: boolean, isDefaultBranch = false, hasOriginRemote = true, + platform: GitHostingPlatform = "github", ): GitQuickAction { + const label = prLabel(platform); + if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; } @@ -207,7 +227,7 @@ export function resolveQuickAction( 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}.`, }; } @@ -219,7 +239,7 @@ export function resolveQuickAction( 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 +249,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", @@ -253,7 +273,7 @@ export function resolveQuickAction( 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", @@ -282,7 +302,7 @@ export function resolveQuickAction( 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 +310,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 +333,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 +356,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..2eb934110 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,16 +70,19 @@ function getMenuActionDisabledReason({ gitStatus, isBusy, hasOriginRemote, + platform, }: { item: GitActionMenuItem; gitStatus: GitStatusResult | null; isBusy: boolean; hasOriginRemote: boolean; + platform: GitHostingPlatform; }): 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"; @@ -107,44 +116,62 @@ function getMenuActionDisabledReason({ } if (hasOpenPr) { - return "View PR is currently unavailable."; + return `View ${label} is currently unavailable.`; } 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 +194,7 @@ 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 currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; const isGitStatusOutOfSync = !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; @@ -197,13 +225,20 @@ 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), + [gitStatusForActions, hasOriginRemote, hostingPlatform, isGitActionRunning], ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], + resolveQuickAction( + gitStatusForActions, + isGitActionRunning, + isDefaultBranch, + hasOriginRemote, + hostingPlatform, + ), + [gitStatusForActions, hasOriginRemote, hostingPlatform, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -213,6 +248,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, + platform: hostingPlatform, }) : null; @@ -226,11 +262,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 +275,12 @@ 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 +336,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, featureBranch, + platform: hostingPlatform, }); const resolvedProgressToastId = progressToastId ?? @@ -342,7 +380,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 +401,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 +429,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 +441,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions : shouldOfferCreatePrCta ? { actionProps: { - children: "Create PR", + children: `Create ${actionLabel}`, onClick: () => { closeResultToast(); void runGitActionWithToast({ @@ -428,6 +467,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ + hostingPlatform, isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, @@ -621,7 +661,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions /> } > - + {quickAction.label} @@ -637,7 +677,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions disabled={isGitActionRunning || quickAction.disabled} onClick={runQuickAction} > - + {quickAction.label} @@ -662,6 +702,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions gitStatus: gitStatusForActions, isBusy: isGitActionRunning, hasOriginRemote, + platform: hostingPlatform, }); if (item.disabled && disabledReason) { return ( @@ -672,7 +713,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions render={} > - + {item.label} @@ -691,14 +732,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..a74aa8779 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 c067475ca..d43e8e565 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( ( @@ -1512,8 +1525,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) @@ -1587,7 +1603,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..c251f7d63 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 0c3930942..9bdbe1c59 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"]); @@ -125,11 +128,9 @@ const GitStatusPr = Schema.Struct({ state: GitStatusPrState, }); -export const GitHostingPlatform = Schema.Literal("github", "gitlab"); -export type GitHostingPlatform = typeof GitHostingPlatform.Type; - export const GitStatusResult = Schema.Struct({ branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hostingPlatform: GitHostingPlatform, hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array( @@ -146,7 +147,6 @@ export const GitStatusResult = Schema.Struct({ aheadCount: NonNegativeInt, behindCount: NonNegativeInt, pr: Schema.NullOr(GitStatusPr), - hostingPlatform: GitHostingPlatform, }); export type GitStatusResult = typeof GitStatusResult.Type; From fdd7852bcd6519e390babb0ddef56dde02437018 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 09:43:33 +1100 Subject: [PATCH 4/6] Tighten GitLab icon viewBox to better match GitHub icon visual weight --- apps/web/src/components/Icons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index a74aa8779..0bf5a66d9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -15,7 +15,7 @@ export const GitHubIcon: Icon = (props) => ( ); export const GitLabIcon: Icon = (props) => ( - + Date: Wed, 11 Mar 2026 10:04:57 +1100 Subject: [PATCH 5/6] fix(server): route findLatestPr through hosting-agnostic listPullRequests abstraction findLatestPr was calling gitHostingCli.execute() with gh-specific CLI args (pr list --head --state --json) which fail on glab since GitLab uses completely different syntax (mr list --source-branch --all --output json). Add listPullRequests method to GitHostingCliShape that accepts state filter and limit, with proper glab flag translation (--all, --closed, --merged). Add updatedAt to PullRequestSummary so the findLatestPr sort-by-recency logic works through the abstraction layer. Remove now-dead parsePullRequestList function from GitManager. --- .../src/git/Layers/GitHostingCliDispatcher.ts | 58 +++++++++++ apps/server/src/git/Layers/GitHubCli.ts | 33 ++++++- apps/server/src/git/Layers/GitManager.test.ts | 18 ++++ apps/server/src/git/Layers/GitManager.ts | 97 ++++--------------- apps/server/src/git/Services/GitHostingCli.ts | 12 +++ apps/server/src/git/Services/GitHubCli.ts | 11 +++ 6 files changed, 148 insertions(+), 81 deletions(-) diff --git a/apps/server/src/git/Layers/GitHostingCliDispatcher.ts b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts index 803ebea2b..7cfdf99c6 100644 --- a/apps/server/src/git/Layers/GitHostingCliDispatcher.ts +++ b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts @@ -158,6 +158,8 @@ function parseGitLabMrList(raw: string): ReadonlyArray { 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, }); } @@ -193,6 +195,19 @@ function parseGitLabMrView(raw: string): PullRequestSummary { }; } +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* () { @@ -255,6 +270,49 @@ const makeGitHostingCliDispatcher = Effect.gen(function* () { ); }, + 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") { diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 80ce43659..b29701e13 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,7 @@ 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 +205,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 6a1af7674..2d9d65e95 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -391,6 +391,24 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe( 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({ cwd: input.cwd, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a6fa13ef4..2512e708f 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -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, @@ -558,37 +507,25 @@ export const makeGitManager = Effect.gen(function* () { const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { - const stdout = yield* gitHostingCli - .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, + }); + } } } diff --git a/apps/server/src/git/Services/GitHostingCli.ts b/apps/server/src/git/Services/GitHostingCli.ts index 00a7c6ce9..47905a0e1 100644 --- a/apps/server/src/git/Services/GitHostingCli.ts +++ b/apps/server/src/git/Services/GitHostingCli.ts @@ -20,6 +20,7 @@ export interface PullRequestSummary { 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; @@ -56,6 +57,17 @@ export interface GitHostingCliShape { 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. */ 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. */ From 634c982d81e580efd378fe4703f941ab60562f53 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 11:58:28 +1100 Subject: [PATCH 6/6] feat: add hosting CLI auth check and fix error message regression Add proactive auth status check for gh/glab CLIs with 60s TTL cache, surfaced as hostingCliAuthenticated in GitStatusResult. When not authenticated, PR/MR creation is disabled with a tooltip guiding the user to run `gh auth login` or `glab auth login`. Fix error toasts showing full Cause.pretty() fiber metadata by extracting clean tagged error messages via Cause.findErrorOption(). --- apps/server/src/git/Layers/GitCore.ts | 1 + .../src/git/Layers/GitHostingCliDispatcher.ts | 51 +++++++ apps/server/src/git/Layers/GitManager.test.ts | 1 + apps/server/src/git/Layers/GitManager.ts | 1 + apps/server/src/git/Services/GitCore.ts | 3 +- apps/server/src/git/Services/GitHostingCli.ts | 8 ++ apps/server/src/wsServer.test.ts | 1 + apps/server/src/wsServer.ts | 27 +++- .../GitActionsControl.logic.test.ts | 130 ++++++++++++++++++ .../src/components/GitActionsControl.logic.ts | 13 +- apps/web/src/components/GitActionsControl.tsx | 28 +++- packages/contracts/src/git.ts | 1 + 12 files changed, 256 insertions(+), 9 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 0c2500146..eb14063f3 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -758,6 +758,7 @@ const makeGitCore = Effect.gen(function* () { 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 index 7cfdf99c6..b2526724b 100644 --- a/apps/server/src/git/Layers/GitHostingCliDispatcher.ts +++ b/apps/server/src/git/Layers/GitHostingCliDispatcher.ts @@ -32,6 +32,55 @@ type HostingProvider = "github" | "gitlab"; 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. * @@ -473,6 +522,8 @@ const makeGitHostingCliDispatcher = Effect.gen(function* () { }, getHostingPlatform: (cwd) => detectHostingProvider(cwd), + + checkAuthStatus: (cwd) => checkHostingAuthStatus(detectHostingProvider(cwd)), }; return service; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 2d9d65e95..1df1c540a 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -372,6 +372,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { getHostingPlatform: (_cwd: string) => "github" as const, + checkAuthStatus: (_cwd: string) => true, execute, listOpenPullRequests: (input) => execute({ diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 2512e708f..aef782de2 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -739,6 +739,7 @@ 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, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index ea4555ba7..8fafedd73 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -24,7 +24,8 @@ import type { import type { GitCommandError } from "../Errors.ts"; -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails + extends Omit { upstreamRef: string | null; } diff --git a/apps/server/src/git/Services/GitHostingCli.ts b/apps/server/src/git/Services/GitHostingCli.ts index 47905a0e1..b1ee4dcc5 100644 --- a/apps/server/src/git/Services/GitHostingCli.ts +++ b/apps/server/src/git/Services/GitHostingCli.ts @@ -116,6 +116,14 @@ export interface GitHostingCliShape { * 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; } /** diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index e9085da08..e87d362f3 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1626,6 +1626,7 @@ describe("WebSocket Server", () => { 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 2c10b7cab..fb7d526ab 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -35,6 +35,7 @@ import { Exit, FileSystem, Layer, + Option, Path, Ref, Schema, @@ -919,6 +920,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 encodeResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); @@ -936,7 +959,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (request._tag === "Failure") { const errorResponse = yield* encodeResponse({ id: "unknown", - error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, + error: { message: `Invalid request format: ${formatCauseForClient(request.cause)}` }, }); ws.send(errorResponse); return; @@ -946,7 +969,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (result._tag === "Failure") { const errorResponse = yield* encodeResponse({ id: request.value.id, - error: { message: Cause.pretty(result.cause) }, + error: { message: formatCauseForClient(result.cause) }, }); ws.send(errorResponse); return; diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 9a0f5bca4..6f975a11f 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -15,6 +15,7 @@ function status(overrides: Partial = {}): GitStatusResult { return { branch: "feature/test", hostingPlatform: "github", + hostingCliAuthenticated: null, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -1142,3 +1143,132 @@ describe("GitLab platform: MR terminology", () => { 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 92e859187..0084ca63c 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -130,6 +130,7 @@ export function buildMenuItems( isBusy: boolean, hasOriginRemote = true, platform: GitHostingPlatform = "github", + hostingCliAuthenticated: boolean | null = null, ): GitActionMenuItem[] { if (!gitStatus) return []; @@ -139,6 +140,7 @@ export function buildMenuItems( 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 && @@ -149,6 +151,7 @@ export function buildMenuItems( (gitStatus.hasUpstream || canPushWithoutUpstream); const canCreatePr = !isBusy && + !isCliUnauthenticated && hasBranch && !hasChanges && !hasOpenPr && @@ -199,8 +202,10 @@ export function resolveQuickAction( 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." }; @@ -221,6 +226,8 @@ 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 { @@ -235,7 +242,7 @@ 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 { @@ -269,7 +276,7 @@ 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 { @@ -298,7 +305,7 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultBranch || skipPr) { return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2eb934110..b7fa00ce3 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -71,12 +71,14 @@ function getMenuActionDisabledReason({ 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."; @@ -88,6 +90,7 @@ function getMenuActionDisabledReason({ 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) { @@ -118,6 +121,9 @@ function getMenuActionDisabledReason({ if (hasOpenPr) { 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 ${label}.`; } @@ -195,6 +201,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions 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; @@ -226,8 +233,14 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const gitActionMenuItems = useMemo( () => - buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote, hostingPlatform), - [gitStatusForActions, hasOriginRemote, hostingPlatform, isGitActionRunning], + buildMenuItems( + gitStatusForActions, + isGitActionRunning, + hasOriginRemote, + hostingPlatform, + hostingCliAuthenticated, + ), + [gitStatusForActions, hasOriginRemote, hostingCliAuthenticated, hostingPlatform, isGitActionRunning], ); const quickAction = useMemo( () => @@ -237,8 +250,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions isDefaultBranch, hasOriginRemote, hostingPlatform, + hostingCliAuthenticated, ), - [gitStatusForActions, hasOriginRemote, hostingPlatform, isDefaultBranch, isGitActionRunning], + [ + gitStatusForActions, + hasOriginRemote, + hostingCliAuthenticated, + hostingPlatform, + isDefaultBranch, + isGitActionRunning, + ], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -703,6 +724,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions isBusy: isGitActionRunning, hasOriginRemote, platform: hostingPlatform, + hostingCliAuthenticated, }); if (item.disabled && disabledReason) { return ( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 9bdbe1c59..f779e63d2 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -131,6 +131,7 @@ 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(