@blogic-cz/agent-tools 1.11.1 → 1.13.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 +18 -43
- package/dist/credential-guard/bash.d.ts +3 -0
- package/dist/credential-guard/bash.d.ts.map +1 -0
- package/dist/credential-guard/claude-hook.d.ts.map +1 -1
- package/dist/credential-guard/engine.test.d.ts +5 -0
- package/dist/credential-guard/engine.test.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts +8 -35
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/credential-guard/ir.d.ts +150 -0
- package/dist/credential-guard/ir.d.ts.map +1 -0
- package/dist/credential-guard/literal-output.d.ts +2 -0
- package/dist/credential-guard/literal-output.d.ts.map +1 -0
- package/dist/credential-guard/passive-awk.d.ts +2 -0
- package/dist/credential-guard/passive-awk.d.ts.map +1 -0
- package/dist/credential-guard/passive-jq.d.ts.map +1 -1
- package/dist/credential-guard/passive-sed.d.ts +2 -0
- package/dist/credential-guard/passive-sed.d.ts.map +1 -0
- package/dist/credential-guard/passive.test.d.ts +2 -0
- package/dist/credential-guard/passive.test.d.ts.map +1 -0
- package/dist/credential-guard/paths.d.ts +14 -0
- package/dist/credential-guard/paths.d.ts.map +1 -0
- package/dist/credential-guard/policy.d.ts +47 -0
- package/dist/credential-guard/policy.d.ts.map +1 -0
- package/dist/credential-guard/powershell.d.ts +3 -0
- package/dist/credential-guard/powershell.d.ts.map +1 -0
- package/dist/credential-guard/registry.d.ts +89 -0
- package/dist/credential-guard/registry.d.ts.map +1 -0
- package/dist/credential-guard/review4.test.d.ts +2 -0
- package/dist/credential-guard/review4.test.d.ts.map +1 -0
- package/dist/credential-guard/review7.test.d.ts +2 -0
- package/dist/credential-guard/review7.test.d.ts.map +1 -0
- package/dist/credential-guard/shell-oracle.d.ts +2 -0
- package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
- package/dist/credential-guard/xargs.d.ts +7 -0
- package/dist/credential-guard/xargs.d.ts.map +1 -0
- package/dist/gh-tool/pr/checkbox-markdown.d.ts +22 -0
- package/dist/gh-tool/pr/checkbox-markdown.d.ts.map +1 -0
- package/dist/gh-tool/pr/checkbox.d.ts +21 -0
- package/dist/gh-tool/pr/checkbox.d.ts.map +1 -0
- package/dist/gh-tool/pr/commands.d.ts +9 -0
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/index.d.ts +1 -1
- package/dist/gh-tool/pr/index.d.ts.map +1 -1
- package/dist/gh-tool/read/api.d.ts +8 -0
- package/dist/gh-tool/read/api.d.ts.map +1 -1
- package/dist/gh-tool/read/cache.d.ts.map +1 -1
- package/dist/gh-tool/read/commands.d.ts +5 -0
- package/dist/gh-tool/read/commands.d.ts.map +1 -1
- package/dist/gh-tool/read/pulls.d.ts +1 -0
- package/dist/gh-tool/read/pulls.d.ts.map +1 -1
- package/dist/gh-tool/read/reads.d.ts +39 -0
- package/dist/gh-tool/read/reads.d.ts.map +1 -1
- package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
- package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
- package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry.d.ts +59 -0
- package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
- package/dist/gh-tool/read/transport.d.ts +9 -0
- package/dist/gh-tool/read/transport.d.ts.map +1 -1
- package/dist/gh-tool/read/writes.d.ts +2 -1
- package/dist/gh-tool/read/writes.d.ts.map +1 -1
- package/package.json +2 -1
- package/schemas/agent-tools.schema.json +1 -1
- package/src/credential-guard/bash.ts +769 -0
- package/src/credential-guard/claude-hook.ts +14 -14
- package/src/credential-guard/engine.test.ts +2050 -0
- package/src/credential-guard/index.ts +285 -4268
- package/src/credential-guard/ir.ts +139 -0
- package/src/credential-guard/literal-output.ts +8 -0
- package/src/credential-guard/passive-awk.ts +239 -0
- package/src/credential-guard/passive-jq.ts +183 -24
- package/src/credential-guard/passive-sed.ts +78 -0
- package/src/credential-guard/passive.test.ts +141 -0
- package/src/credential-guard/paths.ts +108 -0
- package/src/credential-guard/policy.ts +1576 -0
- package/src/credential-guard/powershell.ts +220 -0
- package/src/credential-guard/registry.ts +4720 -0
- package/src/credential-guard/review4.test.ts +83 -0
- package/src/credential-guard/review7.test.ts +51 -0
- package/src/credential-guard/rg-types.json +389 -0
- package/src/credential-guard/shell-oracle.ts +187 -0
- package/src/credential-guard/xargs.ts +65 -0
- package/src/gh-tool/main.ts +32 -6
- package/src/gh-tool/pr/checkbox-markdown.ts +317 -0
- package/src/gh-tool/pr/checkbox.ts +113 -0
- package/src/gh-tool/pr/commands.ts +55 -2
- package/src/gh-tool/pr/core.ts +2 -2
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/read/api.ts +73 -6
- package/src/gh-tool/read/cache.ts +13 -4
- package/src/gh-tool/read/commands.ts +17 -1
- package/src/gh-tool/read/pulls.ts +19 -0
- package/src/gh-tool/read/reads.ts +86 -2
- package/src/gh-tool/read/telemetry-command.ts +45 -0
- package/src/gh-tool/read/telemetry-store.ts +85 -0
- package/src/gh-tool/read/telemetry.ts +191 -0
- package/src/gh-tool/read/transport.ts +95 -6
- package/src/gh-tool/read/writes.ts +19 -2
- package/src/gh-tool/workflow.ts +2 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { GitHubCommandError } from "#gh/errors";
|
|
4
|
+
import {
|
|
5
|
+
bunMarkdownOutline,
|
|
6
|
+
findCheckboxLines,
|
|
7
|
+
validateCheckboxEdit,
|
|
8
|
+
type CheckboxOutlineParser,
|
|
9
|
+
} from "#gh/pr/checkbox-markdown";
|
|
10
|
+
import { repoSlug } from "#gh/read/keys";
|
|
11
|
+
import { reads, resolveRepo } from "#gh/read/reads";
|
|
12
|
+
import { writes } from "#gh/read/writes";
|
|
13
|
+
import { validateOutboundText } from "#gh/text-input";
|
|
14
|
+
|
|
15
|
+
type CheckboxState = "checked" | "unchecked";
|
|
16
|
+
|
|
17
|
+
const readPRBody = Effect.fn("pr.checkbox.readPRBody")(function* (pr: number) {
|
|
18
|
+
const repo = yield* resolveRepo(null);
|
|
19
|
+
const body = yield* reads.prBody(repo, pr);
|
|
20
|
+
return { repo, body };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const checkboxMatchError = (pr: number, count: number) =>
|
|
24
|
+
new GitHubCommandError({
|
|
25
|
+
command: "gh-tool pr checkbox",
|
|
26
|
+
exitCode: 1,
|
|
27
|
+
stderr: `Found ${count} matching task items in PR #${pr}`,
|
|
28
|
+
message: `Expected one matching task item in PR #${pr}, found ${count}`,
|
|
29
|
+
hint: "Use --section to narrow the search or pass a longer --match.",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const togglePRCheckbox = Effect.fn("pr.togglePRCheckbox")(function* (
|
|
33
|
+
opts: {
|
|
34
|
+
pr: number;
|
|
35
|
+
match: string;
|
|
36
|
+
section: string | null;
|
|
37
|
+
state: CheckboxState;
|
|
38
|
+
},
|
|
39
|
+
parseOutline: CheckboxOutlineParser = bunMarkdownOutline,
|
|
40
|
+
) {
|
|
41
|
+
yield* validateOutboundText(opts.match, "gh-tool pr checkbox");
|
|
42
|
+
if (opts.section !== null) {
|
|
43
|
+
yield* validateOutboundText(opts.section, "gh-tool pr checkbox");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (opts.pr <= 0 || opts.match.length === 0 || opts.section === "") {
|
|
47
|
+
return yield* new GitHubCommandError({
|
|
48
|
+
command: "gh-tool pr checkbox",
|
|
49
|
+
exitCode: 1,
|
|
50
|
+
stderr: "--pr must be positive, --match must be non-empty, and --section must be non-empty",
|
|
51
|
+
message: "Invalid PR checkbox selection",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const first = yield* readPRBody(opts.pr);
|
|
56
|
+
const matches = yield* Effect.try({
|
|
57
|
+
try: () => findCheckboxLines(first.body, opts.match, opts.section, parseOutline),
|
|
58
|
+
catch: (cause) =>
|
|
59
|
+
new GitHubCommandError({
|
|
60
|
+
command: "gh-tool pr checkbox",
|
|
61
|
+
exitCode: 1,
|
|
62
|
+
stderr: String(cause),
|
|
63
|
+
message: "Cannot locate PR checkbox safely",
|
|
64
|
+
hint: "PR body structure is ambiguous; tick this item in the GitHub UI.",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (matches.length !== 1) return yield* checkboxMatchError(opts.pr, matches.length);
|
|
68
|
+
|
|
69
|
+
const selected = matches[0];
|
|
70
|
+
if (selected === undefined) return yield* checkboxMatchError(opts.pr, 0);
|
|
71
|
+
|
|
72
|
+
const alreadyRequested = opts.state === "checked" ? selected.checked : !selected.checked;
|
|
73
|
+
if (alreadyRequested) {
|
|
74
|
+
return {
|
|
75
|
+
pr: opts.pr,
|
|
76
|
+
state: "unchanged" as const,
|
|
77
|
+
before: selected.before,
|
|
78
|
+
after: selected.before,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const box = opts.state === "checked" ? "x" : " ";
|
|
83
|
+
yield* Effect.try({
|
|
84
|
+
try: () => validateCheckboxEdit(first.body, selected.offset, box, parseOutline),
|
|
85
|
+
catch: (cause) =>
|
|
86
|
+
new GitHubCommandError({
|
|
87
|
+
command: "gh-tool pr checkbox",
|
|
88
|
+
exitCode: 1,
|
|
89
|
+
stderr: String(cause),
|
|
90
|
+
message: "Cannot locate PR checkbox safely",
|
|
91
|
+
hint: "PR body structure is ambiguous; tick this item in the GitHub UI.",
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
const after = `${selected.before.slice(0, selected.boxIndex)}${box}${selected.before.slice(selected.boxIndex + 1)}`;
|
|
95
|
+
yield* writes.prCheckbox(
|
|
96
|
+
{
|
|
97
|
+
repo: first.repo,
|
|
98
|
+
pr: opts.pr,
|
|
99
|
+
expectedBody: first.body,
|
|
100
|
+
boxOffset: selected.offset,
|
|
101
|
+
replacement: box,
|
|
102
|
+
selectionText: [opts.match, ...(opts.section === null ? [] : [opts.section])],
|
|
103
|
+
},
|
|
104
|
+
[`pr:${repoSlug(first.repo)}#${opts.pr}`, `pulls:${repoSlug(first.repo)}`],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
pr: opts.pr,
|
|
109
|
+
state: "updated" as const,
|
|
110
|
+
before: selected.before,
|
|
111
|
+
after,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "#gh/config";
|
|
34
34
|
|
|
35
35
|
import { mergeStack, readStack, unstackStack } from "./stack";
|
|
36
|
+
import { togglePRCheckbox } from "./checkbox";
|
|
36
37
|
import {
|
|
37
38
|
closePR,
|
|
38
39
|
collectWithStableState,
|
|
@@ -524,8 +525,15 @@ export const prStatusCommand = Command.make(
|
|
|
524
525
|
withRepo(
|
|
525
526
|
repo,
|
|
526
527
|
Effect.gen(function* () {
|
|
527
|
-
const result
|
|
528
|
-
|
|
528
|
+
const [result, rateLimit] = yield* Effect.all([
|
|
529
|
+
detectPRStatus(),
|
|
530
|
+
reads.getPRStatusRateLimit(),
|
|
531
|
+
]);
|
|
532
|
+
const status: PRStatusResult & { readonly rateLimit: typeof rateLimit } = {
|
|
533
|
+
...result,
|
|
534
|
+
rateLimit,
|
|
535
|
+
};
|
|
536
|
+
yield* logFormatted(status, format);
|
|
529
537
|
}),
|
|
530
538
|
),
|
|
531
539
|
).pipe(
|
|
@@ -714,6 +722,51 @@ export const prEditCommand = Command.make(
|
|
|
714
722
|
),
|
|
715
723
|
).pipe(Command.withDescription("Edit an existing PR's title, body, or other metadata"));
|
|
716
724
|
|
|
725
|
+
export const prCheckboxCommand = Command.make(
|
|
726
|
+
"checkbox",
|
|
727
|
+
{
|
|
728
|
+
check: Flag.Boolean("check").pipe(
|
|
729
|
+
Flag.withDescription("Mark the matching task item as checked"),
|
|
730
|
+
Flag.withDefault(false),
|
|
731
|
+
),
|
|
732
|
+
format: formatOption,
|
|
733
|
+
match: Flag.String("match").pipe(
|
|
734
|
+
Flag.withDescription("Plain text that identifies one task item"),
|
|
735
|
+
),
|
|
736
|
+
pr: Flag.Int("pr").pipe(Flag.withDescription("PR number to update")),
|
|
737
|
+
repo: repoOption,
|
|
738
|
+
section: Flag.String("section").pipe(
|
|
739
|
+
Flag.withDescription("Limit the search to a Markdown heading"),
|
|
740
|
+
Flag.optional,
|
|
741
|
+
),
|
|
742
|
+
uncheck: Flag.Boolean("uncheck").pipe(
|
|
743
|
+
Flag.withDescription("Mark the matching task item as unchecked"),
|
|
744
|
+
Flag.withDefault(false),
|
|
745
|
+
),
|
|
746
|
+
},
|
|
747
|
+
({ check, format, match, pr, repo, section, uncheck }) =>
|
|
748
|
+
withRepo(
|
|
749
|
+
repo,
|
|
750
|
+
Effect.gen(function* () {
|
|
751
|
+
if (check === uncheck) {
|
|
752
|
+
return yield* new GitHubCommandError({
|
|
753
|
+
command: "gh-tool pr checkbox",
|
|
754
|
+
exitCode: 1,
|
|
755
|
+
stderr: "Provide exactly one of --check or --uncheck",
|
|
756
|
+
message: "Provide exactly one of --check or --uncheck",
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
const result = yield* togglePRCheckbox({
|
|
760
|
+
pr,
|
|
761
|
+
match,
|
|
762
|
+
section: Option.getOrNull(section),
|
|
763
|
+
state: check ? "checked" : "unchecked",
|
|
764
|
+
});
|
|
765
|
+
yield* logFormatted(result, format);
|
|
766
|
+
}),
|
|
767
|
+
),
|
|
768
|
+
).pipe(Command.withDescription("Check or uncheck one task item in a PR body"));
|
|
769
|
+
|
|
717
770
|
export const prCloseCommand = Command.make(
|
|
718
771
|
"close",
|
|
719
772
|
{
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -248,12 +248,12 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
|
|
|
248
248
|
|
|
249
249
|
// `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
|
|
250
250
|
// is an ordinary state, so it is not an error, but it is also not an empty list: a PR with green
|
|
251
|
-
// checks (
|
|
251
|
+
// checks (example/example-be#1396) once came back empty.
|
|
252
252
|
export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
|
|
253
253
|
// `gh` reports "no checks reported" both for the seconds after a push, before its checks
|
|
254
254
|
// register, and forever for a PR that has none — the message cannot tell them apart. So the
|
|
255
255
|
// wait for registration gets its own short budget: long enough to cover the race (measured
|
|
256
|
-
// at roughly
|
|
256
|
+
// at roughly 40 seconds), short enough that a check-less PR is not held
|
|
257
257
|
// for the caller's whole --timeout.
|
|
258
258
|
const CHECK_REGISTRATION_POLL_SECONDS = 5;
|
|
259
259
|
const CHECK_REGISTRATION_GRACE_SECONDS = 60;
|
package/src/gh-tool/pr/index.ts
CHANGED
package/src/gh-tool/read/api.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CacheRequest, recordRequest } from "./telemetry";
|
|
2
|
+
|
|
1
3
|
import { Clock, Context, Effect } from "effect";
|
|
2
4
|
|
|
3
5
|
import { registerResolvedSecret } from "#shared/content-security";
|
|
@@ -24,6 +26,36 @@ export type GitHubApiResponse<T> = {
|
|
|
24
26
|
rateLimitUsed?: string;
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
export type GitHubRateLimitHeaders = {
|
|
30
|
+
readonly resource?: string;
|
|
31
|
+
readonly limit?: number;
|
|
32
|
+
readonly used?: number;
|
|
33
|
+
readonly remaining?: number;
|
|
34
|
+
readonly reset?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const headerInteger = (headers: Headers, name: string) => {
|
|
38
|
+
const value = headers.get(name);
|
|
39
|
+
if (value === null || !/^\d+$/.test(value)) return undefined;
|
|
40
|
+
const parsed = Number(value);
|
|
41
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const parseRateLimitHeaders = (headers: Headers): GitHubRateLimitHeaders => {
|
|
45
|
+
const resource = headers.get("x-ratelimit-resource");
|
|
46
|
+
const limit = headerInteger(headers, "x-ratelimit-limit");
|
|
47
|
+
const used = headerInteger(headers, "x-ratelimit-used");
|
|
48
|
+
const remaining = headerInteger(headers, "x-ratelimit-remaining");
|
|
49
|
+
const reset = headerInteger(headers, "x-ratelimit-reset");
|
|
50
|
+
return {
|
|
51
|
+
...(resource === null ? {} : { resource }),
|
|
52
|
+
...(limit === undefined ? {} : { limit }),
|
|
53
|
+
...(used === undefined ? {} : { used }),
|
|
54
|
+
...(remaining === undefined ? {} : { remaining }),
|
|
55
|
+
...(reset === undefined ? {} : { reset }),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
27
59
|
const authFailure = (message: string) =>
|
|
28
60
|
new GitHubAuthError({
|
|
29
61
|
message,
|
|
@@ -106,6 +138,8 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
|
|
|
106
138
|
const token = yield* resolveGitHubToken();
|
|
107
139
|
const url = `${GITHUB_API_ROOT}/${opts.path.replace(/^\//, "")}`;
|
|
108
140
|
|
|
141
|
+
const cached = yield* CacheRequest;
|
|
142
|
+
const started = new Date().toISOString();
|
|
109
143
|
const response = yield* Effect.tryPromise({
|
|
110
144
|
try: () =>
|
|
111
145
|
fetch(url, {
|
|
@@ -128,8 +162,41 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
|
|
|
128
162
|
retryable: true,
|
|
129
163
|
hint: "Check network connectivity and VPN state, then retry.",
|
|
130
164
|
}),
|
|
131
|
-
})
|
|
165
|
+
}).pipe(
|
|
166
|
+
Effect.tapError(() =>
|
|
167
|
+
recordRequest({
|
|
168
|
+
ts: started,
|
|
169
|
+
method,
|
|
170
|
+
endpoint: opts.path,
|
|
171
|
+
status: null,
|
|
172
|
+
outcome: "error",
|
|
173
|
+
resource: null,
|
|
174
|
+
used: null,
|
|
175
|
+
remaining: null,
|
|
176
|
+
reset: null,
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
132
180
|
|
|
181
|
+
const quota = parseRateLimitHeaders(response.headers);
|
|
182
|
+
yield* recordRequest({
|
|
183
|
+
ts: started,
|
|
184
|
+
method,
|
|
185
|
+
endpoint: opts.path,
|
|
186
|
+
status: response.status,
|
|
187
|
+
outcome:
|
|
188
|
+
response.status === 304
|
|
189
|
+
? "304"
|
|
190
|
+
: response.status >= 400
|
|
191
|
+
? "error"
|
|
192
|
+
: cached
|
|
193
|
+
? "200"
|
|
194
|
+
: "bypass",
|
|
195
|
+
resource: quota.resource ?? null,
|
|
196
|
+
used: quota.used ?? null,
|
|
197
|
+
remaining: quota.remaining ?? null,
|
|
198
|
+
reset: quota.reset ?? null,
|
|
199
|
+
});
|
|
133
200
|
const text = yield* Effect.tryPromise({
|
|
134
201
|
try: () => response.text(),
|
|
135
202
|
catch: (cause) =>
|
|
@@ -176,7 +243,8 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
|
|
|
176
243
|
}
|
|
177
244
|
|
|
178
245
|
const now = yield* Clock.currentTimeMillis;
|
|
179
|
-
const
|
|
246
|
+
const rateLimitHeaders = parseRateLimitHeaders(response.headers);
|
|
247
|
+
const resetSeconds = rateLimitHeaders.reset ?? Number.NaN;
|
|
180
248
|
const resetDelay =
|
|
181
249
|
Number.isFinite(resetSeconds) && resetSeconds > 0
|
|
182
250
|
? Math.max(0, resetSeconds * 1000 - now + 1000)
|
|
@@ -200,18 +268,17 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
|
|
|
200
268
|
/rate limit|secondary rate limit|abuse detection/i.test(
|
|
201
269
|
apiErrorMessage(parsed, response.status),
|
|
202
270
|
)));
|
|
203
|
-
|
|
204
271
|
const error = new GitHubCommandError({
|
|
205
272
|
message: rateLimit
|
|
206
|
-
? `${apiErrorMessage(parsed, response.status)}; reset time: ${
|
|
273
|
+
? `${apiErrorMessage(parsed, response.status)}; resource: ${rateLimitHeaders.resource ?? "unavailable"}; reset time: ${rateLimitHeaders.reset === undefined ? "unavailable" : new Date(resetSeconds * 1000).toISOString()}; remaining quota: ${rateLimitHeaders.remaining ?? "unavailable"}`
|
|
207
274
|
: apiErrorMessage(parsed, response.status),
|
|
208
275
|
command: `${method} ${opts.path}`,
|
|
209
276
|
exitCode: response.status,
|
|
210
277
|
stderr: text,
|
|
211
278
|
...(rateLimit
|
|
212
279
|
? {
|
|
213
|
-
hint: "Wait for the reset before retrying. Check the
|
|
214
|
-
nextCommand: "gh
|
|
280
|
+
hint: "Wait for the reset before retrying. Check the named resource with the shown command; the lookup does not consume quota.",
|
|
281
|
+
nextCommand: "gh-tool rate-limit",
|
|
215
282
|
}
|
|
216
283
|
: {}),
|
|
217
284
|
...(response.status >= 500 || (rateLimit && canWait) ? { retryable: true } : {}),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CacheRequest, cacheEvent, recordRequest } from "./telemetry";
|
|
1
2
|
import { Console, Context, Effect } from "effect";
|
|
2
3
|
import { resolveGitHubToken, SessionCredential } from "#gh/read/api";
|
|
3
4
|
import { GitHubCommandError } from "#gh/errors";
|
|
@@ -86,16 +87,23 @@ export const cachedRead = <A, E, R>(opts: {
|
|
|
86
87
|
),
|
|
87
88
|
);
|
|
88
89
|
if (prepared === null) return yield* direct();
|
|
89
|
-
if (prepared.kind === "hit")
|
|
90
|
+
if (prepared.kind === "hit") {
|
|
91
|
+
yield* recordRequest(cacheEvent(opts.key.op));
|
|
92
|
+
return prepared.value;
|
|
93
|
+
}
|
|
90
94
|
return yield* Effect.gen(function* () {
|
|
91
95
|
yield* safe(session, () => store.record(opts.key.op, "fetch"));
|
|
92
96
|
const previousValid =
|
|
93
97
|
prepared.previous === null
|
|
94
98
|
? false
|
|
95
99
|
: yield* safe(session, () => store.canServeStale(prepared));
|
|
96
|
-
const fetched = yield* opts
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const fetched = yield* opts
|
|
101
|
+
.fetch(
|
|
102
|
+
prepared.previous === null
|
|
103
|
+
? null
|
|
104
|
+
: { ...prepared.previous, valid: previousValid === true },
|
|
105
|
+
)
|
|
106
|
+
.pipe(Effect.provideService(CacheRequest, true));
|
|
99
107
|
if (fetched.kind === "unchanged") {
|
|
100
108
|
if (prepared.previous === null)
|
|
101
109
|
return yield* Effect.die(new Error("304 without a cached value"));
|
|
@@ -131,6 +139,7 @@ export const cachedRead = <A, E, R>(opts: {
|
|
|
131
139
|
session.staleWarned = true;
|
|
132
140
|
yield* Console.error(`gh-tool: served cached ${opts.key.op} (rate limited)`);
|
|
133
141
|
}
|
|
142
|
+
yield* recordRequest(cacheEvent(opts.key.op));
|
|
134
143
|
yield* safe(session, () => store.record(opts.key.op, "stale"));
|
|
135
144
|
return prepared.previous.value;
|
|
136
145
|
}),
|
|
@@ -2,7 +2,23 @@ import { Effect, Option } from "effect";
|
|
|
2
2
|
import { Command, Flag } from "effect/cli";
|
|
3
3
|
import { formatOption, logFormatted } from "#shared";
|
|
4
4
|
import { cacheStats, clearCache } from "./cache";
|
|
5
|
-
import { resolveRepo } from "./reads";
|
|
5
|
+
import { getRateLimit, resolveRepo } from "./reads";
|
|
6
|
+
|
|
7
|
+
export const rateLimitCommand = Command.make(
|
|
8
|
+
"rate-limit",
|
|
9
|
+
{
|
|
10
|
+
resource: Flag.String("resource").pipe(
|
|
11
|
+
Flag.withDescription("Show one GitHub rate-limit resource"),
|
|
12
|
+
Flag.optional,
|
|
13
|
+
),
|
|
14
|
+
format: formatOption,
|
|
15
|
+
},
|
|
16
|
+
({ resource, format }) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const summary = yield* getRateLimit(Option.getOrUndefined(resource));
|
|
19
|
+
yield* logFormatted(summary, format);
|
|
20
|
+
}),
|
|
21
|
+
).pipe(Command.withDescription("Show GitHub API rate-limit resources and reset times"));
|
|
6
22
|
|
|
7
23
|
const stats = Command.make("stats", { format: formatOption }, ({ format }) =>
|
|
8
24
|
Effect.gen(function* () {
|
|
@@ -101,6 +101,25 @@ export const prHead = Effect.fn("reads.prHead")(function* (
|
|
|
101
101
|
return { headSha: head.head.sha, baseSha: head.base.sha, signature: snapshotDigest(snapshot) };
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
const PRBody = Schema.Struct({ body: Schema.NullOr(Schema.String) });
|
|
105
|
+
|
|
106
|
+
export const prBody = Effect.fn("reads.prBody")(function* (repo: RepoRef, pr: number) {
|
|
107
|
+
const gh = yield* GitHubService;
|
|
108
|
+
const path = `repos/${repoSlug(repo)}/pulls/${pr}`;
|
|
109
|
+
const response = yield* gh.apiRequest<unknown>({ path });
|
|
110
|
+
const parsed = yield* Effect.try({
|
|
111
|
+
try: () => Schema.decodeUnknownSync(PRBody)(response.body),
|
|
112
|
+
catch: (error) =>
|
|
113
|
+
new GitHubCommandError({
|
|
114
|
+
command: `GET ${path}`,
|
|
115
|
+
exitCode: 0,
|
|
116
|
+
message: `GitHub returned an invalid PR body: ${String(error)}`,
|
|
117
|
+
stderr: `GitHub returned an invalid PR body: ${String(error)}`,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
return parsed.body ?? "";
|
|
121
|
+
});
|
|
122
|
+
|
|
104
123
|
export const checkSignal = Effect.fn("reads.checkSignal")(function* (repo: RepoRef, sha: string) {
|
|
105
124
|
const snapshot = yield* restSnapshot({ op: "checkSignal", repo, sha }, [
|
|
106
125
|
{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Schema } from "effect";
|
|
1
|
+
import { Clock, Effect, Result, Schema } from "effect";
|
|
2
2
|
import { GitHubService } from "#gh/read/transport";
|
|
3
3
|
import { GitHubCommandError } from "#gh/errors";
|
|
4
4
|
import type { CheckRunAnnotation } from "#gh/types";
|
|
@@ -6,7 +6,7 @@ import { cachedRead, ReadCache } from "./cache";
|
|
|
6
6
|
import { OPS } from "./classes";
|
|
7
7
|
import { repoSlug } from "./keys";
|
|
8
8
|
import { issueView, issueList, issueComments } from "./issues";
|
|
9
|
-
import { prView, prList, prChecks, prHead, checkSignal } from "./pulls";
|
|
9
|
+
import { prView, prList, prChecks, prHead, prBody, checkSignal } from "./pulls";
|
|
10
10
|
import { prComments, prReviews, reviewThreads, lastHumanReviewer } from "./reviews";
|
|
11
11
|
import { reviewGuards } from "./review-guards";
|
|
12
12
|
import type { RepoRef, RunFilter } from "./keys";
|
|
@@ -21,6 +21,87 @@ import {
|
|
|
21
21
|
|
|
22
22
|
export type JobRef = { readonly id: number; readonly status: string };
|
|
23
23
|
|
|
24
|
+
const RateLimitResponse = Schema.Struct({
|
|
25
|
+
resources: Schema.Record(
|
|
26
|
+
Schema.String,
|
|
27
|
+
Schema.Struct({
|
|
28
|
+
limit: Schema.Int,
|
|
29
|
+
used: Schema.Int,
|
|
30
|
+
remaining: Schema.Int,
|
|
31
|
+
reset: Schema.Int,
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type RateLimitResource = {
|
|
37
|
+
readonly limit: number;
|
|
38
|
+
readonly used: number;
|
|
39
|
+
readonly remaining: number;
|
|
40
|
+
readonly reset: string;
|
|
41
|
+
readonly secondsUntilReset: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RateLimitSummary = Readonly<Record<string, RateLimitResource>>;
|
|
45
|
+
export type PRStatusRateLimit =
|
|
46
|
+
| Readonly<{ core: RateLimitResource; graphql: RateLimitResource }>
|
|
47
|
+
| Readonly<{ error: string }>;
|
|
48
|
+
|
|
49
|
+
export const getRateLimit = Effect.fn("reads.getRateLimit")(function* (resource?: string) {
|
|
50
|
+
const gh = yield* GitHubService;
|
|
51
|
+
const response = yield* gh.runGhJson<unknown>(["api", "rate_limit"]);
|
|
52
|
+
const parsed = yield* Effect.try({
|
|
53
|
+
try: () => parsePreserving(RateLimitResponse)(response),
|
|
54
|
+
catch: (cause) =>
|
|
55
|
+
new GitHubCommandError({
|
|
56
|
+
message: `GitHub rate-limit response was invalid: ${String(cause)}`,
|
|
57
|
+
command: "gh-tool rate-limit",
|
|
58
|
+
exitCode: 1,
|
|
59
|
+
stderr: String(cause),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
const names = Object.keys(parsed.resources).toSorted();
|
|
63
|
+
const now = (yield* Clock.currentTimeMillis) / 1000;
|
|
64
|
+
const selected = resource === undefined ? names : [resource];
|
|
65
|
+
const entries: Array<[string, RateLimitResource]> = [];
|
|
66
|
+
for (const name of selected) {
|
|
67
|
+
const bucket = parsed.resources[name];
|
|
68
|
+
if (bucket === undefined) {
|
|
69
|
+
return yield* new GitHubCommandError({
|
|
70
|
+
message: `Unknown rate-limit resource '${name}'. Available resources: ${names.join(", ")}`,
|
|
71
|
+
command: "gh-tool rate-limit",
|
|
72
|
+
exitCode: 1,
|
|
73
|
+
stderr: "",
|
|
74
|
+
hint: "Select a resource returned by GitHub, or omit --resource to show all resources.",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
entries.push([
|
|
78
|
+
name,
|
|
79
|
+
{
|
|
80
|
+
limit: bucket.limit,
|
|
81
|
+
used: bucket.used,
|
|
82
|
+
remaining: bucket.remaining,
|
|
83
|
+
reset: new Date(bucket.reset * 1000).toISOString(),
|
|
84
|
+
secondsUntilReset: Math.max(0, Math.ceil(bucket.reset - now)),
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
return Object.fromEntries(entries) satisfies RateLimitSummary;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const getPRStatusRateLimit = Effect.fn("reads.getPRStatusRateLimit")(function* () {
|
|
92
|
+
const result = yield* getRateLimit().pipe(Effect.result);
|
|
93
|
+
if (Result.isFailure(result))
|
|
94
|
+
return { error: result.failure.message } satisfies PRStatusRateLimit;
|
|
95
|
+
|
|
96
|
+
const { core, graphql } = result.success;
|
|
97
|
+
if (core === undefined || graphql === undefined) {
|
|
98
|
+
return {
|
|
99
|
+
error: "GitHub rate-limit response omitted the core or graphql resource.",
|
|
100
|
+
} satisfies PRStatusRateLimit;
|
|
101
|
+
}
|
|
102
|
+
return { core, graphql } satisfies PRStatusRateLimit;
|
|
103
|
+
});
|
|
104
|
+
|
|
24
105
|
const completedAttemptJobs = Effect.fn("reads.completedAttemptJobs")(function* (
|
|
25
106
|
repo: RepoRef,
|
|
26
107
|
runId: number,
|
|
@@ -282,6 +363,8 @@ const jobIdentity = Effect.fn("reads.jobIdentity")(function* (repo: RepoRef, job
|
|
|
282
363
|
});
|
|
283
364
|
|
|
284
365
|
export const reads = {
|
|
366
|
+
getRateLimit,
|
|
367
|
+
getPRStatusRateLimit,
|
|
285
368
|
completedAttemptJobs,
|
|
286
369
|
jobIdentity,
|
|
287
370
|
reviewGuards,
|
|
@@ -293,6 +376,7 @@ export const reads = {
|
|
|
293
376
|
prList,
|
|
294
377
|
prChecks,
|
|
295
378
|
prHead,
|
|
379
|
+
prBody,
|
|
296
380
|
checkSignal,
|
|
297
381
|
runList,
|
|
298
382
|
runView,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Command, Flag } from "effect/cli";
|
|
3
|
+
import { formatOption, logFormatted } from "#shared";
|
|
4
|
+
import { GitHubCommandError } from "#gh/errors";
|
|
5
|
+
import { getRateLimit } from "./reads";
|
|
6
|
+
import { summarizeRequests, Telemetry } from "./telemetry";
|
|
7
|
+
|
|
8
|
+
export const telemetryCommand = Command.make(
|
|
9
|
+
"telemetry",
|
|
10
|
+
{
|
|
11
|
+
hours: Flag.Int("hours").pipe(
|
|
12
|
+
Flag.withDefault(1),
|
|
13
|
+
Flag.withDescription("Report window in hours, up to 168"),
|
|
14
|
+
),
|
|
15
|
+
format: formatOption,
|
|
16
|
+
},
|
|
17
|
+
({ hours, format }) =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const store = yield* Telemetry;
|
|
20
|
+
const report = yield* Effect.try({
|
|
21
|
+
try: () => {
|
|
22
|
+
if (!Number.isFinite(hours) || hours <= 0 || hours > 168)
|
|
23
|
+
throw new Error("hours must be greater than 0 and at most 168");
|
|
24
|
+
if (store === null) throw new Error("Telemetry database unavailable");
|
|
25
|
+
const since = new Date(Date.now() - hours * 3600000).toISOString();
|
|
26
|
+
return { since, ...summarizeRequests(store.read(since)) };
|
|
27
|
+
},
|
|
28
|
+
catch: (error) =>
|
|
29
|
+
new GitHubCommandError({
|
|
30
|
+
message: String(error),
|
|
31
|
+
command: "gh-tool telemetry",
|
|
32
|
+
exitCode: 1,
|
|
33
|
+
stderr: String(error),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
const current = yield* getRateLimit().pipe(
|
|
37
|
+
Effect.catch((error) => Effect.succeed({ error: error.message })),
|
|
38
|
+
);
|
|
39
|
+
yield* logFormatted({ ...report, current }, format);
|
|
40
|
+
}),
|
|
41
|
+
).pipe(
|
|
42
|
+
Command.withDescription(
|
|
43
|
+
"Report direct HTTP requests, gh subprocess calls, ReadCache hits and estimated external quota use",
|
|
44
|
+
),
|
|
45
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
import { redactSensitiveText } from "#shared/content-security";
|
|
4
|
+
import type { RequestEvent, RequestRow, TelemetryStore } from "./telemetry";
|
|
5
|
+
|
|
6
|
+
const telemetryCommand = (args: readonly string[]): string => {
|
|
7
|
+
const commandGroups = new Set([
|
|
8
|
+
"pr",
|
|
9
|
+
"issue",
|
|
10
|
+
"repo",
|
|
11
|
+
"label",
|
|
12
|
+
"branch",
|
|
13
|
+
"workflow",
|
|
14
|
+
"release",
|
|
15
|
+
"gist",
|
|
16
|
+
"cache",
|
|
17
|
+
]);
|
|
18
|
+
const pathLength = commandGroups.has(args[0]) ? 2 : 1;
|
|
19
|
+
const path = args.slice(0, pathLength).filter((arg) => !arg.startsWith("-"));
|
|
20
|
+
if (path.join(" ") === "pr stack" && args[2] && !args[2].startsWith("-")) {
|
|
21
|
+
path.push(args[2]);
|
|
22
|
+
}
|
|
23
|
+
const flags: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const arg of args.slice(path.length)) {
|
|
26
|
+
if (arg === "--") break;
|
|
27
|
+
if (arg.startsWith("-")) flags.push(arg.split("=", 1)[0]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [...path, ...flags.toSorted()].join(" ");
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const openRequestTelemetry = (dbPath: string): TelemetryStore => {
|
|
34
|
+
const db = new Database(dbPath, { create: true, strict: true });
|
|
35
|
+
try {
|
|
36
|
+
db.run("PRAGMA busy_timeout=0");
|
|
37
|
+
db.run(`CREATE TABLE IF NOT EXISTS github_requests (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, invocation TEXT NOT NULL,
|
|
39
|
+
command TEXT NOT NULL, method TEXT NOT NULL, endpoint TEXT NOT NULL,
|
|
40
|
+
status INTEGER, outcome TEXT NOT NULL, resource TEXT, used INTEGER,
|
|
41
|
+
remaining INTEGER, reset INTEGER
|
|
42
|
+
) STRICT`);
|
|
43
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_github_requests_ts ON github_requests(ts)");
|
|
44
|
+
db.run(
|
|
45
|
+
"DELETE FROM github_requests WHERE ts < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')",
|
|
46
|
+
);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
db.close(false);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const invocation = crypto.randomUUID();
|
|
52
|
+
const command = telemetryCommand(process.argv.slice(2));
|
|
53
|
+
return {
|
|
54
|
+
record: (event: RequestEvent) => {
|
|
55
|
+
try {
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO github_requests
|
|
58
|
+
(ts, invocation, command, method, endpoint, status, outcome, resource, used, remaining, reset)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
60
|
+
[
|
|
61
|
+
event.ts,
|
|
62
|
+
invocation,
|
|
63
|
+
redactSensitiveText(command),
|
|
64
|
+
event.method,
|
|
65
|
+
event.endpoint,
|
|
66
|
+
event.status,
|
|
67
|
+
event.outcome,
|
|
68
|
+
event.resource,
|
|
69
|
+
event.used,
|
|
70
|
+
event.remaining,
|
|
71
|
+
event.reset,
|
|
72
|
+
],
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
/* Never wait for a writer or change command behavior. */
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
read: (since) =>
|
|
79
|
+
db
|
|
80
|
+
.query<RequestRow, [string]>("SELECT * FROM github_requests WHERE ts >= ? ORDER BY ts, id")
|
|
81
|
+
.all(since)
|
|
82
|
+
.map((row) => Object.assign(row, { command: redactSensitiveText(row.command) })),
|
|
83
|
+
close: () => db.close(false),
|
|
84
|
+
};
|
|
85
|
+
};
|