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