@trim21/personal-pi-extensions 0.1.494 → 0.1.496

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.494",
3
+ "version": "0.1.496",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -105,6 +105,7 @@
105
105
  "linkedom": "^0.18.0",
106
106
  "lodash-es": "^4.18.1",
107
107
  "minimatch": "^10.2.6",
108
+ "octokit": "^5.0.5",
108
109
  "turndown": "^7.2.0",
109
110
  "vscode-jsonrpc": "^9.0.1",
110
111
  "vscode-languageserver-types": "^3.18.0",
@@ -38,6 +38,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
38
  import { Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
+ import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
41
42
  import { type ToolPendant } from "./lib/pendant.js";
42
43
  import { createSeqState } from "./lib/seq-state.js";
43
44
 
@@ -333,44 +334,51 @@ interface ListFilters {
333
334
  assignee?: string;
334
335
  milestone?: string;
335
336
  limit?: number;
337
+ /** Comma-separated field names for the keyword-search result rows. */
338
+ fields?: string;
336
339
  }
337
340
 
338
341
  /**
339
- * List or search issues/PRs with structured filters.
342
+ * Build the `gh` argv for browsing issues/PRs (no keyword search).
340
343
  *
341
- * `gh issue list` / `gh pr list` are used when a repo is available (repo param or
342
- * current directory), with keywords passed via `--search`. When no repo is given
343
- * and keywords are present, falls back to `gh search issues` / `gh search prs`
344
- * with plain keywords never embedding a `repo:` qualifier in the query string,
345
- * because `gh` mis-parses `repo:` values followed by spaces.
344
+ * Keyword searches no longer go through the `gh` CLI the octokit-based client
345
+ * in `./lib/github.ts` handles them with state values (`all`, and `merged` for
346
+ * PRs) that `gh search` cannot express. Browse calls keep `gh issue list` /
347
+ * `gh pr list` semantics: `state` is passed through verbatim, since `gh issue
348
+ * list` accepts open/closed/all and `gh pr list` additionally accepts merged.
346
349
  */
347
- async function listGithub(
348
- kind: "issue" | "pr",
349
- params: ListFilters,
350
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
351
- ): Promise<string> {
352
- const { repo, keywords, state, label, author, assignee, milestone, limit } = params;
353
-
354
- if (!repo && keywords) {
355
- const args = ["search", kind === "issue" ? "issues" : "prs", keywords];
356
- if (state && state !== "all") args.push("--state", state);
357
- if (label) args.push("--label", label);
358
- if (author) args.push("--author", author);
359
- if (assignee) args.push("--assignee", assignee);
360
- if (milestone) args.push("--milestone", milestone);
361
- if (limit) args.push("--limit", String(limit));
362
- return ghExec(args, ctx);
363
- }
350
+ export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
351
+ const { repo, state, label, author, assignee, milestone, limit } = params;
364
352
 
365
353
  const args = [kind, "list", ...repoArgs(repo)];
366
354
  if (state) args.push("--state", state);
367
- if (keywords) args.push("--search", keywords);
368
355
  if (label) args.push("--label", label);
369
356
  if (author) args.push("--author", author);
370
357
  if (assignee) args.push("--assignee", assignee);
371
358
  if (milestone) args.push("--milestone", milestone);
372
359
  if (limit) args.push("--limit", String(limit));
373
- return ghExec(args, ctx);
360
+ return args;
361
+ }
362
+
363
+ async function listGithub(
364
+ kind: "issue" | "pr",
365
+ params: ListFilters,
366
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
367
+ ): Promise<string> {
368
+ return ghExec(listGithubArgs(kind, params), ctx);
369
+ }
370
+
371
+ /** Run a keyword search through the octokit client and render the rows. */
372
+ async function searchList(
373
+ kind: "issue" | "pr",
374
+ params: ListFilters,
375
+ githubSearch: GithubSearch,
376
+ ): Promise<string> {
377
+ const hits = await githubSearch.search(kind, params);
378
+ if (hits.length === 0) {
379
+ return `(no matching ${kind === "issue" ? "issues" : "pull requests"})`;
380
+ }
381
+ return renderHits(hits, { repo: params.repo, fields: params.fields });
374
382
  }
375
383
 
376
384
  // ── CI helpers ───────────────────────────────────────────────────────────────
@@ -1128,6 +1136,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1128
1136
  return;
1129
1137
  }
