"""提交 feedback-issue payload 到 MoviePilot 上游 GitHub 仓库。""" from __future__ import annotations import argparse from pathlib import Path from typing import Any, Optional from feedback_issue_common import ( ALLOWED_ENVIRONMENTS, ALLOWED_ISSUE_TYPES, FEEDBACK_ISSUE_API, FEEDBACK_REPO, FEEDBACK_REQUEST_TIMEOUT, MAX_TITLE_CHARS, build_issue_body, build_prefill_url, check_content_quality, check_recent_duplicate, check_user_rate_limit, classify_failure, load_diagnostics_logs, load_submission_state, read_json_file, record_submission, record_user_submission, result_payload, safe_response_dict, save_submission_state, settings, truncate, validate_enum, ) from app.utils.http import RequestUtils REQUIRED_PAYLOAD_FIELDS = ( "title", "version", "environment", "issue_type", "description", "original_user_request", "diagnostics_file", ) def normalize_payload(raw: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """规范化提交 payload 并返回缺失字段。""" payload = {key: str(raw.get(key) or "").strip() for key in REQUIRED_PAYLOAD_FIELDS} missing = [key for key, value in payload.items() if not value] payload["title"] = truncate(payload["title"], MAX_TITLE_CHARS, marker="...") return payload, missing def validate_payload(payload: dict[str, Any], logs: str) -> Optional[str]: """校验提交 payload 的枚举值和内容质量。""" for value, allowed, field_name in ( (payload["environment"], ALLOWED_ENVIRONMENTS, "environment"), (payload["issue_type"], ALLOWED_ISSUE_TYPES, "issue_type"), ): error = validate_enum(value, allowed, field_name) if error: return error return check_content_quality( title=payload["title"], description=payload["description"], original_user_request=payload["original_user_request"], logs=logs, ) def build_no_token_result(payload: dict[str, Any], logs: str) -> dict[str, Any]: """构造未配置 GitHub Token 时的预填链接降级结果。""" prefill_url = build_prefill_url( title=payload["title"], version=payload["version"], environment=payload["environment"], issue_type=payload["issue_type"], description=payload["description"], logs=logs, ) return { "success": False, "reason": "no_token", "repo": FEEDBACK_REPO, "prefill_url": prefill_url, "message": ( "MoviePilot 未配置可写入的 GitHub Token,无法自动提交 Issue。" "请把 prefill_url 原样发给用户,由用户在浏览器或 GitHub App 中确认提交。" ), } def post_github_issue(payload: dict[str, Any], body: str) -> Any: """调用 GitHub REST API 创建 Issue 并返回响应对象。""" request_headers = { **settings.GITHUB_HEADERS, "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json", } request_payload = { "title": payload["title"], "body": body, "labels": ["bug"], } return RequestUtils( proxies=settings.PROXY, headers=request_headers, timeout=FEEDBACK_REQUEST_TIMEOUT, ).post(FEEDBACK_ISSUE_API, json=request_payload) def build_api_failure_result( *, reason: str, payload: dict[str, Any], logs: str, github_message: str | None = None, ) -> dict[str, Any]: """构造 GitHub API 失败后的预填链接兜底结果。""" prefill_url = build_prefill_url( title=payload["title"], version=payload["version"], environment=payload["environment"], issue_type=payload["issue_type"], description=payload["description"], logs=logs, ) return { "success": False, "reason": reason, "repo": FEEDBACK_REPO, "prefill_url": prefill_url, "github_message": github_message, "message": "GitHub API 未能自动创建 Issue,请把 prefill_url 原样发给用户手动提交。", } def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]: """读取 payload 文件并执行提交或预填链接降级流程。""" raw = read_json_file(payload_file) payload, missing = normalize_payload(raw) if missing: return { "success": False, "reason": "missing_fields", "message": f"payload 缺少必填字段:{', '.join(missing)}", } try: logs, _ = load_diagnostics_logs(payload["diagnostics_file"]) except Exception as err: return { "success": False, "reason": "diagnostics_missing", "message": f"无法读取诊断日志文件:{err}", } error = validate_payload(payload, logs) if error: return { "success": False, "reason": "rejected_quality", "message": error, } body = build_issue_body( version=payload["version"], environment=payload["environment"], issue_type=payload["issue_type"], description=payload["description"], logs=logs, ) state = load_submission_state() if check_recent_duplicate(payload["title"], body, state): return { "success": False, "reason": "duplicate", "message": "该问题反馈在 60 秒内已经提交或尝试提交过一次,已避免重复提交。", } rate_error = check_user_rate_limit(username, state) if rate_error: result = build_api_failure_result( reason="rate_limited_user", payload=payload, logs=logs, ) result["message"] = rate_error + " 如确实是另一个真实问题,请使用 prefill_url 手动提交。" save_submission_state(state) return result record_user_submission(username, state) if not settings.GITHUB_TOKEN: save_submission_state(state) return build_no_token_result(payload, logs) record_submission(payload["title"], body, state) save_submission_state(state) try: response = post_github_issue(payload, body) except Exception as err: return build_api_failure_result( reason="network_error", payload=payload, logs=logs, github_message=str(err), ) if response is None: return build_api_failure_result( reason="network_error", payload=payload, logs=logs, ) if response.status_code == 201: data = safe_response_dict(response) return { "success": True, "repo": FEEDBACK_REPO, "issue_number": data.get("number"), "issue_url": data.get("html_url"), "message": "Issue 已成功提交到 MoviePilot 上游仓库。", } reason = classify_failure(response.status_code, headers=dict(response.headers or {})) api_data = safe_response_dict(response) api_message = api_data.get("message") if api_data else None if not api_message and getattr(response, "text", None): api_message = response.text[:200] return build_api_failure_result( reason=reason, payload=payload, logs=logs, github_message=api_message, ) def parse_args() -> argparse.Namespace: """解析命令行参数。""" parser = argparse.ArgumentParser(description="提交 MoviePilot 反馈 Issue") parser.add_argument("--payload-file", required=True, help="prepare 脚本生成的 payload JSON 文件") parser.add_argument( "--username", default="agent-admin", help="用于提交频率限制的管理员用户名;未知时保留默认值", ) return parser.parse_args() def main() -> int: """脚本入口:输出 JSON 提交结果。""" args = parse_args() result = submit_issue(args.payload_file, args.username) print(result_payload(**result)) return 0 if result.get("success") or result.get("reason") in {"no_token"} else 2 if __name__ == "__main__": raise SystemExit(main())