@trim21/personal-pi-extensions 0.1.684 → 0.1.687
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/README.md +10 -8
- package/package.json +1 -1
- package/src/codemode/declarations.ts +3 -3
- package/src/codemode/tool.ts +13 -26
- package/src/gh/base.ts +125 -60
- package/src/gh/render.ts +259 -0
- package/src/gh/schemas.ts +233 -47
- package/src/gh/tools/download-release-assets.ts +58 -80
- package/src/gh/tools/list-issues.ts +66 -47
- package/src/gh/tools/list-prs.ts +66 -47
- package/src/gh/tools/list-releases.ts +43 -25
- package/src/gh/tools/list-workflow-runs.ts +41 -35
- package/src/gh/tools/read-ci-logs.ts +6 -19
- package/src/gh/tools/read-issue-comments.ts +10 -7
- package/src/gh/tools/read-issue.ts +10 -14
- package/src/gh/tools/read-pr-comments.ts +19 -38
- package/src/gh/tools/read-pr-diff.ts +34 -22
- package/src/gh/tools/read-pr.ts +7 -15
- package/src/gh/tools/read-release.ts +37 -25
- package/src/gh/tools/read-repo.ts +35 -24
- package/src/gh/tools/wait-pr-checks.ts +6 -7
- package/src/gh/tools/watch-run.ts +64 -38
- package/src/lib/github-reads.ts +405 -0
- package/src/lib/github.ts +95 -3
- package/src/lib/lsp/inspect-schemas.ts +44 -0
- package/src/lib/lsp/inspect-tool.ts +188 -137
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gh 工具的 REST 读取层:把「按 owner/repo/编号取数据」这一件事集中在这里,让工具文件
|
|
3
|
+
* 只关心渲染与载荷(与 `createGithubSearch` / `createGithubChecks` 并列)。
|
|
4
|
+
*
|
|
5
|
+
* 与 `gh … --json` 的差别:字段名是 GitHub REST 的(`html_url` / `closed_at` / `user.login`),
|
|
6
|
+
* 工具因此直接把响应原物给模型与结构化载荷,不再有 GraphQL 形状的中间层。
|
|
7
|
+
*
|
|
8
|
+
* 认证、代理与 401 重试都由 `createGithubApi` 承担(同一个缓存 client);二进制下载走
|
|
9
|
+
* `rawFetch`,因为 octokit 会把响应体按 JSON 解析。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createWriteStream } from "node:fs";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
15
|
+
|
|
16
|
+
import type { Octokit } from "octokit";
|
|
17
|
+
|
|
18
|
+
import { createGithubApi, type GithubClientOptions } from "./github.js";
|
|
19
|
+
|
|
20
|
+
/** `actions.listWorkflowRuns*` 的 `status` 取值(octokit 是字面量联合,工具参数是 string)。 */
|
|
21
|
+
type WorkflowRunParams = NonNullable<
|
|
22
|
+
Parameters<Octokit["rest"]["actions"]["listWorkflowRunsForRepo"]>[0]
|
|
23
|
+
>;
|
|
24
|
+
type WorkflowRunStatus = NonNullable<WorkflowRunParams["status"]>;
|
|
25
|
+
|
|
26
|
+
/** 列表类查询的公共过滤条件;与工具的 `ListFilters` 一一对应。 */
|
|
27
|
+
export interface ListQuery {
|
|
28
|
+
state?: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
author?: string;
|
|
31
|
+
assignee?: string;
|
|
32
|
+
milestone?: string;
|
|
33
|
+
limit?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GithubReads {
|
|
37
|
+
issue(owner: string, repo: string, number: number, signal?: AbortSignal): Promise<unknown>;
|
|
38
|
+
pull(owner: string, repo: string, number: number, signal?: AbortSignal): Promise<unknown>;
|
|
39
|
+
issueComments(
|
|
40
|
+
owner: string,
|
|
41
|
+
repo: string,
|
|
42
|
+
number: number,
|
|
43
|
+
signal?: AbortSignal,
|
|
44
|
+
): Promise<unknown[]>;
|
|
45
|
+
pullReviewComments(
|
|
46
|
+
owner: string,
|
|
47
|
+
repo: string,
|
|
48
|
+
number: number,
|
|
49
|
+
signal?: AbortSignal,
|
|
50
|
+
): Promise<unknown[]>;
|
|
51
|
+
pullReviews(
|
|
52
|
+
owner: string,
|
|
53
|
+
repo: string,
|
|
54
|
+
number: number,
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
): Promise<unknown[]>;
|
|
57
|
+
listIssues(
|
|
58
|
+
owner: string,
|
|
59
|
+
repo: string,
|
|
60
|
+
query: ListQuery,
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
): Promise<unknown[]>;
|
|
63
|
+
listPulls(
|
|
64
|
+
owner: string,
|
|
65
|
+
repo: string,
|
|
66
|
+
query: ListQuery,
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
): Promise<unknown[]>;
|
|
69
|
+
pullDiff(owner: string, repo: string, number: number, signal?: AbortSignal): Promise<string>;
|
|
70
|
+
listReleases(
|
|
71
|
+
owner: string,
|
|
72
|
+
repo: string,
|
|
73
|
+
limit: number | undefined,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
): Promise<unknown[]>;
|
|
76
|
+
/** `tag` 缺省时取 latest release(与 `gh release view` 的缺省一致)。 */
|
|
77
|
+
release(
|
|
78
|
+
owner: string,
|
|
79
|
+
repo: string,
|
|
80
|
+
tag: string | undefined,
|
|
81
|
+
signal?: AbortSignal,
|
|
82
|
+
): Promise<unknown>;
|
|
83
|
+
listRuns(
|
|
84
|
+
owner: string,
|
|
85
|
+
repo: string,
|
|
86
|
+
query: { workflow?: string; status?: string; limit?: number },
|
|
87
|
+
signal?: AbortSignal,
|
|
88
|
+
): Promise<unknown[]>;
|
|
89
|
+
run(owner: string, repo: string, runId: number, signal?: AbortSignal): Promise<unknown>;
|
|
90
|
+
repository(owner: string, repo: string, signal?: AbortSignal): Promise<unknown>;
|
|
91
|
+
/** job 的原始日志文本(GitHub 只有按 job id 取日志的端点)。 */
|
|
92
|
+
jobLogs(owner: string, repo: string, jobId: number, signal?: AbortSignal): Promise<string>;
|
|
93
|
+
/** 下载一个 release 资产到 `destPath`(流式,不整体驻留内存)。 */
|
|
94
|
+
downloadAssetTo(
|
|
95
|
+
owner: string,
|
|
96
|
+
repo: string,
|
|
97
|
+
assetId: number,
|
|
98
|
+
destPath: string,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
): Promise<void>;
|
|
101
|
+
/** 下载仓库源码归档(`gh release download --archive` 的对应物)。 */
|
|
102
|
+
downloadArchiveTo(
|
|
103
|
+
owner: string,
|
|
104
|
+
repo: string,
|
|
105
|
+
format: "zip" | "tar.gz",
|
|
106
|
+
ref: string | undefined,
|
|
107
|
+
destPath: string,
|
|
108
|
+
signal?: AbortSignal,
|
|
109
|
+
): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** REST 列表查询把工具的 state 过滤映射成 `state` 参数(issue/PR 的值集合相同)。 */
|
|
113
|
+
function stateParam(state: string | undefined): "open" | "closed" | "all" | undefined {
|
|
114
|
+
if (state === undefined || state === "all") {
|
|
115
|
+
return state === "all" ? "all" : undefined;
|
|
116
|
+
}
|
|
117
|
+
if (state === "open" || state === "closed") {
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
// merged 只对搜索路径有意义(列表端点没有这个状态),列表侧按 all 处理
|
|
121
|
+
if (state === "merged") {
|
|
122
|
+
return "all";
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`invalid state: ${state} (expected open, closed, all)`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `@me` 是 gh CLI 的简写,REST 列表端点不认识;按字面量转发(用户明确不做展开)。 */
|
|
128
|
+
function listParams(query: ListQuery): {
|
|
129
|
+
state?: "open" | "closed" | "all";
|
|
130
|
+
labels?: string;
|
|
131
|
+
assignee?: string;
|
|
132
|
+
creator?: string;
|
|
133
|
+
milestone?: string;
|
|
134
|
+
} {
|
|
135
|
+
const state = stateParam(query.state);
|
|
136
|
+
return {
|
|
137
|
+
...(state !== undefined && { state }),
|
|
138
|
+
...(query.label !== undefined && { labels: query.label }),
|
|
139
|
+
...(query.assignee !== undefined && { assignee: query.assignee }),
|
|
140
|
+
...(query.author !== undefined && { creator: query.author }),
|
|
141
|
+
...(query.milestone !== undefined && { milestone: query.milestone }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function limitOf(limit: number | undefined, fallback: number): number {
|
|
146
|
+
return Math.min(Math.max(limit ?? fallback, 1), 100);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 响应体不是 2xx 时抛错(状态、URL 与 GitHub 的 message 都在里面)。 */
|
|
150
|
+
async function ensureOk(response: Response): Promise<Response> {
|
|
151
|
+
if (response.ok) {
|
|
152
|
+
return response;
|
|
153
|
+
}
|
|
154
|
+
let detail: string | undefined;
|
|
155
|
+
try {
|
|
156
|
+
const body = await response.text();
|
|
157
|
+
detail = (JSON.parse(body) as { message?: string }).message ?? body.slice(0, 200);
|
|
158
|
+
} catch {
|
|
159
|
+
detail = undefined;
|
|
160
|
+
}
|
|
161
|
+
throw new Error(
|
|
162
|
+
`GitHub API error (HTTP ${response.status}) at ${response.url}${detail === undefined || detail === "" ? "" : `: ${detail}`}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function octokitRequest(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
|
167
|
+
return signal === undefined ? {} : { signal };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 把 octokit 的类型化 data 当外部 JSON 交出去:调用方用 schema 校验自己读的字段。 */
|
|
171
|
+
function asJson(value: unknown): unknown {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createGithubReads(options: GithubClientOptions = {}): GithubReads {
|
|
176
|
+
const api = createGithubApi(options);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
async issue(owner, repo, number, signal) {
|
|
180
|
+
const { data } = await api.call((octokit) =>
|
|
181
|
+
octokit.rest.issues.get({ owner, repo, issue_number: number, ...octokitRequest(signal) }),
|
|
182
|
+
);
|
|
183
|
+
return asJson(data);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async pull(owner, repo, number, signal) {
|
|
187
|
+
const { data } = await api.call((octokit) =>
|
|
188
|
+
octokit.rest.pulls.get({ owner, repo, pull_number: number, ...octokitRequest(signal) }),
|
|
189
|
+
);
|
|
190
|
+
return asJson(data);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async issueComments(owner, repo, number, signal) {
|
|
194
|
+
const data = await api.call((octokit) =>
|
|
195
|
+
octokit.paginate(octokit.rest.issues.listComments, {
|
|
196
|
+
owner,
|
|
197
|
+
repo,
|
|
198
|
+
issue_number: number,
|
|
199
|
+
per_page: 100,
|
|
200
|
+
...octokitRequest(signal),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
return data.map((item) => asJson(item));
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async pullReviewComments(owner, repo, number, signal) {
|
|
207
|
+
const data = await api.call((octokit) =>
|
|
208
|
+
octokit.paginate(octokit.rest.pulls.listReviewComments, {
|
|
209
|
+
owner,
|
|
210
|
+
repo,
|
|
211
|
+
pull_number: number,
|
|
212
|
+
per_page: 100,
|
|
213
|
+
...octokitRequest(signal),
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
return data.map((item) => asJson(item));
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async pullReviews(owner, repo, number, signal) {
|
|
220
|
+
const data = await api.call((octokit) =>
|
|
221
|
+
octokit.paginate(octokit.rest.pulls.listReviews, {
|
|
222
|
+
owner,
|
|
223
|
+
repo,
|
|
224
|
+
pull_number: number,
|
|
225
|
+
per_page: 100,
|
|
226
|
+
...octokitRequest(signal),
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
return data.map((item) => asJson(item));
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
async listIssues(owner, repo, query, signal) {
|
|
233
|
+
const limit = limitOf(query.limit, 30);
|
|
234
|
+
const data = await api.call((octokit) =>
|
|
235
|
+
octokit.paginate(octokit.rest.issues.listForRepo, {
|
|
236
|
+
owner,
|
|
237
|
+
repo,
|
|
238
|
+
per_page: limit,
|
|
239
|
+
...listParams(query),
|
|
240
|
+
...octokitRequest(signal),
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
// 列表端点混着 PR(REST 的 issues 列表包含 PR),与 gh issue list 一致地过滤掉
|
|
244
|
+
const issues = data.filter((item) => !("pull_request" in item));
|
|
245
|
+
return issues.slice(0, limit).map((item) => asJson(item));
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async listPulls(owner, repo, query, signal) {
|
|
249
|
+
const limit = limitOf(query.limit, 30);
|
|
250
|
+
const data = await api.call((octokit) =>
|
|
251
|
+
octokit.paginate(octokit.rest.pulls.list, {
|
|
252
|
+
owner,
|
|
253
|
+
repo,
|
|
254
|
+
per_page: limit,
|
|
255
|
+
...listParams(query),
|
|
256
|
+
...octokitRequest(signal),
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
return data.slice(0, limit).map((item) => asJson(item));
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async pullDiff(owner, repo, number, signal) {
|
|
263
|
+
const { data } = await api.call((octokit) =>
|
|
264
|
+
octokit.rest.pulls.get({
|
|
265
|
+
owner,
|
|
266
|
+
repo,
|
|
267
|
+
pull_number: number,
|
|
268
|
+
mediaType: { format: "diff" },
|
|
269
|
+
...octokitRequest(signal),
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
// diff 媒体类型下 data 是原始 diff 文本;octokit 的类型仍按 JSON 声明,所以这里
|
|
273
|
+
// 显式断言而不是把对象 stringify([object Object] 会静默毁掉 diff)
|
|
274
|
+
return data as unknown as string;
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async listReleases(owner, repo, limit, signal) {
|
|
278
|
+
const data = await api.call((octokit) =>
|
|
279
|
+
octokit.paginate(octokit.rest.repos.listReleases, {
|
|
280
|
+
owner,
|
|
281
|
+
repo,
|
|
282
|
+
per_page: limitOf(limit, 10),
|
|
283
|
+
...octokitRequest(signal),
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
return data.map((item) => asJson(item));
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
async release(owner, repo, tag, signal) {
|
|
290
|
+
const { data } = await api.call((octokit) =>
|
|
291
|
+
tag === undefined
|
|
292
|
+
? octokit.rest.repos.getLatestRelease({ owner, repo, ...octokitRequest(signal) })
|
|
293
|
+
: octokit.rest.repos.getReleaseByTag({ owner, repo, tag, ...octokitRequest(signal) }),
|
|
294
|
+
);
|
|
295
|
+
return asJson(data);
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async listRuns(owner, repo, query, signal) {
|
|
299
|
+
const limit = limitOf(query.limit, 20);
|
|
300
|
+
let workflowId: number | undefined;
|
|
301
|
+
if (query.workflow !== undefined) {
|
|
302
|
+
const workflows = await api.call((octokit) =>
|
|
303
|
+
octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
304
|
+
owner,
|
|
305
|
+
repo,
|
|
306
|
+
per_page: 100,
|
|
307
|
+
...octokitRequest(signal),
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const wanted = query.workflow.toLowerCase();
|
|
311
|
+
const match = workflows.find(
|
|
312
|
+
(workflow) =>
|
|
313
|
+
workflow.name.toLowerCase() === wanted ||
|
|
314
|
+
workflow.path.toLowerCase() === wanted ||
|
|
315
|
+
workflow.path.toLowerCase().endsWith(`/${wanted}`),
|
|
316
|
+
);
|
|
317
|
+
if (match === undefined) {
|
|
318
|
+
const names = workflows.map((workflow) => workflow.name).join(", ");
|
|
319
|
+
throw new Error(`workflow "${query.workflow}" not found (available: ${names})`);
|
|
320
|
+
}
|
|
321
|
+
workflowId = match.id;
|
|
322
|
+
}
|
|
323
|
+
const data = await api.call(async (octokit) => {
|
|
324
|
+
const params = {
|
|
325
|
+
owner,
|
|
326
|
+
repo,
|
|
327
|
+
per_page: limit,
|
|
328
|
+
// REST 的 status 同时接受运行状态与结论(success / failure / in_progress …);
|
|
329
|
+
// octokit 把取值声明成字面量联合,这里按它的类型断言(非法取值由 API 报 422)
|
|
330
|
+
...(query.status !== undefined && { status: query.status as WorkflowRunStatus }),
|
|
331
|
+
...octokitRequest(signal),
|
|
332
|
+
};
|
|
333
|
+
return workflowId === undefined
|
|
334
|
+
? octokit.paginate(octokit.rest.actions.listWorkflowRunsForRepo, params)
|
|
335
|
+
: octokit.paginate(octokit.rest.actions.listWorkflowRuns, {
|
|
336
|
+
...params,
|
|
337
|
+
workflow_id: workflowId,
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
return data.slice(0, limit).map((item) => asJson(item));
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
async run(owner, repo, runId, signal) {
|
|
344
|
+
const { data } = await api.call((octokit) =>
|
|
345
|
+
octokit.rest.actions.getWorkflowRun({
|
|
346
|
+
owner,
|
|
347
|
+
repo,
|
|
348
|
+
run_id: runId,
|
|
349
|
+
...octokitRequest(signal),
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
return asJson(data);
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
async repository(owner, repo, signal) {
|
|
356
|
+
const { data } = await api.call((octokit) =>
|
|
357
|
+
octokit.rest.repos.get({ owner, repo, ...octokitRequest(signal) }),
|
|
358
|
+
);
|
|
359
|
+
return asJson(data);
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async jobLogs(owner, repo, jobId, signal) {
|
|
363
|
+
const { data } = await api.call((octokit) =>
|
|
364
|
+
octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
|
365
|
+
owner,
|
|
366
|
+
repo,
|
|
367
|
+
job_id: jobId,
|
|
368
|
+
...octokitRequest(signal),
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
// 日志端点的响应体是文本;octokit 的类型是 `unknown`(按响应内容而定),这里断言
|
|
372
|
+
return data as string;
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
async downloadAssetTo(owner, repo, assetId, destPath, signal) {
|
|
376
|
+
const response = await api.rawFetch(
|
|
377
|
+
`https://api.github.com/repos/${owner}/${repo}/releases/assets/${assetId}`,
|
|
378
|
+
{ headers: { accept: "application/octet-stream" }, ...(signal && { signal }) },
|
|
379
|
+
);
|
|
380
|
+
await ensureOk(response);
|
|
381
|
+
await streamToFile(response, destPath);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
async downloadArchiveTo(owner, repo, format, ref, destPath, signal) {
|
|
385
|
+
const kind = format === "zip" ? "zipball" : "tarball";
|
|
386
|
+
const suffix = ref === undefined ? "" : `/${ref}`;
|
|
387
|
+
const response = await api.rawFetch(
|
|
388
|
+
`https://api.github.com/repos/${owner}/${repo}/${kind}${suffix}`,
|
|
389
|
+
{ ...(signal && { signal }) },
|
|
390
|
+
);
|
|
391
|
+
await ensureOk(response);
|
|
392
|
+
await streamToFile(response, destPath);
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 把响应体流式写入文件;响应没有 body(空资产)时写空文件。 */
|
|
398
|
+
async function streamToFile(response: Response, destPath: string): Promise<void> {
|
|
399
|
+
const body = response.body;
|
|
400
|
+
if (body === null) {
|
|
401
|
+
await pipeline(Readable.from([]), createWriteStream(destPath));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await pipeline(Readable.fromWeb(body), createWriteStream(destPath));
|
|
405
|
+
}
|
package/src/lib/github.ts
CHANGED
|
@@ -186,6 +186,11 @@ export function renderHits(hits: SearchHit[], options: { repo?: string; fields?:
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
function toDate(iso: string | null | undefined): string {
|
|
189
|
+
return dateOnly(iso);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** ISO 时间戳取日期部分(`2026-10-03`);缺省或 null 给空串。 */
|
|
193
|
+
export function dateOnly(iso: string | null | undefined): string {
|
|
189
194
|
return iso ? iso.slice(0, 10) : "";
|
|
190
195
|
}
|
|
191
196
|
|
|
@@ -217,6 +222,53 @@ function normalize(raw: SearchItem): SearchHit {
|
|
|
217
222
|
};
|
|
218
223
|
}
|
|
219
224
|
|
|
225
|
+
/**
|
|
226
|
+
* REST 列表端点(`issues.listForRepo` / `pulls.list`)的条目形状。与搜索 API 的条目差别:
|
|
227
|
+
* repo 不在条目里(由调用方给),PR 的合并信息在 `merged_at`/`merged`。
|
|
228
|
+
*/
|
|
229
|
+
const restListItemSchema = Type.Object({
|
|
230
|
+
number: Type.Number(),
|
|
231
|
+
state: Type.String(),
|
|
232
|
+
title: Type.String(),
|
|
233
|
+
html_url: Type.String(),
|
|
234
|
+
user: Type.Union([Type.Object({ login: Type.String() }), Type.Null()]),
|
|
235
|
+
labels: Type.Array(Type.Object({ name: Type.Optional(Type.String()) })),
|
|
236
|
+
milestone: Type.Union([Type.Object({ title: Type.Optional(Type.String()) }), Type.Null()]),
|
|
237
|
+
assignees: Type.Union([Type.Array(Type.Object({ login: Type.String() })), Type.Null()]),
|
|
238
|
+
comments: Type.Optional(Type.Number()),
|
|
239
|
+
created_at: Type.String(),
|
|
240
|
+
updated_at: Type.String(),
|
|
241
|
+
closed_at: Type.Union([Type.String(), Type.Null()]),
|
|
242
|
+
merged_at: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 把 REST 列表条目归一到 `SearchHit`,与搜索分支共用一套渲染与载荷。`repo` 由调用方给
|
|
247
|
+
* (REST 列表端点的条目里没有仓库信息)。
|
|
248
|
+
*/
|
|
249
|
+
export function normalizeRestList(items: unknown, repo: string): SearchHit[] {
|
|
250
|
+
return parseWithSchema(Type.Array(restListItemSchema), items).map((item) => {
|
|
251
|
+
const mergedAt = item.merged_at ?? null;
|
|
252
|
+
return {
|
|
253
|
+
number: item.number,
|
|
254
|
+
// 已合并的 PR 在 REST 里也是 `closed`,merged 语义与搜索分支一致地由 merged_at 推断
|
|
255
|
+
state: mergedAt === null ? (item.state === "open" ? "open" : "closed") : "merged",
|
|
256
|
+
title: item.title,
|
|
257
|
+
url: item.html_url,
|
|
258
|
+
repo,
|
|
259
|
+
author: item.user?.login ?? "",
|
|
260
|
+
labels: item.labels.map((label) => label.name ?? ""),
|
|
261
|
+
milestone: item.milestone?.title ?? "",
|
|
262
|
+
assignees: (item.assignees ?? []).map((assignee) => assignee.login),
|
|
263
|
+
comments: item.comments ?? 0,
|
|
264
|
+
createdAt: toDate(item.created_at),
|
|
265
|
+
updatedAt: toDate(item.updated_at),
|
|
266
|
+
closedAt: toDate(item.closed_at),
|
|
267
|
+
mergedAt: toDate(mergedAt),
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
220
272
|
/**
|
|
221
273
|
* Error thrown when the GitHub search API rejects the request. Carries the
|
|
222
274
|
* original toolcall params so the model can see the exact input.
|
|
@@ -246,9 +298,14 @@ function describeHttpError(status: number | undefined): string {
|
|
|
246
298
|
return `GitHub API error${status === undefined ? "" : ` (HTTP ${status})`}`;
|
|
247
299
|
}
|
|
248
300
|
|
|
249
|
-
interface GithubApi {
|
|
301
|
+
export interface GithubApi {
|
|
250
302
|
/** Run an octokit request; retries once with a fresh token on 401. */
|
|
251
303
|
call<T>(fn: (octokit: Octokit) => Promise<T>): Promise<T>;
|
|
304
|
+
/**
|
|
305
|
+
* 用同一个 token 直接 fetch(二进制资产、源码归档这类要走原始响应体、不能经 octokit
|
|
306
|
+
* 的 JSON 解析的请求)。重定向自动跟随,401 同样丢缓存重试一次。
|
|
307
|
+
*/
|
|
308
|
+
rawFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
252
309
|
}
|
|
253
310
|
|
|
254
311
|
export interface GithubClientOptions {
|
|
@@ -268,17 +325,41 @@ export interface GithubClientOptions {
|
|
|
268
325
|
* module-level state. A stale cached token can produce 401s; the cache is
|
|
269
326
|
* dropped and the request retried once in that case.
|
|
270
327
|
*/
|
|
271
|
-
function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
328
|
+
export function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
272
329
|
let client: Octokit | undefined;
|
|
330
|
+
// token 单独缓存:rawFetch(二进制/归档下载)不走 octokit,但同样不该每次 spawn
|
|
331
|
+
// `gh auth token`。401 时两个缓存一起丢。
|
|
332
|
+
let token: string | undefined;
|
|
333
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
334
|
+
const tokenProvider = options.token ?? ghAuthToken;
|
|
335
|
+
|
|
336
|
+
async function getToken(): Promise<string> {
|
|
337
|
+
token ??= await tokenProvider();
|
|
338
|
+
return token;
|
|
339
|
+
}
|
|
273
340
|
|
|
274
341
|
async function getClient(): Promise<Octokit> {
|
|
275
342
|
client ??= new Octokit({
|
|
276
|
-
auth: await (
|
|
343
|
+
auth: await getToken(),
|
|
277
344
|
...(options.fetch && { request: { fetch: options.fetch } }),
|
|
278
345
|
});
|
|
279
346
|
return client;
|
|
280
347
|
}
|
|
281
348
|
|
|
349
|
+
async function rawFetchOnce(url: string, init: RequestInit = {}): Promise<Response> {
|
|
350
|
+
const headers = new Headers(init.headers);
|
|
351
|
+
headers.set("authorization", `Bearer ${await getToken()}`);
|
|
352
|
+
headers.set("accept", "application/vnd.github+json");
|
|
353
|
+
headers.set("x-github-api-version", "2022-11-28");
|
|
354
|
+
const response = await fetchImpl(url, { ...init, headers });
|
|
355
|
+
if (response.status === 401) {
|
|
356
|
+
// 缓存的 token 可能已过期:丢缓存后由调用方重试一次
|
|
357
|
+
token = undefined;
|
|
358
|
+
client = undefined;
|
|
359
|
+
}
|
|
360
|
+
return response;
|
|
361
|
+
}
|
|
362
|
+
|
|
282
363
|
return {
|
|
283
364
|
async call(fn) {
|
|
284
365
|
for (let attempt = 0; ; attempt += 1) {
|
|
@@ -287,6 +368,7 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
|
287
368
|
} catch (error) {
|
|
288
369
|
const status = (error as { status?: number }).status;
|
|
289
370
|
if (status === 401 && attempt === 0 && client) {
|
|
371
|
+
token = undefined;
|
|
290
372
|
client = undefined;
|
|
291
373
|
continue;
|
|
292
374
|
}
|
|
@@ -294,6 +376,16 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
|
294
376
|
}
|
|
295
377
|
}
|
|
296
378
|
},
|
|
379
|
+
|
|
380
|
+
async rawFetch(url, init) {
|
|
381
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
382
|
+
const response = await rawFetchOnce(url, init);
|
|
383
|
+
if (response.status !== 401 || attempt > 0) {
|
|
384
|
+
return response;
|
|
385
|
+
}
|
|
386
|
+
await response.body?.cancel();
|
|
387
|
+
}
|
|
388
|
+
},
|
|
297
389
|
};
|
|
298
390
|
}
|
|
299
391
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 三个只读 LSP 查询工具(`lsp-find-definition` / `lsp-find-reference` / `lsp-inspect`)的
|
|
3
|
+
* 结构化结果 schema。
|
|
4
|
+
*
|
|
5
|
+
* 载荷只有我们自己归一化过的数据(`service.inspect` 的产出),没有外部引擎字段,因此这里
|
|
6
|
+
* 不学 AFT 那样全线可选:字段都在,类型确定。位置的行列号是 **1-based**(与工具文本、
|
|
7
|
+
* 与这三个工具自己的 `line` / `character` 参数一致),脚本拿到就能直接回喂给另一次查询。
|
|
8
|
+
*
|
|
9
|
+
* `locations` 给的是查询到的**全部**位置:文本会为了模型上下文对引用列表做截断
|
|
10
|
+
* (每文件 10 条、最多 30 个文件),载荷不跟着截——它是给程序用的数据,截断是渲染的事。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
|
|
15
|
+
/** 一个位置:绝对路径 + 1-based 行列号(与工具文本里的 `path:line:col` 同源)。 */
|
|
16
|
+
export const lspLocationSchema = Type.Object(
|
|
17
|
+
{
|
|
18
|
+
path: Type.String({ description: "文件的绝对路径" }),
|
|
19
|
+
line: Type.Integer({ minimum: 1, description: "1-based 行号" }),
|
|
20
|
+
character: Type.Integer({ minimum: 1, description: "1-based 列号" }),
|
|
21
|
+
},
|
|
22
|
+
{ additionalProperties: false },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** 回答这次查询的语言服务器 id,以及查询到的全部位置。 */
|
|
26
|
+
export const lspLocationsStructuredSchema = Type.Object(
|
|
27
|
+
{
|
|
28
|
+
text: Type.String({ description: "与工具输出一致的渲染文本(引用列表可能被截断)" }),
|
|
29
|
+
serverID: Type.String({ description: "回答这次查询的语言服务器 id" }),
|
|
30
|
+
locations: Type.Array(lspLocationSchema, {
|
|
31
|
+
description: "查询到的全部位置,1-based 行列号,顺序与文本一致",
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
{ additionalProperties: false },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** hover 的内容是服务器给的异构结构,渲染成文本后原样给出。 */
|
|
38
|
+
export const lspHoverStructuredSchema = Type.Object(
|
|
39
|
+
{
|
|
40
|
+
text: Type.String({ description: "与工具输出一致的 hover 文本(服务器返回的内容)" }),
|
|
41
|
+
serverID: Type.String({ description: "回答这次查询的语言服务器 id" }),
|
|
42
|
+
},
|
|
43
|
+
{ additionalProperties: false },
|
|
44
|
+
);
|