@pepys/source-github 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +32 -2
  2. package/package.json +33 -4
  3. package/src/api/serve-api.ts +8 -0
  4. package/src/consumers/github-deploy.ts +199 -0
  5. package/src/consumers/github-pull-request.ts +275 -0
  6. package/src/consumers/github-ref.ts +88 -0
  7. package/src/consumers/github-release.ts +125 -0
  8. package/src/families/build-family-types.ts +16 -0
  9. package/src/families/deployment-family.ts +15 -0
  10. package/src/families/issue-family.ts +38 -0
  11. package/src/families/pull-request-family.ts +42 -0
  12. package/src/families/pull-request-review-family.ts +18 -0
  13. package/src/families/ref-family.ts +14 -0
  14. package/src/families/release-family.ts +28 -0
  15. package/src/families/workflow-run-family.ts +18 -0
  16. package/src/generated/github-deploy-boundary.ts +308 -0
  17. package/src/generated/github-deploy.sql +24 -0
  18. package/src/generated/github-pull-request-boundary.ts +286 -0
  19. package/src/generated/github-pull-request.sql +41 -0
  20. package/src/generated/github-ref-boundary.ts +149 -0
  21. package/src/generated/github-ref.sql +8 -0
  22. package/src/generated/github-release-boundary.ts +251 -0
  23. package/src/generated/github-release.sql +32 -0
  24. package/src/github-deploy-consumer.ts +40 -0
  25. package/src/github-plugin.ts +15 -0
  26. package/src/github-pull-request-consumer.ts +56 -0
  27. package/src/github-pull-request-schema-path.ts +6 -0
  28. package/src/github-ref-consumer.ts +23 -0
  29. package/src/github-release-consumer.ts +43 -0
  30. package/src/github-settings.ts +15 -0
  31. package/src/github-source.ts +56 -0
  32. package/src/index.ts +13 -0
  33. package/src/ingest/build-github-fact.ts +55 -0
  34. package/src/ingest/github-app/build-app-jwt.ts +20 -0
  35. package/src/ingest/github-app/find-app-jwt.ts +17 -0
  36. package/src/ingest/github-app/github-api-url.ts +1 -0
  37. package/src/ingest/github-app/pick-pull-failure.ts +11 -0
  38. package/src/ingest/github-app/read-installation-token.ts +43 -0
  39. package/src/ingest/github-app/request-github-api.ts +75 -0
  40. package/src/ingest/github-event-rules.ts +324 -0
  41. package/src/ingest/read-github-delivery.ts +41 -0
  42. package/src/ingest/recovery/pull-failed-deliveries.ts +100 -0
  43. package/src/ingest/resync/collect-resync-targets.ts +33 -0
  44. package/src/ingest/resync/pull-marked-objects.ts +130 -0
  45. package/src/ingest/serve-ingest.ts +8 -0
  46. package/src/queries/github-delivery-consumers.ts +13 -0
  47. package/src/queries/pull-request-delivery-query.ts +49 -0
  48. package/src/queries/select-pull-request-delivery.ts +155 -0
  49. package/src/utils/build-body-hash.ts +34 -0
  50. package/src/utils/deploy-signals.ts +5 -0
  51. package/src/utils/find-field.ts +19 -0
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@pepys/source-github</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@pepys/source-github"><img src="https://img.shields.io/npm/v/@pepys/source-github" alt="npm version"></a>
6
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/README.md">Documentation</a> •
11
+ <a href="../../docs/design/v1-plug-ins.md">The plug-ins of v1</a>
12
+ </p>
13
+ </div>
14
+
15
+ The pepys GitHub plug-in, for Bun.
16
+
17
+ The plug-in receives GitHub's webhook deliveries as facts, and builds pull request rows from them
18
+ with its consumer.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ bun add @pepys/source-github
24
+ ```
25
+
26
+ ## Features
27
+
28
+ - `githubPlugin`: the plug-in, to add to a deployment.
29
+ - `githubPullRequestConsumer`: the consumer of pull request facts, with its SQL schema.
30
+
31
+ ## Documentation
32
+
33
+ The [pepys docs](../../docs/README.md) hold the design of the plug-ins.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@pepys/source-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The pepys GitHub plug-in: pull requests, releases, commits and deploys",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zgeoff/pepys.git",
9
+ "directory": "packages/source-github"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/test-utils"
15
+ ],
16
+ "type": "module",
17
+ "exports": "./src/index.ts",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@pepys/api": "0.1.0",
23
+ "@pepys/ingest": "0.1.0",
24
+ "@pepys/kit": "0.1.0",
25
+ "@zgeoff/std": "0.4.3",
26
+ "kysely": "0.29.8",
27
+ "zod": "4.6.7"
28
+ },
29
+ "devDependencies": {
30
+ "@msw/data": "1.1.8",
31
+ "@pepys/testing": "0.1.0",
32
+ "msw": "3.0.3",
33
+ "tiny-invariant": "1.3.3"
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ import { serveAPI } from '@pepys/api';
2
+ import { defineDeployment } from '@pepys/kit';
3
+ import { githubPlugin } from '../github-plugin.ts';
4
+
5
+ serveAPI({
6
+ deployment: defineDeployment({ name: 'github', plugins: [githubPlugin] }),
7
+ environment: process.env,
8
+ });
@@ -0,0 +1,199 @@
1
+ import {
2
+ initGithubDeployModel,
3
+ readGithubDeployRow,
4
+ settings,
5
+ toGithubDeployID,
6
+ updateGithubDeployModel,
7
+ } from '../generated/github-deploy-boundary.ts';
8
+ import type {
9
+ EntityID,
10
+ GithubDeployMessage,
11
+ GithubDeployModel,
12
+ GithubDeployOperation,
13
+ GithubDeployRow,
14
+ } from '../generated/github-deploy-boundary.ts';
15
+ import type { DeploySignal } from '../utils/deploy-signals.ts';
16
+ import { findField } from '../utils/find-field.ts';
17
+
18
+ export type {
19
+ GithubDeployMessage,
20
+ GithubDeployModel,
21
+ } from '../generated/github-deploy-boundary.ts';
22
+
23
+ // scriptc takes the model and message types through a re-export, but not the two functions
24
+ export function init(): GithubDeployModel {
25
+ return initGithubDeployModel();
26
+ }
27
+
28
+ export function update(model: GithubDeployModel, message: GithubDeployMessage): GithubDeployModel {
29
+ return updateGithubDeployModel(model, message);
30
+ }
31
+
32
+ export function applyFact(factJSON: string): string {
33
+ return JSON.stringify(mapFact(factJSON));
34
+ }
35
+
36
+ interface DeployReport {
37
+ repositoryID: number;
38
+ environment: string;
39
+ target: string | null;
40
+ sha: string;
41
+ signal: DeploySignal;
42
+ at: string;
43
+ }
44
+
45
+ // The row says "this signal reported this commit", so it keeps the earliest time: a second report
46
+ // of one commit, in any order, changes nothing.
47
+ function mapFact(factJSON: string): GithubDeployOperation[] {
48
+ const fact: unknown = JSON.parse(factJSON);
49
+ const report = pickDeployReport(fact);
50
+
51
+ if (report === undefined) {
52
+ return [];
53
+ }
54
+
55
+ const target = report.target === null ? '' : `:${report.target}`;
56
+
57
+ const id = toGithubDeployID(
58
+ `${report.repositoryID}:${report.environment}:${report.signal}:${report.sha}${target}`,
59
+ );
60
+
61
+ const stored = readGithubDeployRow(id);
62
+
63
+ if (stored !== undefined && stored.deployed_at <= report.at) {
64
+ return [];
65
+ }
66
+
67
+ const row: GithubDeployRow = {
68
+ id,
69
+ repository_id: toEntityID(`github:repo:${report.repositoryID}`),
70
+ environment: report.environment,
71
+ target: report.target,
72
+ sha: report.sha,
73
+ signal: report.signal,
74
+ deployed_at: report.at,
75
+ };
76
+
77
+ return [{ upsert: { github_deploy: row } }];
78
+ }
79
+
80
+ function pickDeployReport(fact: unknown): DeployReport | undefined {
81
+ const type = findField(fact, 'type');
82
+ const at = findField(fact, 'at');
83
+ const payload = findField(fact, 'payload');
84
+
85
+ if (typeof at !== 'string') {
86
+ return undefined;
87
+ }
88
+
89
+ if (type === 'github.ref.pushed') {
90
+ return pickTagReport(payload, at);
91
+ }
92
+
93
+ if (type === 'github.deployment.status_created') {
94
+ return pickStatusReport(payload, at);
95
+ }
96
+
97
+ return undefined;
98
+ }
99
+
100
+ // A push that moves a tag of the deploy tag pattern to a commit. A push that deletes the tag names
101
+ // no commit.
102
+ function pickTagReport(payload: unknown, at: string): DeployReport | undefined {
103
+ const ref = findField(payload, 'ref');
104
+ const sha = findField(payload, 'head_commit', 'id');
105
+ const repositoryID = findField(payload, 'repository', 'id');
106
+
107
+ if (typeof ref !== 'string' || !ref.startsWith('refs/tags/') || typeof sha !== 'string') {
108
+ return undefined;
109
+ }
110
+
111
+ if (findField(payload, 'deleted') === true || typeof repositoryID !== 'number') {
112
+ return undefined;
113
+ }
114
+
115
+ const tag = findTagDeploy(ref.slice('refs/tags/'.length), settings.deployTagPattern);
116
+
117
+ if (tag === undefined) {
118
+ return undefined;
119
+ }
120
+
121
+ return { repositoryID, ...tag, sha, signal: 'tag', at };
122
+ }
123
+
124
+ // The tag is the pattern with an environment in place of `<environment>`, and may end with
125
+ // `/<target>`. Neither part is empty or holds a `/`.
126
+ function findTagDeploy(
127
+ tag: string,
128
+ pattern: string,
129
+ ): { environment: string; target: string | null } | undefined {
130
+ const marker = '<environment>';
131
+ const markerAt = pattern.indexOf(marker);
132
+ const before = pattern.slice(0, markerAt);
133
+ const after = pattern.slice(markerAt + marker.length);
134
+
135
+ if (markerAt === -1 || !tag.startsWith(before)) {
136
+ return undefined;
137
+ }
138
+
139
+ const rest = tag.slice(before.length);
140
+ const environmentEnd = after === '' ? getPartEnd(rest) : rest.indexOf(after);
141
+
142
+ if (environmentEnd <= 0) {
143
+ return undefined;
144
+ }
145
+
146
+ const environment = rest.slice(0, environmentEnd);
147
+ const tail = rest.slice(environmentEnd + after.length);
148
+
149
+ if (environment.includes('/')) {
150
+ return undefined;
151
+ }
152
+
153
+ if (tail === '') {
154
+ return { environment, target: null };
155
+ }
156
+
157
+ const target = tail.slice(1);
158
+
159
+ if (!tail.startsWith('/') || target === '' || target.includes('/')) {
160
+ return undefined;
161
+ }
162
+
163
+ return { environment, target };
164
+ }
165
+
166
+ function getPartEnd(text: string): number {
167
+ const slash = text.indexOf('/');
168
+
169
+ return slash === -1 ? text.length : slash;
170
+ }
171
+
172
+ function pickStatusReport(payload: unknown, at: string): DeployReport | undefined {
173
+ const state = findField(payload, 'deployment_status', 'state');
174
+ const environment = findField(payload, 'deployment', 'environment');
175
+ const sha = findField(payload, 'deployment', 'sha');
176
+ const repositoryID = findField(payload, 'repository', 'id');
177
+
178
+ if (state !== 'success' || typeof environment !== 'string' || environment === '') {
179
+ return undefined;
180
+ }
181
+
182
+ if (typeof sha !== 'string' || typeof repositoryID !== 'number') {
183
+ return undefined;
184
+ }
185
+
186
+ return { repositoryID, environment, target: null, sha, signal: 'deployment_status', at };
187
+ }
188
+
189
+ function isEntityID(text: string): text is EntityID {
190
+ return text.length > 0;
191
+ }
192
+
193
+ function toEntityID(text: string): EntityID {
194
+ if (!isEntityID(text)) {
195
+ throw new Error('an entity ID is never empty');
196
+ }
197
+
198
+ return text;
199
+ }
@@ -0,0 +1,275 @@
1
+ import {
2
+ initGithubPullRequestModel,
3
+ readGithubPullRequestRow,
4
+ toGithubPullRequestID,
5
+ updateGithubPullRequestModel,
6
+ } from '../generated/github-pull-request-boundary.ts';
7
+ import type {
8
+ EntityID,
9
+ GithubPullRequestMessage,
10
+ GithubPullRequestModel,
11
+ GithubPullRequestOperation,
12
+ GithubPullRequestRow,
13
+ GithubReleaseBumpRow,
14
+ } from '../generated/github-pull-request-boundary.ts';
15
+ import { findField } from '../utils/find-field.ts';
16
+
17
+ export type {
18
+ GithubPullRequestMessage,
19
+ GithubPullRequestModel,
20
+ } from '../generated/github-pull-request-boundary.ts';
21
+
22
+ // scriptc takes the model and message types through a re-export, but not the two functions
23
+ export function init(): GithubPullRequestModel {
24
+ return initGithubPullRequestModel();
25
+ }
26
+
27
+ export function update(
28
+ model: GithubPullRequestModel,
29
+ message: GithubPullRequestMessage,
30
+ ): GithubPullRequestModel {
31
+ return updateGithubPullRequestModel(model, message);
32
+ }
33
+
34
+ export function applyFact(factJSON: string): string {
35
+ return JSON.stringify(mapFact(factJSON));
36
+ }
37
+
38
+ function mapFact(factJSON: string): GithubPullRequestOperation[] {
39
+ const row = readRow(factJSON);
40
+
41
+ if (row === undefined) {
42
+ return [];
43
+ }
44
+
45
+ const stored = readGithubPullRequestRow(row.id);
46
+
47
+ if (stored !== undefined && isStale(stored, row)) {
48
+ return [];
49
+ }
50
+
51
+ // The bump rows of the replaced body come from the stored payload, so a late fact of an older
52
+ // body cannot bring back a bump that a newer body dropped.
53
+ const given = collectReleaseBumps(row.id, row.payload);
54
+ const replaced = stored === undefined ? [] : collectReleaseBumps(row.id, stored.payload);
55
+
56
+ const givenKeys = new Set(given.map((bump) => JSON.stringify(bump)));
57
+ const replacedKeys = new Set(replaced.map((bump) => JSON.stringify(bump)));
58
+
59
+ const removed: GithubPullRequestOperation[] = replaced
60
+ .filter((bump) => !givenKeys.has(JSON.stringify(bump)))
61
+ .map((bump) => ({ delete: { github_release_bump: bump } }));
62
+
63
+ const added: GithubPullRequestOperation[] = given
64
+ .filter((bump) => !replacedKeys.has(JSON.stringify(bump)))
65
+ .map((bump) => ({ upsert: { github_release_bump: bump } }));
66
+
67
+ return [{ upsert: { github_pull_request: row } }, ...removed, ...added];
68
+ }
69
+
70
+ const releaseURLStart = 'https://github.com/';
71
+ const releaseURLEnds = [' ', '\n', '\r', '\t', ')', ']', '>', '"', "'", '`', '#', '?'];
72
+
73
+ // One row per `https://github.com/<owner>/<name>/releases/tag/<tag>` URL in the body of a pull
74
+ // request whose author is a bot, each once, with the tag percent-decoded.
75
+ function collectReleaseBumps(
76
+ pullRequestID: GithubReleaseBumpRow['pull_request_id'],
77
+ pullRequest: unknown,
78
+ ): GithubReleaseBumpRow[] {
79
+ const body = findField(pullRequest, 'body');
80
+
81
+ if (findField(pullRequest, 'user', 'type') !== 'Bot' || typeof body !== 'string') {
82
+ return [];
83
+ }
84
+
85
+ const bumps: GithubReleaseBumpRow[] = [];
86
+ const seen: string[] = [];
87
+
88
+ for (const part of body.split(releaseURLStart).slice(1)) {
89
+ const bump = parseReleaseURLPath(part);
90
+
91
+ if (bump === undefined || seen.includes(`${bump.repository}\n${bump.tag}`)) {
92
+ continue;
93
+ }
94
+
95
+ seen.push(`${bump.repository}\n${bump.tag}`);
96
+
97
+ bumps.push({
98
+ pull_request_id: pullRequestID,
99
+ release_repository: bump.repository,
100
+ release_tag: bump.tag,
101
+ });
102
+ }
103
+
104
+ return bumps;
105
+ }
106
+
107
+ // `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text.
108
+ function parseReleaseURLPath(text: string): { repository: string; tag: string } | undefined {
109
+ let end = text.length;
110
+
111
+ for (const mark of releaseURLEnds) {
112
+ const at = text.indexOf(mark);
113
+
114
+ if (at !== -1 && at < end) {
115
+ end = at;
116
+ }
117
+ }
118
+
119
+ const parts = text.slice(0, end).split('/');
120
+ const [owner, name, releases, tagWord, tag] = parts;
121
+
122
+ if (parts.length !== 5 || releases !== 'releases' || tagWord !== 'tag') {
123
+ return undefined;
124
+ }
125
+
126
+ if (owner === undefined || name === undefined || tag === undefined) {
127
+ return undefined;
128
+ }
129
+
130
+ if (owner === '' || name === '' || tag === '') {
131
+ return undefined;
132
+ }
133
+
134
+ const decoded = decodeTag(tag);
135
+
136
+ return decoded === undefined ? undefined : { repository: `${owner}/${name}`, tag: decoded };
137
+ }
138
+
139
+ function decodeTag(tag: string): string | undefined {
140
+ try {
141
+ return decodeURIComponent(tag);
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ // Two updates can share one second. A repeat of the stored row changes nothing, and a different
148
+ // row with the same update time is a distinct update, so the later fact in the stream wins. The
149
+ // host skips a fact it has applied before, so a redelivery never reaches this check.
150
+ function isStale(stored: GithubPullRequestRow, row: GithubPullRequestRow): boolean {
151
+ if (stored.updated_at !== row.updated_at) {
152
+ return stored.updated_at > row.updated_at;
153
+ }
154
+
155
+ return JSON.stringify(stored) === JSON.stringify(row);
156
+ }
157
+
158
+ function readRow(factJSON: string): GithubPullRequestRow | undefined {
159
+ const fact: unknown = JSON.parse(factJSON);
160
+
161
+ if (!isObject(fact) || !('payload' in fact) || !isObject(fact.payload)) {
162
+ return undefined;
163
+ }
164
+
165
+ if (!('pull_request' in fact.payload) || !isObject(fact.payload.pull_request)) {
166
+ return undefined;
167
+ }
168
+
169
+ const pullRequest = fact.payload.pull_request;
170
+
171
+ if (!('id' in pullRequest) || typeof pullRequest.id !== 'number') {
172
+ return undefined;
173
+ }
174
+
175
+ if (!('number' in pullRequest) || typeof pullRequest.number !== 'number') {
176
+ return undefined;
177
+ }
178
+
179
+ if (!('title' in pullRequest) || typeof pullRequest.title !== 'string') {
180
+ return undefined;
181
+ }
182
+
183
+ if (!('state' in pullRequest) || typeof pullRequest.state !== 'string') {
184
+ return undefined;
185
+ }
186
+
187
+ if (!('html_url' in pullRequest) || typeof pullRequest.html_url !== 'string') {
188
+ return undefined;
189
+ }
190
+
191
+ if (!('created_at' in pullRequest) || typeof pullRequest.created_at !== 'string') {
192
+ return undefined;
193
+ }
194
+
195
+ if (!('updated_at' in pullRequest) || typeof pullRequest.updated_at !== 'string') {
196
+ return undefined;
197
+ }
198
+
199
+ const repositoryID = readRepositoryID(pullRequest);
200
+
201
+ if (repositoryID === undefined) {
202
+ return undefined;
203
+ }
204
+
205
+ return {
206
+ id: toGithubPullRequestID(`${pullRequest.id}`),
207
+ number: pullRequest.number,
208
+ repository_id: toEntityID(`github:repo:${repositoryID}`),
209
+ title: pullRequest.title,
210
+ state: pullRequest.state,
211
+ is_draft: 'draft' in pullRequest && pullRequest.draft === true,
212
+ is_merged: 'merged' in pullRequest && pullRequest.merged === true,
213
+ author_id: readAuthorID(pullRequest),
214
+ url: pullRequest.html_url,
215
+ merge_commit_sha:
216
+ 'merge_commit_sha' in pullRequest && typeof pullRequest.merge_commit_sha === 'string'
217
+ ? pullRequest.merge_commit_sha
218
+ : null,
219
+ created_at: pullRequest.created_at,
220
+ updated_at: pullRequest.updated_at,
221
+ closed_at:
222
+ 'closed_at' in pullRequest && typeof pullRequest.closed_at === 'string'
223
+ ? pullRequest.closed_at
224
+ : null,
225
+ merged_at:
226
+ 'merged_at' in pullRequest && typeof pullRequest.merged_at === 'string'
227
+ ? pullRequest.merged_at
228
+ : null,
229
+ payload: pullRequest,
230
+ };
231
+ }
232
+
233
+ function readRepositoryID(pullRequest: object): number | undefined {
234
+ if (!('base' in pullRequest) || !isObject(pullRequest.base)) {
235
+ return undefined;
236
+ }
237
+
238
+ if (!('repo' in pullRequest.base) || !isObject(pullRequest.base.repo)) {
239
+ return undefined;
240
+ }
241
+
242
+ if (!('id' in pullRequest.base.repo) || typeof pullRequest.base.repo.id !== 'number') {
243
+ return undefined;
244
+ }
245
+
246
+ return pullRequest.base.repo.id;
247
+ }
248
+
249
+ function readAuthorID(pullRequest: object): EntityID | null {
250
+ if (!('user' in pullRequest) || !isObject(pullRequest.user)) {
251
+ return null;
252
+ }
253
+
254
+ if (!('id' in pullRequest.user) || typeof pullRequest.user.id !== 'number') {
255
+ return null;
256
+ }
257
+
258
+ return toEntityID(`github:user:${pullRequest.user.id}`);
259
+ }
260
+
261
+ function isObject(value: unknown): value is object {
262
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
263
+ }
264
+
265
+ function isEntityID(text: string): text is EntityID {
266
+ return text.length > 0;
267
+ }
268
+
269
+ function toEntityID(text: string): EntityID {
270
+ if (!isEntityID(text)) {
271
+ throw new Error('an entity ID is never empty');
272
+ }
273
+
274
+ return text;
275
+ }
@@ -0,0 +1,88 @@
1
+ import { initGithubRefModel, updateGithubRefModel } from '../generated/github-ref-boundary.ts';
2
+ import type {
3
+ EntityID,
4
+ GithubCommitRow,
5
+ GithubRefMessage,
6
+ GithubRefModel,
7
+ GithubRefOperation,
8
+ } from '../generated/github-ref-boundary.ts';
9
+ import { findField } from '../utils/find-field.ts';
10
+
11
+ export type { GithubRefMessage, GithubRefModel } from '../generated/github-ref-boundary.ts';
12
+
13
+ // scriptc takes the model and message types through a re-export, but not the two functions
14
+ export function init(): GithubRefModel {
15
+ return initGithubRefModel();
16
+ }
17
+
18
+ export function update(model: GithubRefModel, message: GithubRefMessage): GithubRefModel {
19
+ return updateGithubRefModel(model, message);
20
+ }
21
+
22
+ export function applyFact(factJSON: string): string {
23
+ return JSON.stringify(mapFact(factJSON));
24
+ }
25
+
26
+ // A commit's time is a property of the commit, so a row never changes, and a force push that drops
27
+ // a commit leaves a row that is still true.
28
+ function mapFact(factJSON: string): GithubRefOperation[] {
29
+ const payload = findField(JSON.parse(factJSON), 'payload');
30
+ const ref = findField(payload, 'ref');
31
+ const repositoryID = findField(payload, 'repository', 'id');
32
+ const defaultBranch = findField(payload, 'repository', 'default_branch');
33
+
34
+ if (typeof ref !== 'string' || typeof repositoryID !== 'number') {
35
+ return [];
36
+ }
37
+
38
+ const commits = collectPushedCommits(payload, ref, defaultBranch);
39
+ const repository = toEntityID(`github:repo:${repositoryID}`);
40
+
41
+ return commits.flatMap((commit) => {
42
+ const row = buildCommitRow(repository, commit);
43
+
44
+ return row === undefined ? [] : [{ upsert: { github_commit: row } }];
45
+ });
46
+ }
47
+
48
+ // A tag push gives its head commit; a push to the default branch gives every commit it adds.
49
+ function collectPushedCommits(payload: unknown, ref: string, defaultBranch: unknown): unknown[] {
50
+ if (ref.startsWith('refs/tags/')) {
51
+ return [findField(payload, 'head_commit')];
52
+ }
53
+
54
+ if (typeof defaultBranch === 'string' && ref === `refs/heads/${defaultBranch}`) {
55
+ return toList(findField(payload, 'commits'));
56
+ }
57
+
58
+ return [];
59
+ }
60
+
61
+ function toList(value: unknown): unknown[] {
62
+ return Array.isArray(value) ? value.map((item: unknown) => item) : [];
63
+ }
64
+
65
+ // The time as GitHub writes it, with the committer's offset: scriptc fences `Date`, so a reader
66
+ // compares these times as times, never as text.
67
+ function buildCommitRow(repository: EntityID, commit: unknown): GithubCommitRow | undefined {
68
+ const sha = findField(commit, 'id');
69
+ const timestamp = findField(commit, 'timestamp');
70
+
71
+ if (typeof sha !== 'string' || typeof timestamp !== 'string') {
72
+ return undefined;
73
+ }
74
+
75
+ return { repository_id: repository, sha, committed_at: timestamp };
76
+ }
77
+
78
+ function isEntityID(text: string): text is EntityID {
79
+ return text.length > 0;
80
+ }
81
+
82
+ function toEntityID(text: string): EntityID {
83
+ if (!isEntityID(text)) {
84
+ throw new Error('an entity ID is never empty');
85
+ }
86
+
87
+ return text;
88
+ }