@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.
- package/README.md +246 -123
- package/cli/assets/skill/SKILL.md +78 -59
- package/cli/assets/skill/data/templates/ENGINEERING.md +184 -61
- package/cli/assets/skill/data/templates/bridges/CLAUDE.md +3 -2
- package/cli/assets/skill/data/templates/bridges/CODEX.md +3 -2
- package/cli/assets/skill/data/templates/docs/deployment/deploy-records/_TEMPLATE-DEPLOY-RECORD.md +15 -24
- package/cli/assets/skill/data/templates/docs/deployment/deploy-runbook.md +9 -17
- package/cli/assets/skill/data/templates/docs/reviews/_TEMPLATE-REVIEW.md +20 -11
- package/cli/assets/skill/data/templates/docs/tasks/closure-log.md +24 -0
- package/cli/assets/skill/data/templates/docs/tasks/summaries/_TEMPLATE-TASK-SUMMARY.md +13 -36
- package/cli/assets/skill/data/templates/docs/tasks/taskbook.md +24 -17
- package/cli/assets/skill/data/templates/docs/testing/regression-matrix.md +6 -6
- package/cli/assets/skill/scripts/ap.py +724 -81
- package/cli/src/index.js +0 -17
- package/package.json +1 -1
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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}:{
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
##
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
-
|
|
201
|
-
-
|
|
202
|
-
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
-
|
|
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
|
-
|
|
344
|
-
|
|
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.
|
|
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
|
-
("
|
|
380
|
-
("
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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"
|
|
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:
|