25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 
 

506 satır
17 KiB

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Auto-generate a Word (.docx) test plan for each commit in a git range.
  5. Usage (from repo root):
  6. pip install python-docx
  7. python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD
  8. python scripts/generate_commit_test_plans_docx.py abc123..def456 --out-dir docs/deploy/commit-plans
  9. python scripts/generate_commit_test_plans_docx.py HEAD~5..HEAD --also-md
  10. Output folder (default): docs/deploy/commit-plans/
  11. <date>_<shortsha>_<slug>.docx — one file per commit
  12. _index.md — list of generated plans
  13. Notes:
  14. - Plans are heuristic from commit message + touched paths (good starting point for QA).
  15. - For critical releases, ask the agent to refine steps against the actual diff.
  16. """
  17. from __future__ import annotations
  18. import argparse
  19. import re
  20. import subprocess
  21. import sys
  22. from dataclasses import dataclass, field
  23. from datetime import datetime, timezone
  24. from pathlib import Path
  25. from docx import Document
  26. from docx.enum.text import WD_ALIGN_PARAGRAPH
  27. from docx.oxml.ns import qn
  28. from docx.shared import Cm, Pt, RGBColor
  29. ROOT = Path(__file__).resolve().parents[1]
  30. DEFAULT_OUT = ROOT / "docs" / "deploy" / "commit-plans"
  31. # (path substring, area label, suggested steps, expected results)
  32. AREA_RULES: list[tuple[str, str, list[tuple[str, str]]]] = [
  33. (
  34. "deliveryOrder",
  35. "送貨訂單 / 成品出倉",
  36. [
  37. ("「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。", "放單成功;產生提料票,無未預期 500。"),
  38. ("「成品出倉」`/doworkbench`:撳單 → 掃碼提料 → 填箱數列印。", "票可撳、可提、可完成;狀態「待撳單」→「提貨中」→「已完成」。"),
  39. ("若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。", "票出現在正確樓層/車線;指派篩選與顯示一致。"),
  40. ],
  41. ),
  42. (
  43. "pickOrder",
  44. "提料單 / Workbench",
  45. [
  46. ("「成品出倉」撳單並完成一張提料票(測試庫)。", "掃碼/提交正常;完成後記錄頁可見。"),
  47. ("「查看提貨情況」核對該票狀態。", "狀態與負責人符合操作。"),
  48. ],
  49. ),
  50. (
  51. "SuggestedPickLot",
  52. "建議批號 / UOM",
  53. [
  54. ("出倉或提料時掃建議批號;刻意掃 UOM 不符批號。", "不符 UOM 被擋並有明確提示;相符批號可提交。"),
  55. ("標籤列印/批號列表只應出現同 UOM 選項(若本次改動涵蓋)。", "列表無錯誤 UOM 批號。"),
  56. ],
  57. ),
  58. (
  59. "job_order",
  60. "工單",
  61. [
  62. ("「搜索工單/ 建立工單」建立或搜索受影響工單。", "列表/詳情資料正確。"),
  63. ("依狀態走「放單」→「工單提料」或「工單生產流程」。", "狀態轉換符合預期,無阻塞錯誤。"),
  64. ],
  65. ),
  66. (
  67. "JobOrder",
  68. "工單",
  69. [
  70. ("「搜索工單/ 建立工單」驗證建立/搜索/放單。", "成功提示;狀態正確。"),
  71. ],
  72. ),
  73. (
  74. "modules/bom",
  75. "BOM",
  76. [
  77. ("「設定」→「匯入 BOM」/「BOM 明細」搜索受影響成品。", "可載入明細;啟用狀態正確。"),
  78. ("「建立工單」下拉是否仍能選該 BOM。", "啟用可選、停用不可選。"),
  79. ],
  80. ),
  81. (
  82. "production_schedule",
  83. "排程",
  84. [
  85. ("「排程」→「生產排程」搜索相關生產日 →「詳細」。", "明細數量合理。"),
  86. ("(測試庫)「自動生成工單」。", "產生工單或明確錯誤訊息。"),
  87. ],
  88. ),
  89. (
  90. "ProductionSchedule",
  91. "排程",
  92. [
  93. ("「排程」頁驗證預測/搜索/詳細。", "畫面與 API 正常。"),
  94. ],
  95. ),
  96. (
  97. "stock",
  98. "庫存 / 上架 / 出入倉",
  99. [
  100. ("依改動點進「上架掃碼」或相關庫存查詢頁。", "掃碼/查詢結果與庫存數量合理。"),
  101. ("做一筆小量入/出/調撥(測試庫)。", "成功;庫存異動可查。"),
  102. ],
  103. ),
  104. (
  105. "StockIn",
  106. "來貨 / 品檢",
  107. [
  108. ("「工單生產流程」→「品檢」或待 QC 列表。", "可開品檢;確定後狀態更新。"),
  109. ],
  110. ),
  111. (
  112. "putAway",
  113. "上架",
  114. [
  115. ("「上架掃碼」:貨品 QR → 倉庫 QR →「確定及上架貨物」。", "上架成功;待上架數量減少。"),
  116. ],
  117. ),
  118. (
  119. "m18",
  120. "M18 同步",
  121. [
  122. ("對受影響主檔執行同步或查看最近同步結果(測試環境)。", "對應欄位寫入 MTMS;錯誤有日誌。"),
  123. ],
  124. ),
  125. (
  126. "onpack2030",
  127. "標籤 / OnPack",
  128. [
  129. ("對新增/修改的貨品編號列印標籤(測試機)。", "圖檔/job 正確;可印出。"),
  130. ],
  131. ),
  132. (
  133. "db/changelog",
  134. "資料庫變更",
  135. [
  136. ("部署後確認 Liquibase/changelog 已套用(或啟動 log 無 changeset 失敗)。", "DB schema/資料符合 changeset。"),
  137. ("用相關畫面或 SQL 抽樣驗證新欄位/約束。", "讀寫正常,無缺欄錯誤。"),
  138. ],
  139. ),
  140. (
  141. "modules/master",
  142. "主檔 (Item/Shop 等)",
  143. [
  144. ("主檔搜索受影響編號,核對顯示欄位。", "名稱/單位/類型等與預期一致。"),
  145. ],
  146. ),
  147. (
  148. "Inventory",
  149. "庫存查詢",
  150. [
  151. ("庫存搜索頁用受影響貨品/倉位查詢。", "批號、數量、單位正確。"),
  152. ],
  153. ),
  154. ]
  155. KEYWORD_HINTS: list[tuple[re.Pattern[str], tuple[str, str]]] = [
  156. (
  157. re.compile(r"uom|單位", re.I),
  158. ("掃一筆 UOM 不符的批號/物料。", "系統拒絕或明確提示;相符者可過。"),
  159. ),
  160. (
  161. re.compile(r"isextra|etra|加單", re.I),
  162. ("「成品出倉」→「加單」檢視該日票。", "加單票可見且可撳單。"),
  163. ),
  164. (
  165. re.compile(r"truck\s*x|車線-?x|車線x", re.I),
  166. ("核對「車線-X」票在 2/F/4/F 顯示。", "出現在正確樓層區塊,可指派。"),
  167. ),
  168. (
  169. re.compile(r"label|print|列印|標籤|onpack", re.I),
  170. ("選打印機後列印標籤/送貨單標籤。", "成功列印;內容正確。"),
  171. ),
  172. (
  173. re.compile(r"fix|bug|repair|修正|修復", re.I),
  174. ("重現原問題步驟一次。", "問題不再出現;無新副作用。"),
  175. ),
  176. ]
  177. @dataclass
  178. class CommitPlan:
  179. sha: str
  180. short: str
  181. subject: str
  182. body: str
  183. author: str
  184. date: str
  185. files: list[str] = field(default_factory=list)
  186. areas: list[str] = field(default_factory=list)
  187. tests: list[tuple[str, str]] = field(default_factory=list)
  188. def run(cmd: list[str]) -> str:
  189. r = subprocess.run(
  190. cmd,
  191. cwd=ROOT,
  192. capture_output=True,
  193. text=True,
  194. encoding="utf-8",
  195. errors="replace",
  196. )
  197. if r.returncode != 0:
  198. raise SystemExit((r.stderr or r.stdout or f"failed: {cmd}").strip())
  199. return r.stdout.strip()
  200. def list_commits(rev_range: str) -> list[str]:
  201. out = run(["git", "log", "--reverse", "--format=%H", rev_range])
  202. return [ln.strip() for ln in out.splitlines() if ln.strip()]
  203. def load_commit(sha: str) -> CommitPlan:
  204. subject = run(["git", "log", "-1", "--format=%s", sha])
  205. body = run(["git", "log", "-1", "--format=%b", sha])
  206. author = run(["git", "log", "-1", "--format=%an", sha])
  207. date = run(["git", "log", "-1", "--format=%cs", sha])
  208. files_raw = run(["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha])
  209. files = [f for f in files_raw.splitlines() if f.strip()]
  210. plan = CommitPlan(
  211. sha=sha,
  212. short=sha[:8],
  213. subject=subject or "(no message)",
  214. body=(body or "").strip(),
  215. author=author,
  216. date=date,
  217. files=files,
  218. )
  219. build_tests(plan)
  220. return plan
  221. def build_tests(plan: CommitPlan) -> None:
  222. seen_areas: set[str] = set()
  223. seen_steps: set[str] = set()
  224. tests: list[tuple[str, str]] = []
  225. def add(step: str, expected: str) -> None:
  226. key = step.strip()
  227. if key in seen_steps:
  228. return
  229. seen_steps.add(key)
  230. tests.append((step, expected))
  231. blob = " ".join(plan.files) + "\n" + plan.subject + "\n" + plan.body
  232. for path_key, area, pairs in AREA_RULES:
  233. if any(path_key in f.replace("\\", "/") for f in plan.files) or path_key.lower() in blob.lower():
  234. if area not in seen_areas:
  235. seen_areas.add(area)
  236. plan.areas.append(area)
  237. for step, exp in pairs:
  238. add(step, exp)
  239. for pat, pair in KEYWORD_HINTS:
  240. if pat.search(blob):
  241. add(pair[0], pair[1])
  242. # Generic fallbacks
  243. if not tests:
  244. if plan.files:
  245. add(
  246. f"依 commit 變更檔抽樣驗證(共 {len(plan.files)} 個檔)。主要檔:{plan.files[0]}",
  247. "相關 API/畫面無 500;行為符合 commit 說明。",
  248. )
  249. else:
  250. add("確認此 commit 無業務檔變更(empty / merge)。", "無需功能測試或僅煙霧測試。")
  251. add(
  252. "部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。",
  253. "頁面可開、無全域錯誤橫幅。",
  254. )
  255. add(
  256. "(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。",
  257. "無明顯回退。",
  258. )
  259. if not plan.subject or plan.subject.strip().lower() in ("no message", "(no message)"):
  260. add(
  261. "向作者確認此 commit 的實際意圖(訊息為 empty/no message)。",
  262. "補上說明後再簽核上線。",
  263. )
  264. plan.tests = tests
  265. def slugify(text: str, max_len: int = 40) -> str:
  266. """ASCII-only filename slug (avoids Windows console / zip garbling)."""
  267. tokens = re.findall(r"[a-z0-9]+", text.lower())
  268. seen: set[str] = set()
  269. ordered: list[str] = []
  270. for t in tokens:
  271. if t in seen or len(t) < 2:
  272. continue
  273. seen.add(t)
  274. ordered.append(t)
  275. s = "_".join(ordered)[:max_len].strip("_")
  276. return s or "commit"
  277. def set_run_font(run, *, east_asia: str = "Microsoft JhengHei", ascii_font: str = "Calibri", size: Pt | None = None) -> None:
  278. run.font.name = ascii_font
  279. run._element.rPr.rFonts.set(qn("w:eastAsia"), east_asia)
  280. if size is not None:
  281. run.font.size = size
  282. def add_para(doc: Document, text: str, *, bold: bool = False, italic: bool = False, size: Pt | None = None) -> None:
  283. p = doc.add_paragraph()
  284. run = p.add_run(text)
  285. set_run_font(run, size=size or Pt(11))
  286. run.bold = bold
  287. run.italic = italic
  288. def write_docx(plan: CommitPlan, out_path: Path) -> None:
  289. doc = Document()
  290. section = doc.sections[0]
  291. section.top_margin = Cm(1.8)
  292. section.bottom_margin = Cm(1.8)
  293. section.left_margin = Cm(2)
  294. section.right_margin = Cm(2)
  295. style = doc.styles["Normal"]
  296. style.font.name = "Calibri"
  297. style.font.size = Pt(11)
  298. style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
  299. title = doc.add_heading(level=0)
  300. tr = title.add_run(f"Test plan — {plan.short}")
  301. set_run_font(tr, size=Pt(18))
  302. add_para(doc, plan.subject, bold=True, size=Pt(12))
  303. meta = (
  304. f"Commit: {plan.sha}\n"
  305. f"Date: {plan.date} | Author: {plan.author}\n"
  306. f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
  307. )
  308. add_para(doc, meta, italic=True, size=Pt(9))
  309. doc.add_heading("Summary", level=1)
  310. add_para(doc, plan.subject)
  311. if plan.body:
  312. add_para(doc, plan.body)
  313. doc.add_heading("Areas (auto-detected)", level=1)
  314. if plan.areas:
  315. for a in plan.areas:
  316. doc.add_paragraph(a, style="List Bullet")
  317. else:
  318. add_para(doc, "(No area rule matched — generic steps only)")
  319. doc.add_heading("Files touched", level=1)
  320. for f in plan.files[:40]:
  321. doc.add_paragraph(f, style="List Bullet")
  322. if len(plan.files) > 40:
  323. add_para(doc, f"… and {len(plan.files) - 40} more files")
  324. doc.add_heading("Test plan", level=1)
  325. note = doc.add_paragraph()
  326. nr = note.add_run(
  327. "Auto-generated from paths + commit message. Refine before sign-off on critical deploys."
  328. )
  329. set_run_font(nr, size=Pt(9))
  330. nr.italic = True
  331. nr.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
  332. table = doc.add_table(rows=1 + len(plan.tests), cols=3)
  333. table.style = "Table Grid"
  334. headers = ["#", "Steps", "Expected result"]
  335. for i, h in enumerate(headers):
  336. cell = table.rows[0].cells[i]
  337. cell.text = h
  338. for p in cell.paragraphs:
  339. for r in p.runs:
  340. r.bold = True
  341. set_run_font(r)
  342. for idx, (step, exp) in enumerate(plan.tests, start=1):
  343. row = table.rows[idx]
  344. row.cells[0].text = str(idx)
  345. row.cells[1].text = step
  346. row.cells[2].text = exp
  347. for c in row.cells:
  348. for p in c.paragraphs:
  349. for r in p.runs:
  350. set_run_font(r, size=Pt(10))
  351. doc.add_heading("Out of scope / notes", level=1)
  352. add_para(
  353. doc,
  354. "Frontend-only changes may live in FPSMS-frontend — verify paired repo if UI behavior is expected.",
  355. )
  356. add_para(doc, "Rollback: revert this commit or redeploy previous backend build.")
  357. footer = doc.sections[0].footer.paragraphs[0]
  358. footer.text = f"MTMS commit test plan · {plan.short} · {plan.date}"
  359. footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
  360. out_path.parent.mkdir(parents=True, exist_ok=True)
  361. doc.save(out_path)
  362. def write_md(plan: CommitPlan, out_path: Path) -> None:
  363. lines = [
  364. f"# Test plan — {plan.short}",
  365. "",
  366. f"**{plan.subject}**",
  367. "",
  368. f"- Commit: `{plan.sha}`",
  369. f"- Date: {plan.date}",
  370. f"- Author: {plan.author}",
  371. "",
  372. "## Areas",
  373. "",
  374. ]
  375. for a in plan.areas or ["(generic)"]:
  376. lines.append(f"- {a}")
  377. lines += ["", "## Files", ""]
  378. for f in plan.files:
  379. lines.append(f"- `{f}`")
  380. lines += ["", "## Test plan", "", "| # | Steps | Expected result |", "|---|--------|-----------------|"]
  381. for i, (s, e) in enumerate(plan.tests, 1):
  382. lines.append(f"| {i} | {s.replace('|', '/')} | {e.replace('|', '/')} |")
  383. lines += ["", "## Rollback", "", f"- Revert `{plan.short}` / previous build", ""]
  384. out_path.write_text("\n".join(lines), encoding="utf-8")
  385. def write_index(out_dir: Path, plans: list[CommitPlan], files: list[str]) -> None:
  386. lines = [
  387. "# Commit test plans (auto-generated)",
  388. "",
  389. f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
  390. "",
  391. "| Date | SHA | Subject | Word |",
  392. "|------|-----|---------|------|",
  393. ]
  394. for plan, name in zip(plans, files):
  395. subj = plan.subject.replace("|", "/")
  396. lines.append(f"| {plan.date} | `{plan.short}` | {subj} | [{name}]({name}) |")
  397. lines.append("")
  398. (out_dir / "_index.md").write_text("\n".join(lines), encoding="utf-8")
  399. def main() -> None:
  400. ap = argparse.ArgumentParser(description="Generate Word test plans per commit")
  401. ap.add_argument(
  402. "range",
  403. help="Git revision range, e.g. HEAD~10..HEAD (use A..B exclusive-start semantics)",
  404. )
  405. ap.add_argument(
  406. "--out-dir",
  407. type=Path,
  408. default=DEFAULT_OUT,
  409. help=f"Output folder (default: {DEFAULT_OUT.relative_to(ROOT)})",
  410. )
  411. ap.add_argument("--also-md", action="store_true", help="Also write .md next to each .docx")
  412. ap.add_argument(
  413. "--limit",
  414. type=int,
  415. default=0,
  416. help="Max commits to process (0 = all)",
  417. )
  418. args = ap.parse_args()
  419. out_dir = args.out_dir if args.out_dir.is_absolute() else ROOT / args.out_dir
  420. out_dir.mkdir(parents=True, exist_ok=True)
  421. shas = list_commits(args.range)
  422. if args.limit and args.limit > 0:
  423. shas = shas[-args.limit :]
  424. if not shas:
  425. print("No commits in range.", file=sys.stderr)
  426. sys.exit(1)
  427. plans: list[CommitPlan] = []
  428. names: list[str] = []
  429. for sha in shas:
  430. plan = load_commit(sha)
  431. plans.append(plan)
  432. # Stable name per commit (re-run overwrites same SHA)
  433. fname = f"{plan.date}_{plan.short}_{slugify(plan.subject)}.docx"
  434. target = out_dir / fname
  435. write_docx(plan, target)
  436. names.append(target.name)
  437. print(f"Wrote {target.relative_to(ROOT)}")
  438. if args.also_md:
  439. md_path = target.with_suffix(".md")
  440. write_md(plan, md_path)
  441. print(f"Wrote {md_path.relative_to(ROOT)}")
  442. write_index(out_dir, plans, names)
  443. print(f"Wrote { (out_dir / '_index.md').relative_to(ROOT) }")
  444. print(f"Done: {len(plans)} plan(s) → {out_dir.relative_to(ROOT)}")
  445. if __name__ == "__main__":
  446. main()