@pepys/source-github 0.0.1 → 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/package.json +8 -5
  2. package/src/api/serve-api.ts +6 -35
  3. package/src/consumers/github-deploy.ts +199 -0
  4. package/src/consumers/github-pull-request.ts +96 -1
  5. package/src/consumers/github-ref.ts +88 -0
  6. package/src/consumers/github-release.ts +125 -0
  7. package/src/families/build-family-types.ts +16 -0
  8. package/src/families/deployment-family.ts +15 -0
  9. package/src/families/issue-family.ts +38 -0
  10. package/src/{pull-request-family.ts → families/pull-request-family.ts} +6 -5
  11. package/src/families/pull-request-review-family.ts +18 -0
  12. package/src/families/ref-family.ts +14 -0
  13. package/src/families/release-family.ts +28 -0
  14. package/src/families/workflow-run-family.ts +18 -0
  15. package/src/generated/github-deploy-boundary.ts +308 -0
  16. package/src/generated/github-deploy.sql +24 -0
  17. package/src/generated/github-pull-request-boundary.ts +21 -3
  18. package/src/generated/github-pull-request.sql +9 -0
  19. package/src/generated/github-ref-boundary.ts +149 -0
  20. package/src/generated/github-ref.sql +8 -0
  21. package/src/generated/github-release-boundary.ts +251 -0
  22. package/src/generated/github-release.sql +32 -0
  23. package/src/github-deploy-consumer.ts +40 -0
  24. package/src/github-plugin.ts +11 -22
  25. package/src/github-pull-request-consumer.ts +13 -1
  26. package/src/github-ref-consumer.ts +23 -0
  27. package/src/github-release-consumer.ts +43 -0
  28. package/src/github-settings.ts +15 -0
  29. package/src/github-source.ts +56 -0
  30. package/src/index.ts +10 -0
  31. package/src/ingest/build-github-fact.ts +55 -0
  32. package/src/ingest/github-app/build-app-jwt.ts +20 -0
  33. package/src/ingest/github-app/find-app-jwt.ts +17 -0
  34. package/src/ingest/github-app/github-api-url.ts +1 -0
  35. package/src/ingest/github-app/pick-pull-failure.ts +11 -0
  36. package/src/ingest/github-app/read-installation-token.ts +43 -0
  37. package/src/ingest/github-app/request-github-api.ts +75 -0
  38. package/src/ingest/github-event-rules.ts +324 -0
  39. package/src/ingest/read-github-delivery.ts +41 -0
  40. package/src/ingest/recovery/pull-failed-deliveries.ts +100 -0
  41. package/src/ingest/resync/collect-resync-targets.ts +33 -0
  42. package/src/ingest/resync/pull-marked-objects.ts +130 -0
  43. package/src/queries/github-delivery-consumers.ts +13 -0
  44. package/src/queries/pull-request-delivery-query.ts +49 -0
  45. package/src/queries/select-pull-request-delivery.ts +155 -0
  46. package/src/utils/build-body-hash.ts +34 -0
  47. package/src/utils/deploy-signals.ts +5 -0
  48. package/src/utils/find-field.ts +19 -0
  49. package/src/ingest/build-pull-request-fact.ts +0 -38
  50. package/src/ingest/pull-request-stream.ts +0 -4
  51. package/src/ingest/read-pull-request-delivery.ts +0 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/source-github",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "The pepys GitHub plug-in: pull requests, releases, commits and deploys",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,14 +19,17 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@pepys/api": "0.0.1",
23
- "@pepys/ingest": "0.0.1",
24
- "@pepys/kit": "0.0.1",
25
- "@pepys/schema": "0.0.1",
22
+ "@pepys/api": "0.1.0",
23
+ "@pepys/ingest": "0.1.0",
24
+ "@pepys/kit": "0.1.0",
26
25
  "@zgeoff/std": "0.4.3",
26
+ "kysely": "0.29.8",
27
27
  "zod": "4.6.7"
28
28
  },
29
29
  "devDependencies": {
30
+ "@msw/data": "1.1.8",
31
+ "@pepys/testing": "0.1.0",
32
+ "msw": "3.0.3",
30
33
  "tiny-invariant": "1.3.3"
31
34
  }
32
35
  }
@@ -1,37 +1,8 @@
1
- import { createApp, createStoreReader, openConsumerDatabase, readAPIConfig } from '@pepys/api';
2
- import { makeShutdownHandler } from '@pepys/schema';
3
- import { githubPullRequestConsumer } from '../github-pull-request-consumer.ts';
1
+ import { serveAPI } from '@pepys/api';
2
+ import { defineDeployment } from '@pepys/kit';
3
+ import { githubPlugin } from '../github-plugin.ts';
4
4
 
5
- const config = readAPIConfig(process.env);
6
-
7
- if (!config.ok) {
8
- console.error(config.failure.join('\n'));
9
- process.exit(1);
10
- }
11
-
12
- const store = createStoreReader({
13
- storeDirectory: config.value.storeDirectory,
14
- openDatabase: openConsumerDatabase,
5
+ serveAPI({
6
+ deployment: defineDeployment({ name: 'github', plugins: [githubPlugin] }),
7
+ environment: process.env,
15
8
  });
16
-
17
- const app = createApp({ store, consumers: [githubPullRequestConsumer] });
18
-
19
- app.listen({ hostname: '127.0.0.1', port: config.value.port });
20
- console.log(`api listening on ${app.server?.port}`);
21
-
22
- const shutdown = makeShutdownHandler({
23
- stop: async () => {
24
- await app.stop();
25
- await store.close();
26
- },
27
- logError: (message, error) => {
28
- console.error(message, error);
29
- },
30
- exit: (code) => process.exit(code),
31
- });
32
-
33
- for (const signal of ['SIGINT', 'SIGTERM'] as const) {
34
- process.on(signal, () => {
35
- void shutdown();
36
- });
37
- }
@@ -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
+ }
@@ -10,7 +10,9 @@ import type {
10
10
  GithubPullRequestModel,
11
11
  GithubPullRequestOperation,
12
12
  GithubPullRequestRow,
13
+ GithubReleaseBumpRow,
13
14
  } from '../generated/github-pull-request-boundary.ts';
15
+ import { findField } from '../utils/find-field.ts';
14
16
 
15
17
  export type {
16
18
  GithubPullRequestMessage,
@@ -46,7 +48,100 @@ function mapFact(factJSON: string): GithubPullRequestOperation[] {
46
48
  return [];
47
49
  }
48
50
 
49
- return [{ upsert: { github_pull_request: row } }];
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
+ }
50
145
  }
51
146
 
