autopilot-code 0.0.4 → 0.0.5

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 CHANGED
@@ -39,6 +39,7 @@ Example:
39
39
  Notes:
40
40
  - `repo` must be the GitHub `owner/name`.
41
41
  - `agent` (optional, default `"none"`): set to `"opencode"` to enable OpenCode integration after claiming issues.
42
+ - `ignoreIssueLabels` (optional, default `["autopilot:backlog"]`): issues with any of these labels will be ignored by the runner.
42
43
  - `heartbeatMaxAgeSecs` controls how long an in-progress issue can go without a heartbeat before it's considered stale.
43
44
 
44
45
  ## Workflow (labels)
@@ -86,7 +87,11 @@ On each loop:
86
87
  ## Running locally
87
88
  ### Python runner
88
89
  ```bash
90
+ # Run a single scan/claim/act cycle
89
91
  python3 scripts/run_autopilot.py --root /mnt/f/Source
92
+
93
+ # Run in foreground loop mode (dev-friendly)
94
+ python3 scripts/run_autopilot.py --root /mnt/f/Source --interval-seconds 60
90
95
  ```
91
96
 
92
97
  ### Node CLI wrapper
@@ -104,8 +109,13 @@ node dist/cli.js scan --root /mnt/f/Source
104
109
 
105
110
  # claim exactly one issue + comment
106
111
  node dist/cli.js run-once --root /mnt/f/Source
