From 4ebef7ebd2ae662095ac9e18ca8f9ef23f38779e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9raud-Loup=20Diogo?= <47665233+geraudloup@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:16:05 +0200 Subject: [PATCH 01/14] feat(host-service,desktop): link GitLab merge requests through glab A workspace whose remote is on a GitLab host now shows its merge request the way a GitHub workspace shows its pull request: state, draft, merged, the head pipeline as its check, and "review required" when approval is missing. Create PR opens a merge request with `glab mr create`. Every GitLab call is a `glab` subprocess, like the `gh` path, so it uses the user's own `glab auth login`. Superset passes `-R https:///` built from the remote, and glab maps that host to its API host itself, so self-managed instances and SSH host aliases work without any host settings in Superset. - A host is GitLab when its name contains "gitlab"; other hosts never spawn glab. - The GitLab identity is not written to the project row: avatars, clone matching and the Octokit procedures read projects.repo* as GitHub. - Merge and Ready for review stay hidden for non-GitHub PRs. They call GitHub by owner/repo/number, which a GitLab MR can share with a GitHub mirror of the same project. --- .../PRStatusGroup/PRStatusGroup.tsx | 11 ++- .../pull-requests/pull-requests.test.ts | 63 ++++++++++++ .../runtime/pull-requests/pull-requests.ts | 44 ++++++++- .../utils/github-query/github-query.ts | 99 +++++++++++++++++++ .../pull-requests/utils/github-query/index.ts | 2 + .../pull-requests/utils/workspace-refs.ts | 13 ++- .../procedures/create-for-workspace.ts | 35 +++++++ .../workspace-creation/utils/exec-gh.ts | 14 ++- packages/shared/src/github-remote.test.ts | 26 +++++ packages/shared/src/github-remote.ts | 23 +++++ 10 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/github-remote.test.ts 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..5bbe680405c 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 @@ -31,6 +31,7 @@ import { type OpenPullRequestPane, openPullRequestLink, } from "renderer/lib/github/openPullRequestLink"; +import { pullRequestRefFromUrl } from "renderer/lib/github/pullRequestRef"; import { computeChecksRollup } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/computeChecksStatus"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; import { PRIcon, type PRState } from "renderer/screens/main/components/PRIcon"; @@ -204,10 +205,16 @@ export function PRStatusGroup({ : pr.state === "queued" ? "queued" : "open"; - const canMerge = pr.state === "open" && !pr.isDraft; + // Merge and ready call GitHub by owner/repo, which a GitLab MR shares with + // any GitHub repo of the same name. + const isGitHubPr = pullRequestRefFromUrl(pr.url) !== null; + const canMerge = isGitHubPr && pr.state === "open" && !pr.isDraft; // A closed/merged draft can't transition to ready — GitHub rejects it. const canMarkReady = - linkState === "draft" && pr.state !== "closed" && pr.state !== "merged"; + isGitHubPr && + linkState === "draft" && + pr.state !== "closed" && + pr.state !== "merged"; // Queued PRs are still actively running checks, so keep CI/review indicators. const showIndicators = pr.state === "open" || pr.state === "queued"; 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..4ec2fe16092 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,64 @@ describe("PullRequestRuntimeManager GitHub traffic", () => { expect(getWorkspace(db, "ws")?.pullRequestId).toBeNull(); }); }); + +describe("GitLab through glab", () => { + test("links a workspace to its MR with the head pipeline as its check", async () => { + const db = createRealDb(); + db.insert(schema.projects) + .values({ id: PROJECT_ID, repoPath: "/repo", createdAt: Date.now() }) + .run(); + seedWorkspace(db, { id: "ws", branch: "feat" }); + const webUrl = + "https://gitlab.example.com/group/sub/app/-/merge_requests/7"; + const glabCalls: string[][] = []; + const manager = createManager(db, { + git: async () => ({ + remote: async () => "git@gitlab.example.com:group/sub/app.git\n", + raw: async () => "origin/main\n", + }), + readWorkspaceRefs: async () => ({ + branch: "feat", + headSha: "sha-1", + upstream: { owner: "group/sub", name: "app", branch: "feat" }, + }), + execGlab: async (args) => { + glabCalls.push(args); + if (args[1] === "list") { + return [ + { + iid: 7, + title: "Feat", + web_url: webUrl, + state: "opened", + source_branch: "feat", + sha: "sha-1", + source_project_id: 1, + target_project_id: 1, + }, + ]; + } + return { + detailed_merge_status: "not_approved", + head_pipeline: { + status: "failed", + web_url: "https://gitlab.example.com/p/9", + }, + }; + }, + }); + + await manager.refreshPullRequestsByWorkspaces(["ws"]); + + const pr = getPrById(db, getWorkspace(db, "ws")?.pullRequestId ?? ""); + expect(pr).toMatchObject({ + repoProvider: "gitlab", + url: webUrl, + state: "open", + reviewDecision: "pending", + checksStatus: "failure", + }); + expect(glabCalls[0]).toContain("https://gitlab.example.com/group/sub/app"); + expect(db.select().from(schema.projects).get()?.repoProvider).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..339fcd4fdbe 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -1,7 +1,10 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import type { Octokit } from "@octokit/rest"; -import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { + parseGitHubRemote, + parseGitLabRemote, +} from "@superset/shared/github-remote"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { HostDb } from "../../db"; import { @@ -12,7 +15,10 @@ import { } from "../../db/schema"; 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 ExecGh, + execGlab, +} from "../../trpc/router/workspace-creation/utils/exec-gh"; import { type GitFactory, resolveDefaultBranchName } from "../git"; import { ConditionalGh } from "./utils/conditional-gh"; import { @@ -26,8 +32,10 @@ import { fetchOpenPullRequestsFromGh, fetchPullRequestByHead, fetchPullRequestByHeadFromGh, + fetchPullRequestByHeadFromGlab, fetchPullRequestChecks, fetchPullRequestChecksFromGh, + fetchPullRequestDetailsFromGlab, fetchPullRequestMergeQueueState, fetchPullRequestMergeQueueStateFromGh, fetchPullRequestReviewDecision, @@ -92,7 +100,7 @@ function upstreamKey( return `${owner.toLowerCase()}/${repo.toLowerCase()}#${branch}`; } -type RepoProvider = "github"; +type RepoProvider = "github" | "gitlab"; export interface PullRequestStateSnapshot { url: string; @@ -144,6 +152,7 @@ export interface PullRequestRuntimeManagerOptions { execGh: ExecGh; git: GitFactory; github: () => Promise; + execGlab?: ExecGh; 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 +232,7 @@ export class PullRequestRuntimeManager { private readonly conditionalGh: ConditionalGh; private readonly git: GitFactory; private readonly github: () => Promise; + private readonly execGlab: ExecGh; 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,7 +1060,7 @@ export class PullRequestRuntimeManager { } } - private async getProjectRepository( + async getProjectRepository( projectId: string, ): Promise { const project = this.db.query.projects @@ -1090,7 +1101,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) return null; + const defaultBranch = await this.resolveDefaultBranch(project.repoPath); + return { ...gitlab, remoteName, defaultBranch }; + } this.db .update(projects) @@ -1410,6 +1427,14 @@ export class PullRequestRuntimeManager { head.repo.toLowerCase(), head.branch, ].join("/"); + if (repo.provider === "gitlab") { + return this.cachedGitHubFetch( + this.pullRequestHeadCache, + `gitlab:${cacheKey}`, + options, + () => fetchPullRequestByHeadFromGlab(this.execGlab, repo, head), + ); + } return this.cachedGitHubFetch( this.pullRequestHeadCache, cacheKey, @@ -1449,6 +1474,14 @@ export class PullRequestRuntimeManager { name: repo.name, prNumber: node.number, }; + if (repo.provider === "gitlab") { + return this.cachedGitHubFetch( + this.pullRequestDetailsCache, + `gitlab:${cacheKey}`, + { ...options, fingerprint: node.headRefOid }, + () => fetchPullRequestDetailsFromGlab(this.execGlab, repo, node.number), + ); + } return this.cachedGitHubFetch( this.pullRequestDetailsCache, cacheKey, @@ -1524,6 +1557,7 @@ export class PullRequestRuntimeManager { repo: NormalizedRepoIdentity, options: { bypassCache?: boolean } = {}, ): Promise { + 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.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts index 3aa3022418e..5a2e66ff24c 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-query.ts @@ -507,3 +507,102 @@ export async function fetchPullRequestChecks( (node): node is NonNullable => node !== null, ); } + +interface GitLabRepository { + owner: string; + name: string; + url: string; +} + +// A fork MR gets no head repo, so it never matches a workspace key. +function normalizeMergeRequest( + raw: unknown, + repository: GitLabRepository, +): GitHubPullRequestNode | null { + if (!isRecord(raw) || typeof raw.iid !== "number") 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: 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: String(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: ExecGh, + 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) => normalizeMergeRequest(item, repository)) + .find((node) => node && !node.isCrossRepository) ?? null + ); +} + +const GITLAB_PIPELINE_CONCLUSIONS: Record = { + success: "SUCCESS", + failed: "FAILURE", + canceled: "CANCELLED", + skipped: "SKIPPED", +}; + +// The MR's head pipeline stands in for GitHub's checks. +export async function fetchPullRequestDetailsFromGlab( + execGlab: ExecGh, + repository: GitLabRepository, + number: number, +) { + const raw = await execGlab([ + "mr", + "view", + String(number), + "-R", + repository.url, + "-F", + "json", + ]); + const mr = isRecord(raw) ? raw : {}; + const pipeline = isRecord(mr.head_pipeline) ? mr.head_pipeline : null; + const conclusion = + GITLAB_PIPELINE_CONCLUSIONS[String(pipeline?.status)] ?? null; + const check: GitHubCheckContextNode = { + __typename: "CheckRun", + name: "pipeline", + conclusion, + detailsUrl: String(pipeline?.web_url), + status: conclusion ? "COMPLETED" : "IN_PROGRESS", + startedAt: null, + completedAt: null, + checkSuite: null, + }; + return { + reviewDecision: + mr.detailed_merge_status === "not_approved" + ? ("REVIEW_REQUIRED" as const) + : null, + checks: pipeline ? [check] : [], + 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..909fc783a19 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,8 +3,10 @@ export { fetchOpenPullRequestsFromGh, fetchPullRequestByHead, fetchPullRequestByHeadFromGh, + fetchPullRequestByHeadFromGlab, fetchPullRequestChecks, fetchPullRequestChecksFromGh, + fetchPullRequestDetailsFromGlab, fetchPullRequestMergeQueueState, fetchPullRequestMergeQueueStateFromGh, fetchPullRequestReviewDecision, 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..7bb1b3a8126 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 @@ -2,7 +2,10 @@ // own module (not pull-requests.ts) so the worker task can import it without // pulling the runtime's DB/Octokit graph into the worker bundle. -import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { + parseGitHubRemote, + parseGitLabRemote, +} from "@superset/shared/github-remote"; import type { SimpleGit } from "simple-git"; const UNBORN_HEAD_ERROR_PATTERNS = [ @@ -89,7 +92,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 +119,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/pull-requests/procedures/create-for-workspace.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts index 16f97fd5276..91fed07d725 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 @@ -9,6 +9,7 @@ import { protectedProcedure } from "../../../index"; import { resolveWorktreePath } from "../../git/utils/resolve-worktree"; import { actionRejectionError } from "../../github/github"; import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers"; +import { execGlab } from "../../workspace-creation/utils/exec-gh"; const createInputSchema = z.object({ workspaceId: z.string(), @@ -68,6 +69,40 @@ export const createForWorkspace = protectedProcedure }); } + const project = await ctx.runtime.pullRequests.getProjectRepository( + workspace.projectId, + ); + if (project?.provider === "gitlab") { + const output = String( + await execGlab( + [ + "mr", + "create", + "-R", + project.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(() => {}); + 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/workspace-creation/utils/exec-gh.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts index 5d4cf6c0f2a..3114c5b708a 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) => { +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,7 @@ export const execGh: ExecGh = async (args, options) => { } catch { return trimmed; } -}; +} + +export const execGh: ExecGh = (...args) => execCli("gh", ...args); +export const execGlab: ExecGh = (...args) => execCli("glab", ...args); diff --git a/packages/shared/src/github-remote.test.ts b/packages/shared/src/github-remote.test.ts new file mode 100644 index 00000000000..830b49e6d1f --- /dev/null +++ b/packages/shared/src/github-remote.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { parseGitLabRemote } from "./github-remote"; + +describe("parseGitLabRemote", () => { + test.each([ + ["git@gitlab.com:group/sub/app.git", "gitlab.com", "group/sub"], + [ + "ssh://git@gitlab-ssh.example.com:2222/team/app.git", + "gitlab-ssh.example.com", + "team", + ], + ["https://gitlab.example.com/team/app/", "gitlab.example.com", "team"], + ])("parses %s", (remote, host, owner) => { + expect(parseGitLabRemote(remote)).toEqual({ + provider: "gitlab", + owner, + name: "app", + url: `https://${host}/${owner}/app`, + }); + }); + + test("ignores hosts not named like GitLab", () => { + expect(parseGitLabRemote("git@github.com:team/app.git")).toBeNull(); + expect(parseGitLabRemote("git@git.example.com:team/app.git")).toBeNull(); + }); +}); diff --git a/packages/shared/src/github-remote.ts b/packages/shared/src/github-remote.ts index 48a5ca3b0b8..1cd204668f7 100644 --- a/packages/shared/src/github-remote.ts +++ b/packages/shared/src/github-remote.ts @@ -29,3 +29,26 @@ export function parseGitHubRemote( return null; } + +export interface ParsedGitLabRemote + extends Omit { + provider: "gitlab"; +} + +/** Only hosts named like GitLab: other hosts would spawn `glab` for nothing. */ +export function parseGitLabRemote( + remoteUrl: string, +): ParsedGitLabRemote | null { + const match = + /^(?:git@|ssh:\/\/git@|https:\/\/)(?[^/:]*gitlab[^/:]*)(?::\d+\/|[:/])(?.+)\/(?[^/]+?)(?:\.git)?\/?$/i.exec( + remoteUrl.trim(), + ); + const { host, owner, name } = match?.groups ?? {}; + if (!host || !owner || !name) return null; + return { + provider: "gitlab", + owner, + name, + url: `https://${host}/${owner}/${name}`, + }; +} From 57100001a004218d6bff9499a26ac55c214c865d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9raud-Loup=20Diogo?= <47665233+geraudloup@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:25:17 +0200 Subject: [PATCH 02/14] feat(host-service): merge and mark ready GitLab MRs through glab Merge and Ready for review now work for a linked GitLab MR: the github router runs `glab mr merge` (squash and rebase map to their flags, auto-merge off so the result matches GitHub's immediate merge) and `glab mr update --ready`. The callers send owner/repo/number only, so the host picks GitLab when a linked GitLab MR has that identity, and refuses when a linked GitHub PR has it too: a GitLab project and its GitHub mirror share owner/repo, and a guess could merge the wrong one. The lookup reads the runtime's own pull_requests rows (the rows the PR chip shows), next to findPullRequestRow, so github.ts reads no snapshot fields. This replaces the renderer guard that hid both actions for GitLab MRs, and also covers mobile. --- .../PRStatusGroup/PRStatusGroup.tsx | 11 +--- .../runtime/pull-requests/pull-requests.ts | 18 +++++++ .../src/trpc/router/github/github.ts | 52 +++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) 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 5bbe680405c..95ee84769c9 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 @@ -31,7 +31,6 @@ import { type OpenPullRequestPane, openPullRequestLink, } from "renderer/lib/github/openPullRequestLink"; -import { pullRequestRefFromUrl } from "renderer/lib/github/pullRequestRef"; import { computeChecksRollup } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/computeChecksStatus"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; import { PRIcon, type PRState } from "renderer/screens/main/components/PRIcon"; @@ -205,16 +204,10 @@ export function PRStatusGroup({ : pr.state === "queued" ? "queued" : "open"; - // Merge and ready call GitHub by owner/repo, which a GitLab MR shares with - // any GitHub repo of the same name. - const isGitHubPr = pullRequestRefFromUrl(pr.url) !== null; - const canMerge = isGitHubPr && pr.state === "open" && !pr.isDraft; + const canMerge = pr.state === "open" && !pr.isDraft; // A closed/merged draft can't transition to ready — GitHub rejects it. const canMarkReady = - isGitHubPr && - linkState === "draft" && - pr.state !== "closed" && - pr.state !== "merged"; + linkState === "draft" && pr.state !== "closed" && pr.state !== "merged"; // Queued PRs are still actively running checks, so keep CI/review indicators. const showIndicators = pr.state === "open" || pr.state === "queued"; 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 339fcd4fdbe..922ffd63a69 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -1179,6 +1179,24 @@ export class PullRequestRuntimeManager { .sync(); } + findLinkedPullRequests( + owner: string, + name: string, + prNumber: number, + ): { provider: string; url: string }[] { + return this.db + .select({ provider: pullRequests.repoProvider, url: pullRequests.url }) + .from(pullRequests) + .where( + and( + eq(pullRequests.repoOwner, owner), + eq(pullRequests.repoName, name), + eq(pullRequests.prNumber, prNumber), + ), + ) + .all(); + } + private findPullRequestRowById(id: string): PullRequestRow | undefined { return this.db.query.pullRequests .findFirst({ where: eq(pullRequests.id, id) }) diff --git a/packages/host-service/src/trpc/router/github/github.ts b/packages/host-service/src/trpc/router/github/github.ts index 160090ff4f9..fb149901c58 100644 --- a/packages/host-service/src/trpc/router/github/github.ts +++ b/packages/host-service/src/trpc/router/github/github.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; +import { execGlab } from "../workspace-creation/utils/exec-gh"; import { createGithubWorkItemLoader } from "./work-item"; const loadWorkItem = createGithubWorkItemLoader(); @@ -352,6 +354,22 @@ export const githubRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + const gitlab = gitlabProjectUrl(ctx, 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 +393,18 @@ export const githubRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + const gitlab = gitlabProjectUrl(ctx, 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 +861,25 @@ export function actionRejectionError( }); } } + +// Callers send owner/repo/number only. A linked GitLab MR with that identity +// is the one meant, unless a linked GitHub PR has the same identity. +function gitlabProjectUrl( + ctx: HostServiceContext, + input: { owner: string; repo: string; pullNumber: number }, +): string | null { + const rows = ctx.runtime.pullRequests.findLinkedPullRequests( + input.owner, + input.repo, + input.pullNumber, + ); + const gitlab = rows.find((row) => row.provider === "gitlab"); + if (!gitlab) return null; + if (rows.some((row) => row.provider !== "gitlab")) { + throw new TRPCError({ + code: "CONFLICT", + message: `${input.owner}/${input.repo}#${input.pullNumber} is both a GitHub PR and a GitLab MR on this host. Open it in the browser.`, + }); + } + return gitlab.url.split("/-/")[0] ?? null; +} From 084f131596a7361676648ab1876194c3f2a6f132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9raud-Loup=20Diogo?= <47665233+geraudloup@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:18:41 +0200 Subject: [PATCH 03/14] fix(host-service): key GitLab MRs by host and address review feedback A GitLab owner now carries its host (`gitlab.example.com/group`, with the HTTPS port when the remote names one; an SSH port is not the web port): - MRs of two GitLab instances with the same path and number no longer share a cache entry or a pull_requests row. - An HTTPS remote on a non-default port keeps its port in the glab URL. - Merge and Ready for review pick GitLab when the owner has a slash, which a GitHub owner never has. No stored row is consulted, so a GitHub PR can never be routed to glab, and the runtime lookup is gone. Also from review: - A merge request without a string sha is skipped instead of being stored with headSha "undefined"; a pipeline without a URL has none. - A failed refresh after creating an MR is logged, as on the GitHub path. - The PR menu and card say "Open in browser" (an existing catalog string) instead of "View on GitHub". Files that now hold GitLab code are renamed to github-gitlab-*. The `@superset/shared/github-remote` export keeps its name so its importers do not change. --- .../PRStatusGroup/PRStatusGroup.tsx | 2 +- .../components/PRDetailCard/PRDetailCard.tsx | 2 +- .../pull-requests/pull-requests.test.ts | 7 ++++- .../runtime/pull-requests/pull-requests.ts | 22 ++------------ ...ry.test.ts => github-gitlab-query.test.ts} | 2 +- ...github-query.ts => github-gitlab-query.ts} | 8 ++--- .../pull-requests/utils/github-query/index.ts | 2 +- .../git/utils/reply-to-review-comment.ts | 2 +- .../github/{github.ts => github-gitlab.ts} | 30 +++++-------------- .../src/trpc/router/github/index.ts | 2 +- .../procedures/create-for-workspace.ts | 9 ++++-- .../router/pull-requests/procedures/merge.ts | 2 +- packages/shared/package.json | 4 +-- ...e.test.ts => github-gitlab-remote.test.ts} | 16 +++++----- ...thub-remote.ts => github-gitlab-remote.ts} | 15 ++++++---- packages/shared/src/github-stars.ts | 2 +- 16 files changed, 55 insertions(+), 72 deletions(-) rename packages/host-service/src/runtime/pull-requests/utils/github-query/{github-query.test.ts => github-gitlab-query.test.ts} (99%) rename packages/host-service/src/runtime/pull-requests/utils/github-query/{github-query.ts => github-gitlab-query.ts} (98%) rename packages/host-service/src/trpc/router/github/{github.ts => github-gitlab.ts} (96%) rename packages/shared/src/{github-remote.test.ts => github-gitlab-remote.test.ts} (60%) rename packages/shared/src/{github-remote.ts => github-gitlab-remote.ts} (65%) 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