forked from Gitlink/gitlink-cli
1813 lines
65 KiB
Python
1813 lines
65 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
from collections import Counter, defaultdict
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Iterable
|
||
|
||
|
||
class WorkflowError(RuntimeError):
|
||
pass
|
||
|
||
|
||
CLI_PAGE_SIZE = 100
|
||
TRIAGE_RULES_JSON_START = "<!-- TRIAGE_RULES_JSON_START -->"
|
||
TRIAGE_RULES_JSON_END = "<!-- TRIAGE_RULES_JSON_END -->"
|
||
|
||
DEFAULT_TRIAGE_CONFIG = {
|
||
"enabled": True,
|
||
"state": "open",
|
||
"limit": 50,
|
||
"lang": "zh-CN",
|
||
"skill_path": None,
|
||
"labels_by_type": {
|
||
"bug": "缺陷",
|
||
"feature": "功能",
|
||
"question": "疑问",
|
||
"docs": "文档",
|
||
"security": "缺陷",
|
||
"performance": "缺陷",
|
||
"ci": "测试",
|
||
"refactor": "任务",
|
||
"duplicate": "重复",
|
||
},
|
||
"assigners_by_type": {
|
||
"bug": [153579],
|
||
"feature": [153579],
|
||
"question": [153579],
|
||
"docs": [153579],
|
||
"security": [153579],
|
||
"performance": [153579],
|
||
"ci": [153579],
|
||
"refactor": [153579],
|
||
"duplicate": [153579],
|
||
},
|
||
"priority_ids": {
|
||
"P0": 4,
|
||
"P1": 3,
|
||
"P2": 2,
|
||
"P3": 1,
|
||
"critical": 4,
|
||
"high": 3,
|
||
"normal": 2,
|
||
"low": 1,
|
||
},
|
||
"comment": True,
|
||
}
|
||
|
||
|
||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示"
|
||
)
|
||
parser.add_argument(
|
||
"--config",
|
||
type=Path,
|
||
default=Path("examples/sample_config.json"),
|
||
help="配置文件路径",
|
||
)
|
||
parser.add_argument("--owner", help="覆盖配置中的仓库所有者")
|
||
parser.add_argument("--repo", help="覆盖配置中的仓库名称")
|
||
parser.add_argument(
|
||
"--window-days",
|
||
type=int,
|
||
help="统计窗口,默认从配置文件读取或使用 7 天",
|
||
)
|
||
parser.add_argument(
|
||
"--output-dir",
|
||
type=Path,
|
||
help="输出目录,默认从配置文件读取或使用 outputs",
|
||
)
|
||
parser.add_argument(
|
||
"--publish-issue-id",
|
||
type=int,
|
||
help="发布摘要到指定 Issue 评论,未提供则只生成本地报告",
|
||
)
|
||
parser.add_argument(
|
||
"--now",
|
||
help="固定当前时间,便于测试,格式为 ISO8601",
|
||
)
|
||
parser.add_argument(
|
||
"--skip-releases",
|
||
action="store_true",
|
||
help="跳过 release 列表采集",
|
||
)
|
||
parser.add_argument(
|
||
"--cli-bin",
|
||
help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用",
|
||
)
|
||
parser.add_argument(
|
||
"--skip-triage",
|
||
action="store_true",
|
||
help="跳过新 Issue 自动分类和责任人分配",
|
||
)
|
||
parser.add_argument(
|
||
"--apply-triage",
|
||
action="store_true",
|
||
help="执行真实 Issue 写回;默认只生成 dry-run 计划",
|
||
)
|
||
parser.add_argument(
|
||
"--triage-limit",
|
||
type=int,
|
||
help="覆盖 triage.limit,控制本轮自动分类 Issue 数量",
|
||
)
|
||
parser.add_argument(
|
||
"--triage-state",
|
||
help="覆盖 triage.state,控制本轮自动分类 Issue 状态",
|
||
)
|
||
parser.add_argument(
|
||
"--triage-skill",
|
||
type=Path,
|
||
help="自定义 Issue triage Skill 的 SKILL.md 路径",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def load_json_file(path: Path) -> dict[str, Any]:
|
||
if not path.exists():
|
||
return {}
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def sanitize_repo_name(value: str) -> str:
|
||
return value.replace("/", "_").replace("\\", "_")
|
||
|
||
|
||
def parse_datetime(value: Any) -> datetime | None:
|
||
if value in (None, "", []):
|
||
return None
|
||
if isinstance(value, datetime):
|
||
dt = value
|
||
else:
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
text = text.replace("Z", "+00:00")
|
||
try:
|
||
dt = datetime.fromisoformat(text)
|
||
except ValueError:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt.astimezone(timezone.utc)
|
||
|
||
|
||
def parse_iso_now(value: str | None) -> datetime:
|
||
if not value:
|
||
return datetime.now(timezone.utc)
|
||
dt = parse_datetime(value)
|
||
if dt is None:
|
||
raise WorkflowError(f"无法解析 --now 的值: {value}")
|
||
return dt
|
||
|
||
|
||
def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
|
||
for key in keys:
|
||
if key in item:
|
||
value = item[key]
|
||
if value not in (None, "", []):
|
||
return value
|
||
return default
|
||
|
||
|
||
def normalize_labels(value: Any) -> list[str]:
|
||
labels: list[str] = []
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
name = first_value(item, ("name", "title", "label_name"))
|
||
if name:
|
||
labels.append(str(name))
|
||
elif item not in (None, ""):
|
||
labels.append(str(item))
|
||
elif isinstance(value, str) and value:
|
||
labels.append(value)
|
||
return labels
|
||
|
||
|
||
def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]:
|
||
if isinstance(payload, list):
|
||
return payload
|
||
if isinstance(payload, dict):
|
||
for key in keys:
|
||
value = payload.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
for value in payload.values():
|
||
found = extract_first_list(value, keys)
|
||
if found:
|
||
return found
|
||
return []
|
||
|
||
|
||
def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]:
|
||
if isinstance(payload, dict):
|
||
for key in keys:
|
||
value = payload.get(key)
|
||
if isinstance(value, dict):
|
||
return value
|
||
for value in payload.values():
|
||
found = extract_first_dict(value, keys)
|
||
if found:
|
||
return found
|
||
if isinstance(payload, list):
|
||
for item in payload:
|
||
found = extract_first_dict(item, keys)
|
||
if found:
|
||
return found
|
||
return {}
|
||
|
||
|
||
def run_gitlink_cli(
|
||
command: list[str],
|
||
owner: str,
|
||
repo: str,
|
||
cwd: Path | None = None,
|
||
cli_bin: str | None = None,
|
||
) -> Any:
|
||
cli_path = cli_bin or os.environ.get("GITLINK_CLI_BIN") or shutil_which("gitlink-cli")
|
||
if cli_path is None:
|
||
raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中")
|
||
|
||
if cli_path.lower().endswith((".cmd", ".bat")):
|
||
cmd = [
|
||
"cmd",
|
||
"/c",
|
||
cli_path,
|
||
*command,
|
||
"--owner",
|
||
owner,
|
||
"--repo",
|
||
repo,
|
||
"--format",
|
||
"json",
|
||
]
|
||
else:
|
||
cmd = [
|
||
cli_path,
|
||
*command,
|
||
"--owner",
|
||
owner,
|
||
"--repo",
|
||
repo,
|
||
"--format",
|
||
"json",
|
||
]
|
||
proc = subprocess.run(
|
||
cmd,
|
||
cwd=str(cwd) if cwd else None,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
)
|
||
if proc.returncode != 0:
|
||
stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
|
||
raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}")
|
||
return parse_json_output(proc.stdout)
|
||
|
||
|
||
def parse_json_output(text: str) -> Any:
|
||
stripped = text.strip()
|
||
if not stripped:
|
||
raise WorkflowError("CLI 返回空结果")
|
||
try:
|
||
return json.loads(stripped)
|
||
except json.JSONDecodeError:
|
||
first_json = min(
|
||
[idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1],
|
||
default=-1,
|
||
)
|
||
if first_json > 0:
|
||
return json.loads(stripped[first_json:])
|
||
raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}")
|
||
|
||
|
||
def normalize_repo_info(payload: Any) -> dict[str, Any]:
|
||
repo = extract_first_dict(payload, ("project", "repo", "repository", "data"))
|
||
if not repo and isinstance(payload, dict):
|
||
repo = payload
|
||
return {
|
||
"name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""),
|
||
"description": first_value(repo, ("description", "desc", "summary"), ""),
|
||
"default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""),
|
||
"language": first_value(repo, ("language",), ""),
|
||
"raw": repo,
|
||
}
|
||
|
||
|
||
def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||
raw_status = first_value(item, ("status_id", "status", "state_id"), None)
|
||
raw_name = str(
|
||
first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "")
|
||
).strip().lower()
|
||
if raw_status is not None:
|
||
try:
|
||
raw_status = int(raw_status)
|
||
except (TypeError, ValueError):
|
||
raw_status = str(raw_status).strip().lower()
|
||
if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name:
|
||
return "closed"
|
||
if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name:
|
||
return "open"
|
||
if query_state:
|
||
return query_state
|
||
return "open"
|
||
|
||
|
||
def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||
return {
|
||
"id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")),
|
||
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
|
||
"state": normalize_issue_state(item, query_state=query_state),
|
||
"created_at": parse_datetime(
|
||
first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time"))
|
||
),
|
||
"updated_at": parse_datetime(
|
||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time"))
|
||
),
|
||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||
"raw": item,
|
||
}
|
||
|
||
|
||
def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||
items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
normalized.append(normalize_issue(item, query_state=query_state))
|
||
return normalized
|
||
|
||
|
||
def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||
raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None)
|
||
if raw_status is not None:
|
||
try:
|
||
raw_status = int(raw_status)
|
||
except (TypeError, ValueError):
|
||
raw_status = str(raw_status).strip().lower()
|
||
if raw_status in {1, "1", "merged"}:
|
||
return "merged"
|
||
if raw_status in {2, "2", "closed", "close"}:
|
||
return "closed"
|
||
if raw_status in {0, "0", "open", "opened"}:
|
||
return "open"
|
||
if query_state:
|
||
return query_state
|
||
return "open"
|
||
|
||
|
||
def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||
state = normalize_pr_state(item, query_state=query_state)
|
||
merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time")))
|
||
merged_flag = state == "merged" or merged_at is not None
|
||
return {
|
||
"id": str(
|
||
first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "")
|
||
),
|
||
"title": str(first_value(item, ("title", "subject", "name"), "(untitled)")),
|
||
"state": state,
|
||
"created_at": parse_datetime(
|
||
first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time"))
|
||
),
|
||
"updated_at": parse_datetime(
|
||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time"))
|
||
),
|
||
"merged_at": merged_at
|
||
or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None),
|
||
"merged": merged_flag,
|
||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||
"raw": item,
|
||
}
|
||
|
||
|
||
def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||
items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list"))
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
normalized.append(normalize_pr(item, query_state=query_state))
|
||
return normalized
|
||
|
||
|
||
def normalize_releases(payload: Any) -> list[dict[str, Any]]:
|
||
items = extract_first_list(payload, ("releases", "items", "list"))
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
normalized.append(
|
||
{
|
||
"id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")),
|
||
"title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")),
|
||
"created_at": parse_datetime(
|
||
first_value(item, ("created_at", "createdAt", "released_at", "releasedAt"))
|
||
),
|
||
"raw": item,
|
||
}
|
||
)
|
||
return normalized
|
||
|
||
|
||
def as_list(value: Any) -> list[Any]:
|
||
if value in (None, "", []):
|
||
return []
|
||
if isinstance(value, list):
|
||
return value
|
||
return [value]
|
||
|
||
|
||
def parse_positive_int(value: Any) -> int | None:
|
||
if isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, int) and value > 0:
|
||
return value
|
||
if isinstance(value, float) and value > 0 and value.is_integer():
|
||
return int(value)
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
if text.isdigit():
|
||
parsed = int(text)
|
||
if parsed > 0:
|
||
return parsed
|
||
return None
|
||
|
||
|
||
def merge_unique_ints(*groups: Iterable[int]) -> list[int]:
|
||
result: list[int] = []
|
||
seen: set[int] = set()
|
||
for group in groups:
|
||
for value in group:
|
||
parsed = parse_positive_int(value)
|
||
if parsed is None or parsed in seen:
|
||
continue
|
||
seen.add(parsed)
|
||
result.append(parsed)
|
||
return result
|
||
|
||
|
||
def normalize_lookup_key(value: Any) -> str:
|
||
return str(value).strip().lower()
|
||
|
||
|
||
def normalize_triage_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||
merged = dict(DEFAULT_TRIAGE_CONFIG)
|
||
if config:
|
||
merged.update(config)
|
||
for key in ("labels_by_type", "assigners_by_type", "priority_ids"):
|
||
nested = dict(DEFAULT_TRIAGE_CONFIG.get(key, {}))
|
||
nested.update(config.get(key, {}) or {})
|
||
merged[key] = nested
|
||
return merged
|
||
|
||
|
||
def strip_json_fence(text: str) -> str:
|
||
stripped = text.strip()
|
||
if not stripped.startswith("```"):
|
||
return stripped
|
||
lines = stripped.splitlines()
|
||
if lines and lines[0].startswith("```"):
|
||
lines = lines[1:]
|
||
if lines and lines[-1].strip() == "```":
|
||
lines = lines[:-1]
|
||
return "\n".join(lines).strip()
|
||
|
||
|
||
def extract_skill_frontmatter_value(text: str, key: str) -> str | None:
|
||
if not text.startswith("---"):
|
||
return None
|
||
parts = text.split("---", 2)
|
||
if len(parts) < 3:
|
||
return None
|
||
pattern = re.compile(rf"^\s*{re.escape(key)}\s*:\s*(.+?)\s*$", re.MULTILINE)
|
||
match = pattern.search(parts[1])
|
||
if not match:
|
||
return None
|
||
return match.group(1).strip().strip('"').strip("'")
|
||
|
||
|
||
def extract_skill_triage_json(text: str) -> dict[str, Any]:
|
||
start = text.find(TRIAGE_RULES_JSON_START)
|
||
end = text.find(TRIAGE_RULES_JSON_END)
|
||
if start == -1 or end == -1 or end <= start:
|
||
raise WorkflowError(
|
||
f"Skill 缺少 {TRIAGE_RULES_JSON_START} / {TRIAGE_RULES_JSON_END} 规则块"
|
||
)
|
||
raw_block = text[start + len(TRIAGE_RULES_JSON_START):end]
|
||
try:
|
||
data = json.loads(strip_json_fence(raw_block))
|
||
except json.JSONDecodeError as exc:
|
||
raise WorkflowError(f"Skill 规则 JSON 无法解析:{exc}") from exc
|
||
if not isinstance(data, dict):
|
||
raise WorkflowError("Skill 规则 JSON 必须是对象")
|
||
if not isinstance(data.get("rules"), list) or not data["rules"]:
|
||
raise WorkflowError("Skill 规则 JSON 必须包含非空 rules 数组")
|
||
return data
|
||
|
||
|
||
def load_skill_triage_rules(skill_path: Path) -> dict[str, Any]:
|
||
if not skill_path.exists():
|
||
raise WorkflowError(f"未找到 triage Skill:{skill_path}")
|
||
text = skill_path.read_text(encoding="utf-8")
|
||
ruleset = extract_skill_triage_json(text)
|
||
skill_meta = ruleset.get("skill") if isinstance(ruleset.get("skill"), dict) else {}
|
||
skill_name = (
|
||
skill_meta.get("name")
|
||
or ruleset.get("skill_name")
|
||
or extract_skill_frontmatter_value(text, "name")
|
||
or skill_path.parent.name
|
||
)
|
||
skill_version = (
|
||
skill_meta.get("version")
|
||
or ruleset.get("skill_version")
|
||
or extract_skill_frontmatter_value(text, "version")
|
||
or "unknown"
|
||
)
|
||
ruleset["skill"] = {"name": str(skill_name), "version": str(skill_version)}
|
||
ruleset["skill_path"] = str(skill_path)
|
||
return ruleset
|
||
|
||
|
||
def candidate_skill_paths(
|
||
skill_path: Any = None,
|
||
config_path: Path | None = None,
|
||
) -> list[Path]:
|
||
candidates: list[Path] = []
|
||
if skill_path:
|
||
explicit = Path(str(skill_path)).expanduser()
|
||
if explicit.is_absolute():
|
||
candidates.append(explicit)
|
||
else:
|
||
if config_path is not None:
|
||
config_base = config_path if config_path.is_absolute() else Path.cwd() / config_path
|
||
candidates.append(config_base.resolve().parent / explicit)
|
||
candidates.append(Path.cwd() / explicit)
|
||
|
||
roots: list[Path] = [Path.cwd(), Path(__file__).resolve()]
|
||
if config_path is not None:
|
||
roots.append(config_path if config_path.is_absolute() else Path.cwd() / config_path)
|
||
seen_roots: set[Path] = set()
|
||
for root in roots:
|
||
start = root if root.is_dir() else root.parent
|
||
for parent in (start, *start.parents):
|
||
resolved_parent = parent.resolve()
|
||
if resolved_parent in seen_roots:
|
||
continue
|
||
seen_roots.add(resolved_parent)
|
||
candidates.append(resolved_parent / "gitlink-issue-triage-rules" / "SKILL.md")
|
||
|
||
unique: list[Path] = []
|
||
seen: set[str] = set()
|
||
for candidate in candidates:
|
||
key = str(candidate)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
unique.append(candidate)
|
||
return unique
|
||
|
||
|
||
def resolve_triage_skill_path(
|
||
skill_path: Any = None,
|
||
config_path: Path | None = None,
|
||
) -> Path:
|
||
searched = candidate_skill_paths(skill_path, config_path)
|
||
for candidate in searched:
|
||
if candidate.exists():
|
||
return candidate.resolve()
|
||
rendered = "\n".join(f"- {path}" for path in searched[:8])
|
||
raise WorkflowError(f"未找到自定义 triage Skill,已搜索:\n{rendered}")
|
||
|
||
|
||
def merge_skill_triage_config(
|
||
triage_config: dict[str, Any],
|
||
ruleset: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
merged = normalize_triage_config(triage_config)
|
||
for key in ("labels_by_type", "assigners_by_type", "priority_ids"):
|
||
skill_values = ruleset.get(key)
|
||
if not isinstance(skill_values, dict):
|
||
continue
|
||
combined = dict(skill_values)
|
||
combined.update(merged.get(key, {}) or {})
|
||
merged[key] = combined
|
||
return merged
|
||
|
||
|
||
def normalize_text(value: Any) -> str:
|
||
return str(value or "").strip().lower()
|
||
|
||
|
||
def label_names_for_issue(issue: dict[str, Any]) -> list[str]:
|
||
raw = issue.get("raw") if isinstance(issue.get("raw"), dict) else {}
|
||
labels = list(issue.get("labels") or [])
|
||
labels.extend(normalize_labels(first_value(raw, ("labels", "label_list", "issue_tags", "tags"), [])))
|
||
result: list[str] = []
|
||
seen: set[str] = set()
|
||
for label in labels:
|
||
text = str(label).strip()
|
||
key = text.lower()
|
||
if text and key not in seen:
|
||
seen.add(key)
|
||
result.append(text)
|
||
return result
|
||
|
||
|
||
def issue_body_for_triage(issue: dict[str, Any]) -> str:
|
||
raw = issue.get("raw") if isinstance(issue.get("raw"), dict) else {}
|
||
return str(first_value(raw, ("description", "body", "content", "desc", "message"), ""))
|
||
|
||
|
||
def issue_search_text(issue: dict[str, Any]) -> str:
|
||
raw = issue.get("raw") if isinstance(issue.get("raw"), dict) else {}
|
||
parts = [
|
||
issue.get("title"),
|
||
issue_body_for_triage(issue),
|
||
first_value(raw, ("subject", "title", "name"), ""),
|
||
" ".join(label_names_for_issue(issue)),
|
||
]
|
||
return "\n".join(str(part) for part in parts if part not in (None, "", []))
|
||
|
||
|
||
def contains_any_keyword(text: str, keywords: Any) -> list[str]:
|
||
lowered = text.lower()
|
||
matched: list[str] = []
|
||
for raw_keyword in as_list(keywords):
|
||
keyword = str(raw_keyword).strip()
|
||
if keyword and keyword.lower() in lowered:
|
||
matched.append(keyword)
|
||
return matched
|
||
|
||
|
||
def contains_all_keywords(text: str, keywords: Any) -> list[str] | None:
|
||
keyword_list = [str(item).strip() for item in as_list(keywords) if str(item).strip()]
|
||
if not keyword_list:
|
||
return []
|
||
matched = contains_any_keyword(text, keyword_list)
|
||
return matched if len(matched) == len(keyword_list) else None
|
||
|
||
|
||
def label_condition_matches(labels: list[str], expected: Any, require_absent: bool = False) -> bool:
|
||
expected_keys = {normalize_text(item) for item in as_list(expected) if str(item).strip()}
|
||
if not expected_keys:
|
||
return True
|
||
label_keys = {normalize_text(label) for label in labels}
|
||
has_match = bool(expected_keys & label_keys)
|
||
return not has_match if require_absent else has_match
|
||
|
||
|
||
def regex_matches(text: str, patterns: Any) -> list[str]:
|
||
matched: list[str] = []
|
||
for raw_pattern in as_list(patterns):
|
||
pattern = str(raw_pattern).strip()
|
||
if not pattern:
|
||
continue
|
||
try:
|
||
if re.search(pattern, text, flags=re.IGNORECASE):
|
||
matched.append(pattern)
|
||
except re.error:
|
||
continue
|
||
return matched
|
||
|
||
|
||
def match_skill_rule(issue: dict[str, Any], rule: dict[str, Any]) -> tuple[bool, list[str], int]:
|
||
text = issue_search_text(issue)
|
||
labels = label_names_for_issue(issue)
|
||
match_config = rule.get("match") if isinstance(rule.get("match"), dict) else {}
|
||
exclude_config = rule.get("exclude") if isinstance(rule.get("exclude"), dict) else {}
|
||
matched_notes: list[str] = []
|
||
|
||
excluded = contains_any_keyword(text, exclude_config.get("any_keyword"))
|
||
if excluded:
|
||
return False, [f"excluded keyword: {keyword}" for keyword in excluded], 0
|
||
|
||
any_keywords = contains_any_keyword(text, match_config.get("any_keyword"))
|
||
if match_config.get("any_keyword") and not any_keywords:
|
||
return False, [], 0
|
||
matched_notes.extend(f"keyword: {keyword}" for keyword in any_keywords[:5])
|
||
|
||
all_keywords = contains_all_keywords(text, match_config.get("all_keyword"))
|
||
if all_keywords is None:
|
||
return False, [], 0
|
||
matched_notes.extend(f"all keyword: {keyword}" for keyword in (all_keywords or [])[:5])
|
||
|
||
matched_regex = regex_matches(text, match_config.get("regex"))
|
||
if match_config.get("regex") and not matched_regex:
|
||
return False, [], 0
|
||
matched_notes.extend(f"regex: {pattern}" for pattern in matched_regex[:3])
|
||
|
||
if not label_condition_matches(labels, match_config.get("has_label")):
|
||
return False, [], 0
|
||
if match_config.get("has_label"):
|
||
matched_notes.append(f"has label: {','.join(str(v) for v in as_list(match_config.get('has_label')))}")
|
||
|
||
if not label_condition_matches(labels, match_config.get("no_label"), require_absent=True):
|
||
return False, [], 0
|
||
|
||
min_length = parse_positive_int(match_config.get("min_description_length"))
|
||
if min_length is not None and len(issue_body_for_triage(issue)) < min_length:
|
||
return False, [], 0
|
||
if min_length is not None:
|
||
matched_notes.append(f"description length >= {min_length}")
|
||
|
||
has_any_condition = any(
|
||
match_config.get(key)
|
||
for key in ("any_keyword", "all_keyword", "regex", "has_label", "no_label", "min_description_length")
|
||
)
|
||
if not has_any_condition:
|
||
return False, [], 0
|
||
|
||
confidence = 95 if len(matched_notes) > 1 else 85
|
||
return True, matched_notes, confidence
|
||
|
||
|
||
def priority_for_rule(rule: dict[str, Any], ruleset: dict[str, Any]) -> str:
|
||
defaults = ruleset.get("defaults") if isinstance(ruleset.get("defaults"), dict) else {}
|
||
return str(rule.get("priority") or defaults.get("priority") or "P3")
|
||
|
||
|
||
def classify_issue_with_skill_rules(issue: dict[str, Any], ruleset: dict[str, Any]) -> dict[str, Any]:
|
||
defaults = ruleset.get("defaults") if isinstance(ruleset.get("defaults"), dict) else {}
|
||
skip_when = defaults.get("skip_when") if isinstance(defaults.get("skip_when"), dict) else {}
|
||
labels = label_names_for_issue(issue)
|
||
skipped_labels = [label for label in labels if normalize_text(label) in {
|
||
normalize_text(item) for item in as_list(skip_when.get("has_label_any"))
|
||
}]
|
||
if skipped_labels:
|
||
matched_rules = [f"skipped existing label: {label}" for label in skipped_labels]
|
||
detected_type = "unknown"
|
||
priority = "P3"
|
||
confidence = 0
|
||
labels_for_rule: list[str] = []
|
||
rule_id = ""
|
||
add_comment = None
|
||
else:
|
||
detected_type = "unknown"
|
||
priority = "P3"
|
||
confidence = 20
|
||
matched_rules = []
|
||
labels_for_rule = []
|
||
rule_id = ""
|
||
add_comment = None
|
||
for raw_rule in ruleset.get("rules", []):
|
||
if not isinstance(raw_rule, dict):
|
||
continue
|
||
matched, notes, score = match_skill_rule(issue, raw_rule)
|
||
if not matched:
|
||
continue
|
||
rule_id = str(raw_rule.get("id") or raw_rule.get("type") or "rule")
|
||
detected_type = str(raw_rule.get("type") or "unknown")
|
||
priority = priority_for_rule(raw_rule, ruleset)
|
||
confidence = score
|
||
labels_for_rule = [str(label) for label in as_list(raw_rule.get("label")) if str(label).strip()]
|
||
matched_rules = [rule_id, *notes]
|
||
add_comment = raw_rule.get("add_comment")
|
||
break
|
||
|
||
skill_meta = ruleset.get("skill") if isinstance(ruleset.get("skill"), dict) else {}
|
||
return {
|
||
"issue": {
|
||
"number": issue.get("id"),
|
||
"title": issue.get("title"),
|
||
"labels": labels,
|
||
},
|
||
"detected_type": detected_type,
|
||
"priority": priority,
|
||
"confidence": confidence,
|
||
"matched_rules": matched_rules,
|
||
"rule_id": rule_id,
|
||
"labels": labels_for_rule,
|
||
"add_comment": add_comment,
|
||
"source": "skill-json",
|
||
"skill_name": skill_meta.get("name"),
|
||
"skill_version": skill_meta.get("version"),
|
||
}
|
||
|
||
|
||
def classify_issues_with_skill_rules(issues: list[dict[str, Any]], ruleset: dict[str, Any]) -> dict[str, Any]:
|
||
skill_meta = ruleset.get("skill") if isinstance(ruleset.get("skill"), dict) else {}
|
||
return {
|
||
"source": "skill-json",
|
||
"skill": skill_meta,
|
||
"skill_path": ruleset.get("skill_path"),
|
||
"rules_version": ruleset.get("version"),
|
||
"mode": ruleset.get("mode", "rule"),
|
||
"results": [classify_issue_with_skill_rules(issue, ruleset) for issue in issues],
|
||
}
|
||
|
||
|
||
def build_label_index(payload: Any) -> dict[str, int]:
|
||
labels = extract_first_list(payload, ("issue_tags", "labels", "items", "list"))
|
||
index: dict[str, int] = {}
|
||
for item in labels:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = first_value(item, ("name", "title", "label_name"))
|
||
label_id = parse_positive_int(first_value(item, ("id", "tag_id", "issue_tag_id")))
|
||
if name and label_id is not None:
|
||
index[str(name)] = label_id
|
||
return index
|
||
|
||
|
||
def resolve_label_candidates(
|
||
label_candidates: Iterable[Any],
|
||
label_index: dict[str, int],
|
||
) -> tuple[str | None, int | None]:
|
||
normalized_index = {normalize_lookup_key(name): label_id for name, label_id in label_index.items()}
|
||
for label_name in label_candidates:
|
||
text = str(label_name).strip()
|
||
if not text:
|
||
continue
|
||
label_id = label_index.get(text)
|
||
if label_id is None:
|
||
label_id = normalized_index.get(normalize_lookup_key(text))
|
||
if label_id is not None:
|
||
return text, label_id
|
||
return None, None
|
||
|
||
|
||
def build_assigner_index(payload: Any) -> dict[str, int]:
|
||
assigners = extract_first_list(
|
||
payload,
|
||
("assigners", "users", "members", "collaborators", "items", "list"),
|
||
)
|
||
index: dict[str, int] = {}
|
||
for item in assigners:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
user_id = parse_positive_int(first_value(item, ("id", "user_id", "uid")))
|
||
if user_id is None:
|
||
continue
|
||
for key in ("id", "user_id", "login", "name", "username", "nickname"):
|
||
value = first_value(item, (key,))
|
||
if value not in (None, "", []):
|
||
index[normalize_lookup_key(value)] = user_id
|
||
return index
|
||
|
||
|
||
def object_ids(value: Any) -> list[int]:
|
||
ids: list[int] = []
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
parsed = parse_positive_int(first_value(item, ("id", "user_id", "tag_id")))
|
||
else:
|
||
parsed = parse_positive_int(item)
|
||
if parsed is not None:
|
||
ids.append(parsed)
|
||
elif isinstance(value, dict):
|
||
parsed = parse_positive_int(first_value(value, ("id", "user_id", "tag_id")))
|
||
if parsed is not None:
|
||
ids.append(parsed)
|
||
else:
|
||
parsed = parse_positive_int(value)
|
||
if parsed is not None:
|
||
ids.append(parsed)
|
||
return merge_unique_ints(ids)
|
||
|
||
|
||
def issue_payload_data(payload: Any) -> dict[str, Any]:
|
||
data = extract_first_dict(payload, ("issue", "data"))
|
||
if data:
|
||
return data
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
|
||
def current_issue_metadata(payload: Any) -> tuple[list[int], list[int]]:
|
||
issue = issue_payload_data(payload)
|
||
tag_ids = object_ids(first_value(issue, ("tags", "issue_tags", "labels"), []))
|
||
assigner_ids = object_ids(first_value(issue, ("assigners", "assigned_users"), []))
|
||
return tag_ids, assigner_ids
|
||
|
||
|
||
def resolve_label_for_type(
|
||
issue_type: str,
|
||
labels_by_type: dict[str, Any],
|
||
label_index: dict[str, int],
|
||
) -> tuple[str | None, int | None]:
|
||
return resolve_label_candidates(as_list(labels_by_type.get(issue_type)), label_index)
|
||
|
||
|
||
def resolve_assigner_ids(
|
||
issue_type: str,
|
||
assigners_by_type: dict[str, Any],
|
||
assigner_index: dict[str, int],
|
||
) -> tuple[list[int], list[str]]:
|
||
ids: list[int] = []
|
||
missing: list[str] = []
|
||
for raw in as_list(assigners_by_type.get(issue_type)):
|
||
parsed = parse_positive_int(raw)
|
||
if parsed is not None:
|
||
ids.append(parsed)
|
||
continue
|
||
key = normalize_lookup_key(raw)
|
||
if key in assigner_index:
|
||
ids.append(assigner_index[key])
|
||
elif key:
|
||
missing.append(str(raw))
|
||
return merge_unique_ints(ids), missing
|
||
|
||
|
||
def priority_id_for_result(result: dict[str, Any], priority_ids: dict[str, Any]) -> int | None:
|
||
priority = str(result.get("priority") or "").strip()
|
||
aliases = {
|
||
"critical": "P0",
|
||
"high": "P1",
|
||
"normal": "P2",
|
||
"low": "P3",
|
||
}
|
||
candidates = [priority, priority.upper(), priority.lower()]
|
||
alias = aliases.get(priority.lower())
|
||
if alias:
|
||
candidates.append(alias)
|
||
for key in candidates:
|
||
parsed = parse_positive_int(priority_ids.get(key))
|
||
if parsed is not None:
|
||
return parsed
|
||
return None
|
||
|
||
|
||
def triage_result_issue(result: dict[str, Any]) -> dict[str, Any]:
|
||
issue = result.get("issue")
|
||
return issue if isinstance(issue, dict) else {}
|
||
|
||
|
||
def triage_issue_number(result: dict[str, Any]) -> int | None:
|
||
issue = triage_result_issue(result)
|
||
return parse_positive_int(first_value(issue, ("number", "id")))
|
||
|
||
|
||
def triage_issue_title(result: dict[str, Any]) -> str:
|
||
issue = triage_result_issue(result)
|
||
return str(first_value(issue, ("title",), "(untitled)"))
|
||
|
||
|
||
def build_triage_comment(item: dict[str, Any]) -> str:
|
||
matched = item.get("matched_rules") or []
|
||
matched_text = ", ".join(str(value) for value in matched[:5]) if matched else "无"
|
||
return (
|
||
"🤖 自动分拣:"
|
||
f"分类={item.get('detected_type') or 'unknown'},"
|
||
f"标签={item.get('label_name') or '未匹配'},"
|
||
f"负责人={','.join(str(v) for v in item.get('assigner_ids') or []) or '未分配'},"
|
||
f"优先级={item.get('priority') or 'unknown'},"
|
||
f"置信度={item.get('confidence', 0)},"
|
||
f"命中规则={matched_text}"
|
||
)
|
||
|
||
|
||
def build_triage_plan(
|
||
triage_payload: Any,
|
||
label_payload: Any,
|
||
assigner_payload: Any,
|
||
triage_config: dict[str, Any],
|
||
apply_triage: bool = False,
|
||
owner: str = "",
|
||
repo: str = "",
|
||
) -> dict[str, Any]:
|
||
labels_by_type = triage_config.get("labels_by_type", {}) or {}
|
||
assigners_by_type = triage_config.get("assigners_by_type", {}) or {}
|
||
priority_ids = triage_config.get("priority_ids", {}) or {}
|
||
label_index = build_label_index(label_payload)
|
||
assigner_index = build_assigner_index(assigner_payload)
|
||
results = extract_first_list(triage_payload, ("results", "items", "list"))
|
||
payload_meta = triage_payload if isinstance(triage_payload, dict) else {}
|
||
skill_meta = payload_meta.get("skill") if isinstance(payload_meta.get("skill"), dict) else {}
|
||
|
||
warnings: list[str] = []
|
||
items: list[dict[str, Any]] = []
|
||
for result in results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
number = triage_issue_number(result)
|
||
issue_type = str(result.get("detected_type") or "unknown").strip()
|
||
priority_id = priority_id_for_result(result, priority_ids)
|
||
label_candidates = [label for label in as_list(result.get("labels")) if str(label).strip()]
|
||
label_candidates.extend(as_list(labels_by_type.get(issue_type)))
|
||
label_name, label_id = resolve_label_candidates(label_candidates, label_index)
|
||
assigner_ids, missing_assigners = resolve_assigner_ids(issue_type, assigners_by_type, assigner_index)
|
||
status = "planned"
|
||
skip_reason = ""
|
||
|
||
if number is None:
|
||
status = "skipped"
|
||
skip_reason = "missing issue number"
|
||
warnings.append(f"跳过无法识别编号的 Issue:{triage_issue_title(result)}")
|
||
elif issue_type == "unknown":
|
||
status = "skipped"
|
||
skip_reason = "unknown type"
|
||
warnings.append(f"Issue #{number} 未识别出类型,已跳过写回")
|
||
elif label_name is None and label_candidates:
|
||
warnings.append(f"Issue #{number} 类型 {issue_type} 的候选标签未在仓库中找到")
|
||
if issue_type != "unknown" and not assigner_ids and assigners_by_type.get(issue_type):
|
||
warnings.append(f"Issue #{number} 类型 {issue_type} 未解析到负责人")
|
||
for missing in missing_assigners:
|
||
warnings.append(f"Issue #{number} 负责人 {missing} 不在可分配用户列表中")
|
||
if issue_type != "unknown" and priority_id is None:
|
||
warnings.append(f"Issue #{number} 优先级 {result.get('priority')} 未配置 priority_id")
|
||
|
||
desired_tag_ids = [label_id] if label_id is not None else []
|
||
if status == "planned" and not desired_tag_ids and not assigner_ids and priority_id is None:
|
||
status = "skipped"
|
||
skip_reason = "no resolvable metadata"
|
||
|
||
items.append(
|
||
{
|
||
"issue_number": number,
|
||
"title": triage_issue_title(result),
|
||
"detected_type": issue_type,
|
||
"priority": result.get("priority"),
|
||
"priority_id": priority_id,
|
||
"confidence": result.get("confidence", 0),
|
||
"label_name": label_name,
|
||
"label_id": label_id,
|
||
"desired_tag_ids": desired_tag_ids,
|
||
"assigner_ids": assigner_ids,
|
||
"matched_rules": result.get("matched_rules") or [],
|
||
"rule_id": result.get("rule_id") or "",
|
||
"source": result.get("source") or payload_meta.get("source") or "",
|
||
"skill_name": result.get("skill_name") or skill_meta.get("name"),
|
||
"skill_version": result.get("skill_version") or skill_meta.get("version"),
|
||
"status": status,
|
||
"skip_reason": skip_reason,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"repository": f"{owner}/{repo}" if owner and repo else "",
|
||
"source": payload_meta.get("source") or "skill-json",
|
||
"skill_path": payload_meta.get("skill_path"),
|
||
"skill_name": skill_meta.get("name"),
|
||
"skill_version": skill_meta.get("version"),
|
||
"rules_version": payload_meta.get("rules_version"),
|
||
"dry_run": not apply_triage,
|
||
"enabled": True,
|
||
"analyzed": len(items),
|
||
"items": items,
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def apply_triage_plan(
|
||
plan: dict[str, Any],
|
||
owner: str,
|
||
repo: str,
|
||
runner: Callable[..., Any],
|
||
cli_bin: str | None = None,
|
||
comment: bool = True,
|
||
) -> dict[str, Any]:
|
||
warnings = plan.setdefault("warnings", [])
|
||
for item in plan.get("items", []):
|
||
if item.get("status") != "planned":
|
||
continue
|
||
number = item.get("issue_number")
|
||
try:
|
||
issue_payload = runner(["issue", "+view", "--number", str(number)], owner, repo, cli_bin=cli_bin)
|
||
current_tag_ids, current_assigner_ids = current_issue_metadata(issue_payload)
|
||
final_tag_ids = merge_unique_ints(current_tag_ids, item.get("desired_tag_ids") or [])
|
||
final_assigner_ids = merge_unique_ints(current_assigner_ids, item.get("assigner_ids") or [])
|
||
|
||
command = ["issue", "+update", "--number", str(number)]
|
||
if final_tag_ids:
|
||
command += ["--tag-ids", ",".join(str(value) for value in final_tag_ids)]
|
||
if final_assigner_ids:
|
||
command += ["--assigner-ids", ",".join(str(value) for value in final_assigner_ids)]
|
||
if item.get("priority_id"):
|
||
command += ["--priority-id", str(item["priority_id"])]
|
||
item["existing_tag_ids"] = current_tag_ids
|
||
item["existing_assigner_ids"] = current_assigner_ids
|
||
item["final_tag_ids"] = final_tag_ids
|
||
item["final_assigner_ids"] = final_assigner_ids
|
||
item["update_command"] = command
|
||
item["update_result"] = runner(command, owner, repo, cli_bin=cli_bin)
|
||
item["status"] = "applied"
|
||
if comment:
|
||
comment_command = build_issue_comment_command(int(number), build_triage_comment(item))
|
||
try:
|
||
item["comment_result"] = runner(comment_command, owner, repo, cli_bin=cli_bin)
|
||
except Exception as exc: # noqa: BLE001
|
||
item["comment_error"] = str(exc)
|
||
warnings.append(f"Issue #{number} 审计评论写入失败:{exc}")
|
||
except Exception as exc: # noqa: BLE001
|
||
item["status"] = "failed"
|
||
item["error"] = str(exc)
|
||
warnings.append(f"Issue #{number} 写回失败:{exc}")
|
||
plan["dry_run"] = False
|
||
return plan
|
||
|
||
|
||
def summarize_triage_plan(plan: dict[str, Any]) -> dict[str, Any]:
|
||
items = plan.get("items", [])
|
||
statuses = Counter(str(item.get("status", "unknown")) for item in items)
|
||
return {
|
||
"enabled": plan.get("enabled", False),
|
||
"dry_run": plan.get("dry_run", True),
|
||
"source": plan.get("source"),
|
||
"skill_path": plan.get("skill_path"),
|
||
"skill_name": plan.get("skill_name"),
|
||
"skill_version": plan.get("skill_version"),
|
||
"rules_version": plan.get("rules_version"),
|
||
"analyzed": plan.get("analyzed", len(items)),
|
||
"planned": statuses.get("planned", 0) + statuses.get("applied", 0) + statuses.get("failed", 0),
|
||
"succeeded": statuses.get("applied", 0),
|
||
"failed": statuses.get("failed", 0),
|
||
"skipped": statuses.get("skipped", 0),
|
||
"warnings": plan.get("warnings", []),
|
||
}
|
||
|
||
|
||
def render_triage_plan_markdown(plan: dict[str, Any]) -> str:
|
||
mode = "dry-run" if plan.get("dry_run", True) else "applied"
|
||
lines = [
|
||
"# Issue 自动分类与派单计划",
|
||
"",
|
||
f"- 仓库:`{plan.get('repository') or 'unknown'}`",
|
||
f"- 模式:`{mode}`",
|
||
f"- 分类来源:`{plan.get('source') or 'unknown'}`",
|
||
f"- Skill:`{plan.get('skill_name') or 'unknown'}@{plan.get('skill_version') or 'unknown'}`",
|
||
f"- Skill 路径:`{plan.get('skill_path') or '-'}`",
|
||
f"- 分析 Issue:{plan.get('analyzed', 0)} 条",
|
||
"",
|
||
"| Issue | 标题 | 类型 | 标签 | 负责人ID | 优先级 | 命中规则 | 状态 | 说明 |",
|
||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||
]
|
||
for item in plan.get("items", []):
|
||
title = str(item.get("title", "")).replace("|", "\\|").replace("\n", " ")
|
||
assigners = ",".join(str(value) for value in item.get("assigner_ids") or []) or "-"
|
||
rule = str(item.get("rule_id") or ",".join(str(v) for v in (item.get("matched_rules") or [])[:1]) or "-")
|
||
rule_text = rule.replace("|", "\\|")
|
||
note = item.get("skip_reason") or item.get("error") or ""
|
||
note_text = str(note).replace("|", "\\|")
|
||
lines.append(
|
||
"| "
|
||
f"#{item.get('issue_number') or '-'} | "
|
||
f"{title} | "
|
||
f"{item.get('detected_type') or '-'} | "
|
||
f"{item.get('label_name') or '-'} | "
|
||
f"{assigners} | "
|
||
f"{item.get('priority') or '-'} | "
|
||
f"{rule_text} | "
|
||
f"{item.get('status') or '-'} | "
|
||
f"{note_text} |"
|
||
)
|
||
if plan.get("warnings"):
|
||
lines.extend(["", "## 警告"])
|
||
for warning in plan["warnings"]:
|
||
lines.append(f"- {warning}")
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def is_open(state: str) -> bool:
|
||
return state == "open"
|
||
|
||
|
||
def is_closed(state: str) -> bool:
|
||
return state in {"closed", "close", "done", "resolved"}
|
||
|
||
|
||
def classify_title(title: str) -> str:
|
||
lowered = title.strip().lower()
|
||
prefix = lowered.split(":", 1)[0]
|
||
prefix = prefix.split("(", 1)[0].strip()
|
||
mapping = {
|
||
"feat": "feature",
|
||
"feature": "feature",
|
||
"fix": "fix",
|
||
"bugfix": "fix",
|
||
"docs": "docs",
|
||
"doc": "docs",
|
||
"refactor": "refactor",
|
||
"test": "test",
|
||
"chore": "chore",
|
||
"ci": "ci",
|
||
}
|
||
return mapping.get(prefix, "other")
|
||
|
||
|
||
def within_window(dt: datetime | None, cutoff: datetime) -> bool:
|
||
return dt is not None and dt >= cutoff
|
||
|
||
|
||
def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
seen: set[str] = set()
|
||
result: list[dict[str, Any]] = []
|
||
for item in records:
|
||
key = str(item.get("id", "")).strip()
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
result.append(item)
|
||
return result
|
||
|
||
|
||
def fetch_paginated_payload(
|
||
command: list[str],
|
||
owner: str,
|
||
repo: str,
|
||
item_keys: tuple[str, ...],
|
||
page_size: int = CLI_PAGE_SIZE,
|
||
cli_bin: str | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
items: list[dict[str, Any]] = []
|
||
page = 1
|
||
max_pages = 50
|
||
while True:
|
||
if page > max_pages:
|
||
break
|
||
payload = run_gitlink_cli(
|
||
[*command, "--page", str(page), "--limit", str(page_size)],
|
||
owner,
|
||
repo,
|
||
cli_bin=cli_bin,
|
||
)
|
||
page_items = extract_first_list(payload, item_keys)
|
||
page_items = [item for item in page_items if isinstance(item, dict)]
|
||
if not page_items:
|
||
break
|
||
items.extend(page_items)
|
||
if len(page_items) < page_size:
|
||
break
|
||
page += 1
|
||
return items
|
||
|
||
|
||
def fetch_issues(owner: str, repo: str, cli_bin: str | None = None) -> list[dict[str, Any]]:
|
||
records: list[dict[str, Any]] = []
|
||
for state in ("open", "closed"):
|
||
payloads = fetch_paginated_payload(
|
||
["issue", "+list", "--state", state],
|
||
owner,
|
||
repo,
|
||
("issues", "issue_list", "items", "list"),
|
||
cli_bin=cli_bin,
|
||
)
|
||
records.extend(normalize_issues({"issues": payloads}, query_state=state))
|
||
return dedupe_records(records)
|
||
|
||
|
||
def fetch_triage_issues(
|
||
owner: str,
|
||
repo: str,
|
||
state: str,
|
||
limit: int,
|
||
runner: Callable[..., Any],
|
||
cli_bin: str | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
records: list[dict[str, Any]] = []
|
||
page = 1
|
||
page_size = min(CLI_PAGE_SIZE, max(limit, 1))
|
||
max_pages = 50
|
||
while len(records) < limit and page <= max_pages:
|
||
payload = runner(
|
||
["issue", "+list", "--state", state, "--page", str(page), "--limit", str(page_size)],
|
||
owner,
|
||
repo,
|
||
cli_bin=cli_bin,
|
||
)
|
||
page_items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
|
||
page_items = [item for item in page_items if isinstance(item, dict)]
|
||
if not page_items:
|
||
break
|
||
records.extend(normalize_issues({"issues": page_items}, query_state=state))
|
||
if len(page_items) < page_size:
|
||
break
|
||
page += 1
|
||
return dedupe_records(records)[:limit]
|
||
|
||
|
||
def fetch_prs(owner: str, repo: str, cli_bin: str | None = None) -> list[dict[str, Any]]:
|
||
records: list[dict[str, Any]] = []
|
||
for state in ("open", "merged", "closed"):
|
||
payloads = fetch_paginated_payload(
|
||
["pr", "+list", "--state", state],
|
||
owner,
|
||
repo,
|
||
("pull_requests", "merge_requests", "prs", "items", "list"),
|
||
cli_bin=cli_bin,
|
||
)
|
||
records.extend(normalize_prs({"pull_requests": payloads}, query_state=state))
|
||
return dedupe_records(records)
|
||
|
||
|
||
def fetch_releases(owner: str, repo: str, cli_bin: str | None = None) -> list[dict[str, Any]]:
|
||
payloads = fetch_paginated_payload(
|
||
["release", "+list"],
|
||
owner,
|
||
repo,
|
||
("releases", "items", "list"),
|
||
cli_bin=cli_bin,
|
||
)
|
||
return dedupe_records(normalize_releases({"releases": payloads}))
|
||
|
||
|
||
def summarize_workflow(
|
||
repo_info: dict[str, Any],
|
||
issues: list[dict[str, Any]],
|
||
prs: list[dict[str, Any]],
|
||
releases: list[dict[str, Any]],
|
||
now: datetime,
|
||
window_days: int,
|
||
) -> dict[str, Any]:
|
||
cutoff = now - timedelta(days=window_days)
|
||
|
||
open_issues = [item for item in issues if is_open(item["state"])]
|
||
closed_issues = [item for item in issues if is_closed(item["state"])]
|
||
stale_issues = [
|
||
item
|
||
for item in open_issues
|
||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||
]
|
||
|
||
merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"]
|
||
open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))]
|
||
stale_prs = [
|
||
item
|
||
for item in open_prs
|
||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||
]
|
||
recent_merged_prs = [
|
||
item
|
||
for item in merged_prs
|
||
if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff)
|
||
]
|
||
|
||
issue_label_counter: Counter[str] = Counter()
|
||
for item in issues:
|
||
issue_label_counter.update(item["labels"])
|
||
|
||
pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
for item in recent_merged_prs:
|
||
pr_buckets[classify_title(item["title"])].append(item)
|
||
|
||
actions: list[str] = []
|
||
if stale_issues:
|
||
actions.append(
|
||
f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。"
|
||
)
|
||
if stale_prs:
|
||
actions.append(
|
||
f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。"
|
||
)
|
||
if not releases:
|
||
actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。")
|
||
|
||
return {
|
||
"repo": repo_info,
|
||
"window_days": window_days,
|
||
"now": now,
|
||
"cutoff": cutoff,
|
||
"counts": {
|
||
"issues_total": len(issues),
|
||
"issues_open": len(open_issues),
|
||
"issues_closed": len(closed_issues),
|
||
"issues_stale": len(stale_issues),
|
||
"prs_total": len(prs),
|
||
"prs_open": len(open_prs),
|
||
"prs_merged": len(merged_prs),
|
||
"prs_stale": len(stale_prs),
|
||
"releases_total": len(releases),
|
||
},
|
||
"labels": issue_label_counter.most_common(8),
|
||
"stale_issues": stale_issues,
|
||
"stale_prs": stale_prs,
|
||
"recent_merged_prs": recent_merged_prs,
|
||
"pr_buckets": {key: value for key, value in pr_buckets.items()},
|
||
"actions": actions,
|
||
}
|
||
|
||
|
||
def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str:
|
||
if not items:
|
||
return "- 无"
|
||
lines = []
|
||
for item in items[:10]:
|
||
parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()]
|
||
state = item.get("state")
|
||
if state:
|
||
parts.append(f"({state})")
|
||
dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at")
|
||
if isinstance(dt, datetime):
|
||
parts.append(dt.strftime("%Y-%m-%d"))
|
||
lines.append(" ".join(parts))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def render_markdown_report(summary: dict[str, Any]) -> str:
|
||
repo = summary["repo"]
|
||
counts = summary["counts"]
|
||
lines: list[str] = []
|
||
title = repo["name"] or "GitLink 仓库"
|
||
lines.append(f"# {title} 自动化周报")
|
||
if repo.get("description"):
|
||
lines.append("")
|
||
lines.append(repo["description"])
|
||
lines.append("")
|
||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||
lines.append("")
|
||
lines.append("## 核心指标")
|
||
lines.append("")
|
||
lines.append("| 指标 | 数值 |")
|
||
lines.append("| --- | ---: |")
|
||
lines.append(f"| Issues 总数 | {counts['issues_total']} |")
|
||
lines.append(f"| 打开 Issues | {counts['issues_open']} |")
|
||
lines.append(f"| 超窗 Issue | {counts['issues_stale']} |")
|
||
lines.append(f"| PR 总数 | {counts['prs_total']} |")
|
||
lines.append(f"| 打开 PR | {counts['prs_open']} |")
|
||
lines.append(f"| 已合并 PR | {counts['prs_merged']} |")
|
||
lines.append(f"| Release 数 | {counts['releases_total']} |")
|
||
lines.append("")
|
||
|
||
triage = summary.get("triage")
|
||
if isinstance(triage, dict) and triage.get("enabled"):
|
||
mode = "dry-run" if triage.get("dry_run", True) else "已写回"
|
||
lines.append("## Issue 自动分类与派单")
|
||
lines.append("")
|
||
lines.append(f"- 模式:{mode}")
|
||
lines.append(f"- 分析 Issue:{triage.get('analyzed', 0)} 条")
|
||
lines.append(f"- 计划写回:{triage.get('planned', 0)} 条")
|
||
lines.append(f"- 写回成功:{triage.get('succeeded', 0)} 条")
|
||
lines.append(f"- 写回失败:{triage.get('failed', 0)} 条")
|
||
lines.append(f"- 跳过:{triage.get('skipped', 0)} 条")
|
||
lines.append("")
|
||
|
||
lines.append("## 热点标签")
|
||
if summary["labels"]:
|
||
for label, count in summary["labels"]:
|
||
lines.append(f"- {label}: {count}")
|
||
else:
|
||
lines.append("- 无")
|
||
lines.append("")
|
||
|
||
lines.append("## 最近合并 PR")
|
||
recent_groups = summary["pr_buckets"]
|
||
if recent_groups:
|
||
for bucket, items in recent_groups.items():
|
||
lines.append(f"### {bucket}")
|
||
for item in items[:8]:
|
||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||
lines.append(f"- {item['title']}{suffix}")
|
||
else:
|
||
lines.append("- 无")
|
||
lines.append("")
|
||
|
||
lines.append("## 风险提示")
|
||
if summary["stale_issues"]:
|
||
lines.append("### 超窗 Issue")
|
||
lines.append(render_list_block(summary["stale_issues"]))
|
||
lines.append("")
|
||
if summary["stale_prs"]:
|
||
lines.append("### 超窗 PR")
|
||
lines.append(render_list_block(summary["stale_prs"]))
|
||
lines.append("")
|
||
if summary["actions"]:
|
||
lines.append("### 建议动作")
|
||
for action in summary["actions"]:
|
||
lines.append(f"- {action}")
|
||
else:
|
||
lines.append("- 当前未发现明显风险。")
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def render_release_notes(summary: dict[str, Any]) -> str:
|
||
repo = summary["repo"]
|
||
lines: list[str] = []
|
||
title = repo["name"] or "GitLink 仓库"
|
||
lines.append(f"# {title} Release Notes 草稿")
|
||
lines.append("")
|
||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||
lines.append("")
|
||
lines.append("## 变更概览")
|
||
lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个")
|
||
lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个")
|
||
lines.append("")
|
||
lines.append("## 变更分类")
|
||
groups = summary["pr_buckets"]
|
||
if groups:
|
||
for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"):
|
||
items = groups.get(bucket, [])
|
||
if not items:
|
||
continue
|
||
lines.append(f"### {bucket}")
|
||
for item in items[:10]:
|
||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||
lines.append(f"- {item['title']}{suffix}")
|
||
lines.append("")
|
||
else:
|
||
lines.append("- 无")
|
||
lines.append("")
|
||
lines.append("## 发布说明")
|
||
if summary["actions"]:
|
||
for action in summary["actions"]:
|
||
lines.append(f"- {action}")
|
||
else:
|
||
lines.append("- 当前未发现明显风险。")
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def render_publish_comment(
|
||
summary: dict[str, Any],
|
||
report_path: Path,
|
||
release_notes_path: Path | None = None,
|
||
) -> str:
|
||
repo = summary["repo"]
|
||
counts = summary["counts"]
|
||
lines = [
|
||
f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要",
|
||
"",
|
||
f"- 时间窗:近 {summary['window_days']} 天",
|
||
f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗",
|
||
f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并",
|
||
f"- Release:{counts['releases_total']} 条",
|
||
"",
|
||
f"完整报告已生成:`{report_path.as_posix()}`",
|
||
]
|
||
if release_notes_path is not None:
|
||
lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`")
|
||
triage = summary.get("triage")
|
||
if isinstance(triage, dict) and triage.get("enabled"):
|
||
mode = "dry-run" if triage.get("dry_run", True) else "已写回"
|
||
lines.append(
|
||
f"- Issue 自动分类与派单:{mode},分析 {triage.get('analyzed', 0)} 条,"
|
||
f"成功 {triage.get('succeeded', 0)} 条"
|
||
)
|
||
if summary["actions"]:
|
||
lines.append("")
|
||
lines.append("### 建议动作")
|
||
for action in summary["actions"][:3]:
|
||
lines.append(f"- {action}")
|
||
return "\n".join(lines).rstrip()
|
||
|
||
|
||
def build_issue_comment_command(issue_number: int, comment: str) -> list[str]:
|
||
return ["issue", "+comment", "--number", str(issue_number), "--body", comment]
|
||
|
||
|
||
def safe_fetch(
|
||
label: str,
|
||
func,
|
||
warnings: list[str],
|
||
default: Any,
|
||
) -> Any:
|
||
try:
|
||
return func()
|
||
except Exception as exc: # noqa: BLE001
|
||
warnings.append(f"{label} 失败:{exc}")
|
||
return default
|
||
|
||
|
||
def shutil_which(name: str) -> str | None:
|
||
from shutil import which
|
||
|
||
return which(name)
|
||
|
||
|
||
def run_triage_workflow(
|
||
owner: str,
|
||
repo: str,
|
||
triage_config: dict[str, Any],
|
||
apply_triage: bool,
|
||
output_dir: Path,
|
||
base_name: str,
|
||
cli_bin: str | None = None,
|
||
limit_override: int | None = None,
|
||
state_override: str | None = None,
|
||
triage_skill: Path | None = None,
|
||
config_path: Path | None = None,
|
||
runner: Callable[..., Any] = run_gitlink_cli,
|
||
) -> tuple[dict[str, Any], Path, Path]:
|
||
config = normalize_triage_config(triage_config)
|
||
state = state_override or str(config.get("state") or "open")
|
||
limit = limit_override or int(config.get("limit") or 50)
|
||
skill_path = resolve_triage_skill_path(triage_skill or config.get("skill_path"), config_path=config_path)
|
||
ruleset = load_skill_triage_rules(skill_path)
|
||
config = merge_skill_triage_config(config, ruleset)
|
||
|
||
issues = fetch_triage_issues(owner, repo, state, limit, runner, cli_bin=cli_bin)
|
||
triage_payload = classify_issues_with_skill_rules(issues, ruleset)
|
||
label_payload = runner(["label", "+list"], owner, repo, cli_bin=cli_bin)
|
||
assigner_payload = runner(["issue", "+assigners"], owner, repo, cli_bin=cli_bin)
|
||
plan = build_triage_plan(
|
||
triage_payload,
|
||
label_payload,
|
||
assigner_payload,
|
||
config,
|
||
apply_triage=apply_triage,
|
||
owner=owner,
|
||
repo=repo,
|
||
)
|
||
if apply_triage:
|
||
plan = apply_triage_plan(
|
||
plan,
|
||
owner,
|
||
repo,
|
||
runner,
|
||
cli_bin=cli_bin,
|
||
comment=bool(config.get("comment", True)),
|
||
)
|
||
|
||
markdown_path = output_dir / f"{base_name}_triage_plan.md"
|
||
json_path = output_dir / f"{base_name}_triage_plan.json"
|
||
markdown_path.write_text(render_triage_plan_markdown(plan), encoding="utf-8")
|
||
json_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||
return plan, markdown_path, json_path
|
||
|
||
|
||
def build_artifacts(
|
||
owner: str,
|
||
repo: str,
|
||
window_days: int,
|
||
output_dir: Path,
|
||
now: datetime,
|
||
publish_issue_id: int | None,
|
||
skip_releases: bool,
|
||
triage_config: dict[str, Any] | None = None,
|
||
skip_triage: bool = False,
|
||
apply_triage: bool = False,
|
||
triage_limit: int | None = None,
|
||
triage_state: str | None = None,
|
||
triage_skill: Path | None = None,
|
||
config_path: Path | None = None,
|
||
cli_bin: str | None = None,
|
||
) -> tuple[dict[str, Any], Path, Path, Path, list[str]]:
|
||
warnings: list[str] = []
|
||
repo_info = safe_fetch(
|
||
"repo +info",
|
||
lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo, cli_bin=cli_bin)),
|
||
warnings,
|
||
{"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}},
|
||
)
|
||
issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo, cli_bin=cli_bin), warnings, [])
|
||
prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo, cli_bin=cli_bin), warnings, [])
|
||
releases = [] if skip_releases else safe_fetch(
|
||
"release +list",
|
||
lambda: fetch_releases(owner, repo, cli_bin=cli_bin),
|
||
warnings,
|
||
[],
|
||
)
|
||
|
||
summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days)
|
||
summary["warnings"] = warnings
|
||
summary["owner"] = owner
|
||
summary["repo_name"] = repo
|
||
summary["publish_issue_id"] = publish_issue_id
|
||
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
stamp = now.strftime("%Y%m%d_%H%M%S")
|
||
repo_slug = sanitize_repo_name(repo)
|
||
base_name = f"{owner}_{repo_slug}_{stamp}"
|
||
report_path = output_dir / f"{base_name}_report.md"
|
||
summary_path = output_dir / f"{base_name}_summary.json"
|
||
release_notes_path = output_dir / f"{base_name}_release_notes.md"
|
||
artifacts: dict[str, str] = {
|
||
"report": report_path.as_posix(),
|
||
"summary": summary_path.as_posix(),
|
||
"release_notes": release_notes_path.as_posix(),
|
||
}
|
||
|
||
if skip_triage:
|
||
summary["triage"] = {"enabled": False, "dry_run": True, "analyzed": 0}
|
||
else:
|
||
active_triage_config = normalize_triage_config(triage_config or {})
|
||
if not bool(active_triage_config.get("enabled", True)):
|
||
summary["triage"] = {"enabled": False, "dry_run": True, "analyzed": 0}
|
||
else:
|
||
try:
|
||
triage_plan, triage_markdown_path, triage_json_path = run_triage_workflow(
|
||
owner=owner,
|
||
repo=repo,
|
||
triage_config=active_triage_config,
|
||
apply_triage=apply_triage,
|
||
output_dir=output_dir,
|
||
base_name=base_name,
|
||
cli_bin=cli_bin,
|
||
limit_override=triage_limit,
|
||
state_override=triage_state,
|
||
triage_skill=triage_skill,
|
||
config_path=config_path,
|
||
)
|
||
summary["triage"] = summarize_triage_plan(triage_plan)
|
||
artifacts["triage_plan"] = triage_markdown_path.as_posix()
|
||
artifacts["triage_plan_json"] = triage_json_path.as_posix()
|
||
warnings.extend(triage_plan.get("warnings", []))
|
||
except Exception as exc: # noqa: BLE001
|
||
warning = f"Skill triage 失败:{exc}"
|
||
warnings.append(warning)
|
||
no_issues = "no issues found" in str(exc).lower()
|
||
summary["triage"] = {
|
||
"enabled": True,
|
||
"dry_run": not apply_triage,
|
||
"source": "skill-json",
|
||
"analyzed": 0,
|
||
"planned": 0,
|
||
"succeeded": 0,
|
||
"failed": 0 if no_issues else 1,
|
||
"skipped": 0,
|
||
"warnings": [warning],
|
||
}
|
||
|
||
report_text = render_markdown_report(summary)
|
||
release_notes_text = render_release_notes(summary)
|
||
report_path.write_text(report_text, encoding="utf-8")
|
||
release_notes_path.write_text(release_notes_text, encoding="utf-8")
|
||
summary_path.write_text(
|
||
json.dumps(
|
||
{
|
||
**summary,
|
||
"now": summary["now"].isoformat(),
|
||
"cutoff": summary["cutoff"].isoformat(),
|
||
"artifacts": artifacts,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
default=str,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
if publish_issue_id is not None:
|
||
comment = render_publish_comment(summary, report_path, release_notes_path)
|
||
try:
|
||
run_gitlink_cli(
|
||
build_issue_comment_command(publish_issue_id, comment),
|
||
owner,
|
||
repo,
|
||
cli_bin=cli_bin,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
warnings.append(f"issue +comment 失败:{exc}")
|
||
|
||
return summary, report_path, summary_path, release_notes_path, warnings
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
config = load_json_file(args.config)
|
||
|
||
owner = args.owner or config.get("owner")
|
||
repo = args.repo or config.get("repo")
|
||
if not owner or not repo:
|
||
raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo")
|
||
|
||
window_days = args.window_days or int(config.get("window_days", 7))
|
||
output_dir = args.output_dir or Path(config.get("output_dir", "outputs"))
|
||
now = parse_iso_now(args.now)
|
||
triage_config = normalize_triage_config(config.get("triage") or {})
|
||
|
||
summary, report_path, summary_path, release_notes_path, warnings = build_artifacts(
|
||
owner=owner,
|
||
repo=repo,
|
||
window_days=window_days,
|
||
output_dir=output_dir,
|
||
now=now,
|
||
publish_issue_id=args.publish_issue_id,
|
||
skip_releases=args.skip_releases,
|
||
triage_config=triage_config,
|
||
skip_triage=args.skip_triage,
|
||
apply_triage=args.apply_triage,
|
||
triage_limit=args.triage_limit,
|
||
triage_state=args.triage_state,
|
||
triage_skill=args.triage_skill,
|
||
config_path=args.config,
|
||
cli_bin=args.cli_bin,
|
||
)
|
||
|
||
print(f"已生成报告: {report_path}")
|
||
print(f"已生成摘要: {summary_path}")
|
||
print(f"已生成 Release Notes: {release_notes_path}")
|
||
triage = summary.get("triage")
|
||
if isinstance(triage, dict) and triage.get("enabled"):
|
||
mode = "dry-run" if triage.get("dry_run", True) else "已写回"
|
||
print(
|
||
"Issue 自动分类与派单: "
|
||
f"{mode}, analyzed={triage.get('analyzed', 0)}, "
|
||
f"planned={triage.get('planned', 0)}, "
|
||
f"succeeded={triage.get('succeeded', 0)}, "
|
||
f"failed={triage.get('failed', 0)}, "
|
||
f"skipped={triage.get('skipped', 0)}"
|
||
)
|
||
if warnings:
|
||
print("警告:")
|
||
for warning in warnings:
|
||
print(f"- {warning}")
|
||
print(
|
||
"指标概览: "
|
||
f"Issues={summary['counts']['issues_total']}, "
|
||
f"PR={summary['counts']['prs_total']}, "
|
||
f"Release={summary['counts']['releases_total']}"
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|