@blogic-cz/agent-tools 1.7.5 → 1.9.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 (142) hide show
  1. package/README.md +2 -1
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/types.d.ts +1 -0
  4. package/dist/config/types.d.ts.map +1 -1
  5. package/dist/credential-guard/index.d.ts.map +1 -1
  6. package/dist/db-tool/service.d.ts.map +1 -1
  7. package/dist/gh-tool/branch.d.ts +2 -3
  8. package/dist/gh-tool/branch.d.ts.map +1 -1
  9. package/dist/gh-tool/environment.d.ts +1 -1
  10. package/dist/gh-tool/environment.d.ts.map +1 -1
  11. package/dist/gh-tool/gist.d.ts +8 -28
  12. package/dist/gh-tool/gist.d.ts.map +1 -1
  13. package/dist/gh-tool/issue/commands.d.ts +11 -9
  14. package/dist/gh-tool/issue/commands.d.ts.map +1 -1
  15. package/dist/gh-tool/issue/core.d.ts +116 -9
  16. package/dist/gh-tool/issue/core.d.ts.map +1 -1
  17. package/dist/gh-tool/issue/triage.d.ts +6 -6
  18. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  19. package/dist/gh-tool/label.d.ts +20 -0
  20. package/dist/gh-tool/label.d.ts.map +1 -0
  21. package/dist/gh-tool/pr/commands.d.ts +53 -46
  22. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  23. package/dist/gh-tool/pr/core.d.ts +156 -86
  24. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  25. package/dist/gh-tool/pr/review.d.ts +16 -16
  26. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  27. package/dist/gh-tool/pr/snapshot.d.ts +9 -0
  28. package/dist/gh-tool/pr/snapshot.d.ts.map +1 -0
  29. package/dist/gh-tool/pr/stack-read.d.ts +1 -6
  30. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -1
  31. package/dist/gh-tool/pr/stack.d.ts +3 -3
  32. package/dist/gh-tool/pr/stack.d.ts.map +1 -1
  33. package/dist/gh-tool/{api.d.ts → read/api.d.ts} +9 -3
  34. package/dist/gh-tool/read/api.d.ts.map +1 -0
  35. package/dist/gh-tool/read/cache.d.ts +44 -0
  36. package/dist/gh-tool/read/cache.d.ts.map +1 -0
  37. package/dist/gh-tool/read/catalog.d.ts +170 -0
  38. package/dist/gh-tool/read/catalog.d.ts.map +1 -0
  39. package/dist/gh-tool/read/classes.d.ts +98 -0
  40. package/dist/gh-tool/read/classes.d.ts.map +1 -0
  41. package/dist/gh-tool/read/commands.d.ts +3 -0
  42. package/dist/gh-tool/read/commands.d.ts.map +1 -0
  43. package/dist/gh-tool/read/commits.d.ts +10 -0
  44. package/dist/gh-tool/read/commits.d.ts.map +1 -0
  45. package/dist/gh-tool/read/gated.d.ts +14 -0
  46. package/dist/gh-tool/read/gated.d.ts.map +1 -0
  47. package/dist/gh-tool/read/index.d.ts +5 -0
  48. package/dist/gh-tool/read/index.d.ts.map +1 -0
  49. package/dist/gh-tool/read/issues.d.ts +80 -0
  50. package/dist/gh-tool/read/issues.d.ts.map +1 -0
  51. package/dist/gh-tool/read/keys.d.ts +64 -0
  52. package/dist/gh-tool/read/keys.d.ts.map +1 -0
  53. package/dist/gh-tool/read/porcelain.d.ts +68 -0
  54. package/dist/gh-tool/read/porcelain.d.ts.map +1 -0
  55. package/dist/gh-tool/read/pulls.d.ts +137 -0
  56. package/dist/gh-tool/read/pulls.d.ts.map +1 -0
  57. package/dist/gh-tool/read/reads.d.ts +220 -0
  58. package/dist/gh-tool/read/reads.d.ts.map +1 -0
  59. package/dist/gh-tool/read/rest.d.ts +49 -0
  60. package/dist/gh-tool/read/rest.d.ts.map +1 -0
  61. package/dist/gh-tool/read/review-guards.d.ts +20 -0
  62. package/dist/gh-tool/read/review-guards.d.ts.map +1 -0
  63. package/dist/gh-tool/read/review-model.d.ts +190 -0
  64. package/dist/gh-tool/read/review-model.d.ts.map +1 -0
  65. package/dist/gh-tool/read/reviews.d.ts +9 -0
  66. package/dist/gh-tool/read/reviews.d.ts.map +1 -0
  67. package/dist/gh-tool/read/stacks.d.ts +7 -0
  68. package/dist/gh-tool/read/stacks.d.ts.map +1 -0
  69. package/dist/gh-tool/read/store.d.ts +70 -0
  70. package/dist/gh-tool/read/store.d.ts.map +1 -0
  71. package/dist/gh-tool/{service.d.ts → read/transport.d.ts} +5 -3
  72. package/dist/gh-tool/read/transport.d.ts.map +1 -0
  73. package/dist/gh-tool/read/waits.d.ts +22 -0
  74. package/dist/gh-tool/read/waits.d.ts.map +1 -0
  75. package/dist/gh-tool/read/writes.d.ts +16 -0
  76. package/dist/gh-tool/read/writes.d.ts.map +1 -0
  77. package/dist/gh-tool/release.d.ts +10 -26
  78. package/dist/gh-tool/release.d.ts.map +1 -1
  79. package/dist/gh-tool/repo.d.ts +9 -4
  80. package/dist/gh-tool/repo.d.ts.map +1 -1
  81. package/dist/gh-tool/types.d.ts +1 -1
  82. package/dist/gh-tool/types.d.ts.map +1 -1
  83. package/dist/gh-tool/workflow.d.ts +43 -21
  84. package/dist/gh-tool/workflow.d.ts.map +1 -1
  85. package/dist/observability-tool/shared.d.ts.map +1 -1
  86. package/dist/shared/content-security.d.ts +2 -0
  87. package/dist/shared/content-security.d.ts.map +1 -1
  88. package/package.json +7 -6
  89. package/schemas/agent-tools.schema.json +5 -0
  90. package/src/api-tool/proxy-entry.ts +3 -1
  91. package/src/config/loader.ts +1 -0
  92. package/src/config/types.ts +1 -0
  93. package/src/credential-guard/index.ts +169 -1
  94. package/src/db-tool/service.ts +4 -1
  95. package/src/gh-tool/branch.ts +5 -8
  96. package/src/gh-tool/environment.ts +2 -1
  97. package/src/gh-tool/gist.ts +8 -35
  98. package/src/gh-tool/issue/commands.ts +12 -2
  99. package/src/gh-tool/issue/core.ts +58 -122
  100. package/src/gh-tool/issue/triage.ts +4 -26
  101. package/src/gh-tool/label.ts +57 -0
  102. package/src/gh-tool/main.ts +24 -4
  103. package/src/gh-tool/pr/commands.ts +17 -15
  104. package/src/gh-tool/pr/core.ts +486 -582
  105. package/src/gh-tool/pr/review.ts +106 -454
  106. package/src/gh-tool/pr/snapshot.ts +23 -0
  107. package/src/gh-tool/pr/stack-read.ts +1 -73
  108. package/src/gh-tool/pr/stack.ts +37 -36
  109. package/src/gh-tool/{api.ts → read/api.ts} +40 -7
  110. package/src/gh-tool/read/cache.ts +192 -0
  111. package/src/gh-tool/read/catalog.ts +310 -0
  112. package/src/gh-tool/read/classes.ts +31 -0
  113. package/src/gh-tool/read/commands.ts +25 -0
  114. package/src/gh-tool/read/commits.ts +28 -0
  115. package/src/gh-tool/read/gated.ts +56 -0
  116. package/src/gh-tool/read/index.ts +4 -0
  117. package/src/gh-tool/read/issues.ts +124 -0
  118. package/src/gh-tool/read/keys.ts +104 -0
  119. package/src/gh-tool/read/porcelain.ts +58 -0
  120. package/src/gh-tool/read/pulls.ts +266 -0
  121. package/src/gh-tool/read/reads.ts +306 -0
  122. package/src/gh-tool/read/rest.ts +111 -0
  123. package/src/gh-tool/read/review-guards.ts +61 -0
  124. package/src/gh-tool/read/review-model.ts +286 -0
  125. package/src/gh-tool/read/reviews.ts +114 -0
  126. package/src/gh-tool/read/stacks.ts +73 -0
  127. package/src/gh-tool/read/store.ts +504 -0
  128. package/src/gh-tool/{service.ts → read/transport.ts} +26 -6
  129. package/src/gh-tool/read/waits.ts +52 -0
  130. package/src/gh-tool/read/writes.ts +37 -0
  131. package/src/gh-tool/release.ts +15 -168
  132. package/src/gh-tool/repo.ts +26 -78
  133. package/src/gh-tool/types.ts +1 -1
  134. package/src/gh-tool/workflow.ts +123 -214
  135. package/src/observability-tool/shared.ts +2 -1
  136. package/src/shared/content-security.ts +10 -1
  137. package/src/shared/credential-patterns.ts +1 -1
  138. package/dist/gh-tool/api.d.ts.map +0 -1
  139. package/dist/gh-tool/pr/check-status-cache.d.ts +0 -6
  140. package/dist/gh-tool/pr/check-status-cache.d.ts.map +0 -1
  141. package/dist/gh-tool/service.d.ts.map +0 -1
  142. package/src/gh-tool/pr/check-status-cache.ts +0 -142
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+
3
+ export const resolveTarget = <A extends { number: number }, E, R>(
4
+ pr: number | null,
5
+ current: Effect.Effect<A, E, R>,
6
+ ) => (pr === null ? current.pipe(Effect.map((value) => value.number)) : Effect.succeed(pr));
7
+
8
+ export const stableHead = <S, A, E1, R1, E2, R2>(
9
+ initial: S,
10
+ collect: (state: S) => Effect.Effect<A, E1, R1>,
11
+ refresh: (state: S) => Effect.Effect<S, E2, R2>,
12
+ unchanged: (before: S, after: S) => boolean,
13
+ ): Effect.Effect<{ state: S; value: A } | null, E1 | E2, R1 | R2> =>
14
+ Effect.gen(function* () {
15
+ let before = initial;
16
+ for (let attempt = 0; attempt < 3; attempt += 1) {
17
+ const value = yield* collect(before);
18
+ const after = yield* refresh(before);
19
+ if (unchanged(before, after)) return { state: after, value };
20
+ before = after;
21
+ }
22
+ return null;
23
+ });
@@ -1,73 +1 @@
1
- import { Effect } from "effect";
2
-
3
- import { GitHubService } from "#gh/service";
4
-
5
- import type { StackMember, StackView } from "#gh/types";
6
-
7
- type StacksListResponse = Array<{ number: number }>;
8
-
9
- type StackResponse = {
10
- number: number;
11
- base: { ref: string };
12
- open: boolean;
13
- pull_requests: Array<{
14
- number: number;
15
- title: string;
16
- state: "open" | "closed";
17
- merged_at: string | null;
18
- draft: boolean;
19
- html_url: string;
20
- head: { ref: string };
21
- base: { ref: string };
22
- }>;
23
- };
24
-
25
- export const readStack = Effect.fn("pr.readStack")(function* (opts: { pr: number }) {
26
- const gh = yield* GitHubService;
27
- const repo = yield* gh.getRepoInfo();
28
- const base = `repos/${repo.owner}/${repo.name}`;
29
-
30
- const unstacked: StackView = {
31
- pr: opts.pr,
32
- isStacked: false,
33
- stackNumber: null,
34
- baseRef: null,
35
- members: [],
36
- };
37
-
38
- // A 404 is the repository having no stacks surface at all, which reads the same as a PR
39
- // that belongs to no stack. Every caller gets that reading from here, not its own.
40
- const stacks = yield* gh
41
- .apiRequest<StacksListResponse>({ path: `${base}/stacks?pull_request=${opts.pr}` })
42
- .pipe(Effect.catchTag("GitHubNotFoundError", () => Effect.succeed(null)));
43
-
44
- if (stacks === null) {
45
- return unstacked;
46
- }
47
-
48
- const stackNumber = stacks.body?.[0]?.number;
49
- if (stackNumber === undefined) {
50
- return unstacked;
51
- }
52
-
53
- const stack = yield* gh.apiRequest<StackResponse>({ path: `${base}/stacks/${stackNumber}` });
54
-
55
- const members: StackMember[] = stack.body.pull_requests.map((member, index) => ({
56
- position: index + 1,
57
- number: member.number,
58
- title: member.title,
59
- headRefName: member.head.ref,
60
- baseRefName: member.base.ref,
61
- state: member.merged_at === null ? member.state : "merged",
62
- isDraft: member.draft,
63
- url: member.html_url,
64
- }));
65
-
66
- return {
67
- pr: opts.pr,
68
- isStacked: true,
69
- stackNumber: stack.body.number,
70
- baseRef: stack.body.base.ref,
71
- members,
72
- } satisfies StackView;
73
- });
1
+ export { readStack } from "#gh/read/stacks";
@@ -1,7 +1,11 @@
1
1
  import { Effect } from "effect";