112
+
113
+ # run service in foreground mode (dev-friendly)
114
+ node dist/cli.js service --foreground --interval-seconds 60 --root /mnt/f/Source
107
115
  ```
108
116
 
117
+ The foreground service mode runs continuously with the specified interval and logs to stdout. Press Ctrl+C to shut down cleanly.
118
+
109
119
  ## Roadmap
110
120
  - Spawn a coding agent (Claude Code / OpenCode) in a worktree per issue
111
121
  - Create PRs linked to issues; wait for checks to go green
package/dist/cli.js CHANGED
@@ -6,7 +6,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const commander_1 = require("commander");
8
8
  const node_child_process_1 = require("node:child_process");
9
+ const node_fs_1 = require("node:fs");
9
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const node_readline_1 = require("node:readline");
12
+ const repoRoot = node_path_1.default.resolve(__dirname, "..");
13
+ const packageJson = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(repoRoot, "package.json"), "utf8"));
14
+ const version = packageJson.version;
15
+ const GLOBAL_CONFIG_DIR = node_path_1.default.join(process.env.HOME || "", ".config", "autopilot");
16
+ const GLOBAL_CONFIG_FILE = node_path_1.default.join(GLOBAL_CONFIG_DIR, "config.json");
10
17
  function run(cmd, args) {
11
18
  const res = (0, node_child_process_1.spawnSync)(cmd, args, { stdio: "inherit" });
12
19
  if (res.error)
@@ -22,16 +29,126 @@ function check(cmd, args, label) {
22
29
  }
23
30
  if (typeof res.status === "number" && res.status !== 0) {
24
31
  console.error(`\n${label}: returned exit code ${res.status}`);
25
- process.exit(res.status);
32
+ process.exit(1);
26
33
  }
27
34
  }
28
- const repoRoot = node_path_1.default.resolve(__dirname, "..");
35
+ function loadGlobalConfig() {
36
+ if (!(0, node_fs_1.existsSync)(GLOBAL_CONFIG_FILE)) {
37
+ return null;
38
+ }
39
+ try {
40
+ const data = (0, node_fs_1.readFileSync)(GLOBAL_CONFIG_FILE, "utf8");
41
+ return JSON.parse(data);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function saveGlobalConfig(config) {
48
+ (0, node_fs_1.mkdirSync)(GLOBAL_CONFIG_DIR, { recursive: true });
49
+ (0, node_fs_1.writeFileSync)(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
50
+ }
51
+ function isGitRepo(dirPath) {
52
+ return (0, node_fs_1.existsSync)(node_path_1.default.join(dirPath, ".git"));
53
+ }
54
+ function prompt(question) {
55
+ const rl = (0, node_readline_1.createInterface)({
56
+ input: process.stdin,
57
+ output: process.stdout,
58
+ });
59
+ return new Promise((resolve) => {
60
+ rl.question(question, (answer) => {
61
+ rl.close();
62
+ resolve(answer.trim());
63
+ });
64
+ });
65
+ }
66
+ function validatePath(pathStr) {
67
+ const resolvedPath = node_path_1.default.resolve(pathStr);
68
+ if (!(0, node_fs_1.existsSync)(resolvedPath)) {
69
+ return { valid: false, error: `Path does not exist: ${pathStr}` };
70
+ }
71
+ if (!isGitRepo(resolvedPath)) {
72
+ return { valid: true, isGit: false };
73
+ }
74
+ return { valid: true, isGit: true };
75
+ }
76
+ async function collectSourceFolders() {
77
+ const folders = [];
78
+ console.log("\nšŸ“ Source Folder Selection");
79
+ console.log("Enter one or more directories containing git repos to scan.");
80
+ console.log("Leave empty when done.\n");
81
+ while (true) {
82
+ const input = await prompt(`Source folder #${folders.length + 1} (or press Enter to finish): `);
83
+ if (input === "") {
84
+ if (folders.length === 0) {
85
+ console.log("At least one source folder is required.");
86
+ continue;
87
+ }
88
+ break;
89
+ }
90
+ const validation = validatePath(input);
91
+ if (!validation.valid) {
92
+ console.error(`āŒ ${validation.error}`);
93
+ continue;
94
+ }
95
+ const resolvedPath = node_path_1.default.resolve(input);
96
+ if (folders.includes(resolvedPath)) {
97
+ console.log("āš ļø This folder is already in the list.");
98
+ continue;
99
+ }
100
+ if (validation.isGit) {
101
+ console.log(`āœ… Added git repo: ${resolvedPath}`);
102
+ }
103
+ else {
104
+ console.log(`āœ… Added folder: ${resolvedPath} (will scan for git repos recursively)`);
105
+ }
106
+ folders.push(resolvedPath);
107
+ }
108
+ return folders;
109
+ }
110
+ async function initCommand() {
111
+ console.log("šŸš€ Autopilot Setup\n");
112
+ const existingConfig = loadGlobalConfig();
113
+ if (existingConfig && existingConfig.sourceFolders.length > 0) {
114
+ console.log("Existing configuration found:");
115
+ console.log("Current source folders:");
116
+ existingConfig.sourceFolders.forEach((folder, idx) => {
117
+ const isGit = isGitRepo(folder);
118
+ console.log(` ${idx + 1}. ${folder}${isGit ? " (git repo)" : ""}`);
119
+ });
120
+ console.log();
121
+ const overwrite = await prompt("Do you want to replace this configuration? (y/N): ");
122
+ if (overwrite.toLowerCase() !== "y") {
123
+ console.log("Configuration unchanged.");
124
+ return;
125
+ }
126
+ }
127
+ const sourceFolders = await collectSourceFolders();
128
+ const config = {
129
+ sourceFolders,
130
+ };
131
+ saveGlobalConfig(config);
132
+ console.log("\nāœ… Configuration saved to:", GLOBAL_CONFIG_FILE);
133
+ console.log("\nYou can now run:");
134
+ console.log(" autopilot scan # Scan repos without claiming issues");
135
+ console.log(" autopilot run-once # Claim and work on one issue");
136
+ }
29
137
  const runnerPath = node_path_1.default.join(repoRoot, "scripts", "run_autopilot.py");
30
138
  const program = new commander_1.Command();
31
139
  program
32
140
  .name("autopilot")
33
141
  .description("Repo-issue–driven autopilot runner (CLI wrapper)")
34
- .version("0.0.0");
142
+ .version(version);
143
+ program
144
+ .command("init")
145
+ .description("Initialize autopilot configuration (select source folders)")
146
+ .action(() => {
147
+ initCommand().catch((err) => {
148
+ console.error("Error:", err);
149
+ process.exit(1);
150
+ });
151
+ });
35
152
  program