1130
1138
 
1139
+ const githubSearch = createGithubSearch();
1140
+
1131
1141
  // ── read-github-issue ──────────────────────────────────────────────────────
1132
1142
  pi.registerTool({
1133
1143
  name: "read-github-issue",
@@ -1164,21 +1174,36 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1164
1174
  name: "list-github-issues",
1165
1175
  label: "GitHub Issues List",
1166
1176
  description:
1167
- "List GitHub issues with optional filters and keyword search. When repo is omitted, searches across GitHub using keywords.",
1177
+ 'List GitHub issues with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword search defaults to open issues — pass state="all" to include closed ones. Set fields to choose the columns of each result row.',
1168
1178
  promptSnippet: "List or search GitHub issues",
1169
1179
  parameters: Type.Object({
1170
1180
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1171
1181
  keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
1172
- state: Type.Optional(Type.String({ description: "open, closed, all (default: open)" })),
1182
+ state: Type.Optional(
1183
+ Type.String({
1184
+ description:
1185
+ "open, closed, all (default: open; all applies to keyword search and covers closed too)",
1186
+ }),
1187
+ ),
1173
1188
  label: Type.Optional(Type.String({ description: "Filter by label" })),
1174
1189
  author: Type.Optional(Type.String({ description: "Filter by author" })),
1175
- assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
1190
+ assignee: Type.Optional(
1191
+ Type.String({ description: "Filter by assignee (@me for yourself)" }),
1192
+ ),
1176
1193
  milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1177
- limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
1194
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
1195
+ fields: Type.Optional(
1196
+ Type.String({
1197
+ description:
1198
+ "Comma-separated columns for keyword-search rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt",
1199
+ }),
1200
+ ),
1178
1201
  }),
1179
1202
  async execute(_id, params, signal, _onUpdate, ctx) {
1180
1203
  const result = toToolResult(
1181
- await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
1204
+ params.keywords
1205
+ ? await searchList("issue", params, githubSearch)
1206
+ : await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
1182
1207
  params,
1183
1208
  );
1184
1209
  result.details.pendant = subtitlePendant(params);
@@ -1222,23 +1247,36 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1222
1247
  name: "list-github-prs",
1223
1248
  label: "GitHub PRs List",
1224
1249
  description:
1225
- "List GitHub pull requests with optional filters and keyword search. When repo is omitted, searches across GitHub using keywords.",
1250
+ 'List GitHub pull requests with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword search defaults to open PRs — pass state="merged", state="closed" (merged excluded) or state="all" to broaden. Set fields to choose the columns of each result row.',
1226
1251
  promptSnippet: "List or search GitHub PRs",
1227
1252
  parameters: Type.Object({
1228
1253
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1229
1254
  keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
1230
1255
  state: Type.Optional(
1231
- Type.String({ description: "open, closed, merged, all (default: open)" }),
1256
+ Type.String({
1257
+ description:
1258
+ "open, closed, merged, all (default: open; all applies to keyword search and covers open + closed + merged)",
1259
+ }),
1232
1260
  ),
1233
1261
  label: Type.Optional(Type.String({ description: "Filter by label" })),
1234
1262
  author: Type.Optional(Type.String({ description: "Filter by author" })),
1235
- assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
1263
+ assignee: Type.Optional(
1264
+ Type.String({ description: "Filter by assignee (@me for yourself)" }),
1265
+ ),
1236
1266
  milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1237
- limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
1267
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
1268
+ fields: Type.Optional(
1269
+ Type.String({
1270
+ description:
1271
+ "Comma-separated columns for keyword-search rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt,mergedAt",
1272
+ }),
1273
+ ),
1238
1274
  }),
1239
1275
  async execute(_id, params, signal, _onUpdate, ctx) {
1240
1276
  const result = toToolResult(
1241
- await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }),
1277
+ params.keywords
1278
+ ? await searchList("pr", params, githubSearch)
1279
+ : await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }),
1242
1280
  params,
1243
1281
  );
