@pepys/source-github 0.1.0 → 0.2.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
@@ -27,6 +27,9 @@ bun add @pepys/source-github
27
27
 
28
28
  - `githubPlugin`: the plug-in, to add to a deployment.
29
29
  - `githubPullRequestConsumer`: the consumer of pull request facts, with its SQL schema.
30
+ - `githubDelivery`: what a query of another plug-in needs to read when each pull request merged,
31
+ released and deployed. Pass `consumers` as the query's `reads`, run `select(input).execute(db)`,
32
+ and give the result's `rows` to `parse`.
30
33
 
31
34
  ## Documentation
32
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/source-github",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,17 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@pepys/api": "0.1.0",
23
- "@pepys/ingest": "0.1.0",
24
- "@pepys/kit": "0.1.0",
22
+ "@pepys/api": "0.2.0",
23
+ "@pepys/ingest": "0.2.0",
24
+ "@pepys/kit": "0.2.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.0",
32
+ "@pepys/testing": "0.2.0",
32
33
  "msw": "3.0.3",
33
34
  "tiny-invariant": "1.3.3"
34
35
  }
@@ -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',
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { githubPlugin } from './github-plugin.ts';
2
2
  export { githubPullRequestConsumer } from './github-pull-request-consumer.ts';
3
3
  export { githubPullRequestSchemaPath } from './github-pull-request-schema-path.ts';
4
+ export { githubDelivery } from './queries/github-delivery.ts';
4
5
  export { githubDeliveryConsumers } from './queries/github-delivery-consumers.ts';
5
6
 
6
7
  export {
@@ -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';
@@ -24,7 +25,7 @@ export async function pullFailedDeliveries(
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
1
  import type { 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
7
  entity: string;
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';
@@ -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 {
@@ -0,0 +1,13 @@
1
+ import { githubDeliveryConsumers } from './github-delivery-consumers.ts';
2
+ import {
3
+ parsePullRequestDeliveries,
4
+ selectPullRequestDelivery,
5
+ } from './select-pull-request-delivery.ts';
6
+
7
+ // What a query of another plug-in needs to read deliveries: `consumers` for its `reads`, `select`
8
+ // for the statement to run on its `db`, and `parse` for the rows of the result.
9
+ export const githubDelivery = {
10
+ consumers: githubDeliveryConsumers,
11
+ select: selectPullRequestDelivery,
12
+ parse: parsePullRequestDeliveries,
13
+ } as const;