@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,192 @@
|
|
|
1
|
+
import { ConnectorError, Metrics, Telemetry } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Config, Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
|
+
|
|
5
|
+
import { type GitHubConfig, manifest } from "../manifest";
|
|
6
|
+
import * as GitHubAuth from "./auth";
|
|
7
|
+
import { GITHUB_API_VERSION, baseUrl } from "./constants";
|
|
8
|
+
|
|
9
|
+
const maxRetries = 5;
|
|
10
|
+
const retryBaseDelay = Duration.millis(500);
|
|
11
|
+
const requestTimeout = Duration.minutes(2);
|
|
12
|
+
|
|
13
|
+
export type GitHubPage<A> = {
|
|
14
|
+
readonly body: A;
|
|
15
|
+
readonly next: Option.Option<string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type GitHubClientService = {
|
|
19
|
+
/** GETs a path such as `/repos/o/r/issues?state=all`, or a `next` URL. */
|
|
20
|
+
readonly get: <A>(
|
|
21
|
+
schema: Schema.Decoder<A>,
|
|
22
|
+
url: string,
|
|
23
|
+
) => Effect.Effect<GitHubPage<A>, ConnectorError>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class GitHubClient extends Context.Service<GitHubClient, GitHubClientService>()(
|
|
27
|
+
"@useairfoil/producer-github/GitHubClient",
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
export const withParams = (path: string, params: Readonly<Record<string, string>>): string =>
|
|
31
|
+
`${path}?${new URLSearchParams(params).toString()}`;
|
|
32
|
+
|
|
33
|
+
export const nextLink = (header: string | undefined): Option.Option<string> =>
|
|
34
|
+
Option.fromNullishOr(/<([^>]+)>;\s*rel="next"/.exec(header ?? "")?.[1]);
|
|
35
|
+
|
|
36
|
+
const ErrorBodySchema = Schema.Struct({ message: Schema.optional(Schema.String) });
|
|
37
|
+
|
|
38
|
+
const statusError = (response: HttpClientResponse.HttpClientResponse, path: string) =>
|
|
39
|
+
HttpClientResponse.schemaBodyJson(ErrorBodySchema)(response).pipe(
|
|
40
|
+
Effect.map((body) => body.message),
|
|
41
|
+
Effect.orElseSucceed(() => undefined),
|
|
42
|
+
Effect.flatMap((detail) =>
|
|
43
|
+
Effect.fail(
|
|
44
|
+
new ConnectorError({
|
|
45
|
+
message: `GitHub API returned ${response.status} for ${path}${detail ? `: ${detail}` : ""}`,
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
Effect.tapError((error) => Telemetry.annotateError("api_status", error)),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* GitHub answers rate limits with `403` or `429`. A `403` is a rate limit when
|
|
54
|
+
* it has `retry-after`, `x-ratelimit-remaining: 0`, or a rate limit message;
|
|
55
|
+
* otherwise it's a permission error.
|
|
56
|
+
*/
|
|
57
|
+
const isRateLimited = (response: HttpClientResponse.HttpClientResponse, body: string) =>
|
|
58
|
+
response.status === 429 ||
|
|
59
|
+
(response.status === 403 &&
|
|
60
|
+
(response.headers["retry-after"] !== undefined ||
|
|
61
|
+
response.headers["x-ratelimit-remaining"] === "0" ||
|
|
62
|
+
/rate limit/i.test(body)));
|
|
63
|
+
|
|
64
|
+
// Rate limits fail with a status error, so `retryTransient` retries a `403` too.
|
|
65
|
+
const rateLimitedResponse = (input: unknown) =>
|
|
66
|
+
HttpClientError.isHttpClientError(input) && input.reason._tag === "StatusCodeError"
|
|
67
|
+
? input.reason.response
|
|
68
|
+
: undefined;
|
|
69
|
+
|
|
70
|
+
const retrySchedule = Metrics.retrySchedule({
|
|
71
|
+
connector: manifest.name,
|
|
72
|
+
baseDelay: retryBaseDelay,
|
|
73
|
+
times: maxRetries,
|
|
74
|
+
}).pipe(
|
|
75
|
+
Schedule.modifyDelay(({ input, duration, now, attempt }) => {
|
|
76
|
+
const response = rateLimitedResponse(input);
|
|
77
|
+
if (response === undefined) return Effect.succeed(duration);
|
|
78
|
+
const retryAfter = response.headers["retry-after"];
|
|
79
|
+
if (retryAfter !== undefined) return Effect.succeed(Duration.seconds(Number(retryAfter)));
|
|
80
|
+
if (response.headers["x-ratelimit-remaining"] === "0") {
|
|
81
|
+
// The reset is in whole seconds and our clock can differ, so wait a second more.
|
|
82
|
+
return Effect.succeed(
|
|
83
|
+
Duration.millis(
|
|
84
|
+
Math.max(Number(response.headers["x-ratelimit-reset"]) * 1000 - now, 0) + 1000,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
// With neither header, GitHub says to wait at least a minute, then longer.
|
|
89
|
+
return Effect.succeed(Duration.minutes(2 ** (attempt - 1)));
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export const make = Effect.fnUntraced(function* () {
|
|
94
|
+
const auth = yield* GitHubAuth.GitHubAuth;
|
|
95
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
96
|
+
HttpClient.mapRequest((request) =>
|
|
97
|
+
request.pipe(
|
|
98
|
+
HttpClientRequest.acceptJson,
|
|
99
|
+
HttpClientRequest.setHeader("X-GitHub-Api-Version", GITHUB_API_VERSION),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
HttpClient.mapRequestEffect((request) =>
|
|
103
|
+
auth.get.pipe(Effect.map((token) => HttpClientRequest.bearerToken(request, token))),
|
|
104
|
+
),
|
|
105
|
+
HttpClient.transformResponse(
|
|
106
|
+
Effect.flatMap((response) =>
|
|
107
|
+
// Reads the body inside the timeout. The response caches it for decoding.
|
|
108
|
+
Effect.flatMap(response.text, (body) =>
|
|
109
|
+
isRateLimited(response, body)
|
|
110
|
+
? Effect.fail(
|
|
111
|
+
new HttpClientError.HttpClientError({
|
|
112
|
+
reason: new HttpClientError.StatusCodeError({
|
|
113
|
+
request: response.request,
|
|
114
|
+
response,
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
: Effect.succeed(response),
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
// Rate-limit waits don't count towards the attempt's timeout.
|
|
123
|
+
HttpClient.transform((effect) => Effect.timeout(effect, requestTimeout)),
|
|
124
|
+
HttpClient.retryTransient({
|
|
125
|
+
schedule: retrySchedule,
|
|
126
|
+
while: (error) => rateLimitedResponse(error) !== undefined,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const get = <A>(
|
|
131
|
+
schema: Schema.Decoder<A>,
|
|
132
|
+
url: string,
|
|
133
|
+
): Effect.Effect<GitHubPage<A>, ConnectorError> => {
|
|
134
|
+
const fullUrl = url.startsWith("/") ? `${baseUrl}${url}` : url;
|
|
135
|
+
// `next` URLs come from GitHub, but never send the token anywhere else.
|
|
136
|
+
if (!fullUrl.startsWith(`${baseUrl}/`)) {
|
|
137
|
+
return Effect.fail(new ConnectorError({ message: `Not a GitHub API URL: ${url}` }));
|
|
138
|
+
}
|
|
139
|
+
const path = fullUrl.slice(baseUrl.length).split("?")[0] ?? fullUrl;
|
|
140
|
+
|
|
141
|
+
return client.execute(HttpClientRequest.get(fullUrl)).pipe(
|
|
142
|
+
Effect.catchTag(["HttpClientError", "TimeoutError"], (error) => {
|
|
143
|
+
// Out of retries: report the rate limit with GitHub's message, like any status.
|
|
144
|
+
const response = rateLimitedResponse(error);
|
|
145
|
+
if (response !== undefined) return Effect.succeed(response);
|
|
146
|
+
return Telemetry.annotateError("api_http", error).pipe(
|
|
147
|
+
Effect.andThen(
|
|
148
|
+
Effect.fail(
|
|
149
|
+
new ConnectorError({
|
|
150
|
+
message: `GitHub API request ${error._tag === "TimeoutError" ? "timed out" : "failed"} for ${path}`,
|
|
151
|
+
cause: error,
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}),
|
|
157
|
+
Effect.flatMap((response) =>
|
|
158
|
+
HttpClientResponse.matchStatus(response, {
|
|
159
|
+
"2xx": (ok) =>
|
|
160
|
+
HttpClientResponse.schemaBodyJson(schema)(ok).pipe(
|
|
161
|
+
Effect.tapError((error) => Telemetry.annotateError("api_decode", error)),
|
|
162
|
+
Effect.mapError(
|
|
163
|
+
(cause) =>
|
|
164
|
+
new ConnectorError({ message: `Invalid GitHub API response for ${path}`, cause }),
|
|
165
|
+
),
|
|
166
|
+
Effect.map((body) => ({ body, next: nextLink(ok.headers.link) })),
|
|
167
|
+
),
|
|
168
|
+
orElse: (failed) => statusError(failed, path),
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
Effect.withSpan(Telemetry.SpanName.apiFetch, {
|
|
172
|
+
kind: "client",
|
|
173
|
+
attributes: {
|
|
174
|
+
[Telemetry.Attr.apiPath]: path,
|
|
175
|
+
"github.api.version": GITHUB_API_VERSION,
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return GitHubClient.of({ get });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export const layer = (
|
|
185
|
+
config: GitHubConfig,
|
|
186
|
+
): Layer.Layer<GitHubClient, never, HttpClient.HttpClient> =>
|
|
187
|
+
Layer.effect(GitHubClient)(make()).pipe(Layer.provide(GitHubAuth.layer(config)));
|
|
188
|
+
|
|
189
|
+
export const layerConfig = (
|
|
190
|
+
config: Config.Wrap<GitHubConfig>,
|
|
191
|
+
): Layer.Layer<GitHubClient, Config.ConfigError, HttpClient.HttpClient> =>
|
|
192
|
+
Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
|
package/src/connector.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Connector, type ResourceDefinition } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Config, Context, Effect, Layer, Redacted } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { GitHubConfig } from "./manifest";
|
|
5
|
+
|
|
6
|
+
import * as GitHubClient from "./client/client";
|
|
7
|
+
import { makeCheckRuns } from "./resources/check-runs";
|
|
8
|
+
import { makeCommitStatuses } from "./resources/commit-statuses";
|
|
9
|
+
import { makeCommits } from "./resources/commits";
|
|
10
|
+
import { makeIssueComments } from "./resources/issue-comments";
|
|
11
|
+
import { makeIssues } from "./resources/issues";
|
|
12
|
+
import { makePullRequestCommits } from "./resources/pull-request-commits";
|
|
13
|
+
import { makePullRequestReviewComments } from "./resources/pull-request-review-comments";
|
|
14
|
+
import { makePullRequestReviews } from "./resources/pull-request-reviews";
|
|
15
|
+
import { makePullRequests } from "./resources/pull-requests";
|
|
16
|
+
import { makeReleases } from "./resources/releases";
|
|
17
|
+
import { makeRepositories } from "./resources/repositories";
|
|
18
|
+
import { cachedRepositoryRefs } from "./resources/shared";
|
|
19
|
+
import { makeWorkflowJobs } from "./resources/workflow-jobs";
|
|
20
|
+
import { makeWorkflowRuns } from "./resources/workflow-runs";
|
|
21
|
+
import { makeWebhookRoute } from "./webhook/route";
|
|
22
|
+
export { GitHubConfigDef, manifest } from "./manifest";
|
|
23
|
+
export type { GitHubConfig } from "./manifest";
|
|
24
|
+
|
|
25
|
+
export const make = Effect.fnUntraced(function* (config: GitHubConfig) {
|
|
26
|
+
const client = yield* GitHubClient.GitHubClient;
|
|
27
|
+
// The backfills of the per-repository resources share this list.
|
|
28
|
+
const repositories = yield* cachedRepositoryRefs(client);
|
|
29
|
+
|
|
30
|
+
const Repositories = makeRepositories(client);
|
|
31
|
+
const Issues = makeIssues(client, repositories);
|
|
32
|
+
const PullRequests = makePullRequests(client, repositories);
|
|
33
|
+
const IssueComments = makeIssueComments(client, repositories);
|
|
34
|
+
const PullRequestReviews = makePullRequestReviews(client, repositories);
|
|
35
|
+
const PullRequestReviewComments = makePullRequestReviewComments(client, repositories);
|
|
36
|
+
const PullRequestCommits = makePullRequestCommits(client, repositories);
|
|
37
|
+
const Commits = makeCommits(client, repositories);
|
|
38
|
+
const CheckRuns = makeCheckRuns(client, repositories);
|
|
39
|
+
const CommitStatuses = makeCommitStatuses(client, repositories);
|
|
40
|
+
const WorkflowRuns = makeWorkflowRuns(client, repositories);
|
|
41
|
+
const WorkflowJobs = makeWorkflowJobs(client, repositories);
|
|
42
|
+
const Releases = makeReleases(client, repositories);
|
|
43
|
+
|
|
44
|
+
return Connector.define({
|
|
45
|
+
name: "producer-github",
|
|
46
|
+
title: "GitHub",
|
|
47
|
+
resources: [
|
|
48
|
+
Repositories,
|
|
49
|
+
Issues,
|
|
50
|
+
PullRequests,
|
|
51
|
+
IssueComments,
|
|
52
|
+
PullRequestReviews,
|
|
53
|
+
PullRequestReviewComments,
|
|
54
|
+
PullRequestCommits,
|
|
55
|
+
Commits,
|
|
56
|
+
CheckRuns,
|
|
57
|
+
CommitStatuses,
|
|
58
|
+
WorkflowRuns,
|
|
59
|
+
WorkflowJobs,
|
|
60
|
+
Releases,
|
|
61
|
+
],
|
|
62
|
+
webhooks: [
|
|
63
|
+
makeWebhookRoute({
|
|
64
|
+
secret: Redacted.value(config.webhookSecret),
|
|
65
|
+
installationId: config.installationId,
|
|
66
|
+
targets: new Map<string, ReadonlyArray<ResourceDefinition>>([
|
|
67
|
+
["repository", [Repositories]],
|
|
68
|
+
["issues", [Issues]],
|
|
69
|
+
["pull_request", [PullRequests, PullRequestCommits]],
|
|
70
|
+
["issue_comment", [IssueComments]],
|
|
71
|
+
["pull_request_review", [PullRequestReviews]],
|
|
72
|
+
["pull_request_review_comment", [PullRequestReviewComments]],
|
|
73
|
+
["push", [Commits]],
|
|
74
|
+
["check_run", [CheckRuns]],
|
|
75
|
+
["status", [CommitStatuses]],
|
|
76
|
+
["workflow_run", [WorkflowRuns]],
|
|
77
|
+
["workflow_job", [WorkflowJobs]],
|
|
78
|
+
["release", [Releases]],
|
|
79
|
+
]),
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export type GitHubConnectorRuntime = Effect.Success<ReturnType<typeof make>>;
|
|
86
|
+
|
|
87
|
+
export class GitHubConnector extends Context.Service<GitHubConnector, GitHubConnectorRuntime>()(
|
|
88
|
+
"@useairfoil/producer-github/GitHubConnector",
|
|
89
|
+
) {}
|
|
90
|
+
|
|
91
|
+
export const layer = (config: GitHubConfig) =>
|
|
92
|
+
Layer.effect(GitHubConnector)(
|
|
93
|
+
make(config).pipe(Effect.annotateLogs({ component: "github" })),
|
|
94
|
+
).pipe(Layer.provide(GitHubClient.layer(config)));
|
|
95
|
+
|
|
96
|
+
export const layerConfig = (config: Config.Wrap<GitHubConfig>) =>
|
|
97
|
+
Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ConnectorApp, RuntimeConfig } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Command } from "effect/cli";
|
|
3
|
+
|
|
4
|
+
import { tableSchemas } from "./tables";
|
|
5
|
+
|
|
6
|
+
export const createTableCommand = ConnectorApp.makeCreateTableCommand(tableSchemas);
|
|
7
|
+
|
|
8
|
+
export const setupCommand = ConnectorApp.makeSetupCommand(tableSchemas).pipe(
|
|
9
|
+
Command.provide(RuntimeConfig.layerHosted()),
|
|
10
|
+
);
|