2
2
 
3
- import { pollUntilResolved } from "#shared/poll-until-resolved";
4
- import { GitHubService } from "#gh/service";
3
+ import { waits } from "#gh/read/waits";
4
+ import { GitHubService } from "#gh/read/transport";
5
+ import { writes } from "#gh/read/writes";
6
+ import { repoSlug } from "#gh/read/keys";
7
+ import { passthrough } from "#gh/read/catalog";
8
+ import type { AsyncMergeResult } from "#gh/read/catalog";
5
9
  import { GitHubMergeError } from "#gh/errors";
6
10
 
7
11
  import type { MergeStrategy, StackMember, StackMergeBlocker, StackMergeResult } from "#gh/types";
@@ -11,21 +15,9 @@ import { readStack } from "./stack-read";
11
15
 
12
16
  export { readStack };
13
17
 
14
- type AsyncMergeDetails = {
15
- message?: string;
16
- uuid?: string;
17
- sha?: string;
18
- merge_method?: MergeStrategy;
19
- };
20
-
21
- type AsyncMergeResult = {
22
- status: "pending" | "merged" | "enqueued" | "failed";
23
- details?: AsyncMergeDetails;
24
- };
25
-
26
18
  // One request merges every open member, so the server-side work scales with the stack;
27
19
  // the single-PR budget in core.ts is deliberately shorter.
