@framers/agentos-ext-github 0.2.1 → 0.3.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 (56) hide show
  1. package/README.md +49 -15
  2. package/dist/__tests__/app.test.d.ts +1 -1
  3. package/dist/__tests__/app.test.js +306 -2
  4. package/dist/__tests__/app.test.js.map +1 -1
  5. package/dist/__tests__/documents.test.js +28 -1
  6. package/dist/__tests__/documents.test.js.map +1 -1
  7. package/dist/__tests__/entry.test.d.ts +6 -0
  8. package/dist/__tests__/entry.test.d.ts.map +1 -0
  9. package/dist/__tests__/entry.test.js +17 -0
  10. package/dist/__tests__/entry.test.js.map +1 -0
  11. package/dist/__tests__/events.test.d.ts +8 -0
  12. package/dist/__tests__/events.test.d.ts.map +1 -0
  13. package/dist/__tests__/events.test.js +304 -0
  14. package/dist/__tests__/events.test.js.map +1 -0
  15. package/dist/app/credential.d.ts +4 -3
  16. package/dist/app/credential.d.ts.map +1 -1
  17. package/dist/app/credential.js +8 -4
  18. package/dist/app/credential.js.map +1 -1
  19. package/dist/app/events.d.ts +142 -0
  20. package/dist/app/events.d.ts.map +1 -0
  21. package/dist/app/events.js +182 -0
  22. package/dist/app/events.js.map +1 -0
  23. package/dist/app/http.d.ts +82 -13
  24. package/dist/app/http.d.ts.map +1 -1
  25. package/dist/app/http.js +151 -23
  26. package/dist/app/http.js.map +1 -1
  27. package/dist/app/installations.d.ts +8 -6
  28. package/dist/app/installations.d.ts.map +1 -1
  29. package/dist/app/installations.js +21 -15
  30. package/dist/app/installations.js.map +1 -1
  31. package/dist/app/reconcile.d.ts +25 -1
  32. package/dist/app/reconcile.d.ts.map +1 -1
  33. package/dist/app/reconcile.js +63 -26
  34. package/dist/app/reconcile.js.map +1 -1
  35. package/dist/app/userFlow.d.ts +10 -3
  36. package/dist/app/userFlow.d.ts.map +1 -1
  37. package/dist/app/userFlow.js +43 -4
  38. package/dist/app/userFlow.js.map +1 -1
  39. package/dist/app/webhook.d.ts +4 -1
  40. package/dist/app/webhook.d.ts.map +1 -1
  41. package/dist/app/webhook.js +6 -1
  42. package/dist/app/webhook.js.map +1 -1
  43. package/dist/documents/GitHubDocumentsConnector.d.ts +7 -4
  44. package/dist/documents/GitHubDocumentsConnector.d.ts.map +1 -1
  45. package/dist/documents/GitHubDocumentsConnector.js +39 -18
  46. package/dist/documents/GitHubDocumentsConnector.js.map +1 -1
  47. package/dist/documents/index.d.ts +12 -7
  48. package/dist/documents/index.d.ts.map +1 -1
  49. package/dist/documents/index.js +11 -6
  50. package/dist/documents/index.js.map +1 -1
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +2 -1
  54. package/dist/index.js.map +1 -1
  55. package/manifest.json +4 -2
  56. package/package.json +5 -4
package/README.md CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  This pack gives an agent tools for repository search, issues, pull requests, files, branches, commits, releases, workflow dispatch, gists, and repository indexing. It uses the GitHub API through `@octokit/rest`.
4
4
 
