@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 +2 -1
- package/src/gh-readonly.ts +62 -47
- 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,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
|
|
342
|
+
* Build the `gh` argv for browsing issues/PRs (no keyword search).
|
|
340
343
|
*
|
|
341
|
-
* Keyword searches
|
|
342
|
-
*
|
|
343
|
-
* `gh
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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;
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|