pi-fancy-footer 1.3.0 → 1.3.2
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 +8 -5
- package/package.json +1 -1
- package/src/ci.test.ts +7 -0
- package/src/git.test.ts +128 -1
- package/src/git.ts +16 -8
- package/src/provider-status.test.ts +290 -4
- package/src/provider-status.ts +106 -32
- package/src/pull-request.test.ts +80 -1
- package/src/pull-request.ts +29 -35
- package/src/shared.ts +42 -3
package/README.md
CHANGED
|
@@ -276,9 +276,10 @@ Notes:
|
|
|
276
276
|
from `~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
|
|
277
277
|
`~/.codex/auth.json`. Claude uses pi Anthropic OAuth credentials from
|
|
278
278
|
`~/.pi/agent/auth.json` and reads Claude.ai usage for the 5-hour and weekly
|
|
279
|
-
windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status/`;
|
|
280
|
+
when a refresh fails, cached quota windows keep showing until their reset times
|
|
281
|
+
pass instead of hiding the widget. The widget is hidden when the active model
|
|
282
|
+
selection is not backed by the status provider.
|
|
282
283
|
- `provider-status` also refreshes from `x-codex-*` provider response headers
|
|
283
284
|
when pi exposes them, avoiding a separate Codex status request after provider
|
|
284
285
|
calls. Claude status refreshes from the Claude.ai usage endpoint, not
|
|
@@ -289,13 +290,15 @@ Notes:
|
|
|
289
290
|
work better in terminals that don't use a Nerd Font.
|
|
290
291
|
- Per-widget icon overrides only let you hide the icon. The selected
|
|
291
292
|
`iconFamily` controls which icon each widget uses.
|
|
292
|
-
- The PR widgets appear only for open GitHub
|
|
293
|
-
GitHub
|
|
293
|
+
- The PR widgets appear only for open GitHub and GitHub Enterprise pull
|
|
294
|
+
requests on GitHub-style hosts such as `github.example.com`; they rely on the
|
|
295
|
+
GitHub CLI (`gh`) being available and authenticated for the remote host.
|
|
294
296
|
- `pull-request-review-threads` counts unresolved GitHub review threads
|
|
295
297
|
on the current PR.
|
|
296
298
|
- `pull-request-ci-status` shows GitHub Actions workflow runs for the current
|
|
297
299
|
PR head commit. It links to the relevant run and switches to failed as soon as
|
|
298
300
|
one workflow fails, even when other workflows are still running.
|
|
301
|
+
|
|
299
302
|
## 🧱 Gauge styles
|
|
300
303
|
|
|
301
304
|
The `gaugeStyle` setting controls the characters used by the `context-bar`
|
package/package.json
CHANGED
package/src/ci.test.ts
CHANGED
|
@@ -76,6 +76,13 @@ test("buildWorkflowRunsPath creates GitHub Actions runs endpoint", () => {
|
|
|
76
76
|
buildWorkflowRunsPath("https://github.com/org/repo/pull/42", "abc123"),
|
|
77
77
|
"repos/org/repo/actions/runs?head_sha=abc123&per_page=100",
|
|
78
78
|
);
|
|
79
|
+
assert.equal(
|
|
80
|
+
buildWorkflowRunsPath(
|
|
81
|
+
"https://github.example.com/org/repo/pull/42",
|
|
82
|
+
"abc123",
|
|
83
|
+
),
|
|
84
|
+
"repos/org/repo/actions/runs?head_sha=abc123&per_page=100",
|
|
85
|
+
);
|
|
79
86
|
assert.equal(
|
|
80
87
|
buildWorkflowRunsPath("https://example.com/x/y/pull/1", "abc"),
|
|
81
88
|
undefined,
|
package/src/git.test.ts
CHANGED
|
@@ -129,6 +129,7 @@ test("collectPullRequestInfo ignores foreign branch-name matches and falls back
|
|
|
129
129
|
assert.deepEqual(result.pullRequest, {
|
|
130
130
|
number: 7,
|
|
131
131
|
url: "https://github.com/org/repo/pull/7",
|
|
132
|
+
host: "github.com",
|
|
132
133
|
});
|
|
133
134
|
assert.equal(result.pullRequestLookupEnabled, true);
|
|
134
135
|
assert.notEqual(result.pullRequestLookupAt, 0);
|
|
@@ -223,6 +224,7 @@ test("collectPullRequestInfo includes unresolved review thread count", async ()
|
|
|
223
224
|
assert.deepEqual(result.pullRequest, {
|
|
224
225
|
number: 12,
|
|
225
226
|
url: "https://github.com/me/repo/pull/12",
|
|
227
|
+
host: "github.com",
|
|
226
228
|
unresolvedReviewThreadCount: 2,
|
|
227
229
|
});
|
|
228
230
|
});
|
|
@@ -271,7 +273,9 @@ test("collectPullRequestInfo includes PR CI status when requested", async () =>
|
|
|
271
273
|
if (
|
|
272
274
|
command === "gh" &&
|
|
273
275
|
args[0] === "api" &&
|
|
274
|
-
args[1] === "
|
|
276
|
+
args[1] === "--hostname" &&
|
|
277
|
+
args[2] === "github.com" &&
|
|
278
|
+
args[3] === "repos/me/repo/actions/runs?head_sha=abc123&per_page=100"
|
|
275
279
|
) {
|
|
276
280
|
return {
|
|
277
281
|
code: 0,
|
|
@@ -306,6 +310,7 @@ test("collectPullRequestInfo includes PR CI status when requested", async () =>
|
|
|
306
310
|
assert.deepEqual(result.pullRequest, {
|
|
307
311
|
number: 12,
|
|
308
312
|
url: "https://github.com/me/repo/pull/12",
|
|
313
|
+
host: "github.com",
|
|
309
314
|
headRefOid: "abc123",
|
|
310
315
|
ciStatus: {
|
|
311
316
|
state: "failed",
|
|
@@ -314,6 +319,128 @@ test("collectPullRequestInfo includes PR CI status when requested", async () =>
|
|
|
314
319
|
});
|
|
315
320
|
});
|
|
316
321
|
|
|
322
|
+
test("collectPullRequestInfo uses the GitHub Enterprise host for API calls", async () => {
|
|
323
|
+
const { pi, calls } = createPi(({ command, args }) => {
|
|
324
|
+
if (command === "git" && gitSubcommand(args) === "rev-parse") {
|
|
325
|
+
return { code: 0, stdout: "origin/feature\n", stderr: "" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (command === "git" && gitSubcommand(args) === "config") {
|
|
329
|
+
return {
|
|
330
|
+
code: 0,
|
|
331
|
+
stdout: "remote.origin.url git@github.example.com:me/repo.git",
|
|
332
|
+
stderr: "",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (
|
|
337
|
+
command === "gh" &&
|
|
338
|
+
args[0] === "api" &&
|
|
339
|
+
args[1] === "graphql" &&
|
|
340
|
+
args[2] === "--hostname" &&
|
|
341
|
+
args[3] === "github.example.com" &&
|
|
342
|
+
args.includes("branch=feature")
|
|
343
|
+
) {
|
|
344
|
+
return {
|
|
345
|
+
code: 0,
|
|
346
|
+
stdout: JSON.stringify({
|
|
347
|
+
data: {
|
|
348
|
+
repository: {
|
|
349
|
+
pullRequests: {
|
|
350
|
+
nodes: [
|
|
351
|
+
{
|
|
352
|
+
number: 12,
|
|
353
|
+
url: "https://github.example.com/me/repo/pull/12",
|
|
354
|
+
headRefOid: "abc123",
|
|
355
|
+
headRepositoryOwner: { login: "me" },
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
stderr: "",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
command === "gh" &&
|
|
368
|
+
args[0] === "api" &&
|
|
369
|
+
args[1] === "graphql" &&
|
|
370
|
+
args[2] === "--hostname" &&
|
|
371
|
+
args[3] === "github.example.com" &&
|
|
372
|
+
args.includes("number=12")
|
|
373
|
+
) {
|
|
374
|
+
return {
|
|
375
|
+
code: 0,
|
|
376
|
+
stdout: JSON.stringify({
|
|
377
|
+
data: {
|
|
378
|
+
repository: {
|
|
379
|
+
pullRequest: {
|
|
380
|
+
reviewThreads: {
|
|
381
|
+
pageInfo: {
|
|
382
|
+
hasNextPage: false,
|
|
383
|
+
endCursor: null,
|
|
384
|
+
},
|
|
385
|
+
nodes: [{ isResolved: false }],
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
stderr: "",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (
|
|
396
|
+
command === "gh" &&
|
|
397
|
+
args[0] === "api" &&
|
|
398
|
+
args[1] === "--hostname" &&
|
|
399
|
+
args[2] === "github.example.com" &&
|
|
400
|
+
args[3] === "repos/me/repo/actions/runs?head_sha=abc123&per_page=100"
|
|
401
|
+
) {
|
|
402
|
+
return {
|
|
403
|
+
code: 0,
|
|
404
|
+
stdout: JSON.stringify({
|
|
405
|
+
workflow_runs: [
|
|
406
|
+
{
|
|
407
|
+
status: "completed",
|
|
408
|
+
conclusion: "success",
|
|
409
|
+
html_url: "https://github.example.com/me/repo/actions/runs/1",
|
|
410
|
+
updated_at: "2026-01-01T10:00:00Z",
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
}),
|
|
414
|
+
stderr: "",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
throw new Error(`Unexpected command: ${command} ${args.join(" ")}`);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const result = await collectPullRequestInfo(pi as never, "/repo", "feature", {
|
|
422
|
+
includeCiStatus: true,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
assert.deepEqual(result.pullRequest, {
|
|
426
|
+
number: 12,
|
|
427
|
+
url: "https://github.example.com/me/repo/pull/12",
|
|
428
|
+
host: "github.example.com",
|
|
429
|
+
headRefOid: "abc123",
|
|
430
|
+
unresolvedReviewThreadCount: 1,
|
|
431
|
+
ciStatus: {
|
|
432
|
+
state: "okay",
|
|
433
|
+
url: "https://github.example.com/me/repo/actions/runs/1",
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
assert.equal(
|
|
437
|
+
calls
|
|
438
|
+
.filter((call) => call.command === "gh" && call.args[0] === "api")
|
|
439
|
+
.every((call) => call.args.includes("github.example.com")),
|
|
440
|
+
true,
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
317
444
|
test("collectPullRequestInfo skips GitHub CLI lookups when the repository has no GitHub remote", async () => {
|
|
318
445
|
const { pi, calls } = createPi(({ command, args }) => {
|
|
319
446
|
if (command === "git" && gitSubcommand(args) === "rev-parse") {
|
package/src/git.ts
CHANGED
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
parsePullRequest,
|
|
7
7
|
parsePullRequestReviewThreadsPage,
|
|
8
8
|
selectPullRequestFromGraphQL,
|
|
9
|
-
splitGitHubRepository,
|
|
10
9
|
} from "./pull-request.ts";
|
|
11
10
|
import {
|
|
12
11
|
EMPTY_GIT_INFO,
|
|
12
|
+
type GitHubRepositoryRef,
|
|
13
13
|
type GitInfo,
|
|
14
14
|
parseNumstat,
|
|
15
15
|
toNumber,
|
|
@@ -117,12 +117,11 @@ async function execGit(
|
|
|
117
117
|
async function collectPullRequestFromBaseRepository(
|
|
118
118
|
pi: ExtensionAPI,
|
|
119
119
|
cwd: string,
|
|
120
|
-
baseRepository:
|
|
120
|
+
baseRepository: GitHubRepositoryRef,
|
|
121
121
|
branch: string,
|
|
122
122
|
headOwners: string[],
|
|
123
123
|
): Promise<GitInfo["pullRequest"]> {
|
|
124
|
-
|
|
125
|
-
if (!repository || !branch) return undefined;
|
|
124
|
+
if (!branch) return undefined;
|
|
126
125
|
|
|
127
126
|
const result = await execResult(
|
|
128
127
|
pi,
|
|
@@ -130,12 +129,14 @@ async function collectPullRequestFromBaseRepository(
|
|
|
130
129
|
[
|
|
131
130
|
"api",
|
|
132
131
|
"graphql",
|
|
132
|
+
"--hostname",
|
|
133
|
+
baseRepository.host,
|
|
133
134
|
"-f",
|
|
134
135
|
`query=${PULL_REQUEST_QUERY}`,
|
|
135
136
|
"-F",
|
|
136
|
-
`owner=${
|
|
137
|
+
`owner=${baseRepository.owner}`,
|
|
137
138
|
"-F",
|
|
138
|
-
`name=${
|
|
139
|
+
`name=${baseRepository.name}`,
|
|
139
140
|
"-F",
|
|
140
141
|
`branch=${branch}`,
|
|
141
142
|
],
|
|
@@ -144,7 +145,9 @@ async function collectPullRequestFromBaseRepository(
|
|
|
144
145
|
);
|
|
145
146
|
if (result.code !== 0 || !result.stdout) return undefined;
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
const pullRequest = selectPullRequestFromGraphQL(result.stdout, headOwners);
|
|
149
|
+
if (!pullRequest) return undefined;
|
|
150
|
+
return { ...pullRequest, host: pullRequest.host ?? baseRepository.host };
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
async function collectCurrentBranchPullRequest(
|
|
@@ -178,6 +181,8 @@ async function collectPullRequestReviewThreadCount(
|
|
|
178
181
|
const args = [
|
|
179
182
|
"api",
|
|
180
183
|
"graphql",
|
|
184
|
+
"--hostname",
|
|
185
|
+
location.host,
|
|
181
186
|
"-f",
|
|
182
187
|
`query=${PULL_REQUEST_REVIEW_THREADS_QUERY}`,
|
|
183
188
|
"-F",
|
|
@@ -221,11 +226,14 @@ async function collectPullRequestCiStatus(
|
|
|
221
226
|
pullRequest.headRefOid ?? "",
|
|
222
227
|
);
|
|
223
228
|
if (!path) return undefined;
|
|
229
|
+
const location = parseGitHubPullRequestUrl(pullRequest.url);
|
|
230
|
+
const host = pullRequest.host ?? location?.host;
|
|
231
|
+
if (!host) return undefined;
|
|
224
232
|
|
|
225
233
|
const result = await execResult(
|
|
226
234
|
pi,
|
|
227
235
|
"gh",
|
|
228
|
-
["api", path],
|
|
236
|
+
["api", "--hostname", host, path],
|
|
229
237
|
cwd,
|
|
230
238
|
GITHUB_COMMAND_TIMEOUT_MS,
|
|
231
239
|
);
|
|
@@ -14,10 +14,7 @@ import {
|
|
|
14
14
|
providerStatusColor,
|
|
15
15
|
updateProviderStatusFromHeaders,
|
|
16
16
|
} from "./provider-status.ts";
|
|
17
|
-
import {
|
|
18
|
-
getGaugeStyle,
|
|
19
|
-
type ProviderStatusConfigSnapshot,
|
|
20
|
-
} from "./shared.ts";
|
|
17
|
+
import { getGaugeStyle, type ProviderStatusConfigSnapshot } from "./shared.ts";
|
|
21
18
|
|
|
22
19
|
const now = new Date("2026-05-06T10:00:00Z");
|
|
23
20
|
const providerStatusConfig: ProviderStatusConfigSnapshot = {
|
|
@@ -338,6 +335,295 @@ test("collectProviderStatus does not present expired cache as live status after
|
|
|
338
335
|
assert.match(snapshot?.error ?? "", /No usable Codex OAuth credentials/);
|
|
339
336
|
});
|
|
340
337
|
|
|
338
|
+
test("collectProviderStatus keeps cached quota in effect after a failed refresh", async (t) => {
|
|
339
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
340
|
+
t.after(async () => {
|
|
341
|
+
await rm(dir, { recursive: true, force: true });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const previousHome = process.env.HOME;
|
|
345
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
346
|
+
const previousFetch = globalThis.fetch;
|
|
347
|
+
process.env.HOME = dir;
|
|
348
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
349
|
+
globalThis.fetch = async () =>
|
|
350
|
+
new Response(JSON.stringify({ error: { message: "rate limited" } }), {
|
|
351
|
+
status: 429,
|
|
352
|
+
});
|
|
353
|
+
t.after(() => {
|
|
354
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
355
|
+
else process.env.HOME = previousHome;
|
|
356
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
357
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
358
|
+
globalThis.fetch = previousFetch;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
362
|
+
await writeFile(
|
|
363
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
364
|
+
JSON.stringify({ anthropic: { access: "test-access-token" } }),
|
|
365
|
+
{ mode: 0o600 },
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
|
|
369
|
+
const cacheDir = join(
|
|
370
|
+
process.env.XDG_CACHE_HOME,
|
|
371
|
+
"pi-fancy-footer",
|
|
372
|
+
"provider-status",
|
|
373
|
+
);
|
|
374
|
+
await mkdir(cacheDir, { recursive: true });
|
|
375
|
+
await writeFile(
|
|
376
|
+
join(cacheDir, "anthropic.json"),
|
|
377
|
+
JSON.stringify({
|
|
378
|
+
provider: "anthropic",
|
|
379
|
+
source: "api",
|
|
380
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
381
|
+
state: "ok",
|
|
382
|
+
primary: {
|
|
383
|
+
label: "5h",
|
|
384
|
+
usedPercent: 5,
|
|
385
|
+
leftPercent: 95,
|
|
386
|
+
resetAt: futureResetAt,
|
|
387
|
+
},
|
|
388
|
+
secondary: {
|
|
389
|
+
label: "7d",
|
|
390
|
+
usedPercent: 12,
|
|
391
|
+
leftPercent: 88,
|
|
392
|
+
resetAt: futureResetAt,
|
|
393
|
+
},
|
|
394
|
+
url: "https://claude.ai/settings/usage",
|
|
395
|
+
}),
|
|
396
|
+
{ mode: 0o600 },
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
400
|
+
...anthropicProviderStatusConfig,
|
|
401
|
+
cacheTtlMs: 1,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
assert.equal(snapshots.length, 1);
|
|
405
|
+
const snapshot = snapshots[0];
|
|
406
|
+
assert.equal(snapshot?.source, "cache");
|
|
407
|
+
assert.equal(snapshot?.state, "ok");
|
|
408
|
+
assert.deepEqual(snapshot?.primary, {
|
|
409
|
+
label: "5h",
|
|
410
|
+
usedPercent: 5,
|
|
411
|
+
leftPercent: 95,
|
|
412
|
+
resetAt: futureResetAt,
|
|
413
|
+
});
|
|
414
|
+
assert.deepEqual(snapshot?.secondary, {
|
|
415
|
+
label: "7d",
|
|
416
|
+
usedPercent: 12,
|
|
417
|
+
leftPercent: 88,
|
|
418
|
+
resetAt: futureResetAt,
|
|
419
|
+
});
|
|
420
|
+
assert.match(snapshot?.error ?? "", /429/);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("collectProviderStatus hides cached quota once its windows reset", async (t) => {
|
|
424
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
425
|
+
t.after(async () => {
|
|
426
|
+
await rm(dir, { recursive: true, force: true });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const previousHome = process.env.HOME;
|
|
430
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
431
|
+
const previousFetch = globalThis.fetch;
|
|
432
|
+
process.env.HOME = dir;
|
|
433
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
434
|
+
globalThis.fetch = async () => new Response("rate limited", { status: 429 });
|
|
435
|
+
t.after(() => {
|
|
436
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
437
|
+
else process.env.HOME = previousHome;
|
|
438
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
439
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
440
|
+
globalThis.fetch = previousFetch;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
444
|
+
await writeFile(
|
|
445
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
446
|
+
JSON.stringify({ anthropic: { access: "test-access-token" } }),
|
|
447
|
+
{ mode: 0o600 },
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const cacheDir = join(
|
|
451
|
+
process.env.XDG_CACHE_HOME,
|
|
452
|
+
"pi-fancy-footer",
|
|
453
|
+
"provider-status",
|
|
454
|
+
);
|
|
455
|
+
await mkdir(cacheDir, { recursive: true });
|
|
456
|
+
await writeFile(
|
|
457
|
+
join(cacheDir, "anthropic.json"),
|
|
458
|
+
JSON.stringify({
|
|
459
|
+
provider: "anthropic",
|
|
460
|
+
source: "api",
|
|
461
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
462
|
+
state: "ok",
|
|
463
|
+
primary: { label: "5h", usedPercent: 5, leftPercent: 95, resetAt: 1 },
|
|
464
|
+
secondary: {
|
|
465
|
+
label: "7d",
|
|
466
|
+
usedPercent: 12,
|
|
467
|
+
leftPercent: 88,
|
|
468
|
+
resetAt: 1,
|
|
469
|
+
},
|
|
470
|
+
url: "https://claude.ai/settings/usage",
|
|
471
|
+
}),
|
|
472
|
+
{ mode: 0o600 },
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
476
|
+
...anthropicProviderStatusConfig,
|
|
477
|
+
cacheTtlMs: 1,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
assert.equal(snapshots.length, 1);
|
|
481
|
+
const snapshot = snapshots[0];
|
|
482
|
+
assert.equal(snapshot?.source, "api");
|
|
483
|
+
assert.equal(snapshot?.state, "unavailable");
|
|
484
|
+
assert.equal(snapshot?.primary, undefined);
|
|
485
|
+
assert.equal(snapshot?.secondary, undefined);
|
|
486
|
+
assert.match(snapshot?.error ?? "", /429/);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("collectProviderStatus keeps cached quota in effect after a failed auth refresh", async (t) => {
|
|
490
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
491
|
+
t.after(async () => {
|
|
492
|
+
await rm(dir, { recursive: true, force: true });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const previousHome = process.env.HOME;
|
|
496
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
497
|
+
const previousFetch = globalThis.fetch;
|
|
498
|
+
process.env.HOME = dir;
|
|
499
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
500
|
+
globalThis.fetch = async () => new Response("rate limited", { status: 429 });
|
|
501
|
+
t.after(() => {
|
|
502
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
503
|
+
else process.env.HOME = previousHome;
|
|
504
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
505
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
506
|
+
globalThis.fetch = previousFetch;
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
510
|
+
await writeFile(
|
|
511
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
512
|
+
JSON.stringify({
|
|
513
|
+
anthropic: {
|
|
514
|
+
access: "expired-access-token",
|
|
515
|
+
refresh: "test-refresh-token",
|
|
516
|
+
expires: Date.now() - 60_000,
|
|
517
|
+
},
|
|
518
|
+
}),
|
|
519
|
+
{ mode: 0o600 },
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
|
|
523
|
+
const cacheDir = join(
|
|
524
|
+
process.env.XDG_CACHE_HOME,
|
|
525
|
+
"pi-fancy-footer",
|
|
526
|
+
"provider-status",
|
|
527
|
+
);
|
|
528
|
+
await mkdir(cacheDir, { recursive: true });
|
|
529
|
+
await writeFile(
|
|
530
|
+
join(cacheDir, "anthropic.json"),
|
|
531
|
+
JSON.stringify({
|
|
532
|
+
provider: "anthropic",
|
|
533
|
+
source: "api",
|
|
534
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
535
|
+
state: "ok",
|
|
536
|
+
primary: {
|
|
537
|
+
label: "5h",
|
|
538
|
+
usedPercent: 5,
|
|
539
|
+
leftPercent: 95,
|
|
540
|
+
resetAt: futureResetAt,
|
|
541
|
+
},
|
|
542
|
+
url: "https://claude.ai/settings/usage",
|
|
543
|
+
}),
|
|
544
|
+
{ mode: 0o600 },
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
548
|
+
...anthropicProviderStatusConfig,
|
|
549
|
+
cacheTtlMs: 1,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
assert.equal(snapshots.length, 1);
|
|
553
|
+
const snapshot = snapshots[0];
|
|
554
|
+
assert.equal(snapshot?.source, "cache");
|
|
555
|
+
assert.equal(snapshot?.state, "ok");
|
|
556
|
+
assert.equal(snapshot?.primary?.resetAt, futureResetAt);
|
|
557
|
+
assert.match(snapshot?.error ?? "", /429/);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("collectProviderStatus keeps cached quota in effect regardless of the failure cause", async (t) => {
|
|
561
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
562
|
+
t.after(async () => {
|
|
563
|
+
await rm(dir, { recursive: true, force: true });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const previousHome = process.env.HOME;
|
|
567
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
568
|
+
const previousFetch = globalThis.fetch;
|
|
569
|
+
process.env.HOME = dir;
|
|
570
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
571
|
+
// A non-retryable failure (e.g. revoked credentials) must not invalidate a
|
|
572
|
+
// quota window that has not reset yet.
|
|
573
|
+
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
|
|
574
|
+
t.after(() => {
|
|
575
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
576
|
+
else process.env.HOME = previousHome;
|
|
577
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
578
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
579
|
+
globalThis.fetch = previousFetch;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
583
|
+
await writeFile(
|
|
584
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
585
|
+
JSON.stringify({ anthropic: { access: "test-access-token" } }),
|
|
586
|
+
{ mode: 0o600 },
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
|
|
590
|
+
const cacheDir = join(
|
|
591
|
+
process.env.XDG_CACHE_HOME,
|
|
592
|
+
"pi-fancy-footer",
|
|
593
|
+
"provider-status",
|
|
594
|
+
);
|
|
595
|
+
await mkdir(cacheDir, { recursive: true });
|
|
596
|
+
await writeFile(
|
|
597
|
+
join(cacheDir, "anthropic.json"),
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
provider: "anthropic",
|
|
600
|
+
source: "api",
|
|
601
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
602
|
+
state: "ok",
|
|
603
|
+
primary: {
|
|
604
|
+
label: "5h",
|
|
605
|
+
usedPercent: 5,
|
|
606
|
+
leftPercent: 95,
|
|
607
|
+
resetAt: futureResetAt,
|
|
608
|
+
},
|
|
609
|
+
url: "https://claude.ai/settings/usage",
|
|
610
|
+
}),
|
|
611
|
+
{ mode: 0o600 },
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
615
|
+
...anthropicProviderStatusConfig,
|
|
616
|
+
cacheTtlMs: 1,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
assert.equal(snapshots.length, 1);
|
|
620
|
+
const snapshot = snapshots[0];
|
|
621
|
+
assert.equal(snapshot?.source, "cache");
|
|
622
|
+
assert.equal(snapshot?.state, "ok");
|
|
623
|
+
assert.equal(snapshot?.primary?.resetAt, futureResetAt);
|
|
624
|
+
assert.match(snapshot?.error ?? "", /403/);
|
|
625
|
+
});
|
|
626
|
+
|
|
341
627
|
test("updateProviderStatusFromHeaders honors disabled providers", async () => {
|
|
342
628
|
const updated = await updateProviderStatusFromHeaders(
|
|
343
629
|
{
|
package/src/provider-status.ts
CHANGED
|
@@ -28,6 +28,10 @@ const CLAUDE_SECONDARY_WINDOW_LABEL = "7d";
|
|
|
28
28
|
|
|
29
29
|
type HeaderLike = Record<string, string | number | boolean | undefined | null>;
|
|
30
30
|
|
|
31
|
+
type TokenRefreshResult =
|
|
32
|
+
| { ok: true; stdout: string }
|
|
33
|
+
| { ok: false; error: Error };
|
|
34
|
+
|
|
31
35
|
export interface ProviderStatusSource {
|
|
32
36
|
id: string;
|
|
33
37
|
label: string;
|
|
@@ -106,10 +110,7 @@ export function isProviderStatusRelevantToModel(
|
|
|
106
110
|
providerId: string,
|
|
107
111
|
model: ModelLike | string | undefined,
|
|
108
112
|
): boolean {
|
|
109
|
-
if (
|
|
110
|
-
providerId !== CODEX_SOURCE.id &&
|
|
111
|
-
providerId !== ANTHROPIC_SOURCE.id
|
|
112
|
-
) {
|
|
113
|
+
if (providerId !== CODEX_SOURCE.id && providerId !== ANTHROPIC_SOURCE.id) {
|
|
113
114
|
return true;
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -238,20 +239,76 @@ async function collectProviderStatusFromSource(
|
|
|
238
239
|
await writeProviderStatusCache(snapshot).catch(() => undefined);
|
|
239
240
|
return snapshot;
|
|
240
241
|
} catch (error) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return
|
|
245
|
-
|
|
246
|
-
source
|
|
247
|
-
|
|
248
|
-
state: "unavailable",
|
|
249
|
-
url: source.usageUrl,
|
|
250
|
-
error: error instanceof Error ? error.message : String(error),
|
|
251
|
-
};
|
|
242
|
+
// A refresh failed. The cached quota windows remain valid until they
|
|
243
|
+
// reset, regardless of why the refresh failed, so keep showing whichever
|
|
244
|
+
// windows are still in effect.
|
|
245
|
+
return (
|
|
246
|
+
displayableCachedStatus(cached, error) ??
|
|
247
|
+
unavailableProviderStatus(source, error)
|
|
248
|
+
);
|
|
252
249
|
}
|
|
253
250
|
}
|
|
254
251
|
|
|
252
|
+
// Projects a cached snapshot onto the windows that are still in effect, i.e.
|
|
253
|
+
// have not reset yet. Returns undefined when nothing is left to display.
|
|
254
|
+
function displayableCachedStatus(
|
|
255
|
+
cached: ProviderStatusSnapshot | undefined,
|
|
256
|
+
error: unknown,
|
|
257
|
+
now = new Date(),
|
|
258
|
+
): ProviderStatusSnapshot | undefined {
|
|
259
|
+
if (!cached) return undefined;
|
|
260
|
+
|
|
261
|
+
const primary = windowInEffect(cached.primary, now);
|
|
262
|
+
const secondary = windowInEffect(cached.secondary, now);
|
|
263
|
+
if (!primary && !secondary) return undefined;
|
|
264
|
+
|
|
265
|
+
const {
|
|
266
|
+
primary: _expiredPrimary,
|
|
267
|
+
secondary: _expiredSecondary,
|
|
268
|
+
...rest
|
|
269
|
+
} = cached;
|
|
270
|
+
return {
|
|
271
|
+
...rest,
|
|
272
|
+
source: "cache",
|
|
273
|
+
state: computeProviderStatusState(primary, secondary),
|
|
274
|
+
...(primary ? { primary } : {}),
|
|
275
|
+
...(secondary ? { secondary } : {}),
|
|
276
|
+
error: providerStatusErrorMessage(error),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A window is in effect while its reset time is still in the future. Windows
|
|
281
|
+
// without a reset time have no intrinsic lifetime, so they are not shown once
|
|
282
|
+
// the refresh that would confirm them has failed.
|
|
283
|
+
function windowInEffect(
|
|
284
|
+
window: ProviderStatusWindow | undefined,
|
|
285
|
+
now: Date,
|
|
286
|
+
): ProviderStatusWindow | undefined {
|
|
287
|
+
if (!window?.resetAt) return undefined;
|
|
288
|
+
const resetAtMs = window.resetAt * 1000;
|
|
289
|
+
return Number.isFinite(resetAtMs) && resetAtMs > now.getTime()
|
|
290
|
+
? window
|
|
291
|
+
: undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function unavailableProviderStatus(
|
|
295
|
+
source: ProviderStatusSource,
|
|
296
|
+
error: unknown,
|
|
297
|
+
): ProviderStatusSnapshot {
|
|
298
|
+
return {
|
|
299
|
+
provider: source.id,
|
|
300
|
+
source: "api",
|
|
301
|
+
fetchedAt: new Date().toISOString(),
|
|
302
|
+
state: "unavailable",
|
|
303
|
+
url: source.usageUrl,
|
|
304
|
+
error: providerStatusErrorMessage(error),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function providerStatusErrorMessage(error: unknown): string {
|
|
309
|
+
return error instanceof Error ? error.message : String(error);
|
|
310
|
+
}
|
|
311
|
+
|
|
255
312
|
export async function updateProviderStatusFromHeaders(
|
|
256
313
|
headers: HeaderLike,
|
|
257
314
|
config?: ProviderStatusConfigSnapshot,
|
|
@@ -495,7 +552,9 @@ async function resolveClaudeAuth(): Promise<AuthCredentials> {
|
|
|
495
552
|
);
|
|
496
553
|
}
|
|
497
554
|
|
|
498
|
-
async function refreshIfNeeded(
|
|
555
|
+
async function refreshIfNeeded(
|
|
556
|
+
auth: AuthCredentials,
|
|
557
|
+
): Promise<AuthCredentials> {
|
|
499
558
|
if (!auth.refreshToken || !auth.expiresAtMs) return auth;
|
|
500
559
|
if (auth.expiresAtMs > Date.now() + 5 * 60 * 1000) return auth;
|
|
501
560
|
return refreshAuth(auth);
|
|
@@ -522,12 +581,15 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
|
|
|
522
581
|
client_id: refreshConfig.clientId,
|
|
523
582
|
}).toString();
|
|
524
583
|
|
|
525
|
-
const result = await
|
|
584
|
+
const result = await requestTokenRefresh(
|
|
585
|
+
body,
|
|
586
|
+
refreshConfig.tokenUrl,
|
|
587
|
+
`${refreshConfig.label} auth refresh`,
|
|
588
|
+
);
|
|
526
589
|
|
|
527
|
-
if (result.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
);
|
|
590
|
+
if (!result.ok) throw result.error;
|
|
591
|
+
if (!result.stdout) {
|
|
592
|
+
throw new Error(`Failed to refresh ${refreshConfig.label} auth`);
|
|
531
593
|
}
|
|
532
594
|
|
|
533
595
|
const refreshed = JSON.parse(result.stdout) as Record<string, unknown>;
|
|
@@ -538,7 +600,8 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
|
|
|
538
600
|
);
|
|
539
601
|
}
|
|
540
602
|
|
|
541
|
-
const refreshToken =
|
|
603
|
+
const refreshToken =
|
|
604
|
+
stringValue(refreshed.refresh_token) ?? auth.refreshToken;
|
|
542
605
|
const expiresIn = numberValue(refreshed.expires_in);
|
|
543
606
|
const next: AuthCredentials = {
|
|
544
607
|
...auth,
|
|
@@ -552,10 +615,11 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
|
|
|
552
615
|
return next;
|
|
553
616
|
}
|
|
554
617
|
|
|
555
|
-
async function
|
|
618
|
+
async function requestTokenRefresh(
|
|
556
619
|
body: string,
|
|
557
620
|
tokenUrl: string,
|
|
558
|
-
|
|
621
|
+
label: string,
|
|
622
|
+
): Promise<TokenRefreshResult> {
|
|
559
623
|
try {
|
|
560
624
|
const response = await fetch(tokenUrl, {
|
|
561
625
|
method: "POST",
|
|
@@ -563,13 +627,19 @@ async function localRefresh(
|
|
|
563
627
|
body,
|
|
564
628
|
});
|
|
565
629
|
const text = await response.text();
|
|
566
|
-
if (!response.ok)
|
|
567
|
-
|
|
630
|
+
if (!response.ok) {
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
error: new Error(
|
|
634
|
+
`${label} failed (${response.status}): ${text.slice(0, 500)}`,
|
|
635
|
+
),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
return { ok: true, stdout: text };
|
|
568
639
|
} catch (error) {
|
|
569
640
|
return {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
stderr: error instanceof Error ? error.message : String(error),
|
|
641
|
+
ok: false,
|
|
642
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
573
643
|
};
|
|
574
644
|
}
|
|
575
645
|
}
|
|
@@ -705,7 +775,7 @@ function mergeProviderStatus(
|
|
|
705
775
|
...update,
|
|
706
776
|
...(primary ? { primary } : {}),
|
|
707
777
|
...(secondary ? { secondary } : {}),
|
|
708
|
-
...(update.credits ?? existing.credits
|
|
778
|
+
...((update.credits ?? existing.credits)
|
|
709
779
|
? { credits: update.credits ?? existing.credits }
|
|
710
780
|
: {}),
|
|
711
781
|
state: computeProviderStatusState(primary, secondary),
|
|
@@ -762,7 +832,9 @@ function normalizeClaudeUsageWindow(
|
|
|
762
832
|
);
|
|
763
833
|
}
|
|
764
834
|
|
|
765
|
-
function windowLabelFromSeconds(
|
|
835
|
+
function windowLabelFromSeconds(
|
|
836
|
+
seconds: number | undefined,
|
|
837
|
+
): string | undefined {
|
|
766
838
|
if (seconds === undefined || seconds <= 0) return undefined;
|
|
767
839
|
if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
|
|
768
840
|
if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
|
|
@@ -854,7 +926,9 @@ function stringValue(value: unknown): string | undefined {
|
|
|
854
926
|
}
|
|
855
927
|
|
|
856
928
|
function numberValue(value: unknown): number | undefined {
|
|
857
|
-
return typeof value === "number" && Number.isFinite(value)
|
|
929
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
930
|
+
? value
|
|
931
|
+
: undefined;
|
|
858
932
|
}
|
|
859
933
|
|
|
860
934
|
function numberString(value: string | undefined): number | undefined {
|
package/src/pull-request.test.ts
CHANGED
|
@@ -19,16 +19,84 @@ test("createGitHubRepositoryContext derives repository and PR lookup plan from r
|
|
|
19
19
|
assert.equal(context.repository, "me/repo");
|
|
20
20
|
assert.equal(context.pullRequestLookupEnabled, true);
|
|
21
21
|
assert.deepEqual(context.pullRequestLookupPlan, {
|
|
22
|
-
baseRepositories: [
|
|
22
|
+
baseRepositories: [
|
|
23
|
+
{
|
|
24
|
+
host: "github.com",
|
|
25
|
+
owner: "org",
|
|
26
|
+
name: "repo",
|
|
27
|
+
repository: "org/repo",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
host: "github.com",
|
|
31
|
+
owner: "me",
|
|
32
|
+
name: "repo",
|
|
33
|
+
repository: "me/repo",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
23
36
|
headOwners: ["me", "org"],
|
|
24
37
|
allowCurrentBranchFallback: true,
|
|
25
38
|
});
|
|
26
39
|
});
|
|
27
40
|
|
|
41
|
+
test("createGitHubRepositoryContext supports GitHub Enterprise hosts", () => {
|
|
42
|
+
const context = createGitHubRepositoryContext(
|
|
43
|
+
"remote.origin.url git@github.example.com:org/repo.git",
|
|
44
|
+
"origin/main",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
assert.equal(context.repository, "org/repo");
|
|
48
|
+
assert.equal(context.pullRequestLookupEnabled, true);
|
|
49
|
+
assert.deepEqual(context.pullRequestLookupPlan, {
|
|
50
|
+
baseRepositories: [
|
|
51
|
+
{
|
|
52
|
+
host: "github.example.com",
|
|
53
|
+
owner: "org",
|
|
54
|
+
name: "repo",
|
|
55
|
+
repository: "org/repo",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
headOwners: ["org"],
|
|
59
|
+
allowCurrentBranchFallback: true,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("createGitHubRepositoryContext supports GitHub Enterprise SSH URLs with ports", () => {
|
|
64
|
+
const context = createGitHubRepositoryContext(
|
|
65
|
+
"remote.origin.url ssh://git@github.example.com:2222/org/repo.git",
|
|
66
|
+
"origin/main",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
assert.equal(context.repository, "org/repo");
|
|
70
|
+
assert.deepEqual(context.pullRequestLookupPlan?.baseRepositories, [
|
|
71
|
+
{
|
|
72
|
+
host: "github.example.com",
|
|
73
|
+
owner: "org",
|
|
74
|
+
name: "repo",
|
|
75
|
+
repository: "org/repo",
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("createGitHubRepositoryContext excludes non-GitHub hosts", () => {
|
|
81
|
+
const context = createGitHubRepositoryContext(
|
|
82
|
+
[
|
|
83
|
+
"remote.origin.url git@notgithub.com:org/repo.git",
|
|
84
|
+
"remote.upstream.url https://gitlab.githubmirror.com/org/repo.git",
|
|
85
|
+
"remote.fork.url https://gitlab.com/org/repo.git",
|
|
86
|
+
].join("\n"),
|
|
87
|
+
"origin/main",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
assert.equal(context.repository, "");
|
|
91
|
+
assert.equal(context.pullRequestLookupEnabled, false);
|
|
92
|
+
assert.equal(context.pullRequestLookupPlan, undefined);
|
|
93
|
+
});
|
|
94
|
+
|
|
28
95
|
test("parseGitHubPullRequestUrl extracts owner, repository, and PR number", () => {
|
|
29
96
|
assert.deepEqual(
|
|
30
97
|
parseGitHubPullRequestUrl("https://github.com/org/repo/pull/42"),
|
|
31
98
|
{
|
|
99
|
+
host: "github.com",
|
|
32
100
|
owner: "org",
|
|
33
101
|
name: "repo",
|
|
34
102
|
number: 42,
|
|
@@ -39,6 +107,16 @@ test("parseGitHubPullRequestUrl extracts owner, repository, and PR number", () =
|
|
|
39
107
|
"https://github.com/org/repo/pull/42#discussion_r1",
|
|
40
108
|
),
|
|
41
109
|
{
|
|
110
|
+
host: "github.com",
|
|
111
|
+
owner: "org",
|
|
112
|
+
name: "repo",
|
|
113
|
+
number: 42,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
parseGitHubPullRequestUrl("https://github.example.com/org/repo/pull/42"),
|
|
118
|
+
{
|
|
119
|
+
host: "github.example.com",
|
|
42
120
|
owner: "org",
|
|
43
121
|
name: "repo",
|
|
44
122
|
number: 42,
|
|
@@ -103,6 +181,7 @@ test("selectPullRequestFromGraphQL accepts only candidates from known head owner
|
|
|
103
181
|
assert.deepEqual(selectPullRequestFromGraphQL(output, ["me", "org"]), {
|
|
104
182
|
number: 7,
|
|
105
183
|
url: "https://github.com/org/repo/pull/7",
|
|
184
|
+
host: "github.com",
|
|
106
185
|
});
|
|
107
186
|
assert.equal(selectPullRequestFromGraphQL(output, ["unknown"]), undefined);
|
|
108
187
|
});
|
package/src/pull-request.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type GitHubPullRequest,
|
|
3
|
+
type GitHubRepositoryRef,
|
|
3
4
|
parseGitHubRemote,
|
|
4
5
|
toNumber,
|
|
5
6
|
} from "./shared.ts";
|
|
6
7
|
|
|
7
8
|
interface GitHubRemote {
|
|
8
9
|
name: string;
|
|
9
|
-
|
|
10
|
-
owner: string;
|
|
10
|
+
ref: GitHubRepositoryRef;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
interface PullRequestCandidate {
|
|
@@ -18,6 +18,7 @@ interface PullRequestCandidate {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface GitHubPullRequestLocation {
|
|
21
|
+
host: string;
|
|
21
22
|
owner: string;
|
|
22
23
|
name: string;
|
|
23
24
|
number: number;
|
|
@@ -30,7 +31,7 @@ export interface PullRequestReviewThreadsPage {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface PullRequestLookupPlan {
|
|
33
|
-
baseRepositories:
|
|
34
|
+
baseRepositories: GitHubRepositoryRef[];
|
|
34
35
|
headOwners: string[];
|
|
35
36
|
allowCurrentBranchFallback: boolean;
|
|
36
37
|
}
|
|
@@ -61,21 +62,13 @@ function parseRemoteName(ref: string): string {
|
|
|
61
62
|
return ref.slice(0, slash);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function parseRepositoryOwner(repository: string): string {
|
|
65
|
-
const slash = repository.indexOf("/");
|
|
66
|
-
if (slash <= 0) return "";
|
|
67
|
-
return repository.slice(0, slash);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
65
|
function parseGitHubRemotes(remoteUrls: string): GitHubRemote[] {
|
|
71
66
|
const remotes: GitHubRemote[] = [];
|
|
72
67
|
|
|
73
68
|
for (const [name, url] of parseRemoteUrls(remoteUrls)) {
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
76
|
-
|
|
77
|
-
if (!owner) continue;
|
|
78
|
-
remotes.push({ name, repository, owner });
|
|
69
|
+
const ref = parseGitHubRemote(url);
|
|
70
|
+
if (!ref) continue;
|
|
71
|
+
remotes.push({ name, ref });
|
|
79
72
|
}
|
|
80
73
|
|
|
81
74
|
return remotes;
|
|
@@ -85,6 +78,7 @@ function orderedRemoteValues<T>(
|
|
|
85
78
|
remotes: GitHubRemote[],
|
|
86
79
|
preferredNames: string[],
|
|
87
80
|
pick: (remote: GitHubRemote) => T,
|
|
81
|
+
keyFor: (value: T) => string = String,
|
|
88
82
|
): T[] {
|
|
89
83
|
const byName = new Map(remotes.map((remote) => [remote.name, remote]));
|
|
90
84
|
const ordered: T[] = [];
|
|
@@ -98,7 +92,7 @@ function orderedRemoteValues<T>(
|
|
|
98
92
|
const remote = byName.get(remoteName);
|
|
99
93
|
if (!remote) continue;
|
|
100
94
|
const value = pick(remote);
|
|
101
|
-
const key =
|
|
95
|
+
const key = keyFor(value);
|
|
102
96
|
if (!key || seen.has(key)) continue;
|
|
103
97
|
seen.add(key);
|
|
104
98
|
ordered.push(value);
|
|
@@ -115,7 +109,7 @@ function selectGitHubRepository(
|
|
|
115
109
|
orderedRemoteValues(
|
|
116
110
|
remotes,
|
|
117
111
|
[preferredRemote, "origin", "upstream"],
|
|
118
|
-
(remote) => remote.repository,
|
|
112
|
+
(remote) => remote.ref.repository,
|
|
119
113
|
)[0] ?? ""
|
|
120
114
|
);
|
|
121
115
|
}
|
|
@@ -123,12 +117,13 @@ function selectGitHubRepository(
|
|
|
123
117
|
function selectPullRequestBaseRepositories(
|
|
124
118
|
remotes: GitHubRemote[],
|
|
125
119
|
preferredRemote: string,
|
|
126
|
-
):
|
|
120
|
+
): GitHubRepositoryRef[] {
|
|
127
121
|
// PRs often live in the upstream repo even when the branch tracks a fork remote.
|
|
128
122
|
return orderedRemoteValues(
|
|
129
123
|
remotes,
|
|
130
124
|
["upstream", preferredRemote, "origin"],
|
|
131
|
-
(remote) => remote.
|
|
125
|
+
(remote) => remote.ref,
|
|
126
|
+
(ref) => `${ref.host}/${ref.repository}`,
|
|
132
127
|
);
|
|
133
128
|
}
|
|
134
129
|
|
|
@@ -139,7 +134,7 @@ function selectPullRequestHeadOwners(
|
|
|
139
134
|
return orderedRemoteValues(
|
|
140
135
|
remotes,
|
|
141
136
|
[preferredRemote, "origin", "upstream"],
|
|
142
|
-
(remote) => remote.owner,
|
|
137
|
+
(remote) => remote.ref.owner,
|
|
143
138
|
);
|
|
144
139
|
}
|
|
145
140
|
|
|
@@ -178,9 +173,12 @@ function selectPullRequest(
|
|
|
178
173
|
|
|
179
174
|
if (!bestCandidate) return undefined;
|
|
180
175
|
|
|
176
|
+
const location = parseGitHubPullRequestUrl(bestCandidate.url);
|
|
177
|
+
|
|
181
178
|
return {
|
|
182
179
|
number: bestCandidate.number,
|
|
183
180
|
url: bestCandidate.url,
|
|
181
|
+
...(location ? { host: location.host } : {}),
|
|
184
182
|
...(bestCandidate.headRefOid
|
|
185
183
|
? { headRefOid: bestCandidate.headRefOid }
|
|
186
184
|
: {}),
|
|
@@ -244,17 +242,6 @@ export function createGitHubRepositoryContext(
|
|
|
244
242
|
};
|
|
245
243
|
}
|
|
246
244
|
|
|
247
|
-
export function splitGitHubRepository(
|
|
248
|
-
repository: string,
|
|
249
|
-
): { owner: string; name: string } | undefined {
|
|
250
|
-
const slash = repository.indexOf("/");
|
|
251
|
-
if (slash <= 0 || slash >= repository.length - 1) return undefined;
|
|
252
|
-
return {
|
|
253
|
-
owner: repository.slice(0, slash),
|
|
254
|
-
name: repository.slice(slash + 1),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
245
|
export function parsePullRequest(
|
|
259
246
|
output: string,
|
|
260
247
|
): GitHubPullRequest | undefined {
|
|
@@ -269,7 +256,13 @@ export function parsePullRequest(
|
|
|
269
256
|
const headRefOid =
|
|
270
257
|
typeof parsed?.headRefOid === "string" ? parsed.headRefOid : undefined;
|
|
271
258
|
if (number <= 0 || !url) return undefined;
|
|
272
|
-
|
|
259
|
+
const location = parseGitHubPullRequestUrl(url);
|
|
260
|
+
return {
|
|
261
|
+
number,
|
|
262
|
+
url,
|
|
263
|
+
...(location ? { host: location.host } : {}),
|
|
264
|
+
...(headRefOid ? { headRefOid } : {}),
|
|
265
|
+
};
|
|
273
266
|
} catch {
|
|
274
267
|
return undefined;
|
|
275
268
|
}
|
|
@@ -279,14 +272,15 @@ export function parseGitHubPullRequestUrl(
|
|
|
279
272
|
url: string,
|
|
280
273
|
): GitHubPullRequestLocation | undefined {
|
|
281
274
|
const match = url.match(
|
|
282
|
-
/^https:\/\/
|
|
275
|
+
/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/,
|
|
283
276
|
);
|
|
284
277
|
if (!match) return undefined;
|
|
285
278
|
|
|
286
|
-
const [, owner, name, numberText] = match;
|
|
279
|
+
const [, rawHost, owner, name, numberText] = match;
|
|
280
|
+
const hostRef = parseGitHubRemote(`https://${rawHost}/${owner}/${name}.git`);
|
|
287
281
|
const number = Math.max(0, Math.floor(toNumber(numberText)));
|
|
288
|
-
if (!
|
|
289
|
-
return { owner, name, number };
|
|
282
|
+
if (!hostRef || number <= 0) return undefined;
|
|
283
|
+
return { host: hostRef.host, owner, name, number };
|
|
290
284
|
}
|
|
291
285
|
|
|
292
286
|
export function parsePullRequestReviewThreadsPage(
|
package/src/shared.ts
CHANGED
|
@@ -272,6 +272,7 @@ export interface ProviderStatusSnapshot {
|
|
|
272
272
|
export interface GitHubPullRequest {
|
|
273
273
|
number: number;
|
|
274
274
|
url: string;
|
|
275
|
+
host?: string;
|
|
275
276
|
headRefOid?: string;
|
|
276
277
|
unresolvedReviewThreadCount?: number;
|
|
277
278
|
ciStatus?: {
|
|
@@ -280,6 +281,13 @@ export interface GitHubPullRequest {
|
|
|
280
281
|
};
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
export interface GitHubRepositoryRef {
|
|
285
|
+
host: string;
|
|
286
|
+
owner: string;
|
|
287
|
+
name: string;
|
|
288
|
+
repository: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
283
291
|
export interface GitInfo {
|
|
284
292
|
repository: string;
|
|
285
293
|
branch: string;
|
|
@@ -761,9 +769,40 @@ export function formatThinkingLevel(level: string): string {
|
|
|
761
769
|
return normalized;
|
|
762
770
|
}
|
|
763
771
|
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
772
|
+
function parseGitHubHost(host: string): string {
|
|
773
|
+
const normalized = host.toLowerCase();
|
|
774
|
+
if (normalized === "github.com") return normalized;
|
|
775
|
+
if (/^github(?:[.-][a-z0-9][a-z0-9-]*)+\.[a-z]{2,}$/i.test(normalized)) {
|
|
776
|
+
return normalized;
|
|
777
|
+
}
|
|
778
|
+
return "";
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export function parseGitHubRemote(url: string): GitHubRepositoryRef | undefined {
|
|
782
|
+
const trimmed = url.trim();
|
|
783
|
+
const scpLike = trimmed.includes("://")
|
|
784
|
+
? undefined
|
|
785
|
+
: trimmed.match(/^.+@([^:/]+):([^/][^:]*\/[^:]+)$/);
|
|
786
|
+
const urlLike = trimmed.match(
|
|
787
|
+
/^(?:https?:\/\/|ssh:\/\/.+@)([^/:]+)(?::\d+)?\/(.+\/.+)$/,
|
|
788
|
+
);
|
|
789
|
+
const match = scpLike ?? urlLike;
|
|
790
|
+
if (!match) return undefined;
|
|
791
|
+
|
|
792
|
+
const [, rawHost, rawRepository] = match;
|
|
793
|
+
const host = parseGitHubHost(rawHost ?? "");
|
|
794
|
+
if (!host || !rawRepository) return undefined;
|
|
795
|
+
|
|
796
|
+
const repository = rawRepository.replace(/\.git$/i, "");
|
|
797
|
+
const slash = repository.indexOf("/");
|
|
798
|
+
if (slash <= 0 || slash >= repository.length - 1) return undefined;
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
host,
|
|
802
|
+
owner: repository.slice(0, slash),
|
|
803
|
+
name: repository.slice(slash + 1),
|
|
804
|
+
repository,
|
|
805
|
+
};
|
|
767
806
|
}
|
|
768
807
|
|
|
769
808
|
export function formatTerminalHyperlink(url: string, text: string): string {
|