/** * Pure REST helpers for the corporate GitLab (self-hosted, API v4). * `gitHost` comes from config/company.json; `GITLAB_TOKEN` from the env. * No pi API — unit-testable. */ import { apiRequest, requireToken, ApiError } from "./atlassian.ts"; import { readLocalConfig } from "./company-config.ts"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json"); /** Resolve the GitLab API base URL from company.json (env override for labs). */ export function gitlabBaseUrl(): string { const cfg = readLocalConfig(CONFIG_PATH); const host = process.env.GITLAB_HOST || (cfg.gitHost && cfg.gitHost !== "TODO" ? cfg.gitHost : ""); if (!host) throw new ApiError("Не задан gitHost в config/company.json и нет GITLAB_HOST."); return `https://${host.replace(/^https?:\/\//, "").replace(/\/+$/, "")}/api/v4`; } function token(): string { return requireToken( "GITLAB_TOKEN", "personal/project access token GitLab со scope api или read_api. Где брать: Настройки → Access Tokens → Personal (scope: api)", ); } /** Namespaced project path (user/repo) -> URL-encoded API project id. */ export function projectId(project: string): string { if (!project) throw new ApiError("Укажи проект (namespace/repo или его ID)."); if (/^\d+$/.test(project)) return project; return encodeURIComponent(project.replace(/^https?:\/\/([^/]+)\//, "").replace(/\.git$/, "")); } /** Parse `git remote origin` in cwd into a namespaced project path, or null. */ export function deriveProjectFromOrigin(): string | null { try { // Keep this synchronous & offline-safe; execSync is lazy-loaded so the // module can be imported in tests without a git repo. // eslint-disable-next-line @typescript-eslint/no-var-requires const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); const url = execFileSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" }) .toString() .trim(); if (!url) return null; // Support both scp-style (git@host:path) and scheme-style (https://host/path). let rest = url.replace(/\.git$/, ""); rest = rest.replace(/^[a-z]+:\/\//i, "").replace(/^[^@]*@/, ""); rest = rest.replace(/^[^:/]+[:/]/, ""); // drop host[:port] return rest.split("/").filter(Boolean).join("/") || null; } catch { return null; } } export interface MrCreateInput { title: string; sourceBranch: string; targetBranch?: string; project?: string; // namespace/repo; falls back to origin when omitted removeSourceBranch?: boolean; mergeWhenPipelineSucceeds?: boolean; squash?: boolean; } export async function mrCreate(input: MrCreateInput): Promise<{ url: string; iid?: number }> { const base = gitlabBaseUrl(); if (!input.title || !input.sourceBranch) throw new ApiError("mrCreate требует title и sourceBranch."); const project = input.project || deriveProjectFromOrigin(); if (!project) throw new ApiError("Не удалось определить проект. Укажи project (namespace/repo)."); const body: Record = { source_branch: input.sourceBranch, target_branch: input.targetBranch ?? "main", title: input.title, remove_source_branch: input.removeSourceBranch ?? true, squash: input.squash ?? true, }; if (input.mergeWhenPipelineSucceeds) body.merge_when_pipeline_succeeds = true; const data: any = await apiRequest(base, token(), `/projects/${projectId(project)}/merge_requests`, { method: "POST", body, }); return { url: data?.web_url ?? "", iid: data?.iid }; } /** List pipelines for a project (optionally filtered by ref). */ export async function pipelineStatus( project: string, opts: { ref?: string; perPage?: number } = {}, ): Promise> { const base = gitlabBaseUrl(); if (!project) throw new ApiError("pipelineStatus требует project (namespace/repo)."); const data: any = await apiRequest(base, token(), `/projects/${projectId(project)}/pipelines`, { query: { ref: opts.ref, per_page: opts.perPage ?? 10 }, }); return (data ?? []).map((p: any) => ({ id: p.id, ref: p.ref ?? "", status: p.status ?? "unknown", created_at: p.created_at ?? "", web_url: p.web_url ?? "", })); } /** GitLab code search (blobs). Requires `read_api`/`api` scope; a 403 is surfaced clearly. */ export async function codeSearch( query: string, opts: { project?: string; perPage?: number } = {}, ): Promise> { const base = gitlabBaseUrl(); if (!query) throw new ApiError("codeSearch требует строку поиска (query)."); const q: Record = { scope: "blobs", search: query, per_page: opts.perPage ?? 20, }; if (opts.project) q.project_id = projectId(opts.project); const data: any = await apiRequest(base, token(), "/search", { query: q }); return (data ?? []).map((b: any) => ({ path: b.path ?? "", filename: b.filename ?? "", project: b.project_id ? String(b.project_id) : "", ref: b.ref ?? "", code: b.data ?? b.content ?? "", })); }