@framers/agentos-ext-github 0.4.2 → 0.6.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 +15 -8
- package/dist/__tests__/app.test.js +202 -9
- package/dist/__tests__/app.test.js.map +1 -1
- package/dist/__tests__/documents.test.d.ts +3 -2
- package/dist/__tests__/documents.test.d.ts.map +1 -1
- package/dist/__tests__/documents.test.js +202 -17
- package/dist/__tests__/documents.test.js.map +1 -1
- package/dist/__tests__/events.test.js +17 -0
- package/dist/__tests__/events.test.js.map +1 -1
- package/dist/app/credential.d.ts +26 -2
- package/dist/app/credential.d.ts.map +1 -1
- package/dist/app/credential.js +35 -3
- package/dist/app/credential.js.map +1 -1
- package/dist/app/events.d.ts +14 -10
- package/dist/app/events.d.ts.map +1 -1
- package/dist/app/events.js +17 -11
- package/dist/app/events.js.map +1 -1
- package/dist/app/http.d.ts +10 -0
- package/dist/app/http.d.ts.map +1 -1
- package/dist/app/http.js +16 -0
- package/dist/app/http.js.map +1 -1
- package/dist/app/installations.d.ts +22 -5
- package/dist/app/installations.d.ts.map +1 -1
- package/dist/app/installations.js +39 -13
- package/dist/app/installations.js.map +1 -1
- package/dist/app/reconcile.d.ts +28 -6
- package/dist/app/reconcile.d.ts.map +1 -1
- package/dist/app/reconcile.js +33 -14
- package/dist/app/reconcile.js.map +1 -1
- package/dist/app/webhook.d.ts +7 -0
- package/dist/app/webhook.d.ts.map +1 -1
- package/dist/app/webhook.js +11 -2
- package/dist/app/webhook.js.map +1 -1
- package/dist/documents/GitHubDocumentsConnector.d.ts +14 -3
- package/dist/documents/GitHubDocumentsConnector.d.ts.map +1 -1
- package/dist/documents/GitHubDocumentsConnector.js +20 -8
- package/dist/documents/GitHubDocumentsConnector.js.map +1 -1
- package/dist/documents/index.d.ts +4 -4
- package/dist/documents/index.d.ts.map +1 -1
- package/dist/documents/index.js +4 -4
- package/dist/documents/index.js.map +1 -1
- package/dist/documents/tree.d.ts +53 -9
- package/dist/documents/tree.d.ts.map +1 -1
- package/dist/documents/tree.js +77 -17
- package/dist/documents/tree.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +4 -4
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
|
|
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
|
|
|
@@ -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,38 +434,43 @@ 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
|
|
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
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
|
| --- | --- |
|
|
441
443
|
| `appJwt(identity, nowSeconds?)` | The app's JWT: RS256, `iat` 60 seconds back, `exp` nine minutes ahead, `iss` the client id |
|
|
442
|
-
| `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 |
|
|
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
|
-
| `
|
|
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 |
|
|
448
|
+
| `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
449
|
| `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
450
|
| `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` |
|
|
451
|
+
| `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
452
|
| `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 |
|
|
453
|
+
| `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` |
|
|
454
|
+
| `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 |
|
|
451
455
|
| `GitHubApiError` | An answer outside 2xx; carries the status, `rateLimited` and `retryAt`, and nothing of the body |
|
|
452
456
|
| `GitHubNoAnswer` | A call that got no answer, by its `reason`: `timeout`, `cancelled` or `network` |
|
|
453
457
|
|
|
454
458
|
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
459
|
|
|
460
|
+
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.
|
|
461
|
+
|
|
456
462
|
The connector's filter, `new GitHubDocumentsConnector(credential, installationId, repositories, filter)`:
|
|
457
463
|
|
|
458
464
|
| Field | Default |
|
|
459
465
|
| --- | --- |
|
|
460
466
|
| `extensions` | `.md`, `.markdown`, `.txt`, `.rst`, `.html`, `.htm`, `.pdf`, `.docx` |
|
|
467
|
+
| `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 |
|
|
461
468
|
| `skipFolders` | `node_modules`, `vendor`, `dist`, `build`, `.git`, `third_party`, at any depth |
|
|
462
|
-
| `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 |
|
|
469
|
+
| `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 |
|
|
463
470
|
| `maxFileBytes` | `10485760` (10 MiB): larger files are left out of a listing, and a read past it is answered gone |
|
|
464
471
|
| `maxTreeRequests` | `50` tree requests to list one commit |
|
|
465
472
|
|
|
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
|
|
473
|
+
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
474
|
|
|
468
475
|
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
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, GitHubNoAnswer, installationsOfUser, reconcileInstallations, 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 = [];
|
|
@@ -173,7 +173,7 @@ function reconcileStandIn(hooks) {
|
|
|
173
173
|
return json({ message: 'There is at least one repository that does not exist' }, 422);
|
|
174
174
|
return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
|
|
175
175
|
}
|
|
176
|
-
if (url.pathname === '/
|
|
176
|
+
if (url.pathname === '/repositories/7/branches/main')
|
|
177
177
|
return json({ name: 'main', commit: { sha: HEAD_NOW } });
|
|
178
178
|
if (url.pathname.startsWith('/app/hook/'))
|
|
179
179
|
return hooks(request, url);
|
|
@@ -222,6 +222,85 @@ describe('the GitHub App', () => {
|
|
|
222
222
|
await expect(credential.installationToken(42, [])).rejects.toThrow(TypeError);
|
|
223
223
|
expect(seen).toHaveLength(0);
|
|
224
224
|
});
|
|
225
|
+
it("lists every repository an installation reaches across pages, with a token that reads their metadata alone and is held", async () => {
|
|
226
|
+
const { fetchImpl, seen } = standIn((request) => {
|
|
227
|
+
const url = new URL(request.url);
|
|
228
|
+
if (url.pathname === '/app/installations/21/access_tokens') {
|
|
229
|
+
return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
|
|
230
|
+
}
|
|
231
|
+
if (url.pathname === '/installation/repositories' && url.searchParams.get('page') === '2') {
|
|
232
|
+
return json({ total_count: 2, repository_selection: 'selected', repositories: [{ id: 9, full_name: 'first-team/plans', default_branch: 'trunk', private: true }] });
|
|
233
|
+
}
|
|
234
|
+
if (url.pathname === '/installation/repositories') {
|
|
235
|
+
const next = `${BASE}/installation/repositories?per_page=100&page=2`;
|
|
236
|
+
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"` });
|
|
237
|
+
}
|
|
238
|
+
return json({ message: 'Not Found' }, 404);
|
|
239
|
+
});
|
|
240
|
+
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
241
|
+
const at = { fetchImpl, apiBase: BASE };
|
|
242
|
+
expect(await repositoriesOfInstallation(credential, 21, at)).toEqual([
|
|
243
|
+
{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main' },
|
|
244
|
+
{ id: 9, fullName: 'first-team/plans', defaultBranch: 'trunk' },
|
|
245
|
+
]);
|
|
246
|
+
expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
|
|
247
|
+
'POST /app/installations/21/access_tokens',
|
|
248
|
+
'GET /installation/repositories',
|
|
249
|
+
'GET /installation/repositories',
|
|
250
|
+
]);
|
|
251
|
+
// No repository named, so the token reaches every repository of the installation, and their metadata alone.
|
|
252
|
+
expect(JSON.parse(seen[0].body ?? '')).toEqual({ permissions: { metadata: 'read' } });
|
|
253
|
+
expect(appClaims(seen[0].headers.get('authorization'))).toMatchObject({ iss: CLIENT_ID });
|
|
254
|
+
expect(seen.slice(1).every((request) => request.headers.get('authorization') === 'Bearer ghs_metadata')).toBe(true);
|
|
255
|
+
expect(seen.slice(1).every((request) => request.headers.get('x-github-api-version') === '2026-03-10')).toBe(true);
|
|
256
|
+
// The token is held as an installation token is, apart from the tokens narrowed to repositories.
|
|
257
|
+
await repositoriesOfInstallation(credential, 21, at);
|
|
258
|
+
expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
|
|
259
|
+
await credential.installationToken(21, [7]);
|
|
260
|
+
expect(seen.filter((request) => request.method === 'POST').map((request) => JSON.parse(request.body ?? ''))).toEqual([
|
|
261
|
+
{ permissions: { metadata: 'read' } },
|
|
262
|
+
{ repository_ids: [7] },
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
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 () => {
|
|
266
|
+
let removed = false;
|
|
267
|
+
const { fetchImpl, seen } = standIn((request) => {
|
|
268
|
+
const url = new URL(request.url);
|
|
269
|
+
if (url.pathname === '/app/installations/21/access_tokens') {
|
|
270
|
+
if (removed)
|
|
271
|
+
return json({ message: 'Not Found' }, 404);
|
|
272
|
+
return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
|
|
273
|
+
}
|
|
274
|
+
if (url.pathname === '/installation/repositories') {
|
|
275
|
+
if (removed)
|
|
276
|
+
return json({ message: 'Bad credentials' }, 401);
|
|
277
|
+
return json({ total_count: 1, repository_selection: 'selected', repositories: [{ id: 7, full_name: 'first-team/handbook', default_branch: 'main' }] });
|
|
278
|
+
}
|
|
279
|
+
return json({ message: 'Not Found' }, 404);
|
|
280
|
+
});
|
|
281
|
+
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
282
|
+
const at = { fetchImpl, apiBase: BASE };
|
|
283
|
+
expect(await repositoriesOfInstallation(credential, 21, at)).toHaveLength(1);
|
|
284
|
+
// GitHub removes the installation: the held token is refused, and a new one cannot be minted.
|
|
285
|
+
removed = true;
|
|
286
|
+
const failure = await repositoriesOfInstallation(credential, 21, at).catch((error) => error);
|
|
287
|
+
expect(failure).toBeInstanceOf(GitHubApiError);
|
|
288
|
+
expect(failure.status).toBe(404);
|
|
289
|
+
expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
|
|
290
|
+
'POST /app/installations/21/access_tokens',
|
|
291
|
+
'GET /installation/repositories',
|
|
292
|
+
'GET /installation/repositories',
|
|
293
|
+
'POST /app/installations/21/access_tokens',
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
it('throws a listing whose metadata token GitHub refuses, so a lost installation never reads as one that reaches no repository', async () => {
|
|
297
|
+
const { fetchImpl, seen } = standIn(() => json({ message: 'Not Found' }, 404));
|
|
298
|
+
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
299
|
+
const failure = await repositoriesOfInstallation(credential, 21, { fetchImpl, apiBase: BASE }).catch((error) => error);
|
|
300
|
+
expect(failure).toBeInstanceOf(GitHubApiError);
|
|
301
|
+
expect(failure.status).toBe(404);
|
|
302
|
+
expect(seen.map((request) => new URL(request.url).pathname)).toEqual(['/app/installations/21/access_tokens']);
|
|
303
|
+
});
|
|
225
304
|
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
305
|
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
306
|
const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, fetchImpl);
|
|
@@ -462,9 +541,9 @@ describe('the GitHub App', () => {
|
|
|
462
541
|
return json({ message: 'There is at least one repository that does not exist' }, 422);
|
|
463
542
|
return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
|
|
464
543
|
}
|
|
465
|
-
if (url.pathname === '/
|
|
544
|
+
if (url.pathname === '/repositories/7/branches/main')
|
|
466
545
|
return json({ name: 'main', commit: { sha: headNow } });
|
|
467
|
-
if (url.pathname === '/
|
|
546
|
+
if (url.pathname === '/repositories/8/branches/trunk')
|
|
468
547
|
return json({ name: 'trunk', commit: { sha: headSame } });
|
|
469
548
|
if (url.pathname === '/app/hook/deliveries') {
|
|
470
549
|
const cursor = url.searchParams.get('cursor');
|
|
@@ -505,8 +584,9 @@ describe('the GitHub App', () => {
|
|
|
505
584
|
redelivered: [9001],
|
|
506
585
|
unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
|
|
507
586
|
deliveries: { listingStatus: null, refused: [] },
|
|
587
|
+
retryAt: null,
|
|
508
588
|
});
|
|
509
|
-
const branchRead = seen.find((request) => request.url === `${BASE}/
|
|
589
|
+
const branchRead = seen.find((request) => request.url === `${BASE}/repositories/7/branches/main`);
|
|
510
590
|
expect(branchRead?.headers.get('authorization')).toBe('Bearer ghs_repo7');
|
|
511
591
|
expect(seen.some((request) => request.url.includes('/app/installations/22/'))).toBe(false);
|
|
512
592
|
expect(seen.some((request) => request.url.includes('cursor=page3'))).toBe(false);
|
|
@@ -528,7 +608,7 @@ describe('the GitHub App', () => {
|
|
|
528
608
|
});
|
|
529
609
|
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
530
610
|
const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
|
|
531
|
-
expect(outcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 500, refused: [] } });
|
|
611
|
+
expect(outcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 500, refused: [] }, retryAt: null });
|
|
532
612
|
expect(seen.some((request) => request.url.endsWith('/attempts'))).toBe(false);
|
|
533
613
|
});
|
|
534
614
|
it('goes on past a redelivery GitHub refuses with 403, reporting it beside the lists it computed', async () => {
|
|
@@ -546,7 +626,7 @@ describe('the GitHub App', () => {
|
|
|
546
626
|
});
|
|
547
627
|
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
548
628
|
const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
|
|
549
|
-
expect(outcome).toEqual({ ...RECONCILED, redelivered: [9002], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 403 }] } });
|
|
629
|
+
expect(outcome).toEqual({ ...RECONCILED, redelivered: [9002], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 403 }] }, retryAt: null });
|
|
550
630
|
expect(seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
|
|
551
631
|
'/app/hook/deliveries/9001/attempts',
|
|
552
632
|
'/app/hook/deliveries/9002/attempts',
|
|
@@ -577,6 +657,115 @@ describe('the GitHub App', () => {
|
|
|
577
657
|
expect(outcome.redelivered).toEqual([9106, 9109, 9103, 9104]);
|
|
578
658
|
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`));
|
|
579
659
|
});
|
|
660
|
+
it("reads each repository's head by its id, never through a name another repository has taken since the link was made", async () => {
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
const { fetchImpl, seen } = standIn((request) => {
|
|
663
|
+
const url = new URL(request.url);
|
|
664
|
+
if (url.pathname === '/app/installations')
|
|
665
|
+
return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
|
|
666
|
+
if (url.pathname === '/app/installations/21/access_tokens')
|
|
667
|
+
return json({ token: 'ghs_repo7', expires_at: new Date(now + 3_600_000).toISOString() }, 201);
|
|
668
|
+
if (url.pathname === '/repositories/7/branches/main')
|
|
669
|
+
return json({ name: 'main', commit: { sha: HEAD_BEFORE } });
|
|
670
|
+
// Another account's public repository holds the old name now, at another head.
|
|
671
|
+
if (url.pathname === '/repos/first-team/handbook/branches/main')
|
|
672
|
+
return json({ name: 'main', commit: { sha: HEAD_NOW } });
|
|
673
|
+
if (url.pathname === '/app/hook/deliveries')
|
|
674
|
+
return json([]);
|
|
675
|
+
return json({ message: 'Not Found' }, 404);
|
|
676
|
+
});
|
|
677
|
+
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
678
|
+
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 });
|
|
679
|
+
expect(outcome.moved).toEqual([]);
|
|
680
|
+
expect(outcome.unreadable).toEqual([]);
|
|
681
|
+
expect(seen.map((request) => new URL(request.url).pathname)).toContain('/repositories/7/branches/main');
|
|
682
|
+
expect(seen.some((request) => new URL(request.url).pathname.startsWith('/repos/'))).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
it('stops at a rate limit, making no request after it, and keeps what it found beside the time to run again', async () => {
|
|
685
|
+
const now = Date.now();
|
|
686
|
+
const { fetchImpl, seen } = standIn((request) => {
|
|
687
|
+
const url = new URL(request.url);
|
|
688
|
+
if (url.pathname === '/app/installations')
|
|
689
|
+
return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
|
|
690
|
+
if (url.pathname === '/app/installations/21/access_tokens') {
|
|
691
|
+
const ids = JSON.parse(request.body ?? '{}').repository_ids;
|
|
692
|
+
return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
|
|
693
|
+
}
|
|
694
|
+
if (url.pathname === '/repositories/7/branches/main')
|
|
695
|
+
return json({ name: 'main', commit: { sha: HEAD_NOW } });
|
|
696
|
+
if (url.pathname === '/repositories/8/branches/main')
|
|
697
|
+
return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '120' });
|
|
698
|
+
if (url.pathname === '/repositories/9/branches/main')
|
|
699
|
+
return json({ name: 'main', commit: { sha: HEAD_NOW } });
|
|
700
|
+
if (url.pathname === '/app/hook/deliveries') {
|
|
701
|
+
return json([{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 }]);
|
|
702
|
+
}
|
|
703
|
+
return new Response(null, { status: 202 });
|
|
704
|
+
});
|
|
705
|
+
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
706
|
+
const started = Date.now();
|
|
707
|
+
const outcome = await reconcileInstallations(credential, [
|
|
708
|
+
{
|
|
709
|
+
installationId: 21,
|
|
710
|
+
repositories: [
|
|
711
|
+
{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE },
|
|
712
|
+
{ id: 8, fullName: 'first-team/notes', defaultBranch: 'main', headCommit: HEAD_BEFORE },
|
|
713
|
+
{ id: 9, fullName: 'first-team/wiki', defaultBranch: 'main', headCommit: HEAD_BEFORE },
|
|
714
|
+
],
|
|
715
|
+
},
|
|
716
|
+
{ installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
|
|
717
|
+
], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
|
|
718
|
+
const { retryAt, ...found } = outcome;
|
|
719
|
+
// What it found before the refusal is kept, the gone installation among it; repository 8 was refused, not found unreadable.
|
|
720
|
+
expect(found).toEqual({
|
|
721
|
+
gone: [22],
|
|
722
|
+
moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
|
|
723
|
+
redelivered: [],
|
|
724
|
+
unreadable: [],
|
|
725
|
+
deliveries: { listingStatus: null, refused: [] },
|
|
726
|
+
});
|
|
727
|
+
expect(retryAt).toBeGreaterThanOrEqual(started + 120_000);
|
|
728
|
+
expect(retryAt).toBeLessThanOrEqual(Date.now() + 120_000);
|
|
729
|
+
// The refused read was the last request: not repository 9, not the deliveries, not a redelivery.
|
|
730
|
+
expect(seen[seen.length - 1]?.url).toBe(`${BASE}/repositories/8/branches/main`);
|
|
731
|
+
});
|
|
732
|
+
it('ends the deliveries step at a rate limit, asking for no delivery after it, and answers the time to run again', async () => {
|
|
733
|
+
const now = Date.now();
|
|
734
|
+
const reset = Math.floor(now / 1000) + 900;
|
|
735
|
+
const listing = reconcileStandIn((_request, url) => {
|
|
736
|
+
if (url.pathname === '/app/hook/deliveries') {
|
|
737
|
+
return json({ message: 'API rate limit exceeded' }, 403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
|
|
738
|
+
}
|
|
739
|
+
return new Response(null, { status: 202 });
|
|
740
|
+
});
|
|
741
|
+
const listingCredential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, listing.fetchImpl, BASE);
|
|
742
|
+
const listingOutcome = await reconcileInstallations(listingCredential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl: listing.fetchImpl, apiBase: BASE });
|
|
743
|
+
expect(listingOutcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 403, refused: [] }, retryAt: reset * 1000 });
|
|
744
|
+
const redelivery = reconcileStandIn((_request, url) => {
|
|
745
|
+
if (url.pathname === '/app/hook/deliveries') {
|
|
746
|
+
return json([
|
|
747
|
+
{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 },
|
|
748
|
+
{ id: 9002, guid: 'guid-2', delivered_at: new Date(now - 1_200_000).toISOString(), status_code: 502 },
|
|
749
|
+
]);
|
|
750
|
+
}
|
|
751
|
+
if (url.pathname === '/app/hook/deliveries/9001/attempts')
|
|
752
|
+
return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '60' });
|
|
753
|
+
return new Response(null, { status: 202 });
|
|
754
|
+
});
|
|
755
|
+
const redeliveryCredential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, redelivery.fetchImpl, BASE);
|
|
756
|
+
const started = Date.now();
|
|
757
|
+
const redeliveryOutcome = await reconcileInstallations(redeliveryCredential, RECONCILE_LINKS, {
|
|
758
|
+
since: new Date(now - 86_400_000),
|
|
759
|
+
fetchImpl: redelivery.fetchImpl,
|
|
760
|
+
apiBase: BASE,
|
|
761
|
+
});
|
|
762
|
+
const { retryAt, ...found } = redeliveryOutcome;
|
|
763
|
+
expect(found).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 429 }] } });
|
|
764
|
+
expect(retryAt).toBeGreaterThanOrEqual(started + 60_000);
|
|
765
|
+
expect(redelivery.seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
|
|
766
|
+
'/app/hook/deliveries/9001/attempts',
|
|
767
|
+
]);
|
|
768
|
+
});
|
|
580
769
|
it("ends a repository's installation token request at the reconcile's deadline", async () => {
|
|
581
770
|
const { fetchImpl: answering } = reconcileStandIn(() => json([]));
|
|
582
771
|
const fetchImpl = async (input, init) => {
|
|
@@ -590,7 +779,7 @@ describe('the GitHub App', () => {
|
|
|
590
779
|
expect(failure.reason).toBe('timeout');
|
|
591
780
|
expect(Date.now() - started).toBeLessThan(5_000);
|
|
592
781
|
});
|
|
593
|
-
it('uninstalls with the JWT, taking 202, 204 and 404 as done and throwing other answers without their body', async () => {
|
|
782
|
+
it('uninstalls with the JWT, taking a 2xx (202, 204) and 404 as done and throwing other answers without their body', async () => {
|
|
594
783
|
const { fetchImpl, seen } = standIn((request) => {
|
|
595
784
|
const id = new URL(request.url).pathname.split('/').pop();
|
|
596
785
|
if (id === '31')
|
|
@@ -599,6 +788,8 @@ describe('the GitHub App', () => {
|
|
|
599
788
|
return new Response(null, { status: 204 });
|
|
600
789
|
if (id === '33')
|
|
601
790
|
return json({ message: 'Not Found' }, 404);
|
|
791
|
+
if (id === '35')
|
|
792
|
+
return json({}, 200);
|
|
602
793
|
return json({ message: 'internal detail of the failure' }, 500);
|
|
603
794
|
});
|
|
604
795
|
const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
|
|
@@ -610,7 +801,9 @@ describe('the GitHub App', () => {
|
|
|
610
801
|
expect(failure).toBeInstanceOf(GitHubApiError);
|
|
611
802
|
expect(failure.status).toBe(500);
|
|
612
803
|
expect(failure.message).not.toContain('internal detail');
|
|
613
|
-
|
|
804
|
+
// Any 2xx is GitHub taking the removal, whatever the API version answers it with.
|
|
805
|
+
await expect(uninstallInstallation(credential, 35, at)).resolves.toBeUndefined();
|
|
806
|
+
expect(seen.map((request) => `${request.method} ${request.url}`)).toEqual([31, 32, 33, 34, 35].map((id) => `DELETE ${BASE}/app/installations/${id}`));
|
|
614
807
|
expect(seen.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
|
|
615
808
|
});
|
|
616
809
|
it("takes an uninstall and a redelivery as done at once, never waiting on an answer's body whose cancel does not settle", async () => {
|