@blogic-cz/agent-tools 0.15.1 → 0.15.3
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 +8 -1
- package/package.json +1 -1
- 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/observability-tool/shared.ts +29 -15
- package/src/observability-tool/trace.ts +16 -11
- package/src/observability-tool/types.ts +2 -1
- package/src/session-tool/index.ts +42 -18
- package/src/session-tool/pi.ts +158 -28
- package/src/session-tool/service.ts +65 -26
- package/src/session-tool/summaries.ts +42 -0
- package/src/session-tool/types.ts +12 -2
package/README.md
CHANGED
|
@@ -265,13 +265,14 @@ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Gene
|
|
|
265
265
|
|
|
266
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.
|
|
267
267
|
|
|
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`; `
|
|
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
|
|
|
270
270
|
Useful flows:
|
|
271
271
|
|
|
272
272
|
```bash
|
|
273
273
|
bun gh-tool pr checks-failed --pr 123 --with-logs --format json # includes diagnosis
|
|
274
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
|
|
275
276
|
bun gh-tool pr watch --prs 123,124 --format jsonl --timeout 600
|
|
276
277
|
bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and thread
|
|
277
278
|
# Optional --pr/--thread-id retain legacy flow and are validated before either mutation.
|
|
@@ -279,6 +280,12 @@ bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and
|
|
|
279
280
|
|
|
280
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.
|
|
281
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
|
+
|
|
282
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.
|
|
283
290
|
|
|
284
291
|
## Audit Logging
|
package/package.json
CHANGED
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,
|
package/src/gh-tool/pr/index.ts
CHANGED
package/src/gh-tool/service.ts
CHANGED
|
@@ -14,6 +14,41 @@ const NETWORK_ERROR_RE =
|
|
|
14
14
|
const AUTH_401_RE = /HTTP 401|Bad credentials/i;
|
|
15
15
|
const MAX_GH_RETRIES = 2;
|
|
16
16
|
|
|
17
|
+
// A bare `gh` stderr tells an agent what broke but never what to do next. `hint`/`nextCommand`
|
|
18
|
+
// already exist on GitHubCommandError; these fill them for the failures agents actually hit.
|
|
19
|
+
const KNOWN_STDERR_HINTS: ReadonlyArray<{
|
|
20
|
+
readonly re: RegExp;
|
|
21
|
+
readonly hint: string;
|
|
22
|
+
readonly nextCommand?: string;
|
|
23
|
+
}> = [
|
|
24
|
+
{
|
|
25
|
+
re: /no checks reported/i,
|
|
26
|
+
hint: "The head commit carries no check runs yet. Wait for CI to register, or trigger the workflow when the push event was dropped.",
|
|
27
|
+
nextCommand: "agent-tools-gh pr trigger-checks --pr <number> --workflow <file.yml>",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
re: /no commits between/i,
|
|
31
|
+
hint: "Head and base point at the same commit. Push a commit before opening or updating the PR.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
re: /already exists/i,
|
|
35
|
+
hint: "The resource already exists. Fetch the existing one and update it instead of creating another.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
re: /pending review/i,
|
|
39
|
+
hint: "A pending (unsubmitted) review blocks this mutation. Inspect its contents and submit or discard it before retrying.",
|
|
40
|
+
nextCommand: "agent-tools-gh pr reviews --pr <number>",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
re: /workflow does not have 'workflow_dispatch'/i,
|
|
44
|
+
hint: "This workflow cannot be dispatched manually. Re-run an existing run instead.",
|
|
45
|
+
nextCommand: "agent-tools-gh workflow rerun --run <run-id>",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const resolveStderrHint = (stderr: string) =>
|
|
50
|
+
KNOWN_STDERR_HINTS.find((entry) => entry.re.test(stderr));
|
|
51
|
+
|
|
17
52
|
// Only retry verbs that are unambiguously idempotent reads — never replay a mutation on a timeout.
|
|
18
53
|
const READ_VERBS = new Set(["view", "list", "checks", "status", "diff"]);
|
|
19
54
|
const MUTATION_TOKENS =
|
|
@@ -204,11 +239,14 @@ export class GitHubService extends Context.Service<
|
|
|
204
239
|
});
|
|
205
240
|
}
|
|
206
241
|
|
|
242
|
+
const known = resolveStderrHint(result.stderr);
|
|
207
243
|
return yield* new GitHubCommandError({
|
|
208
244
|
message: result.stderr,
|
|
209
245
|
command: `gh ${args.join(" ")}`,
|
|
210
246
|
exitCode: result.exitCode,
|
|
211
247
|
stderr: result.stderr,
|
|
248
|
+
...(known === undefined ? {} : { hint: known.hint }),
|
|
249
|
+
...(known?.nextCommand === undefined ? {} : { nextCommand: known.nextCommand }),
|
|
212
250
|
});
|
|
213
251
|
}
|
|
214
252
|
|
package/src/gh-tool/workflow.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command, Flag, Param } from "effect/unstable/cli";
|
|
2
|
-
import { Console, Effect, Option } from "effect";
|
|
2
|
+
import { Console, Duration, Effect, Option } from "effect";
|
|
3
3
|
|
|
4
4
|
import { formatOption, logFormatted } from "#shared";
|
|
5
5
|
import { CI_CHECK_WATCH_TIMEOUT_MS } from "#gh/config";
|
|
@@ -241,6 +241,65 @@ export const dispatchWorkflow = Effect.fn("workflow.dispatchWorkflow")(function*
|
|
|
241
241
|
};
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
export type DispatchedRun = {
|
|
245
|
+
databaseId: number;
|
|
246
|
+
headSha: string;
|
|
247
|
+
status: string;
|
|
248
|
+
conclusion: string | null;
|
|
249
|
+
url: string;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const DISPATCH_DISCOVERY_ATTEMPTS = 8;
|
|
253
|
+
const DISPATCH_DISCOVERY_INTERVAL_MS = 2000;
|
|
254
|
+
|
|
255
|
+
export const listDispatchedRuns = Effect.fn("workflow.listDispatchedRuns")(function* (opts: {
|
|
256
|
+
workflow: string;
|
|
257
|
+
ref: string;
|
|
258
|
+
repo: string | null;
|
|
259
|
+
}) {
|
|
260
|
+
const gh = yield* GitHubService;
|
|
261
|
+
const args = [
|
|
262
|
+
"run",
|
|
263
|
+
"list",
|
|
264
|
+
"--json",
|
|
265
|
+
"databaseId,headSha,status,conclusion,url",
|
|
266
|
+
"--limit",
|
|
267
|
+
"20",
|
|
268
|
+
"--workflow",
|
|
269
|
+
opts.workflow,
|
|
270
|
+
"--branch",
|
|
271
|
+
opts.ref,
|
|
272
|
+
"--event",
|
|
273
|
+
"workflow_dispatch",
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
if (opts.repo !== null) {
|
|
277
|
+
args.push("--repo", opts.repo);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return yield* gh
|
|
281
|
+
.runGhJson<DispatchedRun[]>(args)
|
|
282
|
+
.pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed<DispatchedRun[]>([])));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// `gh workflow run` and the REST dispatch endpoint both return an empty body — there is no
|
|
286
|
+
// dispatch-to-run mapping. Polling the run list for an id absent before the dispatch is the only
|
|
287
|
+
// way to name the run we just created, and an unnamed run cannot be watched or verified.
|
|
288
|
+
export const discoverDispatchedRun = Effect.fn("workflow.discoverDispatchedRun")(function* (opts: {
|
|
289
|
+
workflow: string;
|
|
290
|
+
ref: string;
|
|
291
|
+
repo: string | null;
|
|
292
|
+
knownRunIds: ReadonlySet<number>;
|
|
293
|
+
}) {
|
|
294
|
+
for (let attempt = 0; attempt < DISPATCH_DISCOVERY_ATTEMPTS; attempt += 1) {
|
|
295
|
+
const runs = yield* listDispatchedRuns(opts);
|
|
296
|
+
const created = runs.find((run) => !opts.knownRunIds.has(run.databaseId));
|
|
297
|
+
if (created !== undefined) return created;
|
|
298
|
+
yield* Effect.sleep(Duration.millis(DISPATCH_DISCOVERY_INTERVAL_MS));
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
});
|
|
302
|
+
|
|
244
303
|
// `gh run watch` has no native timeout (observed hanging 36 min). Block for the caller's --timeout,
|
|
245
304
|
// then fall back to a one-shot snapshot so a timeout never returns nothing.
|
|
246
305
|
const DEFAULT_WATCH_RUN_TIMEOUT_SECONDS = CI_CHECK_WATCH_TIMEOUT_MS / 1000;
|
|
@@ -781,13 +840,32 @@ export const workflowRunCommand = Command.make(
|
|
|
781
840
|
({ field, format, ref, repo, workflow }) =>
|
|
782
841
|
Effect.gen(function* () {
|
|
783
842
|
const resolvedRepo = yield* resolveRepoArg(repo);
|
|
843
|
+
const before = yield* listDispatchedRuns({ workflow, ref, repo: resolvedRepo });
|
|
784
844
|
const result = yield* dispatchWorkflow({
|
|
785
845
|
workflow,
|
|
786
846
|
ref,
|
|
787
847
|
fields: field,
|
|
788
848
|
repo: resolvedRepo,
|
|
789
849
|
});
|
|
790
|
-
yield*
|
|
850
|
+
const created = yield* discoverDispatchedRun({
|
|
851
|
+
workflow,
|
|
852
|
+
ref,
|
|
853
|
+
repo: resolvedRepo,
|
|
854
|
+
knownRunIds: new Set(before.map((run) => run.databaseId)),
|
|
855
|
+
});
|
|
856
|
+
yield* logFormatted(
|
|
857
|
+
{
|
|
858
|
+
...result,
|
|
859
|
+
runId: created?.databaseId ?? null,
|
|
860
|
+
headSha: created?.headSha ?? null,
|
|
861
|
+
url: created?.url ?? null,
|
|
862
|
+
nextCommand:
|
|
863
|
+
created === null
|
|
864
|
+
? `agent-tools-gh workflow list --workflow ${workflow} --branch ${ref}`
|
|
865
|
+
: `agent-tools-gh workflow watch --run ${created.databaseId}`,
|
|
866
|
+
},
|
|
867
|
+
format,
|
|
868
|
+
);
|
|
791
869
|
}),
|
|
792
870
|
).pipe(Command.withDescription("Dispatch a workflow_dispatch workflow run"));
|
|
793
871
|
|