/** * tracker-github plugin — GitHub Issues as an issue tracker. * * Uses the `gh` CLI for all GitHub API interactions. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { PluginModule, Tracker, Issue, IssueFilters, IssueUpdate, CreateIssueInput, ProjectConfig, } from "@composio/ao-core"; const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function gh(args: string[]): Promise { try { const { stdout } = await execFileAsync("gh", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30_000, }); return stdout.trim(); } catch (err) { throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, { cause: err, }); } } function getErrorText(err: unknown): string { if (!(err instanceof Error)) return ""; const details: string[] = [err.message]; const withIo = err as Error & { stderr?: string; stdout?: string; cause?: unknown }; if (typeof withIo.stderr === "string") details.push(withIo.stderr); if (typeof withIo.stdout === "string") details.push(withIo.stdout); if (withIo.cause instanceof Error) details.push(getErrorText(withIo.cause)); return details.join("\n").toLowerCase(); } function isUnknownJsonFieldError(err: unknown, fieldName: string): boolean { const text = getErrorText(err); if (!text) return false; const unknownFieldSignals = text.includes("unknown json field") || text.includes("unknown field") || text.includes("invalid field"); return unknownFieldSignals && text.includes(fieldName.toLowerCase()); } async function ghIssueViewJson(identifier: string, project: ProjectConfig): Promise { const fieldsWithStateReason = "number,title,body,url,state,stateReason,labels,assignees"; try { return await gh([ "issue", "view", identifier, "--repo", project.repo, "--json", fieldsWithStateReason, ]); } catch (err) { if (!isUnknownJsonFieldError(err, "stateReason")) throw err; return gh([ "issue", "view", identifier, "--repo", project.repo, "--json", "number,title,body,url,state,labels,assignees", ]); } } async function ghIssueListJson(args: string[]): Promise { const withStateReason = [ ...args, "--json", "number,title,body,url,state,stateReason,labels,assignees", ]; try { return await gh(withStateReason); } catch (err) { if (!isUnknownJsonFieldError(err, "stateReason")) throw err; return gh([...args, "--json", "number,title,body,url,state,labels,assignees"]); } } function mapState(ghState: string, stateReason?: string | null): Issue["state"] { const s = ghState.toUpperCase(); if (s === "CLOSED") { if (stateReason?.toUpperCase() === "NOT_PLANNED") return "cancelled"; return "closed"; } return "open"; } // --------------------------------------------------------------------------- // Tracker implementation // --------------------------------------------------------------------------- function createGitHubTracker(): Tracker { return { name: "github", async getIssue(identifier: string, project: ProjectConfig): Promise { const raw = await ghIssueViewJson(identifier, project); const data: { number: number; title: string; body: string; url: string; state: string; stateReason?: string | null; labels: Array<{ name: string }>; assignees: Array<{ login: string }>; } = JSON.parse(raw); return { id: String(data.number), title: data.title, description: data.body ?? "", url: data.url, state: mapState(data.state, data.stateReason), labels: data.labels.map((l) => l.name), assignee: data.assignees[0]?.login, }; }, async isCompleted(identifier: string, project: ProjectConfig): Promise { const raw = await gh([ "issue", "view", identifier, "--repo", project.repo, "--json", "state", ]); const data: { state: string } = JSON.parse(raw); return data.state.toUpperCase() === "CLOSED"; }, issueUrl(identifier: string, project: ProjectConfig): string { const num = identifier.replace(/^#/, ""); return `https://github.com/${project.repo}/issues/${num}`; }, issueLabel(url: string, _project: ProjectConfig): string { // Extract issue number from GitHub URL // Example: https://github.com/owner/repo/issues/42 → "#42" const match = url.match(/\/issues\/(\d+)/); if (match) { return `#${match[1]}`; } // Fallback: return the last segment of the URL const parts = url.split("/"); const lastPart = parts[parts.length - 1]; return lastPart ? `#${lastPart}` : url; }, branchName(identifier: string, _project: ProjectConfig): string { const num = identifier.replace(/^#/, ""); return `feat/issue-${num}`; }, async generatePrompt(identifier: string, project: ProjectConfig): Promise { const issue = await this.getIssue(identifier, project); const lines = [ `You are working on GitHub 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", project.repo, "--limit", String(filters.limit ?? 30), ]; if (filters.state === "closed") { args.push("--state", "closed"); } else if (filters.state === "all") { args.push("--state", "all"); } else { args.push("--state", "open"); } if (filters.labels && filters.labels.length > 0) { args.push("--label", filters.labels.join(",")); } if (filters.assignee) { args.push("--assignee", filters.assignee); } const raw = await ghIssueListJson(args); const issues: Array<{ number: number; title: string; body: string; url: string; state: string; stateReason?: string | null; labels: Array<{ name: string }>; assignees: Array<{ login: string }>; }> = JSON.parse(raw); return issues.map((data) => ({ id: String(data.number), title: data.title, description: data.body ?? "", url: data.url, state: mapState(data.state, data.stateReason), labels: data.labels.map((l) => l.name), assignee: data.assignees[0]?.login, })); }, async updateIssue( identifier: string, update: IssueUpdate, project: ProjectConfig, ): Promise { // Handle state change — GitHub Issues only supports open/closed. // "in_progress" is not a GitHub state, so it is intentionally a no-op. if (update.state === "closed") { await gh(["issue", "close", identifier, "--repo", project.repo]); } else if (update.state === "open") { await gh(["issue", "reopen", identifier, "--repo", project.repo]); } // Handle label removal if (update.removeLabels && update.removeLabels.length > 0) { await gh([ "issue", "edit", identifier, "--repo", project.repo, "--remove-label", update.removeLabels.join(","), ]); } // Handle label changes if (update.labels && update.labels.length > 0) { await gh([ "issue", "edit", identifier, "--repo", project.repo, "--add-label", update.labels.join(","), ]); } // Handle assignee changes if (update.assignee) { await gh([ "issue", "edit", identifier, "--repo", project.repo, "--add-assignee", update.assignee, ]); } // Handle comment if (update.comment) { await gh([ "issue", "comment", identifier, "--repo", project.repo, "--body", update.comment, ]); } }, async createIssue(input: CreateIssueInput, project: ProjectConfig): Promise { const args = [ "issue", "create", "--repo", project.repo, "--title", input.title, "--body", input.description ?? "", ]; if (input.labels && input.labels.length > 0) { args.push("--label", input.labels.join(",")); } if (input.assignee) { args.push("--assignee", input.assignee); } // gh issue create outputs the URL of the new issue const url = await gh(args); // Extract issue number from URL and fetch full details const match = url.match(/\/issues\/(\d+)/); if (!match) { throw new Error(`Failed to parse issue URL from gh output: ${url}`); } const number = match[1]; return this.getIssue(number, project); }, }; } // --------------------------------------------------------------------------- // Plugin module export // --------------------------------------------------------------------------- export const manifest = { name: "github", slot: "tracker" as const, description: "Tracker plugin: GitHub Issues", version: "0.1.0", }; export function create(): Tracker { return createGitHubTracker(); } export default { manifest, create } satisfies PluginModule;