gitlink-cli/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py

1813 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())