36
153
  .command("doctor")
37
154
  .description("Check local prerequisites (gh auth, python3)")
@@ -44,15 +161,75 @@ program
44
161
  program
45
162
  .command("scan")
46
163
  .description("Discover autopilot-enabled repos + show next issue candidate (dry-run)")
47
- .requiredOption("--root <path>", "Root folder that contains git repos")
164
+ .option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
165
+ .option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
48
166
  .action((opts) => {
49
- run("python3", [runnerPath, "--root", opts.root, "--dry-run"]);
167
+ let rootPaths = opts.root;
168
+ if (!rootPaths || rootPaths.length === 0) {
169
+ const config = loadGlobalConfig();
170
+ if (!config || config.sourceFolders.length === 0) {
171
+ console.error("No source folders configured. Run 'autopilot init' first.");
172
+ process.exit(1);
173
+ }
174
+ rootPaths = config.sourceFolders;
175
+ }
176
+ const args = [runnerPath, "--root", ...rootPaths];
177
+ if (opts.maxDepth)
178
+ args.push("--max-depth", opts.maxDepth);
179
+ args.push("--dry-run");
180
+ run("python3", args);
50
181
  });
51
182
  program
52
183
  .command("run-once")
53
184
  .description("Claim exactly one issue and post a progress comment")
54
- .requiredOption("--root <path>", "Root folder that contains git repos")
185
+ .option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
186
+ .option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
55
187
  .action((opts) => {
56
- run("python3", [runnerPath, "--root", opts.root]);
188
+ let rootPaths = opts.root;
189
+ if (!rootPaths || rootPaths.length === 0) {
190
+ const config = loadGlobalConfig();
191
+ if (!config || config.sourceFolders.length === 0) {
192
+ console.error("No source folders configured. Run 'autopilot init' first.");
193
+ process.exit(1);
194
+ }
195
+ rootPaths = config.sourceFolders;
196
+ }
197
+ const args = [runnerPath, "--root", ...rootPaths];
198
+ if (opts.maxDepth)
199
+ args.push("--max-depth", opts.maxDepth);
200
+ run("python3", args);
201
+ });
202
+ program
203
+ .command("service")
204
+ .description("Run the autopilot service (foreground mode with loop)")
205
+ .option("--foreground", "Run in foreground mode (log to stdout)")
206
+ .option("--interval-seconds <number>", "Interval between cycles in seconds", "60")
207
+ .option("--root <paths...>", "Root folder(s) that contain git repos (space-separated)")
208
+ .option("--max-depth <number>", "Maximum depth for recursive repo discovery", "5")
209
+ .action((opts) => {
210
+ let rootPaths = opts.root;
211
+ if (!rootPaths || rootPaths.length === 0) {
212
+ const config = loadGlobalConfig();
213
+ if (!config || config.sourceFolders.length === 0) {
214
+ console.error("No source folders configured. Run 'autopilot init' first.");
215
+ process.exit(1);
216
+ }
217
+ rootPaths = config.sourceFolders;
218
+ }
219
+ const args = [runnerPath, "--root", ...rootPaths];
220
+ if (opts.maxDepth)
221
+ args.push("--max-depth", opts.maxDepth);
222
+ if (opts.foreground) {
223
+ const interval = opts.intervalSeconds || "60";
224
+ args.push("--interval-seconds", interval);
225
+ console.log(`Starting autopilot service in foreground mode (interval: ${interval}s)`);
226
+ console.log("Press Ctrl+C to shut down cleanly\n");
227
+ run("python3", args);
228
+ }
229
+ else {
230
+ console.error("Only --foreground mode is supported currently.");
231
+ console.error("For background service mode, use systemd integration (coming soon).");
232
+ process.exit(1);
233
+ }
57
234
  });
58
235
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autopilot-code",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "private": false,
5
5
  "description": "Repo-issue–driven autopilot runner",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import argparse
