feat: add github extension tool suite
This commit is contained in:
parent
610b8aea63
commit
39fb2467ce
3 changed files with 1474 additions and 0 deletions
207
src/resources/extensions/github/formatters.ts
Normal file
207
src/resources/extensions/github/formatters.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Formatters — produce text summaries for issues, PRs, comments, etc.
|
||||
*
|
||||
* Used by both tools (LLM context) and renderers (TUI display).
|
||||
*/
|
||||
|
||||
import type { GhIssue, GhPullRequest, GhComment, GhReview, GhLabel, GhMilestone } from "./gh-api.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diff = now - then;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
function stateIcon(state: string, draft?: boolean): string {
|
||||
if (draft) return "◇";
|
||||
switch (state) {
|
||||
case "open":
|
||||
return "●";
|
||||
case "closed":
|
||||
return "✓";
|
||||
case "merged":
|
||||
return "⊕";
|
||||
default:
|
||||
return "○";
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBody(body: string | null, maxLines = 10): string {
|
||||
if (!body) return "(no description)";
|
||||
const lines = body.split("\n");
|
||||
if (lines.length <= maxLines) return body;
|
||||
return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`;
|
||||
}
|
||||
|
||||
// ─── Issue formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
export function formatIssueOneLiner(issue: GhIssue): string {
|
||||
const icon = stateIcon(issue.state);
|
||||
const labels = issue.labels.map((l) => l.name).join(", ");
|
||||
const labelStr = labels ? ` [${labels}]` : "";
|
||||
const assignee = issue.assignees.length ? ` → ${issue.assignees.map((a) => a.login).join(", ")}` : "";
|
||||
return `${icon} #${issue.number} ${issue.title}${labelStr}${assignee} (${timeAgo(issue.updated_at)})`;
|
||||
}
|
||||
|
||||
export function formatIssueDetail(issue: GhIssue): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Issue #${issue.number}: ${issue.title}`);
|
||||
lines.push(`State: ${issue.state} | Author: @${issue.user.login} | Created: ${timeAgo(issue.created_at)} | Updated: ${timeAgo(issue.updated_at)}`);
|
||||
|
||||
if (issue.assignees.length) {
|
||||
lines.push(`Assignees: ${issue.assignees.map((a) => `@${a.login}`).join(", ")}`);
|
||||
}
|
||||
if (issue.labels.length) {
|
||||
lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
|
||||
}
|
||||
if (issue.milestone) {
|
||||
lines.push(`Milestone: ${issue.milestone.title}`);
|
||||
}
|
||||
lines.push(`Comments: ${issue.comments}`);
|
||||
lines.push(`URL: ${issue.html_url}`);
|
||||
lines.push("");
|
||||
lines.push(truncateBody(issue.body, 30));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatIssueList(issues: GhIssue[]): string {
|
||||
if (!issues.length) return "No issues found.";
|
||||
return issues.map(formatIssueOneLiner).join("\n");
|
||||
}
|
||||
|
||||
// ─── PR formatting ────────────────────────────────────────────────────────────
|
||||
|
||||
export function formatPROneLiner(pr: GhPullRequest): string {
|
||||
const icon = stateIcon(pr.merged_at ? "merged" : pr.state, pr.draft);
|
||||
const labels = pr.labels.map((l) => l.name).join(", ");
|
||||
const labelStr = labels ? ` [${labels}]` : "";
|
||||
const draftStr = pr.draft ? " (draft)" : "";
|
||||
const reviewers = pr.requested_reviewers.map((r) => r.login).join(", ");
|
||||
const reviewerStr = reviewers ? ` ⟵ ${reviewers}` : "";
|
||||
return `${icon} #${pr.number} ${pr.title}${draftStr}${labelStr}${reviewerStr} (${timeAgo(pr.updated_at)})`;
|
||||
}
|
||||
|
||||
export function formatPRDetail(pr: GhPullRequest): string {
|
||||
const lines: string[] = [];
|
||||
const mergedState = pr.merged_at ? "merged" : pr.state;
|
||||
lines.push(`# PR #${pr.number}: ${pr.title}`);
|
||||
lines.push(`State: ${mergedState}${pr.draft ? " (draft)" : ""} | Author: @${pr.user.login} | Created: ${timeAgo(pr.created_at)} | Updated: ${timeAgo(pr.updated_at)}`);
|
||||
lines.push(`Branch: ${pr.head.ref} → ${pr.base.ref}`);
|
||||
|
||||
if (pr.assignees.length) {
|
||||
lines.push(`Assignees: ${pr.assignees.map((a) => `@${a.login}`).join(", ")}`);
|
||||
}
|
||||
if (pr.labels.length) {
|
||||
lines.push(`Labels: ${pr.labels.map((l) => l.name).join(", ")}`);
|
||||
}
|
||||
if (pr.milestone) {
|
||||
lines.push(`Milestone: ${pr.milestone.title}`);
|
||||
}
|
||||
if (pr.requested_reviewers.length) {
|
||||
lines.push(`Reviewers: ${pr.requested_reviewers.map((r) => `@${r.login}`).join(", ")}`);
|
||||
}
|
||||
|
||||
lines.push(`Mergeable: ${pr.mergeable === null ? "checking..." : pr.mergeable ? "yes" : "no"} (${pr.mergeable_state})`);
|
||||
lines.push(`Comments: ${pr.comments} | Review comments: ${pr.review_comments}`);
|
||||
lines.push(`URL: ${pr.html_url}`);
|
||||
lines.push("");
|
||||
lines.push(truncateBody(pr.body, 30));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatPRList(prs: GhPullRequest[]): string {
|
||||
if (!prs.length) return "No pull requests found.";
|
||||
return prs.map(formatPROneLiner).join("\n");
|
||||
}
|
||||
|
||||
// ─── Comment formatting ──────────────────────────────────────────────────────
|
||||
|
||||
export function formatComment(comment: GhComment): string {
|
||||
return `@${comment.user.login} (${timeAgo(comment.created_at)}):\n${truncateBody(comment.body, 8)}`;
|
||||
}
|
||||
|
||||
export function formatCommentList(comments: GhComment[]): string {
|
||||
if (!comments.length) return "No comments.";
|
||||
return comments.map(formatComment).join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
// ─── Review formatting ───────────────────────────────────────────────────────
|
||||
|
||||
function reviewStateIcon(state: string): string {
|
||||
switch (state) {
|
||||
case "APPROVED":
|
||||
return "✓";
|
||||
case "CHANGES_REQUESTED":
|
||||
return "✗";
|
||||
case "COMMENTED":
|
||||
return "💬";
|
||||
case "DISMISSED":
|
||||
return "—";
|
||||
case "PENDING":
|
||||
return "…";
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReview(review: GhReview): string {
|
||||
const icon = reviewStateIcon(review.state);
|
||||
const body = review.body ? `\n${truncateBody(review.body, 5)}` : "";
|
||||
return `${icon} @${review.user.login}: ${review.state} (${timeAgo(review.submitted_at)})${body}`;
|
||||
}
|
||||
|
||||
export function formatReviewList(reviews: GhReview[]): string {
|
||||
if (!reviews.length) return "No reviews.";
|
||||
return reviews.map(formatReview).join("\n\n");
|
||||
}
|
||||
|
||||
// ─── Label / Milestone formatting ─────────────────────────────────────────────
|
||||
|
||||
export function formatLabel(label: GhLabel): string {
|
||||
const desc = label.description ? ` — ${label.description}` : "";
|
||||
return `• ${label.name} (#${label.color})${desc}`;
|
||||
}
|
||||
|
||||
export function formatLabelList(labels: GhLabel[]): string {
|
||||
if (!labels.length) return "No labels.";
|
||||
return labels.map(formatLabel).join("\n");
|
||||
}
|
||||
|
||||
export function formatMilestone(ms: GhMilestone): string {
|
||||
const progress = ms.open_issues + ms.closed_issues > 0 ? Math.round((ms.closed_issues / (ms.open_issues + ms.closed_issues)) * 100) : 0;
|
||||
const due = ms.due_on ? ` | Due: ${new Date(ms.due_on).toISOString().split("T")[0]}` : "";
|
||||
return `• ${ms.title} (${ms.state}) — ${progress}% complete (${ms.closed_issues}/${ms.open_issues + ms.closed_issues})${due}`;
|
||||
}
|
||||
|
||||
export function formatMilestoneList(milestones: GhMilestone[]): string {
|
||||
if (!milestones.length) return "No milestones.";
|
||||
return milestones.map(formatMilestone).join("\n");
|
||||
}
|
||||
|
||||
// ─── File change formatting ───────────────────────────────────────────────────
|
||||
|
||||
export function formatFileChanges(
|
||||
files: { filename: string; status: string; additions: number; deletions: number; changes: number }[],
|
||||
): string {
|
||||
if (!files.length) return "No files changed.";
|
||||
const lines = files.map((f) => {
|
||||
const statusIcon = f.status === "added" ? "+" : f.status === "removed" ? "-" : "~";
|
||||
return `${statusIcon} ${f.filename} (+${f.additions} -${f.deletions})`;
|
||||
});
|
||||
const totalAdd = files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDel = files.reduce((s, f) => s + f.deletions, 0);
|
||||
lines.push(`\n${files.length} files changed, +${totalAdd} -${totalDel}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
537
src/resources/extensions/github/gh-api.ts
Normal file
537
src/resources/extensions/github/gh-api.ts
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
/**
|
||||
* GitHub API layer — wraps `gh` CLI with fallback to GITHUB_TOKEN + fetch.
|
||||
*
|
||||
* All GitHub communication goes through this module.
|
||||
* Prefers `gh api` when the CLI is available and authenticated.
|
||||
* Falls back to raw REST API with GITHUB_TOKEN env var.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// ─── Auth detection ───────────────────────────────────────────────────────────
|
||||
|
||||
let _useGhCli: boolean | null = null;
|
||||
|
||||
function hasGhCli(): boolean {
|
||||
if (_useGhCli !== null) return _useGhCli;
|
||||
try {
|
||||
execSync("gh auth status", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
||||
_useGhCli = true;
|
||||
} catch {
|
||||
_useGhCli = false;
|
||||
}
|
||||
return _useGhCli;
|
||||
}
|
||||
|
||||
function getToken(): string | undefined {
|
||||
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return hasGhCli() || !!getToken();
|
||||
}
|
||||
|
||||
export function authMethod(): string {
|
||||
if (hasGhCli()) return "gh CLI";
|
||||
if (getToken()) return "GITHUB_TOKEN";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// ─── Repo detection ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface RepoInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
export function detectRepo(cwd: string): RepoInfo | null {
|
||||
try {
|
||||
const remote = execSync("git remote get-url origin", {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
// Handle SSH: git@github.com:owner/repo.git
|
||||
// Handle HTTPS: https://github.com/owner/repo.git
|
||||
const sshMatch = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
||||
if (sshMatch) {
|
||||
return { owner: sshMatch[1], repo: sshMatch[2], fullName: `${sshMatch[1]}/${sshMatch[2]}` };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentBranch(cwd: string): string | null {
|
||||
try {
|
||||
return execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultBranch(cwd: string): string {
|
||||
try {
|
||||
const result = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo refs/remotes/origin/main", {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
return result.replace("refs/remotes/origin/", "");
|
||||
} catch {
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API calls ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call the GitHub REST API. Returns parsed JSON.
|
||||
*
|
||||
* When method is GET and params are provided, they're appended as query params.
|
||||
* When method is POST/PUT/PATCH/DELETE, params are sent as JSON body.
|
||||
*/
|
||||
export async function ghApi<T = unknown>(
|
||||
endpoint: string,
|
||||
options: {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
params?: Record<string, string | number | boolean | string[] | undefined>;
|
||||
body?: Record<string, unknown>;
|
||||
cwd?: string;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const method = options.method ?? "GET";
|
||||
|
||||
if (hasGhCli()) {
|
||||
return ghCliApi<T>(endpoint, method, options.params, options.body, options.cwd);
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) throw new Error("Not authenticated. Install gh CLI or set GITHUB_TOKEN.");
|
||||
|
||||
return fetchApi<T>(endpoint, method, options.params, options.body, token);
|
||||
}
|
||||
|
||||
function shellEscape(s: string): string {
|
||||
// Single-quote wrapping, escaping any existing single quotes
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function ghCliApi<T>(
|
||||
endpoint: string,
|
||||
method: string,
|
||||
params?: Record<string, string | number | boolean | string[] | undefined>,
|
||||
body?: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
): T {
|
||||
const parts = ["gh", "api", shellEscape(endpoint), "--method", method];
|
||||
|
||||
if (params) {
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val === undefined) continue;
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) {
|
||||
parts.push("-f", shellEscape(`${key}[]=${v}`));
|
||||
}
|
||||
} else {
|
||||
parts.push("-f", shellEscape(`${key}=${String(val)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (body) {
|
||||
parts.push("--input", "-");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync(parts.join(" "), {
|
||||
cwd: cwd ?? process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
input: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!result.trim()) return {} as T;
|
||||
return JSON.parse(result) as T;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stderr?: string; stdout?: string; message?: string };
|
||||
const msg = err.stderr?.trim() || err.stdout?.trim() || err.message || String(e);
|
||||
throw new Error(`gh api error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
method: string,
|
||||
params?: Record<string, string | number | boolean | string[] | undefined>,
|
||||
body?: Record<string, unknown>,
|
||||
token?: string,
|
||||
): Promise<T> {
|
||||
let url = endpoint.startsWith("http") ? endpoint : `https://api.github.com${endpoint}`;
|
||||
|
||||
if (method === "GET" && params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val === undefined) continue;
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) qs.append(key, v);
|
||||
} else {
|
||||
qs.set(key, String(val));
|
||||
}
|
||||
}
|
||||
const qsStr = qs.toString();
|
||||
if (qsStr) url += `?${qsStr}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== "GET" && body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`GitHub API ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text.trim()) return {} as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
// ─── Typed API wrappers ───────────────────────────────────────────────────────
|
||||
|
||||
export interface GhIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
body: string | null;
|
||||
user: { login: string };
|
||||
labels: { name: string; color: string }[];
|
||||
assignees: { login: string }[];
|
||||
milestone: { title: string; number: number } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
comments: number;
|
||||
html_url: string;
|
||||
pull_request?: { url: string };
|
||||
}
|
||||
|
||||
export interface GhPullRequest {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
body: string | null;
|
||||
user: { login: string };
|
||||
labels: { name: string; color: string }[];
|
||||
assignees: { login: string }[];
|
||||
milestone: { title: string; number: number } | null;
|
||||
head: { ref: string; sha: string };
|
||||
base: { ref: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
merged_at: string | null;
|
||||
closed_at: string | null;
|
||||
comments: number;
|
||||
review_comments: number;
|
||||
draft: boolean;
|
||||
mergeable: boolean | null;
|
||||
mergeable_state: string;
|
||||
html_url: string;
|
||||
diff_url: string;
|
||||
requested_reviewers: { login: string }[];
|
||||
}
|
||||
|
||||
export interface GhComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GhLabel {
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface GhMilestone {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
state: string;
|
||||
open_issues: number;
|
||||
closed_issues: number;
|
||||
due_on: string | null;
|
||||
}
|
||||
|
||||
export interface GhReview {
|
||||
id: number;
|
||||
user: { login: string };
|
||||
state: string;
|
||||
body: string | null;
|
||||
submitted_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GhCheckRun {
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
// ─── Issues ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listIssues(
|
||||
repo: RepoInfo,
|
||||
options: {
|
||||
state?: "open" | "closed" | "all";
|
||||
labels?: string;
|
||||
assignee?: string;
|
||||
milestone?: string;
|
||||
sort?: "created" | "updated" | "comments";
|
||||
direction?: "asc" | "desc";
|
||||
per_page?: number;
|
||||
page?: number;
|
||||
} = {},
|
||||
): Promise<GhIssue[]> {
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
state: options.state ?? "open",
|
||||
sort: options.sort ?? "updated",
|
||||
direction: options.direction ?? "desc",
|
||||
per_page: String(options.per_page ?? 30),
|
||||
page: String(options.page ?? 1),
|
||||
};
|
||||
if (options.labels) params.labels = options.labels;
|
||||
if (options.assignee) params.assignee = options.assignee;
|
||||
if (options.milestone) params.milestone = options.milestone;
|
||||
|
||||
const issues = await ghApi<GhIssue[]>(`/repos/${repo.fullName}/issues`, { params });
|
||||
// Filter out PRs (GitHub API returns PRs in issues endpoint)
|
||||
return issues.filter((i) => !i.pull_request);
|
||||
}
|
||||
|
||||
export async function getIssue(repo: RepoInfo, number: number): Promise<GhIssue> {
|
||||
return ghApi<GhIssue>(`/repos/${repo.fullName}/issues/${number}`);
|
||||
}
|
||||
|
||||
export async function createIssue(
|
||||
repo: RepoInfo,
|
||||
data: { title: string; body?: string; labels?: string[]; assignees?: string[]; milestone?: number },
|
||||
): Promise<GhIssue> {
|
||||
return ghApi<GhIssue>(`/repos/${repo.fullName}/issues`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIssue(
|
||||
repo: RepoInfo,
|
||||
number: number,
|
||||
data: { title?: string; body?: string; state?: string; labels?: string[]; assignees?: string[]; milestone?: number | null },
|
||||
): Promise<GhIssue> {
|
||||
return ghApi<GhIssue>(`/repos/${repo.fullName}/issues/${number}`, {
|
||||
method: "PATCH",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function addComment(repo: RepoInfo, number: number, body: string): Promise<GhComment> {
|
||||
return ghApi<GhComment>(`/repos/${repo.fullName}/issues/${number}/comments`, {
|
||||
method: "POST",
|
||||
body: { body },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listComments(repo: RepoInfo, number: number): Promise<GhComment[]> {
|
||||
return ghApi<GhComment[]>(`/repos/${repo.fullName}/issues/${number}/comments`);
|
||||
}
|
||||
|
||||
// ─── Pull Requests ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listPullRequests(
|
||||
repo: RepoInfo,
|
||||
options: {
|
||||
state?: "open" | "closed" | "all";
|
||||
sort?: "created" | "updated" | "popularity" | "long-running";
|
||||
direction?: "asc" | "desc";
|
||||
per_page?: number;
|
||||
page?: number;
|
||||
head?: string;
|
||||
base?: string;
|
||||
} = {},
|
||||
): Promise<GhPullRequest[]> {
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
state: options.state ?? "open",
|
||||
sort: options.sort ?? "updated",
|
||||
direction: options.direction ?? "desc",
|
||||
per_page: String(options.per_page ?? 30),
|
||||
page: String(options.page ?? 1),
|
||||
};
|
||||
if (options.head) params.head = options.head;
|
||||
if (options.base) params.base = options.base;
|
||||
|
||||
return ghApi<GhPullRequest[]>(`/repos/${repo.fullName}/pulls`, { params });
|
||||
}
|
||||
|
||||
export async function getPullRequest(repo: RepoInfo, number: number): Promise<GhPullRequest> {
|
||||
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls/${number}`);
|
||||
}
|
||||
|
||||
export async function createPullRequest(
|
||||
repo: RepoInfo,
|
||||
data: { title: string; body?: string; head: string; base: string; draft?: boolean },
|
||||
): Promise<GhPullRequest> {
|
||||
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePullRequest(
|
||||
repo: RepoInfo,
|
||||
number: number,
|
||||
data: { title?: string; body?: string; state?: string; base?: string },
|
||||
): Promise<GhPullRequest> {
|
||||
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls/${number}`, {
|
||||
method: "PATCH",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPullRequestDiff(repo: RepoInfo, number: number): Promise<string> {
|
||||
if (hasGhCli()) {
|
||||
try {
|
||||
return execSync(`gh pr diff ${number} --repo ${repo.fullName}`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stderr?: string; message?: string };
|
||||
throw new Error(err.stderr?.trim() || err.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github.v3.diff",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${repo.fullName}/pulls/${number}`, { headers });
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function listPullRequestFiles(
|
||||
repo: RepoInfo,
|
||||
number: number,
|
||||
): Promise<{ filename: string; status: string; additions: number; deletions: number; changes: number }[]> {
|
||||
return ghApi(`/repos/${repo.fullName}/pulls/${number}/files`);
|
||||
}
|
||||
|
||||
// ─── Reviews ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listReviews(repo: RepoInfo, number: number): Promise<GhReview[]> {
|
||||
return ghApi<GhReview[]>(`/repos/${repo.fullName}/pulls/${number}/reviews`);
|
||||
}
|
||||
|
||||
export async function createReview(
|
||||
repo: RepoInfo,
|
||||
number: number,
|
||||
data: { body?: string; event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT" },
|
||||
): Promise<GhReview> {
|
||||
return ghApi<GhReview>(`/repos/${repo.fullName}/pulls/${number}/reviews`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestReviewers(
|
||||
repo: RepoInfo,
|
||||
number: number,
|
||||
reviewers: string[],
|
||||
): Promise<GhPullRequest> {
|
||||
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls/${number}/requested_reviewers`, {
|
||||
method: "POST",
|
||||
body: { reviewers },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Checks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listCheckRuns(repo: RepoInfo, ref: string): Promise<{ check_runs: GhCheckRun[] }> {
|
||||
return ghApi(`/repos/${repo.fullName}/commits/${ref}/check-runs`);
|
||||
}
|
||||
|
||||
// ─── Labels & Milestones ──────────────────────────────────────────────────────
|
||||
|
||||
export async function listLabels(repo: RepoInfo): Promise<GhLabel[]> {
|
||||
return ghApi<GhLabel[]>(`/repos/${repo.fullName}/labels`, {
|
||||
params: { per_page: "100" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLabel(
|
||||
repo: RepoInfo,
|
||||
data: { name: string; color: string; description?: string },
|
||||
): Promise<GhLabel> {
|
||||
return ghApi<GhLabel>(`/repos/${repo.fullName}/labels`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMilestones(repo: RepoInfo): Promise<GhMilestone[]> {
|
||||
return ghApi<GhMilestone[]>(`/repos/${repo.fullName}/milestones`, {
|
||||
params: { state: "all", per_page: "100" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMilestone(
|
||||
repo: RepoInfo,
|
||||
data: { title: string; description?: string; due_on?: string },
|
||||
): Promise<GhMilestone> {
|
||||
return ghApi<GhMilestone>(`/repos/${repo.fullName}/milestones`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GhSearchResult<T> {
|
||||
total_count: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export async function searchIssues(
|
||||
query: string,
|
||||
options: { per_page?: number; page?: number } = {},
|
||||
): Promise<GhSearchResult<GhIssue>> {
|
||||
return ghApi<GhSearchResult<GhIssue>>("/search/issues", {
|
||||
params: {
|
||||
q: query,
|
||||
per_page: String(options.per_page ?? 30),
|
||||
page: String(options.page ?? 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
730
src/resources/extensions/github/index.ts
Normal file
730
src/resources/extensions/github/index.ts
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
/**
|
||||
* GitHub Extension — /gh
|
||||
*
|
||||
* Full-suite GitHub issues and PR tracker/helper for pi.
|
||||
* Provides LLM tools + /gh slash command for managing issues, PRs,
|
||||
* reviews, labels, milestones, and comments.
|
||||
*
|
||||
* Auth: gh CLI (preferred) → GITHUB_TOKEN env var (fallback)
|
||||
*
|
||||
* Tools:
|
||||
* github_issues — list, view, create, update, close, search issues
|
||||
* github_prs — list, view, create, update, diff, files, checks for PRs
|
||||
* github_comments — list, add comments on issues/PRs
|
||||
* github_reviews — list, create reviews, request reviewers
|
||||
* github_labels — list, create labels; list, create milestones
|
||||
*
|
||||
* Commands:
|
||||
* /gh issues [state] — browse issues
|
||||
* /gh prs [state] — browse PRs
|
||||
* /gh view <number> — view issue or PR detail
|
||||
* /gh create issue — create issue interactively
|
||||
* /gh create pr — create PR from current branch
|
||||
* /gh labels — list labels
|
||||
* /gh milestones — list milestones
|
||||
* /gh status — show auth + repo status
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import {
|
||||
isAuthenticated,
|
||||
authMethod,
|
||||
detectRepo,
|
||||
getCurrentBranch,
|
||||
getDefaultBranch,
|
||||
type RepoInfo,
|
||||
listIssues,
|
||||
getIssue,
|
||||
createIssue,
|
||||
updateIssue,
|
||||
addComment,
|
||||
listComments,
|
||||
listPullRequests,
|
||||
getPullRequest,
|
||||
createPullRequest,
|
||||
updatePullRequest,
|
||||
getPullRequestDiff,
|
||||
listPullRequestFiles,
|
||||
listReviews,
|
||||
createReview,
|
||||
requestReviewers,
|
||||
listCheckRuns,
|
||||
listLabels,
|
||||
createLabel,
|
||||
listMilestones,
|
||||
createMilestone,
|
||||
searchIssues,
|
||||
} from "./gh-api.js";
|
||||
|
||||
import {
|
||||
formatIssueList,
|
||||
formatIssueDetail,
|
||||
formatPRList,
|
||||
formatPRDetail,
|
||||
formatCommentList,
|
||||
formatReviewList,
|
||||
formatFileChanges,
|
||||
formatLabelList,
|
||||
formatMilestoneList,
|
||||
} from "./formatters.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function requireRepo(cwd: string): RepoInfo {
|
||||
const repo = detectRepo(cwd);
|
||||
if (!repo) throw new Error("Not in a GitHub repository. Run this from a git repo with a GitHub remote.");
|
||||
return repo;
|
||||
}
|
||||
|
||||
function requireAuth(): void {
|
||||
if (!isAuthenticated()) {
|
||||
throw new Error("Not authenticated to GitHub. Install and authenticate `gh` CLI, or set GITHUB_TOKEN env var.");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateOutput(text: string): string {
|
||||
const result = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
||||
if (result.truncated) {
|
||||
return result.content + `\n\n[Output truncated: showing ${result.outputLines}/${result.totalLines} lines]`;
|
||||
}
|
||||
return result.content;
|
||||
}
|
||||
|
||||
function textResult(text: string, details?: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: truncateOutput(text) }],
|
||||
...(details ? { details } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Extension ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// ─── Tool: github_issues ────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "github_issues",
|
||||
label: "GitHub Issues",
|
||||
description: "Manage GitHub issues: list, view, create, update, close, reopen, or search issues in the current repository.",
|
||||
promptSnippet: "List, view, create, update, close, reopen, or search GitHub issues",
|
||||
promptGuidelines: [
|
||||
"Use github_issues to interact with GitHub issues instead of running `gh` CLI commands directly.",
|
||||
"When listing issues, default to state='open' and include relevant filters like labels or assignee.",
|
||||
"When searching, use GitHub search syntax in the query (e.g., 'is:open label:bug').",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "view", "create", "update", "close", "reopen", "search"] as const),
|
||||
number: Type.Optional(Type.Number({ description: "Issue number (for view/update/close/reopen)" })),
|
||||
title: Type.Optional(Type.String({ description: "Issue title (for create)" })),
|
||||
body: Type.Optional(Type.String({ description: "Issue body (for create/update)" })),
|
||||
labels: Type.Optional(Type.String({ description: "Comma-separated labels (for list filter or create/update)" })),
|
||||
assignee: Type.Optional(Type.String({ description: "Assignee username (for list filter or create/update)" })),
|
||||
assignees: Type.Optional(Type.String({ description: "Comma-separated assignees (for create/update)" })),
|
||||
milestone: Type.Optional(Type.String({ description: "Milestone number or title (for list filter)" })),
|
||||
state: Type.Optional(StringEnum(["open", "closed", "all"] as const)),
|
||||
query: Type.Optional(Type.String({ description: "Search query using GitHub search syntax (for search action)" })),
|
||||
per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
requireAuth();
|
||||
const repo = requireRepo(ctx.cwd);
|
||||
|
||||
switch (params.action) {
|
||||
case "list": {
|
||||
const issues = await listIssues(repo, {
|
||||
state: params.state,
|
||||
labels: params.labels,
|
||||
assignee: params.assignee,
|
||||
milestone: params.milestone,
|
||||
per_page: params.per_page,
|
||||
});
|
||||
return textResult(
|
||||
`Issues in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatIssueList(issues)}`,
|
||||
{ issues: issues.map((i) => ({ number: i.number, title: i.title, state: i.state })) },
|
||||
);
|
||||
}
|
||||
case "view": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for view action.");
|
||||
const issue = await getIssue(repo, params.number);
|
||||
const comments = await listComments(repo, params.number);
|
||||
let text = formatIssueDetail(issue);
|
||||
if (comments.length) {
|
||||
text += `\n\n## Comments (${comments.length})\n\n${formatCommentList(comments)}`;
|
||||
}
|
||||
return textResult(text, { issue: { number: issue.number, title: issue.title, state: issue.state } });
|
||||
}
|
||||
case "create": {
|
||||
if (!params.title) return textResult("Error: 'title' is required for create action.");
|
||||
const newIssue = await createIssue(repo, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
labels: params.labels?.split(",").map((l) => l.trim()),
|
||||
assignees: params.assignees?.split(",").map((a) => a.trim()),
|
||||
});
|
||||
return textResult(
|
||||
`Created issue #${newIssue.number}: ${newIssue.title}\n${newIssue.html_url}`,
|
||||
{ issue: { number: newIssue.number, title: newIssue.title } },
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for update action.");
|
||||
const updated = await updateIssue(repo, params.number, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
labels: params.labels?.split(",").map((l) => l.trim()),
|
||||
assignees: params.assignees?.split(",").map((a) => a.trim()),
|
||||
});
|
||||
return textResult(
|
||||
`Updated issue #${updated.number}: ${updated.title}\n${updated.html_url}`,
|
||||
{ issue: { number: updated.number, title: updated.title } },
|
||||
);
|
||||
}
|
||||
case "close": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for close action.");
|
||||
const closed = await updateIssue(repo, params.number, { state: "closed" });
|
||||
return textResult(`Closed issue #${closed.number}: ${closed.title}`, { issue: { number: closed.number } });
|
||||
}
|
||||
case "reopen": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for reopen action.");
|
||||
const reopened = await updateIssue(repo, params.number, { state: "open" });
|
||||
return textResult(`Reopened issue #${reopened.number}: ${reopened.title}`, { issue: { number: reopened.number } });
|
||||
}
|
||||
case "search": {
|
||||
if (!params.query) return textResult("Error: 'query' is required for search action.");
|
||||
const q = `repo:${repo.fullName} ${params.query}`;
|
||||
const results = await searchIssues(q, { per_page: params.per_page });
|
||||
const issuesOnly = results.items.filter((i) => !i.pull_request);
|
||||
return textResult(
|
||||
`Search results (${results.total_count} total, showing ${issuesOnly.length}):\n\n${formatIssueList(issuesOnly)}`,
|
||||
{ total: results.total_count },
|
||||
);
|
||||
}
|
||||
default:
|
||||
return textResult(`Unknown action: ${params.action}`);
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("github_issues "));
|
||||
text += theme.fg("muted", `${args.action ?? "?"}`);
|
||||
if (args.number) text += theme.fg("accent", ` #${args.number}`);
|
||||
if (args.title) text += theme.fg("dim", ` "${args.title}"`);
|
||||
if (args.query) text += theme.fg("dim", ` "${args.query}"`);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0);
|
||||
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
||||
if (!expanded) {
|
||||
const firstLine = content.split("\n")[0] ?? "";
|
||||
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
|
||||
}
|
||||
return new Text(content, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Tool: github_prs ───────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "github_prs",
|
||||
label: "GitHub PRs",
|
||||
description: "Manage GitHub pull requests: list, view, create, update, get diff, list files, and check CI status.",
|
||||
promptSnippet: "List, view, create, update, diff, files, and checks for GitHub pull requests",
|
||||
promptGuidelines: [
|
||||
"Use github_prs to interact with GitHub pull requests instead of running `gh` CLI commands directly.",
|
||||
"Use action='diff' to see the actual code changes in a PR.",
|
||||
"Use action='files' for a summary of changed files without the full diff.",
|
||||
"Use action='checks' to see CI/CD status for a PR.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "view", "create", "update", "diff", "files", "checks"] as const),
|
||||
number: Type.Optional(Type.Number({ description: "PR number (for view/update/diff/files/checks)" })),
|
||||
title: Type.Optional(Type.String({ description: "PR title (for create)" })),
|
||||
body: Type.Optional(Type.String({ description: "PR body (for create/update)" })),
|
||||
head: Type.Optional(Type.String({ description: "Head branch (for create, defaults to current branch)" })),
|
||||
base: Type.Optional(Type.String({ description: "Base branch (for create, defaults to repo default branch)" })),
|
||||
draft: Type.Optional(Type.Boolean({ description: "Create as draft PR (for create)" })),
|
||||
state: Type.Optional(StringEnum(["open", "closed", "all"] as const)),
|
||||
per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
requireAuth();
|
||||
const repo = requireRepo(ctx.cwd);
|
||||
|
||||
switch (params.action) {
|
||||
case "list": {
|
||||
const prs = await listPullRequests(repo, {
|
||||
state: params.state,
|
||||
per_page: params.per_page,
|
||||
});
|
||||
return textResult(
|
||||
`Pull requests in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatPRList(prs)}`,
|
||||
{ prs: prs.map((p) => ({ number: p.number, title: p.title, state: p.state, draft: p.draft })) },
|
||||
);
|
||||
}
|
||||
case "view": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for view action.");
|
||||
const pr = await getPullRequest(repo, params.number);
|
||||
const reviews = await listReviews(repo, params.number);
|
||||
let text = formatPRDetail(pr);
|
||||
if (reviews.length) {
|
||||
text += `\n\n## Reviews (${reviews.length})\n\n${formatReviewList(reviews)}`;
|
||||
}
|
||||
return textResult(text, { pr: { number: pr.number, title: pr.title, state: pr.state } });
|
||||
}
|
||||
case "create": {
|
||||
if (!params.title) return textResult("Error: 'title' is required for create action.");
|
||||
const head = params.head ?? getCurrentBranch(ctx.cwd);
|
||||
if (!head) return textResult("Error: Could not determine current branch. Provide 'head' parameter.");
|
||||
const base = params.base ?? getDefaultBranch(ctx.cwd);
|
||||
const newPR = await createPullRequest(repo, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
head,
|
||||
base,
|
||||
draft: params.draft,
|
||||
});
|
||||
return textResult(
|
||||
`Created PR #${newPR.number}: ${newPR.title}\n${newPR.head.ref} → ${newPR.base.ref}\n${newPR.html_url}`,
|
||||
{ pr: { number: newPR.number, title: newPR.title } },
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for update action.");
|
||||
const updated = await updatePullRequest(repo, params.number, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
base: params.base,
|
||||
});
|
||||
return textResult(
|
||||
`Updated PR #${updated.number}: ${updated.title}\n${updated.html_url}`,
|
||||
{ pr: { number: updated.number, title: updated.title } },
|
||||
);
|
||||
}
|
||||
case "diff": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for diff action.");
|
||||
const diff = await getPullRequestDiff(repo, params.number);
|
||||
return textResult(`Diff for PR #${params.number}:\n\n${diff}`);
|
||||
}
|
||||
case "files": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for files action.");
|
||||
const files = await listPullRequestFiles(repo, params.number);
|
||||
return textResult(
|
||||
`Changed files in PR #${params.number}:\n\n${formatFileChanges(files)}`,
|
||||
{ files: files.map((f) => ({ filename: f.filename, status: f.status })) },
|
||||
);
|
||||
}
|
||||
case "checks": {
|
||||
if (!params.number) return textResult("Error: 'number' is required for checks action.");
|
||||
const pr = await getPullRequest(repo, params.number);
|
||||
const checks = await listCheckRuns(repo, pr.head.sha);
|
||||
if (!checks.check_runs.length) {
|
||||
return textResult(`No CI checks found for PR #${params.number}.`);
|
||||
}
|
||||
const lines = checks.check_runs.map((c) => {
|
||||
const icon = c.conclusion === "success" ? "✓" : c.conclusion === "failure" ? "✗" : c.status === "in_progress" ? "⟳" : "…";
|
||||
return `${icon} ${c.name}: ${c.conclusion ?? c.status}`;
|
||||
});
|
||||
return textResult(
|
||||
`CI checks for PR #${params.number}:\n\n${lines.join("\n")}`,
|
||||
{ checks: checks.check_runs.map((c) => ({ name: c.name, conclusion: c.conclusion, status: c.status })) },
|
||||
);
|
||||
}
|
||||
default:
|
||||
return textResult(`Unknown action: ${params.action}`);
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("github_prs "));
|
||||
text += theme.fg("muted", `${args.action ?? "?"}`);
|
||||
if (args.number) text += theme.fg("accent", ` #${args.number}`);
|
||||
if (args.title) text += theme.fg("dim", ` "${args.title}"`);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0);
|
||||
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
||||
if (!expanded) {
|
||||
const firstLine = content.split("\n")[0] ?? "";
|
||||
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
|
||||
}
|
||||
return new Text(content, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Tool: github_comments ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "github_comments",
|
||||
label: "GitHub Comments",
|
||||
description: "List or add comments on GitHub issues and pull requests.",
|
||||
promptSnippet: "List or add comments on GitHub issues and PRs",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add"] as const),
|
||||
number: Type.Number({ description: "Issue or PR number" }),
|
||||
body: Type.Optional(Type.String({ description: "Comment body text (for add)" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
requireAuth();
|
||||
const repo = requireRepo(ctx.cwd);
|
||||
|
||||
switch (params.action) {
|
||||
case "list": {
|
||||
const comments = await listComments(repo, params.number);
|
||||
return textResult(
|
||||
`Comments on #${params.number} (${comments.length}):\n\n${formatCommentList(comments)}`,
|
||||
{ count: comments.length },
|
||||
);
|
||||
}
|
||||
case "add": {
|
||||
if (!params.body) return textResult("Error: 'body' is required for add action.");
|
||||
const comment = await addComment(repo, params.number, params.body);
|
||||
return textResult(
|
||||
`Added comment on #${params.number}: ${comment.html_url}`,
|
||||
{ comment: { id: comment.id } },
|
||||
);
|
||||
}
|
||||
default:
|
||||
return textResult(`Unknown action: ${params.action}`);
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("github_comments "));
|
||||
text += theme.fg("muted", `${args.action ?? "?"}`);
|
||||
text += theme.fg("accent", ` #${args.number ?? "?"}`);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0);
|
||||
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
||||
if (!expanded) {
|
||||
const firstLine = content.split("\n")[0] ?? "";
|
||||
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
|
||||
}
|
||||
return new Text(content, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Tool: github_reviews ───────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "github_reviews",
|
||||
label: "GitHub Reviews",
|
||||
description: "Manage GitHub PR reviews: list reviews, submit a review (approve/request changes/comment), or request reviewers.",
|
||||
promptSnippet: "List reviews, submit reviews, or request reviewers on GitHub PRs",
|
||||
promptGuidelines: [
|
||||
"Use event='APPROVE' to approve, 'REQUEST_CHANGES' to request changes, 'COMMENT' for a general review comment.",
|
||||
"Use action='request_reviewers' to assign reviewers to a PR.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "submit", "request_reviewers"] as const),
|
||||
number: Type.Number({ description: "PR number" }),
|
||||
body: Type.Optional(Type.String({ description: "Review body text (for submit)" })),
|
||||
event: Type.Optional(StringEnum(["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const)),
|
||||
reviewers: Type.Optional(Type.String({ description: "Comma-separated reviewer usernames (for request_reviewers)" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
requireAuth();
|
||||
const repo = requireRepo(ctx.cwd);
|
||||
|
||||
switch (params.action) {
|
||||
case "list": {
|
||||
const reviews = await listReviews(repo, params.number);
|
||||
return textResult(
|
||||
`Reviews on PR #${params.number} (${reviews.length}):\n\n${formatReviewList(reviews)}`,
|
||||
{ count: reviews.length },
|
||||
);
|
||||
}
|
||||
case "submit": {
|
||||
if (!params.event) return textResult("Error: 'event' is required for submit action (APPROVE, REQUEST_CHANGES, or COMMENT).");
|
||||
const review = await createReview(repo, params.number, {
|
||||
body: params.body,
|
||||
event: params.event,
|
||||
});
|
||||
return textResult(
|
||||
`Submitted review on PR #${params.number}: ${review.state}\n${review.html_url}`,
|
||||
{ review: { id: review.id, state: review.state } },
|
||||
);
|
||||
}
|
||||
case "request_reviewers": {
|
||||
if (!params.reviewers) return textResult("Error: 'reviewers' is required for request_reviewers action.");
|
||||
const reviewerList = params.reviewers.split(",").map((r) => r.trim());
|
||||
await requestReviewers(repo, params.number, reviewerList);
|
||||
return textResult(
|
||||
`Requested reviewers on PR #${params.number}: ${reviewerList.join(", ")}`,
|
||||
{ reviewers: reviewerList },
|
||||
);
|
||||
}
|
||||
default:
|
||||
return textResult(`Unknown action: ${params.action}`);
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("github_reviews "));
|
||||
text += theme.fg("muted", `${args.action ?? "?"}`);
|
||||
text += theme.fg("accent", ` #${args.number ?? "?"}`);
|
||||
if (args.event) text += theme.fg("dim", ` ${args.event}`);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Processing..."), 0, 0);
|
||||
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
||||
if (!expanded) {
|
||||
const firstLine = content.split("\n")[0] ?? "";
|
||||
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
|
||||
}
|
||||
return new Text(content, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Tool: github_labels ────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "github_labels",
|
||||
label: "GitHub Labels",
|
||||
description: "Manage GitHub labels and milestones: list/create labels, list/create milestones.",
|
||||
promptSnippet: "List or create GitHub labels and milestones",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list_labels", "create_label", "list_milestones", "create_milestone"] as const),
|
||||
name: Type.Optional(Type.String({ description: "Label or milestone name (for create)" })),
|
||||
color: Type.Optional(Type.String({ description: "Label hex color without # (for create_label, e.g. 'ff0000')" })),
|
||||
description: Type.Optional(Type.String({ description: "Description (for create)" })),
|
||||
due_on: Type.Optional(Type.String({ description: "Milestone due date ISO 8601 (for create_milestone, e.g. '2025-12-31T00:00:00Z')" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
requireAuth();
|
||||
const repo = requireRepo(ctx.cwd);
|
||||
|
||||
switch (params.action) {
|
||||
case "list_labels": {
|
||||
const labels = await listLabels(repo);
|
||||
return textResult(`Labels in ${repo.fullName}:\n\n${formatLabelList(labels)}`, { count: labels.length });
|
||||
}
|
||||
case "create_label": {
|
||||
if (!params.name) return textResult("Error: 'name' is required for create_label.");
|
||||
const label = await createLabel(repo, {
|
||||
name: params.name,
|
||||
color: params.color ?? "ededed",
|
||||
description: params.description,
|
||||
});
|
||||
return textResult(`Created label: ${label.name} (#${label.color})`, { label: { name: label.name } });
|
||||
}
|
||||
case "list_milestones": {
|
||||
const milestones = await listMilestones(repo);
|
||||
return textResult(`Milestones in ${repo.fullName}:\n\n${formatMilestoneList(milestones)}`, { count: milestones.length });
|
||||
}
|
||||
case "create_milestone": {
|
||||
if (!params.name) return textResult("Error: 'name' is required for create_milestone.");
|
||||
const ms = await createMilestone(repo, {
|
||||
title: params.name,
|
||||
description: params.description,
|
||||
due_on: params.due_on,
|
||||
});
|
||||
return textResult(`Created milestone: ${ms.title} (#${ms.number})`, { milestone: { number: ms.number, title: ms.title } });
|
||||
}
|
||||
default:
|
||||
return textResult(`Unknown action: ${params.action}`);
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("github_labels "));
|
||||
text += theme.fg("muted", `${args.action ?? "?"}`);
|
||||
if (args.name) text += theme.fg("dim", ` "${args.name}"`);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0);
|
||||
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
||||
if (!expanded) {
|
||||
const firstLine = content.split("\n")[0] ?? "";
|
||||
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
|
||||
}
|
||||
return new Text(content, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Slash command: /gh ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("gh", {
|
||||
description: "GitHub helper: /gh issues|prs|view|create|labels|milestones|status",
|
||||
|
||||
getArgumentCompletions: (prefix: string) => {
|
||||
const subcommands = ["issues", "prs", "view", "create", "labels", "milestones", "status"];
|
||||
const parts = prefix.trim().split(/\s+/);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return subcommands
|
||||
.filter((cmd) => cmd.startsWith(parts[0] ?? ""))
|
||||
.map((cmd) => ({ value: cmd, label: cmd }));
|
||||
}
|
||||
|
||||
if (parts[0] === "issues" || parts[0] === "prs") {
|
||||
const states = ["open", "closed", "all"];
|
||||
const statePrefix = parts[1] ?? "";
|
||||
return states
|
||||
.filter((s) => s.startsWith(statePrefix))
|
||||
.map((s) => ({ value: `${parts[0]} ${s}`, label: s }));
|
||||
}
|
||||
|
||||
if (parts[0] === "create") {
|
||||
const types = ["issue", "pr"];
|
||||
const typePrefix = parts[1] ?? "";
|
||||
return types
|
||||
.filter((t) => t.startsWith(typePrefix))
|
||||
.map((t) => ({ value: `create ${t}`, label: t }));
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
handler: async (args, ctx) => {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const sub = parts[0];
|
||||
const rest = parts.slice(1).join(" ");
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
ctx.ui.notify("Not authenticated to GitHub. Install `gh` CLI or set GITHUB_TOKEN.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = detectRepo(ctx.cwd);
|
||||
if (!repo && sub !== "status") {
|
||||
ctx.ui.notify("Not in a GitHub repository.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (sub) {
|
||||
case "issues": {
|
||||
const state = (rest as "open" | "closed" | "all") || "open";
|
||||
const issues = await listIssues(repo!, { state });
|
||||
const display = `Issues in ${repo!.fullName} (${state}):\n\n${formatIssueList(issues)}`;
|
||||
pi.sendMessage({ customType: "github", content: display, display: true });
|
||||
break;
|
||||
}
|
||||
case "prs": {
|
||||
const state = (rest as "open" | "closed" | "all") || "open";
|
||||
const prs = await listPullRequests(repo!, { state });
|
||||
const display = `Pull requests in ${repo!.fullName} (${state}):\n\n${formatPRList(prs)}`;
|
||||
pi.sendMessage({ customType: "github", content: display, display: true });
|
||||
break;
|
||||
}
|
||||
case "view": {
|
||||
const num = parseInt(rest, 10);
|
||||
if (isNaN(num)) {
|
||||
ctx.ui.notify("Usage: /gh view <number>", "error");
|
||||
return;
|
||||
}
|
||||
// Try as issue first, then PR
|
||||
try {
|
||||
const issue = await getIssue(repo!, num);
|
||||
if (issue.pull_request) {
|
||||
// It's a PR
|
||||
const pr = await getPullRequest(repo!, num);
|
||||
const reviews = await listReviews(repo!, num);
|
||||
let text = formatPRDetail(pr);
|
||||
if (reviews.length) text += `\n\n## Reviews\n\n${formatReviewList(reviews)}`;
|
||||
pi.sendMessage({ customType: "github", content: text, display: true });
|
||||
} else {
|
||||
const comments = await listComments(repo!, num);
|
||||
let text = formatIssueDetail(issue);
|
||||
if (comments.length) text += `\n\n## Comments\n\n${formatCommentList(comments)}`;
|
||||
pi.sendMessage({ customType: "github", content: text, display: true });
|
||||
}
|
||||
} catch {
|
||||
ctx.ui.notify(`Could not find issue or PR #${num}`, "error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "create": {
|
||||
const type = parts[1];
|
||||
if (type === "issue") {
|
||||
ctx.ui.notify("Use the agent to create an issue: tell it the title, description, and labels you want.", "info");
|
||||
} else if (type === "pr") {
|
||||
const branch = getCurrentBranch(ctx.cwd);
|
||||
const base = getDefaultBranch(ctx.cwd);
|
||||
ctx.ui.notify(
|
||||
`Current branch: ${branch}\nBase: ${base}\n\nTell the agent the PR title and description to create it.`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify("Usage: /gh create issue|pr", "error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "labels": {
|
||||
const labels = await listLabels(repo!);
|
||||
pi.sendMessage({ customType: "github", content: `Labels in ${repo!.fullName}:\n\n${formatLabelList(labels)}`, display: true });
|
||||
break;
|
||||
}
|
||||
case "milestones": {
|
||||
const milestones = await listMilestones(repo!);
|
||||
pi.sendMessage({
|
||||
customType: "github",
|
||||
content: `Milestones in ${repo!.fullName}:\n\n${formatMilestoneList(milestones)}`,
|
||||
display: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
const auth = authMethod();
|
||||
const repoStr = repo ? `${repo.fullName}` : "not detected";
|
||||
const branch = repo ? getCurrentBranch(ctx.cwd) ?? "unknown" : "n/a";
|
||||
const text = `GitHub Extension Status\n\nAuth: ${auth}\nRepo: ${repoStr}\nBranch: ${branch}`;
|
||||
pi.sendMessage({ customType: "github", content: text, display: true });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ctx.ui.notify("Usage: /gh issues|prs|view|create|labels|milestones|status", "info");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
ctx.ui.notify(`GitHub error: ${msg}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Message renderer ───────────────────────────────────────────────────
|
||||
|
||||
pi.registerMessageRenderer("github", (message, _options, theme) => {
|
||||
const content = message.content ?? "";
|
||||
// Apply some light styling to the GitHub output
|
||||
const styled = content
|
||||
.replace(/^(# .+)$/gm, (m: string) => theme.fg("accent", theme.bold(m)))
|
||||
.replace(/(●)/g, theme.fg("success", "$1"))
|
||||
.replace(/(✓)/g, theme.fg("success", "$1"))
|
||||
.replace(/(✗)/g, theme.fg("error", "$1"))
|
||||
.replace(/(⊕)/g, theme.fg("accent", "$1"))
|
||||
.replace(/(◇)/g, theme.fg("dim", "$1"))
|
||||
.replace(/(https:\/\/github\.com\S+)/g, theme.fg("mdLink", "$1"));
|
||||
return new Text(styled, 0, 0);
|
||||
});
|
||||
|
||||
// ─── Session start notification ─────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const auth = authMethod();
|
||||
if (auth === "none") {
|
||||
ctx.ui.notify("GitHub extension: not authenticated. Install `gh` CLI or set GITHUB_TOKEN.", "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue