@pepys/source-github 0.1.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.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  <p>
10
10
  <a href="../../docs/README.md">Documentation</a> •
11
- <a href="../../docs/design/v1-plug-ins.md">The plug-ins of v1</a>
11
+ <a href="../../docs/architecture/plug-ins/github.md">The GitHub plug-in</a>
12
12
  </p>
13
13
  </div>
14
14
 
@@ -20,7 +20,23 @@ with its consumer.
20
20
  ## Install
21
21
 
22
22
  ```sh
23
- bun add @pepys/source-github
23
+ bun add @pepys/source-github @pepys/kit
24
+ bun add -d @pepys/cli
25
+ ```
26
+
27
+ Add the plug-in to a deployment, then build its consumers. The build compiles each consumer, which
28
+ needs zig and wasm-tools, as the [CLI README](../cli/README.md) says.
29
+
30
+ ```ts
31
+ // deployment.ts
32
+ import { defineDeployment } from '@pepys/kit';
33
+ import { githubPlugin } from '@pepys/source-github';
34
+
35
+ export const deployment = defineDeployment({ name: 'home', plugins: [githubPlugin] });
36
+ ```
37
+
38
+ ```sh
39
+ bunx pepys build deployment.ts --out build
24
40
  ```
25
41
 
26
42
  ## Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/source-github",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "The pepys GitHub plug-in: pull requests, releases, commits and deploys",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,16 +19,18 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@pepys/api": "0.1.1",
23
- "@pepys/ingest": "0.1.1",
24
- "@pepys/kit": "0.1.1",
22
+ "@pepys/api": "0.3.0",
23
+ "@pepys/ingest": "0.3.0",
24
+ "@pepys/kit": "0.3.0",
25
25
  "@zgeoff/std": "0.4.3",
26
26
  "kysely": "0.29.8",
27
+ "ts-pattern": "5.9.2",
27
28
  "zod": "4.6.7"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@msw/data": "1.1.8",
31
- "@pepys/testing": "0.1.1",
32
+ "@orpc/client": "2.0.0-beta.50",
33
+ "@pepys/testing": "0.3.0",
32
34
  "msw": "3.0.3",
33
35
  "tiny-invariant": "1.3.3"
34
36
  }
@@ -2,7 +2,8 @@ import { serveAPI } from '@pepys/api';
2
2
  import { defineDeployment } from '@pepys/kit';
3
3
  import { githubPlugin } from '../github-plugin.ts';
4
4
 
