@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 +2 -1
- package/src/gh-readonly.ts +73 -35
- package/src/lib/github.ts +294 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/gh-readonly.ts
CHANGED
|
@@ -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
|
-
*
|
|
342
|
+
* Build the `gh` argv for browsing issues/PRs (no keyword search).
|
|
340
343
|
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|