18
18
  import json
19
19
  import os
20
+ import signal
20
21
  import subprocess
21
22
  import sys
22
23
  import time
@@ -27,12 +28,37 @@ from typing import Any
27
28
  STATE_DIR = ".autopilot"
28
29
  STATE_FILE = "state.json"
29
30
  DEFAULT_HEARTBEAT_MAX_AGE_SECS = 60 * 60 # 1h
31
+ DEFAULT_MAX_DEPTH = 5
32
+ IGNORED_DIRS = {
33
+ "node_modules",
34
+ ".venv",
35
+ "venv",
36
+ ".env",
37
+ "env",
38
+ ".git",
39
+ ".idea",
40
+ ".vscode",
41
+ "dist",
42
+ "build",
43
+ "target",
44
+ "bin",
45
+ "obj",
46
+ "__pycache__",
47
+ }
30
48
 
31
49
 
32
50
  def sh(cmd: list[str], cwd: Path | None = None, check: bool = True) -> str:
33
- p = subprocess.run(cmd, cwd=str(cwd) if cwd else None, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
51
+ p = subprocess.run(
52
+ cmd,
53
+ cwd=str(cwd) if cwd else None,
54
+ text=True,
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.STDOUT,
57
+ )
34
58
  if check and p.returncode != 0:
35
- raise RuntimeError(f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}")
59
+ raise RuntimeError(
60
+ f"command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}"
61
+ )
36
62
  return p.stdout
37
63
 
38
64
 
@@ -49,6 +75,7 @@ class RepoConfig:
49
75
  max_parallel: int
50
76
  heartbeat_max_age_secs: int
51
77
  agent: str
78
+ ignore_issue_labels: list[str]
52
79
 
53
80
 
54
81
  def load_config(repo_root: Path) -> RepoConfig | None:
@@ -72,26 +99,60 @@ def load_config(repo_root: Path) -> RepoConfig | None:
72
99
  label_done=labels.get("done", "autopilot:done"),
73
100
  priority_labels=data.get("priorityLabels", ["p0", "p1", "p2"]),
74
101
  max_parallel=int(data.get("maxParallel", 2)),
75
- heartbeat_max_age_secs=int(data.get("heartbeatMaxAgeSecs", DEFAULT_HEARTBEAT_MAX_AGE_SECS)),
102
+ heartbeat_max_age_secs=int(
103
+ data.get("heartbeatMaxAgeSecs", DEFAULT_HEARTBEAT_MAX_AGE_SECS)
104
+ ),
76
105
  agent=data.get("agent", "none"),
106
+ ignore_issue_labels=list(data.get("ignoreIssueLabels", ["autopilot:backlog"])),
77
107
  )
78
108
 
79
109
 
80
- def discover_repos(root: Path) -> list[RepoConfig]:
110
+ def discover_repos(root: Path, max_depth: int = DEFAULT_MAX_DEPTH) -> list[RepoConfig]:
81
111
  out: list[RepoConfig] = []
82
- for child in root.iterdir():
83
- if not child.is_dir():
84
- continue
85
- if (child / ".git").exists() and (child / ".autopilot" / "autopilot.json").exists():
86
- cfg = load_config(child)
87
- if cfg:
88
- out.append(cfg)
112
+ _discover_repos_recursive(root, 0, max_depth, out)
89
113
  return out
90
114
 
91
115
 
116
+ def _discover_repos_recursive(
117
+ current_dir: Path, current_depth: int, max_depth: int, out: list[RepoConfig]
118
+ ) -> None:
119
+ if current_depth > max_depth:
120
+ return
121
+
122
+ # Check if current directory is a git repo with autopilot config
123
+ if (current_dir / ".git").exists() and (
124
+ current_dir / ".autopilot" / "autopilot.json"
125
+ ).exists():
126
+ cfg = load_config(current_dir)
127
+ if cfg:
128
+ out.append(cfg)
129
+ return
130
+
131
+ # Recursively search subdirectories (depth-first)
132
+ try:
133
+ for child in sorted(current_dir.iterdir(), key=lambda p: p.name.lower()):
134
+ if not child.is_dir():
135
+ continue
136
+ if child.name in IGNORED_DIRS:
137
+ continue
138
+ _discover_repos_recursive(child, current_depth + 1, max_depth, out)
139
+ except PermissionError:
140
+ pass
141
+ except OSError:
142
+ pass
143
+
144
+
92
145
  def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, Any]]:
