@framers/agentos-ext-github 0.8.0 → 0.10.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.
- package/README.md +19 -13
- package/dist/documents/GitHubDocumentsConnector.d.ts +123 -26
- package/dist/documents/GitHubDocumentsConnector.d.ts.map +1 -1
- package/dist/documents/GitHubDocumentsConnector.js +229 -46
- package/dist/documents/GitHubDocumentsConnector.js.map +1 -1
- package/dist/documents/index.d.ts +3 -3
- package/dist/documents/index.d.ts.map +1 -1
- package/dist/documents/index.js +2 -2
- package/dist/documents/index.js.map +1 -1
- package/dist/documents/links.d.ts +28 -14
- package/dist/documents/links.d.ts.map +1 -1
- package/dist/documents/links.js +113 -39
- package/dist/documents/links.js.map +1 -1
- package/dist/documents/tree.d.ts +47 -13
- package/dist/documents/tree.d.ts.map +1 -1
- package/dist/documents/tree.js +98 -15
- package/dist/documents/tree.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +5 -4
- package/dist/__tests__/app.test.d.ts +0 -7
- package/dist/__tests__/app.test.d.ts.map +0 -1
- package/dist/__tests__/app.test.js +0 -1219
- package/dist/__tests__/app.test.js.map +0 -1
- package/dist/__tests__/documents.test.d.ts +0 -9
- package/dist/__tests__/documents.test.d.ts.map +0 -1
- package/dist/__tests__/documents.test.js +0 -644
- package/dist/__tests__/documents.test.js.map +0 -1
- package/dist/__tests__/entry.test.d.ts +0 -7
- package/dist/__tests__/entry.test.d.ts.map +0 -1
- package/dist/__tests__/entry.test.js +0 -34
- package/dist/__tests__/entry.test.js.map +0 -1
- package/dist/__tests__/events.test.d.ts +0 -8
- package/dist/__tests__/events.test.d.ts.map +0 -1
- package/dist/__tests__/events.test.js +0 -321
- package/dist/__tests__/events.test.js.map +0 -1
- package/dist/__tests__/fileAndBranchTools.test.d.ts +0 -6
- package/dist/__tests__/fileAndBranchTools.test.d.ts.map +0 -1
- package/dist/__tests__/fileAndBranchTools.test.js +0 -310
- package/dist/__tests__/fileAndBranchTools.test.js.map +0 -1
- package/dist/__tests__/indexer.test.d.ts +0 -6
- package/dist/__tests__/indexer.test.d.ts.map +0 -1
- package/dist/__tests__/indexer.test.js +0 -253
- package/dist/__tests__/indexer.test.js.map +0 -1
- package/dist/__tests__/issueTools.test.d.ts +0 -5
- package/dist/__tests__/issueTools.test.d.ts.map +0 -1
- package/dist/__tests__/issueTools.test.js +0 -183
- package/dist/__tests__/issueTools.test.js.map +0 -1
- package/dist/__tests__/links.test.d.ts +0 -8
- package/dist/__tests__/links.test.d.ts.map +0 -1
- package/dist/__tests__/links.test.js +0 -182
- package/dist/__tests__/links.test.js.map +0 -1
- package/dist/__tests__/prTools.test.d.ts +0 -5
- package/dist/__tests__/prTools.test.d.ts.map +0 -1
- package/dist/__tests__/prTools.test.js +0 -318
- package/dist/__tests__/prTools.test.js.map +0 -1
- package/dist/__tests__/refs.test.d.ts +0 -7
- package/dist/__tests__/refs.test.d.ts.map +0 -1
- package/dist/__tests__/refs.test.js +0 -420
- package/dist/__tests__/refs.test.js.map +0 -1
- package/dist/__tests__/releaseAndActionsTools.test.d.ts +0 -6
- package/dist/__tests__/releaseAndActionsTools.test.d.ts.map +0 -1
- package/dist/__tests__/releaseAndActionsTools.test.js +0 -315
- package/dist/__tests__/releaseAndActionsTools.test.js.map +0 -1
- package/dist/__tests__/repoTools.test.d.ts +0 -6
- package/dist/__tests__/repoTools.test.d.ts.map +0 -1
- package/dist/__tests__/repoTools.test.js +0 -308
- package/dist/__tests__/repoTools.test.js.map +0 -1
- package/dist/__tests__/service.test.d.ts +0 -6
- package/dist/__tests__/service.test.d.ts.map +0 -1
- package/dist/__tests__/service.test.js +0 -173
- package/dist/__tests__/service.test.js.map +0 -1
|
@@ -1,644 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview `GitHubDocumentsConnector` over a fetch stand-in that answers a repository's branch, trees and raw
|
|
3
|
-
* files and records each request: the documentation filter, a truncated tree read by subtree within its request
|
|
4
|
-
* budget, a folder GitHub cuts short refused whole, a listing kept for a commit, raw reads with their attribution and
|
|
5
|
-
* their cap, a push compared by its tree, a repository read by its id whatever its name has come to mean, and the
|
|
6
|
-
* selections, items and cancelled calls refused before any request.
|
|
7
|
-
*/
|
|
8
|
-
import { generateKeyPairSync } from 'node:crypto';
|
|
9
|
-
import { sourceRefLink } from '@framers/agentos-ext-account-sources';
|
|
10
|
-
import { describe, expect, it } from 'vitest';
|
|
11
|
-
import { CODE_EXTENSIONS, DEFAULT_MAX_FILE_BYTES, GitHubApiError, GitHubAppCredential, GitHubDocumentsConnector, GitHubNoAnswer, isGeneratedCode, pathFilter, readTree, TreeTooLarge, } from '../documents/index.js';
|
|
12
|
-
/** A JSON answer. */
|
|
13
|
-
function json(body, status = 200) {
|
|
14
|
-
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
15
|
-
}
|
|
16
|
-
/** A tree answer as GitHub gives it. */
|
|
17
|
-
function tree(sha, entries, truncated = false) {
|
|
18
|
-
return json({ sha, truncated, tree: entries.map((entry) => ({ mode: entry.type === 'tree' ? '040000' : '100644', ...entry })) });
|
|
19
|
-
}
|
|
20
|
-
const BASE = 'https://stand-in.test';
|
|
21
|
-
const INSTALLATION = 5;
|
|
22
|
-
/** An installation the app no longer has: GitHub answers its token request 404. */
|
|
23
|
-
const GONE_INSTALLATION = 6;
|
|
24
|
-
const HANDBOOK = { id: 77, fullName: 'acme-team/handbook', defaultBranch: 'main' };
|
|
25
|
-
const LARGE = { id: 78, fullName: 'acme-team/large', defaultBranch: 'main' };
|
|
26
|
-
const C1 = '1'.repeat(40);
|
|
27
|
-
const C2 = '2'.repeat(40);
|
|
28
|
-
const C3 = '3'.repeat(40);
|
|
29
|
-
const CL = 'f'.repeat(40);
|
|
30
|
-
const AGENDA = '# Agenda\n\n1. The venue\n2. The budget\n';
|
|
31
|
-
const PDF = new TextEncoder().encode('%PDF-1.4\n% a test file\n');
|
|
32
|
-
const LONG_CHUNKS = 3;
|
|
33
|
-
const LONG_CHUNK_BYTES = 600_000;
|
|
34
|
-
/** When the stand-in's rate limit window resets, in UTC epoch seconds. */
|
|
35
|
-
const LIMIT_RESET = 2_000_000_000;
|
|
36
|
-
/** The handbook at C1: documentation, code, a file past the default cap, and built and vendored folders. */
|
|
37
|
-
const HANDBOOK_C1 = [
|
|
38
|
-
{ path: 'README.md', type: 'blob', sha: 'r1', size: 120 },
|
|
39
|
-
{ path: 'dist', type: 'tree', sha: 'td' },
|
|
40
|
-
{ path: 'dist/z.md', type: 'blob', sha: 'z1', size: 10 },
|
|
41
|
-
{ path: 'docs', type: 'tree', sha: 'tdocs' },
|
|
42
|
-
{ path: 'docs/agenda.md', type: 'blob', sha: 'a1', size: 300 },
|
|
43
|
-
{ path: 'docs/big.md', type: 'blob', sha: 'g1', size: 12 * 1024 * 1024 },
|
|
44
|
-
{ path: 'docs/budget.pdf', type: 'blob', sha: 'b1', size: 2048 },
|
|
45
|
-
{ path: 'node_modules/x/README.md', type: 'blob', sha: 'n1', size: 10 },
|
|
46
|
-
{ path: 'src/index.ts', type: 'blob', sha: 's1', size: 50 },
|
|
47
|
-
{ path: 'vendor/y.md', type: 'blob', sha: 'v1', size: 10 },
|
|
48
|
-
];
|
|
49
|
-
/** The handbook at C2: the agenda changed, an old page dropped by a force push. */
|
|
50
|
-
const HANDBOOK_C2 = [
|
|
51
|
-
{ path: 'README.md', type: 'blob', sha: 'r1', size: 120 },
|
|
52
|
-
{ path: 'docs/agenda.md', type: 'blob', sha: 'a2', size: 320 },
|
|
53
|
-
{ path: 'docs/budget.pdf', type: 'blob', sha: 'b1', size: 2048 },
|
|
54
|
-
];
|
|
55
|
-
/** A stand-in GitHub holding the two repositories; `pulled` counts the bytes a capped read drew from its stream. */
|
|
56
|
-
function github() {
|
|
57
|
-
const seen = [];
|
|
58
|
-
const long = { pulled: 0, cancelled: false };
|
|
59
|
-
const fetchImpl = async (input, init) => {
|
|
60
|
-
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
61
|
-
seen.push({ method: init?.method ?? 'GET', url: href, headers: new Headers(init?.headers) });
|
|
62
|
-
const url = new URL(href);
|
|
63
|
-
const path = url.pathname;
|
|
64
|
-
const recursive = url.searchParams.get('recursive') === '1';
|
|
65
|
-
if (path === `/app/installations/${INSTALLATION}/access_tokens`) {
|
|
66
|
-
return json({ token: 'ghs_documents', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
|
|
67
|
-
}
|
|
68
|
-
if (path === `/app/installations/${GONE_INSTALLATION}/access_tokens`)
|
|
69
|
-
return json({ message: 'Not Found' }, 404);
|
|
70
|
-
if (path === '/repositories/77/branches/main')
|
|
71
|
-
return json({ name: 'main', commit: { sha: C1 } });
|
|
72
|
-
if (path === `/repositories/77/git/trees/${C1}` && recursive)
|
|
73
|
-
return tree(C1, HANDBOOK_C1);
|
|
74
|
-
if (path === `/repositories/77/git/trees/${C2}` && recursive)
|
|
75
|
-
return tree(C2, HANDBOOK_C2);
|
|
76
|
-
if (path === '/repositories/77/contents/docs/agenda.md' && [C1, 'main'].includes(url.searchParams.get('ref') ?? ''))
|
|
77
|
-
return new Response(AGENDA);
|
|
78
|
-
if (path === '/repositories/77/contents/docs/budget.pdf' && url.searchParams.get('ref') === C1)
|
|
79
|
-
return new Response(PDF);
|
|
80
|
-
if (path === '/repositories/77/contents/docs/long.md' && url.searchParams.get('ref') === C1) {
|
|
81
|
-
let sent = 0;
|
|
82
|
-
const body = new ReadableStream({
|
|
83
|
-
pull(controller) {
|
|
84
|
-
if (sent === LONG_CHUNKS) {
|
|
85
|
-
controller.close();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
sent += 1;
|
|
89
|
-
long.pulled += LONG_CHUNK_BYTES;
|
|
90
|
-
controller.enqueue(new Uint8Array(LONG_CHUNK_BYTES).fill(0x61));
|
|
91
|
-
},
|
|
92
|
-
cancel() {
|
|
93
|
-
long.cancelled = true;
|
|
94
|
-
},
|
|
95
|
-
}, { highWaterMark: 0 });
|
|
96
|
-
return new Response(body);
|
|
97
|
-
}
|
|
98
|
-
if (path === '/repositories/77/contents/docs/limited.md') {
|
|
99
|
-
return new Response(JSON.stringify({ message: 'API rate limit exceeded for installation ID 5.' }), {
|
|
100
|
-
status: 403,
|
|
101
|
-
headers: { 'content-type': 'application/json', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(LIMIT_RESET) },
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
if (path === '/repositories/78/branches/main')
|
|
105
|
-
return json({ name: 'main', commit: { sha: CL } });
|
|
106
|
-
if (path === `/repositories/78/git/trees/${CL}` && recursive) {
|
|
107
|
-
return tree(CL, [{ path: 'README.md', type: 'blob', sha: 'lr1', size: 90 }], true);
|
|
108
|
-
}
|
|
109
|
-
if (path === `/repositories/78/git/trees/${CL}`) {
|
|
110
|
-
return tree(CL, [
|
|
111
|
-
{ path: 'README.md', type: 'blob', sha: 'lr1', size: 90 },
|
|
112
|
-
{ path: 'docs', type: 'tree', sha: 'ldocs' },
|
|
113
|
-
{ path: 'guide.md', type: 'blob', sha: 'lg1', size: 400 },
|
|
114
|
-
{ path: 'node_modules', type: 'tree', sha: 'lnm' },
|
|
115
|
-
{ path: 'src', type: 'tree', sha: 'lsrc' },
|
|
116
|
-
]);
|
|
117
|
-
}
|
|
118
|
-
if (path === '/repositories/78/git/trees/ldocs' && recursive) {
|
|
119
|
-
return tree('ldocs', [
|
|
120
|
-
{ path: 'agenda.md', type: 'blob', sha: 'la1', size: 300 },
|
|
121
|
-
{ path: 'deep', type: 'tree', sha: 'ldeep' },
|
|
122
|
-
{ path: 'deep/notes.md', type: 'blob', sha: 'ln1', size: 200 },
|
|
123
|
-
]);
|
|
124
|
-
}
|
|
125
|
-
if (path === '/repositories/78/git/trees/lsrc' && recursive)
|
|
126
|
-
return tree('lsrc', [{ path: 'index.ts', type: 'blob', sha: 'ls1', size: 50 }]);
|
|
127
|
-
return json({ message: 'Not Found' }, 404);
|
|
128
|
-
};
|
|
129
|
-
return { fetchImpl, seen, long };
|
|
130
|
-
}
|
|
131
|
-
const keys = generateKeyPairSync('rsa', {
|
|
132
|
-
modulusLength: 2048,
|
|
133
|
-
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
134
|
-
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
135
|
-
});
|
|
136
|
-
/** A connector to both repositories of one installation, on the stand-in. */
|
|
137
|
-
function connectorOn(stand, filter = {}) {
|
|
138
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, stand.fetchImpl, BASE);
|
|
139
|
-
return new GitHubDocumentsConnector(credential, INSTALLATION, [HANDBOOK, LARGE], filter, stand.fetchImpl, BASE);
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* The stand-in, except that GitHub's installation token address never answers: its request rejects with its signal's
|
|
143
|
-
* reason when the signal aborts, as fetch does.
|
|
144
|
-
*/
|
|
145
|
-
function tokensNeverAnswer(stand) {
|
|
146
|
-
return async (input, init) => {
|
|
147
|
-
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
148
|
-
if (!href.endsWith('/access_tokens'))
|
|
149
|
-
return stand.fetchImpl(input, init);
|
|
150
|
-
const signal = init?.signal ?? undefined;
|
|
151
|
-
return new Promise((_resolve, reject) => {
|
|
152
|
-
if (signal === undefined)
|
|
153
|
-
return;
|
|
154
|
-
if (signal.aborted)
|
|
155
|
-
reject(signal.reason);
|
|
156
|
-
else
|
|
157
|
-
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
158
|
-
});
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* The stand-in, except that a raw read of `docs/stalled.md` answers the start of the file and then stalls, leaving its
|
|
163
|
-
* body open when the read's signal aborts, as a fetch that does not tie its answer's body to its signal would.
|
|
164
|
-
*/
|
|
165
|
-
function contentsStall(stand) {
|
|
166
|
-
let waiting = () => undefined;
|
|
167
|
-
const held = {
|
|
168
|
-
cancels: 0,
|
|
169
|
-
stalled: new Promise((resolve) => {
|
|
170
|
-
waiting = () => resolve();
|
|
171
|
-
}),
|
|
172
|
-
};
|
|
173
|
-
const fetchImpl = async (input, init) => {
|
|
174
|
-
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
175
|
-
if (!href.includes('/contents/docs/stalled.md'))
|
|
176
|
-
return stand.fetchImpl(input, init);
|
|
177
|
-
let pulls = 0;
|
|
178
|
-
return new Response(new ReadableStream({
|
|
179
|
-
pull(controller) {
|
|
180
|
-
pulls += 1;
|
|
181
|
-
if (pulls === 1)
|
|
182
|
-
controller.enqueue(new TextEncoder().encode('# Stalled\n'));
|
|
183
|
-
else
|
|
184
|
-
waiting();
|
|
185
|
-
},
|
|
186
|
-
cancel() {
|
|
187
|
-
held.cancels += 1;
|
|
188
|
-
},
|
|
189
|
-
}, { highWaterMark: 0 }));
|
|
190
|
-
};
|
|
191
|
-
return { fetchImpl, held };
|
|
192
|
-
}
|
|
193
|
-
/** The tree requests made, as their paths and queries after the repository. */
|
|
194
|
-
function treeRequests(seen) {
|
|
195
|
-
return seen.filter((request) => request.url.includes('/git/trees/')).map((request) => request.url.slice(request.url.indexOf('/git/trees/') + '/git/trees/'.length));
|
|
196
|
-
}
|
|
197
|
-
/** The read, or a failure naming the reason it is gone. */
|
|
198
|
-
function asRead(answer) {
|
|
199
|
-
if ('reason' in answer)
|
|
200
|
-
throw new Error(`answered gone: ${answer.reason}`);
|
|
201
|
-
return answer;
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* GitHub after the handbook's name has passed to another account's public repository, as it does once a renamed
|
|
205
|
-
* repository's old name is taken again or the account's old login is claimed: the name answers that repository, whose
|
|
206
|
-
* branch, tree and files any token can read, and the linked repository 77 answers by its id alone.
|
|
207
|
-
*/
|
|
208
|
-
function nameTakenOver() {
|
|
209
|
-
const seen = [];
|
|
210
|
-
const fetchImpl = async (input, init) => {
|
|
211
|
-
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
212
|
-
seen.push({ method: init?.method ?? 'GET', url: href, headers: new Headers(init?.headers) });
|
|
213
|
-
const url = new URL(href);
|
|
214
|
-
const path = url.pathname;
|
|
215
|
-
if (path === `/app/installations/${INSTALLATION}/access_tokens`) {
|
|
216
|
-
return json({ token: 'ghs_documents', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
|
|
217
|
-
}
|
|
218
|
-
if (path === '/repositories/77/branches/main')
|
|
219
|
-
return json({ name: 'main', commit: { sha: C1 } });
|
|
220
|
-
if (path === `/repositories/77/git/trees/${C1}`)
|
|
221
|
-
return tree(C1, HANDBOOK_C1);
|
|
222
|
-
if (path === '/repositories/77/contents/docs/agenda.md' && url.searchParams.get('ref') === C1)
|
|
223
|
-
return new Response(AGENDA);
|
|
224
|
-
if (path === '/repos/acme-team/handbook/branches/main')
|
|
225
|
-
return json({ name: 'main', commit: { sha: C3 } });
|
|
226
|
-
if (path === `/repos/acme-team/handbook/git/trees/${C3}`) {
|
|
227
|
-
return tree(C3, [
|
|
228
|
-
{ path: 'README.md', type: 'blob', sha: 'x1', size: 40 },
|
|
229
|
-
{ path: 'docs/agenda.md', type: 'blob', sha: 'x2', size: 40 },
|
|
230
|
-
]);
|
|
231
|
-
}
|
|
232
|
-
if (path.startsWith('/repos/acme-team/handbook/contents/'))
|
|
233
|
-
return new Response('# Not the handbook\n');
|
|
234
|
-
return json({ message: 'Not Found' }, 404);
|
|
235
|
-
};
|
|
236
|
-
return { fetchImpl, seen };
|
|
237
|
-
}
|
|
238
|
-
/** A repository with a folder too large for one tree answer, whatever way it is asked for. */
|
|
239
|
-
const HUGE = { id: 79, fullName: 'acme-team/huge', defaultBranch: 'main' };
|
|
240
|
-
const CH = 'e'.repeat(40);
|
|
241
|
-
/**
|
|
242
|
-
* GitHub holding the huge repository: its tree at CH is cut short asked for recursively and asked for alone, as the
|
|
243
|
-
* tree answer's required `truncated` field may say of any answer. The cut answer holds the files before the cut alone.
|
|
244
|
-
*/
|
|
245
|
-
function levelCut() {
|
|
246
|
-
const seen = [];
|
|
247
|
-
const fetchImpl = async (input, init) => {
|
|
248
|
-
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
249
|
-
seen.push({ method: init?.method ?? 'GET', url: href, headers: new Headers(init?.headers) });
|
|
250
|
-
const url = new URL(href);
|
|
251
|
-
const path = url.pathname;
|
|
252
|
-
const recursive = url.searchParams.get('recursive') === '1';
|
|
253
|
-
if (path === `/app/installations/${INSTALLATION}/access_tokens`) {
|
|
254
|
-
return json({ token: 'ghs_documents', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
|
|
255
|
-
}
|
|
256
|
-
if (path === '/repositories/79/branches/main')
|
|
257
|
-
return json({ name: 'main', commit: { sha: CH } });
|
|
258
|
-
if (path === `/repositories/79/git/trees/${CH}` && recursive)
|
|
259
|
-
return tree(CH, [{ path: 'README.md', type: 'blob', sha: 'h1', size: 90 }], true);
|
|
260
|
-
if (path === `/repositories/79/git/trees/${CH}`) {
|
|
261
|
-
return tree(CH, [
|
|
262
|
-
{ path: 'README.md', type: 'blob', sha: 'h1', size: 90 },
|
|
263
|
-
{ path: 'docs', type: 'tree', sha: 'hdocs' },
|
|
264
|
-
], true);
|
|
265
|
-
}
|
|
266
|
-
if (path === '/repositories/79/git/trees/hdocs' && recursive)
|
|
267
|
-
return tree('hdocs', [{ path: 'guide.md', type: 'blob', sha: 'h2', size: 300 }]);
|
|
268
|
-
return json({ message: 'Not Found' }, 404);
|
|
269
|
-
};
|
|
270
|
-
return { fetchImpl, seen };
|
|
271
|
-
}
|
|
272
|
-
describe('GitHubDocumentsConnector', () => {
|
|
273
|
-
it("lists a repository's documentation at its default branch's head with the default filter", async () => {
|
|
274
|
-
const stand = github();
|
|
275
|
-
const listed = await connectorOn(stand).list({ ids: ['77'] });
|
|
276
|
-
expect(listed).toEqual({
|
|
277
|
-
items: [
|
|
278
|
-
{ id: '77:README.md', title: 'README.md', size: 120, version: `r1@${C1}` },
|
|
279
|
-
{ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` },
|
|
280
|
-
{ id: '77:docs/budget.pdf', title: 'docs/budget.pdf', size: 2048, version: `b1@${C1}` },
|
|
281
|
-
],
|
|
282
|
-
gone: [],
|
|
283
|
-
});
|
|
284
|
-
const branchRead = stand.seen.find((request) => request.url.endsWith('/repositories/77/branches/main'));
|
|
285
|
-
expect(branchRead?.headers.get('authorization')).toBe('Bearer ghs_documents');
|
|
286
|
-
expect(treeRequests(stand.seen)).toEqual([`${C1}?recursive=1`]);
|
|
287
|
-
});
|
|
288
|
-
it('keeps code beside the documentation only when asked, under the same bounds, leaving out code a tool wrote', async () => {
|
|
289
|
-
const stand = github();
|
|
290
|
-
const listed = await connectorOn(stand, { includeCode: true }).list({ ids: ['77'] });
|
|
291
|
-
// src/index.ts joins the documentation; a skipped folder and the size cap still hold.
|
|
292
|
-
expect(listed.items.map((item) => item.id)).toEqual(['77:README.md', '77:docs/agenda.md', '77:docs/budget.pdf', '77:src/index.ts']);
|
|
293
|
-
expect(listed.items.find((item) => item.id === '77:src/index.ts')).toEqual({ id: '77:src/index.ts', title: 'src/index.ts', size: 50, version: `s1@${C1}` });
|
|
294
|
-
const blob = (path, size = 10) => ({ path, sha: path, size });
|
|
295
|
-
const blobs = [
|
|
296
|
-
blob('app/main.py'),
|
|
297
|
-
blob('app/Main.GO'),
|
|
298
|
-
blob('web/site.min.js'),
|
|
299
|
-
blob('web/vendor.bundle.js'),
|
|
300
|
-
blob('api/service.pb.go'),
|
|
301
|
-
blob('api/service_pb2.py'),
|
|
302
|
-
blob('web/app.js'),
|
|
303
|
-
blob('package-lock.json'),
|
|
304
|
-
blob('config/settings.yaml'),
|
|
305
|
-
blob('assets/logo.png'),
|
|
306
|
-
blob('build/out.js'),
|
|
307
|
-
blob('docs/guide.md'),
|
|
308
|
-
blob('src/huge.ts', DEFAULT_MAX_FILE_BYTES + 1),
|
|
309
|
-
];
|
|
310
|
-
expect(pathFilter({ includeCode: true })(blobs).map((kept) => kept.path)).toEqual(['app/Main.GO', 'app/main.py', 'docs/guide.md', 'web/app.js']);
|
|
311
|
-
// Off by default: the documentation alone, as before.
|
|
312
|
-
expect(pathFilter()(blobs).map((kept) => kept.path)).toEqual(['docs/guide.md']);
|
|
313
|
-
expect(pathFilter({ includeCode: false })(blobs).map((kept) => kept.path)).toEqual(['docs/guide.md']);
|
|
314
|
-
expect(CODE_EXTENSIONS.every((extension) => extension === extension.toLowerCase() && extension.startsWith('.'))).toBe(true);
|
|
315
|
-
expect(['x.min.js', 'A/B.MIN.CSS', 'gen_pb2_grpc.py'].map(isGeneratedCode)).toEqual([true, true, true]);
|
|
316
|
-
expect(['x.js', 'min.js', 'docs/pb2.md'].map(isGeneratedCode)).toEqual([false, false, false]);
|
|
317
|
-
});
|
|
318
|
-
it('leaves out code a tool wrote under an extension the filter names too, and bundled style sheets as bundled scripts', () => {
|
|
319
|
-
const blob = (path) => ({ path, sha: path, size: 10 });
|
|
320
|
-
const blobs = [blob('docs/a.md'), blob('web/app.js'), blob('web/app.min.js'), blob('web/styles.css'), blob('web/styles.bundle.css'), blob('web/vendor.chunk.css')];
|
|
321
|
-
// A `.js` named as documentation is still code a tool wrote when it is minified, once code is asked for.
|
|
322
|
-
expect(pathFilter({ includeCode: true, extensions: ['.md', '.js'] })(blobs).map((kept) => kept.path)).toEqual(['docs/a.md', 'web/app.js', 'web/styles.css']);
|
|
323
|
-
// Without code asked for, the extensions given are kept as they are given.
|
|
324
|
-
expect(pathFilter({ extensions: ['.md', '.js'] })(blobs).map((kept) => kept.path)).toEqual(['docs/a.md', 'web/app.js', 'web/app.min.js']);
|
|
325
|
-
expect(['web/styles.bundle.css', 'web/vendor.chunk.css', 'web/main.bundle.mjs'].map(isGeneratedCode)).toEqual([true, true, true]);
|
|
326
|
-
});
|
|
327
|
-
it('keeps the documentation first under the file cap, the code filling the room left, so turning code on drops no document', () => {
|
|
328
|
-
const blob = (path) => ({ path, sha: path, size: 10 });
|
|
329
|
-
const blobs = [blob('api/a.ts'), blob('api/b.ts'), blob('app/c.py'), blob('docs/guide.md'), blob('docs/z.md')];
|
|
330
|
-
expect(pathFilter({ maxFiles: 3 })(blobs).map((kept) => kept.path)).toEqual(['docs/guide.md', 'docs/z.md']);
|
|
331
|
-
// Both documents stay, and one code file takes the third place, in path order.
|
|
332
|
-
expect(pathFilter({ includeCode: true, maxFiles: 3 })(blobs).map((kept) => kept.path)).toEqual(['api/a.ts', 'docs/guide.md', 'docs/z.md']);
|
|
333
|
-
expect(pathFilter({ includeCode: true, maxFiles: 1 })(blobs).map((kept) => kept.path)).toEqual(['docs/guide.md']);
|
|
334
|
-
});
|
|
335
|
-
it('reads a linked repository by its id, never through a name another repository has taken since the link was made', async () => {
|
|
336
|
-
const stand = nameTakenOver();
|
|
337
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, stand.fetchImpl, BASE);
|
|
338
|
-
const connector = new GitHubDocumentsConnector(credential, INSTALLATION, [HANDBOOK], {}, stand.fetchImpl, BASE);
|
|
339
|
-
expect(await connector.list({ ids: ['77'] })).toEqual({
|
|
340
|
-
items: [
|
|
341
|
-
{ id: '77:README.md', title: 'README.md', size: 120, version: `r1@${C1}` },
|
|
342
|
-
{ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` },
|
|
343
|
-
{ id: '77:docs/budget.pdf', title: 'docs/budget.pdf', size: 2048, version: `b1@${C1}` },
|
|
344
|
-
],
|
|
345
|
-
gone: [],
|
|
346
|
-
});
|
|
347
|
-
const agenda = asRead(await connector.read({ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` }));
|
|
348
|
-
expect(agenda.content).toEqual({ text: AGENDA });
|
|
349
|
-
const reads = stand.seen.filter((request) => !request.url.endsWith('/access_tokens')).map((request) => new URL(request.url).pathname);
|
|
350
|
-
expect(reads).toEqual(['/repositories/77/branches/main', `/repositories/77/git/trees/${C1}`, '/repositories/77/contents/docs/agenda.md']);
|
|
351
|
-
});
|
|
352
|
-
it('reads a tree by a repository id, refusing one that is not a positive integer before any request', async () => {
|
|
353
|
-
const stand = github();
|
|
354
|
-
expect(await readTree(stand.fetchImpl, 'ghs_documents', 77, C2, BASE)).toEqual([
|
|
355
|
-
{ path: 'README.md', sha: 'r1', size: 120 },
|
|
356
|
-
{ path: 'docs/agenda.md', sha: 'a2', size: 320 },
|
|
357
|
-
{ path: 'docs/budget.pdf', sha: 'b1', size: 2048 },
|
|
358
|
-
]);
|
|
359
|
-
expect(stand.seen.map((request) => request.url)).toEqual([`${BASE}/repositories/77/git/trees/${C2}?recursive=1`]);
|
|
360
|
-
for (const repository of [0, -77, 7.5, Number.NaN, 2 ** 53]) {
|
|
361
|
-
await expect(readTree(stand.fetchImpl, 'ghs_documents', repository, C2, BASE)).rejects.toBeInstanceOf(TypeError);
|
|
362
|
-
}
|
|
363
|
-
expect(stand.seen).toHaveLength(1);
|
|
364
|
-
});
|
|
365
|
-
it('reads a truncated tree by subtree, never walking a skipped folder', async () => {
|
|
366
|
-
const stand = github();
|
|
367
|
-
const listing = await connectorOn(stand).listing(78, CL);
|
|
368
|
-
expect(listing).toEqual([
|
|
369
|
-
{ path: 'README.md', sha: 'lr1', size: 90 },
|
|
370
|
-
{ path: 'docs/agenda.md', sha: 'la1', size: 300 },
|
|
371
|
-
{ path: 'docs/deep/notes.md', sha: 'ln1', size: 200 },
|
|
372
|
-
{ path: 'guide.md', sha: 'lg1', size: 400 },
|
|
373
|
-
]);
|
|
374
|
-
expect(treeRequests(stand.seen)).toEqual([`${CL}?recursive=1`, CL, 'ldocs?recursive=1', 'lsrc?recursive=1']);
|
|
375
|
-
});
|
|
376
|
-
it('refuses a truncated tree whose walk would pass the request budget, having made no request past it', async () => {
|
|
377
|
-
const stand = github();
|
|
378
|
-
const failure = await connectorOn(stand, { maxTreeRequests: 3 })
|
|
379
|
-
.listing(78, CL)
|
|
380
|
-
.catch((error) => error);
|
|
381
|
-
expect(failure).toBeInstanceOf(TreeTooLarge);
|
|
382
|
-
expect(failure).toMatchObject({ reason: 'budget', maxRequests: 3 });
|
|
383
|
-
expect(treeRequests(stand.seen)).toEqual([`${CL}?recursive=1`, CL, 'ldocs?recursive=1']);
|
|
384
|
-
});
|
|
385
|
-
it('refuses a tree with a folder GitHub cuts short even asked for alone, so no file past the cut is answered removed', async () => {
|
|
386
|
-
const stand = levelCut();
|
|
387
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, stand.fetchImpl, BASE);
|
|
388
|
-
const connector = new GitHubDocumentsConnector(credential, INSTALLATION, [HUGE], {}, stand.fetchImpl, BASE);
|
|
389
|
-
const failure = await readTree(stand.fetchImpl, 'ghs_documents', 79, CH, BASE).catch((error) => error);
|
|
390
|
-
expect(failure).toBeInstanceOf(TreeTooLarge);
|
|
391
|
-
expect(failure.reason).toBe('truncated');
|
|
392
|
-
expect(failure.message).not.toContain('requests');
|
|
393
|
-
// The cut answer is refused where it comes, before any of its folders is asked for.
|
|
394
|
-
expect(treeRequests(stand.seen)).toEqual([`${CH}?recursive=1`, CH]);
|
|
395
|
-
// A cursor holding a file past the cut: a partial listing would answer it removed.
|
|
396
|
-
const cursor = JSON.stringify({ 'README.md': 'h1', 'docs/guide.md': 'h2', 'zz/late.md': 'h9' });
|
|
397
|
-
await expect(connector.changesSince(cursor, { ids: ['79'], after: CH })).rejects.toBeInstanceOf(TreeTooLarge);
|
|
398
|
-
await expect(connector.list({ ids: ['79'] })).rejects.toBeInstanceOf(TreeTooLarge);
|
|
399
|
-
});
|
|
400
|
-
it('answers the listing at a commit, and compares a push against a listing it is given without any request', async () => {
|
|
401
|
-
const stand = github();
|
|
402
|
-
const connector = connectorOn(stand);
|
|
403
|
-
const listing = await connector.listing(77, C1);
|
|
404
|
-
expect(listing).toEqual([
|
|
405
|
-
{ path: 'README.md', sha: 'r1', size: 120 },
|
|
406
|
-
{ path: 'docs/agenda.md', sha: 'a1', size: 300 },
|
|
407
|
-
{ path: 'docs/budget.pdf', sha: 'b1', size: 2048 },
|
|
408
|
-
]);
|
|
409
|
-
const before = stand.seen.length;
|
|
410
|
-
const changes = await connector.changesSince(JSON.stringify({ 'README.md': 'r1', 'docs/agenda.md': 'a0' }), { ids: ['77'], after: C1, listing });
|
|
411
|
-
expect(changes.changed).toEqual([
|
|
412
|
-
{ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` },
|
|
413
|
-
{ id: '77:docs/budget.pdf', title: 'docs/budget.pdf', size: 2048, version: `b1@${C1}` },
|
|
414
|
-
]);
|
|
415
|
-
expect(changes.gone).toEqual([]);
|
|
416
|
-
expect(stand.seen.length).toBe(before);
|
|
417
|
-
});
|
|
418
|
-
it('reads Markdown as text and a PDF as bytes from their raw contents, each with its attribution at the commit', async () => {
|
|
419
|
-
const stand = github();
|
|
420
|
-
const connector = connectorOn(stand);
|
|
421
|
-
const agenda = asRead(await connector.read({ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` }));
|
|
422
|
-
expect(agenda.content).toEqual({ text: AGENDA });
|
|
423
|
-
expect(agenda.ref).toMatchObject({
|
|
424
|
-
kind: 'github',
|
|
425
|
-
title: 'docs/agenda.md',
|
|
426
|
-
path: 'docs/agenda.md',
|
|
427
|
-
version: C1,
|
|
428
|
-
url: `https://github.com/acme-team/handbook/blob/${C1}/docs/agenda.md`,
|
|
429
|
-
});
|
|
430
|
-
expect(Number.isNaN(Date.parse(agenda.ref.readAt))).toBe(false);
|
|
431
|
-
const budget = asRead(await connector.read({ id: '77:docs/budget.pdf', title: 'docs/budget.pdf', size: 2048, version: `b1@${C1}` }));
|
|
432
|
-
if (!('bytes' in budget.content))
|
|
433
|
-
throw new Error('the PDF was answered as text');
|
|
434
|
-
expect(budget.content.mediaType).toBe('application/pdf');
|
|
435
|
-
expect(Buffer.from(budget.content.bytes).equals(Buffer.from(PDF))).toBe(true);
|
|
436
|
-
expect(budget.ref.url).toBe(`https://github.com/acme-team/handbook/blob/${C1}/docs/budget.pdf`);
|
|
437
|
-
const reads = stand.seen.filter((request) => request.url.includes('/contents/'));
|
|
438
|
-
expect(reads.map((request) => request.headers.get('accept'))).toEqual(['application/vnd.github.raw+json', 'application/vnd.github.raw+json']);
|
|
439
|
-
expect(reads.every((request) => request.headers.get('authorization') === 'Bearer ghs_documents')).toBe(true);
|
|
440
|
-
expect(reads.every((request) => request.headers.get('x-github-api-version') === '2026-03-10')).toBe(true);
|
|
441
|
-
});
|
|
442
|
-
it('answers a file whose raw read passes the cap as gone, without buffering it whole', async () => {
|
|
443
|
-
const stand = github();
|
|
444
|
-
const answer = await connectorOn(stand, { maxFileBytes: 1_000_000 }).read({ id: '77:docs/long.md', title: 'docs/long.md', size: null, version: `l1@${C1}` });
|
|
445
|
-
expect(answer).toEqual({ id: '77:docs/long.md', reason: 'removed' });
|
|
446
|
-
expect(stand.long.pulled).toBeLessThan(LONG_CHUNKS * LONG_CHUNK_BYTES);
|
|
447
|
-
expect(stand.long.cancelled).toBe(true);
|
|
448
|
-
});
|
|
449
|
-
it("compares a push to the default branch by the tree at its after commit, never by the push's commit list", async () => {
|
|
450
|
-
const stand = github();
|
|
451
|
-
const previous = JSON.stringify({ 'README.md': 'r1', 'docs/agenda.md': 'a1', 'docs/budget.pdf': 'b1', 'docs/old.md': 'o1' });
|
|
452
|
-
const changes = await connectorOn(stand).changesSince(previous, { ids: ['77'], after: C2, ref: 'refs/heads/main' });
|
|
453
|
-
expect(changes.changed).toEqual([{ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 320, version: `a2@${C2}` }]);
|
|
454
|
-
expect(changes.gone).toEqual([{ id: '77:docs/old.md', reason: 'removed' }]);
|
|
455
|
-
expect(JSON.parse(changes.cursor)).toEqual({ 'README.md': 'r1', 'docs/agenda.md': 'a2', 'docs/budget.pdf': 'b1' });
|
|
456
|
-
expect(treeRequests(stand.seen)).toEqual([`${C2}?recursive=1`]);
|
|
457
|
-
expect(stand.seen.some((request) => request.url.includes('/commits') || request.url.includes('/compare'))).toBe(false);
|
|
458
|
-
});
|
|
459
|
-
it('throws a read GitHub refuses for a rate limit with the time to try again, never answering the file gone', async () => {
|
|
460
|
-
const stand = github();
|
|
461
|
-
const failure = await connectorOn(stand)
|
|
462
|
-
.read({ id: '77:docs/limited.md', title: 'docs/limited.md', size: 10, version: `m1@${C1}` })
|
|
463
|
-
.catch((error) => error);
|
|
464
|
-
expect(failure).toBeInstanceOf(GitHubApiError);
|
|
465
|
-
expect(failure).toMatchObject({ status: 403, rateLimited: true, retryAt: LIMIT_RESET * 1000 });
|
|
466
|
-
expect(failure.message).toBe('GitHub answered 403');
|
|
467
|
-
});
|
|
468
|
-
it('refuses a cursor it did not answer before any request, quoting none of it', async () => {
|
|
469
|
-
const stand = github();
|
|
470
|
-
const connector = connectorOn(stand);
|
|
471
|
-
for (const cursor of ['{"docs/private-plan.md":', '["docs/private-plan.md"]', '{"docs/private-plan.md":7}', 'null', '"docs/private-plan.md"']) {
|
|
472
|
-
const failure = await connector.changesSince(cursor, { ids: ['77'], after: C1, listing: [] }).catch((error) => error);
|
|
473
|
-
expect(failure).toBeInstanceOf(TypeError);
|
|
474
|
-
expect(failure.message).not.toContain('private');
|
|
475
|
-
}
|
|
476
|
-
expect(stand.seen).toEqual([]);
|
|
477
|
-
});
|
|
478
|
-
it('answers a push to another branch with no change and no request', async () => {
|
|
479
|
-
const stand = github();
|
|
480
|
-
const previous = JSON.stringify({ 'README.md': 'r1' });
|
|
481
|
-
const changes = await connectorOn(stand).changesSince(previous, { ids: ['77'], after: C3, ref: 'refs/heads/feature' });
|
|
482
|
-
expect(changes).toEqual({ changed: [], gone: [], cursor: previous });
|
|
483
|
-
expect(stand.seen).toEqual([]);
|
|
484
|
-
});
|
|
485
|
-
it('refuses a cursor it did not answer for a push to another branch too, so none is handed back to be kept', async () => {
|
|
486
|
-
const stand = github();
|
|
487
|
-
const connector = connectorOn(stand);
|
|
488
|
-
for (const cursor of ['{"docs/private-plan.md":', '["docs/private-plan.md"]', '{"docs/private-plan.md":7}', 'null']) {
|
|
489
|
-
const failure = await connector.changesSince(cursor, { ids: ['77'], after: C3, ref: 'refs/heads/feature' }).catch((error) => error);
|
|
490
|
-
expect(failure).toBeInstanceOf(TypeError);
|
|
491
|
-
expect(failure.message).not.toContain('private');
|
|
492
|
-
}
|
|
493
|
-
expect(stand.seen).toEqual([]);
|
|
494
|
-
});
|
|
495
|
-
it('throws a read whose installation token GitHub refuses, so a lost installation never reads as every file gone', async () => {
|
|
496
|
-
const stand = github();
|
|
497
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, stand.fetchImpl, BASE);
|
|
498
|
-
const connector = new GitHubDocumentsConnector(credential, GONE_INSTALLATION, [HANDBOOK], {}, stand.fetchImpl, BASE);
|
|
499
|
-
const failure = await connector
|
|
500
|
-
.read({ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` })
|
|
501
|
-
.catch((error) => error);
|
|
502
|
-
expect(failure).toBeInstanceOf(GitHubApiError);
|
|
503
|
-
expect(failure).toMatchObject({ status: 404, rateLimited: false });
|
|
504
|
-
expect(stand.seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([`POST /app/installations/${GONE_INSTALLATION}/access_tokens`]);
|
|
505
|
-
});
|
|
506
|
-
it("ends the installation token request at the caller's cancel", async () => {
|
|
507
|
-
const stand = github();
|
|
508
|
-
const fetchImpl = tokensNeverAnswer(stand);
|
|
509
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
510
|
-
const connector = new GitHubDocumentsConnector(credential, INSTALLATION, [HANDBOOK], {}, fetchImpl, BASE);
|
|
511
|
-
const controller = new AbortController();
|
|
512
|
-
const pending = connector.read({ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: `a1@${C1}` }, controller.signal).catch((error) => error);
|
|
513
|
-
controller.abort();
|
|
514
|
-
const failure = await pending;
|
|
515
|
-
expect(failure).toBeInstanceOf(GitHubNoAnswer);
|
|
516
|
-
expect(failure.reason).toBe('cancelled');
|
|
517
|
-
expect(stand.seen.some((request) => request.url.includes('/contents/'))).toBe(false);
|
|
518
|
-
});
|
|
519
|
-
it("cancels a raw read's body at the caller's cancel, so nothing more of the file is read", async () => {
|
|
520
|
-
const stand = github();
|
|
521
|
-
const { fetchImpl, held } = contentsStall(stand);
|
|
522
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
523
|
-
const connector = new GitHubDocumentsConnector(credential, INSTALLATION, [HANDBOOK], {}, fetchImpl, BASE);
|
|
524
|
-
const controller = new AbortController();
|
|
525
|
-
const pending = connector.read({ id: '77:docs/stalled.md', title: 'docs/stalled.md', size: 300, version: `t1@${C1}` }, controller.signal).catch((error) => error);
|
|
526
|
-
await held.stalled;
|
|
527
|
-
controller.abort();
|
|
528
|
-
const failure = await pending;
|
|
529
|
-
expect(failure).toBeInstanceOf(GitHubNoAnswer);
|
|
530
|
-
expect(failure.reason).toBe('cancelled');
|
|
531
|
-
expect(held.cancels).toBe(1);
|
|
532
|
-
});
|
|
533
|
-
it('refuses, before any request, a change set it cannot answer whole: no repository or several, an id that is not a repository id, an after that names no commit, a listing without its commit', async () => {
|
|
534
|
-
const stand = github();
|
|
535
|
-
const connector = connectorOn(stand);
|
|
536
|
-
const previous = JSON.stringify({ 'README.md': 'r1' });
|
|
537
|
-
const listing = [{ path: 'README.md', sha: 'r1', size: 120 }];
|
|
538
|
-
const selections = [
|
|
539
|
-
{ ids: [] },
|
|
540
|
-
{ ids: ['77', '78'], after: C1 },
|
|
541
|
-
{ ids: ['077'], after: C1, listing },
|
|
542
|
-
{ ids: ['77 '], after: C1, listing },
|
|
543
|
-
{ ids: ['0x4d'], after: C1, listing },
|
|
544
|
-
{ ids: ['77'], after: '' },
|
|
545
|
-
{ ids: ['77'], after: '', listing },
|
|
546
|
-
{ ids: ['77'], listing },
|
|
547
|
-
];
|
|
548
|
-
for (const selection of selections) {
|
|
549
|
-
const failure = await connector.changesSince(previous, selection).catch((error) => error);
|
|
550
|
-
expect(failure).toBeInstanceOf(TypeError);
|
|
551
|
-
}
|
|
552
|
-
expect(stand.seen).toEqual([]);
|
|
553
|
-
});
|
|
554
|
-
it('checks every selected id before reading any, so a selection is listed whole or not at all', async () => {
|
|
555
|
-
const stand = github();
|
|
556
|
-
const connector = connectorOn(stand);
|
|
557
|
-
expect(await connector.list({ ids: [] })).toEqual({ items: [], gone: [] });
|
|
558
|
-
for (const ids of [['77', '79'], ['77', '077'], ['77', ''], ['77', '77']]) {
|
|
559
|
-
await expect(connector.list({ ids })).rejects.toThrow();
|
|
560
|
-
}
|
|
561
|
-
expect(stand.seen).toEqual([]);
|
|
562
|
-
});
|
|
563
|
-
it('reads only the file an item names, refusing before any request an id without a path or a path with an empty, . or .. segment', async () => {
|
|
564
|
-
const stand = github();
|
|
565
|
-
const connector = connectorOn(stand);
|
|
566
|
-
for (const id of ['77', '77:', ':README.md', '077:README.md', '77:/README.md', '77:docs//agenda.md', '77:./README.md', '77:docs/../README.md', '77:docs/..']) {
|
|
567
|
-
const failure = await connector.read({ id, title: 'README.md', size: 120, version: `r1@${C1}` }).catch((error) => error);
|
|
568
|
-
expect(failure).toBeInstanceOf(TypeError);
|
|
569
|
-
}
|
|
570
|
-
expect(stand.seen).toEqual([]);
|
|
571
|
-
});
|
|
572
|
-
it('reads an item whose version names no commit at the default branch, as it reads one without a version', async () => {
|
|
573
|
-
const stand = github();
|
|
574
|
-
const connector = connectorOn(stand);
|
|
575
|
-
for (const version of [null, 'a1', 'a1@']) {
|
|
576
|
-
const agenda = asRead(await connector.read({ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version }));
|
|
577
|
-
expect(agenda.content).toEqual({ text: AGENDA });
|
|
578
|
-
expect(agenda.ref).toMatchObject({ version: 'main', url: 'https://github.com/acme-team/handbook/blob/main/docs/agenda.md' });
|
|
579
|
-
}
|
|
580
|
-
const refs = stand.seen.filter((request) => request.url.includes('/contents/')).map((request) => new URL(request.url).searchParams.get('ref'));
|
|
581
|
-
expect(refs).toEqual(['main', 'main', 'main']);
|
|
582
|
-
});
|
|
583
|
-
it("links a file read at a default branch whose name holds a '#' by that name encoded, so the link and its line anchor reach the file", async () => {
|
|
584
|
-
const stand = github();
|
|
585
|
-
// Git allows a "#" in a branch name; GitHub decodes an encoded ref in a file's address.
|
|
586
|
-
const hashed = { ...HANDBOOK, defaultBranch: 'docs#1' };
|
|
587
|
-
const fetchImpl = async (input, init) => {
|
|
588
|
-
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
589
|
-
const url = new URL(href);
|
|
590
|
-
if (url.pathname === '/repositories/77/contents/docs/agenda.md' && url.searchParams.get('ref') === 'docs#1')
|
|
591
|
-
return new Response(AGENDA);
|
|
592
|
-
return stand.fetchImpl(input, init);
|
|
593
|
-
};
|
|
594
|
-
const credential = new GitHubAppCredential({ clientId: 'Iv23liTESTCLIENT', privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
595
|
-
const connector = new GitHubDocumentsConnector(credential, INSTALLATION, [hashed], {}, fetchImpl, BASE);
|
|
596
|
-
const agenda = asRead(await connector.read({ id: '77:docs/agenda.md', title: 'docs/agenda.md', size: 300, version: null }));
|
|
597
|
-
expect(agenda.content).toEqual({ text: AGENDA });
|
|
598
|
-
expect(agenda.ref).toMatchObject({ version: 'docs#1', url: 'https://github.com/acme-team/handbook/blob/docs%231/docs/agenda.md' });
|
|
599
|
-
expect(sourceRefLink({ ...agenda.ref, lines: [3, 4] })).toBe('https://github.com/acme-team/handbook/blob/docs%231/docs/agenda.md#L3-L4');
|
|
600
|
-
});
|
|
601
|
-
it('answers nothing to a call whose signal is already aborted, making no request', async () => {
|
|
602
|
-
const stand = github();
|
|
603
|
-
const connector = connectorOn(stand);
|
|
604
|
-
const listing = [{ path: 'README.md', sha: 'r1', size: 120 }];
|
|
605
|
-
const aborted = AbortSignal.abort();
|
|
606
|
-
const calls = [
|
|
607
|
-
() => connector.list({ ids: ['77'] }, aborted),
|
|
608
|
-
() => connector.read({ id: '77:README.md', title: 'README.md', size: 120, version: `r1@${C1}` }, aborted),
|
|
609
|
-
() => connector.changesSince(JSON.stringify({ 'README.md': 'r0' }), { ids: ['77'], after: C1, listing }, aborted),
|
|
610
|
-
];
|
|
611
|
-
for (const call of calls) {
|
|
612
|
-
const failure = await call().catch((error) => error);
|
|
613
|
-
expect(failure).toBeInstanceOf(GitHubNoAnswer);
|
|
614
|
-
expect(failure.reason).toBe('cancelled');
|
|
615
|
-
}
|
|
616
|
-
expect(stand.seen).toEqual([]);
|
|
617
|
-
});
|
|
618
|
-
it('answers an aborted call cancelled before it checks its inputs, so every aborted call answers alike', async () => {
|
|
619
|
-
const stand = github();
|
|
620
|
-
const connector = connectorOn(stand);
|
|
621
|
-
const listing = [{ path: 'README.md', sha: 'r1', size: 120 }];
|
|
622
|
-
const aborted = AbortSignal.abort();
|
|
623
|
-
const calls = [
|
|
624
|
-
() => connector.list({ ids: ['077'] }, aborted),
|
|
625
|
-
() => connector.list({ ids: ['77', '77'] }, aborted),
|
|
626
|
-
() => connector.list({ ids: ['79'] }, aborted),
|
|
627
|
-
() => connector.read({ id: '77', title: 'README.md', size: 120, version: `r1@${C1}` }, aborted),
|
|
628
|
-
() => connector.read({ id: '79:README.md', title: 'README.md', size: 120, version: `r1@${C1}` }, aborted),
|
|
629
|
-
() => connector.changesSince('not a cursor', { ids: ['77'], after: C1 }, aborted),
|
|
630
|
-
() => connector.changesSince(null, { ids: [] }, aborted),
|
|
631
|
-
() => connector.changesSince(null, { ids: ['77'], listing }, aborted),
|
|
632
|
-
() => connector.changesSince(null, { ids: ['79'], after: C1 }, aborted),
|
|
633
|
-
() => connector.head(79, aborted),
|
|
634
|
-
() => connector.listing(79, C1, aborted),
|
|
635
|
-
];
|
|
636
|
-
for (const call of calls) {
|
|
637
|
-
const failure = await call().catch((error) => error);
|
|
638
|
-
expect(failure).toBeInstanceOf(GitHubNoAnswer);
|
|
639
|
-
expect(failure.reason).toBe('cancelled');
|
|
640
|
-
}
|
|
641
|
-
expect(stand.seen).toEqual([]);
|
|
642
|
-
});
|
|
643
|
-
});
|
|
644
|
-
//# sourceMappingURL=documents.test.js.map
|