@effected/github 0.25.1 → 0.27.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/ActionsOidc.js +7 -4
- package/DeploymentEnvironment.js +7 -2
- package/GitHubApp.js +81 -69
- package/GitHubClient.js +68 -19
- package/GitHubError.js +13 -2
- package/GitHubRelease.js +23 -6
- package/GitHubRepository.js +74 -22
- package/GraphQL.js +7 -1
- package/README.md +3 -3
- package/index.d.ts +262 -36
- package/internal/confinement.js +62 -0
- package/internal/octokit.js +95 -5
- package/internal/requestOptions.js +84 -0
- package/internal/scrub.js +113 -0
- package/internal/uploads.js +70 -0
- package/package.json +7 -6
- package/tsdoc-metadata.json +1 -1
package/ActionsOidc.js
CHANGED
|
@@ -143,7 +143,8 @@ const testClaims = (overrides = {}) => ({
|
|
|
143
143
|
* @remarks
|
|
144
144
|
* {@link ActionsOidc.verify} is `@effected/oidc`'s `Oidc.verify` pinned to the
|
|
145
145
|
* Actions issuer: it checks the signature against GitHub's published JWKS
|
|
146
|
-
* (through `@effected/jwt`'s `JwksResolver`, which caches it
|
|
146
|
+
* (through `@effected/jwt`'s `JwksResolver`, which caches it; the signature
|
|
147
|
+
* itself runs on core `effect/jose` and needs core's `Signature` service), then `iss`,
|
|
147
148
|
* `aud`, `exp`, `nbf` and `iat`, then decodes the payload into
|
|
148
149
|
* {@link ActionsOidcClaims}. Every failure is a `JwtError`; route on its
|
|
149
150
|
* `reason` (`wrongAudience`, `expired`, `claims`, ...), never on its message.
|
|
@@ -168,7 +169,8 @@ const testClaims = (overrides = {}) => ({
|
|
|
168
169
|
* ```ts
|
|
169
170
|
* import { JwksResolver } from "@effected/jwt";
|
|
170
171
|
* import { ActionsOidc } from "@effected/github";
|
|
171
|
-
* import { Data, Effect } from "effect";
|
|
172
|
+
* import { Data, Effect, Layer } from "effect";
|
|
173
|
+
* import { SecureRandom, Signature } from "effect/crypto";
|
|
172
174
|
*
|
|
173
175
|
* class Forbidden extends Data.TaggedError("Forbidden")<{ readonly repositoryId: number }> {}
|
|
174
176
|
*
|
|
@@ -186,7 +188,8 @@ const testClaims = (overrides = {}) => ({
|
|
|
186
188
|
* });
|
|
187
189
|
*
|
|
188
190
|
* // JwksResolver.layer needs an HttpClient and a JwksStore, provided once at the edge.
|
|
189
|
-
*
|
|
191
|
+
* const CryptoLive = Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto));
|
|
192
|
+
* export const program = authorize("eyJ...").pipe(Effect.provide(Layer.merge(JwksResolver.layer, CryptoLive)));
|
|
190
193
|
* ```
|
|
191
194
|
*
|
|
192
195
|
* @public
|
|
@@ -195,7 +198,7 @@ var ActionsOidc = class {
|
|
|
195
198
|
constructor() {}
|
|
196
199
|
/** The Actions OIDC issuer, `https://token.actions.githubusercontent.com`. */
|
|
197
200
|
static issuer = ISSUER;
|
|
198
|
-
/** Verify a runner-issued OIDC token: signature against GitHub's JWKS, iss, aud, exp/nbf, then typed claims
|
|
201
|
+
/** Verify a runner-issued OIDC token: signature against GitHub's JWKS, iss, aud, exp/nbf, then typed claims; requires `JwksResolver` and core `Signature`. */
|
|
199
202
|
static verify = verify;
|
|
200
203
|
/**
|
|
201
204
|
* A valid claims payload for tests, overridable field by field.
|
package/DeploymentEnvironment.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ENVIRONMENT_PUT_ROUTE_PARAMS, reservedBodyKey } from "./internal/requestOptions.js";
|
|
1
2
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
2
3
|
import { GitHubClient } from "./GitHubClient.js";
|
|
3
4
|
import { Repo } from "./Repo.js";
|
|
@@ -54,11 +55,15 @@ const make = (client) => {
|
|
|
54
55
|
repo,
|
|
55
56
|
environment: name
|
|
56
57
|
});
|
|
58
|
+
for (const key of Object.keys(config)) {
|
|
59
|
+
const reason = reservedBodyKey(key, ENVIRONMENT_PUT_ROUTE_PARAMS, "an environment setting");
|
|
60
|
+
if (reason !== void 0) return yield* Effect.die(/* @__PURE__ */ new RangeError(`DeploymentEnvironment.upsert: "${key}" ${reason}`));
|
|
61
|
+
}
|
|
57
62
|
yield* client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}", {
|
|
58
|
-
...config,
|
|
59
63
|
owner,
|
|
60
64
|
repo,
|
|
61
|
-
environment_name: name
|
|
65
|
+
environment_name: name,
|
|
66
|
+
data: { ...config }
|
|
62
67
|
});
|
|
63
68
|
}),
|
|
64
69
|
list: Effect.fn("DeploymentEnvironment.list")(function* () {
|
package/GitHubApp.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { GitHubError } from "./GitHubError.js";
|
|
2
2
|
import { GitHubGraphQLError } from "./GraphQL.js";
|
|
3
|
+
import { uploadChannelOf, withUploadChannel } from "./internal/uploads.js";
|
|
3
4
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
4
5
|
import { GitHubClient, makeClientShape, resolveApiVersion } from "./GitHubClient.js";
|
|
5
6
|
import { numericId } from "./internal/ids.js";
|
|
6
7
|
import { InstallationTokenStore } from "./InstallationTokenStore.js";
|
|
7
8
|
import { Cause, Clock, Context, DateTime, Deferred, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore, Stream } from "effect";
|
|
8
9
|
import { Jwt, JwtKey } from "@effected/jwt";
|
|
10
|
+
import { Signature } from "effect/crypto";
|
|
9
11
|
|
|
10
12
|
// src/GitHubApp.ts
|
|
11
13
|
/**
|
|
@@ -354,7 +356,7 @@ var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitH
|
|
|
354
356
|
* Effect.runPromise(Effect.provide(sweep, layer));
|
|
355
357
|
* ```
|
|
356
358
|
*/
|
|
357
|
-
static appClientLayer = (credentials, options = {}) => Layer.effect(GitHubClient, makeRotatingClient(mintJwt(credentials), () => Effect.void, options, "GitHubApp.appClientLayer"));
|
|
359
|
+
static appClientLayer = (credentials, options = {}) => Layer.effect(GitHubClient, Effect.flatMap(Effect.service(Signature.Signature), (signature) => makeRotatingClient(Effect.provideService(mintJwt(credentials), Signature.Signature, signature), () => Effect.void, options, "GitHubApp.appClientLayer")));
|
|
358
360
|
/**
|
|
359
361
|
* An installation token, reused from an {@link InstallationTokenStore}
|
|
360
362
|
* while it has `margin` (five minutes by default) left to live.
|
|
@@ -533,7 +535,7 @@ const mintJwt = (credentials) => Effect.gen(function* () {
|
|
|
533
535
|
};
|
|
534
536
|
}).pipe(Effect.catchTag("JwtError", (error) => Effect.fail(GitHubAppError.of("jwt", error.detail, error))));
|
|
535
537
|
/** A client speaking as the app itself. */
|
|
536
|
-
const
|
|
538
|
+
const appClientOf = (credentials, options) => Effect.flatMap(mintJwt(credentials), ({ token }) => makeClientShape({
|
|
537
539
|
...options,
|
|
538
540
|
token
|
|
539
541
|
}));
|
|
@@ -544,75 +546,77 @@ const asBearer = (token, options) => makeClientShape({
|
|
|
544
546
|
});
|
|
545
547
|
const appFailure = (kind) => (error) => Effect.fail(GitHubAppError.of(kind, error.reason, error));
|
|
546
548
|
function makeApp(options) {
|
|
547
|
-
return Effect.map(resolveApiVersion(options.apiVersion), () =>
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
549
|
+
return Effect.flatMap(Effect.service(Signature.Signature), (signature) => Effect.map(resolveApiVersion(options.apiVersion), () => makeAppWith(options, signature)));
|
|
550
|
+
}
|
|
551
|
+
function makeAppWith(options, signature) {
|
|
552
|
+
const asApp = (credentials, clientOptions) => Effect.provideService(appClientOf(credentials, clientOptions), Signature.Signature, signature);
|
|
553
|
+
const installations = Effect.fn("GitHubApp.installations")(function* (credentials) {
|
|
554
|
+
return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) => {
|
|
555
|
+
const account = entry.account ?? void 0;
|
|
556
|
+
return Installation.make({
|
|
557
|
+
id: numericId(entry.id),
|
|
558
|
+
...account !== void 0 && typeof account.login === "string" ? { account: account.login } : {},
|
|
559
|
+
...readField("accountType", Schema.String, account?.type),
|
|
560
|
+
...readField("accountId", Schema.Int, typeof account?.id === "bigint" ? numericId(account.id) : account?.id),
|
|
561
|
+
...readField("suspendedAt", SuspendedAt, entry.suspended_at),
|
|
562
|
+
...readField("updatedAt", Schema.DateTimeUtcFromString, entry.updated_at)
|
|
559
563
|
});
|
|
560
564
|
});
|
|
561
|
-
const resolveInstallationId = (request) => request.installationId !== void 0 ? Effect.succeed(request.installationId) : Effect.gen(function* () {
|
|
562
|
-
const all = yield* installations(request);
|
|
563
|
-
if (request.owner !== void 0) {
|
|
564
|
-
const wanted = request.owner.toLowerCase();
|
|
565
|
-
const match = all.find((entry) => entry.account?.toLowerCase() === wanted);
|
|
566
|
-
if (match !== void 0) return match.id;
|
|
567
|
-
return yield* Effect.fail(GitHubAppError.of("installation", `the app is not installed on ${request.owner} (installed on: ${all.map((entry) => entry.account ?? entry.id).join(", ") || "nothing"})`));
|
|
568
|
-
}
|
|
569
|
-
const only = all[0];
|
|
570
|
-
if (all.length === 1 && only !== void 0) return only.id;
|
|
571
|
-
return yield* Effect.fail(GitHubAppError.of("installation", all.length === 0 ? "the app has no installations" : `the app has ${all.length} installations; pass installationId or owner`));
|
|
572
|
-
});
|
|
573
|
-
const token = Effect.fn("GitHubApp.token")(function* (request) {
|
|
574
|
-
const installationId = yield* resolveInstallationId(request);
|
|
575
|
-
const minted = yield* (yield* asApp(request, options)).request("POST /app/installations/{installation_id}/access_tokens", { installation_id: installationId }).pipe(Effect.catch(appFailure("token")));
|
|
576
|
-
return yield* Schema.decodeUnknownEffect(InstallationToken)({
|
|
577
|
-
token: minted.token,
|
|
578
|
-
expiresAt: minted.expires_at,
|
|
579
|
-
installationId,
|
|
580
|
-
permissions: normalizePermissions(minted.permissions)
|
|
581
|
-
}).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("token", "GitHub returned an unexpected token payload", error))));
|
|
582
|
-
});
|
|
583
|
-
const revoke = Effect.fn("GitHubApp.revoke")(function* (value) {
|
|
584
|
-
yield* (yield* asBearer(value, options)).request("DELETE /installation/token", {}).pipe(Effect.catch(appFailure("revoke")));
|
|
585
|
-
});
|
|
586
|
-
const scopedToken = (request) => Effect.acquireRelease(token(request), (minted) => Effect.ignore(revoke(minted.token)));
|
|
587
|
-
return {
|
|
588
|
-
token,
|
|
589
|
-
scopedToken,
|
|
590
|
-
revoke,
|
|
591
|
-
identity: Effect.fn("GitHubApp.identity")(function* (request) {
|
|
592
|
-
const app = yield* (yield* asApp(request, options)).request("GET /app", {}).pipe(Effect.catch(appFailure("identity")));
|
|
593
|
-
if (app === null) return yield* Effect.fail(GitHubAppError.of("identity", "GET /app returned no app"));
|
|
594
|
-
const slug = app.slug ?? "";
|
|
595
|
-
const name = app.name;
|
|
596
|
-
const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${slug}[bot]` }).pipe(Effect.option);
|
|
597
|
-
return AppIdentity.make({
|
|
598
|
-
slug,
|
|
599
|
-
name,
|
|
600
|
-
...Option.isSome(user) ? { userId: numericId(user.value.id) } : {}
|
|
601
|
-
});
|
|
602
|
-
}),
|
|
603
|
-
botUser: Effect.fn("GitHubApp.botUser")(function* (request) {
|
|
604
|
-
yield* Effect.annotateCurrentSpan({ slug: request.slug });
|
|
605
|
-
const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${request.slug}[bot]` }).pipe(Effect.catch(appFailure("identity")));
|
|
606
|
-
const raw = user;
|
|
607
|
-
return yield* Schema.decodeUnknownEffect(BotUser)(typeof raw === "object" && raw !== null ? {
|
|
608
|
-
id: numericId(user.id),
|
|
609
|
-
login: user.login
|
|
610
|
-
} : raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("identity", "GitHub returned an unexpected bot user", error))));
|
|
611
|
-
}),
|
|
612
|
-
installations,
|
|
613
|
-
cachedToken: makeCachedToken(token)
|
|
614
|
-
};
|
|
615
565
|
});
|
|
566
|
+
const resolveInstallationId = (request) => request.installationId !== void 0 ? Effect.succeed(request.installationId) : Effect.gen(function* () {
|
|
567
|
+
const all = yield* installations(request);
|
|
568
|
+
if (request.owner !== void 0) {
|
|
569
|
+
const wanted = request.owner.toLowerCase();
|
|
570
|
+
const match = all.find((entry) => entry.account?.toLowerCase() === wanted);
|
|
571
|
+
if (match !== void 0) return match.id;
|
|
572
|
+
return yield* Effect.fail(GitHubAppError.of("installation", `the app is not installed on ${request.owner} (installed on: ${all.map((entry) => entry.account ?? entry.id).join(", ") || "nothing"})`));
|
|
573
|
+
}
|
|
574
|
+
const only = all[0];
|
|
575
|
+
if (all.length === 1 && only !== void 0) return only.id;
|
|
576
|
+
return yield* Effect.fail(GitHubAppError.of("installation", all.length === 0 ? "the app has no installations" : `the app has ${all.length} installations; pass installationId or owner`));
|
|
577
|
+
});
|
|
578
|
+
const token = Effect.fn("GitHubApp.token")(function* (request) {
|
|
579
|
+
const installationId = yield* resolveInstallationId(request);
|
|
580
|
+
const minted = yield* (yield* asApp(request, options)).request("POST /app/installations/{installation_id}/access_tokens", { installation_id: installationId }).pipe(Effect.catch(appFailure("token")));
|
|
581
|
+
return yield* Schema.decodeUnknownEffect(InstallationToken)({
|
|
582
|
+
token: minted.token,
|
|
583
|
+
expiresAt: minted.expires_at,
|
|
584
|
+
installationId,
|
|
585
|
+
permissions: normalizePermissions(minted.permissions)
|
|
586
|
+
}).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("token", "GitHub returned an unexpected token payload", error))));
|
|
587
|
+
});
|
|
588
|
+
const revoke = Effect.fn("GitHubApp.revoke")(function* (value) {
|
|
589
|
+
yield* (yield* asBearer(value, options)).request("DELETE /installation/token", {}).pipe(Effect.catch(appFailure("revoke")));
|
|
590
|
+
});
|
|
591
|
+
const scopedToken = (request) => Effect.acquireRelease(token(request), (minted) => Effect.ignore(revoke(minted.token)));
|
|
592
|
+
return {
|
|
593
|
+
token,
|
|
594
|
+
scopedToken,
|
|
595
|
+
revoke,
|
|
596
|
+
identity: Effect.fn("GitHubApp.identity")(function* (request) {
|
|
597
|
+
const app = yield* (yield* asApp(request, options)).request("GET /app", {}).pipe(Effect.catch(appFailure("identity")));
|
|
598
|
+
if (app === null) return yield* Effect.fail(GitHubAppError.of("identity", "GET /app returned no app"));
|
|
599
|
+
const slug = app.slug ?? "";
|
|
600
|
+
const name = app.name;
|
|
601
|
+
const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${slug}[bot]` }).pipe(Effect.option);
|
|
602
|
+
return AppIdentity.make({
|
|
603
|
+
slug,
|
|
604
|
+
name,
|
|
605
|
+
...Option.isSome(user) ? { userId: numericId(user.value.id) } : {}
|
|
606
|
+
});
|
|
607
|
+
}),
|
|
608
|
+
botUser: Effect.fn("GitHubApp.botUser")(function* (request) {
|
|
609
|
+
yield* Effect.annotateCurrentSpan({ slug: request.slug });
|
|
610
|
+
const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${request.slug}[bot]` }).pipe(Effect.catch(appFailure("identity")));
|
|
611
|
+
const raw = user;
|
|
612
|
+
return yield* Schema.decodeUnknownEffect(BotUser)(typeof raw === "object" && raw !== null ? {
|
|
613
|
+
id: numericId(user.id),
|
|
614
|
+
login: user.login
|
|
615
|
+
} : raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("identity", "GitHub returned an unexpected bot user", error))));
|
|
616
|
+
}),
|
|
617
|
+
installations,
|
|
618
|
+
cachedToken: makeCachedToken(token)
|
|
619
|
+
};
|
|
616
620
|
}
|
|
617
621
|
/** GitHub's permission values are strings; anything else is not a permission. */
|
|
618
622
|
const normalizePermissions = (raw) => {
|
|
@@ -673,7 +677,11 @@ const makeRotatingClient = (mint, release, options, operation) => Effect.gen(fun
|
|
|
673
677
|
errors: [],
|
|
674
678
|
cause: error
|
|
675
679
|
}))));
|
|
676
|
-
|
|
680
|
+
const upload = (route, params, schema) => Effect.flatMap(current, (client) => {
|
|
681
|
+
const channel = uploadChannelOf(client);
|
|
682
|
+
return channel === void 0 ? Effect.die(/* @__PURE__ */ new Error(`${operation}: the inner client carries no upload channel`)) : channel.upload(route, params, schema);
|
|
683
|
+
});
|
|
684
|
+
const shape = {
|
|
677
685
|
request: (route, params) => Effect.flatMap(current, (client) => client.request(route, params)),
|
|
678
686
|
requestDecoded: (route, params, schema) => Effect.flatMap(current, (client) => client.requestDecoded(route, params, schema)),
|
|
679
687
|
paginate: (route, params, pageOptions) => Effect.flatMap(current, (client) => client.paginate(route, params, pageOptions)),
|
|
@@ -682,6 +690,10 @@ const makeRotatingClient = (mint, release, options, operation) => Effect.gen(fun
|
|
|
682
690
|
rateLimit: Effect.flatMap(Ref.get(held), (state) => Option.isSome(state) ? state.value.client.rateLimit : Effect.succeed(Option.none())),
|
|
683
691
|
apiVersion
|
|
684
692
|
};
|
|
693
|
+
return withUploadChannel(shape, {
|
|
694
|
+
baseUrl: options.baseUrl,
|
|
695
|
+
upload
|
|
696
|
+
});
|
|
685
697
|
});
|
|
686
698
|
|
|
687
699
|
export { AppIdentity, BotIdentity, BotUser, GitHubApp, GitHubAppError, Installation, InstallationToken };
|
package/GitHubClient.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { GitHubError } from "./GitHubError.js";
|
|
2
2
|
import { GitHubGraphQLError } from "./GraphQL.js";
|
|
3
|
+
import { DOTCOM_API_BASE_URL, originOf, uploadsBaseUrlFor, withUploadChannel } from "./internal/uploads.js";
|
|
4
|
+
import { rawRequestRefusal, refusedRequest } from "./internal/confinement.js";
|
|
3
5
|
import { RetryPolicy } from "./Resilience.js";
|
|
4
|
-
import { DEFAULT_API_VERSION, makeTransport } from "./internal/octokit.js";
|
|
6
|
+
import { DEFAULT_API_VERSION, expandOnDotcom, makeTransport } from "./internal/octokit.js";
|
|
5
7
|
import { fromArray, paginate } from "./internal/paginate.js";
|
|
6
8
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
7
9
|
import { Clock, Config, Context, Effect, Layer, Option, Schema, Stream } from "effect";
|
|
@@ -82,6 +84,34 @@ var GitHubFixtures = class {
|
|
|
82
84
|
const resolvePolicy = (retry) => retry === void 0 ? RetryPolicy.default : retry === "off" ? RetryPolicy.none : retry;
|
|
83
85
|
const perPageOf = (options) => options?.perPage ?? DEFAULT_PER_PAGE;
|
|
84
86
|
const dies = unstubbed("GitHubClient.makeTest", "pass an override, or use GitHubClient.layerFixture for recorded responses.");
|
|
87
|
+
const DOTCOM_API_ORIGIN = originOf(DOTCOM_API_BASE_URL);
|
|
88
|
+
const doubleRefusal = (route, params) => rawRequestRefusal(expandOnDotcom, route, typeof params === "object" && params !== null ? params : {}, DOTCOM_API_ORIGIN);
|
|
89
|
+
/**
|
|
90
|
+
* `shape` with the live client's token-confinement refusals applied in front of
|
|
91
|
+
* its four raw members, so a suite on a double sees the `rejected` failure
|
|
92
|
+
* production would raise. A refused call never reaches the double's handler,
|
|
93
|
+
* stub, fixture or `requested` log, as a refused live call never reaches the
|
|
94
|
+
* wire.
|
|
95
|
+
*/
|
|
96
|
+
const confineDouble = (shape) => ({
|
|
97
|
+
...shape,
|
|
98
|
+
request: (route, params) => Effect.suspend(() => {
|
|
99
|
+
const reason = doubleRefusal(route, params);
|
|
100
|
+
return reason !== void 0 ? Effect.fail(refusedRequest(route, reason)) : shape.request(route, params);
|
|
101
|
+
}),
|
|
102
|
+
requestDecoded: (route, params, schema) => Effect.suspend(() => {
|
|
103
|
+
const reason = doubleRefusal(route, params);
|
|
104
|
+
return reason !== void 0 ? Effect.fail(refusedRequest(route, reason)) : shape.requestDecoded(route, params, schema);
|
|
105
|
+
}),
|
|
106
|
+
paginate: (route, params, options) => Effect.suspend(() => {
|
|
107
|
+
const reason = doubleRefusal(route, params);
|
|
108
|
+
return reason !== void 0 ? Effect.fail(refusedRequest(route, reason)) : shape.paginate(route, params, options);
|
|
109
|
+
}),
|
|
110
|
+
paginateStream: (route, params, options) => Stream.suspend(() => {
|
|
111
|
+
const reason = doubleRefusal(route, params);
|
|
112
|
+
return reason !== void 0 ? Stream.fail(refusedRequest(route, reason)) : shape.paginateStream(route, params, options);
|
|
113
|
+
})
|
|
114
|
+
});
|
|
85
115
|
/**
|
|
86
116
|
* The typed GitHub API client.
|
|
87
117
|
*
|
|
@@ -157,10 +187,16 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
|
|
|
157
187
|
* `rateLimit` answers `Option.none()` and `apiVersion` the live default,
|
|
158
188
|
* because a real client legitimately answers both without a request.
|
|
159
189
|
*
|
|
190
|
+
* It refuses what the live client refuses, before any override runs: a
|
|
191
|
+
* `baseUrl`, `url`, `request` or `method` parameter (a body field called
|
|
192
|
+
* `url` goes inside `data`), or a route that resolves off github.com's REST
|
|
193
|
+
* root, fails `kind: "rejected"` with no status. A double has no
|
|
194
|
+
* configured base, so an absolute-URL route must name `api.github.com`.
|
|
195
|
+
*
|
|
160
196
|
* For recorded responses that page for real, use
|
|
161
197
|
* {@link GitHubClient.layerFixture}.
|
|
162
198
|
*/
|
|
163
|
-
static makeTest = (overrides = {}) => ({
|
|
199
|
+
static makeTest = (overrides = {}) => confineDouble({
|
|
164
200
|
request: overrides.request ?? (() => dies("request")),
|
|
165
201
|
requestDecoded: overrides.requestDecoded ?? (() => dies("requestDecoded")),
|
|
166
202
|
paginate: overrides.paginate ?? (() => dies("paginate")),
|
|
@@ -189,8 +225,13 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
|
|
|
189
225
|
* one fixture both records the calls and classifies a raw failure the way
|
|
190
226
|
* the live client would; there is no need to classify by hand inside a
|
|
191
227
|
* {@link GitHubClient.layerTest} handler.
|
|
228
|
+
*
|
|
229
|
+
* It refuses what the live client refuses, exactly as
|
|
230
|
+
* {@link GitHubClient.makeTest} does. A refused call fails
|
|
231
|
+
* `kind: "rejected"` and is **not** recorded in `requested`: it is never
|
|
232
|
+
* served, as a refused live call never reaches the wire.
|
|
192
233
|
*/
|
|
193
|
-
static layerFixture = (fixtures) => Layer.succeed(GitHubClient, makeFixture(fixtures));
|
|
234
|
+
static layerFixture = (fixtures) => Layer.succeed(GitHubClient, confineDouble(makeFixture(fixtures)));
|
|
194
235
|
};
|
|
195
236
|
/** A calendar api-version: `YYYY-MM-DD`. */
|
|
196
237
|
const API_VERSION_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -231,35 +272,43 @@ const makeClientShape = (options) => Effect.gen(function* () {
|
|
|
231
272
|
yield* Effect.annotateCurrentSpan({ route });
|
|
232
273
|
return (yield* transport.request(route, route, params)).data;
|
|
233
274
|
});
|
|
275
|
+
const decodeWith = (route, schema) => (data) => Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "response did not match its schema", error))));
|
|
234
276
|
const requestDecoded = Effect.fn("GitHubClient.requestDecoded")(function* (route, params, schema) {
|
|
235
277
|
yield* Effect.annotateCurrentSpan({ route });
|
|
236
278
|
const response = yield* transport.request(route, route, params);
|
|
237
|
-
return yield*
|
|
279
|
+
return yield* decodeWith(route, schema)(response.data);
|
|
238
280
|
});
|
|
281
|
+
const uploadsBaseUrl = uploadsBaseUrlFor(options.baseUrl);
|
|
282
|
+
const upload = (route, params, schema) => Effect.flatMap(transport.requestAt(route, route, params, uploadsBaseUrl), (response) => decodeWith(route, schema)(response.data));
|
|
239
283
|
const paginateStream = (route, params, options) => paginate(() => transport.pageSource(route, route, {
|
|
240
284
|
per_page: perPageOf(options),
|
|
241
285
|
...params
|
|
242
286
|
}), options?.maxPages);
|
|
243
|
-
|
|
287
|
+
const paginateAll = Effect.fn("GitHubClient.paginate")(function* (route, params, options) {
|
|
288
|
+
yield* Effect.annotateCurrentSpan({
|
|
289
|
+
route,
|
|
290
|
+
perPage: perPageOf(options),
|
|
291
|
+
maxPages: options?.maxPages ?? -1
|
|
292
|
+
});
|
|
293
|
+
return yield* Stream.runCollect(paginateStream(route, params, options));
|
|
294
|
+
});
|
|
295
|
+
const graphql = Effect.fn("GitHubClient.graphql")(function* (document, variables) {
|
|
296
|
+
yield* Effect.annotateCurrentSpan({ document: document.name });
|
|
297
|
+
const raw = yield* transport.graphql(document.name, document.document, document.encodeVariables(variables));
|
|
298
|
+
return yield* document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "response did not match its schema", error))));
|
|
299
|
+
});
|
|
300
|
+
return withUploadChannel({
|
|
244
301
|
request,
|
|
245
302
|
requestDecoded,
|
|
246
|
-
paginate:
|
|
247
|
-
yield* Effect.annotateCurrentSpan({
|
|
248
|
-
route,
|
|
249
|
-
perPage: perPageOf(options),
|
|
250
|
-
maxPages: options?.maxPages ?? -1
|
|
251
|
-
});
|
|
252
|
-
return yield* Stream.runCollect(paginateStream(route, params, options));
|
|
253
|
-
}),
|
|
303
|
+
paginate: paginateAll,
|
|
254
304
|
paginateStream,
|
|
255
|
-
graphql
|
|
256
|
-
yield* Effect.annotateCurrentSpan({ document: document.name });
|
|
257
|
-
const raw = yield* transport.graphql(document.name, document.document, document.encodeVariables(variables));
|
|
258
|
-
return yield* document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "response did not match its schema", error))));
|
|
259
|
-
}),
|
|
305
|
+
graphql,
|
|
260
306
|
rateLimit: transport.rateLimit,
|
|
261
307
|
apiVersion
|
|
262
|
-
}
|
|
308
|
+
}, {
|
|
309
|
+
baseUrl: options.baseUrl,
|
|
310
|
+
upload
|
|
311
|
+
});
|
|
263
312
|
});
|
|
264
313
|
const makeFixture = (fixtures) => {
|
|
265
314
|
const requested = fixtures.requested;
|
package/GitHubError.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { headerNumber, retryAfterMillisFrom } from "./internal/headers.js";
|
|
2
|
+
import { scrubThrowable } from "./internal/scrub.js";
|
|
2
3
|
import { Schema } from "effect";
|
|
3
4
|
|
|
4
5
|
// src/GitHubError.ts
|
|
@@ -106,7 +107,7 @@ var GitHubError = class GitHubError extends Schema.TaggedError()("GitHubError",
|
|
|
106
107
|
* 422s ("Update is not a fast forward") are prose only.
|
|
107
108
|
*/
|
|
108
109
|
validation: Schema.optionalKey(Schema.Array(GitHubValidationEntry)),
|
|
109
|
-
/** The underlying throwable, when one exists. */
|
|
110
|
+
/** The underlying throwable, when one exists; an octokit throwable is kept as a credential-free copy. */
|
|
110
111
|
cause: Schema.optionalKey(Schema.Defect())
|
|
111
112
|
}) {
|
|
112
113
|
/** `"GitBranch.upsert failed (422): Reference already exists"`. */
|
|
@@ -173,9 +174,19 @@ var GitHubError = class GitHubError extends Schema.TaggedError()("GitHubError",
|
|
|
173
174
|
* epoch second, and turning it into a delay needs a "now" the caller controls.
|
|
174
175
|
* The client supplies `Clock.currentTimeMillis`, which is the `TestClock`
|
|
175
176
|
* under test.
|
|
177
|
+
*
|
|
178
|
+
* The kept `cause` is a credential-free copy of the throwable, not the
|
|
179
|
+
* throwable itself. octokit redacts only the `authorization` credential and
|
|
180
|
+
* two query values; the copy also drops the request body (a webhook secret,
|
|
181
|
+
* GraphQL variables), the per-request options and the response body beyond
|
|
182
|
+
* its top-level `message` and `documentation_url`, drops `set-cookie`, and
|
|
183
|
+
* reads `REDACTED` for every other credential-bearing header. It keeps the
|
|
184
|
+
* prototype (so `instanceof` still answers), `name`, `message`, `status`,
|
|
185
|
+
* the method, the redacted url and the remaining headers. A throwable with
|
|
186
|
+
* no `request`, `response` or `headers` field is kept as it is.
|
|
176
187
|
*/
|
|
177
188
|
static fromOctokit(operation, error, nowMillis) {
|
|
178
|
-
return fromFacts(operation, readThrowable(error), nowMillis, error);
|
|
189
|
+
return fromFacts(operation, readThrowable(error), nowMillis, scrubThrowable(error));
|
|
179
190
|
}
|
|
180
191
|
/**
|
|
181
192
|
* Classify a raw HTTP response: a status, its headers and its JSON body.
|
package/GitHubRelease.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { GitHubError } from "./GitHubError.js";
|
|
2
|
+
import { originOf, uploadChannelOf, uploadsBaseUrlFor } from "./internal/uploads.js";
|
|
2
3
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
3
4
|
import { GitHubClient } from "./GitHubClient.js";
|
|
4
5
|
import { Repo } from "./Repo.js";
|
|
@@ -19,7 +20,15 @@ var ReleaseInfo = class extends Schema.Class("ReleaseInfo")({
|
|
|
19
20
|
prerelease: Schema.Boolean,
|
|
20
21
|
/** The web URL. */
|
|
21
22
|
url: Schema.String,
|
|
22
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The templated upload endpoint GitHub hands back for assets.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* {@link GitHubReleaseShape.uploadAsset} never routes by this value: it is
|
|
28
|
+
* response data. It only cross-checks that its origin is the upload host
|
|
29
|
+
* derived from the client's configured base URL, and refuses the upload
|
|
30
|
+
* when it is not.
|
|
31
|
+
*/
|
|
23
32
|
uploadUrl: Schema.String
|
|
24
33
|
}) {};
|
|
25
34
|
/**
|
|
@@ -172,16 +181,26 @@ const make = (client) => {
|
|
|
172
181
|
release: release.id,
|
|
173
182
|
asset: asset.name
|
|
174
183
|
});
|
|
175
|
-
const
|
|
184
|
+
const channel = uploadChannelOf(client);
|
|
185
|
+
const uploadsBaseUrl = uploadsBaseUrlFor(channel?.baseUrl);
|
|
186
|
+
const expected = originOf(uploadsBaseUrl);
|
|
187
|
+
const offered = originOf(release.uploadUrl);
|
|
188
|
+
if (expected === void 0 || offered !== expected) return yield* Effect.fail(new GitHubError({
|
|
189
|
+
kind: "rejected",
|
|
190
|
+
operation: "GitHubRelease.uploadAsset",
|
|
191
|
+
reason: offered === void 0 ? `release ${release.id} has no absolute upload URL; refusing to upload` : `release ${release.id}'s upload URL is on ${offered}, not this client's upload host ${expected ?? uploadsBaseUrl}; refusing to send the token there`
|
|
192
|
+
}));
|
|
193
|
+
const route = asset.label === void 0 ? "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name}" : "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label}";
|
|
194
|
+
const params = {
|
|
176
195
|
owner,
|
|
177
196
|
repo,
|
|
178
197
|
release_id: release.id,
|
|
179
198
|
name: asset.name,
|
|
180
199
|
...asset.label === void 0 ? {} : { label: asset.label },
|
|
181
200
|
data: asset.data,
|
|
182
|
-
baseUrl: UPLOADS_BASE_URL,
|
|
183
201
|
headers: { "content-type": asset.contentType }
|
|
184
|
-
}
|
|
202
|
+
};
|
|
203
|
+
const raw = channel === void 0 ? yield* client.requestDecoded(route, params, AssetResponse) : yield* channel.upload(route, params, AssetResponse);
|
|
185
204
|
return assetOf(raw);
|
|
186
205
|
}),
|
|
187
206
|
listAssets: Effect.fn("GitHubRelease.listAssets")(function* (id, options) {
|
|
@@ -199,7 +218,5 @@ const make = (client) => {
|
|
|
199
218
|
})
|
|
200
219
|
};
|
|
201
220
|
};
|
|
202
|
-
/** Release assets do not go to the API host. */
|
|
203
|
-
const UPLOADS_BASE_URL = "https://uploads.github.com";
|
|
204
221
|
|
|
205
222
|
export { GitHubRelease, ReleaseAsset, ReleaseInfo };
|