1244
1282
  result.details.pendant = subtitlePendant(params);
@@ -0,0 +1,294 @@
1
+ /**
2
+ * GitHub search client built on octokit, authenticated with the token from the
3
+ * system `gh` CLI (`gh auth token`). Used by the gh-readonly search tools.
4
+ *
5
+ * Unlike the `gh search` CLI, the search API has no `--state all` and treats a
6
+ * merged PR's state as `closed` — so merged/closed disambiguation is expressed
7
+ * through qualifiers here (`is:merged`, `state:closed -is:merged`) and the
8
+ * rendered state is derived from `pull_request.merged_at`.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+
13
+ import { Octokit } from "octokit";
14
+
15
+ export type SearchKind = "issue" | "pr";
16
+
17
+ export interface SearchParams {
18
+ repo?: string;
19
+ keywords?: string;
20
+ state?: string;
21
+ label?: string;
22
+ author?: string;
23
+ assignee?: string;
24
+ milestone?: string;
25
+ limit?: number;
26
+ }
27
+
28
+ /** REST /search/issues response shape we consume (item is an issue/pr union). */
29
+ interface RawSearchItem {
30
+ number: number;
31
+ state: string;
32
+ title: string;
33
+ html_url: string;
34
+ /** The search API exposes the repo as a URL, not as an object. */
35
+ repository_url: string;
36
+ user: { login: string } | null;
37
+ labels: { name: string }[];
38
+ milestone: { title: string } | null;
39
+ assignees: { login: string }[];
40
+ comments: number;
41
+ created_at: string;
42
+ updated_at: string;
43
+ closed_at: string | null;
44
+ pull_request: { merged_at: string | null } | null;
45
+ }
46
+
47
+ export interface SearchHit {
48
+ number: number;
49
+ /** `open`, `closed` or `merged` (merged is inferred from pull_request.merged_at). */
50
+ state: "open" | "closed" | "merged";
51
+ title: string;
52
+ url: string;
53
+ repo: string;
54
+ author: string;
55
+ labels: string[];
56
+ milestone: string;
57
+ assignees: string[];
58
+ comments: number;
59
+ createdAt: string;
60
+ updatedAt: string;
61
+ closedAt: string;
62
+ mergedAt: string;
63
+ }
64
+
65
+ /**
66
+ * Build the `q` parameter for the issues-and-pull-requests search endpoint.
67
+ *
68
+ * State semantics (the whole reason this client exists):
69
+ * - default is `open`, matching the browse tools
70
+ * - `all` applies no state filter (open + closed)
71
+ * - for PRs, `merged` maps to `is:merged` and `closed` excludes merged PRs,
72
+ * because the search API reports a merged PR's state as `closed`
73
+ */
74
+ export function buildSearchQuery(kind: SearchKind, params: SearchParams): string {
75
+ const { repo, keywords, label, author, assignee, milestone } = params;
76
+ const state = params.state ?? "open";
77
+
78
+ const parts: string[] = [];
79
+ if (repo) parts.push(`repo:${repo}`);
80
+ parts.push(kind === "issue" ? "is:issue" : "is:pr");
81
+ switch (state) {
82
+ case "open": {
83
+ parts.push("state:open");
84
+
85
+ break;
86
+ }
87
+ case "closed": {
88
+ parts.push(kind === "pr" ? "state:closed -is:merged" : "state:closed");
89
+
90
+ break;
91
+ }
92
+ case "merged": {
93
+ if (kind === "issue") throw new Error("state=merged is only valid for PR searches");
94
+ parts.push("is:merged");
95
+
96
+ break;
97
+ }
98
+ default: {
99
+ if (state !== "all") {
100
+ throw new Error(
101
+ `invalid state: ${state} (expected open, closed, ${kind === "pr" ? "merged, " : ""}all)`,
102
+ );
103
+ }
104
+ }
105
+ }
106
+ if (keywords) parts.push(keywords);
107
+ if (label) parts.push(`label:${quoteQualifier(label)}`);
108
+ if (author) parts.push(`author:${author}`);
109
+ if (assignee) parts.push(`assignee:${assignee}`);
110
+ if (milestone) parts.push(`milestone:${quoteQualifier(milestone)}`);
111
+ return parts.join(" ");
112
+ }
113
+
114
+ /** Quote a qualifier value that contains whitespace or special characters. */
115
+ function quoteQualifier(value: string): string {
116
+ if (/^[\w@./-]+$/.test(value)) return value;
117
+ return `"${value.replaceAll('"', String.raw`\"`)}"`;
118
+ }
119
+
120
+ const FIELD_EXTRACTORS: Record<string, (hit: SearchHit) => string> = {
121
+ number: (h) => String(h.number),
122
+ state: (h) => h.state,
123
+ title: (h) => h.title,
124
+ url: (h) => h.url,
125
+ repo: (h) => h.repo,
126
+ author: (h) => h.author,
127
+ labels: (h) => h.labels.join(","),
128
+ milestone: (h) => h.milestone,
129
+ assignees: (h) => h.assignees.join(","),
130
+ comments: (h) => String(h.comments),
131
+ createdAt: (h) => h.createdAt,
132
+ updatedAt: (h) => h.updatedAt,
133
+ closedAt: (h) => h.closedAt,
134
+ mergedAt: (h) => h.mergedAt,
135
+ };
136
+
137
+ export const SEARCH_FIELDS: readonly string[] = Object.keys(FIELD_EXTRACTORS);
138
+
139
+ /** Render search hits as tab-separated rows; one row per hit, one column per field. */
140
+ export function renderHits(hits: SearchHit[], options: { repo?: string; fields?: string }): string {
141
+ const requested = options.fields
142
+ ? options.fields
143
+ .split(",")
144
+ .map((f) => f.trim())
145
+ .filter(Boolean)
146
+ : options.repo
147
+ ? ["number", "state", "title", "labels", "updatedAt"]
148
+ : ["repo", "number", "state", "title", "labels", "updatedAt"];
149
+ const unknown = requested.filter((f) => !(f in FIELD_EXTRACTORS));
150
+ if (unknown.length > 0) {
151
+ throw new Error(
152
+ `unknown field${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} (valid: ${SEARCH_FIELDS.join(", ")})`,
153
+ );
154
+ }
155
+ return hits.map((hit) => requested.map((f) => FIELD_EXTRACTORS[f](hit)).join("\t")).join("\n");
156
+ }
157
+
158
+ function toDate(iso: string | null | undefined): string {
159
+ return iso ? iso.slice(0, 10) : "";
160
+ }
161
+
162
+ const REPO_URL_RE = /\/repos\/([^/]+\/[^/]+)$/;
163
+
164
+ /** repository_url looks like https://api.github.com/repos/OWNER/REPO */
165
+ function repoName(raw: RawSearchItem): string {
166
+ const match = REPO_URL_RE.exec(raw.repository_url);
167
+ return match?.[1] ?? "";
168
+ }
169
+
170
+ function normalize(raw: RawSearchItem): SearchHit {
171
+ const mergedAt = raw.pull_request?.merged_at ?? "";
172
+ return {
173
+ number: raw.number,
174
+ state: mergedAt ? "merged" : (raw.state as "open" | "closed"),
175
+ title: raw.title,
176
+ url: raw.html_url,
177
+ repo: repoName(raw),
178
+ author: raw.user?.login ?? "",
179
+ labels: raw.labels.map((l) => l.name),
180
+ milestone: raw.milestone?.title ?? "",
181
+ assignees: raw.assignees.map((a) => a.login),
182
+ comments: raw.comments,
183
+ createdAt: toDate(raw.created_at),
184
+ updatedAt: toDate(raw.updated_at),
185
+ closedAt: toDate(raw.closed_at),
186
+ mergedAt: toDate(mergedAt),
187
+ };
188
+ }
189
+
190
+ function ghAuthToken(): Promise<string> {
191
+ return new Promise((resolve, reject) => {
192
+ const proc = spawn("gh", ["auth", "token"], { stdio: ["ignore", "pipe", "pipe"] });
193
+ let stdout = "";
194
+ let stderr = "";
195
+ const timer = setTimeout(() => proc.kill("SIGTERM"), 10_000);
196
+ proc.stdout.on("data", (d: Buffer) => {
197
+ stdout += String(d);
198
+ });
199
+ proc.stderr.on("data", (d: Buffer) => {
200
+ stderr += String(d);
201
+ });
202
+ proc.on("error", (err) => {
203
+ clearTimeout(timer);
204
+ reject(new Error(`failed to start gh: ${err.message}`));
205
+ });
206
+ proc.on("close", (code) => {
207
+ clearTimeout(timer);
208
+ const token = stdout.trim();
209
+ if (code === 0 && token) {
210
+ resolve(token);
211
+ } else {
212
+ reject(
213
+ new Error(
214
+ stderr.trim() || `gh auth token exited with code ${code} — run "gh auth login" first`,
215
+ ),
216
+ );
217
+ }
218
+ });
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Error thrown when the GitHub search API rejects the request. Carries the
224
+ * original toolcall params so the model can see the exact input.
225
+ */
226
+ export class GithubSearchError extends Error {
227
+ readonly params: SearchParams;
228
+ readonly status: number | undefined;
229
+
230
+ constructor(message: string, params: SearchParams, status?: number) {
231
+ super(`${message} (input: ${JSON.stringify(params)})`);
232
+ this.name = "GithubSearchError";
233
+ this.params = params;
234
+ this.status = status;
235
+ }
236
+ }
237
+
238
+ function describeHttpError(status: number | undefined): string {
239
+ if (status === 401)
240
+ return 'GitHub auth failed (401): token invalid or expired — run "gh auth login"';
241
+ if (status === 403) return "GitHub rate limit or permissions error (403)";
242
+ if (status === 404) return "repository not found, or the token has no access to it (404)";
243
+ return `GitHub API error${status === undefined ? "" : ` (HTTP ${status})`}`;
244
+ }
245
+
246
+ export interface GithubSearch {
247
+ search(kind: SearchKind, params: SearchParams): Promise<SearchHit[]>;
248
+ }
249
+
250
+ /**
251
+ * Create a search client. The octokit instance (and its auth token) is cached
252
+ * in the returned closure, so repeated searches reuse the same client without
253
+ * module-level state.
254
+ */
255
+ export function createGithubSearch(): GithubSearch {
256
+ let client: Octokit | undefined;
257
+
258
+ async function getClient(): Promise<Octokit> {
259
+ client ??= new Octokit({ auth: await ghAuthToken() });
260
+ return client;
261
+ }
262
+
263
+ return {
264
+ async search(kind, params) {
265
+ const limit = Math.min(Math.max(params.limit ?? 30, 1), 100);
266
+ const effective = { ...params, limit };
267
+
268
+ for (let attempt = 0; ; attempt += 1) {
269
+ try {
270
+ const octokit = await getClient();
271
+ if (effective.assignee === "@me") {
272
+ const { data } = await octokit.rest.users.getAuthenticated();
273
+ effective.assignee = data.login;
274
+ }
275
+ const q = buildSearchQuery(kind, effective);
276
+ const { data } = await octokit.rest.search.issuesAndPullRequests({
277
+ q,
278
+ per_page: limit,
279
+ });
280
+ return data.items.map((item) => normalize(item as unknown as RawSearchItem));
281
+ } catch (error) {
282
+ const status = (error as { status?: number }).status;
283
+ // A stale cached token can produce 401s; drop the cache and retry once.
284
+ if (status === 401 && attempt === 0 && client) {
285
+ client = undefined;
286
+ continue;
287
+ }
288
+ const message = (error as { message?: string }).message ?? String(error);
289
+ throw new GithubSearchError(`${describeHttpError(status)}: ${message}`, params, status);
290
+ }
291
+ }
292
+ },
293
+ };
294
+ }