@trim21/personal-pi-extensions 0.1.495 → 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.495",
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,52 +334,30 @@ 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
- * Build the `gh` argv for listing or keyword-searching issues/PRs.
342
+ * Build the `gh` argv for browsing issues/PRs (no keyword search).
340
343
  *
341
- * Keyword searches route through `gh search issues` / `gh search prs`, which rank
342
- * results by relevance and are the only path that can include closed items.
343
- * `gh issue list --search` would silently stay on the list's state filter (open
344
- * by default), so keyword lookups must not use it. Browse calls (no keywords)
345
- * keep `gh issue list` / `gh pr list` semantics.
346
- *
347
- * The state value sets differ between the two command families: `gh issue list`
348
- * accepts `--state all` and `gh pr list` `--state merged`, while `gh search`
349
- * only knows `--state open|closed` plus `--merged` for PRs. The shared `state`
350
- * param is translated here: search defaults to open, `all` means open+closed
351
- * (no state filter), and `merged` maps to `--merged` for PRs.
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.
352
349
  */
353
350
  export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
354
- const { repo, keywords, state, label, author, assignee, milestone, limit } = params;
355
-
356
- const addFilters = (args: string[]) => {
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 args;
363
- };
364
-
365
- if (keywords) {
366
- const args = ["search", kind === "issue" ? "issues" : "prs", ...repoArgs(repo), keywords];
367
- if (kind === "pr" && state === "merged") {
368
- args.push("--merged");
369
- } else if (state && state !== "all") {
370
- args.push("--state", state);
371
- } else if (!state) {
372
- // keep the browse tools' default rather than gh search's implicit
373
- // open+closed; pass state="all" to cover closed items
374
- args.push("--state", "open");
375
- }
376
- return addFilters(args);
377
- }
351
+ const { repo, state, label, author, assignee, milestone, limit } = params;
378
352
 
379
353
  const args = [kind, "list", ...repoArgs(repo)];
380
354
  if (state) args.push("--state", state);
381
- return addFilters(args);
355
+ if (label) args.push("--label", label);
356
+ if (author) args.push("--author", author);
357
+ if (assignee) args.push("--assignee", assignee);
358
+ if (milestone) args.push("--milestone", milestone);
359
+ if (limit) args.push("--limit", String(limit));
360
+ return args;
382
361
  }
383
362
 
384
363
  async function listGithub(
@@ -389,6 +368,19 @@ async function listGithub(
389
368
  return ghExec(listGithubArgs(kind, params), ctx);
390
369
  }
391
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 });
382
+ }
383
+
392
384
  // ── CI helpers ───────────────────────────────────────────────────────────────
393
385
 
394
386
  export interface StepInfo {
@@ -1144,6 +1136,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1144
1136
  return;
1145
1137
  }
1146
1138
 
1139
+ const githubSearch = createGithubSearch();
1140
+
1147
1141
  // ── read-github-issue ──────────────────────────────────────────────────────
1148
1142
  pi.registerTool({
1149
1143
  name: "read-github-issue",
@@ -1180,25 +1174,36 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1180
1174
  name: "list-github-issues",
1181
1175
  label: "GitHub Issues List",
1182
1176
  description:
1183
- 'List GitHub issues with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword results default to open issues — pass state="all" to include closed ones.',
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.',
1184
1178
  promptSnippet: "List or search GitHub issues",
1185
1179
  parameters: Type.Object({
1186
1180
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1187
1181
  keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
1188
1182
  state: Type.Optional(
1189
1183
  Type.String({
1190
- description: "open, closed, all (default: open; with keywords, all covers closed too)",
1184
+ description:
1185
+ "open, closed, all (default: open; all applies to keyword search and covers closed too)",
1191
1186
  }),
1192
1187
  ),
1193
1188
  label: Type.Optional(Type.String({ description: "Filter by label" })),
1194
1189
  author: Type.Optional(Type.String({ description: "Filter by author" })),
1195
- assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
1190
+ assignee: Type.Optional(
1191
+ Type.String({ description: "Filter by assignee (@me for yourself)" }),
1192
+ ),
1196
1193
  milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1197
- 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
+ ),
1198
1201
  }),
1199
1202
  async execute(_id, params, signal, _onUpdate, ctx) {
1200
1203
  const result = toToolResult(
1201
- 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 }),
1202
1207
  params,
1203
1208
  );
1204
1209
  result.details.pendant = subtitlePendant(params);
@@ -1242,7 +1247,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1242
1247
  name: "list-github-prs",
1243
1248
  label: "GitHub PRs List",
1244
1249
  description:
1245
- 'List GitHub pull requests with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword results default to open PRs — pass state="all" (open + closed) or state="merged" to broaden.',
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.',
1246
1251
  promptSnippet: "List or search GitHub PRs",
1247
1252
  parameters: Type.Object({
1248
1253
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
@@ -1250,18 +1255,28 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1250
1255
  state: Type.Optional(
1251
1256
  Type.String({
1252
1257
  description:
1253
- "open, closed, merged, all (default: open; with keywords, merged and all broaden the search)",
1258
+ "open, closed, merged, all (default: open; all applies to keyword search and covers open + closed + merged)",
1254
1259
  }),
1255
1260
  ),
1256
1261
  label: Type.Optional(Type.String({ description: "Filter by label" })),
1257
1262
  author: Type.Optional(Type.String({ description: "Filter by author" })),
1258
- assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
1263
+ assignee: Type.Optional(
1264
+ Type.String({ description: "Filter by assignee (@me for yourself)" }),
1265
+ ),
1259
1266
  milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1260
- 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
+ ),
1261
1274
  }),
1262
1275
  async execute(_id, params, signal, _onUpdate, ctx) {
1263
1276
  const result = toToolResult(
1264
- 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 }),
1265
1280
  params,
1266
1281
  );
1267
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
+ }