@elvis1513/auto-coding-skill 0.3.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.
@@ -11,6 +11,7 @@ import json
11
11
  import os
12
12
  import time
13
13
  import urllib.parse
14
+ import urllib.error
14
15
  import urllib.request
15
16
  from pathlib import Path
16
17
  from typing import Optional, List
@@ -18,6 +19,9 @@ from typing import Optional, List
18
19
  from core import APError, ensure_git_repo, copy_tree, run, load_yaml, find_config, run_shell, http_get_status
19
20
 
20
21
 
22
+ _JENKINS_CRUMB_CACHE: dict[str, dict[str, str]] = {}
23
+
24
+
21
25
  def _skill_root() -> Path:
22
26
  return Path(__file__).resolve().parent.parent
23
27
 
@@ -56,29 +60,160 @@ def _run_configured_command(repo: Path, cfg: dict, name: str) -> bool:
56
60
 
57
61
  def _jenkins_basic_auth_headers(cfg: dict) -> dict:
58
62
  jenkins_cfg = (cfg.get("jenkins") or {})
59
- direct_user = str(jenkins_cfg.get("api_user") or "").strip()
63
+ direct_user = str(jenkins_cfg.get("api_user") or jenkins_cfg.get("ui_username") or "").strip()
60
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()
61
66
  user_env = str(jenkins_cfg.get("api_user_env") or "JENKINS_USER").strip() or "JENKINS_USER"
62
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"
63
69
  user = direct_user or os.getenv(user_env) or os.getenv("JENKINS_USER")
64
- token = direct_token or os.getenv(token_env) or os.getenv("JENKINS_TOKEN") or os.getenv("JENKINS_PASSWORD")
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
+ )
65
78
  if not user or not token:
66
79
  raise APError(
67
- f"Missing Jenkins API credentials. Fill jenkins.api_user / jenkins.api_token in docs/ENGINEERING.md, "
68
- f"or set env vars {user_env} and {token_env}."
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}."
69
82
  )
70
83
  raw = f"{user}:{token}".encode("utf-8")
71
84
  auth = base64.b64encode(raw).decode("ascii")
72
85
  return {"Authorization": f"Basic {auth}"}
73
86
 
74
87
 
75
- def _jenkins_api_get_json(url: str, cfg: dict, timeout_s: int = 15) -> dict:
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]:
76
176
  headers = {"Accept": "application/json"}
77
177
  headers.update(_jenkins_basic_auth_headers(cfg))
78
178
  req = urllib.request.Request(url, headers=headers, method="GET")
79
179
  try:
80
180
  with urllib.request.urlopen(req, timeout=timeout_s) as resp:
81
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
82
217
  except Exception as exc:
83
218
  raise APError(f"Jenkins API request failed: {url}\n{exc}") from exc
84
219
  try:
@@ -95,6 +230,14 @@ def _resolve_git_short_sha(repo: Path, ref: str) -> str:
95
230
  return ref.strip()
96
231
 
97
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
+
98
241
  def _jenkins_builds_api_url(job_url: str, max_builds: int) -> str:
99
242
  base = str(job_url or "").strip().rstrip("/")
100
243
  if not base:
@@ -103,6 +246,148 @@ def _jenkins_builds_api_url(job_url: str, max_builds: int) -> str:
103
246
  return f"{base}/api/json?tree={urllib.parse.quote(tree, safe='=,')}"
104
247
 
105
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
+
106
391
  def cmd_install(args: argparse.Namespace) -> None:
107
392
  repo = Path(args.repo).resolve()
108
393
  templates = _skill_root() / "data" / "templates"
@@ -129,7 +414,7 @@ def cmd_install(args: argparse.Namespace) -> None:
129
414
  gi.write_text(secret_line + "\n", encoding="utf-8")
130
415
 
131
416
  print(f"[install] OK: scaffold installed into {repo}")
132
- 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")
133
418
 
134
419
 
135
420
  def _infer_title(taskbook: Path, task_id: str) -> str:
@@ -160,7 +445,7 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
160
445
  if out_file.exists() and not args.force:
161
446
  raise APError(f"Summary already exists: {out_file} (use --force to overwrite)")
162
447
 
