feat: add github issue autocomplete extension

This commit is contained in:
Ogulcan Celik 2026-05-08 23:13:27 +03:00
parent 793f127e54
commit 18fe29b219
4 changed files with 523 additions and 0 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ __pycache__/
/.pi/todos
/.pi/issues
/.pi/cache

View File

@ -0,0 +1,490 @@
/**
* `@issue:` GitHub issues and `@pr:` GitHub PRs autocomplete provider.
*
* On `@issue:<token>` in the input editor, suggests open issues from the current repo.
* On `@pr:<token>` in the input editor, suggests open PRs from the current repo.
*
* Accepting a completion inserts the reference (e.g. `issue owner/repo#123` or `pr owner/repo#45`).
* A `before_agent_start` nudge tells the agent how to fetch details with `gh`.
*
* Issues and PRs are fetched once on session start and cached in memory.
*/
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type {
AutocompleteItem,
AutocompleteProvider,
AutocompleteSuggestions,
} from "@earendil-works/pi-tui";
// --- Constants ---
const ISSUE_PREFIX = "@issue";
const PR_PREFIX = "@pr";
/** Match `@issue`, `@issue:`, or `@issue:<token>` at end of text before cursor. */
const ISSUE_TOKEN_RE = /(?:^|\s)(@issue(?::\s*([^\s@]*))?)$/;
/** Match `@pr`, `@pr:`, or `@pr:<token>` at end of text before cursor. */
const PR_TOKEN_RE = /(?:^|\s)(@pr(?::\s*([^\s@]*))?)$/;
const MAX_SUGGESTIONS = 20;
// --- Types ---
interface RepoInfo {
owner: string;
repo: string;
/** `owner/repo` string */
fullName: string;
}
interface IssueInfo {
number: number;
title: string;
labels: string;
author: string;
updatedAt: string;
}
interface PRInfo {
number: number;
title: string;
author: string;
headRefName: string;
updatedAt: string;
isDraft: boolean;
}
interface CachedData {
repo: RepoInfo | null;
issues: IssueInfo[];
prs: PRInfo[];
}
function getCachePath(cwd: string): string {
return join(cwd, ".pi", "cache", "github-refs.json");
}
async function readCache(cwd: string): Promise<CachedData> {
try {
const raw = await readFile(getCachePath(cwd), "utf8");
const data = JSON.parse(raw) as CachedData;
return {
repo: data.repo ?? null,
issues: Array.isArray(data.issues) ? data.issues : [],
prs: Array.isArray(data.prs) ? data.prs : [],
};
} catch {
return { repo: null, issues: [], prs: [] };
}
}
async function writeCache(cwd: string, data: CachedData): Promise<void> {
const cachePath = getCachePath(cwd);
await mkdir(join(cwd, ".pi", "cache"), { recursive: true });
await writeFile(cachePath, JSON.stringify(data, null, 2), "utf8");
}
// --- Shell helpers (use pi.exec) ---
async function fetchRepoName(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<RepoInfo | null> {
const result = await exec("gh", ["repo", "view", "--json", "owner,name"], { cwd });
if (result.code !== 0 || !result.stdout.trim()) return null;
try {
const data = JSON.parse(result.stdout) as {
owner: { login: string };
name: string;
};
const owner = data.owner?.login ?? "";
const repo = data.name ?? "";
if (!owner || !repo) return null;
return { owner, repo, fullName: `${owner}/${repo}` };
} catch {
return null;
}
}
async function fetchIssues(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<IssueInfo[]> {
const result = await exec(
"gh",
["issue", "list", "--json", "number,title,labels,author,updatedAt", "--limit", "50", "--state", "open"],
{ cwd },
);
if (result.code !== 0 || !result.stdout.trim()) return [];
try {
const items = JSON.parse(result.stdout) as Array<{
number: number;
title: string;
labels: Array<{ name: string }>;
author: { login: string };
updatedAt: string;
}>;
return items.map((item) => ({
number: item.number,
title: item.title,
labels: item.labels.map((l) => l.name).join(", "),
author: item.author?.login ?? "",
updatedAt: item.updatedAt,
}));
} catch {
return [];
}
}
async function fetchPRs(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<PRInfo[]> {
const result = await exec(
"gh",
["pr", "list", "--json", "number,title,author,headRefName,updatedAt,isDraft", "--limit", "50", "--state", "open"],
{ cwd },
);
if (result.code !== 0 || !result.stdout.trim()) return [];
try {
const items = JSON.parse(result.stdout || "[]") as Array<{
number: number;
title: string;
author: { login: string };
headRefName: string;
updatedAt: string;
isDraft: boolean;
}>;
return items.map((item) => ({
number: item.number,
title: item.title,
author: item.author?.login ?? "",
headRefName: item.headRefName,
updatedAt: item.updatedAt,
isDraft: item.isDraft,
}));
} catch {
return [];
}
}
/** Fetch all data needed for the session in parallel. */
async function fetchAll(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<CachedData> {
const [repo, issues, prs] = await Promise.all([
fetchRepoName(exec, cwd),
fetchIssues(exec, cwd),
fetchPRs(exec, cwd),
]);
return { repo, issues, prs };
}
// --- Autocomplete replacement ---
function replaceAutocompletePrefix(
lines: string[],
cursorLine: number,
cursorCol: number,
prefix: string,
value: string,
) {
const currentLine = lines[cursorLine] ?? "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const newLines = [...lines];
newLines[cursorLine] = `${beforePrefix}${value}${afterCursor}`;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + value.length,
};
}
function extractPrefixCandidate(
textBeforeCursor: string,
targetPrefix: string,
): string | undefined {
const match = textBeforeCursor.match(/(^|\s)(@\S*)$/);
const candidate = match?.[2];
if (!candidate || !targetPrefix.startsWith(candidate)) return undefined;
return candidate;
}
function createPrefixCompletionItem(
value: string,
label: string,
description: string,
): AutocompleteItem {
return { value, label, description };
}
// --- Provider factory ---
export function createGithubAutocompleteProvider(
current: AutocompleteProvider,
data: CachedData,
): AutocompleteProvider {
return {
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options,
): Promise<AutocompleteSuggestions | null> {
const currentLine = lines[cursorLine] ?? "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
const issueMatch = textBeforeCursor.match(ISSUE_TOKEN_RE);
const prMatch = textBeforeCursor.match(PR_TOKEN_RE);
const issuePrefix = issueMatch?.[1];
const prPrefix = prMatch?.[1];
const issueToken = issueMatch ? issueMatch[2] ?? "" : undefined;
const prToken = prMatch ? prMatch[2] ?? "" : undefined;
// Neither complete prefix matched — check for partial prefix candidates first.
// Do not await the built-in @file provider for @iss/@pr-like input: pi queues
// autocomplete requests serially, so slow file scans can delay issue results.
if (issueToken === undefined && prToken === undefined) {
const prefixItems: AutocompleteItem[] = [];
const issueCandidate = extractPrefixCandidate(textBeforeCursor, `${ISSUE_PREFIX}:`);
if (issueCandidate !== undefined) {
prefixItems.push(
createPrefixCompletionItem(ISSUE_PREFIX, ISSUE_PREFIX, "GitHub issues"),
);
}
const prCandidate = extractPrefixCandidate(textBeforeCursor, `${PR_PREFIX}:`);
if (prCandidate !== undefined) {
prefixItems.push(
createPrefixCompletionItem(PR_PREFIX, PR_PREFIX, "GitHub pull requests"),
);
}
if (prefixItems.length > 0) {
return {
items: prefixItems,
prefix: issueCandidate ?? prCandidate ?? "",
};
}
return current.getSuggestions(lines, cursorLine, cursorCol, options);
}
if (options.signal.aborted) return null;
const repoPrefix = data.repo ? `${data.repo.fullName}#` : "#";
// --- Issues ---
if (issueToken !== undefined) {
const tokenLower = issueToken.toLowerCase();
const filtered = data.issues
.filter(
(i) =>
i.title.toLowerCase().includes(tokenLower) ||
String(i.number).includes(tokenLower),
)
.slice(0, MAX_SUGGESTIONS);
if (filtered.length === 0) return null;
const items: AutocompleteItem[] = filtered.map((i) => ({
value: `issue ${repoPrefix}${i.number}`,
label: `#${i.number} ${i.title}`,
description: [i.labels, i.author].filter(Boolean).join(" · "),
}));
return { items, prefix: issuePrefix ?? `${ISSUE_PREFIX}:${issueToken}` };
}
// --- PRs ---
if (prToken !== undefined) {
const tokenLower = prToken.toLowerCase();
const filtered = data.prs
.filter(
(p) =>
p.title.toLowerCase().includes(tokenLower) ||
String(p.number).includes(tokenLower) ||
p.headRefName.toLowerCase().includes(tokenLower),
)
.slice(0, MAX_SUGGESTIONS);
if (filtered.length === 0) return null;
const items: AutocompleteItem[] = filtered.map((p) => ({
value: `pr ${repoPrefix}${p.number}`,
label: `#${p.number} ${p.title}`,
description: [p.headRefName, p.author, p.isDraft ? "draft" : ""]
.filter(Boolean)
.join(" · "),
}));
return { items, prefix: prPrefix ?? `${PR_PREFIX}:${prToken}` };
}
return current.getSuggestions(lines, cursorLine, cursorCol, options);
},
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
) {
if (prefix.startsWith(ISSUE_PREFIX) || prefix.startsWith(PR_PREFIX)) {
return replaceAutocompletePrefix(
lines,
cursorLine,
cursorCol,
prefix,
item.value,
);
}
// Prefix item completion (typing toward `@issue` or `@pr`)
if (item.value === ISSUE_PREFIX || item.value === PR_PREFIX) {
return replaceAutocompletePrefix(
lines,
cursorLine,
cursorCol,
prefix,
item.value,
);
}
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
},
shouldTriggerFileCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
) {
const currentLine = lines[cursorLine] ?? "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (
textBeforeCursor.match(ISSUE_TOKEN_RE) ||
textBeforeCursor.match(PR_TOKEN_RE)
) {
return true;
}
return (
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
true
);
},
};
}
// --- Nudge on before_agent_start ---
/** Match `issue org/repo#123` or `issue #123` */
const ISSUE_REF_RE = /issue\s+(\S+?#(\d+))/g;
/** Match `pr org/repo#45` or `pr #45` */
const PR_REF_RE = /pr\s+(\S+?#(\d+))/g;
interface RefMatch {
type: "issue" | "pr";
/** Full reference string e.g. `owner/repo#123` */
refs: string[];
}
function buildNudge(matches: RefMatch[]): string {
const lines: string[] = [];
for (const m of matches) {
const uniqueRefs = [...new Set(m.refs)];
if (m.type === "issue" && uniqueRefs.length > 0) {
lines.push(
`GitHub issues referenced: ${uniqueRefs.map((r) => `issue ${r}`).join(", ")}.`,
`To fetch issue details, use: gh issue view <number> --json number,title,body,labels,author,assignees,comments`,
`To list issues, use: gh issue list --json number,title,labels,author,updatedAt --state open`,
);
}
if (m.type === "pr" && uniqueRefs.length > 0) {
lines.push(
`GitHub PRs referenced: ${uniqueRefs.map((r) => `pr ${r}`).join(", ")}.`,
`To fetch PR details, use: gh pr view <number> --json number,title,body,headRefName,baseRefName,author,reviews,mergeable`,
`To list PRs, use: gh pr list --json number,title,author,headRefName,updatedAt,isDraft --state open`,
);
}
}
return lines.join("\n");
}
// --- Extension entry point ---
export default async function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const cwd = ctx.cwd;
const data = await readCache(cwd);
ctx.ui.addAutocompleteProvider((current) =>
createGithubAutocompleteProvider(current, data),
);
void fetchAll(pi.exec, cwd).then(async (fresh) => {
data.repo = fresh.repo;
data.issues = fresh.issues;
data.prs = fresh.prs;
await writeCache(cwd, fresh);
}).catch(() => {
// Keep stale cache when gh is unavailable or slow.
});
});
pi.on("before_agent_start", async (event) => {
const text = event.prompt;
const issueRefs: string[] = [];
const prRefs: string[] = [];
let match: RegExpExecArray | null;
const issueRe = new RegExp(ISSUE_REF_RE.source, "g");
match = issueRe.exec(text);
while (match !== null) {
if (match[1]) issueRefs.push(match[1]);
match = issueRe.exec(text);
}
const prRe = new RegExp(PR_REF_RE.source, "g");
match = prRe.exec(text);
while (match !== null) {
if (match[1]) prRefs.push(match[1]);
match = prRe.exec(text);
}
if (issueRefs.length === 0 && prRefs.length === 0) return;
const nudge = buildNudge([
{ type: "issue", refs: issueRefs },
{ type: "pr", refs: prRefs },
]);
return {
message: {
customType: "github-ref:nudge",
content: nudge,
display: false,
},
} as const;
});
}

View File

@ -0,0 +1,21 @@
{
"name": "pi-github-issues-autocomplete",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
"build": "echo 'nothing to build'",
"check": "tsc --noEmit"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest",
"typescript": "latest"
}
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["index.ts"]
}