@elvis1513/auto-coding-skill 0.2.0 → 1.0.0

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.
@@ -5,14 +5,23 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  import argparse
8
+ import base64
8
9
  import datetime as _dt
10
+ import json
11
+ import os
9
12
  import time
13
+ import urllib.parse
14
+ import urllib.error
15
+ import urllib.request
10
16
  from pathlib import Path
11
17
  from typing import Optional, List
12
18
 
13
19
  from core import APError, ensure_git_repo, copy_tree, run, load_yaml, find_config, run_shell, http_get_status
14
20
 
15
21
 
22
+ _JENKINS_CRUMB_CACHE: dict[str, dict[str, str]] = {}
23
+
24
+
16
25
  def _skill_root() -> Path:
17
26
  return Path(__file__).resolve().parent.parent
18
27
 
@@ -49,6 +58,336 @@ def _run_configured_command(repo: Path, cfg: dict, name: str) -> bool:
49
58
  return True
50
59
 
51
60
 
61
+ def _jenkins_basic_auth_headers(cfg: dict) -> dict:
62
+ jenkins_cfg = (cfg.get("jenkins") or {})
63
+ direct_user = str(jenkins_cfg.get("api_user") or jenkins_cfg.get("ui_username") or "").strip()
64
+ direct_token = str(jenkins_cfg.get("api_token") or "").strip()
65
+ direct_password = str(jenkins_cfg.get("api_password") or jenkins_cfg.get("ui_password") or "").strip()
66
+ user_env = str(jenkins_cfg.get("api_user_env") or "JENKINS_USER").strip() or "JENKINS_USER"
67
+ token_env = str(jenkins_cfg.get("api_token_env") or "JENKINS_TOKEN").strip() or "JENKINS_TOKEN"
68
+ password_env = str(jenkins_cfg.get("api_password_env") or "JENKINS_PASSWORD").strip() or "JENKINS_PASSWORD"
69
+ user = direct_user or os.getenv(user_env) or os.getenv("JENKINS_USER")
70
+ token = (
71
+ direct_token
72
+ or direct_password
73
+ or os.getenv(token_env)
74
+ or os.getenv(password_env)
75
+ or os.getenv("JENKINS_TOKEN")
76
+ or os.getenv("JENKINS_PASSWORD")
77
+ )
78
+ if not user or not token:
79
+ raise APError(
80
+ "Missing Jenkins API credentials. Fill jenkins.api_user / jenkins.api_token / jenkins.api_password "
81
+ f"in docs/ENGINEERING.md, or set env vars {user_env}, {token_env}, {password_env}."
82
+ )
83
+ raw = f"{user}:{token}".encode("utf-8")
84
+ auth = base64.b64encode(raw).decode("ascii")
85
+ return {"Authorization": f"Basic {auth}"}
86
+
87
+
88
+ def _http_error_body(exc: urllib.error.HTTPError) -> str:
89
+ try:
90
+ return exc.read().decode("utf-8", errors="replace").strip()
91
+ except Exception:
92
+ return ""
93
+
94
+
95
+ def _http_get(url: str, headers: Optional[dict[str, str]] = None, timeout_s: int = 10) -> tuple[int, str]:
96
+ req = urllib.request.Request(url, headers=headers or {}, method="GET")
97
+ try:
98
+ with urllib.request.urlopen(req, timeout=timeout_s) as response:
99
+ return response.status, response.read().decode("utf-8", errors="replace")
100
+ except urllib.error.HTTPError as exc:
101
+ return exc.code, exc.read().decode("utf-8", errors="replace")
102
+
103
+
104
+ def _basic_auth_header(username: str, password: str) -> dict[str, str]:
105
+ raw = f"{username}:{password}".encode("utf-8")
106
+ auth = base64.b64encode(raw).decode("ascii")
107
+ return {"Authorization": f"Basic {auth}"}
108
+
109
+
110
+ def _jenkins_root_url(cfg: dict, job_url: str = "") -> str:
111
+ jenkins_cfg = (cfg.get("jenkins") or {})
112
+ base_url = str(jenkins_cfg.get("base_url") or "").strip().rstrip("/")
113
+ if base_url:
114
+ return base_url
115
+
116
+ source = str(job_url or jenkins_cfg.get("job_url") or "").strip().rstrip("/")
117
+ if not source:
118
+ return ""
119
+ if "/job/" in source:
120
+ return source.split("/job/", 1)[0].rstrip("/")
121
+ return source
122
+
123
+
124
+ def _jenkins_crumb_api_url(cfg: dict, job_url: str = "") -> str:
125
+ jenkins_cfg = (cfg.get("jenkins") or {})
126
+ explicit = str(jenkins_cfg.get("crumb_url") or "").strip()
127
+ if explicit:
128
+ return explicit
129
+ root = _jenkins_root_url(cfg, job_url=job_url)
130
+ if not root:
131
+ return ""
132
+ return root.rstrip("/") + "/crumbIssuer/api/json"
133
+
134
+
135
+ def _jenkins_crumb_headers(cfg: dict, job_url: str = "", timeout_s: int = 15) -> dict:
136
+ crumb_url = _jenkins_crumb_api_url(cfg, job_url=job_url)
137
+ if not crumb_url:
138
+ return {}
139
+ cached = _JENKINS_CRUMB_CACHE.get(crumb_url)
140
+ if cached:
141
+ return dict(cached)
142
+
143
+ headers = {"Accept": "application/json"}
144
+ headers.update(_jenkins_basic_auth_headers(cfg))
145
+ req = urllib.request.Request(crumb_url, headers=headers, method="GET")
146
+ try:
147
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
148
+ data = resp.read().decode("utf-8")
149
+ except urllib.error.HTTPError as exc:
150
+ if exc.code == 404:
151
+ return {}
152
+ body = _http_error_body(exc)
153
+ raise APError(
154
+ f"Jenkins crumb request failed: {crumb_url}\n"
155
+ f"HTTP {exc.code}\n{body or '(empty response body)'}"
156
+ ) from exc
157
+ except Exception as exc:
158
+ raise APError(f"Jenkins crumb request failed: {crumb_url}\n{exc}") from exc
159
+
160
+ try:
161
+ payload = json.loads(data)
162
+ except json.JSONDecodeError as exc:
163
+ raise APError(f"Jenkins crumb endpoint returned non-JSON response: {crumb_url}\n{exc}") from exc
164
+
165
+ field = str(payload.get("crumbRequestField") or "").strip()
166
+ crumb = str(payload.get("crumb") or "").strip()
167
+ if not field or not crumb:
168
+ return {}
169
+
170
+ crumb_headers = {field: crumb}
171
+ _JENKINS_CRUMB_CACHE[crumb_url] = crumb_headers
172
+ return dict(crumb_headers)
173
+
174
+
175
+ def _jenkins_api_get_json(url: str, cfg: dict, timeout_s: int = 15, allow_404: bool = False) -> Optional[dict]:
176
+ headers = {"Accept": "application/json"}
177
+ headers.update(_jenkins_basic_auth_headers(cfg))
178
+ req = urllib.request.Request(url, headers=headers, method="GET")
179
+ try:
180
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
181
+ data = resp.read().decode("utf-8")
182
+ except urllib.error.HTTPError as exc:
183
+ body = _http_error_body(exc)
184
+ if exc.code == 404 and allow_404:
185
+ return None
186
+ if exc.code == 403:
187
+ crumb_headers = _jenkins_crumb_headers(cfg, job_url=url, timeout_s=timeout_s)
188
+ if crumb_headers:
189
+ retry_headers = dict(headers)
190
+ retry_headers.update(crumb_headers)
191
+ retry_req = urllib.request.Request(url, headers=retry_headers, method="GET")
192
+ try:
193
+ with urllib.request.urlopen(retry_req, timeout=timeout_s) as resp:
194
+ data = resp.read().decode("utf-8")
195
+ except urllib.error.HTTPError as retry_exc:
196
+ if retry_exc.code == 404 and allow_404:
197
+ return None
198
+ retry_body = _http_error_body(retry_exc)
199
+ raise APError(
200
+ f"Jenkins API request failed after crumb retry: {url}\n"
201
+ f"HTTP {retry_exc.code}\n{retry_body or '(empty response body)'}"
202
+ ) from retry_exc
203
+ except Exception as retry_exc:
204
+ raise APError(f"Jenkins API request failed after crumb retry: {url}\n{retry_exc}") from retry_exc
205
+ else:
206
+ raise APError(
207
+ f"Jenkins API request failed: {url}\n"
208
+ f"HTTP 403\n{body or '(empty response body)'}\n"
209
+ "Jenkins may require crumb/CSRF handling, but no crumb issuer endpoint was available. "
210
+ "Fill jenkins.base_url or jenkins.crumb_url in docs/ENGINEERING.md if needed."
211
+ ) from exc
212
+ else:
213
+ raise APError(
214
+ f"Jenkins API request failed: {url}\n"
215
+ f"HTTP {exc.code}\n{body or '(empty response body)'}"
216
+ ) from exc
217
+ except Exception as exc:
218
+ raise APError(f"Jenkins API request failed: {url}\n{exc}") from exc
219
+ try:
220
+ return json.loads(data)
221
+ except json.JSONDecodeError as exc:
222
+ raise APError(f"Jenkins API returned non-JSON response: {url}\n{exc}") from exc
223
+
224
+
225
+ def _resolve_git_short_sha(repo: Path, ref: str) -> str:
226
+ result = run(["git", "rev-parse", "--short=12", ref], cwd=repo, check=False)
227
+ value = result.stdout.strip()
228
+ if value:
229
+ return value
230
+ return ref.strip()
231
+
232
+
233
+ def _resolve_git_branch_name(repo: Path, ref: str) -> str:
234
+ result = run(["git", "rev-parse", "--abbrev-ref", ref], cwd=repo, check=False)
235
+ value = result.stdout.strip()
236
+ if value and value != "HEAD":
237
+ return value
238
+ return ""
239
+
240
+
241
+ def _jenkins_builds_api_url(job_url: str, max_builds: int) -> str:
242
+ base = str(job_url or "").strip().rstrip("/")
243
+ if not base:
244
+ raise APError("Missing jenkins.job_url in docs/ENGINEERING.md")
245
+ tree = f"builds[number,result,building,description,url]{{0,{max_builds}}}"
246
+ return f"{base}/api/json?tree={urllib.parse.quote(tree, safe='=,')}"
247
+
248
+
249
+ def _jenkins_job_path(job_name: str) -> str:
250
+ parts = [p.strip() for p in str(job_name or "").split("/") if p.strip()]
251
+ if not parts:
252
+ raise APError("Jenkins job name is empty. Pass --job-name or fill jenkins.job_name.")
253
+ return "/".join(f"job/{urllib.parse.quote(part, safe='')}" for part in parts)
254
+
255
+
256
+ def _jenkins_job_url_from_name(base_url: str, job_name: str) -> str:
257
+ base = str(base_url or "").strip().rstrip("/")
258
+ if not base:
259
+ raise APError("Missing Jenkins base URL. Fill jenkins.base_url or pass --job-url.")
260
+ return f"{base}/{_jenkins_job_path(job_name)}"
261
+
262
+
263
+ def _jenkins_branch_job_candidates(branch_name: str) -> List[str]:
264
+ raw = str(branch_name or "").strip()
265
+ if not raw:
266
+ return []
267
+
268
+ candidates: List[str] = []
269
+
270
+ def add(value: str) -> None:
271
+ if value and value not in candidates:
272
+ candidates.append(value)
273
+
274
+ if "/" not in raw:
275
+ add(urllib.parse.quote(raw, safe=""))
276
+ single = urllib.parse.quote(raw, safe="")
277
+ double = urllib.parse.quote(single, safe="")
278
+ add(single)
279
+ add(double)
280
+ return candidates
281
+
282
+
283
+ def _jenkins_branch_job_urls(root_job_url: str, branch_name: str) -> List[str]:
284
+ base = str(root_job_url or "").strip().rstrip("/")
285
+ if not base:
286
+ raise APError("Missing Jenkins multibranch root job URL.")
287
+ urls: List[str] = []
288
+ for candidate in _jenkins_branch_job_candidates(branch_name):
289
+ url = f"{base}/job/{candidate}"
290
+ if url not in urls:
291
+ urls.append(url)
292
+ return urls
293
+
294
+
295
+ def _resolve_jenkins_job_url(cfg: dict, job_name: str = "", job_url: str = "") -> str:
296
+ jenkins_cfg = (cfg.get("jenkins") or {})
297
+ explicit_url = str(job_url or "").strip()
298
+ requested_name = str(job_name or "").strip()
299
+ configured_name = str(jenkins_cfg.get("job_name") or "").strip()
300
+ configured_url = str(jenkins_cfg.get("job_url") or "").strip()
301
+ base_url = str(jenkins_cfg.get("base_url") or "").strip()
302
+
303
+ if explicit_url:
304
+ return explicit_url.rstrip("/")
305
+ if requested_name:
306
+ if configured_name and requested_name == configured_name and configured_url:
307
+ return configured_url.rstrip("/")
308
+ if base_url:
309
+ return _jenkins_job_url_from_name(base_url, requested_name)
310
+ raise APError(
311
+ f"Cannot resolve Jenkins job URL for job '{requested_name}'. "
312
+ "Pass --job-url, or fill jenkins.base_url in docs/ENGINEERING.md."
313
+ )
314
+ if configured_url:
315
+ return configured_url.rstrip("/")
316
+ if configured_name and base_url:
317
+ return _jenkins_job_url_from_name(base_url, configured_name)
318
+ raise APError(
319
+ "Missing Jenkins job location. Fill jenkins.job_url, or fill both "
320
+ "jenkins.base_url and jenkins.job_name in docs/ENGINEERING.md."
321
+ )
322
+
323
+
324
+ def _resolve_jenkins_job_candidates(
325
+ cfg: dict,
326
+ repo: Path,
327
+ git_ref: str = "",
328
+ job_name: str = "",
329
+ job_url: str = "",
330
+ multibranch_root_job: str = "",
331
+ branch_name: str = "",
332
+ ) -> List[str]:
333
+ jenkins_cfg = (cfg.get("jenkins") or {})
334
+ effective_branch = str(branch_name or jenkins_cfg.get("branch_name") or "").strip()
335
+ if not effective_branch:
336
+ inferred_branch = _resolve_git_branch_name(repo, git_ref or "HEAD")
337
+ if inferred_branch:
338
+ effective_branch = inferred_branch
339
+
340
+ effective_root = str(multibranch_root_job or jenkins_cfg.get("multibranch_root_job") or "").strip()
341
+ explicit_url = str(job_url or "").strip()
342
+ explicit_name = str(job_name or "").strip()
343
+ configured_url = str(jenkins_cfg.get("job_url") or "").strip()
344
+ configured_name = str(jenkins_cfg.get("job_name") or "").strip()
345
+
346
+ if effective_branch:
347
+ if explicit_url:
348
+ return _jenkins_branch_job_urls(explicit_url, effective_branch)
349
+ if effective_root:
350
+ base_url = str(jenkins_cfg.get("base_url") or "").strip()
351
+ return _jenkins_branch_job_urls(_jenkins_job_url_from_name(base_url, effective_root), effective_branch)
352
+ if explicit_name:
353
+ base_url = str(jenkins_cfg.get("base_url") or "").strip()
354
+ return _jenkins_branch_job_urls(_jenkins_job_url_from_name(base_url, explicit_name), effective_branch)
355
+ if configured_url:
356
+ return _jenkins_branch_job_urls(configured_url, effective_branch)
357
+ if configured_name:
358
+ base_url = str(jenkins_cfg.get("base_url") or "").strip()
359
+ return _jenkins_branch_job_urls(_jenkins_job_url_from_name(base_url, configured_name), effective_branch)
360
+ raise APError(
361
+ "Missing Jenkins multibranch root job location. Fill jenkins.multibranch_root_job + jenkins.base_url, "
362
+ "or pass --job-url / --job-name together with --branch-name."
363
+ )
364
+
365
+ return [_resolve_jenkins_job_url(cfg, job_name=job_name, job_url=job_url)]
366
+
367
+
368
+ def _jenkins_build_api_url(job_url: str, build_number: int) -> str:
369
+ base = str(job_url or "").strip().rstrip("/")
370
+ if not base:
371
+ raise APError("Missing Jenkins job URL.")
372
+ if build_number <= 0:
373
+ raise APError("Build number must be a positive integer.")
374
+ tree = "number,result,building,description,url"
375
+ return f"{base}/{build_number}/api/json?tree={urllib.parse.quote(tree, safe='=,')}"
376
+
377
+
378
+ def _assert_jenkins_build_success(build: dict, identifier: str, allow_no_deploy: bool) -> tuple[str, str]:
379
+ if build.get("building"):
380
+ raise APError(f"Jenkins build is still running: {identifier}")
381
+
382
+ result = str(build.get("result") or "").strip().upper()
383
+ description = str(build.get("description") or "").strip()
384
+ if result != "SUCCESS":
385
+ raise APError(f"Jenkins build did not succeed: {identifier} result={result or '(empty)'}")
386
+ if not allow_no_deploy and description.startswith("no-deploy:"):
387
+ raise APError(f"Jenkins build succeeded but did not deploy: {identifier} {description}")
388
+ return result, description
389
+
390
+
52
391
  def cmd_install(args: argparse.Namespace) -> None:
53
392
  repo = Path(args.repo).resolve()
54
393
  templates = _skill_root() / "data" / "templates"
@@ -59,10 +398,11 @@ def cmd_install(args: argparse.Namespace) -> None:
59
398
  if args.bridges:
60
399
  copy_tree(templates / "bridges", repo)
61
400
 
62
- scripts_dir = repo / "scripts" / "autopipeline"
63
- scripts_dir.mkdir(parents=True, exist_ok=True)
64
- copy_tree(Path(__file__).resolve(), scripts_dir / "ap.py")
65
- copy_tree(Path(__file__).resolve().parent / "core.py", scripts_dir / "core.py")
401
+ tools_dir = repo / "docs" / "tools" / "autopipeline"
402
+ tools_dir.mkdir(parents=True, exist_ok=True)
403
+ copy_tree(Path(__file__).resolve(), tools_dir / "ap.py")
404
+ copy_tree(Path(__file__).resolve().parent / "core.py", tools_dir / "core.py")
405
+ copy_tree(Path(__file__).resolve().parent / "http_checks.py", tools_dir / "http_checks.py")
66
406
 
67
407
  gi = repo / ".gitignore"
68
408
  secret_line = "docs/ENGINEERING.md"
@@ -74,7 +414,7 @@ def cmd_install(args: argparse.Namespace) -> None:
74
414
  gi.write_text(secret_line + "\n", encoding="utf-8")
