@framers/agentos-ext-github 0.4.1 → 0.5.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.
Files changed (45) hide show
  1. package/README.md +10 -6
  2. package/dist/__tests__/app.test.js +230 -8
  3. package/dist/__tests__/app.test.js.map +1 -1
  4. package/dist/__tests__/documents.test.d.ts +3 -2
  5. package/dist/__tests__/documents.test.d.ts.map +1 -1
  6. package/dist/__tests__/documents.test.js +201 -17
  7. package/dist/__tests__/documents.test.js.map +1 -1
  8. package/dist/__tests__/events.test.js +17 -0
  9. package/dist/__tests__/events.test.js.map +1 -1
  10. package/dist/app/events.d.ts +14 -10
  11. package/dist/app/events.d.ts.map +1 -1
  12. package/dist/app/events.js +17 -11
  13. package/dist/app/events.js.map +1 -1
  14. package/dist/app/http.d.ts +38 -8
  15. package/dist/app/http.d.ts.map +1 -1
  16. package/dist/app/http.js +112 -27
  17. package/dist/app/http.js.map +1 -1
  18. package/dist/app/installations.d.ts +3 -3
  19. package/dist/app/installations.js +4 -4
  20. package/dist/app/installations.js.map +1 -1
  21. package/dist/app/reconcile.d.ts +28 -6
  22. package/dist/app/reconcile.d.ts.map +1 -1
  23. package/dist/app/reconcile.js +34 -15
  24. package/dist/app/reconcile.js.map +1 -1
  25. package/dist/app/userFlow.d.ts +2 -1
  26. package/dist/app/userFlow.d.ts.map +1 -1
  27. package/dist/app/userFlow.js +12 -11
  28. package/dist/app/userFlow.js.map +1 -1
  29. package/dist/app/webhook.d.ts +7 -0
  30. package/dist/app/webhook.d.ts.map +1 -1
  31. package/dist/app/webhook.js +11 -2
  32. package/dist/app/webhook.js.map +1 -1
  33. package/dist/documents/GitHubDocumentsConnector.d.ts +14 -3
  34. package/dist/documents/GitHubDocumentsConnector.d.ts.map +1 -1
  35. package/dist/documents/GitHubDocumentsConnector.js +20 -8
  36. package/dist/documents/GitHubDocumentsConnector.js.map +1 -1
  37. package/dist/documents/index.d.ts +1 -1
  38. package/dist/documents/index.d.ts.map +1 -1
  39. package/dist/documents/index.js.map +1 -1
  40. package/dist/documents/tree.d.ts +27 -7
  41. package/dist/documents/tree.d.ts.map +1 -1
  42. package/dist/documents/tree.js +30 -10
  43. package/dist/documents/tree.js.map +1 -1
  44. package/manifest.json +1 -1
  45. package/package.json +2 -2
package/README.md CHANGED
@@ -351,6 +351,8 @@ Top-level pull request comments use the issues API. `github_pr_comment_list` ret
351
351
 
352
352
  The `./documents` entry reads repositories through a GitHub App instead of one personal token. Every call goes to GitHub's REST API over the `fetch` you pass (the global `fetch` by default) with `X-GitHub-Api-Version: 2026-03-10`, so tests point it at a stand-in with `{ fetchImpl, apiBase }`.
353
353
 
