@pepys/source-github 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/package.json +6 -5
- 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/index.ts +1 -0
- 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 +48 -10
- package/src/ingest/resync/collect-resync-targets.ts +12 -2
- package/src/ingest/resync/pull-marked-objects.ts +5 -6
- package/src/queries/github-delivery.ts +13 -0
package/README.md
CHANGED
|
@@ -27,6 +27,9 @@ bun add @pepys/source-github
|
|
|
27
27
|
|
|
28
28
|
- `githubPlugin`: the plug-in, to add to a deployment.
|
|
29
29
|
- `githubPullRequestConsumer`: the consumer of pull request facts, with its SQL schema.
|
|
30
|
+
- `githubDelivery`: what a query of another plug-in needs to read when each pull request merged,
|
|
31
|
+
released and deployed. Pass `consumers` as the query's `reads`, run `select(input).execute(db)`,
|
|
32
|
+
and give the result's `rows` to `parse`.
|
|
30
33
|
|
|
31
34
|
## Documentation
|
|
32
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/source-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The pepys GitHub plug-in: pull requests, releases, commits and deploys",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,16 +19,17 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pepys/api": "0.
|
|
23
|
-
"@pepys/ingest": "0.
|
|
24
|
-
"@pepys/kit": "0.
|
|
22
|
+
"@pepys/api": "0.2.0",
|
|
23
|
+
"@pepys/ingest": "0.2.0",
|
|
24
|
+
"@pepys/kit": "0.2.0",
|
|
25
25
|
"@zgeoff/std": "0.4.3",
|
|
26
26
|
"kysely": "0.29.8",
|
|
27
|
+
"ts-pattern": "5.9.2",
|
|
27
28
|
"zod": "4.6.7"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@msw/data": "1.1.8",
|
|
31
|
-
"@pepys/testing": "0.
|
|
32
|
+
"@pepys/testing": "0.2.0",
|
|
32
33
|
"msw": "3.0.3",
|
|
33
34
|
"tiny-invariant": "1.3.3"
|
|
34
35
|
}
|
|
@@ -104,7 +104,8 @@ function collectReleaseBumps(
|
|
|
104
104
|
return bumps;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text.
|
|
107
|
+
// `<owner>/<name>/releases/tag/<tag>`, up to the first character that ends a URL in text. GitHub
|
|
108
|
+
// serves a tag that holds `/` both escaped and unescaped, so the tag is the rest of the path.
|
|
108
109
|
function parseReleaseURLPath(text: string): { repository: string; tag: string } | undefined {
|
|
109
110
|
let end = text.length;
|
|
110
111
|
|
|
@@ -117,17 +118,19 @@ function parseReleaseURLPath(text: string): { repository: string; tag: string }
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const parts = text.slice(0, end).split('/');
|
|
120
|
-
const [owner, name, releases, tagWord
|
|
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',
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { githubPlugin } from './github-plugin.ts';
|
|
2
2
|
export { githubPullRequestConsumer } from './github-pull-request-consumer.ts';
|
|
3
3
|
export { githubPullRequestSchemaPath } from './github-pull-request-schema-path.ts';
|
|
4
|
+
export { githubDelivery } from './queries/github-delivery.ts';
|
|
4
5
|
export { githubDeliveryConsumers } from './queries/github-delivery-consumers.ts';
|
|
5
6
|
|
|
6
7
|
export {
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { parseFactEnvelope } from '@pepys/kit';
|
|
2
2
|
import type { PendingFact, StreamName } from '@pepys/kit';
|
|
3
3
|
import { toTagged } from '@zgeoff/std';
|
|
4
|
+
import { match } from 'ts-pattern';
|
|
4
5
|
import { buildBodyHash } from '../utils/build-body-hash.ts';
|
|
5
6
|
import { githubEventRules } from './github-event-rules.ts';
|
|
6
7
|
|
|
7
8
|
// `delivery`: a webhook body, from the receive path or the recovery pull. `observation`: an object
|
|
8
|
-
// that the resync pull read from the API, in the shape of a webhook body
|
|
9
|
-
|
|
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';
|
|
@@ -24,7 +25,7 @@ export async function pullFailedDeliveries(
|
|
|
24
25
|
return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const seenID = cursor === null ?
|
|
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
1
|
import type { 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
7
|
entity: string;
|
|
7
8
|
repository: string;
|
|
8
9
|
path: string;
|
|
10
|
+
lastBodyDigest: string | undefined;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const locatorPatterns = [
|
|
@@ -24,7 +26,15 @@ export function collectResyncTargets(marks: readonly ResyncMark[]): ResyncTarget
|
|
|
24
26
|
const kind = locatorPattern.kind;
|
|
25
27
|
|
|
26
28
|
if (repository !== undefined && mark.entity.startsWith(`github:${kind}:`)) {
|
|
27
|
-
return [
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
kind,
|
|
32
|
+
entity: mark.entity,
|
|
33
|
+
repository,
|
|
34
|
+
path: locator,
|
|
35
|
+
lastBodyDigest: mark.lastBodyDigest,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { buildGithubFact } from '../build-github-fact.ts';
|
|
4
|
+
import type { GithubFactOrigin } from '../build-github-fact.ts';
|
|
4
5
|
import { findAppJWT } from '../github-app/find-app-jwt.ts';
|
|
5
6
|
import { pickPullFailure } from '../github-app/pick-pull-failure.ts';
|
|
6
7
|
import { readInstallationToken } from '../github-app/read-installation-token.ts';
|
|
@@ -97,13 +98,11 @@ async function readObject(target: ResyncTarget, token: string): Promise<ReadObje
|
|
|
97
98
|
return { kind: 'failed', status: answer.status };
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
const origin: GithubFactOrigin = { kind: 'observation', lastBodyDigest: target.lastBodyDigest };
|
|
102
|
+
|
|
100
103
|
if (target.kind === 'pull_request') {
|
|
101
104
|
return toReadObject(
|
|
102
|
-
buildGithubFact(
|
|
103
|
-
'pull_request',
|
|
104
|
-
{ action: 'resynced', pull_request: answer.body },
|
|
105
|
-
'observation',
|
|
106
|
-
),
|
|
105
|
+
buildGithubFact('pull_request', { action: 'resynced', pull_request: answer.body }, origin),
|
|
107
106
|
);
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -122,7 +121,7 @@ async function readObject(target: ResyncTarget, token: string): Promise<ReadObje
|
|
|
122
121
|
repository: { id: parsed.data.id, full_name: parsed.data.full_name },
|
|
123
122
|
};
|
|
124
123
|
|
|
125
|
-
return toReadObject(buildGithubFact('release', body,
|
|
124
|
+
return toReadObject(buildGithubFact('release', body, origin));
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
function toReadObject(fact: PendingFact | 'ignored' | 'invalid'): ReadObject {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { githubDeliveryConsumers } from './github-delivery-consumers.ts';
|
|
2
|
+
import {
|
|
3
|
+
parsePullRequestDeliveries,
|
|
4
|
+
selectPullRequestDelivery,
|
|
5
|
+
} from './select-pull-request-delivery.ts';
|
|
6
|
+
|
|
7
|
+
// What a query of another plug-in needs to read deliveries: `consumers` for its `reads`, `select`
|
|
8
|
+
// for the statement to run on its `db`, and `parse` for the rows of the result.
|
|
9
|
+
export const githubDelivery = {
|
|
10
|
+
consumers: githubDeliveryConsumers,
|
|
11
|
+
select: selectPullRequestDelivery,
|
|
12
|
+
parse: parsePullRequestDeliveries,
|
|
13
|
+
} as const;
|