146
+ """List candidate issues in the queue.
147
+
148
+ We exclude issues containing cfg.ignore_issue_labels (e.g. autopilot:backlog).
149
+ """
150
+
93
151
  # Sort by created date asc; prioritize p0/p1/p2 by label presence.
94
152
  q = " ".join([f"label:{l}" for l in cfg.queue_labels])
153
+ excludes = " ".join([f"-label:{l}" for l in cfg.ignore_issue_labels if l])
154
+ search = f"is:open {q} {excludes}".strip()
155
+
95
156
  cmd = [
96
157
  "gh",
97
158
  "issue",
@@ -101,13 +162,21 @@ def list_candidate_issues(cfg: RepoConfig, limit: int = 10) -> list[dict[str, An
101
162
  "--limit",
102
163
  str(limit),
103
164
  "--search",
104
- f"is:open {q}",
165
+ search,
105
166
  "--json",
106
167
  "number,title,labels,updatedAt,createdAt,url",
107
168
  ]
108
169
  raw = sh(cmd)
109
170
  issues = json.loads(raw)
110
171
 
172
+ # Defense in depth in case GitHub search syntax changes.
173
+ ignore_set = set(cfg.ignore_issue_labels)
174
+ issues = [
175
+ it
176
+ for it in issues
177
+ if not ({l["name"] for l in it.get("labels", [])} & ignore_set)
178
+ ]
179
+
111
180
  def pri_rank(it: dict[str, Any]) -> int:
112
181
  labs = {l["name"] for l in it.get("labels", [])}
113
182
  for i, p in enumerate(cfg.priority_labels):
@@ -234,25 +303,22 @@ def maybe_mark_blocked(cfg: RepoConfig, issue: dict[str, Any]) -> None:
234
303
  )
235
304
 
236
305
 
237
- def main() -> int:
238
- ap = argparse.ArgumentParser()
239
- ap.add_argument("--root", default="/mnt/f/Source")
240
- ap.add_argument("--max-parallel", type=int, default=2)
241
- ap.add_argument("--dry-run", action="store_true")
242
- args = ap.parse_args()
306
+ def run_cycle(
307
+ all_configs: list[RepoConfig],
308
+ dry_run: bool = False,
309
+ ) -> int:
310
+ """Run a single scan/claim/act cycle.
243
311
 
244
- root = Path(args.root)
245
- configs = discover_repos(root)
246
- if not configs:
247
- print("No autopilot-enabled repos found.")
248
- return 0
312
+ Returns the number of issues claimed.
313
+ """
314
+ claimed_count = 0
249
315
 
250
- for cfg in configs:
316
+ for cfg in all_configs:
251
317
  # 1) First, check any in-progress issues and mark blocked if stale.
252
318
  inprog = list_in_progress_issues(cfg)
253
319
  for it in inprog:
254
320
  # This is conservative: only mark blocked if we have no fresh local heartbeat.
255
- if not args.dry_run:
321
+ if not dry_run:
256
322
  maybe_mark_blocked(cfg, it)
257
323
 
258
324
  # 2) If nothing is currently in progress, claim one from the queue.
@@ -272,9 +338,10 @@ def main() -> int:
272
338
  "the runner does not inspect processes.)"
273
339
  )
274
340
  print(f"[{cfg.repo}] next issue: #{issue['number']} {issue['title']}")
275
- if args.dry_run:
341
+ if dry_run:
276
342
  continue
277
343
  claim_issue(cfg, issue, msg)
344
+ claimed_count += 1
278
345
 
