/** * tracker-gitlab plugin — GitLab Issues as an issue tracker. * * Uses the `glab` CLI for all GitLab API interactions. */ import type { PluginModule, Tracker, Issue, IssueFilters, IssueUpdate, CreateIssueInput, ProjectConfig, } from "@aoagents/ao-core"; import { glab, parseJSON, extractHost, stripHost } from "@aoagents/ao-plugin-scm-gitlab/glab-utils"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- interface GitLabIssueData { iid: number; title: string; description: string; web_url: string; state: string; labels: string[]; assignees: Array<{ username: string }>; } function toIssue(data: GitLabIssueData): Issue { return { id: String(data.iid), title: data.title, description: data.description ?? "", url: data.web_url, state: data.state.toLowerCase() === "closed" ? "closed" : "open", labels: data.labels ?? [], assignee: data.assignees?.[0]?.username, }; } function requireRepo(project: ProjectConfig): string { if (!project.repo) { throw new Error("GitLab tracker requires a 'repo' field in project config"); } return project.repo; } // --------------------------------------------------------------------------- // Tracker implementation // --------------------------------------------------------------------------- function createGitLabTracker(config?: Record): Tracker { const hostname = typeof config?.host === "string" ? config.host : undefined; const defaultHost = hostname ?? "gitlab.com"; return { name: "gitlab", async getIssue(identifier: string, project: ProjectConfig): Promise { const raw = await glab( ["issue", "view", identifier, "--repo", requireRepo(project), "-F", "json"], hostname, ); return toIssue(parseJSON(raw, `getIssue for issue ${identifier}`)); }, async isCompleted(identifier: string, project: ProjectConfig): Promise { const raw = await glab( ["issue", "view", identifier, "--repo", requireRepo(project), "-F", "json"], hostname, ); const data = parseJSON<{ state: string }>(raw, `isCompleted for issue ${identifier}`); return data.state.toLowerCase() === "closed"; }, issueUrl(identifier: string, project: ProjectConfig): string { const num = identifier.replace(/^#/, ""); const repo = requireRepo(project); const host = extractHost(repo) ?? defaultHost; return `https://${host}/${stripHost(repo)}/-/issues/${num}`; }, issueLabel(url: string, _project: ProjectConfig): string { const match = url.match(/\/-\/issues\/(\d+)/); if (match) return `#${match[1]}`; const parts = url.split("/"); const lastPart = parts[parts.length - 1]; return lastPart ? `#${lastPart}` : url; }, branchName(identifier: string, _project: ProjectConfig): string { return `feat/issue-${identifier.replace(/^#/, "")}`; }, async generatePrompt(identifier: string, project: ProjectConfig): Promise { const issue = await this.getIssue(identifier, project); const lines = [ `You are working on GitLab issue #${issue.id}: ${issue.title}`, `Issue URL: ${issue.url}`, "", ]; if (issue.labels.length > 0) { lines.push(`Labels: ${issue.labels.join(", ")}`); } if (issue.description) { lines.push("## Description", "", issue.description); } lines.push( "", "Please implement the changes described in this issue. When done, commit and push your changes.", ); return lines.join("\n"); }, async listIssues(filters: IssueFilters, project: ProjectConfig): Promise { const args = [ "issue", "list", "--repo", requireRepo(project), "-O", "json", "-P", String(filters.limit ?? 30), ]; if (filters.state === "closed") { args.push("--closed"); } else if (filters.state === "all") { args.push("--all"); } if (filters.labels && filters.labels.length > 0) { for (const label of filters.labels) { args.push("--label", label); } } if (filters.assignee) { args.push("--assignee", filters.assignee); } const raw = await glab(args, hostname); const issues = parseJSON(raw, "listIssues"); return issues.map(toIssue); }, async updateIssue( identifier: string, update: IssueUpdate, project: ProjectConfig, ): Promise { const repo = requireRepo(project); if (update.state === "closed") { await glab(["issue", "close", identifier, "--repo", repo], hostname); } else if (update.state === "open") { await glab(["issue", "reopen", identifier, "--repo", repo], hostname); } if (update.labels && update.labels.length > 0) { await glab( ["issue", "update", identifier, "--repo", repo, "--label", update.labels.join(",")], hostname, ); } if (update.comment) { await glab( ["issue", "note", identifier, "--repo", repo, "-m", update.comment], hostname, ); } }, async createIssue(input: CreateIssueInput, project: ProjectConfig): Promise { const args = [ "issue", "create", "--repo", requireRepo(project), "--title", input.title, "--description", input.description ?? "", ]; if (input.labels && input.labels.length > 0) { args.push("--label", input.labels.join(",")); } if (input.assignee) { args.push("--assignee", input.assignee); } const url = await glab(args, hostname); const match = url.match(/\/-\/issues\/(\d+)/); if (!match?.[1]) { throw new Error(`Failed to parse issue URL from glab output: ${url}`); } return this.getIssue(match[1], project); }, }; } // --------------------------------------------------------------------------- // Plugin module export // --------------------------------------------------------------------------- export const manifest = { name: "gitlab", slot: "tracker" as const, description: "Tracker plugin: GitLab Issues", version: "0.1.0", }; export function create(config?: Record): Tracker { return createGitLabTracker(config); } export default { manifest, create } satisfies PluginModule;