52
147
  // Two updates can share one second. A repeat of the stored row changes nothing, and a different
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ initGithubReleaseModel,
3
+ readGithubReleaseRow,
4
+ toGithubReleaseID,
5
+ updateGithubReleaseModel,
6
+ } from '../generated/github-release-boundary.ts';
7
+ import type {
8
+ EntityID,
9
+ GithubReleaseMessage,
10
+ GithubReleaseModel,
11
+ GithubReleaseOperation,
12
+ GithubReleaseRow,
13
+ } from '../generated/github-release-boundary.ts';
14
+ import { findField } from '../utils/find-field.ts';
15
+
16
+ export type {
17
+ GithubReleaseMessage,
18
+ GithubReleaseModel,
19
+ } from '../generated/github-release-boundary.ts';
20
+
21
+ // scriptc takes the model and message types through a re-export, but not the two functions
22
+ export function init(): GithubReleaseModel {
23
+ return initGithubReleaseModel();
24
+ }
25
+
26
+ export function update(
27
+ model: GithubReleaseModel,
28
+ message: GithubReleaseMessage,
29
+ ): GithubReleaseModel {
30
+ return updateGithubReleaseModel(model, message);
31
+ }
32
+
33
+ export function applyFact(factJSON: string): string {
34
+ return JSON.stringify(mapFact(factJSON));
35
+ }
36
+
37
+ // GitHub never uses a release ID again, so a tombstone is never cleared. On a tie of times the
38
+ // later fact in the stream wins: the host gives each fact ID once, so every replay keeps that order.
39
+ function mapFact(factJSON: string): GithubReleaseOperation[] {
40
+ const fact: unknown = JSON.parse(factJSON);
41
+ const type = findField(fact, 'type');
42
+ const at = findField(fact, 'at');
43
+ const releaseID = findField(fact, 'payload', 'release', 'id');
44
+
45
+ if (typeof at !== 'string' || typeof releaseID !== 'number') {
46
+ return [];
47
+ }
48
+
49
+ const id = toGithubReleaseID(`${releaseID}`);
50
+ const stored = readGithubReleaseRow(id);
51
+ const isDelete = type === 'github.release.deleted';
52
+ const deletedAt = stored?.deleted_at ?? (isDelete ? at : null);
53
+ const given = buildReleaseRow(id, findField(fact, 'payload'), at);
54
+ const wins = given !== undefined && (stored === undefined || at >= stored.updated_at);
55
+ const row = wins ? given : stored;
56
+
57
+ if (row === undefined) {
58
+ return [];
59
+ }
60
+
61
+ const next: GithubReleaseRow = { ...row, deleted_at: deletedAt };
62
+
63
+ if (stored !== undefined && JSON.stringify(stored) === JSON.stringify(next)) {
64
+ return [];
65
+ }
66
+
67
+ return [{ upsert: { github_release: next } }];
68
+ }
69
+
70
+ // A fact that holds the release ID alone gives no row of its own.
71
+ function buildReleaseRow(
72
+ id: GithubReleaseRow['id'],
73
+ payload: unknown,
74
+ at: string,
75
+ ): GithubReleaseRow | undefined {
76
+ const release = findField(payload, 'release');
77
+ const repositoryID = findField(payload, 'repository', 'id');
78
+ const fullName = findField(payload, 'repository', 'full_name');
79
+ const tagName = findField(release, 'tag_name');
80
+ const target = findField(release, 'target_commitish');
81
+ const name = findField(release, 'name');
82
+ const url = findField(release, 'html_url');
83
+ const createdAt = findField(release, 'created_at');
84
+ const publishedAt = findField(release, 'published_at');
85
+
86
+ if (typeof repositoryID !== 'number' || typeof fullName !== 'string') {
87
+ return undefined;
88
+ }
89
+
90
+ if (typeof tagName !== 'string' || typeof target !== 'string' || typeof url !== 'string') {
91
+ return undefined;
92
+ }
93
+
94
+ if (typeof createdAt !== 'string') {
95
+ return undefined;
96
+ }
97
+
98
+ return {
99
+ id,
100
+ repository_id: toEntityID(`github:repo:${repositoryID}`),
101
+ repository_full_name: fullName,
102
+ tag_name: tagName,
103
+ target_commitish: target,
104
+ name: typeof name === 'string' && name !== '' ? name : tagName,
105
+ url,
106
+ is_draft: findField(release, 'draft') === true,
107
+ is_prerelease: findField(release, 'prerelease') === true,
108
+ created_at: createdAt,
109
+ published_at: typeof publishedAt === 'string' ? publishedAt : null,
110
+ updated_at: at,
111
+ deleted_at: null,
112
+ };
113
+ }
114
+
115
+ function isEntityID(text: string): text is EntityID {
116
+ return text.length > 0;
117
+ }
118
+
119
+ function toEntityID(text: string): EntityID {
120
+ if (!isEntityID(text)) {
121
+ throw new Error('an entity ID is never empty');
122
+ }
123
+
124
+ return text;
125
+ }
@@ -0,0 +1,16 @@
1
+ import type { z } from 'zod';
2
+
3
+ // One fact type per action, `github.<family>.<action>`, each with the family's payload schema.
4
+ export function buildFamilyTypes(
5
+ family: string,
6
+ actions: readonly string[],
7
+ schema: z.ZodType,
8
+ ): Readonly<Record<string, z.ZodType>> {
9
+ const types: Record<string, z.ZodType> = {};
10
+
11
+ for (const action of actions) {
12
+ types[`github.${family}.${action}`] = schema;
13
+ }
14
+
15
+ return types;
16
+ }
@@ -0,0 +1,15 @@
1
+ import { defineFactFamily } from '@pepys/kit';
2
+ import { z } from 'zod';
3
+
4
+ const payloadSchema = z.looseObject({ deployment: z.looseObject({ id: z.number().int() }) });
5
+
6
+ // A deployment and each of its statuses, as facts of the one deployment entity.
7
+ export const deploymentFamily = defineFactFamily({
8
+ stream: 'facts.github.deployment',
9
+ api: 'github/2022-11-28',
10
+ entityKind: 'github:deployment',
11
+ types: {
12
+ 'github.deployment.created': payloadSchema,
13
+ 'github.deployment.status_created': payloadSchema,
14
+ },
15
+ });
@@ -0,0 +1,38 @@
1
+ import { defineFactFamily } from '@pepys/kit';
2
+ import { z } from 'zod';
3
+ import { buildFamilyTypes } from './build-family-types.ts';
4
+
5
+ // The `action` values of GitHub's `issues` webhook event.
6
+ const actions = [
7
+ 'assigned',
8
+ 'closed',
9
+ 'deleted',
10
+ 'demilestoned',
11
+ 'edited',
12
+ 'labeled',
13
+ 'locked',
14
+ 'milestoned',
15
+ 'opened',
16
+ 'pinned',
17
+ 'reopened',
18
+ 'transferred',
19
+ 'typed',
20
+ 'unassigned',
21
+ 'unlabeled',
22
+ 'unlocked',
23
+ 'unpinned',
24
+ 'untyped',
25
+ ] as const;
26
+
27
+ const payloadSchema = z.looseObject({
28
+ action: z.string(),
29
+ issue: z.looseObject({ id: z.number().int() }),
30
+ });
31
+
32
+ // Stored with no consumer.
33
+ export const issueFamily = defineFactFamily({
34
+ stream: 'facts.github.issue',
35
+ api: 'github/2022-11-28',
36
+ entityKind: 'github:issue',
37
+ types: buildFamilyTypes('issue', actions, payloadSchema),
38
+ });