feat: add github issue autocomplete extension
This commit is contained in:
parent
793f127e54
commit
18fe29b219
|
|
@ -10,3 +10,4 @@ __pycache__/
|
|||
|
||||
/.pi/todos
|
||||
/.pi/issues
|
||||
/.pi/cache
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue