@blogic-cz/agent-tools 0.15.6 → 0.15.8
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/package.json +1 -1
- package/src/gh-tool/index.ts +2 -0
- package/src/gh-tool/pr/commands.ts +98 -0
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/k8s-tool/index.ts +4 -2
- package/src/k8s-tool/service.ts +14 -3
- package/src/shared/prerequisites/guardian.ts +18 -12
- package/src/shared/prerequisites/runtime.ts +3 -1
package/README.md
CHANGED
|
@@ -263,7 +263,7 @@ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Gene
|
|
|
263
263
|
|
|
264
264
|
### gh-tool machine contracts
|
|
265
265
|
|
|
266
|
-
`pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR.
|
|
266
|
+
`pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR. `pr request-review --reviewers alice,bob` emits sorted `submittedReviewers` (normalized input) and `requestedReviewers` (GitHub-confirmed result).
|
|
267
267
|
|
|
268
268
|
`pr watch --prs 12,34 --until terminal --format jsonl` accepts at most 50 unique, digits-only PR numbers and emits only JSONL state transitions. Identity uses `repo/pr/headSha/runId/attempt/jobId`; `runId`, `attempt`, and `jobId` are omitted from an event rather than emitted as `null`, and `checkId` is no longer emitted. State/bucket revisions emit even when identity stays stable; `supersedes` appears only when identity changes. Open PRs with no checks become terminal only after three stable empty snapshots, allowing bounded GitHub eventual consistency, and carry `checksObserved: false` — a terminal snapshot with no observed check is never green evidence. `pr checks`, batch checks, triage, and batch triage keep stderr silent with `--format json`; JSONL watch is also informationally silent. Failures still return structured nonzero errors on stderr.
|
|
269
269
|
|
|
@@ -275,6 +275,7 @@ bun gh-tool pr rerun-checks --pr 123 --failed-only --watch --timeout 600
|
|
|
275
275
|
bun gh-tool pr trigger-checks --pr 123 --workflow dotnet-pull-request.yml # only when zero checks reported
|
|
276
276
|
bun gh-tool pr watch --prs 123,124 --format jsonl --timeout 600
|
|
277
277
|
bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and thread
|
|
278
|
+
bun gh-tool pr request-review --repo be --pr 123 --reviewers alice,bob
|
|
278
279
|
# Optional --pr/--thread-id retain legacy flow and are validated before either mutation.
|
|
279
280
|
```
|
|
280
281
|
|
package/package.json
CHANGED
package/src/gh-tool/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
prEditCommand,
|
|
28
28
|
prMergeCommand,
|
|
29
29
|
prReadyCommand,
|
|
30
|
+
prRequestReviewCommand,
|
|
30
31
|
prThreadsCommand,
|
|
31
32
|
prCommentsCommand,
|
|
32
33
|
prIssueCommentsCommand,
|
|
@@ -84,6 +85,7 @@ const prCommand = Command.make("pr", {}).pipe(
|
|
|
84
85
|
prEditCommand,
|
|
85
86
|
prMergeCommand,
|
|
86
87
|
prReadyCommand,
|
|
88
|
+
prRequestReviewCommand,
|
|
87
89
|
prWaitMergeableCommand,
|
|
88
90
|
prThreadsCommand,
|
|
89
91
|
prCommentsCommand,
|
|
@@ -76,6 +76,85 @@ const withRepo = <A, E, R>(repo: Option.Option<string>, effect: Effect.Effect<A,
|
|
|
76
76
|
return yield* gh.withRepoTarget(Option.getOrNull(repo), effect);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
const reviewerInputError = (reviewers: string) =>
|
|
80
|
+
new GitHubCommandError({
|
|
81
|
+
message: `--reviewers must be comma-separated GitHub user logins without empty segments: ${JSON.stringify(reviewers)}`,
|
|
82
|
+
command: "gh-tool pr request-review",
|
|
83
|
+
exitCode: 1,
|
|
84
|
+
stderr: "",
|
|
85
|
+
hint: "Pass user logins such as --reviewers octocat,hubot.",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const prNumberError = (pr: number) =>
|
|
89
|
+
new GitHubCommandError({
|
|
90
|
+
message: `--pr must be a positive integer: ${pr}`,
|
|
91
|
+
command: "gh-tool pr request-review",
|
|
92
|
+
exitCode: 1,
|
|
93
|
+
stderr: "",
|
|
94
|
+
hint: "Pass a positive PR number, e.g. --pr 123.",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const GITHUB_LOGIN_RE = /^(?!.*--)[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/;
|
|
98
|
+
|
|
99
|
+
export const parseReviewers = (reviewers: string) => {
|
|
100
|
+
const parsed = reviewers.split(",").map((reviewer) => reviewer.trim().toLowerCase());
|
|
101
|
+
if (parsed.length === 0 || parsed.some((reviewer) => !GITHUB_LOGIN_RE.test(reviewer))) {
|
|
102
|
+
return Effect.fail(reviewerInputError(reviewers));
|
|
103
|
+
}
|
|
104
|
+
return Effect.succeed([...new Set(parsed)].toSorted());
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type RequestedReviewersResponse = { requested_reviewers?: unknown };
|
|
108
|
+
|
|
109
|
+
const confirmedReviewers = (response: RequestedReviewersResponse) => {
|
|
110
|
+
if (!Array.isArray(response.requested_reviewers)) {
|
|
111
|
+
return Effect.fail(
|
|
112
|
+
new GitHubCommandError({
|
|
113
|
+
message: "GitHub requested-reviewers response omitted requested_reviewers.",
|
|
114
|
+
command: "gh-tool pr request-review",
|
|
115
|
+
exitCode: 1,
|
|
116
|
+
stderr: JSON.stringify(response),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const logins = response.requested_reviewers.map((reviewer) =>
|
|
121
|
+
typeof reviewer === "object" && reviewer !== null && "login" in reviewer
|
|
122
|
+
? (reviewer as { login?: unknown }).login
|
|
123
|
+
: undefined,
|
|
124
|
+
);
|
|
125
|
+
if (logins.some((login) => typeof login !== "string")) {
|
|
126
|
+
return Effect.fail(
|
|
127
|
+
new GitHubCommandError({
|
|
128
|
+
message: "GitHub requested-reviewers response contained an invalid reviewer.",
|
|
129
|
+
command: "gh-tool pr request-review",
|
|
130
|
+
exitCode: 1,
|
|
131
|
+
stderr: JSON.stringify(response),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return Effect.succeed([...new Set(logins as string[])].toSorted());
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const requestReview = (pr: number, reviewers: string) =>
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
if (!Number.isInteger(pr) || pr <= 0) return yield* prNumberError(pr);
|
|
141
|
+
const submittedReviewers = yield* parseReviewers(reviewers);
|
|
142
|
+
const gh = yield* GitHubService;
|
|
143
|
+
const repo = yield* gh.getRepoInfo();
|
|
144
|
+
const response = yield* gh.runGhJson<RequestedReviewersResponse>([
|
|
145
|
+
"api",
|
|
146
|
+
"--method",
|
|
147
|
+
"POST",
|
|
148
|
+
`repos/${repo.owner}/${repo.name}/pulls/${pr}/requested_reviewers`,
|
|
149
|
+
...submittedReviewers.flatMap((reviewer) => ["-f", `reviewers[]=${reviewer}`]),
|
|
150
|
+
]);
|
|
151
|
+
return {
|
|
152
|
+
pr,
|
|
153
|
+
submittedReviewers,
|
|
154
|
+
requestedReviewers: yield* confirmedReviewers(response),
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
79
158
|
type ReviewTriageSummary = {
|
|
80
159
|
readonly visibleOpenReviewThreadsCount: number;
|
|
81
160
|
readonly unrepliedReviewThreadsCount: number;
|
|
@@ -1347,6 +1426,25 @@ export const prReviewTriageCommand = Command.make(
|
|
|
1347
1426
|
),
|
|
1348
1427
|
);
|
|
1349
1428
|
|
|
1429
|
+
export const prRequestReviewCommand = Command.make(
|
|
1430
|
+
"request-review",
|
|
1431
|
+
{
|
|
1432
|
+
format: formatOption,
|
|
1433
|
+
pr: Flag.integer("pr").pipe(Flag.withDescription("Positive pull request number")),
|
|
1434
|
+
repo: repoOption,
|
|
1435
|
+
reviewers: Flag.string("reviewers").pipe(
|
|
1436
|
+
Flag.withDescription("Comma-separated GitHub user logins"),
|
|
1437
|
+
),
|
|
1438
|
+
},
|
|
1439
|
+
({ format, pr, repo, reviewers }) =>
|
|
1440
|
+
withRepo(
|
|
1441
|
+
repo,
|
|
1442
|
+
Effect.gen(function* () {
|
|
1443
|
+
yield* logFormatted(yield* requestReview(pr, reviewers), format);
|
|
1444
|
+
}),
|
|
1445
|
+
),
|
|
1446
|
+
).pipe(Command.withDescription("Request review from comma-separated GitHub user logins"));
|
|
1447
|
+
|
|
1350
1448
|
export const prReviewTriageBatchCommand = Command.make(
|
|
1351
1449
|
"review-triage-batch",
|
|
1352
1450
|
{
|
package/src/gh-tool/pr/index.ts
CHANGED
package/src/k8s-tool/index.ts
CHANGED
|
@@ -120,8 +120,10 @@ const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
|
|
|
120
120
|
const errorResult: CommandResult = {
|
|
121
121
|
success: false,
|
|
122
122
|
error: error.message,
|
|
123
|
-
hint:
|
|
124
|
-
|
|
123
|
+
hint:
|
|
124
|
+
error.hint ??
|
|
125
|
+
`Verify cluster ID "${k8sConfig.clusterId}" matches a context in kubectl config. Run: kubectl config get-contexts`,
|
|
126
|
+
...(error.hint ? {} : { nextCommand: "kubectl config get-contexts" }),
|
|
125
127
|
executionTimeMs: 0,
|
|
126
128
|
};
|
|
127
129
|
return Effect.succeed(errorResult);
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { K8sConfig } from "#config";
|
|
|
16
16
|
import { collectProcessOutput, quoteShellArg } from "#shared/exec";
|
|
17
17
|
import { resolveEnvTemplate } from "#shared/env-template";
|
|
18
18
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
19
|
+
import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
|
|
19
20
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
20
21
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
21
22
|
import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
|
|
@@ -275,14 +276,23 @@ export class K8sService extends Context.Service<
|
|
|
275
276
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
276
277
|
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
277
278
|
const apiProbeTimeoutMs = k8sConfig.apiProbeTimeoutMs ?? 2000;
|
|
279
|
+
const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
|
|
280
|
+
const reachableWithoutPrerequisites = yield* probeApiReachable(
|
|
281
|
+
context,
|
|
282
|
+
kubeconfig,
|
|
283
|
+
apiProbeTimeoutMs,
|
|
284
|
+
);
|
|
285
|
+
const vpnGated = normalizeProfilePrerequisites(k8sConfig).some(
|
|
286
|
+
(prerequisite) => prerequisite.type === "vpn",
|
|
287
|
+
);
|
|
278
288
|
return yield* runWithProfilePrerequisites(
|
|
279
289
|
config ?? {},
|
|
280
290
|
k8sConfig,
|
|
281
291
|
runPrerequisiteCommand,
|
|
282
292
|
Effect.gen(function* () {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
293
|
+
const reachable =
|
|
294
|
+
reachableWithoutPrerequisites ||
|
|
295
|
+
(vpnGated && (yield* probeApiReachable(context, kubeconfig, apiProbeTimeoutMs)));
|
|
286
296
|
if (!reachable) {
|
|
287
297
|
return yield* new K8sContextError({
|
|
288
298
|
message: `Kubernetes API server (${k8sConfig.clusterId}) not reachable within ${apiProbeTimeoutMs}ms. VPN likely not connected, or the cluster API is degraded.`,
|
|
@@ -332,6 +342,7 @@ export class K8sService extends Context.Service<
|
|
|
332
342
|
command: fullCommand,
|
|
333
343
|
};
|
|
334
344
|
}),
|
|
345
|
+
{ alreadySatisfied: apiProbeTimeoutMs > 0 && reachableWithoutPrerequisites },
|
|
335
346
|
).pipe(
|
|
336
347
|
Effect.mapError((error) =>
|
|
337
348
|
isPrerequisiteRunError(error)
|
|
@@ -78,32 +78,38 @@ export async function stopWhenIdle(
|
|
|
78
78
|
const deadline = now() + init.disconnectTimeoutMs;
|
|
79
79
|
let evidence = "VPN stop did not produce confirmed disconnected status.";
|
|
80
80
|
try {
|
|
81
|
-
|
|
81
|
+
const remaining = deadline - now();
|
|
82
82
|
if (remaining > 0) {
|
|
83
83
|
const stop = await runCommand("stop", remaining);
|
|
84
|
-
remaining = deadline - now();
|
|
85
84
|
if (stop.exitCode !== 0) {
|
|
86
|
-
evidence =
|
|
85
|
+
evidence = `VPN stop command failed (exit ${stop.exitCode}); ownership is unknown and stop will not be retried.`;
|
|
87
86
|
} else {
|
|
88
|
-
const
|
|
87
|
+
const stillConnected =
|
|
88
|
+
"VPN still reported connected after stop when the disconnect deadline expired.";
|
|
89
|
+
const confirmDisconnected = async (): Promise<true | string> => {
|
|
89
90
|
const statusRemaining = deadline - now();
|
|
90
|
-
if (statusRemaining <= 0)
|
|
91
|
+
if (statusRemaining <= 0) {
|
|
92
|
+
return "VPN stop was dispatched, but the disconnect deadline expired before status could be confirmed.";
|
|
93
|
+
}
|
|
91
94
|
const status = await runCommand("status", statusRemaining);
|
|
92
|
-
remaining = deadline - now();
|
|
93
95
|
const connected = parseVpnStatus(init.driver, status);
|
|
94
96
|
if (connected === false) return true;
|
|
95
|
-
if (connected === undefined
|
|
97
|
+
if (connected === undefined) {
|
|
98
|
+
return status.exitCode === 0
|
|
99
|
+
? "VPN status output after stop was unparseable; ownership is unknown."
|
|
100
|
+
: `VPN status command after stop failed (exit ${status.exitCode}); ownership is unknown.`;
|
|
101
|
+
}
|
|
96
102
|
const sleepRemaining = deadline - now();
|
|
97
|
-
if (sleepRemaining <= 0) return
|
|
103
|
+
if (sleepRemaining <= 0) return stillConnected;
|
|
98
104
|
await sleep(Math.min(250, sleepRemaining));
|
|
99
|
-
|
|
100
|
-
return remaining > 0 && confirmDisconnected();
|
|
105
|
+
return deadline - now() > 0 ? confirmDisconnected() : stillConnected;
|
|
101
106
|
};
|
|
102
|
-
|
|
107
|
+
const confirmed = await confirmDisconnected();
|
|
108
|
+
if (confirmed === true) {
|
|
103
109
|
store.commitStop(guard, true, "VPN stop confirmed disconnected.", now());
|
|
104
110
|
return;
|
|
105
111
|
}
|
|
106
|
-
evidence =
|
|
112
|
+
evidence = confirmed;
|
|
107
113
|
}
|
|
108
114
|
} else {
|
|
109
115
|
evidence = "VPN disconnect deadline expired before a command could safely start.";
|
|
@@ -742,6 +742,8 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
|
742
742
|
runCommand: PrerequisiteCommandRunner<CommandError>,
|
|
743
743
|
effect: Effect.Effect<A, E, never>,
|
|
744
744
|
options?: {
|
|
745
|
+
/** Caller proved the target is already reachable: skip the VPN entirely (no status, no lease). */
|
|
746
|
+
alreadySatisfied?: boolean;
|
|
745
747
|
tryWithoutPrerequisites?: boolean;
|
|
746
748
|
runGuardianInProcess?: boolean;
|
|
747
749
|
guardianSpawn?: GuardianSpawner;
|
|
@@ -750,7 +752,7 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
|
750
752
|
const vpnPrerequisites = normalizeProfilePrerequisites(profile).filter(
|
|
751
753
|
(prerequisite) => prerequisite.type === "vpn",
|
|
752
754
|
);
|
|
753
|
-
if (vpnPrerequisites.length === 0) return effect;
|
|
755
|
+
if (vpnPrerequisites.length === 0 || options?.alreadySatisfied === true) return effect;
|
|
754
756
|
|
|
755
757
|
return Effect.gen(function* () {
|
|
756
758
|
const tryDirect = () => effect.pipe(Effect.result);
|