@shivaedev/github 0.10.0 → 0.11.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 (98) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +20 -2
  3. package/dist/GitHub.d.ts +51 -50
  4. package/dist/GitHub.d.ts.map +1 -1
  5. package/dist/GitHub.js +8 -6
  6. package/dist/GitHub.js.map +1 -1
  7. package/dist/adapters/manualRedirects.d.ts +4 -0
  8. package/dist/adapters/manualRedirects.d.ts.map +1 -0
  9. package/dist/adapters/manualRedirects.js +6 -0
  10. package/dist/adapters/manualRedirects.js.map +1 -0
  11. package/dist/adapters/mergeResponseHeaders.d.ts +3 -0
  12. package/dist/adapters/mergeResponseHeaders.d.ts.map +1 -0
  13. package/dist/adapters/mergeResponseHeaders.js +5 -0
  14. package/dist/adapters/mergeResponseHeaders.js.map +1 -0
  15. package/dist/adapters/signAppJwt.d.ts +5 -0
  16. package/dist/adapters/signAppJwt.d.ts.map +1 -0
  17. package/dist/adapters/signAppJwt.js +15 -0
  18. package/dist/adapters/signAppJwt.js.map +1 -0
  19. package/dist/adapters/verifySignature.d.ts +4 -0
  20. package/dist/adapters/verifySignature.d.ts.map +1 -0
  21. package/dist/adapters/verifySignature.js +13 -0
  22. package/dist/adapters/verifySignature.js.map +1 -0
  23. package/dist/appJwt.d.ts +2 -3
  24. package/dist/appJwt.d.ts.map +1 -1
  25. package/dist/appJwt.js +3 -12
  26. package/dist/appJwt.js.map +1 -1
  27. package/dist/artifacts.d.ts.map +1 -1
  28. package/dist/artifacts.js +3 -2
  29. package/dist/artifacts.js.map +1 -1
  30. package/dist/authenticatedRequest.d.ts +5 -5
  31. package/dist/authenticatedRequest.d.ts.map +1 -1
  32. package/dist/authenticatedRequest.js +6 -5
  33. package/dist/authenticatedRequest.js.map +1 -1
  34. package/dist/cache.d.ts.map +1 -1
  35. package/dist/cache.js +2 -1
  36. package/dist/cache.js.map +1 -1
  37. package/dist/checkRuns.d.ts +4 -4
  38. package/dist/errors.d.ts +8 -0
  39. package/dist/errors.d.ts.map +1 -1
  40. package/dist/errors.js +6 -0
  41. package/dist/errors.js.map +1 -1
  42. package/dist/installationClients.d.ts +44 -44
  43. package/dist/markdown.d.ts +1 -0
  44. package/dist/markdown.d.ts.map +1 -1
  45. package/dist/markdown.js +3 -0
  46. package/dist/markdown.js.map +1 -1
  47. package/dist/markdownRows.d.ts +8 -1
  48. package/dist/markdownRows.d.ts.map +1 -1
  49. package/dist/markdownRows.js +39 -8
  50. package/dist/markdownRows.js.map +1 -1
  51. package/dist/pagedLists.d.ts +4 -4
  52. package/dist/pagedLists.d.ts.map +1 -1
  53. package/dist/pagedLists.js.map +1 -1
  54. package/dist/repositoryAddress.d.ts +11 -0
  55. package/dist/repositoryAddress.d.ts.map +1 -0
  56. package/dist/repositoryAddress.js +23 -0
  57. package/dist/repositoryAddress.js.map +1 -0
  58. package/dist/repositoryReads.d.ts +14 -13
  59. package/dist/repositoryReads.d.ts.map +1 -1
  60. package/dist/repositoryReads.js.map +1 -1
  61. package/dist/representations.d.ts +5 -5
  62. package/dist/representations.d.ts.map +1 -1
  63. package/dist/representations.js.map +1 -1
  64. package/dist/requests.d.ts +10 -6
  65. package/dist/requests.d.ts.map +1 -1
  66. package/dist/requests.js +11 -6
  67. package/dist/requests.js.map +1 -1
  68. package/dist/status.d.ts +2 -2
  69. package/dist/status.d.ts.map +1 -1
  70. package/dist/status.js +2 -3
  71. package/dist/status.js.map +1 -1
  72. package/dist/stickyComments.d.ts +1 -1
  73. package/dist/verifyWebhook.d.ts +2 -2
  74. package/dist/verifyWebhook.d.ts.map +1 -1
  75. package/dist/verifyWebhook.js +3 -10
  76. package/dist/verifyWebhook.js.map +1 -1
  77. package/dist/viewerRepositoryAccess.d.ts +1 -1
  78. package/dist/viewerRepositoryAccess.d.ts.map +1 -1
  79. package/package.json +6 -6
  80. package/src/GitHub.ts +21 -9
  81. package/src/adapters/manualRedirects.ts +6 -0
  82. package/src/adapters/mergeResponseHeaders.ts +8 -0
  83. package/src/adapters/signAppJwt.ts +16 -0
  84. package/src/adapters/verifySignature.ts +13 -0
  85. package/src/appJwt.ts +3 -12
  86. package/src/artifacts.ts +3 -5
  87. package/src/authenticatedRequest.ts +8 -9
  88. package/src/cache.ts +2 -1
  89. package/src/errors.ts +6 -0
  90. package/src/markdown.ts +4 -0
  91. package/src/markdownRows.ts +54 -9
  92. package/src/pagedLists.ts +4 -4
  93. package/src/repositoryAddress.ts +32 -0
  94. package/src/repositoryReads.ts +6 -5
  95. package/src/representations.ts +3 -3
  96. package/src/requests.ts +12 -14
  97. package/src/status.ts +3 -3
  98. package/src/verifyWebhook.ts +4 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/github",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Typed GitHub REST requests, conditional reads and resource budgets for Effect applications",
5
5
  "keywords": [
6
6
  "effect"
@@ -43,17 +43,17 @@
43
43
  "README.md"
44
44
  ],
45
45
  "dependencies": {
46
- "@shivaedev/errors": "0.5.0",
47
- "@shivaedev/http": "0.4.0",
46
+ "@shivaedev/errors": "0.6.0",
47
+ "@shivaedev/http": "0.5.0",
48
48
  "@shivaedev/service": "0.6.1"
49
49
  },
50
50
  "devDependencies": {
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.56.0",
55
- "@shivaedev/test": "0.13.0",
56
- "@shivaedev/test-story": "0.34.0",
54
+ "@shivaedev/quality": "0.57.0",
55
+ "@shivaedev/test": "0.14.0",
56
+ "@shivaedev/test-story": "0.35.0",
57
57
  "@types/node": "24.19.2",
58
58
  "@zip.js/zip.js": "2.24.0",
59
59
  "effect": "4.0.6",
package/src/GitHub.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Effect, Schema, Stream } from "effect";
2
2
  import { HttpClient } from "effect/http";
3
+ import type { ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
3
4
  import { defineService } from "@shivaedev/service/defineService.ts";
5
+ import { genericMethod } from "@shivaedev/service/genericMethod.ts";
4
6
  import { artifacts } from "#artifacts.ts";
5
7
  import { checkRuns } from "#checkRuns.ts";
6
8
  import { GitHubConfig } from "#GitHubConfig.ts";
@@ -11,33 +13,34 @@ import { repositoryReads } from "#repositoryReads.ts";
11
13
  import { representations } from "#representations.ts";
12
14
  import { requests } from "#requests.ts";
13
15
  import { stickyComments } from "#stickyComments.ts";
14
- import { TokenRefresh } from "#TokenRefresh.ts";
15
16
 
16
17
  export const GitHub = defineService({
17
18
  id: "@shivaedev/github/GitHub",
18
19
  initialize: Effect.gen(function* () {
19
20
  const config = yield* GitHubConfig;
20
21
  const rates = rateLimits();
21
- const client = yield* HttpClient.HttpClient;
22
- const token = yield* GitHubToken;
23
- const refresh = yield* TokenRefresh;
24
22
  return {
25
23
  apiUrl: config.apiUrl,
26
24
  archive: yield* artifacts(config.apiUrl, rates),
27
25
  rates,
28
- request: requests(client, token, rates, refresh),
26
+ request: yield* requests(rates),
29
27
  responseByteLimit: config.responseByteLimit,
30
28
  };
31
29
  }),
32
30
  methods: ({ apiUrl, archive, rates, request, responseByteLimit }) => {
33
31
  const { get, ...client } = representations(apiUrl, request, rates.resource, responseByteLimit);
32
+ const reads = repositoryReads(client);
34
33
  return {
35
34
  bytes: (path: string, accept: string, limit: number) => Effect.map(request(get(path, accept), limit), (entry) => entry.bytes),
36
35
  ...client,
36
+ list: genericMethod(client.list),
37
37
  ...archive,
38
38
  ...stickyComments(client),
39
39
  ...checkRuns(client),
40
- ...repositoryReads(client),
40
+ ...reads,
41
+ listCommits: genericMethod(reads.listCommits),
42
+ listIssues: genericMethod(reads.listIssues),
43
+ listPullRequestFiles: genericMethod(reads.listPullRequestFiles),
41
44
  observations: () => Effect.sync(() => [...rates.observations.values()]),
42
45
  resource: (path: string) => Effect.sync(() => rates.resource(new URL(path, apiUrl).pathname)),
43
46
  text: (path: string, accept: string, limit: number) =>
@@ -51,15 +54,24 @@ export function read<TSchema extends Schema.Constraint>(path: string, schema: TS
51
54
  return Effect.flatMap(GitHub, (github) => Effect.flatMap(github.read(path, responseByteLimit), (response) => response.decode(schema)));
52
55
  }
53
56
 
54
- export function listPages<TSchema extends Schema.Constraint>(path: string, schema: TSchema, options?: ListOptions | number) {
57
+ export function listPages<TSchema extends Schema.Constraint, E = ServiceUnavailable>(
58
+ path: string,
59
+ schema: TSchema,
60
+ options?: ListOptions<E> | number,
61
+ ) {
55
62
  return Stream.unwrap(Effect.map(GitHub, (github) => decodedPages(github, path, schema, options)));
56
63
  }
57
64
 
58
- export function list<TSchema extends Schema.Constraint>(path: string, schema: TSchema, options?: ListOptions | number) {
65
+ export function list<TSchema extends Schema.Constraint, E = ServiceUnavailable>(path: string, schema: TSchema, options?: ListOptions<E> | number) {
59
66
  return listPages(path, schema, options).pipe(Stream.flatMap((page) => Stream.fromIterable(page.items)));
60
67
  }
61
68
 
62
- export function listBounded<TSchema extends Schema.Constraint>(path: string, schema: TSchema, pageCap: number, options?: ListOptions | number) {
69
+ export function listBounded<TSchema extends Schema.Constraint, E = ServiceUnavailable>(
70
+ path: string,
71
+ schema: TSchema,
72
+ pageCap: number,
73
+ options?: ListOptions<E> | number,
74
+ ) {
63
75
  return listPages(path, schema, options).pipe(
64
76
  Stream.take(pageCap),
65
77
  Stream.runCollect,
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { FetchHttpClient } from "effect/http";
3
+
4
+ export function manualRedirects<A, E, R>(request: Effect.Effect<A, E, R>) {
5
+ return request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "manual" }));
6
+ }
@@ -0,0 +1,8 @@
1
+ import { HttpClientResponse } from "effect/http";
2
+
3
+ export function mergeResponseHeaders(cached: HttpClientResponse.HttpClientResponse, response: HttpClientResponse.HttpClientResponse) {
4
+ return HttpClientResponse.fromWeb(
5
+ response.request,
6
+ new Response(null, { headers: { ...cached.headers, ...response.headers }, status: response.status }),
7
+ );
8
+ }
@@ -0,0 +1,16 @@
1
+ import { sign } from "node:crypto";
2
+ import { Effect, Redacted } from "effect";
3
+ import type { AppCredentials } from "#appJwt.ts";
4
+ import { GitHubAppError } from "#errors.ts";
5
+
6
+ export function signAppJwt(options: AppCredentials, now: number) {
7
+ return Effect.try({
8
+ catch: (cause) => new GitHubAppError({ reason: "sign" }, { cause }),
9
+ try: () => {
10
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
11
+ const payload = Buffer.from(JSON.stringify({ exp: now + 600, iat: now - 60, iss: options.appId })).toString("base64url");
12
+ const input = `${header}.${payload}`;
13
+ return Redacted.make(`${input}.${sign("RSA-SHA256", Buffer.from(input), Redacted.value(options.privateKey)).toString("base64url")}`);
14
+ },
15
+ });
16
+ }
@@ -0,0 +1,13 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Effect, Redacted } from "effect";
3
+ import { GitHubWebhookSignatureError } from "#errors.ts";
4
+
5
+ export const verifySignature = Effect.fnUntraced(function* (body: Uint8Array, signature: string | undefined, secret: Redacted.Redacted<string>) {
6
+ if (signature === undefined || !/^sha256=[0-9a-f]{64}$/u.test(signature)) {
7
+ return yield* new GitHubWebhookSignatureError();
8
+ }
9
+ const expected = createHmac("sha256", Redacted.value(secret)).update(body).digest();
10
+ if (!timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"))) {
11
+ return yield* new GitHubWebhookSignatureError();
12
+ }
13
+ });
package/src/appJwt.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { sign } from "node:crypto";
2
- import { Clock, Effect, Redacted } from "effect";
3
- import { GitHubAppError } from "#errors.ts";
1
+ import { Clock, Effect, type Redacted } from "effect";
2
+ import { signAppJwt } from "#adapters/signAppJwt.ts";
4
3
 
5
4
  export interface AppCredentials {
6
5
  readonly appId: string;
@@ -9,13 +8,5 @@ export interface AppCredentials {
9
8
 
10
9
  export const appJwt = Effect.fnUntraced(function* (options: AppCredentials) {
11
10
  const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
12
- return yield* Effect.try({
13
- catch: (cause) => new GitHubAppError({ reason: "sign" }, { cause }),
14
- try: () => {
15
- const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
16
- const payload = Buffer.from(JSON.stringify({ exp: now + 600, iat: now - 60, iss: options.appId })).toString("base64url");
17
- const input = `${header}.${payload}`;
18
- return Redacted.make(`${input}.${sign("RSA-SHA256", Buffer.from(input), Redacted.value(options.privateKey)).toString("base64url")}`);
19
- },
20
- });
11
+ return yield* signAppJwt(options, now);
21
12
  });
package/src/artifacts.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { Effect } from "effect";
2
- import { FetchHttpClient, HttpClient, type HttpClientError, HttpClientRequest } from "effect/http";
2
+ import { HttpClient, type HttpClientError, HttpClientRequest } from "effect/http";
3
3
  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 { manualRedirects } from "#adapters/manualRedirects.ts";
7
8
  import { resolveApiUrl } from "#adapters/resolveApiUrl.ts";
8
9
  import { type ArtifactExtractionOptions, extractArtifactArchive } from "#artifactExtraction.ts";
9
10
  import { authenticatedRequest } from "#authenticatedRequest.ts";
@@ -44,10 +45,7 @@ export const artifacts = Effect.fn("GitHub.artifacts")(function* (apiUrl: string
44
45
  if (redirect.status !== HttpStatus.fromLiteral("Found") || location === undefined) {
45
46
  return yield* new GitHubArtifactError({ stage: "redirect", status: redirect.status });
46
47
  }
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
- );
48
+ const response = yield* manualRedirects(download.get(location)).pipe(Effect.mapError((cause) => downloadFailure(path, cause)));
51
49
  return yield* readBoundedBody(response, options.byteLimit).pipe(
52
50
  Effect.catchTags({
53
51
  HttpClientError: (cause) => Effect.fail(new GitHubTransportError({ path }, { cause })),
@@ -1,11 +1,12 @@
1
1
  import { DateTime, Effect, Option, Redacted, Stream } from "effect";
2
- import { FetchHttpClient, type HttpClient, type HttpClientError, HttpClientRequest } from "effect/http";
2
+ import { type HttpClient, type HttpClientError, HttpClientRequest } from "effect/http";
3
3
  import * as HttpStatus from "effect/http/HttpStatus";
4
- import { GitHubTransportError } from "./errors.ts";
5
- import type { GitHubToken } from "./GitHubToken.ts";
6
- import { failRateLimited, type RateLimits } from "./rateLimits.ts";
7
- import { checkStatus, isUnauthorized, isUnchanged } from "./status.ts";
8
- import type { TokenRefresh } from "./TokenRefresh.ts";
4
+ import { manualRedirects } from "#adapters/manualRedirects.ts";
5
+ import { GitHubTransportError } from "#errors.ts";
6
+ import type { GitHubToken } from "#GitHubToken.ts";
7
+ import { failRateLimited, type RateLimits } from "#rateLimits.ts";
8
+ import { checkStatus, isUnauthorized, isUnchanged } from "#status.ts";
9
+ import type { TokenRefresh } from "#TokenRefresh.ts";
9
10
 
10
11
  export function authenticatedRequest<R>(
11
12
  client: HttpClient.HttpClient.With<HttpClientError.HttpClientError, R>,
@@ -23,9 +24,7 @@ export function authenticatedRequest<R>(
23
24
  }
24
25
  function send(current: Option.Option<Redacted.Redacted<string>>) {
25
26
  const sent = client.execute(withCredential(input, current));
26
- return (
27
- mode === "manual" ? sent.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "manual" })) : sent
28
- ).pipe(Effect.mapError((cause) => new GitHubTransportError({ path }, { cause })));
27
+ return (mode === "manual" ? manualRedirects(sent) : sent).pipe(Effect.mapError((cause) => new GitHubTransportError({ path }, { cause })));
29
28
  }
30
29
  const credential = yield* accessToken;
31
30
  let response = yield* send(credential);
package/src/cache.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Schema } from "effect";
2
2
  import type { HttpClientResponse } from "effect/http";
3
+ import { mergeResponseHeaders } from "#adapters/mergeResponseHeaders.ts";
3
4
 
4
5
  export interface CachedResponse {
5
6
  readonly bytes: Uint8Array;
@@ -9,7 +10,7 @@ export interface CachedResponse {
9
10
  }
10
11
 
11
12
  export function revalidatedResponse(cached: CachedResponse, response: HttpClientResponse.HttpClientResponse): CachedResponse {
12
- return { ...cached, etag: response.headers.etag ?? cached.etag, response };
13
+ return { ...cached, etag: response.headers.etag ?? cached.etag, response: mergeResponseHeaders(cached.response, response) };
13
14
  }
14
15
 
15
16
  export function responseCache() {
package/src/errors.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { Schema } from "effect";
2
2
 
3
+ export class GitHubAddressInvalid extends Schema.TaggedError<GitHubAddressInvalid>()("GitHubAddressInvalid", {}) {}
4
+
5
+ export class MarkdownBudgetTooSmall extends Schema.TaggedError<MarkdownBudgetTooSmall>()("MarkdownBudgetTooSmall", {
6
+ byteLimit: Schema.Number,
7
+ }) {}
8
+
3
9
  export class GitHubPermissionDenied extends Schema.TaggedError<GitHubPermissionDenied>()("GitHubPermissionDenied", {
4
10
  path: Schema.String,
5
11
  }) {}
package/src/markdown.ts CHANGED
@@ -23,3 +23,7 @@ export function tableCell(text: string, format: "text" | "code" = "text"): strin
23
23
  const content = text.replace(/\r\n|[\r\n\u2028\u2029]/gu, " ");
24
24
  return format === "code" ? codeSpan(content.replaceAll("|", "\\|")) : plainText(content);
25
25
  }
26
+
27
+ export function details(summary: string, text: string): string {
28
+ return `<details>\n<summary>${plainText(summary)}</summary>\n\n${plainText(text)}\n\n</details>`;
29
+ }
@@ -1,9 +1,45 @@
1
+ import { Result } from "effect";
2
+ import { MarkdownBudgetTooSmall } from "./errors.ts";
3
+
1
4
  export interface MarkdownRowsOptions {
2
5
  readonly byteLimit?: number;
3
6
  readonly header?: string;
4
7
  }
5
8
 
6
- export function renderRows(rows: readonly string[], { byteLimit = 60_000, header = "" }: MarkdownRowsOptions = {}): string {
9
+ export interface MarkdownRowsSection {
10
+ readonly header?: string;
11
+ readonly rows: readonly string[];
12
+ }
13
+
14
+ export function renderRows(
15
+ rows: readonly string[],
16
+ { byteLimit = 60_000, header = "" }: MarkdownRowsOptions = {},
17
+ ): Result.Result<string, MarkdownBudgetTooSmall> {
18
+ return prepareRows({ header, rows }).render(byteLimit);
19
+ }
20
+
21
+ export function renderSections(
22
+ sections: readonly MarkdownRowsSection[],
23
+ { byteLimit = 60_000 }: Pick<MarkdownRowsOptions, "byteLimit"> = {},
24
+ ): Result.Result<string, MarkdownBudgetTooSmall> {
25
+ const prepared = sections.filter(({ rows, header = "" }) => rows.length > 0 || header !== "").map(prepareRows);
26
+ let reserved = prepared.reduce((total, section) => total + section.minimumBytes, 0) + Math.max(0, prepared.length - 1) * 2;
27
+ let remaining = byteLimit;
28
+ const bodies: string[] = [];
29
+ for (const section of prepared) {
30
+ reserved -= section.minimumBytes;
31
+ const body = section.render(remaining - reserved);
32
+ if (Result.isFailure(body)) {
33
+ return Result.fail(new MarkdownBudgetTooSmall({ byteLimit }));
34
+ }
35
+ bodies.push(body.success);
36
+ remaining -= new TextEncoder().encode(body.success).byteLength + 2;
37
+ reserved -= 2;
38
+ }
39
+ return Result.succeed(bodies.join("\n\n"));
40
+ }
41
+
42
+ function prepareRows({ rows, header = "" }: MarkdownRowsSection) {
7
43
  const encoder = new TextEncoder();
8
44
  const sizes = [0];
9
45
  for (const row of rows) {
@@ -16,15 +52,24 @@ export function renderRows(rows: readonly string[], { byteLimit = 60_000, header
16
52
  function body(kept: number) {
17
53
  return [...(header === "" ? [] : [header]), ...rows.slice(0, kept)].join("\n");
18
54
  }
19
- if (bodySize(rows.length) <= byteLimit) {
20
- return body(rows.length);
21
- }
22
- for (let kept = rows.length - 1; kept >= 0; kept -= 1) {
55
+ function omitted(kept: number) {
23
56
  const notice = `and ${rows.length - kept} more`;
24
57
  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
- }
58
+ return { bytes: bodySize(kept) + separator.length + encoder.encode(notice).byteLength, notice, separator };
28
59
  }
29
- throw new RangeError("The byte limit must fit the header and omitted-row notice");
60
+ return {
61
+ minimumBytes: rows.length === 0 ? headerBytes : Math.min(bodySize(rows.length), omitted(0).bytes),
62
+ render(byteLimit: number): Result.Result<string, MarkdownBudgetTooSmall> {
63
+ if (bodySize(rows.length) <= byteLimit) {
64
+ return Result.succeed(body(rows.length));
65
+ }
66
+ for (let kept = rows.length - 1; kept >= 0; kept -= 1) {
67
+ const { notice, separator, bytes } = omitted(kept);
68
+ if (bytes <= byteLimit) {
69
+ return Result.succeed(body(kept) + separator + notice);
70
+ }
71
+ }
72
+ return Result.fail(new MarkdownBudgetTooSmall({ byteLimit }));
73
+ },
74
+ };
30
75
  }
package/src/pagedLists.ts CHANGED
@@ -2,18 +2,18 @@ import { Effect, Option, Schema, Stream } from "effect";
2
2
  import type { ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
3
3
  import type { Representations } from "#representations.ts";
4
4
 
5
- export interface ListOptions {
5
+ export interface ListOptions<E = ServiceUnavailable> {
6
6
  readonly arrayKey?: string | undefined;
7
- readonly beforeRequest?: (resource: string) => Effect.Effect<void, ServiceUnavailable>;
7
+ readonly beforeRequest?: (resource: string) => Effect.Effect<void, E>;
8
8
  readonly onDecodeError?: "fail" | "skip" | undefined;
9
9
  readonly responseByteLimit?: number | undefined;
10
10
  }
11
11
 
12
- export function decodedPages<TSchema extends Schema.Constraint>(
12
+ export function decodedPages<TSchema extends Schema.Constraint, E = ServiceUnavailable>(
13
13
  client: Representations,
14
14
  path: string,
15
15
  schema: TSchema,
16
- options: ListOptions | number = {},
16
+ options: ListOptions<E> | number = {},
17
17
  ) {
18
18
  const settings = typeof options === "number" ? { responseByteLimit: options } : options;
19
19
  const decode = Schema.decodeUnknownEffect(schema);
@@ -0,0 +1,32 @@
1
+ import { Result } from "effect";
2
+ import { GitHubAddressInvalid } from "./errors.ts";
3
+
4
+ export interface RepositoryAddress {
5
+ readonly kind: "remote" | "shorthand" | "pull-request";
6
+ readonly name: string;
7
+ readonly number: number | undefined;
8
+ readonly owner: string;
9
+ readonly repository: string;
10
+ }
11
+
12
+ const REPOSITORY =
13
+ /^(?:(?<remote>(?:git\+)?(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git:\/\/github\.com\/|git@github\.com:))|github:)?(?<owner>[A-Za-z0-9-]+)\/(?<name>[\w.-]+?)(?:\.git)?\/?$/u;
14
+ const PULL_REQUEST = /^https:\/\/github\.com\/(?<owner>[A-Za-z0-9-]+)\/(?<name>[\w.-]+)\/pull\/(?<number>[1-9]\d*)$/u;
15
+
16
+ export function parseRepositoryAddress(address: string): Result.Result<RepositoryAddress, GitHubAddressInvalid> {
17
+ const input = address.trim();
18
+ const groups = (PULL_REQUEST.exec(input) ?? REPOSITORY.exec(input))?.groups;
19
+ const owner = groups?.owner;
20
+ const name = groups?.name;
21
+ const number = groups?.number === undefined ? undefined : Number(groups.number);
22
+ if (owner === undefined || name === undefined || name === "." || name === ".." || (number !== undefined && !Number.isSafeInteger(number))) {
23
+ return Result.fail(new GitHubAddressInvalid({}));
24
+ }
25
+ let kind: RepositoryAddress["kind"] = "shorthand";
26
+ if (number !== undefined) {
27
+ kind = "pull-request";
28
+ } else if (groups?.remote !== undefined) {
29
+ kind = "remote";
30
+ }
31
+ return Result.succeed({ kind, name, number, owner, repository: `${owner}/${name}` });
32
+ }
@@ -1,10 +1,11 @@
1
1
  import { Effect } from "effect";
2
+ import type { ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
2
3
  import { Commit, CommitDetail, Comparison, GitTree } from "#gitSchemas.ts";
3
4
  import { decodedPages, type ListOptions } from "#pagedLists.ts";
4
5
  import { CollaboratorPermission, Issue, PullRequestFile, User } from "#repositorySchemas.ts";
5
6
  import type { Representations } from "#representations.ts";
6
7
 
7
- export interface RepositoryListOptions extends ListOptions {
8
+ export interface RepositoryListOptions<E = ServiceUnavailable> extends ListOptions<E> {
8
9
  readonly query?: Readonly<Record<string, string | number | boolean | undefined>>;
9
10
  }
10
11
 
@@ -12,7 +13,7 @@ export function repositoryReads(client: Representations) {
12
13
  function repository(owner: string, repo: string) {
13
14
  return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
14
15
  }
15
- function pathWithQuery(path: string, options?: RepositoryListOptions) {
16
+ function pathWithQuery<E>(path: string, options?: RepositoryListOptions<E>) {
16
17
  const query = new URLSearchParams({ "per_page": "100" });
17
18
  for (const [key, value] of Object.entries(options?.query ?? {})) {
18
19
  if (value !== undefined) {
@@ -51,11 +52,11 @@ export function repositoryReads(client: Representations) {
51
52
  Effect.flatMap(client.read(username === undefined ? "/user" : `/users/${encodeURIComponent(username)}`, byteLimit), (page) =>
52
53
  page.decode(User),
53
54
  ),
54
- listCommits: (owner: string, repo: string, options?: RepositoryListOptions) =>
55
+ listCommits: <E = ServiceUnavailable>(owner: string, repo: string, options?: RepositoryListOptions<E>) =>
55
56
  Effect.succeed(decodedPages(client, pathWithQuery(`${repository(owner, repo)}/commits`, options), Commit, options)),
56
- listIssues: (owner: string, repo: string, options?: RepositoryListOptions) =>
57
+ listIssues: <E = ServiceUnavailable>(owner: string, repo: string, options?: RepositoryListOptions<E>) =>
57
58
  Effect.succeed(decodedPages(client, pathWithQuery(`${repository(owner, repo)}/issues`, options), Issue, options)),
58
- listPullRequestFiles: (owner: string, repo: string, number: number, options?: RepositoryListOptions) =>
59
+ listPullRequestFiles: <E = ServiceUnavailable>(owner: string, repo: string, number: number, options?: RepositoryListOptions<E>) =>
59
60
  Effect.succeed(decodedPages(client, pathWithQuery(`${repository(owner, repo)}/pulls/${number}/files`, options), PullRequestFile, options)),
60
61
  };
61
62
  }
@@ -10,7 +10,7 @@ import type { requests } from "#requests.ts";
10
10
 
11
11
  export function representations(
12
12
  apiUrl: string,
13
- request: ReturnType<typeof requests>,
13
+ request: Effect.Success<ReturnType<typeof requests>>,
14
14
  resource: (path: string) => string,
15
15
  responseByteLimit = 1024 * 1024,
16
16
  ) {
@@ -20,9 +20,9 @@ export function representations(
20
20
  function readRepresentation(path: string, byteLimit = responseByteLimit) {
21
21
  return Effect.map(request(get(path), byteLimit), jsonResponse);
22
22
  }
23
- function listRepresentations(
23
+ function listRepresentations<E = ServiceUnavailable>(
24
24
  path: string,
25
- beforeRequest?: (resource: string) => Effect.Effect<void, ServiceUnavailable>,
25
+ beforeRequest?: (resource: string) => Effect.Effect<void, E>,
26
26
  byteLimit = responseByteLimit,
27
27
  ) {
28
28
  const entries = new WeakMap<HttpClientResponse.HttpClientResponse, CachedResponse>();
package/src/requests.ts CHANGED
@@ -2,20 +2,18 @@ import { Effect } from "effect";
2
2
  import { HttpClient, HttpClientRequest } from "effect/http";
3
3
  import { readBoundedBody } from "@shivaedev/http/body.ts";
4
4
  import { ResponseBodyTooLarge } from "@shivaedev/http/errors.ts";
5
- import { authenticatedRequest } from "./authenticatedRequest.ts";
6
- import { type CachedResponse, responseCache, revalidatedResponse } 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";
5
+ import { authenticatedRequest } from "#authenticatedRequest.ts";
6
+ import { type CachedResponse, responseCache, revalidatedResponse } from "#cache.ts";
7
+ import { GitHubTransportError } from "#errors.ts";
8
+ import { GitHubToken } from "#GitHubToken.ts";
9
+ import type { RateLimits } from "#rateLimits.ts";
10
+ import { isUnchanged } from "#status.ts";
11
+ import { TokenRefresh } from "#TokenRefresh.ts";
12
12
 
13
- export function requests(
14
- client: HttpClient.HttpClient,
15
- token: GitHubToken["Service"],
16
- rates: RateLimits,
17
- refresh: typeof TokenRefresh.Service = undefined,
18
- ) {
13
+ export const requests = Effect.fnUntraced(function* (rates: RateLimits) {
14
+ const client = yield* HttpClient.HttpClient;
15
+ const token = yield* GitHubToken;
16
+ const refresh = yield* TokenRefresh;
19
17
  const cache = responseCache();
20
18
  const send = authenticatedRequest(HttpClient.withScope(client), token, rates, refresh);
21
19
  return Effect.fn("GitHub.request")(function* (input: HttpClientRequest.HttpClientRequest, limit: number) {
@@ -43,4 +41,4 @@ export function requests(
43
41
  }
44
42
  return entry;
45
43
  }, Effect.scoped);
46
- }
44
+ });
package/src/status.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { DateTime, Effect, Option, Schema } from "effect";
2
- import { HttpClient, type HttpClientResponse } from "effect/http";
2
+ import type { HttpClientResponse } from "effect/http";
3
3
  import * as HttpStatus from "effect/http/HttpStatus";
4
4
  import { readBoundedText } from "@shivaedev/http/body.ts";
5
- import { filterStatus } from "@shivaedev/http/filterStatus.ts";
5
+ import { checkStatus as checkResponseStatus } from "@shivaedev/http/checkStatus.ts";
6
6
  import { parseRetryAfter } from "@shivaedev/http/parseRetryAfter.ts";
7
7
  import { GitHubConversationLocked, GitHubPermissionDenied, GitHubRateLimited } from "#errors.ts";
8
8
 
@@ -29,7 +29,7 @@ export const checkStatus = Effect.fnUntraced(function* (response: HttpClientResp
29
29
  return yield* refusal;
30
30
  }
31
31
  }
32
- return yield* filterStatus(HttpClient.makeWith(() => Effect.succeed(response), Effect.succeed)).execute(response.request);
32
+ return yield* checkResponseStatus(response);
33
33
  });
34
34
 
35
35
  const Message = Schema.fromJsonString(Schema.Struct({ message: Schema.String }));
@@ -1,6 +1,6 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
- import { Effect, Redacted, Schema } from "effect";
3
- import { GitHubWebhookSignatureError } from "#errors.ts";
1
+ import { Effect, type Redacted, Schema } from "effect";
2
+ import { verifySignature } from "#adapters/verifySignature.ts";
3
+ import type { GitHubWebhookSignatureError } from "#errors.ts";
4
4
 
5
5
  const verify = Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(
6
6
  body: Uint8Array,
@@ -8,13 +8,7 @@ const verify = Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(
8
8
  secret: Redacted.Redacted<string>,
9
9
  schema?: TSchema,
10
10
  ) {
11
- if (signature === undefined || !/^sha256=[0-9a-f]{64}$/u.test(signature)) {
12
- return yield* new GitHubWebhookSignatureError();
13
- }
14
- const expected = createHmac("sha256", Redacted.value(secret)).update(body).digest();
15
- if (!timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"))) {
16
- return yield* new GitHubWebhookSignatureError();
17
- }
11
+ yield* verifySignature(body, signature, secret);
18
12
  if (schema === undefined) {
19
13
  return;
20
14
  }