354
+ A repository is read by its id, at `/repositories/<id>/branches/<branch>`, `/repositories/<id>/git/trees/<sha>` and `/repositories/<id>/contents/<path>`, the address GitHub's own redirects for a renamed or moved repository lead to, and never by its name. A name can come to mean another repository: a renamed repository's old name can be taken again, and an account's old login claimed by another account, which ends GitHub's redirects to it; and GitHub answers a public repository's branch, tree and files to any token, an installation token narrowed to other repositories included. The full name builds a read's attribution link and nothing else.
355
+
354
356
  ```ts
355
357
  import { readFileSync } from 'node:fs';
356
358
  import {
@@ -432,9 +434,9 @@ const changes = await connector.changesSince(cursor, { ids: [String(repositoryId
432
434
  | `ping` | `{ kind: 'ping' }` |
433
435
  | anything else | `{ kind: 'other' }`, as is a payload without the fields above; `delivery.name` holds the event's name |
434
436
 
435
- A delivery is refused with `WebhookDeliveryRefused`, checked in this order: `too_large` past `maxBytes` (25 MiB by default; the request's stream is cancelled at the cap, and a `Content-Length` past it is refused unread), `signature` when `X-Hub-Signature-256` does not hold under the secret, `delivery` without a usable `X-GitHub-Delivery`, `body` for a signed body that is not a JSON object. An empty secret throws a `TypeError`. `readWebhookDelivery({ headers, body }, secret, options)` does the same for a body already read. No error quotes a payload.
437
+ A delivery is refused with `WebhookDeliveryRefused`, checked in this order: `too_large` past `maxBytes` (25 MiB by default; the request's stream is cancelled at the cap, and a `Content-Length` past it is refused unread), `signature` when `X-Hub-Signature-256` does not hold under the secret, `delivery` without a usable `X-GitHub-Delivery`, `body` for a signed body that is not a JSON object. An empty secret throws a `TypeError` before any of these, whatever the delivery, before its size is measured or a byte of its body read. `readWebhookDelivery({ headers, body }, secret, options)` does the same for a body already read. No error quotes a payload.
436
438
 
437
- Every call runs under a deadline, 30 seconds by default (`GITHUB_TIMEOUT_MS`), its answer's body included, joined with the caller's signal where a method takes one. The functions that take `{ fetchImpl, apiBase }` take `timeoutMs` beside them, `GitHubAppUserFlow` takes `{ timeoutMs, apiBase }` as its fourth argument, for the code exchange and `revoke`, and `installationToken(installationId, repositoryIds, { signal, timeoutMs })` takes both: the connector's calls pass their signal on to the token request, and `reconcileInstallations` its `timeoutMs`; the connector's own calls keep the default deadline. A call that gets no answer throws `GitHubNoAnswer` with its `reason`: `timeout`, `cancelled` or `network`, and so does a refused call whose body stops while it is read to tell a rate limit. The deadline and the cancel hold whatever the injected `fetch` does with the signal it is handed: a fetch that leaves its request or its answer's body open past that signal still ends the call on time, and an answer it gives after the deadline has its body released unread. A JSON answer is read under 16 MiB (`GITHUB_MAX_ANSWER_BYTES`). An answer outside 2xx throws `GitHubApiError` with `status`, `rateLimited` and `retryAt`, following GitHub's [rules for a refused call](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit): a 429, or a 403 with `retry-after`, `x-ratelimit-remaining: 0` or a body naming a rate limit, is a rate limit, and `retryAt` (epoch milliseconds) is `retry-after` first, then `x-ratelimit-reset` when the remaining count is 0, else a minute on. Do not call again before `retryAt`: GitHub may ban an integration that keeps calling while rate limited. A 5xx's `retry-after` sets `retryAt` too.
439
+ Every call runs under a deadline, 30 seconds by default (`GITHUB_TIMEOUT_MS`), its answer's body included, joined with the caller's signal where a method takes one. The functions that take `{ fetchImpl, apiBase }` take `timeoutMs` beside them, `GitHubAppUserFlow` takes `{ timeoutMs, apiBase }` as its fourth argument, for the code exchange and `revoke`, and `installationToken(installationId, repositoryIds, { signal, timeoutMs })` takes both: the connector's calls pass their signal on to the token request, and `reconcileInstallations` its `timeoutMs`; the connector's own calls keep the default deadline. A call that gets no answer throws `GitHubNoAnswer` with its `reason`: `timeout`, `cancelled` or `network`, and so does a refused call whose body stops while it is read to tell a rate limit. The deadline and the cancel hold whatever the injected `fetch` does with the signal it is handed: a fetch that leaves its request or its answer's body open past that signal still ends the call on time, and an answer it gives after the deadline has its body released unread. A body still being read when its call ends is cancelled there, so nothing more of it is read, and a body the pack lets go (a refusal told by its status and headers, the answer to an uninstall, a redelivery or a revocation) is released without waiting on its cancel. A JSON answer is read under 16 MiB (`GITHUB_MAX_ANSWER_BYTES`). An answer outside 2xx throws `GitHubApiError` with `status`, `rateLimited` and `retryAt`, following GitHub's [rules for a refused call](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit): a 429, or a 403 with `retry-after`, `x-ratelimit-remaining: 0` or a body naming a rate limit, is a rate limit, and `retryAt` (epoch milliseconds) is `retry-after` first, then `x-ratelimit-reset` when the remaining count is 0, else a minute on. Do not call again before `retryAt`: GitHub may ban an integration that keeps calling while rate limited. A 5xx's `retry-after` sets `retryAt` too.
438
440
 
439
441
  | Export | What it does |
440
442
  | --- | --- |
@@ -442,17 +444,19 @@ Every call runs under a deadline, 30 seconds by default (`GITHUB_TIMEOUT_MS`), i
442
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 |
443
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 |
444
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 |
445
- | `listInstallations`, `uninstallInstallation` | The app's installations, and its uninstall, which takes 202 (GitHub removes it in the background), 204 and 404 as done |
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 |
446
448
  | `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 |