279
346
  # If agent==opencode, delegate to bash script
280
347
  if cfg.agent == "opencode":
@@ -286,6 +353,94 @@ def main() -> int:
286
353
  ]
287
354
  )
288
355
 
356
+ return claimed_count
357
+
358
+
359
+ def main() -> int:
360
+ ap = argparse.ArgumentParser()
361
+ ap.add_argument(
362
+ "--root",
363
+ nargs="+",
364
+ default=["/mnt/f/Source"],
365
+ help="One or more root folders to scan for git repos (space-separated)",
366
+ )
367
+ ap.add_argument("--max-parallel", type=int, default=2)
368
+ ap.add_argument(
369
+ "--max-depth",
370
+ type=int,
371
+ default=DEFAULT_MAX_DEPTH,
372
+ help=f"Maximum depth for recursive repo discovery (default: {DEFAULT_MAX_DEPTH})",
373
+ )
374
+ ap.add_argument("--dry-run", action="store_true")
375
+ ap.add_argument(
376
+ "--interval-seconds",
377
+ type=int,
378
+ default=None,
379
+ help="Run in loop mode with this interval between cycles (for foreground service mode)",
380
+ )
381
+ args = ap.parse_args()
382
+
383
+ all_configs: list[RepoConfig] = []
384
+ for root_str in args.root:
385
+ root = Path(root_str).resolve()
386
+ if not root.exists():
387
+ print(f"Warning: root path {root} does not exist, skipping")
388
+ continue
389
+ if not root.is_dir():
390
+ print(f"Warning: root path {root} is not a directory, skipping")
391
+ continue
392
+ configs = discover_repos(root, max_depth=args.max_depth)
393
+ all_configs.extend(configs)
394
+
395
+ if not all_configs:
396
+ print("No autopilot-enabled repos found.")
397
+ return 0
398
+
399
+ print(f"Found {len(all_configs)} autopilot-enabled repo(s)")
400
+ for cfg in all_configs:
401
+ print(f" - {cfg.repo} at {cfg.root}")
402
+
403
+ # Loop mode (for foreground service)
404
+ if args.interval_seconds is not None:
405
+ shutdown_requested = False
406
+
407
+ def signal_handler(signum: int, frame: Any) -> None:
408
+ nonlocal shutdown_requested
409
+ print("\nShutdown requested (Ctrl+C), finishing current cycle...")
410
+ shutdown_requested = True
411
+
412
+ signal.signal(signal.SIGINT, signal_handler)
413
+ signal.signal(signal.SIGTERM, signal_handler)
414
+
415
+ print(f"Running in foreground mode with {args.interval_seconds}s interval")
416
+ print("Press Ctrl+C to shut down cleanly\n")
417
+
418
+ cycle_num = 0
419
+ while not shutdown_requested:
420
+ cycle_num += 1
421
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z")
422
+ print(f"\n=== Cycle {cycle_num} at {timestamp} ===")
423
+
424
+ try:
425
+ claimed = run_cycle(all_configs, dry_run=args.dry_run)
426
+ if claimed == 0:
427
+ print("No issues claimed in this cycle")
428
+ else:
429
+ print(f"Claimed {claimed} issue(s) in this cycle")
430
+ except Exception as e:
431
+ print(f"Error in cycle {cycle_num}: {e}")
432
+
433
+ if shutdown_requested:
434
+ break
435
+
436
+ print(f"Sleeping for {args.interval_seconds}s...")
437
+ time.sleep(args.interval_seconds)
438
+
439
+ print("\nShutdown complete")
440
+ return 0
441
+
442
+ # Single-shot mode (run-once)
443
+ run_cycle(all_configs, dry_run=args.dry_run)
289
444
  return 0
290
445
 
291
446
 
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "priorityLabels": ["p0", "p1", "p2"],
11
11
  "maxParallel": 2,
12
+ "ignoreIssueLabels": ["autopilot:backlog"],
12
13
  "branchPrefix": "autopilot/",
13
14
  "allowedBaseBranch": "main"
14
15
  }