@framers/agentos-ext-github 0.5.0 → 0.7.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 CHANGED
@@ -10,7 +10,7 @@ Its second entry, `@framers/agentos-ext-github/documents`, is for a server that
10
10
  npm install @framers/agentos-ext-github @framers/agentos @framers/agentos-ext-account-sources
11
11
  ```
12
12
 
13
- Peer dependencies: `@framers/agentos` with range `>=0.13.144`, whose auth module's `RedirectOAuthFlow` refuses a token answer that is JSON but not an object, and `@framers/agentos-ext-account-sources` with range `>=0.7.0`, whose connector types, capped reads and retry timing the `./documents` entry uses. A host keeps one copy of each; only the `./documents` entry loads `@framers/agentos-ext-account-sources`.
13
+ Peer dependencies: `@framers/agentos` with range `>=0.13.144`, whose auth module's `RedirectOAuthFlow` refuses a token answer that is JSON but not an object, and `@framers/agentos-ext-account-sources`, whose connector types, capped reads and retry timing the `./documents` entry uses, at or above the floor this package's `peerDependencies` names; `npm view @framers/agentos-ext-github peerDependencies` prints the latest release's. A host keeps one copy of each; only the `./documents` entry loads `@framers/agentos-ext-account-sources`.
14
14
 
15
15
  ## Usage
16
16
 
@@ -441,29 +441,36 @@ Every call runs under a deadline, 30 seconds by default (`GITHUB_TIMEOUT_MS`), i
441
441
  | Export | What it does |
442
442
  | --- | --- |
443
443
  | `appJwt(identity, nowSeconds?)` | The app's JWT: RS256, `iat` 60 seconds back, `exp` nine minutes ahead, `iss` the client id |
444
- | `GitHubAppCredential` | Mints installation tokens narrowed to the repositories asked for (`repository_ids`), each request under the caller's `signal` and `timeoutMs`, and holds each token until five minutes before its hour ends; an empty list throws a `TypeError`, since GitHub answers it with a token for every repository of the installation |
444
+ | `GitHubAppCredential` | Mints installation tokens narrowed to the repositories asked for (`repository_ids`), each request under the caller's `signal` and `timeoutMs`, and holds each token until five minutes before its hour ends; an empty list throws a `TypeError`, since GitHub answers it with a token for every repository of the installation. `metadataToken(installationId, { signal, timeoutMs })` mints, and holds the same way, a token for every repository of the installation that reads their metadata alone (`permissions: { metadata: 'read' }`): enough to list them, never to read a file. `forget(installationId)` drops the tokens held for an installation, so the next call mints again |
445
445
  | `GitHubAppUserFlow` | The user's web flow on the auth module's `RedirectOAuthFlow`: GitHub's authorize address with `state` and PKCE and no `scope`, and the code exchange under a deadline (`new GitHubAppUserFlow(client, fetchImpl, endpoints, { timeoutMs, apiBase })`), which never follows a redirect from the token address: the redirect is refused with `OAuthGrantRefused` and its status, so the code and the client secret reach that address alone; it has no store, so the user token is used once; `revoke(token)` deletes it at GitHub with the app's client id and secret (`DELETE /applications/{client_id}/token`, Basic authentication), throwing `OAuthGrantRefused` (reason `revoke`, its status) for any answer but 204, another 2xx included, and refusing a redirect unfollowed; the answer is told by its status alone, its body released unread however large or slow |
446
446
  | `userOfToken`, `installationsOfUser`, `repositoriesOfUser` | The person a user token belongs to, the app's installations they can access, and the repositories of an installation they can read, across every page |
447
- | `listInstallations`, `uninstallInstallation` | The app's installations, and its uninstall, which takes a 2xx (202 at the pinned API version, where GitHub removes it in the background; 204 at earlier versions) and 404 as done |
447
+ | `repositoriesOfInstallation(credential, installationId)` | Every repository an installation reaches (`GET /installation/repositories`), across every page, read with the credential's metadata token: the whole of what the app may read through the installation, whoever asks, so a repository its owner took off the app is missing from it. When an installation goes from all repositories to a selection, GitHub's `installation_repositories` delivery lists none of the repositories that left; this listing finds them. A held token GitHub refuses (401) is dropped and one new token asked for, once; a refused token request throws `GitHubApiError` (404 for an installation GitHub no longer has), so a lost installation never reads as one that reaches no repository. The listing is read a page at a time, and a repository added to or taken off the installation between two pages changes GitHub's `total_count` and can move another one past a page's end: a read whose pages disagree on that count is read again, up to three reads, and then `GitHubListingChanged` is thrown |
448
+ | `repositoriesLost(credential, installationId, held)` | Which of the repositories `held` an installation no longer reaches: those missing from two listings, the second read only when the first misses one, since an addition and a removal between the same two pages leave the count as it was and can still move a repository past a page's end; in the order of `held`, each id once, with no request for an empty `held`; a refused listing, or `GitHubListingChanged`, is thrown. A limit: a repository is answered lost when, in each of the two listings, an addition and a removal between the same two pages moved it past a page's end |
449
+ | `listInstallations`, `getInstallation`, `uninstallInstallation` | The app's installations, each with `suspendedAt` (GitHub's `suspended_at`, null while it is not suspended); one installation by its id (`GET /app/installations/{installation_id}`), with `suspendedAt`, or null on 404, any other refusal thrown; and its uninstall, which takes a 2xx (202 at the pinned API version, where GitHub removes it in the background; 204 at earlier versions) and 404 as done |
448
450
  | `verifyWebhookSignature(body, header, secret)` | Checks `X-Hub-Signature-256` against the HMAC SHA-256 of the raw body in constant time; an empty or missing secret throws a `TypeError`, since anyone can sign a delivery under an empty key |
449
451
  | `readWebhookRequest(request, secret, { maxBytes })`, `readWebhookDelivery({ headers, body }, secret, { maxBytes })` | Read a delivery under a cap, check its signature and id, and answer it as a typed event, or throw `WebhookDeliveryRefused` with its reason |
450
- | `reconcileInstallations(credential, links, { since })` | Answers the linked installations GitHub no longer lists, the repositories whose default branch moved past the head last read, the repositories it could not read, and asks again for each event since `since` that no delivery attempt delivered, reporting GitHub's refusals of that step in `deliveries`; a rate limit ends it, with `retryAt` |
452
+ | `reconcileInstallations(credential, links, { since })` | Answers the linked installations GitHub no longer has (`gone`: missing from the listing and answered 404 at their own address, since an installation removed while the listing is read can move another past a page's end), those GitHub has suspended (`suspended`, whose repositories it does not read), the repositories whose default branch moved past the head last read, the repositories it could not read, and asks again for each event since `since` that no delivery attempt delivered, reporting GitHub's refusals of that step in `deliveries`; a rate limit ends it, with `retryAt` |
451
453
  | `GitHubDocumentsConnector` | An `AccountSourceConnector` of `@framers/agentos-ext-account-sources` over one installation's linked repositories |
452
- | `readTree`, `pathFilter`, `TreeTooLarge` | A tree at a commit within a request budget, `readTree(fetchImpl, token, repositoryId, commit)` reading the repository by its id (a full name is taken too, and reads whatever repository holds that name; a number that is not a positive integer is a `TypeError` before any request), and the filter that keeps documentation; `TreeTooLarge` carries `reason`, `budget` or `truncated` |
454
+ | `readTree`, `pathFilter`, `TreeTooLarge` | A tree at a commit within a request budget, `readTree(fetchImpl, token, repositoryId, commit)` reading the repository by its id (a full name is taken too, and reads whatever repository holds that name; a number that is not a positive integer is a `TypeError` before any request), and the filter that keeps documentation, and code when asked (`includeCode`); `TreeTooLarge` carries `reason`, `budget` or `truncated` |
455
+ | `CODE_EXTENSIONS`, `GENERATED_CODE_ENDINGS`, `isGeneratedCode(path)` | The source files a filter that includes code keeps, and the endings of the code files a tool writes (minified and bundled files, generated Protocol Buffers and Dart files), which it leaves out |
453
456
  | `GitHubApiError` | An answer outside 2xx; carries the status, `rateLimited` and `retryAt`, and nothing of the body |
457
+ | `GitHubListingChanged` | An installation's repositories changed during each of three reads of their listing (its pages disagreed on `total_count`), so no read is whole; its message names nothing of the installation |
454
458
  | `GitHubNoAnswer` | A call that got no answer, by its `reason`: `timeout`, `cancelled` or `network` |
455
459
 
456
- The reconcile leaves failed a delivery whose newest attempt the receiving route answered with a 4xx other than 408 and 429, such as a 413 for a body past the route's size cap: that refusal answers the same on every attempt. A 408, a 429, a 5xx or any other failed attempt is asked for again. When GitHub answers the deliveries listing outside 2xx, `deliveries.listingStatus` holds its status and no delivery is asked for again, since a delivery's other attempts may lie on the pages not read; a redelivery GitHub refuses is listed in `deliveries.refused` with its status, and the others are still asked for. Either way the outcome carries `gone`, `moved` and `unreadable`.
460
+ The reconcile leaves failed a delivery whose newest attempt the receiving route answered with a 4xx other than 408 and 429, such as a 413 for a body past the route's size cap: that refusal answers the same on every attempt. A 408, a 429, a 5xx or any other failed attempt is asked for again. When GitHub answers the deliveries listing outside 2xx, `deliveries.listingStatus` holds its status and no delivery is asked for again, since a delivery's other attempts may lie on the pages not read; a redelivery GitHub refuses is listed in `deliveries.refused` with its status, and the others are still asked for. Either way the outcome carries `gone`, `suspended`, `moved` and `unreadable`.
457
461
 
458
- A call GitHub refuses for a rate limit ends the reconcile: it makes no call after that one, sets `retryAt` (epoch milliseconds) to the time GitHub gave, and answers what it found before the refusal. The gone installations are always whole, since they need no call past the installations listing; the repository whose head read was refused is not counted unreadable, and the repositories and the deliveries step it had not reached wait for the next run, which starts no sooner than `retryAt`. A refused redelivery is still listed in `deliveries.refused`. `retryAt` is null when no call was rate limited. A rate limit on the installations listing itself throws `GitHubApiError` with its `retryAt`, before anything is found.
462
+ `suspended` is GitHub's word on each linked installation that is not gone: a linked installation in neither `gone` nor `suspended` is not suspended. GitHub blocks a suspended installation's API access and its webhook events, and its `suspend` and `unsuspend` deliveries can arrive out of order or be refused by a receiving route that is switched off, so a caller that keeps a link's suspension from those deliveries sets it from this list.
463
+
464
+ A call GitHub refuses for a rate limit ends the reconcile: it makes no call after that one, sets `retryAt` (epoch milliseconds) to the time GitHub gave, and answers what it found before the refusal. The gone and the suspended installations are always whole, since they are settled before any head is read; the repository whose head read was refused is not counted unreadable, and the repositories and the deliveries step it had not reached wait for the next run, which starts no sooner than `retryAt`. A refused redelivery is still listed in `deliveries.refused`. `retryAt` is null when no call was rate limited. A rate limit on the installations listing itself, or on a missing installation's own address, throws `GitHubApiError` with its `retryAt`, before anything is found, as any other refusal of those calls does (a 404 at an installation's own address apart, which answers it gone).
459
465
 
460
466
  The connector's filter, `new GitHubDocumentsConnector(credential, installationId, repositories, filter)`:
461
467
 
462
468
  | Field | Default |
463
469
  | --- | --- |
464
470
  | `extensions` | `.md`, `.markdown`, `.txt`, `.rst`, `.html`, `.htm`, `.pdf`, `.docx` |
471
+ | `includeCode` | `false`: when true, the source files of `CODE_EXTENSIONS` are kept beside `extensions` under the same bounds and skipped folders, each read as UTF-8 text, and a code file a tool wrote is left out (`isGeneratedCode`: a name ending in `.min.js`, `.min.css`, `.bundle.js`, `.bundle.css`, `.pb.go`, `_pb2.py` and the like), under an extension `extensions` names too. Data and configuration files (JSON, YAML, XML, CSV) are not code here; name their extensions in `extensions` to keep them |
465
472
  | `skipFolders` | `node_modules`, `vendor`, `dist`, `build`, `.git`, `third_party`, at any depth |
466
- | `maxFiles` | `2000`, in path order: a listing keeps the first `maxFiles` documentation paths, so when a new path sorts before them, the last one kept leaves the listing and `changesSince` answers it gone |
473
+ | `maxFiles` | `2000`, in path order: a listing keeps the first `maxFiles` documentation paths, so when a new path sorts before them, the last one kept leaves the listing and `changesSince` answers it gone. With `includeCode`, the documentation keeps its places first and the code paths, in path order, take the room left, so turning code on drops no document |
467
474
  | `maxFileBytes` | `10485760` (10 MiB): larger files are left out of a listing, and a read past it is answered gone |
468
475
  | `maxTreeRequests` | `50` tree requests to list one commit |
469
476
 
@@ -6,7 +6,7 @@
6
6
  import { createHash, createHmac, createVerify, generateKeyPairSync } from 'node:crypto';
7
7
  import { afterEach, describe, expect, it, vi } from 'vitest';
8
8
  import { githubFetch } from '../app/http.js';
9
- import { appJwt, GITHUB_OAUTH_ENDPOINTS, GitHubApiError, GitHubAppCredential, GitHubAppUserFlow, GitHubNoAnswer, installationsOfUser, reconcileInstallations, repositoriesOfUser, uninstallInstallation, userOfToken, verifyWebhookSignature, } from '../documents/index.js';
9
+ import { appJwt, GITHUB_OAUTH_ENDPOINTS, GitHubApiError, GitHubAppCredential, GitHubAppUserFlow, GitHubListingChanged, GitHubNoAnswer, getInstallation, installationsOfUser, listInstallations, reconcileInstallations, repositoriesLost, repositoriesOfInstallation, repositoriesOfUser, uninstallInstallation, userOfToken, verifyWebhookSignature, } from '../documents/index.js';
10
10
  /** A fetch that answers from `route` and records every request. */
11
11
  function standIn(route) {
12
12
  const seen = [];
@@ -154,6 +154,7 @@ const RECONCILE_LINKS = [
154
154
  /** What a reconcile of {@link RECONCILE_LINKS} finds before its deliveries step. */
155
155
  const RECONCILED = {
156
156
  gone: [22],
157
+ suspended: [],
157
158
  moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
158
159
  unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
159
160
  };
@@ -222,6 +223,177 @@ describe('the GitHub App', () => {
222
223
  await expect(credential.installationToken(42, [])).rejects.toThrow(TypeError);
223
224
  expect(seen).toHaveLength(0);
224
225
  });
226
+ it("lists every repository an installation reaches across pages, with a token that reads their metadata alone and is held", async () => {
227
+ const { fetchImpl, seen } = standIn((request) => {
228
+ const url = new URL(request.url);
229
+ if (url.pathname === '/app/installations/21/access_tokens') {
230
+ return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
231
+ }
232
+ if (url.pathname === '/installation/repositories' && url.searchParams.get('page') === '2') {
233
+ return json({ total_count: 2, repository_selection: 'selected', repositories: [{ id: 9, full_name: 'first-team/plans', default_branch: 'trunk', private: true }] });
234
+ }
235
+ if (url.pathname === '/installation/repositories') {
236
+ const next = `${BASE}/installation/repositories?per_page=100&page=2`;
237
+ return json({ total_count: 2, repository_selection: 'selected', repositories: [{ id: 7, full_name: 'first-team/handbook', default_branch: 'main', private: true }] }, 200, { link: `<${next}>; rel="next", <${next}>; rel="last"` });
238
+ }
239
+ return json({ message: 'Not Found' }, 404);
240
+ });
241
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
242
+ const at = { fetchImpl, apiBase: BASE };
243
+ expect(await repositoriesOfInstallation(credential, 21, at)).toEqual([
244
+ { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main' },
245
+ { id: 9, fullName: 'first-team/plans', defaultBranch: 'trunk' },
246
+ ]);
247
+ expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
248
+ 'POST /app/installations/21/access_tokens',
249
+ 'GET /installation/repositories',
250
+ 'GET /installation/repositories',
251
+ ]);
252
+ // No repository named, so the token reaches every repository of the installation, and their metadata alone.
253
+ expect(JSON.parse(seen[0].body ?? '')).toEqual({ permissions: { metadata: 'read' } });
254
+ expect(appClaims(seen[0].headers.get('authorization'))).toMatchObject({ iss: CLIENT_ID });
255
+ expect(seen.slice(1).every((request) => request.headers.get('authorization') === 'Bearer ghs_metadata')).toBe(true);
256
+ expect(seen.slice(1).every((request) => request.headers.get('x-github-api-version') === '2026-03-10')).toBe(true);
257
+ // The token is held as an installation token is, apart from the tokens narrowed to repositories.
258
+ await repositoriesOfInstallation(credential, 21, at);
259
+ expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
260
+ await credential.installationToken(21, [7]);
261
+ expect(seen.filter((request) => request.method === 'POST').map((request) => JSON.parse(request.body ?? ''))).toEqual([
262
+ { permissions: { metadata: 'read' } },
263
+ { repository_ids: [7] },
264
+ ]);
265
+ });
266
+ it('mints the metadata token again when GitHub refuses the held one, so an installation removed since reads as gone (404), not as a refused token', async () => {
267
+ let removed = false;
268
+ const { fetchImpl, seen } = standIn((request) => {
269
+ const url = new URL(request.url);
270
+ if (url.pathname === '/app/installations/21/access_tokens') {
271
+ if (removed)
272
+ return json({ message: 'Not Found' }, 404);
273
+ return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
274
+ }
275
+ if (url.pathname === '/installation/repositories') {
276
+ if (removed)
277
+ return json({ message: 'Bad credentials' }, 401);
278
+ return json({ total_count: 1, repository_selection: 'selected', repositories: [{ id: 7, full_name: 'first-team/handbook', default_branch: 'main' }] });
279
+ }
280
+ return json({ message: 'Not Found' }, 404);
281
+ });
282
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
283
+ const at = { fetchImpl, apiBase: BASE };
284
+ expect(await repositoriesOfInstallation(credential, 21, at)).toHaveLength(1);
285
+ // GitHub removes the installation: the held token is refused, and a new one cannot be minted.
286
+ removed = true;
287
+ const failure = await repositoriesOfInstallation(credential, 21, at).catch((error) => error);
288
+ expect(failure).toBeInstanceOf(GitHubApiError);
289
+ expect(failure.status).toBe(404);
290
+ expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
291
+ 'POST /app/installations/21/access_tokens',
292
+ 'GET /installation/repositories',
293
+ 'GET /installation/repositories',
294
+ 'POST /app/installations/21/access_tokens',
295
+ ]);
296
+ });
297
+ it('throws a listing whose metadata token GitHub refuses, so a lost installation never reads as one that reaches no repository', async () => {
298
+ const { fetchImpl, seen } = standIn(() => json({ message: 'Not Found' }, 404));
299
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
300
+ const failure = await repositoriesOfInstallation(credential, 21, { fetchImpl, apiBase: BASE }).catch((error) => error);
301
+ expect(failure).toBeInstanceOf(GitHubApiError);
302
+ expect(failure.status).toBe(404);
303
+ expect(seen.map((request) => new URL(request.url).pathname)).toEqual(['/app/installations/21/access_tokens']);
304
+ });
305
+ it('reads a listing again while its pages disagree on the count, and throws one that keeps changing', async () => {
306
+ // GitHub's pages of two: while a read is between its pages, a repository is taken off the installation, so the count
307
+ // the second page gives is one less and another repository has moved onto the first page, already read.
308
+ let reached = [5, 7, 9, 11, 13];
309
+ let firstPages = 0;
310
+ let removals = 1;
311
+ const { fetchImpl, seen } = standIn((request) => {
312
+ const url = new URL(request.url);
313
+ if (url.pathname === '/app/installations/21/access_tokens') {
314
+ return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
315
+ }
316
+ if (url.pathname === '/installation/repositories') {
317
+ const page = Number(url.searchParams.get('page') ?? '1');
318
+ if (page === 1)
319
+ firstPages += 1;
320
+ if (page === 2 && removals > 0) {
321
+ removals -= 1;
322
+ reached = reached.slice(1);
323
+ }
324
+ const pages = Math.ceil(reached.length / 2);
325
+ const next = `${BASE}/installation/repositories?per_page=100&page=${page + 1}`;
326
+ return json({
327
+ total_count: reached.length,
328
+ repository_selection: 'selected',
329
+ repositories: reached.slice((page - 1) * 2, page * 2).map((id) => ({ id, full_name: `first-team/repository-${id}`, default_branch: 'main' })),
330
+ }, 200, page < pages ? { link: `<${next}>; rel="next"` } : {});
331
+ }
332
+ return json({ message: 'Not Found' }, 404);
333
+ });
334
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
335
+ const at = { fetchImpl, apiBase: BASE };
336
+ // The first read lost 9 to the move and gave counts of 5 and 4; the second agrees with itself and is answered.
337
+ expect((await repositoriesOfInstallation(credential, 21, at)).map((repository) => repository.id)).toEqual([7, 9, 11, 13]);
338
+ expect(firstPages).toBe(2);
339
+ // A listing that changes during each of three reads is thrown, answering no repository.
340
+ reached = [5, 7, 9, 11, 13, 15, 17];
341
+ firstPages = 0;
342
+ removals = 3;
343
+ const failure = await repositoriesOfInstallation(credential, 21, at).catch((error) => error);
344
+ expect(failure).toBeInstanceOf(GitHubListingChanged);
345
+ expect(failure.message).not.toMatch(/\d/);
346
+ expect(firstPages).toBe(3);
347
+ expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
348
+ });
349
+ it("answers a held repository lost only when two listings miss it, so one a change moved past a page's end is kept", async () => {
350
+ // GitHub's pages of two: while the first listing is between its pages, repository 5 is taken off the installation
351
+ // and 13 added to it, which leaves the count as it was, so 9 moves onto the first page, already read, and that
352
+ // listing misses it.
353
+ let reached = [5, 7, 9, 11];
354
+ let firstPages = 0;
355
+ const { fetchImpl, seen } = standIn((request) => {
356
+ const url = new URL(request.url);
357
+ if (url.pathname === '/app/installations/21/access_tokens') {
358
+ return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
359
+ }
360
+ if (url.pathname === '/installation/repositories') {
361
+ const page = Number(url.searchParams.get('page') ?? '1');
362
+ if (page === 1)
363
+ firstPages += 1;
364
+ if (page === 2 && firstPages === 1)
365
+ reached = [...reached.filter((id) => id !== 5), 13];
366
+ const pages = Math.ceil(reached.length / 2);
367
+ const next = `${BASE}/installation/repositories?per_page=100&page=${page + 1}`;
368
+ return json({
369
+ total_count: reached.length,
370
+ repository_selection: 'selected',
371
+ repositories: reached.slice((page - 1) * 2, page * 2).map((id) => ({ id, full_name: `first-team/repository-${id}`, default_branch: 'main' })),
372
+ }, 200, page < pages ? { link: `<${next}>; rel="next"` } : {});
373
+ }
374
+ return json({ message: 'Not Found' }, 404);
375
+ });
376
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
377
+ const at = { fetchImpl, apiBase: BASE };
378
+ const listings = () => seen.filter((request) => new URL(request.url).pathname === '/installation/repositories').length;
379
+ // 9 is missing from the first listing alone and kept; 12 is missing from both and lost. 5, which the first listing
380
+ // still held, waits for the next run.
381
+ expect(await repositoriesLost(credential, 21, [5, 7, 9, 12, 12], at)).toEqual([12]);
382
+ expect(listings()).toBe(4);
383
+ expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
384
+ // A listing that holds every held repository is read once, and an empty list asks GitHub for nothing.
385
+ const before = listings();
386
+ expect(await repositoriesLost(credential, 21, [7, 9, 11], at)).toEqual([]);
387
+ expect(listings() - before).toBe(2);
388
+ expect(await repositoriesLost(credential, 21, [], at)).toEqual([]);
389
+ expect(listings() - before).toBe(2);
390
+ // A refused listing throws, answering nothing lost.
391
+ const refusing = standIn(() => json({ message: 'Not Found' }, 404));
392
+ const refused = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, refusing.fetchImpl, BASE);
393
+ const failure = await repositoriesLost(refused, 21, [7], { fetchImpl: refusing.fetchImpl, apiBase: BASE }).catch((error) => error);
394
+ expect(failure).toBeInstanceOf(GitHubApiError);
395
+ expect(failure.status).toBe(404);
396
+ });
225
397
  it("begins the user's web flow with state and PKCE and no scope, and completes it into a token it never stores", async () => {
226
398
  const { fetchImpl, seen } = standIn(() => json({ access_token: 'ghu_testuser', expires_in: 28_800, refresh_token: 'ghr_testuser', refresh_token_expires_in: 15_897_600, scope: '', token_type: 'bearer' }));
227
399
  const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, fetchImpl);
@@ -501,6 +673,7 @@ describe('the GitHub App', () => {
501
673
  ], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
502
674
  expect(outcome).toEqual({
503
675
  gone: [22],
676
+ suspended: [],
504
677
  moved: [{ installationId: 21, repositoryId: 7, head: headNow }],
505
678
  redelivered: [9001],
506
679
  unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
@@ -621,6 +794,8 @@ describe('the GitHub App', () => {
621
794
  if (url.pathname === '/app/hook/deliveries') {
622
795
  return json([{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 }]);
623
796
  }
797
+ if (url.pathname === '/app/installations/22')
798
+ return json({ message: 'Not Found' }, 404);
624
799
  return new Response(null, { status: 202 });
625
800
  });
626
801
  const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
@@ -640,6 +815,7 @@ describe('the GitHub App', () => {
640
815
  // What it found before the refusal is kept, the gone installation among it; repository 8 was refused, not found unreadable.
641
816
  expect(found).toEqual({
642
817
  gone: [22],
818
+ suspended: [],
643
819
  moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
644
820
  redelivered: [],
645
821
  unreadable: [],
@@ -700,6 +876,126 @@ describe('the GitHub App', () => {
700
876
  expect(failure.reason).toBe('timeout');
701
877
  expect(Date.now() - started).toBeLessThan(5_000);
702
878
  });
879
+ it('answers a linked installation gone only once its own address answers 404, and reads one the listing missed', async () => {
880
+ const now = Date.now();
881
+ const { fetchImpl, seen } = standIn((request) => {
882
+ const url = new URL(request.url);
883
+ // Installation 23 still exists: an installation removed while the listing was read moved it past a page's end.
884
+ if (url.pathname === '/app/installations')
885
+ return json([{ id: 21, account: { login: 'first-team', id: 901 }, suspended_at: null }]);
886
+ if (url.pathname === '/app/installations/23')
887
+ return json({ id: 23, account: { login: 'third-team', id: 903 }, suspended_at: null });
888
+ const tokenFor = /^\/app\/installations\/(\d+)\/access_tokens$/.exec(url.pathname);
889
+ if (tokenFor !== null)
890
+ return json({ token: `ghs_installation${tokenFor[1]}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
891
+ if (url.pathname === '/repositories/7/branches/main')
892
+ return json({ name: 'main', commit: { sha: HEAD_BEFORE } });
893
+ if (url.pathname === '/repositories/40/branches/main')
894
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
895
+ if (url.pathname === '/app/hook/deliveries')
896
+ return json([]);
897
+ return json({ message: 'Not Found' }, 404);
898
+ });
899
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
900
+ const outcome = await reconcileInstallations(credential, [
901
+ { installationId: 21, repositories: [{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
902
+ { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
903
+ { installationId: 23, repositories: [{ id: 40, fullName: 'third-team/notes', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
904
+ { installationId: 22, repositories: [] },
905
+ ], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
906
+ expect(outcome).toEqual({
907
+ gone: [22],
908
+ suspended: [],
909
+ moved: [{ installationId: 23, repositoryId: 40, head: HEAD_NOW }],
910
+ redelivered: [],
911
+ unreadable: [],
912
+ deliveries: { listingStatus: null, refused: [] },
913
+ retryAt: null,
914
+ });
915
+ // Each installation the listing missed was asked for once, at its own address with the app's JWT, and no token was
916
+ // asked for the gone one.
917
+ const ownAddresses = seen.filter((request) => /^\/app\/installations\/\d+$/.test(new URL(request.url).pathname));
918
+ expect(ownAddresses.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual(['GET /app/installations/22', 'GET /app/installations/23']);
919
+ expect(ownAddresses.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
920
+ expect(seen.some((request) => request.url.includes('/app/installations/22/'))).toBe(false);
921
+ });
922
+ it('answers the linked installations GitHub suspended, reading none of their repositories, and their time in the listing', async () => {
923
+ const now = Date.now();
924
+ const { fetchImpl, seen } = standIn((request) => {
925
+ const url = new URL(request.url);
926
+ if (url.pathname === '/app/installations') {
927
+ return json([
928
+ { id: 21, account: { login: 'first-team', id: 901 }, suspended_at: '2026-10-20T10:00:00Z', suspended_by: { login: 'first-owner', id: 911 } },
929
+ { id: 24, account: { login: 'fourth-team', id: 904 }, suspended_at: null, suspended_by: null },
930
+ ]);
931
+ }
932
+ // Installation 25 was moved past the listing's page end, and is suspended too.
933
+ if (url.pathname === '/app/installations/25')
934
+ return json({ id: 25, account: { login: 'fifth-team', id: 905 }, suspended_at: '2026-10-21T08:00:00Z' });
935
+ const tokenFor = /^\/app\/installations\/(\d+)\/access_tokens$/.exec(url.pathname);
936
+ if (tokenFor !== null)
937
+ return json({ token: `ghs_installation${tokenFor[1]}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
938
+ if (url.pathname === '/repositories/50/branches/main')
939
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
940
+ if (url.pathname === '/app/hook/deliveries')
941
+ return json([]);
942
+ return json({ message: 'Not Found' }, 404);
943
+ });
944
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
945
+ const at = { fetchImpl, apiBase: BASE };
946
+ const outcome = await reconcileInstallations(credential, [
947
+ { installationId: 21, repositories: [{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
948
+ { installationId: 24, repositories: [{ id: 50, fullName: 'fourth-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
949
+ { installationId: 25, repositories: [{ id: 60, fullName: 'fifth-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
950
+ ], { since: new Date(now - 86_400_000), ...at });
951
+ expect(outcome).toEqual({
952
+ gone: [],
953
+ suspended: [21, 25],
954
+ moved: [{ installationId: 24, repositoryId: 50, head: HEAD_NOW }],
955
+ redelivered: [],
956
+ unreadable: [],
957
+ deliveries: { listingStatus: null, refused: [] },
958
+ retryAt: null,
959
+ });
960
+ // No token was asked for a suspended installation, and none of their repositories was read.
961
+ expect(seen.filter((request) => request.url.endsWith('/access_tokens')).map((request) => new URL(request.url).pathname)).toEqual(['/app/installations/24/access_tokens']);
962
+ expect(seen.some((request) => /\/repositories\/(7|60)\//.test(new URL(request.url).pathname))).toBe(false);
963
+ // The app's own calls answer each installation with when it was suspended, and one GitHub no longer has as null.
964
+ expect(await listInstallations(credential, at)).toEqual([
965
+ { id: 21, account: { login: 'first-team', id: 901 }, suspendedAt: '2026-10-20T10:00:00Z' },
966
+ { id: 24, account: { login: 'fourth-team', id: 904 }, suspendedAt: null },
967
+ ]);
968
+ expect(await getInstallation(credential, 25, at)).toEqual({ id: 25, account: { login: 'fifth-team', id: 905 }, suspendedAt: '2026-10-21T08:00:00Z' });
969
+ expect(await getInstallation(credential, 26, at)).toBeNull();
970
+ });
971
+ it("throws, answering nothing gone, when GitHub refuses an installation's own address with a status other than 404", async () => {
972
+ const now = Date.now();
973
+ const refusals = [
974
+ { status: 502, headers: {} },
975
+ { status: 429, headers: { 'retry-after': '120' } },
976
+ ];
977
+ for (const { status, headers } of refusals) {
978
+ const { fetchImpl, seen } = standIn((request) => {
979
+ const url = new URL(request.url);
980
+ if (url.pathname === '/app/installations')
981
+ return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
982
+ if (url.pathname === '/app/installations/22')
983
+ return json({ message: 'refused' }, status, headers);
984
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
985
+ });
986
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
987
+ const started = Date.now();
988
+ const failure = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE }).catch((error) => error);
989
+ expect(failure).toBeInstanceOf(GitHubApiError);
990
+ expect(failure.status).toBe(status);
991
+ if (status === 429) {
992
+ expect(failure.rateLimited).toBe(true);
993
+ expect(failure.retryAt).toBeGreaterThanOrEqual(started + 120_000);
994
+ }
995
+ // Nothing was read after the refusal: no token, no head, no deliveries.
996
+ expect(seen.map((request) => new URL(request.url).pathname)).toEqual(['/app/installations', '/app/installations/22']);
997
+ }
998
+ });
703
999
  it('uninstalls with the JWT, taking a 2xx (202, 204) and 404 as done and throwing other answers without their body', async () => {
704
1000
  const { fetchImpl, seen } = standIn((request) => {
705
1001
  const id = new URL(request.url).pathname.split('/').pop();