|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- Auto-generate a Word (.docx) test plan for each commit in a git range.
-
- Usage (from repo root):
- pip install python-docx
- python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD
- python scripts/generate_commit_test_plans_docx.py abc123..def456 --out-dir docs/deploy/commit-plans
- python scripts/generate_commit_test_plans_docx.py HEAD~5..HEAD --also-md
-
- Output folder (default): docs/deploy/commit-plans/
- <date>_<shortsha>_<slug>.docx — one file per commit
- _index.md — list of generated plans
-
- Notes:
- - Plans are heuristic from commit message + touched paths (good starting point for QA).
- - For critical releases, ask the agent to refine steps against the actual diff.
- """
-
- from __future__ import annotations
-
- import argparse
- import re
- import subprocess
- import sys
- from dataclasses import dataclass, field
- from datetime import datetime, timezone
- from pathlib import Path
-
- from docx import Document
- from docx.enum.text import WD_ALIGN_PARAGRAPH
- from docx.oxml.ns import qn
- from docx.shared import Cm, Pt, RGBColor
-
- ROOT = Path(__file__).resolve().parents[1]
- DEFAULT_OUT = ROOT / "docs" / "deploy" / "commit-plans"
-
- # (path substring, area label, suggested steps, expected results)
- AREA_RULES: list[tuple[str, str, list[tuple[str, str]]]] = [
- (
- "deliveryOrder",
- "送貨訂單 / 成品出倉",
- [
- ("「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。", "放單成功;產生提料票,無未預期 500。"),
- ("「成品出倉」`/doworkbench`:撳單 → 掃碼提料 → 填箱數列印。", "票可撳、可提、可完成;狀態「待撳單」→「提貨中」→「已完成」。"),
- ("若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。", "票出現在正確樓層/車線;指派篩選與顯示一致。"),
- ],
- ),
- (
- "pickOrder",
- "提料單 / Workbench",
- [
- ("「成品出倉」撳單並完成一張提料票(測試庫)。", "掃碼/提交正常;完成後記錄頁可見。"),
- ("「查看提貨情況」核對該票狀態。", "狀態與負責人符合操作。"),
- ],
- ),
- (
- "SuggestedPickLot",
- "建議批號 / UOM",
- [
- ("出倉或提料時掃建議批號;刻意掃 UOM 不符批號。", "不符 UOM 被擋並有明確提示;相符批號可提交。"),
- ("標籤列印/批號列表只應出現同 UOM 選項(若本次改動涵蓋)。", "列表無錯誤 UOM 批號。"),
- ],
- ),
- (
- "job_order",
- "工單",
- [
- ("「搜索工單/ 建立工單」建立或搜索受影響工單。", "列表/詳情資料正確。"),
- ("依狀態走「放單」→「工單提料」或「工單生產流程」。", "狀態轉換符合預期,無阻塞錯誤。"),
- ],
- ),
- (
- "JobOrder",
- "工單",
- [
- ("「搜索工單/ 建立工單」驗證建立/搜索/放單。", "成功提示;狀態正確。"),
- ],
- ),
- (
- "modules/bom",
- "BOM",
- [
- ("「設定」→「匯入 BOM」/「BOM 明細」搜索受影響成品。", "可載入明細;啟用狀態正確。"),
- ("「建立工單」下拉是否仍能選該 BOM。", "啟用可選、停用不可選。"),
- ],
- ),
- (
- "production_schedule",
- "排程",
- [
- ("「排程」→「生產排程」搜索相關生產日 →「詳細」。", "明細數量合理。"),
- ("(測試庫)「自動生成工單」。", "產生工單或明確錯誤訊息。"),
- ],
- ),
- (
- "ProductionSchedule",
- "排程",
- [
- ("「排程」頁驗證預測/搜索/詳細。", "畫面與 API 正常。"),
- ],
- ),
- (
- "stock",
- "庫存 / 上架 / 出入倉",
- [
- ("依改動點進「上架掃碼」或相關庫存查詢頁。", "掃碼/查詢結果與庫存數量合理。"),
- ("做一筆小量入/出/調撥(測試庫)。", "成功;庫存異動可查。"),
- ],
- ),
- (
- "StockIn",
- "來貨 / 品檢",
- [
- ("「工單生產流程」→「品檢」或待 QC 列表。", "可開品檢;確定後狀態更新。"),
- ],
- ),
- (
- "putAway",
- "上架",
- [
- ("「上架掃碼」:貨品 QR → 倉庫 QR →「確定及上架貨物」。", "上架成功;待上架數量減少。"),
- ],
- ),
- (
- "m18",
- "M18 同步",
- [
- ("對受影響主檔執行同步或查看最近同步結果(測試環境)。", "對應欄位寫入 MTMS;錯誤有日誌。"),
- ],
- ),
- (
- "onpack2030",
- "標籤 / OnPack",
- [
- ("對新增/修改的貨品編號列印標籤(測試機)。", "圖檔/job 正確;可印出。"),
- ],
- ),
- (
- "db/changelog",
- "資料庫變更",
- [
- ("部署後確認 Liquibase/changelog 已套用(或啟動 log 無 changeset 失敗)。", "DB schema/資料符合 changeset。"),
- ("用相關畫面或 SQL 抽樣驗證新欄位/約束。", "讀寫正常,無缺欄錯誤。"),
- ],
- ),
- (
- "modules/master",
- "主檔 (Item/Shop 等)",
- [
- ("主檔搜索受影響編號,核對顯示欄位。", "名稱/單位/類型等與預期一致。"),
- ],
- ),
- (
- "Inventory",
- "庫存查詢",
- [
- ("庫存搜索頁用受影響貨品/倉位查詢。", "批號、數量、單位正確。"),
- ],
- ),
- ]
-
- KEYWORD_HINTS: list[tuple[re.Pattern[str], tuple[str, str]]] = [
- (
- re.compile(r"uom|單位", re.I),
- ("掃一筆 UOM 不符的批號/物料。", "系統拒絕或明確提示;相符者可過。"),
- ),
- (
- re.compile(r"isextra|etra|加單", re.I),
- ("「成品出倉」→「加單」檢視該日票。", "加單票可見且可撳單。"),
- ),
- (
- re.compile(r"truck\s*x|車線-?x|車線x", re.I),
- ("核對「車線-X」票在 2/F/4/F 顯示。", "出現在正確樓層區塊,可指派。"),
- ),
- (
- re.compile(r"label|print|列印|標籤|onpack", re.I),
- ("選打印機後列印標籤/送貨單標籤。", "成功列印;內容正確。"),
- ),
- (
- re.compile(r"fix|bug|repair|修正|修復", re.I),
- ("重現原問題步驟一次。", "問題不再出現;無新副作用。"),
- ),
- ]
-
-
- @dataclass
- class CommitPlan:
- sha: str
- short: str
- subject: str
- body: str
- author: str
- date: str
- files: list[str] = field(default_factory=list)
- areas: list[str] = field(default_factory=list)
- tests: list[tuple[str, str]] = field(default_factory=list)
-
-
- def run(cmd: list[str]) -> str:
- r = subprocess.run(
- cmd,
- cwd=ROOT,
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="replace",
- )
- if r.returncode != 0:
- raise SystemExit((r.stderr or r.stdout or f"failed: {cmd}").strip())
- return r.stdout.strip()
-
-
- def list_commits(rev_range: str) -> list[str]:
- out = run(["git", "log", "--reverse", "--format=%H", rev_range])
- return [ln.strip() for ln in out.splitlines() if ln.strip()]
-
-
- def load_commit(sha: str) -> CommitPlan:
- subject = run(["git", "log", "-1", "--format=%s", sha])
- body = run(["git", "log", "-1", "--format=%b", sha])
- author = run(["git", "log", "-1", "--format=%an", sha])
- date = run(["git", "log", "-1", "--format=%cs", sha])
- files_raw = run(["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha])
- files = [f for f in files_raw.splitlines() if f.strip()]
- plan = CommitPlan(
- sha=sha,
- short=sha[:8],
- subject=subject or "(no message)",
- body=(body or "").strip(),
- author=author,
- date=date,
- files=files,
- )
- build_tests(plan)
- return plan
-
-
- def build_tests(plan: CommitPlan) -> None:
- seen_areas: set[str] = set()
- seen_steps: set[str] = set()
- tests: list[tuple[str, str]] = []
-
- def add(step: str, expected: str) -> None:
- key = step.strip()
- if key in seen_steps:
- return
- seen_steps.add(key)
- tests.append((step, expected))
-
- blob = " ".join(plan.files) + "\n" + plan.subject + "\n" + plan.body
-
- for path_key, area, pairs in AREA_RULES:
- if any(path_key in f.replace("\\", "/") for f in plan.files) or path_key.lower() in blob.lower():
- if area not in seen_areas:
- seen_areas.add(area)
- plan.areas.append(area)
- for step, exp in pairs:
- add(step, exp)
-
- for pat, pair in KEYWORD_HINTS:
- if pat.search(blob):
- add(pair[0], pair[1])
-
- # Generic fallbacks
- if not tests:
- if plan.files:
- add(
- f"依 commit 變更檔抽樣驗證(共 {len(plan.files)} 個檔)。主要檔:{plan.files[0]}",
- "相關 API/畫面無 500;行為符合 commit 說明。",
- )
- else:
- add("確認此 commit 無業務檔變更(empty / merge)。", "無需功能測試或僅煙霧測試。")
-
- add(
- "部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。",
- "頁面可開、無全域錯誤橫幅。",
- )
- add(
- "(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。",
- "無明顯回退。",
- )
-
- if not plan.subject or plan.subject.strip().lower() in ("no message", "(no message)"):
- add(
- "向作者確認此 commit 的實際意圖(訊息為 empty/no message)。",
- "補上說明後再簽核上線。",
- )
-
- plan.tests = tests
-
-
- def slugify(text: str, max_len: int = 40) -> str:
- """ASCII-only filename slug (avoids Windows console / zip garbling)."""
- tokens = re.findall(r"[a-z0-9]+", text.lower())
- seen: set[str] = set()
- ordered: list[str] = []
- for t in tokens:
- if t in seen or len(t) < 2:
- continue
- seen.add(t)
- ordered.append(t)
- s = "_".join(ordered)[:max_len].strip("_")
- return s or "commit"
-
-
- def set_run_font(run, *, east_asia: str = "Microsoft JhengHei", ascii_font: str = "Calibri", size: Pt | None = None) -> None:
- run.font.name = ascii_font
- run._element.rPr.rFonts.set(qn("w:eastAsia"), east_asia)
- if size is not None:
- run.font.size = size
-
-
- def add_para(doc: Document, text: str, *, bold: bool = False, italic: bool = False, size: Pt | None = None) -> None:
- p = doc.add_paragraph()
- run = p.add_run(text)
- set_run_font(run, size=size or Pt(11))
- run.bold = bold
- run.italic = italic
-
-
- def write_docx(plan: CommitPlan, out_path: Path) -> None:
- doc = Document()
- section = doc.sections[0]
- section.top_margin = Cm(1.8)
- section.bottom_margin = Cm(1.8)
- section.left_margin = Cm(2)
- section.right_margin = Cm(2)
-
- style = doc.styles["Normal"]
- style.font.name = "Calibri"
- style.font.size = Pt(11)
- style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
-
- title = doc.add_heading(level=0)
- tr = title.add_run(f"Test plan — {plan.short}")
- set_run_font(tr, size=Pt(18))
-
- add_para(doc, plan.subject, bold=True, size=Pt(12))
- meta = (
- f"Commit: {plan.sha}\n"
- f"Date: {plan.date} | Author: {plan.author}\n"
- f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
- )
- add_para(doc, meta, italic=True, size=Pt(9))
-
- doc.add_heading("Summary", level=1)
- add_para(doc, plan.subject)
- if plan.body:
- add_para(doc, plan.body)
-
- doc.add_heading("Areas (auto-detected)", level=1)
- if plan.areas:
- for a in plan.areas:
- doc.add_paragraph(a, style="List Bullet")
- else:
- add_para(doc, "(No area rule matched — generic steps only)")
-
- doc.add_heading("Files touched", level=1)
- for f in plan.files[:40]:
- doc.add_paragraph(f, style="List Bullet")
- if len(plan.files) > 40:
- add_para(doc, f"… and {len(plan.files) - 40} more files")
-
- doc.add_heading("Test plan", level=1)
- note = doc.add_paragraph()
- nr = note.add_run(
- "Auto-generated from paths + commit message. Refine before sign-off on critical deploys."
- )
- set_run_font(nr, size=Pt(9))
- nr.italic = True
- nr.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
-
- table = doc.add_table(rows=1 + len(plan.tests), cols=3)
- table.style = "Table Grid"
- headers = ["#", "Steps", "Expected result"]
- for i, h in enumerate(headers):
- cell = table.rows[0].cells[i]
- cell.text = h
- for p in cell.paragraphs:
- for r in p.runs:
- r.bold = True
- set_run_font(r)
-
- for idx, (step, exp) in enumerate(plan.tests, start=1):
- row = table.rows[idx]
- row.cells[0].text = str(idx)
- row.cells[1].text = step
- row.cells[2].text = exp
- for c in row.cells:
- for p in c.paragraphs:
- for r in p.runs:
- set_run_font(r, size=Pt(10))
-
- doc.add_heading("Out of scope / notes", level=1)
- add_para(
- doc,
- "Frontend-only changes may live in FPSMS-frontend — verify paired repo if UI behavior is expected.",
- )
- add_para(doc, "Rollback: revert this commit or redeploy previous backend build.")
-
- footer = doc.sections[0].footer.paragraphs[0]
- footer.text = f"MTMS commit test plan · {plan.short} · {plan.date}"
- footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
-
- out_path.parent.mkdir(parents=True, exist_ok=True)
- doc.save(out_path)
-
-
- def write_md(plan: CommitPlan, out_path: Path) -> None:
- lines = [
- f"# Test plan — {plan.short}",
- "",
- f"**{plan.subject}**",
- "",
- f"- Commit: `{plan.sha}`",
- f"- Date: {plan.date}",
- f"- Author: {plan.author}",
- "",
- "## Areas",
- "",
- ]
- for a in plan.areas or ["(generic)"]:
- lines.append(f"- {a}")
- lines += ["", "## Files", ""]
- for f in plan.files:
- lines.append(f"- `{f}`")
- lines += ["", "## Test plan", "", "| # | Steps | Expected result |", "|---|--------|-----------------|"]
- for i, (s, e) in enumerate(plan.tests, 1):
- lines.append(f"| {i} | {s.replace('|', '/')} | {e.replace('|', '/')} |")
- lines += ["", "## Rollback", "", f"- Revert `{plan.short}` / previous build", ""]
- out_path.write_text("\n".join(lines), encoding="utf-8")
-
-
- def write_index(out_dir: Path, plans: list[CommitPlan], files: list[str]) -> None:
- lines = [
- "# Commit test plans (auto-generated)",
- "",
- f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
- "",
- "| Date | SHA | Subject | Word |",
- "|------|-----|---------|------|",
- ]
- for plan, name in zip(plans, files):
- subj = plan.subject.replace("|", "/")
- lines.append(f"| {plan.date} | `{plan.short}` | {subj} | [{name}]({name}) |")
- lines.append("")
- (out_dir / "_index.md").write_text("\n".join(lines), encoding="utf-8")
-
-
- def main() -> None:
- ap = argparse.ArgumentParser(description="Generate Word test plans per commit")
- ap.add_argument(
- "range",
- help="Git revision range, e.g. HEAD~10..HEAD (use A..B exclusive-start semantics)",
- )
- ap.add_argument(
- "--out-dir",
- type=Path,
- default=DEFAULT_OUT,
- help=f"Output folder (default: {DEFAULT_OUT.relative_to(ROOT)})",
- )
- ap.add_argument("--also-md", action="store_true", help="Also write .md next to each .docx")
- ap.add_argument(
- "--limit",
- type=int,
- default=0,
- help="Max commits to process (0 = all)",
- )
- args = ap.parse_args()
-
- out_dir = args.out_dir if args.out_dir.is_absolute() else ROOT / args.out_dir
- out_dir.mkdir(parents=True, exist_ok=True)
-
- shas = list_commits(args.range)
- if args.limit and args.limit > 0:
- shas = shas[-args.limit :]
- if not shas:
- print("No commits in range.", file=sys.stderr)
- sys.exit(1)
-
- plans: list[CommitPlan] = []
- names: list[str] = []
- for sha in shas:
- plan = load_commit(sha)
- plans.append(plan)
- # Stable name per commit (re-run overwrites same SHA)
- fname = f"{plan.date}_{plan.short}_{slugify(plan.subject)}.docx"
- target = out_dir / fname
- write_docx(plan, target)
- names.append(target.name)
- print(f"Wrote {target.relative_to(ROOT)}")
- if args.also_md:
- md_path = target.with_suffix(".md")
- write_md(plan, md_path)
- print(f"Wrote {md_path.relative_to(ROOT)}")
-
- write_index(out_dir, plans, names)
- print(f"Wrote { (out_dir / '_index.md').relative_to(ROOT) }")
- print(f"Done: {len(plans)} plan(s) → {out_dir.relative_to(ROOT)}")
-
-
- if __name__ == "__main__":
- main()
|