Files
paseo/packages/server/src/services/gitlab-service.ts
T
a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00

1291 lines
42 KiB
TypeScript

import { z } from "zod";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import {
createCachedCliPathResolver,
createForgeCliRunner,
ForgeAuthenticationError,
ForgeCliMissingError,
defaultResolveRemoteUrl,
parseCliJsonOutput,
probeHostViaCliAuthStatus,
ForgeCommandError,
type ForgeCommandFailureParams,
} from "./forge-cli-command.js";
import {
compareTimelineItems,
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
} from "./forge-service.js";
import type {
CheckDetails,
CreatePullRequestOptions,
CurrentPullRequestStatus,
DisablePullRequestAutoMergeOptions,
EnablePullRequestAutoMergeOptions,
ForgeReadOptions,
ForgeService,
GetCheckDetailsOptions,
GetPullRequestOptions,
GetPullRequestTimelineOptions,
IssueSummary,
ListIssuesOptions,
ListPullRequestsOptions,
MergePullRequestOptions,
PipelineDetails,
PipelineJob,
PipelineJobStatus,
PipelineStage,
PullRequestAutoMergeResult,
PullRequestChecksStatus,
PullRequestCheckoutTarget,
PullRequestCreateResult,
PullRequestMergeable,
PullRequestMergeResult,
PullRequestSummary,
PullRequestTimeline,
PullRequestTimelineCommentLocation,
PullRequestTimelineError,
PullRequestTimelineErrorKind,
PullRequestTimelineItem,
SearchIssuesAndPrsOptions,
SearchResult,
} from "./forge-service.js";
import {
GITLAB_ACTIVE_PIPELINE_STATUS_SET,
isGitLabStatusFacts,
type GitLabStatusFacts,
} from "./gitlab-facts.js";
const GLAB_ENV = {
GIT_TERMINAL_PROMPT: "0",
GLAB_CHECK_UPDATE: "0",
} as const;
const GLAB_COMMAND_TIMEOUT_MS = 30_000;
const GITLAB_DETAILED_MERGEABLE_STATUS = "mergeable";
const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged";
export class GlabCliMissingError extends ForgeCliMissingError {
constructor() {
super("GitLab CLI (glab) is not installed or not in PATH");
this.name = "GlabCliMissingError";
}
}
export class GlabAuthenticationError extends ForgeAuthenticationError {
constructor(params: { stderr: string }) {
super("GitLab CLI authentication failed", params);
this.name = "GlabAuthenticationError";
}
}
export class GlabCommandError extends ForgeCommandError {
constructor(params: ForgeCommandFailureParams) {
super({ brand: "GitLab", binary: "glab" }, params);
this.name = "GlabCommandError";
}
}
export interface GlabCommandRunnerOptions {
cwd: string;
envOverlay?: Record<string, string>;
}
export interface GlabCommandResult {
stdout: string;
stderr: string;
}
export type GlabCommandRunner = (
args: string[],
options: GlabCommandRunnerOptions,
) => Promise<GlabCommandResult>;
export interface CreateGitLabServiceOptions {
runner?: GlabCommandRunner;
resolveGlabPath?: () => Promise<string | null>;
resolveRemoteUrl?: (cwd: string) => Promise<string | null>;
}
const GitLabPipelineSchema = z
.object({
id: z.number().optional(),
status: z.string().optional(),
web_url: z.string().optional(),
})
.passthrough();
const GitLabPipelineJobSchema = z
.object({
id: z.number(),
name: z.string(),
stage: z.string(),
status: z.string(),
allow_failure: z.boolean().optional(),
web_url: z.string().nullable().optional(),
duration: z.number().nullable().optional(),
})
.passthrough();
const GitLabPipelineDetailsSchema = z
.object({
id: z.number(),
status: z.string(),
ref: z.string().nullable().optional(),
sha: z.string().nullable().optional(),
web_url: z.string().nullable().optional(),
jobs: z.array(GitLabPipelineJobSchema).optional().default([]),
})
.passthrough();
const GitLabMergeRequestSchema = z
.object({
iid: z.number(),
title: z.string(),
web_url: z.string(),
state: z.string(),
source_branch: z.string(),
target_branch: z.string(),
source_project_id: z.number().nullable().optional(),
target_project_id: z.number().nullable().optional(),
draft: z.boolean().optional(),
work_in_progress: z.boolean().optional(),
has_conflicts: z.boolean().optional(),
blocking_discussions_resolved: z.boolean().optional(),
merge_when_pipeline_succeeds: z.boolean().optional(),
approvals_required: z.number().nullable().optional(),
approvals_given: z.number().nullable().optional(),
merged_at: z.string().nullable().optional(),
detailed_merge_status: z.string().optional(),
merge_status: z.string().optional(),
description: z.string().nullable().optional(),
labels: z.array(z.string()).optional(),
updated_at: z.string().optional(),
references: z.object({ full: z.string().optional() }).passthrough().optional(),
head_pipeline: GitLabPipelineSchema.nullable().optional(),
})
.passthrough();
const GitLabIssueSchema = z
.object({
iid: z.number(),
title: z.string(),
web_url: z.string(),
state: z.string(),
description: z.string().nullable().optional(),
labels: z.array(z.string()).optional(),
updated_at: z.string().optional(),
references: z.object({ full: z.string().optional() }).passthrough().optional(),
})
.passthrough();
const GitLabNoteAuthorSchema = z
.object({
username: z.string().optional(),
name: z.string().optional(),
web_url: z.string().nullable().optional(),
avatar_url: z.string().nullable().optional(),
})
.passthrough();
const GitLabNoteLineRangeEndpointSchema = z
.object({
new_line: z.number().nullable().optional(),
old_line: z.number().nullable().optional(),
})
.passthrough();
const GitLabNotePositionSchema = z
.object({
new_path: z.string().nullable().optional(),
old_path: z.string().nullable().optional(),
new_line: z.number().nullable().optional(),
old_line: z.number().nullable().optional(),
line_range: z
.object({
start: GitLabNoteLineRangeEndpointSchema.nullable().optional(),
end: GitLabNoteLineRangeEndpointSchema.nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
const GitLabNoteSchema = z
.object({
id: z.number(),
body: z.string().nullable().optional(),
system: z.boolean().optional(),
type: z.string().nullable().optional(),
created_at: z.string().nullable().optional(),
resolvable: z.boolean().optional(),
resolved: z.boolean().optional(),
author: GitLabNoteAuthorSchema.nullable().optional(),
position: GitLabNotePositionSchema.nullable().optional(),
})
.passthrough();
const GitLabDiscussionSchema = z
.object({
id: z.string(),
individual_note: z.boolean().optional(),
notes: z.array(GitLabNoteSchema).optional().default([]),
})
.passthrough();
const GitLabApprovalsSchema = z
.object({
approvals_required: z.number().nullable().optional(),
approvals_left: z.number().nullable().optional(),
approved_by: z.array(z.unknown()).nullable().optional(),
})
.passthrough();
type GitLabMergeRequest = z.infer<typeof GitLabMergeRequestSchema>;
type GitLabIssue = z.infer<typeof GitLabIssueSchema>;
type GitLabNote = z.infer<typeof GitLabNoteSchema>;
type GitLabNoteLineRangeEndpoint = z.infer<typeof GitLabNoteLineRangeEndpointSchema>;
type GitLabDiscussion = z.infer<typeof GitLabDiscussionSchema>;
type GitLabApprovals = z.infer<typeof GitLabApprovalsSchema>;
const TIMELINE_PAGE_SIZE = 100;
async function resolveGlabPath(): Promise<string | null> {
return findExecutable("glab");
}
const glabCliRunner = createForgeCliRunner({
binary: "glab",
envOverlay: GLAB_ENV,
timeoutMs: GLAB_COMMAND_TIMEOUT_MS,
isAuthFailureText,
errorClasses: {
isAlreadyClassified: (candidate) =>
candidate instanceof GlabAuthenticationError || candidate instanceof GlabCliMissingError,
isCommandError: (candidate): candidate is GlabCommandError =>
candidate instanceof GlabCommandError,
createAuthError: (stderr) => new GlabAuthenticationError({ stderr }),
createMissingError: () => new GlabCliMissingError(),
createCommandError: (params) => new GlabCommandError(params),
},
});
async function runGlabCommand(
args: string[],
options: GlabCommandRunnerOptions,
): Promise<GlabCommandResult> {
return glabCliRunner.run(args, options);
}
export function parseGitLabHostFromRemoteUrl(url: string): string | null {
return parseGitRemoteLocation(url)?.host ?? null;
}
function mapMergeRequestState(state: string): string {
if (state === "opened") {
return "open";
}
return state === "merged" ? "merged" : "closed";
}
/**
* Direct-merge readiness from GitLab's merge signals. `detailed_merge_status` is
* GitLab 15.6+ only; when it is absent (older self-managed instances) fall back
* to the legacy `merge_status === "can_be_merged"` with no conflicts instead of
* refusing every merge.
*/
function isGitLabMergeReady(params: {
detailedMergeStatus: string | null | undefined;
mergeStatus: string | null | undefined;
hasConflicts: boolean;
}): boolean {
if (params.detailedMergeStatus != null) {
return params.detailedMergeStatus === GITLAB_DETAILED_MERGEABLE_STATUS;
}
return params.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && !params.hasConflicts;
}
function mapMergeable(mr: GitLabMergeRequest): PullRequestMergeable {
if (mr.has_conflicts === true) {
return "CONFLICTING";
}
if (
isGitLabMergeReady({
detailedMergeStatus: mr.detailed_merge_status,
mergeStatus: mr.merge_status,
hasConflicts: mr.has_conflicts ?? false,
})
) {
return "MERGEABLE";
}
return "UNKNOWN";
}
function mapPipelineChecksStatus(status: string | undefined): PullRequestChecksStatus {
switch (status) {
case "success":
case "passed":
return "success";
case "failed":
return "failure";
case "canceled":
case "cancelled":
return "none";
case "running":
case "pending":
case "created":
case "scheduled":
case "preparing":
case "waiting_for_resource":
case "manual":
return "pending";
default:
return "none";
}
}
function splitProjectPath(fullReference: string | undefined): {
owner?: string;
name?: string;
} {
if (!fullReference) {
return {};
}
const projectPath = fullReference.split("!")[0]?.split("#")[0];
if (!projectPath) {
return {};
}
const segments = projectPath.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return {};
}
return { owner: segments[0], name: segments[segments.length - 1] };
}
function toPullRequestSummary(mr: GitLabMergeRequest): PullRequestSummary {
const projectPath = extractProjectPath(mr.references?.full);
return {
number: mr.iid,
title: mr.title,
url: mr.web_url,
state: mapMergeRequestState(mr.state),
body: mr.description ?? null,
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
labels: mr.labels ?? [],
...(projectPath ? { projectPath } : {}),
updatedAt: mr.updated_at ?? "",
};
}
function toIssueSummary(issue: GitLabIssue): IssueSummary {
const projectPath = extractProjectPath(issue.references?.full);
return {
number: issue.iid,
title: issue.title,
url: issue.web_url,
state: issue.state,
body: issue.description ?? null,
labels: issue.labels ?? [],
...(projectPath ? { projectPath } : {}),
updatedAt: issue.updated_at ?? "",
};
}
function extractProjectPath(fullReference: string | undefined): string | undefined {
if (!fullReference) {
return undefined;
}
const projectPath = fullReference.split("!")[0]?.split("#")[0];
return projectPath && projectPath.length > 0 ? projectPath : undefined;
}
function countApprovalsGiven(approvals: GitLabApprovals | null | undefined): number | null {
if (!approvals) {
return null;
}
if (Array.isArray(approvals.approved_by)) {
return approvals.approved_by.length;
}
if (approvals.approvals_required != null && approvals.approvals_left != null) {
return Math.max(0, approvals.approvals_required - approvals.approvals_left);
}
return null;
}
function toGitLabStatusFacts(
mr: GitLabMergeRequest,
approvals?: GitLabApprovals | null,
): GitLabStatusFacts {
return {
detailedMergeStatus: mr.detailed_merge_status ?? null,
mergeStatus: mr.merge_status ?? null,
hasConflicts: mr.has_conflicts ?? false,
blockingDiscussionsResolved: mr.blocking_discussions_resolved ?? true,
approvalsRequired: approvals?.approvals_required ?? mr.approvals_required ?? 0,
approvalsGiven: countApprovalsGiven(approvals) ?? mr.approvals_given ?? 0,
pipelineStatus: mr.head_pipeline?.status ?? null,
pipelineId: mr.head_pipeline?.id ?? null,
pipelineUrl: mr.head_pipeline?.web_url ?? null,
mergeWhenPipelineSucceeds: mr.merge_when_pipeline_succeeds ?? false,
};
}
function parseGitLabTimestamp(value: string | null | undefined): number {
if (!value) {
return 0;
}
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
/**
* A GitLab discussion is a reply chain when it is not an explicit standalone
* note (`individual_note: true`). Older payloads may omit the flag, so a
* discussion that already holds multiple notes is also treated as a thread.
*/
function isThreadDiscussion(discussion: GitLabDiscussion): boolean {
if (discussion.individual_note === true) {
return false;
}
return discussion.individual_note === false || discussion.notes.length > 1;
}
function resolvePositionLine(endpoint: GitLabNoteLineRangeEndpoint): number | undefined {
return endpoint.new_line ?? endpoint.old_line ?? undefined;
}
function toTimelineCommentLocation(
note: GitLabNote,
discussion: GitLabDiscussion,
): PullRequestTimelineCommentLocation | undefined {
const position = note.position;
const path = position?.new_path ?? position?.old_path ?? undefined;
if (!position || !path) {
return undefined;
}
const line = resolvePositionLine(position);
const start = position.line_range?.start;
const startLine = start ? resolvePositionLine(start) : undefined;
// Only emit a resolution state GitLab can actually own: a note that is not
// resolvable (ordinary comments, system context) has no meaningful resolved
// flag, and defaulting it to `false` would render a fake "unresolved" badge.
const isResolved = note.resolvable === true ? (note.resolved ?? false) : undefined;
return {
path,
...(line != null ? { line } : {}),
...(startLine != null && startLine !== line ? { startLine } : {}),
threadId: discussion.id,
...(isResolved !== undefined ? { isResolved } : {}),
};
}
/**
* Maps a GitLab note to a neutral timeline item. The forge has no GitHub-style
* review verdict on a note, so every human note becomes a `comment`; approvals
* are surfaced separately on the status facts, not as timeline reviews. System
* notes (events like "approved", "mentioned in commit") are dropped.
*/
function toTimelineComment(
note: GitLabNote,
discussion: GitLabDiscussion,
mrWebUrl: string,
): PullRequestTimelineItem | null {
if (note.system === true) {
return null;
}
const location = toTimelineCommentLocation(note, discussion);
// Top-level thread id groups every note of a reply chain — including general
// (non-file) discussions that carry no `location` — into one timeline thread.
const threadId = isThreadDiscussion(discussion) ? discussion.id : undefined;
// A resolvable discussion without a file position (a general thread) carries its
// resolution here, since `location.isResolved` only exists for file threads.
const threadIsResolved =
!location && note.resolvable === true ? (note.resolved ?? false) : undefined;
return {
kind: "comment",
id: String(note.id),
author: note.author?.username ?? note.author?.name ?? "unknown",
authorUrl: note.author?.web_url ?? null,
avatarUrl: note.author?.avatar_url ?? null,
body: note.body ?? "",
createdAt: parseGitLabTimestamp(note.created_at),
url: `${mrWebUrl}#note_${note.id}`,
...(threadId ? { threadId } : {}),
...(threadIsResolved !== undefined ? { threadIsResolved } : {}),
...(location ? { location } : {}),
};
}
function classifyGlabTimelineErrorKind(stderr: string): PullRequestTimelineErrorKind {
const normalized = stderr.toLowerCase();
if (normalized.includes("404") || normalized.includes("not found")) {
return "not_found";
}
if (normalized.includes("403") || normalized.includes("forbidden") || isAuthFailureText(stderr)) {
return "forbidden";
}
return "unknown";
}
function mapGlabTimelineError(error: unknown): PullRequestTimelineError {
if (error instanceof GlabAuthenticationError) {
return { kind: "forbidden", message: error.stderr || error.message };
}
if (error instanceof GlabCommandError) {
return {
kind: classifyGlabTimelineErrorKind(error.stderr),
message: error.stderr || error.message,
};
}
return { kind: "unknown", message: error instanceof Error ? error.message : String(error) };
}
function normalizePipelineJobStatus(raw: string): PipelineJobStatus {
switch (raw) {
case "success":
case "passed":
return "success";
case "failed":
return "failed";
case "running":
return "running";
case "pending":
case "waiting_for_resource":
case "preparing":
case "scheduled":
return "pending";
case "created":
return "created";
case "canceled":
case "cancelled":
return "canceled";
case "skipped":
return "skipped";
case "manual":
return "manual";
default:
return "unknown";
}
}
const STAGE_STATUS_PRIORITY: PipelineJobStatus[] = [
"running",
"failed",
"pending",
"created",
"manual",
"canceled",
"skipped",
"success",
];
function aggregateStageStatus(jobs: PipelineJob[]): PipelineJobStatus {
const present = new Set(
jobs.map((job) => (job.status === "failed" && job.allowFailure ? "success" : job.status)),
);
for (const status of STAGE_STATUS_PRIORITY) {
if (present.has(status)) {
return status;
}
}
return "unknown";
}
function toPipelineDetails(pipeline: z.infer<typeof GitLabPipelineDetailsSchema>): PipelineDetails {
// glab returns jobs newest-first; ascending id restores creation order.
const jobs: PipelineJob[] = [...pipeline.jobs]
.sort((a, b) => a.id - b.id)
.map((job) => ({
id: job.id,
name: job.name,
stage: job.stage,
status: normalizePipelineJobStatus(job.status),
rawStatus: job.status,
url: job.web_url ?? null,
allowFailure: job.allow_failure ?? false,
durationSeconds: job.duration ?? null,
}));
const stages: PipelineStage[] = [];
const stageIndex = new Map<string, PipelineStage>();
for (const job of jobs) {
let stage = stageIndex.get(job.stage);
if (!stage) {
stage = { name: job.stage, status: "unknown", jobs: [] };
stageIndex.set(job.stage, stage);
stages.push(stage);
}
stage.jobs.push(job);
}
for (const stage of stages) {
stage.status = aggregateStageStatus(stage.jobs);
}
return {
id: pipeline.id,
status: normalizePipelineJobStatus(pipeline.status),
rawStatus: pipeline.status,
url: pipeline.web_url ?? null,
ref: pipeline.ref ?? null,
sha: pipeline.sha ?? null,
stages,
};
}
function toCheckDetails(pipeline: z.infer<typeof GitLabPipelineDetailsSchema>): CheckDetails {
return {
checkRunId: pipeline.id,
workflowRunId: null,
name: pipeline.ref ? `Pipeline (${pipeline.ref})` : `Pipeline #${pipeline.id}`,
status: null,
conclusion: null,
url: pipeline.web_url ?? null,
detailsUrl: pipeline.web_url ?? null,
output: null,
annotations: [],
failedJobs: [],
truncated: false,
pipeline: toPipelineDetails(pipeline),
};
}
function toCurrentPullRequestStatus(
mr: GitLabMergeRequest,
approvals?: GitLabApprovals | null,
): CurrentPullRequestStatus {
const { owner, name } = splitProjectPath(mr.references?.full);
const projectPath = extractProjectPath(mr.references?.full);
return {
number: mr.iid,
...(owner ? { repoOwner: owner } : {}),
...(name ? { repoName: name } : {}),
...(projectPath ? { projectPath } : {}),
url: mr.web_url,
title: mr.title,
state: mapMergeRequestState(mr.state),
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
isMerged: mr.state === "merged" || mr.merged_at != null,
isDraft: mr.draft ?? mr.work_in_progress ?? false,
mergeable: mapMergeable(mr),
checks: [],
checksStatus: mapPipelineChecksStatus(mr.head_pipeline?.status),
reviewDecision: null,
forgeSpecific: { forge: "gitlab", ...toGitLabStatusFacts(mr, approvals) },
};
}
function isAuthFailureText(text: string): boolean {
return /\b(401|unauthorized|not logged in|authentication failed|no token|invalid token)\b/i.test(
text,
);
}
/**
* Matches only glab's genuine "no open merge request for this branch" phrasing.
* A broad `not found` match would swallow a 404/403 for a private or missing
* project as "no MR", so those are left to propagate as errors instead.
*/
function isNoMergeRequestText(text: string): boolean {
return /no (open )?merge requests?/i.test(text);
}
/**
* Guards a positional glab argument (a branch/ref or MR selector) so a value
* beginning with `-` cannot be reinterpreted as a flag. glab positionals carry
* no `--` separator here; git already forbids ref names that start with `-`, so
* a leading dash is treated as injection rather than a legitimate ref.
*/
function assertSafeGlabPositional(value: string): void {
if (value.startsWith("-")) {
throw new Error(`Refusing to pass a GitLab argument that begins with '-': ${value}`);
}
}
function extractMergeRequestUrl(stdout: string): string | null {
const match = stdout.match(/https?:\/\/\S+\/-\/merge_requests\/\d+/);
return match ? match[0] : null;
}
function parseIidFromUrl(url: string): number | null {
const match = url.match(/\/merge_requests\/(\d+)/);
return match ? Number(match[1]) : null;
}
/**
* Probe whether `host` is a GitLab instance by asking glab about its auth status
* for that hostname (exit 0 => a configured GitLab instance, even for
* self-managed hosts whose name carries no "gitlab" hint). The forge resolver
* uses this to detect self-managed hosts the name heuristic can't classify.
*/
export async function probeGitLabHost(host: string): Promise<boolean> {
return probeHostViaCliAuthStatus({
cli: "glab",
host,
envOverlay: GLAB_ENV,
});
}
function getGitlabStatusFacts(status: MergePullRequestOptions["status"]): GitLabStatusFacts | null {
const forgeSpecific = status?.forgeSpecific;
return isGitLabStatusFacts(forgeSpecific) ? forgeSpecific : null;
}
/**
* Server-side guard for GitLab auto-merge: `glab mr merge --auto-merge` only
* schedules the merge while a pipeline is active. Without one it merges on the
* spot, so this is enforced at execution time as well as in UI policy.
*/
export function assertGitLabAutoMergeEnableReady(
input: Pick<EnablePullRequestAutoMergeOptions, "status">,
): void {
const gitlab = getGitlabStatusFacts(input.status);
if (!gitlab) {
throw new Error("GitLab auto-merge facts are unavailable for this merge request");
}
if (gitlab.mergeWhenPipelineSucceeds) {
throw new Error("Auto-merge is already enabled for this merge request");
}
if (
gitlab.pipelineStatus === null ||
!GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(gitlab.pipelineStatus)
) {
throw new Error(
"GitLab auto-merge requires an in-progress pipeline; without one the merge would run immediately",
);
}
}
/**
* Server-side guard for a GitLab direct merge, mirroring the GitHub adapter's
* assertDirectPullRequestMergeReady: refuse the merge unless GitLab reports the
* MR as directly mergeable and auto-merge is not already scheduled. Enforced
* here (not just in the UI policy) because the resolved status can go stale
* between the client check and execution, and the RPC can be called directly.
*/
function assertGitLabDirectMergeReady(input: Pick<MergePullRequestOptions, "status">): void {
const gitlab = getGitlabStatusFacts(input.status);
if (!gitlab) {
throw new Error("GitLab merge facts are unavailable for this merge request");
}
if (gitlab.mergeWhenPipelineSucceeds) {
throw new Error("Direct merge is not available because auto-merge is already enabled");
}
if (
!isGitLabMergeReady({
detailedMergeStatus: gitlab.detailedMergeStatus,
mergeStatus: gitlab.mergeStatus,
hasConflicts: gitlab.hasConflicts,
})
) {
throw new Error("GitLab does not report this merge request as ready for direct merge");
}
}
function isGlabSearchAuthFailure(reason: unknown): boolean {
return reason instanceof GlabCliMissingError || reason instanceof GlabAuthenticationError;
}
function getGlabUnavailableSearchAuthState(
results: PromiseSettledResult<unknown>[],
): "cli_missing" | "unauthenticated" | null {
if (
results.length === 0 ||
!results.every(
(result) => result.status === "rejected" && isGlabSearchAuthFailure(result.reason),
)
) {
return null;
}
return results.some(
(result) => result.status === "rejected" && result.reason instanceof GlabCliMissingError,
)
? "cli_missing"
: "unauthenticated";
}
function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult<unknown>[]): void {
const failed = results.find(
(result): result is PromiseRejectedResult =>
result.status === "rejected" && !isGlabSearchAuthFailure(result.reason),
);
if (failed) {
throw failed.reason;
}
}
export function createGitLabService(options: CreateGitLabServiceOptions = {}): ForgeService {
const runner = options.runner ?? runGlabCommand;
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
async function run(args: string[], runOptions: GlabCommandRunnerOptions): Promise<string> {
const glabPath = await resolveGlab();
if (!glabPath) {
throw new GlabCliMissingError();
}
try {
const result = await runner(args, runOptions);
return result.stdout.trim();
} catch (error) {
throw glabCliRunner.normalizeError(error, { args, cwd: runOptions.cwd });
}
}
// Centralize parse + validate so a malformed or empty glab payload surfaces as
// a classified GlabCommandError (with args + cwd) instead of a raw
// SyntaxError/ZodError that bypasses the Glab* error classification.
async function runJson<T>(
args: string[],
runOptions: GlabCommandRunnerOptions,
schema: z.ZodType<T>,
): Promise<T> {
const stdout = await run(args, runOptions);
return parseCliJsonOutput({
commandName: "glab",
args,
cwd: runOptions.cwd,
stdout,
schema,
createCommandError: (params) => new GlabCommandError(params),
});
}
async function viewMergeRequest(cwd: string, ref: string): Promise<GitLabMergeRequest> {
assertSafeGlabPositional(ref);
return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema);
}
function isNumericBranchRef(ref: string): boolean {
return /^[0-9]+$/.test(ref);
}
async function listOpenMergeRequestsBySourceBranch(
cwd: string,
sourceBranch: string,
): Promise<GitLabMergeRequest[]> {
return runJson(
["mr", "list", "--source-branch", sourceBranch, "-F", "json"],
{ cwd },
z.array(GitLabMergeRequestSchema),
);
}
async function resolveCurrentMergeRequest(
cwd: string,
headRef: string,
): Promise<GitLabMergeRequest | null> {
if (!isNumericBranchRef(headRef)) {
return viewMergeRequest(cwd, headRef);
}
const mergeRequests = await listOpenMergeRequestsBySourceBranch(cwd, headRef);
const match =
mergeRequests.find(
(mr) => mr.source_branch === headRef && mapMergeRequestState(mr.state) === "open",
) ?? null;
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
/**
* Detects whether discussions exist beyond the first fetched page. The command
* runner exposes no pagination headers, so instead of a bare `length >= 100`
* (which falsely flags an exactly-full page as truncated) we probe for a single
* discussion on the next page. Best-effort: if the probe fails we keep the
* already-fetched notes and conservatively report truncation, since a full
* first page means at least one page of discussions exists.
*/
async function hasDiscussionsAfterFirstPage(
cwd: string,
projectPath: string,
iid: number,
): Promise<boolean> {
try {
const probe = await runJson(
[
"api",
`projects/${encodeURIComponent(projectPath)}/merge_requests/${iid}/discussions?per_page=1&page=${TIMELINE_PAGE_SIZE + 1}`,
],
{ cwd },
z.array(GitLabDiscussionSchema),
);
return probe.length > 0;
} catch {
return true;
}
}
/**
* Fetches MR approval counts from the dedicated approvals endpoint, which
* `glab mr view` omits. Best-effort: a host without the endpoint must not
* break the MR status, so failures leave the counts at their fallback (0).
*/
async function fetchApprovals(
cwd: string,
mr: GitLabMergeRequest,
): Promise<GitLabApprovals | null> {
const projectPath = extractProjectPath(mr.references?.full);
if (!projectPath) {
return null;
}
try {
return await runJson(
["api", `projects/${encodeURIComponent(projectPath)}/merge_requests/${mr.iid}/approvals`],
{ cwd },
GitLabApprovalsSchema,
);
} catch {
return null;
}
}
async function runMergeRequestList(
input: ListPullRequestsOptions,
): Promise<PullRequestSummary[]> {
const args = ["mr", "list", "-F", "json"];
const query = input.query?.trim();
if (query) {
args.push("--search", query);
}
if (typeof input.limit === "number") {
args.push("-P", String(input.limit));
}
const mergeRequests = await runJson(
args,
{ cwd: input.cwd },
z.array(GitLabMergeRequestSchema),
);
return mergeRequests.map(toPullRequestSummary);
}
/**
* `glab issue list` toggles JSON output with `-O/--output` (text|json); its
* `-F/--output-format` flag means something else (details|ids|urls) and
* silently falls back to the human-readable table for an unknown value. This
* differs from `glab mr list`, where `-F/--output` is the JSON toggle. Using
* the wrong flag here would emit a text table that fails JSON parsing.
*/
async function runIssueList(input: ListIssuesOptions): Promise<IssueSummary[]> {
const args = ["issue", "list", "-O", "json"];
const query = input.query?.trim();
if (query) {
args.push("--search", query);
}
if (typeof input.limit === "number") {
args.push("-P", String(input.limit));
}
const issues = await runJson(args, { cwd: input.cwd }, z.array(GitLabIssueSchema));
return issues.map(toIssueSummary);
}
return {
async isAuthenticated(input: { cwd: string } & ForgeReadOptions): Promise<boolean> {
const glabPath = await resolveGlab();
if (!glabPath) {
return false;
}
const remoteUrl = await resolveRemoteUrl(input.cwd);
const host = remoteUrl ? parseGitLabHostFromRemoteUrl(remoteUrl) : null;
if (!host) {
return false;
}
try {
await runner(["auth", "status", "--hostname", host], { cwd: input.cwd });
return true;
} catch {
return false;
}
},
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
try {
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef);
if (!mr) {
return null;
}
const approvals = await fetchApprovals(input.cwd, mr);
return toCurrentPullRequestStatus(mr, approvals);
} catch (error) {
if (error instanceof GlabCommandError && isNoMergeRequestText(error.stderr)) {
return null;
}
throw error;
}
},
async getPullRequest(input: GetPullRequestOptions): Promise<PullRequestSummary> {
const mr = await viewMergeRequest(input.cwd, String(input.number));
return toPullRequestSummary(mr);
},
async getPullRequestHeadRef(input: GetPullRequestOptions): Promise<string> {
const mr = await viewMergeRequest(input.cwd, String(input.number));
return mr.source_branch;
},
async getPullRequestCheckoutTarget(
input: GetPullRequestOptions,
): Promise<PullRequestCheckoutTarget> {
const mr = await viewMergeRequest(input.cwd, String(input.number));
return {
number: mr.iid,
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
checkoutRefs: [
{ remoteName: "origin", remoteRef: `refs/merge-requests/${mr.iid}/head` },
{ remoteName: "origin", remoteRef: `refs/heads/${mr.source_branch}` },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository:
mr.source_project_id !== undefined &&
mr.source_project_id !== null &&
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id,
};
},
listPullRequests(input: ListPullRequestsOptions): Promise<PullRequestSummary[]> {
return runMergeRequestList(input);
},
listIssues(input: ListIssuesOptions): Promise<IssueSummary[]> {
return runIssueList(input);
},
async createPullRequest(input: CreatePullRequestOptions): Promise<PullRequestCreateResult> {
const args = [
"mr",
"create",
"--title",
input.title,
"--description",
input.body ?? "",
"--source-branch",
input.head,
"--target-branch",
input.base,
"--yes",
];
const stdout = await run(args, { cwd: input.cwd });
const url = extractMergeRequestUrl(stdout);
if (!url) {
throw new Error("GitLab merge request was created but no URL was returned by glab");
}
const number = parseIidFromUrl(url);
if (number === null) {
throw new Error(`GitLab merge request URL did not contain an iid: ${url}`);
}
return { url, number };
},
async mergePullRequest(input: MergePullRequestOptions): Promise<PullRequestMergeResult> {
assertGitLabDirectMergeReady(input);
// `--auto-merge=false` forces an immediate merge: without it glab's default
// would schedule "merge when the pipeline succeeds" while a pipeline runs,
// turning a direct merge into an auto-merge. Mirrors `gh pr merge` without
// `--auto`. The pre-flight guard above stays — both are needed.
const args = ["mr", "merge", String(input.prNumber), "--auto-merge=false", "--yes"];
if (input.mergeMethod === "squash") {
args.push("--squash");
} else if (input.mergeMethod === "rebase") {
args.push("--rebase");
}
await run(args, { cwd: input.cwd });
return { success: true };
},
async getPullRequestTimeline(
input: GetPullRequestTimelineOptions,
): Promise<PullRequestTimeline> {
const identity = {
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
};
try {
const mr = await viewMergeRequest(input.cwd, String(input.prNumber));
const projectPath = extractProjectPath(mr.references?.full);
if (!projectPath) {
return {
...identity,
items: [],
truncated: false,
error: {
kind: "not_found",
message: "GitLab merge request project path is unavailable",
},
};
}
const discussions = await runJson(
[
"api",
`projects/${encodeURIComponent(projectPath)}/merge_requests/${mr.iid}/discussions?per_page=${TIMELINE_PAGE_SIZE}`,
],
{ cwd: input.cwd },
z.array(GitLabDiscussionSchema),
);
const items = discussions
.flatMap((discussion) =>
discussion.notes.map((note) => toTimelineComment(note, discussion, mr.web_url)),
)
.filter((item): item is PullRequestTimelineItem => item !== null)
.sort(compareTimelineItems);
const truncated =
discussions.length >= TIMELINE_PAGE_SIZE
? await hasDiscussionsAfterFirstPage(input.cwd, projectPath, mr.iid)
: false;
return {
...identity,
items,
truncated,
error: null,
};
} catch (error) {
return { ...identity, items: [], truncated: false, error: mapGlabTimelineError(error) };
}
},
/**
* Fetches a pipeline's stages/jobs for the drill-down. Addressing it by MR
* iid uses `glab ci get --merge-request`, which requires a recent glab
* (roughly 1.40+); on an older CLI the command errors and the drill-down
* surfaces that failure. The `--pipeline-id` path works on older glab.
*/
async getCheckDetails(input: GetCheckDetailsOptions): Promise<CheckDetails> {
// Prefer addressing the change request's head pipeline by iid: glab then
// resolves a fork/detached MR pipeline in its source project, which
// `--pipeline-id` run in the checkout's target project would 404 on. Fall
// back to the pipeline id when no iid is available.
const selector =
input.changeRequestNumber !== undefined
? ["--merge-request", String(input.changeRequestNumber)]
: ["--pipeline-id", String(input.checkRunId)];
const pipeline = await runJson(
["ci", "get", ...selector, "--with-job-details", "-F", "json"],
{ cwd: input.cwd },
GitLabPipelineDetailsSchema,
);
return toCheckDetails(pipeline);
},
async searchIssuesAndPrs(input: SearchIssuesAndPrsOptions): Promise<SearchResult> {
if (input.force && !input.reason) {
throw new Error("ForgeService forced read requires a reason");
}
const kinds = normalizeForgeSearchKinds(input.kinds);
const shouldFetchIssues = kinds.includes("issue");
const shouldFetchMergeRequests = kinds.includes("change_request");
const [issuesResult, mergeRequestsResult] = await Promise.allSettled([
shouldFetchIssues
? runIssueList({ cwd: input.cwd, query: input.query, limit: input.limit })
: Promise.resolve(null),
shouldFetchMergeRequests
? runMergeRequestList({ cwd: input.cwd, query: input.query, limit: input.limit })
: Promise.resolve(null),
]);
const requestedResults = [
shouldFetchIssues ? issuesResult : null,
shouldFetchMergeRequests ? mergeRequestsResult : null,
].filter((result) => result !== null);
const unavailableAuthState = getGlabUnavailableSearchAuthState(requestedResults);
if (unavailableAuthState) {
return createUnavailableSearchResult(unavailableAuthState);
}
throwFirstNonGlabAuthSearchRejection(requestedResults);
const items: SearchResult["items"] = [];
if (shouldFetchIssues && issuesResult.status === "fulfilled") {
for (const issue of issuesResult.value ?? []) {
items.push({
kind: "issue",
number: issue.number,
title: issue.title,
url: issue.url,
state: issue.state,
body: issue.body,
labels: issue.labels,
...(issue.projectPath ? { projectPath: issue.projectPath } : {}),
baseRefName: null,
headRefName: null,
updatedAt: issue.updatedAt,
});
}
}
if (shouldFetchMergeRequests && mergeRequestsResult.status === "fulfilled") {
for (const mergeRequest of mergeRequestsResult.value ?? []) {
items.push({
kind: "change_request",
number: mergeRequest.number,
title: mergeRequest.title,
url: mergeRequest.url,
state: mergeRequest.state,
body: mergeRequest.body,
labels: mergeRequest.labels,
...(mergeRequest.projectPath ? { projectPath: mergeRequest.projectPath } : {}),
baseRefName: mergeRequest.baseRefName,
headRefName: mergeRequest.headRefName,
updatedAt: mergeRequest.updatedAt,
});
}
}
items.sort(
(left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt),
);
return {
items,
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
};
},
async enablePullRequestAutoMerge(
input: EnablePullRequestAutoMergeOptions,
): Promise<PullRequestAutoMergeResult> {
// GitLab's auto-merge is "merge when pipeline succeeds": passing
// `--auto-merge` while a pipeline is running schedules the merge instead
// of performing it immediately. The merge strategy mirrors mergePullRequest.
assertGitLabAutoMergeEnableReady({ status: input.status });
const args = ["mr", "merge", String(input.prNumber), "--auto-merge", "--yes"];
if (input.mergeMethod === "squash") {
args.push("--squash");
} else if (input.mergeMethod === "rebase") {
args.push("--rebase");
}
await run(args, { cwd: input.cwd });
return { success: true };
},
async disablePullRequestAutoMerge(
input: DisablePullRequestAutoMergeOptions,
): Promise<PullRequestAutoMergeResult> {
// `glab mr merge --auto-merge=false` would merge immediately rather than
// cancel a scheduled auto-merge, so cancel via the REST endpoint instead.
// The `:fullpath` placeholder resolves the project from the cwd's remote.
await run(
[
"api",
"--method",
"POST",
`projects/:fullpath/merge_requests/${input.prNumber}/cancel_merge_when_pipeline_succeeds`,
],
{ cwd: input.cwd },
);
return { success: true };
},
invalidate(_input: { cwd: string }): void {},
};
}