diff --git a/apps/desktop/src/renderer/lib/github/openPullRequestLink/openPullRequestLink.test.ts b/apps/desktop/src/renderer/lib/github/openPullRequestLink/openPullRequestLink.test.ts
index f5ed32a1601..589e9aba166 100644
--- a/apps/desktop/src/renderer/lib/github/openPullRequestLink/openPullRequestLink.test.ts
+++ b/apps/desktop/src/renderer/lib/github/openPullRequestLink/openPullRequestLink.test.ts
@@ -13,8 +13,8 @@ function open(url: string, action: LinkAction | null) {
}
describe("openPullRequestLink", () => {
- it("opens a URL that is not a GitHub pull request in the browser", () => {
- const url = "https://gitlab.com/acme/app/-/merge_requests/3";
+ it("opens a URL that is not a pull request in the browser", () => {
+ const url = "https://gitlab.com/acme/app/-/issues/3";
const { openPane, openInBrowser } = open(url, "pane");
expect(openInBrowser).toHaveBeenCalledWith(url);
expect(openPane).not.toHaveBeenCalled();
@@ -38,6 +38,13 @@ describe("openPullRequestLink", () => {
expect(openInBrowser).not.toHaveBeenCalled();
});
+ it("splits the pull request pane for a GitLab merge request", () => {
+ const url = "https://gitlab.com/acme/sub/app/-/merge_requests/3";
+ const { openPane } = open(url, "pane");
+ const ref = { repoFullName: "gitlab.com/acme/sub/app", number: 3 };
+ expect(openPane).toHaveBeenCalledWith(ref, "split");
+ });
+
it("falls back to the split pane for an unbound tier", () => {
const { openPane, openInBrowser } = open(PR_URL, null);
expect(openPane).toHaveBeenCalledWith(PR_REF, "split");
diff --git a/apps/desktop/src/renderer/lib/github/pullRequestRef.ts b/apps/desktop/src/renderer/lib/github/pullRequestRef.ts
index 30867d28e2f..1a0433c424d 100644
--- a/apps/desktop/src/renderer/lib/github/pullRequestRef.ts
+++ b/apps/desktop/src/renderer/lib/github/pullRequestRef.ts
@@ -7,8 +7,12 @@ export interface PullRequestRef {
const PULL_REQUEST_URL =
/^https:\/\/github\.com\/([^/?#]+\/[^/?#]+)\/pull\/(\d+)(?:[/?#]|$)/;
+// A GitLab MR keeps its host in the name, so it never reads as a GitHub repo.
+const MERGE_REQUEST_URL =
+ /^https:\/\/([^/?#]+\/[^?#]+?)\/-\/merge_requests\/(\d+)(?:[/?#]|$)/;
+
export function pullRequestRefFromUrl(url: string): PullRequestRef | null {
- const match = PULL_REQUEST_URL.exec(url);
+ const match = PULL_REQUEST_URL.exec(url) ?? MERGE_REQUEST_URL.exec(url);
if (!match?.[1] || !match[2]) return null;
return { repoFullName: match[1], number: Number(match[2]) };
}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/PRStatusGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/PRStatusGroup.tsx
index 95ee84769c9..1c5360fbe6d 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/PRStatusGroup.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/PRStatusGroup.tsx
@@ -445,7 +445,7 @@ export function PRStatusGroup({
- View on GitHub
+ Open in browser
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx
index e010289630c..a0dff48b5ad 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx
@@ -96,7 +96,7 @@ export function PRDetailCard({ pr, checks, linkState }: PRDetailCardProps) {
className="group flex items-center justify-between border-t border-border/60 px-3 py-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
- View on GitHub
+ Open in browser
diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts
index 4bc80d615f8..aec51c9f20c 100644
--- a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts
+++ b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts
@@ -149,6 +149,7 @@ function createManager(
db: HostDb,
overrides: {
execGh?: (args: string[]) => Promise;
+ execGlab?: (args: string[]) => Promise;
github?: () => Promise;
git?: unknown;
readWorkspaceRefs?: (
@@ -174,6 +175,7 @@ function createManager(
((async () => {
throw new Error("octokit should not be used");
}) as never),
+ execGlab: overrides.execGlab as never,
gitWatcher: { onChanged: () => () => {} } as never,
readWorkspaceRefs: overrides.readWorkspaceRefs,
// Seeded worktree paths are fabricated; default the disk gate open so
@@ -2490,3 +2492,89 @@ describe("PullRequestRuntimeManager GitHub traffic", () => {
expect(getWorkspace(db, "ws")?.pullRequestId).toBeNull();
});
});
+
+describe("GitLab through glab", () => {
+ function gitlabProject(owner: string, answer: (args: string[]) => unknown) {
+ const db = createRealDb();
+ db.insert(schema.projects)
+ .values({ id: PROJECT_ID, repoPath: "/repo", createdAt: Date.now() })
+ .run();
+ seedWorkspace(db, {
+ id: "ws",
+ branch: "feat",
+ upstreamOwner: owner,
+ upstreamRepo: "app",
+ upstreamBranch: "feat",
+ });
+ const calls: string[][] = [];
+ const manager = createManager(db, {
+ git: async () => ({
+ remote: async () => `git@${owner.replace("/", ":")}/app.git`,
+ raw: async () => "origin/main\n",
+ }),
+ execGlab: async (args) => {
+ calls.push(args);
+ return answer(args);
+ },
+ });
+ return { db, calls, refresh: () => projectRefresher(manager)(PROJECT_ID) };
+ }
+
+ test("links a workspace to its MR with the head pipeline as its check", async () => {
+ const webUrl =
+ "https://gitlab.example.com/group/sub/app/-/merge_requests/7";
+ const glab: Record = {
+ "config get": "gitlab.example.com",
+ "mr list": [
+ {
+ iid: 7,
+ web_url: webUrl,
+ state: "opened",
+ source_branch: "feat",
+ sha: "sha-1",
+ source_project_id: 1,
+ target_project_id: 1,
+ },
+ ],
+ "mr view": {
+ web_url: webUrl,
+ target_project_id: 1,
+ detailed_merge_status: "not_approved",
+ head_pipeline: { id: 9 },
+ },
+ "ci get": { jobs: [{ name: "test", status: "failed" }] },
+ };
+ const { db, calls, refresh } = gitlabProject(
+ "gitlab.example.com/group/sub",
+ (args) => glab[`${args[0]} ${args[1]}`] ?? {},
+ );
+
+ await refresh();
+
+ const pr = getPrById(db, getWorkspace(db, "ws")?.pullRequestId ?? "");
+ expect(pr).toMatchObject({
+ repoProvider: "gitlab",
+ repoOwner: "gitlab.example.com/group/sub",
+ url: webUrl,
+ state: "open",
+ reviewDecision: "pending",
+ checksStatus: "failure",
+ });
+ expect(calls[1]).toContain("https://gitlab.example.com/group/sub/app");
+ expect(db.select().from(schema.projects).get()?.repoProvider).toBeNull();
+ });
+
+ test("asks glab again about an unknown host and looks up no MR", async () => {
+ const { db, calls, refresh } = gitlabProject(
+ "git.example.com/team",
+ () => ({}),
+ );
+
+ await refresh();
+ await refresh();
+
+ const ask = ["config", "get", "api_host", "--host", "git.example.com"];
+ expect(calls).toEqual([ask, ask]);
+ expect(getWorkspace(db, "ws")?.pullRequestId).toBeNull();
+ });
+});
diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts
index 5b80c2ed1f2..c5519187e84 100644
--- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts
+++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import type { Octokit } from "@octokit/rest";
import { parseGitHubRemote } from "@superset/shared/github-remote";
+import { parseGitLabRemote } from "@superset/shared/gitlab-remote";
import { and, eq, inArray, isNull } from "drizzle-orm";
import type { HostDb } from "../../db";
import {
@@ -13,6 +14,10 @@ import {
import type { EventBus } from "../../events/event-bus";
import type { GitWatcher } from "../../events/git-watcher";
import type { ExecGh } from "../../trpc/router/workspace-creation/utils/exec-gh";
+import {
+ type ExecGlab,
+ execGlab,
+} from "../../trpc/router/workspace-creation/utils/exec-glab";
import { type GitFactory, resolveDefaultBranchName } from "../git";
import { ConditionalGh } from "./utils/conditional-gh";
import {
@@ -26,8 +31,10 @@ import {
fetchOpenPullRequestsFromGh,
fetchPullRequestByHead,
fetchPullRequestByHeadFromGh,
+ fetchPullRequestByHeadFromGlab,
fetchPullRequestChecks,
fetchPullRequestChecksFromGh,
+ fetchPullRequestDetailsFromGlab,
fetchPullRequestMergeQueueState,
fetchPullRequestMergeQueueStateFromGh,
fetchPullRequestReviewDecision,
@@ -92,7 +99,7 @@ function upstreamKey(
return `${owner.toLowerCase()}/${repo.toLowerCase()}#${branch}`;
}
-type RepoProvider = "github";
+type RepoProvider = "github" | "gitlab";
export interface PullRequestStateSnapshot {
url: string;
@@ -144,6 +151,7 @@ export interface PullRequestRuntimeManagerOptions {
execGh: ExecGh;
git: GitFactory;
github: () => Promise;
+ execGlab?: ExecGlab;
gitWatcher: GitWatcher;
/** Override to run the per-workspace branch/HEAD/upstream read off the
* event loop (app wiring passes a worker-pool-backed reader). Defaults
@@ -223,6 +231,8 @@ export class PullRequestRuntimeManager {
private readonly conditionalGh: ConditionalGh;
private readonly git: GitFactory;
private readonly github: () => Promise;
+ private readonly execGlab: ExecGlab;
+ private readonly glabHosts = new Set();
private readonly gitWatcher: GitWatcher;
private safetyNetTimer: ReturnType | null = null;
private projectRefreshTimer: ReturnType | null = null;
@@ -288,6 +298,7 @@ export class PullRequestRuntimeManager {
this.execGh = this.conditionalGh.exec;
this.git = options.git;
this.github = options.github;
+ this.execGlab = options.execGlab ?? execGlab;
this.gitWatcher = options.gitWatcher;
this.readWorkspaceRefs =
options.readWorkspaceRefs ??
@@ -1049,6 +1060,13 @@ export class PullRequestRuntimeManager {
}
}
+ async getGitLabRepository(
+ projectId: string,
+ ): Promise {
+ const repo = await this.getProjectRepository(projectId);
+ return repo?.provider === "gitlab" ? repo : null;
+ }
+
private async getProjectRepository(
projectId: string,
): Promise {
@@ -1090,7 +1108,13 @@ export class PullRequestRuntimeManager {
}
const parsedRemote = parseGitHubRemote(remoteUrl);
- if (!parsedRemote) return null;
+ if (!parsedRemote) {
+ // Not persisted: the rest of the app reads projects.repo* as GitHub.
+ const gitlab = parseGitLabRemote(remoteUrl);
+ if (!gitlab || !(await this.isGlabHost(gitlab))) return null;
+ const defaultBranch = await this.resolveDefaultBranch(project.repoPath);
+ return { ...gitlab, remoteName, defaultBranch };
+ }
this.db
.update(projects)
@@ -1111,6 +1135,23 @@ export class PullRequestRuntimeManager {
return { ...identity, defaultBranch };
}
+ // glab knows a host when its config has an API host for it (a local read).
+ // Only known hosts are kept, so a host added later shows at the next refresh.
+ private async isGlabHost({ owner }: { owner: string }): Promise {
+ const host = owner.slice(0, owner.indexOf("/"));
+ if (this.glabHosts.has(host)) return true;
+ const apiHost = await this.execGlab([
+ "config",
+ "get",
+ "api_host",
+ "--host",
+ host,
+ ]).catch(() => "");
+ if (typeof apiHost !== "string" || apiHost === "") return false;
+ this.glabHosts.add(host);
+ return true;
+ }
+
// Shared origin/HEAD resolver; a repo-open failure disables the guard
// rather than aborting the whole refresh.
private async resolveDefaultBranch(repoPath: string): Promise {
@@ -1415,23 +1456,25 @@ export class PullRequestRuntimeManager {
cacheKey,
options,
() =>
- this.fetchFromGitHub(
- "PR head lookup",
- { owner: repo.owner, name: repo.name, head },
- () =>
- fetchPullRequestByHeadFromGh(
- this.execGh,
- { owner: repo.owner, name: repo.name },
- head,
- ),
- async () =>
- fetchPullRequestByHead(
- await this.github(),
- { owner: repo.owner, name: repo.name },
- head,
+ repo.provider === "gitlab"
+ ? fetchPullRequestByHeadFromGlab(this.execGlab, repo, head)
+ : this.fetchFromGitHub(
+ "PR head lookup",
+ { owner: repo.owner, name: repo.name, head },
+ () =>
+ fetchPullRequestByHeadFromGh(
+ this.execGh,
+ { owner: repo.owner, name: repo.name },
+ head,
+ ),
+ async () =>
+ fetchPullRequestByHead(
+ await this.github(),
+ { owner: repo.owner, name: repo.name },
+ head,
+ ),
+ { probe: options.bypassCache === true },
),
- { probe: options.bypassCache === true },
- ),
);
}
@@ -1454,6 +1497,13 @@ export class PullRequestRuntimeManager {
cacheKey,
{ ...options, fingerprint: node.headRefOid },
async () => {
+ if (repo.provider === "gitlab") {
+ return fetchPullRequestDetailsFromGlab(
+ this.execGlab,
+ repo,
+ node.number,
+ );
+ }
const [reviewDecision, checks] = await this.fetchFromGitHub(
"PR review/check lookup",
context,
@@ -1524,6 +1574,9 @@ export class PullRequestRuntimeManager {
repo: NormalizedRepoIdentity,
options: { bypassCache?: boolean } = {},
): Promise {
+ // No GitLab sweep yet. GitLab's branch filter is also case-sensitive, so a
+ // branch whose case drifted from its MR stays unlinked.
+ if (repo.provider === "gitlab") return [];
const cacheKey = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}`;
return this.cachedGitHubFetch(
this.openPullRequestsCache,
diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-gitlab-query.test.ts
similarity index 99%
rename from packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts
rename to packages/host-service/src/runtime/pull-requests/utils/github-query/github-gitlab-query.test.ts
index 50a76f25fe1..9fe2d110306 100644
--- a/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts
+++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-gitlab-query.test.ts
@@ -6,7 +6,7 @@ import {
fetchPullRequestMergeQueueStateFromGh,
fetchPullRequestReviewDecisionFromGh,
parseMergedAt,
-} from "./github-query";
+} from "./github-gitlab-query";
interface GhCall {
args: string[];
diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-gitlab-query.ts
similarity index 77%
rename from packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
rename to packages/host-service/src/runtime/pull-requests/utils/github-query/github-gitlab-query.ts
index 3aa3022418e..d85259ce9ea 100644
--- a/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
+++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-gitlab-query.ts
@@ -1,5 +1,6 @@
import type { Octokit } from "@octokit/rest";
import type { ExecGh } from "../../../../trpc/router/workspace-creation/utils/exec-gh";
+import type { ExecGlab } from "../../../../trpc/router/workspace-creation/utils/exec-glab";
import type {
GitHubCheckContextNode,
GitHubPullRequestHeadRef,
@@ -77,6 +78,7 @@ export function parseMergedAt(mergedAt: string | null): number | null {
: null;
}
+// If you change this, also look at its GitLab twin `normalizeGitLabMergeRequest`.
function normalizePullRequest(raw: unknown): GitHubPullRequestNode | null {
if (!isRecord(raw) || !isRecord(raw.head)) return null;
const headRepo = isRecord(raw.head.repo) ? raw.head.repo : null;
@@ -162,6 +164,8 @@ function normalizePullRequestCandidates(
);
}
+// If you change this, also look at its GitLab twin: `reviewDecision` in
+// `fetchPullRequestDetailsFromGlab`.
function mapReviewDecision(
rawReviews: unknown,
prState: PullRequestState,
@@ -197,6 +201,7 @@ function mapReviewDecision(
return prState === "OPEN" ? "REVIEW_REQUIRED" : null;
}
+// If you change this, also look at its GitLab twin `toGitLabJobCheck`.
function toCheckRunNode(raw: RestCheckRun): GitHubCheckContextNode | null {
if (!raw.name) return null;
@@ -507,3 +512,124 @@ export async function fetchPullRequestChecks(
(node): node is NonNullable => node !== null,
);
}
+
+interface GitLabRepository {
+ owner: string;
+ name: string;
+ url: string;
+}
+
+// An MR names its source project by id only, so a fork MR gets no head repo and
+// is never linked: forks are not supported.
+// If you change this, also look at its GitHub twin `normalizePullRequest`.
+function normalizeGitLabMergeRequest(
+ raw: unknown,
+ repository: GitLabRepository,
+): GitHubPullRequestNode | null {
+ if (!isRecord(raw) || typeof raw.sha !== "string") return null;
+ const sameProject = raw.source_project_id === raw.target_project_id;
+ const mergedAt = typeof raw.merged_at === "string" ? raw.merged_at : null;
+ return {
+ number: Number(raw.iid),
+ title: String(raw.title),
+ url: String(raw.web_url),
+ state: normalizePullRequestState(String(raw.state), mergedAt),
+ isDraft: raw.draft === true,
+ headRefName: String(raw.source_branch),
+ headRefOid: raw.sha,
+ isCrossRepository: !sameProject,
+ headRepositoryOwner: sameProject ? { login: repository.owner } : null,
+ headRepository: sameProject ? { name: repository.name } : null,
+ updatedAt: String(raw.updated_at),
+ mergedAt: parseMergedAt(mergedAt),
+ };
+}
+
+export async function fetchPullRequestByHeadFromGlab(
+ execGlab: ExecGlab,
+ repository: GitLabRepository,
+ head: GitHubPullRequestHeadRef,
+): Promise {
+ const raw = await execGlab([
+ "mr",
+ "list",
+ "--all",
+ "-R",
+ repository.url,
+ "-s",
+ head.branch,
+ "-F",
+ "json",
+ ]);
+ return (
+ asArray(raw)
+ .map((item) => normalizeGitLabMergeRequest(item, repository))
+ .find((node) => node && !node.isCrossRepository) ?? null
+ );
+}
+
+const GITLAB_JOB_CONCLUSIONS: Record = {
+ success: "SUCCESS",
+ failed: "FAILURE",
+ canceled: "CANCELLED",
+ skipped: "SKIPPED",
+ manual: "SKIPPED",
+};
+
+// If you change this, also look at its GitHub twin `toCheckRunNode`.
+function toGitLabJobCheck(job: unknown): GitHubCheckContextNode {
+ if (!isRecord(job)) return null;
+ // A manual job blocks the pipeline unless it may fail; only then is it skipped.
+ const conclusion =
+ job.allow_failure !== true && job.status === "manual"
+ ? null
+ : job.allow_failure === true && job.status === "failed"
+ ? "NEUTRAL"
+ : (GITLAB_JOB_CONCLUSIONS[String(job.status)] ?? null);
+ return {
+ __typename: "CheckRun",
+ name: String(job.name),
+ conclusion,
+ detailsUrl: job.web_url ? String(job.web_url) : null,
+ status: conclusion ? "COMPLETED" : "IN_PROGRESS",
+ startedAt: null,
+ completedAt: null,
+ checkSuite: null,
+ };
+}
+
+// The jobs of the MR's head pipeline stand in for GitHub's checks.
+export async function fetchPullRequestDetailsFromGlab(
+ execGlab: ExecGlab,
+ repository: GitLabRepository,
+ number: number,
+) {
+ const glabJson = (...args: string[]) =>
+ execGlab([...args, "-R", repository.url, "-F", "json"]);
+ const raw = await glabJson("mr", "view", String(number));
+ const mr = isRecord(raw) ? raw : {};
+ const pipeline = isRecord(mr.head_pipeline) ? mr.head_pipeline : null;
+ const [ci, approvals] = await Promise.all([
+ pipeline ? glabJson("ci", "get", "-p", String(pipeline.id)) : null,
+ execGlab([
+ "api",
+ "--hostname",
+ new URL(String(mr.web_url)).host,
+ `projects/${mr.target_project_id}/merge_requests/${number}/approvals`,
+ ]).catch(() => null),
+ ]);
+ return {
+ // With no approval rule GitLab says `approved`, so also require an approver.
+ // If you change this, also look at its GitHub twin `mapReviewDecision`.
+ reviewDecision:
+ isRecord(approvals) &&
+ approvals.approved === true &&
+ asArray(approvals.approved_by).length > 0
+ ? ("APPROVED" as const)
+ : mr.detailed_merge_status === "not_approved"
+ ? ("REVIEW_REQUIRED" as const)
+ : null,
+ checks: isRecord(ci) ? asArray(ci.jobs).map(toGitLabJobCheck) : [],
+ isInMergeQueue: null,
+ };
+}
diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts
index 4bac2e86d8e..0879a8dccfc 100644
--- a/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts
+++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts
@@ -3,14 +3,16 @@ export {
fetchOpenPullRequestsFromGh,
fetchPullRequestByHead,
fetchPullRequestByHeadFromGh,
+ fetchPullRequestByHeadFromGlab,
fetchPullRequestChecks,
fetchPullRequestChecksFromGh,
+ fetchPullRequestDetailsFromGlab,
fetchPullRequestMergeQueueState,
fetchPullRequestMergeQueueStateFromGh,
fetchPullRequestReviewDecision,
fetchPullRequestReviewDecisionFromGh,
parseMergedAt,
-} from "./github-query";
+} from "./github-gitlab-query";
export type {
GitHubCheckContextNode,
GitHubPullRequestHeadRef,
diff --git a/packages/host-service/src/runtime/pull-requests/utils/workspace-refs.ts b/packages/host-service/src/runtime/pull-requests/utils/workspace-refs.ts
index 6ec67bf8f09..fad3f0ec08b 100644
--- a/packages/host-service/src/runtime/pull-requests/utils/workspace-refs.ts
+++ b/packages/host-service/src/runtime/pull-requests/utils/workspace-refs.ts
@@ -3,6 +3,7 @@
// pulling the runtime's DB/Octokit graph into the worker bundle.
import { parseGitHubRemote } from "@superset/shared/github-remote";
+import { parseGitLabRemote } from "@superset/shared/gitlab-remote";
import type { SimpleGit } from "simple-git";
const UNBORN_HEAD_ERROR_PATTERNS = [
@@ -89,7 +90,9 @@ async function resolveWorkspaceUpstream(
const slash = pushRef.indexOf("/");
if (slash > 0) {
const url = await resolveRemoteValueToUrl(git, pushRef.slice(0, slash));
- const parsed = url ? parseGitHubRemote(url) : null;
+ const parsed = url
+ ? (parseGitHubRemote(url) ?? parseGitLabRemote(url))
+ : null;
if (parsed) {
return {
owner: parsed.owner,
@@ -114,7 +117,9 @@ async function resolveWorkspaceUpstream(
if (!remoteValue) return null;
const url = await resolveRemoteValueToUrl(git, remoteValue);
- const parsed = url ? parseGitHubRemote(url) : null;
+ const parsed = url
+ ? (parseGitHubRemote(url) ?? parseGitLabRemote(url))
+ : null;
if (!parsed) return null;
// `gh pr checkout` renames the local branch on collision (`main` →
diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts
index 064e830931b..4389221a604 100644
--- a/packages/host-service/src/trpc/router/git/git.ts
+++ b/packages/host-service/src/trpc/router/git/git.ts
@@ -25,6 +25,7 @@ import { cancelAndWaitWorkspaceTitleCommit } from "../../../workspaces/workspace
import { protectedProcedure, queryProcedure, router } from "../../index";
import { rethrowWorkerTaskAbort } from "../../worker-abort";
import { resolveGithubRepo } from "../workspace-creation/shared/project-helpers";
+import { execGlab } from "../workspace-creation/utils/exec-glab";
import type {
ChangedFile,
CheckConclusionState,
@@ -49,6 +50,11 @@ import {
} from "./utils/git-helpers";
import { gitStatusRefreshLimiter } from "./utils/git-status-refresh-limiter";
import { gitStatusStore } from "./utils/git-status-store";
+import {
+ fetchGitLabThreads,
+ replyToGitLabThread,
+ setGitLabThreadResolution,
+} from "./utils/gitlab-discussions";
import {
type GraphQLThreadsResult,
parseGraphQLThreads,
@@ -1061,6 +1067,23 @@ export const gitRouter = router({
});
}
+ const project =
+ pr.repoProvider === "gitlab" ? pr.url.split("/-/")[0] : undefined;
+ const gitlabJobId =
+ project && input.detailsUrl.startsWith(`${project}/-/jobs/`)
+ ? input.detailsUrl.match(/\/-\/jobs\/(\d+)/)?.[1]
+ : undefined;
+ if (project && gitlabJobId) {
+ const logs = await execGlab(
+ ["ci", "trace", gitlabJobId, "-R", project],
+ {
+ timeout: 30_000,
+ maxBuffer: 50 * 1024 * 1024,
+ },
+ );
+ return { logs: String(logs) };
+ }
+
// GitHub Actions check details URLs look like
// https://github.com///actions/runs//job/
const isGithubUrl =
@@ -1114,6 +1137,7 @@ export const gitRouter = router({
});
}
+ if (pr.repoProvider === "gitlab") return fetchGitLabThreads(pr.url);
// Session workspaces (null projectId) have no GitHub remote.
if (workspace.projectId === null) {
return { reviewThreads: [], conversationComments: [] };
@@ -1209,6 +1233,16 @@ export const gitRouter = router({
});
}
+ const pr = workspace.pullRequestId
+ ? ctx.db.query.pullRequests
+ .findFirst({ where: eq(pullRequests.id, workspace.pullRequestId) })
+ .sync()
+ : undefined;
+ if (pr?.repoProvider === "gitlab") {
+ await setGitLabThreadResolution(pr.url, input.threadId, input.resolved);
+ return { threadId: input.threadId, isResolved: input.resolved };
+ }
+
const octokit = await ctx.github();
const mutation = input.resolved
? `mutation($threadId: ID!) {
@@ -1267,6 +1301,9 @@ export const gitRouter = router({
});
}
+ if (pr.repoProvider === "gitlab") {
+ return replyToGitLabThread(pr.url, input.commentId, input.body);
+ }
// The PR row already names the repo the PR lives in, so there's no
// remote to parse (resolveGithubRepo). A comment id from some other
// PR 404s rather than landing somewhere unexpected.
diff --git a/packages/host-service/src/trpc/router/git/utils/gitlab-discussions.test.ts b/packages/host-service/src/trpc/router/git/utils/gitlab-discussions.test.ts
new file mode 100644
index 00000000000..84f89ffa93c
--- /dev/null
+++ b/packages/host-service/src/trpc/router/git/utils/gitlab-discussions.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test";
+import { parseGitLabDiscussions } from "./gitlab-discussions";
+
+const MR_URL = "https://gitlab.example.com/team/app/-/merge_requests/7";
+const author = { username: "ada", avatar_url: "https://a/ada.png" };
+const note = (id: number, extra: object = {}) => ({
+ id,
+ body: `note ${id}`,
+ created_at: "2026-10-21T10:00:00Z",
+ author,
+ ...extra,
+});
+
+describe("parseGitLabDiscussions", () => {
+ test("maps diff notes to threads and other notes to conversation", () => {
+ const position = {
+ new_path: "src/a.ts",
+ old_path: "src/a.ts",
+ new_line: 12,
+ };
+ const result = parseGitLabDiscussions(
+ [
+ { id: "sys", notes: [note(1, { system: true })] },
+ {
+ id: "diff",
+ notes: [note(2, { position, resolved: true }), note(3, { position })],
+ },
+ { id: "talk", notes: [note(4)] },
+ ],
+ MR_URL,
+ );
+
+ expect(result.reviewThreads).toEqual([
+ {
+ id: "diff",
+ isResolved: true,
+ isOutdated: false,
+ diffSide: "RIGHT",
+ line: 12,
+ path: "src/a.ts",
+ comments: [2, 3].map((id) => ({
+ id: String(id),
+ databaseId: id,
+ author: { login: "ada", avatarUrl: "https://a/ada.png" },
+ body: `note ${id}`,
+ createdAt: "2026-10-21T10:00:00Z",
+ })),
+ },
+ ]);
+ expect(result.conversationComments).toEqual([
+ {
+ id: 4,
+ user: { login: "ada", avatarUrl: "https://a/ada.png" },
+ body: "note 4",
+ createdAt: "2026-10-21T10:00:00Z",
+ htmlUrl: `${MR_URL}#note_4`,
+ },
+ ]);
+ });
+});
diff --git a/packages/host-service/src/trpc/router/git/utils/gitlab-discussions.ts b/packages/host-service/src/trpc/router/git/utils/gitlab-discussions.ts
new file mode 100644
index 00000000000..560de36a9ea
--- /dev/null
+++ b/packages/host-service/src/trpc/router/git/utils/gitlab-discussions.ts
@@ -0,0 +1,126 @@
+import { TRPCError } from "@trpc/server";
+import { execGlab } from "../../workspace-creation/utils/exec-glab";
+import type { IssueComment, PullRequestReviewThread } from "../types";
+
+interface GitLabNote {
+ id: number;
+ system?: boolean;
+ body: string;
+ created_at: string;
+ resolved?: boolean;
+ author: { username: string; avatar_url?: string | null };
+ position?: {
+ new_path?: string | null;
+ old_path?: string | null;
+ new_line?: number | null;
+ old_line?: number | null;
+ } | null;
+}
+
+interface GitLabDiscussion {
+ id: string;
+ notes?: GitLabNote[];
+}
+
+// `glab api` takes no -R: name the MR's project by its web host and path.
+function mergeRequestApi(mrUrl: string, path: string, args: string[] = []) {
+ const [project = "", iid = ""] = mrUrl.split("/-/merge_requests/");
+ const { host, pathname } = new URL(project);
+ const endpoint = `projects/${encodeURIComponent(pathname.slice(1))}/merge_requests/${Number.parseInt(iid, 10)}${path}`;
+ return execGlab(["api", "--hostname", host, ...args, endpoint]);
+}
+
+async function fetchDiscussions(mrUrl: string): Promise {
+ const raw = await mergeRequestApi(mrUrl, "/discussions", [
+ "--paginate",
+ "--output",
+ "ndjson",
+ ]);
+ // One discussion per line; a single one arrives already parsed.
+ return typeof raw === "string"
+ ? raw.split("\n").map((line) => JSON.parse(line))
+ : [raw as GitLabDiscussion];
+}
+
+const actor = (note: GitLabNote) => ({
+ login: note.author.username,
+ avatarUrl: note.author.avatar_url ?? "",
+});
+
+/** Diff notes become review threads; every other comment is conversation. */
+export function parseGitLabDiscussions(
+ discussions: GitLabDiscussion[],
+ mrUrl: string,
+) {
+ const reviewThreads: PullRequestReviewThread[] = [];
+ const conversationComments: IssueComment[] = [];
+ for (const discussion of discussions) {
+ const notes = (discussion.notes ?? []).filter((note) => !note.system);
+ const position = notes[0]?.position;
+ if (!position) {
+ for (const note of notes) {
+ conversationComments.push({
+ id: note.id,
+ user: actor(note),
+ body: note.body,
+ createdAt: note.created_at,
+ htmlUrl: `${mrUrl}#note_${note.id}`,
+ });
+ }
+ continue;
+ }
+ reviewThreads.push({
+ id: discussion.id,
+ isResolved: notes[0]?.resolved === true,
+ isOutdated: false,
+ diffSide: position.new_line == null ? "LEFT" : "RIGHT",
+ line: position.new_line ?? position.old_line ?? null,
+ path: position.new_path ?? position.old_path ?? "",
+ comments: notes.map((note) => ({
+ id: String(note.id),
+ databaseId: note.id,
+ author: actor(note),
+ body: note.body,
+ createdAt: note.created_at,
+ })),
+ });
+ }
+ return { reviewThreads, conversationComments };
+}
+
+export async function fetchGitLabThreads(mrUrl: string) {
+ return parseGitLabDiscussions(await fetchDiscussions(mrUrl), mrUrl);
+}
+
+export async function setGitLabThreadResolution(
+ mrUrl: string,
+ discussionId: string,
+ resolved: boolean,
+) {
+ await mergeRequestApi(mrUrl, `/discussions/${discussionId}`, [
+ "--method",
+ "PUT",
+ "-F",
+ `resolved=${resolved}`,
+ ]);
+}
+
+// The UI replies to a note id; GitLab threads a reply onto its discussion.
+export async function replyToGitLabThread(
+ mrUrl: string,
+ noteId: number,
+ body: string,
+) {
+ const discussion = (await fetchDiscussions(mrUrl)).find((candidate) =>
+ candidate.notes?.some((note) => note.id === noteId),
+ );
+ if (!discussion) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Thread not found" });
+ }
+ const note = await mergeRequestApi(
+ mrUrl,
+ `/discussions/${discussion.id}/notes`,
+ ["--method", "POST", "-f", `body=${body}`],
+ );
+ return { id: Number((note as { id?: unknown }).id) };
+}
diff --git a/packages/host-service/src/trpc/router/git/utils/reply-to-review-comment.ts b/packages/host-service/src/trpc/router/git/utils/reply-to-review-comment.ts
index 27e77b0dc9f..be86d90388e 100644
--- a/packages/host-service/src/trpc/router/git/utils/reply-to-review-comment.ts
+++ b/packages/host-service/src/trpc/router/git/utils/reply-to-review-comment.ts
@@ -1,5 +1,5 @@
import type { Octokit } from "@octokit/rest";
-import { actionRejectionError } from "../../github/github";
+import { actionRejectionError } from "../../github/github-gitlab";
export interface ReplyToReviewCommentInput {
owner: string;
diff --git a/packages/host-service/src/trpc/router/github/github.ts b/packages/host-service/src/trpc/router/github/github-gitlab.ts
similarity index 96%
rename from packages/host-service/src/trpc/router/github/github.ts
rename to packages/host-service/src/trpc/router/github/github-gitlab.ts
index 160090ff4f9..5f35a8a54b9 100644
--- a/packages/host-service/src/trpc/router/github/github.ts
+++ b/packages/host-service/src/trpc/router/github/github-gitlab.ts
@@ -1,6 +1,7 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { protectedProcedure, router } from "../../index";
+import { execGlab } from "../workspace-creation/utils/exec-glab";
import { createGithubWorkItemLoader } from "./work-item";
const loadWorkItem = createGithubWorkItemLoader();
@@ -352,6 +353,22 @@ export const githubRouter = router({
}),
)
.mutation(async ({ ctx, input }) => {
+ const gitlab = gitlabProjectUrl(input);
+ if (gitlab) {
+ const method =
+ input.mergeMethod === "merge" ? [] : [`--${input.mergeMethod}`];
+ await execGlab([
+ "mr",
+ "merge",
+ String(input.pullNumber),
+ "-R",
+ gitlab,
+ "--auto-merge=false",
+ "--yes",
+ ...method,
+ ]);
+ return null;
+ }
const octokit = await ctx.github();
try {
const { data } = await octokit.pulls.merge({
@@ -375,6 +392,18 @@ export const githubRouter = router({
}),
)
.mutation(async ({ ctx, input }) => {
+ const gitlab = gitlabProjectUrl(input);
+ if (gitlab) {
+ await execGlab([
+ "mr",
+ "update",
+ String(input.pullNumber),
+ "-R",
+ gitlab,
+ "--ready",
+ ]);
+ return;
+ }
const octokit = await ctx.github();
try {
const id = await pullRequestNodeId(octokit, input);
@@ -831,3 +860,10 @@ export function actionRejectionError(
});
}
}
+
+// GitLab owners carry their host (`host/group`); GitHub owners have no slash.
+function gitlabProjectUrl(input: { owner: string; repo: string }) {
+ return input.owner.includes("/")
+ ? `https://${input.owner}/${input.repo}`
+ : null;
+}
diff --git a/packages/host-service/src/trpc/router/github/index.ts b/packages/host-service/src/trpc/router/github/index.ts
index 4fa29a0d96d..ca5314fda55 100644
--- a/packages/host-service/src/trpc/router/github/index.ts
+++ b/packages/host-service/src/trpc/router/github/index.ts
@@ -1 +1 @@
-export { githubRouter } from "./github";
+export { githubRouter } from "./github-gitlab";
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts
index 16f97fd5276..5502cf3ac83 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts
@@ -7,8 +7,9 @@ import { getHostWorkerPool } from "../../../../workers/host-worker-pool";
import { gitPrHeadBaseTask } from "../../../../workers/tasks/git";
import { protectedProcedure } from "../../../index";
import { resolveWorktreePath } from "../../git/utils/resolve-worktree";
-import { actionRejectionError } from "../../github/github";
+import { actionRejectionError } from "../../github/github-gitlab";
import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers";
+import { execGlab } from "../../workspace-creation/utils/exec-glab";
const createInputSchema = z.object({
workspaceId: z.string(),
@@ -68,6 +69,45 @@ export const createForWorkspace = protectedProcedure
});
}
+ const gitlab = await ctx.runtime.pullRequests.getGitLabRepository(
+ workspace.projectId,
+ );
+ if (gitlab) {
+ const output = String(
+ await execGlab(
+ [
+ "mr",
+ "create",
+ "-R",
+ gitlab.url,
+ "-s",
+ head,
+ "-b",
+ base,
+ "-t",
+ input.title,
+ "-d",
+ input.body ?? "",
+ ...(input.draft ? ["--draft"] : []),
+ "--yes",
+ ],
+ { cwd: worktreePath },
+ ),
+ );
+ const created = /\S+\/-\/merge_requests\/(\d+)/.exec(output);
+ if (!created) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: output });
+ }
+ await ctx.runtime.pullRequests
+ .refreshPullRequestsByWorkspaces([input.workspaceId])
+ .catch((error) =>
+ console.warn(
+ "[pull-requests:create-for-workspace] created MR but failed to refresh workspace link",
+ { workspaceId: input.workspaceId, error },
+ ),
+ );
+ return { number: Number(created[1]), url: created[0] };
+ }
const repo = await resolveGithubRepo(ctx, workspace.projectId);
const octokit = await ctx.github();
let created: { number: number; html_url: string };
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/get-content-by-repo.test.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/get-content-by-repo.test.ts
index b9adc95f736..23b657c83ae 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/get-content-by-repo.test.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/get-content-by-repo.test.ts
@@ -12,7 +12,10 @@ import { getContentByRepo } from "./get-content-by-repo";
const createCaller = createCallerFactory(
router({ getContent, getContentByRepo }),
);
-const caller = createCaller({ isAuthenticated: true } as HostServiceContext);
+const caller = createCaller({
+ isAuthenticated: true,
+ runtime: { pullRequests: { getGitLabRepository: async () => null } },
+} as unknown as HostServiceContext);
const rawContent = {
number: 12,
title: "Fix the fallback",
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/get-content.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/get-content.ts
index 423e4584d2f..0913de79b9e 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/get-content.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/get-content.ts
@@ -2,12 +2,19 @@ import { z } from "zod";
import { protectedProcedure } from "../../../index";
import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers";
import { fetchPullRequestContent } from "../shared/fetch-pull-request-content";
+import { fetchMergeRequestContent } from "../shared/gitlab-merge-request";
export const getContent = protectedProcedure
.input(
z.object({ projectId: z.string(), prNumber: z.number().int().positive() }),
)
.query(async ({ ctx, input }) => {
+ const gitlab = await ctx.runtime.pullRequests.getGitLabRepository(
+ input.projectId,
+ );
+ if (gitlab) {
+ return fetchMergeRequestContent(gitlab, input.prNumber);
+ }
const repo = await resolveGithubRepo(ctx, input.projectId);
return fetchPullRequestContent(repo, input.prNumber);
});
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/get-diff.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/get-diff.ts
index 4781c46dd96..f696208c70d 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/get-diff.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/get-diff.ts
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { protectedProcedure } from "../../../index";
import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers";
+import { execGlab } from "../../workspace-creation/utils/exec-glab";
import { fetchPullRequestDiff } from "../shared/fetch-pull-request-diff";
export const getDiff = protectedProcedure
@@ -10,6 +11,24 @@ export const getDiff = protectedProcedure
)
.query(async ({ ctx, input }) => {
try {
+ const gitlab = await ctx.runtime.pullRequests.getGitLabRepository(
+ input.projectId,
+ );
+ if (gitlab) {
+ const diff = [
+ "mr",
+ "diff",
+ String(input.prNumber),
+ "-R",
+ gitlab.url,
+ "--raw",
+ ];
+ const patch = await execGlab(diff, {
+ timeout: 30_000,
+ maxBuffer: 200 * 1024 * 1024,
+ });
+ return { patch: String(patch) };
+ }
const repo = await resolveGithubRepo(ctx, input.projectId);
return await fetchPullRequestDiff(
`${repo.owner}/${repo.name}`,
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/merge.test.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/merge.test.ts
index d9b6fb3d59a..7e1c96c21b8 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/merge.test.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/merge.test.ts
@@ -75,6 +75,7 @@ async function createHarness(
github: async () => octokit,
runtime: {
pullRequests: {
+ getGitLabRepository: async () => null,
refreshPullRequestsByWorkspaces: async (ids: string[]) => {
refreshCalls.push(ids);
if (options.refresh) await options.refresh(ids);
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/merge.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/merge.ts
index ada9095ee66..20b99fef6c2 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/merge.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/merge.ts
@@ -1,7 +1,8 @@
import { z } from "zod";
import { protectedProcedure } from "../../../index";
-import { actionRejectionError } from "../../github/github";
+import { actionRejectionError } from "../../github/github-gitlab";
import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers";
+import { writeGitLabMergeRequest } from "../shared/gitlab-merge-request";
import { syncPullRequestAfterWrite } from "../shared/sync-after-write";
const mergeInputSchema = z.object({
@@ -19,6 +20,22 @@ const mergeInputSchema = z.object({
export const mergePR = protectedProcedure
.input(mergeInputSchema)
.mutation(async ({ ctx, input }) => {
+ const merge = [
+ "merge",
+ String(input.prNumber),
+ "--auto-merge=false",
+ "--yes",
+ ];
+ if (input.mergeMethod !== "merge") merge.push(`--${input.mergeMethod}`);
+ if (input.commitMessage) {
+ merge.push(
+ input.mergeMethod === "squash" ? "--squash-message" : "--message",
+ input.commitMessage,
+ );
+ }
+ if (await writeGitLabMergeRequest(ctx, input, "merge", merge)) {
+ return { merged: true };
+ }
const repo = await resolveGithubRepo(ctx, input.projectId);
const octokit = await ctx.github();
let merged: Awaited>["data"];
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.test.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.test.ts
index c7a26cce512..7d690b04bdf 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.test.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.test.ts
@@ -8,7 +8,8 @@ import { setDraft } from "./set-draft";
const caller = createCallerFactory(router({ setDraft }))({
isAuthenticated: true,
-} as HostServiceContext);
+ runtime: { pullRequests: { getGitLabRepository: async () => null } },
+} as unknown as HostServiceContext);
afterEach(() => mock.restore());
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.ts
index 92bd7395bc2..a6723afd0d4 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/set-draft.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { protectedProcedure } from "../../../index";
import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers";
import { execGh } from "../../workspace-creation/utils/exec-gh";
+import { writeGitLabMergeRequest } from "../shared/gitlab-merge-request";
import { syncPullRequestAfterWrite } from "../shared/sync-after-write";
const setDraftInputSchema = z.object({
@@ -15,8 +16,12 @@ const setDraftInputSchema = z.object({
export const setDraft = protectedProcedure
.input(setDraftInputSchema)
.mutation(async ({ ctx, input }) => {
- const repo = await resolveGithubRepo(ctx, input.projectId);
const action = input.draft ? "draft" : "ready";
+ const update = ["update", String(input.prNumber), `--${action}`];
+ if (await writeGitLabMergeRequest(ctx, input, action, update)) {
+ return { ok: true };
+ }
+ const repo = await resolveGithubRepo(ctx, input.projectId);
try {
await execGh([
"pr",
diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/set-state.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/set-state.ts
index 584a72563c3..3ace925f136 100644
--- a/packages/host-service/src/trpc/router/pull-requests/procedures/set-state.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/procedures/set-state.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { protectedProcedure } from "../../../index";
import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers";
import { execGh } from "../../workspace-creation/utils/exec-gh";
+import { writeGitLabMergeRequest } from "../shared/gitlab-merge-request";
import { syncPullRequestAfterWrite } from "../shared/sync-after-write";
const setStateInputSchema = z.object({
@@ -16,8 +17,16 @@ const setStateInputSchema = z.object({
export const setState = protectedProcedure
.input(setStateInputSchema)
.mutation(async ({ ctx, input }) => {
- const repo = await resolveGithubRepo(ctx, input.projectId);
const verb = input.state === "closed" ? "close" : "reopen";
+ if (
+ await writeGitLabMergeRequest(ctx, input, verb, [
+ verb,
+ String(input.prNumber),
+ ])
+ ) {
+ return { ok: true };
+ }
+ const repo = await resolveGithubRepo(ctx, input.projectId);
try {
await execGh([
"pr",
diff --git a/packages/host-service/src/trpc/router/pull-requests/shared/fetch-pull-request-diff.test.ts b/packages/host-service/src/trpc/router/pull-requests/shared/fetch-pull-request-diff.test.ts
index fa601203038..51c8db9790c 100644
--- a/packages/host-service/src/trpc/router/pull-requests/shared/fetch-pull-request-diff.test.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/shared/fetch-pull-request-diff.test.ts
@@ -25,7 +25,8 @@ test("project and repository endpoints share a case-insensitive in-flight cache"
});
const caller = createCallerFactory(router({ getDiff, getDiffByRepo }))({
isAuthenticated: true,
- } as HostServiceContext);
+ runtime: { pullRequests: { getGitLabRepository: async () => null } },
+ } as unknown as HostServiceContext);
const byProject = caller.getDiff({ projectId: "project", prNumber: 81 });
const byRepo = caller.getDiffByRepo({
repoFullName: "owner/shared",
diff --git a/packages/host-service/src/trpc/router/pull-requests/shared/gitlab-merge-request.ts b/packages/host-service/src/trpc/router/pull-requests/shared/gitlab-merge-request.ts
new file mode 100644
index 00000000000..a2a680cf8fb
--- /dev/null
+++ b/packages/host-service/src/trpc/router/pull-requests/shared/gitlab-merge-request.ts
@@ -0,0 +1,203 @@
+import { TRPCError } from "@trpc/server";
+import { fetchPullRequestDetailsFromGlab } from "../../../../runtime/pull-requests/utils/github-query";
+import type { HostServiceContext } from "../../../../types";
+import { fetchGitLabThreads } from "../../git/utils/gitlab-discussions";
+import { execGlab } from "../../workspace-creation/utils/exec-glab";
+import { normalizePullRequestChecks } from "../pull-request-checks";
+import { syncPullRequestAfterWrite } from "./sync-after-write";
+
+type PullRequestWrite = Parameters<
+ typeof syncPullRequestAfterWrite
+>[1]["action"];
+
+/** What `glab mr view|list -F json` returns, as far as Superset reads it. */
+interface MergeRequestJson {
+ iid: number;
+ title: string;
+ description: string | null;
+ web_url: string;
+ state: string;
+ draft: boolean;
+ sha: string;
+ source_branch: string;
+ target_branch: string;
+ source_project_id: number;
+ target_project_id: number;
+ author: { username: string } | null;
+ created_at: string;
+ updated_at: string;
+ merged_at: string | null;
+ closed_at: string | null;
+ has_conflicts: boolean;
+ detailed_merge_status: string;
+ changes_count: string | null;
+ reviewers: { username: string; name: string | null }[] | null;
+ labels: string[] | null;
+}
+
+const glabMrJson = (projectUrl: string, ...args: string[]) =>
+ execGlab(["mr", ...args, "-R", projectUrl, "-F", "json"]);
+
+const toState = (state: string): "open" | "closed" | "merged" =>
+ state === "opened" ? "open" : state === "merged" ? "merged" : "closed";
+
+/**
+ * Runs `glab mr -R ` when the project is on GitLab, then
+ * syncs the host's copies as a GitHub write does. False for other projects.
+ */
+export async function writeGitLabMergeRequest(
+ ctx: HostServiceContext,
+ input: { projectId: string; prNumber: number },
+ action: PullRequestWrite,
+ args: string[],
+): Promise {
+ const repo = await ctx.runtime.pullRequests.getGitLabRepository(
+ input.projectId,
+ );
+ if (!repo) return false;
+ await execGlab(["mr", ...args, "-R", repo.url]);
+ await syncPullRequestAfterWrite(ctx, {
+ repo,
+ prNumber: input.prNumber,
+ action,
+ });
+ return true;
+}
+
+/** A merge request in the shape `fetchPullRequestContent` gives a GitHub PR. */
+export async function fetchMergeRequestContent(
+ repo: { owner: string; name: string; url: string },
+ number: number,
+) {
+ const [raw, details] = await Promise.all([
+ glabMrJson(repo.url, "view", String(number)),
+ fetchPullRequestDetailsFromGlab(execGlab, repo, number),
+ ]);
+ const mr = raw as MergeRequestJson;
+ const { conversationComments } = await fetchGitLabThreads(mr.web_url);
+ return {
+ number: mr.iid,
+ title: mr.title,
+ body: mr.description ?? "",
+ url: mr.web_url,
+ state: toState(mr.state),
+ branch: mr.source_branch,
+ baseBranch: mr.target_branch,
+ headRepositoryOwner: null,
+ isCrossRepository: mr.source_project_id !== mr.target_project_id,
+ author: mr.author?.username ?? null,
+ isDraft: mr.draft,
+ createdAt: mr.created_at,
+ updatedAt: mr.updated_at,
+ ...normalizePullRequestChecks(details.checks),
+ mergedAt: mr.merged_at,
+ closedAt: mr.closed_at,
+ mergeability: mr.has_conflicts
+ ? ("conflicting" as const)
+ : mr.detailed_merge_status === "mergeable"
+ ? ("mergeable" as const)
+ : ("unknown" as const),
+ mergeStateStatus: null,
+ additions: 0,
+ deletions: 0,
+ changedFiles: Number.parseInt(mr.changes_count ?? "", 10) || 0,
+ reviewDecision: details.reviewDecision,
+ reviewers: (mr.reviewers ?? []).map((reviewer) => ({
+ login: reviewer.username,
+ name: reviewer.name,
+ })),
+ comments: conversationComments.map((comment) => ({
+ id: String(comment.id),
+ kind: "comment" as const,
+ author: { login: comment.user.login, name: null },
+ body: comment.body,
+ createdAt: comment.createdAt,
+ reviewState: null,
+ url: comment.htmlUrl,
+ })),
+ commits: [],
+ labels: (mr.labels ?? []).map((name) => ({ name, color: null })),
+ };
+}
+
+/** One project's merge requests, as `searchPullRequests` pages GitHub PRs. */
+export async function searchMergeRequests(
+ repo: { url: string },
+ projectId: string,
+ input: {
+ query?: string;
+ limit?: number;
+ page?: number;
+ includeClosed?: boolean;
+ mergedOnly?: boolean;
+ },
+) {
+ const page = input.page ?? 1;
+ const limit = input.limit ?? 30;
+ const query = input.query?.trim() ?? "";
+ // `12`, `!12` or the MR's URL looks the MR up directly.
+ const iid = /(?:^!?|\/-\/merge_requests\/)(\d+)\/?$/.exec(query)?.[1];
+ const state = input.mergedOnly ? "--merged" : input.includeClosed && "--all";
+ const raw = iid
+ ? [await glabMrJson(repo.url, "view", iid)]
+ : await glabMrJson(
+ repo.url,
+ "list",
+ ...(state ? [state] : []),
+ ...(query ? ["--search", query] : []),
+ ...["--per-page", String(limit), "--page", String(page)],
+ );
+ const pullRequests = (Array.isArray(raw) ? raw : []).map(
+ (mr: MergeRequestJson) => ({
+ projectId,
+ prNumber: mr.iid,
+ title: mr.title,
+ url: mr.web_url,
+ state: toState(mr.state),
+ isDraft: mr.draft,
+ authorLogin: mr.author?.username ?? null,
+ updatedAt: mr.updated_at,
+ checks: [],
+ checksStatus: "none" as const,
+ additions: null,
+ deletions: null,
+ headRefName: mr.source_branch,
+ }),
+ );
+ return {
+ pullRequests,
+ totalCount: (page - 1) * limit + pullRequests.length,
+ hasNextPage: pullRequests.length === limit,
+ page,
+ };
+}
+
+/** What a workspace checks out for a merge request, as `gh pr view` gives it. */
+export async function fetchMergeRequestMetadata(
+ projectUrl: string,
+ iid: number,
+) {
+ const mr = (await glabMrJson(
+ projectUrl,
+ "view",
+ String(iid),
+ )) as MergeRequestJson;
+ if (mr.source_project_id !== mr.target_project_id) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Merge requests from forks are not supported yet (!${iid})`,
+ });
+ }
+ return {
+ number: mr.iid,
+ url: mr.web_url,
+ title: mr.title,
+ headRefName: mr.source_branch,
+ headRefOid: mr.sha,
+ baseRefName: mr.target_branch,
+ headRepositoryOwner: "",
+ headRepositoryName: "",
+ isCrossRepository: false,
+ state: toState(mr.state),
+ };
+}
diff --git a/packages/host-service/src/trpc/router/pull-requests/shared/linked-workspaces.ts b/packages/host-service/src/trpc/router/pull-requests/shared/linked-workspaces.ts
index f8f6c775d82..899ef0821f7 100644
--- a/packages/host-service/src/trpc/router/pull-requests/shared/linked-workspaces.ts
+++ b/packages/host-service/src/trpc/router/pull-requests/shared/linked-workspaces.ts
@@ -37,7 +37,6 @@ export function findPullRequestRows(
.from(pullRequests)
.where(
and(
- eq(pullRequests.repoProvider, "github"),
eq(sql`lower(${pullRequests.repoOwner})`, repo.owner.toLowerCase()),
eq(sql`lower(${pullRequests.repoName})`, repo.name.toLowerCase()),
eq(pullRequests.prNumber, prNumber),
diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts
index 6464da4348e..556da9ee89d 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts
@@ -18,6 +18,7 @@ import {
normalizePullRequestChecks,
pullRequestCheckContextSchema,
} from "../../pull-requests/pull-request-checks";
+import { searchMergeRequests } from "../../pull-requests/shared/gitlab-merge-request";
import { normalizeGitHubQuery } from "../normalize-github-query";
import { githubSearchInputSchema } from "../schemas";
import {
@@ -985,6 +986,12 @@ async function enrichPageWithChecks(
export const searchPullRequests = protectedProcedure
.input(searchPullRequestsInputSchema)
.query(async ({ ctx, input }): Promise => {
+ const gitlab = input.projectIds
+ ? null
+ : await ctx.runtime.pullRequests.getGitLabRepository(input.projectId);
+ if (gitlab) {
+ return searchMergeRequests(gitlab, input.projectId, input);
+ }
const projectIds = input.projectIds ?? [input.projectId];
const projectRepos: ProjectRepo[] = await resolveProjectRepos(
projectIds,
diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts
index 5d4cf6c0f2a..2fbf8d8ca63 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts
@@ -14,7 +14,7 @@ export interface ExecGhOptions {
}
/**
- * Shell to `gh`. Relies on the user's existing `gh auth login` rather than
+ * Shell to `gh`/`glab`. Relies on the user's existing `auth login` rather than
* the git credential manager, matching V1. Returns parsed JSON when stdout
* is JSON, else the trimmed string. Throws on non-zero exit so callers can
* fall back.
@@ -24,9 +24,12 @@ export type ExecGh = (
options?: ExecGhOptions,
) => Promise;
-export const execGh: ExecGh = async (args, options) => {
+export async function execCli(
+ bin: "gh" | "glab",
+ ...[args, options]: Parameters
+): Promise {
const env = await getToolEnvironment();
- const child = execFileAsync("gh", args, {
+ const child = execFileAsync(bin, args, {
encoding: "utf8",
timeout: options?.timeout ?? 10_000,
// Node's 1MB default dies on large REST payloads (open-PR sweeps of
@@ -44,4 +47,6 @@ export const execGh: ExecGh = async (args, options) => {
} catch {
return trimmed;
}
-};
+}
+
+export const execGh: ExecGh = (...args) => execCli("gh", ...args);
diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/exec-glab.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-glab.ts
new file mode 100644
index 00000000000..889a78aa150
--- /dev/null
+++ b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-glab.ts
@@ -0,0 +1,14 @@
+import { type ExecGh, execCli } from "./exec-gh";
+
+export type ExecGlab = ExecGh;
+
+// glab prints its reason to stderr; execFile's message repeats the whole command.
+export const execGlab: ExecGlab = (...args) =>
+ execCli("glab", ...args).catch((error) => {
+ const reason = String(error?.stderr ?? "")
+ .replace(/\s+/g, " ")
+ .trim();
+ throw reason
+ ? new Error(reason.replace(/^ERROR /, ""), { cause: error })
+ : error;
+ });
diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
index f5fc37591c4..9c1e5871ec2 100644
--- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts
+++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
@@ -47,6 +47,7 @@ import {
DEFAULT_LOCAL_WORKSPACE_NAME,
listLiveLocalWorkspaces,
} from "../project/utils/create-local-workspace";
+import { fetchMergeRequestMetadata } from "../pull-requests/shared/gitlab-merge-request";
import { getHostWorktreeBaseDir } from "../settings/worktree-location";
import { claimWorkspaceRestore } from "../workspace-cleanup";
import { createSession } from "../workspace-creation/procedures/create-session";
@@ -808,11 +809,16 @@ export const workspacesRouter = router({
`pr:${input.projectId}:${input.pr}`,
);
try {
- const prMetadata = await fetchPrMetadata({
- cwd: repoPath,
- prNumber: input.pr,
- execGh: ctx.execGh,
- });
+ const gitlab = await ctx.runtime.pullRequests.getGitLabRepository(
+ input.projectId,
+ );
+ const prMetadata = gitlab
+ ? await fetchMergeRequestMetadata(gitlab.url, input.pr)
+ : await fetchPrMetadata({
+ cwd: repoPath,
+ prNumber: input.pr,
+ execGh: ctx.execGh,
+ });
resolvedBranch = derivePrLocalBranchName(prMetadata);
const existing = findExistingWorkspaceByBranch(
diff --git a/packages/shared/package.json b/packages/shared/package.json
index f86e7d8532d..6233d752597 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -401,6 +401,10 @@
"types": "./src/github-remote.ts",
"default": "./src/github-remote.ts"
},
+ "./gitlab-remote": {
+ "types": "./src/gitlab-remote.ts",
+ "default": "./src/gitlab-remote.ts"
+ },
"./simple-git-options": {
"types": "./src/simple-git-options.ts",
"default": "./src/simple-git-options.ts"
diff --git a/packages/shared/src/gitlab-remote.test.ts b/packages/shared/src/gitlab-remote.test.ts
new file mode 100644
index 00000000000..8657017a5c0
--- /dev/null
+++ b/packages/shared/src/gitlab-remote.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from "bun:test";
+import { parseGitLabRemote } from "./gitlab-remote";
+
+describe("parseGitLabRemote", () => {
+ test.each([
+ ["git@gitlab.com:group/sub/app.git", "gitlab.com/group/sub"],
+ ["git@git.example.com:team/app.git", "git.example.com/team"],
+ ["ssh://git@ssh.example.com:2222/team/app.git", "ssh.example.com/team"],
+ ["https://git.example.com:8443/team/app/", "git.example.com:8443/team"],
+ ])("parses %s", (remote, owner) => {
+ expect(parseGitLabRemote(remote)).toEqual({
+ provider: "gitlab",
+ owner,
+ name: "app",
+ url: `https://${owner}/app`,
+ });
+ });
+
+ test("ignores what is not a hosted repository", () => {
+ expect(parseGitLabRemote("/srv/repos/app.git")).toBeNull();
+ expect(parseGitLabRemote("git@git.example.com:app.git")).toBeNull();
+ });
+});
diff --git a/packages/shared/src/gitlab-remote.ts b/packages/shared/src/gitlab-remote.ts
new file mode 100644
index 00000000000..513b0def35b
--- /dev/null
+++ b/packages/shared/src/gitlab-remote.ts
@@ -0,0 +1,29 @@
+import type { ParsedGitHubRemote } from "./github-remote";
+
+export interface ParsedGitLabRemote
+ extends Omit {
+ provider: "gitlab";
+}
+
+/**
+ * Any host: the caller asks glab whether it knows it.
+ * The owner carries the host (`host/group`): MRs of two instances never share
+ * an identity, and no GitHub owner (no slash allowed) can match one.
+ */
+export function parseGitLabRemote(
+ remoteUrl: string,
+): ParsedGitLabRemote | null {
+ const match =
+ /^(?git@|ssh:\/\/git@|https:\/\/)(?[^/:]+)(?:\d+)?[:/](?.+)\/(?[^/]+?)(?:\.git)?\/?$/i.exec(
+ remoteUrl.trim(),
+ );
+ const { scheme, host, port, owner, name } = match?.groups ?? {};
+ if (!host || !owner || !name) return null;
+ const origin = scheme === "https://" ? `${host}${port ?? ""}` : host;
+ return {
+ provider: "gitlab",
+ owner: `${origin}/${owner}`,
+ name,
+ url: `https://${origin}/${owner}/${name}`,
+ };
+}