@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,43 @@
1
+ import { z } from 'zod';
2
+ import { requestGithubAPI } from './request-github-api.ts';
3
+
4
+ // `not-installed`: the App has no installation on the repository, so it can read nothing there.
5
+ export type InstallationToken =
6
+ | { kind: 'token'; token: string }
7
+ | { kind: 'not-installed' }
8
+ | { kind: 'failed'; status: number };
9
+
10
+ const installationSchema = z.looseObject({ id: z.number().int() });
11
+ const accessTokenSchema = z.looseObject({ token: z.string().min(1) });
12
+
13
+ // The token of the App's installation on one repository, `owner/name`, from the App's JWT.
14
+ export async function readInstallationToken(
15
+ repository: string,
16
+ appJWT: string,
17
+ ): Promise<InstallationToken> {
18
+ const installation = await requestGithubAPI(`/repos/${repository}/installation`, appJWT);
19
+
20
+ if (installation.status === 404) {
21
+ return { kind: 'not-installed' };
22
+ }
23
+
24
+ const parsedInstallation = installationSchema.safeParse(installation.body);
25
+
26
+ if (installation.status !== 200 || !parsedInstallation.success) {
27
+ return { kind: 'failed', status: installation.status };
28
+ }
29
+
30
+ const created = await requestGithubAPI(
31
+ `/app/installations/${parsedInstallation.data.id}/access_tokens`,
32
+ appJWT,
33
+ 'POST',
34
+ );
35
+
36
+ const parsedToken = accessTokenSchema.safeParse(created.body);
37
+
38
+ if (created.status !== 201 || !parsedToken.success) {
39
+ return { kind: 'failed', status: created.status };
40
+ }
41
+
42
+ return { kind: 'token', token: parsedToken.data.token };
43
+ }
@@ -0,0 +1,75 @@
1
+ import { githubAPIURL } from './github-api-url.ts';
2
+
3
+ // One answer of the GitHub REST API. `next` is the URL of the next page, from the `link` header.
4
+ export interface GithubAnswer {
5
+ status: number;
6
+ body: unknown;
7
+ next: string | undefined;
8
+ }
9
+
10
+ // One call to the GitHub REST API, with a path or a whole URL from a `link` header. A transport
11
+ // failure answers status 0.
12
+ export async function requestGithubAPI(
13
+ pathOrURL: string,
14
+ token: string,
15
+ method: 'GET' | 'POST' = 'GET',
16
+ ): Promise<GithubAnswer> {
17
+ const url = pathOrURL.startsWith('/') ? `${githubAPIURL}${pathOrURL}` : pathOrURL;
18
+
19
+ const response = await sendRequest(url, token, method);
20
+
21
+ if (response === undefined) {
22
+ return { status: 0, body: undefined, next: undefined };
23
+ }
24
+
25
+ const text = await response.text();
26
+
27
+ return {
28
+ status: response.status,
29
+ body: parseBody(text),
30
+ next: findNextPage(response.headers.get('link')),
31
+ };
32
+ }
33
+
34
+ async function sendRequest(
35
+ url: string,
36
+ token: string,
37
+ method: 'GET' | 'POST',
38
+ ): Promise<Response | undefined> {
39
+ try {
40
+ const response = await fetch(url, {
41
+ method,
42
+ headers: {
43
+ accept: 'application/vnd.github+json',
44
+ authorization: `Bearer ${token}`,
45
+ 'user-agent': 'pepys-ingest',
46
+ 'x-github-api-version': '2022-11-28',
47
+ },
48
+ });
49
+
50
+ return response;
51
+ } catch (error: unknown) {
52
+ console.error(`a GitHub API call to ${url} failed`, error);
53
+
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ function parseBody(text: string): unknown {
59
+ try {
60
+ const body: unknown = JSON.parse(text);
61
+
62
+ return body;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function findNextPage(link: string | null): string | undefined {
69
+ const next = link
70
+ ?.split(',')
71
+ .map((part) => /<(?<url>[^>]+)>;\s*rel="next"/u.exec(part.trim()))
72
+ .find((found) => found !== null);
73
+
74
+ return next?.groups?.['url'];
75
+ }
@@ -0,0 +1,324 @@
1
+ import type { FactFamily } from '@pepys/kit';
2
+ import { z } from 'zod';
3
+ import { deploymentFamily } from '../families/deployment-family.ts';
4
+ import { issueFamily } from '../families/issue-family.ts';
5
+ import { pullRequestFamily } from '../families/pull-request-family.ts';
6
+ import { pullRequestReviewFamily } from '../families/pull-request-review-family.ts';
7
+ import { refFamily } from '../families/ref-family.ts';
8
+ import { releaseFamily } from '../families/release-family.ts';
9
+ import { workflowRunFamily } from '../families/workflow-run-family.ts';
10
+
11
+ // What a rule reads from one delivery body. `locator` is the API path the resync pull reads the
12
+ // entity at.
13
+ interface GithubFactFields {
14
+ type: string;
15
+ entity: string;
16
+ actor: string | undefined;
17
+ at: string;
18
+ locator: string | undefined;
19
+ }
20
+
21
+ // `ignored`: a delivery pepys does not keep, such as a push to a feature branch, or an action that
22
+ // the family does not list. `invalid`: the body is not what the event documents.
23
+ export interface GithubEventRule {
24
+ readonly family: FactFamily;
25
+ readonly read: (body: unknown) => GithubFactFields | 'ignored' | 'invalid';
26
+ }
27
+
28
+ const senderSchema = z.looseObject({ id: z.number().int() }).optional();
29
+ const timeSchema = z.string().min(1);
30
+
31
+ const pullRequestBodySchema = z.looseObject({
32
+ action: z.string().min(1),
33
+ pull_request: z.looseObject({
34
+ id: z.number().int(),
35
+ updated_at: timeSchema,
36
+ url: z.string().optional(),
37
+ }),
38
+ sender: senderSchema,
39
+ });
40
+
41
+ const releaseBodySchema = z.looseObject({
42
+ action: z.string().min(1),
43
+ release: z.looseObject({
44
+ id: z.number().int(),
45
+ url: z.string().optional(),
46
+ created_at: timeSchema,
47
+ published_at: timeSchema.nullish(),
48
+ updated_at: timeSchema.nullish(),
49
+ }),
50
+ sender: senderSchema,
51
+ });
52
+
53
+ const pushedAtSchema = z.union([z.number(), z.string()]).nullish();
54
+
55
+ const pushRepositorySchema = z.looseObject({
56
+ id: z.number().int(),
57
+ default_branch: z.string(),
58
+ pushed_at: pushedAtSchema,
59
+ });
60
+
61
+ const pushBodySchema = z.looseObject({
62
+ ref: z.string().min(1),
63
+ repository: pushRepositorySchema,
64
+ head_commit: z.looseObject({ timestamp: timeSchema }).nullish(),
65
+ sender: senderSchema,
66
+ });
67
+
68
+ const deploymentBodySchema = z.looseObject({
69
+ deployment: z.looseObject({ id: z.number().int(), created_at: timeSchema }),
70
+ sender: senderSchema,
71
+ });
72
+
73
+ const deploymentStatusBodySchema = z.looseObject({
74
+ deployment_status: z.looseObject({ created_at: timeSchema }),
75
+ deployment: z.looseObject({ id: z.number().int() }),
76
+ sender: senderSchema,
77
+ });
78
+
79
+ const workflowRunBodySchema = z.looseObject({
80
+ action: z.string().min(1),
81
+ workflow_run: z.looseObject({ id: z.number().int(), updated_at: timeSchema }),
82
+ sender: senderSchema,
83
+ });
84
+
85
+ const issueBodySchema = z.looseObject({
86
+ action: z.string().min(1),
87
+ issue: z.looseObject({ id: z.number().int(), updated_at: timeSchema }),
88
+ sender: senderSchema,
89
+ });
90
+
91
+ const reviewBodySchema = z.looseObject({
92
+ action: z.string().min(1),
93
+ review: z.looseObject({ id: z.number().int(), submitted_at: timeSchema.nullish() }),
94
+ pull_request: z.looseObject({ updated_at: timeSchema }),
95
+ sender: senderSchema,
96
+ });
97
+
98
+ // One rule per webhook event name. Any other event is ignored.
99
+ export const githubEventRules: Readonly<Record<string, GithubEventRule>> = {
100
+ pull_request: {
101
+ family: pullRequestFamily,
102
+ read: (body) => {
103
+ const parsed = pullRequestBodySchema.safeParse(body);
104
+
105
+ if (!parsed.success) {
106
+ return 'invalid';
107
+ }
108
+
109
+ const pullRequest = parsed.data.pull_request;
110
+
111
+ return pickFields(pullRequestFamily, `github.pull_request.${parsed.data.action}`, {
112
+ entity: `github:pull_request:${pullRequest.id}`,
113
+ actor: parsed.data.sender,
114
+ time: pullRequest.updated_at,
115
+ locator: toAPIPath(pullRequest.url),
116
+ });
117
+ },
118
+ },
119
+ release: {
120
+ family: releaseFamily,
121
+ read: (body) => {
122
+ const parsed = releaseBodySchema.safeParse(body);
123
+
124
+ if (!parsed.success) {
125
+ return 'invalid';
126
+ }
127
+
128
+ const release = parsed.data.release;
129
+
130
+ return pickFields(releaseFamily, `github.release.${parsed.data.action}`, {
131
+ entity: `github:release:${release.id}`,
132
+ actor: parsed.data.sender,
133
+ time: release.updated_at ?? release.published_at ?? release.created_at,
134
+ locator: toAPIPath(release.url),
135
+ });
136
+ },
137
+ },
138
+ push: {
139
+ family: refFamily,
140
+ read: (body) => {
141
+ const parsed = pushBodySchema.safeParse(body);
142
+
143
+ if (!parsed.success) {
144
+ return 'invalid';
145
+ }
146
+
147
+ const push = parsed.data;
148
+ const isDefaultBranch = push.ref === `refs/heads/${push.repository.default_branch}`;
149
+
150
+ if (!isDefaultBranch && !push.ref.startsWith('refs/tags/')) {
151
+ return 'ignored';
152
+ }
153
+
154
+ // A push gives its time as seconds, or as text with an offset; the fact holds one form, so
155
+ // the times of two pushes compare as text.
156
+ const pushedAt = push.repository.pushed_at;
157
+
158
+ const timeValue =
159
+ typeof pushedAt === 'number'
160
+ ? pushedAt * 1000
161
+ : (pushedAt ?? push.head_commit?.timestamp ?? '');
162
+
163
+ const time = new Date(timeValue);
164
+
165
+ return pickFields(refFamily, 'github.ref.pushed', {
166
+ entity: `github:ref:${push.repository.id}:${push.ref}`,
167
+ actor: push.sender,
168
+ time: Number.isNaN(time.getTime()) ? undefined : time.toISOString(),
169
+ locator: undefined,
170
+ });
171
+ },
172
+ },
173
+ deployment: {
174
+ family: deploymentFamily,
175
+ read: (body) => {
176
+ const parsed = deploymentBodySchema.safeParse(body);
177
+
178
+ if (!parsed.success) {
179
+ return 'invalid';
180
+ }
181
+
182
+ return pickFields(deploymentFamily, 'github.deployment.created', {
183
+ entity: `github:deployment:${parsed.data.deployment.id}`,
184
+ actor: parsed.data.sender,
185
+ time: parsed.data.deployment.created_at,
186
+ locator: undefined,
187
+ });
188
+ },
189
+ },
190
+ deployment_status: {
191
+ family: deploymentFamily,
192
+ read: (body) => {
193
+ const parsed = deploymentStatusBodySchema.safeParse(body);
194
+
195
+ if (!parsed.success) {
196
+ return 'invalid';
197
+ }
198
+
199
+ return pickFields(deploymentFamily, 'github.deployment.status_created', {
200
+ entity: `github:deployment:${parsed.data.deployment.id}`,
201
+ actor: parsed.data.sender,
202
+ time: parsed.data.deployment_status.created_at,
203
+ locator: undefined,
204
+ });
205
+ },
206
+ },
207
+ workflow_run: {
208
+ family: workflowRunFamily,
209
+ read: (body) => {
210
+ const parsed = workflowRunBodySchema.safeParse(body);
211
+
212
+ if (!parsed.success) {
213
+ return 'invalid';
214
+ }
215
+
216
+ return pickFields(workflowRunFamily, `github.workflow_run.${parsed.data.action}`, {
217
+ entity: `github:workflow_run:${parsed.data.workflow_run.id}`,
218
+ actor: parsed.data.sender,
219
+ time: parsed.data.workflow_run.updated_at,
220
+ locator: undefined,
221
+ });
222
+ },
223
+ },
224
+ issues: {
225
+ family: issueFamily,
226
+ read: (body) => {
227
+ const parsed = issueBodySchema.safeParse(body);
228
+
229
+ if (!parsed.success) {
230
+ return 'invalid';
231
+ }
232
+
233
+ return pickFields(issueFamily, `github.issue.${parsed.data.action}`, {
234
+ entity: `github:issue:${parsed.data.issue.id}`,
235
+ actor: parsed.data.sender,
236
+ time: parsed.data.issue.updated_at,
237
+ locator: undefined,
238
+ });
239
+ },
240
+ },
241
+ pull_request_review: {
242
+ family: pullRequestReviewFamily,
243
+ read: (body) => {
244
+ const parsed = reviewBodySchema.safeParse(body);
245
+
246
+ if (!parsed.success) {
247
+ return 'invalid';
248
+ }
249
+
250
+ const review = parsed.data.review;
251
+
252
+ return pickFields(
253
+ pullRequestReviewFamily,
254
+ `github.pull_request_review.${parsed.data.action}`,
255
+ {
256
+ entity: `github:pull_request_review:${review.id}`,
257
+ actor: parsed.data.sender,
258
+ time: review.submitted_at ?? parsed.data.pull_request.updated_at,
259
+ locator: undefined,
260
+ },
261
+ );
262
+ },
263
+ },
264
+ };
265
+
266
+ interface RuleValues {
267
+ entity: string;
268
+ actor: { id: number } | undefined;
269
+ time: string | null | undefined;
270
+ locator: string | undefined;
271
+ }
272
+
273
+ // An action the family does not list is ignored, so GitHub records no failed delivery for it. A
274
+ // time with an offset, as a push's commit gives, is written in UTC.
275
+ function pickFields(
276
+ family: FactFamily,
277
+ type: string,
278
+ values: RuleValues,
279
+ ): GithubFactFields | 'ignored' | 'invalid' {
280
+ if (!Object.hasOwn(family.types, type)) {
281
+ return 'ignored';
282
+ }
283
+
284
+ const at = toFactTime(values.time);
285
+
286
+ if (at === undefined) {
287
+ return 'invalid';
288
+ }
289
+
290
+ return {
291
+ type,
292
+ entity: values.entity,
293
+ actor: values.actor === undefined ? undefined : `github:user:${values.actor.id}`,
294
+ at,
295
+ locator: values.locator,
296
+ };
297
+ }
298
+
299
+ const utcTimeSchema = z.iso.datetime();
300
+
301
+ function toFactTime(time: string | null | undefined): string | undefined {
302
+ if (time === null || time === undefined) {
303
+ return undefined;
304
+ }
305
+
306
+ if (utcTimeSchema.safeParse(time).success) {
307
+ return time;
308
+ }
309
+
310
+ const date = new Date(time);
311
+
312
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
313
+ }
314
+
315
+ // The resync pull reads only the path, from the API origin it knows.
316
+ function toAPIPath(url: string | undefined): string | undefined {
317
+ if (url === undefined || !URL.canParse(url)) {
318
+ return undefined;
319
+ }
320
+
321
+ const path = new URL(url).pathname;
322
+
323
+ return path.startsWith('/repos/') ? path : undefined;
324
+ }
@@ -0,0 +1,41 @@
1
+ import type { Delivery, Received } from '@pepys/kit';
2
+ import { buildGithubFact } from './build-github-fact.ts';
3
+
4
+ // The read of a checked webhook delivery: the event's rule makes one fact, and an event with no
5
+ // rule is ignored. The delivery ID is required and goes in no fact.
6
+ export function readGithubDelivery(delivery: Delivery): Received {
7
+ const event = delivery.headers.get('x-github-event') ?? '';
8
+ const deliveryID = delivery.headers.get('x-github-delivery');
9
+
10
+ if (deliveryID === null || deliveryID === '') {
11
+ return { kind: 'refused', status: 400, reason: 'no delivery ID' };
12
+ }
13
+
14
+ const body = parseJSON(new TextDecoder().decode(delivery.body));
15
+
16
+ if (!body.ok) {
17
+ return { kind: 'refused', status: 400, reason: 'the body is not JSON' };
18
+ }
19
+
20
+ const fact = buildGithubFact(event, body.value, 'delivery');
21
+
22
+ if (fact === 'ignored') {
23
+ return { kind: 'ignored' };
24
+ }
25
+
26
+ if (fact === 'invalid') {
27
+ return { kind: 'refused', status: 422, reason: `not a ${event} delivery` };
28
+ }
29
+
30
+ return { kind: 'batch', batch: { facts: [fact] } };
31
+ }
32
+
33
+ function parseJSON(text: string): { ok: true; value: unknown } | { ok: false } {
34
+ try {
35
+ const value: unknown = JSON.parse(text);
36
+
37
+ return { ok: true, value };
38
+ } catch {
39
+ return { ok: false };
40
+ }
41
+ }
@@ -0,0 +1,100 @@
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 { requestGithubAPI } from '../github-app/request-github-api.ts';
7
+ import type { GithubAnswer } from '../github-app/request-github-api.ts';
8
+
9
+ const deliveryDetailSchema = z.looseObject({
10
+ event: z.string(),
11
+ request: z.looseObject({ payload: z.unknown() }),
12
+ });
13
+
14
+ // The recovery pull: each delivery of the App's webhook that got no 2xx answer, read from GitHub's
15
+ // delivery log and built as the receive path builds it, so a delivery that did arrive is a copy.
16
+ // The cursor is the highest delivery ID seen; GitHub keeps deliveries for 3 days.
17
+ export async function pullFailedDeliveries(
18
+ cursor: string | null,
19
+ context: PullContext,
20
+ ): Promise<Pulled> {
21
+ const appJWT = findAppJWT(context.env, Date.now());
22
+
23
+ if (appJWT === undefined) {
24
+ return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
25
+ }
26
+
27
+ const seenID = cursor === null ? 0 : Number(cursor);
28
+
29
+ const listed = await readDeliveriesAfter(seenID, appJWT);
30
+
31
+ if (listed.kind !== 'listed') {
32
+ return pickPullFailure(listed.status);
33
+ }
34
+
35
+ const facts: PendingFact[] = [];
36
+
37
+ for (const id of collectFailedIDs(listed.items)) {
38
+ const answer = await requestGithubAPI(`/app/hook/deliveries/${id}`, appJWT);
39
+
40
+ const detail = deliveryDetailSchema.safeParse(answer.body);
41
+
42
+ if (answer.status !== 200 || !detail.success) {
43
+ return pickPullFailure(answer.status);
44
+ }
45
+
46
+ const fact = buildGithubFact(detail.data.event, detail.data.request.payload, 'delivery');
47
+
48
+ if (fact !== 'ignored' && fact !== 'invalid') {
49
+ facts.push(fact);
50
+ }
51
+ }
52
+
53
+ const highestID = Math.max(seenID, ...listed.items.map((item) => item.id));
54
+
55
+ return { kind: 'batch', batch: { facts }, cursor: `${highestID}`, isDone: true };
56
+ }
57
+
58
+ const deliveryItemSchema = z.looseObject({
59
+ id: z.number().int(),
60
+ status_code: z.number().int(),
61
+ event: z.string(),
62
+ });
63
+
64
+ const deliveryListSchema = z.array(deliveryItemSchema);
65
+
66
+ type DeliveryItem = z.infer<typeof deliveryItemSchema>;
67
+
68
+ type DeliveryList = { kind: 'listed'; items: DeliveryItem[] } | { kind: 'failed'; status: number };
69
+
70
+ // GitHub lists the newest delivery first, so the pages stop at the first one already seen.
71
+ async function readDeliveriesAfter(seenID: number, appJWT: string): Promise<DeliveryList> {
72
+ const items: DeliveryItem[] = [];
73
+ let url: string | undefined = '/app/hook/deliveries?per_page=100';
74
+
75
+ while (url !== undefined) {
76
+ const answer: GithubAnswer = await requestGithubAPI(url, appJWT);
77
+
78
+ const page = deliveryListSchema.safeParse(answer.body);
79
+
80
+ if (answer.status !== 200 || !page.success) {
81
+ return { kind: 'failed', status: answer.status };
82
+ }
83
+
84
+ const unseen = page.data.filter((item) => item.id > seenID);
85
+
86
+ items.push(...unseen);
87
+
88
+ url = unseen.length < page.data.length ? undefined : answer.next;
89
+ }
90
+
91
+ return { kind: 'listed', items };
92
+ }
93
+
94
+ // oldest first, so the facts reach the log in the order GitHub sent them
95
+ function collectFailedIDs(items: readonly DeliveryItem[]): number[] {
96
+ return items
97
+ .filter((item) => item.status_code < 200 || item.status_code >= 300)
98
+ .map((item) => item.id)
99
+ .toSorted((left, right) => left - right);
100
+ }
@@ -0,0 +1,33 @@
1
+ import type { ResyncMark } from '@pepys/kit';
2
+
3
+ // One marked object that the resync pull reads again: its repository, `owner/name`, and its API path.
4
+ export interface ResyncTarget {
5
+ kind: 'pull_request' | 'release';
6
+ entity: string;
7
+ repository: string;
8
+ path: string;
9
+ }
10
+
11
+ const locatorPatterns = [
12
+ { kind: 'pull_request', pattern: /^\/repos\/(?<repository>[^/]+\/[^/]+)\/pulls\/\d+$/u },
13
+ { kind: 'release', pattern: /^\/repos\/(?<repository>[^/]+\/[^/]+)\/releases\/\d+$/u },
14
+ ] as const;
15
+
16
+ // The marks that the pull can read. A mark of another family, or with no locator, has nothing to
17
+ // read, so the pull leaves it out, and ingest removes it with the rest once the batch is stored.
18
+ export function collectResyncTargets(marks: readonly ResyncMark[]): ResyncTarget[] {
19
+ return marks.flatMap((mark) => {
20
+ const locator = mark.locator ?? '';
21
+
22
+ for (const locatorPattern of locatorPatterns) {
23
+ const repository = locatorPattern.pattern.exec(locator)?.groups?.['repository'];
24
+ const kind = locatorPattern.kind;
25
+
26
+ if (repository !== undefined && mark.entity.startsWith(`github:${kind}:`)) {
27
+ return [{ kind, entity: mark.entity, repository, path: locator }];
28
+ }
29
+ }
30
+
31
+ return [];
32
+ });
33
+ }