@framers/agentos-ext-github 0.7.0 → 0.9.0

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,702 @@
1
+ /**
2
+ * @fileoverview A link's ref settled through GitHub's matching references (a branch, then a tag, then a short commit
3
+ * through GitHub's commits), a tree planned with what it leaves out and why, and a repository's documentation listed
4
+ * and compared at a named branch or commit and within a folder, over a fetch stand-in that answers a repository's
5
+ * branches, references, tags, commits and trees and records each request.
6
+ */
7
+ import { generateKeyPairSync } from 'node:crypto';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { DEFAULT_MAX_FILE_BYTES, GitHubApiError, GitHubAppCredential, GitHubDocumentsConnector, GitHubNoAnswer, IMAGE_EXTENSIONS, pathFilter, planTree, } from '../documents/index.js';
10
+ /** A JSON answer. */
11
+ function json(body, status = 200) {
12
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
13
+ }
14
+ const BASE = 'https://stand-in.test';
15
+ const INSTALLATION = 5;
16
+ const HANDBOOK = { id: 77, fullName: 'acme-team/handbook', defaultBranch: 'main' };
17
+ /** A repository with no commit yet: GitHub answers its references 409. */
18
+ const EMPTY = { id: 80, fullName: 'acme-team/empty', defaultBranch: 'main' };
19
+ const MAIN = '1'.repeat(40);
20
+ const RELEASE = '2'.repeat(40);
21
+ const RELEASE_10 = '3'.repeat(40);
22
+ const RELEASE_NOTES = '4'.repeat(40);
23
+ const DOCS_1 = '5'.repeat(40);
24
+ /** The release branch's head after a push. */
25
+ const RELEASE_NEXT = '6'.repeat(40);
26
+ /** A commit a link pins. */
27
+ const PINNED = '9f2c4e6a'.repeat(5);
28
+ /** The commit a lightweight tag names. */
29
+ const TAGGED = '7'.repeat(40);
30
+ /** An annotated tag's own object, and the commit it names. */
31
+ const TAG_OBJECT = 'a'.repeat(40);
32
+ const ANNOTATED = '8'.repeat(40);
33
+ /** The commit a tag holding a slash names. */
34
+ const STABLE = 'b'.repeat(40);
35
+ /** A commit whose tree holds more documents before `docs/` than a listing keeps. */
36
+ const CROWDED = 'c'.repeat(40);
37
+ /** The handbook's branches by name. Git keeps no branch beside one under its name; the stand-in does, so the longest wins. */
38
+ const BRANCHES = {
39
+ main: MAIN,
40
+ release: RELEASE,
41
+ 'release/1.0': RELEASE_10,
42
+ 'release-notes': RELEASE_NOTES,
43
+ 'docs#1': DOCS_1,
44
+ };
45
+ /** The handbook's tags by name: a lightweight tag names a commit, an annotated tag its own tag object. */
46
+ const TAGS = {
47
+ 'v1.0': { type: 'commit', sha: TAGGED },
48
+ 'v1.0-rc1': { type: 'commit', sha: MAIN },
49
+ 'v2.0': { type: 'tag', sha: TAG_OBJECT },
50
+ 'stable/2026.10': { type: 'commit', sha: STABLE },
51
+ // A tag under a branch's name, which git keeps beside the branch: it names more of a link than the branch does.
52
+ 'release/2.0': { type: 'commit', sha: TAGGED },
53
+ // A tag of a branch's name: the branch is read, and the tag never asked for.
54
+ release: { type: 'commit', sha: PINNED },
55
+ };
56
+ /** Each annotated tag's object by its sha, with the commit it names. */
57
+ const TAG_OBJECTS = { [TAG_OBJECT]: ANNOTATED };
58
+ /** Every commit of the handbook, which a short commit names by its first characters. */
59
+ const COMMITS = [MAIN, RELEASE, RELEASE_10, RELEASE_NOTES, DOCS_1, PINNED, TAGGED, ANNOTATED, STABLE, CROWDED];
60
+ /** The handbook's trees by commit. */
61
+ const TREES = {
62
+ [MAIN]: [
63
+ { path: 'README.md', sha: 'r1', size: 120 },
64
+ { path: 'docs/agenda.md', sha: 'a1', size: 300 },
65
+ ],
66
+ [RELEASE]: [
67
+ { path: 'README.md', sha: 'r1', size: 120 },
68
+ { path: 'docs/agenda.md', sha: 'a2', size: 320 },
69
+ { path: 'docs/setup.md', sha: 's1', size: 200 },
70
+ ],
71
+ [RELEASE_NEXT]: [
72
+ { path: 'README.md', sha: 'r1', size: 120 },
73
+ { path: 'docs/agenda.md', sha: 'a3', size: 330 },
74
+ ],
75
+ [RELEASE_10]: [{ path: 'docs/setup.md', sha: 's10', size: 210 }],
76
+ [PINNED]: [
77
+ { path: 'README.md', sha: 'r0', size: 100 },
78
+ { path: 'docs/old.md', sha: 'o1', size: 50 },
79
+ ],
80
+ [TAGGED]: [{ path: 'docs/setup.md', sha: 's7', size: 70 }],
81
+ [ANNOTATED]: [
82
+ { path: 'README.md', sha: 'r8', size: 80 },
83
+ { path: 'docs/setup.md', sha: 's8', size: 81 },
84
+ ],
85
+ [STABLE]: [{ path: 'docs/notes.md', sha: 'n9', size: 90 }],
86
+ // Two thousand documents whose paths sort before docs/, as many as a listing keeps by default, then the folder's.
87
+ [CROWDED]: [
88
+ ...Array.from({ length: 2_000 }, (_, index) => ({ path: `a/${String(index).padStart(4, '0')}.md`, sha: `x${index}`, size: 1 })),
89
+ { path: 'docs/guide.md', sha: 'g1', size: 40 },
90
+ { path: 'docs/setup.md', sha: 's2', size: 50 },
91
+ ],
92
+ };
93
+ /** A stand-in GitHub holding the handbook and the empty repository. */
94
+ function github() {
95
+ const seen = [];
96
+ const fetchImpl = async (input, init) => {
97
+ const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
98
+ seen.push({ method: init?.method ?? 'GET', url: href, headers: new Headers(init?.headers) });
99
+ const url = new URL(href);
100
+ const path = url.pathname;
101
+ if (path === `/app/installations/${INSTALLATION}/access_tokens`) {
102
+ return json({ token: 'ghs_documents', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
103
+ }
104
+ const matching = '/repositories/77/git/matching-refs/heads/';
105
+ if (path.startsWith(matching)) {
106
+ // GitHub answers every reference whose name starts with the one asked for.
107
+ const prefix = `refs/heads/${decodeURIComponent(path.slice(matching.length))}`;
108
+ const references = Object.entries(BRANCHES).map(([name, sha]) => ({ ref: `refs/heads/${name}`, object: { type: 'commit', sha } }));
109
+ return json(references.filter((reference) => reference.ref.startsWith(prefix)));
110
+ }
111
+ const matchingTags = '/repositories/77/git/matching-refs/tags/';
112
+ if (path.startsWith(matchingTags)) {
113
+ const prefix = `refs/tags/${decodeURIComponent(path.slice(matchingTags.length))}`;
114
+ const references = Object.entries(TAGS).map(([name, object]) => ({ ref: `refs/tags/${name}`, object }));
115
+ return json(references.filter((reference) => reference.ref.startsWith(prefix)));
116
+ }
117
+ const tagObjects = '/repositories/77/git/tags/';
118
+ if (path.startsWith(tagObjects)) {
119
+ const sha = path.slice(tagObjects.length);
120
+ const commit = TAG_OBJECTS[sha];
121
+ return commit === undefined ? json({ message: 'Not Found' }, 404) : json({ sha, tag: 'v2.0', object: { type: 'commit', sha: commit } });
122
+ }
123
+ const commits = '/repositories/77/commits/';
124
+ if (path.startsWith(commits)) {
125
+ // The commit's sha alone under GitHub's sha media type; 422 for a prefix no commit has, or several have.
126
+ if (new Headers(init?.headers).get('accept') !== 'application/vnd.github.sha')
127
+ return json({ message: 'Unsupported media type' }, 415);
128
+ const prefix = decodeURIComponent(path.slice(commits.length)).toLowerCase();
129
+ const found = COMMITS.filter((sha) => sha.startsWith(prefix));
130
+ if (found.length !== 1)
131
+ return json({ message: `No commit found for SHA: ${prefix}` }, 422);
132
+ return new Response(found[0], { status: 200, headers: { 'content-type': 'application/vnd.github.sha; charset=utf-8' } });
133
+ }
134
+ if (path.startsWith('/repositories/80/git/matching-refs/'))
135
+ return json({ message: 'Git Repository is empty.' }, 409);
136
+ const branches = '/repositories/77/branches/';
137
+ if (path.startsWith(branches)) {
138
+ const name = decodeURIComponent(path.slice(branches.length));
139
+ const sha = BRANCHES[name];
140
+ return sha === undefined ? json({ message: 'Branch not found' }, 404) : json({ name, commit: { sha } });
141
+ }
142
+ const trees = '/repositories/77/git/trees/';
143
+ const entries = path.startsWith(trees) && url.searchParams.get('recursive') === '1' ? TREES[path.slice(trees.length)] : undefined;
144
+ if (entries !== undefined) {
145
+ return json({ sha: path.slice(trees.length), truncated: false, tree: entries.map((entry) => ({ mode: '100644', type: 'blob', ...entry })) });
146
+ }
147
+ return json({ message: 'Not Found' }, 404);
148
+ };
149
+ return { fetchImpl, seen };
150
+ }
151
+ const keys = generateKeyPairSync('rsa', {
152
+ modulusLength: 2048,
153
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
154
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
155
+ });
156
+ /** A connector to the handbook and the empty repository, on the stand-in. */
157
+ function connectorOn(stand, filter = {}) {
158
+ const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, stand.fetchImpl, BASE);
159
+ return new GitHubDocumentsConnector(credential, INSTALLATION, [HANDBOOK, EMPTY], filter, stand.fetchImpl, BASE);
160
+ }
161
+ /** The requests made other than for an installation token, as their paths and queries. */
162
+ function reads(seen) {
163
+ return seen.filter((request) => !request.url.endsWith('/access_tokens')).map((request) => request.url.slice(BASE.length));
164
+ }
165
+ /** A plan as its paths and outcomes. */
166
+ function outcomes(plan) {
167
+ return plan.map((planned) => [planned.path, planned.outcome]);
168
+ }
169
+ /** A blob of ten bytes, or of `size`. */
170
+ function blob(path, size = 10) {
171
+ return { path, sha: `b-${path}`, size };
172
+ }
173
+ describe('resolveRef', () => {
174
+ it("settles a branch holding a slash through GitHub's matching references, by the repository's id, the tags read for a longer name", async () => {
175
+ const stand = github();
176
+ expect(await connectorOn(stand).resolveRef(77, 'release/1.0/docs/setup.md')).toEqual({
177
+ ref: 'release/1.0',
178
+ path: 'docs/setup.md',
179
+ commit: false,
180
+ kind: 'branch',
181
+ name: 'release/1.0',
182
+ });
183
+ expect(reads(stand.seen)).toEqual(['/repositories/77/git/matching-refs/heads/release', '/repositories/77/git/matching-refs/tags/release']);
184
+ const request = stand.seen.find((seen) => seen.url.includes('/matching-refs/'));
185
+ expect(request?.headers.get('authorization')).toBe('Bearer ghs_documents');
186
+ expect(request?.headers.get('x-github-api-version')).toBe('2026-03-10');
187
+ });
188
+ it('takes the longest branch that is the text or starts it at a slash, never one that only starts with the same letters', async () => {
189
+ const connector = connectorOn(github());
190
+ expect(await connector.resolveRef(77, 'main/docs')).toEqual({ ref: 'main', path: 'docs', commit: false, kind: 'branch', name: 'main' });
191
+ expect(await connector.resolveRef(77, 'release/docs/setup.md')).toEqual({
192
+ ref: 'release',
193
+ path: 'docs/setup.md',
194
+ commit: false,
195
+ kind: 'branch',
196
+ name: 'release',
197
+ });
198
+ expect(await connector.resolveRef(77, 'release/1.0')).toEqual({ ref: 'release/1.0', path: '', commit: false, kind: 'branch', name: 'release/1.0' });
199
+ expect(await connector.resolveRef(77, 'release-notes/2026.md')).toEqual({
200
+ ref: 'release-notes',
201
+ path: '2026.md',
202
+ commit: false,
203
+ kind: 'branch',
204
+ name: 'release-notes',
205
+ });
206
+ // No branch, tag or commit is the segment: the segment alone is the ref, and the listing at it answers GitHub's 404.
207
+ expect(await connector.resolveRef(77, 'nightly/docs')).toEqual({ ref: 'nightly', path: 'docs', commit: false, kind: 'branch', name: 'nightly' });
208
+ });
209
+ it('asks for the first segment encoded, so a branch holding a # is asked for whole, and the tags the same way', async () => {
210
+ const stand = github();
211
+ expect(await connectorOn(stand).resolveRef(77, 'docs#1/guide.md')).toEqual({ ref: 'docs#1', path: 'guide.md', commit: false, kind: 'branch', name: 'docs#1' });
212
+ expect(reads(stand.seen)).toEqual(['/repositories/77/git/matching-refs/heads/docs%231', '/repositories/77/git/matching-refs/tags/docs%231']);
213
+ });
214
+ it('takes a commit of 40 hexadecimal characters in either case, and HEAD as the default branch, with no request', async () => {
215
+ const stand = github();
216
+ const connector = connectorOn(stand);
217
+ expect(await connector.resolveRef(77, `${PINNED}/docs/old.md`)).toEqual({ ref: PINNED, path: 'docs/old.md', commit: true, kind: 'commit', name: PINNED });
218
+ expect(await connector.resolveRef(77, `${PINNED.toUpperCase()}/docs`)).toEqual({
219
+ ref: PINNED,
220
+ path: 'docs',
221
+ commit: true,
222
+ kind: 'commit',
223
+ name: PINNED.toUpperCase(),
224
+ });
225
+ expect(await connector.resolveRef(77, PINNED)).toEqual({ ref: PINNED, path: '', commit: true, kind: 'commit', name: PINNED });
226
+ expect(await connector.resolveRef(77, 'HEAD/docs/agenda.md')).toEqual({ ref: 'main', path: 'docs/agenda.md', commit: false, kind: 'branch', name: 'HEAD' });
227
+ expect(stand.seen).toEqual([]);
228
+ });
229
+ it('settles a tag no branch is at the commit it names: a lightweight tag at its own, an annotated tag peeled, the longest tag that is the text or starts it at a slash', async () => {
230
+ const stand = github();
231
+ const connector = connectorOn(stand);
232
+ expect(await connector.resolveRef(77, 'v1.0/docs')).toEqual({ ref: TAGGED, path: 'docs', commit: true, kind: 'tag', name: 'v1.0' });
233
+ expect(reads(stand.seen)).toEqual(['/repositories/77/git/matching-refs/heads/v1.0', '/repositories/77/git/matching-refs/tags/v1.0']);
234
+ expect(await connector.resolveRef(77, 'v2.0/docs/setup.md')).toEqual({ ref: ANNOTATED, path: 'docs/setup.md', commit: true, kind: 'tag', name: 'v2.0' });
235
+ expect(reads(stand.seen).slice(2)).toEqual([
236
+ '/repositories/77/git/matching-refs/heads/v2.0',
237
+ '/repositories/77/git/matching-refs/tags/v2.0',
238
+ `/repositories/77/git/tags/${TAG_OBJECT}`,
239
+ ]);
240
+ expect(await connector.resolveRef(77, 'stable/2026.10/docs')).toEqual({ ref: STABLE, path: 'docs', commit: true, kind: 'tag', name: 'stable/2026.10' });
241
+ expect(await connector.resolveRef(77, 'v1.0')).toEqual({ ref: TAGGED, path: '', commit: true, kind: 'tag', name: 'v1.0' });
242
+ // A branch and a tag of one name: the tags are read for a longer name, and the same name settles as the branch.
243
+ const before = stand.seen.length;
244
+ expect(await connector.resolveRef(77, 'release/docs')).toEqual({ ref: 'release', path: 'docs', commit: false, kind: 'branch', name: 'release' });
245
+ expect(reads(stand.seen.slice(before))).toEqual(['/repositories/77/git/matching-refs/heads/release', '/repositories/77/git/matching-refs/tags/release']);
246
+ // A branch whose name is the whole text: no name of a tag is longer, so no tag is asked for.
247
+ const whole = stand.seen.length;
248
+ expect(await connector.resolveRef(77, 'release/1.0')).toEqual({ ref: 'release/1.0', path: '', commit: false, kind: 'branch', name: 'release/1.0' });
249
+ expect(reads(stand.seen.slice(whole))).toEqual(['/repositories/77/git/matching-refs/heads/release']);
250
+ });
251
+ it('takes a tag that names more of the text than the branch it starts with, the longest name of a branch or a tag', async () => {
252
+ const stand = github();
253
+ const connector = connectorOn(stand);
254
+ // The branch `release` starts `release/2.0/docs` at a slash, and the tag `release/2.0` names more of it.
255
+ expect(await connector.resolveRef(77, 'release/2.0/docs')).toEqual({ ref: TAGGED, path: 'docs', commit: true, kind: 'tag', name: 'release/2.0' });
256
+ expect(reads(stand.seen)).toEqual(['/repositories/77/git/matching-refs/heads/release', '/repositories/77/git/matching-refs/tags/release']);
257
+ });
258
+ it("settles a short commit through GitHub's commits, asked for its sha alone, and asks no commit for a name that cannot be one", async () => {
259
+ const stand = github();
260
+ const connector = connectorOn(stand);
261
+ const short = PINNED.slice(0, 7);
262
+ expect(await connector.resolveRef(77, `${short}/docs/old.md`)).toEqual({ ref: PINNED, path: 'docs/old.md', commit: true, kind: 'commit', name: short });
263
+ expect(reads(stand.seen)).toEqual([
264
+ `/repositories/77/git/matching-refs/heads/${short}`,
265
+ `/repositories/77/git/matching-refs/tags/${short}`,
266
+ `/repositories/77/commits/${short}`,
267
+ ]);
268
+ expect(stand.seen[stand.seen.length - 1].headers.get('accept')).toBe('application/vnd.github.sha');
269
+ expect(await connector.resolveRef(77, `${short.toUpperCase()}/docs`)).toEqual({
270
+ ref: PINNED,
271
+ path: 'docs',
272
+ commit: true,
273
+ kind: 'commit',
274
+ name: short.toUpperCase(),
275
+ });
276
+ // GitHub answers 422 for a prefix no commit has: the segment is answered as a branch, whose listing answers 404.
277
+ const nowhere = await connector.resolveRef(77, 'abcdef0/docs');
278
+ expect(nowhere).toEqual({ ref: 'abcdef0', path: 'docs', commit: false, kind: 'branch', name: 'abcdef0' });
279
+ const listed = await connector.list({ ids: ['77'], at: nowhere.ref }).catch((error) => error);
280
+ expect(listed).toBeInstanceOf(GitHubApiError);
281
+ expect(listed.status).toBe(404);
282
+ // Fewer than four hexadecimal characters, or any other letter, names no commit: GitHub's commits are not asked.
283
+ for (const text of ['abc/docs', 'nightly/docs']) {
284
+ const before = stand.seen.length;
285
+ const first = text.split('/')[0];
286
+ expect(await connector.resolveRef(77, text), text).toEqual({ ref: first, path: 'docs', commit: false, kind: 'branch', name: first });
287
+ expect(reads(stand.seen.slice(before)), text).toEqual([
288
+ `/repositories/77/git/matching-refs/heads/${first}`,
289
+ `/repositories/77/git/matching-refs/tags/${first}`,
290
+ ]);
291
+ }
292
+ });
293
+ it('lists, reads the head of and compares a tag and a short commit at the commit resolveRef answers, with no branch read', async () => {
294
+ const stand = github();
295
+ const connector = connectorOn(stand);
296
+ const tag = await connector.resolveRef(77, 'v2.0/docs');
297
+ const short = await connector.resolveRef(77, `${TAGGED.slice(0, 8)}/docs`);
298
+ const before = stand.seen.length;
299
+ expect(await connector.list({ ids: ['77'], at: tag.ref })).toEqual({
300
+ items: [
301
+ { id: '77:README.md', title: 'README.md', size: 80, version: `r8@${ANNOTATED}` },
302
+ { id: '77:docs/setup.md', title: 'docs/setup.md', size: 81, version: `s8@${ANNOTATED}` },
303
+ ],
304
+ gone: [],
305
+ });
306
+ expect((await connector.list({ ids: ['77'], at: short.ref })).items).toEqual([
307
+ { id: '77:docs/setup.md', title: 'docs/setup.md', size: 70, version: `s7@${TAGGED}` },
308
+ ]);
309
+ expect(await connector.head(77, tag.ref)).toBe(ANNOTATED);
310
+ expect(await connector.head(77, short.ref)).toBe(TAGGED);
311
+ // Read at a commit, a tag's listing never changes: a push to any branch answers no change.
312
+ const cursor = JSON.stringify({ 'README.md': 'r8', 'docs/setup.md': 's8' });
313
+ expect(await connector.changesSince(cursor, { ids: ['77'], at: tag.ref, ref: 'refs/heads/main', after: MAIN })).toEqual({ changed: [], gone: [], cursor });
314
+ expect(await connector.changesSince(cursor, { ids: ['77'], at: tag.ref })).toEqual({ changed: [], gone: [], cursor });
315
+ expect(reads(stand.seen.slice(before))).toEqual([
316
+ `/repositories/77/git/trees/${ANNOTATED}?recursive=1`,
317
+ `/repositories/77/git/trees/${TAGGED}?recursive=1`,
318
+ `/repositories/77/git/trees/${ANNOTATED}?recursive=1`,
319
+ ]);
320
+ });
321
+ it('refuses, before any request, a first segment no ref name can begin, and after the reads one no branch, tag or commit is that is no branch name', async () => {
322
+ const stand = github();
323
+ const connector = connectorOn(stand);
324
+ const unnamed = ['a:b/docs', 'a~1/docs', 'a^/docs', 'a?/docs', 'a*/docs', 'a[1]/docs', 'a\\b/docs', 'a@{1}/docs', 'x..y/docs', '.hidden/docs', 'x.lock/docs', 'a b/docs', 'a\tb/docs'];
325
+ for (const text of unnamed) {
326
+ await expect(connector.resolveRef(77, text), text).rejects.toBeInstanceOf(TypeError);
327
+ }
328
+ expect(stand.seen).toEqual([]);
329
+ // A segment that may begin a longer ref name but is no branch name alone, which no branch, tag or commit is.
330
+ for (const text of ['-x/docs', '@/docs', 'end./docs']) {
331
+ await expect(connector.resolveRef(77, text), text).rejects.toBeInstanceOf(TypeError);
332
+ }
333
+ });
334
+ it('refuses, before any request, a text with an empty, . or .. segment, a repository outside the link and an aborted call', async () => {
335
+ const stand = github();
336
+ const connector = connectorOn(stand);
337
+ for (const text of ['', '/main/docs', 'main/', 'main//docs', './docs', 'main/./docs', 'main/../secret', '..']) {
338
+ const failure = await connector.resolveRef(77, text).catch((error) => error);
339
+ expect(failure, text).toBeInstanceOf(TypeError);
340
+ expect(failure.message).not.toContain('secret');
341
+ }
342
+ await expect(connector.resolveRef(79, 'main/docs')).rejects.toThrow('repository 79 is not part of this link');
343
+ const aborted = await connector.resolveRef(77, '', AbortSignal.abort()).catch((error) => error);
344
+ expect(aborted).toBeInstanceOf(GitHubNoAnswer);
345
+ expect(aborted.reason).toBe('cancelled');
346
+ expect(stand.seen).toEqual([]);
347
+ });
348
+ it("throws GitHub's refusal of the references, never a ref it did not read", async () => {
349
+ const failure = await connectorOn(github())
350
+ .resolveRef(80, 'main/docs')
351
+ .catch((error) => error);
352
+ expect(failure).toBeInstanceOf(GitHubApiError);
353
+ expect(failure.status).toBe(409);
354
+ });
355
+ });
356
+ describe('planTree', () => {
357
+ const TREE = [
358
+ blob('src/index.ts', 50),
359
+ blob('README.md', 120),
360
+ blob('docs/big.md', 12 * 1024 * 1024),
361
+ blob('assets/logo.png', 4096),
362
+ blob('docs/budget.pdf', 2048),
363
+ blob('node_modules/x/README.md'),
364
+ blob('docs/agenda.md', 300),
365
+ ];
366
+ it('answers every file in path order: the documentation a note, the rest skipped with its reason', () => {
367
+ expect(outcomes(planTree(TREE))).toEqual([
368
+ ['README.md', 'note'],
369
+ ['assets/logo.png', { skipped: 'image' }],
370
+ ['docs/agenda.md', 'note'],
371
+ ['docs/big.md', { skipped: 'size' }],
372
+ ['docs/budget.pdf', 'note'],
373
+ ['node_modules/x/README.md', { skipped: 'folder' }],
374
+ ['src/index.ts', { skipped: 'code' }],
375
+ ]);
376
+ expect(planTree(TREE, { includeCode: true }).find((planned) => planned.path === 'src/index.ts')?.outcome).toBe('note');
377
+ });
378
+ it('tells code left out from code a tool wrote, a file of no kind it reads, and a code file past its own bound', () => {
379
+ const blobs = [
380
+ blob('package.json'),
381
+ blob('config/settings.yaml'),
382
+ blob('LICENSE'),
383
+ blob('web/app.min.js'),
384
+ blob('web/app.js'),
385
+ blob('src/huge.ts', 2_000_000),
386
+ blob('docs/long.md', 2_000_000),
387
+ ];
388
+ expect(outcomes(planTree(blobs))).toEqual([
389
+ ['LICENSE', { skipped: 'kind' }],
390
+ ['config/settings.yaml', { skipped: 'kind' }],
391
+ ['docs/long.md', 'note'],
392
+ ['package.json', { skipped: 'kind' }],
393
+ ['src/huge.ts', { skipped: 'code' }],
394
+ ['web/app.js', { skipped: 'code' }],
395
+ ['web/app.min.js', { skipped: 'code' }],
396
+ ]);
397
+ expect(outcomes(planTree(blobs, { includeCode: true, maxCodeBytes: 1_048_576 }))).toEqual([
398
+ ['LICENSE', { skipped: 'kind' }],
399
+ ['config/settings.yaml', { skipped: 'kind' }],
400
+ ['docs/long.md', 'note'],
401
+ ['package.json', { skipped: 'kind' }],
402
+ ['src/huge.ts', { skipped: 'size' }],
403
+ ['web/app.js', 'note'],
404
+ ['web/app.min.js', { skipped: 'generated' }],
405
+ ]);
406
+ });
407
+ it('bounds a code file by maxCodeBytes while code is asked for, even when the filter names its extension as documentation', () => {
408
+ const blobs = [blob('docs/guide.md', 2_000_000), blob('src/huge.ts', 2_000_000), blob('src/small.ts', 100)];
409
+ const filter = { extensions: ['.md', '.ts'], maxCodeBytes: 1_048_576 };
410
+ expect(outcomes(planTree(blobs, { ...filter, includeCode: true }))).toEqual([
411
+ ['docs/guide.md', 'note'],
412
+ ['src/huge.ts', { skipped: 'size' }],
413
+ ['src/small.ts', 'note'],
414
+ ]);
415
+ // Without code asked for, an extension the filter names is documentation alone, under maxFileBytes.
416
+ expect(outcomes(planTree(blobs, filter))).toEqual([
417
+ ['docs/guide.md', 'note'],
418
+ ['src/huge.ts', 'note'],
419
+ ['src/small.ts', 'note'],
420
+ ]);
421
+ });
422
+ it('keeps the documentation first under the file count and the code in the room left, the rest past the count', () => {
423
+ const blobs = [blob('api/a.ts'), blob('api/b.ts'), blob('app/c.py'), blob('docs/guide.md'), blob('docs/z.md')];
424
+ expect(outcomes(planTree(blobs, { includeCode: true, maxFiles: 3 }))).toEqual([
425
+ ['api/a.ts', 'note'],
426
+ ['api/b.ts', { skipped: 'count' }],
427
+ ['app/c.py', { skipped: 'count' }],
428
+ ['docs/guide.md', 'note'],
429
+ ['docs/z.md', 'note'],
430
+ ]);
431
+ expect(outcomes(planTree(blobs, { maxFiles: 1 }))).toEqual([
432
+ ['api/a.ts', { skipped: 'code' }],
433
+ ['api/b.ts', { skipped: 'code' }],
434
+ ['app/c.py', { skipped: 'code' }],
435
+ ['docs/guide.md', 'note'],
436
+ ['docs/z.md', { skipped: 'count' }],
437
+ ]);
438
+ });
439
+ it('answers as notes exactly what pathFilter keeps under the same filter', () => {
440
+ const blobs = [
441
+ ...TREE,
442
+ blob('web/app.min.js'),
443
+ blob('web/app.js'),
444
+ blob('docs/x.min.js'),
445
+ blob('package.json'),
446
+ blob('vendor/y.md'),
447
+ blob('src/big.ts', DEFAULT_MAX_FILE_BYTES + 1),
448
+ ...Array.from({ length: 6 }, (_, index) => blob(`notes/n${index}.md`)),
449
+ ...Array.from({ length: 6 }, (_, index) => blob(`lib/m${index}.py`)),
450
+ ];
451
+ const filters = [
452
+ {},
453
+ { includeCode: true },
454
+ { maxFiles: 4 },
455
+ { includeCode: true, maxFiles: 9 },
456
+ { includeCode: true, maxFiles: 12 },
457
+ { extensions: ['.md', '.js'] },
458
+ { includeCode: true, extensions: ['.md', '.js'] },
459
+ { skipFolders: [] },
460
+ { maxFileBytes: 100 },
461
+ ];
462
+ for (const filter of filters) {
463
+ const notes = planTree(blobs, filter)
464
+ .filter((planned) => planned.outcome === 'note')
465
+ .map(({ path, sha, size }) => ({ path, sha, size }));
466
+ expect(notes, JSON.stringify(filter)).toEqual(pathFilter(filter)(blobs));
467
+ }
468
+ });
469
+ it('reads the image extensions it is given, and keeps as documentation any extension the filter names', () => {
470
+ const blobs = [blob('art/cover.png'), blob('art/scan.jxl')];
471
+ expect(outcomes(planTree(blobs))).toEqual([
472
+ ['art/cover.png', { skipped: 'image' }],
473
+ ['art/scan.jxl', { skipped: 'kind' }],
474
+ ]);
475
+ expect(outcomes(planTree(blobs, { imageExtensions: ['.JXL'] }))).toEqual([
476
+ ['art/cover.png', { skipped: 'kind' }],
477
+ ['art/scan.jxl', { skipped: 'image' }],
478
+ ]);
479
+ expect(outcomes(planTree(blobs, { extensions: ['.png'] }))).toEqual([
480
+ ['art/cover.png', 'note'],
481
+ ['art/scan.jxl', { skipped: 'kind' }],
482
+ ]);
483
+ expect(IMAGE_EXTENSIONS).toContain('.svg');
484
+ });
485
+ it('answers new objects holding the path, the sha, the size and the outcome alone, and refuses a code bound that is not a size', () => {
486
+ const held = { path: 'README.md', sha: 'r1', size: 120, mode: '100644' };
487
+ expect(planTree([held])).toEqual([{ path: 'README.md', sha: 'r1', size: 120, outcome: 'note' }]);
488
+ for (const maxCodeBytes of [-1, Number.NaN, 1.5, Number.POSITIVE_INFINITY]) {
489
+ expect(() => planTree([], { maxCodeBytes }), String(maxCodeBytes)).toThrow(TypeError);
490
+ }
491
+ });
492
+ });
493
+ describe('the listing at a named branch or commit', () => {
494
+ it("lists the head of the branch at names, a slash in its name included, and a commit with no branch read", async () => {
495
+ const stand = github();
496
+ const connector = connectorOn(stand);
497
+ expect(await connector.list({ ids: ['77'], at: 'release' })).toEqual({
498
+ items: [
499
+ { id: '77:README.md', title: 'README.md', size: 120, version: `r1@${RELEASE}` },
500
+ { id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 320, version: `a2@${RELEASE}` },
501
+ { id: '77:docs/setup.md', title: 'docs/setup.md', size: 200, version: `s1@${RELEASE}` },
502
+ ],
503
+ gone: [],
504
+ });
505
+ expect((await connector.list({ ids: ['77'], at: 'release/1.0' })).items).toEqual([
506
+ { id: '77:docs/setup.md', title: 'docs/setup.md', size: 210, version: `s10@${RELEASE_10}` },
507
+ ]);
508
+ expect((await connector.list({ ids: ['77'], at: PINNED.toUpperCase() })).items).toEqual([
509
+ { id: '77:README.md', title: 'README.md', size: 100, version: `r0@${PINNED}` },
510
+ { id: '77:docs/old.md', title: 'docs/old.md', size: 50, version: `o1@${PINNED}` },
511
+ ]);
512
+ expect(reads(stand.seen)).toEqual([
513
+ '/repositories/77/branches/release',
514
+ `/repositories/77/git/trees/${RELEASE}?recursive=1`,
515
+ '/repositories/77/branches/release%2F1.0',
516
+ `/repositories/77/git/trees/${RELEASE_10}?recursive=1`,
517
+ `/repositories/77/git/trees/${PINNED}?recursive=1`,
518
+ ]);
519
+ });
520
+ it("compares at the head of the branch at names when no push names a commit", async () => {
521
+ const stand = github();
522
+ const changes = await connectorOn(stand).changesSince(JSON.stringify({ 'README.md': 'r1' }), { ids: ['77'], at: 'release' });
523
+ expect(changes.changed.map((item) => item.version)).toEqual([`a2@${RELEASE}`, `s1@${RELEASE}`]);
524
+ expect(changes.gone).toEqual([]);
525
+ expect(reads(stand.seen)).toEqual(['/repositories/77/branches/release', `/repositories/77/git/trees/${RELEASE}?recursive=1`]);
526
+ });
527
+ it('compares a push to the branch at names, and answers a push to any other branch, the default included, with no change and no request', async () => {
528
+ const stand = github();
529
+ const connector = connectorOn(stand);
530
+ const previous = JSON.stringify({ 'README.md': 'r1', 'docs/agenda.md': 'a2', 'docs/setup.md': 's1' });
531
+ const changes = await connector.changesSince(previous, { ids: ['77'], at: 'release', ref: 'refs/heads/release', after: RELEASE_NEXT });
532
+ expect(changes.changed).toEqual([{ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 330, version: `a3@${RELEASE_NEXT}` }]);
533
+ expect(changes.gone).toEqual([{ id: '77:docs/setup.md', reason: 'removed' }]);
534
+ expect(reads(stand.seen)).toEqual([`/repositories/77/git/trees/${RELEASE_NEXT}?recursive=1`]);
535
+ const before = stand.seen.length;
536
+ expect(await connector.changesSince(previous, { ids: ['77'], at: 'release', ref: 'refs/heads/main', after: MAIN })).toEqual({ changed: [], gone: [], cursor: previous });
537
+ expect(await connector.changesSince(previous, { ids: ['77'], at: 'release/1.0', ref: 'refs/heads/release', after: RELEASE_NEXT })).toEqual({
538
+ changed: [],
539
+ gone: [],
540
+ cursor: previous,
541
+ });
542
+ expect(stand.seen.length).toBe(before);
543
+ });
544
+ it('compares a commit at itself, so a push never changes it, and refuses an after naming another commit', async () => {
545
+ const stand = github();
546
+ const connector = connectorOn(stand);
547
+ const cursor = JSON.stringify({ 'README.md': 'r0', 'docs/old.md': 'o1' });
548
+ expect(await connector.changesSince(cursor, { ids: ['77'], at: PINNED })).toEqual({ changed: [], gone: [], cursor });
549
+ expect(await connector.changesSince(null, { ids: ['77'], at: PINNED.toUpperCase(), after: PINNED })).toEqual({
550
+ changed: [
551
+ { id: '77:README.md', title: 'README.md', size: 100, version: `r0@${PINNED}` },
552
+ { id: '77:docs/old.md', title: 'docs/old.md', size: 50, version: `o1@${PINNED}` },
553
+ ],
554
+ gone: [],
555
+ cursor,
556
+ });
557
+ expect(reads(stand.seen)).toEqual([`/repositories/77/git/trees/${PINNED}?recursive=1`, `/repositories/77/git/trees/${PINNED}?recursive=1`]);
558
+ const before = stand.seen.length;
559
+ expect(await connector.changesSince(cursor, { ids: ['77'], at: PINNED, ref: 'refs/heads/main', after: MAIN })).toEqual({ changed: [], gone: [], cursor });
560
+ await expect(connector.changesSince(cursor, { ids: ['77'], at: PINNED, after: MAIN })).rejects.toBeInstanceOf(TypeError);
561
+ expect(stand.seen.length).toBe(before);
562
+ });
563
+ it('reads head at a named branch or commit, and at the default branch when handed a signal alone as before', async () => {
564
+ const stand = github();
565
+ const connector = connectorOn(stand);
566
+ expect(await connector.head(77, 'release/1.0')).toBe(RELEASE_10);
567
+ expect(await connector.head(77, PINNED.toUpperCase())).toBe(PINNED);
568
+ expect(await connector.head(77)).toBe(MAIN);
569
+ expect(await connector.head(77, new AbortController().signal)).toBe(MAIN);
570
+ expect(await connector.head(77, undefined, new AbortController().signal)).toBe(MAIN);
571
+ expect(reads(stand.seen)).toEqual([
572
+ '/repositories/77/branches/release%2F1.0',
573
+ '/repositories/77/branches/main',
574
+ '/repositories/77/branches/main',
575
+ '/repositories/77/branches/main',
576
+ ]);
577
+ });
578
+ it('refuses, before any request, an at that is neither a branch name nor a commit, and an at over other than one repository', async () => {
579
+ const stand = github();
580
+ const connector = connectorOn(stand);
581
+ const names = ['', 'feature..x', 'feature x', '-x', 'HEAD', '@', 'release/', '/release', 'release//1.0', 'x.lock', 'docs/.hidden', 'end.'];
582
+ for (const at of [...names, 'a~1', 'a^', 'a:b', 'a?', 'a*', 'a[1]', 'a\\b', 'a@{1}', 'a\tb']) {
583
+ await expect(connector.list({ ids: ['77'], at }), at).rejects.toBeInstanceOf(TypeError);
584
+ await expect(connector.changesSince(null, { ids: ['77'], at }), at).rejects.toBeInstanceOf(TypeError);
585
+ await expect(connector.head(77, at), at).rejects.toBeInstanceOf(TypeError);
586
+ }
587
+ await expect(connector.list({ ids: ['77', '80'], at: 'release' })).rejects.toBeInstanceOf(TypeError);
588
+ await expect(connector.list({ ids: [], at: 'release' })).rejects.toBeInstanceOf(TypeError);
589
+ expect(stand.seen).toEqual([]);
590
+ });
591
+ it('answers an aborted call cancelled before it checks at', async () => {
592
+ const stand = github();
593
+ const connector = connectorOn(stand);
594
+ const aborted = AbortSignal.abort();
595
+ const calls = [
596
+ () => connector.list({ ids: ['77'], at: '' }, aborted),
597
+ () => connector.changesSince(null, { ids: ['77'], at: '' }, aborted),
598
+ () => connector.head(77, '', aborted),
599
+ () => connector.head(77, 'release', aborted),
600
+ ];
601
+ for (const call of calls) {
602
+ const failure = await call().catch((error) => error);
603
+ expect(failure).toBeInstanceOf(GitHubNoAnswer);
604
+ expect(failure.reason).toBe('cancelled');
605
+ }
606
+ expect(stand.seen).toEqual([]);
607
+ });
608
+ });
609
+ describe('the listing of a folder or a file', () => {
610
+ /** The folder `docs` of the crowded commit, whose two thousand documents under `a/` sort before it. */
611
+ const folder = { ids: ['77'], at: CROWDED, path: 'docs' };
612
+ const GUIDE = { id: '77:docs/guide.md', title: 'docs/guide.md', size: 40, version: `g1@${CROWDED}` };
613
+ const SETUP = { id: '77:docs/setup.md', title: 'docs/setup.md', size: 50, version: `s2@${CROWDED}` };
614
+ it("keeps the folder's files before the file count, so the files sorting before it never crowd them out", async () => {
615
+ const connector = connectorOn(github());
616
+ // Over the whole repository the count keeps a/ alone: a filter of that listing finds none of the folder's files.
617
+ const whole = await connector.list({ ids: ['77'], at: CROWDED });
618
+ expect(whole.items).toHaveLength(2_000);
619
+ expect(whole.items.filter((item) => item.title.startsWith('docs/'))).toEqual([]);
620
+ expect(await connector.list(folder)).toEqual({ items: [GUIDE, SETUP], gone: [] });
621
+ const everything = { ids: ['77'], at: CROWDED, path: '' };
622
+ expect((await connector.list(everything)).items).toHaveLength(2_000);
623
+ });
624
+ it('answers the file a path names, as a /tree/ address naming a file does, and nothing for a path the tree does not hold', async () => {
625
+ const connector = connectorOn(github());
626
+ const file = { ids: ['77'], at: CROWDED, path: 'docs/guide.md' };
627
+ const missing = { ids: ['77'], at: CROWDED, path: 'docs/guide' };
628
+ expect((await connector.list(file)).items).toEqual([GUIDE]);
629
+ expect((await connector.list(missing)).items).toEqual([]);
630
+ });
631
+ it("compares the folder alone, so its files are never answered gone for the files sorting before it", async () => {
632
+ const connector = connectorOn(github());
633
+ const cursor = JSON.stringify({ 'docs/guide.md': 'g1', 'docs/setup.md': 's2' });
634
+ expect(await connector.changesSince(cursor, folder)).toEqual({ changed: [], gone: [], cursor });
635
+ // Over the whole repository the folder's files are past the count, and read as gone though GitHub has them.
636
+ const whole = await connector.changesSince(cursor, { ids: ['77'], at: CROWDED });
637
+ expect(whole.gone).toEqual([
638
+ { id: '77:docs/guide.md', reason: 'removed' },
639
+ { id: '77:docs/setup.md', reason: 'removed' },
640
+ ]);
641
+ });
642
+ it('reads the folder afresh when a listing handed in holds the whole count, which may have been cut before the folder', async () => {
643
+ const stand = github();
644
+ const connector = connectorOn(stand);
645
+ const cursor = JSON.stringify({ 'docs/guide.md': 'g1', 'docs/setup.md': 's2' });
646
+ // A listing of the whole repository keeps a/ alone: narrowed to docs it would answer the folder's files gone.
647
+ const whole = await connector.listing(77, CROWDED);
648
+ expect(whole).toHaveLength(2_000);
649
+ const compared = { ids: ['77'], at: CROWDED, after: CROWDED, listing: whole, path: 'docs' };
650
+ expect(await connector.changesSince(cursor, compared)).toEqual({ changed: [], gone: [], cursor });
651
+ });
652
+ it("reads a folder's listing to hand changesSince, under the caller's signal", async () => {
653
+ const stand = github();
654
+ const connector = connectorOn(stand);
655
+ const listingOf = connector.listing.bind(connector);
656
+ const listing = await listingOf(77, CROWDED, { path: 'docs' });
657
+ expect(listing).toEqual([
658
+ { path: 'docs/guide.md', sha: 'g1', size: 40 },
659
+ { path: 'docs/setup.md', sha: 's2', size: 50 },
660
+ ]);
661
+ const before = stand.seen.length;
662
+ const compared = { ids: ['77'], at: CROWDED, after: CROWDED, listing, path: 'docs' };
663
+ expect(await connector.changesSince(JSON.stringify({ 'docs/guide.md': 'g0' }), compared)).toEqual({
664
+ changed: [GUIDE, SETUP],
665
+ gone: [],
666
+ cursor: JSON.stringify({ 'docs/guide.md': 'g1', 'docs/setup.md': 's2' }),
667
+ });
668
+ expect(stand.seen.length).toBe(before);
669
+ const aborted = await listingOf(77, CROWDED, { path: 'docs', signal: AbortSignal.abort() }).catch((error) => error);
670
+ expect(aborted).toBeInstanceOf(GitHubNoAnswer);
671
+ expect(aborted.reason).toBe('cancelled');
672
+ expect(stand.seen.length).toBe(before);
673
+ });
674
+ it('plans the folder or the file alone, its files counted before the count bound, as list keeps them', () => {
675
+ const crowded = TREES[CROWDED].map((entry) => ({ ...entry }));
676
+ expect(planTree(crowded).filter((planned) => planned.path.startsWith('docs/')).map((planned) => planned.outcome)).toEqual([
677
+ { skipped: 'count' },
678
+ { skipped: 'count' },
679
+ ]);
680
+ const inFolder = { path: 'docs', maxFiles: 2_000 };
681
+ expect(outcomes(planTree(crowded, inFolder))).toEqual([
682
+ ['docs/guide.md', 'note'],
683
+ ['docs/setup.md', 'note'],
684
+ ]);
685
+ const oneFile = { path: 'docs/guide.md', maxFiles: 2_000 };
686
+ expect(outcomes(planTree(crowded, oneFile))).toEqual([['docs/guide.md', 'note']]);
687
+ });
688
+ it('refuses, before any request, a path that is not text or has an empty, . or .. segment, and a folder of other than one repository', async () => {
689
+ const stand = github();
690
+ const connector = connectorOn(stand);
691
+ for (const path of ['docs/', '/docs', 'docs//guide.md', './docs', 'docs/..', 'docs/./guide.md', '..', 42, null]) {
692
+ const selection = { ids: ['77'], at: CROWDED, path };
693
+ await expect(connector.list(selection), String(path)).rejects.toBeInstanceOf(TypeError);
694
+ await expect(connector.changesSince(null, selection), String(path)).rejects.toBeInstanceOf(TypeError);
695
+ expect(() => planTree([], { path, maxFiles: 1 }), String(path)).toThrow(TypeError);
696
+ }
697
+ const two = { ids: ['77', '80'], path: 'docs' };
698
+ await expect(connector.list(two)).rejects.toBeInstanceOf(TypeError);
699
+ expect(stand.seen).toEqual([]);
700
+ });
701
+ });
702
+ //# sourceMappingURL=refs.test.js.map