5
- Its second entry, `@framers/agentos-ext-github/documents`, is for a server that reads people's repositories through a GitHub App: the app's JWT and installation tokens, the user's web flow, the installations and repositories a person can reach, webhook signatures, a daily reconcile, and `GitHubDocumentsConnector`, which reads a repository's documentation as account sources. See [GitHub App installation mode](#github-app-installation-mode).
5
+ Its second entry, `@framers/agentos-ext-github/documents`, is for a server that reads people's repositories through a GitHub App: the app's JWT and installation tokens, the user's web flow, the installations and repositories a person can reach, webhook deliveries checked and read as typed events, a daily reconcile, and `GitHubDocumentsConnector`, which reads a repository's documentation as account sources. See [GitHub App installation mode](#github-app-installation-mode).
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```bash
10
- npm install @framers/agentos-ext-github
10
+ npm install @framers/agentos-ext-github @framers/agentos @framers/agentos-ext-account-sources
11
11
  ```
12
12
 
13
- Peer dependency: `@framers/agentos` with range `>=0.13.40`, the first release whose auth module has `RedirectOAuthFlow`.
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`.
14
14
 
15
15
  ## Usage
16
16
 
@@ -343,7 +343,7 @@ List and search tools fetch a page of results and cap `per_page` as described in
343
343
 
344
344
  Top-level pull request comments use the issues API. `github_pr_comment_list` returns inline review comments; `github_comment_list` returns top-level comments.
345
345
 
346
- `GitHubRepoIndexer` indexes the repositories its constructor is given, `new GitHubRepoIndexer(service, { repositories: [{ owner, repo }] })`, and the six repositories of `ECOSYSTEM_REPOS` when it is given none.
346
+ `GitHubRepoIndexer` indexes the repositories its constructor is given, `new GitHubRepoIndexer(service, { repositories: [{ owner, repo }] })`, and the six repositories of `ECOSYSTEM_REPOS` when it is given none. The package's entry exports both, with the types `IndexedRepository` and `GitHubRepoIndexerOptions`.
347
347
 
348
348
  ## GitHub App installation mode
349
349
 
@@ -391,29 +391,63 @@ for (const repository of repositories) {
391
391
  }
392
392
  ```
393
393
 
394
- A push arrives at the app's webhook. Check its signature on the raw body before parsing it, then compare the tree at the push's `after` commit with the blobs read before:
394
+ A delivery arrives at the app's webhook. `readWebhookRequest` reads its body under a cap, checks its signature on the raw bytes, reads its id, and only then parses it into a typed event. Answer GitHub at once (it expects a 2xx within 10 seconds), apply each delivery id once (a redelivery carries the id of the delivery it repeats), and do the reading in a job:
395
395
 
396
396
  ```ts
397
- import { verifyWebhookSignature } from '@framers/agentos-ext-github/documents';
397
+ import { readWebhookRequest, WebhookDeliveryRefused } from '@framers/agentos-ext-github/documents';
398
398
 
399
- if (!verifyWebhookSignature(rawBody, request.headers.get('x-hub-signature-256'), webhookSecret)) return new Response(null, { status: 401 });
400
- const push = JSON.parse(new TextDecoder().decode(rawBody));
401
- const changes = await connector.changesSince(cursor, { ids: [String(push.repository.id)], after: push.after, ref: push.ref });
399
+ const REFUSALS = { too_large: 413, signature: 401, delivery: 400, body: 400 } as const;
400
+
401
+ export async function POST(request: Request): Promise<Response> {
402
+ let delivery;
403
+ try {
404
+ delivery = await readWebhookRequest(request, webhookSecret, { maxBytes: 5 * 1024 * 1024 });
405
+ } catch (error) {
406
+ if (error instanceof WebhookDeliveryRefused) return new Response(null, { status: REFUSALS[error.reason] });
407
+ throw error;
408
+ }
409
+ if (!(await firstSight(delivery.id))) return new Response(null, { status: 200 });
410
+ const event = delivery.event;
411
+ if (event.kind === 'push' && event.toDefaultBranch) await owe(event.installationId, event.repository.id, event.after);
412
+ if (event.kind === 'installation' && event.action === 'deleted') await unlink(event.installationId);
413
+ if (event.kind === 'installation_repositories') await drop(event.installationId, event.removed.map((repository) => repository.id));
414
+ return new Response(null, { status: 200 });
415
+ }
416
+
417
+ // The job compares the tree at the push's after commit with the blobs read before.
418
+ const changes = await connector.changesSince(cursor, { ids: [String(repositoryId)], after });
402
419
  // Read changes.changed again, delete changes.gone, keep changes.cursor for the next push.
403
420
  ```
404
421
 
422
+ | Event | Answered as |
423
+ | --- | --- |
424
+ | `push` | `{ kind: 'push', installationId, repository: { id, fullName, defaultBranch }, ref, before, after, created, deleted, forced, toDefaultBranch }`; `toDefaultBranch` is true when `ref` is `refs/heads/<default branch>` and the push did not delete it |
425
+ | `installation` | `{ kind: 'installation', action, installationId, account, repositories }` for `created`, `deleted`, `suspend`, `unsuspend` and `new_permissions_accepted`; `repositories` lists what a new installation on selected repositories can access |
426
+ | `installation_repositories` | `{ kind: 'installation_repositories', action: 'added' \| 'removed', installationId, selection: 'all' \| 'selected', added, removed }`; when an installation goes from all repositories to a selection, GitHub lists none of the repositories that left, so read the installation's repositories again |
427
+ | `installation_target` | `{ kind: 'installation_target', action: 'renamed', installationId, account }`, the account under its new login or slug |
428
+ | `ping` | `{ kind: 'ping' }` |
429
+ | anything else | `{ kind: 'other' }`, as is a payload without the fields above; `delivery.name` holds the event's name |
430
+
431
+ 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.
432
+
433
+ 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, and `GitHubAppUserFlow` takes `{ timeoutMs }` as its fourth argument; the credential's token requests and the connector's reads keep the default. A call that gets no answer throws `GitHubNoAnswer` with its `reason`: `timeout`, `cancelled` or `network`. 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.
434
+
405
435
  | Export | What it does |
406
436
  | --- | --- |
407
437
  | `appJwt(identity, nowSeconds?)` | The app's JWT: RS256, `iat` 60 seconds back, `exp` nine minutes ahead, `iss` the client id |
408
- | `GitHubAppCredential` | Mints installation tokens narrowed to the repositories asked for (`repository_ids`) and holds each until five minutes before its hour ends |
409
- | `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; it has no store, so the user token is used once |
438
+ | `GitHubAppCredential` | Mints installation tokens narrowed to the repositories asked for (`repository_ids`) and holds each 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 |
439
+ | `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 })`), 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 |
410
440
  | `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 |
