@pepys/source-github 0.0.1 → 0.1.1

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 (53) hide show
  1. package/README.md +3 -0
  2. package/package.json +8 -5
  3. package/src/api/serve-api.ts +6 -35
  4. package/src/consumers/github-deploy.ts +199 -0
  5. package/src/consumers/github-pull-request.ts +96 -1
  6. package/src/consumers/github-ref.ts +88 -0
  7. package/src/consumers/github-release.ts +125 -0
  8. package/src/families/build-family-types.ts +16 -0
  9. package/src/families/deployment-family.ts +15 -0
  10. package/src/families/issue-family.ts +38 -0
  11. package/src/{pull-request-family.ts → families/pull-request-family.ts} +6 -5
  12. package/src/families/pull-request-review-family.ts +18 -0
  13. package/src/families/ref-family.ts +14 -0
  14. package/src/families/release-family.ts +28 -0
  15. package/src/families/workflow-run-family.ts +18 -0
  16. package/src/generated/github-deploy-boundary.ts +308 -0
  17. package/src/generated/github-deploy.sql +24 -0
  18. package/src/generated/github-pull-request-boundary.ts +21 -3
  19. package/src/generated/github-pull-request.sql +9 -0
  20. package/src/generated/github-ref-boundary.ts +149 -0
  21. package/src/generated/github-ref.sql +8 -0
  22. package/src/generated/github-release-boundary.ts +251 -0
  23. package/src/generated/github-release.sql +32 -0
  24. package/src/github-deploy-consumer.ts +40 -0
  25. package/src/github-plugin.ts +11 -22
  26. package/src/github-pull-request-consumer.ts +13 -1
  27. package/src/github-ref-consumer.ts +23 -0
  28. package/src/github-release-consumer.ts +43 -0
  29. package/src/github-settings.ts +15 -0
  30. package/src/github-source.ts +56 -0
  31. package/src/index.ts +11 -0
  32. package/src/ingest/build-github-fact.ts +55 -0
  33. package/src/ingest/github-app/build-app-jwt.ts +20 -0
  34. package/src/ingest/github-app/find-app-jwt.ts +17 -0
  35. package/src/ingest/github-app/github-api-url.ts +1 -0
  36. package/src/ingest/github-app/pick-pull-failure.ts +11 -0
  37. package/src/ingest/github-app/read-installation-token.ts +43 -0
  38. package/src/ingest/github-app/request-github-api.ts +75 -0
  39. package/src/ingest/github-event-rules.ts +324 -0
  40. package/src/ingest/read-github-delivery.ts +41 -0
  41. package/src/ingest/recovery/pull-failed-deliveries.ts +100 -0
  42. package/src/ingest/resync/collect-resync-targets.ts +33 -0
  43. package/src/ingest/resync/pull-marked-objects.ts +130 -0
  44. package/src/queries/github-delivery-consumers.ts +13 -0
  45. package/src/queries/github-delivery.ts +13 -0
  46. package/src/queries/pull-request-delivery-query.ts +49 -0
  47. package/src/queries/select-pull-request-delivery.ts +155 -0
  48. package/src/utils/build-body-hash.ts +34 -0
  49. package/src/utils/deploy-signals.ts +5 -0
  50. package/src/utils/find-field.ts +19 -0
  51. package/src/ingest/build-pull-request-fact.ts +0 -38
  52. package/src/ingest/pull-request-stream.ts +0 -4
  53. package/src/ingest/read-pull-request-delivery.ts +0 -44
