@blogic-cz/agent-tools 1.7.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/gh-tool/branch.d.ts +2 -3
- package/dist/gh-tool/branch.d.ts.map +1 -1
- package/dist/gh-tool/environment.d.ts +1 -1
- package/dist/gh-tool/environment.d.ts.map +1 -1
- package/dist/gh-tool/gist.d.ts +8 -28
- package/dist/gh-tool/gist.d.ts.map +1 -1
- package/dist/gh-tool/issue/commands.d.ts +9 -9
- package/dist/gh-tool/issue/commands.d.ts.map +1 -1
- package/dist/gh-tool/issue/core.d.ts +103 -9
- package/dist/gh-tool/issue/core.d.ts.map +1 -1
- package/dist/gh-tool/issue/triage.d.ts +6 -6
- package/dist/gh-tool/issue/triage.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts +53 -46
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts +179 -74
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts +16 -16
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/pr/snapshot.d.ts +9 -0
- package/dist/gh-tool/pr/snapshot.d.ts.map +1 -0
- package/dist/gh-tool/pr/stack-read.d.ts +1 -6
- package/dist/gh-tool/pr/stack-read.d.ts.map +1 -1
- package/dist/gh-tool/pr/stack.d.ts +3 -3
- package/dist/gh-tool/pr/stack.d.ts.map +1 -1
- package/dist/gh-tool/{api.d.ts → read/api.d.ts} +9 -3
- package/dist/gh-tool/read/api.d.ts.map +1 -0
- package/dist/gh-tool/read/cache.d.ts +44 -0
- package/dist/gh-tool/read/cache.d.ts.map +1 -0
- package/dist/gh-tool/read/catalog.d.ts +170 -0
- package/dist/gh-tool/read/catalog.d.ts.map +1 -0
- package/dist/gh-tool/read/classes.d.ts +94 -0
- package/dist/gh-tool/read/classes.d.ts.map +1 -0
- package/dist/gh-tool/read/commands.d.ts +3 -0
- package/dist/gh-tool/read/commands.d.ts.map +1 -0
- package/dist/gh-tool/read/gated.d.ts +14 -0
- package/dist/gh-tool/read/gated.d.ts.map +1 -0
- package/dist/gh-tool/read/index.d.ts +5 -0
- package/dist/gh-tool/read/index.d.ts.map +1 -0
- package/dist/gh-tool/read/issues.d.ts +68 -0
- package/dist/gh-tool/read/issues.d.ts.map +1 -0
- package/dist/gh-tool/read/keys.d.ts +61 -0
- package/dist/gh-tool/read/keys.d.ts.map +1 -0
- package/dist/gh-tool/read/porcelain.d.ts +68 -0
- package/dist/gh-tool/read/porcelain.d.ts.map +1 -0
- package/dist/gh-tool/read/pulls.d.ts +137 -0
- package/dist/gh-tool/read/pulls.d.ts.map +1 -0
- package/dist/gh-tool/read/reads.d.ts +209 -0
- package/dist/gh-tool/read/reads.d.ts.map +1 -0
- package/dist/gh-tool/read/rest.d.ts +49 -0
- package/dist/gh-tool/read/rest.d.ts.map +1 -0
- package/dist/gh-tool/read/review-guards.d.ts +20 -0
- package/dist/gh-tool/read/review-guards.d.ts.map +1 -0
- package/dist/gh-tool/read/review-model.d.ts +190 -0
- package/dist/gh-tool/read/review-model.d.ts.map +1 -0
- package/dist/gh-tool/read/reviews.d.ts +9 -0
- package/dist/gh-tool/read/reviews.d.ts.map +1 -0
- package/dist/gh-tool/read/stacks.d.ts +7 -0
- package/dist/gh-tool/read/stacks.d.ts.map +1 -0
- package/dist/gh-tool/read/store.d.ts +70 -0
- package/dist/gh-tool/read/store.d.ts.map +1 -0
- package/dist/gh-tool/{service.d.ts → read/transport.d.ts} +5 -3
- package/dist/gh-tool/read/transport.d.ts.map +1 -0
- package/dist/gh-tool/read/waits.d.ts +22 -0
- package/dist/gh-tool/read/waits.d.ts.map +1 -0
- package/dist/gh-tool/read/writes.d.ts +16 -0
- package/dist/gh-tool/read/writes.d.ts.map +1 -0
- package/dist/gh-tool/release.d.ts +10 -26
- package/dist/gh-tool/release.d.ts.map +1 -1
- package/dist/gh-tool/repo.d.ts +4 -4
- package/dist/gh-tool/repo.d.ts.map +1 -1
- package/dist/gh-tool/types.d.ts +1 -1
- package/dist/gh-tool/types.d.ts.map +1 -1
- package/dist/gh-tool/workflow.d.ts +43 -21
- package/dist/gh-tool/workflow.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +2 -0
- package/dist/shared/content-security.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/api-tool/proxy-entry.ts +3 -1
- package/src/db-tool/service.ts +4 -1
- package/src/gh-tool/branch.ts +5 -8
- package/src/gh-tool/environment.ts +2 -1
- package/src/gh-tool/gist.ts +8 -35
- package/src/gh-tool/issue/commands.ts +1 -1
- package/src/gh-tool/issue/core.ts +55 -122
- package/src/gh-tool/issue/triage.ts +4 -26
- package/src/gh-tool/main.ts +5 -2
- package/src/gh-tool/pr/commands.ts +19 -23
- package/src/gh-tool/pr/core.ts +542 -582
- package/src/gh-tool/pr/review.ts +106 -454
- package/src/gh-tool/pr/snapshot.ts +23 -0
- package/src/gh-tool/pr/stack-read.ts +1 -73
- package/src/gh-tool/pr/stack.ts +37 -36
- package/src/gh-tool/{api.ts → read/api.ts} +40 -7
- package/src/gh-tool/read/cache.ts +192 -0
- package/src/gh-tool/read/catalog.ts +310 -0
- package/src/gh-tool/read/classes.ts +30 -0
- package/src/gh-tool/read/commands.ts +25 -0
- package/src/gh-tool/read/gated.ts +56 -0
- package/src/gh-tool/read/index.ts +4 -0
- package/src/gh-tool/read/issues.ts +107 -0
- package/src/gh-tool/read/keys.ts +102 -0
- package/src/gh-tool/read/porcelain.ts +58 -0
- package/src/gh-tool/read/pulls.ts +266 -0
- package/src/gh-tool/read/reads.ts +306 -0
- package/src/gh-tool/read/rest.ts +111 -0
- package/src/gh-tool/read/review-guards.ts +61 -0
- package/src/gh-tool/read/review-model.ts +286 -0
- package/src/gh-tool/read/reviews.ts +114 -0
- package/src/gh-tool/read/stacks.ts +73 -0
- package/src/gh-tool/read/store.ts +504 -0
- package/src/gh-tool/{service.ts → read/transport.ts} +26 -6
- package/src/gh-tool/read/waits.ts +52 -0
- package/src/gh-tool/read/writes.ts +37 -0
- package/src/gh-tool/release.ts +15 -168
- package/src/gh-tool/repo.ts +5 -78
- package/src/gh-tool/types.ts +1 -1
- package/src/gh-tool/workflow.ts +123 -214
- package/src/observability-tool/shared.ts +2 -1
- package/src/shared/content-security.ts +10 -1
- package/src/shared/credential-patterns.ts +1 -1
- package/dist/gh-tool/api.d.ts.map +0 -1
- package/dist/gh-tool/pr/check-status-cache.d.ts +0 -6
- package/dist/gh-tool/pr/check-status-cache.d.ts.map +0 -1
- package/dist/gh-tool/service.d.ts.map +0 -1
- 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
|
-
|
|
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";
|
package/src/gh-tool/pr/stack.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { GitHubService } from "#gh/
|
|
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 =
|
|
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*
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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*
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 "
|
|
6
|
-
import type { GitHubApiError } from "
|
|
7
|
-
import { ghEnvironment } from "
|
|
8
|
-
import { validateOutboundText } from "
|
|
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([
|
|
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
|
-
|
|
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
|
+
});
|