447
449
  | `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 |
448
- | `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` |
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` |
449
451
  | `GitHubDocumentsConnector` | An `AccountSourceConnector` of `@framers/agentos-ext-account-sources` over one installation's linked repositories |
450
- | `readTree`, `pathFilter`, `TreeTooLarge` | A tree at a commit within a request budget, and the filter that keeps documentation |
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` |
451
453
  | `GitHubApiError` | An answer outside 2xx; carries the status, `rateLimited` and `retryAt`, and nothing of the body |
452
454
  | `GitHubNoAnswer` | A call that got no answer, by its `reason`: `timeout`, `cancelled` or `network` |
453
455
 
454
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`.
455
457
 
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.
459
+
456
460
  The connector's filter, `new GitHubDocumentsConnector(credential, installationId, repositories, filter)`:
457
461
 
458
462
  | Field | Default |
@@ -463,7 +467,7 @@ The connector's filter, `new GitHubDocumentsConnector(credential, installationId
463
467
  | `maxFileBytes` | `10485760` (10 MiB): larger files are left out of a listing, and a read past it is answered gone |
464
468
  | `maxTreeRequests` | `50` tree requests to list one commit |
465
469
 
466
- An item's id is `<repositoryId>:<path>` and its version `<blob>@<commit>`. A tree is asked for recursively; when GitHub answers it truncated (past 100,000 entries or 7 MB), its top level is read and each subtree is asked for the same way, never walking a skipped folder, and `TreeTooLarge` is thrown rather than pass `maxTreeRequests`. Files are read through the contents endpoint's raw media type as a stream cut at `maxFileBytes`: PDF, Word and HTML as bytes with their media type, the rest as UTF-8 text, each with a `github` source reference whose `url` is the file at the commit. An item whose version names no commit (none, or no `@<commit>` part) is read at the default branch, and its reference names the branch. `changesSince` takes the cursor it answered last (each documentation path's blob), never reads a push's commit list, so a force push is read like any other, and answers a push to another branch than the default with no change. `listing(repositoryId, commit)` lets a caller keep a commit's listing and pass it to `changesSince` as `listing`, with that commit as `after`; it then reads no tree. A cursor `changesSince` did not answer throws a `TypeError` that quotes none of it, whatever the push's branch. A file read GitHub refuses for a rate limit throws `GitHubApiError` with its `retryAt`, never an answer that the file is gone, and so does a refused installation token (404 for an installation GitHub no longer has): only the contents read's own 404 answers a file `removed`, so a lost installation never reads as every file gone.
470
+ An item's id is `<repositoryId>:<path>` and its version `<blob>@<commit>`. A tree is asked for recursively; when GitHub answers it truncated (past 100,000 entries or 7 MB), its top level is read and each subtree is asked for the same way, never walking a skipped folder. `TreeTooLarge` is thrown rather than pass `maxTreeRequests` (`reason: 'budget'`), and at a level GitHub answers truncated when it is asked for alone as well (`reason: 'truncated'`), before any folder under it is asked for: no part of a tree is answered, since the files it left out would read as removed. Files are read through the contents endpoint's raw media type as a stream cut at `maxFileBytes`: PDF, Word and HTML as bytes with their media type, the rest as UTF-8 text, each with a `github` source reference whose `url` is the file at the commit, the commit or branch encoded segment by segment as the path is, so a branch named `docs#1` links as `docs%231`. An item whose version names no commit (none, or no `@<commit>` part) is read at the default branch, and its reference names the branch. `changesSince` takes the cursor it answered last (each documentation path's blob), never reads a push's commit list, so a force push is read like any other, and answers a push to another branch than the default with no change. `listing(repositoryId, commit)` lets a caller keep a commit's listing and pass it to `changesSince` as `listing`, with that commit as `after`; it then reads no tree. A cursor `changesSince` did not answer throws a `TypeError` that quotes none of it, whatever the push's branch. A file read GitHub refuses for a rate limit throws `GitHubApiError` with its `retryAt`, never an answer that the file is gone, and so does a refused installation token (404 for an installation GitHub no longer has): only the contents read's own 404 answers a file `removed`, so a lost installation never reads as every file gone.
467
471
 
468
472
  The connector refuses, before any request, what it cannot answer whole, and no refusal quotes a cursor, an item id or a path. A repository id, in a selection or in an item id, is a positive integer in decimal (`77`; never `077`, `0x4d` or ` 77`), else a `TypeError`. `list` checks every id of its selection before it reads any repository, so a selection is listed whole or not at all: a repository named twice is a `TypeError`, and one outside the link an `Error` naming its number. `read` throws a `TypeError` for an item id that names no file: one without a path, or whose path has an empty, `.` or `..` segment. `changesSince` compares one repository a call: a selection of none or several, an empty `after`, or a `listing` without the `after` commit it was read at is a `TypeError`, so call it once per repository, each with that repository's own cursor. A call whose signal is already aborted throws `GitHubNoAnswer` (`cancelled`) before it checks anything else, so every aborted call answers alike, whatever its inputs.
469
473
 
@@ -82,6 +82,46 @@ function bodyIgnoresSignal(status, start) {
82
82
  },
83
83
  }), { status, headers: { 'content-type': 'application/json' } });
