Files

130 lines
5.2 KiB
TypeScript

/**
* 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<string, unknown> = {
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<Array<{ id: number; ref: string; status: string; created_at: string; web_url: string }>> {
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<Array<{ path: string; filename: string; project: string; ref: string; code: string }>> {
const base = gitlabBaseUrl();
if (!query) throw new ApiError("codeSearch требует строку поиска (query).");
const q: Record<string, string | number> = {
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 ?? "",
}));
}