/**
* GitHub issues/PRs via REST API — HTML pages are chrome soup and our list
* sanitizer turns them into "[Long list]".
*/
import { fetch } from "undici";
import { validateUrl, timeoutSignal } from "../utils.js";
import { sanitizeForContext } from "./markdown.js";
import type { ReadFormat } from "../types.js";
export interface GitHubIssueRef {
owner: string;
repo: string;
kind: "issues" | "pull";
number: number;
}
const GITHUB_HTML_HOSTS = new Set(["github.com", "www.github.com"]);
/** Parse github.com/{owner}/{repo}/issues|pull/{n} (optional trailing slash / fragment). */
export function parseGitHubIssueUrl(url: string): GitHubIssueRef | null {
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
if (!GITHUB_HTML_HOSTS.has(u.hostname.toLowerCase())) return null;
// /owner/repo/issues/123 or /owner/repo/pull/123
const m = u.pathname.match(
/^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/|$)/i,
);
if (!m) return null;
const owner = m[1];
const repo = m[2];
// skip reserved org-ish paths that aren't repos
if (owner === "." || owner === ".." || repo.endsWith(".git")) return null;
const kind = m[3].toLowerCase() === "pull" ? "pull" : "issues";
const number = Number(m[4]);
if (!Number.isFinite(number) || number <= 0) return null;
return { owner, repo, kind, number };
}
function githubToken(): string | undefined {
const t =
process.env.GITHUB_TOKEN?.trim() ||
process.env.GH_TOKEN?.trim() ||
process.env.GITHUB_API_TOKEN?.trim();
return t || undefined;
}
async function apiGet(
apiUrl: string,
options: { signal?: AbortSignal; timeoutMs?: number },
): Promise<{ status: number; json: unknown; finalUrl: string }> {
const ssrf = validateUrl(apiUrl);
if (ssrf) throw new Error(ssrf);
const headers: Record = {
accept: "application/vnd.github+json",
"user-agent": "pi-web-complete",
"x-github-api-version": "2022-11-28",
};
const token = githubToken();
if (token) headers.authorization = `Bearer ${token}`;
const response = await fetch(apiUrl, {
method: "GET",
headers,
redirect: "follow",
signal: timeoutSignal(options.signal, options.timeoutMs ?? 30_000),
});
const finalUrl = response.url || apiUrl;
const finalSsrf = validateUrl(finalUrl);
if (finalSsrf) throw new Error(finalSsrf);
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = { message: text.slice(0, 500) };
}
return { status: response.status, json, finalUrl };
}
function asRecord(v: unknown): Record | null {
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record)
: null;
}
function str(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : String(v);
}
function userLogin(v: unknown): string {
const r = asRecord(v);
return r ? str(r.login) : "";
}
function labelNames(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v
.map((x) => {
const r = asRecord(x);
return r ? str(r.name) : "";
})
.filter(Boolean);
}
function truncate(content: string, maxChars?: number): string {
const cleaned = sanitizeForContext(content);
if (!maxChars || maxChars <= 0 || cleaned.length <= maxChars) return cleaned;
return cleaned.slice(0, maxChars) + "\n\n…[truncated]";
}
function formatIssueMarkdown(
ref: GitHubIssueRef,
issue: Record,
comments: Record[],
pr?: Record | null,
): string {
const number = issue.number ?? ref.number;
const title = str(issue.title) || "Untitled";
const state = str(issue.state);
const htmlUrl = str(issue.html_url) || `https://github.com/${ref.owner}/${ref.repo}/${ref.kind}/${ref.number}`;
const author = userLogin(issue.user);
const created = str(issue.created_at);
const updated = str(issue.updated_at);
const labels = labelNames(issue.labels);
const body = str(issue.body).trim() || "_(no description)_";
const lines: string[] = [
`# ${ref.owner}/${ref.repo}#${number}: ${title}`,
"",
`- **Type:** ${ref.kind === "pull" ? "pull request" : "issue"}`,
`- **State:** ${state}${issue.draft === true ? " (draft)" : ""}${issue.merged === true || pr?.merged === true ? " (merged)" : ""}`,
`- **URL:** ${htmlUrl}`,
];
if (author) lines.push(`- **Author:** ${author}`);
if (created) lines.push(`- **Created:** ${created}`);
if (updated) lines.push(`- **Updated:** ${updated}`);
if (labels.length) lines.push(`- **Labels:** ${labels.join(", ")}`);
if (pr) {
const base = asRecord(pr.base);
const head = asRecord(pr.head);
const baseRef = base ? str(base.ref) : "";
const headRef = head ? str(head.ref) : "";
if (baseRef || headRef) {
lines.push(`- **Branches:** ${headRef || "?"} → ${baseRef || "?"}`);
}
if (pr.merged_at) lines.push(`- **Merged at:** ${str(pr.merged_at)}`);
}
lines.push("", "## Description", "", body);
if (comments.length > 0) {
lines.push("", `## Comments (${comments.length})`, "");
for (const c of comments) {
const who = userLogin(c.user) || "unknown";
const when = str(c.created_at);
const cbody = str(c.body).trim() || "_(empty)_";
lines.push(`### ${who}${when ? ` · ${when}` : ""}`, "", cbody, "");
}
}
return lines.join("\n").trim() + "\n";
}
function formatAs(
markdown: string,
format: ReadFormat,
): string {
if (format === "markdown") return markdown;
if (format === "text") {
return markdown
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*]\s+\*\*[^*]+\*\*:\s*/gm, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/`([^`]+)`/g, "$1");
}
// html — minimal wrap; agents rarely want html for issues
const escaped = markdown
.replace(/&/g, "&")
.replace(//g, ">");
return `${escaped}`;
}
export interface GitHubReadResult {
url: string;
finalUrl: string;
title?: string;
author?: string;
published?: string;
site: string;
language?: string;
mode: string;
format: ReadFormat;
content: string;
status: number;
chars: number;
}
/**
* Fetch issue/PR via api.github.com. Returns null if URL is not a GH issue/PR.
* Throws on network/API failure after parse match.
*/
export async function tryReadGitHubIssue(
url: string,
options: {
format?: ReadFormat;
maxChars?: number;
timeoutMs?: number;
signal?: AbortSignal;
} = {},
): Promise {
const ref = parseGitHubIssueUrl(url);
if (!ref) return null;
const format = options.format ?? "markdown";
const base = `https://api.github.com/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`;
// Issues API covers both issues and PRs for body + issue comments
const issueUrl = `${base}/issues/${ref.number}`;
const commentsUrl = `${base}/issues/${ref.number}/comments?per_page=100`;
const issueRes = await apiGet(issueUrl, options);
if (issueRes.status === 404) {
throw new Error(
`GitHub ${ref.kind} not found: ${ref.owner}/${ref.repo}#${ref.number}`,
);
}
if (issueRes.status === 403 || issueRes.status === 401) {
const msg = asRecord(issueRes.json)?.message;
throw new Error(
`GitHub API ${issueRes.status}${msg ? `: ${msg}` : ""}. ` +
`Set GITHUB_TOKEN or GH_TOKEN for private repos / higher rate limits.`,
);
}
if (issueRes.status < 200 || issueRes.status >= 300) {
const msg = asRecord(issueRes.json)?.message;
throw new Error(
`GitHub API error ${issueRes.status}${msg ? `: ${msg}` : ""}`,
);
}
const issue = asRecord(issueRes.json);
if (!issue) throw new Error("GitHub API returned non-object issue payload");
let pr: Record | null = null;
if (ref.kind === "pull" || issue.pull_request) {
try {
const prRes = await apiGet(`${base}/pulls/${ref.number}`, options);
if (prRes.status >= 200 && prRes.status < 300) {
pr = asRecord(prRes.json);
}
} catch {
// body from issues endpoint is enough
}
}
let comments: Record[] = [];
try {
const cRes = await apiGet(commentsUrl, options);
if (cRes.status >= 200 && cRes.status < 300 && Array.isArray(cRes.json)) {
comments = cRes.json
.map(asRecord)
.filter((x): x is Record => x != null);
}
} catch {
// comments optional
}
const md = formatIssueMarkdown(ref, issue, comments, pr);
const content = truncate(formatAs(md, format), options.maxChars);
const title = str(issue.title)
? `${ref.owner}/${ref.repo}#${ref.number}: ${str(issue.title)}`
: `${ref.owner}/${ref.repo}#${ref.number}`;
return {
url,
finalUrl: str(issue.html_url) || url,
title,
author: userLogin(issue.user) || undefined,
published: str(issue.created_at) || undefined,
site: "GitHub",
language: "en",
mode: "github-api",
format,
content,
status: issueRes.status,
chars: content.length,
};
}