5
- serveAPI({
5
+ await serveAPI({
6
6
  deployment: defineDeployment({ name: 'github', plugins: [githubPlugin] }),
7
7
  environment: process.env,
8
+ argv: process.argv.slice(2),
8
9
  });
@@ -104,7 +104,8 @@ function collectReleaseBumps(
104
104
  return bumps;
105
105
  }
106
106
 
107
- // `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text.
107
+ // `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text. GitHub
108
+ // serves a tag that holds `/` both escaped and unescaped, so the tag is the rest of the path.
108
109
  function parseReleaseURLPath(text: string): { repository: string; tag: string } | undefined {
109
110
  let end = text.length;
110
111
 
@@ -117,17 +118,19 @@ function parseReleaseURLPath(text: string): { repository: string; tag: string }
117
118
  }
118
119
 
119
120
  const parts = text.slice(0, end).split('/');
120
- const [owner, name, releases, tagWord, tag] = parts;
121
+ const [owner, name, releases, tagWord] = parts;
122
+ const tagParts = parts.slice(4);
123
+ const tag = tagParts.join('/');
121
124
 
122
- if (parts.length !== 5 || releases !== 'releases' || tagWord !== 'tag') {
125
+ if (parts.length < 5 || releases !== 'releases' || tagWord !== 'tag') {
123
126
  return undefined;
124
127
  }
125
128
 
126
- if (owner === undefined || name === undefined || tag === undefined) {
129
+ if (owner === undefined || name === undefined) {
127
130
  return undefined;
128
131
  }
129
132
 
130
- if (owner === '' || name === '' || tag === '') {
133
+ if (owner === '' || name === '' || tagParts.includes('')) {
131
134
  return undefined;
132
135
  }
133
136
 
@@ -1,6 +1,5 @@
1
1
  import type { z } from 'zod';
2
2
 
3
- // One fact type per action, `github.<family>.<action>`, each with the family's payload schema.
4
3
  export function buildFamilyTypes(
5
4
  family: string,
6
5
  actions: readonly string[],
@@ -29,7 +29,6 @@ const payloadSchema = z.looseObject({
29
29
  issue: z.looseObject({ id: z.number().int() }),
30
30
  });
31
31
 
32
- // Stored with no consumer.
33
32
  export const issueFamily = defineFactFamily({
34
33
  stream: 'facts.github.issue',
35
34
  api: 'github/2022-11-28',
@@ -9,7 +9,6 @@ const payloadSchema = z.looseObject({
9
9
  review: z.looseObject({ id: z.number().int() }),
10
10
  });
11
11
 
12
- // Stored with no consumer.
13
12
  export const pullRequestReviewFamily = defineFactFamily({
14
13
  stream: 'facts.github.pull_request_review',
15
14
  api: 'github/2022-11-28',
@@ -9,7 +9,6 @@ const payloadSchema = z.looseObject({
9
9
  workflow_run: z.looseObject({ id: z.number().int() }),
10
10
  });
11
11
 
12
- // Stored with no consumer.
13
12
  export const workflowRunFamily = defineFactFamily({
14
13
  stream: 'facts.github.workflow_run',
15
14
  api: 'github/2022-11-28',
@@ -85,6 +85,10 @@ export function updateGithubDeployModel(model: GithubDeployModel, message: Githu
85
85
  return message.kind === 'reset' ? initGithubDeployModel() : { ...model, input: message.input };
86
86
  }
87
87
 
88
+ declare const factTag: unique symbol;
89
+
90
+ export type FactID = string & { readonly [factTag]: 'FactID' };
91
+
88
92
  export interface GithubRefPushedPayload {
89
93
  ref: string;
90
94
  repository: GithubRefPushedPayloadRepository;
@@ -103,8 +107,8 @@ export interface GithubDeploymentPayloadDeployment {
103
107
  }
104
108
 
105
109
  export type GithubDeployFact =
106
- | { id: string; type: 'github.ref.pushed'; entity: string; at: string; payload: GithubRefPushedPayload }
107
- | { id: string; type: 'github.deployment.created' | 'github.deployment.status_created'; entity: string; at: string; payload: GithubDeploymentPayload };
110
+ | { id: FactID; type: 'github.ref.pushed'; entity: EntityID; at: string; payload: GithubRefPushedPayload }
111
+ | { id: FactID; type: 'github.deployment.created' | 'github.deployment.status_created'; entity: EntityID; at: string; payload: GithubDeploymentPayload };
108
112
 
109
113
  export function parseGithubDeployFact(factJSON: string): GithubDeployFact | undefined {
110
114
  const parsed: unknown = JSON.parse(factJSON);
@@ -127,6 +131,10 @@ function decodeGithubDeployFact(value: unknown): GithubDeployFact | undefined {
127
131
  return undefined;
128
132
  }
129
133
 
134
+ if (!isFactID(id) || !isFactEntityID(entity)) {
135
+ return undefined;
136
+ }
137
+
130
138
  if (type === 'github.ref.pushed') {
131
139
  const decoded = decodeGithubRefPushedPayload(payload);
132
140
 
@@ -150,6 +158,14 @@ function decodeGithubDeployFact(value: unknown): GithubDeployFact | undefined {
150
158
  return undefined;
151
159
  }
152
160
 
161
+ function isFactID(text: string): text is FactID {
162
+ return text.length > 0;
163
+ }
164
+
165
+ function isFactEntityID(text: string): text is EntityID {
166
+ return text.length > 0;
167
+ }
168
+
153
169
  function decodeGithubRefPushedPayload(value: unknown): GithubRefPushedPayload | undefined {
154
170
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
155
171
  return undefined;
@@ -45,6 +45,10 @@ export function updateGithubPullRequestModel(model: GithubPullRequestModel, mess
45
45
  return message.kind === 'reset' ? initGithubPullRequestModel() : { ...model, input: message.input };
46
46
  }
47
47
 
48
+ declare const factTag: unique symbol;
49
+
50
+ export type FactID = string & { readonly [factTag]: 'FactID' };
51
+
48
52
  export interface GithubPullRequestPayload {
49
53
  action: string;
50
54
  pull_request: GithubPullRequestPayloadPullRequest;
@@ -56,7 +60,7 @@ export interface GithubPullRequestPayloadPullRequest {
56
60
  }
57
61
 
58
62
  export type GithubPullRequestFact =
59
- | { id: string; type: 'github.pull_request.assigned' | 'github.pull_request.auto_merge_disabled' | 'github.pull_request.auto_merge_enabled' | 'github.pull_request.closed' | 'github.pull_request.converted_to_draft' | 'github.pull_request.demilestoned' | 'github.pull_request.dequeued' | 'github.pull_request.edited' | 'github.pull_request.enqueued' | 'github.pull_request.labeled' | 'github.pull_request.locked' | 'github.pull_request.milestoned' | 'github.pull_request.opened' | 'github.pull_request.ready_for_review' | 'github.pull_request.reopened' | 'github.pull_request.resynced' | 'github.pull_request.review_request_removed' | 'github.pull_request.review_requested' | 'github.pull_request.synchronize' | 'github.pull_request.unassigned' | 'github.pull_request.unlabeled' | 'github.pull_request.unlocked'; entity: string; at: string; payload: GithubPullRequestPayload };
63
+ | { id: FactID; type: 'github.pull_request.assigned' | 'github.pull_request.auto_merge_disabled' | 'github.pull_request.auto_merge_enabled' | 'github.pull_request.closed' | 'github.pull_request.converted_to_draft' | 'github.pull_request.demilestoned' | 'github.pull_request.dequeued' | 'github.pull_request.edited' | 'github.pull_request.enqueued' | 'github.pull_request.labeled' | 'github.pull_request.locked' | 'github.pull_request.milestoned' | 'github.pull_request.opened' | 'github.pull_request.ready_for_review' | 'github.pull_request.reopened' | 'github.pull_request.resynced' | 'github.pull_request.review_request_removed' | 'github.pull_request.review_requested' | 'github.pull_request.synchronize' | 'github.pull_request.unassigned' | 'github.pull_request.unlabeled' | 'github.pull_request.unlocked'; entity: EntityID; at: string; payload: GithubPullRequestPayload };
60
64
 
61
65
  export function parseGithubPullRequestFact(factJSON: string): GithubPullRequestFact | undefined {
62
66
  const parsed: unknown = JSON.parse(factJSON);
@@ -79,6 +83,10 @@ function decodeGithubPullRequestFact(value: unknown): GithubPullRequestFact | un
79
83
  return undefined;
80
84
  }
81
85
 
86
+ if (!isFactID(id) || !isFactEntityID(entity)) {
87
+ return undefined;
88
+ }
89
+
82
90
  if (type === 'github.pull_request.assigned' || type === 'github.pull_request.auto_merge_disabled' || type === 'github.pull_request.auto_merge_enabled' || type === 'github.pull_request.closed' || type === 'github.pull_request.converted_to_draft' || type === 'github.pull_request.demilestoned' || type === 'github.pull_request.dequeued' || type === 'github.pull_request.edited' || type === 'github.pull_request.enqueued' || type === 'github.pull_request.labeled' || type === 'github.pull_request.locked' || type === 'github.pull_request.milestoned' || type === 'github.pull_request.opened' || type === 'github.pull_request.ready_for_review' || type === 'github.pull_request.reopened' || type === 'github.pull_request.resynced' || type === 'github.pull_request.review_request_removed' || type === 'github.pull_request.review_requested' || type === 'github.pull_request.synchronize' || type === 'github.pull_request.unassigned' || type === 'github.pull_request.unlabeled' || type === 'github.pull_request.unlocked') {
83
91
  const decoded = decodeGithubPullRequestPayload(payload);
84
92
 
@@ -92,6 +100,14 @@ function decodeGithubPullRequestFact(value: unknown): GithubPullRequestFact | un
92
100
  return undefined;
93
101
  }
94
102
 
103
+ function isFactID(text: string): text is FactID {
104
+ return text.length > 0;
105
+ }
106
+
107
+ function isFactEntityID(text: string): text is EntityID {
108
+ return text.length > 0;
109
+ }
110
+
95
111
  function decodeGithubPullRequestPayload(value: unknown): GithubPullRequestPayload | undefined {
96
112
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
97
113
  return undefined;
@@ -45,6 +45,10 @@ export function updateGithubRefModel(model: GithubRefModel, message: GithubRefMe
45
45
  return message.kind === 'reset' ? initGithubRefModel() : { ...model, input: message.input };
46
46
  }
47
47
 
48
+ declare const factTag: unique symbol;
49
+
50
+ export type FactID = string & { readonly [factTag]: 'FactID' };
51
+
48
52
  export interface GithubRefPushedPayload {
49
53
  ref: string;
50
54
  repository: GithubRefPushedPayloadRepository;
@@ -55,7 +59,7 @@ export interface GithubRefPushedPayloadRepository {
55
59
  }
56
60
 
57
61
  export type GithubRefFact =
58
- | { id: string; type: 'github.ref.pushed'; entity: string; at: string; payload: GithubRefPushedPayload };
62
+ | { id: FactID; type: 'github.ref.pushed'; entity: EntityID; at: string; payload: GithubRefPushedPayload };
59
63
 
60
64
  export function parseGithubRefFact(factJSON: string): GithubRefFact | undefined {
61
65
  const parsed: unknown = JSON.parse(factJSON);
@@ -78,6 +82,10 @@ function decodeGithubRefFact(value: unknown): GithubRefFact | undefined {
78
82
  return undefined;
79
83
  }
80
84
 
85
+ if (!isFactID(id) || !isFactEntityID(entity)) {
86
+ return undefined;
87
+ }
88
+
81
89
  if (type === 'github.ref.pushed') {
82
90
  const decoded = decodeGithubRefPushedPayload(payload);
83
91
 
@@ -91,6 +99,14 @@ function decodeGithubRefFact(value: unknown): GithubRefFact | undefined {
91
99
  return undefined;
92
100
  }
93
101
 
102
+ function isFactID(text: string): text is FactID {
103
+ return text.length > 0;
104
+ }
105
+
106
+ function isFactEntityID(text: string): text is EntityID {
107
+ return text.length > 0;
108
+ }
109
+
94
110
  function decodeGithubRefPushedPayload(value: unknown): GithubRefPushedPayload | undefined {
95
111
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
96
112
  return undefined;
@@ -45,6 +45,10 @@ export function updateGithubReleaseModel(model: GithubReleaseModel, message: Git
45
45
  return message.kind === 'reset' ? initGithubReleaseModel() : { ...model, input: message.input };
46
46
  }
47
47
 
48
+ declare const factTag: unique symbol;
49
+
50
+ export type FactID = string & { readonly [factTag]: 'FactID' };
51
+
48
52
  export interface GithubReleasePayload {
49
53
  action: string;
50
54
  release: GithubReleasePayloadRelease;
@@ -55,7 +59,7 @@ export interface GithubReleasePayloadRelease {
55
59
  }
56
60
 
57
61
  export type GithubReleaseFact =
58
- | { id: string; type: 'github.release.created' | 'github.release.deleted' | 'github.release.edited' | 'github.release.prereleased' | 'github.release.published' | 'github.release.released' | 'github.release.resynced' | 'github.release.unpublished'; entity: string; at: string; payload: GithubReleasePayload };
62
+ | { id: FactID; type: 'github.release.created' | 'github.release.deleted' | 'github.release.edited' | 'github.release.prereleased' | 'github.release.published' | 'github.release.released' | 'github.release.resynced' | 'github.release.unpublished'; entity: EntityID; at: string; payload: GithubReleasePayload };
59
63
 
60
64
  export function parseGithubReleaseFact(factJSON: string): GithubReleaseFact | undefined {
61
65
  const parsed: unknown = JSON.parse(factJSON);
@@ -78,6 +82,10 @@ function decodeGithubReleaseFact(value: unknown): GithubReleaseFact | undefined
78
82
  return undefined;
79
83
  }
80
84
 
85
+ if (!isFactID(id) || !isFactEntityID(entity)) {
86
+ return undefined;
87
+ }
88
+
81
89
  if (type === 'github.release.created' || type === 'github.release.deleted' || type === 'github.release.edited' || type === 'github.release.prereleased' || type === 'github.release.published' || type === 'github.release.released' || type === 'github.release.resynced' || type === 'github.release.unpublished') {
82
90
  const decoded = decodeGithubReleasePayload(payload);
83
91
 
@@ -91,6 +99,14 @@ function decodeGithubReleaseFact(value: unknown): GithubReleaseFact | undefined
91
99
  return undefined;
92
100
  }
93
101
 
102
+ function isFactID(text: string): text is FactID {
103
+ return text.length > 0;
104
+ }
105
+
106
+ function isFactEntityID(text: string): text is EntityID {
107
+ return text.length > 0;
108
+ }
109
+
94
110
  function decodeGithubReleasePayload(value: unknown): GithubReleasePayload | undefined {
95
111
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
96
112
  return undefined;
@@ -29,4 +29,5 @@ SELECT
29
29
  "url" AS "url",
30
30
  NULL AS "actor",
31
31
  "updated_at" AS "updated_at"
32
- FROM "github_release";
32
+ FROM "github_release"
33
+ WHERE "deleted_at" IS NULL;
@@ -1,7 +1,8 @@
1
1
  import { defineConsumer, defineTable } from '@pepys/kit';
2
2
  import { releaseFamily } from './families/release-family.ts';
3
3
 
4
- // One row per release, never deleted: a release GitHub deleted keeps its row with `deleted_at`.
4
+ // One row per release, never deleted: a release GitHub deleted keeps its row with `deleted_at`,
5
+ // which leaves it out of the `entity` view.
5
6
  export const githubReleaseConsumer = defineConsumer({
6
7
  name: 'github_release',
7
8
  entry: new URL('consumers/github-release.ts', import.meta.url),
@@ -36,6 +37,7 @@ export const githubReleaseConsumer = defineConsumer({
36
37
  url: 'url',
37
38
  actor: null,
38
39
  updatedAt: 'updated_at',
40
+ deletedAt: 'deleted_at',
39
41
  },
40
42
  ],
41
43
  reads: ['github_release'],
@@ -1,16 +1,20 @@
1
1
  import { parseFactEnvelope } from '@pepys/kit';
2
2
  import type { PendingFact, StreamName } from '@pepys/kit';
3
3
  import { toTagged } from '@zgeoff/std';
4
+ import { match } from 'ts-pattern';
4
5
  import { buildBodyHash } from '../utils/build-body-hash.ts';
5
6
  import { githubEventRules } from './github-event-rules.ts';
6
7
 
7
8
  // `delivery`: a webhook body, from the receive path or the recovery pull. `observation`: an object
8
- // that the resync pull read from the API, in the shape of a webhook body.
9
- export type GithubFactOrigin = 'delivery' | 'observation';
9
+ // that the resync pull read from the API, in the shape of a webhook body, for a mark whose last
10
+ // stored body has the digest `lastBodyDigest`.
11
+ export type GithubFactOrigin =
12
+ | { kind: 'delivery' }
13
+ | { kind: 'observation'; lastBodyDigest: string | undefined };
10
14
 
11
15
  // One body of one webhook event as a fact, with an ID from the hash of the canonical body: GitHub
12
- // signs the body and not the delivery ID header, so only the body decides it. One delivery has one
13
- // ID on the receive path and on the recovery pull, and two reads of one state are one observation.
16
+ // signs the body and not the delivery ID header, so only the body decides it. An observation's ID
17
+ // also hashes the last stored body, so a read after a newer tied body is a fact the host applies.
14
18
  export function buildGithubFact(
15
19
  event: string,
16
20
  body: unknown,
@@ -29,10 +33,13 @@ export function buildGithubFact(
29
33
  }
30
34
 
31
35
  const envelope = parseFactEnvelope({
32
- id:
33
- origin === 'delivery'
34
- ? `webhook:github:h-${buildBodyHash(body)}`
35
- : `observation:${buildBodyHash(body)}`,
36
+ id: match(origin)
37
+ .with({ kind: 'delivery' }, () => `webhook:github:h-${buildBodyHash(body)}`)
38
+ .with(
39
+ { kind: 'observation' },
40
+ (observation) => `observation:${buildObservationHash(body, observation.lastBodyDigest)}`,
41
+ )
42
+ .exhaustive(),
36
43
  type: fields.type,
37
44
  v: 1,
38
45
  api: rule.family.api,
@@ -53,3 +60,9 @@ export function buildGithubFact(
53
60
  ...(fields.locator === undefined ? {} : { locator: fields.locator }),
54
61
  };
55
62
  }
63
+
64
+ function buildObservationHash(body: unknown, lastBodyDigest: string | undefined): string {
65
+ return lastBodyDigest === undefined
66
+ ? buildBodyHash(body)
67
+ : buildBodyHash({ observed: body, lastBodyDigest });
68
+ }
@@ -1,6 +1,5 @@
1
1
  import { buildAppJWT } from './build-app-jwt.ts';
2
2
 
3
- // The App's JWT from the pull's env, or undefined when the private key cannot sign.
4
3
  export function findAppJWT(
5
4
  env: Readonly<Record<string, string>>,
6
5
  nowMs: number,
@@ -1,8 +1,10 @@
1
1
  import { githubAPIURL } from './github-api-url.ts';
2
2
 
3
- // One answer of the GitHub REST API. `next` is the URL of the next page, from the `link` header.
3
+ // One answer of the GitHub REST API. `text` is the body as sent, for a caller that must read a
4
+ // number above 2^53 exactly. `next` is the URL of the next page, from the `link` header.
4
5
  export interface GithubAnswer {
5
6
  status: number;
7
+ text: string;
6
8
  body: unknown;
7
9
  next: string | undefined;
8
10
  }
@@ -19,13 +21,14 @@ export async function requestGithubAPI(
19
21
  const response = await sendRequest(url, token, method);
20
22
 
21
23
  if (response === undefined) {
22
- return { status: 0, body: undefined, next: undefined };
24
+ return { status: 0, text: '', body: undefined, next: undefined };
23
25
  }
24
26
 
25
27
  const text = await response.text();
26
28
 
27
29
  return {
28
30
  status: response.status,
31
+ text,
29
32
  body: parseBody(text),
30
33
  next: findNextPage(response.headers.get('link')),
31
34
  };
@@ -17,7 +17,7 @@ export function readGithubDelivery(delivery: Delivery): Received {
17
17
  return { kind: 'refused', status: 400, reason: 'the body is not JSON' };
18
18
  }
19
19
 
20
- const fact = buildGithubFact(event, body.value, 'delivery');
20
+ const fact = buildGithubFact(event, body.value, { kind: 'delivery' });
21
21
 
22
22
  if (fact === 'ignored') {
23
23
  return { kind: 'ignored' };
@@ -1,3 +1,4 @@
1
+ import { planAppends } from '@pepys/ingest';
1
2
  import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
2
3
  import { z } from 'zod';
3
4
  import { buildGithubFact } from '../build-github-fact.ts';
@@ -18,13 +19,13 @@ export async function pullFailedDeliveries(
18
19
  cursor: string | null,
19
20
  context: PullContext,
20
21
  ): Promise<Pulled> {
21
- const appJWT = findAppJWT(context.env, Date.now());
22
+ const appJWT = findAppJWT(context.env, context.now());
22
23
 
23
24
  if (appJWT === undefined) {
24
25
  return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
25
26
  }
26
27
 
27
- const seenID = cursor === null ? 0 : Number(cursor);
28
+ const seenID = cursor === null ? 0n : BigInt(cursor);
28
29
 
29
30
  const listed = await readDeliveriesAfter(seenID, appJWT);
30
31
 
@@ -43,20 +44,38 @@ export async function pullFailedDeliveries(
43
44
  return pickPullFailure(answer.status);
44
45
  }
45
46
 
46
- const fact = buildGithubFact(detail.data.event, detail.data.request.payload, 'delivery');
47
+ const fact = buildGithubFact(detail.data.event, detail.data.request.payload, {
48
+ kind: 'delivery',
49
+ });
47
50
 
48
- if (fact !== 'ignored' && fact !== 'invalid') {
49
- facts.push(fact);
51
+ if (fact === 'ignored' || fact === 'invalid') {
52
+ continue;
50
53
  }
54
+
55
+ // The receive path answers 413 to a body this large and stores nothing; a batch that held it
56
+ // would be refused whole, with every other delivery and the cursor.
57
+ if (!planAppends([fact.fact]).ok) {
58
+ console.error(`the GitHub delivery ${id} is larger than one append of the log takes`);
59
+ continue;
60
+ }
61
+
62
+ facts.push(fact);
51
63
  }
52
64
 
53
- const highestID = Math.max(seenID, ...listed.items.map((item) => item.id));
65
+ const highestID = listed.items.reduce(
66
+ (highest, item) => (item.id > highest ? item.id : highest),
67
+ seenID,
68
+ );
54
69
 
55
70
  return { kind: 'batch', batch: { facts }, cursor: `${highestID}`, isDone: true };
56
71
  }
57
72
 
73
+ // A delivery ID is above 2^53, so it is read from its digits in the body, never from a number.
58
74
  const deliveryItemSchema = z.looseObject({
59
- id: z.number().int(),
75
+ id: z
76
+ .string()
77
+ .regex(/^[1-9]\d*$/u)
78
+ .transform(BigInt),
60
79
  status_code: z.number().int(),
61
80
  event: z.string(),
62
81
  });
@@ -68,14 +87,14 @@ type DeliveryItem = z.infer<typeof deliveryItemSchema>;
68
87
  type DeliveryList = { kind: 'listed'; items: DeliveryItem[] } | { kind: 'failed'; status: number };
69
88
 
70
89
  // GitHub lists the newest delivery first, so the pages stop at the first one already seen.
71
- async function readDeliveriesAfter(seenID: number, appJWT: string): Promise<DeliveryList> {
90
+ async function readDeliveriesAfter(seenID: bigint, appJWT: string): Promise<DeliveryList> {
72
91
  const items: DeliveryItem[] = [];
73
92
  let url: string | undefined = '/app/hook/deliveries?per_page=100';
74
93
 
75
94
  while (url !== undefined) {
76
95
  const answer: GithubAnswer = await requestGithubAPI(url, appJWT);
77
96
 
78
- const page = deliveryListSchema.safeParse(answer.body);
97
+ const page = deliveryListSchema.safeParse(parseDeliveryList(answer.text));
79
98
 
80
99
  if (answer.status !== 200 || !page.success) {
81
100
  return { kind: 'failed', status: answer.status };
@@ -91,10 +110,29 @@ async function readDeliveriesAfter(seenID: number, appJWT: string): Promise<Deli
91
110
  return { kind: 'listed', items };
92
111
  }
93
112
 
113
+ interface ReviverContext {
114
+ source?: string;
115
+ }
116
+
117
+ // `JSON.parse` rounds a number above 2^53, so each `id` keeps the source text the reviver is given.
118
+ function parseDeliveryList(text: string): unknown {
119
+ try {
120
+ const list: unknown = JSON.parse(
121
+ text,
122
+ (key: string, value: unknown, context?: ReviverContext): unknown =>
123
+ key === 'id' && typeof value === 'number' ? context?.source : value,
124
+ );
125
+
126
+ return list;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
94
132
  // oldest first, so the facts reach the log in the order GitHub sent them
95
- function collectFailedIDs(items: readonly DeliveryItem[]): number[] {
133
+ function collectFailedIDs(items: readonly DeliveryItem[]): bigint[] {
96
134
  return items
97
135
  .filter((item) => item.status_code < 200 || item.status_code >= 300)
98
136
  .map((item) => item.id)
99
- .toSorted((left, right) => left - right);
137
+ .toSorted((left, right) => Number(left - right));
100
138
  }
@@ -1,11 +1,13 @@
1
- import type { ResyncMark } from '@pepys/kit';
1
+ import type { EntityID, ResyncMark } from '@pepys/kit';
2
2
 
3
- // One marked object that the resync pull reads again: its repository, `owner/name`, and its API path.
3
+ // One marked object that the resync pull reads again: its repository, `owner/name`, its API path,
4
+ // and the digest of the last body ingest stored for it.
4
5
  export interface ResyncTarget {
5
6
  kind: 'pull_request' | 'release';
6
- entity: string;
7
+ entity: EntityID;
7
8
  repository: string;
8
9
  path: string;
10
+ lastBodyDigest: string | undefined;
9
11
  }
10
12
 
11
13
  const locatorPatterns = [
@@ -24,7 +26,15 @@ export function collectResyncTargets(marks: readonly ResyncMark[]): ResyncTarget
24
26
  const kind = locatorPattern.kind;
25
27
 
26
28
  if (repository !== undefined && mark.entity.startsWith(`github:${kind}:`)) {
27
- return [{ kind, entity: mark.entity, repository, path: locator }];
29
+ return [
30
+ {
31
+ kind,
32
+ entity: mark.entity,
33
+ repository,
34
+ path: locator,
35
+ lastBodyDigest: mark.lastBodyDigest,
36
+ },
37
+ ];
28
38
  }
29
39
  }
30
40
 
@@ -1,6 +1,7 @@
1
1
  import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
2
2
  import { z } from 'zod';
3
3
  import { buildGithubFact } from '../build-github-fact.ts';
4
+ import type { GithubFactOrigin } from '../build-github-fact.ts';
4
5
  import { findAppJWT } from '../github-app/find-app-jwt.ts';
5
6
  import { pickPullFailure } from '../github-app/pick-pull-failure.ts';
6
7
  import { readInstallationToken } from '../github-app/read-installation-token.ts';
@@ -22,7 +23,7 @@ export async function pullMarkedObjects(
22
23
  return { kind: 'batch', batch: { facts: [] }, cursor: 'resynced', isDone: true };
23
24
  }
24
25
 
25
- const appJWT = findAppJWT(context.env, Date.now());
26
+ const appJWT = findAppJWT(context.env, context.now());
26
27
 
27
28
  if (appJWT === undefined) {
28
29
  return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
@@ -97,13 +98,11 @@ async function readObject(target: ResyncTarget, token: string): Promise<ReadObje
97
98
  return { kind: 'failed', status: answer.status };
98
99
  }
99
100
 
101
+ const origin: GithubFactOrigin = { kind: 'observation', lastBodyDigest: target.lastBodyDigest };
102
+
100
103
  if (target.kind === 'pull_request') {
101
104
  return toReadObject(
102
- buildGithubFact(
103
- 'pull_request',
104
- { action: 'resynced', pull_request: answer.body },
105
- 'observation',
106
- ),
105
+ buildGithubFact('pull_request', { action: 'resynced', pull_request: answer.body }, origin),
107
106
  );
108
107
  }
109
108
 
@@ -122,7 +121,7 @@ async function readObject(target: ResyncTarget, token: string): Promise<ReadObje
122
121
  repository: { id: parsed.data.id, full_name: parsed.data.full_name },
123
122
  };
124
123
 
125
- return toReadObject(buildGithubFact('release', body, 'observation'));
124
+ return toReadObject(buildGithubFact('release', body, origin));
126
125
  }
127
126
 
128
127
  function toReadObject(fact: PendingFact | 'ignored' | 'invalid'): ReadObject {
@@ -1,4 +1,4 @@
1
- import { defineQuery } from '@pepys/kit';
1
+ import { defineQuery, entityIDSchema, isEntityID } from '@pepys/kit';
2
2
  import { z } from 'zod';
3
3
  import { deploySignals } from '../utils/deploy-signals.ts';
4
4
  import { githubDeliveryConsumers } from './github-delivery-consumers.ts';
@@ -9,12 +9,12 @@ import {
9
9
 
10
10
  const pullRequestsSchema = z.array(z.string()).max(200);
11
11
  const nameSchema = z.string().min(1);
12
- const releaseSchema = z.object({ id: z.string(), tag: z.string(), at: z.string() });
12
+ const releaseSchema = z.object({ id: entityIDSchema, tag: z.string(), at: z.string() });
13
13
  const signalSchema = z.enum(deploySignals);
14
- const deploySchema = z.object({ id: z.string(), at: z.string(), signal: signalSchema });
14
+ const deploySchema = z.object({ id: entityIDSchema, at: z.string(), signal: signalSchema });
15
15
 
16
16
  const deliverySchema = z.object({
17
- pullRequest: z.string(),
17
+ pullRequest: entityIDSchema,
18
18
  mergedAt: z.string().nullable(),
19
19
  release: releaseSchema.nullable(),
20
20
  deploy: deploySchema.nullable(),
@@ -32,7 +32,8 @@ export const pullRequestDeliveryQuery = defineQuery({
32
32
  output: z.array(deliverySchema),
33
33
  reads: githubDeliveryConsumers,
34
34
  handler: async (call) => {
35
- const [first, ...rest] = call.input.pullRequests;
35
+ // text that is no entity ID names no stored pull request, so it has no row either way
36
+ const [first, ...rest] = call.input.pullRequests.filter((id) => isEntityID(id));
36
37
 
37
38
  if (first === undefined) {
38
39
  return [];
@@ -1,3 +1,5 @@
1
+ import { entityIDSchema } from '@pepys/kit';
2
+ import type { EntityID } from '@pepys/kit';
1
3
  import { sql } from 'kysely';
2
4
  import type { RawBuilder } from 'kysely';
3
5
  import { z } from 'zod';
@@ -6,7 +8,7 @@ import type { DeploySignal } from '../utils/deploy-signals.ts';
6
8
 
7
9
  interface DeliveryOptions {
8
10
  // at least one
9
- pullRequests: readonly [string, ...string[]];
11
+ pullRequests: readonly [EntityID, ...EntityID[]];
10
12
  environment: string;
11
13
 
12
14
  // Absent: a deploy of any target, and of none, counts. Given: only a deploy of that target.
@@ -14,8 +16,8 @@ interface DeliveryOptions {
14
16
  }
15
17
 
16
18
  // The statement that ties pull requests to releases and deploys. The rules it holds are in the
17
- // "Delivery query" section of the plug-ins design doc. Run it with `.execute(db)`, and turn its rows
18
- // into deliveries with the parse function below.
19
+ // "The delivery query" section of docs/architecture/plug-ins/github.md. Run it with `.execute(db)`,
20
+ // and turn its rows into deliveries with the parse function below.
19
21
  export function selectPullRequestDelivery(options: DeliveryOptions): RawBuilder<unknown> {
20
22
  return sql`select
21
23
  pull_request.id as "pullRequest",
@@ -115,22 +117,22 @@ function selectFirstDeployID(options: DeliveryOptions): RawBuilder<unknown> {
115
117
  }
116
118
 
117
119
  export interface PullRequestDelivery {
118
- pullRequest: string;
120
+ pullRequest: EntityID;
119
121
  mergedAt: string | null;
120
- release: { id: string; tag: string; at: string } | null;
121
- deploy: { id: string; at: string; signal: DeploySignal } | null;
122
+ release: { id: EntityID; tag: string; at: string } | null;
123
+ deploy: { id: EntityID; at: string; signal: DeploySignal } | null;
122
124
  }
123
125
 
124
126
  const textSchema = z.string().nullable();
125
127
 
126
128
  // One row per pull request, as the statement selects it.
127
129
  const deliveryRowSchema = z.object({
128
- pullRequest: z.string(),
130
+ pullRequest: entityIDSchema,
129
131
  mergedAt: textSchema,
130
- releaseID: textSchema,
132
+ releaseID: entityIDSchema.nullable(),
131
133
  releaseTag: textSchema,
132
134
  releaseAt: textSchema,
133
- deployID: textSchema,
135
+ deployID: entityIDSchema.nullable(),
134
136
  deployAt: textSchema,
135
137
  deploySignal: z.enum(deploySignals).nullable(),
136
138
  });