@elvis1513/auto-coding-skill 0.3.0 → 1.0.1

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