411
441
  | `listInstallations`, `uninstallInstallation` | The app's installations, and its uninstall, which takes 202 (GitHub removes it in the background), 204 and 404 as done |
412
- | `verifyWebhookSignature(body, header, secret)` | Checks `X-Hub-Signature-256` against the HMAC SHA-256 of the raw body in constant time |
413
- | `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 |
442
+ | `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 |
443
+ | `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 |
444
+ | `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` |
414
445
  | `GitHubDocumentsConnector` | An `AccountSourceConnector` of `@framers/agentos-ext-account-sources` over one installation's linked repositories |
415
446
  | `readTree`, `pathFilter`, `TreeTooLarge` | A tree at a commit within a request budget, and the filter that keeps documentation |
416
- | `GitHubApiError` | An answer outside 2xx; carries the status and nothing of the body |
447
+ | `GitHubApiError` | An answer outside 2xx; carries the status, `rateLimited` and `retryAt`, and nothing of the body |
448
+ | `GitHubNoAnswer` | A call that got no answer, by its `reason`: `timeout`, `cancelled` or `network` |
449
+
450
+ 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`.
417
451
 
418
452
  The connector's filter, `new GitHubDocumentsConnector(credential, installationId, repositories, filter)`:
419
453
 
@@ -425,7 +459,7 @@ The connector's filter, `new GitHubDocumentsConnector(credential, installationId
425
459
  | `maxFileBytes` | `10485760` (10 MiB): larger files are left out of a listing, and a read past it is answered gone |
426
460
  | `maxTreeRequests` | `50` tree requests to list one commit |
427
461
 
428
- 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. `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`, which then reads no tree.
462
+ 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. `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`, which then reads no tree. A cursor `changesSince` did not answer throws a `TypeError` that quotes none of it, and a file read GitHub refuses for a rate limit throws `GitHubApiError` with its `retryAt`, never an answer that the file is gone.
429
463
 
430
464
  ## License
431
465
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @fileoverview The GitHub App half of the `./documents` entry, driven over a fetch stand-in that records each
3
3
  * request: the app's JWT, installation tokens, the user's web flow, a user's installations and repositories, the
4
- * webhook signature, the daily reconcile and the uninstall.
4
+ * webhook signature, the daily reconcile and the uninstall, and the REST calls under them.
5
5
  */
6
6
  export {};
7
7
  //# sourceMappingURL=app.test.d.ts.map
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * @fileoverview The GitHub App half of the `./documents` entry, driven over a fetch stand-in that records each
3
3
  * request: the app's JWT, installation tokens, the user's web flow, a user's installations and repositories, the
4
- * webhook signature, the daily reconcile and the uninstall.
4
+ * webhook signature, the daily reconcile and the uninstall, and the REST calls under them.
5
5
  */
