From 18fe29b219f62d26413df08a143c698aade9cd15 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 8 May 2026 23:13:27 +0300 Subject: [PATCH] feat: add github issue autocomplete extension --- .gitignore | 1 + .../github-issues-autocomplete/index.ts | 490 ++++++++++++++++++ .../github-issues-autocomplete/package.json | 21 + .../github-issues-autocomplete/tsconfig.json | 11 + 4 files changed, 523 insertions(+) create mode 100644 .pi/extensions/github-issues-autocomplete/index.ts create mode 100644 .pi/extensions/github-issues-autocomplete/package.json create mode 100644 .pi/extensions/github-issues-autocomplete/tsconfig.json diff --git a/.gitignore b/.gitignore index 76299a60..f344da80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ /.pi/todos /.pi/issues +/.pi/cache 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:` in the input editor, suggests open issues from the current repo. + * On `@pr:` 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:` at end of text before cursor. */ +const ISSUE_TOKEN_RE = /(?:^|\s)(@issue(?::\s*([^\s@]*))?)$/; + +/** Match `@pr`, `@pr:`, or `@pr:` 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 --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 --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"] +}