agent-orchestrator/packages/plugins/tracker-github/src/index.ts

335 lines
8.3 KiB
TypeScript

/**
* 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<string> {
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 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<Issue> {
const raw = await gh([
"issue",
"view",
identifier,
"--repo",
project.repo,
"--json",
"number,title,body,url,state,stateReason,labels,assignees",
]);
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<boolean> {
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<string> {
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<Issue[]> {
const args = [
"issue",
"list",
"--repo",
project.repo,
"--json",
"number,title,body,url,state,stateReason,labels,assignees",
"--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 gh(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<void> {
// 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 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<Issue> {
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<Tracker>;