@shivaedev/github 0.9.0 → 0.10.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +95 -5
  3. package/dist/GitHub.d.ts +262 -3
  4. package/dist/GitHub.d.ts.map +1 -1
  5. package/dist/GitHub.js +15 -5
  6. package/dist/GitHub.js.map +1 -1
  7. package/dist/adapters/resolveApiUrl.d.ts +2 -0
  8. package/dist/adapters/resolveApiUrl.d.ts.map +1 -0
  9. package/dist/adapters/resolveApiUrl.js +6 -0
  10. package/dist/adapters/resolveApiUrl.js.map +1 -0
  11. package/dist/adapters/zipEntries.d.ts +17 -0
  12. package/dist/adapters/zipEntries.d.ts.map +1 -0
  13. package/dist/adapters/zipEntries.js +57 -0
  14. package/dist/adapters/zipEntries.js.map +1 -0
  15. package/dist/appTokens.d.ts.map +1 -1
  16. package/dist/appTokens.js +2 -1
  17. package/dist/appTokens.js.map +1 -1
  18. package/dist/artifactErrors.d.ts +34 -0
  19. package/dist/artifactErrors.d.ts.map +1 -0
  20. package/dist/artifactErrors.js +27 -0
  21. package/dist/artifactErrors.js.map +1 -0
  22. package/dist/artifactExtraction.d.ts +12 -0
  23. package/dist/artifactExtraction.d.ts.map +1 -0
  24. package/dist/artifactExtraction.js +40 -0
  25. package/dist/artifactExtraction.js.map +1 -0
  26. package/dist/artifactPaths.d.ts +4 -0
  27. package/dist/artifactPaths.d.ts.map +1 -0
  28. package/dist/artifactPaths.js +35 -0
  29. package/dist/artifactPaths.js.map +1 -0
  30. package/dist/artifacts.d.ts +13 -8
  31. package/dist/artifacts.d.ts.map +1 -1
  32. package/dist/artifacts.js +15 -8
  33. package/dist/artifacts.js.map +1 -1
  34. package/dist/checkRuns.d.ts.map +1 -1
  35. package/dist/checkRuns.js +5 -3
  36. package/dist/checkRuns.js.map +1 -1
  37. package/dist/gitSchemas.d.ts +195 -0
  38. package/dist/gitSchemas.d.ts.map +1 -0
  39. package/dist/gitSchemas.js +45 -0
  40. package/dist/gitSchemas.js.map +1 -0
  41. package/dist/installationClients.d.ts +278 -2
  42. package/dist/installationClients.d.ts.map +1 -1
  43. package/dist/installationClients.js +14 -5
  44. package/dist/installationClients.js.map +1 -1
  45. package/dist/pagedLists.d.ts +15 -0
  46. package/dist/pagedLists.d.ts.map +1 -0
  47. package/dist/pagedLists.js +16 -0
  48. package/dist/pagedLists.js.map +1 -0
  49. package/dist/repositoryReads.d.ts +261 -0
  50. package/dist/repositoryReads.d.ts.map +1 -0
  51. package/dist/repositoryReads.js +30 -0
  52. package/dist/repositoryReads.js.map +1 -0
  53. package/dist/repositorySchemas.d.ts +73 -0
  54. package/dist/repositorySchemas.d.ts.map +1 -0
  55. package/dist/repositorySchemas.js +38 -0
  56. package/dist/repositorySchemas.js.map +1 -0
  57. package/dist/representations.d.ts.map +1 -1
  58. package/dist/representations.js +3 -2
  59. package/dist/representations.js.map +1 -1
  60. package/dist/viewerRepositoryAccess.d.ts +46 -0
  61. package/dist/viewerRepositoryAccess.d.ts.map +1 -0
  62. package/dist/viewerRepositoryAccess.js +54 -0
  63. package/dist/viewerRepositoryAccess.js.map +1 -0
  64. package/package.json +11 -4
  65. package/src/GitHub.ts +17 -10
  66. package/src/adapters/resolveApiUrl.ts +5 -0
  67. package/src/adapters/zipEntries.ts +69 -0
  68. package/src/appTokens.ts +2 -1
  69. package/src/artifactErrors.ts +26 -0
  70. package/src/artifactExtraction.ts +62 -0
  71. package/src/artifactPaths.ts +36 -0
  72. package/src/artifacts.ts +36 -39
  73. package/src/checkRuns.ts +5 -5
  74. package/src/gitSchemas.ts +52 -0
  75. package/src/installationClients.ts +18 -12
  76. package/src/pagedLists.ts +40 -0
  77. package/src/repositoryReads.ts +61 -0
  78. package/src/repositorySchemas.ts +43 -0
  79. package/src/representations.ts +3 -2
  80. package/src/viewerRepositoryAccess.ts +66 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/github",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Typed GitHub REST requests, conditional reads and resource budgets for Effect applications",
