@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.37",
3
+ "version": "0.14.39",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -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
  },
@@ -11,6 +11,7 @@ export type {
11
11
  AuditConfig,
12
12
  CliToolOverride,
13
13
  CredentialGuardConfig,
14
+ GitHubPrTitlePolicy,
14
15
  GitHubRepoConfig,
15
16
  } from "./types";
16
17
 
@@ -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([
@@ -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
  /**
@@ -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
- * Build the kubectl args for the lightweight API-server reachability probe.
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,
@@ -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 = [
@@ -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
  );
@@ -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
+ }