@openparachute/vault 0.6.0-rc.1 → 0.6.1
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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared git-availability preflight (vault#415).
|
|
3
|
+
*
|
|
4
|
+
* Found live: importing a repo on a git-less Amazon Linux EC2 box failed
|
|
5
|
+
* with a raw `Executable not found in $PATH: "git"` 500. The preflight gives
|
|
6
|
+
* every git entry point a fast, friendly, actionable failure instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
GitNotInstalledError,
|
|
12
|
+
ensureGitAvailable,
|
|
13
|
+
isGitNotFoundSpawnError,
|
|
14
|
+
} from "./git-preflight.ts";
|
|
15
|
+
|
|
16
|
+
describe("ensureGitAvailable", () => {
|
|
17
|
+
test("throws GitNotInstalledError when which returns null", () => {
|
|
18
|
+
expect(() => ensureGitAvailable(() => null)).toThrow(GitNotInstalledError);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("does not throw when which resolves git", () => {
|
|
22
|
+
expect(() => ensureGitAvailable(() => "/usr/bin/git")).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("defaults to Bun.which (git is present in this test env)", () => {
|
|
26
|
+
// The test host has git; the default-arg path resolves it cleanly.
|
|
27
|
+
expect(() => ensureGitAvailable()).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("GitNotInstalledError message", () => {
|
|
32
|
+
test("is OS-agnostic-but-helpful — names dnf, apt-get, and brew", () => {
|
|
33
|
+
const msg = new GitNotInstalledError().message;
|
|
34
|
+
expect(msg).toContain("git is required for this operation");
|
|
35
|
+
expect(msg).toContain("sudo dnf install git");
|
|
36
|
+
expect(msg).toContain("sudo apt-get install -y git");
|
|
37
|
+
expect(msg).toContain("brew install git");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("carries the GitNotInstalledError name (instanceof + name both work)", () => {
|
|
41
|
+
const err = new GitNotInstalledError();
|
|
42
|
+
expect(err).toBeInstanceOf(GitNotInstalledError);
|
|
43
|
+
expect(err.name).toBe("GitNotInstalledError");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isGitNotFoundSpawnError", () => {
|
|
48
|
+
test("matches Bun's executable-not-found message for git", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isGitNotFoundSpawnError(
|
|
51
|
+
new Error('Executable not found in $PATH: "git"'),
|
|
52
|
+
),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("matches an ENOENT spawn error mentioning git", () => {
|
|
57
|
+
const err = new Error("spawn git ENOENT") as Error & { code?: string };
|
|
58
|
+
err.code = "ENOENT";
|
|
59
|
+
expect(isGitNotFoundSpawnError(err)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not match an unrelated error", () => {
|
|
63
|
+
expect(isGitNotFoundSpawnError(new Error("network unreachable"))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not match a non-Error value", () => {
|
|
67
|
+
expect(isGitNotFoundSpawnError("git missing")).toBe(false);
|
|
68
|
+
expect(isGitNotFoundSpawnError(null)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git-availability preflight.
|
|
3
|
+
*
|
|
4
|
+
* Every git-using entry point in vault (mirror import, mirror sync/commit/
|
|
5
|
+
* push, internal-mirror bootstrap) shells out to the `git` binary via
|
|
6
|
+
* `Bun.spawn(["git", ...])`. On a server where `git` isn't installed (a
|
|
7
|
+
* fresh Amazon Linux / minimal Docker image, etc.) Bun throws a raw
|
|
8
|
+
* `Executable not found in $PATH: "git"` error, which the import route only
|
|
9
|
+
* caught in its generic `internal` 500 branch — surfacing an unhelpful,
|
|
10
|
+
* un-actionable error to the operator.
|
|
11
|
+
*
|
|
12
|
+
* This module centralizes the preflight so every git entry point fails
|
|
13
|
+
* fast with a clear, actionable message that tells the operator HOW to
|
|
14
|
+
* fix it (install git via their distro's package manager).
|
|
15
|
+
*
|
|
16
|
+
* Found live on the gitcoin-parachute EC2 deploy (Amazon Linux, no git).
|
|
17
|
+
* See vault#415-era fix.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Thrown when `git` is required for an operation but isn't on PATH. Carries
|
|
22
|
+
* an OS-agnostic-but-helpful message with the common install commands so the
|
|
23
|
+
* operator can act without leaving the error surface.
|
|
24
|
+
*/
|
|
25
|
+
export class GitNotInstalledError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(
|
|
28
|
+
"git is required for this operation but was not found on the server. " +
|
|
29
|
+
"Install git and retry — e.g. `sudo dnf install git` (Amazon Linux / Fedora), " +
|
|
30
|
+
"`sudo apt-get install -y git` (Debian / Ubuntu), or `brew install git` (macOS).",
|
|
31
|
+
);
|
|
32
|
+
this.name = "GitNotInstalledError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Throw `GitNotInstalledError` if `git` isn't resolvable on PATH.
|
|
38
|
+
*
|
|
39
|
+
* `which` is a TEST SEAM (default `Bun.which`) so tests can force the
|
|
40
|
+
* git-missing branch without uninstalling git from the test host. Production
|
|
41
|
+
* callers pass nothing and get the real `Bun.which`.
|
|
42
|
+
*/
|
|
43
|
+
export function ensureGitAvailable(
|
|
44
|
+
which: (cmd: string) => string | null = Bun.which,
|
|
45
|
+
): void {
|
|
46
|
+
if (which("git") === null) {
|
|
47
|
+
throw new GitNotInstalledError();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Heuristic: does this error look like the "git executable not found" failure
|
|
53
|
+
* Bun throws when it can't resolve the binary? Used as a belt-and-suspenders
|
|
54
|
+
* catch around `Bun.spawn(["git", ...])` so a spawn that slips past the
|
|
55
|
+
* preflight (race where git is removed between check and spawn, or a code path
|
|
56
|
+
* that didn't preflight) still surfaces the friendly error instead of the raw
|
|
57
|
+
* `Executable not found in $PATH: "git"` string.
|
|
58
|
+
*/
|
|
59
|
+
export function isGitNotFoundSpawnError(err: unknown): boolean {
|
|
60
|
+
if (!(err instanceof Error)) return false;
|
|
61
|
+
const msg = err.message ?? "";
|
|
62
|
+
// Bun: `Executable not found in $PATH: "git"`.
|
|
63
|
+
// Node/posix: ENOENT spawn errors mention the missing file.
|
|
64
|
+
return (
|
|
65
|
+
(msg.includes("Executable not found") && msg.includes("git")) ||
|
|
66
|
+
((err as { code?: string }).code === "ENOENT" && msg.includes("git"))
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* - pollForToken: granted / pending / slow_down / expired / denied
|
|
8
8
|
* - fetchUser happy path
|
|
9
9
|
* - listRepos: single-page, paginated, truncated
|
|
10
|
-
* -
|
|
10
|
+
* - listInstallations: happy (user + org), empty, bad shape, API error
|
|
11
|
+
* - listInstallationRepos: happy, paginated/truncated, bad shape
|
|
12
|
+
* - createRepo happy path + GitHubApiError status (403 = no Administration)
|
|
13
|
+
* - app slug helpers + install URL
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { describe, expect, test } from "bun:test";
|
|
@@ -15,9 +18,15 @@ import { describe, expect, test } from "bun:test";
|
|
|
15
18
|
import {
|
|
16
19
|
createRepo,
|
|
17
20
|
fetchUser,
|
|
18
|
-
|
|
21
|
+
GITHUB_APP_SLUG_DEFAULT,
|
|
22
|
+
GITHUB_CLIENT_ID_DEFAULT,
|
|
23
|
+
getGithubAppSlug,
|
|
19
24
|
getGithubClientId,
|
|
25
|
+
GitHubApiError,
|
|
26
|
+
installUrlForSlug,
|
|
20
27
|
isPlaceholderClientId,
|
|
28
|
+
listInstallationRepos,
|
|
29
|
+
listInstallations,
|
|
21
30
|
listRepos,
|
|
22
31
|
pollForToken,
|
|
23
32
|
requestDeviceCode,
|
|
@@ -70,23 +79,68 @@ describe("client id helpers", () => {
|
|
|
70
79
|
}
|
|
71
80
|
});
|
|
72
81
|
|
|
73
|
-
test("getGithubClientId falls back to
|
|
82
|
+
test("getGithubClientId falls back to the shared-app default when env unset", () => {
|
|
74
83
|
const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
75
84
|
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
76
85
|
try {
|
|
77
|
-
expect(getGithubClientId()).toBe(
|
|
86
|
+
expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_DEFAULT);
|
|
78
87
|
} finally {
|
|
79
88
|
if (prev !== undefined) process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
|
|
80
89
|
}
|
|
81
90
|
});
|
|
82
91
|
|
|
83
|
-
test("
|
|
84
|
-
|
|
92
|
+
test("the shipped default is the registered Parachute GitHub App id", () => {
|
|
93
|
+
// Pin the exact value — it's public by design, and a typo'd constant
|
|
94
|
+
// would otherwise still pass a shape check.
|
|
95
|
+
expect(GITHUB_CLIENT_ID_DEFAULT).toBe("Iv23livaRF4VcvPhu3uB");
|
|
96
|
+
expect(isPlaceholderClientId(GITHUB_CLIENT_ID_DEFAULT)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("isPlaceholderClientId catches placeholder-shaped env overrides", () => {
|
|
100
|
+
expect(isPlaceholderClientId("Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE")).toBe(true);
|
|
85
101
|
expect(isPlaceholderClientId("PLACEHOLDER_X")).toBe(true);
|
|
86
102
|
expect(isPlaceholderClientId("Iv1.realone")).toBe(false);
|
|
87
103
|
});
|
|
88
104
|
});
|
|
89
105
|
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// App slug helpers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("app slug helpers", () => {
|
|
111
|
+
test("getGithubAppSlug reads from env when set (BYO-app pairing)", () => {
|
|
112
|
+
const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
113
|
+
try {
|
|
114
|
+
process.env.PARACHUTE_GITHUB_APP_SLUG = "my-own-app";
|
|
115
|
+
expect(getGithubAppSlug()).toBe("my-own-app");
|
|
116
|
+
} finally {
|
|
117
|
+
if (prev === undefined) delete process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
118
|
+
else process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("getGithubAppSlug falls back to the shared-app default when env unset", () => {
|
|
123
|
+
const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
124
|
+
delete process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
125
|
+
try {
|
|
126
|
+
expect(getGithubAppSlug()).toBe(GITHUB_APP_SLUG_DEFAULT);
|
|
127
|
+
} finally {
|
|
128
|
+
if (prev !== undefined) process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the shipped default slug is the registered Parachute GitHub App's", () => {
|
|
133
|
+
// Pin the exact value — a typo'd slug would build a 404 install link.
|
|
134
|
+
expect(GITHUB_APP_SLUG_DEFAULT).toBe("parachute-computer");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("installUrlForSlug builds the installations/new URL", () => {
|
|
138
|
+
expect(installUrlForSlug("parachute-computer")).toBe(
|
|
139
|
+
"https://github.com/apps/parachute-computer/installations/new",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
90
144
|
// ---------------------------------------------------------------------------
|
|
91
145
|
// requestDeviceCode
|
|
92
146
|
// ---------------------------------------------------------------------------
|
|
@@ -351,6 +405,187 @@ describe("listRepos", () => {
|
|
|
351
405
|
});
|
|
352
406
|
});
|
|
353
407
|
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// listInstallations
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
describe("listInstallations", () => {
|
|
413
|
+
test("returns user + org installations with typed fields", async () => {
|
|
414
|
+
const fetcher = mockFetch([
|
|
415
|
+
{
|
|
416
|
+
match: (u) => u.includes("/user/installations"),
|
|
417
|
+
response: {
|
|
418
|
+
ok: true,
|
|
419
|
+
status: 200,
|
|
420
|
+
body: {
|
|
421
|
+
total_count: 2,
|
|
422
|
+
installations: [
|
|
423
|
+
{
|
|
424
|
+
id: 101,
|
|
425
|
+
app_slug: "parachute-computer",
|
|
426
|
+
account: { login: "aaron", type: "User" },
|
|
427
|
+
repository_selection: "selected",
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
id: 202,
|
|
431
|
+
app_slug: "parachute-computer",
|
|
432
|
+
account: { login: "unforced-org", type: "Organization" },
|
|
433
|
+
repository_selection: "all",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
]);
|
|
440
|
+
const installations = await listInstallations("ghu_test", fetcher);
|
|
441
|
+
expect(installations).toHaveLength(2);
|
|
442
|
+
expect(installations[0]!.id).toBe(101);
|
|
443
|
+
expect(installations[0]!.account.login).toBe("aaron");
|
|
444
|
+
expect(installations[0]!.account.type).toBe("User");
|
|
445
|
+
expect(installations[0]!.repository_selection).toBe("selected");
|
|
446
|
+
expect(installations[1]!.account.type).toBe("Organization");
|
|
447
|
+
expect(installations[1]!.app_slug).toBe("parachute-computer");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("empty installations array = authorized but not installed", async () => {
|
|
451
|
+
const fetcher = mockFetch([
|
|
452
|
+
{
|
|
453
|
+
match: () => true,
|
|
454
|
+
response: { ok: true, status: 200, body: { total_count: 0, installations: [] } },
|
|
455
|
+
},
|
|
456
|
+
]);
|
|
457
|
+
const installations = await listInstallations("ghu_test", fetcher);
|
|
458
|
+
expect(installations).toEqual([]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("throws on a response missing the installations array", async () => {
|
|
462
|
+
const fetcher = mockFetch([
|
|
463
|
+
{
|
|
464
|
+
match: () => true,
|
|
465
|
+
response: { ok: true, status: 200, body: { total_count: 0 } },
|
|
466
|
+
},
|
|
467
|
+
]);
|
|
468
|
+
await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
|
|
469
|
+
/missing installations array/,
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("throws on an item missing required fields", async () => {
|
|
474
|
+
const fetcher = mockFetch([
|
|
475
|
+
{
|
|
476
|
+
match: () => true,
|
|
477
|
+
response: {
|
|
478
|
+
ok: true,
|
|
479
|
+
status: 200,
|
|
480
|
+
body: { total_count: 1, installations: [{ id: "not-a-number", account: null }] },
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
]);
|
|
484
|
+
await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
|
|
485
|
+
/missing required fields/,
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("throws GitHubApiError carrying the status on non-2xx", async () => {
|
|
490
|
+
const fetcher = mockFetch([
|
|
491
|
+
{
|
|
492
|
+
match: () => true,
|
|
493
|
+
response: { ok: false, status: 401, body: { message: "Bad credentials" } },
|
|
494
|
+
},
|
|
495
|
+
]);
|
|
496
|
+
try {
|
|
497
|
+
await listInstallations("ghu_revoked", fetcher);
|
|
498
|
+
throw new Error("expected listInstallations to throw");
|
|
499
|
+
} catch (err) {
|
|
500
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
501
|
+
expect((err as GitHubApiError).status).toBe(401);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// listInstallationRepos
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
function installationRepoItem(i: number, owner = "aaron"): Record<string, unknown> {
|
|
511
|
+
return {
|
|
512
|
+
name: `repo${i}`,
|
|
513
|
+
full_name: `${owner}/repo${i}`,
|
|
514
|
+
private: true,
|
|
515
|
+
html_url: `https://github.com/${owner}/repo${i}`,
|
|
516
|
+
description: null,
|
|
517
|
+
updated_at: "2026-06-10T00:00:00Z",
|
|
518
|
+
clone_url: `https://github.com/${owner}/repo${i}.git`,
|
|
519
|
+
owner: { login: owner },
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
describe("listInstallationRepos", () => {
|
|
524
|
+
test("single page returns GitHubRepoInfo shapes from the installation", async () => {
|
|
525
|
+
const fetcher = mockFetch([
|
|
526
|
+
{
|
|
527
|
+
match: (u) => u.includes("/user/installations/101/repositories") && u.includes("page=1"),
|
|
528
|
+
response: {
|
|
529
|
+
ok: true,
|
|
530
|
+
status: 200,
|
|
531
|
+
body: { total_count: 1, repositories: [installationRepoItem(0)] },
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
]);
|
|
535
|
+
const result = await listInstallationRepos("ghu_test", 101, { perPage: 100, maxPages: 3 }, fetcher);
|
|
536
|
+
expect(result.repos).toHaveLength(1);
|
|
537
|
+
expect(result.truncated).toBe(false);
|
|
538
|
+
expect(result.repos[0]!.full_name).toBe("aaron/repo0");
|
|
539
|
+
expect(result.repos[0]!.owner).toBe("aaron");
|
|
540
|
+
expect(result.repos[0]!.private).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("paginates until a short page; truncates at the maxPages cap", async () => {
|
|
544
|
+
const fullPage = { total_count: 4, repositories: [installationRepoItem(0), installationRepoItem(1)] };
|
|
545
|
+
const fetcher = mockFetch([
|
|
546
|
+
{ match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
|
|
547
|
+
{ match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: fullPage } },
|
|
548
|
+
]);
|
|
549
|
+
const result = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 2 }, fetcher);
|
|
550
|
+
expect(result.repos).toHaveLength(4);
|
|
551
|
+
expect(result.truncated).toBe(true);
|
|
552
|
+
|
|
553
|
+
// Short-page end: page 2 has fewer than perPage → untruncated.
|
|
554
|
+
const fetcher2 = mockFetch([
|
|
555
|
+
{ match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
|
|
556
|
+
{
|
|
557
|
+
match: (u) => u.includes("page=2"),
|
|
558
|
+
response: { ok: true, status: 200, body: { total_count: 3, repositories: [installationRepoItem(2)] } },
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
const result2 = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 3 }, fetcher2);
|
|
562
|
+
expect(result2.repos).toHaveLength(3);
|
|
563
|
+
expect(result2.truncated).toBe(false);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("throws on a response missing the repositories array", async () => {
|
|
567
|
+
const fetcher = mockFetch([
|
|
568
|
+
{ match: () => true, response: { ok: true, status: 200, body: { total_count: 0 } } },
|
|
569
|
+
]);
|
|
570
|
+
await expect(
|
|
571
|
+
listInstallationRepos("ghu_test", 101, {}, fetcher),
|
|
572
|
+
).rejects.toThrow(/missing repositories array/);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("throws GitHubApiError carrying the status on non-2xx", async () => {
|
|
576
|
+
const fetcher = mockFetch([
|
|
577
|
+
{ match: () => true, response: { ok: false, status: 404, body: { message: "Not Found" } } },
|
|
578
|
+
]);
|
|
579
|
+
try {
|
|
580
|
+
await listInstallationRepos("ghu_test", 999, {}, fetcher);
|
|
581
|
+
throw new Error("expected listInstallationRepos to throw");
|
|
582
|
+
} catch (err) {
|
|
583
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
584
|
+
expect((err as GitHubApiError).status).toBe(404);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
354
589
|
// ---------------------------------------------------------------------------
|
|
355
590
|
// createRepo
|
|
356
591
|
// ---------------------------------------------------------------------------
|
|
@@ -401,4 +636,28 @@ describe("createRepo", () => {
|
|
|
401
636
|
createRepo("gho_test", { name: "exists" }, fetcher),
|
|
402
637
|
).rejects.toThrow(/already exists/);
|
|
403
638
|
});
|
|
639
|
+
|
|
640
|
+
test("403 (shared Contents-only app) throws GitHubApiError with status 403", async () => {
|
|
641
|
+
// POST /user/repos needs Administration:write — the shared app's
|
|
642
|
+
// expected failure. The route maps this status to the guided-manual
|
|
643
|
+
// error, so the status must survive the throw.
|
|
644
|
+
const fetcher = mockFetch([
|
|
645
|
+
{
|
|
646
|
+
match: () => true,
|
|
647
|
+
response: {
|
|
648
|
+
ok: false,
|
|
649
|
+
status: 403,
|
|
650
|
+
body: { message: "Resource not accessible by integration" },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
]);
|
|
654
|
+
try {
|
|
655
|
+
await createRepo("ghu_test", { name: "my-vault" }, fetcher);
|
|
656
|
+
throw new Error("expected createRepo to throw");
|
|
657
|
+
} catch (err) {
|
|
658
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
659
|
+
expect((err as GitHubApiError).status).toBe(403);
|
|
660
|
+
expect((err as Error).message).toContain("Resource not accessible");
|
|
661
|
+
}
|
|
662
|
+
});
|
|
404
663
|
});
|