@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.
- package/README.md +32 -2
- package/package.json +33 -4
- package/src/api/serve-api.ts +8 -0
- package/src/consumers/github-deploy.ts +199 -0
- package/src/consumers/github-pull-request.ts +275 -0
- package/src/consumers/github-ref.ts +88 -0
- package/src/consumers/github-release.ts +125 -0
- package/src/families/build-family-types.ts +16 -0
- package/src/families/deployment-family.ts +15 -0
- package/src/families/issue-family.ts +38 -0
- package/src/families/pull-request-family.ts +42 -0
- package/src/families/pull-request-review-family.ts +18 -0
- package/src/families/ref-family.ts +14 -0
- package/src/families/release-family.ts +28 -0
- package/src/families/workflow-run-family.ts +18 -0
- package/src/generated/github-deploy-boundary.ts +308 -0
- package/src/generated/github-deploy.sql +24 -0
- package/src/generated/github-pull-request-boundary.ts +286 -0
- package/src/generated/github-pull-request.sql +41 -0
- package/src/generated/github-ref-boundary.ts +149 -0
- package/src/generated/github-ref.sql +8 -0
- package/src/generated/github-release-boundary.ts +251 -0
- package/src/generated/github-release.sql +32 -0
- package/src/github-deploy-consumer.ts +40 -0
- package/src/github-plugin.ts +15 -0
- package/src/github-pull-request-consumer.ts +56 -0
- package/src/github-pull-request-schema-path.ts +6 -0
- package/src/github-ref-consumer.ts +23 -0
- package/src/github-release-consumer.ts +43 -0
- package/src/github-settings.ts +15 -0
- package/src/github-source.ts +56 -0
- package/src/index.ts +13 -0
- package/src/ingest/build-github-fact.ts +55 -0
- package/src/ingest/github-app/build-app-jwt.ts +20 -0
- package/src/ingest/github-app/find-app-jwt.ts +17 -0
- package/src/ingest/github-app/github-api-url.ts +1 -0
- package/src/ingest/github-app/pick-pull-failure.ts +11 -0
- package/src/ingest/github-app/read-installation-token.ts +43 -0
- package/src/ingest/github-app/request-github-api.ts +75 -0
- package/src/ingest/github-event-rules.ts +324 -0
- package/src/ingest/read-github-delivery.ts +41 -0
- package/src/ingest/recovery/pull-failed-deliveries.ts +100 -0
- package/src/ingest/resync/collect-resync-targets.ts +33 -0
- package/src/ingest/resync/pull-marked-objects.ts +130 -0
- package/src/ingest/serve-ingest.ts +8 -0
- package/src/queries/github-delivery-consumers.ts +13 -0
- package/src/queries/pull-request-delivery-query.ts +49 -0
- package/src/queries/select-pull-request-delivery.ts +155 -0
- package/src/utils/build-body-hash.ts +34 -0
- package/src/utils/deploy-signals.ts +5 -0
- package/src/utils/find-field.ts +19 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Generated by `pepys build`. Do not edit.
|
|
2
|
+
|
|
3
|
+
declare const idTag: unique symbol;
|
|
4
|
+
declare const kindTag: unique symbol;
|
|
5
|
+
|
|
6
|
+
export type EntityID = string & { readonly [idTag]: 'EntityID' };
|
|
7
|
+
|
|
8
|
+
type KindedID<Kind extends string> = EntityID & { readonly [kindTag]: Kind };
|
|
9
|
+
|
|
10
|
+
export type GithubReleaseID = KindedID<'github:release'>;
|
|
11
|
+
|
|
12
|
+
export function toGithubReleaseID(id: string): GithubReleaseID {
|
|
13
|
+
const text = 'github:release:' + id;
|
|
14
|
+
|
|
15
|
+
if (!isGithubReleaseID(text)) {
|
|
16
|
+
throw new Error('an ID of the kind github:release is never empty');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseGithubReleaseID(text: string): GithubReleaseID | undefined {
|
|
23
|
+
return isGithubReleaseID(text) ? text : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isGithubReleaseID(text: string): text is GithubReleaseID {
|
|
27
|
+
return text.length > 'github:release:'.length && text.startsWith('github:release:');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const settings = {
|
|
31
|
+
"deployTagPattern": "deployed/<environment>"
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export interface GithubReleaseModel {
|
|
35
|
+
input: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GithubReleaseMessage = { kind: 'apply'; input: string } | { kind: 'reset' };
|
|
39
|
+
|
|
40
|
+
export function initGithubReleaseModel(): GithubReleaseModel {
|
|
41
|
+
return { input: '' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function updateGithubReleaseModel(model: GithubReleaseModel, message: GithubReleaseMessage): GithubReleaseModel {
|
|
45
|
+
return message.kind === 'reset' ? initGithubReleaseModel() : { ...model, input: message.input };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GithubReleasePayload {
|
|
49
|
+
action: string;
|
|
50
|
+
release: GithubReleasePayloadRelease;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GithubReleasePayloadRelease {
|
|
54
|
+
id: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
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 };
|
|
59
|
+
|
|
60
|
+
export function parseGithubReleaseFact(factJSON: string): GithubReleaseFact | undefined {
|
|
61
|
+
const parsed: unknown = JSON.parse(factJSON);
|
|
62
|
+
|
|
63
|
+
return decodeGithubReleaseFact(parsed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function decodeGithubReleaseFact(value: unknown): GithubReleaseFact | undefined {
|
|
67
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const id = 'id' in value ? value.id : undefined;
|
|
72
|
+
const type = 'type' in value ? value.type : undefined;
|
|
73
|
+
const entity = 'entity' in value ? value.entity : undefined;
|
|
74
|
+
const at = 'at' in value ? value.at : undefined;
|
|
75
|
+
const payload = 'payload' in value ? value.payload : undefined;
|
|
76
|
+
|
|
77
|
+
if (typeof id !== 'string' || typeof entity !== 'string' || typeof at !== 'string') {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
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
|
+
const decoded = decodeGithubReleasePayload(payload);
|
|
83
|
+
|
|
84
|
+
if (decoded === undefined) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { id, type, entity, at, payload: decoded };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decodeGithubReleasePayload(value: unknown): GithubReleasePayload | undefined {
|
|
95
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const raw0 = 'action' in value ? value.action : undefined;
|
|
100
|
+
const field0 = typeof raw0 === 'string' ? raw0 : undefined;
|
|
101
|
+
|
|
102
|
+
if (field0 === undefined) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const raw1 = 'release' in value ? value.release : undefined;
|
|
107
|
+
const field1 = decodeGithubReleasePayloadRelease(raw1);
|
|
108
|
+
|
|
109
|
+
if (field1 === undefined) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
action: field0,
|
|
115
|
+
release: field1,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodeGithubReleasePayloadRelease(value: unknown): GithubReleasePayloadRelease | undefined {
|
|
120
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const raw0 = 'id' in value ? value.id : undefined;
|
|
125
|
+
const field0 = typeof raw0 === 'number' ? raw0 : undefined;
|
|
126
|
+
|
|
127
|
+
if (field0 === undefined) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
id: field0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface GithubReleaseRow {
|
|
137
|
+
id: GithubReleaseID;
|
|
138
|
+
repository_id: EntityID;
|
|
139
|
+
repository_full_name: string;
|
|
140
|
+
tag_name: string;
|
|
141
|
+
target_commitish: string;
|
|
142
|
+
name: string;
|
|
143
|
+
url: string;
|
|
144
|
+
is_draft: boolean;
|
|
145
|
+
is_prerelease: boolean;
|
|
146
|
+
created_at: string;
|
|
147
|
+
published_at: string | null;
|
|
148
|
+
updated_at: string;
|
|
149
|
+
deleted_at: string | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface GithubReleaseKey {
|
|
153
|
+
id: GithubReleaseID;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type GithubReleaseOperation =
|
|
157
|
+
| { upsert: { github_release: GithubReleaseRow } }
|
|
158
|
+
| { delete: { github_release: GithubReleaseKey } };
|
|
159
|
+
|
|
160
|
+
declare function readGithubReleaseLength(key: string): number;
|
|
161
|
+
declare function readGithubReleaseInto(key: string, row: Uint8Array): void;
|
|
162
|
+
|
|
163
|
+
export function readGithubReleaseRow(key: string): GithubReleaseRow | undefined {
|
|
164
|
+
const length = readGithubReleaseLength(key);
|
|
165
|
+
|
|
166
|
+
if (length < 0) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const row = new Uint8Array(length);
|
|
171
|
+
|
|
172
|
+
readGithubReleaseInto(key, row);
|
|
173
|
+
|
|
174
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(row));
|
|
175
|
+
|
|
176
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!('id' in parsed) || (typeof parsed.id !== 'string' || !isGithubReleaseID(parsed.id))) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!('repository_id' in parsed) || (!isEntityID(parsed.repository_id))) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!('repository_full_name' in parsed) || (typeof parsed.repository_full_name !== 'string')) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!('tag_name' in parsed) || (typeof parsed.tag_name !== 'string')) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!('target_commitish' in parsed) || (typeof parsed.target_commitish !== 'string')) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!('name' in parsed) || (typeof parsed.name !== 'string')) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!('url' in parsed) || (typeof parsed.url !== 'string')) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!('is_draft' in parsed) || (typeof parsed.is_draft !== 'boolean')) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!('is_prerelease' in parsed) || (typeof parsed.is_prerelease !== 'boolean')) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!('created_at' in parsed) || (typeof parsed.created_at !== 'string')) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!('published_at' in parsed) || (parsed.published_at !== null && (typeof parsed.published_at !== 'string'))) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!('updated_at' in parsed) || (typeof parsed.updated_at !== 'string')) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!('deleted_at' in parsed) || (parsed.deleted_at !== null && (typeof parsed.deleted_at !== 'string'))) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: parsed.id,
|
|
234
|
+
repository_id: parsed.repository_id,
|
|
235
|
+
repository_full_name: parsed.repository_full_name,
|
|
236
|
+
tag_name: parsed.tag_name,
|
|
237
|
+
target_commitish: parsed.target_commitish,
|
|
238
|
+
name: parsed.name,
|
|
239
|
+
url: parsed.url,
|
|
240
|
+
is_draft: parsed.is_draft,
|
|
241
|
+
is_prerelease: parsed.is_prerelease,
|
|
242
|
+
created_at: parsed.created_at,
|
|
243
|
+
published_at: parsed.published_at,
|
|
244
|
+
updated_at: parsed.updated_at,
|
|
245
|
+
deleted_at: parsed.deleted_at,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isEntityID(value: unknown): value is EntityID {
|
|
250
|
+
return typeof value === 'string';
|
|
251
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Generated by `pepys build`. Do not edit.
|
|
2
|
+
|
|
3
|
+
CREATE TABLE "github_release" (
|
|
4
|
+
"id" TEXT NOT NULL,
|
|
5
|
+
"repository_id" TEXT NOT NULL,
|
|
6
|
+
"repository_full_name" TEXT NOT NULL,
|
|
7
|
+
"tag_name" TEXT NOT NULL,
|
|
8
|
+
"target_commitish" TEXT NOT NULL,
|
|
9
|
+
"name" TEXT NOT NULL,
|
|
10
|
+
"url" TEXT NOT NULL,
|
|
11
|
+
"is_draft" INTEGER NOT NULL,
|
|
12
|
+
"is_prerelease" INTEGER NOT NULL,
|
|
13
|
+
"created_at" TEXT NOT NULL,
|
|
14
|
+
"published_at" TEXT,
|
|
15
|
+
"updated_at" TEXT NOT NULL,
|
|
16
|
+
"deleted_at" TEXT,
|
|
17
|
+
PRIMARY KEY ("id")
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX "github_release_repository_id_idx" ON "github_release" ("repository_id");
|
|
21
|
+
|
|
22
|
+
CREATE INDEX "github_release_repository_full_name_tag_name_idx" ON "github_release" ("repository_full_name", "tag_name");
|
|
23
|
+
|
|
24
|
+
CREATE VIEW "entity" AS
|
|
25
|
+
SELECT
|
|
26
|
+
"id" AS "id",
|
|
27
|
+
'github:release' AS "kind",
|
|
28
|
+
"name" AS "title",
|
|
29
|
+
"url" AS "url",
|
|
30
|
+
NULL AS "actor",
|
|
31
|
+
"updated_at" AS "updated_at"
|
|
32
|
+
FROM "github_release";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
|
+
import { deploymentFamily } from './families/deployment-family.ts';
|
|
3
|
+
import { refFamily } from './families/ref-family.ts';
|
|
4
|
+
|
|
5
|
+
// One row per signal that reported a commit to an environment, with the earliest time it did.
|
|
6
|
+
export const githubDeployConsumer = defineConsumer({
|
|
7
|
+
name: 'github_deploy',
|
|
8
|
+
entry: new URL('consumers/github-deploy.ts', import.meta.url),
|
|
9
|
+
input: { mode: 'fact', streams: [refFamily.stream, deploymentFamily.stream] },
|
|
10
|
+
tables: {
|
|
11
|
+
github_deploy: defineTable({
|
|
12
|
+
columns: {
|
|
13
|
+
// `github:deploy:<repository ID>:<environment>:<signal>:<sha>`, and `:<target>` after it
|
|
14
|
+
// for a row with a target, so two targets of one commit do not share a row
|
|
15
|
+
id: { kind: 'entityID' },
|
|
16
|
+
repository_id: { kind: 'entityID' },
|
|
17
|
+
environment: { kind: 'text' },
|
|
18
|
+
target: { kind: 'text', nullable: true },
|
|
19
|
+
sha: { kind: 'text' },
|
|
20
|
+
signal: { kind: 'text' },
|
|
21
|
+
deployed_at: { kind: 'timestamp' },
|
|
22
|
+
},
|
|
23
|
+
primaryKey: ['id'],
|
|
24
|
+
indexes: [{ columns: ['repository_id', 'environment'] }],
|
|
25
|
+
}),
|
|
26
|
+
},
|
|
27
|
+
entityViews: [
|
|
28
|
+
{
|
|
29
|
+
table: 'github_deploy',
|
|
30
|
+
kind: 'github:deploy',
|
|
31
|
+
id: 'id',
|
|
32
|
+
title: 'sha',
|
|
33
|
+
url: null,
|
|
34
|
+
actor: null,
|
|
35
|
+
updatedAt: 'deployed_at',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
reads: ['github_deploy'],
|
|
39
|
+
links: [],
|
|
40
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { definePlugin } from '@pepys/kit';
|
|
2
|
+
import { githubSettings } from './github-settings.ts';
|
|
3
|
+
import { githubSource } from './github-source.ts';
|
|
4
|
+
import { githubDeliveryConsumers } from './queries/github-delivery-consumers.ts';
|
|
5
|
+
import { pullRequestDeliveryQuery } from './queries/pull-request-delivery-query.ts';
|
|
6
|
+
|
|
7
|
+
// One plug-in for every deployment. A deployment that uses another tag pattern sets
|
|
8
|
+
// `settings.github.deployTagPattern` in its deployment file.
|
|
9
|
+
export const githubPlugin = definePlugin({
|
|
10
|
+
name: 'github',
|
|
11
|
+
settings: githubSettings,
|
|
12
|
+
sources: [githubSource],
|
|
13
|
+
consumers: [...githubDeliveryConsumers],
|
|
14
|
+
queries: [pullRequestDeliveryQuery],
|
|
15
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
|
+
import { pullRequestFamily } from './families/pull-request-family.ts';
|
|
3
|
+
|
|
4
|
+
export const githubPullRequestConsumer = defineConsumer({
|
|
5
|
+
name: 'github_pull_request',
|
|
6
|
+
entry: new URL('consumers/github-pull-request.ts', import.meta.url),
|
|
7
|
+
input: { mode: 'fact', streams: [pullRequestFamily.stream] },
|
|
8
|
+
tables: {
|
|
9
|
+
github_pull_request: defineTable({
|
|
10
|
+
columns: {
|
|
11
|
+
id: { kind: 'entityID' },
|
|
12
|
+
number: { kind: 'integer' },
|
|
13
|
+
repository_id: { kind: 'entityID' },
|
|
14
|
+
title: { kind: 'text' },
|
|
15
|
+
state: { kind: 'text' },
|
|
16
|
+
is_draft: { kind: 'boolean' },
|
|
17
|
+
is_merged: { kind: 'boolean' },
|
|
18
|
+
author_id: { kind: 'entityID', nullable: true },
|
|
19
|
+
url: { kind: 'text' },
|
|
20
|
+
merge_commit_sha: { kind: 'text', nullable: true },
|
|
21
|
+
created_at: { kind: 'timestamp' },
|
|
22
|
+
updated_at: { kind: 'timestamp' },
|
|
23
|
+
closed_at: { kind: 'timestamp', nullable: true },
|
|
24
|
+
merged_at: { kind: 'timestamp', nullable: true },
|
|
25
|
+
payload: { kind: 'json' },
|
|
26
|
+
},
|
|
27
|
+
primaryKey: ['id'],
|
|
28
|
+
indexes: [{ columns: ['repository_id'] }],
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
// One row per release that a bot's pull request names in its body, by the release URL. The rows
|
|
32
|
+
// follow the pull request row: a fact that loses writes none, and one that wins replaces them.
|
|
33
|
+
github_release_bump: defineTable({
|
|
34
|
+
columns: {
|
|
35
|
+
pull_request_id: { kind: 'entityID' },
|
|
36
|
+
release_repository: { kind: 'text' },
|
|
37
|
+
release_tag: { kind: 'text' },
|
|
38
|
+
},
|
|
39
|
+
primaryKey: ['pull_request_id', 'release_repository', 'release_tag'],
|
|
40
|
+
indexes: [{ columns: ['release_repository', 'release_tag'] }],
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
entityViews: [
|
|
44
|
+
{
|
|
45
|
+
table: 'github_pull_request',
|
|
46
|
+
kind: pullRequestFamily.entityKind,
|
|
47
|
+
id: 'id',
|
|
48
|
+
title: 'title',
|
|
49
|
+
url: 'url',
|
|
50
|
+
actor: 'author_id',
|
|
51
|
+
updatedAt: 'updated_at',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
reads: ['github_pull_request'],
|
|
55
|
+
links: [],
|
|
56
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
|
+
import { refFamily } from './families/ref-family.ts';
|
|
3
|
+
|
|
4
|
+
// One row per commit of a push to the default branch, and one for the commit a pushed tag names.
|
|
5
|
+
export const githubRefConsumer = defineConsumer({
|
|
6
|
+
name: 'github_ref',
|
|
7
|
+
entry: new URL('consumers/github-ref.ts', import.meta.url),
|
|
8
|
+
input: { mode: 'fact', streams: [refFamily.stream] },
|
|
9
|
+
tables: {
|
|
10
|
+
github_commit: defineTable({
|
|
11
|
+
columns: {
|
|
12
|
+
repository_id: { kind: 'entityID' },
|
|
13
|
+
sha: { kind: 'text' },
|
|
14
|
+
committed_at: { kind: 'timestamp' },
|
|
15
|
+
},
|
|
16
|
+
primaryKey: ['repository_id', 'sha'],
|
|
17
|
+
indexes: [],
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
entityViews: [],
|
|
21
|
+
reads: [],
|
|
22
|
+
links: [],
|
|
23
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
|
+
import { releaseFamily } from './families/release-family.ts';
|
|
3
|
+
|
|
4
|
+
// One row per release, never deleted: a release GitHub deleted keeps its row with `deleted_at`.
|
|
5
|
+
export const githubReleaseConsumer = defineConsumer({
|
|
6
|
+
name: 'github_release',
|
|
7
|
+
entry: new URL('consumers/github-release.ts', import.meta.url),
|
|
8
|
+
input: { mode: 'fact', streams: [releaseFamily.stream] },
|
|
9
|
+
tables: {
|
|
10
|
+
github_release: defineTable({
|
|
11
|
+
columns: {
|
|
12
|
+
id: { kind: 'entityID' },
|
|
13
|
+
repository_id: { kind: 'entityID' },
|
|
14
|
+
repository_full_name: { kind: 'text' },
|
|
15
|
+
tag_name: { kind: 'text' },
|
|
16
|
+
target_commitish: { kind: 'text' },
|
|
17
|
+
name: { kind: 'text' },
|
|
18
|
+
url: { kind: 'text' },
|
|
19
|
+
is_draft: { kind: 'boolean' },
|
|
20
|
+
is_prerelease: { kind: 'boolean' },
|
|
21
|
+
created_at: { kind: 'timestamp' },
|
|
22
|
+
published_at: { kind: 'timestamp', nullable: true },
|
|
23
|
+
updated_at: { kind: 'timestamp' },
|
|
24
|
+
deleted_at: { kind: 'timestamp', nullable: true },
|
|
25
|
+
},
|
|
26
|
+
primaryKey: ['id'],
|
|
27
|
+
indexes: [{ columns: ['repository_id'] }, { columns: ['repository_full_name', 'tag_name'] }],
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
entityViews: [
|
|
31
|
+
{
|
|
32
|
+
table: 'github_release',
|
|
33
|
+
kind: releaseFamily.entityKind,
|
|
34
|
+
id: 'id',
|
|
35
|
+
title: 'name',
|
|
36
|
+
url: 'url',
|
|
37
|
+
actor: null,
|
|
38
|
+
updatedAt: 'updated_at',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
reads: ['github_release'],
|
|
42
|
+
links: [],
|
|
43
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// `pepys build` writes the checked values into the glue of each consumer, so a changed pattern is a
|
|
4
|
+
// new version of `github_deploy`. A tag with one more part, `<pattern>/<target>`, names a target.
|
|
5
|
+
export const githubSettings = {
|
|
6
|
+
schema: z.object({
|
|
7
|
+
deployTagPattern: z
|
|
8
|
+
.string()
|
|
9
|
+
.includes('<environment>')
|
|
10
|
+
.refine((pattern) => pattern.split('<environment>').length === 2, {
|
|
11
|
+
message: 'the pattern holds <environment> once',
|
|
12
|
+
}),
|
|
13
|
+
}),
|
|
14
|
+
defaults: { deployTagPattern: 'deployed/<environment>' },
|
|
15
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineSource } from '@pepys/kit';
|
|
2
|
+
import { deploymentFamily } from './families/deployment-family.ts';
|
|
3
|
+
import { issueFamily } from './families/issue-family.ts';
|
|
4
|
+
import { pullRequestFamily } from './families/pull-request-family.ts';
|
|
5
|
+
import { pullRequestReviewFamily } from './families/pull-request-review-family.ts';
|
|
6
|
+
import { refFamily } from './families/ref-family.ts';
|
|
7
|
+
import { releaseFamily } from './families/release-family.ts';
|
|
8
|
+
import { workflowRunFamily } from './families/workflow-run-family.ts';
|
|
9
|
+
import { readGithubDelivery } from './ingest/read-github-delivery.ts';
|
|
10
|
+
import { pullFailedDeliveries } from './ingest/recovery/pull-failed-deliveries.ts';
|
|
11
|
+
import { pullMarkedObjects } from './ingest/resync/pull-marked-objects.ts';
|
|
12
|
+
|
|
13
|
+
// The GitHub App that both pulls call the API as.
|
|
14
|
+
const appEnv = ['PEPYS_GITHUB_APP_ID', 'PEPYS_GITHUB_APP_PRIVATE_KEY'];
|
|
15
|
+
|
|
16
|
+
// One webhook route, checked by GitHub's signature, for seven families. The recovery pull reads
|
|
17
|
+
// each failed delivery every hour; the resync pull reads each marked object every minute.
|
|
18
|
+
export const githubSource = defineSource({
|
|
19
|
+
name: 'github',
|
|
20
|
+
families: [
|
|
21
|
+
pullRequestFamily,
|
|
22
|
+
releaseFamily,
|
|
23
|
+
refFamily,
|
|
24
|
+
deploymentFamily,
|
|
25
|
+
workflowRunFamily,
|
|
26
|
+
issueFamily,
|
|
27
|
+
pullRequestReviewFamily,
|
|
28
|
+
],
|
|
29
|
+
live: {
|
|
30
|
+
kind: 'receive',
|
|
31
|
+
route: '/webhook/github',
|
|
32
|
+
check: {
|
|
33
|
+
kind: 'hmac-sha256',
|
|
34
|
+
header: 'x-hub-signature-256',
|
|
35
|
+
secretEnv: 'PEPYS_GITHUB_WEBHOOK_SECRET',
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// GitHub calls from the public internet
|
|
39
|
+
reach: 'public',
|
|
40
|
+
read: readGithubDelivery,
|
|
41
|
+
},
|
|
42
|
+
recovery: {
|
|
43
|
+
kind: 'pull',
|
|
44
|
+
name: 'redeliver',
|
|
45
|
+
intervalMs: 3_600_000,
|
|
46
|
+
env: appEnv,
|
|
47
|
+
pull: pullFailedDeliveries,
|
|
48
|
+
},
|
|
49
|
+
resync: {
|
|
50
|
+
kind: 'pull',
|
|
51
|
+
name: 'resync',
|
|
52
|
+
intervalMs: 60_000,
|
|
53
|
+
env: appEnv,
|
|
54
|
+
pull: pullMarkedObjects,
|
|
55
|
+
},
|
|
56
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { githubPlugin } from './github-plugin.ts';
|
|
2
|
+
export { githubPullRequestConsumer } from './github-pull-request-consumer.ts';
|
|
3
|
+
export { githubPullRequestSchemaPath } from './github-pull-request-schema-path.ts';
|
|
4
|
+
export { githubDeliveryConsumers } from './queries/github-delivery-consumers.ts';
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
parsePullRequestDeliveries,
|
|
8
|
+
selectPullRequestDelivery,
|
|
9
|
+
} from './queries/select-pull-request-delivery.ts';
|
|
10
|
+
|
|
11
|
+
export type { PullRequestDelivery } from './queries/select-pull-request-delivery.ts';
|
|
12
|
+
export { deploySignals } from './utils/deploy-signals.ts';
|
|
13
|
+
export type { DeploySignal } from './utils/deploy-signals.ts';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parseFactEnvelope } from '@pepys/kit';
|
|
2
|
+
import type { PendingFact, StreamName } from '@pepys/kit';
|
|
3
|
+
import { toTagged } from '@zgeoff/std';
|
|
4
|
+
import { buildBodyHash } from '../utils/build-body-hash.ts';
|
|
5
|
+
import { githubEventRules } from './github-event-rules.ts';
|
|
6
|
+
|
|
7
|
+
// `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';
|
|
10
|
+
|
|
11
|
+
// 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.
|
|
14
|
+
export function buildGithubFact(
|
|
15
|
+
event: string,
|
|
16
|
+
body: unknown,
|
|
17
|
+
origin: GithubFactOrigin,
|
|
18
|
+
): PendingFact | 'ignored' | 'invalid' {
|
|
19
|
+
const rule = Object.hasOwn(githubEventRules, event) ? githubEventRules[event] : undefined;
|
|
20
|
+
|
|
21
|
+
if (rule === undefined) {
|
|
22
|
+
return 'ignored';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fields = rule.read(body);
|
|
26
|
+
|
|
27
|
+
if (fields === 'ignored' || fields === 'invalid') {
|
|
28
|
+
return fields;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const envelope = parseFactEnvelope({
|
|
32
|
+
id:
|
|
33
|
+
origin === 'delivery'
|
|
34
|
+
? `webhook:github:h-${buildBodyHash(body)}`
|
|
35
|
+
: `observation:${buildBodyHash(body)}`,
|
|
36
|
+
type: fields.type,
|
|
37
|
+
v: 1,
|
|
38
|
+
api: rule.family.api,
|
|
39
|
+
entity: fields.entity,
|
|
40
|
+
...(fields.actor === undefined ? {} : { actor: fields.actor }),
|
|
41
|
+
at: fields.at,
|
|
42
|
+
payload: body,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!envelope.ok) {
|
|
46
|
+
return 'invalid';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
stream: toTagged<StreamName>(rule.family.stream),
|
|
51
|
+
fact: envelope.value,
|
|
52
|
+
blobs: [],
|
|
53
|
+
...(fields.locator === undefined ? {} : { locator: fields.locator }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createSign } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// The JWT a GitHub App signs to call the API as itself. GitHub refuses an `exp` more than ten
|
|
4
|
+
// minutes after `iat`, and sets `iat` a minute back for clocks that drift. A private key kept in
|
|
5
|
+
// one line of an env file holds `\n` in place of each line break.
|
|
6
|
+
export function buildAppJWT(appID: string, privateKey: string, nowMs: number): string {
|
|
7
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
8
|
+
const header = toSegment({ alg: 'RS256', typ: 'JWT' });
|
|
9
|
+
const claims = toSegment({ iat: nowSeconds - 60, exp: nowSeconds + 540, iss: appID });
|
|
10
|
+
|
|
11
|
+
const signature = createSign('RSA-SHA256')
|
|
12
|
+
.update(`${header}.${claims}`)
|
|
13
|
+
.sign(privateKey.replaceAll(String.raw`\n`, '\n'), 'base64url');
|
|
14
|
+
|
|
15
|
+
return `${header}.${claims}.${signature}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toSegment(value: object): string {
|
|
19
|
+
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { buildAppJWT } from './build-app-jwt.ts';
|
|
2
|
+
|
|
3
|
+
// The App's JWT from the pull's env, or undefined when the private key cannot sign.
|
|
4
|
+
export function findAppJWT(
|
|
5
|
+
env: Readonly<Record<string, string>>,
|
|
6
|
+
nowMs: number,
|
|
7
|
+
): string | undefined {
|
|
8
|
+
try {
|
|
9
|
+
return buildAppJWT(
|
|
10
|
+
env['PEPYS_GITHUB_APP_ID'] ?? '',
|
|
11
|
+
env['PEPYS_GITHUB_APP_PRIVATE_KEY'] ?? '',
|
|
12
|
+
nowMs,
|
|
13
|
+
);
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const githubAPIURL = 'https://api.github.com';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Pulled } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
const retryAfterMs = 60_000;
|
|
4
|
+
|
|
5
|
+
// What a pull answers for an API status that stopped it. A 401 means the App's credentials are
|
|
6
|
+
// wrong, and no retry mends that; any other status, or 0 for a transport failure, may pass.
|
|
7
|
+
export function pickPullFailure(status: number): Pulled {
|
|
8
|
+
return status === 401
|
|
9
|
+
? { kind: 'rejected', reason: 'GitHub refused the App token' }
|
|
10
|
+
: { kind: 'unavailable', retryAfterMs };
|
|
11
|
+
}
|