6
6
  import { createHash, createHmac, createVerify, generateKeyPairSync } from 'node:crypto';
7
7
  import { afterEach, describe, expect, it, vi } from 'vitest';
8
- import { appJwt, GitHubApiError, GitHubAppCredential, GitHubAppUserFlow, installationsOfUser, reconcileInstallations, repositoriesOfUser, uninstallInstallation, userOfToken, verifyWebhookSignature, } from '../documents/index.js';
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
10
  /** A fetch that answers from `route` and records every request. */
10
11
  function standIn(route) {
11
12
  const seen = [];
@@ -26,6 +27,33 @@ function standIn(route) {
26
27
  function json(body, status = 200, headers = {}) {
27
28
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
28
29
  }
30
+ /** A fetch that never answers, rejecting with its signal's reason when the signal aborts, as fetch does. */
31
+ const neverAnswers = async (_input, init) => {
32
+ const signal = init?.signal ?? undefined;
33
+ return new Promise((_resolve, reject) => {
34
+ if (signal === undefined)
35
+ return;
36
+ if (signal.aborted)
37
+ reject(signal.reason);
38
+ else
39
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
40
+ });
41
+ };
42
+ /**
43
+ * A fetch that answers 200 with the start of a body and then stalls, erroring the body when its signal aborts, as
44
+ * fetch does to a body still streaming.
45
+ */
46
+ const stallsInBody = async (_input, init) => {
47
+ const signal = init?.signal ?? undefined;
48
+ const body = new ReadableStream({
49
+ start(controller) {
50
+ controller.enqueue(new TextEncoder().encode('{"login":'));
51
+ if (signal !== undefined)
52
+ signal.addEventListener('abort', () => controller.error(signal.reason), { once: true });
53
+ },
54
+ });
55
+ return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
56
+ };
29
57
  const BASE = 'https://stand-in.test';
30
58
  const CLIENT_ID = 'Iv23liTESTCLIENT';
31
59
  const keys = generateKeyPairSync('rsa', {
@@ -42,6 +70,48 @@ function appClaims(authorization) {
42
70
  const verified = createVerify('RSA-SHA256').update(`${head}.${body}`).verify(keys.publicKey, Buffer.from(signature, 'base64url'));
43
71
  return verified ? JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) : null;
44
72
  }
73
+ const HEAD_BEFORE = 'a'.repeat(40);
74
+ const HEAD_NOW = 'b'.repeat(40);
75
+ /** Two links: installation 21, whose repository 7 moved and whose repository 9 left it, and installation 22, gone. */
76
+ const RECONCILE_LINKS = [
77
+ {
78
+ installationId: 21,
79
+ repositories: [
80
+ { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE },
81
+ { id: 9, fullName: 'first-team/removed', defaultBranch: 'main', headCommit: HEAD_BEFORE },
82
+ ],
83
+ },
84
+ { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
85
+ ];
86
+ /** What a reconcile of {@link RECONCILE_LINKS} finds before its deliveries step. */
87
+ const RECONCILED = {
88
+ gone: [22],
89
+ moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
90
+ unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
91
+ };
92
+ /**
93
+ * A stand-in for a reconcile of {@link RECONCILE_LINKS}: GitHub lists installation 21 alone, mints a token for
94
+ * repository 7 and refuses one for repository 9 with 422, answers repository 7's head as {@link HEAD_NOW}, and leaves
95
+ * the app's hook routes to `hooks`.
96
+ */
97
+ function reconcileStandIn(hooks) {
98
+ return standIn((request) => {
99
+ const url = new URL(request.url);
100
+ if (url.pathname === '/app/installations')
101
+ return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
102
+ if (url.pathname === '/app/installations/21/access_tokens') {
103
+ const ids = JSON.parse(request.body ?? '{}').repository_ids;
104
+ if (ids.includes(9))
105
+ return json({ message: 'There is at least one repository that does not exist' }, 422);
106
+ return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
107
+ }
108
+ if (url.pathname === '/repos/first-team/handbook/branches/main')
109
+ return json({ name: 'main', commit: { sha: HEAD_NOW } });
110
+ if (url.pathname.startsWith('/app/hook/'))
111
+ return hooks(request, url);
112
+ return json({ message: 'Not Found' }, 404);
113
+ });
114
+ }
45
115
  afterEach(() => {
46
116
  vi.useRealTimers();
47
117
  });
@@ -78,6 +148,12 @@ describe('the GitHub App', () => {
78
148
  expect(await credential.installationToken(42, [3, 7])).toBe('ghs_test2');
79
149
  expect(seen).toHaveLength(2);
80
150
  });
151
+ it('refuses an empty repository list before any request, since GitHub would answer a token for every repository', async () => {
152
+ const { fetchImpl, seen } = standIn(() => json({ token: 'ghs_every', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201));
153
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
154
+ await expect(credential.installationToken(42, [])).rejects.toThrow(TypeError);
155
+ expect(seen).toHaveLength(0);
156
+ });
81
157
  it("begins the user's web flow with state and PKCE and no scope, and completes it into a token it never stores", async () => {
82
158
  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' }));
83
159
  const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, fetchImpl);
@@ -105,6 +181,52 @@ describe('the GitHub App', () => {
105
181
  expect(form.get('redirect_uri')).toBe(redirectUri);
106
182
  await expect(flow.keep('person', tokens)).rejects.toThrow('this flow has no store');
107
183
  });
184
+ it("ends the user's code exchange at its deadline, and names a token address that cannot be reached", async () => {
185
+ const redirectUri = 'https://app.example/sources/github/callback';
186
+ const late = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, neverAnswers, GITHUB_OAUTH_ENDPOINTS, { timeoutMs: 50 });
187
+ const begun = late.begin({ redirectUri });
188
+ const timedOut = await late
189
+ .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
190
+ .catch((error) => error);
191
+ expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
192
+ expect(timedOut.reason).toBe('timeout');
193
+ const unreachable = async () => {
194
+ throw new TypeError('fetch failed');
195
+ };
196
+ const offline = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, unreachable);
197
+ const second = offline.begin({ redirectUri });
198
+ const failed = await offline
199
+ .complete({ code: 'test-code', state: second.state, expectedState: second.state, codeVerifier: second.codeVerifier, redirectUri })
200
+ .catch((error) => error);
201
+ expect(failed).toBeInstanceOf(GitHubNoAnswer);
202
+ expect(failed.reason).toBe('network');
203
+ });
204
+ it('refuses a redirect from the token address, so the code and the client secret are never sent anywhere else', async () => {
205
+ const elsewhere = 'https://elsewhere.test/collect';
206
+ const seen = [];
207
+ // Redirects as fetch does: a 307 is followed with the same method and body unless the call asks for `manual`,
208
+ // which answers the redirect itself, or `error`, which fails the call.
209
+ const redirecting = async function follow(input, init) {
210
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
211
+ seen.push({ url, body: typeof init?.body === 'string' ? init.body : null });
212
+ if (url !== GITHUB_OAUTH_ENDPOINTS.token)
213
+ return json({ access_token: 'ghu_elsewhere', token_type: 'bearer' });
214
+ const moved = new Response(null, { status: 307, headers: { location: elsewhere } });
215
+ if (init?.redirect === 'manual')
216
+ return moved;
217
+ if (init?.redirect === 'error')
218
+ throw new TypeError('fetch failed');
219
+ return follow(elsewhere, init);
220
+ };
221
+ const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, redirecting);
222
+ const redirectUri = 'https://app.example/sources/github/callback';
223
+ const begun = flow.begin({ redirectUri });
224
+ const refused = await flow
225
+ .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
226
+ .catch((error) => error);
227
+ expect(refused).toMatchObject({ name: 'OAuthGrantRefused', reason: 'exchange', status: 307 });
228
+ expect(seen.map((request) => request.url)).toEqual([GITHUB_OAUTH_ENDPOINTS.token]);
229
+ });
108
230
  it("reads a user token's person, its installations across pages and an installation's repositories at the API base given", async () => {
109
231
  const { fetchImpl, seen } = standIn((request) => {
110
232
  const url = new URL(request.url);
@@ -153,6 +275,13 @@ describe('the GitHub App', () => {
153
275
  expect(verifyWebhookSignature(body, `${right}00`, secret)).toBe(false);
154
276
  expect(verifyWebhookSignature(body, `sha1=${createHmac('sha1', secret).update(body).digest('hex')}`, secret)).toBe(false);
155
277
  });
278
+ it('refuses an empty or missing secret, under which anyone can sign a delivery', () => {
279
+ const body = new TextEncoder().encode('Hello, World!');
280
+ const signedWithoutKey = `sha256=${createHmac('sha256', '').update(body).digest('hex')}`;
281
+ expect(() => verifyWebhookSignature(body, signedWithoutKey, '')).toThrow(TypeError);
282
+ expect(() => verifyWebhookSignature(body, null, '')).toThrow(TypeError);
283
+ expect(() => verifyWebhookSignature(body, signedWithoutKey, undefined)).toThrow(TypeError);
284
+ });
156
285
  it("reconciles links: an installation gone, a repository's head moved, a day's failed deliveries asked for again", async () => {
157
286
  const now = Date.now();
158
287
  const hours = (count) => new Date(now - count * 3_600_000).toISOString();
@@ -212,6 +341,7 @@ describe('the GitHub App', () => {
212
341
  moved: [{ installationId: 21, repositoryId: 7, head: headNow }],
213
342
  redelivered: [9001],
214
343
  unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
344
+ deliveries: { listingStatus: null, refused: [] },
215
345
  });
216
346
  const branchRead = seen.find((request) => request.url === `${BASE}/repos/first-team/handbook/branches/main`);
217
347
  expect(branchRead?.headers.get('authorization')).toBe('Bearer ghs_repo7');
@@ -221,6 +351,69 @@ describe('the GitHub App', () => {
221
351
  expect(appCalls.length).toBeGreaterThan(0);
222
352
  expect(appCalls.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
223
353
  });
354
+ it('keeps the lists it computed when GitHub answers a page of the deliveries 500, asking for no delivery again', async () => {
355
+ const now = Date.now();
356
+ const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
357
+ if (url.pathname === '/app/hook/deliveries' && url.searchParams.get('cursor') === null) {
358
+ return json([{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 3_600_000).toISOString(), status_code: 500 }], 200, {
359
+ link: `<${BASE}/app/hook/deliveries?per_page=100&cursor=page2>; rel="next"`,
360
+ });
361
+ }
362
+ if (url.pathname === '/app/hook/deliveries')
363
+ return json({ message: 'Server Error' }, 500);
364
+ return new Response(null, { status: 202 });
365
+ });
366
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
367
+ const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
368
+ expect(outcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 500, refused: [] } });
369
+ expect(seen.some((request) => request.url.endsWith('/attempts'))).toBe(false);
370
+ });
371
+ it('goes on past a redelivery GitHub refuses with 403, reporting it beside the lists it computed', async () => {
372
+ const now = Date.now();
373
+ const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
374
+ if (url.pathname === '/app/hook/deliveries') {
375
+ return json([
376
+ { id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 },
377
+ { id: 9002, guid: 'guid-2', delivered_at: new Date(now - 1_200_000).toISOString(), status_code: 502 },
378
+ ]);
379
+ }
380
+ if (url.pathname === '/app/hook/deliveries/9001/attempts')
381
+ return json({ message: 'Forbidden' }, 403);
382
+ return new Response(null, { status: 202 });
383
+ });
384
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
385
+ const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
386
+ expect(outcome).toEqual({ ...RECONCILED, redelivered: [9002], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 403 }] } });
387
+ expect(seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
388
+ '/app/hook/deliveries/9001/attempts',
389
+ '/app/hook/deliveries/9002/attempts',
390
+ ]);
391
+ });
392
+ it('asks again for a delivery its route answered 408, 429 or 5xx, never for one it refused with another 4xx', async () => {
393
+ const now = Date.now();
394
+ // Newest first, as GitHub lists them: guid-e failed with 404 and then 503, guid-f with 500 and then 422.
395
+ const attempts = [
396
+ [9101, 'guid-a', 10, 413],
397
+ [9106, 'guid-e', 20, 503],
398
+ [9107, 'guid-f', 30, 422],
399
+ [9102, 'guid-b', 40, 400],
400
+ [9109, 'guid-g', 50, 500],
401
+ [9103, 'guid-c', 60, 408],
402
+ [9104, 'guid-d', 70, 429],
403
+ [9105, 'guid-e', 80, 404],
404
+ [9108, 'guid-f', 90, 500],
405
+ ];
406
+ const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
407
+ if (url.pathname === '/app/hook/deliveries') {
408
+ return json(attempts.map(([id, guid, minutesAgo, statusCode]) => ({ id, guid, delivered_at: new Date(now - minutesAgo * 60_000).toISOString(), status_code: statusCode })));
409
+ }
410
+ return new Response(null, { status: 202 });
411
+ });
412
+ const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
413
+ const outcome = await reconcileInstallations(credential, [], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
414
+ expect(outcome.redelivered).toEqual([9106, 9109, 9103, 9104]);
415
+ 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`));
416
+ });
224
417
  it('uninstalls with the JWT, taking 202, 204 and 404 as done and throwing other answers without their body', async () => {
225
418
  const { fetchImpl, seen } = standIn((request) => {
226
419
  const id = new URL(request.url).pathname.split('/').pop();
@@ -245,4 +438,115 @@ describe('the GitHub App', () => {
245
438
  expect(seen.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
246
439
  });
247
440
  });
441
+ describe('the REST calls', () => {
442
+ it('refuses a next page on another origin, so the bearer never goes there', async () => {
443
+ const { fetchImpl, seen } = standIn(() => json({ total_count: 2, installations: [{ id: 11, account: { login: 'first-team', id: 901 } }] }, 200, {
444
+ link: '<https://elsewhere.example.com/user/installations?per_page=100&page=2>; rel="next"',
445
+ }));
446
+ await expect(installationsOfUser('ghu_testuser', { fetchImpl, apiBase: BASE })).rejects.toThrow('GitHub named a next page on another origin');
447
+ expect(seen.map((request) => request.url)).toEqual([`${BASE}/user/installations?per_page=100`]);
448
+ });
449
+ it('answers a body that is not JSON with an error that does not quote it', async () => {
450
+ const { fetchImpl } = standIn(() => new Response('<html>upstream detail</html>', { status: 200, headers: { 'content-type': 'text/html' } }));
451
+ const failure = await userOfToken('ghu_testuser', { fetchImpl, apiBase: BASE }).catch((error) => error);
452
+ expect(failure).toBeInstanceOf(Error);
453
+ expect(failure.message).toBe('GitHub answered a body that is not JSON');
454
+ });
455
+ it('answers undefined for a 202 or a 204 without a body', async () => {
456
+ const { fetchImpl, seen } = standIn((request) => new Response(null, { status: request.method === 'POST' ? 202 : 204 }));
457
+ await expect(githubFetch(fetchImpl, `${BASE}/app/hook/deliveries/9001/attempts`, { token: 'ghs_test', method: 'POST' })).resolves.toBeUndefined();
458
+ await expect(githubFetch(fetchImpl, `${BASE}/app/installations/31`, { token: 'ghs_test', method: 'DELETE' })).resolves.toBeUndefined();
459
+ expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual(['POST /app/hook/deliveries/9001/attempts', 'DELETE /app/installations/31']);
460
+ });
461
+ it("sends the pack's User-Agent beside the bearer, the media type and the pinned API version", async () => {
462
+ const { fetchImpl, seen } = standIn(() => json({ login: 'test-person', id: 501 }));
463
+ await userOfToken('ghu_testuser', { fetchImpl, apiBase: BASE });
464
+ expect(seen).toHaveLength(1);
465
+ expect(seen[0].headers.get('user-agent')).toBe('agentos-ext-github');
466
+ expect(seen[0].headers.get('authorization')).toBe('Bearer ghu_testuser');
467
+ expect(seen[0].headers.get('accept')).toBe('application/vnd.github+json');
468
+ expect(seen[0].headers.get('x-github-api-version')).toBe('2026-03-10');
469
+ });
470
+ it('says when a rate-limited call may be made again: retry-after first, then the window reset, else a minute', async () => {
471
+ vi.useFakeTimers({ toFake: ['Date'] });
472
+ vi.setSystemTime(new Date('2026-10-15T12:00:00Z'));
473
+ const now = Date.now();
474
+ const reset = Math.floor(now / 1000) + 1800;
475
+ // The answers of https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit
476
+ const { fetchImpl } = standIn((request) => {
477
+ switch (request.headers.get('authorization')) {
478
+ case 'Bearer primary':
479
+ return json({ message: 'API rate limit exceeded for installation ID 5.' }, 403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
480
+ case 'Bearer primary-429':
481
+ return json({ message: 'API rate limit exceeded for installation ID 5.' }, 429, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
482
+ case 'Bearer secondary-after':
483
+ return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '30', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
484
+ case 'Bearer secondary-long':
485
+ return json({ message: 'You have exceeded a secondary rate limit.' }, 403, { 'retry-after': '7200' });
486
+ case 'Bearer secondary-date':
487
+ return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': new Date(now + 90_000).toUTCString() });
488
+ case 'Bearer secondary-bare':
489
+ return json({ message: 'You have exceeded a secondary rate limit. Please wait a few minutes before you try again.' }, 403);
490
+ case 'Bearer forbidden':
491
+ return json({ message: 'Resource not accessible by integration', documentation_url: 'https://docs.github.com/rest/users/users' }, 403);
492
+ case 'Bearer unavailable':
493
+ return json({ message: 'Unicorn detail of an outage' }, 503, { 'retry-after': '120' });
494
+ default:
495
+ return json({ message: 'Not Found' }, 404);
496
+ }
497
+ });
498
+ const refusalOf = async (token) => {
499
+ const failure = await userOfToken(token, { fetchImpl, apiBase: BASE }).catch((error) => error);
500
+ expect(failure).toBeInstanceOf(GitHubApiError);
501
+ return failure;
502
+ };
503
+ const timing = (failure) => [failure.status, failure.rateLimited, failure.retryAt];
504
+ expect(timing(await refusalOf('primary'))).toEqual([403, true, reset * 1000]);
505
+ expect(timing(await refusalOf('primary-429'))).toEqual([429, true, reset * 1000]);
506
+ expect(timing(await refusalOf('secondary-after'))).toEqual([429, true, now + 30_000]);
507
+ // A wait GitHub names is kept whole, however long, as delay-seconds or as an HTTP date.
508
+ expect(timing(await refusalOf('secondary-long'))).toEqual([403, true, now + 7_200_000]);
509
+ expect(timing(await refusalOf('secondary-date'))).toEqual([429, true, now + 90_000]);
510
+ expect(timing(await refusalOf('secondary-bare'))).toEqual([403, true, now + 60_000]);
511
+ expect(timing(await refusalOf('forbidden'))).toEqual([403, false, null]);
512
+ expect(timing(await refusalOf('unavailable'))).toEqual([503, false, now + 120_000]);
513
+ expect(timing(await refusalOf('missing'))).toEqual([404, false, null]);
514
+ const quoted = await refusalOf('secondary-bare');
515
+ expect(quoted.message).toBe('GitHub answered 403');
516
+ expect((await refusalOf('unavailable')).message).not.toContain('Unicorn');
517
+ });
518
+ it('ends a call at its deadline, its body included, and names a cancelled call and a failed connection', async () => {
519
+ const timedOut = await userOfToken('ghu_testuser', { fetchImpl: neverAnswers, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
520
+ expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
521
+ expect(timedOut.reason).toBe('timeout');
522
+ expect(timedOut.message).toBe('GitHub gave no answer: timeout');
523
+ const stalled = await userOfToken('ghu_testuser', { fetchImpl: stallsInBody, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
524
+ expect(stalled).toBeInstanceOf(GitHubNoAnswer);
525
+ expect(stalled.reason).toBe('timeout');
526
+ const controller = new AbortController();
527
+ const pending = githubFetch(neverAnswers, `${BASE}/user`, { token: 'ghu_testuser', signal: controller.signal }).catch((error) => error);
528
+ controller.abort();
529
+ const cancelled = await pending;
530
+ expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
531
+ expect(cancelled.reason).toBe('cancelled');
532
+ const refusing = async () => {
533
+ throw new TypeError('fetch failed');
534
+ };
535
+ const offline = await installationsOfUser('ghu_testuser', { fetchImpl: refusing, apiBase: BASE }).catch((error) => error);
536
+ expect(offline).toBeInstanceOf(GitHubNoAnswer);
537
+ expect(offline.reason).toBe('network');
538
+ });
539
+ it('refuses an answer whose body states more than the cap before reading it', async () => {
540
+ let pulled = false;
541
+ const oversized = async () => new Response(new ReadableStream({
542
+ pull() {
543
+ pulled = true;
544
+ },
545
+ }, { highWaterMark: 0 }), { status: 200, headers: { 'content-type': 'application/json', 'content-length': String(64 * 1024 * 1024) } });
546
+ const failure = await userOfToken('ghu_testuser', { fetchImpl: oversized, apiBase: BASE }).catch((error) => error);
547
+ expect(failure).toBeInstanceOf(Error);
548
+ expect(failure.message).toBe('GitHub answered a body past the size cap');
549
+ expect(pulled).toBe(false);
550
+ });
551
+ });
248
552
  //# sourceMappingURL=app.test.js.map