@blogic-cz/agent-tools 0.14.37 → 0.14.39
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/schemas/agent-tools.schema.json +23 -0
- package/src/config/index.ts +1 -0
- package/src/config/loader.ts +8 -0
- package/src/config/types.ts +14 -0
- package/src/db-tool/service.ts +3 -20
- package/src/gh-tool/pr/core.ts +44 -0
- package/src/gh-tool/service.ts +20 -2
- package/src/k8s-tool/service.ts +46 -0
- package/src/shared/k8s-probe.ts +23 -0
package/package.json
CHANGED
|
@@ -95,6 +95,29 @@
|
|
|
95
95
|
"repo": {
|
|
96
96
|
"type": "string",
|
|
97
97
|
"description": "Repository name."
|
|
98
|
+
},
|
|
99
|
+
"prTitle": {
|
|
100
|
+
"$ref": "#/definitions/GitHubPrTitlePolicy"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"GitHubPrTitlePolicy": {
|
|
105
|
+
"description": "Optional pull request title validation policy for this repository profile.",
|
|
106
|
+
"type": "object",
|
|
107
|
+
"additionalProperties": false,
|
|
108
|
+
"required": ["pattern", "expected"],
|
|
109
|
+
"properties": {
|
|
110
|
+
"pattern": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"description": "JavaScript regular expression source that valid PR titles must match."
|
|
113
|
+
},
|
|
114
|
+
"expected": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "Human-readable required title format shown when validation fails."
|
|
117
|
+
},
|
|
118
|
+
"example": {
|
|
119
|
+
"type": "string",
|
|
120
|
+
"description": "Example valid title shown when validation fails."
|
|
98
121
|
}
|
|
99
122
|
}
|
|
100
123
|
},
|
package/src/config/index.ts
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -80,6 +80,7 @@ const K8sConfigSchema = Schema.Struct({
|
|
|
80
80
|
clusterId: Schema.String,
|
|
81
81
|
namespaces: Schema.Record(Schema.String, Schema.String),
|
|
82
82
|
timeoutMs: Schema.optionalKey(Schema.Number),
|
|
83
|
+
apiProbeTimeoutMs: Schema.optionalKey(Schema.Number),
|
|
83
84
|
prerequisites: Schema.optionalKey(PrerequisitesSchema),
|
|
84
85
|
vpn: Schema.optionalKey(Schema.String),
|
|
85
86
|
});
|
|
@@ -141,6 +142,13 @@ const AuditConfigSchema = Schema.Struct({
|
|
|
141
142
|
const GitHubRepoConfigSchema = Schema.Struct({
|
|
142
143
|
owner: Schema.String,
|
|
143
144
|
repo: Schema.String,
|
|
145
|
+
prTitle: Schema.optionalKey(
|
|
146
|
+
Schema.Struct({
|
|
147
|
+
pattern: Schema.String,
|
|
148
|
+
expected: Schema.String,
|
|
149
|
+
example: Schema.optionalKey(Schema.String),
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
144
152
|
});
|
|
145
153
|
|
|
146
154
|
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
package/src/config/types.ts
CHANGED
|
@@ -71,6 +71,13 @@ export type K8sConfig = ProfilePrerequisites & {
|
|
|
71
71
|
/** Named namespaces, e.g. { test: "my-app-test", prod: "my-app-prod" } */
|
|
72
72
|
namespaces: Record<string, string>;
|
|
73
73
|
timeoutMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Timeout in milliseconds for the cheap Kubernetes API-server reachability probe run
|
|
76
|
+
* before each command. When the cluster API is unreachable (VPN down / off the office
|
|
77
|
+
* network, or the API degraded) a real kubectl command hangs until `timeoutMs`; the
|
|
78
|
+
* probe lets it fail fast with a clear message instead. Set to 0 to disable. Defaults to 2000.
|
|
79
|
+
*/
|
|
80
|
+
apiProbeTimeoutMs?: number;
|
|
74
81
|
};
|
|
75
82
|
|
|
76
83
|
/** Single database environment connection details */
|
|
@@ -154,9 +161,16 @@ export type AuditConfig = {
|
|
|
154
161
|
};
|
|
155
162
|
|
|
156
163
|
/** Single GitHub repository configuration */
|
|
164
|
+
export type GitHubPrTitlePolicy = {
|
|
165
|
+
pattern: string;
|
|
166
|
+
expected: string;
|
|
167
|
+
example?: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
157
170
|
export type GitHubRepoConfig = {
|
|
158
171
|
owner: string;
|
|
159
172
|
repo: string;
|
|
173
|
+
prTitle?: GitHubPrTitlePolicy;
|
|
160
174
|
};
|
|
161
175
|
|
|
162
176
|
/**
|
package/src/db-tool/service.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
|
8
8
|
import { resolveEnvTemplate } from "#shared/env-template";
|
|
9
9
|
import { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
|
|
10
10
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
11
|
+
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
11
12
|
import { DbConfigService, DbConfigServiceLayer, TUNNEL_CHECK_INTERVAL_MS } from "./config-service";
|
|
12
13
|
import {
|
|
13
14
|
DbConnectionError,
|
|
@@ -51,26 +52,8 @@ export function resolveDbAccessMode(
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* Hitting `/version` via `--raw` is the cheapest authenticated round-trip; `--request-timeout`
|
|
57
|
-
* bounds it so an unreachable server (VPN down, off the office network) fails fast instead of
|
|
58
|
-
* letting a subsequent `kubectl port-forward` hang on a silent TCP connect.
|
|
59
|
-
*/
|
|
60
|
-
export function buildApiProbeArgs(
|
|
61
|
-
kubeconfig: string | undefined,
|
|
62
|
-
context: string,
|
|
63
|
-
timeoutMs: number,
|
|
64
|
-
): string[] {
|
|
65
|
-
return [
|
|
66
|
-
...(kubeconfig ? ["--kubeconfig", kubeconfig] : []),
|
|
67
|
-
"--context",
|
|
68
|
-
context,
|
|
69
|
-
"get",
|
|
70
|
-
"--raw=/version",
|
|
71
|
-
`--request-timeout=${timeoutMs}ms`,
|
|
72
|
-
];
|
|
73
|
-
}
|
|
55
|
+
// Re-exported from the shared probe so existing `#db/service` consumers keep working.
|
|
56
|
+
export { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
74
57
|
|
|
75
58
|
export class DbService extends Context.Service<
|
|
76
59
|
DbService,
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -21,6 +21,45 @@ import { runLocalCommand } from "./helpers";
|
|
|
21
21
|
const CHECK_JSON_FIELDS = "name,state,bucket,link";
|
|
22
22
|
const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
|
|
23
23
|
|
|
24
|
+
const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string) {
|
|
25
|
+
const gh = yield* GitHubService;
|
|
26
|
+
const repoConfig = yield* gh.getRepoConfig();
|
|
27
|
+
const policy = repoConfig?.prTitle;
|
|
28
|
+
|
|
29
|
+
if (!policy) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pattern = yield* Effect.try({
|
|
34
|
+
try: () => new RegExp(policy.pattern),
|
|
35
|
+
catch: (error) =>
|
|
36
|
+
new GitHubCommandError({
|
|
37
|
+
command: "pr title validation",
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
stderr: `Invalid PR title policy regex: ${error instanceof Error ? error.message : String(error)}`,
|
|
40
|
+
message: "Invalid PR title policy regex",
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (pattern.test(title)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lines = [
|
|
49
|
+
"PR title does not match the required format.",
|
|
50
|
+
`Got: ${title}`,
|
|
51
|
+
`Expected: ${policy.expected}`,
|
|
52
|
+
...(policy.example ? [`Example: ${policy.example}`] : []),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
return yield* new GitHubCommandError({
|
|
56
|
+
command: "pr title validation",
|
|
57
|
+
exitCode: 1,
|
|
58
|
+
stderr: lines.join("\n"),
|
|
59
|
+
message: lines[0] ?? "PR title does not match the required format.",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
24
63
|
type WorkflowRunJobsForRerun = {
|
|
25
64
|
databaseId: number;
|
|
26
65
|
jobs: Array<{
|
|
@@ -422,6 +461,7 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
|
|
|
422
461
|
head: string | null;
|
|
423
462
|
}) {
|
|
424
463
|
const gh = yield* GitHubService;
|
|
464
|
+
yield* validatePRTitle(opts.title);
|
|
425
465
|
|
|
426
466
|
// When --head is provided (e.g. GitButler workspace), use `gh pr list --head`
|
|
427
467
|
// to find existing PR since `gh pr view` relies on the current git branch.
|
|
@@ -719,6 +759,10 @@ export const editPR = Effect.fn("pr.editPR")(function* (opts: {
|
|
|
719
759
|
}
|
|
720
760
|
|
|
721
761
|
const gh = yield* GitHubService;
|
|
762
|
+
if (opts.title !== null) {
|
|
763
|
+
yield* validatePRTitle(opts.title);
|
|
764
|
+
}
|
|
765
|
+
|
|
722
766
|
const repo = yield* gh.getRepoInfo();
|
|
723
767
|
|
|
724
768
|
const editArgs = [
|
package/src/gh-tool/service.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
2
|
import { Context, Effect, Layer, Stream } from "effect";
|
|
3
3
|
|
|
4
|
+
import type { GitHubRepoConfig } from "#config";
|
|
4
5
|
import type { RepoInfo } from "./types";
|
|
5
6
|
|
|
6
7
|
import { GH_BINARY } from "./config";
|
|
7
8
|
import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
|
|
8
|
-
import { ConfigService, resolveGitHubRepoTarget } from "#config";
|
|
9
|
+
import { ConfigService, getGitHubConfig, resolveGitHubRepoTarget } from "#config";
|
|
9
10
|
|
|
10
11
|
type GhResult = {
|
|
11
12
|
stdout: string;
|
|
@@ -24,6 +25,7 @@ export class GitHubService extends Context.Service<
|
|
|
24
25
|
query: string,
|
|
25
26
|
variables: Record<string, string | number | null>,
|
|
26
27
|
) => Effect.Effect<unknown, GhError>;
|
|
28
|
+
readonly getRepoConfig: () => Effect.Effect<GitHubRepoConfig | undefined, never>;
|
|
27
29
|
readonly getRepoInfo: () => Effect.Effect<RepoInfo, GhError>;
|
|
28
30
|
readonly withRepoTarget: <A, E, R>(
|
|
29
31
|
target: string | null,
|
|
@@ -76,6 +78,22 @@ export class GitHubService extends Context.Service<
|
|
|
76
78
|
return yield* effect.pipe(Effect.provideService(RepoTarget, resolved));
|
|
77
79
|
});
|
|
78
80
|
|
|
81
|
+
const getRepoConfig = Effect.fn("GitHubService.getRepoConfig")(function* () {
|
|
82
|
+
const ghRepo = yield* RepoTarget;
|
|
83
|
+
const repos = config?.github;
|
|
84
|
+
|
|
85
|
+
if (repos && ghRepo) {
|
|
86
|
+
const repoConfig = Object.values(repos).find(
|
|
87
|
+
(repo) => `${repo.owner}/${repo.repo}` === ghRepo,
|
|
88
|
+
);
|
|
89
|
+
if (repoConfig) {
|
|
90
|
+
return repoConfig;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return getGitHubConfig(config);
|
|
95
|
+
});
|
|
96
|
+
|
|
79
97
|
const executeGh = (args: string[]) =>
|
|
80
98
|
Effect.scoped(
|
|
81
99
|
Effect.gen(function* () {
|
|
@@ -242,7 +260,7 @@ export class GitHubService extends Context.Service<
|
|
|
242
260
|
return repoInfo;
|
|
243
261
|
});
|
|
244
262
|
|
|
245
|
-
return { runGh, runGhJson, runGraphQL, getRepoInfo, withRepoTarget };
|
|
263
|
+
return { runGh, runGhJson, runGraphQL, getRepoConfig, getRepoInfo, withRepoTarget };
|
|
246
264
|
}),
|
|
247
265
|
),
|
|
248
266
|
);
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { collectProcessOutput, quoteShellArg } from "#shared/exec";
|
|
|
15
15
|
import { resolveEnvTemplate } from "#shared/env-template";
|
|
16
16
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
17
17
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
18
|
+
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
18
19
|
import { isKubectlCommandAllowed } from "./security";
|
|
19
20
|
|
|
20
21
|
export class K8sService extends Context.Service<
|
|
@@ -146,6 +147,40 @@ export class K8sService extends Context.Service<
|
|
|
146
147
|
),
|
|
147
148
|
);
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Cheap pre-flight: is the cluster API reachable right now? Returns true when the probe
|
|
152
|
+
* is disabled (apiProbeTimeoutMs <= 0) so behaviour is unchanged unless a probe can run.
|
|
153
|
+
* A false result lets the command fail fast instead of hanging until the full timeoutMs.
|
|
154
|
+
*/
|
|
155
|
+
const probeApiReachable = (
|
|
156
|
+
context: string,
|
|
157
|
+
kubeconfig: string | undefined,
|
|
158
|
+
timeoutMs: number,
|
|
159
|
+
) =>
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
if (timeoutMs <= 0) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const probe = ChildProcess.make(
|
|
166
|
+
"kubectl",
|
|
167
|
+
buildApiProbeArgs(kubeconfig, context, timeoutMs),
|
|
168
|
+
{
|
|
169
|
+
stdout: "pipe",
|
|
170
|
+
stderr: "pipe",
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const result = yield* Effect.scoped(
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
const process = yield* executor.spawn(probe);
|
|
177
|
+
return yield* collectProcessOutput(process);
|
|
178
|
+
}),
|
|
179
|
+
).pipe(Effect.catch(() => Effect.succeed({ stdout: "", stderr: "", exitCode: 1 })));
|
|
180
|
+
|
|
181
|
+
return result.exitCode === 0;
|
|
182
|
+
});
|
|
183
|
+
|
|
149
184
|
const resolveContext = Effect.fn("K8sService.resolveContext")(function* (
|
|
150
185
|
profile: string | undefined,
|
|
151
186
|
k8sConfig: K8sConfig,
|
|
@@ -223,12 +258,23 @@ export class K8sService extends Context.Service<
|
|
|
223
258
|
) {
|
|
224
259
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
225
260
|
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
261
|
+
const apiProbeTimeoutMs = k8sConfig.apiProbeTimeoutMs ?? 2000;
|
|
226
262
|
return yield* runWithProfilePrerequisites(
|
|
227
263
|
config ?? {},
|
|
228
264
|
k8sConfig,
|
|
229
265
|
runPrerequisiteCommand,
|
|
230
266
|
Effect.gen(function* () {
|
|
231
267
|
const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
|
|
268
|
+
|
|
269
|
+
const reachable = yield* probeApiReachable(context, kubeconfig, apiProbeTimeoutMs);
|
|
270
|
+
if (!reachable) {
|
|
271
|
+
return yield* new K8sContextError({
|
|
272
|
+
message: `Kubernetes API server (${k8sConfig.clusterId}) not reachable within ${apiProbeTimeoutMs}ms. VPN likely not connected, or the cluster API is degraded.`,
|
|
273
|
+
clusterId: k8sConfig.clusterId,
|
|
274
|
+
hint: "Check the VPN connection and cluster health, then retry. Set kubernetes.apiProbeTimeoutMs to 0 in agent-tools.json5 to disable this pre-flight probe.",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
232
278
|
const fullCommand = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
|
|
233
279
|
|
|
234
280
|
const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared kubectl API-server reachability probe, used by every VPN-gated tool
|
|
3
|
+
* (db-tool, k8s-tool) to fail fast instead of hanging on a silently unreachable cluster.
|
|
4
|
+
*
|
|
5
|
+
* Hitting `/version` via `--raw` is the cheapest authenticated round-trip; `--request-timeout`
|
|
6
|
+
* bounds it so an unreachable server (VPN down / off the office network, or the cluster API
|
|
7
|
+
* degraded) fails in ~`timeoutMs` with a clear message instead of waiting out the tool's full
|
|
8
|
+
* command/tunnel timeout.
|
|
9
|
+
*/
|
|
10
|
+
export function buildApiProbeArgs(
|
|
11
|
+
kubeconfig: string | undefined,
|
|
12
|
+
context: string,
|
|
13
|
+
timeoutMs: number,
|
|
14
|
+
): string[] {
|
|
15
|
+
return [
|
|
16
|
+
...(kubeconfig ? ["--kubeconfig", kubeconfig] : []),
|
|
17
|
+
"--context",
|
|
18
|
+
context,
|
|
19
|
+
"get",
|
|
20
|
+
"--raw=/version",
|
|
21
|
+
`--request-timeout=${timeoutMs}ms`,
|
|
22
|
+
];
|
|
23
|
+
}
|