28
- const POLL_INTERVAL_MS = 2000;
20
+ const POLL_INTERVAL_MS = 5000;
29
21
  const MAX_WAIT_SECONDS = 300;
30
22
 
31
23
  const stackMergeFailure = (opts: {
@@ -164,12 +156,15 @@ export const mergeStack = Effect.fn("pr.mergeStack")(function* (opts: {
164
156
 
165
157
  const asyncPath = `repos/${repo.owner}/${repo.name}/pulls/${target.number}/merge-async`;
166
158
 
167
- const requested = yield* gh.apiRequest<AsyncMergeResult>({
168
- path: asyncPath,
169
- method: "PUT",
170
- body: { merge_method: opts.strategy, merge_action: "direct_merge" },
171
- alsoAcceptStatus: [202, 409],
172
- });
159
+ const requested = yield* writes.api<AsyncMergeResult>(
160
+ {
161
+ path: asyncPath,
162
+ method: "PUT",
163
+ body: { merge_method: opts.strategy, merge_action: "direct_merge" },
164
+ alsoAcceptStatus: [202, 409],
165
+ },
166
+ [`repo:${repoSlug(repo)}`],
167
+ );
173
168
 
174
169
  // A 409 hands back an existing request whose options may differ from the ones asked
175
170
  // for, so the result reports the strategy GitHub is actually applying.
@@ -188,16 +183,19 @@ export const mergeStack = Effect.fn("pr.mergeStack")(function* (opts: {
188
183
  }
189
184
 
190
185
  if (uuid !== undefined) {
191
- latest = yield* pollUntilResolved({
192
- initial: latest,
193
- isPending: (value) => value.status === "pending",
194
- fetchLatest: () =>
195
- gh
196
- .apiRequest<AsyncMergeResult>({ path: `${asyncPath}/${uuid}` })
197
- .pipe(Effect.map((response) => response.body)),
198
- intervalMs: POLL_INTERVAL_MS,
199
- budgetSeconds: MAX_WAIT_SECONDS,
200
- });
186
+ const outcome = yield* waits.until(
187
+ passthrough
188
+ .stackMergeStatus(`${asyncPath}/${uuid}`)
189
+ .pipe(Effect.map((response) => response.body)),
190
+ (value) => value.status !== "pending",
191
+ {
192
+ initial: { value: latest },
193
+ intervalMs: POLL_INTERVAL_MS,
194
+ timeoutMs: MAX_WAIT_SECONDS * 1000,
195
+ readAtDeadline: true,
196
+ },
197
+ );
198
+ latest = outcome.kind === "done" ? outcome.value : outcome.last;
201
199
  }
202
200
 
203
201
  if (latest.status === "merged") {
@@ -269,11 +267,14 @@ export const unstackStack = Effect.fn("pr.unstackStack")(function* (opts: {
269
267
 
270
268
  // The endpoint removes every unmerged member at once; it has no per-PR form. A 204 means
271
269
  // nothing was left and the stack is gone, a 200 means some members could not be removed.
272
- const result = yield* gh.apiRequest<unknown>({
273
- path: `repos/${repo.owner}/${repo.name}/stacks/${view.stackNumber}/unstack`,
274
- method: "POST",
275
- alsoAcceptStatus: [204],
276
- });
270
+ const result = yield* writes.api<unknown>(
271
+ {
272
+ path: `repos/${repo.owner}/${repo.name}/stacks/${view.stackNumber}/unstack`,
273
+ method: "POST",
274
+ alsoAcceptStatus: [204],
275
+ },
276
+ [`repo:${repoSlug(repo)}`],
277
+ );
277
278
 
278
279
  return {
279
280
  ...plan,
@@ -1,11 +1,12 @@
1
- import { Clock, Effect } from "effect";
1
+ import { Clock, Context, Effect } from "effect";
2
2
 
3
+ import { registerResolvedSecret } from "#shared/content-security";
3
4
  import { retryTransient } from "#shared/retry-transient";
4
5
 
5
- import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
6
- import type { GitHubApiError } from "./errors";
7
- import { ghEnvironment } from "./environment";
8
- import { validateOutboundText } from "./text-input";
6
+ import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "#gh/errors";
7
+ import type { GitHubApiError } from "#gh/errors";
8
+ import { ghEnvironment } from "#gh/environment";
9
+ import { validateOutboundText } from "#gh/text-input";
9
10
 
10
11
  // Direct HTTP, not `gh api`: the CLI collapses every failure into a non-zero exit and
11
12
  // loses the status code, but merge-async answers 202 (accepted), 200 (already merged or
@@ -17,6 +18,10 @@ const GITHUB_API_VERSION = "2022-11-28";
17
18
  export type GitHubApiResponse<T> = {
18
19
  status: number;
19
20
  body: T;
21
+ etag?: string;
22
+ link?: string;
23
+ rateLimitResource?: string;
24
+ rateLimitUsed?: string;
20
25
  };
21
26
 
22
27
  const authFailure = (message: string) =>
@@ -29,7 +34,16 @@ const authFailure = (message: string) =>
29
34
  // Environment first, GH_TOKEN before GITHUB_TOKEN to match the gh CLI: the active gh
30
35
  // account is directory-scoped global state and the shell exports the matching token.
31
36
  // `gh auth token` only covers shells that export none.
37
+ export const SessionCredential = Context.Reference<string | null>(
38
+ "@agent-tools/SessionCredential",
39
+ {
40
+ defaultValue: () => null,
41
+ },
42
+ );
43
+
32
44
  export const resolveGitHubToken = Effect.fn("gh.resolveGitHubToken")(function* () {
45
+ const pinned = yield* SessionCredential;
46
+ if (pinned !== null) return pinned;
33
47
  const fromEnv = [process.env["GH_TOKEN"], process.env["GITHUB_TOKEN"]].find(
34
48
  (candidate) => candidate !== undefined && candidate.length > 0,
35
49
  );
@@ -57,6 +71,7 @@ export const resolveGitHubToken = Effect.fn("gh.resolveGitHubToken")(function* (
57
71
  return yield* authFailure("No GitHub token available.");
58
72
  }
59
73
 
74
+ registerResolvedSecret(token);
60
75
  return token;
61
76
  });
62
77
 
@@ -65,6 +80,7 @@ export type GitHubApiRequest = {
65
80
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
66
81
  body?: unknown;
67
82
  alsoAcceptStatus?: number[];
83
+ ifNoneMatch?: string;
68
84
  };
69
85
 
70
86
  const MAX_API_RETRIES = 2;
@@ -98,6 +114,7 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
98
114
  Accept: GITHUB_ACCEPT,
99
115
  Authorization: `Bearer ${token}`,
100
116
  "X-GitHub-Api-Version": GITHUB_API_VERSION,
117
+ ...(opts.ifNoneMatch === undefined ? {} : { "If-None-Match": opts.ifNoneMatch }),
101
118
  ...(serializedBody === undefined ? {} : { "Content-Type": "application/json" }),
102
119
  },
103
120
  ...(serializedBody === undefined ? {} : { body: serializedBody }),
@@ -126,9 +143,23 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
126
143
  });
127
144
  const parsed: unknown = text.length === 0 ? null : safeJsonParse(text);
128
145
 
129
- const accepted = new Set([200, ...(opts.alsoAcceptStatus ?? [])]);
146
+ const accepted = new Set([
147
+ 200,
148
+ ...(opts.alsoAcceptStatus ?? []),
149
+ ...(method === "GET" && opts.ifNoneMatch !== undefined ? [304] : []),
150
+ ]);
130
151
  if (accepted.has(response.status)) {
131
- return { status: response.status, body: parsed as T };
152
+ const metadata = {
153
+ etag: response.headers.get("etag"),
154
+ link: response.headers.get("link"),
155
+ rateLimitResource: response.headers.get("x-ratelimit-resource"),
156
+ rateLimitUsed: response.headers.get("x-ratelimit-used"),
157
+ };
158
+ return {
159
+ status: response.status,
160
+ body: parsed as T,
161
+ ...Object.fromEntries(Object.entries(metadata).filter(([, value]) => value !== null)),
162
+ };
132
163
  }
133
164
 
134
165
  if (response.status === 401) {
@@ -193,10 +224,12 @@ export const githubApi = <T>(
193
224
  const path = opts.path;
194
225
  const method = opts.method ?? "GET";
195
226
  const alsoAcceptStatus = opts.alsoAcceptStatus;
227
+ const ifNoneMatch = opts.ifNoneMatch;
196
228
  const request = {
197
229
  path,
198
230
  method,
199
231
  ...(alsoAcceptStatus === undefined ? {} : { alsoAcceptStatus: [...alsoAcceptStatus] }),
232
+ ...(ifNoneMatch === undefined ? {} : { ifNoneMatch }),
200
233
  };
201
234
  const body = opts.body;
202
235
  if (body === undefined) return { request, serialized: undefined, decoded: "" };
@@ -0,0 +1,192 @@
1
+ import { Console, Context, Effect } from "effect";
2
+ import { resolveGitHubToken, SessionCredential } from "#gh/read/api";
3
+ import { GitHubCommandError } from "#gh/errors";
4
+ import { digest } from "./keys";
5
+ import type { ReadKey, RepoRef, Tag } from "./keys";
6
+ import type { CacheClass } from "./classes";
7
+ import { FileStore } from "./store";
8
+ import type { ReadFence } from "./store";
9
+
10
+ type CacheSession = { store: FileStore | null; warned: boolean; staleWarned?: boolean };
11
+ export const ReadCache = Context.Reference<CacheSession | null>("@agent-tools/ReadCache", {
12
+ defaultValue: () => null,
13
+ });
14
+
15
+ const disable = (session: CacheSession, error: unknown) =>
16
+ Effect.gen(function* () {
17
+ session.store = null;
18
+ if (session.warned) return;
19
+ session.warned = true;
20
+ yield* Console.error(
21
+ `gh-tool: cache off (${error instanceof Error ? error.message : String(error)})`,
22
+ );
23
+ });
24
+ const safe = <A>(session: CacheSession, io: () => Promise<A>) =>
25
+ Effect.tryPromise({ try: io, catch: (error) => error }).pipe(
26
+ Effect.catch((error) => disable(session, error).pipe(Effect.as(null))),
27
+ );
28
+
29
+ export const withReadCache = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
30
+ Effect.gen(function* () {
31
+ if (process.env["GH_TOOL_CACHE"] === "off") return yield* effect;
32
+ const token = yield* resolveGitHubToken().pipe(Effect.orElseSucceed(() => null));
33
+ if (token === null) return yield* effect;
34
+ const session: CacheSession = { store: null, warned: false };
35
+ session.store = yield* safe(session, () => FileStore.open(digest(token)));
36
+ return yield* effect.pipe(
37
+ Effect.provideService(ReadCache, session),
38
+ Effect.provideService(SessionCredential, token),
39
+ );
40
+ });
41
+
42
+ export type Fetched<A> =
43
+ | { readonly kind: "fresh"; readonly value: A; readonly etag?: string }
44
+ | { readonly kind: "unchanged" };
45
+ export const cachedRead = <A, E, R>(opts: {
46
+ readonly key: ReadKey;
47
+ readonly policy: CacheClass;
48
+ readonly decode: (value: unknown) => A;
49
+ readonly fetch: (
50
+ previous: { value: A; etag?: string; valid: boolean } | null,
51
+ ) => Effect.Effect<Fetched<A>, E, R>;
52
+ readonly immutableWhen?: (value: A) => boolean;
53
+ readonly policyForValue?: (value: A) => CacheClass;
54
+ readonly consistency?: "cached" | "origin";
55
+ readonly validateOrigin?: boolean;
56
+ readonly cacheableWhen?: (value: A) => boolean;
57
+ }): Effect.Effect<A, E, R> =>
58
+ Effect.gen(function* () {
59
+ const session = yield* ReadCache;
60
+ const store = session?.store;
61
+ const direct = () =>
62
+ opts
63
+ .fetch(null)
64
+ .pipe(
65
+ Effect.flatMap((fetched) =>
66
+ fetched.kind === "fresh"
67
+ ? Effect.succeed(fetched.value)
68
+ : Effect.die(new Error("304 without a cached value")),
69
+ ),
70
+ );
71
+ if (
72
+ session === null ||
73
+ store === null ||
74
+ store === undefined ||
75
+ opts.policy.kind === "passthrough" ||
76
+ (opts.consistency === "origin" && opts.validateOrigin !== true)
77
+ )
78
+ return yield* direct();
79
+ const prepared = yield* safe(session, () =>
80
+ store.prepare(
81
+ opts.key,
82
+ opts.policy,
83
+ opts.decode,
84
+ opts.policyForValue,
85
+ opts.consistency === "origin",
86
+ ),
87
+ );
88
+ if (prepared === null) return yield* direct();
89
+ if (prepared.kind === "hit") return prepared.value;
90
+ return yield* Effect.gen(function* () {
91
+ yield* safe(session, () => store.record(opts.key.op, "fetch"));
92
+ const previousValid =
93
+ prepared.previous === null
94
+ ? false
95
+ : yield* safe(session, () => store.canServeStale(prepared));
96
+ const fetched = yield* opts.fetch(
97
+ prepared.previous === null ? null : { ...prepared.previous, valid: previousValid === true },
98
+ );
99
+ if (fetched.kind === "unchanged") {
100
+ if (prepared.previous === null)
101
+ return yield* Effect.die(new Error("304 without a cached value"));
102
+ const previous = prepared.previous;
103
+ yield* safe(session, () => store.record(opts.key.op, "notModified"));
104
+ yield* safe(session, () =>
105
+ store.put(prepared, previous.value, previous.immutable, previous.etag),
106
+ );
107
+ return previous.value;
108
+ }
109
+ if (opts.cacheableWhen?.(fetched.value) !== false)
110
+ yield* safe(session, () =>
111
+ store.put(
112
+ prepared,
113
+ fetched.value,
114
+ opts.immutableWhen?.(fetched.value) ?? opts.policy.kind === "immutable",
115
+ fetched.etag,
116
+ ),
117
+ );
118
+ return fetched.value;
119
+ }).pipe(
120
+ Effect.catch((error) =>
121
+ Effect.gen(function* () {
122
+ const rateLimited =
123
+ error instanceof GitHubCommandError &&
124
+ (error.exitCode === 429 ||
125
+ /rate limit|abuse detection/i.test(`${error.message}\n${error.stderr}`));
126
+ if (opts.consistency === "origin" || !rateLimited || prepared.previous === null)
127
+ return yield* Effect.fail(error);
128
+ const valid = yield* safe(session, () => store.canServeStale(prepared));
129
+ if (valid !== true) return yield* Effect.fail(error);
130
+ if (!session.staleWarned) {
131
+ session.staleWarned = true;
132
+ yield* Console.error(`gh-tool: served cached ${opts.key.op} (rate limited)`);
133
+ }
134
+ yield* safe(session, () => store.record(opts.key.op, "stale"));
135
+ return prepared.previous.value;
136
+ }),
137
+ ),
138
+ Effect.ensuring(safe(session, () => store.release(prepared.key, prepared.owner))),
139
+ );
140
+ });
141
+
142
+ export const readFence = Effect.gen(function* () {
143
+ const session = yield* ReadCache;
144
+ if (session === null || session.store === null) return null;
145
+ const store = session.store;
146
+ return yield* safe(session, () => store.readClock());
147
+ });
148
+
149
+ export const seedRead = <A>(key: ReadKey, value: A, clock: ReadFence | null) =>
150
+ Effect.gen(function* () {
151
+ const session = yield* ReadCache;
152
+ if (session === null || session.store === null || clock === null) return;
153
+ const store = session.store;
154
+ yield* safe(session, async () => {
155
+ const prepared = await store.prepare(
156
+ key,
157
+ { kind: "passthrough" },
158
+ (raw) => raw,
159
+ undefined,
160
+ true,
161
+ );
162
+ if (prepared.kind !== "fetch") return;
163
+ try {
164
+ await store.put({ ...prepared, startedAtClock: clock }, value, false);
165
+ } finally {
166
+ await store.release(prepared.key, prepared.owner);
167
+ }
168
+ });
169
+ });
170
+
171
+ export const invalidate = (tags: readonly Tag[]) =>
172
+ Effect.gen(function* () {
173
+ const session = yield* ReadCache;
174
+ const store = session?.store;
175
+ if (session !== null && store !== null && store !== undefined)
176
+ yield* safe(session, () => store.bump(tags));
177
+ });
178
+
179
+ export const cacheStats = Effect.gen(function* () {
180
+ const session = yield* ReadCache;
181
+ const store = session?.store;
182
+ if (session === null || store === null || store === undefined) return {};
183
+ return (yield* safe(session, () => store.stats())) ?? {};
184
+ });
185
+
186
+ export const clearCache = (repo: RepoRef | null) =>
187
+ Effect.gen(function* () {
188
+ const session = yield* ReadCache;
189
+ const store = session?.store;
190
+ if (session === null || store === null || store === undefined) return false;
191
+ return (yield* safe(session, () => store.clear(repo))) !== null;
192
+ });