diff --git a/packages/opencode/src/cli/cmd/gitlab.ts b/packages/opencode/src/cli/cmd/gitlab.ts new file mode 100644 index 000000000..57865b8a0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/gitlab.ts @@ -0,0 +1,493 @@ +import { UI } from "../ui" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { Session } from "../../session" +import type { SessionID } from "../../session/schema" +import { MessageID, PartID } from "../../session/schema" +import { Provider } from "../../provider/provider" +import type { ProviderID } from "../../provider/schema" +import type { ModelID } from "../../provider/schema" +import { Bus } from "../../bus" +import { MessageV2 } from "../../session/message-v2" +import { SessionPrompt } from "@/session/prompt" +import { extractResponseText, formatPromptTooLargeError } from "./github" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface GitLabMRMetadata { + title: string + description: string + author: { username: string } + source_branch: string + target_branch: string + state: string + web_url: string + sha: string + diff_refs: { + base_sha: string + head_sha: string + start_sha: string + } +} + +interface GitLabMRChange { + old_path: string + new_path: string + new_file: boolean + renamed_file: boolean + deleted_file: boolean + diff: string +} + +interface GitLabMRChangesResponse extends GitLabMRMetadata { + changes: GitLabMRChange[] +} + +interface GitLabNote { + id: number + body: string + author: { username: string } + created_at: string +} + +// --------------------------------------------------------------------------- +// URL parsing +// --------------------------------------------------------------------------- + +/** + * Parse a GitLab MR URL into its components. + * + * Supports: + * https://gitlab.com/org/repo/-/merge_requests/123 + * https://gitlab.example.com/org/group/repo/-/merge_requests/42 + * https://gitlab.com/org/repo/-/merge_requests/123#note_456 + */ +export function parseGitLabMRUrl(url: string): { + instanceUrl: string + projectPath: string + mrIid: number +} | null { + try { + const parsed = new URL(url) + // Match path like /org/repo/-/merge_requests/123 or /org/group/sub/repo/-/merge_requests/123 + const match = parsed.pathname.match(/^\/(.+?)\/-\/merge_requests\/(\d+)/) + if (!match) return null + return { + instanceUrl: `${parsed.protocol}//${parsed.host}`, + projectPath: match[1], + mrIid: parseInt(match[2], 10), + } + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// GitLab API helpers +// --------------------------------------------------------------------------- + +function maskToken(token: string): string { + if (token.length <= 8) return "****" + return token.slice(0, 4) + "****" + token.slice(-4) +} + +async function gitlabApi( + instanceUrl: string, + path: string, + token: string, + options: { method?: string; body?: Record } = {}, +): Promise { + const url = `${instanceUrl}/api/v4${path}` + const headers: Record = { + "PRIVATE-TOKEN": token, + "Content-Type": "application/json", + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30_000) + const res = await fetch(url, { + method: options.method ?? "GET", + headers, + signal: controller.signal, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }).finally(() => clearTimeout(timeout)) + + if (!res.ok) { + if (res.status === 401) { + throw new Error( + `GitLab authentication failed (HTTP 401). Verify your token (${maskToken(token)}) has api scope.`, + ) + } + if (res.status === 404) { + throw new Error(`GitLab resource not found (HTTP 404). Check the project path and MR IID.`) + } + const text = await res.text().catch(() => "") + throw new Error(`GitLab API error: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`) + } + + return (await res.json()) as T +} + +async function fetchMRMetadata( + instanceUrl: string, + projectId: string, + mrIid: number, + token: string, +): Promise { + return gitlabApi( + instanceUrl, + `/projects/${projectId}/merge_requests/${mrIid}`, + token, + ) +} + +async function fetchMRChanges( + instanceUrl: string, + projectId: string, + mrIid: number, + token: string, +): Promise { + return gitlabApi( + instanceUrl, + `/projects/${projectId}/merge_requests/${mrIid}/changes`, + token, + ) +} + +async function fetchMRNotes( + instanceUrl: string, + projectId: string, + mrIid: number, + token: string, +): Promise { + const all: GitLabNote[] = [] + let page = 1 + while (true) { + const batch = await gitlabApi( + instanceUrl, + `/projects/${projectId}/merge_requests/${mrIid}/notes?sort=asc&per_page=100&page=${page}`, + token, + ) + all.push(...batch) + if (batch.length < 100) break + page += 1 + } + return all +} + +async function postMRNote( + instanceUrl: string, + projectId: string, + mrIid: number, + token: string, + body: string, +): Promise { + return gitlabApi( + instanceUrl, + `/projects/${projectId}/merge_requests/${mrIid}/notes`, + token, + { method: "POST", body: { body } }, + ) +} + +async function updateMRNote( + instanceUrl: string, + projectId: string, + mrIid: number, + token: string, + noteId: number, + body: string, +): Promise { + return gitlabApi( + instanceUrl, + `/projects/${projectId}/merge_requests/${mrIid}/notes/${noteId}`, + token, + { method: "PUT", body: { body } }, + ) +} + +// --------------------------------------------------------------------------- +// Prompt building +// --------------------------------------------------------------------------- + +function buildReviewPrompt(mr: GitLabMRChangesResponse, notes: GitLabNote[]): string { + const changedFiles = mr.changes.map((c) => { + const status = c.new_file ? "added" : c.deleted_file ? "deleted" : c.renamed_file ? "renamed" : "modified" + return `- ${c.new_path} (${status})` + }) + + const diffs = mr.changes.map((c) => { + return [`--- ${c.old_path}`, `+++ ${c.new_path}`, c.diff].join("\n") + }) + + const noteLines = notes + .filter((n) => !n.body.startsWith("")) + .map((n) => `- ${n.author.username} at ${n.created_at}: ${n.body}`) + + return [ + "You are reviewing a GitLab Merge Request. Provide a thorough code review.", + "", + "", + `Title: ${mr.title}`, + `Description: ${mr.description || "(no description)"}`, + `Author: ${mr.author.username}`, + `Source Branch: ${mr.source_branch}`, + `Target Branch: ${mr.target_branch}`, + `State: ${mr.state}`, + "", + "", + ...changedFiles, + "", + "", + "", + ...diffs, + "", + ...(noteLines.length > 0 ? ["", "", ...noteLines, ""] : []), + "", + "", + "Review the code changes above. Focus on:", + "- Bugs, logic errors, and edge cases", + "- Security issues", + "- Performance concerns", + "- Code quality and maintainability", + "- Missing error handling", + "", + "Provide your review as a well-structured markdown comment.", + ].join("\n") +} + +// --------------------------------------------------------------------------- +// Token resolution +// --------------------------------------------------------------------------- + +function resolveToken(): string { + const token = process.env["GITLAB_PERSONAL_ACCESS_TOKEN"] || process.env["GITLAB_TOKEN"] + if (!token) { + throw new Error( + "GitLab token not found. Set GITLAB_PERSONAL_ACCESS_TOKEN or GITLAB_TOKEN environment variable.\n" + + "Create a token at: /-/user_settings/personal_access_tokens (scope: api)", + ) + } + return token +} + +// --------------------------------------------------------------------------- +// CLI command +// --------------------------------------------------------------------------- + +export const GitlabCommand = cmd({ + command: "gitlab", + describe: "manage GitLab MR reviews", + builder: (yargs) => yargs.command(GitlabReviewCommand).demandCommand(), + async handler() {}, +}) + +export const GitlabReviewCommand = cmd({ + command: "review ", + describe: "review a GitLab merge request", + builder: (yargs) => + yargs + .positional("mr-url", { + type: "string", + describe: "GitLab MR URL (e.g. https://gitlab.com/org/repo/-/merge_requests/123)", + demandOption: true, + }) + .option("post-comment", { + type: "boolean", + describe: "post the review as an MR comment", + default: true, + }) + .option("model", { + type: "string", + describe: "model to use (e.g. anthropic/claude-sonnet-4-20250514)", + }), + async handler(args) { + await bootstrap(process.cwd(), async () => { + const mrUrl = args["mr-url"] as string + const shouldPost = args["post-comment"] as boolean + + // Parse MR URL + const parsed = parseGitLabMRUrl(mrUrl) + if (!parsed) { + throw new Error( + `Invalid GitLab MR URL: ${mrUrl}\nExpected format: https://gitlab.com/org/repo/-/merge_requests/123`, + ) + } + + // Resolve auth and instance + const token = resolveToken() + // GITLAB_INSTANCE_URL env var overrides the URL parsed from the MR link, + // allowing self-hosted proxies or internal mirrors to reroute API calls. + const envInstanceUrl = process.env["GITLAB_INSTANCE_URL"]?.replace(/\/+$/, "") + const instanceUrl = envInstanceUrl || parsed.instanceUrl + const projectId = encodeURIComponent(parsed.projectPath) + const mrIid = parsed.mrIid + + UI.println(`Reviewing MR !${mrIid} in ${parsed.projectPath} on ${instanceUrl}`) + + // Resolve model + const modelStr = + args.model || process.env["MODEL"] || process.env["ALTIMATE_MODEL"] || "anthropic/claude-sonnet-4-20250514" + const { providerID, modelID } = Provider.parseModel(modelStr) + if (!providerID.length || !modelID.length) { + throw new Error( + `Invalid model format: ${modelStr}. Use "provider/model" (e.g. anthropic/claude-sonnet-4-20250514).`, + ) + } + + // Fetch MR data + UI.println("Fetching MR metadata and changes...") + const [mrData, notes] = await Promise.all([ + fetchMRChanges(instanceUrl, projectId, mrIid, token), + fetchMRNotes(instanceUrl, projectId, mrIid, token), + ]) + + UI.println(` Title: ${mrData.title}`) + UI.println(` Author: ${mrData.author.username}`) + UI.println(` Branch: ${mrData.source_branch} -> ${mrData.target_branch}`) + UI.println(` Changed files: ${mrData.changes.length}`) + + // Build prompt + const reviewPrompt = buildReviewPrompt(mrData, notes) + + // Create session and run review + UI.println("Running AI review...") + const variant = process.env["VARIANT"] || undefined + const session = await Session.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }) + + // Subscribe to session events for live output + subscribeSessionEvents(session) + + // subscribeSessionEvents() already renders the completed assistant message, + // so we only need the text for posting — no duplicate printing here. + const reviewText = await runReview(session.id, variant, providerID, modelID, reviewPrompt) + + // Post to GitLab (deduplicate: update existing review note if present) + if (shouldPost) { + const commentBody = `\n${reviewText}` + const existingReview = notes.find((n) => n.body.startsWith("")) + + if (existingReview) { + UI.println("Updating existing review note on GitLab MR...") + const note = await updateMRNote(instanceUrl, projectId, mrIid, token, existingReview.id, commentBody) + UI.println(`Review updated (note #${note.id}): ${mrData.web_url}#note_${note.id}`) + } else { + UI.println("Posting review to GitLab MR...") + const note = await postMRNote(instanceUrl, projectId, mrIid, token, commentBody) + UI.println(`Review posted as note #${note.id}: ${mrData.web_url}#note_${note.id}`) + } + } + + UI.println("Done.") + }) + }, +}) + +// --------------------------------------------------------------------------- +// AI review runner +// --------------------------------------------------------------------------- + +async function runReview( + sessionID: SessionID, + variant: string | undefined, + providerID: ProviderID, + modelID: ModelID, + prompt: string, +): Promise { + const result = await SessionPrompt.prompt({ + sessionID, + messageID: MessageID.ascending(), + variant, + model: { providerID, modelID }, + tools: { "*": false }, // No tools needed for review — text-only + parts: [ + { + id: PartID.ascending(), + type: "text", + text: prompt, + }, + ], + }) + + if (result.info.role === "assistant" && result.info.error) { + const err = result.info.error + if (err.name === "ContextOverflowError") { + throw new Error(formatPromptTooLargeError([])) + } + throw new Error(`${err.name}: ${err.data?.message || ""}`) + } + + const text = extractResponseText(result.parts) + if (!text) { + throw new Error("No review text returned from the model.") + } + return text +} + +// --------------------------------------------------------------------------- +// Session event subscriber (mirrors github.ts pattern) +// --------------------------------------------------------------------------- + +function subscribeSessionEvents(session: { id: SessionID; title: string; version: string }) { + const TOOL: Record = { + todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], + todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], + bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], + glob: ["Glob", UI.Style.TEXT_INFO_BOLD], + grep: ["Grep", UI.Style.TEXT_INFO_BOLD], + list: ["List", UI.Style.TEXT_INFO_BOLD], + read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], + write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], + websearch: ["Search", UI.Style.TEXT_DIM_BOLD], + } + + function printEvent(color: string, type: string, title: string) { + UI.println( + color + `|`, + UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, + "", + UI.Style.TEXT_NORMAL + title, + ) + } + + let text = "" + Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + if (evt.properties.part.sessionID !== session.id) return + const part = evt.properties.part + + if (part.type === "tool" && part.state.status === "completed") { + const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] + const title = + part.state.title || + (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") + console.log() + printEvent(color, tool, title) + } + + if (part.type === "text") { + text = part.text + + if (part.time?.end) { + UI.empty() + UI.println(UI.markdown(text)) + UI.empty() + text = "" + return + } + } + }) +} + +// Re-export API helpers for potential use in CI/CD integrations +export { fetchMRMetadata, fetchMRChanges, fetchMRNotes, postMRNote, updateMRNote } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9a32dd967..c5d536cdf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -20,6 +20,9 @@ import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" import { GithubCommand } from "./cli/cmd/github" +// altimate_change start — gitlab: native GitLab MR review integration +import { GitlabCommand } from "./cli/cmd/gitlab" +// altimate_change end import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/tui/attach" @@ -199,6 +202,9 @@ let cli = yargs(hideBin(process.argv)) .command(ExportCommand) .command(ImportCommand) .command(GithubCommand) + // altimate_change start — gitlab: native GitLab MR review integration + .command(GitlabCommand) + // altimate_change end .command(PrCommand) .command(SessionCommand) .command(DbCommand) diff --git a/packages/opencode/test/cli/gitlab-mr-url.test.ts b/packages/opencode/test/cli/gitlab-mr-url.test.ts new file mode 100644 index 000000000..5bfdbb818 --- /dev/null +++ b/packages/opencode/test/cli/gitlab-mr-url.test.ts @@ -0,0 +1,71 @@ +import { test, expect } from "bun:test" +import { parseGitLabMRUrl } from "../../src/cli/cmd/gitlab" + +test("parses standard gitlab.com MR URL", () => { + expect(parseGitLabMRUrl("https://gitlab.com/org/repo/-/merge_requests/123")).toEqual({ + instanceUrl: "https://gitlab.com", + projectPath: "org/repo", + mrIid: 123, + }) +}) + +test("parses nested group MR URL", () => { + expect(parseGitLabMRUrl("https://gitlab.com/org/group/subgroup/repo/-/merge_requests/42")).toEqual({ + instanceUrl: "https://gitlab.com", + projectPath: "org/group/subgroup/repo", + mrIid: 42, + }) +}) + +test("parses self-hosted instance URL", () => { + expect(parseGitLabMRUrl("https://gitlab.example.com/team/project/-/merge_requests/7")).toEqual({ + instanceUrl: "https://gitlab.example.com", + projectPath: "team/project", + mrIid: 7, + }) +}) + +test("parses URL with fragment (note anchor)", () => { + expect(parseGitLabMRUrl("https://gitlab.com/org/repo/-/merge_requests/99#note_456")).toEqual({ + instanceUrl: "https://gitlab.com", + projectPath: "org/repo", + mrIid: 99, + }) +}) + +test("parses http URL", () => { + expect(parseGitLabMRUrl("http://gitlab.internal/team/repo/-/merge_requests/1")).toEqual({ + instanceUrl: "http://gitlab.internal", + projectPath: "team/repo", + mrIid: 1, + }) +}) + +test("parses URL with port", () => { + expect(parseGitLabMRUrl("https://gitlab.local:8443/org/repo/-/merge_requests/5")).toEqual({ + instanceUrl: "https://gitlab.local:8443", + projectPath: "org/repo", + mrIid: 5, + }) +}) + +test("returns null for GitHub URLs", () => { + expect(parseGitLabMRUrl("https://github.com/owner/repo/pull/123")).toBeNull() +}) + +test("returns null for non-MR GitLab URLs", () => { + expect(parseGitLabMRUrl("https://gitlab.com/org/repo/-/issues/10")).toBeNull() + expect(parseGitLabMRUrl("https://gitlab.com/org/repo/-/pipelines/50")).toBeNull() + expect(parseGitLabMRUrl("https://gitlab.com/org/repo")).toBeNull() +}) + +test("returns null for invalid URLs", () => { + expect(parseGitLabMRUrl("not-a-url")).toBeNull() + expect(parseGitLabMRUrl("")).toBeNull() + expect(parseGitLabMRUrl("gitlab.com/org/repo/-/merge_requests/1")).toBeNull() +}) + +test("returns null for MR URL without IID", () => { + expect(parseGitLabMRUrl("https://gitlab.com/org/repo/-/merge_requests/")).toBeNull() + expect(parseGitLabMRUrl("https://gitlab.com/org/repo/-/merge_requests")).toBeNull() +})