@blogic-cz/agent-tools 1.10.0 → 1.11.1
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 +13 -5
- package/dist/db-tool/service.d.ts +2 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/db-tool/sql-client.d.ts +1 -0
- package/dist/db-tool/sql-client.d.ts.map +1 -1
- package/dist/gh-tool/issue/triage.d.ts +3 -3
- package/dist/gh-tool/issue/triage.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts +7 -5
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts +18 -0
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/pr/stack.d.ts.map +1 -1
- package/dist/gh-tool/read/api.d.ts.map +1 -1
- package/dist/gh-tool/read/pulls.d.ts.map +1 -1
- package/dist/gh-tool/read/review-model.d.ts +11 -8
- package/dist/gh-tool/read/review-model.d.ts.map +1 -1
- package/dist/gh-tool/read/reviews.d.ts.map +1 -1
- package/dist/gh-tool/read/waits.d.ts +1 -1
- package/dist/gh-tool/read/waits.d.ts.map +1 -1
- package/dist/gh-tool/types.d.ts +9 -3
- package/dist/gh-tool/types.d.ts.map +1 -1
- package/dist/observability-tool/logs.d.ts +1 -1
- package/dist/observability-tool/logs.d.ts.map +1 -1
- package/dist/observability-tool/metrics.d.ts +1 -1
- package/dist/observability-tool/metrics.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts +2 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/observability-tool/trace.d.ts +1 -1
- package/dist/observability-tool/trace.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +3 -1
- package/dist/shared/content-security.d.ts.map +1 -1
- package/dist/shared/secret-env.d.ts +22 -0
- package/dist/shared/secret-env.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/db-tool/main.ts +2 -0
- package/src/db-tool/service.ts +122 -140
- package/src/db-tool/sql-client.ts +7 -4
- package/src/gh-tool/pr/commands.ts +12 -7
- package/src/gh-tool/pr/core.ts +41 -4
- package/src/gh-tool/pr/review.ts +18 -1
- package/src/gh-tool/pr/stack.ts +12 -2
- package/src/gh-tool/read/api.ts +6 -0
- package/src/gh-tool/read/pulls.ts +4 -1
- package/src/gh-tool/read/review-model.ts +11 -8
- package/src/gh-tool/read/reviews.ts +48 -2
- package/src/gh-tool/read/waits.ts +12 -2
- package/src/gh-tool/types.ts +15 -3
- package/src/observability-tool/main.ts +7 -1
- package/src/observability-tool/shared.ts +20 -14
- package/src/shared/content-security.ts +7 -2
- package/src/shared/secret-env.ts +230 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Context, Layer } from "effect";
|
|
2
|
+
export type LookupResult = {
|
|
3
|
+
readonly _tag: "found";
|
|
4
|
+
readonly value: string;
|
|
5
|
+
} | {
|
|
6
|
+
readonly _tag: "absent";
|
|
7
|
+
} | {
|
|
8
|
+
readonly _tag: "failed";
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
};
|
|
11
|
+
export type ZshRunner = (name: string, timeoutMs: number) => Promise<LookupResult>;
|
|
12
|
+
declare const SecretEnvResolver_base: Context.ServiceClass<SecretEnvResolver, "@agent-tools/SecretEnvResolver", {
|
|
13
|
+
readonly resolveSecretEnv: (name: string) => Promise<string>;
|
|
14
|
+
readonly lookupConfigEnv: (name: string) => Promise<LookupResult>;
|
|
15
|
+
}>;
|
|
16
|
+
export declare class SecretEnvResolver extends SecretEnvResolver_base {
|
|
17
|
+
static get liveLayer(): Layer.Layer<SecretEnvResolver, never, never>;
|
|
18
|
+
static readonly testLayer: (runner?: ZshRunner) => Layer.Layer<SecretEnvResolver, never, never>;
|
|
19
|
+
}
|
|
20
|
+
export declare function makeSecretEnvResolverLayer(runner?: ZshRunner, timeoutMs?: number): Layer.Layer<SecretEnvResolver, never, never>;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=secret-env.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-env.d.ts","sourceRoot":"","sources":["../../src/shared/secret-env.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAqBxC,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzD,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;;+BAmJpD,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC;8BAClC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC;;AAJrE,qBAAa,iBAAkB,SAAQ,sBAMF;IACnC,MAAM,KAAK,SAAS,iDAEnB;IACD,MAAM,CAAC,QAAQ,CAAC,SAAS,YAAY,SAAS,kDACT;CACtC;AAED,wBAAgB,0BAA0B,CACxC,MAAM,GAAE,SAA6B,EACrC,SAAS,SAAqB,gDAwC/B"}
|
package/package.json
CHANGED
package/src/db-tool/main.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VERSION,
|
|
15
15
|
} from "#shared";
|
|
16
16
|
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
17
|
+
import { SecretEnvResolver } from "#shared/secret-env";
|
|
17
18
|
import { withRedactedOutput } from "#shared/output-boundary";
|
|
18
19
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
|
|
19
20
|
import { DbConfigService, makeDbConfigLayer } from "./config-service";
|
|
@@ -248,6 +249,7 @@ const MainLayer = DbService.layer.pipe(
|
|
|
248
249
|
// provideMerge (not provide) so DbConfigService stays in the program context for the `envs` command,
|
|
249
250
|
// which reads it directly rather than going through DbService.
|
|
250
251
|
Layer.provideMerge(DbSqlClient.layer),
|
|
252
|
+
Layer.provideMerge(SecretEnvResolver.liveLayer),
|
|
251
253
|
Layer.provideMerge(dbConfigLayer),
|
|
252
254
|
Layer.provideMerge(ConfigServiceLayer),
|
|
253
255
|
Layer.provideMerge(BunServices.layer),
|
package/src/db-tool/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
2
|
-
import { Clock, Context, Duration, Effect, Layer,
|
|
2
|
+
import { Clock, Context, Duration, Effect, Layer, Result, Stream } from "effect";
|
|
3
3
|
|
|
4
4
|
import type { DbConfig, DbMutationOperation, QueryResult, SchemaMode } from "./types";
|
|
5
5
|
|
|
@@ -7,6 +7,7 @@ import { ConfigService } from "#config";
|
|
|
7
7
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
8
8
|
import { resolveEnvTemplate } from "#shared/env-template";
|
|
9
9
|
import { registerResolvedSecret, unsafeOutboundTextReason } from "#shared/content-security";
|
|
10
|
+
import { SecretEnvResolver } from "#shared/secret-env";
|
|
10
11
|
import { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
|
|
11
12
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
12
13
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
@@ -43,12 +44,6 @@ const MIN_TCP_PORT = 1;
|
|
|
43
44
|
const MAX_TCP_PORT = 65535;
|
|
44
45
|
const INTEGER_ONLY = /^\d+$/;
|
|
45
46
|
|
|
46
|
-
// Bun.env is the primary source: it carries the values Bun loads from a .env file, which is where
|
|
47
|
-
// a per-worktree host/port lives. It aliases process.env, which is also the only one that exists
|
|
48
|
-
// when this module runs outside Bun (the vitest runner).
|
|
49
|
-
const readProcessEnv = (envVar: string): string | undefined =>
|
|
50
|
-
(typeof Bun === "undefined" ? process.env : Bun.env)[envVar];
|
|
51
|
-
|
|
52
47
|
export type ResolvedPortOverride =
|
|
53
48
|
| { readonly ok: true; readonly port: number }
|
|
54
49
|
| { readonly ok: false; readonly message: string };
|
|
@@ -159,6 +154,7 @@ export class DbService extends Context.Service<
|
|
|
159
154
|
Effect.gen(function* () {
|
|
160
155
|
const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
161
156
|
const sqlClient = yield* DbSqlClient;
|
|
157
|
+
const secretEnvResolver = yield* SecretEnvResolver;
|
|
162
158
|
const agentToolsConfig = yield* ConfigService;
|
|
163
159
|
const dbConfig = yield* DbConfigService;
|
|
164
160
|
|
|
@@ -185,152 +181,138 @@ export class DbService extends Context.Service<
|
|
|
185
181
|
const apiProbeTimeoutMs = dbConfig.apiProbeTimeoutMs ?? 2000;
|
|
186
182
|
const remotePort = dbConfig.remotePort ?? 5432;
|
|
187
183
|
|
|
188
|
-
const zshrcEnvCache = yield* Ref.make<Record<string, string> | null>(null);
|
|
189
184
|
const envTemplateRegex = /^\$\{([A-Za-z0-9_]+)\}$/;
|
|
185
|
+
const lookupConfigEnv = (name: string) =>
|
|
186
|
+
Effect.tryPromise({
|
|
187
|
+
try: () => secretEnvResolver.lookupConfigEnv(name),
|
|
188
|
+
catch: () => new Error(`${name} lookup failed`),
|
|
189
|
+
});
|
|
190
190
|
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const home = process.env.HOME;
|
|
198
|
-
if (!home || home.trim() === "") {
|
|
199
|
-
yield* Ref.set(zshrcEnvCache, {});
|
|
200
|
-
return {};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const zshrcPath = `${home}/.zshrc`;
|
|
204
|
-
const content = yield* Effect.tryPromise(async () => {
|
|
205
|
-
const file = Bun.file(zshrcPath);
|
|
206
|
-
if (!(await file.exists())) {
|
|
207
|
-
return "";
|
|
191
|
+
const resolvePassword = (config: DbConfig, env: string) =>
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
if (config.password) {
|
|
194
|
+
registerResolvedSecret(config.password);
|
|
195
|
+
return config.password;
|
|
208
196
|
}
|
|
209
|
-
return await file.text();
|
|
210
|
-
}).pipe(Effect.orElseSucceed(() => ""));
|
|
211
197
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
198
|
+
const passwordEnvVar = config.passwordEnvVar;
|
|
199
|
+
if (passwordEnvVar) {
|
|
200
|
+
return yield* Effect.tryPromise({
|
|
201
|
+
try: () => secretEnvResolver.resolveSecretEnv(passwordEnvVar),
|
|
202
|
+
catch: () =>
|
|
203
|
+
new Error(
|
|
204
|
+
`${passwordEnvVar} is not in the environment or in the interactive zsh`,
|
|
205
|
+
),
|
|
206
|
+
}).pipe(
|
|
207
|
+
Effect.mapError(
|
|
208
|
+
() =>
|
|
209
|
+
new DbConnectionError({
|
|
210
|
+
message: `${passwordEnvVar} is not in the environment or in the interactive zsh`,
|
|
211
|
+
environment: env,
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
224
216
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
) {
|
|
229
|
-
if (config.password) {
|
|
230
|
-
registerResolvedSecret(config.password);
|
|
231
|
-
return config.password;
|
|
232
|
-
}
|
|
217
|
+
// Local databases typically don't need a password
|
|
218
|
+
return "";
|
|
219
|
+
});
|
|
233
220
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
221
|
+
const resolveConfigString = (value: string, env: string, label: string) =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const match = value.match(envTemplateRegex);
|
|
224
|
+
if (!match) return value;
|
|
225
|
+
|
|
226
|
+
const envVar = match[1];
|
|
227
|
+
const result = yield* lookupConfigEnv(envVar).pipe(
|
|
228
|
+
Effect.mapError(
|
|
229
|
+
() =>
|
|
230
|
+
new DbConnectionError({
|
|
231
|
+
message: `${envVar} lookup failed (required for '${label}' config field) in environment ${env}.`,
|
|
232
|
+
environment: env,
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
if (result._tag === "failed") {
|
|
237
|
+
return yield* new DbConnectionError({
|
|
238
|
+
message: `${envVar} lookup failed (required for '${label}' config field) in environment ${env}.`,
|
|
239
|
+
environment: env,
|
|
240
|
+
});
|
|
239
241
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return fromZsh;
|
|
242
|
+
if (result._tag === "absent") {
|
|
243
|
+
return yield* new DbConnectionError({
|
|
244
|
+
message: `${envVar} is not in the environment or in the interactive zsh (required for '${label}' config field) in environment ${env}.`,
|
|
245
|
+
environment: env,
|
|
246
|
+
});
|
|
246
247
|
}
|
|
247
|
-
|
|
248
|
-
return yield* new DbConnectionError({
|
|
249
|
-
message: `Environment variable ${config.passwordEnvVar} is not set.`,
|
|
250
|
-
environment: env,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Local databases typically don't need a password
|
|
255
|
-
return "";
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const resolveConfigString = Effect.fn("DbService.resolveConfigString")(function* (
|
|
259
|
-
value: string,
|
|
260
|
-
env: string,
|
|
261
|
-
label: string,
|
|
262
|
-
zshrcEnv: Record<string, string>,
|
|
263
|
-
) {
|
|
264
|
-
const match = value.match(envTemplateRegex);
|
|
265
|
-
if (!match) return value;
|
|
266
|
-
|
|
267
|
-
const envVar = match[1];
|
|
268
|
-
const fromEnv = Bun.env[envVar];
|
|
269
|
-
if (fromEnv !== undefined) return fromEnv;
|
|
270
|
-
|
|
271
|
-
const fromZsh = zshrcEnv[envVar];
|
|
272
|
-
if (fromZsh !== undefined) return fromZsh;
|
|
273
|
-
|
|
274
|
-
return yield* new DbConnectionError({
|
|
275
|
-
message: `Environment variable ${envVar} (required for '${label}' config field) is not set in environment ${env}.`,
|
|
276
|
-
environment: env,
|
|
248
|
+
return result.value;
|
|
277
249
|
});
|
|
278
|
-
});
|
|
279
250
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
251
|
+
const resolveDbConfig = (config: DbConfig, env: string) =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const resolveOverride = (name: string | undefined) => {
|
|
254
|
+
if (!name) return Effect.succeed(undefined);
|
|
255
|
+
return lookupConfigEnv(name).pipe(
|
|
256
|
+
Effect.flatMap((result) => {
|
|
257
|
+
if (result._tag === "absent") return Effect.succeed(undefined);
|
|
258
|
+
if (result._tag === "failed") {
|
|
259
|
+
return Effect.fail(
|
|
260
|
+
new DbConnectionError({
|
|
261
|
+
message: `${name} lookup failed in environment ${env}.`,
|
|
262
|
+
environment: env,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return Effect.succeed(result.value);
|
|
267
|
+
}),
|
|
268
|
+
Effect.mapError(
|
|
269
|
+
() =>
|
|
270
|
+
new DbConnectionError({
|
|
271
|
+
message: `${name} lookup failed in environment ${env}.`,
|
|
272
|
+
environment: env,
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
const hostValue = yield* resolveOverride(config.hostEnvVar);
|
|
278
|
+
const portValue = yield* resolveOverride(config.portEnvVar);
|
|
279
|
+
|
|
280
|
+
const host = resolveHostOverride(config.host, config.hostEnvVar, hostValue);
|
|
281
|
+
const portOverride = resolvePortOverride(
|
|
282
|
+
env,
|
|
283
|
+
config.port,
|
|
284
|
+
config.portEnvVar,
|
|
285
|
+
portValue,
|
|
286
|
+
);
|
|
305
287
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
288
|
+
if (!portOverride.ok) {
|
|
289
|
+
return yield* new DbConnectionError({
|
|
290
|
+
message: portOverride.message,
|
|
291
|
+
environment: env,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
312
294
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
295
|
+
// A host override can flip the tunnel decision (a forwarded localhost port vs a directly
|
|
296
|
+
// reachable host), so re-derive the access mode whenever the override changed the host.
|
|
297
|
+
const needsTunnel =
|
|
298
|
+
host === config.host
|
|
299
|
+
? config.needsTunnel
|
|
300
|
+
: resolveDbAccessMode(
|
|
301
|
+
env,
|
|
302
|
+
host,
|
|
303
|
+
dbConfig.kubectl !== undefined,
|
|
304
|
+
config.allowedMutations,
|
|
305
|
+
).needsTunnel;
|
|
324
306
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
307
|
+
return {
|
|
308
|
+
...config,
|
|
309
|
+
host,
|
|
310
|
+
port: portOverride.port,
|
|
311
|
+
needsTunnel,
|
|
312
|
+
user: yield* resolveConfigString(config.user, env, "user"),
|
|
313
|
+
database: yield* resolveConfigString(config.database, env, "database"),
|
|
314
|
+
};
|
|
315
|
+
});
|
|
334
316
|
|
|
335
317
|
const executeShellCommand = (command: ChildProcess.Command) =>
|
|
336
318
|
Effect.scoped(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect";
|
|
2
2
|
|
|
3
3
|
import { DbQueryError } from "./errors";
|
|
4
|
+
import { redactSensitiveText } from "#shared/content-security";
|
|
4
5
|
|
|
5
6
|
export type DbConnection = {
|
|
6
7
|
readonly host: string;
|
|
@@ -22,6 +23,11 @@ type BunQueryResult = unknown[] & { count?: number; command?: string };
|
|
|
22
23
|
export const toErrorMessage = (cause: unknown): string =>
|
|
23
24
|
cause instanceof Error ? cause.message : String(cause);
|
|
24
25
|
|
|
26
|
+
export const toDbQueryError = (cause: unknown, sql: string): DbQueryError => {
|
|
27
|
+
const message = redactSensitiveText(toErrorMessage(cause));
|
|
28
|
+
return new DbQueryError({ message, sql, stderr: message });
|
|
29
|
+
};
|
|
30
|
+
|
|
25
31
|
const runWithBunSql = (connection: DbConnection, sql: string) =>
|
|
26
32
|
Effect.tryPromise({
|
|
27
33
|
try: async (): Promise<DbQueryOutcome> => {
|
|
@@ -61,10 +67,7 @@ const runWithBunSql = (connection: DbConnection, sql: string) =>
|
|
|
61
67
|
await client.close().catch(() => undefined);
|
|
62
68
|
}
|
|
63
69
|
},
|
|
64
|
-
catch: (cause) =>
|
|
65
|
-
const message = toErrorMessage(cause);
|
|
66
|
-
return new DbQueryError({ message, sql, stderr: message });
|
|
67
|
-
},
|
|
70
|
+
catch: (cause) => toDbQueryError(cause, sql),
|
|
68
71
|
});
|
|
69
72
|
|
|
70
73
|
export class DbSqlClient extends Context.Service<
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
detectPRStatus,
|
|
42
42
|
editPR,
|
|
43
43
|
fetchPRView,
|
|
44
|
-
|
|
44
|
+
fetchCheckReport,
|
|
45
45
|
fetchChecksBatch,
|
|
46
46
|
fetchChecksForCommand,
|
|
47
47
|
fetchFailedChecks,
|
|
@@ -191,8 +191,10 @@ type ReviewTriageClassification = {
|
|
|
191
191
|
export const classifyReviewTriage = (
|
|
192
192
|
summary: ReviewTriageSummary,
|
|
193
193
|
checks: readonly CheckResult[],
|
|
194
|
+
checksStatus: "reported" | "none_reported" = "reported",
|
|
194
195
|
): ReviewTriageClassification => {
|
|
195
196
|
const reasons = [
|
|
197
|
+
...(checksStatus === "none_reported" ? ["checks_not_reported"] : []),
|
|
196
198
|
...(checks.some((check) => check.bucket === "fail") ? ["failed_checks"] : []),
|
|
197
199
|
...(checks.some((check) => check.bucket === "cancel") ? ["cancelled_checks"] : []),
|
|
198
200
|
...(checks.some(
|
|
@@ -397,7 +399,6 @@ const countFeedbackOrigins = (items: ReadonlyArray<{ feedbackOrigin: string }>)
|
|
|
397
399
|
|
|
398
400
|
export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
|
|
399
401
|
prNumber: number | null,
|
|
400
|
-
format: "toon" | "json" = "toon",
|
|
401
402
|
) {
|
|
402
403
|
const { info, value } = yield* withStableHead(
|
|
403
404
|
prNumber,
|
|
@@ -405,16 +406,17 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
|
|
|
405
406
|
Effect.all([
|
|
406
407
|
fetchThreads(number, false, false, headSha),
|
|
407
408
|
fetchDiscussionSummary(number),
|
|
408
|
-
|
|
409
|
+
fetchCheckReport(number),
|
|
409
410
|
fetchReviews(number, null, null, null, headSha),
|
|
410
411
|
fetchComments(number, null, headSha),
|
|
411
412
|
]),
|
|
412
413
|
{ operation: "review triage", command: "gh-tool pr review-triage" },
|
|
413
414
|
);
|
|
414
|
-
const [allThreads, summary,
|
|
415
|
+
const [allThreads, summary, checkReport, reviews, inlineComments] = value;
|
|
416
|
+
const checks = checkReport.checks ?? [];
|
|
415
417
|
const unresolvedThreads = allThreads.filter((thread) => !thread.isResolved);
|
|
416
418
|
const visibleOpenThreads = allThreads.filter((thread) => thread.isVisibleOpen);
|
|
417
|
-
const classification = classifyReviewTriage(summary, checks);
|
|
419
|
+
const classification = classifyReviewTriage(summary, checks, checkReport.checksStatus);
|
|
418
420
|
|
|
419
421
|
// Single merge-readiness verdict so agents stop re-stitching mergeable + checks + threads +
|
|
420
422
|
// review state across separate calls (F2). `blocking` names exactly what's left to do.
|
|
@@ -423,6 +425,7 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
|
|
|
423
425
|
if (checks.some((check) => check.bucket === "fail")) blocking.push("failing_checks");
|
|
424
426
|
if (checks.some((check) => check.bucket === "pending")) blocking.push("pending_checks");
|
|
425
427
|
if (checks.some((check) => check.bucket === "cancel")) blocking.push("cancelled_checks");
|
|
428
|
+
if (checkReport.checksStatus === "none_reported") blocking.push("checks_not_reported");
|
|
426
429
|
if (
|
|
427
430
|
checks.some(
|
|
428
431
|
(check) => !["pass", "fail", "pending", "skipping", "cancel"].includes(check.bucket),
|
|
@@ -466,6 +469,8 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
|
|
|
466
469
|
visibleOpenThreads,
|
|
467
470
|
summary,
|
|
468
471
|
checks,
|
|
472
|
+
checksStatus: checkReport.checksStatus,
|
|
473
|
+
...(checkReport.checksStatus === "none_reported" ? { checksReason: checkReport.reason } : {}),
|
|
469
474
|
reviews,
|
|
470
475
|
inlineComments,
|
|
471
476
|
feedbackOriginCounts: {
|
|
@@ -1609,7 +1614,7 @@ export const prReviewTriageCommand = Command.make(
|
|
|
1609
1614
|
repo,
|
|
1610
1615
|
Effect.gen(function* () {
|
|
1611
1616
|
const prNumber = Option.getOrNull(pr);
|
|
1612
|
-
const result = yield* fetchReviewTriage(prNumber
|
|
1617
|
+
const result = yield* fetchReviewTriage(prNumber);
|
|
1613
1618
|
yield* logFormatted(
|
|
1614
1619
|
omitTriageSections(result, Option.getOrNull(omit)?.split(",") ?? []),
|
|
1615
1620
|
format,
|
|
@@ -1654,7 +1659,7 @@ export const prReviewTriageBatchCommand = Command.make(
|
|
|
1654
1659
|
Effect.gen(function* () {
|
|
1655
1660
|
const numbers = yield* parsePrNumbers(prs);
|
|
1656
1661
|
const results = yield* Effect.all(
|
|
1657
|
-
numbers.map((prNumber) => fetchReviewTriage(prNumber
|
|
1662
|
+
numbers.map((prNumber) => fetchReviewTriage(prNumber)),
|
|
1658
1663
|
{ concurrency: "unbounded" },
|
|
1659
1664
|
);
|
|
1660
1665
|
yield* logFormatted(results, format);
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -257,6 +257,8 @@ export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
|
|
|
257
257
|
// for the caller's whole --timeout.
|
|
258
258
|
const CHECK_REGISTRATION_POLL_SECONDS = 5;
|
|
259
259
|
const CHECK_REGISTRATION_GRACE_SECONDS = 60;
|
|
260
|
+
const CHECK_POLL_INTERVAL_MS = 15_000;
|
|
261
|
+
const CHECK_POLL_MAX_INTERVAL_MS = 60_000;
|
|
260
262
|
const NO_CHECKS_REPORTED_REASON =
|
|
261
263
|
"gh reported no checks for this head. Checks may not be registered yet, the PR may have no CI, or GitHub returned an empty rollup. This is not a CI verdict.";
|
|
262
264
|
|
|
@@ -1469,9 +1471,21 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
1469
1471
|
const started = Number(yield* Clock.currentTimeMillis);
|
|
1470
1472
|
const graceDeadlineMs =
|
|
1471
1473
|
started + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
|
|
1474
|
+
let previousChecks = "";
|
|
1475
|
+
let intervalMs = CHECK_POLL_INTERVAL_MS;
|
|
1472
1476
|
const poll = Effect.gen(function* () {
|
|
1473
1477
|
const checks = yield* fetchCheckResults(pr);
|
|
1474
|
-
|
|
1478
|
+
const signature = JSON.stringify(checks);
|
|
1479
|
+
intervalMs =
|
|
1480
|
+
signature === previousChecks
|
|
1481
|
+
? Math.min(CHECK_POLL_MAX_INTERVAL_MS, intervalMs * 2)
|
|
1482
|
+
: CHECK_POLL_INTERVAL_MS;
|
|
1483
|
+
previousChecks = signature;
|
|
1484
|
+
return {
|
|
1485
|
+
checks,
|
|
1486
|
+
at: Number(yield* Clock.currentTimeMillis),
|
|
1487
|
+
intervalMs: checks.length === 0 ? CHECK_REGISTRATION_POLL_SECONDS * 1000 : intervalMs,
|
|
1488
|
+
};
|
|
1475
1489
|
});
|
|
1476
1490
|
const watchOutcome = yield* waits
|
|
1477
1491
|
.until(
|
|
@@ -1481,7 +1495,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
1481
1495
|
? at >= graceDeadlineMs
|
|
1482
1496
|
: (failFast && checks.some((check) => check.bucket === "fail")) ||
|
|
1483
1497
|
checks.every((check) => check.bucket !== "pending"),
|
|
1484
|
-
{ intervalMs:
|
|
1498
|
+
{ intervalMs: (value) => value.intervalMs, timeoutMs: timeoutSeconds * 1000 },
|
|
1485
1499
|
)
|
|
1486
1500
|
.pipe(
|
|
1487
1501
|
Effect.timeoutOrElse({
|
|
@@ -1504,6 +1518,16 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
1504
1518
|
stderr: "",
|
|
1505
1519
|
});
|
|
1506
1520
|
const results = yield* fetchCheckResults(pr);
|
|
1521
|
+
if (
|
|
1522
|
+
results.some((check) => check.bucket === "fail") &&
|
|
1523
|
+
(failFast || !results.some((check) => check.bucket === "pending"))
|
|
1524
|
+
)
|
|
1525
|
+
return yield* new GitHubCommandError({
|
|
1526
|
+
command: buildChecksCommand(pr, true),
|
|
1527
|
+
exitCode: 1,
|
|
1528
|
+
message: "",
|
|
1529
|
+
stderr: "",
|
|
1530
|
+
});
|
|
1507
1531
|
if (!quiet && graceExpired) {
|
|
1508
1532
|
const prRef = pr === null ? "<number>" : String(pr);
|
|
1509
1533
|
yield* Console.warn(
|
|
@@ -1608,7 +1632,7 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
|
|
|
1608
1632
|
const initial = yield* viewPR(pr);
|
|
1609
1633
|
const snapshot = yield* collectWithStableState(
|
|
1610
1634
|
initial,
|
|
1611
|
-
(info) =>
|
|
1635
|
+
(info) => fetchCheckReport(info.number),
|
|
1612
1636
|
(info) =>
|
|
1613
1637
|
fetchPRView(info.number, "origin").pipe(
|
|
1614
1638
|
Effect.map((refreshed) => ({ ...refreshed, baseSha: initial.baseSha })),
|
|
@@ -1616,7 +1640,20 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
|
|
|
1616
1640
|
(before, after) => after.headSha === before.headSha,
|
|
1617
1641
|
);
|
|
1618
1642
|
if (snapshot !== null) {
|
|
1619
|
-
|
|
1643
|
+
if (snapshot.value.checksStatus === "none_reported") {
|
|
1644
|
+
return {
|
|
1645
|
+
evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
|
|
1646
|
+
status: "none_reported",
|
|
1647
|
+
reason: snapshot.value.reason,
|
|
1648
|
+
message: "No checks were reported for this PR head.",
|
|
1649
|
+
summary: { total: 0, failed: 0, pending: 0, passed: 0 },
|
|
1650
|
+
failedChecks: [],
|
|
1651
|
+
pendingChecks: [],
|
|
1652
|
+
hint: "This is not a CI verdict. Check whether CI exists or has registered for this head.",
|
|
1653
|
+
nextCommands: [`bun agent-tools-gh pr checks --pr ${snapshot.state.number}`],
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
return yield* buildFailedChecksReport(snapshot.state.number, snapshot.value.checks, {
|
|
1620
1657
|
withLogs: withLogs || fullLogs,
|
|
1621
1658
|
fullLogs,
|
|
1622
1659
|
evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
|
package/src/gh-tool/pr/review.ts
CHANGED
|
@@ -113,6 +113,9 @@ const enrichThreads = (
|
|
|
113
113
|
feedbackOrigin: feedbackOrigin(comment.commit?.oid ?? null, currentHeadSha),
|
|
114
114
|
path: comment.path,
|
|
115
115
|
line: comment.line,
|
|
116
|
+
originalLine: root?.originalLine ?? null,
|
|
117
|
+
originalCommitSha: root?.originalCommitSha ?? null,
|
|
118
|
+
diffHunk: root?.diffHunk ?? null,
|
|
116
119
|
body: comment.body,
|
|
117
120
|
isResolved: node.isResolved,
|
|
118
121
|
hasReply,
|
|
@@ -131,7 +134,18 @@ const enrichThreads = (
|
|
|
131
134
|
const dedupedByKey = new Map<string, number>();
|
|
132
135
|
const deduped: ReviewThread[] = [];
|
|
133
136
|
for (const thread of mapped) {
|
|
134
|
-
const
|
|
137
|
+
const originalLocation =
|
|
138
|
+
thread.line === null
|
|
139
|
+
? JSON.stringify([
|
|
140
|
+
thread.originalLine,
|
|
141
|
+
thread.originalCommitSha,
|
|
142
|
+
thread.diffHunk,
|
|
143
|
+
thread.originalLine === null || thread.originalCommitSha === null
|
|
144
|
+
? thread.commentId
|
|
145
|
+
: null,
|
|
146
|
+
])
|
|
147
|
+
: "";
|
|
148
|
+
const key = `${thread.path}\u0000${thread.line}\u0000${thread.body.trim()}\u0000${originalLocation}`;
|
|
135
149
|
const existingIndex = dedupedByKey.get(key);
|
|
136
150
|
if (existingIndex === undefined) {
|
|
137
151
|
dedupedByKey.set(key, deduped.length);
|
|
@@ -224,6 +238,9 @@ export const fetchComments = Effect.fn("pr.fetchComments")(function* (
|
|
|
224
238
|
body: c.body,
|
|
225
239
|
path: c.path,
|
|
226
240
|
line: c.line,
|
|
241
|
+
originalLine: c.original_line ?? null,
|
|
242
|
+
originalCommitSha: c.original_commit_id ?? null,
|
|
243
|
+
diffHunk: c.diff_hunk ?? null,
|
|
227
244
|
createdAt: c.created_at,
|
|
228
245
|
reviewId: c.pull_request_review_id ?? null,
|
|
229
246
|
updatedAt: c.updated_at,
|
package/src/gh-tool/pr/stack.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { GitHubMergeError } from "#gh/errors";
|
|
|
10
10
|
|
|
11
11
|
import type { MergeStrategy, StackMember, StackMergeBlocker, StackMergeResult } from "#gh/types";
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { fetchCheckReport, fetchPRView } from "./core";
|
|
14
14
|
import { readStack } from "./stack-read";
|
|
15
15
|
|
|
16
16
|
export { readStack };
|
|
@@ -62,7 +62,17 @@ const collectBlockers = Effect.fn("pr.collectStackBlockers")(function* (members:
|
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const
|
|
65
|
+
const report = yield* fetchCheckReport(member.number);
|
|
66
|
+
if (report.checksStatus === "none_reported") {
|
|
67
|
+
blockers.push({
|
|
68
|
+
number: member.number,
|
|
69
|
+
reason: "checks_none_reported",
|
|
70
|
+
detail: `checks: none_reported. ${report.reason}`,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const checks = report.checks;
|
|
66
76
|
const failing = checks.filter((check) => check.bucket === "fail");
|
|
67
77
|
if (failing.length > 0) {
|
|
68
78
|
blockers.push({
|
package/src/gh-tool/read/api.ts
CHANGED
|
@@ -208,6 +208,12 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
|
|
|
208
208
|
command: `${method} ${opts.path}`,
|
|
209
209
|
exitCode: response.status,
|
|
210
210
|
stderr: text,
|
|
211
|
+
...(rateLimit
|
|
212
|
+
? {
|
|
213
|
+
hint: "Wait for the reset before retrying. Check the remaining quota once with the shown command; it does not consume the quota.",
|
|
214
|
+
nextCommand: "gh api rate_limit",
|
|
215
|
+
}
|
|
216
|
+
: {}),
|
|
211
217
|
...(response.status >= 500 || (rateLimit && canWait) ? { retryable: true } : {}),
|
|
212
218
|
});
|
|
213
219
|
if (rateLimit && canWait && waitMs !== undefined) rateLimitRetryDelays.set(error, waitMs);
|
|
@@ -227,7 +227,10 @@ export const prChecks = Effect.fn("reads.prChecks")(function* (
|
|
|
227
227
|
const sha = head.headSha;
|
|
228
228
|
let verified = true;
|
|
229
229
|
const stableFetch = Effect.gen(function* () {
|
|
230
|
-
const before =
|
|
230
|
+
const before =
|
|
231
|
+
headSha === null
|
|
232
|
+
? head
|
|
233
|
+
: yield* prHead(repo, number, "origin").pipe(Effect.orElseSucceed(() => null));
|
|
231
234
|
if (before !== null && before.headSha !== sha) return yield* movedHead(number);
|
|
232
235
|
const value = yield* gh.runGhJson<CheckResult[]>([
|
|
233
236
|
"pr",
|