@useairfoil/producer-github 0.0.1 → 0.0.3
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/LICENSE.txt +201 -0
- package/README.md +116 -0
- package/dist/index.d.mts +1939 -0
- package/dist/index.mjs +3 -0
- package/dist/main.d.mts +1 -0
- package/dist/main.mjs +700 -0
- package/dist/main.mjs.map +1 -0
- package/dist/manifest.d.mts +56 -0
- package/dist/manifest.mjs +137 -0
- package/dist/manifest.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/src-DKDUc00e.mjs +1563 -0
- package/dist/src-DKDUc00e.mjs.map +1 -0
- package/package.json +46 -8
- package/src/client/auth.ts +113 -0
- package/src/client/client.ts +192 -0
- package/src/client/constants.ts +4 -0
- package/src/connector.ts +97 -0
- package/src/create-table.ts +10 -0
- package/src/demo/changes.ts +420 -0
- package/src/demo/index.ts +59 -0
- package/src/demo/setup.ts +34 -0
- package/src/demo/shared.ts +101 -0
- package/src/index.ts +13 -0
- package/src/main.ts +28 -0
- package/src/manifest.ts +45 -0
- package/src/resources/check-runs.ts +72 -0
- package/src/resources/commit-statuses.ts +84 -0
- package/src/resources/commits.ts +101 -0
- package/src/resources/issue-comments.ts +75 -0
- package/src/resources/issues.ts +78 -0
- package/src/resources/pull-request-commits.ts +91 -0
- package/src/resources/pull-request-review-comments.ts +78 -0
- package/src/resources/pull-request-reviews.ts +76 -0
- package/src/resources/pull-requests.ts +79 -0
- package/src/resources/releases.ts +74 -0
- package/src/resources/repositories.ts +101 -0
- package/src/resources/shared.ts +480 -0
- package/src/resources/workflow-jobs.ts +73 -0
- package/src/resources/workflow-runs.ts +70 -0
- package/src/sandbox.ts +35 -0
- package/src/schemas/check-runs.ts +72 -0
- package/src/schemas/commit-statuses.ts +51 -0
- package/src/schemas/commits.ts +80 -0
- package/src/schemas/events.ts +139 -0
- package/src/schemas/issue-comments.ts +48 -0
- package/src/schemas/issues.ts +76 -0
- package/src/schemas/pull-request-commits.ts +42 -0
- package/src/schemas/pull-request-review-comments.ts +85 -0
- package/src/schemas/pull-request-reviews.ts +53 -0
- package/src/schemas/pull-requests.ts +95 -0
- package/src/schemas/releases.ts +70 -0
- package/src/schemas/repositories.ts +73 -0
- package/src/schemas/shared.ts +76 -0
- package/src/schemas/workflow-jobs.ts +78 -0
- package/src/schemas/workflow-runs.ts +72 -0
- package/src/start.ts +47 -0
- package/src/tables.ts +31 -0
- package/src/webhook/route.ts +46 -0
- package/src/webhook/signature.ts +20 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { ConnectorError, Cursor, Fetch } from "@useairfoil/connector-kit";
|
|
2
|
+
import {
|
|
3
|
+
Array as Arr,
|
|
4
|
+
DateTime,
|
|
5
|
+
Duration,
|
|
6
|
+
Effect,
|
|
7
|
+
Exit,
|
|
8
|
+
Option,
|
|
9
|
+
Order,
|
|
10
|
+
Schema,
|
|
11
|
+
SchemaTransformation,
|
|
12
|
+
Stream,
|
|
13
|
+
} from "effect";
|
|
14
|
+
|
|
15
|
+
import { type GitHubClientService, withParams } from "../client/client";
|
|
16
|
+
import { type RepositoryRef, RepositoryRefSchema } from "../schemas/repositories";
|
|
17
|
+
|
|
18
|
+
type Timestamps = { readonly created_at: Date; readonly updated_at: Date };
|
|
19
|
+
|
|
20
|
+
export const timestamps = {
|
|
21
|
+
createdAt: (item: Timestamps) => item.created_at,
|
|
22
|
+
updatedAt: (item: Timestamps) => item.updated_at,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type DeleteRow = {
|
|
26
|
+
readonly id: bigint;
|
|
27
|
+
readonly version: Date;
|
|
28
|
+
readonly _deleted: true;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const pageSize = "100";
|
|
32
|
+
export const changesInterval = Duration.minutes(15);
|
|
33
|
+
// A run stops after this many pages and the next one starts right away.
|
|
34
|
+
const maxChangesPagesPerRun = 20;
|
|
35
|
+
// Updates can show up in a list late, so each changes read starts a bit early.
|
|
36
|
+
const changesOverlap = Duration.minutes(5);
|
|
37
|
+
const repositoryListTtl = Duration.minutes(5);
|
|
38
|
+
|
|
39
|
+
export const installationRepositoriesPath = "/installation/repositories";
|
|
40
|
+
|
|
41
|
+
export const repositoryPage = <A>(item: Schema.Decoder<A>) =>
|
|
42
|
+
Schema.Struct({ repositories: Schema.Array(item) });
|
|
43
|
+
|
|
44
|
+
/** Some lists wrap their items, such as `{ total_count, workflow_runs: [...] }`. */
|
|
45
|
+
export const wrappedList = <A>(
|
|
46
|
+
key: string,
|
|
47
|
+
item: Schema.Decoder<A>,
|
|
48
|
+
): Schema.Decoder<ReadonlyArray<A>> =>
|
|
49
|
+
Schema.Struct({ [key]: Schema.Array(item) }).pipe(
|
|
50
|
+
Schema.decodeTo(
|
|
51
|
+
Schema.Array(Schema.toType(item)),
|
|
52
|
+
SchemaTransformation.transform({
|
|
53
|
+
decode: (page) => page[key],
|
|
54
|
+
encode: (items) => ({ [key]: items }),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const readPages = <A>(
|
|
60
|
+
client: GitHubClientService,
|
|
61
|
+
pageSchema: Schema.Decoder<ReadonlyArray<A>>,
|
|
62
|
+
url: string,
|
|
63
|
+
keepGoing: (items: ReadonlyArray<A>) => boolean = () => true,
|
|
64
|
+
): Effect.Effect<ReadonlyArray<A>, ConnectorError> =>
|
|
65
|
+
Stream.paginate(url, (current) =>
|
|
66
|
+
client
|
|
67
|
+
.get(pageSchema, current)
|
|
68
|
+
.pipe(
|
|
69
|
+
Effect.map(
|
|
70
|
+
(page) => [page.body, keepGoing(page.body) ? page.next : Option.none()] as const,
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
).pipe(Stream.runCollect);
|
|
74
|
+
|
|
75
|
+
/** Every repository the installation can read, ordered by ID. */
|
|
76
|
+
export const listRepositories = <A extends { readonly id: number | bigint }>(
|
|
77
|
+
client: GitHubClientService,
|
|
78
|
+
schema: Schema.Decoder<A>,
|
|
79
|
+
): Effect.Effect<ReadonlyArray<A>, ConnectorError> =>
|
|
80
|
+
readPages(
|
|
81
|
+
client,
|
|
82
|
+
wrappedList("repositories", schema),
|
|
83
|
+
withParams(installationRepositoriesPath, { per_page: pageSize }),
|
|
84
|
+
).pipe(
|
|
85
|
+
Effect.map(Arr.sort(Order.mapInput(Order.Number, (repository: A) => Number(repository.id)))),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
export const cachedRepositoryRefs = (client: GitHubClientService) =>
|
|
89
|
+
listRepositories(client, RepositoryRefSchema).pipe(
|
|
90
|
+
Effect.cachedWithTTL((exit) => (Exit.isSuccess(exit) ? repositoryListTtl : 0)),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `since` is when the last run started, and `repositoryIds` are the
|
|
95
|
+
* repositories it read. Any other repository is new and is read from the start.
|
|
96
|
+
* `pending` is set while a run is split.
|
|
97
|
+
*/
|
|
98
|
+
const ChangesCursorSchema = Schema.Struct({
|
|
99
|
+
since: Schema.DateTimeUtcFromString,
|
|
100
|
+
repositoryIds: Schema.optional(Schema.Array(Schema.Number)),
|
|
101
|
+
pending: Schema.optional(
|
|
102
|
+
Schema.Struct({
|
|
103
|
+
until: Schema.DateTimeUtcFromString,
|
|
104
|
+
repositoryIds: Schema.Array(Schema.Number),
|
|
105
|
+
repositoryId: Schema.Number,
|
|
106
|
+
url: Schema.optional(Schema.String),
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
type ChangesCursor = Schema.Schema.Type<typeof ChangesCursorSchema>;
|
|
112
|
+
|
|
113
|
+
const ChangesCursorCodec = Schema.Union([
|
|
114
|
+
Schema.fromJsonString(ChangesCursorSchema),
|
|
115
|
+
// The first cursor is the backfill cutoff, a plain ISO time. Every repository
|
|
116
|
+
// the installation has then counts as read.
|
|
117
|
+
Schema.DateTimeUtcFromString.pipe(
|
|
118
|
+
Schema.decodeTo(
|
|
119
|
+
Schema.toType(ChangesCursorSchema),
|
|
120
|
+
SchemaTransformation.transform({
|
|
121
|
+
decode: (since): ChangesCursor => ({ since }),
|
|
122
|
+
encode: (cursor) => cursor.since,
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const cursorError = (cause: Schema.SchemaError) =>
|
|
129
|
+
new ConnectorError({ message: "Invalid GitHub cursor", cause });
|
|
130
|
+
|
|
131
|
+
const decodeChangesCursor = (value: string) =>
|
|
132
|
+
Schema.decodeUnknownEffect(ChangesCursorCodec)(value).pipe(Effect.mapError(cursorError));
|
|
133
|
+
|
|
134
|
+
const encodeChangesCursor = (cursor: ChangesCursor) =>
|
|
135
|
+
Schema.encodeEffect(Schema.fromJsonString(ChangesCursorSchema))(cursor).pipe(
|
|
136
|
+
Effect.mapError(cursorError),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** When to read a repository's changes from. None means from the start. */
|
|
140
|
+
const readFrom = (state: ChangesCursor, repositoryId: number): Option.Option<Date> =>
|
|
141
|
+
state.repositoryIds === undefined || state.repositoryIds.includes(repositoryId)
|
|
142
|
+
? Option.some(DateTime.toDateUtc(DateTime.subtractDuration(state.since, changesOverlap)))
|
|
143
|
+
: Option.none();
|
|
144
|
+
|
|
145
|
+
export const isSince = (time: Option.Option<Date>, date: Date) =>
|
|
146
|
+
Option.match(time, { onNone: () => true, onSome: (since) => date.getTime() >= since.getTime() });
|
|
147
|
+
|
|
148
|
+
export const readChanges = <A extends { readonly id: number | bigint }, Row>(
|
|
149
|
+
cursor: string,
|
|
150
|
+
repositories: Effect.Effect<ReadonlyArray<A>, ConnectorError>,
|
|
151
|
+
toRows: (
|
|
152
|
+
repositories: ReadonlyArray<{ readonly repository: A; readonly since: Option.Option<Date> }>,
|
|
153
|
+
) => ReadonlyArray<Row>,
|
|
154
|
+
) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
const state = yield* decodeChangesCursor(cursor);
|
|
157
|
+
const runStart = yield* DateTime.now;
|
|
158
|
+
const listed = yield* repositories;
|
|
159
|
+
return {
|
|
160
|
+
rows: toRows(
|
|
161
|
+
listed.map((repository) => ({
|
|
162
|
+
repository,
|
|
163
|
+
since: readFrom(state, Number(repository.id)),
|
|
164
|
+
})),
|
|
165
|
+
),
|
|
166
|
+
cursor: yield* encodeChangesCursor({
|
|
167
|
+
since: runStart,
|
|
168
|
+
repositoryIds: listed.map((repository) => Number(repository.id)),
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export type RepoResourceSpec<Item, Row extends object> = {
|
|
174
|
+
readonly path: string;
|
|
175
|
+
readonly pageSchema: Schema.Decoder<ReadonlyArray<Item>>;
|
|
176
|
+
readonly params: Readonly<Record<string, string>>;
|
|
177
|
+
/** Whether the list takes `since`. */
|
|
178
|
+
readonly since: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* `sorted` lists take `sort` and `direction`, so changes stop at the first
|
|
181
|
+
* older item. `newest` lists come newest first, so backfill reads every page.
|
|
182
|
+
*/
|
|
183
|
+
readonly order: "sorted" | "newest";
|
|
184
|
+
/**
|
|
185
|
+
* On a `newest` list, changes stop at the first page created this long
|
|
186
|
+
* before `since`. Without it, they read every page.
|
|
187
|
+
*/
|
|
188
|
+
readonly recentFor?: Duration.Duration;
|
|
189
|
+
readonly createdAt: (item: Item) => Date;
|
|
190
|
+
readonly updatedAt: (item: Item) => Date;
|
|
191
|
+
/** Items the resource keeps. The issues list also returns pull requests. */
|
|
192
|
+
readonly keep: (item: Item) => boolean;
|
|
193
|
+
/** GitHub returns 404 for pull requests and comments when the feature is off. */
|
|
194
|
+
readonly enabled: (repository: RepositoryRef) => boolean;
|
|
195
|
+
/** Rows for one page. A child resource reads its children here. */
|
|
196
|
+
readonly rows: (
|
|
197
|
+
items: ReadonlyArray<Item>,
|
|
198
|
+
repository: RepositoryRef,
|
|
199
|
+
) => Effect.Effect<ReadonlyArray<Row>, ConnectorError>;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const rowsOf =
|
|
203
|
+
<Item, Row>(toRow: (item: Item, repositoryId: bigint) => Row) =>
|
|
204
|
+
(items: ReadonlyArray<Item>, repository: RepositoryRef) =>
|
|
205
|
+
Effect.succeed(items.map((item) => toRow(item, BigInt(repository.id))));
|
|
206
|
+
|
|
207
|
+
export const repoPath = (repository: RepositoryRef, path: string) =>
|
|
208
|
+
`/repos/${repository.full_name}/${path}`;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `rows` for a child resource, such as reviews of pull requests. Parents are
|
|
212
|
+
* read one at a time, as GitHub asks. `readAt` is the version for children
|
|
213
|
+
* without an update time.
|
|
214
|
+
*/
|
|
215
|
+
export const childRows =
|
|
216
|
+
<Parent, Child, Row>(
|
|
217
|
+
client: GitHubClientService,
|
|
218
|
+
child: {
|
|
219
|
+
readonly path: (parent: Parent) => string;
|
|
220
|
+
readonly pageSchema: Schema.Decoder<ReadonlyArray<Child>>;
|
|
221
|
+
readonly params?: Readonly<Record<string, string>>;
|
|
222
|
+
readonly toRow: (child: Child, parent: Parent, repositoryId: bigint, readAt: Date) => Row;
|
|
223
|
+
},
|
|
224
|
+
) =>
|
|
225
|
+
(parents: ReadonlyArray<Parent>, repository: RepositoryRef) =>
|
|
226
|
+
Effect.gen(function* () {
|
|
227
|
+
const readAt = yield* DateTime.nowAsDate;
|
|
228
|
+
const rows = yield* Effect.forEach(parents, (parent) =>
|
|
229
|
+
readPages(
|
|
230
|
+
client,
|
|
231
|
+
child.pageSchema,
|
|
232
|
+
withParams(repoPath(repository, child.path(parent)), {
|
|
233
|
+
...child.params,
|
|
234
|
+
per_page: pageSize,
|
|
235
|
+
}),
|
|
236
|
+
).pipe(
|
|
237
|
+
Effect.map((children) =>
|
|
238
|
+
children.map((item) => child.toRow(item, parent, BigInt(repository.id), readAt)),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
return rows.flat();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** Lists one item, then reads its rows, so a child resource checks its own call too. */
|
|
246
|
+
export const checkRepoResource = <Item, Row extends object>(
|
|
247
|
+
client: GitHubClientService,
|
|
248
|
+
spec: RepoResourceSpec<Item, Row>,
|
|
249
|
+
): Effect.Effect<void, ConnectorError> =>
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
const page = yield* client.get(
|
|
252
|
+
repositoryPage(RepositoryRefSchema),
|
|
253
|
+
withParams(installationRepositoriesPath, { per_page: pageSize }),
|
|
254
|
+
);
|
|
255
|
+
// An installation without such a repository has nothing to check yet.
|
|
256
|
+
const repository = Arr.findFirst(page.body.repositories, spec.enabled);
|
|
257
|
+
if (Option.isSome(repository)) {
|
|
258
|
+
const items = yield* client.get(
|
|
259
|
+
spec.pageSchema,
|
|
260
|
+
withParams(repoPath(repository.value, spec.path), { ...spec.params, per_page: "1" }),
|
|
261
|
+
);
|
|
262
|
+
yield* spec.rows(items.body.filter(spec.keep), repository.value);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const BackfillCursorSchema = Schema.fromJsonString(
|
|
267
|
+
Schema.Struct({
|
|
268
|
+
repositoryId: Schema.Number,
|
|
269
|
+
next: Schema.optional(Schema.String),
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
type BackfillCursor = Schema.Schema.Type<typeof BackfillCursorSchema>;
|
|
274
|
+
|
|
275
|
+
const decodeBackfillCursor = (value: string) =>
|
|
276
|
+
Schema.decodeUnknownEffect(BackfillCursorSchema)(value).pipe(Effect.mapError(cursorError));
|
|
277
|
+
|
|
278
|
+
const encodeBackfillCursor = (cursor: BackfillCursor) =>
|
|
279
|
+
Schema.encodeEffect(BackfillCursorSchema)(cursor).pipe(Effect.mapError(cursorError));
|
|
280
|
+
|
|
281
|
+
export const parseCutoff = (value: string): Date => DateTime.toDateUtc(DateTime.makeUnsafe(value));
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Reads one repository at a time up to the cutoff, oldest first when the list
|
|
285
|
+
* can sort. The cursor keeps the `next` URL, because the issues list pages
|
|
286
|
+
* with a cursor.
|
|
287
|
+
*/
|
|
288
|
+
export const backfillRepoResource = <Item, Row extends object>(
|
|
289
|
+
client: GitHubClientService,
|
|
290
|
+
repositories: Effect.Effect<ReadonlyArray<RepositoryRef>, ConnectorError>,
|
|
291
|
+
spec: RepoResourceSpec<Item, Row>,
|
|
292
|
+
) =>
|
|
293
|
+
Fetch.page({
|
|
294
|
+
pageCursor: Cursor.string(),
|
|
295
|
+
cutoff: Cursor.isoDateTime(),
|
|
296
|
+
fetch: ({ pageCursor, cutoff }) =>
|
|
297
|
+
Effect.gen(function* () {
|
|
298
|
+
const cutoffDate = parseCutoff(String(cutoff));
|
|
299
|
+
const listed = (yield* repositories).filter(spec.enabled);
|
|
300
|
+
const position =
|
|
301
|
+
typeof pageCursor === "string"
|
|
302
|
+
? Option.some(yield* decodeBackfillCursor(pageCursor))
|
|
303
|
+
: Option.none();
|
|
304
|
+
// A repository removed from the installation is skipped.
|
|
305
|
+
const repository = Option.match(position, {
|
|
306
|
+
onNone: () => listed[0],
|
|
307
|
+
onSome: ({ repositoryId }) => listed.find((item) => item.id >= repositoryId),
|
|
308
|
+
});
|
|
309
|
+
if (repository === undefined) return { rows: [], hasMore: false };
|
|
310
|
+
|
|
311
|
+
const url = Option.flatMap(position, ({ repositoryId, next }) =>
|
|
312
|
+
repositoryId === repository.id ? Option.fromNullishOr(next) : Option.none(),
|
|
313
|
+
).pipe(
|
|
314
|
+
Option.getOrElse(() =>
|
|
315
|
+
withParams(repoPath(repository, spec.path), {
|
|
316
|
+
...spec.params,
|
|
317
|
+
...(spec.order === "sorted" ? { sort: "created", direction: "asc" } : {}),
|
|
318
|
+
per_page: pageSize,
|
|
319
|
+
}),
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
const page = yield* client.get(spec.pageSchema, url);
|
|
323
|
+
const beforeCutoff = page.body.filter(
|
|
324
|
+
(item) => spec.createdAt(item).getTime() <= cutoffDate.getTime(),
|
|
325
|
+
);
|
|
326
|
+
const rows = yield* spec.rows(beforeCutoff.filter(spec.keep), repository);
|
|
327
|
+
// A sorted list is past the cutoff at the first newer item.
|
|
328
|
+
const pastCutoff = spec.order === "sorted" && beforeCutoff.length < page.body.length;
|
|
329
|
+
|
|
330
|
+
if (!pastCutoff && Option.isSome(page.next)) {
|
|
331
|
+
return {
|
|
332
|
+
rows,
|
|
333
|
+
nextPageCursor: yield* encodeBackfillCursor({
|
|
334
|
+
repositoryId: repository.id,
|
|
335
|
+
next: page.next.value,
|
|
336
|
+
}),
|
|
337
|
+
hasMore: true,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const following = listed.find((item) => item.id > repository.id);
|
|
341
|
+
if (following === undefined) return { rows, hasMore: false };
|
|
342
|
+
return {
|
|
343
|
+
rows,
|
|
344
|
+
nextPageCursor: yield* encodeBackfillCursor({ repositoryId: following.id }),
|
|
345
|
+
hasMore: true,
|
|
346
|
+
};
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
type ChangesPage<Row> = {
|
|
351
|
+
readonly repository: RepositoryRef;
|
|
352
|
+
readonly rows: ReadonlyArray<Row>;
|
|
353
|
+
readonly next: Option.Option<string>;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* One repository's changes, newest first, so an edit during the read repeats a
|
|
358
|
+
* row instead of skipping one.
|
|
359
|
+
*/
|
|
360
|
+
const changesPages = <Item, Row extends object>(
|
|
361
|
+
client: GitHubClientService,
|
|
362
|
+
spec: RepoResourceSpec<Item, Row>,
|
|
363
|
+
repository: RepositoryRef,
|
|
364
|
+
since: Option.Option<Date>,
|
|
365
|
+
url: Option.Option<string>,
|
|
366
|
+
): Stream.Stream<ChangesPage<Row>, ConnectorError> => {
|
|
367
|
+
const first = withParams(repoPath(repository, spec.path), {
|
|
368
|
+
...spec.params,
|
|
369
|
+
...(spec.order === "sorted" ? { sort: "updated", direction: "desc" } : {}),
|
|
370
|
+
per_page: pageSize,
|
|
371
|
+
// Never send an old `since` to mean everything: GitHub then returns nothing.
|
|
372
|
+
...(spec.since && Option.isSome(since) ? { since: since.value.toISOString() } : {}),
|
|
373
|
+
});
|
|
374
|
+
const isRecent = (item: Item) => isSince(since, spec.updatedAt(item));
|
|
375
|
+
const { order, recentFor } = spec;
|
|
376
|
+
const createdFrom = Option.map(since, (date) =>
|
|
377
|
+
DateTime.toDateUtc(
|
|
378
|
+
DateTime.subtractDuration(DateTime.fromDateUnsafe(date), recentFor ?? Duration.zero),
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
const goOn = (items: ReadonlyArray<Item>) => {
|
|
382
|
+
if (order === "sorted") return items.every(isRecent);
|
|
383
|
+
if (recentFor === undefined) return true;
|
|
384
|
+
return items.every((item) => isSince(createdFrom, spec.createdAt(item)));
|
|
385
|
+
};
|
|
386
|
+
return Stream.paginate(
|
|
387
|
+
Option.getOrElse(url, () => first),
|
|
388
|
+
(current) =>
|
|
389
|
+
Effect.gen(function* () {
|
|
390
|
+
const page = yield* client.get(spec.pageSchema, current);
|
|
391
|
+
const next = goOn(page.body) ? page.next : Option.none();
|
|
392
|
+
const rows = yield* spec.rows(
|
|
393
|
+
page.body.filter((item) => isRecent(item) && spec.keep(item)),
|
|
394
|
+
repository,
|
|
395
|
+
);
|
|
396
|
+
return [[{ repository, rows, next }], next] as const;
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export const changesRepoResource = <Item, Row extends object>(
|
|
402
|
+
client: GitHubClientService,
|
|
403
|
+
spec: RepoResourceSpec<Item, Row>,
|
|
404
|
+
) =>
|
|
405
|
+
Fetch.changes({
|
|
406
|
+
cursor: Cursor.string(),
|
|
407
|
+
interval: changesInterval,
|
|
408
|
+
fetch: ({ cursor }) =>
|
|
409
|
+
Effect.gen(function* () {
|
|
410
|
+
const state = yield* decodeChangesCursor(String(cursor));
|
|
411
|
+
const listed = yield* listRepositories(client, RepositoryRefSchema);
|
|
412
|
+
const pending = state.pending;
|
|
413
|
+
const until = pending?.until ?? (yield* DateTime.now);
|
|
414
|
+
// Only repositories with the feature on count as read. One that turns it on
|
|
415
|
+
// later is read in full, because GitHub keeps the hidden items.
|
|
416
|
+
const repositoryIds =
|
|
417
|
+
pending?.repositoryIds ?? listed.filter(spec.enabled).map((repository) => repository.id);
|
|
418
|
+
const repositories = listed.filter(
|
|
419
|
+
(repository) =>
|
|
420
|
+
spec.enabled(repository) &&
|
|
421
|
+
repositoryIds.includes(repository.id) &&
|
|
422
|
+
repository.id >= (pending?.repositoryId ?? 0),
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const pages = yield* Stream.fromIterable(repositories).pipe(
|
|
426
|
+
Stream.flatMap((repository) =>
|
|
427
|
+
changesPages(
|
|
428
|
+
client,
|
|
429
|
+
spec,
|
|
430
|
+
repository,
|
|
431
|
+
readFrom(state, repository.id),
|
|
432
|
+
Option.fromNullishOr(
|
|
433
|
+
pending?.repositoryId === repository.id ? pending.url : undefined,
|
|
434
|
+
),
|
|
435
|
+
),
|
|
436
|
+
),
|
|
437
|
+
Stream.take(maxChangesPagesPerRun),
|
|
438
|
+
Stream.runCollect,
|
|
439
|
+
);
|
|
440
|
+
const rows = pages.flatMap((page) => page.rows);
|
|
441
|
+
|
|
442
|
+
const position = Option.flatMap(Arr.last(pages), ({ repository, next }) =>
|
|
443
|
+
Option.match(next, {
|
|
444
|
+
onSome: (url) => Option.some({ repositoryId: repository.id, url }),
|
|
445
|
+
onNone: () =>
|
|
446
|
+
Arr.findFirst(repositories, (item) => item.id > repository.id).pipe(
|
|
447
|
+
Option.map((item) => ({ repositoryId: item.id })),
|
|
448
|
+
),
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
if (pages.length === maxChangesPagesPerRun && Option.isSome(position)) {
|
|
452
|
+
return {
|
|
453
|
+
rows,
|
|
454
|
+
cursor: yield* encodeChangesCursor({
|
|
455
|
+
...state,
|
|
456
|
+
pending: { until, repositoryIds, ...position.value },
|
|
457
|
+
}),
|
|
458
|
+
hasMore: true,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
rows,
|
|
463
|
+
cursor: yield* encodeChangesCursor({ since: until, repositoryIds }),
|
|
464
|
+
hasMore: false,
|
|
465
|
+
};
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
/** Schemas check that the URL ends in a number. */
|
|
470
|
+
export const numberAtEnd = (url: string): bigint => BigInt(url.slice(url.lastIndexOf("/") + 1));
|
|
471
|
+
|
|
472
|
+
export const deleteRow = (id: bigint): Effect.Effect<DeleteRow> =>
|
|
473
|
+
DateTime.nowAsDate.pipe(Effect.map((version): DeleteRow => ({ id, version, _deleted: true })));
|
|
474
|
+
|
|
475
|
+
export const decodeEvent = <A>(schema: Schema.Decoder<A>, payload: unknown) =>
|
|
476
|
+
Schema.decodeUnknownEffect(schema)(payload).pipe(
|
|
477
|
+
Effect.mapError(
|
|
478
|
+
(cause) => new ConnectorError({ message: "GitHub webhook payload does not decode", cause }),
|
|
479
|
+
),
|
|
480
|
+
);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ConnectorError, Resource } from "@useairfoil/connector-kit";
|
|
2
|
+
import { DateTime, Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { GitHubClientService } from "../client/client";
|
|
5
|
+
import type { RepositoryRef } from "../schemas/repositories";
|
|
6
|
+
import type { WorkflowRunObject } from "../schemas/workflow-runs";
|
|
7
|
+
|
|
8
|
+
import { WebhookPayloadSchema, WorkflowJobEventSchema } from "../schemas/events";
|
|
9
|
+
import {
|
|
10
|
+
type WorkflowJob,
|
|
11
|
+
type WorkflowJobObject,
|
|
12
|
+
WorkflowJobObjectSchema,
|
|
13
|
+
WorkflowJobSchema,
|
|
14
|
+
} from "../schemas/workflow-jobs";
|
|
15
|
+
import {
|
|
16
|
+
type RepoResourceSpec,
|
|
17
|
+
backfillRepoResource,
|
|
18
|
+
changesRepoResource,
|
|
19
|
+
checkRepoResource,
|
|
20
|
+
childRows,
|
|
21
|
+
decodeEvent,
|
|
22
|
+
wrappedList,
|
|
23
|
+
} from "./shared";
|
|
24
|
+
import { workflowRunSpec } from "./workflow-runs";
|
|
25
|
+
|
|
26
|
+
const toRow = (
|
|
27
|
+
{ steps, ...rest }: WorkflowJobObject,
|
|
28
|
+
repository_id: bigint,
|
|
29
|
+
version: Date,
|
|
30
|
+
): WorkflowJob => ({ ...rest, version, repository_id, steps: steps ?? [] });
|
|
31
|
+
|
|
32
|
+
const workflowJobSpec = (
|
|
33
|
+
client: GitHubClientService,
|
|
34
|
+
): RepoResourceSpec<WorkflowRunObject, WorkflowJob> => ({
|
|
35
|
+
...workflowRunSpec,
|
|
36
|
+
rows: childRows(client, {
|
|
37
|
+
path: (run) => `actions/runs/${run.id}/jobs`,
|
|
38
|
+
pageSchema: wrappedList("jobs", WorkflowJobObjectSchema),
|
|
39
|
+
// Earlier attempts' jobs too, so a re-run keeps the history.
|
|
40
|
+
params: { filter: "all" },
|
|
41
|
+
toRow: (job, _run, repositoryId, readAt) => toRow(job, repositoryId, readAt),
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const makeWorkflowJobs = (
|
|
46
|
+
client: GitHubClientService,
|
|
47
|
+
repositories: Effect.Effect<ReadonlyArray<RepositoryRef>, ConnectorError>,
|
|
48
|
+
) => {
|
|
49
|
+
const spec = workflowJobSpec(client);
|
|
50
|
+
return Resource.entity({
|
|
51
|
+
name: "workflow_jobs",
|
|
52
|
+
rowSchema: WorkflowJobSchema,
|
|
53
|
+
version: "version",
|
|
54
|
+
|
|
55
|
+
check: checkRepoResource(client, spec),
|
|
56
|
+
backfill: backfillRepoResource(client, repositories, spec),
|
|
57
|
+
changes: changesRepoResource(client, spec),
|
|
58
|
+
webhook: {
|
|
59
|
+
schema: WebhookPayloadSchema,
|
|
60
|
+
// No update time, so read the latest state instead of trusting the payload.
|
|
61
|
+
handler: ({ payload }) =>
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
const { repository, workflow_job } = yield* decodeEvent(WorkflowJobEventSchema, payload);
|
|
64
|
+
const readAt = yield* DateTime.nowAsDate;
|
|
65
|
+
const page = yield* client.get(
|
|
66
|
+
WorkflowJobObjectSchema,
|
|
67
|
+
`/repos/${repository.full_name}/actions/jobs/${workflow_job.id}`,
|
|
68
|
+
);
|
|
69
|
+
return [toRow(page.body, repository.id, readAt)];
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ConnectorError, Resource } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Duration, Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { GitHubClientService } from "../client/client";
|
|
5
|
+
import type { RepositoryRef } from "../schemas/repositories";
|
|
6
|
+
|
|
7
|
+
import { WebhookPayloadSchema, WorkflowRunEventSchema } from "../schemas/events";
|
|
8
|
+
import {
|
|
9
|
+
type WorkflowRun,
|
|
10
|
+
type WorkflowRunObject,
|
|
11
|
+
WorkflowRunObjectSchema,
|
|
12
|
+
WorkflowRunSchema,
|
|
13
|
+
} from "../schemas/workflow-runs";
|
|
14
|
+
import {
|
|
15
|
+
type RepoResourceSpec,
|
|
16
|
+
backfillRepoResource,
|
|
17
|
+
changesRepoResource,
|
|
18
|
+
checkRepoResource,
|
|
19
|
+
decodeEvent,
|
|
20
|
+
rowsOf,
|
|
21
|
+
timestamps,
|
|
22
|
+
wrappedList,
|
|
23
|
+
} from "./shared";
|
|
24
|
+
|
|
25
|
+
const toRow = (
|
|
26
|
+
{ updated_at, pull_requests, ...rest }: WorkflowRunObject,
|
|
27
|
+
repository_id: bigint,
|
|
28
|
+
): WorkflowRun => ({
|
|
29
|
+
...rest,
|
|
30
|
+
version: updated_at,
|
|
31
|
+
repository_id,
|
|
32
|
+
pull_requests: pull_requests ?? [],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const workflowRunSpec: RepoResourceSpec<WorkflowRunObject, WorkflowRun> = {
|
|
36
|
+
path: "actions/runs",
|
|
37
|
+
pageSchema: wrappedList("workflow_runs", WorkflowRunObjectSchema),
|
|
38
|
+
// Filters cap the list at 1,000 runs, so none are used.
|
|
39
|
+
params: {},
|
|
40
|
+
since: false,
|
|
41
|
+
order: "newest",
|
|
42
|
+
// Most runs finish within a day. A later change, such as a re-run of an old
|
|
43
|
+
// run, comes by webhook.
|
|
44
|
+
recentFor: Duration.days(1),
|
|
45
|
+
...timestamps,
|
|
46
|
+
keep: () => true,
|
|
47
|
+
enabled: () => true,
|
|
48
|
+
rows: rowsOf(toRow),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const makeWorkflowRuns = (
|
|
52
|
+
client: GitHubClientService,
|
|
53
|
+
repositories: Effect.Effect<ReadonlyArray<RepositoryRef>, ConnectorError>,
|
|
54
|
+
) =>
|
|
55
|
+
Resource.entity({
|
|
56
|
+
name: "workflow_runs",
|
|
57
|
+
rowSchema: WorkflowRunSchema,
|
|
58
|
+
version: "version",
|
|
59
|
+
|
|
60
|
+
check: checkRepoResource(client, workflowRunSpec),
|
|
61
|
+
backfill: backfillRepoResource(client, repositories, workflowRunSpec),
|
|
62
|
+
changes: changesRepoResource(client, workflowRunSpec),
|
|
63
|
+
webhook: {
|
|
64
|
+
schema: WebhookPayloadSchema,
|
|
65
|
+
handler: ({ payload }) =>
|
|
66
|
+
decodeEvent(WorkflowRunEventSchema, payload).pipe(
|
|
67
|
+
Effect.map((event) => [toRow(event.workflow_run, event.repository.id)]),
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
});
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectorApp,
|
|
3
|
+
Ingestor,
|
|
4
|
+
RuntimeConfig,
|
|
5
|
+
StateStore,
|
|
6
|
+
Telemetry,
|
|
7
|
+
} from "@useairfoil/connector-kit";
|
|
8
|
+
import { Effect, Layer, Logger } from "effect";
|
|
9
|
+
import { Command } from "effect/cli";
|
|
10
|
+
|
|
11
|
+
import { GitHubConnector } from "./index";
|
|
12
|
+
|
|
13
|
+
const ConnectorLayer = GitHubConnector.layerConfig(GitHubConnector.GitHubConfigDef.config);
|
|
14
|
+
|
|
15
|
+
const TelemetryLayer = Layer.mergeAll(Telemetry.layerOtlp(), Telemetry.layerMetricsConsoleDump());
|
|
16
|
+
|
|
17
|
+
export const sandboxCommand = Command.make("sandbox", {}, () =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const port = yield* RuntimeConfig.httpPort;
|
|
20
|
+
const entrypoint = yield* GitHubConnector.GitHubConnector;
|
|
21
|
+
|
|
22
|
+
return yield* ConnectorApp.start(entrypoint, { port });
|
|
23
|
+
}).pipe(
|
|
24
|
+
Effect.annotateLogs({ component: "github" }),
|
|
25
|
+
Effect.provide(
|
|
26
|
+
Layer.mergeAll(
|
|
27
|
+
StateStore.layerMemory,
|
|
28
|
+
Ingestor.layerConsole,
|
|
29
|
+
ConnectorLayer,
|
|
30
|
+
Logger.layer([Logger.consolePretty()]),
|
|
31
|
+
TelemetryLayer,
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
).pipe(Command.withDescription("Run the connector locally and log ingested data"));
|