aboutsummaryrefslogtreecommitdiff
path: root/.pi
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 23:13:27 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 23:13:27 +0300
commit18fe29b219f62d26413df08a143c698aade9cd15 (patch)
treef422e037532610e09012d1a072b9cdeaef13c40a /.pi
parent793f127e54dd5f2cfb78922470b7efcb74a1fa5e (diff)
feat: add github issue autocomplete extension
Diffstat (limited to '.pi')
-rw-r--r--.pi/extensions/github-issues-autocomplete/index.ts490
-rw-r--r--.pi/extensions/github-issues-autocomplete/package.json21
-rw-r--r--.pi/extensions/github-issues-autocomplete/tsconfig.json11
3 files changed, 522 insertions, 0 deletions
diff --git a/.pi/extensions/github-issues-autocomplete/index.ts b/.pi/extensions/github-issues-autocomplete/index.ts
new file mode 100644
index 00000000..81067226
--- /dev/null
+++ b/.pi/extensions/github-issues-autocomplete/index.ts
@@ -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;
+ });
+}
diff --git a/.pi/extensions/github-issues-autocomplete/package.json b/.pi/extensions/github-issues-autocomplete/package.json
new file mode 100644
index 00000000..9c59fc7d
--- /dev/null
+++ b/.pi/extensions/github-issues-autocomplete/package.json
@@ -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"
+ }
+}
diff --git a/.pi/extensions/github-issues-autocomplete/tsconfig.json b/.pi/extensions/github-issues-autocomplete/tsconfig.json
new file mode 100644
index 00000000..976dde46
--- /dev/null
+++ b/.pi/extensions/github-issues-autocomplete/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["index.ts"]
+}