@@ -0,0 +1,130 @@
1
+ import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
2
+ import { z } from 'zod';
3
+ import { buildGithubFact } from '../build-github-fact.ts';
4
+ import { findAppJWT } from '../github-app/find-app-jwt.ts';
5
+ import { pickPullFailure } from '../github-app/pick-pull-failure.ts';
6
+ import { readInstallationToken } from '../github-app/read-installation-token.ts';
7
+ import type { InstallationToken } from '../github-app/read-installation-token.ts';
8
+ import { requestGithubAPI } from '../github-app/request-github-api.ts';
9
+ import { collectResyncTargets } from './collect-resync-targets.ts';
10
+ import type { ResyncTarget } from './collect-resync-targets.ts';
11
+
12
+ // The resync pull: each marked pull request and release, read again from the API as one fact in the
13
+ // shape of its webhook body. The fact comes after the deliveries, so it wins a tie of times and sets
14
+ // the row to GitHub's state.
15
+ export async function pullMarkedObjects(
16
+ _cursor: string | null,
17
+ context: PullContext,
18
+ ): Promise<Pulled> {
19
+ const targets = collectResyncTargets(context.marks);
20
+
21
+ if (targets.length === 0) {
22
+ return { kind: 'batch', batch: { facts: [] }, cursor: 'resynced', isDone: true };
23
+ }
24
+
25
+ const appJWT = findAppJWT(context.env, Date.now());
26
+
27
+ if (appJWT === undefined) {
28
+ return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
29
+ }
30
+
31
+ const tokens = new Map<string, InstallationToken>();
32
+
33
+ const facts: PendingFact[] = [];
34
+
35
+ for (const target of targets) {
36
+ const token = await readCachedToken(tokens, target.repository, appJWT);
37
+
38
+ if (token.kind === 'failed') {
39
+ return pickPullFailure(token.status);
40
+ }
41
+
42
+ if (token.kind === 'not-installed') {
43
+ continue;
44
+ }
45
+
46
+ const read = await readObject(target, token.token);
47
+
48
+ if (read.kind === 'failed') {
49
+ return pickPullFailure(read.status);
50
+ }
51
+
52
+ if (read.kind === 'fact') {
53
+ facts.push(read.fact);
54
+ }
55
+ }
56
+
57
+ return { kind: 'batch', batch: { facts }, cursor: 'resynced', isDone: true };
58
+ }
59
+
60
+ // one installation token per repository in one run
61
+ async function readCachedToken(
62
+ tokens: Map<string, InstallationToken>,
63
+ repository: string,
64
+ appJWT: string,
65
+ ): Promise<InstallationToken> {
66
+ const known = tokens.get(repository);
67
+
68
+ if (known !== undefined) {
69
+ return known;
70
+ }
71
+
72
+ const token = await readInstallationToken(repository, appJWT);
73
+
74
+ tokens.set(repository, token);
75
+
76
+ return token;
77
+ }
78
+
79
+ const repositorySchema = z.looseObject({ id: z.number().int(), full_name: z.string() });
80
+
81
+ type ReadObject =
82
+ | { kind: 'fact'; fact: PendingFact }
83
+ | { kind: 'none' }
84
+ | { kind: 'failed'; status: number };
85
+
86
+ // An object the API answers 404 for is left alone. GitHub deletes no pull request, and shows a draft
87
+ // release only to a token that can push, so a 404 cannot prove a delete. A deleted release's own
88
+ // `deleted` delivery sets its tombstone.
89
+ async function readObject(target: ResyncTarget, token: string): Promise<ReadObject> {
90
+ const answer = await requestGithubAPI(target.path, token);
91
+
92
+ if (answer.status === 404) {
93
+ return { kind: 'none' };
94
+ }
95
+
96
+ if (answer.status !== 200) {
97
+ return { kind: 'failed', status: answer.status };
98
+ }
99
+
100
+ if (target.kind === 'pull_request') {
101
+ return toReadObject(
102
+ buildGithubFact(
103
+ 'pull_request',
104
+ { action: 'resynced', pull_request: answer.body },
105
+ 'observation',
106
+ ),
107
+ );
108
+ }
109
+
110
+ const repository = await requestGithubAPI(`/repos/${target.repository}`, token);
111
+
112
+ const parsed = repositorySchema.safeParse(repository.body);
113
+
114
+ if (repository.status !== 200 || !parsed.success) {
115
+ return { kind: 'failed', status: repository.status };
116
+ }
117
+
118
+ // only the two fields the release consumer reads, so the hash moves with the release alone
119
+ const body = {
120
+ action: 'resynced',
121
+ release: answer.body,
122
+ repository: { id: parsed.data.id, full_name: parsed.data.full_name },
123
+ };
124
+
125
+ return toReadObject(buildGithubFact('release', body, 'observation'));
126
+ }
127
+
128
+ function toReadObject(fact: PendingFact | 'ignored' | 'invalid'): ReadObject {
129
+ return typeof fact === 'string' ? { kind: 'none' } : { kind: 'fact', fact };
130
+ }
@@ -0,0 +1,13 @@
1
+ import { githubDeployConsumer } from '../github-deploy-consumer.ts';
2
+ import { githubPullRequestConsumer } from '../github-pull-request-consumer.ts';
3
+ import { githubRefConsumer } from '../github-ref-consumer.ts';
4
+ import { githubReleaseConsumer } from '../github-release-consumer.ts';
5
+
6
+ // The consumers whose tables the delivery query reads. A query of another plug-in that calls the
7
+ // query module passes these in its `reads`.
8
+ export const githubDeliveryConsumers = [
9
+ githubPullRequestConsumer,
10
+ githubReleaseConsumer,
11
+ githubRefConsumer,
12
+ githubDeployConsumer,
13
+ ] as const;
@@ -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;
@@ -0,0 +1,49 @@
1
+ import { defineQuery } from '@pepys/kit';
2
+ import { z } from 'zod';
3
+ import { deploySignals } from '../utils/deploy-signals.ts';
4
+ import { githubDeliveryConsumers } from './github-delivery-consumers.ts';
5
+ import {
6
+ parsePullRequestDeliveries,
7
+ selectPullRequestDelivery,
8
+ } from './select-pull-request-delivery.ts';
9
+
10
+ const pullRequestsSchema = z.array(z.string()).max(200);
11
+ const nameSchema = z.string().min(1);
12
+ const releaseSchema = z.object({ id: z.string(), tag: z.string(), at: z.string() });
13
+ const signalSchema = z.enum(deploySignals);
14
+ const deploySchema = z.object({ id: z.string(), at: z.string(), signal: signalSchema });
15
+
16
+ const deliverySchema = z.object({
17
+ pullRequest: z.string(),
18
+ mergedAt: z.string().nullable(),
19
+ release: releaseSchema.nullable(),
20
+ deploy: deploySchema.nullable(),
21
+ });
22
+
23
+ export const pullRequestDeliveryQuery = defineQuery({
24
+ name: 'github_pull_request_delivery',
25
+ description:
26
+ 'Returns, for each pull request, when it merged, the first release of its repository that contains it, and the first deploy to one environment of a commit that pins such a release. With a target, only a deploy of that target counts. A pull request with no row is left out.',
27
+ input: z.object({
28
+ pullRequests: pullRequestsSchema,
29
+ environment: nameSchema,
30
+ target: nameSchema.optional(),
31
+ }),
32
+ output: z.array(deliverySchema),
33
+ reads: githubDeliveryConsumers,
34
+ handler: async (call) => {
35
+ const [first, ...rest] = call.input.pullRequests;
36
+
37
+ if (first === undefined) {
38
+ return [];
39
+ }
40
+
41
+ const result = await selectPullRequestDelivery({
42
+ pullRequests: [first, ...rest],
43
+ environment: call.input.environment,
44
+ target: call.input.target,
45
+ }).execute(call.db);
46
+
47
+ return parsePullRequestDeliveries(result.rows);
48
+ },
49
+ });
@@ -0,0 +1,155 @@
1
+ import { sql } from 'kysely';
2
+ import type { RawBuilder } from 'kysely';
3
+ import { z } from 'zod';
4
+ import { deploySignals } from '../utils/deploy-signals.ts';
5
+ import type { DeploySignal } from '../utils/deploy-signals.ts';
6
+
7
+ interface DeliveryOptions {
8
+ // at least one
9
+ pullRequests: readonly [string, ...string[]];
10
+ environment: string;
11
+
12
+ // Absent: a deploy of any target, and of none, counts. Given: only a deploy of that target.
13
+ target?: string | undefined;
14
+ }
15
+
16
+ // The statement that ties pull requests to releases and deploys. The rules it holds are in the
17
+ // "Delivery query" section of the plug-ins design doc. Run it with `.execute(db)`, and turn its rows
18
+ // into deliveries with the parse function below.
19
+ export function selectPullRequestDelivery(options: DeliveryOptions): RawBuilder<unknown> {
20
+ return sql`select
21
+ pull_request.id as "pullRequest",
22
+ pull_request.merged_at as "mergedAt",
23
+ release.id as "releaseID",
24
+ release.tag_name as "releaseTag",
25
+ release.published_at as "releaseAt",
26
+ deploy.id as "deployID",
27
+ deploy.deployed_at as "deployAt",
28
+ deploy.signal as "deploySignal"
29
+ from github_pull_request as pull_request
30
+ left join github_release as release on release.id = ${selectFirstReleaseID()}
31
+ left join github_deploy as deploy on deploy.id = ${selectFirstDeployID(options)}
32
+ where pull_request.id in (${sql.join(options.pullRequests)})
33
+ order by pull_request.id`;
34
+ }
35
+
36
+ function selectFirstReleaseID(): RawBuilder<unknown> {
37
+ const releaseTime = formatCommitTime(
38
+ 'candidate.repository_id',
39
+ 'candidate.target_commitish',
40
+ 'candidate.created_at',
41
+ );
42
+
43
+ return sql`(select candidate.id from github_release as candidate
44
+ where ${formatContains('candidate')}
45
+ order by ${releaseTime}, candidate.id
46
+ limit 1)`;
47
+ }
48
+
49
+ // The commit time of a sha of a repository, or the fallback time. The three names are columns.
50
+ function formatCommitTime(repository: string, sha: string, fallback: string): RawBuilder<unknown> {
51
+ return sql`julianday(coalesce((select commit_row.committed_at from github_commit as commit_row
52
+ where commit_row.repository_id = ${sql.ref(repository)}
53
+ and commit_row.sha = ${sql.ref(sha)}), ${sql.ref(fallback)}))`;
54
+ }
55
+
56
+ // The release under `alias` contains `pull_request`.
57
+ function formatContains(alias: string): RawBuilder<unknown> {
58
+ const release = sql.table(alias);
59
+
60
+ const releaseTime = formatCommitTime(
61
+ `${alias}.repository_id`,
62
+ `${alias}.target_commitish`,
63
+ `${alias}.created_at`,
64
+ );
65
+
66
+ const mergeTime = formatCommitTime(
67
+ 'pull_request.repository_id',
68
+ 'pull_request.merge_commit_sha',
69
+ 'pull_request.merged_at',
70
+ );
71
+
72
+ return sql`(pull_request.merged_at is not null
73
+ and ${release}.repository_id = pull_request.repository_id
74
+ and ${release}.published_at is not null
75
+ and ${release}.is_draft = 0
76
+ and ${release}.deleted_at is null
77
+ and ${releaseTime} >= ${mergeTime})`;
78
+ }
79
+
80
+ function selectFirstDeployID(options: DeliveryOptions): RawBuilder<unknown> {
81
+ const target = options.target ?? null;
82
+
83
+ const deployTime = formatCommitTime(
84
+ 'carrier.repository_id',
85
+ 'carrier.sha',
86
+ 'carrier.deployed_at',
87
+ );
88
+
89
+ const pinnerMergeTime = formatCommitTime(
90
+ 'pinner.repository_id',
91
+ 'pinner.merge_commit_sha',
92
+ 'pinner.merged_at',
93
+ );
94
+
95
+ return sql`(select carrier.id from github_deploy as carrier
96
+ join github_pull_request as pinner
97
+ on pinner.repository_id = carrier.repository_id and pinner.merged_at is not null
98
+ join github_release_bump as bump on bump.pull_request_id = pinner.id
99
+ join github_release as pinned
100
+ on pinned.repository_full_name = bump.release_repository
101
+ and pinned.tag_name = bump.release_tag
102
+ where ${formatContains('pinned')}
103
+ and carrier.environment = ${options.environment}
104
+ and (${target} is null or carrier.target = ${target})
105
+ and carrier.signal = (case when exists (
106
+ select 1 from github_deploy as tagged
107
+ where tagged.repository_id = carrier.repository_id
108
+ and tagged.environment = carrier.environment
109
+ and (${target} is null or tagged.target = ${target})
110
+ and tagged.signal = 'tag'
111
+ ) then 'tag' else 'deployment_status' end)
112
+ and ${deployTime} >= ${pinnerMergeTime}
113
+ order by julianday(carrier.deployed_at), carrier.id
114
+ limit 1)`;
115
+ }
116
+
117
+ export interface PullRequestDelivery {
118
+ pullRequest: string;
119
+ mergedAt: string | null;
120
+ release: { id: string; tag: string; at: string } | null;
121
+ deploy: { id: string; at: string; signal: DeploySignal } | null;
122
+ }
123
+
124
+ const textSchema = z.string().nullable();
125
+
126
+ // One row per pull request, as the statement selects it.
127
+ const deliveryRowSchema = z.object({
128
+ pullRequest: z.string(),
129
+ mergedAt: textSchema,
130
+ releaseID: textSchema,
131
+ releaseTag: textSchema,
132
+ releaseAt: textSchema,
133
+ deployID: textSchema,
134
+ deployAt: textSchema,
135
+ deploySignal: z.enum(deploySignals).nullable(),
136
+ });
137
+
138
+ const deliveryRowsSchema = z.array(deliveryRowSchema);
139
+
140
+ // The rows of the statement as deliveries. A row of another shape is a bug in the statement, so the
141
+ // parse throws.
142
+ export function parsePullRequestDeliveries(rows: unknown): PullRequestDelivery[] {
143
+ return deliveryRowsSchema.parse(rows).map((row) => ({
144
+ pullRequest: row.pullRequest,
145
+ mergedAt: row.mergedAt,
146
+ release:
147
+ row.releaseID === null || row.releaseTag === null || row.releaseAt === null
148
+ ? null
149
+ : { id: row.releaseID, tag: row.releaseTag, at: row.releaseAt },
150
+ deploy:
151
+ row.deployID === null || row.deployAt === null || row.deploySignal === null
152
+ ? null
153
+ : { id: row.deployID, at: row.deployAt, signal: row.deploySignal },
154
+ }));
155
+ }
@@ -0,0 +1,34 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // The SHA-256 of the canonical JSON of a value: keys sorted at every depth, no white space. Two
4
+ // bodies that parse to one JSON value give one hash, however each was spaced or ordered.
5
+ export function buildBodyHash(value: unknown): string {
6
+ return createHash('sha256').update(formatCanonicalJSON(value)).digest('hex');
7
+ }
8
+
9
+ function formatCanonicalJSON(value: unknown): string {
10
+ if (Array.isArray(value)) {
11
+ return `[${value.map((item: unknown) => formatCanonicalJSON(item)).join(',')}]`;
12
+ }
13
+
14
+ if (typeof value === 'object' && value !== null) {
15
+ // code-unit order, as JSON canonical forms sort keys, and never the locale's order
16
+ const entries = Object.entries(value)
17
+ .toSorted(([left], [right]) => compareCodeUnits(left, right))
18
+ .map(
19
+ ([key, item]: [string, unknown]) => `${JSON.stringify(key)}:${formatCanonicalJSON(item)}`,
20
+ );
21
+
22
+ return `{${entries.join(',')}}`;
23
+ }
24
+
25
+ return JSON.stringify(value);
26
+ }
27
+
28
+ function compareCodeUnits(left: string, right: string): number {
29
+ if (left === right) {
30
+ return 0;
31
+ }
32
+
33
+ return left < right ? -1 : 1;
34
+ }
@@ -0,0 +1,5 @@
1
+ // The two signals that report a deploy, in the order a query trusts them: the tag rows of one
2
+ // repository and environment when it has any, and its deployment status rows otherwise.
3
+ export const deploySignals = ['tag', 'deployment_status'] as const;
4
+
5
+ export type DeploySignal = (typeof deploySignals)[number];
@@ -0,0 +1,19 @@
1
+ // The value at a path of keys inside a parsed JSON value, or undefined when a step is no object or
2
+ // has no such key. A consumer reads a delivery body with it and no schema library.
3
+ export function findField(value: unknown, ...path: readonly string[]): unknown {
4
+ let current = value;
5
+
6
+ for (const key of path) {
7
+ if (typeof current !== 'object' || current === null || Array.isArray(current)) {
8
+ return undefined;
9
+ }
10
+
11
+ if (!Object.hasOwn(current, key)) {
12
+ return undefined;
13
+ }
14
+
15
+ current = Reflect.get(current, key);
16
+ }
17
+
18
+ return current;
19
+ }
@@ -1,38 +0,0 @@
1
- import { buildWebhookFactID, parseFactEnvelope } from '@pepys/kit';
2
- import type { FactEnvelope, Result } from '@pepys/kit';
3
- import { z } from 'zod';
4
- import type { ZodError } from 'zod';
5
-
6
- interface PullRequestFactParameters {
7
- deliveryID: string;
8
- body: unknown;
9
- }
10
-
11
- const pullRequestBodySchema = z.looseObject({
12
- action: z.string().min(1),
13
- pull_request: z.looseObject({ id: z.number().int(), updated_at: z.string() }),
14
- sender: z.looseObject({ id: z.number().int() }).optional(),
15
- });
16
-
17
- export function buildPullRequestFact(
18
- parameters: PullRequestFactParameters,
19
- ): Result<FactEnvelope<unknown>, ZodError> {
20
- const parsed = pullRequestBodySchema.safeParse(parameters.body);
21
-
22
- if (!parsed.success) {
23
- return { ok: false, failure: parsed.error };
24
- }
25
-
26
- const body = parsed.data;
27
-
28
- return parseFactEnvelope({
29
- id: buildWebhookFactID({ source: 'github', deliveryID: parameters.deliveryID }),
30
- type: `github.pull_request.${body.action}`,
31
- v: 1,
32
- api: 'github/2022-11-28',
33
- entity: `github:pull_request:${body.pull_request.id}`,
34
- ...(body.sender === undefined ? {} : { actor: `github:user:${body.sender.id}` }),
35
- at: body.pull_request.updated_at,
36
- payload: parameters.body,
37
- });
38
- }
@@ -1,4 +0,0 @@
1
- import type { StreamName } from '@pepys/kit';
2
- import { toTagged } from '@zgeoff/std';
3
-
4
- export const pullRequestStream = toTagged<StreamName>('facts.github.pull_request');
@@ -1,44 +0,0 @@
1
- import type { Delivery, Received } from '@pepys/kit';
2
- import { buildPullRequestFact } from './build-pull-request-fact.ts';
3
- import { pullRequestStream } from './pull-request-stream.ts';
4
-
5
- // The read of a checked webhook delivery: a pull request event becomes one fact, and any other event
6
- // is ignored.
7
- export function readPullRequestDelivery(delivery: Delivery): Received {
8
- if (delivery.headers.get('x-github-event') !== 'pull_request') {
9
- return { kind: 'ignored' };
10
- }
11
-
12
- const deliveryID = delivery.headers.get('x-github-delivery');
13
-
14
- if (deliveryID === null || deliveryID === '') {
15
- return { kind: 'refused', status: 400, reason: 'no delivery ID' };
16
- }
17
-
18
- const body = parseJSON(new TextDecoder().decode(delivery.body));
19
-
20
- if (!body.ok) {
21
- return { kind: 'refused', status: 400, reason: 'the body is not JSON' };
22
- }
23
-
24
- const fact = buildPullRequestFact({ deliveryID, body: body.value });
25
-
26
- if (!fact.ok) {
27
- return { kind: 'refused', status: 422, reason: 'not a pull request delivery' };
28
- }
29
-
30
- return {
31
- kind: 'batch',
32
- batch: { facts: [{ stream: pullRequestStream, fact: fact.value, blobs: [] }] },
33
- };
34
- }
35
-
36
- function parseJSON(text: string): { ok: true; value: unknown } | { ok: false } {
37
- try {
38
- const value: unknown = JSON.parse(text);
39
-
40
- return { ok: true, value };
41
- } catch {
42
- return { ok: false };
43
- }
44
- }