feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)
这个提交包含在:
可执行文件
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Change Failure Rate reports from regression JSON artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
WINDOW_PATTERN = re.compile(r"^(\d+)d$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegressionRun:
|
||||
timestamp: datetime
|
||||
commit_sha: str
|
||||
failed: bool
|
||||
report_file: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class HotfixEvent:
|
||||
commit_sha: str
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
def parse_iso_datetime(value: str) -> datetime:
|
||||
normalized = value.strip()
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def parse_windows(value: str) -> list[int]:
|
||||
windows: list[int] = []
|
||||
for chunk in value.split(","):
|
||||
item = chunk.strip()
|
||||
if not item:
|
||||
continue
|
||||
matched = WINDOW_PATTERN.match(item)
|
||||
if not matched:
|
||||
raise ValueError(f"invalid window '{item}', expected like 7d/30d/90d")
|
||||
days = int(matched.group(1))
|
||||
if days <= 0:
|
||||
raise ValueError(f"window days must be >0: {item}")
|
||||
windows.append(days)
|
||||
if not windows:
|
||||
windows.append(30)
|
||||
return sorted(set(windows))
|
||||
|
||||
|
||||
def load_regression_runs(input_dir: Path) -> list[RegressionRun]:
|
||||
runs: list[RegressionRun] = []
|
||||
|
||||
for path in sorted(input_dir.rglob("*.json")):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
schema_version = str(payload.get("schema_version", "")).strip()
|
||||
if schema_version != "1.0":
|
||||
continue
|
||||
|
||||
generated_at_raw = payload.get("generated_at")
|
||||
overall = payload.get("overall") if isinstance(payload.get("overall"), dict) else {}
|
||||
git = payload.get("git") if isinstance(payload.get("git"), dict) else {}
|
||||
|
||||
if not generated_at_raw or not isinstance(generated_at_raw, str):
|
||||
continue
|
||||
|
||||
commit_sha = str(git.get("sha", "unknown")).strip() or "unknown"
|
||||
status = str(overall.get("status", "")).strip().lower()
|
||||
|
||||
try:
|
||||
generated_at = parse_iso_datetime(generated_at_raw)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
runs.append(
|
||||
RegressionRun(
|
||||
timestamp=generated_at,
|
||||
commit_sha=commit_sha,
|
||||
failed=status != "passed",
|
||||
report_file=path,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(runs, key=lambda x: x.timestamp)
|
||||
|
||||
|
||||
def load_hotfix_events(path: Path | None) -> list[HotfixEvent]:
|
||||
if path is None:
|
||||
return []
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"hotfix csv not found: {path}")
|
||||
|
||||
events: list[HotfixEvent] = []
|
||||
with path.open(encoding="utf-8", newline="") as fp:
|
||||
reader = csv.DictReader(fp)
|
||||
for row in reader:
|
||||
commit_sha = (row.get("commit_sha") or "").strip()
|
||||
occurred_at_raw = (row.get("occurred_at") or "").strip()
|
||||
if not commit_sha or not occurred_at_raw:
|
||||
continue
|
||||
try:
|
||||
occurred_at = parse_iso_datetime(occurred_at_raw)
|
||||
except Exception:
|
||||
continue
|
||||
events.append(HotfixEvent(commit_sha=commit_sha, occurred_at=occurred_at))
|
||||
|
||||
return sorted(events, key=lambda x: x.occurred_at)
|
||||
|
||||
|
||||
def ratio(numerator: int, denominator: int) -> float:
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return numerator / denominator
|
||||
|
||||
|
||||
def has_hotfix_hit_within_24h(run_times: Iterable[datetime], events: list[HotfixEvent]) -> bool:
|
||||
event_times = [event.occurred_at for event in events]
|
||||
for run_time in run_times:
|
||||
for event_time in event_times:
|
||||
seconds = (event_time - run_time).total_seconds()
|
||||
if 0 <= seconds <= 24 * 3600:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_window_metrics(
|
||||
runs: list[RegressionRun],
|
||||
hotfix_events: list[HotfixEvent],
|
||||
now: datetime,
|
||||
window_days: int,
|
||||
) -> dict[str, object]:
|
||||
cutoff = now - timedelta(days=window_days)
|
||||
window_runs = [run for run in runs if run.timestamp >= cutoff]
|
||||
failed_runs = [run for run in window_runs if run.failed]
|
||||
|
||||
commits: dict[str, list[RegressionRun]] = {}
|
||||
for run in window_runs:
|
||||
commits.setdefault(run.commit_sha, []).append(run)
|
||||
|
||||
hotfix_by_commit: dict[str, list[HotfixEvent]] = {}
|
||||
for event in hotfix_events:
|
||||
if event.occurred_at >= cutoff:
|
||||
hotfix_by_commit.setdefault(event.commit_sha, []).append(event)
|
||||
|
||||
failed_commit_count = 0
|
||||
for commit_sha, commit_runs in commits.items():
|
||||
run_failed = any(run.failed for run in commit_runs)
|
||||
hotfix_hit = has_hotfix_hit_within_24h(
|
||||
(run.timestamp for run in commit_runs),
|
||||
hotfix_by_commit.get(commit_sha, []),
|
||||
)
|
||||
if run_failed or hotfix_hit:
|
||||
failed_commit_count += 1
|
||||
|
||||
total_runs = len(window_runs)
|
||||
total_commits = len(commits)
|
||||
failed_runs_count = len(failed_runs)
|
||||
|
||||
run_cfr = ratio(failed_runs_count, total_runs)
|
||||
commit_cfr = ratio(failed_commit_count, total_commits)
|
||||
|
||||
return {
|
||||
"window": f"{window_days}d",
|
||||
"cutoff": cutoff.isoformat().replace("+00:00", "Z"),
|
||||
"total_runs": total_runs,
|
||||
"failed_runs": failed_runs_count,
|
||||
"run_cfr": round(run_cfr, 6),
|
||||
"run_cfr_pct": round(run_cfr * 100, 2),
|
||||
"total_commits": total_commits,
|
||||
"failed_commits": failed_commit_count,
|
||||
"commit_cfr": round(commit_cfr, 6),
|
||||
"commit_cfr_pct": round(commit_cfr * 100, 2),
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(result: dict[str, object]) -> str:
|
||||
lines = [
|
||||
"# Change Failure Rate 报告",
|
||||
"",
|
||||
f"- 生成时间: {result['generated_at']}",
|
||||
f"- 输入目录: `{result['input_dir']}`",
|
||||
f"- 报告总数: {result['reports_scanned']}",
|
||||
"",
|
||||
"| Window | Total Runs | Failed Runs | Run CFR | Total Commits | Failed Commits | Commit CFR |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
]
|
||||
|
||||
windows: dict[str, dict[str, object]] = result["windows"] # type: ignore[assignment]
|
||||
for key in sorted(windows.keys(), key=lambda x: int(x[:-1])):
|
||||
item = windows[key]
|
||||
lines.append(
|
||||
f"| {key} | {item['total_runs']} | {item['failed_runs']} | {item['run_cfr_pct']}% | "
|
||||
f"{item['total_commits']} | {item['failed_commits']} | {item['commit_cfr_pct']}% |"
|
||||
)
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate Change Failure Rate report")
|
||||
parser.add_argument("--input-dir", required=True, help="Directory that stores regression JSON artifacts")
|
||||
parser.add_argument("--window", default="30d", help="Comma separated windows, e.g. 7d,30d,90d")
|
||||
parser.add_argument("--output-json", help="Optional output JSON file path")
|
||||
parser.add_argument("--output-md", help="Optional output markdown file path")
|
||||
parser.add_argument("--hotfix-csv", help="Optional hotfix CSV path (columns: commit_sha,occurred_at)")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
input_dir = Path(args.input_dir).resolve()
|
||||
if not input_dir.exists():
|
||||
print(f"[ERR] input dir not found: {input_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
windows = parse_windows(args.window)
|
||||
except ValueError as exc:
|
||||
print(f"[ERR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
hotfix_events = load_hotfix_events(Path(args.hotfix_csv).resolve() if args.hotfix_csv else None)
|
||||
except Exception as exc:
|
||||
print(f"[ERR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
runs = load_regression_runs(input_dir)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
result: dict[str, object] = {
|
||||
"generated_at": now.isoformat().replace("+00:00", "Z"),
|
||||
"input_dir": str(input_dir),
|
||||
"reports_scanned": len(runs),
|
||||
"hotfix_events": len(hotfix_events),
|
||||
"windows": {},
|
||||
}
|
||||
|
||||
for days in windows:
|
||||
result["windows"][f"{days}d"] = build_window_metrics(runs, hotfix_events, now, days) # type: ignore[index]
|
||||
|
||||
rendered_json = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
if args.output_json:
|
||||
output_json = Path(args.output_json)
|
||||
output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_json.write_text(rendered_json, encoding="utf-8")
|
||||
|
||||
if args.output_md:
|
||||
output_md = Path(args.output_md)
|
||||
output_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_md.write_text(render_markdown(result), encoding="utf-8")
|
||||
|
||||
if not args.output_json and not args.output_md:
|
||||
print(rendered_json, end="")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -24,9 +24,10 @@ CSV_FACT_SOURCES = [
|
||||
ROOT / "issues" / "2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv",
|
||||
ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv",
|
||||
ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv",
|
||||
ROOT / "issues" / "2026-02-12_01-09-41-p13-l5-governance.csv",
|
||||
]
|
||||
|
||||
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z]*$")
|
||||
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$")
|
||||
STATUS_INDEX = 6
|
||||
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户