@effected/github 0.1.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/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import { GitHubError, GitHubErrorKind } from "./GitHubError.js";
2
+ import { GitHubGraphQLError, GraphQLDocument, GraphQLErrorEntry } from "./GraphQL.js";
3
+ import { RateLimitSnapshot, RetryPolicy } from "./Resilience.js";
4
+ import { GitHubClient } from "./GitHubClient.js";
5
+ import { ArtifactMetadata, StorageRecordInput } from "./ArtifactMetadata.js";
6
+ import { InvalidRepoRefError, Repo, RepoRef } from "./Repo.js";
7
+ import { Attestation, AttestationListEntry, AttestationRecord } from "./Attestation.js";
8
+ import { Annotation, AnnotationLevel, CheckConclusion, CheckRun, CheckRunOutput, CheckRunRef } from "./CheckRun.js";
9
+ import { GitBranch } from "./GitBranch.js";
10
+ import { CommitRef, FileChange, FileContent, FileDeletion, FileMode, GitCommit } from "./GitCommit.js";
11
+ import { AppIdentity, BotIdentity, GitHubApp, GitHubAppError, Installation, InstallationToken } from "./GitHubApp.js";
12
+ import { CommitComparison, CommitFile, CommitSummary, FileStatus, GitHubCommit } from "./GitHubCommit.js";
13
+ import { GitHubContent } from "./GitHubContent.js";
14
+ import { GitHubIssue, IssueInfo, LinkedIssue } from "./GitHubIssue.js";
15
+ import { GitHubRelease, ReleaseAsset, ReleaseInfo } from "./GitHubRelease.js";
16
+ import { GitHubRepository } from "./GitHubRepository.js";
17
+ import { GitTag, SemverTag, TagRef, versionFromTag } from "./GitTag.js";
18
+ import { PageOptions } from "./Rest.js";
19
+ import { MergeMethod, PullRequest, PullRequestInfo } from "./PullRequest.js";
20
+ import { CommentMarker, CommentRecord, PullRequestComment } from "./PullRequestComment.js";
21
+ import { ExtraPermission, PermissionGap, PermissionLevel, PermissionResult, TokenPermissionError, TokenPermissions } from "./TokenPermissions.js";
22
+ import { WorkflowDispatch, WorkflowRunStatus } from "./WorkflowDispatch.js";
23
+
24
+ export { Annotation, AnnotationLevel, AppIdentity, ArtifactMetadata, Attestation, AttestationListEntry, AttestationRecord, BotIdentity, CheckConclusion, CheckRun, CheckRunOutput, CheckRunRef, CommentMarker, CommentRecord, CommitComparison, CommitFile, CommitRef, CommitSummary, ExtraPermission, FileChange, FileContent, FileDeletion, FileMode, FileStatus, GitBranch, GitCommit, GitHubApp, GitHubAppError, GitHubClient, GitHubCommit, GitHubContent, GitHubError, GitHubErrorKind, GitHubGraphQLError, GitHubIssue, GitHubRelease, GitHubRepository, GitTag, GraphQLDocument, GraphQLErrorEntry, Installation, InstallationToken, InvalidRepoRefError, IssueInfo, LinkedIssue, MergeMethod, PageOptions, PermissionGap, PermissionLevel, PermissionResult, PullRequest, PullRequestComment, PullRequestInfo, RateLimitSnapshot, ReleaseAsset, ReleaseInfo, Repo, RepoRef, RetryPolicy, SemverTag, StorageRecordInput, TagRef, TokenPermissionError, TokenPermissions, WorkflowDispatch, WorkflowRunStatus, versionFromTag };
@@ -0,0 +1,52 @@
1
+ //#region src/internal/headers.ts
2
+ /**
3
+ * Reading GitHub's response headers.
4
+ *
5
+ * @remarks
6
+ * octokit hands headers back as a plain object whose values may be `string`,
7
+ * `number` or absent depending on the fetch implementation, so every read here
8
+ * is defensive about both. Header names are already lowercased by octokit.
9
+ *
10
+ * @internal
11
+ */
12
+ /** A header's value as a string, when it is present and non-empty. */
13
+ const headerString = (headers, name) => {
14
+ const value = headers?.[name];
15
+ if (typeof value === "string") return value.length > 0 ? value : void 0;
16
+ if (typeof value === "number") return String(value);
17
+ };
18
+ /** A header's value as a finite integer, when it parses as one. */
19
+ const headerNumber = (headers, name) => {
20
+ const raw = headerString(headers, name);
21
+ if (raw === void 0) return void 0;
22
+ const parsed = Number(raw);
23
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : void 0;
24
+ };
25
+ /**
26
+ * How long GitHub asked us to wait, in milliseconds, or `undefined` when it
27
+ * did not ask.
28
+ *
29
+ * @remarks
30
+ * Two mechanisms, checked in GitHub's own order of specificity:
31
+ *
32
+ * 1. `retry-after` — whole seconds, sent for secondary rate limits and abuse
33
+ * detection. Authoritative when present.
34
+ * 2. `x-ratelimit-remaining: 0` plus `x-ratelimit-reset` — the primary rate
35
+ * limit, where the reset is an absolute epoch **second**. Only meaningful
36
+ * when the budget is actually exhausted: every successful response carries a
37
+ * reset header too, and treating that as a delay would make every call look
38
+ * rate-limited.
39
+ *
40
+ * `nowMillis` is a parameter rather than a `Date.now()` read so this stays pure
41
+ * and so a test can pin it.
42
+ */
43
+ const retryAfterMillisFrom = (headers, nowMillis) => {
44
+ const retryAfterSeconds = headerNumber(headers, "retry-after");
45
+ if (retryAfterSeconds !== void 0 && retryAfterSeconds >= 0) return retryAfterSeconds * 1e3;
46
+ const remaining = headerNumber(headers, "x-ratelimit-remaining");
47
+ const reset = headerNumber(headers, "x-ratelimit-reset");
48
+ if (remaining !== void 0 && remaining <= 0 && reset !== void 0) return Math.max(0, reset * 1e3 - nowMillis);
49
+ };
50
+
51
+ //#endregion
52
+ export { headerNumber, headerString, retryAfterMillisFrom };
@@ -0,0 +1,115 @@
1
+ import { GitHubError, readRateLimitHeaders } from "../GitHubError.js";
2
+ import { GitHubGraphQLError } from "../GraphQL.js";
3
+ import { RateLimitSnapshot } from "../Resilience.js";
4
+ import { Clock, Effect, Option, Redacted, Ref } from "effect";
5
+ import { Octokit } from "@octokit/core";
6
+ import { composePaginateRest } from "@octokit/plugin-paginate-rest";
7
+
8
+ //#region src/internal/octokit.ts
9
+ const SILENT_LOG = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {}
14
+ };
15
+ /**
16
+ * Build the octokit instance.
17
+ *
18
+ * @remarks
19
+ * `auth` takes the raw token string. `@octokit/auth-token` inspects it and
20
+ * emits `bearer` for a three-segment JWT and `token` otherwise — exactly the
21
+ * distinction an app JWT and an installation token need, so the App path
22
+ * requires no separate auth strategy.
23
+ *
24
+ * `log` is silenced. The package this replaces installed
25
+ * `plugin-request-log` and then rerouted every line of it into an Actions
26
+ * `::debug::` workflow command, which is how GitHub Actions knowledge ended up
27
+ * inside a GitHub API client. Here retries log through `Effect.logDebug`, and an
28
+ * application maps Effect's logs to whatever its runtime wants.
29
+ */
30
+ const makeOctokit = (options) => new Octokit({
31
+ ...options.token !== void 0 ? { auth: Redacted.value(options.token) } : {},
32
+ ...options.baseUrl !== void 0 ? { baseUrl: options.baseUrl } : {},
33
+ ...options.userAgent !== void 0 ? { userAgent: options.userAgent } : {},
34
+ ...options.fetch !== void 0 ? { request: { fetch: options.fetch } } : {},
35
+ log: SILENT_LOG
36
+ });
37
+ /**
38
+ * Build a transport over a freshly constructed octokit instance.
39
+ *
40
+ * @remarks
41
+ * The rate-limit cell lives here, in the closure of the layer that writes it.
42
+ * The package this replaces made it an optional shared `Ref` **service** that
43
+ * the writer and the reader both resolved through `Effect.serviceOption`, so an
44
+ * application that forgot to provide it got two private cells, a limiter that
45
+ * never saw the client's headers, and no error, warning or type signal saying
46
+ * so.
47
+ */
48
+ const makeTransport = (options) => Effect.gen(function* () {
49
+ const octokit = makeOctokit(options);
50
+ const snapshot = yield* Ref.make(Option.none());
51
+ const policy = options.retry;
52
+ const record = (headers) => {
53
+ const parsed = readRateLimitHeaders(headers);
54
+ return parsed === void 0 ? Effect.void : Ref.set(snapshot, Option.some(RateLimitSnapshot.make(parsed)));
55
+ };
56
+ /**
57
+ * Run one promise-producing call, classifying whatever it throws.
58
+ *
59
+ * @remarks
60
+ * The `AbortSignal` `Effect.tryPromise` supplies is threaded into octokit's
61
+ * `request.signal`, so interrupting the fiber aborts the in-flight HTTP
62
+ * request rather than leaving it running unobserved.
63
+ */
64
+ const attempt = (call, classify) => Effect.tryPromise({
65
+ try: call,
66
+ catch: (error) => error
67
+ }).pipe(Effect.catch((error) => Effect.gen(function* () {
68
+ const now = yield* Clock.currentTimeMillis;
69
+ yield* record(readThrownHeaders(error));
70
+ return yield* Effect.fail(classify(error, now));
71
+ })));
72
+ const withRetry = (operation, effect) => effect.pipe(Effect.tapError((error) => policy.retries(error) ? Effect.logDebug("github.retry").pipe(Effect.annotateLogs({ operation })) : Effect.void), Effect.retry(policy.schedule()));
73
+ const request = (operation, route, params) => withRetry(operation, attempt((signal) => octokit.request(route, withSignal(params, signal)), (error, now) => GitHubError.fromOctokit(operation, error, now)).pipe(Effect.tap((response) => record(response.headers))));
74
+ const pageSource = (operation, route, params) => {
75
+ const iterator = composePaginateRest.iterator(octokit, route, params)[Symbol.asyncIterator]();
76
+ let finished = false;
77
+ return { next: Effect.suspend(() => finished ? Effect.succeed(Option.none()) : withRetry(operation, attempt(() => iterator.next(), (error, now) => GitHubError.fromOctokit(operation, error, now)).pipe(Effect.flatMap((result) => {
78
+ if (result.done === true || result.value === void 0) {
79
+ finished = true;
80
+ return Effect.succeed(Option.none());
81
+ }
82
+ const response = result.value;
83
+ return record(response.headers).pipe(Effect.as(Option.some(response.data)));
84
+ })))) };
85
+ };
86
+ const graphql = (operation, document, variables) => withRetry(operation, attempt(() => octokit.graphql(document, variables), (error, now) => GitHubGraphQLError.fromThrowable(operation, error, now)));
87
+ return {
88
+ request,
89
+ pageSource,
90
+ graphql,
91
+ rateLimit: Ref.get(snapshot)
92
+ };
93
+ });
94
+ /** Merge our abort signal into octokit's per-request options without clobbering them. */
95
+ const withSignal = (params, signal) => {
96
+ const existing = typeof params.request === "object" && params.request !== null ? params.request : {};
97
+ return {
98
+ ...params,
99
+ request: {
100
+ ...existing,
101
+ signal
102
+ }
103
+ };
104
+ };
105
+ /** Response headers off a throwable, when it carried any. */
106
+ const readThrownHeaders = (error) => {
107
+ if (typeof error !== "object" || error === null) return void 0;
108
+ const response = error.response;
109
+ if (typeof response !== "object" || response === null) return void 0;
110
+ const headers = response.headers;
111
+ return typeof headers === "object" && headers !== null ? headers : void 0;
112
+ };
113
+
114
+ //#endregion
115
+ export { makeTransport };
@@ -0,0 +1,54 @@
1
+ import { Effect, Option, Stream } from "effect";
2
+
3
+ //#region src/internal/paginate.ts
4
+ /**
5
+ * Walk a {@link PageSource} into a stream of its items.
6
+ *
7
+ * @remarks
8
+ * **This is the only pagination implementation in the package**, and it is why
9
+ * the fixture double cannot drift from the live client: both build a
10
+ * `PageSource` and hand it here, so `maxPages` and item flattening have exactly
11
+ * one behavior. The package this replaces had a live loop that honored
12
+ * `maxPages` and a test double whose equivalent parameters were named
13
+ * `_options` and ignored — which made every truncation path structurally
14
+ * untestable.
15
+ *
16
+ * `maxPages` bounds **requests, not items**: the walk stops issuing them rather
17
+ * than fetching everything and slicing.
18
+ *
19
+ * @internal
20
+ */
21
+ const paginate = (openSource, maxPages) => Stream.suspend(() => {
22
+ const source = openSource();
23
+ return Stream.paginate(0, (pagesTaken) => Effect.map(source.next, (page) => {
24
+ if (Option.isNone(page)) return [[], Option.none()];
25
+ const taken = pagesTaken + 1;
26
+ const exhausted = maxPages !== void 0 && taken >= maxPages;
27
+ return [page.value, exhausted ? Option.none() : Option.some(taken)];
28
+ }));
29
+ });
30
+ /**
31
+ * A {@link PageSource} over an already-collected array, sliced into pages.
32
+ *
33
+ * @remarks
34
+ * What the fixture double records. Slicing here rather than inside the double
35
+ * keeps "what is a page" in one place: a recorded fixture of 250 items with
36
+ * `perPage: 100` pages exactly as GitHub would, so a test can assert a caller's
37
+ * `maxPages` truncation against real page boundaries.
38
+ *
39
+ * @internal
40
+ */
41
+ const fromArray = (items, perPage) => {
42
+ let offset = 0;
43
+ let finished = false;
44
+ return { next: Effect.sync(() => {
45
+ if (finished) return Option.none();
46
+ const page = items.slice(offset, offset + perPage);
47
+ offset += perPage;
48
+ if (page.length < perPage) finished = true;
49
+ return page.length === 0 ? Option.none() : Option.some(page);
50
+ }) };
51
+ };
52
+
53
+ //#endregion
54
+ export { fromArray, paginate };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@effected/github",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Typed GitHub REST and GraphQL services over the octokit core request surface, with app auth and resource helpers.",
6
+ "keywords": [
7
+ "github",
8
+ "octokit",
9
+ "rest",
10
+ "graphql",
11
+ "app-auth",
12
+ "effect",
13
+ "effected"
14
+ ],
15
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/github#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/spencerbeggs/effected/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/spencerbeggs/effected.git",
22
+ "directory": "packages/github"
23
+ },
24
+ "license": "MIT",
25
+ "author": {
26
+ "name": "C. Spencer Beggs",
27
+ "email": "spencer@beggs.codes",
28
+ "url": "https://spencerbeg.gs"
29
+ },
30
+ "sideEffects": false,
31
+ "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./index.d.ts",
35
+ "import": "./index.js",
36
+ "default": "./index.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "dependencies": {
41
+ "@effected/semver": "~0.2.1",
42
+ "@octokit/core": "^7.0.6",
43
+ "@octokit/plugin-paginate-rest": "^14.0.0",
44
+ "@octokit/types": "^16.0.0",
45
+ "universal-github-app-jwt": "^2.2.2"
46
+ },
47
+ "peerDependencies": {
48
+ "effect": "4.0.0-beta.101"
49
+ },
50
+ "engines": {
51
+ "node": ">=24.11.0"
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.58.12"
9
+ }
10
+ ]
11
+ }