@trim21/personal-pi-extensions 0.1.685 → 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.
@@ -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
@@ -223,70 +223,47 @@ function normalize(raw: SearchItem): SearchHit {
223
223
  }
224
224
 
225
225
  /**
226
- * `gh issue list --json …` / `gh pr list --json …` 的响应形状。gh 走 GraphQL,字段是
227
- * camelCase,`state` 是大写的 `OPEN` / `CLOSED` / `MERGED`,`comments` 是整个评论数组
228
- * (不是计数)——所以浏览分支要在归一化这一步把它压成计数,才能和搜索分支(REST,
229
- * 下划线命名、state 小写、comments 是数字)共用同一套 SearchHit、渲染与载荷。
226
+ * REST 列表端点(`issues.listForRepo` / `pulls.list`)的条目形状。与搜索 API 的条目差别:
227
+ * repo 不在条目里(由调用方给),PR 的合并信息在 `merged_at`/`merged`。
230
228
  */
231
- const ghListRecordSchema = Type.Object({
229
+ const restListItemSchema = Type.Object({
232
230
  number: Type.Number(),
233
- title: Type.String(),
234
231
  state: Type.String(),
235
- url: Type.String(),
236
- labels: Type.Array(Type.Object({ name: Type.String() }, { additionalProperties: true })),
237
- milestone: Type.Union([
238
- Type.Object({ title: Type.String() }, { additionalProperties: true }),
239
- Type.Null(),
240
- ]),
241
- assignees: Type.Array(Type.Object({ login: Type.String() }, { additionalProperties: true })),
242
- author: Type.Union([
243
- Type.Object({ login: Type.String() }, { additionalProperties: true }),
244
- Type.Null(),
245
- ]),
246
- comments: Type.Array(Type.Unknown()),
247
- createdAt: Type.String(),
248
- updatedAt: Type.String(),
249
- closedAt: Type.Union([Type.String(), Type.Null()]),
250
- mergedAt: Type.Optional(Type.Union([Type.String(), Type.Null()])),
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()])),
251
243
  });
252
244
 
253
- /** 浏览分支要 gh 给的字段,与 `SearchHit` 的列一一对应(`comments` 只用来数个数)。 */
254
- export const GH_LIST_FIELDS = [
255
- "number",
256
- "title",
257
- "state",
258
- "url",
259
- "labels",
260
- "milestone",
261
- "assignees",
262
- "author",
263
- "comments",
264
- "createdAt",
265
- "updatedAt",
266
- "closedAt",
267
- "mergedAt",
268
- ].join(",");
269
-
270
- /** 把 `gh issue list --json` / `gh pr list --json` 的输出归一到 `SearchHit`(repo 由调用方给)。 */
271
- export function normalizeGhList(stdout: string, repo: string): SearchHit[] {
272
- const items = parseWithSchema(Type.Array(ghListRecordSchema), JSON.parse(stdout));
273
- return items.map((item) => {
274
- const mergedAt = item.mergedAt ?? null;
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;
275
252
  return {
276
253
  number: item.number,
277
- // gh 给 MERGED 状态的 PR 与 OPEN/CLOSED 一样是 state 字段,merged 语义与搜索分支对齐
278
- state: mergedAt ? "merged" : (item.state.toLowerCase() as SearchHit["state"]),
254
+ // 已合并的 PR 在 REST 里也是 `closed`,merged 语义与搜索分支一致地由 merged_at 推断
255
+ state: mergedAt === null ? (item.state === "open" ? "open" : "closed") : "merged",
279
256
  title: item.title,
280
- url: item.url,
257
+ url: item.html_url,
281
258
  repo,
282
- author: item.author?.login ?? "",
283
- labels: item.labels.map((label) => label.name),
259
+ author: item.user?.login ?? "",
260
+ labels: item.labels.map((label) => label.name ?? ""),
284
261
  milestone: item.milestone?.title ?? "",
285
- assignees: item.assignees.map((assignee) => assignee.login),
286
- comments: item.comments.length,
287
- createdAt: toDate(item.createdAt),
288
- updatedAt: toDate(item.updatedAt),
289
- closedAt: toDate(item.closedAt),
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),
290
267
  mergedAt: toDate(mergedAt),
291
268
  };
292
269
  });
@@ -321,9 +298,14 @@ function describeHttpError(status: number | undefined): string {
321
298
  return `GitHub API error${status === undefined ? "" : ` (HTTP ${status})`}`;
322
299
  }
323
300
 
324
- interface GithubApi {
301
+ export interface GithubApi {
325
302
  /** Run an octokit request; retries once with a fresh token on 401. */
326
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>;
327
309
  }
328
310
 
329
311
  export interface GithubClientOptions {
@@ -343,17 +325,41 @@ export interface GithubClientOptions {
343
325
  * module-level state. A stale cached token can produce 401s; the cache is
344
326
  * dropped and the request retried once in that case.
345
327
  */
346
- function createGithubApi(options: GithubClientOptions = {}): GithubApi {
328
+ export function createGithubApi(options: GithubClientOptions = {}): GithubApi {
347
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
+ }
348
340
 
349
341
  async function getClient(): Promise<Octokit> {
350
342
  client ??= new Octokit({
351
- auth: await (options.token ?? ghAuthToken)(),
343
+ auth: await getToken(),
352
344
  ...(options.fetch && { request: { fetch: options.fetch } }),
353
345
  });
354
346
  return client;
355
347
  }
356
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
+
357
363
  return {
358
364
  async call(fn) {
359
365
  for (let attempt = 0; ; attempt += 1) {
@@ -362,6 +368,7 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
362
368
  } catch (error) {
363
369
  const status = (error as { status?: number }).status;
364
370
  if (status === 401 && attempt === 0 && client) {
371
+ token = undefined;
365
372
  client = undefined;
366
373
  continue;
367
374
  }
@@ -369,6 +376,16 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
369
376
  }
370
377
  }
371
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
+ },
372
389
  };
373
390
  }
374
391