163
- title = _infer_title(taskbook, task_id)
448
+ title = str(args.title or "").strip() or _infer_title(taskbook, task_id)
164
449
  date = _dt.date.today().isoformat()
165
450
 
166
451
  staged = run(["git", "diff", "--cached", "--name-only"], cwd=repo, check=False).stdout.strip()
@@ -169,6 +454,8 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
169
454
 
170
455
  content = f"""# Task Summary — {task_id} — {title}
171
456
 
457
+ > 仅用于高风险、跨模块、阶段性里程碑、需要完整复盘的任务。
458
+
172
459
  - Task ID:{task_id}
173
460
  - Date:{date}
174
461
  - Scope(本次范围):TODO
@@ -180,7 +467,7 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
180
467
  - 目标:TODO
181
468
  - 验收结论:PASS / FAIL — TODO
182
469
 
183
- ## 2. 变更概览(代码/配置/本地运行/Jenkins)
470
+ ## 2. 变更概览
184
471
  ### Git change snapshot
185
472
  - Staged files:
186
473
  {('- ' + staged.replace('\n','\n- ')) if staged else '- (none)'}
@@ -194,19 +481,19 @@ def cmd_gen_summary(args: argparse.Namespace) -> None:
194
481
  ## 3. 接口变更(以 API Markdown 为准)
195
482
  - 变更记录位置:`{api_change_log}`
196
483
 
197
- ## 5. 质量门禁证据(必须可追溯)
198
- - 后端测试:`commands.test` — TODO
199
- - 前端构建:`commands.build` TODO
200
- - 静态分析:`commands.lint` — TODO
201
- - 前端类型检查:`commands.typecheck` — TODO
202
- - Review 文档:TODO
203
- - DD 文档:TODO
204
- - Jenkins 准备:TODO
205
- - 回归矩阵:`{regression_matrix}`(全量 PASS,0 fail,且每项必须有真实证据)
206
-
207
- ## 6. 本地运行与 Jenkins 部署记录
208
- - Local compose:TODO
209
- - 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
210
497
  """
211
498
 
212
499
  out_file.write_text(content, encoding="utf-8")
@@ -281,6 +568,26 @@ def _load_cfg(repo: Path) -> dict:
281
568
  return load_yaml(cfg_path)
282
569
 
283
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
+
284
591
  def cmd_run(args: argparse.Namespace) -> None:
