feat(scm): Gitlab#2462
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Built for teams:
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 |
ApprovabilityVerdict: 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. |
juliusmarminge
left a comment
There was a problem hiding this comment.
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:
GitLabClishould not use the oldprocessRunner/rawreadFilepath. 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.createMergeRequestreads 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.normalizeHeadSelectorstripsowner: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.SourceControlProviderRegistryis 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.
There was a problem hiding this comment.
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 withlower.includes("token provided")to match actual glab authentication errors ("no token provided", "invalid token provided") without false-matching unrelated errors like "unexpected token".
- Replaced the overly broad
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") |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 5e6fd83. Configure here.
juliusmarminge
left a comment
There was a problem hiding this comment.
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:
SourceControlProviderRegistrystill depends directly onGitVcsDriverand@t3tools/shared/gitURL 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 throughGitVcsDriver.readConfigValue("remote.origin.url")orgit remote -voutput. 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. SourceControlProviderShapeuses onlycwdand strings likeheadSelector. 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.normalizeHeadSelectorstill stripsowner: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.
5e6fd83 to
710794b
Compare
There was a problem hiding this comment.
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
isVcsProcessSpawnErrorutility 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.
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.
d399b0b to
0e387b9
Compare
| detail: "Merge request not found. Check the MR number or URL and try again.", | ||
| cause: error, | ||
| }); | ||
| } |
There was a problem hiding this comment.
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".
Reviewed by Cursor Bugbot for commit d399b0b. Configure here.
There was a problem hiding this comment.
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.
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.
|
Bugbot Autofix prepared fixes for both issues found in the latest run.
Or push these changes by commenting: 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. |
fd5331d to
e292d85
Compare
7aa00ff to
f4180a4
Compare
e97c82a to
e34e11d
Compare
bc39023 to
bee3900
Compare
There was a problem hiding this comment.
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_idpassed for cross-repo MRs- Removed the fallback to
source.ownerinsourceProjectIdentifier()so that onlysource.repository(a valid path_with_namespace) is used forsource_project_id, preventing a bare username from being sent as a bogus project identifier.
- Removed the fallback to
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 linesYou can send follow-ups to the cloud agent here.
bee3900 to
2f65d25
Compare
- 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
2f65d25 to
b343faf
Compare
- 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
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
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.
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.
- 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
Co-authored-by: Julius Marminge <[email protected]>



Summary
VcsDriver,VcsProcess, registry, and typed contracts.Testing
bun fmtbun lintbun typecheckNote
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,
pushCurrentBranchpushes to the correct remote branch name (avoids creatingorigin/<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
glabCLIGitLabCliwrapping theglabCLI for MR list/view/create/checkout and repository queries, with structured error normalization viaGitLabCliError.GitLabSourceControlProvideradapting GitLab MR data to the provider-neutralChangeRequestshape and registers it inSourceControlProviderRegistryand the server'sGitManagerLayerLive.aheadOfDefaultCounttoVcsStatusRemoteShape,GitStatusDetails, and all status paths so the UI can distinguish ahead-of-default from ahead-of-upstream.buildMenuItemsandresolveQuickActionto useaheadOfDefaultCountfor enabling Create PR, and removes the uncommitted-changes guard from the Push action.pushCurrentBranchto strip remote shorthands before constructing therefs/heads/<branch>push target, preventing spurious branches likeorigin/mainon the remote.Macroscope summarized a812dcb.