agent-orchestrator/packages/plugins/scm-gitlab/src/index.ts

890 lines
28 KiB
TypeScript

/**
* scm-gitlab plugin — GitLab MRs, CI pipelines, reviews, merge readiness.
*
* Uses the `glab` CLI for GitLab API interactions.
*/
import { createHash, timingSafeEqual } from "node:crypto";
import {
CI_STATUS,
recordActivityEvent,
type PluginModule,
type SCM,
type SCMWebhookEvent,
type SCMWebhookRequest,
type SCMWebhookVerificationResult,
type Session,
type ProjectConfig,
type PRInfo,
type PRState,
type MergeMethod,
type CICheck,
type CIStatus,
type Review,
type ReviewDecision,
type ReviewComment,
type ReviewSummary,
type ReviewThreadsResult,
type MergeReadiness,
} from "@aoagents/ao-core";
import {
getWebhookHeader,
parseWebhookBranchRef,
parseWebhookJsonObject,
parseWebhookTimestamp,
} from "@aoagents/ao-core/scm-webhook-utils";
import { glab, parseJSON, stripHost } from "./glab-utils.js";
const BOT_AUTHORS = new Set([
"gitlab-bot",
"ghost",
"dependabot[bot]",
"renovate[bot]",
"sast-bot",
"codeclimate[bot]",
"sonarcloud[bot]",
"snyk-bot",
]);
const ciSummaryFailClosedEmitted = new Set<string>();
const reviewFetchFailedEmitted = new Set<string>();
/** Test-only: reset once-per-PR activity event guards. */
export function _resetGitLabActivityEventDedupeForTesting(): void {
ciSummaryFailClosedEmitted.clear();
reviewFetchFailedEmitted.clear();
}
function isBot(username: string): boolean {
return (
BOT_AUTHORS.has(username) || /^project_\d+_bot/.test(username) || username.endsWith("[bot]")
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function repoFlag(pr: PRInfo): string {
return `${pr.owner}/${pr.repo}`;
}
function prEventKey(pr: PRInfo): string {
return `${repoFlag(pr)}#${pr.number}`;
}
function parseDate(val: string | undefined | null): Date {
if (!val) return new Date(0);
const d = new Date(val);
return isNaN(d.getTime()) ? new Date(0) : d;
}
function extractHostFromOwner(owner: string): string | undefined {
const parts = owner.split("/");
const first = parts[0];
return first && first.includes(".") && parts.length >= 2 ? first : undefined;
}
function encodeProjectId(owner: string, repo: string): string {
return encodeURIComponent(stripHost(`${owner}/${repo}`));
}
function mapJobStatus(status: string): CICheck["status"] {
switch (status) {
case "pending":
case "waiting_for_resource":
case "preparing":
case "created":
case "scheduled":
case "manual":
return "pending";
case "running":
return "running";
case "success":
return "passed";
case "failed":
case "canceled":
return "failed";
case "skipped":
return "skipped";
default:
return "failed";
}
}
function mapPRState(state: string): PRState {
const s = state.toLowerCase();
if (s === "merged") return "merged";
if (s === "closed") return "closed";
return "open";
}
function getGitLabWebhookConfig(project: ProjectConfig) {
const webhook = project.scm?.webhook;
return {
enabled: webhook?.enabled !== false,
path: webhook?.path ?? "/api/webhooks/gitlab",
secretEnvVar: webhook?.secretEnvVar,
signatureHeader: webhook?.signatureHeader ?? "x-gitlab-token",
eventHeader: webhook?.eventHeader ?? "x-gitlab-event",
deliveryHeader: webhook?.deliveryHeader ?? "x-gitlab-event-uuid",
maxBodyBytes: webhook?.maxBodyBytes,
};
}
function verifyGitLabToken(secret: string, providedToken: string): boolean {
const toDigest = (value: string): Buffer => createHash("sha256").update(value).digest();
return timingSafeEqual(toDigest(secret), toDigest(providedToken));
}
function parseGitLabRepository(payload: Record<string, unknown>) {
const project = payload["project"];
if (!project || typeof project !== "object") return undefined;
const projectRecord = project as Record<string, unknown>;
const pathWithNamespace = projectRecord["path_with_namespace"];
if (typeof pathWithNamespace === "string" && pathWithNamespace.length > 0) {
const parts = pathWithNamespace.split("/");
if (parts.length >= 2) {
const name = parts[parts.length - 1];
const owner = parts.slice(0, -1).join("/");
if (owner && name) return { owner, name };
}
}
const namespace =
typeof projectRecord["namespace"] === "string" ? projectRecord["namespace"] : undefined;
const name = typeof projectRecord["path"] === "string" ? projectRecord["path"] : undefined;
if (!namespace || !name) return undefined;
return { owner: namespace, name };
}
function isGitLabTagRef(
payload: Record<string, unknown>,
objectAttributes: Record<string, unknown> | undefined,
): boolean {
return (
objectAttributes?.["tag"] === true ||
payload["ref_type"] === "tag" ||
payload["object_kind"] === "tag_push"
);
}
function parseGitLabCiBranch(
payload: Record<string, unknown>,
objectAttributes: Record<string, unknown> | undefined,
): string | undefined {
const isTag = isGitLabTagRef(payload, objectAttributes);
if (isTag) return undefined;
return parseWebhookBranchRef(payload["ref"] ?? objectAttributes?.["ref"]);
}
function parseGitLabPushBranch(
payload: Record<string, unknown>,
objectAttributes: Record<string, unknown> | undefined,
): string | undefined {
const isTag = isGitLabTagRef(payload, objectAttributes);
if (isTag) return undefined;
const refValue = payload["ref"];
return parseWebhookBranchRef(refValue);
}
function parseGitLabWebhookEvent(
request: SCMWebhookRequest,
payload: Record<string, unknown>,
config: ReturnType<typeof getGitLabWebhookConfig>,
): SCMWebhookEvent | null {
const rawEventType = getWebhookHeader(request.headers, config.eventHeader);
if (!rawEventType) return null;
const normalizedEventType = rawEventType.toLowerCase();
const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader);
const repository = parseGitLabRepository(payload);
const objectAttributes =
payload["object_attributes"] && typeof payload["object_attributes"] === "object"
? (payload["object_attributes"] as Record<string, unknown>)
: undefined;
const action =
typeof objectAttributes?.["action"] === "string"
? (objectAttributes["action"] as string)
: typeof payload["action"] === "string"
? (payload["action"] as string)
: rawEventType;
if (normalizedEventType === "merge request hook" || payload["object_kind"] === "merge_request") {
const mergeRequest =
payload["object_attributes"] && typeof payload["object_attributes"] === "object"
? (payload["object_attributes"] as Record<string, unknown>)
: undefined;
if (!mergeRequest) return null;
return {
provider: "gitlab",
kind: "pull_request",
action,
rawEventType,
deliveryId,
repository,
prNumber:
typeof mergeRequest["iid"] === "number"
? (mergeRequest["iid"] as number)
: typeof mergeRequest["id"] === "number"
? (mergeRequest["id"] as number)
: undefined,
branch: parseWebhookBranchRef(mergeRequest["source_branch"]),
sha:
typeof mergeRequest["last_commit"] === "object" && mergeRequest["last_commit"]
? ((mergeRequest["last_commit"] as Record<string, unknown>)["id"] as string | undefined)
: undefined,
timestamp: parseWebhookTimestamp(mergeRequest["updated_at"]),
data: payload,
};
}
if (normalizedEventType === "note hook" || payload["object_kind"] === "note") {
const mergeRequest =
payload["merge_request"] && typeof payload["merge_request"] === "object"
? (payload["merge_request"] as Record<string, unknown>)
: undefined;
const noteableType = objectAttributes?.["noteable_type"];
if (!mergeRequest || noteableType !== "MergeRequest") return null;
return {
provider: "gitlab",
kind: "comment",
action,
rawEventType,
deliveryId,
repository,
prNumber:
typeof mergeRequest["iid"] === "number" ? (mergeRequest["iid"] as number) : undefined,
branch: parseWebhookBranchRef(mergeRequest["source_branch"]),
sha:
typeof mergeRequest["last_commit"] === "object" && mergeRequest["last_commit"]
? ((mergeRequest["last_commit"] as Record<string, unknown>)["id"] as string | undefined)
: undefined,
timestamp: parseWebhookTimestamp(
objectAttributes?.["updated_at"] ?? objectAttributes?.["created_at"],
),
data: payload,
};
}
if (
normalizedEventType === "pipeline hook" ||
normalizedEventType === "job hook" ||
payload["object_kind"] === "pipeline" ||
payload["object_kind"] === "build"
) {
return {
provider: "gitlab",
kind: "ci",
action,
rawEventType,
deliveryId,
repository,
prNumber:
typeof payload["merge_request"] === "object" && payload["merge_request"]
? (((payload["merge_request"] as Record<string, unknown>)["iid"] as number | undefined) ??
((payload["merge_request"] as Record<string, unknown>)["id"] as number | undefined))
: undefined,
branch: parseGitLabCiBranch(payload, objectAttributes),
sha:
typeof payload["checkout_sha"] === "string"
? (payload["checkout_sha"] as string)
: typeof payload["sha"] === "string"
? (payload["sha"] as string)
: typeof objectAttributes?.["sha"] === "string"
? (objectAttributes["sha"] as string)
: undefined,
timestamp: parseWebhookTimestamp(
objectAttributes?.["finished_at"] ??
objectAttributes?.["updated_at"] ??
payload["commit_timestamp"] ??
payload["updated_at"],
),
data: payload,
};
}
if (
normalizedEventType === "push hook" ||
normalizedEventType === "tag push hook" ||
payload["object_kind"] === "push" ||
payload["object_kind"] === "tag_push"
) {
return {
provider: "gitlab",
kind: "push",
action,
rawEventType,
deliveryId,
repository,
branch: parseGitLabPushBranch(payload, objectAttributes),
sha:
typeof payload["after"] === "string"
? (payload["after"] as string)
: typeof payload["checkout_sha"] === "string"
? (payload["checkout_sha"] as string)
: undefined,
timestamp: parseWebhookTimestamp(payload["event_created_at"] ?? payload["commit_timestamp"]),
data: payload,
};
}
return {
provider: "gitlab",
kind: "unknown",
action,
rawEventType,
deliveryId,
repository,
timestamp: parseWebhookTimestamp(
objectAttributes?.["updated_at"] ?? payload["event_created_at"],
),
data: payload,
};
}
interface GitLabNote {
id: number;
author: { username: string };
body: string;
resolvable: boolean;
resolved: boolean;
position?: { new_path?: string; new_line?: number | null };
created_at: string;
}
interface GitLabDiscussion {
id: string;
notes: GitLabNote[];
}
function mrApiPath(pr: PRInfo): string {
return `projects/${encodeProjectId(pr.owner, pr.repo)}/merge_requests/${pr.number}`;
}
async function fetchDiscussions(
pr: PRInfo,
hostname: string | undefined,
context: string,
): Promise<GitLabDiscussion[]> {
const raw = await glab(["api", `${mrApiPath(pr)}/discussions?per_page=100`], hostname);
return parseJSON<GitLabDiscussion[]>(raw, context);
}
// ---------------------------------------------------------------------------
// SCM implementation
// ---------------------------------------------------------------------------
function createGitLabSCM(config?: Record<string, unknown>): SCM {
const configHostname = typeof config?.host === "string" ? config.host : undefined;
function resolveHostname(pr?: PRInfo): string | undefined {
return configHostname ?? (pr ? extractHostFromOwner(pr.owner) : undefined);
}
return {
name: "gitlab",
async verifyWebhook(
request: SCMWebhookRequest,
project: ProjectConfig,
): Promise<SCMWebhookVerificationResult> {
const webhookConfig = getGitLabWebhookConfig(project);
if (!webhookConfig.enabled) {
return { ok: false, reason: "Webhook is disabled for this project" };
}
if (request.method.toUpperCase() !== "POST") {
return { ok: false, reason: "Webhook requests must use POST" };
}
if (
webhookConfig.maxBodyBytes !== undefined &&
Buffer.byteLength(request.body, "utf8") > webhookConfig.maxBodyBytes
) {
return { ok: false, reason: "Webhook payload exceeds configured maxBodyBytes" };
}
const eventType = getWebhookHeader(request.headers, webhookConfig.eventHeader);
if (!eventType) {
return { ok: false, reason: `Missing ${webhookConfig.eventHeader} header` };
}
const deliveryId = getWebhookHeader(request.headers, webhookConfig.deliveryHeader);
const secretName = webhookConfig.secretEnvVar;
if (!secretName) {
return { ok: true, deliveryId, eventType };
}
const secret = process.env[secretName];
if (!secret) {
return { ok: false, reason: `Webhook secret env var ${secretName} is not configured` };
}
const providedToken = getWebhookHeader(request.headers, webhookConfig.signatureHeader);
if (!providedToken) {
return { ok: false, reason: `Missing ${webhookConfig.signatureHeader} header` };
}
if (!verifyGitLabToken(secret, providedToken)) {
return {
ok: false,
reason: "Webhook token verification failed",
deliveryId,
eventType,
};
}
return { ok: true, deliveryId, eventType };
},
async parseWebhook(
request: SCMWebhookRequest,
project: ProjectConfig,
): Promise<SCMWebhookEvent | null> {
const webhookConfig = getGitLabWebhookConfig(project);
const payload = parseWebhookJsonObject(request.body);
return parseGitLabWebhookEvent(request, payload, webhookConfig);
},
async detectPR(session: Session, project: ProjectConfig): Promise<PRInfo | null> {
if (!session.branch || !project.repo) return null;
const parts = project.repo.split("/");
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new Error(`Invalid repo format "${project.repo}", expected "owner/repo"`);
}
const owner = parts.slice(0, -1).join("/");
const repo = parts[parts.length - 1];
try {
const raw = await glab(
[
"mr",
"list",
"--source-branch",
session.branch,
"--repo",
project.repo,
"-F",
"json",
"-P",
"1",
],
resolveHostname(),
);
const mrs = parseJSON<
Array<{
iid: number;
web_url: string;
title: string;
source_branch: string;
target_branch: string;
draft: boolean;
}>
>(raw, `detectPR for branch "${session.branch}"`);
if (mrs.length === 0) return null;
const mr = mrs[0];
return {
number: mr.iid,
url: mr.web_url,
title: mr.title,
owner,
repo,
branch: mr.source_branch,
baseBranch: mr.target_branch,
isDraft: mr.draft ?? false,
};
} catch (err) {
console.warn(`detectPR: failed for branch "${session.branch}": ${(err as Error).message}`);
return null;
}
},
async getPRState(pr: PRInfo): Promise<PRState> {
const raw = await glab(
["mr", "view", String(pr.number), "--repo", repoFlag(pr), "-F", "json"],
resolveHostname(pr),
);
const data = parseJSON<{ state: string }>(raw, `getPRState for MR !${pr.number}`);
return mapPRState(data.state);
},
async getPRSummary(pr: PRInfo) {
const raw = await glab(
["mr", "view", String(pr.number), "--repo", repoFlag(pr), "-F", "json"],
resolveHostname(pr),
);
const data = parseJSON<{ state: string; title: string }>(
raw,
`getPRSummary for MR !${pr.number}`,
);
return {
state: mapPRState(data.state),
title: data.title ?? "",
additions: 0,
deletions: 0,
};
},
async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise<void> {
const args = ["mr", "merge", String(pr.number), "--repo", repoFlag(pr)];
if (method === "squash") args.push("--squash");
else if (method === "rebase") args.push("--rebase");
args.push("-d", "-y");
await glab(args, resolveHostname(pr));
},
async closePR(pr: PRInfo): Promise<void> {
await glab(["mr", "close", String(pr.number), "--repo", repoFlag(pr)], resolveHostname(pr));
},
async getCIChecks(pr: PRInfo): Promise<CICheck[]> {
try {
const apiBase = mrApiPath(pr);
const hostname = resolveHostname(pr);
const pipelinesRaw = await glab(["api", `${apiBase}/pipelines`], hostname);
const pipelines = parseJSON<Array<{ id: number }>>(
pipelinesRaw,
`getCIChecks pipelines for MR !${pr.number}`,
);
if (pipelines.length === 0) return [];
const latestPipelineId = pipelines[0].id;
const projectId = encodeProjectId(pr.owner, pr.repo);
const jobsRaw = await glab(
["api", `projects/${projectId}/pipelines/${latestPipelineId}/jobs`],
hostname,
);
const jobs = parseJSON<
Array<{
name: string;
status: string;
web_url: string;
started_at: string | null;
finished_at: string | null;
}>
>(jobsRaw, `getCIChecks jobs for pipeline ${latestPipelineId}`);
return jobs.map((j) => ({
name: j.name,
status: mapJobStatus(j.status),
url: j.web_url || undefined,
conclusion: j.status || undefined,
startedAt: j.started_at ? new Date(j.started_at) : undefined,
completedAt: j.finished_at ? new Date(j.finished_at) : undefined,
}));
} catch (err) {
throw new Error("Failed to fetch CI checks", { cause: err });
}
},
async getCISummary(pr: PRInfo): Promise<CIStatus> {
let checks: CICheck[];
try {
checks = await this.getCIChecks(pr);
} catch (err) {
console.warn(
`getCISummary: CI check fetch failed for MR !${pr.number}: ${(err as Error).message}`,
);
try {
const state = await this.getPRState(pr);
if (state === "merged" || state === "closed") return "none";
} catch (innerErr) {
console.warn(
`getCISummary: PR state fallback also failed for MR !${pr.number}: ${(innerErr as Error).message}`,
);
}
const eventKey = prEventKey(pr);
if (!ciSummaryFailClosedEmitted.has(eventKey)) {
ciSummaryFailClosedEmitted.add(eventKey);
const errorMessage = err instanceof Error ? err.message : String(err);
recordActivityEvent({
source: "scm",
kind: "scm.ci_summary_failclosed",
level: "warn",
summary: `getCISummary failed-closed for MR !${pr.number}`,
data: {
plugin: "scm-gitlab",
prNumber: pr.number,
prOwner: pr.owner,
prRepo: pr.repo,
errorMessage,
},
});
}
return "failing";
}
if (checks.length === 0) return "none";
const hasFailing = checks.some((c) => c.status === "failed");
if (hasFailing) return "failing";
const hasPending = checks.some((c) => c.status === "pending" || c.status === "running");
if (hasPending) return "pending";
const hasPassing = checks.some((c) => c.status === "passed");
if (!hasPassing) return "none";
return "passing";
},
async getReviews(pr: PRInfo): Promise<Review[]> {
const hostname = resolveHostname(pr);
const reviews: Review[] = [];
const approvalsRaw = await glab(["api", `${mrApiPath(pr)}/approvals`], hostname);
const approvals = parseJSON<{
approved_by: Array<{ user: { username: string } }>;
}>(approvalsRaw, `getReviews approvals for MR !${pr.number}`);
const approvedUsers = new Set<string>();
for (const a of approvals.approved_by ?? []) {
const author = a.user?.username ?? "unknown";
approvedUsers.add(author);
reviews.push({ author, state: "approved", submittedAt: new Date(0) });
}
try {
const discussions = await fetchDiscussions(
pr,
hostname,
`getReviews discussions for MR !${pr.number}`,
);
const requestedAuthors = new Set<string>();
for (const d of discussions) {
const note = d.notes[0];
if (!note) continue;
if (!note.resolvable || note.resolved) continue;
const author = note.author?.username ?? "";
if (isBot(author) || approvedUsers.has(author) || requestedAuthors.has(author)) continue;
requestedAuthors.add(author);
reviews.push({
author,
state: "changes_requested",
submittedAt: parseDate(note.created_at),
});
}
} catch (err) {
console.warn(
`getReviews: discussions fetch failed for MR !${pr.number}: ${(err as Error).message}`,
);
const eventKey = prEventKey(pr);
if (!reviewFetchFailedEmitted.has(eventKey)) {
reviewFetchFailedEmitted.add(eventKey);
const errorMessage = err instanceof Error ? err.message : String(err);
recordActivityEvent({
source: "scm",
kind: "scm.review_fetch_failed",
level: "warn",
summary: `getReviews discussions fetch failed for MR !${pr.number}`,
data: {
plugin: "scm-gitlab",
prNumber: pr.number,
prOwner: pr.owner,
prRepo: pr.repo,
errorMessage,
},
});
}
}
return reviews;
},
async getReviewDecision(pr: PRInfo): Promise<ReviewDecision> {
const raw = await glab(["api", `${mrApiPath(pr)}/approvals`], resolveHostname(pr));
const data = parseJSON<{ approved: boolean; approvals_left: number }>(
raw,
`getReviewDecision for MR !${pr.number}`,
);
if (data.approved) return "approved";
if (data.approvals_left > 0) return "pending";
return "none";
},
async getPendingComments(pr: PRInfo): Promise<ReviewComment[]> {
const discussions = await fetchDiscussions(
pr,
resolveHostname(pr),
`getPendingComments for MR !${pr.number}`,
);
const comments: ReviewComment[] = [];
for (const d of discussions) {
const note = d.notes[0];
if (!note) continue;
if (!note.resolvable || note.resolved) continue;
if (isBot(note.author?.username ?? "")) continue;
comments.push({
id: String(note.id),
author: note.author?.username ?? "unknown",
body: note.body,
path: note.position?.new_path || undefined,
line: note.position?.new_line ?? undefined,
isResolved: false,
createdAt: parseDate(note.created_at),
url: "",
});
}
return comments;
},
async getReviewThreads(pr: PRInfo): Promise<ReviewThreadsResult> {
const hostname = resolveHostname(pr);
const discussions = await fetchDiscussions(
pr,
hostname,
`getReviewThreads for MR !${pr.number}`,
);
// Unresolved threads — includes both human and bot comments (with isBot flag)
const threads: ReviewComment[] = [];
for (const d of discussions) {
const note = d.notes[0];
if (!note) continue;
if (!note.resolvable || note.resolved) continue;
const author = note.author?.username ?? "unknown";
threads.push({
id: String(note.id),
threadId: d.id,
author,
body: note.body,
path: note.position?.new_path || undefined,
line: note.position?.new_line ?? undefined,
isResolved: false,
createdAt: parseDate(note.created_at),
url: "",
isBot: isBot(author),
});
}
// Review summaries from approvals
const reviews: ReviewSummary[] = [];
try {
const approvalsRaw = await glab(["api", `${mrApiPath(pr)}/approvals`], hostname);
const approvals = parseJSON<{
approved_by: Array<{ user: { username: string } }>;
}>(approvalsRaw, `getReviewThreads approvals for MR !${pr.number}`);
for (const a of approvals.approved_by ?? []) {
reviews.push({
author: a.user?.username ?? "unknown",
state: "APPROVED",
body: "",
submittedAt: new Date(0),
});
}
} catch {
// Best-effort — threads are the critical data
}
return { threads, reviews };
},
async getMergeability(pr: PRInfo): Promise<MergeReadiness> {
const hostname = resolveHostname(pr);
const mrRaw = await glab(
["mr", "view", String(pr.number), "--repo", repoFlag(pr), "-F", "json"],
hostname,
);
const mrData = parseJSON<{ state: string; draft: boolean }>(
mrRaw,
`getMergeability mr view for MR !${pr.number}`,
);
const state = mapPRState(mrData.state);
if (state === "merged") {
return {
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
};
}
if (state === "closed") {
return {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: ["MR is closed"],
};
}
const apiRaw = await glab(["api", mrApiPath(pr)], hostname);
const apiData = parseJSON<{
merge_status: string;
has_conflicts: boolean;
blocking_discussions_resolved: boolean;
}>(apiRaw, `getMergeability api for MR !${pr.number}`);
const blockers: string[] = [];
const ciStatus = await this.getCISummary(pr);
const ciPassing = ciStatus === CI_STATUS.PASSING || ciStatus === CI_STATUS.NONE;
if (!ciPassing) {
blockers.push(`CI is ${ciStatus}`);
}
const reviewDecision = await this.getReviewDecision(pr);
const approved = reviewDecision === "approved";
if (reviewDecision === "pending") {
blockers.push("Approval required");
}
const noConflicts = !apiData.has_conflicts;
if (!noConflicts) {
blockers.push("Merge conflicts");
}
if (apiData.merge_status === "cannot_be_merged" && noConflicts) {
blockers.push("Merge status: cannot be merged");
} else if (apiData.merge_status === "checking") {
blockers.push("Merge status unknown (GitLab is computing)");
}
if (!apiData.blocking_discussions_resolved) {
blockers.push("Unresolved discussions blocking merge");
}
if (mrData.draft) {
blockers.push("MR is still a draft");
}
return {
mergeable: blockers.length === 0,
ciPassing,
approved,
noConflicts,
blockers,
};
},
};
}
// ---------------------------------------------------------------------------
// Plugin module export
// ---------------------------------------------------------------------------
export const manifest = {
name: "gitlab",
slot: "scm" as const,
description: "SCM plugin: GitLab MRs, CI pipelines, reviews, merge readiness",
version: "0.1.0",
};
export function create(config?: Record<string, unknown>): SCM {
return createGitLabSCM(config);
}
export default { manifest, create } satisfies PluginModule<SCM>;