@blogic-cz/agent-tools 0.14.62 → 0.15.2
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 +14 -4
- package/package.json +1 -1
- package/schemas/agent-tools.schema.json +17 -8
- package/src/config/loader.ts +38 -4
- package/src/config/types.ts +3 -2
- package/src/gh-tool/index.ts +3 -1
- package/src/gh-tool/pr/commands.ts +194 -7
- package/src/gh-tool/pr/core.ts +92 -6
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/service.ts +38 -0
- package/src/gh-tool/workflow.ts +80 -2
- package/src/shared/prerequisites/driver-commands.ts +88 -0
- package/src/shared/prerequisites/guardian-entry.ts +49 -0
- package/src/shared/prerequisites/guardian.ts +157 -0
- package/src/shared/prerequisites/runtime.ts +703 -531
- package/src/shared/prerequisites/store.ts +715 -0
- package/src/shared/prerequisites/types.ts +0 -25
package/README.md
CHANGED
|
@@ -178,6 +178,10 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
178
178
|
// auto defaults to true:
|
|
179
179
|
// darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
|
|
180
180
|
name: "ExampleVPN",
|
|
181
|
+
// Reuse package-managed connections for 30 seconds after the last command. Set 0 for immediate cleanup.
|
|
182
|
+
idleDisconnectMs: 30000,
|
|
183
|
+
// Total window for stop plus disconnected-status confirmation.
|
|
184
|
+
disconnectTimeoutMs: 10000,
|
|
181
185
|
// Optional: pass IPSec shared secret to macOS scutil from env without storing the value in config.
|
|
182
186
|
secretEnvVar: "EXAMPLE_VPN_IPSEC_SHARED_SECRET",
|
|
183
187
|
},
|
|
@@ -187,8 +191,7 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
187
191
|
clusterId: "your-cluster-id",
|
|
188
192
|
namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
|
|
189
193
|
prerequisites: [{ type: "vpn", key: "exampleVpn" }],
|
|
190
|
-
//
|
|
191
|
-
// automatic VPN connect/disconnect execution is planned for a follow-up release.
|
|
194
|
+
// agent-tools starts disconnected VPNs, shares package-local leases, and disconnects only connections it owns.
|
|
192
195
|
},
|
|
193
196
|
},
|
|
194
197
|
logs: {
|
|
@@ -262,13 +265,14 @@ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Gene
|
|
|
262
265
|
|
|
263
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.
|
|
264
267
|
|
|
265
|
-
`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`; `
|
|
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.
|
|
266
269
|
|
|
267
270
|
Useful flows:
|
|
268
271
|
|
|
269
272
|
```bash
|
|
270
273
|
bun gh-tool pr checks-failed --pr 123 --with-logs --format json # includes diagnosis
|
|
271
274
|
bun gh-tool pr rerun-checks --pr 123 --failed-only --watch --timeout 600
|
|
275
|
+
bun gh-tool pr trigger-checks --pr 123 --workflow dotnet-pull-request.yml # only when zero checks reported
|
|
272
276
|
bun gh-tool pr watch --prs 123,124 --format jsonl --timeout 600
|
|
273
277
|
bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and thread
|
|
274
278
|
# Optional --pr/--thread-id retain legacy flow and are validated before either mutation.
|
|
@@ -276,6 +280,12 @@ bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and
|
|
|
276
280
|
|
|
277
281
|
Reruns preflight every target before mutation and fail closed with `evidence_unavailable` when attempt jobs or logs cannot be read. Failed jobs use one `gh run rerun RUN --failed` mutation per workflow run. Without `--watch`, output returns current attempt metadata immediately; with `--watch`, discovery and watching share one absolute `--timeout` deadline and report `discovery_timeout` or `watch_timeout` with latest attempt state. Repeated matching pre-test infrastructure failures return `escalation_required` without mutation. See [`skills/gh-tool/SKILL.md`](skills/gh-tool/SKILL.md) for operating guidance; this section is canonical for added output fields.
|
|
278
282
|
|
|
283
|
+
Zero reported checks is a state, not an error: `gh pr checks` exits nonzero on an empty result, and every command that reads checks maps that to `[]`. `pr trigger-checks` covers the case where GitHub dropped the `pull_request` event and no run exists at all — it dispatches the named `workflow_dispatch` workflow **on the PR head branch**, then compares the created run's `headSha` back to the PR head and reports `matchesPrHead`. A run dispatched on the wrong ref goes green for another branch and must never be read as PR evidence, so `workflow run` also returns the discovered `runId`/`headSha` instead of a bare `dispatched: true`.
|
|
284
|
+
|
|
285
|
+
`pr feedback` returns the whole inventory by default. Narrow it with `--only visible-open|needs-human-reply|current-head` (narrowed filters drop issue comments), drop bots with `--exclude-authors github-actions,dependabot`, and read `omitted` for what each filter removed. Base64 report payloads in bodies are replaced with `[base64 payload omitted]`; pass `--raw-bodies` to keep them. `review-triage --omit reviews,inlineComments` trims a repeated snapshot to the verdict and check state, and lists what it left out in `omittedSections`.
|
|
286
|
+
|
|
287
|
+
Call the wrappers through `bun run --silent <tool>` in repos that expose them as package scripts; without `--silent` every invocation echoes the resolved command line into the agent's context.
|
|
288
|
+
|
|
279
289
|
`audit-tool` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
|
|
280
290
|
|
|
281
291
|
## Audit Logging
|
|
@@ -420,7 +430,7 @@ Secrets are **never** stored in the config file. The `db-tool` config references
|
|
|
420
430
|
}
|
|
421
431
|
```
|
|
422
432
|
|
|
423
|
-
Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails.
|
|
433
|
+
Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails. Package-managed VPNs remain reusable for `idleDisconnectMs` (default 30000) after the last lease; `0` restores immediate cleanup. Preconnected or `leave-running` connections are treated as external and never stopped automatically. If runtime state is corrupt, unknown, or contains legacy artifacts, first stop all agent-tools processes using the VPN, then remove that VPN state directory under `~/.agent-tools/runtime/vpn-prerequisites`.
|
|
424
434
|
|
|
425
435
|
```json5
|
|
426
436
|
{
|
package/package.json
CHANGED
|
@@ -558,16 +558,25 @@
|
|
|
558
558
|
"enum": ["leave-running", "stop-if-started"]
|
|
559
559
|
},
|
|
560
560
|
"connectTimeoutMs": {
|
|
561
|
-
"type": "number"
|
|
561
|
+
"type": "number",
|
|
562
|
+
"minimum": 0,
|
|
563
|
+
"maximum": 2147483647,
|
|
564
|
+
"multipleOf": 1
|
|
562
565
|
},
|
|
563
566
|
"disconnectTimeoutMs": {
|
|
564
|
-
"type": "number"
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
"
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
567
|
+
"type": "number",
|
|
568
|
+
"minimum": 0,
|
|
569
|
+
"maximum": 2147483647,
|
|
570
|
+
"multipleOf": 1,
|
|
571
|
+
"description": "Total bounded stop-and-confirm window in milliseconds."
|
|
572
|
+
},
|
|
573
|
+
"idleDisconnectMs": {
|
|
574
|
+
"type": "number",
|
|
575
|
+
"minimum": 0,
|
|
576
|
+
"maximum": 2147483647,
|
|
577
|
+
"multipleOf": 1,
|
|
578
|
+
"default": 30000,
|
|
579
|
+
"description": "Managed VPN reuse window after the last lease. Set to 0 for immediate cleanup."
|
|
571
580
|
},
|
|
572
581
|
"secretEnvVar": {
|
|
573
582
|
"type": "string",
|
package/src/config/loader.ts
CHANGED
|
@@ -18,6 +18,20 @@ const CredentialGuardConfigSchema = Schema.Struct({
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
const CleanupPolicySchema = Schema.Literals(["leave-running", "stop-if-started"]);
|
|
21
|
+
const VPN_TIMER_FIELDS = ["connectTimeoutMs", "disconnectTimeoutMs", "idleDisconnectMs"] as const;
|
|
22
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
23
|
+
|
|
24
|
+
function validateVpnTimer(key: string, field: (typeof VPN_TIMER_FIELDS)[number], value: unknown) {
|
|
25
|
+
if (
|
|
26
|
+
typeof value !== "number" ||
|
|
27
|
+
!Number.isFinite(value) ||
|
|
28
|
+
!Number.isInteger(value) ||
|
|
29
|
+
value < 0 ||
|
|
30
|
+
value > MAX_TIMER_MS
|
|
31
|
+
) {
|
|
32
|
+
throw new Error(`VPN "${key}" ${field} must be an integer from 0 to ${MAX_TIMER_MS}.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
|
|
22
36
|
const VpnPrerequisiteSchema = Schema.Struct({
|
|
23
37
|
type: Schema.Literal("vpn"),
|
|
@@ -56,8 +70,7 @@ const VpnConfigSchema = Schema.Struct({
|
|
|
56
70
|
defaultCleanup: Schema.optionalKey(CleanupPolicySchema),
|
|
57
71
|
connectTimeoutMs: Schema.optionalKey(Schema.Number),
|
|
58
72
|
disconnectTimeoutMs: Schema.optionalKey(Schema.Number),
|
|
59
|
-
|
|
60
|
-
leaseTtlMs: Schema.optionalKey(Schema.Number),
|
|
73
|
+
idleDisconnectMs: Schema.optionalKey(Schema.Number),
|
|
61
74
|
secretEnvVar: Schema.optionalKey(Schema.String),
|
|
62
75
|
drivers: Schema.optionalKey(
|
|
63
76
|
Schema.Struct({
|
|
@@ -215,8 +228,29 @@ export function decodeConfig(
|
|
|
215
228
|
const sanitized = stripUnknownTopLevelKeys(parsed);
|
|
216
229
|
|
|
217
230
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
231
|
+
if (isRecord(sanitized) && isRecord(sanitized.vpns)) {
|
|
232
|
+
for (const [key, value] of Object.entries(sanitized.vpns)) {
|
|
233
|
+
if (!isRecord(value)) continue;
|
|
234
|
+
if ("cooldownMs" in value || "leaseTtlMs" in value) {
|
|
235
|
+
throw new Error(`VPN "${key}" uses removed cooldownMs or leaseTtlMs configuration.`);
|
|
236
|
+
}
|
|
237
|
+
for (const field of VPN_TIMER_FIELDS) {
|
|
238
|
+
if (value[field] !== undefined) validateVpnTimer(key, field, value[field]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized) as AgentToolsConfig;
|
|
243
|
+
return decoded.vpns
|
|
244
|
+
? {
|
|
245
|
+
...decoded,
|
|
246
|
+
vpns: Object.fromEntries(
|
|
247
|
+
Object.entries(decoded.vpns).map(([key, vpn]) => [
|
|
248
|
+
key,
|
|
249
|
+
{ idleDisconnectMs: 30_000, ...vpn },
|
|
250
|
+
]),
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
: decoded;
|
|
220
254
|
} catch (error) {
|
|
221
255
|
throw new Error(
|
|
222
256
|
`Invalid agent-tools config at ${configPath}: ${
|
package/src/config/types.ts
CHANGED
|
@@ -43,9 +43,10 @@ export type VpnConfig = {
|
|
|
43
43
|
auto?: boolean;
|
|
44
44
|
defaultCleanup?: CleanupPolicy;
|
|
45
45
|
connectTimeoutMs?: number;
|
|
46
|
+
/** Total bounded stop-and-confirm window in milliseconds. */
|
|
46
47
|
disconnectTimeoutMs?: number;
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
/** Managed VPN reuse window after the last lease. Defaults to 30000; 0 disconnects immediately. */
|
|
49
|
+
idleDisconnectMs?: number;
|
|
49
50
|
/** Name of environment variable holding the VPN shared secret for supported drivers. */
|
|
50
51
|
secretEnvVar?: string;
|
|
51
52
|
drivers?: {
|
package/src/gh-tool/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
prChecksCommand,
|
|
41
41
|
prChecksFailedCommand,
|
|
42
42
|
prRerunChecksCommand,
|
|
43
|
+
prTriggerChecksCommand,
|
|
43
44
|
prReplyAndResolveCommand,
|
|
44
45
|
prReviewTriageBatchCommand,
|
|
45
46
|
prReviewTriageCommand,
|
|
@@ -97,6 +98,7 @@ const prCommand = Command.make("pr", {}).pipe(
|
|
|
97
98
|
prChecksFailedCommand,
|
|
98
99
|
prWatchCommand,
|
|
99
100
|
prRerunChecksCommand,
|
|
101
|
+
prTriggerChecksCommand,
|
|
100
102
|
prReplyAndResolveCommand,
|
|
101
103
|
prReviewTriageCommand,
|
|
102
104
|
prReviewTriageBatchCommand,
|
|
@@ -174,7 +176,7 @@ WORKFLOW FOR AI AGENTS:
|
|
|
174
176
|
2. Use 'pr discussion-summary' for overview (counts + latest discussion comment)
|
|
175
177
|
3. Use 'pr threads' and 'pr issue-comments-latest --author <username> --body-contains "Review"' for review context
|
|
176
178
|
4. Use 'pr submit-review', 'pr reply', 'pr comment' and 'pr resolve' to handle feedback
|
|
177
|
-
5. Use 'pr checks' to monitor CI status
|
|
179
|
+
5. Use 'pr checks' to monitor CI status; 'pr trigger-checks --workflow <file.yml>' when zero checks were reported
|
|
178
180
|
6. Use 'pr merge' to merge (dry-run by default)
|
|
179
181
|
7. Use 'issue list' to list open/closed issues
|
|
180
182
|
8. Use 'issue triage --issue N --verbosity full' to inspect one issue in one call
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command, Flag } from "effect/unstable/cli";
|
|
1
|
+
import { Command, Flag, Param } from "effect/unstable/cli";
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
|
|
4
4
|
import type { CheckResult, PRStatusResult } from "#gh/types";
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
listPRs,
|
|
40
40
|
mergePR,
|
|
41
41
|
rerunChecks,
|
|
42
|
+
triggerChecks,
|
|
42
43
|
watchPRs,
|
|
43
44
|
viewPR,
|
|
44
45
|
waitForMergeable,
|
|
@@ -182,6 +183,101 @@ export const fetchCurrentFeedback = (pr: number | null) =>
|
|
|
182
183
|
command: "gh-tool pr feedback",
|
|
183
184
|
}).pipe(Effect.map(({ value }) => value));
|
|
184
185
|
|
|
186
|
+
// Bot bodies carry base64 report payloads that no agent reads but every agent pays for.
|
|
187
|
+
const BASE64_PAYLOAD_RE = /data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]{40,}/gi;
|
|
188
|
+
|
|
189
|
+
export const trimNoisyBody = (body: string): string =>
|
|
190
|
+
body.replace(BASE64_PAYLOAD_RE, "[base64 payload omitted]");
|
|
191
|
+
|
|
192
|
+
export const FEEDBACK_FILTERS = [
|
|
193
|
+
"all",
|
|
194
|
+
"visible-open",
|
|
195
|
+
"needs-human-reply",
|
|
196
|
+
"current-head",
|
|
197
|
+
] as const;
|
|
198
|
+
|
|
199
|
+
export type FeedbackFilter = (typeof FEEDBACK_FILTERS)[number];
|
|
200
|
+
|
|
201
|
+
type FeedbackInventory = {
|
|
202
|
+
readonly reviews: ReadonlyArray<{ author: string; body: string; feedbackOrigin: string }>;
|
|
203
|
+
readonly threads: ReadonlyArray<{
|
|
204
|
+
commentId: number;
|
|
205
|
+
body: string;
|
|
206
|
+
feedbackOrigin: string;
|
|
207
|
+
isVisibleOpen: boolean;
|
|
208
|
+
needsHumanReply: boolean;
|
|
209
|
+
}>;
|
|
210
|
+
readonly inlineComments: ReadonlyArray<{
|
|
211
|
+
id: number;
|
|
212
|
+
inReplyToId: number | null;
|
|
213
|
+
author: string;
|
|
214
|
+
body: string;
|
|
215
|
+
feedbackOrigin: string;
|
|
216
|
+
}>;
|
|
217
|
+
readonly issueComments: ReadonlyArray<{ author: string; body: string; feedbackOrigin: string }>;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const filterFeedback = <T extends FeedbackInventory>(
|
|
221
|
+
feedback: T,
|
|
222
|
+
options: {
|
|
223
|
+
only: FeedbackFilter;
|
|
224
|
+
excludeAuthors: ReadonlyArray<string>;
|
|
225
|
+
rawBodies: boolean;
|
|
226
|
+
},
|
|
227
|
+
) => {
|
|
228
|
+
const excluded = options.excludeAuthors
|
|
229
|
+
.map((author) => author.toLowerCase())
|
|
230
|
+
.filter((a) => a !== "");
|
|
231
|
+
const keepAuthor = (author: string) =>
|
|
232
|
+
!excluded.some((needle) => author.toLowerCase().includes(needle));
|
|
233
|
+
|
|
234
|
+
const threads = feedback.threads.filter((thread) =>
|
|
235
|
+
options.only === "visible-open"
|
|
236
|
+
? thread.isVisibleOpen
|
|
237
|
+
: options.only === "needs-human-reply"
|
|
238
|
+
? thread.needsHumanReply
|
|
239
|
+
: options.only === "current-head"
|
|
240
|
+
? thread.feedbackOrigin === "current_head"
|
|
241
|
+
: true,
|
|
242
|
+
);
|
|
243
|
+
const keptThreadRoots = new Set(threads.map((thread) => thread.commentId));
|
|
244
|
+
|
|
245
|
+
const inlineComments = feedback.inlineComments
|
|
246
|
+
.filter((comment) =>
|
|
247
|
+
options.only === "all"
|
|
248
|
+
? true
|
|
249
|
+
: options.only === "current-head"
|
|
250
|
+
? comment.feedbackOrigin === "current_head"
|
|
251
|
+
: keptThreadRoots.has(comment.inReplyToId ?? comment.id),
|
|
252
|
+
)
|
|
253
|
+
.filter((comment) => keepAuthor(comment.author));
|
|
254
|
+
|
|
255
|
+
const reviews = feedback.reviews
|
|
256
|
+
.filter((review) => options.only !== "current-head" || review.feedbackOrigin === "current_head")
|
|
257
|
+
.filter((review) => keepAuthor(review.author));
|
|
258
|
+
|
|
259
|
+
const issueComments = (options.only === "all" ? feedback.issueComments : []).filter((comment) =>
|
|
260
|
+
keepAuthor(comment.author),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const body = <I extends { body: string }>(item: I): I =>
|
|
264
|
+
options.rawBodies ? item : { ...item, body: trimNoisyBody(item.body) };
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
filter: options.only,
|
|
268
|
+
omitted: {
|
|
269
|
+
reviews: feedback.reviews.length - reviews.length,
|
|
270
|
+
threads: feedback.threads.length - threads.length,
|
|
271
|
+
inlineComments: feedback.inlineComments.length - inlineComments.length,
|
|
272
|
+
issueComments: feedback.issueComments.length - issueComments.length,
|
|
273
|
+
},
|
|
274
|
+
reviews: reviews.map(body),
|
|
275
|
+
threads: threads.map(body),
|
|
276
|
+
inlineComments: inlineComments.map(body),
|
|
277
|
+
issueComments: issueComments.map(body),
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
185
281
|
const countFeedbackOrigins = (items: ReadonlyArray<{ feedbackOrigin: string }>) => ({
|
|
186
282
|
current_head: items.filter((item) => item.feedbackOrigin === "current_head").length,
|
|
187
283
|
pre_existing: items.filter((item) => item.feedbackOrigin === "pre_existing").length,
|
|
@@ -720,6 +816,39 @@ export const prRerunChecksCommand = Command.make(
|
|
|
720
816
|
Command.withDescription("Rerun CI checks for a PR (GitHub Actions only, failed by default)"),
|
|
721
817
|
);
|
|
722
818
|
|
|
819
|
+
export const prTriggerChecksCommand = Command.make(
|
|
820
|
+
"trigger-checks",
|
|
821
|
+
{
|
|
822
|
+
field: Param.variadic(
|
|
823
|
+
Param.string(Param.flagKind, "field").pipe(
|
|
824
|
+
Param.withAlias("f"),
|
|
825
|
+
Param.withDescription("Workflow input as key=value; may be repeated"),
|
|
826
|
+
),
|
|
827
|
+
),
|
|
828
|
+
format: formatOption,
|
|
829
|
+
pr: Flag.integer("pr").pipe(
|
|
830
|
+
Flag.withDescription("PR number (default: current branch PR)"),
|
|
831
|
+
Flag.optional,
|
|
832
|
+
),
|
|
833
|
+
repo: repoOption,
|
|
834
|
+
workflow: Flag.string("workflow").pipe(
|
|
835
|
+
Flag.withDescription("workflow_dispatch workflow file to run against the PR head branch"),
|
|
836
|
+
),
|
|
837
|
+
},
|
|
838
|
+
({ field, format, pr, repo, workflow }) =>
|
|
839
|
+
withRepo(
|
|
840
|
+
repo,
|
|
841
|
+
Effect.gen(function* () {
|
|
842
|
+
const result = yield* triggerChecks(Option.getOrNull(pr), workflow, field);
|
|
843
|
+
yield* logFormatted(result, format);
|
|
844
|
+
}),
|
|
845
|
+
),
|
|
846
|
+
).pipe(
|
|
847
|
+
Command.withDescription(
|
|
848
|
+
"Dispatch a workflow against the PR head branch when no checks were reported, and verify the created run matches the PR head",
|
|
849
|
+
),
|
|
850
|
+
);
|
|
851
|
+
|
|
723
852
|
export const prThreadsCommand = Command.make(
|
|
724
853
|
"threads",
|
|
725
854
|
{
|
|
@@ -851,24 +980,50 @@ export const prReviewsCommand = Command.make(
|
|
|
851
980
|
export const prFeedbackCommand = Command.make(
|
|
852
981
|
"feedback",
|
|
853
982
|
{
|
|
983
|
+
excludeAuthors: Flag.string("exclude-authors").pipe(
|
|
984
|
+
Flag.withDescription(
|
|
985
|
+
"Comma-separated author substrings to drop (e.g. github-actions,dependabot)",
|
|
986
|
+
),
|
|
987
|
+
Flag.optional,
|
|
988
|
+
),
|
|
854
989
|
format: formatOption,
|
|
990
|
+
only: Flag.choice("only", FEEDBACK_FILTERS).pipe(
|
|
991
|
+
Flag.withDefault("all" as FeedbackFilter),
|
|
992
|
+
Flag.withDescription(
|
|
993
|
+
"all (default) | visible-open | needs-human-reply | current-head; narrowed filters drop issue comments",
|
|
994
|
+
),
|
|
995
|
+
),
|
|
855
996
|
pr: Flag.integer("pr").pipe(
|
|
856
997
|
Flag.withDescription("PR number (default: current branch PR)"),
|
|
857
998
|
Flag.optional,
|
|
858
999
|
),
|
|
1000
|
+
rawBodies: Flag.boolean("raw-bodies").pipe(
|
|
1001
|
+
Flag.withDefault(false),
|
|
1002
|
+
Flag.withDescription("Keep base64 report payloads in bodies instead of omitting them"),
|
|
1003
|
+
),
|
|
859
1004
|
repo: repoOption,
|
|
860
1005
|
},
|
|
861
|
-
({ format, pr, repo }) =>
|
|
1006
|
+
({ excludeAuthors, format, only, pr, rawBodies, repo }) =>
|
|
862
1007
|
withRepo(
|
|
863
1008
|
repo,
|
|
864
1009
|
Effect.gen(function* () {
|
|
865
1010
|
const feedback = yield* fetchCurrentFeedback(Option.getOrNull(pr));
|
|
866
|
-
yield* logFormatted(
|
|
1011
|
+
yield* logFormatted(
|
|
1012
|
+
filterFeedback(feedback, {
|
|
1013
|
+
only,
|
|
1014
|
+
excludeAuthors:
|
|
1015
|
+
Option.getOrNull(excludeAuthors)
|
|
1016
|
+
?.split(",")
|
|
1017
|
+
.map((a) => a.trim()) ?? [],
|
|
1018
|
+
rawBodies,
|
|
1019
|
+
}),
|
|
1020
|
+
format,
|
|
1021
|
+
);
|
|
867
1022
|
}),
|
|
868
1023
|
),
|
|
869
1024
|
).pipe(
|
|
870
1025
|
Command.withDescription(
|
|
871
|
-
"Full review-response inventory in one call: submitted reviews + threads (with state) + inline comments + issue comments",
|
|
1026
|
+
"Full review-response inventory in one call: submitted reviews + threads (with state) + inline comments + issue comments. Narrow with --only, drop bots with --exclude-authors; `omitted` reports what each filter removed",
|
|
872
1027
|
),
|
|
873
1028
|
);
|
|
874
1029
|
|
|
@@ -1110,28 +1265,60 @@ export const prSubmitReviewCommand = Command.make(
|
|
|
1110
1265
|
),
|
|
1111
1266
|
);
|
|
1112
1267
|
|
|
1268
|
+
export const OMITTABLE_TRIAGE_SECTIONS = [
|
|
1269
|
+
"reviews",
|
|
1270
|
+
"inlineComments",
|
|
1271
|
+
"unresolvedThreads",
|
|
1272
|
+
] as const;
|
|
1273
|
+
|
|
1274
|
+
export type OmittableTriageSection = (typeof OMITTABLE_TRIAGE_SECTIONS)[number];
|
|
1275
|
+
|
|
1276
|
+
export const omitTriageSections = <T extends Record<string, unknown>>(
|
|
1277
|
+
triage: T,
|
|
1278
|
+
omit: ReadonlyArray<string>,
|
|
1279
|
+
) => {
|
|
1280
|
+
const requested = omit.map((section) => section.trim()).filter((section) => section !== "");
|
|
1281
|
+
const applied = requested.filter((section): section is OmittableTriageSection =>
|
|
1282
|
+
(OMITTABLE_TRIAGE_SECTIONS as ReadonlyArray<string>).includes(section),
|
|
1283
|
+
);
|
|
1284
|
+
if (applied.length === 0) return triage;
|
|
1285
|
+
const kept = Object.fromEntries(
|
|
1286
|
+
Object.entries(triage).filter(([key]) => !applied.includes(key as OmittableTriageSection)),
|
|
1287
|
+
) as Partial<T>;
|
|
1288
|
+
return { ...kept, omittedSections: applied };
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1113
1291
|
export const prReviewTriageCommand = Command.make(
|
|
1114
1292
|
"review-triage",
|
|
1115
1293
|
{
|
|
1116
1294
|
format: formatOption,
|
|
1295
|
+
omit: Flag.string("omit").pipe(
|
|
1296
|
+
Flag.withDescription(
|
|
1297
|
+
`Comma-separated sections to leave out (${OMITTABLE_TRIAGE_SECTIONS.join(", ")}) — use for cheap repeated snapshots; \`pr feedback\` owns the full inventory`,
|
|
1298
|
+
),
|
|
1299
|
+
Flag.optional,
|
|
1300
|
+
),
|
|
1117
1301
|
pr: Flag.integer("pr").pipe(
|
|
1118
1302
|
Flag.withDescription("PR number (default: current branch PR)"),
|
|
1119
1303
|
Flag.optional,
|
|
1120
1304
|
),
|
|
1121
1305
|
repo: repoOption,
|
|
1122
1306
|
},
|
|
1123
|
-
({ format, pr, repo }) =>
|
|
1307
|
+
({ format, omit, pr, repo }) =>
|
|
1124
1308
|
withRepo(
|
|
1125
1309
|
repo,
|
|
1126
1310
|
Effect.gen(function* () {
|
|
1127
1311
|
const prNumber = Option.getOrNull(pr);
|
|
1128
1312
|
const result = yield* fetchReviewTriage(prNumber, format);
|
|
1129
|
-
yield* logFormatted(
|
|
1313
|
+
yield* logFormatted(
|
|
1314
|
+
omitTriageSections(result, Option.getOrNull(omit)?.split(",") ?? []),
|
|
1315
|
+
format,
|
|
1316
|
+
);
|
|
1130
1317
|
}),
|
|
1131
1318
|
),
|
|
1132
1319
|
).pipe(
|
|
1133
1320
|
Command.withDescription(
|
|
1134
|
-
"Composite: PR info + unresolved threads + visible-open threads + discussion summary + checks status in one call",
|
|
1321
|
+
"Composite: PR info + merge-readiness verdict + unresolved threads + visible-open threads + discussion summary + checks status in one call",
|
|
1135
1322
|
),
|
|
1136
1323
|
);
|
|
1137
1324
|
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -22,7 +22,15 @@ import { GitHubService } from "#gh/service";
|
|
|
22
22
|
|
|
23
23
|
import type { ButStatusJson, PRViewJsonResult } from "./helpers";
|
|
24
24
|
import { runLocalCommand } from "./helpers";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
diagnoseLogEntries,
|
|
27
|
+
discoverDispatchedRun,
|
|
28
|
+
dispatchWorkflow,
|
|
29
|
+
fetchJobLogs,
|
|
30
|
+
formatLogEntries,
|
|
31
|
+
listDispatchedRuns,
|
|
32
|
+
parseRawJobLogs,
|
|
33
|
+
} from "#gh/workflow";
|
|
26
34
|
|
|
27
35
|
const CHECK_JSON_FIELDS = "name,state,bucket,link";
|
|
28
36
|
const LONG_LIVED_BRANCHES = new Set(["main", "master", "develop", "staging", "production"]);
|
|
@@ -187,6 +195,10 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
|
|
|
187
195
|
return context;
|
|
188
196
|
});
|
|
189
197
|
|
|
198
|
+
// `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
|
|
199
|
+
// is an ordinary state, so map it to [] and keep the zero-check paths downstream reachable.
|
|
200
|
+
export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
|
|
201
|
+
|
|
190
202
|
const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
|
|
191
203
|
const gh = yield* GitHubService;
|
|
192
204
|
|
|
@@ -195,7 +207,15 @@ const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: numbe
|
|
|
195
207
|
args.push(String(pr));
|
|
196
208
|
}
|
|
197
209
|
|
|
198
|
-
return yield* gh
|
|
210
|
+
return yield* gh
|
|
211
|
+
.runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS])
|
|
212
|
+
.pipe(
|
|
213
|
+
Effect.catchTag("GitHubCommandError", (error) =>
|
|
214
|
+
NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
|
|
215
|
+
? Effect.succeed<CheckResult[]>([])
|
|
216
|
+
: Effect.fail(error),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
199
219
|
});
|
|
200
220
|
|
|
201
221
|
const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
|
|
@@ -1049,6 +1069,69 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
1049
1069
|
return results;
|
|
1050
1070
|
});
|
|
1051
1071
|
|
|
1072
|
+
// Dispatching on the wrong ref produces a green run for another branch that reads as PR evidence.
|
|
1073
|
+
// The ref is therefore taken from the PR head, and the created run's headSha is compared back to it.
|
|
1074
|
+
export const triggerChecks = Effect.fn("pr.triggerChecks")(function* (
|
|
1075
|
+
pr: number | null,
|
|
1076
|
+
workflow: string,
|
|
1077
|
+
fields: ReadonlyArray<string>,
|
|
1078
|
+
) {
|
|
1079
|
+
const gh = yield* GitHubService;
|
|
1080
|
+
const info = yield* viewPR(pr);
|
|
1081
|
+
const repoInfo = yield* gh.getRepoInfo();
|
|
1082
|
+
const repo = `${repoInfo.owner}/${repoInfo.name}`;
|
|
1083
|
+
|
|
1084
|
+
const existing = yield* fetchCheckResults(info.number);
|
|
1085
|
+
if (existing.length > 0) {
|
|
1086
|
+
return {
|
|
1087
|
+
triggered: false as const,
|
|
1088
|
+
pr: info.number,
|
|
1089
|
+
prHeadSha: info.headSha,
|
|
1090
|
+
message: `${existing.length} check(s) already reported for ${info.headSha ?? "head"}; nothing to trigger.`,
|
|
1091
|
+
nextCommand: `agent-tools-gh pr watch --prs ${info.number} --until terminal --format jsonl`,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const before = yield* listDispatchedRuns({ workflow, ref: info.headRefName, repo });
|
|
1096
|
+
yield* dispatchWorkflow({ workflow, ref: info.headRefName, fields, repo });
|
|
1097
|
+
const created = yield* discoverDispatchedRun({
|
|
1098
|
+
workflow,
|
|
1099
|
+
ref: info.headRefName,
|
|
1100
|
+
repo,
|
|
1101
|
+
knownRunIds: new Set(before.map((run) => run.databaseId)),
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const matchesPrHead = created !== null && created.headSha === info.headSha;
|
|
1105
|
+
if (created !== null && !matchesPrHead) {
|
|
1106
|
+
yield* Console.warn(
|
|
1107
|
+
`⚠️ Dispatched run ${created.databaseId} is on ${created.headSha}, not PR head ${info.headSha ?? "unknown"}. ` +
|
|
1108
|
+
`Its result is NOT evidence for PR #${info.number}.`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
triggered: true as const,
|
|
1114
|
+
pr: info.number,
|
|
1115
|
+
workflow,
|
|
1116
|
+
ref: info.headRefName,
|
|
1117
|
+
runId: created?.databaseId ?? null,
|
|
1118
|
+
runHeadSha: created?.headSha ?? null,
|
|
1119
|
+
prHeadSha: info.headSha,
|
|
1120
|
+
matchesPrHead,
|
|
1121
|
+
url: created?.url ?? null,
|
|
1122
|
+
message:
|
|
1123
|
+
created === null
|
|
1124
|
+
? "Dispatched, but no matching run appeared yet. List runs to find it before treating anything as evidence."
|
|
1125
|
+
: matchesPrHead
|
|
1126
|
+
? `Run ${created.databaseId} is running against PR head ${info.headSha ?? "unknown"}.`
|
|
1127
|
+
: `Run ${created.databaseId} is on ${created.headSha}, not PR head ${info.headSha ?? "unknown"}; not evidence for this PR.`,
|
|
1128
|
+
nextCommand:
|
|
1129
|
+
created === null
|
|
1130
|
+
? `agent-tools-gh workflow list --workflow ${workflow} --branch ${info.headRefName}`
|
|
1131
|
+
: `agent-tools-gh workflow watch --run ${created.databaseId}`,
|
|
1132
|
+
};
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1052
1135
|
export const collectWithStableState = <S, A, E1, R1, E2, R2>(
|
|
1053
1136
|
initial: S,
|
|
1054
1137
|
collect: (state: S) => Effect.Effect<A, E1, R1>,
|
|
@@ -1284,10 +1367,13 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
|
|
|
1284
1367
|
repo: `${repo.owner}/${repo.name}`,
|
|
1285
1368
|
pr: number,
|
|
1286
1369
|
headSha: state.head,
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1370
|
+
...(run?.databaseId === undefined || run.databaseId === null
|
|
1371
|
+
? {}
|
|
1372
|
+
: { runId: run.databaseId }),
|
|
1373
|
+
...(run?.attempt === undefined || run.attempt === null
|
|
1374
|
+
? {}
|
|
1375
|
+
: { attempt: run.attempt }),
|
|
1376
|
+
...(jobId === null ? {} : { jobId }),
|
|
1291
1377
|
name: check.name,
|
|
1292
1378
|
state: check.state,
|
|
1293
1379
|
bucket: check.bucket,
|