75
415
 
76
416
  print(f"[install] OK: scaffold installed into {repo}")
77
- print("[install] Next: edit docs/ENGINEERING.md frontmatter and fill project/runtime/jenkins fields")
417
+ print("[install] Next: edit docs/ENGINEERING.md frontmatter and fill project/commands/target_env/jenkins fields")
78
418
 
79
419
 
80
420
  def _infer_title(taskbook: Path, task_id: str) -> str:
@@ -105,7 +445,7 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
105
445
  if out_file.exists() and not args.force:
106
446
  raise APError(f"Summary already exists: {out_file} (use --force to overwrite)")
107
447
 
108
- title = _infer_title(taskbook, task_id)
448
+ title = str(args.title or "").strip() or _infer_title(taskbook, task_id)
109
449
  date = _dt.date.today().isoformat()
110
450
 
111
451
  staged = run(["git", "diff", "--cached", "--name-only"], cwd=repo, check=False).stdout.strip()
@@ -114,6 +454,8 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
114
454
 
115
455
  content = f"""# Task Summary — {task_id} — {title}
116
456
 
457
+ > 仅用于高风险、跨模块、阶段性里程碑、需要完整复盘的任务。
458
+
117
459
  - Task ID:{task_id}
118
460
  - Date:{date}
119
461
  - Scope(本次范围):TODO
@@ -125,7 +467,7 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
125
467
  - 目标:TODO
126
468
  - 验收结论:PASS / FAIL — TODO
127
469
 
128
- ## 2. 变更概览(代码/配置/本地运行/Jenkins)
470
+ ## 2. 变更概览
129
471
  ### Git change snapshot
130
472
  - Staged files:
131
473
  {('- ' + staged.replace('\n','\n- ')) if staged else '- (none)'}
@@ -139,17 +481,19 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
139
481
  ## 3. 接口变更(以 API Markdown 为准)
140
482
  - 变更记录位置:`{api_change_log}`
141
483
 
142
- ## 5. 质量门禁证据(必须可追溯)
143
- - 本地CI:TODO
144
- - 静态分析:TODO
145
- - Review 文档:TODO
146
- - DD 文档:TODO
147
- - Jenkins 准备:TODO
148
- - 回归矩阵:`{regression_matrix}`(全量 PASS,0 fail)
149
-
150
- ## 6. 本地运行与 Jenkins 部署记录
151
- - Local compose:TODO
152
- - Jenkins build / deploy:TODO
484
+ ## 4. 质量证据
485
+ - 本地轻量校验:build / test or quick_test / lint / typecheck / api docs / jenkinsfile / diff-check — TODO
486
+ - Jenkins Build:TODO
487
+ - 目标环境验证:TODO
488
+ - 闭环记录:TODO
489
+ - 回归矩阵(如有):`{regression_matrix}`
490
+
491
+ ## 5. 风险与回滚
492
+ - 风险:TODO
493
+ - 回滚:TODO
494
+
495
+ ## 6. 后续行动
496
+ - TODO:TODO
153
497
  """
