@shivaedev/effect-github 0.0.0 → 0.3.0
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 +21 -0
- package/README.md +142 -1
- package/dist/GitHub.d.ts +87 -0
- package/dist/GitHub.d.ts.map +1 -0
- package/dist/GitHub.js +45 -0
- package/dist/GitHub.js.map +1 -0
- package/dist/GitHubConfig.d.ts +5 -0
- package/dist/GitHubConfig.d.ts.map +1 -0
- package/dist/GitHubConfig.js +5 -0
- package/dist/GitHubConfig.js.map +1 -0
- package/dist/GitHubToken.d.ts +9 -0
- package/dist/GitHubToken.d.ts.map +1 -0
- package/dist/GitHubToken.js +8 -0
- package/dist/GitHubToken.js.map +1 -0
- package/dist/cache.d.ts +12 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +17 -0
- package/dist/cache.js.map +1 -0
- package/dist/checkRuns.d.ts +104 -0
- package/dist/checkRuns.d.ts.map +1 -0
- package/dist/checkRuns.js +42 -0
- package/dist/checkRuns.js.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/githubApp.d.ts +14 -0
- package/dist/githubApp.d.ts.map +1 -0
- package/dist/githubApp.js +51 -0
- package/dist/githubApp.js.map +1 -0
- package/dist/json.d.ts +7 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +14 -0
- package/dist/json.js.map +1 -0
- package/dist/rateLimits.d.ts +20 -0
- package/dist/rateLimits.d.ts.map +1 -0
- package/dist/rateLimits.js +43 -0
- package/dist/rateLimits.js.map +1 -0
- package/dist/representations.d.ts +18 -0
- package/dist/representations.d.ts.map +1 -0
- package/dist/representations.js +28 -0
- package/dist/representations.js.map +1 -0
- package/dist/requests.d.ts +9 -0
- package/dist/requests.d.ts.map +1 -0
- package/dist/requests.js +57 -0
- package/dist/requests.js.map +1 -0
- package/dist/status.d.ts +6 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +33 -0
- package/dist/status.js.map +1 -0
- package/dist/stickyComments.d.ts +34 -0
- package/dist/stickyComments.d.ts.map +1 -0
- package/dist/stickyComments.js +32 -0
- package/dist/stickyComments.js.map +1 -0
- package/dist/verifyWebhook.d.ts +4 -0
- package/dist/verifyWebhook.d.ts.map +1 -0
- package/dist/verifyWebhook.js +14 -0
- package/dist/verifyWebhook.js.map +1 -0
- package/package.json +65 -3
- package/src/GitHub.ts +52 -0
- package/src/GitHubConfig.ts +5 -0
- package/src/GitHubToken.ts +18 -0
- package/src/cache.ts +25 -0
- package/src/checkRuns.ts +48 -0
- package/src/errors.ts +17 -0
- package/src/githubApp.ts +66 -0
- package/src/json.ts +16 -0
- package/src/rateLimits.ts +56 -0
- package/src/representations.ts +48 -0
- package/src/requests.ts +68 -0
- package/src/status.ts +37 -0
- package/src/stickyComments.ts +41 -0
- package/src/verifyWebhook.ts +19 -0
package/src/GitHub.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Effect, Schema, Stream } from "effect";
|
|
2
|
+
import { HttpClient } from "effect/http";
|
|
3
|
+
import { defineService } from "@shivaedev/effect-service/defineService.ts";
|
|
4
|
+
import { checkRuns } from "#checkRuns.ts";
|
|
5
|
+
import { GitHubConfig } from "#GitHubConfig.ts";
|
|
6
|
+
import { GitHubToken } from "#GitHubToken.ts";
|
|
7
|
+
import { rateLimits } from "#rateLimits.ts";
|
|
8
|
+
import { representations } from "#representations.ts";
|
|
9
|
+
import { requests } from "#requests.ts";
|
|
10
|
+
import { stickyComments } from "#stickyComments.ts";
|
|
11
|
+
|
|
12
|
+
export const GitHub = defineService({
|
|
13
|
+
id: "@shivaedev/effect-github/GitHub",
|
|
14
|
+
initialize: Effect.gen(function* () {
|
|
15
|
+
const config = yield* GitHubConfig;
|
|
16
|
+
const rates = rateLimits();
|
|
17
|
+
return {
|
|
18
|
+
apiUrl: config.apiUrl,
|
|
19
|
+
rates,
|
|
20
|
+
request: requests(yield* HttpClient.HttpClient, yield* GitHubToken, rates),
|
|
21
|
+
};
|
|
22
|
+
}),
|
|
23
|
+
methods: ({ apiUrl, rates, request }) => {
|
|
24
|
+
const { get, ...client } = representations(apiUrl, request, rates.resource);
|
|
25
|
+
return {
|
|
26
|
+
bytes: (path: string, accept: string, limit: number) => Effect.map(request(get(path, accept), limit), (entry) => entry.bytes),
|
|
27
|
+
...client,
|
|
28
|
+
...stickyComments(client),
|
|
29
|
+
...checkRuns(client),
|
|
30
|
+
observations: () => Effect.sync(() => [...rates.observations.values()]),
|
|
31
|
+
resource: (path: string) => Effect.sync(() => rates.resource(new URL(path, apiUrl).pathname)),
|
|
32
|
+
text: (path: string, accept: string, limit: number) =>
|
|
33
|
+
Effect.map(request(get(path, accept), limit), (entry) => new TextDecoder().decode(entry.bytes)),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
requires: [HttpClient.HttpClient, GitHubToken],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export function read<TSchema extends Schema.Constraint>(path: string, schema: TSchema) {
|
|
40
|
+
return Effect.flatMap(GitHub, (github) => Effect.flatMap(github.read(path), (response) => response.decode(schema)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function list<TSchema extends Schema.Constraint>(path: string, schema: TSchema) {
|
|
44
|
+
return Stream.unwrap(Effect.flatMap(GitHub, (github) => github.list(path))).pipe(
|
|
45
|
+
Stream.mapEffect((response) => response.decode(Schema.Array(schema))),
|
|
46
|
+
Stream.flatMap(Stream.fromIterable),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function write<TSchema extends Schema.Constraint>(method: "POST" | "PATCH", path: string, body: unknown, schema: TSchema) {
|
|
51
|
+
return Effect.flatMap(GitHub, (github) => Effect.flatMap(github.write(method, path, body), (response) => response.decode(schema)));
|
|
52
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Config, Context, Effect, Layer, type Option, type Redacted } from "effect";
|
|
2
|
+
import type { GitHubAppError } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
type Token = Option.Option<Redacted.Redacted<string>>;
|
|
5
|
+
|
|
6
|
+
export class GitHubToken extends Context.Service<GitHubToken, Token | Effect.Effect<Token, GitHubAppError>>()(
|
|
7
|
+
"@shivaedev/effect-github/GitHubToken",
|
|
8
|
+
) {
|
|
9
|
+
static readonly fromEnv = Layer.effect(GitHubToken)(
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
const token = yield* Config.Redacted("GH_TOKEN").pipe(
|
|
12
|
+
Config.orElse(() => Config.Redacted("GITHUB_TOKEN")),
|
|
13
|
+
Config.option,
|
|
14
|
+
);
|
|
15
|
+
return token;
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Schema } from "effect";
|
|
2
|
+
import type { HttpClientResponse } from "effect/http";
|
|
3
|
+
|
|
4
|
+
export interface CachedResponse {
|
|
5
|
+
readonly bytes: Uint8Array;
|
|
6
|
+
readonly decoded: Map<Schema.Constraint, unknown>;
|
|
7
|
+
readonly response: HttpClientResponse.HttpClientResponse;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function responseCache() {
|
|
11
|
+
const entries = new Map<string, CachedResponse>();
|
|
12
|
+
return {
|
|
13
|
+
get: (key: string) => entries.get(key),
|
|
14
|
+
set(key: string, entry: CachedResponse) {
|
|
15
|
+
entries.delete(key);
|
|
16
|
+
entries.set(key, entry);
|
|
17
|
+
if (entries.size > 1000) {
|
|
18
|
+
const first = entries.keys().next();
|
|
19
|
+
if (!first.done) {
|
|
20
|
+
entries.delete(first.value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/checkRuns.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { Representations } from "./representations.ts";
|
|
3
|
+
|
|
4
|
+
export const CheckRunOutput = Schema.Struct({
|
|
5
|
+
summary: Schema.String,
|
|
6
|
+
text: Schema.optional(Schema.String),
|
|
7
|
+
title: Schema.String,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const CheckRunConclusion = Schema.Literals(["action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"]);
|
|
11
|
+
export const CheckRunStatus = Schema.Literals(["queued", "in_progress", "completed"]);
|
|
12
|
+
|
|
13
|
+
export const CreateCheckRun = Schema.Struct({
|
|
14
|
+
conclusion: Schema.optional(CheckRunConclusion),
|
|
15
|
+
headSha: Schema.String,
|
|
16
|
+
name: Schema.String,
|
|
17
|
+
output: Schema.optional(CheckRunOutput),
|
|
18
|
+
status: CheckRunStatus,
|
|
19
|
+
}).pipe(Schema.encodeKeys({ headSha: "head_sha" }));
|
|
20
|
+
|
|
21
|
+
export const UpdateCheckRun = Schema.Union([
|
|
22
|
+
Schema.Struct({ output: Schema.optional(CheckRunOutput), status: Schema.Literals(["queued", "in_progress"]) }),
|
|
23
|
+
Schema.Struct({ conclusion: CheckRunConclusion, output: Schema.optional(CheckRunOutput), status: Schema.Literal("completed") }),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const CheckRun = Schema.Struct({
|
|
27
|
+
conclusion: Schema.NullOr(CheckRunConclusion),
|
|
28
|
+
headSha: Schema.String,
|
|
29
|
+
id: Schema.Number,
|
|
30
|
+
name: Schema.String,
|
|
31
|
+
output: Schema.Struct({ summary: Schema.NullOr(Schema.String), text: Schema.NullOr(Schema.String), title: Schema.NullOr(Schema.String) }),
|
|
32
|
+
status: CheckRunStatus,
|
|
33
|
+
}).pipe(Schema.encodeKeys({ headSha: "head_sha" }));
|
|
34
|
+
|
|
35
|
+
export function checkRuns(client: Representations) {
|
|
36
|
+
return {
|
|
37
|
+
createCheckRun: Effect.fn("GitHub.createCheckRun")(function* (owner: string, repo: string, input: typeof CreateCheckRun.Type) {
|
|
38
|
+
const body = yield* Schema.encodeEffect(CreateCheckRun)(input);
|
|
39
|
+
const response = yield* client.write("POST", `/repos/${owner}/${repo}/check-runs`, body);
|
|
40
|
+
return yield* response.decode(CheckRun);
|
|
41
|
+
}),
|
|
42
|
+
updateCheckRun: Effect.fn("GitHub.updateCheckRun")(function* (owner: string, repo: string, id: number, input: typeof UpdateCheckRun.Type) {
|
|
43
|
+
const body = yield* Schema.encodeEffect(UpdateCheckRun)(input);
|
|
44
|
+
const response = yield* client.write("PATCH", `/repos/${owner}/${repo}/check-runs/${id}`, body);
|
|
45
|
+
return yield* response.decode(CheckRun);
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class GitHubRateLimited extends Schema.TaggedError<GitHubRateLimited>()("GitHubRateLimited", {
|
|
4
|
+
resource: Schema.String,
|
|
5
|
+
retryAt: Schema.DateTimeUtc,
|
|
6
|
+
}) {}
|
|
7
|
+
|
|
8
|
+
export class GitHubTransportError extends Schema.TaggedError<GitHubTransportError>()("GitHubTransportError", {
|
|
9
|
+
path: Schema.String,
|
|
10
|
+
}) {}
|
|
11
|
+
|
|
12
|
+
export class GitHubAppError extends Schema.TaggedError<GitHubAppError>()("GitHubAppError", {
|
|
13
|
+
stage: Schema.Literals(["sign", "exchange"]),
|
|
14
|
+
status: Schema.optional(Schema.Number),
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
export class GitHubWebhookSignatureError extends Schema.TaggedError<GitHubWebhookSignatureError>()("GitHubWebhookSignatureError", {}) {}
|
package/src/githubApp.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sign } from "node:crypto";
|
|
2
|
+
import { Clock, Effect, Exit, Layer, Option, Redacted, Schema } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
|
+
import { readBoundedJson } from "@shivaedev/platform/http-client/body.ts";
|
|
5
|
+
import { GitHubAppError } from "./errors.ts";
|
|
6
|
+
import { GitHubConfig } from "./GitHubConfig.ts";
|
|
7
|
+
import { GitHubToken } from "./GitHubToken.ts";
|
|
8
|
+
|
|
9
|
+
export type GitHubAppOptions = {
|
|
10
|
+
readonly appId: string;
|
|
11
|
+
readonly privateKey: Redacted.Redacted<string>;
|
|
12
|
+
} & ({ readonly installationId: number } | { readonly owner: string; readonly repository: string });
|
|
13
|
+
|
|
14
|
+
const Installation = Schema.Struct({ id: Schema.Number });
|
|
15
|
+
const AccessToken = Schema.Struct({ expiresAt: Schema.DateFromString, token: Schema.RedactedFromValue(Schema.String) }).pipe(
|
|
16
|
+
Schema.encodeKeys({ expiresAt: "expires_at" }),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export function githubApp(options: GitHubAppOptions) {
|
|
20
|
+
return Layer.effect(GitHubToken)(
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const client = yield* HttpClient.HttpClient;
|
|
23
|
+
const { apiUrl } = yield* GitHubConfig;
|
|
24
|
+
const clock = yield* Clock.Clock;
|
|
25
|
+
let installationId = "installationId" in options ? options.installationId : undefined;
|
|
26
|
+
const request = appRequests(client, apiUrl);
|
|
27
|
+
const exchange = Effect.gen(function* () {
|
|
28
|
+
const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
|
|
29
|
+
const jwt = yield* Effect.try({
|
|
30
|
+
catch: () => new GitHubAppError({ stage: "sign" }),
|
|
31
|
+
try: () => {
|
|
32
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
33
|
+
const payload = Buffer.from(JSON.stringify({ exp: now + 600, iat: now - 60, iss: options.appId })).toString("base64url");
|
|
34
|
+
const input = `${header}.${payload}`;
|
|
35
|
+
return `${input}.${sign("RSA-SHA256", Buffer.from(input), Redacted.value(options.privateKey)).toString("base64url")}`;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (installationId === undefined && "owner" in options) {
|
|
39
|
+
const path = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repository)}/installation`;
|
|
40
|
+
installationId = (yield* request("GET", path, jwt, Installation)).id;
|
|
41
|
+
}
|
|
42
|
+
return yield* request("POST", `/app/installations/${installationId}/access_tokens`, jwt, AccessToken);
|
|
43
|
+
});
|
|
44
|
+
const cached = yield* Effect.cachedWithTTL(exchange, (exit) =>
|
|
45
|
+
Exit.isSuccess(exit) ? Math.max(0, exit.value.expiresAt.getTime() - clock.currentTimeMillisUnsafe() - 300_000) : 0,
|
|
46
|
+
);
|
|
47
|
+
return Effect.map(cached, ({ token }) => Option.some(token));
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function appRequests(client: HttpClient.HttpClient, apiUrl: string) {
|
|
53
|
+
return Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(method: "GET" | "POST", path: string, jwt: string, schema: TSchema) {
|
|
54
|
+
const response = yield* client
|
|
55
|
+
.execute(
|
|
56
|
+
HttpClientRequest.make(method)(new URL(path, apiUrl).href, {
|
|
57
|
+
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${jwt}`, "X-GitHub-Api-Version": "2022-11-28" },
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
.pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
|
|
61
|
+
if (response.status < 200 || response.status >= 300) {
|
|
62
|
+
return yield* new GitHubAppError({ stage: "exchange", status: response.status });
|
|
63
|
+
}
|
|
64
|
+
return yield* readBoundedJson(response, 64 * 1024, schema).pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { CachedResponse } from "./cache.ts";
|
|
3
|
+
|
|
4
|
+
export const decodeJson = Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(entry: CachedResponse, schema: TSchema) {
|
|
5
|
+
const cached = entry.decoded.get(schema);
|
|
6
|
+
if (entry.decoded.has(schema) && Schema.is(schema)(cached)) {
|
|
7
|
+
return cached;
|
|
8
|
+
}
|
|
9
|
+
const value = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(entry.bytes));
|
|
10
|
+
entry.decoded.set(schema, value);
|
|
11
|
+
return value;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function jsonResponse(entry: CachedResponse) {
|
|
15
|
+
return { decode: <TSchema extends Schema.Constraint>(schema: TSchema) => decodeJson(entry, schema) };
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Clock, DateTime, Effect } from "effect";
|
|
2
|
+
import type { HttpClientResponse } from "effect/http";
|
|
3
|
+
import type { GitHubRateLimited } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface RateLimitObservation {
|
|
6
|
+
readonly limit: number;
|
|
7
|
+
readonly remaining: number;
|
|
8
|
+
readonly reset: DateTime.Utc;
|
|
9
|
+
readonly resource: string;
|
|
10
|
+
readonly used: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function rateLimits() {
|
|
14
|
+
const observations = new Map<string, RateLimitObservation>();
|
|
15
|
+
const blocked = new Map<string, GitHubRateLimited>();
|
|
16
|
+
const failures = new Map<string, number>();
|
|
17
|
+
const resources = new Map<string, string>();
|
|
18
|
+
function resource(path: string) {
|
|
19
|
+
return resources.get(path) ?? (path.startsWith("/search/") ? "search" : "core");
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
blocked,
|
|
23
|
+
failures,
|
|
24
|
+
observations,
|
|
25
|
+
observe(response: HttpClientResponse.HttpClientResponse) {
|
|
26
|
+
const headers = response.headers;
|
|
27
|
+
const path = new URL(response.request.url).pathname;
|
|
28
|
+
const name = headers["x-ratelimit-resource"] ?? resource(path);
|
|
29
|
+
resources.set(path, name);
|
|
30
|
+
const values = ["limit", "remaining", "used", "reset"].map((key) => headers[`x-ratelimit-${key}`]);
|
|
31
|
+
if (values.every((value) => value !== undefined && Number.isFinite(Number(value)))) {
|
|
32
|
+
observations.set(name, {
|
|
33
|
+
limit: Number(headers["x-ratelimit-limit"]),
|
|
34
|
+
remaining: Number(headers["x-ratelimit-remaining"]),
|
|
35
|
+
reset: DateTime.makeUnsafe(Number(headers["x-ratelimit-reset"]) * 1000),
|
|
36
|
+
resource: name,
|
|
37
|
+
used: Number(headers["x-ratelimit-used"]),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return name;
|
|
41
|
+
},
|
|
42
|
+
resource,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type RateLimits = ReturnType<typeof rateLimits>;
|
|
47
|
+
|
|
48
|
+
export const failRateLimited = Effect.fnUntraced(function* (path: string, error: GitHubRateLimited, remaining: number | undefined) {
|
|
49
|
+
const fields = { path, remaining, resource: error.resource, retryAt: DateTime.formatIso(error.retryAt) };
|
|
50
|
+
yield* Effect.logWarning("GitHub rate limited", fields);
|
|
51
|
+
const span = yield* Effect.currentSpan.pipe(Effect.option);
|
|
52
|
+
if (span._tag === "Some") {
|
|
53
|
+
span.value.event("GitHub.rateLimited", yield* Clock.currentTimeNanos, fields);
|
|
54
|
+
}
|
|
55
|
+
return yield* error;
|
|
56
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import type { ServiceUnavailable } from "@shivaedev/platform/errors/taxonomy.ts";
|
|
4
|
+
import { paginate } from "@shivaedev/platform/http-client/paginate.ts";
|
|
5
|
+
import type { CachedResponse } from "#cache.ts";
|
|
6
|
+
import { jsonResponse } from "#json.ts";
|
|
7
|
+
import type { requests } from "#requests.ts";
|
|
8
|
+
|
|
9
|
+
export function representations(apiUrl: string, request: ReturnType<typeof requests>, resource: (path: string) => string) {
|
|
10
|
+
function get(path: string, accept = "application/vnd.github+json") {
|
|
11
|
+
return HttpClientRequest.get(new URL(path, apiUrl).href, { headers: { Accept: accept } });
|
|
12
|
+
}
|
|
13
|
+
function readRepresentation(path: string) {
|
|
14
|
+
return Effect.map(request(get(path), 1024 * 1024), jsonResponse);
|
|
15
|
+
}
|
|
16
|
+
function listRepresentations(path: string, beforeRequest?: (resource: string) => Effect.Effect<void, ServiceUnavailable>) {
|
|
17
|
+
const entries = new WeakMap<HttpClientResponse.HttpClientResponse, CachedResponse>();
|
|
18
|
+
function fetchPage(input: HttpClientRequest.HttpClientRequest) {
|
|
19
|
+
return (beforeRequest?.(resource(new URL(input.url).pathname)) ?? Effect.void).pipe(
|
|
20
|
+
Effect.andThen(request(input, 1024 * 1024)),
|
|
21
|
+
Effect.map((entry) => {
|
|
22
|
+
entries.set(entry.response, entry);
|
|
23
|
+
return entry.response;
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const client = HttpClient.makeWith(
|
|
28
|
+
(effect: Effect.Effect<HttpClientRequest.HttpClientRequest>) => Effect.flatMap(effect, fetchPage),
|
|
29
|
+
Effect.succeed,
|
|
30
|
+
);
|
|
31
|
+
return Effect.succeed(
|
|
32
|
+
paginate(client, get(path)).pipe(
|
|
33
|
+
Stream.mapEffect((response) => Effect.fromNullishOr(entries.get(response))),
|
|
34
|
+
Stream.map(jsonResponse),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
function writeRepresentation(method: "POST" | "PATCH", path: string, body: unknown) {
|
|
39
|
+
return HttpClientRequest.make(method)(new URL(path, apiUrl).href, { headers: { Accept: "application/vnd.github+json" } }).pipe(
|
|
40
|
+
HttpClientRequest.bodyJson(body),
|
|
41
|
+
Effect.flatMap((input) => request(input, 1024 * 1024)),
|
|
42
|
+
Effect.map(jsonResponse),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return { get, list: listRepresentations, read: readRepresentation, write: writeRepresentation };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Representations = Omit<ReturnType<typeof representations>, "get">;
|
package/src/requests.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DateTime, Effect, Option, Redacted } from "effect";
|
|
2
|
+
import { type HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import { readBoundedBody } from "@shivaedev/platform/http-client/body.ts";
|
|
4
|
+
import { ResponseBodyTooLarge } from "@shivaedev/platform/http-client/errors.ts";
|
|
5
|
+
import { type CachedResponse, responseCache } from "./cache.ts";
|
|
6
|
+
import { GitHubTransportError } from "./errors.ts";
|
|
7
|
+
import type { GitHubToken } from "./GitHubToken.ts";
|
|
8
|
+
import { failRateLimited, type RateLimits } from "./rateLimits.ts";
|
|
9
|
+
import { checkStatus, isUnchanged } from "./status.ts";
|
|
10
|
+
|
|
11
|
+
export function requests(client: HttpClient.HttpClient, token: GitHubToken["Service"], rates: RateLimits) {
|
|
12
|
+
const cache = responseCache();
|
|
13
|
+
const accessToken = Effect.isEffect(token) ? token : Effect.succeed(token);
|
|
14
|
+
return Effect.fn("GitHub.request")(function* (input: HttpClientRequest.HttpClientRequest, limit: number) {
|
|
15
|
+
const path = new URL(input.url).pathname;
|
|
16
|
+
const resource = rates.resource(path);
|
|
17
|
+
const blocked = rates.blocked.get(resource);
|
|
18
|
+
if (blocked !== undefined && DateTime.isGreaterThan(blocked.retryAt, yield* DateTime.now)) {
|
|
19
|
+
return yield* failRateLimited(path, blocked, rates.observations.get(resource)?.remaining);
|
|
20
|
+
}
|
|
21
|
+
const key = `${input.url} ${input.headers.accept}`;
|
|
22
|
+
const cached = input.method === "GET" ? cache.get(key) : undefined;
|
|
23
|
+
const request = requestHeaders(input, cached, yield* accessToken);
|
|
24
|
+
const response = yield* client.execute(request).pipe(Effect.mapError(() => new GitHubTransportError({ path })));
|
|
25
|
+
const observedResource = rates.observe(response);
|
|
26
|
+
if (isUnchanged(response) && cached !== undefined) {
|
|
27
|
+
rates.failures.delete(observedResource);
|
|
28
|
+
rates.blocked.delete(observedResource);
|
|
29
|
+
if (cached.bytes.byteLength > limit) {
|
|
30
|
+
return yield* new ResponseBodyTooLarge({ limit });
|
|
31
|
+
}
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
34
|
+
yield* checkStatus(response, observedResource, rates.failures.get(observedResource) ?? 0).pipe(
|
|
35
|
+
Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))),
|
|
36
|
+
Effect.catchTag("GitHubRateLimited", (error) => {
|
|
37
|
+
rates.blocked.set(error.resource, error);
|
|
38
|
+
rates.failures.set(error.resource, (rates.failures.get(error.resource) ?? 0) + 1);
|
|
39
|
+
return failRateLimited(path, error, rates.observations.get(error.resource)?.remaining);
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
rates.failures.delete(observedResource);
|
|
43
|
+
rates.blocked.delete(observedResource);
|
|
44
|
+
const bytes = yield* readBoundedBody(response, limit).pipe(
|
|
45
|
+
Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))),
|
|
46
|
+
);
|
|
47
|
+
const entry: CachedResponse = { bytes, decoded: new Map(), response };
|
|
48
|
+
if (input.method === "GET" && response.headers.etag !== undefined) {
|
|
49
|
+
cache.set(key, entry);
|
|
50
|
+
}
|
|
51
|
+
return entry;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function requestHeaders(
|
|
56
|
+
input: HttpClientRequest.HttpClientRequest,
|
|
57
|
+
cached: CachedResponse | undefined,
|
|
58
|
+
token: Option.Option<Redacted.Redacted<string>>,
|
|
59
|
+
) {
|
|
60
|
+
let request = HttpClientRequest.removeHeader(input, "if-none-match").pipe(HttpClientRequest.setHeader("X-GitHub-Api-Version", "2022-11-28"));
|
|
61
|
+
if (Option.isSome(token)) {
|
|
62
|
+
request = HttpClientRequest.setHeader(request, "Authorization", `Bearer ${Redacted.value(token.value)}`);
|
|
63
|
+
}
|
|
64
|
+
if (cached?.response.headers.etag !== undefined) {
|
|
65
|
+
request = HttpClientRequest.setHeader(request, "If-None-Match", cached.response.headers.etag);
|
|
66
|
+
}
|
|
67
|
+
return request;
|
|
68
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DateTime, Effect, Option, Schema } from "effect";
|
|
2
|
+
import { HttpClient, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import { readBoundedText } from "@shivaedev/platform/http-client/body.ts";
|
|
4
|
+
import { filterStatus } from "@shivaedev/platform/http-client/filterStatus.ts";
|
|
5
|
+
import { parseRetryAfter } from "@shivaedev/platform/http-client/parseRetryAfter.ts";
|
|
6
|
+
import { GitHubRateLimited } from "./errors.ts";
|
|
7
|
+
|
|
8
|
+
export function isUnchanged(response: HttpClientResponse.HttpClientResponse) {
|
|
9
|
+
return response.status === 304;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const checkStatus = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse, resource: string, failures = 0) {
|
|
13
|
+
const { status, headers } = response;
|
|
14
|
+
if (status === 403 || status === 429) {
|
|
15
|
+
const limitedByHeaders = status === 429 || headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined;
|
|
16
|
+
const limitedByMessage = !limitedByHeaders && status === 403 && (yield* messageNamesRateLimit(response));
|
|
17
|
+
if (limitedByHeaders || limitedByMessage) {
|
|
18
|
+
const now = yield* DateTime.now;
|
|
19
|
+
const reset = Number(headers["x-ratelimit-reset"] ?? 0) * 1000;
|
|
20
|
+
const fallback =
|
|
21
|
+
headers["x-ratelimit-remaining"] === "0" && reset > DateTime.toEpochMillis(now)
|
|
22
|
+
? reset
|
|
23
|
+
: DateTime.toEpochMillis(now) + Math.min(15 * 60_000, 60_000 * 2 ** failures);
|
|
24
|
+
const retryAt = parseRetryAfter(headers["retry-after"], now) ?? DateTime.makeUnsafe(fallback);
|
|
25
|
+
return yield* new GitHubRateLimited({ resource, retryAt });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return yield* filterStatus(HttpClient.makeWith(() => Effect.succeed(response), Effect.succeed)).execute(response.request);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const Message = Schema.fromJsonString(Schema.Struct({ message: Schema.String }));
|
|
32
|
+
|
|
33
|
+
const messageNamesRateLimit = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
|
|
34
|
+
const text = yield* readBoundedText(response, 64 * 1024);
|
|
35
|
+
const body = Schema.decodeUnknownOption(Message)(text);
|
|
36
|
+
return Option.isSome(body) && /rate limit/iu.test(body.value.message);
|
|
37
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect, Option, Schema, Stream } from "effect";
|
|
2
|
+
import type { Representations } from "./representations.ts";
|
|
3
|
+
|
|
4
|
+
export const PullRequestComment = Schema.Struct({
|
|
5
|
+
body: Schema.String,
|
|
6
|
+
id: Schema.Number,
|
|
7
|
+
user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const StickyComment = Schema.Struct({
|
|
11
|
+
author: Schema.optional(Schema.String),
|
|
12
|
+
body: Schema.String,
|
|
13
|
+
marker: Schema.String,
|
|
14
|
+
number: Schema.Number,
|
|
15
|
+
owner: Schema.String,
|
|
16
|
+
repo: Schema.String,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const CommentBody = Schema.Struct({ body: Schema.String });
|
|
20
|
+
|
|
21
|
+
export function stickyComments(client: Representations) {
|
|
22
|
+
return {
|
|
23
|
+
upsertStickyComment: Effect.fn("GitHub.upsertStickyComment")(function* (input: typeof StickyComment.Type) {
|
|
24
|
+
const { owner, repo, number, marker, body, author } = yield* Schema.decodeUnknownEffect(StickyComment)(input);
|
|
25
|
+
const hiddenMarker = `<!-- ${marker} -->`;
|
|
26
|
+
const root = `/repos/${owner}/${repo}/issues`;
|
|
27
|
+
const pages = yield* client.list(`${root}/${number}/comments`);
|
|
28
|
+
const existing = yield* pages.pipe(
|
|
29
|
+
Stream.mapEffect((page) => page.decode(Schema.Array(PullRequestComment))),
|
|
30
|
+
Stream.flatMap(Stream.fromIterable),
|
|
31
|
+
Stream.filter((comment) => comment.body.includes(hiddenMarker) && (author === undefined || comment.user?.login === author)),
|
|
32
|
+
Stream.runHead,
|
|
33
|
+
);
|
|
34
|
+
const payload = yield* Schema.encodeEffect(CommentBody)({ body: `${hiddenMarker}\n${body}` });
|
|
35
|
+
const response = yield* Option.isSome(existing)
|
|
36
|
+
? client.write("PATCH", `${root}/comments/${existing.value.id}`, payload)
|
|
37
|
+
: client.write("POST", `${root}/${number}/comments`, payload);
|
|
38
|
+
return yield* response.decode(PullRequestComment);
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Effect, Redacted, Schema } from "effect";
|
|
3
|
+
import { GitHubWebhookSignatureError } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
export const verifyWebhook = Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(
|
|
6
|
+
body: Uint8Array,
|
|
7
|
+
signature: string | undefined,
|
|
8
|
+
secret: Redacted.Redacted<string>,
|
|
9
|
+
schema: TSchema,
|
|
10
|
+
) {
|
|
11
|
+
if (signature === undefined || !/^sha256=[0-9a-f]{64}$/u.test(signature)) {
|
|
12
|
+
return yield* new GitHubWebhookSignatureError();
|
|
13
|
+
}
|
|
14
|
+
const expected = createHmac("sha256", Redacted.value(secret)).update(body).digest();
|
|
15
|
+
if (!timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"))) {
|
|
16
|
+
return yield* new GitHubWebhookSignatureError();
|
|
17
|
+
}
|
|
18
|
+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(body));
|
|
19
|
+
});
|