Skill: autoxj/gitcode-pr-audit (version 1.0.0)

--- name: gitcode-pr-audit description: "Quality audit for merged GitCode PRs: sample by time range or repo list, check compliance (labels, comments, tests, size, etc.), output table. Use when user asks to 抽检/质量检查 已合入的 PR 规范性、多仓库 PR、或 将结果整理成表格. Multi-repo (owner/repo). Python 3.7+ stdlib only." metadata: {"openclaw": {"requires": {"env": ["GITCODE_TOKEN"]}, "primaryEnv": "GITCODE_TOKEN"}} --- # GitCode 合入 PR 质量抽检 对指定时间范围内合入主分支的 PR 进行抽检，按多维度判断质量并输出表格。支持多仓库、可配置阈值与必选标签，输出可写为 Markdown 或 CSV。 ## 何时使用 - 用户表达以下任一意图时使用本 skill： - **规范性抽检/质量抽检**：如「对已合入的 PR 进行质量抽检」「最近 30 天合入的 PR 抽检」「已合入 PR 规范性检查」； - **多仓库/仓库列表**：如「对以下仓库的 PR 抽检」「对owner组织下某几个仓库的 PR 检查」； - **输出形式**：如「将抽检结果整理成表格」「输出表格形式」。 - 需提供 **至少一个仓库**（格式 `owner/repo`）；可多仓库、可选时间（默认最近 30 天）与分支、可选抽检数量或「全部」检查。 ## 认证 **GITCODE_TOKEN**：按以下优先级读取。 | 优先级 | 来源 | |--------|------| | 1 | 进程环境变量 `GITCODE_TOKEN` | | 2 | Windows 用户级环境变量 | | 3 | Windows 系统级环境变量 | - Linux/macOS：在 `~/.bashrc` 或 `~/.zshrc` 中 `export GITCODE_TOKEN="..."`。 - 未配置时：脚本报错并提示到 [GitCode 个人访问令牌](https://gitcode.com/setting/token-classic) 创建并设置环境变量。 ## 路径与跨平台 - **技能根目录**（`SKILL_ROOT`）：本 SKILL.md 所在目录。脚本通过 `__file__` 定位，不依赖当前工作目录。 - 支持 **Linux / macOS / Windows**；执行时使用脚本**绝对路径**（如 `python <SKILL_ROOT>/scripts/pr_audit.py ...`）。 ## 固化流程 1. **解析参数**：从用户输入提取 `--repo`（可多个）、`--pr`（可多个）、`--branch`、`--since`、`--until`、`--days`、`--all`、`--output` 等。 2. **调用脚本**： ```bash python <SKILL_ROOT>/scripts/pr_audit.py --repo owner/repo [--pr N] [--pr N2 ...] [--branch BRANCH] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--days N] [--all] [--output path.md|path.csv] ``` - **指定 PR 时**：使用 `--pr N`（可多次）并**且仅指定一个** `--repo`，脚本只检查这些 PR，不按时间范围拉取。 - 未指定 `--pr` 时：未指定时间则默认 **最近 30 天**（`--days 30`）；指定 `--since`/`--until` 则按用户时间。 - 未指定分支时，每个仓库依次尝试 **master → develop → main**，都不存在则报错；指定则用指定分支，不存在则报错。 - 未指定抽检数量时，**每个仓库**随机抽 **5～10 条**分析，不足 5 条则全部分析；用户明确「全部」或「所有」或传 `--all` 时，该时间范围内合入的 PR **全部**检查。 3. **读取结果**：脚本将**表格输出到 stdout**（Markdown 格式）；若指定 `--output` 则同时写入该文件（.md 或 .csv）。退出码非 0 或 stderr 有错误时，向用户展示错误并结束。 4. **成功**：将 stdout 表格呈现给用户；若写入了文件，说明路径。 ## 脚本参数 | 参数 | 必填 | 说明 | |------|------|------| | `--repo` | 是（可多次） | 仓库，格式 `owner/repo`，可多次传入多仓库 | | `--pr` | 否（可多次） | 指定 PR 编号；与时间范围二选一，使用时常需且仅需一个 `--repo` | | `--branch` | 否 | 目标分支；未传时自动尝试 master → develop → main | | `--since` | 否 | 起始日期 YYYY-MM-DD（含该日 00:00 上海时间） | | `--until` | 否 | 结束日期 YYYY-MM-DD（含该日 24:00 前） | | `--days` | 否 | 最近 N 天（默认 30）；与 since/until 二选一，未指定 since/until 时生效 | | `--all` | 否 | 不抽样，该时间范围内合入的 PR 全部检查 | | `--output` | 否 | 输出文件路径，扩展名 .md 或 .csv，UTF-8（CSV 带 BOM） | | `--config` | 否 | 配置文件路径，未传则使用技能根目录下 config.json | - **指定 PR**：传 `--pr N`（可多个）时仅检查这些 PR，需且仅需一个 `--repo`，不按时间筛选。 - 时间约定：仅用 `--days`、或仅用 `--since`/`--until`。若同时传，脚本按 `--since`/`--until` 优先。 - 多仓库时：**每个仓库**各自按上述规则抽样或全部检查（方案 A）。 ## 分析维度（8 项） | 维度 | 说明 | 判定 | |------|------|------| | 评论未解决 | 是否存在未解决的 diff 检视意见 | PR 评论中 `comment_type == "diff_comment"` 且 `resolved == false` 则记为「是」 | | 流水线/标签 | 是否缺少必选标签（含流水线通过） | 配置的 `required_labels` 缺任一则「是」 | | 缺必选标签 | 同上，与「流水线」合并为配置的必选标签集合 | 见 config.json `required_labels` | | 新增超行数 | 新增代码行是否超过阈值 | 超过 `max_additions`（默认 1000）则「是」 | | 无测试 | 修改文件是否包含测试 | 变更文件路径中均不包含 `test` 或 `ut` 则「是」 | | 多 Issue | 是否关联超过 1 个 Issue | 关联 Issue 数 > 1 则「是」 | | 缺检视 | 大 PR 是否缺乏检视意见 | 新增行数 > `review_lines_threshold`（默认 500）且 diff 评论数为 0 则「是」 | | 标题/描述不清晰 | 标题与描述是否过短或过于笼统 | 标题长度 < `title_min_length`（默认 5）或描述长度 < `body_min_length`（默认 10）则「是」；满足长度后若标题/描述明显含糊（如仅「fix」「修改」无实质说明），则判为「是」 | - **表格列**：仓库、**PR**（以 `#编号` 带链接，无单独链接列）、标题、各维度详情、问题汇总。 - **维度列为详情文案**： - 评论未解决：`否（已解决数/总 diff 评论数）` 或 `是（未解决数/总数）`，如 `否（1/10）`、`是（2/5）`。 - 缺必选标签：`否` 或 `是（缺：label1、label2）`。 - 新增超行数：`否（实际行数）` 或 `是（实际行数）`。 - 多 Issue：`否（0）` 或 `是（N）`。 - 缺检视：`否` 或 `是（新增 N 行，0 个检视）`。 - 标题/描述不清晰：`否` 或 `是（原因，如标题过短、描述过短、描述笼统）`。 - 最后一列「问题汇总」为该项 PR 所有不通过维度的简短罗列。 ## 配置（config.json） 位于技能根目录，或通过 `--config` 指定。字段说明： | 字段 | 含义 | 默认 | |------|------|------| | `required_labels` | 必选标签列表（缺一即不通过） | `["ci-pipeline-passed", "approved", "lgtm"]` | | `max_additions` | 新增行数阈值，超过记为「新增超行数」 | 1000 | | `review_lines_threshold` | 超过此行数且无 diff 评论则记为「缺检视」 | 500 | | `title_min_length` | 标题最小字符数 | 5 | | `body_min_length` | 描述最小字符数 | 10 | | `test_substrings` | 判定「含测试」时路径需包含的子串 | `["test", "ut"]` | ## 输出格式 - **表格列**：仓库、PR（`#编号` 带链接）、标题、评论未解决、缺必选标签、新增超行数、无测试、多 Issue、缺检视、标题/描述不清晰、问题汇总（无单独链接列，链接在 PR 列）。 - **stdout**：始终输出 Markdown 表格（便于控制台查看）。 - **--output .md**：与 stdout 相同的 Markdown 表格写入文件。 - **--output .csv**：同一内容写入 CSV，UTF-8 带 BOM，便于 Excel 打开。 ## 禁止 - 禁止在未得到脚本输出前猜测或伪造数据。 - 禁止在用户未提供至少一个 `--repo` 时执行脚本。 ## 示例 | 用户意图 | 命令 | |----------|------| | 最近 30 天单仓抽检 | `python <S> --repo owner/repo` | | 指定 PR 检查 | `python <S> --repo owner/repo --pr 2905 --pr 2606` | | 指定时间多仓抽检 | `python <S> --repo org/a --repo org/b --since 2026-02-01 --until 2026-03-10` | | 全部检查不抽样 | `python <S> --repo owner/repo --days 7 --all` | | 指定分支并写 CSV | `python <S> --repo owner/repo --branch main --output report.csv` | | 自定义配置 | `python <S> --repo owner/repo --config /path/to/config.json` | 其中 `<S>` 为 `<SKILL_ROOT>/scripts/pr_audit.py` 的绝对路径。 ## 历史版本 **v1.0.0** (2026-03-11) - 🎉 初始版本发布 - 📊 支持按时间范围抽检、8 维度质量分析

path: config.json
content: {
  "required_labels": ["ci-pipeline-passed", "approved", "lgtm"],
  "max_additions": 1000,
  "review_lines_threshold": 500,
  "title_min_length": 5,
  "body_min_length": 10,
  "test_substrings": ["test", "ut"]
}


path: scripts/pr_audit.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GitCode 合入 PR 质量抽检：按时间范围拉取已合并 PR，按多维度检查，输出表格。
仅使用 Python 3.7+ 标准库；需 GITCODE_TOKEN。
支持 Linux / macOS / Windows。
"""

import argparse
import csv
import json
import os
import random
import subprocess
import sys
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

GITCODE_API_BASE = "https://api.gitcode.com/api/v5"
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_ROOT = SCRIPT_DIR.parent

DEFAULT_TIMEZONE = "Asia/Shanghai"
SHANGHAI_UTC_OFFSET_HOURS = 8
REQUEST_SLEEP_SEC = 0.3
REQUEST_TIMEOUT_SEC = 30
RETRY_TIMES = 2
RETRY_INTERVAL_SEC = 2
DEFAULT_DAYS = 30
SAMPLE_MIN, SAMPLE_MAX = 5, 10

# 表格列：无单独“链接”，PR 列含链接；各维度列为详情文案
HEADERS = [
    "仓库", "PR", "标题",
    "评论未解决", "缺必选标签", "新增超行数", "无测试", "多Issue", "缺检视", "标题/描述不清晰",
    "问题汇总",
]
DIMENSION_KEYS = [
    "comment_detail", "labels_detail", "additions_detail", "no_test_detail",
    "multi_issue_detail", "lack_review_detail", "title_body_unclear_detail",
]
DIMENSION_NAMES_CN = [
    "评论未解决", "缺必选标签", "新增超行数", "无测试", "多Issue", "缺检视", "标题/描述不清晰",
]
# 用于 problem_summary 的布尔键（与详情对应）
DIMENSION_BOOL_KEYS = [
    "comment_unresolved", "missing_labels", "over_additions", "no_test",
    "multi_issue", "lack_review", "title_body_unclear",
]


def _get_token_windows(scope):
    """Windows 下读取用户级或系统级 GITCODE_TOKEN。"""
    if sys.platform != "win32":
        return None
    try:
        out = subprocess.check_output(
            [
                "powershell",
                "-NoProfile",
                "-Command",
                "[Environment]::GetEnvironmentVariable('GITCODE_TOKEN','%s')" % scope,
            ],
            creationflags=0x08000000,
            timeout=5,
            stderr=subprocess.DEVNULL,
        )
        if out:
            return out.decode("utf-8", errors="replace").strip()
    except Exception:
        pass
    return None


def get_token():
    """GITCODE_TOKEN：进程环境变量 → Windows 用户级 → 系统级。"""
    [REDACTED_SECRET]"GITCODE_TOKEN")
    if [REDACTED_SECRET] token.strip()
    for scope in ("User", "Machine"):
        t = _get_token_windows(scope)
        if t:
            return t
    return None


def _err_detail(e, path, url=None):
    parts = [path]
    if isinstance(e, HTTPError):
        parts.append("HTTP %s" % e.code)
        try:
            body = e.read().decode("utf-8", errors="replace")[:500]
            if body.strip():
                parts.append("body: %s" % body.strip())
        except Exception:
            pass
    else:
        parts.append(str(e))
    if url:
        parts.append("url: %s" % url)
    return " | ".join(parts)


def api_get(token, path, params=None, timeout_sec=REQUEST_TIMEOUT_SEC):
    """请求 GitCode API；请求后 sleep；失败重试。返回 (data, error_str)。"""
    url = GITCODE_API_BASE.rstrip("/") + "/" + path.lstrip("/")
    if params:
        url = url + ("&" if "?" in url else "?") + urlencode(params)
    url = url.replace(" ", "%20")
    req = Request(url, headers={"PRIVATE-TOKEN": token})
    last_err = None
    for attempt in range(RETRY_TIMES + 1):
        try:
            with urlopen(req, timeout=timeout_sec) as resp:
                time.sleep(REQUEST_SLEEP_SEC)
                raw = resp.read().decode("utf-8")
                return (json.loads(raw), None)
        except HTTPError as e:
            last_err = _err_detail(e, path, url)
            if e.code == 429:
                wait = int(e.headers.get("Retry-After", 60))
                time.sleep(min(wait, 120))
            else:
                time.sleep(RETRY_INTERVAL_SEC)
        except (URLError, OSError, ValueError) as e:
            last_err = _err_detail(e, path, url)
            time.sleep(RETRY_INTERVAL_SEC)
        except Exception as e:
            last_err = _err_detail(e, path, url)
            break
    return None, (last_err or "请求失败")


def get_branches(token, owner, repo):
    """GET /repos/:owner/:repo/branches。"""
    return api_get(token, "repos/%s/%s/branches" % (owner, repo), {"per_page": 100})


def resolve_branch(token, owner, repo, specified_branch=None):
    """
    若 specified_branch 有值则校验该分支存在；否则依次尝试 master → develop → main。
    返回 (branch_name, error)。
    """
    data, err = get_branches(token, owner, repo)
    if err:
        return None, "获取分支列表失败: %s" % err
    names = {b.get("name") for b in (data or []) if b.get("name")}
    if specified_branch:
        if specified_branch in names:
            return specified_branch, None
        return None, "仓库 %s/%s 中未找到分支: %s" % (owner, repo, specified_branch)
    for candidate in ("master", "develop", "main"):
        if candidate in names:
            return candidate, None
    return None, "仓库 %s/%s 中未找到 master、develop 或 main，请使用 --branch 指定。当前: %s" % (
        owner, repo, ", ".join(sorted(names)[:20]) or "无",
    )


def parse_date_utc(date_str, end_of_day=False):
    """YYYY-MM-DD 转为 UTC 时间（上海 00:00 或 23:59:59），带 timezone。"""
    try:
        local_dt = datetime.strptime(date_str.strip()[:10], "%Y-%m-%d")
        utc_dt = local_dt - timedelta(hours=SHANGHAI_UTC_OFFSET_HOURS)
        if end_of_day:
            utc_dt = utc_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
        return utc_dt.replace(tzinfo=timezone.utc)
    except ValueError:
        return None


def load_config(config_path=None):
    """加载 config.json，缺失键用默认值。"""
    path = Path(config_path) if config_path else (SKILL_ROOT / "config.json")
    defaults = {
        "required_labels": ["ci-pipeline-passed", "approved", "lgtm"],
        "max_additions": 1000,
        "review_lines_threshold": 500,
        "title_min_length": 5,
        "body_min_length": 10,
        "test_substrings": ["test", "ut"],
    }
    if not path.is_file():
        return defaults
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        for k, v in defaults.items():
            if k not in data:
                data[k] = v
        return data
    except Exception:
        return defaults


def list_merged_pulls(token, owner, repo, base_ref, since_utc, until_utc):
    """
    拉取 state=merged 的 PR，过滤 base.ref == base_ref 且 merged_at 在 [since_utc, until_utc]。
    返回 (list of {number, title, merged_at, html_url}, error)。
    """
    result = []
    page = 1
    per_page = 100
    while True:
        data, err = api_get(
            token,
            "repos/%s/%s/pulls" % (owner, repo),
            {"state": "merged", "per_page": per_page, "page": page},
        )
        if err:
            return [], err
        lst = data if isinstance(data, list) else []
        if not lst:
            break
        for pr in lst:
            base = pr.get("base") or {}
            ref = (base.get("ref") or base.get("name") or "").strip()
            if ref != base_ref:
                continue
            merged_at = pr.get("merged_at") or ""
            if not merged_at:
                continue
            try:
                if "T" in merged_at:
                    dt = datetime.fromisoformat(merged_at.replace("Z", "+00:00"))
                else:
                    dt = datetime.strptime(merged_at[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
                if dt.tzinfo is None:
                    dt = dt.replace(tzinfo=timezone.utc)
            except Exception:
                continue
            if since_utc and dt < since_utc:
                continue
            if until_utc and dt > until_utc:
                continue
            result.append({
                "number": pr.get("number") or pr.get("id"),
                "title": (pr.get("title") or "").strip(),
                "merged_at": merged_at,
                "html_url": (pr.get("html_url") or pr.get("url") or "").strip(),
            })
        if len(lst) < per_page:
            break
        page += 1
    return result, None


def get_pull_detail(token, owner, repo, number):
    """GET 单个 PR 详情。"""
    return api_get(token, "repos/%s/%s/pulls/%s" % (owner, repo, number))


def get_pull_labels(token, owner, repo, number):
    """GET PR 标签列表（先 pulls 再 merge_requests），返回 label name 集合。"""
    for path_prefix in ("pulls", "merge_requests"):
        data, err = api_get(token, "repos/%s/%s/%s/%s/labels" % (owner, repo, path_prefix, number))
        if err or not data:
            continue
        names = set()
        for item in (data if isinstance(data, list) else [data]):
            n = item.get("name") if isinstance(item, dict) else None
            if n:
                names.add(str(n).strip())
        if names or data:
            return names
    return set()


def _is_diff_comment(c):
    """判断是否为 diff/行内检视评论（兼容多种 API 字段）。"""
    ct = (c.get("comment_type") or c.get("type") or c.get("noteable_type") or "").strip().lower()
    if ct in ("diff_comment", "diffcomment", "diff", "line_note", "note"):
        return True
    # 有 line_code / position / path 的也视为行内评论
    if c.get("line_code") or c.get("position") or c.get("path") or c.get("line"):
        return True
    return False


def _is_resolved(c):
    """判断评论是否已解决（兼容多种 API 字段）。"""
    resolved = c.get("resolved")
    if resolved is True or (isinstance(resolved, str) and resolved.lower() in ("true", "1", "yes")):
        return True
    if c.get("resolved_at"):
        return True
    if (c.get("state") or "").strip().lower() in ("resolved", "closed"):
        return True
    if resolved is False or (isinstance(resolved, str) and resolved.lower() in ("false", "0", "no")):
        return False
    return False


def get_pull_comments(token, owner, repo, number):
    """GET PR 评论；先试 pulls，再试 merge_requests。返回原始列表。"""
    for path_prefix in ("pulls", "merge_requests"):
        path = "repos/%s/%s/%s/%s/comments" % (owner, repo, path_prefix, number)
        data, err = api_get(token, path)
        if err:
            continue
        if data is not None:
            return data if isinstance(data, list) else [data]
    return []


def get_pull_files(token, owner, repo, number):
    """GET PR 变更文件（先 pulls 再 merge_requests；/files/json 或 /files），返回 list of { filename, additions }。"""
    for path_prefix in ("pulls", "merge_requests"):
        for path_suffix in ("/files/json", "/files"):
            data, err = api_get(
                token,
                "repos/%s/%s/%s/%s%s" % (owner, repo, path_prefix, number, path_suffix),
            )
            if err:
                continue
            lst = data if isinstance(data, list) else []
            out = []
            for item in lst:
                if isinstance(item, dict):
                    fn = item.get("filename") or item.get("file_name") or item.get("new_file") or ""
                    add = item.get("additions")
                    if add is None:
                        add = 0
                    try:
                        add = int(add)
                    except (TypeError, ValueError):
                        add = 0
                    out.append({"filename": fn, "additions": add})
            if out:
                return out
    return []


def get_pull_issues(token, owner, repo, number):
    """GET PR 关联 issues（先 pulls 再 merge_requests），返回列表长度。"""
    for path_prefix in ("pulls", "merge_requests"):
        data, err = api_get(token, "repos/%s/%s/%s/%s/issues" % (owner, repo, path_prefix, number))
        if not err and data is not None:
            lst = data if isinstance(data, list) else [data] if data else []
            return len(lst)
    return 0


def _get_pull_detail_any_path(token, owner, repo, number):
    """获取 PR 详情，先试 pulls 再试 merge_requests。"""
    for path_prefix in ("pulls", "merge_requests"):
        path = "repos/%s/%s/%s/%s" % (owner, repo, path_prefix, number)
        data, err = api_get(token, path)
        if not err and data and isinstance(data, dict):
            return data, None
    return None, "无法获取 PR 详情（pulls 与 merge_requests 均失败）"


def audit_one_pr(token, owner, repo, pr_info, config):
    """
    对单个 PR 做 8 维检查，返回 dict：含 DIMENSION_BOOL_KEYS、详情文案（*_detail）及 title, link 等。
    """
    number = pr_info.get("number")
    title = (pr_info.get("title") or "").strip()
    link = (pr_info.get("html_url") or "").strip()
    if not link and owner and repo and number:
        link = "https://gitcode.com/%s/%s/merge_requests/%s" % (owner, repo, number)
    if not link:
        link = "https://gitcode.com/%s/%s/pulls/%s" % (owner, repo, number)

    detail, err = _get_pull_detail_any_path(token, owner, repo, number)
    if err:
        return None, err
    body = (detail.get("body") or "").strip() if isinstance(detail, dict) else ""
    if not title and isinstance(detail, dict):
        title = (detail.get("title") or "").strip()

    labels = get_pull_labels(token, owner, repo, number)
    required = set(config.get("required_labels") or [])
    missing_set = required - labels if required else set()
    missing_labels = bool(missing_set)
    labels_detail = "是（缺：%s）" % "、".join(sorted(missing_set)) if missing_set else "否"

    comments = get_pull_comments(token, owner, repo, number)
    diff_total = 0
    diff_resolved = 0
    for c in comments:
        if not _is_diff_comment(c):
            continue
        diff_total += 1
        if _is_resolved(c):
            diff_resolved += 1
    unresolved_count = diff_total - diff_resolved
    comment_unresolved = unresolved_count > 0
    if diff_total == 0:
        comment_detail = "否（0/0）"
    elif comment_unresolved:
        comment_detail = "是（%s/%s）" % (unresolved_count, diff_total)
    else:
        comment_detail = "否（%s/%s）" % (diff_resolved, diff_total)

    files = get_pull_files(token, owner, repo, number)
    total_additions = sum(f.get("additions", 0) for f in files)
    max_additions = int(config.get("max_additions") or 1000)
    over_additions = total_additions > max_additions
    additions_detail = "是（%s）" % total_additions if over_additions else "否（%s）" % total_additions

    test_substrings = config.get("test_substrings") or ["test", "ut"]
    has_test = False
    for f in files:
        fn = (f.get("filename") or "").lower()
        for sub in test_substrings:
            if sub.lower() in fn:
                has_test = True
                break
        if has_test:
            break
    no_test = not has_test and bool(files)
    no_test_detail = "是" if no_test else "否"

    issue_count = get_pull_issues(token, owner, repo, number)
    multi_issue = issue_count > 1
    multi_issue_detail = "是（%s）" % issue_count if multi_issue else "否（%s）" % issue_count

    review_threshold = int(config.get("review_lines_threshold") or 500)
    lack_review = total_additions > review_threshold and diff_total == 0
    if lack_review:
        lack_review_detail = "是（新增%s行，0个检视）" % total_additions
    elif total_additions > review_threshold and diff_total > 0:
        lack_review_detail = "否（新增%s行，%s个检视）" % (total_additions, diff_total)
    else:
        lack_review_detail = "否"

    title_min = int(config.get("title_min_length") or 5)
    body_min = int(config.get("body_min_length") or 10)
    title_short = len(title) < title_min
    body_short = len(body) < body_min
    generic_titles = ("fix", "update", "修改", "优化", "fix.", "updates")
    title_generic = title.lower().strip() in generic_titles and (body_short or len(body) < 20)
    title_body_unclear = title_short or body_short or title_generic
    unclear_reasons = []
    if title_short:
        unclear_reasons.append("标题过短（%s字）" % len(title))
    if body_short:
        unclear_reasons.append("描述过短（%s字）" % len(body))
    if title_generic and not title_short and not body_short:
        unclear_reasons.append("标题或描述过于笼统")
    title_body_unclear_detail = "是（%s）" % "；".join(unclear_reasons) if unclear_reasons else "否"

    row = {
        "repo": "%s/%s" % (owner, repo),
        "number": number,
        "title": title[:80] + "..." if len(title) > 80 else title,
        "link": link,
        "comment_unresolved": comment_unresolved,
        "comment_detail": comment_detail,
        "missing_labels": missing_labels,
        "labels_detail": labels_detail,
        "over_additions": over_additions,
        "additions_detail": additions_detail,
        "no_test": no_test,
        "no_test_detail": no_test_detail,
        "multi_issue": multi_issue,
        "multi_issue_detail": multi_issue_detail,
        "lack_review": lack_review,
        "lack_review_detail": lack_review_detail,
        "title_body_unclear": title_body_unclear,
        "title_body_unclear_detail": title_body_unclear_detail,
    }
    return row, None


def problem_summary(row):
    """根据维度布尔值生成「问题汇总」字符串。"""
    parts = []
    for key in DIMENSION_BOOL_KEYS:
        if row.get(key):
            parts.append(DIMENSION_NAMES_CN[DIMENSION_BOOL_KEYS.index(key)])
    return "; ".join(parts) if parts else "无"


def to_markdown_table(rows):
    """生成 Markdown 表格字符串。PR 列含链接 [#num](url)，无单独链接列；维度列为详情文案。"""
    if not rows:
        return "无数据。"
    cn_headers = HEADERS
    col_keys = ["repo", "pr_display", "title"] + DIMENSION_KEYS + ["summary"]
    lines = []
    lines.append("| " + " | ".join(cn_headers) + " |")
    lines.append("| " + " | ".join(["---"] * len(cn_headers)) + " |")
    for r in rows:
        r["summary"] = problem_summary(r)
        pr_display = "[#%s](%s)" % (r.get("number"), r.get("link", ""))
        r["pr_display"] = pr_display
        cells = []
        for k in col_keys:
            v = r.get(k, "")
            cells.append(str(v).replace("|", "\\|").replace("\n", " "))
        lines.append("| " + " | ".join(cells) + " |")
    return "\n".join(lines)


def to_csv_rows(rows):
    """生成 CSV 行（含表头）；PR 列为 #num，链接列为 URL；维度列为详情文案。"""
    col_keys = ["repo", "number", "title", "link"] + DIMENSION_KEYS + ["summary"]
    csv_headers = ["仓库", "PR", "标题", "链接"] + DIMENSION_NAMES_CN + ["问题汇总"]
    out = [csv_headers]
    for r in rows:
        r["summary"] = problem_summary(r)
        out_row = []
        for k in col_keys:
            v = r.get(k, "")
            if k == "number":
                v = "#%s" % v
            out_row.append(str(v))
        out.append(out_row)
    return out


def main():
    parser = argparse.ArgumentParser(
        description="GitCode 合入 PR 质量抽检：按时间范围拉取已合并 PR，多维度检查，输出表格。",
    )
    parser.add_argument("--repo", action="append", metavar="owner/repo", help="仓库，可多次指定")
    parser.add_argument("--pr", type=int, action="append", metavar="N", help="指定 PR 编号，可多次传入；与时间范围二选一，指定后仅检查这些 PR（需且仅需一个 --repo）")
    parser.add_argument("--branch", default="", help="目标分支；未传时依次尝试 master、develop、main")
    parser.add_argument("--since", metavar="YYYY-MM-DD", help="起始日期（含该日 00:00 上海时间）")
    parser.add_argument("--until", metavar="YYYY-MM-DD", help="结束日期（含该日 24:00 前）")
    parser.add_argument("--days", type=int, default=DEFAULT_DAYS, metavar="N", help="最近 N 天，默认 %s" % DEFAULT_DAYS)
    parser.add_argument("--all", action="store_true", help="不抽样，全部检查")
    parser.add_argument("--output", metavar="path", help="输出文件路径，.md 或 .csv（UTF-8，CSV 带 BOM）")
    parser.add_argument("--config", metavar="path", help="配置文件路径，默认技能根目录 config.json")
    args = parser.parse_args()

    if sys.platform == "win32":
        try:
            sys.stdout.reconfigure(encoding="utf-8")
            sys.stderr.reconfigure(encoding="utf-8")
        except Exception:
            pass

    repos = args.repo or []
    if not repos:
        sys.stderr.write("错误: 请至少指定一个 --repo owner/repo\n")
        sys.exit(1)
    repo_list = []
    for r in repos:
        r = (r or "").strip()
        if "/" not in r:
            sys.stderr.write("错误: --repo 必须为 owner/repo 格式: %s\n" % r)
            sys.exit(1)
        owner, repo = r.split("/", 1)
        repo_list.append((owner.strip(), repo.strip()))

    pr_numbers = args.pr or []
    if pr_numbers:
        if len(repo_list) != 1:
            sys.stderr.write("错误: 使用 --pr 时需且仅需指定一个 --repo\n")
            sys.exit(1)

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"错误: 未配置 GITCODE_TOKEN。请到 https://gitcode.com/setting/token-classic 创建并设置环境变量 GITCODE_TOKEN。\n")
        sys.exit(1)

    config = load_config(args.config)
    branch_spec = (args.branch or "").strip()

    all_rows = []

    if pr_numbers:
        owner, repo = repo_list[0]
        for num in pr_numbers:
            pr_info, err = _get_pull_detail_any_path(token, owner, repo, num)
            if err or not pr_info:
                sys.stderr.write("警告: 无法获取 PR #%s: %s\n" % (num, err or "无详情"))
                continue
            pr_info["number"] = num
            pr_info.setdefault("html_url", "https://gitcode.com/%s/%s/merge_requests/%s" % (owner, repo, num))
            row, err = audit_one_pr(token, owner, repo, pr_info, config)
            if err:
                sys.stderr.write("警告: PR #%s 检查失败: %s\n" % (num, err))
                continue
            if row:
                all_rows.append(row)
    else:
        since_utc = None
        until_utc = None
        if args.since or args.until:
            if args.since:
                since_utc = parse_date_utc(args.since, end_of_day=False)
                if not since_utc:
                    sys.stderr.write("错误: --since 格式应为 YYYY-MM-DD\n")
                    sys.exit(1)
            if args.until:
                until_utc = parse_date_utc(args.until, end_of_day=True)
                if not until_utc:
                    sys.stderr.write("错误: --until 格式应为 YYYY-MM-DD\n")
                    sys.exit(1)
            if since_utc and until_utc and since_utc > until_utc:
                sys.stderr.write("错误: --since 不能晚于 --until\n")
                sys.exit(1)
        else:
            until_utc = datetime.now(timezone.utc)
            since_utc = until_utc - timedelta(days=max(1, args.days))

        for owner, repo in repo_list:
            branch, err = resolve_branch(token, owner, repo, branch_spec or None)
            if err:
                sys.stderr.write("错误: %s\n" % err)
                sys.exit(1)
            merged, err = list_merged_pulls(token, owner, repo, branch, since_utc, until_utc)
            if err:
                sys.stderr.write("错误: %s/%s 拉取 PR 列表失败: %s\n" % (owner, repo, err))
                sys.exit(1)
            if not merged:
                continue
            if args.all:
                sample = merged
            else:
                n = min(len(merged), random.randint(SAMPLE_MIN, SAMPLE_MAX)) if len(merged) >= SAMPLE_MIN else len(merged)
                sample = random.sample(merged, n) if n else []
            for pr_info in sample:
                row, err = audit_one_pr(token, owner, repo, pr_info, config)
                if err:
                    sys.stderr.write("警告: PR #%s 检查失败: %s\n" % (pr_info.get("number"), err))
                    continue
                if row:
                    all_rows.append(row)

    table_md = to_markdown_table(all_rows)
    try:
        sys.stdout.buffer.write(table_md.encode("utf-8"))
        sys.stdout.buffer.write(b"\n")
        sys.stdout.buffer.flush()
    except (AttributeError, OSError):
        print(table_md)

    if args.output:
        out_path = Path(args.output)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        suffix = out_path.suffix.lower()
        try:
            if suffix == ".csv":
                csv_rows = to_csv_rows(all_rows)
                with open(out_path, "w", encoding="utf-8-sig", newline="") as f:
                    w = csv.writer(f)
                    for r in csv_rows:
                        w.writerow(r)
            else:
                with open(out_path, "w", encoding="utf-8") as f:
                    f.write(table_md)
                    f.write("\n")
        except OSError as e:
            sys.stderr.write("错误: 写入文件失败: %s\n" % e)
            sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)