285
592
  """
286
593
  Run any configured gate command by name.
@@ -302,6 +609,61 @@ def cmd_run(args: argparse.Namespace) -> None:
302
609
  print(f"[run] OK: {name}")
303
610
 
304
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
+
305
667
  def cmd_runtime_up(args: argparse.Namespace) -> None:
306
668
  repo = Path(args.repo).resolve()
307
669
  cfg = _load_cfg(repo)
@@ -338,10 +700,13 @@ def cmd_wait_health(args: argparse.Namespace) -> None:
338
700
  url = _join_url(str(runtime_cfg.get("health_base_url") or ""), str(runtime_cfg.get("health_path") or ""))
339
701
  timeout_s = int(runtime_cfg.get("startup_timeout_sec") or 120)
340
702
  else:
703
+ target_cfg = (cfg.get("target_env") or {})
341
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 "")
342
707
  url = _join_url(
343
- str(jenkins_cfg.get("prod_health_base_url") or ""),
344
- str(jenkins_cfg.get("prod_health_path") or "")
708
+ base_url,
709
+ path,
345
710
  )
346
711
  timeout_s = int(jenkins_cfg.get("deploy_timeout_sec") or 1800)
347
712
 
@@ -360,85 +725,275 @@ def cmd_wait_health(args: argparse.Namespace) -> None:
360
725
  raise APError(f"Health check timeout for {scope}: {url}\nLast result: {last_error}")
361
726
 
362
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
+
363
780
  def cmd_verify_jenkins(args: argparse.Namespace) -> None:
364
781
  repo = Path(args.repo).resolve()
365
782
  cfg = _load_cfg(repo)
366
783
  project_cfg = (cfg.get("project") or {})
367
784
  jenkins_cfg = (cfg.get("jenkins") or {})
785
+ target_cfg = (cfg.get("target_env") or {})
368
786
  jenkinsfile = Path(repo, str(project_cfg.get("jenkinsfile") or "Jenkinsfile"))
369
787
  if not jenkinsfile.exists():
370
788
  raise APError(f"Jenkinsfile not found: {jenkinsfile}")
371
789
 
372
790
  required = [
373
- ("jenkins.job_name", jenkins_cfg.get("job_name")),
374
- ("jenkins.job_url", jenkins_cfg.get("job_url")),
375
791
  ("jenkins.trigger_branch", jenkins_cfg.get("trigger_branch")),
376
792
  ("jenkins.image_repository", jenkins_cfg.get("image_repository")),
377
793
  ("jenkins.image_tag_strategy", jenkins_cfg.get("image_tag_strategy")),
378
794
  ("jenkins.deploy_env", jenkins_cfg.get("deploy_env")),
379
- ("jenkins.prod_health_base_url", jenkins_cfg.get("prod_health_base_url")),
380
- ("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")),
381
797
  ]
382
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
+ )
383
808
  if missing:
384
809
  raise APError("Missing Jenkins config: " + ", ".join(missing))
385
810
  print(f"[verify-jenkins] OK: {jenkinsfile}")
386
811
 
387
812
 
388
- def cmd_verify_jenkins_build(args: argparse.Namespace) -> None:
813
+ def cmd_doctor(args: argparse.Namespace) -> None:
389
814
  repo = Path(args.repo).resolve()
390
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 {})
391
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()
392
851
  job_url = str(jenkins_cfg.get("job_url") or "").strip()
393
- if not job_url:
394
- raise APError("Missing jenkins.job_url in docs/ENGINEERING.md")
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
+
395
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 {})
396
896
  git_ref = str(args.git_ref or "HEAD").strip()
397
- git_short_sha = _resolve_git_short_sha(repo, git_ref)
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
398
907
  max_builds = int(args.max_builds or 20)
399
908
  timeout_s = int(args.timeout_sec or 300)
400
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)"
401
925
 
402
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)
403
965
  matched = None
404
- api_url = _jenkins_builds_api_url(job_url, max_builds)
405
966
 
406
967
  while time.time() < deadline:
407
- payload = _jenkins_api_get_json(api_url, cfg)
408
- builds = payload.get("builds") or []
409
- matched = next((b for b in builds if git_short_sha in str(b.get("description") or "")), None)
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
410
978
  if matched and not matched.get("building"):
411
979
  break
412
980
  time.sleep(poll_s)
413
981
 
414
982
  if not matched:
415
983
  raise APError(
416
- f"No Jenkins build found for commit {git_short_sha} under {job_url}. "
984
+ f"No Jenkins build found for commit {git_short_sha} under any candidate job URL. "
417
985
  f"Checked latest {max_builds} builds for up to {timeout_s}s."
418
986
  )
419
987
 
420
- if matched.get("building"):
421
- raise APError(
422
- f"Jenkins build for commit {git_short_sha} is still running: "
423
- f"#{matched.get('number')} {matched.get('url')}"
424
- )
425
-
426
- result = str(matched.get("result") or "").strip().upper()
427
- description = str(matched.get("description") or "").strip()
428
- if result != "SUCCESS":
429
- raise APError(
430
- f"Jenkins build for commit {git_short_sha} did not succeed: "
431
- f"#{matched.get('number')} result={result or '(empty)'} {matched.get('url')}"
432
- )
433
- if not args.allow_no_deploy and description.startswith("no-deploy:"):
434
- raise APError(
435
- f"Jenkins build for commit {git_short_sha} succeeded but did not deploy: "
436
- f"#{matched.get('number')} {description}"
437
- )
438
-
988
+ result, description = _assert_jenkins_build_success(
989
+ matched,
990
+ f"#{matched.get('number')} {matched.get('url')}",
991
+ args.allow_no_deploy,
992
+ )
439
993
  print(
440
994
  "[verify-jenkins-build] OK: "
441
995
  f"commit={git_short_sha} "
996
+ f"job={job_label} "
442
997
  f"build=#{matched.get('number')} "
443
998
  f"result={result} "
444
999
  f"description={description} "
@@ -459,26 +1014,68 @@ def cmd_verify_api_docs(args: argparse.Namespace) -> None:
459
1014
  print(f"[verify-api-docs] OK: {api_doc} + {change_log}")
460
1015
 
461
1016
 
462
- def cmd_commit_push(args: argparse.Namespace) -> None:
1017
+ def cmd_record_closure(args: argparse.Namespace) -> None:
463
1018
  repo = Path(args.repo).resolve()
464
1019
  ensure_git_repo(repo)
465
1020
  cfg = _load_cfg(repo)
466
1021
  docs_cfg = (cfg.get("docs") or {})
467
- 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")
468
1028
 
469
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
+
470
1068
  msg = args.msg
471
1069
 
472
- summary = summary_dir / f"{task_id}.md"
473
- if not summary.exists():
474
- raise APError(
475
- f"Task summary missing: {summary}\n"
476
- f"Generate: python3 docs/tools/autopipeline/ap.py gen-summary {task_id}"
477
- )
1070
+ if args.record_closure and not args.result:
1071
+ raise APError("When using --record-closure, --result is required.")
478
1072
 
479
1073
  if args.require_runtime_health:
480
1074
  cmd_wait_health(argparse.Namespace(repo=str(repo), scope="runtime"))
481
1075
 
1076
+ if args.require_light_gate:
1077
+ cmd_light_gate(argparse.Namespace(repo=str(repo)))
1078
+
482
1079
  if args.require_jenkins:
483
1080
  cmd_verify_jenkins(argparse.Namespace(repo=str(repo)))
484
1081
 
@@ -492,6 +1089,23 @@ def cmd_commit_push(args: argparse.Namespace) -> None:
492
1089
 
493
1090
  run(["git", "commit", "-m", msg], cwd=repo)
494
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
+ )
495
1109
  print("[commit-push] OK - push completed, Jenkins should auto-trigger")
496
1110
 
497
1111
 
@@ -506,6 +1120,7 @@ def main(argv: Optional[List[str]] = None) -> int:
506
1120
 
507
1121
  s = sp.add_parser("gen-summary")
508
1122
  s.add_argument("task_id")
1123
+ s.add_argument("--title")
509
1124
  s.add_argument("--force", action="store_true")
510
1125
  s.set_defaults(func=cmd_gen_summary)
511
1126
 
@@ -516,6 +1131,12 @@ def main(argv: Optional[List[str]] = None) -> int:
516
1131
  s.add_argument("name")
517
1132
  s.set_defaults(func=cmd_run)
518
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
+
519
1140
  s = sp.add_parser("runtime-up")
520
1141
  s.set_defaults(func=cmd_runtime_up)
521
1142
 
@@ -523,14 +1144,19 @@ def main(argv: Optional[List[str]] = None) -> int:
523
1144
  s.set_defaults(func=cmd_runtime_down)
524
1145
 
525
1146
  s = sp.add_parser("wait-health")
526
- s.add_argument("--scope", choices=["runtime", "prod"], default="runtime")
1147
+ s.add_argument("--scope", choices=["runtime", "target", "prod"], default="runtime")
527
1148
  s.set_defaults(func=cmd_wait_health)
528
1149
 
529
1150
  s = sp.add_parser("verify-jenkins")
530
1151
  s.set_defaults(func=cmd_verify_jenkins)
531
1152
 
532
1153
  s = sp.add_parser("verify-jenkins-build")
533
- s.add_argument("--git-ref", default="HEAD")
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)
534
1160
  s.add_argument("--max-builds", type=int, default=20)
535
1161
  s.add_argument("--timeout-sec", type=int, default=300)
536
1162
  s.add_argument("--poll-sec", type=int, default=5)
@@ -540,12 +1166,44 @@ def main(argv: Optional[List[str]] = None) -> int:
540
1166
  s = sp.add_parser("verify-api-docs")
541
1167
  s.set_defaults(func=cmd_verify_api_docs)
542
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
+
543
1190
  s = sp.add_parser("commit-push")
544
1191
  s.add_argument("task_id")
1192
+ s.add_argument("--title")
545
1193
  s.add_argument("--msg", required=True)
1194
+ s.add_argument("--require-light-gate", action="store_true")
546
1195
  s.add_argument("--require-runtime-health", action="store_true")
547
1196
  s.add_argument("--require-jenkins", action="store_true")
548
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")
549
1207
  s.set_defaults(func=cmd_commit_push)
550
1208
 
551
1209
  try: