Skip to content

feat(scm): Gitlab#2462

Merged
juliusmarminge merged 11 commits intomainfrom
t3code/gitlab-adapter
May 3, 2026
Merged

feat(scm): Gitlab#2462
juliusmarminge merged 11 commits intomainfrom
t3code/gitlab-adapter

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 2, 2026

Summary

  • Introduce a provider-neutral VCS layer with a new VcsDriver, VcsProcess, registry, and typed contracts.
  • Add source-control provider abstractions plus GitHub and GitLab implementations, and route GitManager/WebSocket flows through the new layers.
  • Refactor server text generation and git orchestration code to use the new module boundaries and shared contracts.
  • Update web UI state, parsing, and presentation code for provider-neutral refs and pull-request handling.
  • Refresh release workflow and Discord notification scripts to match the new build and notification flow.

Testing

  • Not run
  • Expected repo checks: bun fmt
  • Expected repo checks: bun lint
  • Expected repo checks: bun typecheck

Note

Medium Risk
Adds a new GitLab glab-backed provider and modifies core git status/push/upstream logic, which can affect PR checkout, branch tracking, and remote pushes across repositories.

Overview
Adds first-class GitLab support via a new GitLabCli + GitLabSourceControlProvider (list/get/create/checkout merge requests, repo clone URLs, default branch) and wires it into server startup/SourceControlProviderRegistry, marking GitLab discovery as implemented.

Extends VCS status with aheadOfDefaultCount (contracts, server, driver, websocket, and UI) so the UI can enable Create PR when a branch is synced with upstream but ahead of the default branch.

Fixes and hardens git operations: pushing no longer blocks on a dirty worktree, pushCurrentBranch pushes to the correct remote branch name (avoids creating origin/<branch> on the remote), and PR local checkout now restores upstream tracking even when provider head-repo metadata is missing, including fetching the remote-tracking ref when needed.

Reviewed by Cursor Bugbot for commit a812dcb. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add GitLab as a source control provider via glab CLI

  • Implements GitLabCli wrapping the glab CLI for MR list/view/create/checkout and repository queries, with structured error normalization via GitLabCliError.
  • Adds GitLabSourceControlProvider adapting GitLab MR data to the provider-neutral ChangeRequest shape and registers it in SourceControlProviderRegistry and the server's GitManagerLayerLive.
  • Introduces aheadOfDefaultCount to VcsStatusRemoteShape, GitStatusDetails, and all status paths so the UI can distinguish ahead-of-default from ahead-of-upstream.
  • Updates buildMenuItems and resolveQuickAction to use aheadOfDefaultCount for enabling Create PR, and removes the uncommitted-changes guard from the Push action.
  • Fixes pushCurrentBranch to strip remote shorthands before constructing the refs/heads/<branch> push target, preventing spurious branches like origin/main on the remote.
  • Risk: Behavioral change — Push is now allowed even with a dirty working tree; previously it was blocked.

Macroscope summarized a812dcb.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 43f213f9-88a9-44ac-b4e0-0c3be5ea1342

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/gitlab-adapter

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get your free trial and get 200 agent minutes per Slack user (a $50 value).


Comment @coderabbitai help to get the list of available commands and usage tips.

@juliusmarminge juliusmarminge changed the base branch from main to t3code/pluggable-git-integration May 2, 2026 08:40
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels May 2, 2026
@juliusmarminge juliusmarminge changed the title Add pluggable VCS and source-control providers feat(scm): Gitlab May 2, 2026
Comment thread apps/server/src/sourceControl/GitLabCli.ts
Comment thread apps/server/src/sourceControl/GitLabCli.ts
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 2, 2026

Approvability

Verdict: Needs human review

New feature adding GitLab source control integration with substantial new code (~600 lines across new files), new external CLI integration, and behavior changes to status reporting and PR creation logic. Unresolved review comments identify bugs in error message handling.

You can customize Macroscope's approvability policy. Learn more.

Copy link
Copy Markdown
Member Author

@juliusmarminge juliusmarminge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this against the source-control provider plan. The provider-neutral GitLabSourceControlProvider direction is right, and this is a useful implementation reference. I would tighten a few things before this becomes the long-term provider pattern.

What I would change:

  • GitLabCli should not use the old processRunner/raw readFile path. For new source-control providers, we should add/use a shared Effect-native source-control process service: effect/unstable/process, scoped child processes, typed exit codes, stream-first stdout/stderr with explicit collection helpers, and detailed provider errors.
  • createMergeRequest reads the whole body file and passes it as --description <body>. That exposes the full generated MR body in process args and risks command length limits. Prefer a file/stdin/API transport for long body content.
  • normalizeHeadSelector strips owner: and keeps only the branch. The existing flow uses owner-qualified selectors to disambiguate forks. GitLab needs to preserve source project/fork semantics where possible, or the provider contract needs a richer selector shape than a provider-neutral string.
  • SourceControlProviderRegistry is edited directly to add GitLab. That is okay for a first experiment, but it is not the pluggable shape we want. The next provider should be registered through layers/driver registration rather than requiring central registry edits every time.
  • The JSON list decoder silently skips invalid entries. For provider reliability, we should at least surface diagnostics/count skipped entries, and ideally fail if the CLI schema has drifted enough that nothing valid remains.

The larger goal is to make GitLab a SourceControlProvider, not a second hardcoded CLI path beside GitHub. This PR is close in naming, but the process layer, selector semantics, and provider registration need to match that architecture.

Comment thread apps/server/src/sourceControl/GitLabCli.ts
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:XXL 1,000+ changed lines (additions + deletions). labels May 2, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Overly broad "token" substring match misclassifies errors
    • Replaced the overly broad lower.includes("token") check with lower.includes("token provided") to match actual glab authentication errors ("no token provided", "invalid token provided") without false-matching unrelated errors like "unexpected token".

Create PR

Or push these changes by commenting:

@cursor push eca35079dd
Preview (eca35079dd)
diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts
--- a/apps/server/src/sourceControl/GitLabCli.ts
+++ b/apps/server/src/sourceControl/GitLabCli.ts
@@ -101,7 +101,7 @@
       lower.includes("authentication failed") ||
       lower.includes("not logged in") ||
       lower.includes("glab auth login") ||
-      lower.includes("token")
+      lower.includes("token provided")
     ) {
       return new GitLabCliError({
         operation,

You can send follow-ups to the cloud agent here.

lower.includes("authentication failed") ||
lower.includes("not logged in") ||
lower.includes("glab auth login") ||
lower.includes("token")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly broad "token" substring match misclassifies errors

Medium Severity

The normalizeGitLabCliError authentication check uses lower.includes("token"), which is far too broad. Any error message containing the word "token" — such as JSON parse errors ("unexpected token"), API rate-limit messages, or unrelated CLI output — will be misclassified as an authentication failure, telling the user to run glab auth login. The analogous check in normalizeGitHubCliError uses the much more specific "no oauth token" phrase. This check also runs before the "not found" / "404" check, so a message containing both "token" and "404" would be misrouted.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5e6fd83. Configure here.

Copy link
Copy Markdown
Member Author

@juliusmarminge juliusmarminge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass after the follow-up commits. The process wrapper work and API-based MR creation addressed real issues from the first review. The PR is better, but I still think it violates the long-term provider/VCS separation in an important way.

Required changes / architectural blockers:

  • SourceControlProviderRegistry still depends directly on GitVcsDriver and @t3tools/shared/git URL parsing to resolve the provider. That means source-control provider routing is not actually independent from Git. In a JJ repo, Sapling repo, or any future VCS with remotes, provider detection should not have to go through GitVcsDriver.readConfigValue("remote.origin.url") or git remote -v output. The registry needs generic remote/provider context from the VCS layer, or a separate source-control remote detection service, not a Git-only implementation.
  • The provider registry remains a central hardcoded map (github, gitlab) plus direct construction. That makes every provider PR edit the registry instead of registering a provider. If the goal is pluggable source-control providers, this should move toward a provider registration/layer model now, before GitLab/Azure both copy the same central pattern.
  • SourceControlProviderShape uses only cwd and strings like headSelector. That keeps the provider layer dependent on provider-specific string parsing inside each CLI wrapper. We should introduce a small provider context/selector model: detected provider kind, remote name/url, repository identity, source ref, target ref, and optional provider-specific source repository. That would preserve fork/source-project semantics instead of normalizing everything into a single branch-looking string.
  • GitLabCli.normalizeHeadSelector still strips owner: and keeps only the branch. That can be wrong for forks and is exactly the kind of GitHub-shaped simplification that the generic provider layer should avoid. If GitLab needs source project/source branch separately, the provider contract should express that instead of throwing information away.
  • There is duplication with the Azure PR: provider registry detection, verbose remote parsing, CLI error normalization patterns, JSON decode wrapping, and web presentation terminology. We should pull the shared parts into this branch/core instead of letting each provider PR establish a slightly different pattern.

This is a useful GitLab adapter, but it still builds on Git-only provider routing. The long-term architecture needs source-control providers layered above generic VCS remote context, not above GitVcsDriver. I would not merge this until that boundary is corrected.

@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch from 5e6fd83 to 710794b Compare May 2, 2026 16:37
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicated isVcsProcessSpawnError utility across CLI modules
    • Extracted the duplicated isVcsProcessSpawnError function into a single shared export in VcsProcess.ts, and updated both GitHubCli.ts and GitLabCli.ts to import it from there.

Create PR

Or push these changes by commenting:

@cursor push 5048488d04
Preview (5048488d04)
diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts
--- a/apps/server/src/sourceControl/GitHubCli.ts
+++ b/apps/server/src/sourceControl/GitHubCli.ts
@@ -7,7 +7,7 @@
   decodeGitHubPullRequestListJson,
   formatGitHubJsonDecodeError,
 } from "../git/githubPullRequests.ts";
-import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import { VcsProcess, type VcsProcessOutput, isVcsProcessSpawnError } from "../vcs/VcsProcess.ts";
 
 const DEFAULT_TIMEOUT_MS = 30_000;
 
@@ -75,15 +75,6 @@
   "t3/source-control/GitHubCli",
 ) {}
 
-function isVcsProcessSpawnError(error: unknown): boolean {
-  return (
-    typeof error === "object" &&
-    error !== null &&
-    "_tag" in error &&
-    error._tag === "VcsProcessSpawnError"
-  );
-}
-
 function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError {
   if (error instanceof Error) {
     if (error.message.includes("Command not found: gh") || isVcsProcessSpawnError(error)) {

diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts
--- a/apps/server/src/sourceControl/GitLabCli.ts
+++ b/apps/server/src/sourceControl/GitLabCli.ts
@@ -7,7 +7,7 @@
   decodeGitLabMergeRequestListJson,
   formatGitLabJsonDecodeError,
 } from "../git/gitlabMergeRequests.ts";
-import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import { VcsProcess, type VcsProcessOutput, isVcsProcessSpawnError } from "../vcs/VcsProcess.ts";
 import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
 
 const DEFAULT_TIMEOUT_MS = 30_000;
@@ -81,15 +81,6 @@
   "t3/source-control/GitLabCli",
 ) {}
 
-function isVcsProcessSpawnError(error: unknown): boolean {
-  return (
-    typeof error === "object" &&
-    error !== null &&
-    "_tag" in error &&
-    error._tag === "VcsProcessSpawnError"
-  );
-}
-
 function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError {
   if (error instanceof Error) {
     if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) {

diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts
--- a/apps/server/src/vcs/VcsProcess.ts
+++ b/apps/server/src/vcs/VcsProcess.ts
@@ -9,6 +9,15 @@
   VcsProcessTimeoutError,
 } from "@t3tools/contracts";
 
+export function isVcsProcessSpawnError(error: unknown): boolean {
+  return (
+    typeof error === "object" &&
+    error !== null &&
+    "_tag" in error &&
+    error._tag === "VcsProcessSpawnError"
+  );
+}
+
 export interface VcsProcessInput {
   readonly operation: string;
   readonly command: string;

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/GitLabCli.ts
@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch 2 times, most recently from d399b0b to 0e387b9 Compare May 2, 2026 19:22
detail: "Merge request not found. Check the MR number or URL and try again.",
cause: error,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly broad "not found" matching misattributes non-MR errors

Medium Severity

The normalizeGitLabCliError function matches any error containing "not found" or "404" and maps it to "Merge request not found. Check the MR number or URL and try again." This is far too broad — normalizeGitLabCliError is called for all glab operations via execute, including getRepositoryCloneUrls and getDefaultBranch. A 404 from a project lookup or a "repository not found" error would be incorrectly reported as a merge request not found. Compare with the GitHub equivalent normalizeGitHubCliError, which uses highly specific patterns like "could not resolve to a pullrequest".

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d399b0b. Configure here.

Comment thread apps/web/src/components/GitActionsControl.tsx Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: String owner passed as numeric source_project_id to GitLab API
    • The createMergeRequest method now resolves the string project path/owner to a numeric project ID via the GitLab projects API before passing it as source_project_id.

Create PR

Or push these changes by commenting:

@cursor push 8d0655966c
Preview (8d0655966c)
diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts
--- a/apps/server/src/sourceControl/GitLabCli.ts
+++ b/apps/server/src/sourceControl/GitLabCli.ts
@@ -147,6 +147,10 @@
   ssh_url_to_repo: TrimmedNonEmptyString,
 });
 
+const RawGitLabProjectIdSchema = Schema.Struct({
+  id: Schema.Number,
+});
+
 const RawGitLabDefaultBranchSchema = Schema.Struct({
   default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
 });
@@ -164,7 +168,7 @@
 function decodeGitLabJson<S extends Schema.Top>(
   raw: string,
   schema: S,
-  operation: "getRepositoryCloneUrls" | "getDefaultBranch",
+  operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createMergeRequest",
   invalidDetail: string,
 ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
   return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
@@ -205,7 +209,7 @@
   return input.source?.refName ?? normalizeHeadSelector(input.headSelector);
 }
 
-function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null {
+function sourceProjectPath(source: SourceControlRefSelector | undefined): string | null {
   return source?.repository ?? source?.owner ?? null;
 }
 
@@ -311,25 +315,50 @@
         Effect.map(normalizeRepositoryCloneUrls),
       ),
     createMergeRequest: (input) => {
-      const sourceProject = sourceProjectIdentifier(input.source);
-      return execute({
-        cwd: input.cwd,
-        args: [
-          "api",
-          "--method",
-          "POST",
-          "projects/:fullpath/merge_requests",
-          "--raw-field",
-          `source_branch=${sourceRefName(input)}`,
-          "--raw-field",
-          `target_branch=${input.target?.refName ?? input.baseBranch}`,
-          ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []),
-          "--raw-field",
-          `title=${input.title}`,
-          "--field",
-          `description=@${input.bodyFile}`,
-        ],
-      }).pipe(Effect.asVoid);
+      const projectPath = sourceProjectPath(input.source);
+      const resolveSourceProjectId: Effect.Effect<number | null, GitLabCliError> = projectPath
+        ? execute({
+            cwd: input.cwd,
+            args: ["api", `projects/${encodeURIComponent(projectPath)}`],
+          }).pipe(
+            Effect.map((result) => result.stdout.trim()),
+            Effect.flatMap((raw) =>
+              decodeGitLabJson(
+                raw,
+                RawGitLabProjectIdSchema,
+                "createMergeRequest",
+                "GitLab CLI returned invalid project JSON while resolving source_project_id.",
+              ),
+            ),
+            Effect.map((project) => project.id),
+          )
+        : Effect.succeed(null);
+
+      return resolveSourceProjectId.pipe(
+        Effect.flatMap((sourceProjectId) =>
+          execute({
+            cwd: input.cwd,
+            args: [
+              "api",
+              "--method",
+              "POST",
+              "projects/:fullpath/merge_requests",
+              "--raw-field",
+              `source_branch=${sourceRefName(input)}`,
+              "--raw-field",
+              `target_branch=${input.target?.refName ?? input.baseBranch}`,
+              ...(sourceProjectId != null
+                ? ["--raw-field", `source_project_id=${sourceProjectId}`]
+                : []),
+              "--raw-field",
+              `title=${input.title}`,
+              "--field",
+              `description=@${input.bodyFile}`,
+            ],
+          }),
+        ),
+        Effect.asVoid,
+      );
     },
     getDefaultBranch: (input) =>
       execute({

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/GitLabCli.ts
@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented May 2, 2026

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Overly broad "not found" matching misattributes non-MR errors
    • Narrowed the error matching to only trigger on merge-request-specific patterns ("merge request not found", "no merge request found", or "merge_requests" combined with "404") instead of any generic "not found" or "404" string.
  • ✅ Fixed: Duplicated change request icon component across two files
    • Removed the private ChangeRequestIcon from GitActionsControl.tsx and replaced all usages with the already-exported ChangeRequestStatusIcon from ThreadStatusIndicators.tsx, eliminating the duplication.

Create PR

Or push these changes by commenting:

@cursor push 206afbeaa1
Preview (206afbeaa1)
diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts
--- a/apps/server/src/sourceControl/GitLabCli.ts
+++ b/apps/server/src/sourceControl/GitLabCli.ts
@@ -116,8 +116,8 @@
 
     if (
       lower.includes("merge request not found") ||
-      lower.includes("not found") ||
-      lower.includes("404")
+      lower.includes("no merge request found") ||
+      (lower.includes("merge_requests") && lower.includes("404"))
     ) {
       return new GitLabCliError({
         operation,

diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -7,15 +7,9 @@
 } from "@t3tools/contracts";
 import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
 import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
+import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
+import { ChangeRequestStatusIcon } from "./ThreadStatusIndicators";
 import {
-  ChevronDownIcon,
-  CloudUploadIcon,
-  GitCommitIcon,
-  GitPullRequestIcon,
-  InfoIcon,
-} from "lucide-react";
-import { GitHubIcon, GitLabIcon } from "./Icons";
-import {
   buildGitActionProgressStages,
   buildMenuItems,
   type GitActionIconName,
@@ -202,18 +196,6 @@
 const COMMIT_DIALOG_DESCRIPTION =
   "Review and confirm your commit. Leave the message blank to auto-generate one.";
 
-function ChangeRequestIcon({
-  presentation,
-  className,
-}: {
-  presentation: ChangeRequestPresentation;
-  className?: string;
-}) {
-  if (presentation.icon === "github") return <GitHubIcon className={className} />;
-  if (presentation.icon === "gitlab") return <GitLabIcon className={className} />;
-  return <GitPullRequestIcon className={className} />;
-}
-
 function GitActionItemIcon({
   icon,
   changeRequestPresentation,
@@ -223,7 +205,7 @@
 }) {
   if (icon === "commit") return <GitCommitIcon />;
   if (icon === "push") return <CloudUploadIcon />;
-  return <ChangeRequestIcon presentation={changeRequestPresentation} />;
+  return <ChangeRequestStatusIcon icon={changeRequestPresentation.icon} />;
 }
 
 function GitQuickActionIcon({
@@ -235,7 +217,9 @@
 }) {
   const iconClassName = "size-3.5";
   if (quickAction.kind === "open_pr") {
-    return <ChangeRequestIcon presentation={changeRequestPresentation} className={iconClassName} />;
+    return (
+      <ChangeRequestStatusIcon icon={changeRequestPresentation.icon} className={iconClassName} />
+    );
   }
   if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />;
   if (quickAction.kind === "run_action") {
@@ -243,7 +227,9 @@
     if (quickAction.action === "push" || quickAction.action === "commit_push") {
       return <CloudUploadIcon className={iconClassName} />;
     }
-    return <ChangeRequestIcon presentation={changeRequestPresentation} className={iconClassName} />;
+    return (
+      <ChangeRequestStatusIcon icon={changeRequestPresentation.icon} className={iconClassName} />
+    );
   }
   if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;
   return <InfoIcon className={iconClassName} />;

You can send follow-ups to the cloud agent here.

@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch 2 times, most recently from fd5331d to e292d85 Compare May 2, 2026 21:12
@juliusmarminge juliusmarminge force-pushed the t3code/pluggable-git-integration branch from 7aa00ff to f4180a4 Compare May 2, 2026 21:27
@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch 6 times, most recently from e97c82a to e34e11d Compare May 2, 2026 22:15
@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch 2 times, most recently from bc39023 to bee3900 Compare May 2, 2026 22:31
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Invalid source_project_id passed for cross-repo MRs
    • Removed the fallback to source.owner in sourceProjectIdentifier() so that only source.repository (a valid path_with_namespace) is used for source_project_id, preventing a bare username from being sent as a bogus project identifier.

Create PR

Or push these changes by commenting:

@cursor push 0308f988eb
Preview (0308f988eb)
diff --git a/apps/server/src/git/gitlabMergeRequests.ts b/apps/server/src/git/gitlabMergeRequests.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/git/gitlabMergeRequests.ts
@@ -1,0 +1,148 @@
+import { Cause, DateTime, Exit, Option, Result, Schema } from "effect";
+import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
+import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
+
+export interface NormalizedGitLabMergeRequestRecord {
+  readonly number: number;
+  readonly title: string;
+  readonly url: string;
+  readonly baseRefName: string;
+  readonly headRefName: string;
+  readonly state: "open" | "closed" | "merged";
+  readonly updatedAt: Option.Option<DateTime.Utc>;
+  readonly isCrossRepository?: boolean;
+  readonly headRepositoryNameWithOwner?: string | null;
+  readonly headRepositoryOwnerLogin?: string | null;
+}
+
+const GitLabProjectReferenceSchema = Schema.Struct({
+  path_with_namespace: Schema.optional(Schema.String),
+  pathWithNamespace: Schema.optional(Schema.String),
+  namespace: Schema.optional(
+    Schema.NullOr(
+      Schema.Struct({
+        path: Schema.optional(Schema.String),
+        full_path: Schema.optional(Schema.String),
+        fullPath: Schema.optional(Schema.String),
+      }),
+    ),
+  ),
+});
+
+const GitLabMergeRequestSchema = Schema.Struct({
+  iid: PositiveInt,
+  title: TrimmedNonEmptyString,
+  web_url: TrimmedNonEmptyString,
+  source_branch: TrimmedNonEmptyString,
+  target_branch: TrimmedNonEmptyString,
+  state: Schema.optional(Schema.NullOr(Schema.String)),
+  updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
+  source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
+  target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
+  source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
+  target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
+});
+
+function trimOptionalString(value: string | null | undefined): string | null {
+  const trimmed = value?.trim() ?? "";
+  return trimmed.length > 0 ? trimmed : null;
+}
+
+function normalizeGitLabMergeRequestState(
+  state: string | null | undefined,
+): "open" | "closed" | "merged" {
+  const normalized = state?.trim().toLowerCase();
+  if (normalized === "merged") {
+    return "merged";
+  }
+  if (normalized === "closed") {
+    return "closed";
+  }
+  return "open";
+}
+
+function projectPathWithNamespace(
+  project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined,
+): string | null {
+  const explicit =
+    trimOptionalString(project?.path_with_namespace) ??
+    trimOptionalString(project?.pathWithNamespace);
+  if (explicit) {
+    return explicit;
+  }
+
+  const namespacePath =
+    trimOptionalString(project?.namespace?.full_path) ??
+    trimOptionalString(project?.namespace?.fullPath) ??
+    trimOptionalString(project?.namespace?.path);
+  return namespacePath;
+}
+
+function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null {
+  const [owner] = pathWithNamespace?.split("/") ?? [];
+  return trimOptionalString(owner);
+}
+
+function normalizeGitLabMergeRequestRecord(
+  raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>,
+): NormalizedGitLabMergeRequestRecord {
+  const sourceProjectPath = projectPathWithNamespace(raw.source_project);
+  const targetProjectPath = projectPathWithNamespace(raw.target_project);
+  const isCrossRepository =
+    typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number"
+      ? raw.source_project_id !== raw.target_project_id
+      : sourceProjectPath !== null && targetProjectPath !== null
+        ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase()
+        : undefined;
+  const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath);
+
+  return {
+    number: raw.iid,
+    title: raw.title,
+    url: raw.web_url,
+    baseRefName: raw.target_branch,
+    headRefName: raw.source_branch,
+    state: normalizeGitLabMergeRequestState(raw.state),
+    updatedAt: raw.updated_at ?? Option.none(),
+    ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
+    ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
+    ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
+  };
+}
+
+const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
+const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema);
+const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema);
+
+export const formatGitLabJsonDecodeError = formatSchemaError;
+
+export function decodeGitLabMergeRequestListJson(
+  raw: string,
+): Result.Result<
+  ReadonlyArray<NormalizedGitLabMergeRequestRecord>,
+  Cause.Cause<Schema.SchemaError>
+> {
+  const result = decodeGitLabMergeRequestList(raw);
+  if (Result.isSuccess(result)) {
+    const mergeRequests: NormalizedGitLabMergeRequestRecord[] = [];
+    for (const entry of result.success) {
+      const decodedEntry = decodeGitLabMergeRequestEntry(entry);
+      if (Exit.isFailure(decodedEntry)) {
+        continue;
+      }
+      mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value));
+    }
+    return Result.succeed(mergeRequests);
+  }
+  return Result.fail(result.failure);
+}
+
+export function decodeGitLabMergeRequestJson(
+  raw: string,
+): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> {
+  const result = decodeGitLabMergeRequest(raw);
+  if (Result.isSuccess(result)) {
+    return Result.succeed(normalizeGitLabMergeRequestRecord(result.success));
+  }
+  return Result.fail(result.failure);
+}

diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -26,6 +26,7 @@
 import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
 import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
 import * as GitHubCli from "./sourceControl/GitHubCli.ts";
+import * as GitLabCli from "./sourceControl/GitLabCli.ts";
 import * as TextGeneration from "./textGeneration/TextGeneration.ts";
 import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
 import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
@@ -165,7 +166,7 @@
   Layer.provideMerge(GitVcsDriver.layer),
   Layer.provideMerge(
     SourceControlProviderRegistry.layer.pipe(
-      Layer.provide(GitHubCli.layer),
+      Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
       Layer.provideMerge(VcsDriverRegistryLayerLive),
     ),
   ),

diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/GitLabCli.test.ts
@@ -1,0 +1,266 @@
+import { assert, it } from "@effect/vitest";
+import { Effect, Layer } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import { afterEach, expect, vi } from "vitest";
+
+import { VcsProcessExitError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts";
+import * as GitLabCli from "./GitLabCli.ts";
+
+const mockedRun = vi.fn<VcsProcessShape["run"]>();
+const layer = it.layer(
+  GitLabCli.layer.pipe(
+    Layer.provide(
+      Layer.mock(VcsProcess)({
+        run: mockedRun,
+      }),
+    ),
+  ),
+);
+
+function processOutput(stdout: string): VcsProcessOutput {
+  return {
+    exitCode: ChildProcessSpawner.ExitCode(0),
+    stdout,
+    stderr: "",
+    stdoutTruncated: false,
+    stderrTruncated: false,
+  };
+}
+
+afterEach(() => {
+  mockedRun.mockReset();
+});
+
+layer("GitLabCli.layer", (it) => {
+  it.effect("parses merge request view output", () =>
+    Effect.gen(function* () {
+      mockedRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify({
+              iid: 42,
+              title: "Add MR thread creation",
+              web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
+              target_branch: "main",
+              source_branch: "feature/mr-threads",
+              state: "opened",
+              source_project_id: 101,
+              target_project_id: 100,
+              source_project: {
+                path_with_namespace: "octocat/t3code",
+              },
+            }),
+          ),
+        ),
+      );
+
+      const result = yield* Effect.gen(function* () {
+        const glab = yield* GitLabCli.GitLabCli;
+        return yield* glab.getMergeRequest({
+          cwd: "/repo",
+          reference: "42",
+        });
+      });
+
+      assert.deepStrictEqual(result, {
+        number: 42,
+        title: "Add MR thread creation",
+        url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
+        baseRefName: "main",
+        headRefName: "feature/mr-threads",
+        state: "open",
+        isCrossRepository: true,
+        headRepositoryNameWithOwner: "octocat/t3code",
+        headRepositoryOwnerLogin: "octocat",
+      });
+      expect(mockedRun).toHaveBeenCalledWith(
+        expect.objectContaining({
+          command: "glab",
+          cwd: "/repo",
+          args: ["mr", "view", "42", "--output", "json"],
+        }),
+      );
+    }),
+  );
+
+  it.effect("skips invalid entries when parsing MR lists", () =>
+    Effect.gen(function* () {
+      mockedRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify([
+              {
+                iid: 0,
+                title: "invalid",
+                web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0",
+                target_branch: "main",
+                source_branch: "feature/invalid",
+              },
+              {
+                iid: 43,
+                title: "  Valid MR  ",
+                web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ",
+                target_branch: " main ",
+                source_branch: " feature/mr-list ",
+                state: "merged",
+              },
+            ]),
+          ),
+        ),
+      );
+
+      const result = yield* Effect.gen(function* () {
+        const glab = yield* GitLabCli.GitLabCli;
+        return yield* glab.listMergeRequests({
+          cwd: "/repo",
+          headSelector: "feature/mr-list",
+          state: "all",
+        });
+      });
+
+      assert.deepStrictEqual(result, [
+        {
+          number: 43,
+          title: "Valid MR",
+          url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43",
+          baseRefName: "main",
+          headRefName: "feature/mr-list",
+          state: "merged",
+        },
+      ]);
+      expect(mockedRun).toHaveBeenCalledWith(
+        expect.objectContaining({
+          command: "glab",
+          cwd: "/repo",
+          args: [
+            "mr",
+            "list",
+            "--source-branch",
+            "feature/mr-list",
+            "--all",
+            "--per-page",
+            "20",
+            "--output",
+            "json",
+          ],
+        }),
+      );
+    }),
+  );
+
+  it.effect("reads repository clone URLs", () =>
+    Effect.gen(function* () {
+      mockedRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify({
+              path_with_namespace: "octocat/t3code",
+              web_url: "https://gitlab.com/octocat/t3code",
+              http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
+              ssh_url_to_repo: "[email protected]:octocat/t3code.git",
+            }),
+          ),
+        ),
+      );
+
+      const result = yield* Effect.gen(function* () {
+        const glab = yield* GitLabCli.GitLabCli;
+        return yield* glab.getRepositoryCloneUrls({
+          cwd: "/repo",
+          repository: "octocat/t3code",
+        });
+      });
+
+      assert.deepStrictEqual(result, {
+        nameWithOwner: "octocat/t3code",
+        url: "https://gitlab.com/octocat/t3code.git",
+        sshUrl: "[email protected]:octocat/t3code.git",
+      });
+    }),
+  );
+
+  it.effect("creates merge requests through the GitLab API without placing the body in argv", () =>
+    Effect.gen(function* () {
+      mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
+
+      const glab = yield* GitLabCli.GitLabCli;
+      yield* glab.createMergeRequest({
+        cwd: "/repo",
+        baseBranch: "main",
+        headSelector: "owner:feature/provider",
+        title: "Provider MR",
+        bodyFile: "/tmp/t3-mr-body.md",
+      });
+
+      expect(mockedRun).toHaveBeenCalledWith(
+        expect.objectContaining({
+          command: "glab",
+          cwd: "/repo",
+          args: [
+            "api",
+            "--method",
+            "POST",
+            "projects/:fullpath/merge_requests",
+            "--raw-field",
+            "source_branch=feature/provider",
+            "--raw-field",
+            "target_branch=main",
+            "--raw-field",
+            "title=Provider MR",
+            "--field",
+            "description=@/tmp/t3-mr-body.md",
+          ],
+        }),
+      );
+    }),
+  );
+
+  it.effect("passes --force when checking out merge requests with force enabled", () =>
+    Effect.gen(function* () {
+      mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
+
+      const glab = yield* GitLabCli.GitLabCli;
+      yield* glab.checkoutMergeRequest({
+        cwd: "/repo",
+        reference: "42",
+        force: true,
+      });
+
+      expect(mockedRun).toHaveBeenCalledWith(
+        expect.objectContaining({
+          command: "glab",
+          cwd: "/repo",
+          args: ["mr", "checkout", "42", "--force"],
+        }),
+      );
+    }),
+  );
+
+  it.effect("surfaces a friendly error when the merge request is not found", () =>
+    Effect.gen(function* () {
+      mockedRun.mockReturnValueOnce(
+        Effect.fail(
+          new VcsProcessExitError({
+            operation: "GitLabCli.execute",
+            command: "glab mr view 4888",
+            cwd: "/repo",
+            exitCode: 1,
+            detail: "GET 404 merge request not found",
+          }),
+        ),
+      );
+
+      const error = yield* Effect.gen(function* () {
+        const glab = yield* GitLabCli.GitLabCli;
+        return yield* glab.getMergeRequest({
+          cwd: "/repo",
+          reference: "4888",
+        });
+      }).pipe(Effect.flip);
+
+      assert.equal(error.message.includes("Merge request not found"), true);
+    }),
+  );
+});

diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/GitLabCli.ts
@@ -1,0 +1,358 @@
+import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";
+
+import { GitLabCliError, TrimmedNonEmptyString } from "@t3tools/contracts";
+
+import {
+  decodeGitLabMergeRequestJson,
+  decodeGitLabMergeRequestListJson,
+  formatGitLabJsonDecodeError,
+} from "../git/gitlabMergeRequests.ts";
+import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+export interface GitLabMergeRequestSummary {
+  readonly number: number;
+  readonly title: string;
+  readonly url: string;
+  readonly baseRefName: string;
+  readonly headRefName: string;
+  readonly state?: "open" | "closed" | "merged";
+  readonly updatedAt?: Option.Option<DateTime.Utc>;
+  readonly isCrossRepository?: boolean;
+  readonly headRepositoryNameWithOwner?: string | null;
+  readonly headRepositoryOwnerLogin?: string | null;
+}
+
+export interface GitLabRepositoryCloneUrls {
+  readonly nameWithOwner: string;
+  readonly url: string;
+  readonly sshUrl: string;
+}
+
+export interface GitLabCliShape {
+  readonly execute: (input: {
+    readonly cwd: string;
+    readonly args: ReadonlyArray<string>;
+    readonly timeoutMs?: number;
+  }) => Effect.Effect<VcsProcessOutput, GitLabCliError>;
+
+  readonly listMergeRequests: (input: {
+    readonly cwd: string;
+    readonly headSelector: string;
+    readonly source?: SourceControlRefSelector;
+    readonly state: "open" | "closed" | "merged" | "all";
+    readonly limit?: number;
+  }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
+
+  readonly getMergeRequest: (input: {
+    readonly cwd: string;
+    readonly reference: string;
+  }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;
+
+  readonly getRepositoryCloneUrls: (input: {
+    readonly cwd: string;
+    readonly repository: string;
+  }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
+
+  readonly createMergeRequest: (input: {
+    readonly cwd: string;
+    readonly baseBranch: string;
+    readonly headSelector: string;
+    readonly source?: SourceControlRefSelector;
+    readonly target?: SourceControlRefSelector;
+    readonly title: string;
+    readonly bodyFile: string;
+  }) => Effect.Effect<void, GitLabCliError>;
+
+  readonly getDefaultBranch: (input: {
+    readonly cwd: string;
+  }) => Effect.Effect<string | null, GitLabCliError>;
+
+  readonly checkoutMergeRequest: (input: {
+    readonly cwd: string;
+    readonly reference: string;
+    readonly force?: boolean;
+  }) => Effect.Effect<void, GitLabCliError>;
+}
+
+export class GitLabCli extends Context.Service<GitLabCli, GitLabCliShape>()(
+  "t3/source-control/GitLabCli",
+) {}
+
+function isVcsProcessSpawnError(error: unknown): boolean {
+  return (
+    typeof error === "object" &&
+    error !== null &&
+    "_tag" in error &&
+    error._tag === "VcsProcessSpawnError"
+  );
+}
+
+function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError {
+  if (error instanceof Error) {
+    if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) {
+      return new GitLabCliError({
+        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("token")
+    ) {
+      return new GitLabCliError({
+        operation,
+        detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
+        cause: error,
+      });
+    }
+
+    if (
+      lower.includes("merge request not found") ||
+      lower.includes("not found") ||
+      lower.includes("404")
+    ) {
+      return new GitLabCliError({
+        operation,
+        detail: "Merge request not found. Check the MR number or URL and try again.",
+        cause: error,
+      });
+    }
+
+    return new GitLabCliError({
+      operation,
+      detail: `GitLab CLI command failed: ${error.message}`,
+      cause: error,
+    });
+  }
+
+  return new GitLabCliError({
+    operation,
+    detail: "GitLab CLI command failed.",
+    cause: error,
+  });
+}
+
+const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({
+  path_with_namespace: TrimmedNonEmptyString,
+  web_url: TrimmedNonEmptyString,
+  http_url_to_repo: TrimmedNonEmptyString,
+  ssh_url_to_repo: TrimmedNonEmptyString,
+});
+
+const RawGitLabDefaultBranchSchema = Schema.Struct({
+  default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+});
+
+function normalizeRepositoryCloneUrls(
+  raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
+): GitLabRepositoryCloneUrls {
+  return {
+    nameWithOwner: raw.path_with_namespace,
+    url: raw.http_url_to_repo || raw.web_url,
+    sshUrl: raw.ssh_url_to_repo,
+  };
+}
+
+function decodeGitLabJson<S extends Schema.Top>(
+  raw: string,
+  schema: S,
+  operation: "getRepositoryCloneUrls" | "getDefaultBranch",
+  invalidDetail: string,
+): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
+  return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
+    Effect.mapError(
+      (error) =>
+        new GitLabCliError({
+          operation,
+          detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
+          cause: error,
+        }),
+    ),
+  );
+}
+
+function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
+  switch (state) {
+    case "open":
+      return [];
+    case "closed":
+      return ["--closed"];
+    case "merged":
+      return ["--merged"];
+    case "all":
+      return ["--all"];
+  }
+}
+
+function normalizeHeadSelector(headSelector: string): string {
+  const trimmed = headSelector.trim();
+  const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed);
+  return ownerBranch?.[1]?.trim() || trimmed;
+}
+
+function sourceRefName(input: {
+  readonly headSelector: string;
+  readonly source?: SourceControlRefSelector;
+}): string {
+  return input.source?.refName ?? normalizeHeadSelector(input.headSelector);
+}
+
+function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null {
+  return source?.repository ?? null;
+}
+
+function toSummaryWithOptionalUpdatedAt(
+  record: GitLabMergeRequestSummary & {
+    readonly updatedAt: Option.Option<DateTime.Utc>;
+  },
+): GitLabMergeRequestSummary {
+  const { updatedAt, ...summary } = record;
+  return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
+}
+
+export const make = Effect.fn("makeGitLabCli")(function* () {
+  const process = yield* VcsProcess;
+
+  const execute: GitLabCliShape["execute"] = (input) =>
+    process
+      .run({
+        operation: "GitLabCli.execute",
+        command: "glab",
+        args: input.args,
+        cwd: input.cwd,
+        timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+      })
+      .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error)));
+
+  return GitLabCli.of({
+    execute,
+    listMergeRequests: (input) =>
+      execute({
+        cwd: input.cwd,
+        args: [
+          "mr",
+          "list",
+          "--source-branch",
+          sourceRefName(input),
+          ...stateArgs(input.state),
+          "--per-page",
+          String(input.limit ?? 20),
+          "--output",
+          "json",
+        ],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          raw.length === 0
+            ? Effect.succeed([])
+            : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(
+                Effect.flatMap((decoded) => {
+                  if (!Result.isSuccess(decoded)) {
+                    return Effect.fail(
+                      new GitLabCliError({
+                        operation: "listMergeRequests",
+                        detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`,
+                        cause: decoded.failure,
+                      }),
+                    );
+                  }
+
+                  return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt));
+                }),
+              ),
+        ),
+      ),
+    getMergeRequest: (input) =>
+      execute({
+        cwd: input.cwd,
+        args: ["mr", "view", input.reference, "--output", "json"],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe(
+            Effect.flatMap((decoded) => {
+              if (!Result.isSuccess(decoded)) {
+                return Effect.fail(
+                  new GitLabCliError({
+                    operation: "getMergeRequest",
+                    detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`,
+                    cause: decoded.failure,
+                  }),
+                );
+              }
+
+              return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success));
+            }),
+          ),
+        ),
+      ),
+    getRepositoryCloneUrls: (input) =>
+      execute({
+        cwd: input.cwd,
+        args: ["api", `projects/${encodeURIComponent(input.repository)}`],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          decodeGitLabJson(
+            raw,
+            RawGitLabRepositoryCloneUrlsSchema,
+            "getRepositoryCloneUrls",
+            "GitLab CLI returned invalid repository JSON.",
+          ),
+        ),
+        Effect.map(normalizeRepositoryCloneUrls),
+      ),
+    createMergeRequest: (input) => {
+      const sourceProject = sourceProjectIdentifier(input.source);
+      return execute({
+        cwd: input.cwd,
+        args: [
+          "api",
+          "--method",
+          "POST",
+          "projects/:fullpath/merge_requests",
+          "--raw-field",
+          `source_branch=${sourceRefName(input)}`,
+          "--raw-field",
+          `target_branch=${input.target?.refName ?? input.baseBranch}`,
+          ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []),
+          "--raw-field",
+          `title=${input.title}`,
+          "--field",
+          `description=@${input.bodyFile}`,
+        ],
+      }).pipe(Effect.asVoid);
+    },
+    getDefaultBranch: (input) =>
+      execute({
+        cwd: input.cwd,
+        args: ["api", "projects/:fullpath"],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          decodeGitLabJson(
+            raw,
+            RawGitLabDefaultBranchSchema,
+            "getDefaultBranch",
+            "GitLab CLI returned invalid repository JSON.",
+          ),
+        ),
+        Effect.map((value) => value.default_branch ?? null),
... diff truncated: showing 800 of 1180 lines

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/GitLabCli.ts
@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch from bee3900 to 2f65d25 Compare May 2, 2026 22:42
Base automatically changed from t3code/pluggable-git-integration to main May 2, 2026 23:08
Julius Marminge added 5 commits May 2, 2026 16:10
- Add GitLab CLI and source control provider wiring
- Update UI copy and actions for merge requests
- Extend contracts and tests for GitLab flows
- Switch MR creation to `glab api` so the body file is sent by path instead of argv
- Add coverage for forced MR checkout and API-based MR creation
@juliusmarminge juliusmarminge force-pushed the t3code/gitlab-adapter branch from 2f65d25 to b343faf Compare May 2, 2026 23:10
Julius Marminge and others added 4 commits May 2, 2026 16:36
- Resolve remote branch names before publishing
- Ensure push targets refs/heads/<branch> instead of upstream shorthand
- Add regression coverage for upstream push behavior
- Keep Push enabled when the branch is ahead, even if the working tree still has local changes
- Add coverage for the Git actions menu state
- track ahead-of-default status through server, shared contracts, and UI
- allow push flows to skip committing dirty worktrees when already ahead
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Redundant git operations doubles status check cost
    • Cached the result of the first computeAheadCountAgainstBase call and reused it for aheadOfDefaultCount when the branch has no upstream, avoiding the duplicate git operations.

Create PR

Or push these changes by commenting:

@cursor push 4588517201
Preview (4588517201)
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -1252,10 +1252,12 @@
       }
     }
 
