@shivaedev/github 0.0.0-stage → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +239 -2
- package/dist/GitHub.d.ts +93 -0
- package/dist/GitHub.d.ts.map +1 -0
- package/dist/GitHub.js +53 -0
- package/dist/GitHub.js.map +1 -0
- package/dist/GitHubConfig.d.ts +6 -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/TokenRefresh.d.ts +5 -0
- package/dist/TokenRefresh.d.ts.map +1 -0
- package/dist/TokenRefresh.js +5 -0
- package/dist/TokenRefresh.js.map +1 -0
- package/dist/appJwt.d.ts +8 -0
- package/dist/appJwt.d.ts.map +1 -0
- package/dist/appJwt.js +16 -0
- package/dist/appJwt.js.map +1 -0
- package/dist/artifacts.d.ts +16 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +31 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/authenticatedRequest.d.ts +8 -0
- package/dist/authenticatedRequest.d.ts.map +1 -0
- package/dist/authenticatedRequest.js +42 -0
- package/dist/authenticatedRequest.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 +44 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/githubApp.d.ts +24 -0
- package/dist/githubApp.d.ts.map +1 -0
- package/dist/githubApp.js +55 -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/markdown.d.ts +4 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +24 -0
- package/dist/markdown.js.map +1 -0
- package/dist/markdownRows.d.ts +6 -0
- package/dist/markdownRows.d.ts.map +1 -0
- package/dist/markdownRows.js +26 -0
- package/dist/markdownRows.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 +10 -0
- package/dist/requests.d.ts.map +1 -0
- package/dist/requests.js +34 -0
- package/dist/requests.js.map +1 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +57 -0
- package/dist/status.js.map +1 -0
- package/dist/stickyComments.d.ts +46 -0
- package/dist/stickyComments.d.ts.map +1 -0
- package/dist/stickyComments.js +37 -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 +74 -3
- package/src/GitHub.ts +68 -0
- package/src/GitHubConfig.ts +5 -0
- package/src/GitHubToken.ts +16 -0
- package/src/TokenRefresh.ts +7 -0
- package/src/appJwt.ts +21 -0
- package/src/artifacts.ts +57 -0
- package/src/authenticatedRequest.ts +55 -0
- package/src/cache.ts +25 -0
- package/src/checkRuns.ts +48 -0
- package/src/errors.ts +34 -0
- package/src/githubApp.ts +87 -0
- package/src/json.ts +16 -0
- package/src/markdown.ts +25 -0
- package/src/markdownRows.ts +30 -0
- package/src/rateLimits.ts +56 -0
- package/src/representations.ts +57 -0
- package/src/requests.ts +44 -0
- package/src/status.ts +64 -0
- package/src/stickyComments.ts +49 -0
- package/src/verifyWebhook.ts +19 -0
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import { readBoundedBody } from "@shivaedev/http/body.ts";
|
|
4
|
+
import { authenticatedRequest } from "./authenticatedRequest.ts";
|
|
5
|
+
import { GitHubArtifactError, GitHubArtifactTooLarge, GitHubTransportError } from "./errors.ts";
|
|
6
|
+
import type { GitHubToken } from "./GitHubToken.ts";
|
|
7
|
+
import type { RateLimits } from "./rateLimits.ts";
|
|
8
|
+
import type { TokenRefresh } from "./TokenRefresh.ts";
|
|
9
|
+
|
|
10
|
+
export interface ArtifactArchiveOptions {
|
|
11
|
+
readonly artifactId: number;
|
|
12
|
+
readonly byteLimit: number;
|
|
13
|
+
readonly owner: string;
|
|
14
|
+
readonly repo: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function artifacts(
|
|
18
|
+
apiUrl: string,
|
|
19
|
+
client: HttpClient.HttpClient,
|
|
20
|
+
token: GitHubToken["Service"],
|
|
21
|
+
rates: RateLimits,
|
|
22
|
+
refresh: typeof TokenRefresh.Service = undefined,
|
|
23
|
+
) {
|
|
24
|
+
const send = authenticatedRequest(HttpClient.withScope(client), token, rates, refresh);
|
|
25
|
+
const download = HttpClient.withScope(client);
|
|
26
|
+
return {
|
|
27
|
+
downloadArtifactArchive: (options: ArtifactArchiveOptions) =>
|
|
28
|
+
Effect.scoped(
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const path = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/actions/artifacts/${options.artifactId}/zip`;
|
|
31
|
+
const redirect = yield* send(HttpClientRequest.get(new URL(path, apiUrl).href, { accept: "application/vnd.github+json" }), "manual").pipe(
|
|
32
|
+
Effect.catchTags({
|
|
33
|
+
RemoteGone: (error) => Effect.fail(new GitHubArtifactError({ stage: "redirect", status: error.status })),
|
|
34
|
+
RemoteNotFound: (error) => Effect.fail(new GitHubArtifactError({ stage: "redirect", status: error.status })),
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
const location = redirect.headers.location;
|
|
38
|
+
if (redirect.status !== 302 || location === undefined) {
|
|
39
|
+
return yield* new GitHubArtifactError({ stage: "redirect", status: redirect.status });
|
|
40
|
+
}
|
|
41
|
+
const response = yield* download.get(location).pipe(
|
|
42
|
+
Effect.provideService(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "manual" }),
|
|
43
|
+
Effect.mapError(() => new GitHubTransportError({ path })),
|
|
44
|
+
);
|
|
45
|
+
if (response.status < 200 || response.status >= 300) {
|
|
46
|
+
return yield* new GitHubArtifactError({ stage: "download", status: response.status });
|
|
47
|
+
}
|
|
48
|
+
return yield* readBoundedBody(response, options.byteLimit).pipe(
|
|
49
|
+
Effect.catchTags({
|
|
50
|
+
HttpClientError: () => Effect.fail(new GitHubTransportError({ path })),
|
|
51
|
+
ResponseBodyTooLarge: ({ limit }) => Effect.fail(new GitHubArtifactTooLarge({ limit })),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { DateTime, Effect, Option, Redacted } from "effect";
|
|
2
|
+
import { FetchHttpClient, type HttpClient, type HttpClientError, HttpClientRequest } from "effect/http";
|
|
3
|
+
import { GitHubTransportError } from "./errors.ts";
|
|
4
|
+
import type { GitHubToken } from "./GitHubToken.ts";
|
|
5
|
+
import { failRateLimited, type RateLimits } from "./rateLimits.ts";
|
|
6
|
+
import { checkStatus, isUnauthorized, isUnchanged } from "./status.ts";
|
|
7
|
+
import type { TokenRefresh } from "./TokenRefresh.ts";
|
|
8
|
+
|
|
9
|
+
export function authenticatedRequest<R>(
|
|
10
|
+
client: HttpClient.HttpClient.With<HttpClientError.HttpClientError, R>,
|
|
11
|
+
token: GitHubToken["Service"],
|
|
12
|
+
rates: RateLimits,
|
|
13
|
+
refresh: typeof TokenRefresh.Service = undefined,
|
|
14
|
+
) {
|
|
15
|
+
const accessToken = Effect.isEffect(token) ? token : Effect.succeed(token);
|
|
16
|
+
return Effect.fnUntraced(function* (input: HttpClientRequest.HttpClientRequest, mode?: "manual" | "revalidate") {
|
|
17
|
+
const path = new URL(input.url).pathname;
|
|
18
|
+
const resource = rates.resource(path);
|
|
19
|
+
const blocked = rates.blocked.get(resource);
|
|
20
|
+
if (blocked !== undefined && DateTime.isGreaterThan(blocked.retryAt, yield* DateTime.now)) {
|
|
21
|
+
return yield* failRateLimited(path, blocked, rates.observations.get(resource)?.remaining);
|
|
22
|
+
}
|
|
23
|
+
function send(current: Option.Option<Redacted.Redacted<string>>) {
|
|
24
|
+
const sent = client.execute(withCredential(input, current));
|
|
25
|
+
return (
|
|
26
|
+
mode === "manual" ? sent.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "manual" })) : sent
|
|
27
|
+
).pipe(Effect.mapError(() => new GitHubTransportError({ path })));
|
|
28
|
+
}
|
|
29
|
+
const credential = yield* accessToken;
|
|
30
|
+
let response = yield* send(credential);
|
|
31
|
+
if (isUnauthorized(response) && refresh !== undefined) {
|
|
32
|
+
yield* refresh(credential);
|
|
33
|
+
response = yield* send(yield* accessToken);
|
|
34
|
+
}
|
|
35
|
+
const observedResource = rates.observe(response);
|
|
36
|
+
if (!((mode === "revalidate" && isUnchanged(response)) || (mode === "manual" && response.status === 302))) {
|
|
37
|
+
yield* checkStatus(response, observedResource, rates.failures.get(observedResource) ?? 0).pipe(
|
|
38
|
+
Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))),
|
|
39
|
+
Effect.catchTag("GitHubRateLimited", (error) => {
|
|
40
|
+
rates.blocked.set(error.resource, error);
|
|
41
|
+
rates.failures.set(error.resource, (rates.failures.get(error.resource) ?? 0) + 1);
|
|
42
|
+
return failRateLimited(path, error, rates.observations.get(error.resource)?.remaining);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
rates.failures.delete(observedResource);
|
|
47
|
+
rates.blocked.delete(observedResource);
|
|
48
|
+
return response;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function withCredential(input: HttpClientRequest.HttpClientRequest, credential: Option.Option<Redacted.Redacted<string>>) {
|
|
53
|
+
const request = HttpClientRequest.setHeader(input, "X-GitHub-Api-Version", "2022-11-28");
|
|
54
|
+
return Option.isSome(credential) ? HttpClientRequest.setHeader(request, "Authorization", `Bearer ${Redacted.value(credential.value)}`) : request;
|
|
55
|
+
}
|
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,34 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class GitHubPermissionDenied extends Schema.TaggedError<GitHubPermissionDenied>()("GitHubPermissionDenied", {
|
|
4
|
+
path: Schema.String,
|
|
5
|
+
}) {}
|
|
6
|
+
|
|
7
|
+
export class GitHubConversationLocked extends Schema.TaggedError<GitHubConversationLocked>()("GitHubConversationLocked", {
|
|
8
|
+
path: Schema.String,
|
|
9
|
+
}) {}
|
|
10
|
+
|
|
11
|
+
export class GitHubRateLimited extends Schema.TaggedError<GitHubRateLimited>()("GitHubRateLimited", {
|
|
12
|
+
resource: Schema.String,
|
|
13
|
+
retryAt: Schema.DateTimeUtc,
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
export class GitHubTransportError extends Schema.TaggedError<GitHubTransportError>()("GitHubTransportError", {
|
|
17
|
+
path: Schema.String,
|
|
18
|
+
}) {}
|
|
19
|
+
|
|
20
|
+
export class GitHubAppError extends Schema.TaggedError<GitHubAppError>()("GitHubAppError", {
|
|
21
|
+
stage: Schema.Literals(["sign", "exchange"]),
|
|
22
|
+
status: Schema.optional(Schema.Number),
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
export class GitHubWebhookSignatureError extends Schema.TaggedError<GitHubWebhookSignatureError>()("GitHubWebhookSignatureError", {}) {}
|
|
26
|
+
|
|
27
|
+
export class GitHubArtifactError extends Schema.TaggedError<GitHubArtifactError>()("GitHubArtifactError", {
|
|
28
|
+
stage: Schema.Literals(["redirect", "download"]),
|
|
29
|
+
status: Schema.Number,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
export class GitHubArtifactTooLarge extends Schema.TaggedError<GitHubArtifactTooLarge>()("GitHubArtifactTooLarge", {
|
|
33
|
+
limit: Schema.Number,
|
|
34
|
+
}) {}
|
package/src/githubApp.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Cache, Clock, Context, Effect, Exit, Layer, Option, Redacted, Schema } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import { readBoundedJson } from "@shivaedev/http/body.ts";
|
|
4
|
+
import { type AppCredentials, appJwt } from "#appJwt.ts";
|
|
5
|
+
import { GitHubAppError } from "#errors.ts";
|
|
6
|
+
import { GitHubConfig } from "#GitHubConfig.ts";
|
|
7
|
+
import { GitHubToken } from "#GitHubToken.ts";
|
|
8
|
+
import { TokenRefresh } from "#TokenRefresh.ts";
|
|
9
|
+
|
|
10
|
+
export interface InstallationScope {
|
|
11
|
+
readonly permissions?: Readonly<Record<string, "read" | "write" | "admin">>;
|
|
12
|
+
readonly repositories?: readonly string[];
|
|
13
|
+
readonly repositoryIds?: readonly number[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type GitHubAppOptions = AppCredentials &
|
|
17
|
+
(
|
|
18
|
+
| { readonly installationId: number; readonly scope?: InstallationScope }
|
|
19
|
+
| { readonly owner: string; readonly repository: string; readonly scope?: InstallationScope }
|
|
20
|
+
| { readonly installationId?: never; readonly owner?: never; readonly repository?: never; readonly scope?: never }
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const Installation = Schema.Struct({ id: Schema.Number });
|
|
24
|
+
const AccessToken = Schema.Struct({ expiresAt: Schema.DateFromString, token: Schema.RedactedFromValue(Schema.String) }).pipe(
|
|
25
|
+
Schema.encodeKeys({ expiresAt: "expires_at" }),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export function githubApp(options: GitHubAppOptions) {
|
|
29
|
+
return Layer.effectContext(
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
if (!("installationId" in options || "owner" in options)) {
|
|
32
|
+
return Context.make(GitHubToken, Effect.map(appJwt(options), Option.some)).pipe(Context.add(TokenRefresh, undefined));
|
|
33
|
+
}
|
|
34
|
+
const client = yield* HttpClient.HttpClient;
|
|
35
|
+
const { apiUrl } = yield* GitHubConfig;
|
|
36
|
+
const clock = yield* Clock.Clock;
|
|
37
|
+
let installationId = "installationId" in options ? options.installationId : undefined;
|
|
38
|
+
const request = appRequests(client, apiUrl);
|
|
39
|
+
const exchange = Effect.gen(function* () {
|
|
40
|
+
const jwt = yield* appJwt(options);
|
|
41
|
+
if (installationId === undefined && "owner" in options && options.owner !== undefined) {
|
|
42
|
+
const path = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repository)}/installation`;
|
|
43
|
+
installationId = (yield* request("GET", path, jwt, Installation)).id;
|
|
44
|
+
}
|
|
45
|
+
return yield* request("POST", `/app/installations/${installationId}/access_tokens`, jwt, AccessToken, {
|
|
46
|
+
permissions: options.scope?.permissions,
|
|
47
|
+
repositories: options.scope?.repositories,
|
|
48
|
+
"repository_ids": options.scope?.repositoryIds,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
const cache = yield* Cache.makeWith((_: undefined) => exchange, {
|
|
52
|
+
capacity: 1,
|
|
53
|
+
timeToLive: (exit) => (Exit.isSuccess(exit) ? Math.max(0, exit.value.expiresAt.getTime() - clock.currentTimeMillisUnsafe() - 300_000) : 0),
|
|
54
|
+
});
|
|
55
|
+
return Context.make(
|
|
56
|
+
GitHubToken,
|
|
57
|
+
Effect.map(Cache.get(cache, undefined), ({ token }) => Option.some(token)),
|
|
58
|
+
).pipe(
|
|
59
|
+
Context.add(TokenRefresh, (rejected) =>
|
|
60
|
+
Cache.invalidateWhen(cache, undefined, ({ token }) => Option.isSome(rejected) && token === rejected.value),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function appRequests(client: HttpClient.HttpClient, apiUrl: string) {
|
|
68
|
+
return Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(
|
|
69
|
+
method: "GET" | "POST",
|
|
70
|
+
path: string,
|
|
71
|
+
jwt: Redacted.Redacted<string>,
|
|
72
|
+
schema: TSchema,
|
|
73
|
+
body?: unknown,
|
|
74
|
+
) {
|
|
75
|
+
let input = HttpClientRequest.make(method)(new URL(path, apiUrl).href, {
|
|
76
|
+
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${Redacted.value(jwt)}`, "X-GitHub-Api-Version": "2022-11-28" },
|
|
77
|
+
});
|
|
78
|
+
if (body !== undefined) {
|
|
79
|
+
input = yield* HttpClientRequest.bodyJson(input, body).pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
|
|
80
|
+
}
|
|
81
|
+
const response = yield* client.execute(input).pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
|
|
82
|
+
if (response.status < 200 || response.status >= 300) {
|
|
83
|
+
return yield* new GitHubAppError({ stage: "exchange", status: response.status });
|
|
84
|
+
}
|
|
85
|
+
return yield* readBoundedJson(response, 64 * 1024, schema).pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
|
|
86
|
+
});
|
|
87
|
+
}
|
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
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function plainText(text: string): string {
|
|
2
|
+
return text.replace(/[!-/:-@[-`{-~]/gu, (character) => {
|
|
3
|
+
const separator = character === "@" || character === "#" ? "​" : "";
|
|
4
|
+
return `&#${character.charCodeAt(0)};${separator}`;
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function codeSpan(text: string): string {
|
|
9
|
+
const content = text.replace(/\r\n|\r|\n/gu, " ");
|
|
10
|
+
if (content === "") {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
let longest = 0;
|
|
14
|
+
for (const [run] of content.matchAll(/`+/gu)) {
|
|
15
|
+
longest = Math.max(longest, run.length);
|
|
16
|
+
}
|
|
17
|
+
const fence = "`".repeat(longest + 1);
|
|
18
|
+
const pad = content.startsWith("`") || content.endsWith("`") || (/^ .* $/u.test(content) && /[^ ]/u.test(content)) ? " " : "";
|
|
19
|
+
return `${fence}${pad}${content}${pad}${fence}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function tableCell(text: string, format: "text" | "code" = "text"): string {
|
|
23
|
+
const content = text.replace(/\r\n|[\r\n\u2028\u2029]/gu, " ");
|
|
24
|
+
return format === "code" ? codeSpan(content.replaceAll("|", "\\|")) : plainText(content);
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface MarkdownRowsOptions {
|
|
2
|
+
readonly byteLimit?: number;
|
|
3
|
+
readonly header?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function renderRows(rows: readonly string[], { byteLimit = 60_000, header = "" }: MarkdownRowsOptions = {}): string {
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
const sizes = [0];
|
|
9
|
+
for (const row of rows) {
|
|
10
|
+
sizes.push((sizes.at(-1) ?? 0) + encoder.encode(row).byteLength + 1);
|
|
11
|
+
}
|
|
12
|
+
const headerBytes = encoder.encode(header).byteLength;
|
|
13
|
+
function bodySize(kept: number) {
|
|
14
|
+
return headerBytes + (kept > 0 ? (header === "" ? 0 : 1) + (sizes[kept] ?? 0) - 1 : 0);
|
|
15
|
+
}
|
|
16
|
+
function body(kept: number) {
|
|
17
|
+
return [...(header === "" ? [] : [header]), ...rows.slice(0, kept)].join("\n");
|
|
18
|
+
}
|
|
19
|
+
if (bodySize(rows.length) <= byteLimit) {
|
|
20
|
+
return body(rows.length);
|
|
21
|
+
}
|
|
22
|
+
for (let kept = rows.length - 1; kept >= 0; kept -= 1) {
|
|
23
|
+
const notice = `and ${rows.length - kept} more`;
|
|
24
|
+
const separator = kept > 0 || header !== "" ? "\n\n" : "";
|
|
25
|
+
if (bodySize(kept) + separator.length + encoder.encode(notice).byteLength <= byteLimit) {
|
|
26
|
+
return body(kept) + separator + notice;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
throw new RangeError("The byte limit must fit the header and omitted-row notice");
|
|
30
|
+
}
|
|
@@ -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,57 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import type { ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
4
|
+
import { paginate } from "@shivaedev/http/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(
|
|
10
|
+
apiUrl: string,
|
|
11
|
+
request: ReturnType<typeof requests>,
|
|
12
|
+
resource: (path: string) => string,
|
|
13
|
+
responseByteLimit = 1024 * 1024,
|
|
14
|
+
) {
|
|
15
|
+
function get(path: string, accept = "application/vnd.github+json") {
|
|
16
|
+
return HttpClientRequest.get(new URL(path, apiUrl).href, { headers: { Accept: accept } });
|
|
17
|
+
}
|
|
18
|
+
function readRepresentation(path: string, byteLimit = responseByteLimit) {
|
|
19
|
+
return Effect.map(request(get(path), byteLimit), jsonResponse);
|
|
20
|
+
}
|
|
21
|
+
function listRepresentations(
|
|
22
|
+
path: string,
|
|
23
|
+
beforeRequest?: (resource: string) => Effect.Effect<void, ServiceUnavailable>,
|
|
24
|
+
byteLimit = responseByteLimit,
|
|
25
|
+
) {
|
|
26
|
+
const entries = new WeakMap<HttpClientResponse.HttpClientResponse, CachedResponse>();
|
|
27
|
+
function fetchPage(input: HttpClientRequest.HttpClientRequest) {
|
|
28
|
+
return (beforeRequest?.(resource(new URL(input.url).pathname)) ?? Effect.void).pipe(
|
|
29
|
+
Effect.andThen(request(input, byteLimit)),
|
|
30
|
+
Effect.map((entry) => {
|
|
31
|
+
entries.set(entry.response, entry);
|
|
32
|
+
return entry.response;
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const client = HttpClient.makeWith(
|
|
37
|
+
(effect: Effect.Effect<HttpClientRequest.HttpClientRequest>) => Effect.flatMap(effect, fetchPage),
|
|
38
|
+
Effect.succeed,
|
|
39
|
+
);
|
|
40
|
+
return Effect.succeed(
|
|
41
|
+
paginate(client, get(path)).pipe(
|
|
42
|
+
Stream.mapEffect((response) => Effect.fromNullishOr(entries.get(response))),
|
|
43
|
+
Stream.map(jsonResponse),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
function writeRepresentation(method: "POST" | "PATCH", path: string, body: unknown, byteLimit = responseByteLimit) {
|
|
48
|
+
return HttpClientRequest.make(method)(new URL(path, apiUrl).href, { headers: { Accept: "application/vnd.github+json" } }).pipe(
|
|
49
|
+
HttpClientRequest.bodyJson(body),
|
|
50
|
+
Effect.flatMap((input) => request(input, byteLimit)),
|
|
51
|
+
Effect.map(jsonResponse),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return { get, list: listRepresentations, read: readRepresentation, write: writeRepresentation };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Representations = Omit<ReturnType<typeof representations>, "get">;
|
package/src/requests.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { type HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import { readBoundedBody } from "@shivaedev/http/body.ts";
|
|
4
|
+
import { ResponseBodyTooLarge } from "@shivaedev/http/errors.ts";
|
|
5
|
+
import { authenticatedRequest } from "./authenticatedRequest.ts";
|
|
6
|
+
import { type CachedResponse, responseCache } from "./cache.ts";
|
|
7
|
+
import { GitHubTransportError } from "./errors.ts";
|
|
8
|
+
import type { GitHubToken } from "./GitHubToken.ts";
|
|
9
|
+
import type { RateLimits } from "./rateLimits.ts";
|
|
10
|
+
import { isUnchanged } from "./status.ts";
|
|
11
|
+
import type { TokenRefresh } from "./TokenRefresh.ts";
|
|
12
|
+
|
|
13
|
+
export function requests(
|
|
14
|
+
client: HttpClient.HttpClient,
|
|
15
|
+
token: GitHubToken["Service"],
|
|
16
|
+
rates: RateLimits,
|
|
17
|
+
refresh: typeof TokenRefresh.Service = undefined,
|
|
18
|
+
) {
|
|
19
|
+
const cache = responseCache();
|
|
20
|
+
const send = authenticatedRequest(client, token, rates, refresh);
|
|
21
|
+
return Effect.fn("GitHub.request")(function* (input: HttpClientRequest.HttpClientRequest, limit: number) {
|
|
22
|
+
const key = `${input.url} ${input.headers.accept}`;
|
|
23
|
+
const cached = input.method === "GET" ? cache.get(key) : undefined;
|
|
24
|
+
let request = HttpClientRequest.removeHeader(input, "if-none-match");
|
|
25
|
+
if (cached?.response.headers.etag !== undefined) {
|
|
26
|
+
request = HttpClientRequest.setHeader(request, "If-None-Match", cached.response.headers.etag);
|
|
27
|
+
}
|
|
28
|
+
const response = yield* send(request, cached === undefined ? undefined : "revalidate");
|
|
29
|
+
if (isUnchanged(response) && cached !== undefined) {
|
|
30
|
+
if (cached.bytes.byteLength > limit) {
|
|
31
|
+
return yield* new ResponseBodyTooLarge({ limit });
|
|
32
|
+
}
|
|
33
|
+
return cached;
|
|
34
|
+
}
|
|
35
|
+
const bytes = yield* readBoundedBody(response, limit).pipe(
|
|
36
|
+
Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path: new URL(input.url).pathname }))),
|
|
37
|
+
);
|
|
38
|
+
const entry: CachedResponse = { bytes, decoded: new Map(), response };
|
|
39
|
+
if (input.method === "GET" && response.headers.etag !== undefined) {
|
|
40
|
+
cache.set(key, entry);
|
|
41
|
+
}
|
|
42
|
+
return entry;
|
|
43
|
+
});
|
|
44
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DateTime, Effect, Option, Schema } from "effect";
|
|
2
|
+
import { HttpClient, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import { readBoundedText } from "@shivaedev/http/body.ts";
|
|
4
|
+
import { filterStatus } from "@shivaedev/http/filterStatus.ts";
|
|
5
|
+
import { parseRetryAfter } from "@shivaedev/http/parseRetryAfter.ts";
|
|
6
|
+
import { GitHubConversationLocked, GitHubPermissionDenied, GitHubRateLimited } from "#errors.ts";
|
|
7
|
+
|
|
8
|
+
export function isUnauthorized(response: HttpClientResponse.HttpClientResponse) {
|
|
9
|
+
return response.status === 401;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isUnchanged(response: HttpClientResponse.HttpClientResponse) {
|
|
13
|
+
return response.status === 304;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const checkStatus = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse, resource: string, failures = 0) {
|
|
17
|
+
const { status, headers } = response;
|
|
18
|
+
if (status === 403 || status === 429) {
|
|
19
|
+
const limitedByHeaders = status === 429 || headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined;
|
|
20
|
+
const message = !limitedByHeaders && status === 403 ? yield* responseMessage(response) : undefined;
|
|
21
|
+
const limitedByMessage = message !== undefined && /rate limit/iu.test(message);
|
|
22
|
+
if (limitedByHeaders || limitedByMessage) {
|
|
23
|
+
return yield* refuseRateLimit(response, resource, failures);
|
|
24
|
+
}
|
|
25
|
+
const refusal = githubRefusal(message, new URL(response.request.url).pathname);
|
|
26
|
+
if (refusal !== undefined) {
|
|
27
|
+
return yield* refusal;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return yield* filterStatus(HttpClient.makeWith(() => Effect.succeed(response), Effect.succeed)).execute(response.request);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const Message = Schema.fromJsonString(Schema.Struct({ message: Schema.String }));
|
|
34
|
+
|
|
35
|
+
const responseMessage = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
|
|
36
|
+
const text = yield* readBoundedText(response, 64 * 1024);
|
|
37
|
+
const body = Schema.decodeUnknownOption(Message)(text);
|
|
38
|
+
return Option.isSome(body) ? body.value.message : undefined;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function githubRefusal(message: string | undefined, path: string) {
|
|
42
|
+
if (message === undefined) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (/^Resource not accessible by integration$/iu.test(message)) {
|
|
46
|
+
return new GitHubPermissionDenied({ path });
|
|
47
|
+
}
|
|
48
|
+
if (/\bconversation (?:is|has been) locked\b/iu.test(message)) {
|
|
49
|
+
return new GitHubConversationLocked({ path });
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const refuseRateLimit = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse, resource: string, failures: number) {
|
|
55
|
+
const { headers } = response;
|
|
56
|
+
const now = yield* DateTime.now;
|
|
57
|
+
const reset = Number(headers["x-ratelimit-reset"] ?? 0) * 1000;
|
|
58
|
+
const fallback =
|
|
59
|
+
headers["x-ratelimit-remaining"] === "0" && reset > DateTime.toEpochMillis(now)
|
|
60
|
+
? reset
|
|
61
|
+
: DateTime.toEpochMillis(now) + Math.min(15 * 60_000, 60_000 * 2 ** failures);
|
|
62
|
+
const retryAt = parseRetryAfter(headers["retry-after"], now) ?? DateTime.makeUnsafe(fallback);
|
|
63
|
+
return yield* new GitHubRateLimited({ resource, retryAt });
|
|
64
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
performedViaGithubApp: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Number }))),
|
|
8
|
+
user: Schema.NullOr(Schema.Struct({ login: Schema.String, type: Schema.optional(Schema.String) })),
|
|
9
|
+
}).pipe(Schema.encodeKeys({ performedViaGithubApp: "performed_via_github_app" }));
|
|
10
|
+
|
|
11
|
+
export const StickyComment = Schema.Struct({
|
|
12
|
+
appId: Schema.optional(Schema.Number),
|
|
13
|
+
author: Schema.optional(Schema.String),
|
|
14
|
+
body: Schema.String,
|
|
15
|
+
marker: Schema.String,
|
|
16
|
+
number: Schema.Number,
|
|
17
|
+
owner: Schema.String,
|
|
18
|
+
repo: Schema.String,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const CommentBody = Schema.Struct({ body: Schema.String });
|
|
22
|
+
|
|
23
|
+
export function stickyComments(client: Representations) {
|
|
24
|
+
return {
|
|
25
|
+
upsertStickyComment: Effect.fn("GitHub.upsertStickyComment")(function* (input: typeof StickyComment.Type) {
|
|
26
|
+
const { owner, repo, number, marker, body, author, appId } = yield* Schema.decodeUnknownEffect(StickyComment)(input);
|
|
27
|
+
const hiddenMarker = `<!-- ${marker} -->`;
|
|
28
|
+
const root = `/repos/${owner}/${repo}/issues`;
|
|
29
|
+
const pages = yield* client.list(`${root}/${number}/comments`);
|
|
30
|
+
const existing = yield* pages.pipe(
|
|
31
|
+
Stream.mapEffect((page) => page.decode(Schema.Array(PullRequestComment))),
|
|
32
|
+
Stream.flatMap(Stream.fromIterable),
|
|
33
|
+
Stream.filter(
|
|
34
|
+
(comment) =>
|
|
35
|
+
(author === undefined || comment.user?.login === author)
|
|
36
|
+
&& (appId === undefined
|
|
37
|
+
? comment.body.includes(hiddenMarker)
|
|
38
|
+
: comment.body.startsWith(hiddenMarker) && comment.performedViaGithubApp?.id === appId && comment.user?.type === "Bot"),
|
|
39
|
+
),
|
|
40
|
+
Stream.runHead,
|
|
41
|
+
);
|
|
42
|
+
const payload = yield* Schema.encodeEffect(CommentBody)({ body: `${hiddenMarker}\n${body}` });
|
|
43
|
+
const response = yield* Option.isSome(existing)
|
|
44
|
+
? client.write("PATCH", `${root}/comments/${existing.value.id}`, payload)
|
|
45
|
+
: client.write("POST", `${root}/${number}/comments`, payload);
|
|
46
|
+
return yield* response.decode(PullRequestComment);
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|