@blogic-cz/agent-tools 0.14.40 → 0.14.41
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/package.json +1 -1
- package/src/db-tool/index.ts +28 -2
- package/src/db-tool/service.ts +11 -5
- package/src/gh-tool/index.ts +2 -0
- package/src/gh-tool/issue/commands.ts +5 -1
- package/src/gh-tool/pr/commands.ts +68 -4
- package/src/gh-tool/pr/core.ts +53 -20
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/service.ts +67 -5
- package/src/gh-tool/workflow.ts +12 -1
- package/src/shared/audit.ts +35 -6
- package/src/shared/error-renderer.ts +10 -1
package/package.json
CHANGED
package/src/db-tool/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { SchemaMode } from "./types";
|
|
|
8
8
|
import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
|
|
9
9
|
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
10
10
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
|
|
11
|
-
import { makeDbConfigLayer } from "./config-service";
|
|
11
|
+
import { DbConfigService, makeDbConfigLayer } from "./config-service";
|
|
12
12
|
import { DbConnectionError } from "./errors";
|
|
13
13
|
import { DbService } from "./service";
|
|
14
14
|
|
|
@@ -107,9 +107,35 @@ const schemaCommand = Command.make(
|
|
|
107
107
|
}),
|
|
108
108
|
).pipe(Command.withDescription("Introspect database schema (tables, columns, relationships)"));
|
|
109
109
|
|
|
110
|
+
const envsCommand = Command.make(
|
|
111
|
+
"envs",
|
|
112
|
+
{
|
|
113
|
+
format: formatOption,
|
|
114
|
+
profile: Flag.optional(Flag.string("profile")).pipe(
|
|
115
|
+
Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
({ format }) =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const config = yield* ConfigService;
|
|
121
|
+
const dbConfig = yield* DbConfigService;
|
|
122
|
+
const environments = dbConfig ? Object.keys(dbConfig.environments) : [];
|
|
123
|
+
const result = {
|
|
124
|
+
success: true as const,
|
|
125
|
+
environments,
|
|
126
|
+
default: getDefaultEnvironment(config) ?? null,
|
|
127
|
+
message: `${environments.length} environment(s) configured`,
|
|
128
|
+
executionTimeMs: 0,
|
|
129
|
+
};
|
|
130
|
+
yield* Console.log(formatOutput(result, format));
|
|
131
|
+
}),
|
|
132
|
+
).pipe(
|
|
133
|
+
Command.withDescription("List configured database environments and the default (no network)"),
|
|
134
|
+
);
|
|
135
|
+
|
|
110
136
|
const mainCommand = Command.make("db-tool", {}).pipe(
|
|
111
137
|
Command.withDescription("Database Query Tool for Coding Agents"),
|
|
112
|
-
Command.withSubcommands([sqlCommand, schemaCommand]),
|
|
138
|
+
Command.withSubcommands([sqlCommand, schemaCommand, envsCommand]),
|
|
113
139
|
);
|
|
114
140
|
|
|
115
141
|
const cli = Command.run(mainCommand, {
|
package/src/db-tool/service.ts
CHANGED
|
@@ -681,11 +681,17 @@ export class DbService extends Context.Service<
|
|
|
681
681
|
);
|
|
682
682
|
};
|
|
683
683
|
|
|
684
|
-
const getConfigForEnv = (env: string)
|
|
684
|
+
const getConfigForEnv = Effect.fn("DbService.getConfigForEnv")(function* (env: string) {
|
|
685
685
|
const envConfig = dbConfig.environments[env];
|
|
686
686
|
if (!envConfig) {
|
|
687
687
|
const available = Object.keys(dbConfig.environments).join(", ");
|
|
688
|
-
|
|
688
|
+
// Tagged failure (not a bare throw) so the hint + nextCommand reach the agent (M3).
|
|
689
|
+
return yield* new DbConnectionError({
|
|
690
|
+
message: `Unknown environment "${env}". Available: ${available}`,
|
|
691
|
+
environment: env,
|
|
692
|
+
hint: "List configured environments with: db-tool envs",
|
|
693
|
+
nextCommand: "db-tool envs",
|
|
694
|
+
});
|
|
689
695
|
}
|
|
690
696
|
|
|
691
697
|
const accessMode = resolveDbAccessMode(
|
|
@@ -707,13 +713,13 @@ export class DbService extends Context.Service<
|
|
|
707
713
|
allowMutations: accessMode.allowMutations,
|
|
708
714
|
allowedMutations: accessMode.allowedMutations,
|
|
709
715
|
};
|
|
710
|
-
};
|
|
716
|
+
});
|
|
711
717
|
|
|
712
718
|
const executeQuery = Effect.fn("DbService.executeQuery")(function* (
|
|
713
719
|
env: string,
|
|
714
720
|
sql: string,
|
|
715
721
|
) {
|
|
716
|
-
const config = getConfigForEnv(env);
|
|
722
|
+
const config = yield* getConfigForEnv(env);
|
|
717
723
|
const startTimeMs = yield* Clock.currentTimeMillis;
|
|
718
724
|
const resolvedConfig = yield* resolveDbConfig(config, env);
|
|
719
725
|
const password = yield* resolvePassword(resolvedConfig, env);
|
|
@@ -753,7 +759,7 @@ export class DbService extends Context.Service<
|
|
|
753
759
|
mode: SchemaMode,
|
|
754
760
|
table?: string,
|
|
755
761
|
) {
|
|
756
|
-
const config = getConfigForEnv(env);
|
|
762
|
+
const config = yield* getConfigForEnv(env);
|
|
757
763
|
const startTimeMs = yield* Clock.currentTimeMillis;
|
|
758
764
|
const resolvedConfig = yield* resolveDbConfig(config, env);
|
|
759
765
|
const password = yield* resolvePassword(resolvedConfig, env);
|
package/src/gh-tool/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
prViewCommand,
|
|
22
22
|
prStatusCommand,
|
|
23
|
+
prListCommand,
|
|
23
24
|
prCreateCommand,
|
|
24
25
|
prCloseCommand,
|
|
25
26
|
prEditCommand,
|
|
@@ -68,6 +69,7 @@ const prCommand = Command.make("pr", {}).pipe(
|
|
|
68
69
|
Command.withSubcommands([
|
|
69
70
|
prViewCommand,
|
|
70
71
|
prStatusCommand,
|
|
72
|
+
prListCommand,
|
|
71
73
|
prCreateCommand,
|
|
72
74
|
prCloseCommand,
|
|
73
75
|
prEditCommand,
|
|
@@ -73,7 +73,11 @@ export const issueViewCommand = Command.make(
|
|
|
73
73
|
yield* logFormatted(info, format);
|
|
74
74
|
}),
|
|
75
75
|
),
|
|
76
|
-
).pipe(
|
|
76
|
+
).pipe(
|
|
77
|
+
Command.withDescription(
|
|
78
|
+
"View one issue's details. For multiple issues use `gh issue snapshot-batch --issues 1,2,3` (one call, not N).",
|
|
79
|
+
),
|
|
80
|
+
);
|
|
77
81
|
|
|
78
82
|
export const issueCommentsCommand = Command.make(
|
|
79
83
|
"comments",
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
fetchChecks,
|
|
26
26
|
fetchChecksForCommand,
|
|
27
27
|
fetchFailedChecks,
|
|
28
|
+
listPRs,
|
|
28
29
|
mergePR,
|
|
29
30
|
rerunChecks,
|
|
30
31
|
viewPR,
|
|
@@ -97,7 +98,25 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
|
|
|
97
98
|
fetchChecks(prNumber, false, false, 0),
|
|
98
99
|
]);
|
|
99
100
|
const classification = classifyReviewTriage(summary, checks);
|
|
100
|
-
|
|
101
|
+
|
|
102
|
+
// Single merge-readiness verdict so agents stop re-stitching mergeable + checks + threads +
|
|
103
|
+
// review state across separate calls (F2). `blocking` names exactly what's left to do.
|
|
104
|
+
const blocking: string[] = [];
|
|
105
|
+
if (info.mergeable !== "MERGEABLE") blocking.push(`mergeable=${info.mergeable || "UNKNOWN"}`);
|
|
106
|
+
if (checks.some((check) => check.bucket === "fail")) blocking.push("failing_checks");
|
|
107
|
+
if (checks.some((check) => check.bucket === "pending")) blocking.push("pending_checks");
|
|
108
|
+
if (summary.unresolvedReviewThreadsCount > 0) blocking.push("unresolved_threads");
|
|
109
|
+
if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
|
|
110
|
+
blocking.push(`review=${info.reviewDecision}`);
|
|
111
|
+
}
|
|
112
|
+
const ready = {
|
|
113
|
+
ready: blocking.length === 0,
|
|
114
|
+
mergeable: info.mergeable,
|
|
115
|
+
reviewDecision: info.reviewDecision || null,
|
|
116
|
+
blocking,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return { ready, classification, info, unresolvedThreads, visibleOpenThreads, summary, checks };
|
|
101
120
|
});
|
|
102
121
|
|
|
103
122
|
export const prViewCommand = Command.make(
|
|
@@ -136,12 +155,57 @@ export const prStatusCommand = Command.make(
|
|
|
136
155
|
Command.withDescription("Auto-detect PR for current branch or GitButler workspace branches"),
|
|
137
156
|
);
|
|
138
157
|
|
|
158
|
+
export const prListCommand = Command.make(
|
|
159
|
+
"list",
|
|
160
|
+
{
|
|
161
|
+
format: formatOption,
|
|
162
|
+
state: Flag.choice("state", ["open", "closed", "merged", "all"]).pipe(
|
|
163
|
+
Flag.withDescription("Filter by state: open, closed, merged, all"),
|
|
164
|
+
Flag.withDefault("open"),
|
|
165
|
+
),
|
|
166
|
+
author: Flag.string("author").pipe(
|
|
167
|
+
Flag.withDescription("Filter by author login (use @me for yourself)"),
|
|
168
|
+
Flag.optional,
|
|
169
|
+
),
|
|
170
|
+
base: Flag.string("base").pipe(Flag.withDescription("Filter by base branch"), Flag.optional),
|
|
171
|
+
head: Flag.string("head").pipe(Flag.withDescription("Filter by head branch"), Flag.optional),
|
|
172
|
+
search: Flag.string("search").pipe(
|
|
173
|
+
Flag.withDescription("GitHub search query (e.g. 'review:required')"),
|
|
174
|
+
Flag.optional,
|
|
175
|
+
),
|
|
176
|
+
limit: Flag.integer("limit").pipe(
|
|
177
|
+
Flag.withDescription("Maximum number of PRs to return"),
|
|
178
|
+
Flag.withDefault(30),
|
|
179
|
+
),
|
|
180
|
+
repo: repoOption,
|
|
181
|
+
},
|
|
182
|
+
({ format, state, author, base, head, search, limit, repo }) =>
|
|
183
|
+
withRepo(
|
|
184
|
+
repo,
|
|
185
|
+
Effect.gen(function* () {
|
|
186
|
+
const prs = yield* listPRs({
|
|
187
|
+
state,
|
|
188
|
+
limit,
|
|
189
|
+
author: Option.getOrNull(author),
|
|
190
|
+
base: Option.getOrNull(base),
|
|
191
|
+
head: Option.getOrNull(head),
|
|
192
|
+
search: Option.getOrNull(search),
|
|
193
|
+
});
|
|
194
|
+
yield* logFormatted(prs, format);
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
).pipe(
|
|
198
|
+
Command.withDescription(
|
|
199
|
+
"List PRs (default: open; filter with --state/--author/--base/--head/--search)",
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
|
|
139
203
|
export const prCreateCommand = Command.make(
|
|
140
204
|
"create",
|
|
141
205
|
{
|
|
142
206
|
base: Flag.string("base").pipe(
|
|
143
|
-
Flag.withDescription("Base branch for the PR"),
|
|
144
|
-
Flag.
|
|
207
|
+
Flag.withDescription("Base branch for the PR (default: repository default branch)"),
|
|
208
|
+
Flag.optional,
|
|
145
209
|
),
|
|
146
210
|
body: Flag.string("body").pipe(Flag.withDescription("PR body/description"), Flag.optional),
|
|
147
211
|
bodyFile: Flag.string("body-file").pipe(
|
|
@@ -181,7 +245,7 @@ export const prCreateCommand = Command.make(
|
|
|
181
245
|
});
|
|
182
246
|
|
|
183
247
|
const info = yield* createPR({
|
|
184
|
-
base,
|
|
248
|
+
base: Option.getOrNull(base),
|
|
185
249
|
body: resolvedBody,
|
|
186
250
|
draft,
|
|
187
251
|
head: Option.getOrNull(head),
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
WorkflowRunDetail,
|
|
13
13
|
} from "#gh/types";
|
|
14
14
|
|
|
15
|
-
import { GitHubCommandError, GitHubMergeError
|
|
15
|
+
import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
|
|
16
16
|
import { GitHubService } from "#gh/service";
|
|
17
17
|
|
|
18
18
|
import type { ButStatusJson, PRViewJsonResult } from "./helpers";
|
|
@@ -20,6 +20,9 @@ import { runLocalCommand } from "./helpers";
|
|
|
20
20
|
|
|
21
21
|
const CHECK_JSON_FIELDS = "name,state,bucket,link";
|
|
22
22
|
const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
|
|
23
|
+
// A single blocking `--watch` is capped here so an agent never loses a whole turn to a 30-min
|
|
24
|
+
// foreground wait. On hitting the cap we return the partial snapshot, not a failure (H1).
|
|
25
|
+
const MAX_WATCH_SECONDS = 120;
|
|
23
26
|
|
|
24
27
|
const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string) {
|
|
25
28
|
const gh = yield* GitHubService;
|
|
@@ -254,7 +257,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
|
|
|
254
257
|
const hint =
|
|
255
258
|
failedChecks.length === 0
|
|
256
259
|
? pendingChecks.length > 0
|
|
257
|
-
? "
|
|
260
|
+
? "Some checks are still running. Re-run this command to refresh the snapshot."
|
|
258
261
|
: "All current checks are green."
|
|
259
262
|
: pendingChecks.length > 0
|
|
260
263
|
? "Inspect the failed workflow run first. Other checks are still running and may change overall merge readiness."
|
|
@@ -453,8 +456,34 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
|
|
|
453
456
|
};
|
|
454
457
|
});
|
|
455
458
|
|
|
459
|
+
export const listPRs = Effect.fn("pr.listPRs")(function* (opts: {
|
|
460
|
+
state: string;
|
|
461
|
+
limit: number;
|
|
462
|
+
author: string | null;
|
|
463
|
+
base: string | null;
|
|
464
|
+
head: string | null;
|
|
465
|
+
search: string | null;
|
|
466
|
+
}) {
|
|
467
|
+
const gh = yield* GitHubService;
|
|
468
|
+
const args = [
|
|
469
|
+
"pr",
|
|
470
|
+
"list",
|
|
471
|
+
"--state",
|
|
472
|
+
opts.state,
|
|
473
|
+
"--limit",
|
|
474
|
+
String(opts.limit),
|
|
475
|
+
"--json",
|
|
476
|
+
"number,url,title,headRefName,baseRefName,state,isDraft,author,createdAt,reviewDecision",
|
|
477
|
+
];
|
|
478
|
+
if (opts.author !== null) args.push("--author", opts.author);
|
|
479
|
+
if (opts.base !== null) args.push("--base", opts.base);
|
|
480
|
+
if (opts.head !== null) args.push("--head", opts.head);
|
|
481
|
+
if (opts.search !== null) args.push("--search", opts.search);
|
|
482
|
+
return yield* gh.runGhJson<PRInfo[]>(args);
|
|
483
|
+
});
|
|
484
|
+
|
|
456
485
|
export const createPR = Effect.fn("pr.createPR")(function* (opts: {
|
|
457
|
-
base: string;
|
|
486
|
+
base: string | null;
|
|
458
487
|
title: string;
|
|
459
488
|
body: string;
|
|
460
489
|
draft: boolean;
|
|
@@ -463,6 +492,10 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
|
|
|
463
492
|
const gh = yield* GitHubService;
|
|
464
493
|
yield* validatePRTitle(opts.title);
|
|
465
494
|
|
|
495
|
+
// Default to the repo's real default branch instead of a hardcoded "test" — an omitted --base
|
|
496
|
+
// must never silently open a PR against the wrong trunk (L1).
|
|
497
|
+
const baseBranch = opts.base ?? (yield* gh.getRepoInfo()).defaultBranch;
|
|
498
|
+
|
|
466
499
|
// When --head is provided (e.g. GitButler workspace), use `gh pr list --head`
|
|
467
500
|
// to find existing PR since `gh pr view` relies on the current git branch.
|
|
468
501
|
const existing = yield* opts.head !== null
|
|
@@ -500,7 +533,7 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
|
|
|
500
533
|
"pr",
|
|
501
534
|
"create",
|
|
502
535
|
"--base",
|
|
503
|
-
|
|
536
|
+
baseBranch,
|
|
504
537
|
"--title",
|
|
505
538
|
opts.title,
|
|
506
539
|
"--body",
|
|
@@ -806,31 +839,31 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
806
839
|
watchArgs.push("--fail-fast");
|
|
807
840
|
}
|
|
808
841
|
|
|
809
|
-
|
|
810
|
-
|
|
842
|
+
// Cap the blocking wait; on timeout fall through to a snapshot instead of failing with no state.
|
|
843
|
+
const cappedSeconds = Math.min(timeoutSeconds, MAX_WATCH_SECONDS);
|
|
844
|
+
const watchOutcome = yield* gh.runGh(watchArgs).pipe(
|
|
811
845
|
Effect.timeoutOrElse({
|
|
812
|
-
duration:
|
|
813
|
-
orElse: () =>
|
|
814
|
-
Effect.fail(
|
|
815
|
-
new GitHubTimeoutError({
|
|
816
|
-
message: `CI check monitoring timed out after ${timeoutSeconds}s`,
|
|
817
|
-
timeoutMs,
|
|
818
|
-
hint: "CI checks are still running. Retry with a longer --timeout or check status manually.",
|
|
819
|
-
nextCommand: `agent-tools-gh pr checks${pr !== null ? ` --pr ${pr}` : ""}`,
|
|
820
|
-
retryable: true,
|
|
821
|
-
}),
|
|
822
|
-
),
|
|
846
|
+
duration: cappedSeconds * 1000,
|
|
847
|
+
orElse: () => Effect.succeed(null),
|
|
823
848
|
}),
|
|
824
849
|
);
|
|
825
850
|
|
|
826
|
-
|
|
851
|
+
const results = yield* fetchCheckResults(pr);
|
|
852
|
+
if (watchOutcome === null && results.some((c) => c.bucket === "pending")) {
|
|
853
|
+
const pending = results.filter((c) => c.bucket === "pending").length;
|
|
854
|
+
yield* Console.warn(
|
|
855
|
+
`ℹ️ Watch capped at ${cappedSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
|
|
856
|
+
`Re-run to keep watching:\n ${buildChecksCommand(pr, true)}`,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
return results;
|
|
827
860
|
}
|
|
828
861
|
|
|
829
862
|
const results = yield* fetchCheckResults(pr);
|
|
830
863
|
if (results.some((c) => c.bucket === "pending")) {
|
|
831
864
|
yield* Console.warn(
|
|
832
|
-
`ℹ️ Some checks are still running.
|
|
833
|
-
` ${buildChecksCommand(pr,
|
|
865
|
+
`ℹ️ Some checks are still running. Re-run to refresh — each call returns the latest snapshot:\n` +
|
|
866
|
+
` ${buildChecksCommand(pr, false)}`,
|
|
834
867
|
);
|
|
835
868
|
}
|
|
836
869
|
return results;
|
package/src/gh-tool/pr/index.ts
CHANGED
package/src/gh-tool/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
|
-
import { Context, Effect, Layer, Stream } from "effect";
|
|
2
|
+
import { Context, Duration, Effect, Layer, Stream } from "effect";
|
|
3
3
|
|
|
4
4
|
import type { GitHubRepoConfig } from "#config";
|
|
5
5
|
import type { RepoInfo } from "./types";
|
|
@@ -8,6 +8,23 @@ import { GH_BINARY } from "./config";
|
|
|
8
8
|
import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
|
|
9
9
|
import { ConfigService, getGitHubConfig, resolveGitHubRepoTarget } from "#config";
|
|
10
10
|
|
|
11
|
+
// Transient GitHub-side failures worth a silent retry (vs. a hard error the agent must act on).
|
|
12
|
+
const NETWORK_ERROR_RE =
|
|
13
|
+
/i\/o timeout|dial tcp|operation timed out|connection reset|\bEOF\b|HTTP 50[0-9]|50[0-9] (?:Bad Gateway|Service Unavailable|Gateway Timeout)|timeout awaiting/i;
|
|
14
|
+
const AUTH_401_RE = /HTTP 401|Bad credentials/i;
|
|
15
|
+
const MAX_GH_RETRIES = 2;
|
|
16
|
+
|
|
17
|
+
// Only retry verbs that are unambiguously idempotent reads — never replay a mutation on a timeout.
|
|
18
|
+
const READ_VERBS = new Set(["view", "list", "checks", "status", "diff"]);
|
|
19
|
+
const MUTATION_TOKENS =
|
|
20
|
+
/\b(create|edit|merge|comment|close|reopen|delete|review|ready|sync|rerun|cancel|mutation)\b|(?:-X|--method)\s+(?:POST|PATCH|PUT|DELETE)/i;
|
|
21
|
+
const isSafeRetryRead = (args: readonly string[]): boolean => {
|
|
22
|
+
const joined = args.join(" ");
|
|
23
|
+
if (MUTATION_TOKENS.test(joined)) return false;
|
|
24
|
+
if (args[0] === "api") return true; // GET by default; mutating methods already excluded above
|
|
25
|
+
return args.some((a) => READ_VERBS.has(a));
|
|
26
|
+
};
|
|
27
|
+
|
|
11
28
|
type GhResult = {
|
|
12
29
|
stdout: string;
|
|
13
30
|
stderr: string;
|
|
@@ -134,7 +151,7 @@ export class GitHubService extends Context.Service<
|
|
|
134
151
|
),
|
|
135
152
|
);
|
|
136
153
|
|
|
137
|
-
const
|
|
154
|
+
const runGhAttempt = Effect.fn("GitHubService.runGhAttempt")(function* (args: string[]) {
|
|
138
155
|
const result = yield* executeGh(args);
|
|
139
156
|
|
|
140
157
|
if (result.exitCode !== 0) {
|
|
@@ -149,15 +166,41 @@ export class GitHubService extends Context.Service<
|
|
|
149
166
|
});
|
|
150
167
|
}
|
|
151
168
|
|
|
169
|
+
// Expired/invalid token (401) is distinct from "never logged in" — the fix is refresh.
|
|
170
|
+
if (AUTH_401_RE.test(result.stderr)) {
|
|
171
|
+
return yield* new GitHubAuthError({
|
|
172
|
+
message: "GitHub credentials rejected (HTTP 401). Token is missing or expired.",
|
|
173
|
+
hint: "Refresh the GitHub CLI token, then retry.",
|
|
174
|
+
nextCommand: "gh auth refresh -h github.com",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Transient network/5xx — flag retryable so the wrapper below can replay safe reads.
|
|
179
|
+
if (NETWORK_ERROR_RE.test(result.stderr)) {
|
|
180
|
+
return yield* new GitHubCommandError({
|
|
181
|
+
message: `Transient GitHub network error: ${result.stderr.trim()}`,
|
|
182
|
+
command: `gh ${args.join(" ")}`,
|
|
183
|
+
exitCode: result.exitCode,
|
|
184
|
+
stderr: result.stderr,
|
|
185
|
+
retryable: true,
|
|
186
|
+
hint: "Transient GitHub/network failure. Read commands auto-retry; if it persists, check VPN/connectivity.",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
152
190
|
if (
|
|
153
191
|
result.stderr.includes("not found") ||
|
|
154
192
|
result.stderr.includes("Could not resolve")
|
|
155
193
|
) {
|
|
194
|
+
const ghRepo = yield* RepoTarget;
|
|
156
195
|
return yield* new GitHubNotFoundError({
|
|
157
|
-
message:
|
|
158
|
-
|
|
196
|
+
message: ghRepo
|
|
197
|
+
? `${result.stderr.trim()} (queried repo: ${ghRepo})`
|
|
198
|
+
: result.stderr,
|
|
199
|
+
resource: ghRepo ?? "unknown",
|
|
159
200
|
identifier: "unknown",
|
|
160
|
-
hint:
|
|
201
|
+
hint: ghRepo
|
|
202
|
+
? `Queried ${ghRepo}. If the resource lives in another repo, pass --repo (e.g. --repo fe).`
|
|
203
|
+
: "Verify the resource exists and you have access. Check repository owner/name spelling.",
|
|
161
204
|
});
|
|
162
205
|
}
|
|
163
206
|
|
|
@@ -172,6 +215,25 @@ export class GitHubService extends Context.Service<
|
|
|
172
215
|
return result;
|
|
173
216
|
});
|
|
174
217
|
|
|
218
|
+
// Auto-retry transient failures, but only for idempotent reads (never replay a mutation).
|
|
219
|
+
const runGh = (args: string[]): Effect.Effect<GhResult, GhError> => {
|
|
220
|
+
const canRetry = isSafeRetryRead(args);
|
|
221
|
+
const loop = (attempt: number): Effect.Effect<GhResult, GhError> =>
|
|
222
|
+
runGhAttempt(args).pipe(
|
|
223
|
+
Effect.catch((err) => {
|
|
224
|
+
const retryable =
|
|
225
|
+
err instanceof GitHubCommandError && err.retryable === true && canRetry;
|
|
226
|
+
if (retryable && attempt < MAX_GH_RETRIES) {
|
|
227
|
+
return Effect.sleep(Duration.millis(500 * 2 ** attempt)).pipe(
|
|
228
|
+
Effect.flatMap(() => loop(attempt + 1)),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return Effect.fail(err);
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
return loop(0);
|
|
235
|
+
};
|
|
236
|
+
|
|
175
237
|
const runGhJson = <T>(args: string[]) =>
|
|
176
238
|
Effect.gen(function* () {
|
|
177
239
|
const result = yield* runGh(args);
|
package/src/gh-tool/workflow.ts
CHANGED
|
@@ -212,6 +212,10 @@ const cancelRun = Effect.fn("workflow.cancelRun")(function* (runId: number, repo
|
|
|
212
212
|
};
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
// `gh run watch` has no native timeout and was observed hanging a turn for 36 min. Cap it and
|
|
216
|
+
// fall back to a one-shot snapshot, mirroring the `pr checks --watch` cap (M1).
|
|
217
|
+
const WATCH_RUN_TIMEOUT_SECONDS = 120;
|
|
218
|
+
|
|
215
219
|
const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo: string | null) {
|
|
216
220
|
const gh = yield* GitHubService;
|
|
217
221
|
|
|
@@ -232,6 +236,10 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
|
|
|
232
236
|
}
|
|
233
237
|
return Effect.fail(error);
|
|
234
238
|
}),
|
|
239
|
+
Effect.timeoutOrElse({
|
|
240
|
+
duration: WATCH_RUN_TIMEOUT_SECONDS * 1000,
|
|
241
|
+
orElse: () => Effect.succeed(null),
|
|
242
|
+
}),
|
|
235
243
|
);
|
|
236
244
|
|
|
237
245
|
const finalState = yield* viewRun(runId, repo);
|
|
@@ -245,7 +253,10 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
|
|
|
245
253
|
status: job.status,
|
|
246
254
|
conclusion: job.conclusion,
|
|
247
255
|
})),
|
|
248
|
-
watchOutput:
|
|
256
|
+
watchOutput:
|
|
257
|
+
result === null
|
|
258
|
+
? `(watch capped at ${WATCH_RUN_TIMEOUT_SECONDS}s; status taken from snapshot — re-run to keep watching)`
|
|
259
|
+
: result.stdout,
|
|
249
260
|
};
|
|
250
261
|
});
|
|
251
262
|
|
package/src/shared/audit.ts
CHANGED
|
@@ -116,9 +116,34 @@ const formatUnknownError = (error: unknown): string => {
|
|
|
116
116
|
return String(error);
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
+
// effect-cli `ShowHelp` is a control-flow error: empty `errors[]` means a plain `--help` (exit 0),
|
|
120
|
+
// a non-empty `errors[]` means a real parse failure (unrecognized option, missing arg, …) that was
|
|
121
|
+
// downgraded to a help render. Distinguish them so we neither mislabel help as a failure (H4) nor
|
|
122
|
+
// hide the actual syntax error behind the opaque "Help requested" string (M4).
|
|
123
|
+
const getShowHelpErrors = (error: unknown): readonly unknown[] | undefined => {
|
|
124
|
+
if (typeof error === "object" && error !== null && Reflect.get(error, "_tag") === "ShowHelp") {
|
|
125
|
+
const errors = Reflect.get(error, "errors");
|
|
126
|
+
return Array.isArray(errors) ? errors : [];
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const isPureHelpCause = (cause: Cause.Cause<unknown>): boolean => {
|
|
132
|
+
const firstFailure = cause.reasons.find(Cause.isFailReason);
|
|
133
|
+
const showHelpErrors =
|
|
134
|
+
firstFailure === undefined ? undefined : getShowHelpErrors(firstFailure.error);
|
|
135
|
+
return showHelpErrors !== undefined && showHelpErrors.length === 0;
|
|
136
|
+
};
|
|
137
|
+
|
|
119
138
|
const formatCause = (cause: Cause.Cause<unknown>): string => {
|
|
120
139
|
const firstFailure = cause.reasons.find(Cause.isFailReason);
|
|
121
140
|
if (firstFailure !== undefined) {
|
|
141
|
+
const showHelpErrors = getShowHelpErrors(firstFailure.error);
|
|
142
|
+
if (showHelpErrors !== undefined) {
|
|
143
|
+
return showHelpErrors.length > 0
|
|
144
|
+
? showHelpErrors.map((e) => formatUnknownError(e)).join("; ")
|
|
145
|
+
: "Help requested";
|
|
146
|
+
}
|
|
122
147
|
return formatUnknownError(firstFailure.error);
|
|
123
148
|
}
|
|
124
149
|
|
|
@@ -296,19 +321,23 @@ export const withAudit = <A, E, R>(
|
|
|
296
321
|
const tool = safeToolName(toolName || deriveToolNameFromArgv());
|
|
297
322
|
|
|
298
323
|
return Effect.matchCauseEffect(program, {
|
|
299
|
-
onFailure: (cause) =>
|
|
300
|
-
|
|
324
|
+
onFailure: (cause) => {
|
|
325
|
+
// A bare `--help` exits 0 and is not a tool failure — recording it as one inflated the
|
|
326
|
+
// suite-wide fail rate by ~21% (H4). A ShowHelp carrying parse errors is still a failure.
|
|
327
|
+
const pureHelp = isPureHelpCause(cause);
|
|
328
|
+
return Effect.flatMap(
|
|
301
329
|
safelyRecord({
|
|
302
330
|
tool,
|
|
303
331
|
project,
|
|
304
332
|
args,
|
|
305
333
|
duration: Date.now() - startedAt,
|
|
306
|
-
success:
|
|
307
|
-
error: formatCause(cause),
|
|
308
|
-
exitCode: extractExitCode(cause),
|
|
334
|
+
success: pureHelp,
|
|
335
|
+
error: pureHelp ? undefined : formatCause(cause),
|
|
336
|
+
exitCode: pureHelp ? 0 : extractExitCode(cause),
|
|
309
337
|
}),
|
|
310
338
|
() => Effect.failCause(cause),
|
|
311
|
-
)
|
|
339
|
+
);
|
|
340
|
+
},
|
|
312
341
|
onSuccess: (value) =>
|
|
313
342
|
Effect.flatMap(
|
|
314
343
|
safelyRecord({
|
|
@@ -12,12 +12,16 @@ const formatError = (error: unknown): string => {
|
|
|
12
12
|
const tag = (error as Record<string, unknown>)._tag as string;
|
|
13
13
|
const message = (error as Record<string, unknown>).message;
|
|
14
14
|
const hint = (error as Record<string, unknown>).hint;
|
|
15
|
+
const nextCommand = (error as Record<string, unknown>).nextCommand;
|
|
15
16
|
let result = "";
|
|
16
17
|
if (typeof message === "string") {
|
|
17
18
|
result = `${tag}: ${message}`;
|
|
18
19
|
} else {
|
|
19
20
|
const details = Object.entries(error as Record<string, unknown>)
|
|
20
|
-
.filter(
|
|
21
|
+
.filter(
|
|
22
|
+
([key, val]) =>
|
|
23
|
+
typeof val === "string" && key !== "_tag" && key !== "hint" && key !== "nextCommand",
|
|
24
|
+
)
|
|
21
25
|
.map(([key, val]) => `${key}=${String(val)}`)
|
|
22
26
|
.join(", ");
|
|
23
27
|
result = details ? `${tag}: ${details}` : tag;
|
|
@@ -25,6 +29,11 @@ const formatError = (error: unknown): string => {
|
|
|
25
29
|
if (typeof hint === "string") {
|
|
26
30
|
result += `\n Hint: ${hint}`;
|
|
27
31
|
}
|
|
32
|
+
// Surface the exact corrective invocation that tagged errors already carry — without this
|
|
33
|
+
// the affordance is computed everywhere and shown nowhere.
|
|
34
|
+
if (typeof nextCommand === "string" && nextCommand.length > 0) {
|
|
35
|
+
result += `\n Try: ${nextCommand}`;
|
|
36
|
+
}
|
|
28
37
|
return result;
|
|
29
38
|
}
|
|
30
39
|
if (error instanceof Error) return error.message;
|