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