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