84
84
  }
85
+ /**
86
+ * A fetch that answers `status` with `headers` and the start of a body, then stalls, leaving the body open when its
87
+ * signal aborts, as a fetch that does not tie its answer's body to its signal would. It counts the body's cancels; with
88
+ * `settles` false a cancel never settles, as with a stream whose source never answers one.
89
+ */
90
+ function heldBody(status, start, options = {}) {
91
+ let cancels = 0;
92
+ let waiting = () => undefined;
93
+ const stalled = new Promise((resolve) => {
94
+ waiting = () => resolve();
95
+ });
96
+ const fetchImpl = async () => {
97
+ let pulls = 0;
98
+ return new Response(new ReadableStream({
99
+ pull(controller) {
100
+ pulls += 1;
101
+ if (pulls === 1)
102
+ controller.enqueue(new TextEncoder().encode(start));
103
+ else
104
+ waiting();
105
+ },
106
+ cancel() {
107
+ cancels += 1;
108
+ return options.settles === false ? new Promise(() => undefined) : undefined;
109
+ },
110
+ }, { highWaterMark: 0 }), { status, headers: { 'content-type': 'application/json', ...options.headers } });
111
+ };
112
+ return { fetchImpl, cancels: () => cancels, stalled };
113
+ }
114
+ /** A 202 whose body never settles a cancel, counting the cancels it gets. */
115
+ function acceptedHolding() {
116
+ let cancels = 0;
117
+ const answer = () => new Response(new ReadableStream({
118
+ cancel() {
119
+ cancels += 1;
120
+ return new Promise(() => undefined);
121
+ },
122
+ }), { status: 202 });
123
+ return { answer, cancels: () => cancels };
124
+ }
85
125
  const BASE = 'https://stand-in.test';
86
126
  const CLIENT_ID = 'Iv23liTESTCLIENT';
87
127
  const keys = generateKeyPairSync('rsa', {
@@ -133,7 +173,7 @@ function reconcileStandIn(hooks) {
133
173
  return json({ message: 'There is at least one repository that does not exist' }, 422);
134
174
  return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
135
175
  }
136
- if (url.pathname === '/repos/first-team/handbook/branches/main')
176
+ if (url.pathname === '/repositories/7/branches/main')
137
177
  return json({ name: 'main', commit: { sha: HEAD_NOW } });
138
178
  if (url.pathname.startsWith('/app/hook/'))
139
179
  return hooks(request, url);
@@ -242,6 +282,18 @@ describe('the GitHub App', () => {
242
282
  expect(timedOut.reason).toBe('timeout');
243
283
  }
244
284
  });
285
+ it("cancels the token answer's body when the code exchange ends at its deadline, so nothing more of it is read", async () => {
286
+ const redirectUri = 'https://app.example/sources/github/callback';
287
+ const held = heldBody(200, '{"access_token":');
288
+ const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, held.fetchImpl, GITHUB_OAUTH_ENDPOINTS, { timeoutMs: 50 });
289
+ const begun = flow.begin({ redirectUri });
290
+ const timedOut = await flow
291
+ .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
292
+ .catch((error) => error);
293
+ expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
294
+ expect(timedOut.reason).toBe('timeout');
295
+ expect(held.cancels()).toBe(1);
296
+ });
245
297
  it('refuses a redirect from the token address, so the code and the client secret are never sent anywhere else', async () => {
246
298
  const elsewhere = 'https://elsewhere.test/collect';
247
299
  const seen = [];
@@ -410,9 +462,9 @@ describe('the GitHub App', () => {
410
462
  return json({ message: 'There is at least one repository that does not exist' }, 422);
411
463
  return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
412
464
  }
413
- if (url.pathname === '/repos/first-team/handbook/branches/main')
465
+ if (url.pathname === '/repositories/7/branches/main')
414
466
  return json({ name: 'main', commit: { sha: headNow } });
415
- if (url.pathname === '/repos/first-team/notes/branches/trunk')
467
+ if (url.pathname === '/repositories/8/branches/trunk')
416
468
  return json({ name: 'trunk', commit: { sha: headSame } });
417
469
  if (url.pathname === '/app/hook/deliveries') {
418
470
  const cursor = url.searchParams.get('cursor');
@@ -453,8 +505,9 @@ describe('the GitHub App', () => {
453
505
  redelivered: [9001],
454
506
  unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
455
507
  deliveries: { listingStatus: null, refused: [] },
508
+ retryAt: null,
456
509
  });
457
- const branchRead = seen.find((request) => request.url === `${BASE}/repos/first-team/handbook/branches/main`);
510
+ const branchRead = seen.find((request) => request.url === `${BASE}/repositories/7/branches/main`);
458
511
  expect(branchRead?.headers.get('authorization')).toBe('Bearer ghs_repo7');
459
512
  expect(seen.some((request) => request.url.includes('/app/installations/22/'))).toBe(false);
460
513
  expect(seen.some((request) => request.url.includes('cursor=page3'))).toBe(false);
@@ -476,7 +529,7 @@ describe('the GitHub App', () => {
476
529
  });
477
530
  const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
478
531
  const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
479
- expect(outcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 500, refused: [] } });
532
+ expect(outcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 500, refused: [] }, retryAt: null });
480
533
  expect(seen.some((request) => request.url.endsWith('/attempts'))).toBe(false);
481
534
  });
482
535
  it('goes on past a redelivery GitHub refuses with 403, reporting it beside the lists it computed', async () => {
@@ -494,7 +547,7 @@ describe('the GitHub App', () => {
494
547
  });
495
548
  const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
496
549
  const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
497
- expect(outcome).toEqual({ ...RECONCILED, redelivered: [9002], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 403 }] } });
550
+ expect(outcome).toEqual({ ...RECONCILED, redelivered: [9002], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 403 }] }, retryAt: null });
498
551
  expect(seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
499
552
  '/app/hook/deliveries/9001/attempts',
500
553
  '/app/hook/deliveries/9002/attempts',
@@ -525,6 +578,115 @@ describe('the GitHub App', () => {
525
578
  expect(outcome.redelivered).toEqual([9106, 9109, 9103, 9104]);
526
579
  expect(seen.filter((request) => request.method === 'POST').map((request) => new URL(request.url).pathname)).toEqual([9106, 9109, 9103, 9104].map((id) => `/app/hook/deliveries/${id}/attempts`));
527
580
  });
581
+ it("reads each repository's head by its id, never through a name another repository has taken since the link was made", async () => {
582
+ const now = Date.now();
583
+ const { fetchImpl, seen } = standIn((request) => {
584
+ const url = new URL(request.url);
585
+ if (url.pathname === '/app/installations')
586
+ return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
587
+ if (url.pathname === '/app/installations/21/access_tokens')
588
+ return json({ token: 'ghs_repo7', expires_at: new Date(now + 3_600_000).toISOString() }, 201);
589
+ if (url.pathname === '/repositories/7/branches/main')
590
+ return json({ name: 'main', commit: { sha: HEAD_BEFORE } });
591
+ // Another account's public repository holds the old name now, at another head.
592
+ if (url.pathname === '/repos/first-team/handbook/branches/main')
593
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
594
+ if (url.pathname === '/app/hook/deliveries')
595
+ return json([]);
596
+ return json({ message: 'Not Found' }, 404);
597
+ });
598
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
599
+ const outcome = await reconcileInstallations(credential, [{ installationId: 21, repositories: [{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE }] }], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
600
+ expect(outcome.moved).toEqual([]);
601
+ expect(outcome.unreadable).toEqual([]);
602
+ expect(seen.map((request) => new URL(request.url).pathname)).toContain('/repositories/7/branches/main');
603
+ expect(seen.some((request) => new URL(request.url).pathname.startsWith('/repos/'))).toBe(false);
604
+ });
605
+ it('stops at a rate limit, making no request after it, and keeps what it found beside the time to run again', async () => {
606
+ const now = Date.now();
607
+ const { fetchImpl, seen } = standIn((request) => {
608
+ const url = new URL(request.url);
609
+ if (url.pathname === '/app/installations')
610
+ return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
611
+ if (url.pathname === '/app/installations/21/access_tokens') {
612
+ const ids = JSON.parse(request.body ?? '{}').repository_ids;
613
+ return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
614
+ }
615
+ if (url.pathname === '/repositories/7/branches/main')
616
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
617
+ if (url.pathname === '/repositories/8/branches/main')
618
+ return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '120' });
619
+ if (url.pathname === '/repositories/9/branches/main')
620
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
621
+ if (url.pathname === '/app/hook/deliveries') {
622
+ return json([{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 }]);
623
+ }
624
+ return new Response(null, { status: 202 });
625
+ });
626
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
627
+ const started = Date.now();
628
+ const outcome = await reconcileInstallations(credential, [
629
+ {
630
+ installationId: 21,
631
+ repositories: [
632
+ { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE },
633
+ { id: 8, fullName: 'first-team/notes', defaultBranch: 'main', headCommit: HEAD_BEFORE },
634
+ { id: 9, fullName: 'first-team/wiki', defaultBranch: 'main', headCommit: HEAD_BEFORE },
635
+ ],
636
+ },
637
+ { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
638
+ ], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
639
+ const { retryAt, ...found } = outcome;
640
+ // What it found before the refusal is kept, the gone installation among it; repository 8 was refused, not found unreadable.
641
+ expect(found).toEqual({
642
+ gone: [22],
643
+ moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
644
+ redelivered: [],
645
+ unreadable: [],
646
+ deliveries: { listingStatus: null, refused: [] },
647
+ });
648
+ expect(retryAt).toBeGreaterThanOrEqual(started + 120_000);
649
+ expect(retryAt).toBeLessThanOrEqual(Date.now() + 120_000);
650
+ // The refused read was the last request: not repository 9, not the deliveries, not a redelivery.
651
+ expect(seen[seen.length - 1]?.url).toBe(`${BASE}/repositories/8/branches/main`);
652
+ });
653
+ it('ends the deliveries step at a rate limit, asking for no delivery after it, and answers the time to run again', async () => {
654
+ const now = Date.now();
655
+ const reset = Math.floor(now / 1000) + 900;
656
+ const listing = reconcileStandIn((_request, url) => {
657
+ if (url.pathname === '/app/hook/deliveries') {
658
+ return json({ message: 'API rate limit exceeded' }, 403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
659
+ }
660
+ return new Response(null, { status: 202 });
661
+ });
662
+ const listingCredential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, listing.fetchImpl, BASE);
663
+ const listingOutcome = await reconcileInstallations(listingCredential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl: listing.fetchImpl, apiBase: BASE });
664
+ expect(listingOutcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 403, refused: [] }, retryAt: reset * 1000 });
665
+ const redelivery = reconcileStandIn((_request, url) => {
666
+ if (url.pathname === '/app/hook/deliveries') {
667
+ return json([
668
+ { id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 },
669
+ { id: 9002, guid: 'guid-2', delivered_at: new Date(now - 1_200_000).toISOString(), status_code: 502 },
670
+ ]);
671
+ }
672
+ if (url.pathname === '/app/hook/deliveries/9001/attempts')
673
+ return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '60' });
674
+ return new Response(null, { status: 202 });
675
+ });
676
+ const redeliveryCredential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, redelivery.fetchImpl, BASE);
677
+ const started = Date.now();
678
+ const redeliveryOutcome = await reconcileInstallations(redeliveryCredential, RECONCILE_LINKS, {
679
+ since: new Date(now - 86_400_000),
680
+ fetchImpl: redelivery.fetchImpl,
681
+ apiBase: BASE,
682
+ });
683
+ const { retryAt, ...found } = redeliveryOutcome;
684
+ expect(found).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 429 }] } });
685
+ expect(retryAt).toBeGreaterThanOrEqual(started + 60_000);
686
+ expect(redelivery.seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
687
+ '/app/hook/deliveries/9001/attempts',
688
+ ]);
689
+ });
528
690
  it("ends a repository's installation token request at the reconcile's deadline", async () => {
529
691
  const { fetchImpl: answering } = reconcileStandIn(() => json([]));
530
692
  const fetchImpl = async (input, init) => {
@@ -538,7 +700,7 @@ describe('the GitHub App', () => {
538
700
  expect(failure.reason).toBe('timeout');
539
701
  expect(Date.now() - started).toBeLessThan(5_000);
540
702
  });
541
- it('uninstalls with the JWT, taking 202, 204 and 404 as done and throwing other answers without their body', async () => {
703
+ it('uninstalls with the JWT, taking a 2xx (202, 204) and 404 as done and throwing other answers without their body', async () => {
542
704
  const { fetchImpl, seen } = standIn((request) => {
543
705
  const id = new URL(request.url).pathname.split('/').pop();
544
706
  if (id === '31')
@@ -547,6 +709,8 @@ describe('the GitHub App', () => {
547
709
  return new Response(null, { status: 204 });
548
710
  if (id === '33')
549
711
  return json({ message: 'Not Found' }, 404);
712
+ if (id === '35')
713
+ return json({}, 200);
550
714
  return json({ message: 'internal detail of the failure' }, 500);
551
715
  });
552
716
  const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
@@ -558,9 +722,32 @@ describe('the GitHub App', () => {
558
722
  expect(failure).toBeInstanceOf(GitHubApiError);
559
723
  expect(failure.status).toBe(500);
560
724
  expect(failure.message).not.toContain('internal detail');
561
- expect(seen.map((request) => `${request.method} ${request.url}`)).toEqual([31, 32, 33, 34].map((id) => `DELETE ${BASE}/app/installations/${id}`));
725
+ // Any 2xx is GitHub taking the removal, whatever the API version answers it with.
726
+ await expect(uninstallInstallation(credential, 35, at)).resolves.toBeUndefined();
727
+ expect(seen.map((request) => `${request.method} ${request.url}`)).toEqual([31, 32, 33, 34, 35].map((id) => `DELETE ${BASE}/app/installations/${id}`));
562
728
  expect(seen.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
563
729
  });
730
+ it("takes an uninstall and a redelivery as done at once, never waiting on an answer's body whose cancel does not settle", async () => {
731
+ // A deadline past the test's own: a call that waited on the cancel would end the test, not answer.
732
+ const uninstalled = acceptedHolding();
733
+ const { fetchImpl: uninstalling } = standIn(() => uninstalled.answer());
734
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, uninstalling, BASE);
735
+ await expect(uninstallInstallation(credential, 31, { fetchImpl: uninstalling, apiBase: BASE, timeoutMs: 60_000 })).resolves.toBeUndefined();
736
+ expect(uninstalled.cancels()).toBe(1);
737
+ const now = Date.now();
738
+ const redelivery = acceptedHolding();
739
+ const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
740
+ if (url.pathname === '/app/hook/deliveries') {
741
+ return json([{ id: 9201, guid: 'guid-h', delivered_at: new Date(now - 600_000).toISOString(), status_code: 502 }]);
742
+ }
743
+ return redelivery.answer();
744
+ });
745
+ const reconciling = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
746
+ const outcome = await reconcileInstallations(reconciling, [], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE, timeoutMs: 60_000 });
747
+ expect(outcome.redelivered).toEqual([9201]);
748
+ expect(seen.filter((request) => request.method === 'POST').map((request) => new URL(request.url).pathname)).toEqual(['/app/hook/deliveries/9201/attempts']);
749
+ expect(redelivery.cancels()).toBe(1);
750
+ });
564
751
  });
565
752
  describe('the REST calls', () => {
566
753
  it('refuses a next page on another origin, so the bearer never goes there', async () => {
@@ -674,6 +861,41 @@ describe('the REST calls', () => {
674
861
  expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
675
862
  expect(cancelled.reason).toBe('cancelled');
676
863
  });
864
+ it('cancels the body a call stops reading at its deadline or its cancel, so nothing more of it is read', async () => {
865
+ const answers = [
866
+ [200, '{"login":'],
867
+ [403, '{"message":"You have exceeded a secondary rate'],
868
+ ];
869
+ for (const [status, start] of answers) {
870
+ const open = heldBody(status, start);
871
+ const timedOut = await userOfToken('ghu_testuser', { fetchImpl: open.fetchImpl, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
872
+ expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
873
+ expect(timedOut.reason).toBe('timeout');
874
+ expect(open.cancels()).toBe(1);
875
+ }
876
+ const held = heldBody(200, '{"login":');
877
+ const controller = new AbortController();
878
+ const pending = githubFetch(held.fetchImpl, `${BASE}/user`, { token: 'ghu_testuser', signal: controller.signal }).catch((error) => error);
879
+ await held.stalled;
880
+ controller.abort();
881
+ const cancelled = await pending;
882
+ expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
883
+ expect(cancelled.reason).toBe('cancelled');
884
+ expect(held.cancels()).toBe(1);
885
+ });
886
+ it('tells a refusal by its status and headers at once, never waiting on a body whose cancel does not settle', async () => {
887
+ // A deadline past the test's own: a call that waited on the cancel would end the test, not answer.
888
+ const limited = heldBody(429, '{"message":"You have exceeded a secondary rate limit', { headers: { 'retry-after': '30' }, settles: false });
889
+ const refused = await userOfToken('ghu_testuser', { fetchImpl: limited.fetchImpl, apiBase: BASE, timeoutMs: 60_000 }).catch((error) => error);
890
+ expect(refused).toBeInstanceOf(GitHubApiError);
891
+ expect(refused).toMatchObject({ status: 429, rateLimited: true });
892
+ expect(limited.cancels()).toBe(1);
893
+ const unavailable = heldBody(503, '{"message":"Unicorn', { settles: false });
894
+ const failed = await userOfToken('ghu_testuser', { fetchImpl: unavailable.fetchImpl, apiBase: BASE, timeoutMs: 60_000 }).catch((error) => error);
895
+ expect(failed).toBeInstanceOf(GitHubApiError);
896
+ expect(failed).toMatchObject({ status: 503, rateLimited: false, retryAt: null });
897
+ expect(unavailable.cancels()).toBe(1);
898
+ });
677
899
  it('reads a refusal whose body stops before its end as no answer, never as a refusal told by its status alone', async () => {
678
900
  const timedOut = await userOfToken('ghu_testuser', { fetchImpl: refusalStallsInBody, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
679
901
  expect(timedOut).toBeInstanceOf(GitHubNoAnswer);