5
5
  "keywords": [
6
6
  "effect"
@@ -51,17 +51,24 @@
51
51
  "@effect/platform-node": "4.0.6",
52
52
  "@effect/platform-node-shared": "4.0.6",
53
53
  "@effect/vitest": "4.0.6",
54
- "@shivaedev/quality": "0.55.0",
55
- "@shivaedev/test": "0.12.1",
56
- "@shivaedev/test-story": "0.33.0",
54
+ "@shivaedev/quality": "0.56.0",
55
+ "@shivaedev/test": "0.13.0",
56
+ "@shivaedev/test-story": "0.34.0",
57
57
  "@types/node": "24.19.2",
58
+ "@zip.js/zip.js": "2.24.0",
58
59
  "effect": "4.0.6",
59
60
  "typescript": "7.0.2",
60
61
  "vitest": "5.0.5"
61
62
  },
62
63
  "peerDependencies": {
64
+ "@zip.js/zip.js": "2.24.0",
63
65
  "effect": "4.0.6"
64
66
  },
67
+ "peerDependenciesMeta": {
68
+ "@zip.js/zip.js": {
69
+ "optional": true
70
+ }
71
+ },
65
72
  "engines": {
66
73
  "node": ">=24"
67
74
  },
package/src/GitHub.ts CHANGED
@@ -5,7 +5,9 @@ import { artifacts } from "#artifacts.ts";
5
5
  import { checkRuns } from "#checkRuns.ts";
6
6
  import { GitHubConfig } from "#GitHubConfig.ts";
7
7
  import { GitHubToken } from "#GitHubToken.ts";
8
+ import { decodedPages, type ListOptions } from "#pagedLists.ts";
8
9
  import { rateLimits } from "#rateLimits.ts";
10
+ import { repositoryReads } from "#repositoryReads.ts";
9
11
  import { representations } from "#representations.ts";
10
12
  import { requests } from "#requests.ts";
11
13
  import { stickyComments } from "#stickyComments.ts";
@@ -21,7 +23,7 @@ export const GitHub = defineService({
21
23
  const refresh = yield* TokenRefresh;
22
24
  return {
23
25
  apiUrl: config.apiUrl,
24
- archive: artifacts(config.apiUrl, client, token, rates, refresh),
26
+ archive: yield* artifacts(config.apiUrl, rates),
25
27
  rates,
26
28
  request: requests(client, token, rates, refresh),
27
29
  responseByteLimit: config.responseByteLimit,
@@ -35,6 +37,7 @@ export const GitHub = defineService({
35
37
  ...archive,
36
38
  ...stickyComments(client),
37
39
  ...checkRuns(client),
40
+ ...repositoryReads(client),
38
41
  observations: () => Effect.sync(() => [...rates.observations.values()]),
39
42
  resource: (path: string) => Effect.sync(() => rates.resource(new URL(path, apiUrl).pathname)),
40
43
  text: (path: string, accept: string, limit: number) =>
@@ -48,19 +51,23 @@ export function read<TSchema extends Schema.Constraint>(path: string, schema: TS
48
51
  return Effect.flatMap(GitHub, (github) => Effect.flatMap(github.read(path, responseByteLimit), (response) => response.decode(schema)));
49
52
  }
50
53
 
51
- export function list<TSchema extends Schema.Constraint>(path: string, schema: TSchema, responseByteLimit?: number) {
52
- return Stream.unwrap(Effect.flatMap(GitHub, (github) => github.list(path, undefined, responseByteLimit))).pipe(
53
- Stream.mapEffect((response) => response.decode(Schema.Array(schema))),
54
- Stream.flatMap(Stream.fromIterable),
55
- );
54
+ export function listPages<TSchema extends Schema.Constraint>(path: string, schema: TSchema, options?: ListOptions | number) {
55
+ return Stream.unwrap(Effect.map(GitHub, (github) => decodedPages(github, path, schema, options)));
56
+ }
57
+
58
+ export function list<TSchema extends Schema.Constraint>(path: string, schema: TSchema, options?: ListOptions | number) {
59
+ return listPages(path, schema, options).pipe(Stream.flatMap((page) => Stream.fromIterable(page.items)));
56
60
  }
57
61
 
58
- export function listBounded<TSchema extends Schema.Constraint>(path: string, schema: TSchema, pageCap: number, responseByteLimit?: number) {
59
- return Stream.unwrap(Effect.flatMap(GitHub, (github) => github.list(path, undefined, responseByteLimit))).pipe(
62
+ export function listBounded<TSchema extends Schema.Constraint>(path: string, schema: TSchema, pageCap: number, options?: ListOptions | number) {
63
+ return listPages(path, schema, options).pipe(
60
64
  Stream.take(pageCap),
61
- Stream.mapEffect((page) => Effect.map(page.decode(Schema.Array(schema)), (items) => ({ items, truncated: page.hasNext }))),
62
65
  Stream.runCollect,
63
- Effect.map((pages) => ({ items: pages.flatMap((page) => page.items), truncated: pages.at(-1)?.truncated ?? false })),
66
+ Effect.map((pages) => ({
67
+ items: pages.flatMap((page) => page.items),
68
+ skipped: pages.reduce((count, page) => count + page.skipped, 0),
69
+ truncated: pages.at(-1)?.hasNext ?? false,
70
+ })),
64
71
  );
65
72
  }
66
73
 
@@ -0,0 +1,5 @@
1
+ export function resolveApiUrl(apiUrl: string, path: string) {
2
+ const base = new URL(apiUrl);
3
+ base.pathname = `${base.pathname.replace(/\/$/u, "")}/`;
4
+ return new URL(path.replace(/^\/(?!\/)/u, ""), base).href;
5
+ }
@@ -0,0 +1,69 @@
1
+ import type { Entry } from "@zip.js/zip.js";
2
+ import { Effect, Stream } from "effect";
3
+ import { GitHubArtifactCompressionRatio, GitHubArtifactExtractedTooLarge, GitHubArtifactExtractionError } from "#artifactErrors.ts";
4
+
5
+ interface ReadLimits {
6
+ readonly compressionRatioLimit: number;
7
+ readonly remaining: number;
8
+ readonly totalSizeLimit: number;
9
+ }
10
+
11
+ function invalidArchive() {
12
+ return new GitHubArtifactExtractionError({ reason: "invalid-archive" });
13
+ }
14
+
15
+ function readEntry(entry: Entry, limits: ReadLimits) {
16
+ return Effect.tryPromise({
17
+ catch: (error) =>
18
+ error instanceof GitHubArtifactExtractedTooLarge || error instanceof GitHubArtifactCompressionRatio ? error : invalidArchive(),
19
+ try: async (signal) => {
20
+ let size = 0;
21
+ const chunks: Uint8Array[] = [];
22
+ const output = new WritableStream<Uint8Array>({
23
+ write(chunk) {
24
+ size += chunk.byteLength;
25
+ if (size > limits.remaining) {
26
+ throw new GitHubArtifactExtractedTooLarge({ limit: limits.totalSizeLimit });
27
+ }
28
+ if (size > entry.compressedSize * limits.compressionRatioLimit) {
29
+ throw new GitHubArtifactCompressionRatio({ limit: limits.compressionRatioLimit, path: entry.filename });
30
+ }
31
+ chunks.push(chunk);
32
+ },
33
+ });
34
+ if (!entry.directory) {
35
+ await entry.getData(output, { signal });
36
+ }
37
+ const result = new Uint8Array(size);
38
+ let offset = 0;
39
+ for (const chunk of chunks) {
40
+ result.set(chunk, offset);
41
+ offset += chunk.byteLength;
42
+ }
43
+ return result;
44
+ },
45
+ });
46
+ }
47
+
48
+ export const zipEntries = Effect.fn("GitHub.zipEntries")(function* (bytes: Uint8Array) {
49
+ const { ZipReader, Uint8ArrayReader } = yield* Effect.tryPromise({
50
+ catch: () => new GitHubArtifactExtractionError({ reason: "reader-unavailable" }),
51
+ try: () => import("@zip.js/zip.js"),
52
+ });
53
+ const reader = new ZipReader(new Uint8ArrayReader(bytes), {
54
+ checkCrc32: true,
55
+ // Core validates paths so refusals retain their dedicated typed error.
56
+ filenameValidation: "tolerant",
57
+ useWebWorkers: false,
58
+ });
59
+ return Stream.fromAsyncIterable(reader.getEntriesGenerator(), invalidArchive).pipe(
60
+ Stream.map((entry) => ({
61
+ compressedSize: entry.compressedSize,
62
+ directory: entry.directory,
63
+ path: entry.filename,
64
+ read: (limits: ReadLimits) => readEntry(entry, limits),
65
+ symlink: entry.symlink,
66
+ uncompressedSize: entry.uncompressedSize,
67
+ })),
68
+ );
69
+ });
package/src/appTokens.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Cache, Clock, Context, Effect, Exit, Layer, Option, type Redacted, Schema } from "effect";
2
2
  import { HttpClient, HttpClientRequest } from "effect/http";
3
3
  import { readBoundedJson } from "@shivaedev/http/body.ts";
4
+ import { resolveApiUrl } from "#adapters/resolveApiUrl.ts";
4
5
  import { appJwt } from "#appJwt.ts";
5
6
  import { authenticatedRequest } from "#authenticatedRequest.ts";
6
7
  import { GitHubAppError } from "#errors.ts";
@@ -65,7 +66,7 @@ function appRequests(client: HttpClient.HttpClient, apiUrl: string) {
65
66
  schema: TSchema,
66
67
  body?: unknown,
67
68
  ) {
68
- let input = HttpClientRequest.make(method)(new URL(path, apiUrl).href, {
69
+ let input = HttpClientRequest.make(method)(resolveApiUrl(apiUrl, path), {
69
70
  headers: { Accept: "application/vnd.github+json" },
70
71
  });
71
72
  if (body !== undefined) {
@@ -0,0 +1,26 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class GitHubArtifactPathTraversal extends Schema.TaggedError<GitHubArtifactPathTraversal>()("GitHubArtifactPathTraversal", {
4
+ path: Schema.String,
5
+ }) {}
6
+
7
+ export class GitHubArtifactSymlink extends Schema.TaggedError<GitHubArtifactSymlink>()("GitHubArtifactSymlink", {
8
+ path: Schema.String,
9
+ }) {}
10
+
11
+ export class GitHubArtifactDuplicateEntry extends Schema.TaggedError<GitHubArtifactDuplicateEntry>()("GitHubArtifactDuplicateEntry", {
12
+ path: Schema.String,
13
+ }) {}
14
+
15
+ export class GitHubArtifactCompressionRatio extends Schema.TaggedError<GitHubArtifactCompressionRatio>()("GitHubArtifactCompressionRatio", {
16
+ limit: Schema.Number,
17
+ path: Schema.String,
18
+ }) {}
19
+
20
+ export class GitHubArtifactExtractedTooLarge extends Schema.TaggedError<GitHubArtifactExtractedTooLarge>()("GitHubArtifactExtractedTooLarge", {
21
+ limit: Schema.Number,
22
+ }) {}
23
+
24
+ export class GitHubArtifactExtractionError extends Schema.TaggedError<GitHubArtifactExtractionError>()("GitHubArtifactExtractionError", {
25
+ reason: Schema.Literals(["reader-unavailable", "invalid-archive", "invalid-limits"]),
26
+ }) {}
@@ -0,0 +1,62 @@
1
+ import { Effect, Stream } from "effect";
2
+ import { zipEntries } from "#adapters/zipEntries.ts";
3
+ import {
4
+ GitHubArtifactCompressionRatio,
5
+ GitHubArtifactExtractedTooLarge,
6
+ GitHubArtifactExtractionError,
7
+ GitHubArtifactSymlink,
8
+ } from "#artifactErrors.ts";
9
+ import { artifactPaths } from "#artifactPaths.ts";
10
+
11
+ export interface ArtifactExtractionOptions {
12
+ readonly compressionRatioLimit?: number;
13
+ readonly totalSizeLimit?: number;
14
+ }
15
+
16
+ export interface ArtifactFile {
17
+ readonly bytes: Uint8Array;
18
+ readonly path: string;
19
+ }
20
+
21
+ export const extractArtifactArchive = Effect.fn("GitHub.extractArtifactArchive")(function* (bytes: Uint8Array, options: ArtifactExtractionOptions) {
22
+ const totalSizeLimit = options.totalSizeLimit ?? 100 * 1024 * 1024;
23
+ const compressionRatioLimit = options.compressionRatioLimit ?? 100;
24
+ if (!Number.isSafeInteger(totalSizeLimit) || totalSizeLimit < 0 || !Number.isFinite(compressionRatioLimit) || compressionRatioLimit <= 0) {
25
+ return yield* new GitHubArtifactExtractionError({ reason: "invalid-limits" });
26
+ }
27
+ const entries = yield* zipEntries(bytes);
28
+ const pathOf = artifactPaths();
29
+ const files: ArtifactFile[] = [];
30
+ let total = 0;
31
+ yield* entries.pipe(
32
+ Stream.runForEach((entry) =>
33
+ Effect.gen(function* () {
34
+ const path = yield* pathOf(entry.path, entry.directory);
35
+ yield* validateEntry(entry, total, { compressionRatioLimit, totalSizeLimit });
36
+ const content = yield* entry.read({ compressionRatioLimit, remaining: totalSizeLimit - total, totalSizeLimit });
37
+ total += content.byteLength;
38
+ if (!entry.directory) {
39
+ files.push({ bytes: content, path });
40
+ }
41
+ }),
42
+ ),
43
+ );
44
+ return files;
45
+ });
46
+
47
+ type ZipEntry = Stream.Success<Effect.Success<ReturnType<typeof zipEntries>>>;
48
+
49
+ const validateEntry = Effect.fnUntraced(function* (entry: ZipEntry, total: number, options: Required<ArtifactExtractionOptions>) {
50
+ if (entry.symlink) {
51
+ return yield* new GitHubArtifactSymlink({ path: entry.path });
52
+ }
53
+ if (entry.directory && entry.uncompressedSize !== 0) {
54
+ return yield* new GitHubArtifactExtractionError({ reason: "invalid-archive" });
55
+ }
56
+ if (total + entry.uncompressedSize > options.totalSizeLimit) {
57
+ return yield* new GitHubArtifactExtractedTooLarge({ limit: options.totalSizeLimit });
58
+ }
59
+ if (entry.uncompressedSize > entry.compressedSize * options.compressionRatioLimit) {
60
+ return yield* new GitHubArtifactCompressionRatio({ limit: options.compressionRatioLimit, path: entry.path });
61
+ }
62
+ });
@@ -0,0 +1,36 @@
1
+ import { Effect } from "effect";
2
+ import { GitHubArtifactDuplicateEntry, GitHubArtifactPathTraversal } from "#artifactErrors.ts";
3
+
4
+ const safeName = Effect.fnUntraced(function* (path: string, directory: boolean) {
5
+ const name = directory && path.endsWith("/") ? path.slice(0, -1) : path;
6
+ const segments = name.split("/");
7
+ if (/[\\:\0]/u.test(name) || segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
8
+ return yield* new GitHubArtifactPathTraversal({ path });
9
+ }
10
+ return { name, segments };
11
+ });
12
+
13
+ export function artifactPaths() {
14
+ const entries = new Set<string>();
15
+ const files = new Set<string>();
16
+ const directories = new Set<string>();
17
+ const registerParents = Effect.fnUntraced(function* (path: string, segments: readonly string[]) {
18
+ for (let index = 1; index < segments.length; index += 1) {
19
+ const parent = segments.slice(0, index).join("/");
20
+ if (files.has(parent)) {
21
+ return yield* new GitHubArtifactDuplicateEntry({ path });
22
+ }
23
+ directories.add(parent);
24
+ }
25
+ });
26
+ return Effect.fnUntraced(function* (path: string, directory: boolean) {
27
+ const { name, segments } = yield* safeName(path, directory);
28
+ if (entries.has(name) || (!directory && directories.has(name))) {
29
+ return yield* new GitHubArtifactDuplicateEntry({ path });
30
+ }
31
+ yield* registerParents(path, segments);
32
+ entries.add(name);
33
+ (directory ? directories : files).add(name);
34
+ return name;
35
+ });
36
+ }
package/src/artifacts.ts CHANGED
@@ -4,13 +4,15 @@ import * as HttpStatus from "effect/http/HttpStatus";
4
4
  import { readBoundedBody } from "@shivaedev/http/body.ts";
5
5
  import type { RemoteStatusError } from "@shivaedev/http/errors.ts";
6
6
  import { filterStatus } from "@shivaedev/http/filterStatus.ts";
7
- import { authenticatedRequest } from "./authenticatedRequest.ts";
8
- import { GitHubArtifactError, GitHubArtifactTooLarge, GitHubTransportError } from "./errors.ts";
9
- import type { GitHubToken } from "./GitHubToken.ts";
10
- import type { RateLimits } from "./rateLimits.ts";
11
- import type { TokenRefresh } from "./TokenRefresh.ts";
7
+ import { resolveApiUrl } from "#adapters/resolveApiUrl.ts";
8
+ import { type ArtifactExtractionOptions, extractArtifactArchive } from "#artifactExtraction.ts";
9
+ import { authenticatedRequest } from "#authenticatedRequest.ts";
10
+ import { GitHubArtifactError, GitHubArtifactTooLarge, GitHubTransportError } from "#errors.ts";
11
+ import { GitHubToken } from "#GitHubToken.ts";
12
+ import type { RateLimits } from "#rateLimits.ts";
13
+ import { TokenRefresh } from "#TokenRefresh.ts";
12
14
 
13
- export interface ArtifactArchiveOptions {
15
+ export interface ArtifactArchiveOptions extends ArtifactExtractionOptions {
14
16
  readonly artifactId: number;
15
17
  readonly byteLimit: number;
16
18
  readonly owner: string;
@@ -23,41 +25,36 @@ function downloadFailure(path: string, cause: HttpClientError.HttpClientError |
23
25
  : new GitHubArtifactError({ stage: "download", status: cause.status });
24
26
  }
25
27
 
26
- export function artifacts(
27
- apiUrl: string,
28
- client: HttpClient.HttpClient,
29
- token: GitHubToken["Service"],
30
- rates: RateLimits,
31
- refresh: typeof TokenRefresh.Service = undefined,
32
- ) {
28
+ export const artifacts = Effect.fn("GitHub.artifacts")(function* (apiUrl: string, rates: RateLimits) {
29
+ const client = yield* HttpClient.HttpClient;
30
+ const token = yield* GitHubToken;
31
+ const refresh = yield* TokenRefresh;
33
32
  const send = authenticatedRequest(HttpClient.withScope(client), token, rates, refresh);
34
33
  const download = filterStatus(HttpClient.withScope(client));
35
34
  return {
36
- downloadArtifactArchive: (options: ArtifactArchiveOptions) =>
37
- Effect.scoped(
38
- Effect.gen(function* () {
39
- const path = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/actions/artifacts/${options.artifactId}/zip`;
40
- const redirect = yield* send(HttpClientRequest.get(new URL(path, apiUrl).href, { accept: "application/vnd.github+json" }), "manual").pipe(
41
- Effect.catchTags({
42
- RemoteGone: (error) => Effect.fail(new GitHubArtifactError({ stage: "redirect", status: error.status }, { cause: error })),
43
- RemoteNotFound: (error) => Effect.fail(new GitHubArtifactError({ stage: "redirect", status: error.status }, { cause: error })),
44
- }),
45
- );
46
- const location = redirect.headers.location;
47
- if (redirect.status !== HttpStatus.fromLiteral("Found") || location === undefined) {
48
- return yield* new GitHubArtifactError({ stage: "redirect", status: redirect.status });
49
- }
50
- const response = yield* download.get(location).pipe(
51
- Effect.provideService(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "manual" }),
52
- Effect.mapError((cause) => downloadFailure(path, cause)),
53
- );
54
- return yield* readBoundedBody(response, options.byteLimit).pipe(
55
- Effect.catchTags({
56
- HttpClientError: (cause) => Effect.fail(new GitHubTransportError({ path }, { cause })),
57
- ResponseBodyTooLarge: (cause) => Effect.fail(new GitHubArtifactTooLarge({ limit: cause.limit }, { cause })),
58
- }),
59
- );
35
+ downloadArtifactArchive: Effect.fn("GitHub.downloadArtifactArchive")(function* (options: ArtifactArchiveOptions) {
36
+ const path = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/actions/artifacts/${options.artifactId}/zip`;
37
+ const redirect = yield* send(HttpClientRequest.get(resolveApiUrl(apiUrl, path), { accept: "application/vnd.github+json" }), "manual").pipe(
38
+ Effect.catchTags({
39
+ RemoteGone: (error) => Effect.fail(new GitHubArtifactError({ stage: "redirect", status: error.status }, { cause: error })),
40
+ RemoteNotFound: (error) => Effect.fail(new GitHubArtifactError({ stage: "redirect", status: error.status }, { cause: error })),
60
41
  }),
61
- ),
42
+ );
43
+ const location = redirect.headers.location;
44
+ if (redirect.status !== HttpStatus.fromLiteral("Found") || location === undefined) {
45
+ return yield* new GitHubArtifactError({ stage: "redirect", status: redirect.status });
46
+ }
47
+ const response = yield* download.get(location).pipe(
48
+ Effect.provideService(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "manual" }),
49
+ Effect.mapError((cause) => downloadFailure(path, cause)),
50
+ );
51
+ return yield* readBoundedBody(response, options.byteLimit).pipe(
52
+ Effect.catchTags({
53
+ HttpClientError: (cause) => Effect.fail(new GitHubTransportError({ path }, { cause })),
54
+ ResponseBodyTooLarge: (cause) => Effect.fail(new GitHubArtifactTooLarge({ limit: cause.limit }, { cause })),
55
+ }),
56
+ Effect.flatMap((bytes) => extractArtifactArchive(bytes, options)),
57
+ );
58
+ }, Effect.scoped),
62
59
  };
63
- }
60
+ });
package/src/checkRuns.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Effect, Option, Schema, Stream } from "effect";
2
+ import { decodedPages } from "#pagedLists.ts";
2
3
  import type { Representations } from "#representations.ts";
3
4
 
4
5
  export const CheckRunOutput = Schema.Struct({
@@ -50,8 +51,6 @@ export const CheckRun = Schema.Struct({
50
51
  status: CheckRunStatus,
51
52
  }).pipe(Schema.encodeKeys({ detailsUrl: "details_url", headSha: "head_sha" }));
52
53
 
53
- const CheckRunPage = Schema.Struct({ checkRuns: Schema.Array(CheckRun) }).pipe(Schema.encodeKeys({ checkRuns: "check_runs" }));
54
-
55
54
  export const FindCheckRun = Schema.Union([
56
55
  Schema.Struct({ appId: Schema.optional(Schema.Number), headSha: Schema.String, name: Schema.String }),
57
56
  Schema.Struct({ appId: Schema.Number, headSha: Schema.String, name: Schema.optional(Schema.String) }),
@@ -74,10 +73,11 @@ export function checkRuns(client: Representations) {
74
73
  if (name !== undefined) {
75
74
  query.set("check_name", name);
76
75
  }
77
- const pages = yield* client.list(`/repos/${owner}/${repo}/commits/${encodeURIComponent(headSha)}/check-runs?${query}`);
76
+ const pages = decodedPages(client, `/repos/${owner}/${repo}/commits/${encodeURIComponent(headSha)}/check-runs?${query}`, CheckRun, {
77
+ arrayKey: "check_runs",
78
+ });
78
79
  return yield* pages.pipe(
79
- Stream.mapEffect((page) => page.decode(CheckRunPage)),
80
- Stream.flatMap((page) => Stream.fromIterable(page.checkRuns)),
80
+ Stream.flatMap((page) => Stream.fromIterable(page.items)),
81
81
  Stream.filter((run) => (name === undefined || run.name === name) && (appId === undefined || run.app?.id === appId)),
82
82
  Stream.runFold(
83
83
  () => Option.none<typeof CheckRun.Type>(),
@@ -0,0 +1,52 @@
1
+ import { Schema } from "effect";
2
+ import { PullRequestFile, User } from "#repositorySchemas.ts";
3
+
4
+ const CommitIdentity = Schema.Struct({ date: Schema.String, email: Schema.String, name: Schema.String });
5
+ const CommitData = Schema.Struct({
6
+ author: Schema.NullOr(CommitIdentity),
7
+ committer: Schema.NullOr(CommitIdentity),
8
+ message: Schema.String,
9
+ tree: Schema.Struct({ sha: Schema.String, url: Schema.String }),
10
+ });
11
+
12
+ const commitFields = {
13
+ author: Schema.NullOr(User),
14
+ commit: CommitData,
15
+ committer: Schema.NullOr(User),
16
+ htmlUrl: Schema.String,
17
+ parents: Schema.Array(Schema.Struct({ sha: Schema.String, url: Schema.String })),
18
+ sha: Schema.String,
19
+ };
20
+
21
+ export const Commit = Schema.Struct(commitFields).pipe(Schema.encodeKeys({ htmlUrl: "html_url" }));
22
+
23
+ export const CommitDetail = Schema.Struct({
24
+ ...commitFields,
25
+ files: Schema.optional(Schema.Array(PullRequestFile)),
26
+ stats: Schema.Struct({ additions: Schema.Number, deletions: Schema.Number, total: Schema.Number }),
27
+ }).pipe(Schema.encodeKeys({ htmlUrl: "html_url" }));
28
+
29
+ export const Comparison = Schema.Struct({
30
+ aheadBy: Schema.Number,
31
+ behindBy: Schema.Number,
32
+ commits: Schema.Array(Commit),
33
+ files: Schema.optional(Schema.Array(PullRequestFile)),
34
+ status: Schema.Literals(["diverged", "ahead", "behind", "identical"]),
35
+ totalCommits: Schema.Number,
36
+ }).pipe(Schema.encodeKeys({ aheadBy: "ahead_by", behindBy: "behind_by", totalCommits: "total_commits" }));
37
+
38
+ export const GitTree = Schema.Struct({
39
+ sha: Schema.String,
40
+ tree: Schema.Array(
41
+ Schema.Struct({
42
+ mode: Schema.String,
43
+ path: Schema.String,
44
+ sha: Schema.String,
45
+ size: Schema.optional(Schema.Number),
46
+ type: Schema.Literals(["blob", "tree", "commit"]),
47
+ url: Schema.optional(Schema.String),
48
+ }),
49
+ ),
50
+ truncated: Schema.Boolean,
51
+ url: Schema.String,
52
+ });
@@ -5,34 +5,40 @@ import type { AppCredentials } from "#appJwt.ts";
5
5
  import { appTokens } from "#appTokens.ts";
6
6
  import { GitHub } from "#GitHub.ts";
7
7
  import { GitHubConfig } from "#GitHubConfig.ts";
8
+ import { viewerRepositoryAccess } from "#viewerRepositoryAccess.ts";
8
9
 
9
10
  export const GitHubAppCredentials = Context.Service<{ readonly credentials: AppCredentials }>("@shivaedev/github/AppCredentials");
10
11
 
11
12
  export const GitHubApp = defineService({
12
13
  id: "@shivaedev/github/GitHubApp",
13
14
  initialize: Effect.gen(function* () {
14
- const { credentials } = yield* GitHubAppCredentials;
15
+ const { credentials: supplied } = yield* GitHubAppCredentials;
16
+ const credentials = { appId: supplied.appId, privateKey: supplied.privateKey };
15
17
  const http = yield* HttpClient.HttpClient;
16
18
  const config = yield* GitHubConfig;
17
- return yield* Cache.make({
19
+ const environment = Layer.mergeAll(Layer.succeed(HttpClient.HttpClient)(http), Layer.succeed(GitHubConfig)(config));
20
+ const app = yield* Layer.build(GitHub.layer.pipe(Layer.provide(appTokens(credentials)), Layer.provide(environment))).pipe(
21
+ Effect.scoped,
22
+ Effect.map((context) => Context.get(context, GitHub)),
23
+ );
24
+ const clients = yield* Cache.make({
18
25
  capacity: 100,
19
26
  lookup: (installationId: number) =>
20
- Layer.build(
21
- GitHub.layer.pipe(
22
- Layer.provide(appTokens({ ...credentials, installationId })),
23
- Layer.provide(Layer.succeed(HttpClient.HttpClient)(http)),
24
- Layer.provide(Layer.succeed(GitHubConfig)(config)),
25
- ),
26
- ).pipe(
27
+ Layer.build(GitHub.layer.pipe(Layer.provide(appTokens({ ...credentials, installationId })), Layer.provide(environment))).pipe(
27
28
  Effect.scoped,
28
29
  Effect.map((context) => Context.get(context, GitHub)),
29
30
  ),
30
31
  timeToLive: "30 minutes",
31
32
  });
33
+ function installation(id: number) {
34
+ return Cache.get(clients, id).pipe(Effect.tap((client) => Cache.set(clients, id, client)));
35
+ }
36
+ return { installation, viewers: yield* viewerRepositoryAccess(app, installation) };
32
37
  }),
33
- methods: (clients) => ({
34
- installation: (installationId: number) =>
35
- Cache.get(clients, installationId).pipe(Effect.tap((client) => Cache.set(clients, installationId, client))),
38
+ methods: ({ installation, viewers }) => ({
39
+ installation,
40
+ viewerRepositories: (account: number | string) =>
41
+ Cache.get(viewers, typeof account === "number" ? `id:${account}` : `login:${account.toLowerCase()}`),
36
42
  }),
37
43
  requires: [GitHubAppCredentials, HttpClient.HttpClient, GitHubConfig],
38
44
  });
@@ -0,0 +1,40 @@
1
+ import { Effect, Option, Schema, Stream } from "effect";
2
+ import type { ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
3
+ import type { Representations } from "#representations.ts";
4
+
5
+ export interface ListOptions {
6
+ readonly arrayKey?: string | undefined;
7
+ readonly beforeRequest?: (resource: string) => Effect.Effect<void, ServiceUnavailable>;
8
+ readonly onDecodeError?: "fail" | "skip" | undefined;
9
+ readonly responseByteLimit?: number | undefined;
10
+ }
11
+
12
+ export function decodedPages<TSchema extends Schema.Constraint>(
13
+ client: Representations,
14
+ path: string,
15
+ schema: TSchema,
16
+ options: ListOptions | number = {},
17
+ ) {
18
+ const settings = typeof options === "number" ? { responseByteLimit: options } : options;
19
+ const decode = Schema.decodeUnknownEffect(schema);
20
+ return Stream.unwrap(client.list(path, settings.beforeRequest, settings.responseByteLimit)).pipe(
21
+ Stream.mapEffect((page) =>
22
+ Effect.gen(function* () {
23
+ const body = yield* page.decode(Schema.Unknown);
24
+ const source =
25
+ settings.arrayKey === undefined
26
+ ? body
27
+ : (yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown))(body))[settings.arrayKey];
28
+ const values = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Unknown))(source);
29
+ const decoded = yield* Effect.forEach(values, (value) =>
30
+ decode(value).pipe(
31
+ Effect.map(Option.some),
32
+ Effect.catchTag("SchemaError", (error) => (settings.onDecodeError === "skip" ? Effect.succeed(Option.none()) : Effect.fail(error))),
33
+ ),
34
+ );
35
+ const items = decoded.flatMap(Option.toArray);
36
+ return { hasNext: page.hasNext, items, skipped: values.length - items.length };
37
+ }),
38
+ ),
39
+ );
40
+ }
@@ -0,0 +1,61 @@
1
+ import { Effect } from "effect";
2
+ import { Commit, CommitDetail, Comparison, GitTree } from "#gitSchemas.ts";
3
+ import { decodedPages, type ListOptions } from "#pagedLists.ts";
4
+ import { CollaboratorPermission, Issue, PullRequestFile, User } from "#repositorySchemas.ts";
5
+ import type { Representations } from "#representations.ts";
6
+
7
+ export interface RepositoryListOptions extends ListOptions {
8
+ readonly query?: Readonly<Record<string, string | number | boolean | undefined>>;
9
+ }
10
+
11
+ export function repositoryReads(client: Representations) {
12
+ function repository(owner: string, repo: string) {
13
+ return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
14
+ }
15
+ function pathWithQuery(path: string, options?: RepositoryListOptions) {
16
+ const query = new URLSearchParams({ "per_page": "100" });
17
+ for (const [key, value] of Object.entries(options?.query ?? {})) {
18
+ if (value !== undefined) {
19
+ query.set(key, String(value));
20
+ }
21
+ }
22
+ return `${path}?${query}`;
23
+ }
24
+ return {
25
+ compareCommits: (owner: string, repo: string, base: string, head: string, byteLimit?: number) =>
26
+ Effect.flatMap(client.read(`${repository(owner, repo)}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, byteLimit), (page) =>
27
+ page.decode(Comparison),
28
+ ),
29
+ getCollaboratorPermission: (owner: string, repo: string, username: string, byteLimit?: number) =>
30
+ Effect.flatMap(client.read(`${repository(owner, repo)}/collaborators/${encodeURIComponent(username)}/permission`, byteLimit), (page) =>
31
+ page.decode(CollaboratorPermission),
32
+ ),
33
+ getCommit: (owner: string, repo: string, ref: string, byteLimit?: number) =>
34
+ Effect.flatMap(client.read(`${repository(owner, repo)}/commits/${encodeURIComponent(ref)}`, byteLimit), (page) => page.decode(CommitDetail)),
35
+ getIssue: (owner: string, repo: string, number: number, byteLimit?: number) =>
36
+ Effect.flatMap(client.read(`${repository(owner, repo)}/issues/${number}`, byteLimit), (page) => page.decode(Issue)),
37
+ getTree: (
38
+ owner: string,
39
+ repo: string,
40
+ sha: string,
41
+ options?: { readonly recursive?: boolean; readonly responseByteLimit?: number | undefined },
42
+ ) =>
43
+ Effect.flatMap(
44
+ client.read(
45
+ `${repository(owner, repo)}/git/trees/${encodeURIComponent(sha)}${options?.recursive ? "?recursive=1" : ""}`,
46
+ options?.responseByteLimit,
47
+ ),
48
+ (page) => page.decode(GitTree),
49
+ ),
50
+ getUser: (username?: string, byteLimit?: number) =>
51
+ Effect.flatMap(client.read(username === undefined ? "/user" : `/users/${encodeURIComponent(username)}`, byteLimit), (page) =>
52
+ page.decode(User),
53
+ ),
54
+ listCommits: (owner: string, repo: string, options?: RepositoryListOptions) =>
55
+ Effect.succeed(decodedPages(client, pathWithQuery(`${repository(owner, repo)}/commits`, options), Commit, options)),
56
+ listIssues: (owner: string, repo: string, options?: RepositoryListOptions) =>
57
+ Effect.succeed(decodedPages(client, pathWithQuery(`${repository(owner, repo)}/issues`, options), Issue, options)),
58
+ listPullRequestFiles: (owner: string, repo: string, number: number, options?: RepositoryListOptions) =>
59
+ Effect.succeed(decodedPages(client, pathWithQuery(`${repository(owner, repo)}/pulls/${number}/files`, options), PullRequestFile, options)),
60
+ };
61
+ }