+    let computedAheadAgainstBase: number | null = null;
     if (!upstreamRef && refName) {
-      aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe(
+      computedAheadAgainstBase = yield* computeAheadCountAgainstBase(cwd, refName).pipe(
         Effect.catch(() => Effect.succeed(0)),
       );
+      aheadCount = computedAheadAgainstBase;
       behindCount = 0;
     }
 
@@ -1264,9 +1266,11 @@
       (refName === defaultBranch ||
         (defaultBranch === null && (refName === "main" || refName === "master")));
     if (refName && !isDefaultBranch) {
-      aheadOfDefaultCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe(
-        Effect.catch(() => Effect.succeed(0)),
-      );
+      aheadOfDefaultCount =
+        computedAheadAgainstBase ??
+        (yield* computeAheadCountAgainstBase(cwd, refName).pipe(
+          Effect.catch(() => Effect.succeed(0)),
+        ));
     }
 
     const stagedEntries = parseNumstatEntries(stagedNumstatStdout);

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 196c1f1. Configure here.

Comment thread apps/server/src/vcs/GitVcsDriverCore.ts
- refetch missing remote-tracking branches before setting local PR upstreams
- keep same-repo PRs on the originating remote when provider metadata is incomplete
- stop passing `--force` to `glab mr checkout`
- Reuse the no-upstream ahead count when comparing against the default branch
- Add coverage for feature branches without upstreams
- Move source control provider icons into shared exports
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels May 3, 2026
@juliusmarminge juliusmarminge merged commit 0ce7e56 into main May 3, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/gitlab-adapter branch May 3, 2026 01:59
Morphexe pushed a commit to Morphexe/t3code that referenced this pull request May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant