@shivaedev/pr-watch 0.1.2 → 0.3.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 (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +20 -5
  3. package/dist/GitHub.d.ts.map +1 -1
  4. package/dist/GitHub.js +5 -19
  5. package/dist/GitHub.js.map +1 -1
  6. package/dist/GitHubHttp.d.ts +5 -6
  7. package/dist/GitHubHttp.d.ts.map +1 -1
  8. package/dist/GitHubHttp.js +34 -29
  9. package/dist/GitHubHttp.js.map +1 -1
  10. package/dist/Watcher.d.ts +1 -1
  11. package/dist/Watcher.d.ts.map +1 -1
  12. package/dist/Watcher.js +4 -4
  13. package/dist/Watcher.js.map +1 -1
  14. package/dist/arguments.d.ts +4 -4
  15. package/dist/arguments.d.ts.map +1 -1
  16. package/dist/arguments.js +19 -3
  17. package/dist/arguments.js.map +1 -1
  18. package/dist/cli.js +13 -12
  19. package/dist/cli.js.map +1 -1
  20. package/dist/confirmQueue.d.ts +3 -2
  21. package/dist/confirmQueue.d.ts.map +1 -1
  22. package/dist/confirmQueue.js +2 -2
  23. package/dist/confirmQueue.js.map +1 -1
  24. package/dist/events.d.ts +4 -2
  25. package/dist/events.d.ts.map +1 -1
  26. package/dist/events.js +5 -2
  27. package/dist/events.js.map +1 -1
  28. package/dist/health.d.ts +6 -0
  29. package/dist/health.d.ts.map +1 -0
  30. package/dist/health.js +19 -0
  31. package/dist/health.js.map +1 -0
  32. package/dist/model.d.ts +3 -1
  33. package/dist/model.d.ts.map +1 -1
  34. package/dist/model.js +1 -0
  35. package/dist/model.js.map +1 -1
  36. package/dist/program.d.ts +3 -2
  37. package/dist/program.d.ts.map +1 -1
  38. package/dist/program.js +42 -23
  39. package/dist/program.js.map +1 -1
  40. package/dist/quota.d.ts +3 -9
  41. package/dist/quota.d.ts.map +1 -1
  42. package/dist/quota.js +10 -30
  43. package/dist/quota.js.map +1 -1
  44. package/package.json +5 -4
  45. package/src/GitHub.ts +5 -19
  46. package/src/GitHubHttp.ts +47 -31
  47. package/src/Watcher.ts +5 -5
  48. package/src/arguments.ts +20 -3
  49. package/src/cli.ts +19 -16
  50. package/src/confirmQueue.ts +2 -2
  51. package/src/events.ts +11 -6
  52. package/src/health.ts +26 -0
  53. package/src/model.ts +3 -1
  54. package/src/program.ts +56 -26
  55. package/src/quota.ts +16 -37
  56. package/dist/httpResponse.d.ts +0 -15
  57. package/dist/httpResponse.d.ts.map +0 -1
  58. package/dist/httpResponse.js +0 -31
  59. package/dist/httpResponse.js.map +0 -1
  60. package/src/httpResponse.ts +0 -37
package/src/cli.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { NodeFileSystem } from "@effect/platform-node";
4
- import { Config, Effect, Layer } from "effect";
4
+ import { Effect, Layer, Logger } from "effect";
5
5
  import { FetchHttpClient } from "effect/http";
6
- import { GitHub } from "./GitHub.ts";
7
- import { GitHubHttp } from "./GitHubHttp.ts";
8
- import { WatchError } from "./model.ts";
9
- import { program } from "./program.ts";
10
- import { StateFile } from "./StateFile.ts";
11
- import { Watcher } from "./Watcher.ts";
6
+ import { GitHub as GitHubClient } from "@shivaedev/effect-github/GitHub.ts";
7
+ import { GitHubToken } from "@shivaedev/effect-github/GitHubToken.ts";
8
+ import { GitHub } from "#GitHub.ts";
9
+ import { GitHubHttp } from "#GitHubHttp.ts";
10
+ import { WatchError } from "#model.ts";
11
+ import { program } from "#program.ts";
12
+ import { StateFile } from "#StateFile.ts";
13
+ import { Watcher } from "#Watcher.ts";
12
14
 
13
15
  const services = Watcher.layer.pipe(
14
- Layer.provide(Layer.mergeAll(StateFile.layer, GitHub.layer.pipe(Layer.provide(GitHubHttp.layer)))),
16
+ Layer.provide(
17
+ Layer.mergeAll(
18
+ StateFile.layer,
19
+ GitHub.layer.pipe(Layer.provide(GitHubHttp.layer.pipe(Layer.provide(GitHubClient.layer), Layer.provide(GitHubToken.fromEnv)))),
20
+ ),
21
+ ),
15
22
  Layer.provide(
16
23
  Layer.mergeAll(
17
24
  NodeFileSystem.layer,
@@ -31,14 +38,10 @@ process.once("SIGINT", () => abort.abort());
31
38
  process.once("SIGTERM", () => abort.abort());
32
39
  try {
33
40
  await Effect.runPromise(
34
- Effect.gen(function* () {
35
- const token = yield* Config.String("GH_TOKEN").pipe(
36
- Config.orElse(() => Config.String("GITHUB_TOKEN")),
37
- Config.withDefault(""),
38
- );
39
- yield* program(process.argv.slice(2), token, write(process.stdout), write(process.stderr));
40
- }).pipe(Effect.provide(services)),
41
- { signal: abort.signal },
41
+ program(process.argv.slice(2), write(process.stdout), write(process.stderr)).pipe(Effect.provide(services), Effect.provide(Logger.layer([]))),
42
+ {
43
+ signal: abort.signal,
44
+ },
42
45
  );
43
46
  } catch (cause) {
44
47
  if (!abort.signal.aborted) {
@@ -40,8 +40,8 @@ export const confirmQueue = Effect.fn("GitHub.confirmQueue")(function* (
40
40
  url.pathname = `${url.pathname.replace(/\/$/u, "")}/graphql`;
41
41
  }
42
42
  const [owner, name] = options.repository.split("/");
43
- const response = yield* http.post(url.href, options.token, { query, variables: { name, number: snapshot.number, owner } });
44
- const body = yield* Schema.decodeUnknownEffect(QueueResponse)(response.body).pipe(
43
+ const response = yield* http.post(url.href, { query, variables: { name, number: snapshot.number, owner } });
44
+ const body = yield* Schema.decodeUnknownEffect(QueueResponse)(response).pipe(
45
45
  Effect.mapError(() => new WatchError({ message: "Invalid GitHub queue confirmation" })),
46
46
  );
47
47
  const pr = body.data?.repository?.pullRequest;
package/src/events.ts CHANGED
@@ -1,10 +1,13 @@
1
- import type { Snapshot } from "./model.ts";
1
+ import type { Snapshot } from "#model.ts";
2
+
3
+ export const eventKinds = ["merged", "closed", "conflict", "queued", "dequeued", "auto-merge-off", "checks-failed", "stuck"] as const;
4
+ export type EventKind = (typeof eventKinds)[number];
2
5
 
3
6
  function stuck(pr: Snapshot): boolean {
4
7
  return pr.state === "open" && pr.autoMerge && (pr.dirty || pr.failed);
5
8
  }
6
- function openEvents(before: Snapshot, after: Snapshot): readonly string[] {
7
- const transitions: readonly (readonly [boolean, string])[] = [
9
+ function openEvents(before: Snapshot, after: Snapshot): readonly EventKind[] {
10
+ const transitions: readonly (readonly [boolean, EventKind])[] = [
8
11
  [!before.dirty && after.dirty, "conflict"],
9
12
  [!before.queued && after.queued, "queued"],
10
13
  [before.queued && !after.queued, "dequeued"],
@@ -14,12 +17,12 @@ function openEvents(before: Snapshot, after: Snapshot): readonly string[] {
14
17
  ];
15
18
  return transitions.flatMap(([changed, event]) => (changed ? [event] : []));
16
19
  }
17
- export function events(before: Snapshot | undefined, after: Snapshot): readonly string[] {
20
+ export function events(before: Snapshot | undefined, after: Snapshot, selected?: readonly EventKind[]): readonly string[] {
18
21
  if (before === undefined && !stuck(after)) {
19
22
  return [];
20
23
  }
21
24
  const baseline = before ?? { ...after, autoMerge: false, dirty: false, failed: false };
22
- const found: string[] = [];
25
+ const found: EventKind[] = [];
23
26
  if (!baseline.merged && after.merged) {
24
27
  found.push("merged");
25
28
  }
@@ -29,5 +32,7 @@ export function events(before: Snapshot | undefined, after: Snapshot): readonly
29
32
  if (after.state === "open") {
30
33
  found.push(...openEvents(baseline, after));
31
34
  }
32
- return found.map((event) => JSON.stringify({ branch: after.branch, event, number: after.number, title: after.title }));
35
+ return found
36
+ .filter((event) => selected === undefined || selected.includes(event))
37
+ .map((event) => JSON.stringify({ branch: after.branch, event, number: after.number, title: after.title }));
33
38
  }
package/src/health.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { Clock, Effect } from "effect";
2
+ import type { WatchError } from "#model.ts";
3
+
4
+ export type Emit = (line: string) => Effect.Effect<void, WatchError>;
5
+ export const health = Effect.fnUntraced(function* (
6
+ emit: Emit,
7
+ event: "heartbeat" | "cooldown" | "error" | "stopped",
8
+ fields: Record<string, unknown> = {},
9
+ ) {
10
+ const at = new Date(yield* Clock.currentTimeMillis).toISOString();
11
+ yield* emit(JSON.stringify({ at, event, ...fields }));
12
+ });
13
+
14
+ export const waitForPoll = Effect.fnUntraced(function* (emit: Emit, interval: number, delay: number) {
15
+ const next = (yield* Clock.currentTimeMillis) + delay;
16
+ const nextPollAt = new Date(next).toISOString();
17
+ if (delay > interval) {
18
+ yield* health(emit, "cooldown", { delayMs: delay, message: "GitHub quota pacing", nextPollAt });
19
+ }
20
+ let remaining = delay;
21
+ while (remaining > 0) {
22
+ yield* health(emit, "heartbeat", { nextPollAt });
23
+ yield* Effect.sleep(Math.min(interval, remaining));
24
+ remaining = next - (yield* Clock.currentTimeMillis);
25
+ }
26
+ });
package/src/model.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Schema } from "effect";
2
+ import type { EventKind } from "#events.ts";
2
3
  export class WatchError extends Schema.TaggedError<WatchError>()("WatchError", {
3
4
  message: Schema.String,
5
+ retryAt: Schema.optionalKey(Schema.Number),
4
6
  }) {}
5
7
  export const Snapshot = Schema.Struct({
6
8
  autoMerge: Schema.Boolean,
@@ -23,10 +25,10 @@ export const State = Schema.Struct({
23
25
  export type State = typeof State.Type;
24
26
  export interface Options {
25
27
  readonly apiUrl: string;
28
+ readonly events?: readonly EventKind[];
26
29
  readonly fleet: boolean;
27
30
  readonly interval: number;
28
31
  readonly numbers: readonly number[];
29
32
  readonly repository: string;
30
33
  readonly stateFile: string;
31
- readonly token: string;
32
34
  }
package/src/program.ts CHANGED
@@ -1,34 +1,64 @@
1
- import { Effect } from "effect";
2
- import { argumentsFor, help } from "./arguments.ts";
3
- import type { WatchError } from "./model.ts";
4
- import { Watcher } from "./Watcher.ts";
1
+ import { Effect, Exit } from "effect";
2
+ import { argumentsFor, help } from "#arguments.ts";
3
+ import { type Emit, health, waitForPoll } from "#health.ts";
4
+ import type { WatchError } from "#model.ts";
5
+ import { Watcher } from "#Watcher.ts";
6
+
5
7
  export const program = Effect.fn("PrWatch.program")(function* (
6
8
  args: readonly string[],
7
- token: string,
8
- emit: (line: string) => Effect.Effect<void, WatchError>,
9
+ emit: Emit,
9
10
  diagnostic: (message: string) => Effect.Effect<void, WatchError>,
10
11
  ) {
11
- const parsed = yield* argumentsFor(args, token);
12
- if (parsed.help) {
13
- yield* emit(help);
14
- return;
15
- }
16
- const watcher = yield* Watcher;
17
- let state = yield* watcher.load(parsed.options);
18
- while (true) {
19
- const result = yield* Effect.result(watcher.poll(parsed.options, state, emit, diagnostic));
20
- if (result._tag === "Success") {
21
- state = result.success;
22
- } else {
12
+ let reported: string | undefined;
13
+ let watching = true;
14
+ const run = Effect.gen(function* () {
15
+ const parsed = yield* argumentsFor(args);
16
+ if (parsed.help) {
17
+ watching = false;
18
+ yield* emit(help);
19
+ return;
20
+ }
21
+ const watcher = yield* Watcher;
22
+ let state = yield* watcher.load(parsed.options);
23
+ function report(message: string) {
24
+ return health(emit, "error", { message }).pipe(
25
+ Effect.tap(
26
+ Effect.sync(() => {
27
+ reported = message;
28
+ }),
29
+ ),
30
+ Effect.andThen(diagnostic(message)),
31
+ );
32
+ }
33
+ while (true) {
34
+ reported = undefined;
35
+ state = yield* watcher.poll(parsed.options, state, emit, report);
23
36
  if (parsed.once) {
24
- return yield* Effect.fail(result.failure);
37
+ return;
25
38
  }
26
- yield* diagnostic(result.failure.message);
27
- state = yield* watcher.load(parsed.options);
39
+ yield* waitForPoll(emit, parsed.options.interval, yield* watcher.delay(parsed.options.interval));
28
40
  }
29
- if (parsed.once) {
30
- return;
31
- }
32
- yield* Effect.sleep(yield* watcher.delay(parsed.options.interval));
33
- }
41
+ });
42
+ yield* run.pipe(
43
+ Effect.tapError((error) =>
44
+ Effect.gen(function* () {
45
+ if (reported !== error.message) {
46
+ yield* health(emit, "error", { message: error.message });
47
+ }
48
+ if (error.retryAt !== undefined) {
49
+ yield* health(emit, "cooldown", { message: error.message, nextPollAt: new Date(error.retryAt).toISOString() });
50
+ }
51
+ }),
52
+ ),
53
+ Effect.onExit((exit) => {
54
+ if (!watching) {
55
+ return Effect.void;
56
+ }
57
+ let reason = "completed";
58
+ if (exit._tag === "Failure") {
59
+ reason = Exit.hasInterrupts(exit) ? "interrupted" : "failed";
60
+ }
61
+ return health(emit, "stopped", { reason }).pipe(Effect.orDie);
62
+ }),
63
+ );
34
64
  });
package/src/quota.ts CHANGED
@@ -1,42 +1,21 @@
1
- import type { HttpClientResponse } from "effect/http";
1
+ import { DateTime } from "effect";
2
+ import type { RateLimitObservation } from "@shivaedev/effect-github/rateLimits.ts";
2
3
 
3
- export interface Budget {
4
- blockedUntil: number;
5
- limit: number;
6
- remaining: number;
7
- reset: number;
4
+ export function quotaRetryAt(observations: readonly RateLimitObservation[], retryAt = 0): number {
5
+ return Math.max(retryAt, ...observations.filter((budget) => budget.remaining === 0).map((budget) => DateTime.toEpochMillis(budget.reset)));
8
6
  }
9
- function numeric(headers: Readonly<Record<string, string>>, name: string): number | undefined {
10
- const value = headers[name];
11
- return value !== undefined && Number.isFinite(Number(value)) ? Number(value) : undefined;
12
- }
13
- export function observeBudget(budget: Budget, response: Pick<HttpClientResponse.HttpClientResponse, "headers" | "status">, now: number): void {
14
- budget.remaining = numeric(response.headers, "x-ratelimit-remaining") ?? budget.remaining;
15
- budget.limit = numeric(response.headers, "x-ratelimit-limit") ?? budget.limit;
16
- budget.reset = (numeric(response.headers, "x-ratelimit-reset") ?? budget.reset / 1000) * 1000;
17
- if (response.status === 429 || response.status === 403) {
18
- const retry = response.headers["retry-after"] ?? null;
19
- const until = retryUntil(retry, now);
20
- budget.blockedUntil = Math.max(budget.blockedUntil, Number.isFinite(until) ? until : now + 60_000);
21
- }
22
- if (budget.remaining === 0) {
23
- budget.blockedUntil = Math.max(budget.blockedUntil, budget.reset);
24
- }
25
- }
26
- export function pollDelay(budget: Budget, interval: number, requests: number, now: number): number {
27
- const ratio = budget.remaining / Math.max(1, budget.limit);
28
- const multiplier = slowdown(ratio);
29
- const paced = budget.remaining > 0 && ratio < 0.5 ? Math.ceil((Math.max(0, budget.reset - now) * requests) / budget.remaining) : 0;
30
- return Math.max(interval * multiplier, paced, budget.blockedUntil - now);
31
- }
32
- function retryUntil(retry: string | null, now: number): number {
33
- if (retry === null) {
34
- return now + 60_000;
35
- }
36
- if (Number.isFinite(Number(retry))) {
37
- return now + Number(retry) * 1000;
38
- }
39
- return Date.parse(retry);
7
+
8
+ export function pollDelay(observations: readonly RateLimitObservation[], interval: number, requests: number, now: number, retryAt = 0): number {
9
+ return Math.max(
10
+ interval,
11
+ quotaRetryAt(observations, retryAt) - now,
12
+ ...observations.map((budget) => {
13
+ const ratio = budget.remaining / Math.max(1, budget.limit);
14
+ const reset = DateTime.toEpochMillis(budget.reset);
15
+ const paced = budget.remaining > 0 && ratio < 0.5 ? Math.ceil((Math.max(0, reset - now) * requests) / budget.remaining) : 0;
16
+ return Math.max(interval * slowdown(ratio), paced);
17
+ }),
18
+ );
40
19
  }
41
20
  function slowdown(ratio: number): number {
42
21
  if (ratio < 0.1) {
@@ -1,15 +0,0 @@
1
- import { Effect } from "effect";
2
- import { type HttpClient, type HttpClientResponse } from "effect/http";
3
- import { WatchError } from "./model.ts";
4
- export interface CachedResponse {
5
- readonly body: unknown;
6
- readonly etag: string | null;
7
- readonly next: string | undefined;
8
- }
9
- export declare function fetchResponse(client: HttpClient.HttpClient, url: string, token: string, cached: CachedResponse | undefined, body?: unknown): Effect.Effect<HttpClientResponse.HttpClientResponse, import("effect/http/HttpBody").HttpBodyError | import("effect/http/HttpClientError").HttpClientError, never>;
10
- export declare function decodeResponse(url: string, response: HttpClientResponse.HttpClientResponse): Effect.Effect<{
11
- body: import("effect/Schema").Json;
12
- etag: string | null;
13
- next: string | undefined;
14
- }, WatchError, never>;
15
- //# sourceMappingURL=httpResponse.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"httpResponse.d.ts","sourceRoot":"","sources":["../src/httpResponse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,KAAK,UAAU,EAAqB,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAC1F,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AACD,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,CAAC,UAAU,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,SAAS,EAAE,IAAI,CAAC,EAAE,OAAO,qKAW1I;AACD,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,CAAC,kBAAkB;;;;sBAgB1F"}
@@ -1,31 +0,0 @@
1
- import { Effect } from "effect";
2
- import { HttpClientRequest } from "effect/http";
3
- import { WatchError } from "./model.js";
4
- export function fetchResponse(client, url, token, cached, body) {
5
- const headers = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" };
6
- if (token) {
7
- headers.Authorization = `Bearer ${token}`;
8
- }
9
- if (cached?.etag) {
10
- headers["If-None-Match"] = cached.etag;
11
- }
12
- return body === undefined
13
- ? client.get(url, { headers })
14
- : HttpClientRequest.post(url, { headers }).pipe(HttpClientRequest.bodyJson(body), Effect.flatMap(client.execute));
15
- }
16
- export function decodeResponse(url, response) {
17
- return Effect.gen(function* () {
18
- if (response.status < 200 || response.status >= 300) {
19
- return yield* Effect.fail(new WatchError({ message: `GitHub HTTP ${response.status} for ${new URL(url).pathname}` }));
20
- }
21
- const body = yield* response.json;
22
- const next = response.headers.link?.match(/<(?<url>[^>]+)>; rel="next"/u)?.groups?.url;
23
- yield* Effect.try(() => {
24
- if (next !== undefined && new URL(next).origin !== new URL(url).origin) {
25
- throw new Error("Cross-origin pagination refused");
26
- }
27
- });
28
- return { body, etag: response.headers.etag ?? null, next };
29
- }).pipe(Effect.mapError((cause) => (cause instanceof WatchError ? cause : new WatchError({ message: "Invalid GitHub response or pagination URL" }))));
30
- }
31
- //# sourceMappingURL=httpResponse.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"httpResponse.js","sourceRoot":"","sources":["../src/httpResponse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAmB,iBAAiB,EAA2B,MAAM,aAAa,CAAC;AAC1F,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAMxC,MAAM,UAAU,aAAa,CAAC,MAA6B,EAAE,GAAW,EAAE,KAAa,EAAE,MAAkC,EAAE,IAAc;IAC1I,MAAM,OAAO,GAA2B,EAAE,MAAM,EAAE,6BAA6B,EAAE,sBAAsB,EAAE,YAAY,EAAE,CAAC;IACxH,IAAI,KAAK,EAAE,CAAC;QACX,OAAO,CAAC,aAAa,GAAG,UAAU,KAAK,EAAE,CAAC;IAC3C,CAAC;IACD,IAAI,MAAM,EAAE,IAAI,EAAE,CAAC;QAClB,OAAO,CAAC,eAAe,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC;IACxC,CAAC;IACD,OAAO,IAAI,KAAK,SAAS;QACxB,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC;QAC9B,CAAC,CAAC,iBAAiB,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;AACpH,CAAC;AACD,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,QAA+C;IAC1F,OAAO,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAC1B,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC;YACrD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,EAAE,OAAO,EAAE,eAAe,QAAQ,CAAC,MAAM,QAAQ,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC;QACvH,CAAC;QACD,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAClC,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,8BAA8B,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC;QACvF,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE;YACtB,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC;gBACxE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;YACpD,CAAC;QACF,CAAC,CAAC,CAAC;QACH,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,EAAE,IAAI,EAA2B,CAAC;IACrF,CAAC,CAAC,CAAC,IAAI,CACN,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,YAAY,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,EAAE,OAAO,EAAE,2CAA2C,EAAE,CAAC,CAAC,CAAC,CAC5I,CAAC;AACH,CAAC"}
@@ -1,37 +0,0 @@
1
- import { Effect } from "effect";
2
- import { type HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
3
- import { WatchError } from "./model.ts";
4
- export interface CachedResponse {
5
- readonly body: unknown;
6
- readonly etag: string | null;
7
- readonly next: string | undefined;
8
- }
9
- export function fetchResponse(client: HttpClient.HttpClient, url: string, token: string, cached: CachedResponse | undefined, body?: unknown) {
10
- const headers: Record<string, string> = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" };
11
- if (token) {
12
- headers.Authorization = `Bearer ${token}`;
13
- }
14
- if (cached?.etag) {
15
- headers["If-None-Match"] = cached.etag;
16
- }
17
- return body === undefined
18
- ? client.get(url, { headers })
19
- : HttpClientRequest.post(url, { headers }).pipe(HttpClientRequest.bodyJson(body), Effect.flatMap(client.execute));
20
- }
21
- export function decodeResponse(url: string, response: HttpClientResponse.HttpClientResponse) {
22
- return Effect.gen(function* () {
23
- if (response.status < 200 || response.status >= 300) {
24
- return yield* Effect.fail(new WatchError({ message: `GitHub HTTP ${response.status} for ${new URL(url).pathname}` }));
25
- }
26
- const body = yield* response.json;
27
- const next = response.headers.link?.match(/<(?<url>[^>]+)>; rel="next"/u)?.groups?.url;
28
- yield* Effect.try(() => {
29
- if (next !== undefined && new URL(next).origin !== new URL(url).origin) {
30
- throw new Error("Cross-origin pagination refused");
31
- }
32
- });
33
- return { body, etag: response.headers.etag ?? null, next } satisfies CachedResponse;
34
- }).pipe(
35
- Effect.mapError((cause) => (cause instanceof WatchError ? cause : new WatchError({ message: "Invalid GitHub response or pagination URL" }))),
36
- );
37
- }