154
498
 
155
499
  out_file.write_text(content, encoding="utf-8")
@@ -166,6 +510,28 @@ def cmd_check_matrix(args: argparse.Namespace) -> None:
166
510
 
167
511
  rows = 0
168
512
  fail = []
513
+
514
+ def evidence_missing(value: str) -> bool:
515
+ stripped = value.strip()
516
+ lower = stripped.lower()
517
+ if not stripped:
518
+ return True
519
+ if stripped.startswith("<") and stripped.endswith(">"):
520
+ return True
521
+ placeholder_tokens = [
522
+ "todo",
523
+ "tbd",
524
+ "pending",
525
+ "replace-with",
526
+ "paste log path",
527
+ "paste evidence",
528
+ "fill-with",
529
+ "待补",
530
+ "待填",
531
+ "占位",
532
+ ]
533
+ return any(token in lower for token in placeholder_tokens)
534
+
169
535
  for line in matrix.read_text(encoding="utf-8").splitlines():
170
536
  s = line.strip()
171
537
  if not s.startswith("|"):
@@ -182,6 +548,10 @@ def cmd_check_matrix(args: argparse.Namespace) -> None:
182
548
  rows += 1
183
549
  if status != "PASS":
184
550
  fail.append((rid, status or "(empty)"))
551
+ continue
552
+ evidence = cols[7] if len(cols) > 7 else ""
553
+ if evidence_missing(evidence):
554
+ fail.append((rid, "PASS-without-evidence"))
185
555
 
186
556
  if rows == 0:
187
557
  raise APError(f"No regression rows found in matrix: {matrix}")
@@ -198,6 +568,26 @@ def _load_cfg(repo: Path) -> dict:
198
568
  return load_yaml(cfg_path)
199
569
 
200
570
 
571
+ def _pair_state(left: str, right: str) -> str:
572
+ if left and right:
573
+ return "complete"
574
+ if not left and not right:
575
+ return "empty"
576
+ return "partial"
577
+
578
+
579
+ def _run_git_diff_check(repo: Path, cfg: dict) -> None:
580
+ commands = (cfg.get("commands") or {})
581
+ configured = str(commands.get("diff_check") or "").strip()
582
+ if configured:
583
+ print(f"[diff-check] {configured}")
584
+ run_shell(configured, cwd=repo)
585
+ else:
586
+ print("[diff-check] git diff --check")
587
+ run(["git", "diff", "--check"], cwd=repo)
588
+ print("[diff-check] OK")
589
+
590
+
201
591
  def cmd_run(args: argparse.Namespace) -> None:
202
592
  """
203
593
  Run any configured gate command by name.
@@ -219,6 +609,61 @@ def cmd_run(args: argparse.Namespace) -> None:
219
609
  print(f"[run] OK: {name}")
220
610
 
221
611
 
612
+ def cmd_light_gate(args: argparse.Namespace) -> None:
613
+ repo = Path(args.repo).resolve()
614
+ cfg = _load_cfg(repo)
615
+ commands = (cfg.get("commands") or {})
616
+
617
+ executed: List[str] = []
618
+ missing: List[str] = []
619
+
620
+ if not str(commands.get("build") or "").strip():
621
+ missing.append("commands.build")
622
+ else:
623
+ _run_configured_command(repo, cfg, "build")
624
+ executed.append("build")
625
+
626
+ if str(commands.get("quick_test") or "").strip():
627
+ _run_configured_command(repo, cfg, "quick_test")
628
+ executed.append("quick_test")
629
+ elif str(commands.get("test") or "").strip():
630
+ _run_configured_command(repo, cfg, "test")
631
+ executed.append("test")
632
+ else:
633
+ missing.append("commands.quick_test or commands.test")
634
+
635
+ static_executed = False
636
+ if str(commands.get("lint") or "").strip():
637
+ _run_configured_command(repo, cfg, "lint")
638
+ executed.append("lint")
639
+ static_executed = True
640
+
641
+ if str(commands.get("typecheck") or "").strip():
642
+ _run_configured_command(repo, cfg, "typecheck")
643
+ executed.append("typecheck")
644
+ static_executed = True
645
+
646
+ if not static_executed:
647
+ missing.append("commands.lint or commands.typecheck")
648
+
649
+ if str(commands.get("script_syntax") or "").strip():
650
+ _run_configured_command(repo, cfg, "script_syntax")
651
+ executed.append("script_syntax")
652
+
653
+ if missing:
654
+ raise APError(
655
+ "Light gate is under-configured. Missing required commands: "
656
+ + ", ".join(missing)
657
+ + ". Edit docs/ENGINEERING.md frontmatter."
658
+ )
659
+
660
+ _run_git_diff_check(repo, cfg)
661
+ cmd_verify_api_docs(argparse.Namespace(repo=str(repo)))
662
+ cmd_verify_jenkins(argparse.Namespace(repo=str(repo)))
663
+ executed.extend(["diff_check", "verify_api_docs", "verify_jenkins"])
664
+ print("[light-gate] OK: " + ", ".join(executed))
665
+
666
+
222
667
  def cmd_runtime_up(args: argparse.Namespace) -> None:
223
668
  repo = Path(args.repo).resolve()
224
669
  cfg = _load_cfg(repo)
@@ -255,10 +700,13 @@ def cmd_wait_health(args: argparse.Namespace) -> None:
255
700
  url = _join_url(str(runtime_cfg.get("health_base_url") or ""), str(runtime_cfg.get("health_path") or ""))
256
701
  timeout_s = int(runtime_cfg.get("startup_timeout_sec") or 120)
257
702
  else:
703
+ target_cfg = (cfg.get("target_env") or {})
258
704
  jenkins_cfg = (cfg.get("jenkins") or {})
705
+ base_url = str(target_cfg.get("health_base_url") or "")
706
+ path = str(target_cfg.get("health_path") or "")
259
707
  url = _join_url(
260
- str(jenkins_cfg.get("prod_health_base_url") or ""),
261
- str(jenkins_cfg.get("prod_health_path") or "")
708
+ base_url,
709
+ path,
262
710
  )
263
711
  timeout_s = int(jenkins_cfg.get("deploy_timeout_sec") or 1800)
264
712
 
@@ -277,31 +725,282 @@ def cmd_wait_health(args: argparse.Namespace) -> None:
277
725
  raise APError(f"Health check timeout for {scope}: {url}\nLast result: {last_error}")
278
726
 
279
727
 
728
+ def cmd_verify_target(args: argparse.Namespace) -> None:
729
+ repo = Path(args.repo).resolve()
730
+ cfg = _load_cfg(repo)
731
+ target_cfg = (cfg.get("target_env") or {})
732
+
733
+ cmd_wait_health(argparse.Namespace(repo=str(repo), scope="target"))
734
+
735
+ checks: List[str] = []
736
+
737
+ backend_base = str(target_cfg.get("backend_base_url") or "").strip().rstrip("/")
738
+ frontend_base = str(target_cfg.get("frontend_base_url") or "").strip().rstrip("/")
739
+
740
+ backend_headers: dict[str, str] = {}
741
+ frontend_headers: dict[str, str] = {}
742
+ if args.backend_basic_auth:
743
+ user = str(target_cfg.get("backend_username") or "").strip()
744
+ password = str(target_cfg.get("backend_password") or "").strip()
745
+ if not user or not password:
746
+ raise APError("Missing target_env.backend_username / target_env.backend_password for backend basic auth.")
747
+ backend_headers = _basic_auth_header(user, password)
748
+ if args.frontend_basic_auth:
749
+ user = str(target_cfg.get("frontend_username") or "").strip()
750
+ password = str(target_cfg.get("frontend_password") or "").strip()
751
+ if not user or not password:
752
+ raise APError("Missing target_env.frontend_username / target_env.frontend_password for frontend basic auth.")
753
+ frontend_headers = _basic_auth_header(user, password)
754
+
755
+ for path in args.backend_path or []:
756
+ if not backend_base:
757
+ raise APError("Missing target_env.backend_base_url for backend path verification.")
758
+ url = _join_url(backend_base, path)
759
+ status, body = _http_get(url, headers=backend_headers, timeout_s=10)
760
+ if not (200 <= status < 400):
761
+ raise APError(f"Backend target verification failed: {url} -> {status}\n{body[:400]}")
762
+ checks.append(f"backend:{url}->{status}")
763
+
764
+ for path in args.frontend_path or []:
765
+ if not frontend_base:
766
+ raise APError("Missing target_env.frontend_base_url for frontend path verification.")
767
+ url = _join_url(frontend_base, path)
768
+ status, body = _http_get(url, headers=frontend_headers, timeout_s=10)
769
+ if not (200 <= status < 400):
770
+ raise APError(f"Frontend target verification failed: {url} -> {status}\n{body[:400]}")
771
+ checks.append(f"frontend:{url}->{status}")
772
+
773
+ note = str(target_cfg.get("verify_notes") or "").strip()
774
+ summary = ", ".join(checks) if checks else "health-only"
775
+ if note:
776
+ summary = summary + f" | notes={note}"
777
+ print(f"[verify-target] OK: {summary}")
778
+
779
+
280
780
  def cmd_verify_jenkins(args: argparse.Namespace) -> None:
281
781
  repo = Path(args.repo).resolve()
282
782
  cfg = _load_cfg(repo)
283
783
  project_cfg = (cfg.get("project") or {})
284
784
  jenkins_cfg = (cfg.get("jenkins") or {})
785
+ target_cfg = (cfg.get("target_env") or {})
285
786
  jenkinsfile = Path(repo, str(project_cfg.get("jenkinsfile") or "Jenkinsfile"))
286
787
  if not jenkinsfile.exists():
287
788
  raise APError(f"Jenkinsfile not found: {jenkinsfile}")
288
789
 
289
790
  required = [
290
- ("jenkins.job_name", jenkins_cfg.get("job_name")),
291
- ("jenkins.job_url", jenkins_cfg.get("job_url")),
292
791
  ("jenkins.trigger_branch", jenkins_cfg.get("trigger_branch")),
293
792
  ("jenkins.image_repository", jenkins_cfg.get("image_repository")),
294
793
  ("jenkins.image_tag_strategy", jenkins_cfg.get("image_tag_strategy")),
295
794
  ("jenkins.deploy_env", jenkins_cfg.get("deploy_env")),
296
- ("jenkins.prod_health_base_url", jenkins_cfg.get("prod_health_base_url")),
297
- ("jenkins.prod_health_path", jenkins_cfg.get("prod_health_path")),
795
+ ("target_env.health_base_url", target_cfg.get("health_base_url")),
796
+ ("target_env.health_path", target_cfg.get("health_path")),
298
797
  ]
299
798
  missing = [name for name, value in required if not str(value or "").strip()]
799
+ if not str(jenkins_cfg.get("job_url") or "").strip():
800
+ base_url = str(jenkins_cfg.get("base_url") or "").strip()
801
+ job_name = str(jenkins_cfg.get("job_name") or "").strip()
802
+ multibranch_root = str(jenkins_cfg.get("multibranch_root_job") or "").strip()
803
+ if not (base_url and job_name) and not (base_url and multibranch_root):
804
+ missing.append(
805
+ "jenkins.job_url (or jenkins.base_url + jenkins.job_name, "
806
+ "or jenkins.base_url + jenkins.multibranch_root_job)"
807
+ )
300
808
  if missing:
301
809
  raise APError("Missing Jenkins config: " + ", ".join(missing))
302
810
  print(f"[verify-jenkins] OK: {jenkinsfile}")
303
811
 
304
812
 
813
+ def cmd_doctor(args: argparse.Namespace) -> None:
814
+ repo = Path(args.repo).resolve()
815
+ cfg = _load_cfg(repo)
816
+ project_cfg = (cfg.get("project") or {})
817
+ commands = (cfg.get("commands") or {})
818
+ target_cfg = (cfg.get("target_env") or {})
819
+ jenkins_cfg = (cfg.get("jenkins") or {})
820
+ docs_cfg = (cfg.get("docs") or {})
821
+ runtime_cfg = (cfg.get("runtime") or {})
822
+
823
+ missing: List[str] = []
824
+ warnings: List[str] = []
825
+
826
+ if not str(project_cfg.get("name") or "").strip():
827
+ missing.append("project.name")
828
+ if not str(commands.get("build") or "").strip():
829
+ missing.append("commands.build")
830
+ if not (str(commands.get("quick_test") or "").strip() or str(commands.get("test") or "").strip()):
831
+ missing.append("commands.quick_test or commands.test")
832
+ if not (str(commands.get("lint") or "").strip() or str(commands.get("typecheck") or "").strip()):
833
+ missing.append("commands.lint or commands.typecheck")
834
+ if not str(target_cfg.get("name") or "").strip():
835
+ missing.append("target_env.name")
836
+ if not str(target_cfg.get("health_base_url") or "").strip():
837
+ missing.append("target_env.health_base_url")
838
+ if not str(target_cfg.get("health_path") or "").strip():
839
+ missing.append("target_env.health_path")
840
+ if not str(jenkins_cfg.get("trigger_branch") or "").strip():
841
+ missing.append("jenkins.trigger_branch")
842
+ if not str(jenkins_cfg.get("image_repository") or "").strip():
843
+ missing.append("jenkins.image_repository")
844
+ if not str(jenkins_cfg.get("image_tag_strategy") or "").strip():
845
+ missing.append("jenkins.image_tag_strategy")
846
+ if not str(jenkins_cfg.get("deploy_env") or "").strip():
847
+ missing.append("jenkins.deploy_env")
848
+
849
+ base_url = str(jenkins_cfg.get("base_url") or "").strip()
850
+ job_name = str(jenkins_cfg.get("job_name") or "").strip()
851
+ job_url = str(jenkins_cfg.get("job_url") or "").strip()
852
+ multibranch_root = str(jenkins_cfg.get("multibranch_root_job") or "").strip()
853
+ if not job_url and not (base_url and job_name) and not (base_url and multibranch_root):
854
+ missing.append("jenkins.job_url or (jenkins.base_url + jenkins.job_name) or (jenkins.base_url + jenkins.multibranch_root_job)")
855
+
856
+ repo_docs = {
857
+ "docs.taskbook": Path(repo, str(docs_cfg.get("taskbook", "docs/tasks/taskbook.md"))),
858
+ "docs.closure_log": Path(repo, str(docs_cfg.get("closure_log", "docs/tasks/closure-log.md"))),
859
+ "docs.api_doc": Path(repo, str(docs_cfg.get("api_doc", "docs/interfaces/api.md"))),
860
+ "docs.api_change_log": Path(repo, str(docs_cfg.get("api_change_log", "docs/interfaces/api-change-log.md"))),
861
+ }
862
+ for key, path in repo_docs.items():
863
+ if not path.exists():
864
+ warnings.append(f"{key} missing on disk: {path}")
865
+
866
+ if _pair_state(str(target_cfg.get("backend_username") or "").strip(), str(target_cfg.get("backend_password") or "").strip()) == "partial":
867
+ warnings.append("target_env.backend_username / target_env.backend_password is partial")
868
+ if _pair_state(str(target_cfg.get("frontend_username") or "").strip(), str(target_cfg.get("frontend_password") or "").strip()) == "partial":
869
+ warnings.append("target_env.frontend_username / target_env.frontend_password is partial")
870
+ if _pair_state(str(jenkins_cfg.get("ui_username") or "").strip(), str(jenkins_cfg.get("ui_password") or "").strip()) == "partial":
871
+ warnings.append("jenkins.ui_username / jenkins.ui_password is partial")
872
+
873
+ api_user = str(jenkins_cfg.get("api_user") or "").strip()
874
+ api_secret = str(jenkins_cfg.get("api_token") or jenkins_cfg.get("api_password") or "").strip()
875
+ if _pair_state(api_user, api_secret) == "partial":
876
+ warnings.append("jenkins.api_user with jenkins.api_token/api_password is partial")
877
+
878
+ runtime_enabled = any(str(runtime_cfg.get(key) or "").strip() for key in ["docker_compose_file", "docker_service", "health_base_url", "health_path"])
879
+ if runtime_enabled and not (str(commands.get("compose_up") or "").strip() or str(runtime_cfg.get("docker_compose_file") or "").strip()):
880
+ warnings.append("runtime config is partially enabled but compose_up or docker_compose_file is missing")
881
+
882
+ if missing:
883
+ raise APError("Doctor found blocking config issues:\n- " + "\n- ".join(missing) + (("\nWarnings:\n- " + "\n- ".join(warnings)) if warnings else ""))
884
+
885
+ print("[doctor] OK")
886
+ if warnings:
887
+ print("[doctor] Warnings:")
888
+ for item in warnings:
889
+ print(f"- {item}")
890
+
891
+
892
+ def cmd_verify_jenkins_build(args: argparse.Namespace) -> None:
893
+ repo = Path(args.repo).resolve()
894
+ cfg = _load_cfg(repo)
895
+ jenkins_cfg = (cfg.get("jenkins") or {})
896
+ git_ref = str(args.git_ref or "HEAD").strip()
897
+ candidate_job_urls = _resolve_jenkins_job_candidates(
898
+ cfg,
899
+ repo,
900
+ git_ref=git_ref,
901
+ job_name=args.job_name,
902
+ job_url=args.job_url,
903
+ multibranch_root_job=args.multibranch_root_job,
904
+ branch_name=args.branch_name,
905
+ )
906
+ build_number = args.build_number
907
+ max_builds = int(args.max_builds or 20)
908
+ timeout_s = int(args.timeout_sec or 300)
909
+ poll_s = int(args.poll_sec or 5)
910
+ inferred_branch = _resolve_git_branch_name(repo, git_ref)
911
+ branch_hint = str(args.branch_name or jenkins_cfg.get("branch_name") or inferred_branch or "").strip()
912
+ root_hint = str(
913
+ args.multibranch_root_job
914
+ or jenkins_cfg.get("multibranch_root_job")
915
+ or args.job_name
916
+ or jenkins_cfg.get("job_name")
917
+ or args.job_url
918
+ or jenkins_cfg.get("job_url")
919
+ or ""
920
+ ).strip()
921
+ if branch_hint and root_hint:
922
+ job_label = f"{root_hint}/{branch_hint}"
923
+ else:
924
+ job_label = branch_hint or root_hint or "(configured)"
925
+
926
+ deadline = time.time() + timeout_s
927
+ if build_number is not None:
928
+ payload = None
929
+ while time.time() < deadline:
930
+ payload = None
931
+ for candidate_job_url in candidate_job_urls:
932
+ api_url = _jenkins_build_api_url(candidate_job_url, int(build_number))
933
+ payload = _jenkins_api_get_json(api_url, cfg, allow_404=True)
934
+ if payload is not None:
935
+ break
936
+ if payload is not None and not payload.get("building"):
937
+ break
938
+ time.sleep(poll_s)
939
+ if not payload:
940
+ raise APError(
941
+ f"No Jenkins build payload found for build #{build_number} under any candidate job URL. "
942
+ f"Checked for up to {timeout_s}s."
943
+ )
944
+ if payload.get("building"):
945
+ raise APError(
946
+ f"Jenkins build is still running: "
947
+ f"#{payload.get('number')} {payload.get('url')}"
948
+ )
949
+ result, description = _assert_jenkins_build_success(
950
+ payload,
951
+ f"#{payload.get('number')} {payload.get('url')}",
952
+ args.allow_no_deploy,
953
+ )
954
+ print(
955
+ "[verify-jenkins-build] OK: "
956
+ f"job={job_label} "
957
+ f"build=#{payload.get('number')} "
958
+ f"result={result} "
959
+ f"description={description} "
960
+ f"url={payload.get('url')}"
961
+ )
962
+ return
963
+
964
+ git_short_sha = _resolve_git_short_sha(repo, git_ref)
965
+ matched = None
966
+
967
+ while time.time() < deadline:
968
+ matched = None
969
+ for candidate_job_url in candidate_job_urls:
970
+ api_url = _jenkins_builds_api_url(candidate_job_url, max_builds)
971
+ payload = _jenkins_api_get_json(api_url, cfg, allow_404=True)
972
+ if payload is None:
973
+ continue
974
+ builds = payload.get("builds") or []
975
+ matched = next((b for b in builds if git_short_sha in str(b.get("description") or "")), None)
976
+ if matched:
977
+ break
978
+ if matched and not matched.get("building"):
979
+ break
980
+ time.sleep(poll_s)
981
+
982
+ if not matched:
983
+ raise APError(
984
+ f"No Jenkins build found for commit {git_short_sha} under any candidate job URL. "
985
+ f"Checked latest {max_builds} builds for up to {timeout_s}s."
986
+ )
987
+
988
+ result, description = _assert_jenkins_build_success(
989
+ matched,
990
+ f"#{matched.get('number')} {matched.get('url')}",
991
+ args.allow_no_deploy,
992
+ )
993
+ print(
994
+ "[verify-jenkins-build] OK: "
995
+ f"commit={git_short_sha} "
996
+ f"job={job_label} "
997
+ f"build=#{matched.get('number')} "
998
+ f"result={result} "
999
+ f"description={description} "
1000
+ f"url={matched.get('url')}"
1001
+ )
1002
+
1003
+
305
1004
  def cmd_verify_api_docs(args: argparse.Namespace) -> None:
306
1005
  """Ensure API markdown doc and change-log exist."""
307
1006
  repo = Path(args.repo).resolve()
@@ -315,26 +1014,68 @@ def cmd_verify_api_docs(args: argparse.Namespace) -> None:
315
1014
  print(f"[verify-api-docs] OK: {api_doc} + {change_log}")
316
1015
 
317
1016
 
318
- def cmd_commit_push(args: argparse.Namespace) -> None:
1017
+ def cmd_record_closure(args: argparse.Namespace) -> None:
319
1018
  repo = Path(args.repo).resolve()
320
1019
  ensure_git_repo(repo)
321
1020
  cfg = _load_cfg(repo)
322
1021
  docs_cfg = (cfg.get("docs") or {})
323
- summary_dir = Path(repo, str(docs_cfg.get("summary_dir", "docs/tasks/summaries")))
1022
+ target_cfg = (cfg.get("target_env") or {})
1023
+ taskbook = Path(repo, str(docs_cfg.get("taskbook", "docs/tasks/taskbook.md")))
1024
+ closure_log = Path(repo, str(docs_cfg.get("closure_log", "docs/tasks/closure-log.md")))
1025
+ closure_log.parent.mkdir(parents=True, exist_ok=True)
1026
+ if not closure_log.exists():
1027
+ closure_log.write_text("# Closure Log\n\n", encoding="utf-8")
324
1028
 
325
1029
  task_id = args.task_id
1030
+ title = str(args.title or "").strip() or _infer_title(taskbook, task_id)
1031
+ timestamp = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
1032
+ commit_value = _resolve_git_short_sha(repo, args.commit)
1033
+ target_env = str(args.target_env or target_cfg.get("name") or "").strip() or "(not set)"
1034
+ verification_items = args.verification or []
1035
+ verification_text = "; ".join(verification_items) if verification_items else "TODO"
1036
+ follow_up = str(args.follow_up or "").strip() or "none"
1037
+ jenkins_build = str(args.jenkins or "").strip() or "TODO"
1038
+
1039
+ lines = [
1040
+ f"## {task_id} — {title} — {timestamp}",
1041
+ f"- Task: {task_id}",
1042
+ f"- Commit: {commit_value}",
1043
+ f"- Jenkins Build: {jenkins_build}",
1044
+ f"- Target Env: {target_env}",
1045
+ f"- Verification: {verification_text}",
1046
+ f"- Result: {args.result}",
1047
+ f"- Follow-up: {follow_up}",
1048
+ ]
1049
+ if str(args.initial_commit or "").strip():
1050
+ lines.append(f"- Initial Commit: {args.initial_commit.strip()}")
1051
+ if str(args.jenkins_failure or "").strip():
1052
+ lines.append(f"- Jenkins Failure: {args.jenkins_failure.strip()}")
1053
+ if str(args.fix_commit or "").strip():
1054
+ lines.append(f"- Fix Commit: {args.fix_commit.strip()}")
1055
+
1056
+ with closure_log.open("a", encoding="utf-8") as f:
1057
+ if closure_log.stat().st_size > 0:
1058
+ f.write("\n")
1059
+ f.write("\n".join(lines))
1060
+ f.write("\n")
1061
+ print(f"[record-closure] OK: {closure_log}")
1062
+
1063
+
1064
+ def cmd_commit_push(args: argparse.Namespace) -> None:
1065
+ repo = Path(args.repo).resolve()
1066
+ ensure_git_repo(repo)
1067
+
326
1068
  msg = args.msg
327
1069
 
328
- summary = summary_dir / f"{task_id}.md"
329
- if not summary.exists():
330
- raise APError(
331
- f"Task summary missing: {summary}\n"
332
- f"Generate: python3 scripts/autopipeline/ap.py gen-summary {task_id}"
333
- )
1070
+ if args.record_closure and not args.result:
1071
+ raise APError("When using --record-closure, --result is required.")
334
1072
 
335
1073
  if args.require_runtime_health:
336
1074
  cmd_wait_health(argparse.Namespace(repo=str(repo), scope="runtime"))
337
1075
 
1076
+ if args.require_light_gate:
1077
+ cmd_light_gate(argparse.Namespace(repo=str(repo)))
1078
+
338
1079
  if args.require_jenkins:
339
1080
  cmd_verify_jenkins(argparse.Namespace(repo=str(repo)))
340
1081
 
@@ -348,6 +1089,23 @@ def cmd_commit_push(args: argparse.Namespace) -> None:
348
1089
 
349
1090
  run(["git", "commit", "-m", msg], cwd=repo)
350
1091
  run(["git", "push"], cwd=repo)
1092
+ if args.record_closure:
1093
+ cmd_record_closure(
1094
+ argparse.Namespace(
1095
+ repo=str(repo),
1096
+ task_id=args.task_id,
1097
+ title=args.title,
1098
+ commit="HEAD",
1099
+ jenkins=args.jenkins_build,
1100
+ target_env=args.target_env,
1101
+ verification=args.verification,
1102
+ result=args.result,
1103
+ follow_up=args.follow_up,
1104
+ initial_commit=args.initial_commit,
1105
+ jenkins_failure=args.jenkins_failure,
1106
+ fix_commit=args.fix_commit,
1107
+ )
1108
+ )
351
1109
  print("[commit-push] OK - push completed, Jenkins should auto-trigger")
352
1110
 
353
1111
 
@@ -362,6 +1120,7 @@ def main(argv: Optional[List[str]] = None) -> int:
362
1120
 
363
1121
  s = sp.add_parser("gen-summary")
364
1122
  s.add_argument("task_id")
1123
+ s.add_argument("--title")
365
1124
  s.add_argument("--force", action="store_true")
366
1125
  s.set_defaults(func=cmd_gen_summary)
367
1126
 
@@ -372,6 +1131,12 @@ def main(argv: Optional[List[str]] = None) -> int:
372
1131
  s.add_argument("name")
373
1132
  s.set_defaults(func=cmd_run)
374
1133
 
1134
+ s = sp.add_parser("light-gate")
1135
+ s.set_defaults(func=cmd_light_gate)
1136
+
1137
+ s = sp.add_parser("doctor")
1138
+ s.set_defaults(func=cmd_doctor)
1139
+
375
1140
  s = sp.add_parser("runtime-up")
376
1141
  s.set_defaults(func=cmd_runtime_up)
377
1142
 
@@ -379,21 +1144,66 @@ def main(argv: Optional[List[str]] = None) -> int:
379
1144
  s.set_defaults(func=cmd_runtime_down)
380
1145
 
381
1146
  s = sp.add_parser("wait-health")
382
- s.add_argument("--scope", choices=["runtime", "prod"], default="runtime")
1147
+ s.add_argument("--scope", choices=["runtime", "target", "prod"], default="runtime")
383
1148
  s.set_defaults(func=cmd_wait_health)
384
1149
 
385
1150
  s = sp.add_parser("verify-jenkins")
386
1151
  s.set_defaults(func=cmd_verify_jenkins)
387
1152
 
1153
+ s = sp.add_parser("verify-jenkins-build")
1154
+ s.add_argument("--git-ref")
1155
+ s.add_argument("--job-name")
1156
+ s.add_argument("--job-url")
1157
+ s.add_argument("--multibranch-root-job")
1158
+ s.add_argument("--branch-name")
1159
+ s.add_argument("--build-number", type=int)
1160
+ s.add_argument("--max-builds", type=int, default=20)
1161
+ s.add_argument("--timeout-sec", type=int, default=300)
1162
+ s.add_argument("--poll-sec", type=int, default=5)
1163
+ s.add_argument("--allow-no-deploy", action="store_true")
1164
+ s.set_defaults(func=cmd_verify_jenkins_build)
1165
+
388
1166
  s = sp.add_parser("verify-api-docs")
389
1167
  s.set_defaults(func=cmd_verify_api_docs)
390
1168
 
1169
+ s = sp.add_parser("verify-target")
1170
+ s.add_argument("--backend-path", action="append")
1171
+ s.add_argument("--frontend-path", action="append")
1172
+ s.add_argument("--backend-basic-auth", action="store_true")
1173
+ s.add_argument("--frontend-basic-auth", action="store_true")
1174
+ s.set_defaults(func=cmd_verify_target)
1175
+
1176
+ s = sp.add_parser("record-closure")
1177
+ s.add_argument("task_id")
1178
+ s.add_argument("--title")
1179
+ s.add_argument("--commit", default="HEAD")
1180
+ s.add_argument("--jenkins")
1181
+ s.add_argument("--target-env")
1182
+ s.add_argument("--verification", action="append")
1183
+ s.add_argument("--result", choices=["PASS", "FAIL", "PARTIAL"], required=True)
1184
+ s.add_argument("--follow-up")
1185
+ s.add_argument("--initial-commit")
1186
+ s.add_argument("--jenkins-failure")
1187
+ s.add_argument("--fix-commit")
1188
+ s.set_defaults(func=cmd_record_closure)
1189
+
391
1190
  s = sp.add_parser("commit-push")
392
1191
  s.add_argument("task_id")
1192
+ s.add_argument("--title")
393
1193
  s.add_argument("--msg", required=True)
1194
+ s.add_argument("--require-light-gate", action="store_true")
394
1195
  s.add_argument("--require-runtime-health", action="store_true")
395
1196
  s.add_argument("--require-jenkins", action="store_true")
396
1197
  s.add_argument("--require-matrix", action="store_true")
1198
+ s.add_argument("--record-closure", action="store_true")
1199
+ s.add_argument("--jenkins-build")
1200
+ s.add_argument("--target-env")
1201
+ s.add_argument("--verification", action="append")
1202
+ s.add_argument("--result", choices=["PASS", "FAIL", "PARTIAL"])
1203
+ s.add_argument("--follow-up")
1204
+ s.add_argument("--initial-commit")
1205
+ s.add_argument("--jenkins-failure")
1206
+ s.add_argument("--fix-commit")
397
1207
  s.set_defaults(func=cmd_commit_push)
398
1208
 
399
1209
  try: