@pepys/source-github 0.0.0-stage → 0.0.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.
- package/README.md +32 -2
- package/package.json +30 -4
- package/src/api/serve-api.ts +37 -0
- package/src/consumers/github-pull-request.ts +180 -0
- package/src/generated/github-pull-request-boundary.ts +268 -0
- package/src/generated/github-pull-request.sql +32 -0
- package/src/github-plugin.ts +26 -0
- package/src/github-pull-request-consumer.ts +44 -0
- package/src/github-pull-request-schema-path.ts +6 -0
- package/src/index.ts +3 -0
- package/src/ingest/build-pull-request-fact.ts +38 -0
- package/src/ingest/pull-request-stream.ts +4 -0
- package/src/ingest/read-pull-request-delivery.ts +44 -0
- package/src/ingest/serve-ingest.ts +8 -0
- package/src/pull-request-family.ts +41 -0
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@pepys/source-github</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@pepys/source-github"><img src="https://img.shields.io/npm/v/@pepys/source-github" alt="npm version"></a>
|
|
6
|
+
<a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/README.md">Documentation</a> •
|
|
11
|
+
<a href="../../docs/design/v1-plug-ins.md">The plug-ins of v1</a>
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
The pepys GitHub plug-in, for Bun.
|
|
16
|
+
|
|
17
|
+
The plug-in receives GitHub's webhook deliveries as facts, and builds pull request rows from them
|
|
18
|
+
with its consumer.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bun add @pepys/source-github
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- `githubPlugin`: the plug-in, to add to a deployment.
|
|
29
|
+
- `githubPullRequestConsumer`: the consumer of pull request facts, with its SQL schema.
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
The [pepys docs](../../docs/README.md) hold the design of the plug-ins.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/source-github",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The pepys GitHub plug-in: pull requests, releases, commits and deploys",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zgeoff/pepys.git",
|
|
9
|
+
"directory": "packages/source-github"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/test-utils"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": "./src/index.ts",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@pepys/api": "0.0.1",
|
|
23
|
+
"@pepys/ingest": "0.0.1",
|
|
24
|
+
"@pepys/kit": "0.0.1",
|
|
25
|
+
"@pepys/schema": "0.0.1",
|
|
26
|
+
"@zgeoff/std": "0.4.3",
|
|
27
|
+
"zod": "4.6.7"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"tiny-invariant": "1.3.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createApp, createStoreReader, openConsumerDatabase, readAPIConfig } from '@pepys/api';
|
|
2
|
+
import { makeShutdownHandler } from '@pepys/schema';
|
|
3
|
+
import { githubPullRequestConsumer } from '../github-pull-request-consumer.ts';
|
|
4
|
+
|
|
5
|
+
const config = readAPIConfig(process.env);
|
|
6
|
+
|
|
7
|
+
if (!config.ok) {
|
|
8
|
+
console.error(config.failure.join('\n'));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const store = createStoreReader({
|
|
13
|
+
storeDirectory: config.value.storeDirectory,
|
|
14
|
+
openDatabase: openConsumerDatabase,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const app = createApp({ store, consumers: [githubPullRequestConsumer] });
|
|
18
|
+
|
|
19
|
+
app.listen({ hostname: '127.0.0.1', port: config.value.port });
|
|
20
|
+
console.log(`api listening on ${app.server?.port}`);
|
|
21
|
+
|
|
22
|
+
const shutdown = makeShutdownHandler({
|
|
23
|
+
stop: async () => {
|
|
24
|
+
await app.stop();
|
|
25
|
+
await store.close();
|
|
26
|
+
},
|
|
27
|
+
logError: (message, error) => {
|
|
28
|
+
console.error(message, error);
|
|
29
|
+
},
|
|
30
|
+
exit: (code) => process.exit(code),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
34
|
+
process.on(signal, () => {
|
|
35
|
+
void shutdown();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initGithubPullRequestModel,
|
|
3
|
+
readGithubPullRequestRow,
|
|
4
|
+
toGithubPullRequestID,
|
|
5
|
+
updateGithubPullRequestModel,
|
|
6
|
+
} from '../generated/github-pull-request-boundary.ts';
|
|
7
|
+
import type {
|
|
8
|
+
EntityID,
|
|
9
|
+
GithubPullRequestMessage,
|
|
10
|
+
GithubPullRequestModel,
|
|
11
|
+
GithubPullRequestOperation,
|
|
12
|
+
GithubPullRequestRow,
|
|
13
|
+
} from '../generated/github-pull-request-boundary.ts';
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
GithubPullRequestMessage,
|
|
17
|
+
GithubPullRequestModel,
|
|
18
|
+
} from '../generated/github-pull-request-boundary.ts';
|
|
19
|
+
|
|
20
|
+
// scriptc takes the model and message types through a re-export, but not the two functions
|
|
21
|
+
export function init(): GithubPullRequestModel {
|
|
22
|
+
return initGithubPullRequestModel();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function update(
|
|
26
|
+
model: GithubPullRequestModel,
|
|
27
|
+
message: GithubPullRequestMessage,
|
|
28
|
+
): GithubPullRequestModel {
|
|
29
|
+
return updateGithubPullRequestModel(model, message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function applyFact(factJSON: string): string {
|
|
33
|
+
return JSON.stringify(mapFact(factJSON));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mapFact(factJSON: string): GithubPullRequestOperation[] {
|
|
37
|
+
const row = readRow(factJSON);
|
|
38
|
+
|
|
39
|
+
if (row === undefined) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stored = readGithubPullRequestRow(row.id);
|
|
44
|
+
|
|
45
|
+
if (stored !== undefined && isStale(stored, row)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [{ upsert: { github_pull_request: row } }];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Two updates can share one second. A repeat of the stored row changes nothing, and a different
|
|
53
|
+
// row with the same update time is a distinct update, so the later fact in the stream wins. The
|
|
54
|
+
// host skips a fact it has applied before, so a redelivery never reaches this check.
|
|
55
|
+
function isStale(stored: GithubPullRequestRow, row: GithubPullRequestRow): boolean {
|
|
56
|
+
if (stored.updated_at !== row.updated_at) {
|
|
57
|
+
return stored.updated_at > row.updated_at;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return JSON.stringify(stored) === JSON.stringify(row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRow(factJSON: string): GithubPullRequestRow | undefined {
|
|
64
|
+
const fact: unknown = JSON.parse(factJSON);
|
|
65
|
+
|
|
66
|
+
if (!isObject(fact) || !('payload' in fact) || !isObject(fact.payload)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!('pull_request' in fact.payload) || !isObject(fact.payload.pull_request)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pullRequest = fact.payload.pull_request;
|
|
75
|
+
|
|
76
|
+
if (!('id' in pullRequest) || typeof pullRequest.id !== 'number') {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!('number' in pullRequest) || typeof pullRequest.number !== 'number') {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!('title' in pullRequest) || typeof pullRequest.title !== 'string') {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!('state' in pullRequest) || typeof pullRequest.state !== 'string') {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!('html_url' in pullRequest) || typeof pullRequest.html_url !== 'string') {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!('created_at' in pullRequest) || typeof pullRequest.created_at !== 'string') {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!('updated_at' in pullRequest) || typeof pullRequest.updated_at !== 'string') {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const repositoryID = readRepositoryID(pullRequest);
|
|
105
|
+
|
|
106
|
+
if (repositoryID === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: toGithubPullRequestID(`${pullRequest.id}`),
|
|
112
|
+
number: pullRequest.number,
|
|
113
|
+
repository_id: toEntityID(`github:repo:${repositoryID}`),
|
|
114
|
+
title: pullRequest.title,
|
|
115
|
+
state: pullRequest.state,
|
|
116
|
+
is_draft: 'draft' in pullRequest && pullRequest.draft === true,
|
|
117
|
+
is_merged: 'merged' in pullRequest && pullRequest.merged === true,
|
|
118
|
+
author_id: readAuthorID(pullRequest),
|
|
119
|
+
url: pullRequest.html_url,
|
|
120
|
+
merge_commit_sha:
|
|
121
|
+
'merge_commit_sha' in pullRequest && typeof pullRequest.merge_commit_sha === 'string'
|
|
122
|
+
? pullRequest.merge_commit_sha
|
|
123
|
+
: null,
|
|
124
|
+
created_at: pullRequest.created_at,
|
|
125
|
+
updated_at: pullRequest.updated_at,
|
|
126
|
+
closed_at:
|
|
127
|
+
'closed_at' in pullRequest && typeof pullRequest.closed_at === 'string'
|
|
128
|
+
? pullRequest.closed_at
|
|
129
|
+
: null,
|
|
130
|
+
merged_at:
|
|
131
|
+
'merged_at' in pullRequest && typeof pullRequest.merged_at === 'string'
|
|
132
|
+
? pullRequest.merged_at
|
|
133
|
+
: null,
|
|
134
|
+
payload: pullRequest,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readRepositoryID(pullRequest: object): number | undefined {
|
|
139
|
+
if (!('base' in pullRequest) || !isObject(pullRequest.base)) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!('repo' in pullRequest.base) || !isObject(pullRequest.base.repo)) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!('id' in pullRequest.base.repo) || typeof pullRequest.base.repo.id !== 'number') {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return pullRequest.base.repo.id;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAuthorID(pullRequest: object): EntityID | null {
|
|
155
|
+
if (!('user' in pullRequest) || !isObject(pullRequest.user)) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!('id' in pullRequest.user) || typeof pullRequest.user.id !== 'number') {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return toEntityID(`github:user:${pullRequest.user.id}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isObject(value: unknown): value is object {
|
|
167
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isEntityID(text: string): text is EntityID {
|
|
171
|
+
return text.length > 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toEntityID(text: string): EntityID {
|
|
175
|
+
if (!isEntityID(text)) {
|
|
176
|
+
throw new Error('an entity ID is never empty');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return text;
|
|
180
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
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 GithubPullRequestID = KindedID<'github:pull_request'>;
|
|
11
|
+
|
|
12
|
+
export function toGithubPullRequestID(id: string): GithubPullRequestID {
|
|
13
|
+
const text = 'github:pull_request:' + id;
|
|
14
|
+
|
|
15
|
+
if (!isGithubPullRequestID(text)) {
|
|
16
|
+
throw new Error('an ID of the kind github:pull_request is never empty');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseGithubPullRequestID(text: string): GithubPullRequestID | undefined {
|
|
23
|
+
return isGithubPullRequestID(text) ? text : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isGithubPullRequestID(text: string): text is GithubPullRequestID {
|
|
27
|
+
return text.length > 'github:pull_request:'.length && text.startsWith('github:pull_request:');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GithubPullRequestModel {
|
|
31
|
+
input: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type GithubPullRequestMessage = { kind: 'apply'; input: string } | { kind: 'reset' };
|
|
35
|
+
|
|
36
|
+
export function initGithubPullRequestModel(): GithubPullRequestModel {
|
|
37
|
+
return { input: '' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function updateGithubPullRequestModel(model: GithubPullRequestModel, message: GithubPullRequestMessage): GithubPullRequestModel {
|
|
41
|
+
return message.kind === 'reset' ? initGithubPullRequestModel() : { ...model, input: message.input };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GithubPullRequestPayload {
|
|
45
|
+
action: string;
|
|
46
|
+
pull_request: GithubPullRequestPayloadPullRequest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GithubPullRequestPayloadPullRequest {
|
|
50
|
+
id: number;
|
|
51
|
+
updated_at: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type GithubPullRequestFact =
|
|
55
|
+
| { id: string; 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.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: string; at: string; payload: GithubPullRequestPayload };
|
|
56
|
+
|
|
57
|
+
export function parseGithubPullRequestFact(factJSON: string): GithubPullRequestFact | undefined {
|
|
58
|
+
const parsed: unknown = JSON.parse(factJSON);
|
|
59
|
+
|
|
60
|
+
return decodeGithubPullRequestFact(parsed);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeGithubPullRequestFact(value: unknown): GithubPullRequestFact | undefined {
|
|
64
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const id = 'id' in value ? value.id : undefined;
|
|
69
|
+
const type = 'type' in value ? value.type : undefined;
|
|
70
|
+
const entity = 'entity' in value ? value.entity : undefined;
|
|
71
|
+
const at = 'at' in value ? value.at : undefined;
|
|
72
|
+
const payload = 'payload' in value ? value.payload : undefined;
|
|
73
|
+
|
|
74
|
+
if (typeof id !== 'string' || typeof entity !== 'string' || typeof at !== 'string') {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
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.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') {
|
|
79
|
+
const decoded = decodeGithubPullRequestPayload(payload);
|
|
80
|
+
|
|
81
|
+
if (decoded === undefined) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { id, type, entity, at, payload: decoded };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function decodeGithubPullRequestPayload(value: unknown): GithubPullRequestPayload | undefined {
|
|
92
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const raw0 = 'action' in value ? value.action : undefined;
|
|
97
|
+
const field0 = typeof raw0 === 'string' ? raw0 : undefined;
|
|
98
|
+
|
|
99
|
+
if (field0 === undefined) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const raw1 = 'pull_request' in value ? value.pull_request : undefined;
|
|
104
|
+
const field1 = decodeGithubPullRequestPayloadPullRequest(raw1);
|
|
105
|
+
|
|
106
|
+
if (field1 === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
action: field0,
|
|
112
|
+
pull_request: field1,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodeGithubPullRequestPayloadPullRequest(value: unknown): GithubPullRequestPayloadPullRequest | undefined {
|
|
117
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const raw0 = 'id' in value ? value.id : undefined;
|
|
122
|
+
const field0 = typeof raw0 === 'number' ? raw0 : undefined;
|
|
123
|
+
|
|
124
|
+
if (field0 === undefined) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const raw1 = 'updated_at' in value ? value.updated_at : undefined;
|
|
129
|
+
const field1 = typeof raw1 === 'string' ? raw1 : undefined;
|
|
130
|
+
|
|
131
|
+
if (field1 === undefined) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
id: field0,
|
|
137
|
+
updated_at: field1,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface GithubPullRequestRow {
|
|
142
|
+
id: GithubPullRequestID;
|
|
143
|
+
number: number;
|
|
144
|
+
repository_id: EntityID;
|
|
145
|
+
title: string;
|
|
146
|
+
state: string;
|
|
147
|
+
is_draft: boolean;
|
|
148
|
+
is_merged: boolean;
|
|
149
|
+
author_id: EntityID | null;
|
|
150
|
+
url: string;
|
|
151
|
+
merge_commit_sha: string | null;
|
|
152
|
+
created_at: string;
|
|
153
|
+
updated_at: string;
|
|
154
|
+
closed_at: string | null;
|
|
155
|
+
merged_at: string | null;
|
|
156
|
+
payload: unknown;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface GithubPullRequestKey {
|
|
160
|
+
id: GithubPullRequestID;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type GithubPullRequestOperation =
|
|
164
|
+
| { upsert: { github_pull_request: GithubPullRequestRow } }
|
|
165
|
+
| { delete: { github_pull_request: GithubPullRequestKey } };
|
|
166
|
+
|
|
167
|
+
declare function readGithubPullRequestLength(key: string): number;
|
|
168
|
+
declare function readGithubPullRequestInto(key: string, row: Uint8Array): void;
|
|
169
|
+
|
|
170
|
+
export function readGithubPullRequestRow(key: string): GithubPullRequestRow | undefined {
|
|
171
|
+
const length = readGithubPullRequestLength(key);
|
|
172
|
+
|
|
173
|
+
if (length < 0) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const row = new Uint8Array(length);
|
|
178
|
+
|
|
179
|
+
readGithubPullRequestInto(key, row);
|
|
180
|
+
|
|
181
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(row));
|
|
182
|
+
|
|
183
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!('id' in parsed) || (typeof parsed.id !== 'string' || !isGithubPullRequestID(parsed.id))) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!('number' in parsed) || (typeof parsed.number !== 'number')) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!('repository_id' in parsed) || (!isEntityID(parsed.repository_id))) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!('title' in parsed) || (typeof parsed.title !== 'string')) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!('state' in parsed) || (typeof parsed.state !== 'string')) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!('is_draft' in parsed) || (typeof parsed.is_draft !== 'boolean')) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!('is_merged' in parsed) || (typeof parsed.is_merged !== 'boolean')) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!('author_id' in parsed) || (parsed.author_id !== null && (!isEntityID(parsed.author_id)))) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!('url' in parsed) || (typeof parsed.url !== 'string')) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!('merge_commit_sha' in parsed) || (parsed.merge_commit_sha !== null && (typeof parsed.merge_commit_sha !== 'string'))) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!('created_at' in parsed) || (typeof parsed.created_at !== 'string')) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!('updated_at' in parsed) || (typeof parsed.updated_at !== 'string')) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!('closed_at' in parsed) || (parsed.closed_at !== null && (typeof parsed.closed_at !== 'string'))) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!('merged_at' in parsed) || (parsed.merged_at !== null && (typeof parsed.merged_at !== 'string'))) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!('payload' in parsed)) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
id: parsed.id,
|
|
249
|
+
number: parsed.number,
|
|
250
|
+
repository_id: parsed.repository_id,
|
|
251
|
+
title: parsed.title,
|
|
252
|
+
state: parsed.state,
|
|
253
|
+
is_draft: parsed.is_draft,
|
|
254
|
+
is_merged: parsed.is_merged,
|
|
255
|
+
author_id: parsed.author_id,
|
|
256
|
+
url: parsed.url,
|
|
257
|
+
merge_commit_sha: parsed.merge_commit_sha,
|
|
258
|
+
created_at: parsed.created_at,
|
|
259
|
+
updated_at: parsed.updated_at,
|
|
260
|
+
closed_at: parsed.closed_at,
|
|
261
|
+
merged_at: parsed.merged_at,
|
|
262
|
+
payload: parsed.payload,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isEntityID(value: unknown): value is EntityID {
|
|
267
|
+
return typeof value === 'string';
|
|
268
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Generated by `pepys build`. Do not edit.
|
|
2
|
+
|
|
3
|
+
CREATE TABLE "github_pull_request" (
|
|
4
|
+
"id" TEXT NOT NULL,
|
|
5
|
+
"number" INTEGER NOT NULL,
|
|
6
|
+
"repository_id" TEXT NOT NULL,
|
|
7
|
+
"title" TEXT NOT NULL,
|
|
8
|
+
"state" TEXT NOT NULL,
|
|
9
|
+
"is_draft" INTEGER NOT NULL,
|
|
10
|
+
"is_merged" INTEGER NOT NULL,
|
|
11
|
+
"author_id" TEXT,
|
|
12
|
+
"url" TEXT NOT NULL,
|
|
13
|
+
"merge_commit_sha" TEXT,
|
|
14
|
+
"created_at" TEXT NOT NULL,
|
|
15
|
+
"updated_at" TEXT NOT NULL,
|
|
16
|
+
"closed_at" TEXT,
|
|
17
|
+
"merged_at" TEXT,
|
|
18
|
+
"payload" TEXT NOT NULL,
|
|
19
|
+
PRIMARY KEY ("id")
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE INDEX "github_pull_request_repository_id_idx" ON "github_pull_request" ("repository_id");
|
|
23
|
+
|
|
24
|
+
CREATE VIEW "entity" AS
|
|
25
|
+
SELECT
|
|
26
|
+
"id" AS "id",
|
|
27
|
+
'github:pull_request' AS "kind",
|
|
28
|
+
"title" AS "title",
|
|
29
|
+
"url" AS "url",
|
|
30
|
+
"author_id" AS "actor",
|
|
31
|
+
"updated_at" AS "updated_at"
|
|
32
|
+
FROM "github_pull_request";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { definePlugin, defineSource } from '@pepys/kit';
|
|
2
|
+
import { githubPullRequestConsumer } from './github-pull-request-consumer.ts';
|
|
3
|
+
import { readPullRequestDelivery } from './ingest/read-pull-request-delivery.ts';
|
|
4
|
+
import { pullRequestFamily } from './pull-request-family.ts';
|
|
5
|
+
|
|
6
|
+
export const githubPlugin = definePlugin({
|
|
7
|
+
name: 'github',
|
|
8
|
+
sources: [
|
|
9
|
+
defineSource({
|
|
10
|
+
name: 'github',
|
|
11
|
+
families: [pullRequestFamily],
|
|
12
|
+
live: {
|
|
13
|
+
kind: 'receive',
|
|
14
|
+
route: '/webhook',
|
|
15
|
+
check: {
|
|
16
|
+
kind: 'hmac-sha256',
|
|
17
|
+
header: 'x-hub-signature-256',
|
|
18
|
+
secretEnv: 'PEPYS_GITHUB_WEBHOOK_SECRET',
|
|
19
|
+
},
|
|
20
|
+
reach: 'public',
|
|
21
|
+
read: readPullRequestDelivery,
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
],
|
|
25
|
+
consumers: [githubPullRequestConsumer],
|
|
26
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
|
+
import { pullRequestFamily } from './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
|
+
entityViews: [
|
|
32
|
+
{
|
|
33
|
+
table: 'github_pull_request',
|
|
34
|
+
kind: pullRequestFamily.entityKind,
|
|
35
|
+
id: 'id',
|
|
36
|
+
title: 'title',
|
|
37
|
+
url: 'url',
|
|
38
|
+
actor: 'author_id',
|
|
39
|
+
updatedAt: 'updated_at',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
reads: ['github_pull_request'],
|
|
43
|
+
links: [],
|
|
44
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { serveIngest } from '@pepys/ingest';
|
|
2
|
+
import { defineDeployment } from '@pepys/kit';
|
|
3
|
+
import { githubPlugin } from '../github-plugin.ts';
|
|
4
|
+
|
|
5
|
+
await serveIngest({
|
|
6
|
+
deployment: defineDeployment({ name: 'github', plugins: [githubPlugin] }),
|
|
7
|
+
environment: process.env,
|
|
8
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineFactFamily } from '@pepys/kit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// The `action` values of GitHub's `pull_request` webhook event.
|
|
5
|
+
const actions = [
|
|
6
|
+
'assigned',
|
|
7
|
+
'auto_merge_disabled',
|
|
8
|
+
'auto_merge_enabled',
|
|
9
|
+
'closed',
|
|
10
|
+
'converted_to_draft',
|
|
11
|
+
'demilestoned',
|
|
12
|
+
'dequeued',
|
|
13
|
+
'edited',
|
|
14
|
+
'enqueued',
|
|
15
|
+
'labeled',
|
|
16
|
+
'locked',
|
|
17
|
+
'milestoned',
|
|
18
|
+
'opened',
|
|
19
|
+
'ready_for_review',
|
|
20
|
+
'reopened',
|
|
21
|
+
'review_request_removed',
|
|
22
|
+
'review_requested',
|
|
23
|
+
'synchronize',
|
|
24
|
+
'unassigned',
|
|
25
|
+
'unlabeled',
|
|
26
|
+
'unlocked',
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
const deliverySchema = z.looseObject({
|
|
30
|
+
action: z.string(),
|
|
31
|
+
pull_request: z.looseObject({ id: z.number().int(), updated_at: z.string() }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const pullRequestFamily = defineFactFamily({
|
|
35
|
+
stream: 'facts.github.pull_request',
|
|
36
|
+
api: 'github/2022-11-28',
|
|
37
|
+
entityKind: 'github:pull_request',
|
|
38
|
+
types: Object.fromEntries(
|
|
39
|
+
actions.map((action) => [`github.pull_request.${action}`, deliverySchema]),
|
|
40
|
+
),
|
|
41
|
+
});
|