@pepys/source-github 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/package.json +7 -5
- package/src/api/serve-api.ts +2 -1
- package/src/consumers/github-pull-request.ts +8 -5
- package/src/families/build-family-types.ts +0 -1
- package/src/families/issue-family.ts +0 -1
- package/src/families/pull-request-review-family.ts +0 -1
- package/src/families/workflow-run-family.ts +0 -1
- package/src/generated/github-deploy-boundary.ts +18 -2
- package/src/generated/github-pull-request-boundary.ts +17 -1
- package/src/generated/github-ref-boundary.ts +17 -1
- package/src/generated/github-release-boundary.ts +17 -1
- package/src/generated/github-release.sql +2 -1
- package/src/github-release-consumer.ts +3 -1
- package/src/ingest/build-github-fact.ts +21 -8
- package/src/ingest/github-app/find-app-jwt.ts +0 -1
- package/src/ingest/github-app/request-github-api.ts +5 -2
- package/src/ingest/read-github-delivery.ts +1 -1
- package/src/ingest/recovery/pull-failed-deliveries.ts +49 -11
- package/src/ingest/resync/collect-resync-targets.ts +14 -4
- package/src/ingest/resync/pull-marked-objects.ts +6 -7
- package/src/queries/pull-request-delivery-query.ts +6 -5
- package/src/queries/select-pull-request-delivery.ts +11 -9
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
<p>
|
|
10
10
|
<a href="../../docs/README.md">Documentation</a> •
|
|
11
|
-
<a href="../../docs/
|
|
11
|
+
<a href="../../docs/architecture/plug-ins/github.md">The GitHub plug-in</a>
|
|
12
12
|
</p>
|
|
13
13
|
</div>
|
|
14
14
|
|
|
@@ -20,7 +20,23 @@ with its consumer.
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
22
|
```sh
|
|
23
|
-
bun add @pepys/source-github
|
|
23
|
+
bun add @pepys/source-github @pepys/kit
|
|
24
|
+
bun add -d @pepys/cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Add the plug-in to a deployment, then build its consumers. The build compiles each consumer, which
|
|
28
|
+
needs zig and wasm-tools, as the [CLI README](../cli/README.md) says.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// deployment.ts
|
|
32
|
+
import { defineDeployment } from '@pepys/kit';
|
|
33
|
+
import { githubPlugin } from '@pepys/source-github';
|
|
34
|
+
|
|
35
|
+
export const deployment = defineDeployment({ name: 'home', plugins: [githubPlugin] });
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bunx pepys build deployment.ts --out build
|
|
24
40
|
```
|
|
25
41
|
|
|
26
42
|
## Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/source-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The pepys GitHub plug-in: pull requests, releases, commits and deploys",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,16 +19,18 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pepys/api": "0.
|
|
23
|
-
"@pepys/ingest": "0.
|
|
24
|
-
"@pepys/kit": "0.
|
|
22
|
+
"@pepys/api": "0.3.0",
|
|
23
|
+
"@pepys/ingest": "0.3.0",
|
|
24
|
+
"@pepys/kit": "0.3.0",
|
|
25
25
|
"@zgeoff/std": "0.4.3",
|
|
26
26
|
"kysely": "0.29.8",
|
|
27
|
+
"ts-pattern": "5.9.2",
|
|
27
28
|
"zod": "4.6.7"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@msw/data": "1.1.8",
|
|
31
|
-
"@
|
|
32
|
+
"@orpc/client": "2.0.0-beta.50",
|
|
33
|
+
"@pepys/testing": "0.3.0",
|
|
32
34
|
"msw": "3.0.3",
|
|
33
35
|
"tiny-invariant": "1.3.3"
|
|
34
36
|
}
|
package/src/api/serve-api.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { serveAPI } from '@pepys/api';
|
|
|
2
2
|
import { defineDeployment } from '@pepys/kit';
|
|
3
3
|
import { githubPlugin } from '../github-plugin.ts';
|
|
4
4
|
|
|
5
|
-
serveAPI({
|
|
5
|
+
await serveAPI({
|
|
6
6
|
deployment: defineDeployment({ name: 'github', plugins: [githubPlugin] }),
|
|
7
7
|
environment: process.env,
|
|
8
|
+
argv: process.argv.slice(2),
|
|
8
9
|
});
|
|
@@ -104,7 +104,8 @@ function collectReleaseBumps(
|
|
|
104
104
|
return bumps;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text.
|
|
107
|
+
// `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text. GitHub
|
|
108
|
+
// serves a tag that holds `/` both escaped and unescaped, so the tag is the rest of the path.
|
|
108
109
|
function parseReleaseURLPath(text: string): { repository: string; tag: string } | undefined {
|
|
109
110
|
let end = text.length;
|
|
110
111
|
|
|
@@ -117,17 +118,19 @@ function parseReleaseURLPath(text: string): { repository: string; tag: string }
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const parts = text.slice(0, end).split('/');
|
|
120
|
-
const [owner, name, releases, tagWord
|
|
121
|
+
const [owner, name, releases, tagWord] = parts;
|
|
122
|
+
const tagParts = parts.slice(4);
|
|
123
|
+
const tag = tagParts.join('/');
|
|
121
124
|
|
|
122
|
-
if (parts.length
|
|
125
|
+
if (parts.length < 5 || releases !== 'releases' || tagWord !== 'tag') {
|
|
123
126
|
return undefined;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
if (owner === undefined || name === undefined
|
|
129
|
+
if (owner === undefined || name === undefined) {
|
|
127
130
|
return undefined;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
if (owner === '' || name === '' ||
|
|
133
|
+
if (owner === '' || name === '' || tagParts.includes('')) {
|
|
131
134
|
return undefined;
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -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',
|
|
@@ -85,6 +85,10 @@ export function updateGithubDeployModel(model: GithubDeployModel, message: Githu
|
|
|
85
85
|
return message.kind === 'reset' ? initGithubDeployModel() : { ...model, input: message.input };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
declare const factTag: unique symbol;
|
|
89
|
+
|
|
90
|
+
export type FactID = string & { readonly [factTag]: 'FactID' };
|
|
91
|
+
|
|
88
92
|
export interface GithubRefPushedPayload {
|
|
89
93
|
ref: string;
|
|
90
94
|
repository: GithubRefPushedPayloadRepository;
|
|
@@ -103,8 +107,8 @@ export interface GithubDeploymentPayloadDeployment {
|
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
export type GithubDeployFact =
|
|
106
|
-
| { id:
|
|
107
|
-
| { id:
|
|
110
|
+
| { id: FactID; type: 'github.ref.pushed'; entity: EntityID; at: string; payload: GithubRefPushedPayload }
|
|
111
|
+
| { id: FactID; type: 'github.deployment.created' | 'github.deployment.status_created'; entity: EntityID; at: string; payload: GithubDeploymentPayload };
|
|
108
112
|
|
|
109
113
|
export function parseGithubDeployFact(factJSON: string): GithubDeployFact | undefined {
|
|
110
114
|
const parsed: unknown = JSON.parse(factJSON);
|
|
@@ -127,6 +131,10 @@ function decodeGithubDeployFact(value: unknown): GithubDeployFact | undefined {
|
|
|
127
131
|
return undefined;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
if (!isFactID(id) || !isFactEntityID(entity)) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
130
138
|
if (type === 'github.ref.pushed') {
|
|
131
139
|
const decoded = decodeGithubRefPushedPayload(payload);
|
|
132
140
|
|
|
@@ -150,6 +158,14 @@ function decodeGithubDeployFact(value: unknown): GithubDeployFact | undefined {
|
|
|
150
158
|
return undefined;
|
|
151
159
|
}
|
|
152
160
|
|
|
161
|
+
function isFactID(text: string): text is FactID {
|
|
162
|
+
return text.length > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isFactEntityID(text: string): text is EntityID {
|
|
166
|
+
return text.length > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
153
169
|
function decodeGithubRefPushedPayload(value: unknown): GithubRefPushedPayload | undefined {
|
|
154
170
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
155
171
|
return undefined;
|
|
@@ -45,6 +45,10 @@ export function updateGithubPullRequestModel(model: GithubPullRequestModel, mess
|
|
|
45
45
|
return message.kind === 'reset' ? initGithubPullRequestModel() : { ...model, input: message.input };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
declare const factTag: unique symbol;
|
|
49
|
+
|
|
50
|
+
export type FactID = string & { readonly [factTag]: 'FactID' };
|
|
51
|
+
|
|
48
52
|
export interface GithubPullRequestPayload {
|
|
49
53
|
action: string;
|
|
50
54
|
pull_request: GithubPullRequestPayloadPullRequest;
|
|
@@ -56,7 +60,7 @@ export interface GithubPullRequestPayloadPullRequest {
|
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
export type GithubPullRequestFact =
|
|
59
|
-
| { id:
|
|
63
|
+
| { id: FactID; type: 'github.pull_request.assigned' | 'github.pull_request.auto_merge_disabled' | 'github.pull_request.auto_merge_enabled' | 'github.pull_request.closed' | 'github.pull_request.converted_to_draft' | 'github.pull_request.demilestoned' | 'github.pull_request.dequeued' | 'github.pull_request.edited' | 'github.pull_request.enqueued' | 'github.pull_request.labeled' | 'github.pull_request.locked' | 'github.pull_request.milestoned' | 'github.pull_request.opened' | 'github.pull_request.ready_for_review' | 'github.pull_request.reopened' | 'github.pull_request.resynced' | 'github.pull_request.review_request_removed' | 'github.pull_request.review_requested' | 'github.pull_request.synchronize' | 'github.pull_request.unassigned' | 'github.pull_request.unlabeled' | 'github.pull_request.unlocked'; entity: EntityID; at: string; payload: GithubPullRequestPayload };
|
|
60
64
|
|
|
61
65
|
export function parseGithubPullRequestFact(factJSON: string): GithubPullRequestFact | undefined {
|
|
62
66
|
const parsed: unknown = JSON.parse(factJSON);
|
|
@@ -79,6 +83,10 @@ function decodeGithubPullRequestFact(value: unknown): GithubPullRequestFact | un
|
|
|
79
83
|
return undefined;
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
if (!isFactID(id) || !isFactEntityID(entity)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
if (type === 'github.pull_request.assigned' || type === 'github.pull_request.auto_merge_disabled' || type === 'github.pull_request.auto_merge_enabled' || type === 'github.pull_request.closed' || type === 'github.pull_request.converted_to_draft' || type === 'github.pull_request.demilestoned' || type === 'github.pull_request.dequeued' || type === 'github.pull_request.edited' || type === 'github.pull_request.enqueued' || type === 'github.pull_request.labeled' || type === 'github.pull_request.locked' || type === 'github.pull_request.milestoned' || type === 'github.pull_request.opened' || type === 'github.pull_request.ready_for_review' || type === 'github.pull_request.reopened' || type === 'github.pull_request.resynced' || type === 'github.pull_request.review_request_removed' || type === 'github.pull_request.review_requested' || type === 'github.pull_request.synchronize' || type === 'github.pull_request.unassigned' || type === 'github.pull_request.unlabeled' || type === 'github.pull_request.unlocked') {
|
|
83
91
|
const decoded = decodeGithubPullRequestPayload(payload);
|
|
84
92
|
|
|
@@ -92,6 +100,14 @@ function decodeGithubPullRequestFact(value: unknown): GithubPullRequestFact | un
|
|
|
92
100
|
return undefined;
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
function isFactID(text: string): text is FactID {
|
|
104
|
+
return text.length > 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isFactEntityID(text: string): text is EntityID {
|
|
108
|
+
return text.length > 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
function decodeGithubPullRequestPayload(value: unknown): GithubPullRequestPayload | undefined {
|
|
96
112
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
97
113
|
return undefined;
|
|
@@ -45,6 +45,10 @@ export function updateGithubRefModel(model: GithubRefModel, message: GithubRefMe
|
|
|
45
45
|
return message.kind === 'reset' ? initGithubRefModel() : { ...model, input: message.input };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
declare const factTag: unique symbol;
|
|
49
|
+
|
|
50
|
+
export type FactID = string & { readonly [factTag]: 'FactID' };
|
|
51
|
+
|
|
48
52
|
export interface GithubRefPushedPayload {
|
|
49
53
|
ref: string;
|
|
50
54
|
repository: GithubRefPushedPayloadRepository;
|
|
@@ -55,7 +59,7 @@ export interface GithubRefPushedPayloadRepository {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export type GithubRefFact =
|
|
58
|
-
| { id:
|
|
62
|
+
| { id: FactID; type: 'github.ref.pushed'; entity: EntityID; at: string; payload: GithubRefPushedPayload };
|
|
59
63
|
|
|
60
64
|
export function parseGithubRefFact(factJSON: string): GithubRefFact | undefined {
|
|
61
65
|
const parsed: unknown = JSON.parse(factJSON);
|
|
@@ -78,6 +82,10 @@ function decodeGithubRefFact(value: unknown): GithubRefFact | undefined {
|
|
|
78
82
|
return undefined;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
if (!isFactID(id) || !isFactEntityID(entity)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
if (type === 'github.ref.pushed') {
|
|
82
90
|
const decoded = decodeGithubRefPushedPayload(payload);
|
|
83
91
|
|
|
@@ -91,6 +99,14 @@ function decodeGithubRefFact(value: unknown): GithubRefFact | undefined {
|
|
|
91
99
|
return undefined;
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
function isFactID(text: string): text is FactID {
|
|
103
|
+
return text.length > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isFactEntityID(text: string): text is EntityID {
|
|
107
|
+
return text.length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
function decodeGithubRefPushedPayload(value: unknown): GithubRefPushedPayload | undefined {
|
|
95
111
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
96
112
|
return undefined;
|
|
@@ -45,6 +45,10 @@ export function updateGithubReleaseModel(model: GithubReleaseModel, message: Git
|
|
|
45
45
|
return message.kind === 'reset' ? initGithubReleaseModel() : { ...model, input: message.input };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
declare const factTag: unique symbol;
|
|
49
|
+
|
|
50
|
+
export type FactID = string & { readonly [factTag]: 'FactID' };
|
|
51
|
+
|
|
48
52
|
export interface GithubReleasePayload {
|
|
49
53
|
action: string;
|
|
50
54
|
release: GithubReleasePayloadRelease;
|
|
@@ -55,7 +59,7 @@ export interface GithubReleasePayloadRelease {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export type GithubReleaseFact =
|
|
58
|
-
| { id:
|
|
62
|
+
| { id: FactID; type: 'github.release.created' | 'github.release.deleted' | 'github.release.edited' | 'github.release.prereleased' | 'github.release.published' | 'github.release.released' | 'github.release.resynced' | 'github.release.unpublished'; entity: EntityID; at: string; payload: GithubReleasePayload };
|
|
59
63
|
|
|
60
64
|
export function parseGithubReleaseFact(factJSON: string): GithubReleaseFact | undefined {
|
|
61
65
|
const parsed: unknown = JSON.parse(factJSON);
|
|
@@ -78,6 +82,10 @@ function decodeGithubReleaseFact(value: unknown): GithubReleaseFact | undefined
|
|
|
78
82
|
return undefined;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
if (!isFactID(id) || !isFactEntityID(entity)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
if (type === 'github.release.created' || type === 'github.release.deleted' || type === 'github.release.edited' || type === 'github.release.prereleased' || type === 'github.release.published' || type === 'github.release.released' || type === 'github.release.resynced' || type === 'github.release.unpublished') {
|
|
82
90
|
const decoded = decodeGithubReleasePayload(payload);
|
|
83
91
|
|
|
@@ -91,6 +99,14 @@ function decodeGithubReleaseFact(value: unknown): GithubReleaseFact | undefined
|
|
|
91
99
|
return undefined;
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
function isFactID(text: string): text is FactID {
|
|
103
|
+
return text.length > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isFactEntityID(text: string): text is EntityID {
|
|
107
|
+
return text.length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
function decodeGithubReleasePayload(value: unknown): GithubReleasePayload | undefined {
|
|
95
111
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
96
112
|
return undefined;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
2
|
import { releaseFamily } from './families/release-family.ts';
|
|
3
3
|
|
|
4
|
-
// One row per release, never deleted: a release GitHub deleted keeps its row with `deleted_at
|
|
4
|
+
// One row per release, never deleted: a release GitHub deleted keeps its row with `deleted_at`,
|
|
5
|
+
// which leaves it out of the `entity` view.
|
|
5
6
|
export const githubReleaseConsumer = defineConsumer({
|
|
6
7
|
name: 'github_release',
|
|
7
8
|
entry: new URL('consumers/github-release.ts', import.meta.url),
|
|
@@ -36,6 +37,7 @@ export const githubReleaseConsumer = defineConsumer({
|
|
|
36
37
|
url: 'url',
|
|
37
38
|
actor: null,
|
|
38
39
|
updatedAt: 'updated_at',
|
|
40
|
+
deletedAt: 'deleted_at',
|
|
39
41
|
},
|
|
40
42
|
],
|
|
41
43
|
reads: ['github_release'],
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { parseFactEnvelope } from '@pepys/kit';
|
|
2
2
|
import type { PendingFact, StreamName } from '@pepys/kit';
|
|
3
3
|
import { toTagged } from '@zgeoff/std';
|
|
4
|
+
import { match } from 'ts-pattern';
|
|
4
5
|
import { buildBodyHash } from '../utils/build-body-hash.ts';
|
|
5
6
|
import { githubEventRules } from './github-event-rules.ts';
|
|
6
7
|
|
|
7
8
|
// `delivery`: a webhook body, from the receive path or the recovery pull. `observation`: an object
|
|
8
|
-
// that the resync pull read from the API, in the shape of a webhook body
|
|
9
|
-
|
|
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.
|
|
13
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
:
|
|
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,8 +1,10 @@
|
|
|
1
1
|
import { githubAPIURL } from './github-api-url.ts';
|
|
2
2
|
|
|
3
|
-
// One answer of the GitHub REST API. `
|
|
3
|
+
// One answer of the GitHub REST API. `text` is the body as sent, for a caller that must read a
|
|
4
|
+
// number above 2^53 exactly. `next` is the URL of the next page, from the `link` header.
|
|
4
5
|
export interface GithubAnswer {
|
|
5
6
|
status: number;
|
|
7
|
+
text: string;
|
|
6
8
|
body: unknown;
|
|
7
9
|
next: string | undefined;
|
|
8
10
|
}
|
|
@@ -19,13 +21,14 @@ export async function requestGithubAPI(
|
|
|
19
21
|
const response = await sendRequest(url, token, method);
|
|
20
22
|
|
|
21
23
|
if (response === undefined) {
|
|
22
|
-
return { status: 0, body: undefined, next: undefined };
|
|
24
|
+
return { status: 0, text: '', body: undefined, next: undefined };
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const text = await response.text();
|
|
26
28
|
|
|
27
29
|
return {
|
|
28
30
|
status: response.status,
|
|
31
|
+
text,
|
|
29
32
|
body: parseBody(text),
|
|
30
33
|
next: findNextPage(response.headers.get('link')),
|
|
31
34
|
};
|
|
@@ -17,7 +17,7 @@ export function readGithubDelivery(delivery: Delivery): Received {
|
|
|
17
17
|
return { kind: 'refused', status: 400, reason: 'the body is not JSON' };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const fact = buildGithubFact(event, body.value, 'delivery');
|
|
20
|
+
const fact = buildGithubFact(event, body.value, { kind: 'delivery' });
|
|
21
21
|
|
|
22
22
|
if (fact === 'ignored') {
|
|
23
23
|
return { kind: 'ignored' };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { planAppends } from '@pepys/ingest';
|
|
1
2
|
import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { buildGithubFact } from '../build-github-fact.ts';
|
|
@@ -18,13 +19,13 @@ export async function pullFailedDeliveries(
|
|
|
18
19
|
cursor: string | null,
|
|
19
20
|
context: PullContext,
|
|
20
21
|
): Promise<Pulled> {
|
|
21
|
-
const appJWT = findAppJWT(context.env,
|
|
22
|
+
const appJWT = findAppJWT(context.env, context.now());
|
|
22
23
|
|
|
23
24
|
if (appJWT === undefined) {
|
|
24
25
|
return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const seenID = cursor === null ?
|
|
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,
|
|
47
|
+
const fact = buildGithubFact(detail.data.event, detail.data.request.payload, {
|
|
48
|
+
kind: 'delivery',
|
|
49
|
+
});
|
|
47
50
|
|
|
48
|
-
if (fact
|
|
49
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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.
|
|
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[]):
|
|
133
|
+
function collectFailedIDs(items: readonly DeliveryItem[]): bigint[] {
|
|
96
134
|
return items
|
|
97
135
|
.filter((item) => item.status_code < 200 || item.status_code >= 300)
|
|
98
136
|
.map((item) => item.id)
|
|
99
|
-
.toSorted((left, right) => left - right);
|
|
137
|
+
.toSorted((left, right) => Number(left - right));
|
|
100
138
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type { ResyncMark } from '@pepys/kit';
|
|
1
|
+
import type { EntityID, ResyncMark } from '@pepys/kit';
|
|
2
2
|
|
|
3
|
-
// One marked object that the resync pull reads again: its repository, `owner/name`,
|
|
3
|
+
// One marked object that the resync pull reads again: its repository, `owner/name`, its API path,
|
|
4
|
+
// and the digest of the last body ingest stored for it.
|
|
4
5
|
export interface ResyncTarget {
|
|
5
6
|
kind: 'pull_request' | 'release';
|
|
6
|
-
entity:
|
|
7
|
+
entity: EntityID;
|
|
7
8
|
repository: string;
|
|
8
9
|
path: string;
|
|
10
|
+
lastBodyDigest: string | undefined;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const locatorPatterns = [
|
|
@@ -24,7 +26,15 @@ export function collectResyncTargets(marks: readonly ResyncMark[]): ResyncTarget
|
|
|
24
26
|
const kind = locatorPattern.kind;
|
|
25
27
|
|
|
26
28
|
if (repository !== undefined && mark.entity.startsWith(`github:${kind}:`)) {
|
|
27
|
-
return [
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
kind,
|
|
32
|
+
entity: mark.entity,
|
|
33
|
+
repository,
|
|
34
|
+
path: locator,
|
|
35
|
+
lastBodyDigest: mark.lastBodyDigest,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { buildGithubFact } from '../build-github-fact.ts';
|
|
4
|
+
import type { GithubFactOrigin } from '../build-github-fact.ts';
|
|
4
5
|
import { findAppJWT } from '../github-app/find-app-jwt.ts';
|
|
5
6
|
import { pickPullFailure } from '../github-app/pick-pull-failure.ts';
|
|
6
7
|
import { readInstallationToken } from '../github-app/read-installation-token.ts';
|
|
@@ -22,7 +23,7 @@ export async function pullMarkedObjects(
|
|
|
22
23
|
return { kind: 'batch', batch: { facts: [] }, cursor: 'resynced', isDone: true };
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
const appJWT = findAppJWT(context.env,
|
|
26
|
+
const appJWT = findAppJWT(context.env, context.now());
|
|
26
27
|
|
|
27
28
|
if (appJWT === undefined) {
|
|
28
29
|
return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
|
|
@@ -97,13 +98,11 @@ async function readObject(target: ResyncTarget, token: string): Promise<ReadObje
|
|
|
97
98
|
return { kind: 'failed', status: answer.status };
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
const origin: GithubFactOrigin = { kind: 'observation', lastBodyDigest: target.lastBodyDigest };
|
|
102
|
+
|
|
100
103
|
if (target.kind === 'pull_request') {
|
|
101
104
|
return toReadObject(
|
|
102
|
-
buildGithubFact(
|
|
103
|
-
'pull_request',
|
|
104
|
-
{ action: 'resynced', pull_request: answer.body },
|
|
105
|
-
'observation',
|
|
106
|
-
),
|
|
105
|
+
buildGithubFact('pull_request', { action: 'resynced', pull_request: answer.body }, origin),
|
|
107
106
|
);
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -122,7 +121,7 @@ async function readObject(target: ResyncTarget, token: string): Promise<ReadObje
|
|
|
122
121
|
repository: { id: parsed.data.id, full_name: parsed.data.full_name },
|
|
123
122
|
};
|
|
124
123
|
|
|
125
|
-
return toReadObject(buildGithubFact('release', body,
|
|
124
|
+
return toReadObject(buildGithubFact('release', body, origin));
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
function toReadObject(fact: PendingFact | 'ignored' | 'invalid'): ReadObject {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineQuery } from '@pepys/kit';
|
|
1
|
+
import { defineQuery, entityIDSchema, isEntityID } from '@pepys/kit';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { deploySignals } from '../utils/deploy-signals.ts';
|
|
4
4
|
import { githubDeliveryConsumers } from './github-delivery-consumers.ts';
|
|
@@ -9,12 +9,12 @@ import {
|
|
|
9
9
|
|
|
10
10
|
const pullRequestsSchema = z.array(z.string()).max(200);
|
|
11
11
|
const nameSchema = z.string().min(1);
|
|
12
|
-
const releaseSchema = z.object({ id:
|
|
12
|
+
const releaseSchema = z.object({ id: entityIDSchema, tag: z.string(), at: z.string() });
|
|
13
13
|
const signalSchema = z.enum(deploySignals);
|
|
14
|
-
const deploySchema = z.object({ id:
|
|
14
|
+
const deploySchema = z.object({ id: entityIDSchema, at: z.string(), signal: signalSchema });
|
|
15
15
|
|
|
16
16
|
const deliverySchema = z.object({
|
|
17
|
-
pullRequest:
|
|
17
|
+
pullRequest: entityIDSchema,
|
|
18
18
|
mergedAt: z.string().nullable(),
|
|
19
19
|
release: releaseSchema.nullable(),
|
|
20
20
|
deploy: deploySchema.nullable(),
|
|
@@ -32,7 +32,8 @@ export const pullRequestDeliveryQuery = defineQuery({
|
|
|
32
32
|
output: z.array(deliverySchema),
|
|
33
33
|
reads: githubDeliveryConsumers,
|
|
34
34
|
handler: async (call) => {
|
|
35
|
-
|
|
35
|
+
// text that is no entity ID names no stored pull request, so it has no row either way
|
|
36
|
+
const [first, ...rest] = call.input.pullRequests.filter((id) => isEntityID(id));
|
|
36
37
|
|
|
37
38
|
if (first === undefined) {
|
|
38
39
|
return [];
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { entityIDSchema } from '@pepys/kit';
|
|
2
|
+
import type { EntityID } from '@pepys/kit';
|
|
1
3
|
import { sql } from 'kysely';
|
|
2
4
|
import type { RawBuilder } from 'kysely';
|
|
3
5
|
import { z } from 'zod';
|
|
@@ -6,7 +8,7 @@ import type { DeploySignal } from '../utils/deploy-signals.ts';
|
|
|
6
8
|
|
|
7
9
|
interface DeliveryOptions {
|
|
8
10
|
// at least one
|
|
9
|
-
pullRequests: readonly [
|
|
11
|
+
pullRequests: readonly [EntityID, ...EntityID[]];
|
|
10
12
|
environment: string;
|
|
11
13
|
|
|
12
14
|
// Absent: a deploy of any target, and of none, counts. Given: only a deploy of that target.
|
|
@@ -14,8 +16,8 @@ interface DeliveryOptions {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
// The statement that ties pull requests to releases and deploys. The rules it holds are in the
|
|
17
|
-
// "
|
|
18
|
-
// into deliveries with the parse function below.
|
|
19
|
+
// "The delivery query" section of docs/architecture/plug-ins/github.md. Run it with `.execute(db)`,
|
|
20
|
+
// and turn its rows into deliveries with the parse function below.
|
|
19
21
|
export function selectPullRequestDelivery(options: DeliveryOptions): RawBuilder<unknown> {
|
|
20
22
|
return sql`select
|
|
21
23
|
pull_request.id as "pullRequest",
|
|
@@ -115,22 +117,22 @@ function selectFirstDeployID(options: DeliveryOptions): RawBuilder<unknown> {
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
export interface PullRequestDelivery {
|
|
118
|
-
pullRequest:
|
|
120
|
+
pullRequest: EntityID;
|
|
119
121
|
mergedAt: string | null;
|
|
120
|
-
release: { id:
|
|
121
|
-
deploy: { id:
|
|
122
|
+
release: { id: EntityID; tag: string; at: string } | null;
|
|
123
|
+
deploy: { id: EntityID; at: string; signal: DeploySignal } | null;
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
const textSchema = z.string().nullable();
|
|
125
127
|
|
|
126
128
|
// One row per pull request, as the statement selects it.
|
|
127
129
|
const deliveryRowSchema = z.object({
|
|
128
|
-
pullRequest:
|
|
130
|
+
pullRequest: entityIDSchema,
|
|
129
131
|
mergedAt: textSchema,
|
|
130
|
-
releaseID:
|
|
132
|
+
releaseID: entityIDSchema.nullable(),
|
|
131
133
|
releaseTag: textSchema,
|
|
132
134
|
releaseAt: textSchema,
|
|
133
|
-
deployID:
|
|
135
|
+
deployID: entityIDSchema.nullable(),
|
|
134
136
|
deployAt: textSchema,
|
|
135
137
|
deploySignal: z.enum(deploySignals).nullable(),
|
|
136
138
|
});
|