mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-09 22:43:44 +08:00
refactor(agent): move feedback issue flow into skill scripts
This commit is contained in:
308
skills/feedback-issue/scripts/collect_feedback_diagnostics.py
Normal file
308
skills/feedback-issue/scripts/collect_feedback_diagnostics.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""收集 feedback-issue 提交流程需要的本地诊断日志。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from feedback_issue_common import (
|
||||
MAX_LOGS_CHARS,
|
||||
feedback_runtime_dir,
|
||||
result_payload,
|
||||
runtime_file,
|
||||
sanitize_logs,
|
||||
settings,
|
||||
write_json_file,
|
||||
)
|
||||
|
||||
|
||||
_MAX_READ_BYTES = 512 * 1024
|
||||
_DEFAULT_TIME_WINDOW_MINUTES = 30
|
||||
_MIN_TIME_WINDOW_MINUTES = 5
|
||||
_MAX_TIME_WINDOW_MINUTES = 24 * 60
|
||||
|
||||
_LOG_TIMESTAMP_RE = re.compile(r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})")
|
||||
_LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_MODULE_RE = re.compile(
|
||||
r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+-\s+([^\s][^\-]*?)\s+-\s+"
|
||||
)
|
||||
|
||||
_META_NOISE_MODULES = frozenset({
|
||||
"collect_feedback_diagnostics.py",
|
||||
"prepare_feedback_issue.py",
|
||||
"submit_feedback_issue.py",
|
||||
"ask_user_choice.py",
|
||||
"base.py",
|
||||
"agent",
|
||||
"factory.py",
|
||||
"callback",
|
||||
"prompt",
|
||||
"memory.py",
|
||||
"activity_log.py",
|
||||
"message.py",
|
||||
"event.py",
|
||||
"chain",
|
||||
"discord",
|
||||
"telegram",
|
||||
"telegram.py",
|
||||
"execute_command.py",
|
||||
})
|
||||
|
||||
_VAGUE_KEYWORDS = frozenset({
|
||||
"错误", "异常", "失败", "error", "exception", "failed", "warn", "warning",
|
||||
"日志", "问题", "bug", "log", "logs",
|
||||
})
|
||||
|
||||
_FEEDBACK_VERB_PHRASES: tuple[str, ...] = (
|
||||
"反馈", "提交", "上报", "汇报",
|
||||
"提 issue", "提issue", "提 bug", "提bug",
|
||||
"报 bug", "报bug", "报告 bug", "报告bug",
|
||||
"新建 issue", "新建issue", "开 issue", "开issue",
|
||||
"让上游", "给上游",
|
||||
"file an issue", "report a bug", "open an upstream issue",
|
||||
"submit an issue", "raise an issue", "report this upstream",
|
||||
"report upstream",
|
||||
)
|
||||
_FEEDBACK_TARGET_TOKENS: tuple[str, ...] = (
|
||||
"issue", "bug", "问题", "错误报告",
|
||||
"上游", "mp", "moviepilot",
|
||||
)
|
||||
_FEEDBACK_STANDALONE_PHRASES: tuple[str, ...] = (
|
||||
"file an issue", "report a bug", "open an upstream issue",
|
||||
"submit an issue", "raise an issue", "report this upstream",
|
||||
"report upstream",
|
||||
"新建 issue", "新建issue", "开 issue", "开issue",
|
||||
"提 issue", "提issue", "提 bug", "提bug",
|
||||
"报 bug", "报bug", "报告 bug", "报告bug",
|
||||
"让上游", "给上游",
|
||||
)
|
||||
_FEEDBACK_REGEX_PATTERNS: tuple[re.Pattern, ...] = (
|
||||
re.compile(r"提.{0,6}(bug|issue|问题|错误报告)", re.IGNORECASE),
|
||||
re.compile(r"报.{0,6}(bug|issue|错误报告)", re.IGNORECASE),
|
||||
re.compile(r"反馈.{0,8}(issue|bug|问题|上游|错误)", re.IGNORECASE),
|
||||
re.compile(r"开.{0,4}(issue|bug)", re.IGNORECASE),
|
||||
re.compile(r"上报.{0,6}(bug|issue|问题|错误)", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def read_tail(path: Path) -> str:
|
||||
"""读取日志文件尾部,避免大日志一次性进入内存。"""
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with path.open("rb") as file_obj:
|
||||
if size > _MAX_READ_BYTES:
|
||||
file_obj.seek(size - _MAX_READ_BYTES)
|
||||
return file_obj.read().decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def candidate_log_files() -> list[Path]:
|
||||
"""返回反馈诊断可读取的主日志和插件日志文件。"""
|
||||
files = [settings.LOG_PATH / "moviepilot.log"]
|
||||
plugin_log_dir = settings.LOG_PATH / "plugins"
|
||||
if plugin_log_dir.exists():
|
||||
files.extend(sorted(plugin_log_dir.rglob("*.log")))
|
||||
return [path for path in files if path.exists() and path.is_file()]
|
||||
|
||||
|
||||
def normalize_keywords(keywords: Optional[list[str]]) -> list[str]:
|
||||
"""过滤掉过短或过于宽泛的日志关键词。"""
|
||||
normalized: list[str] = []
|
||||
for item in keywords or []:
|
||||
item = str(item or "").strip()
|
||||
if len(item) < 2:
|
||||
continue
|
||||
if item.lower() in _VAGUE_KEYWORDS:
|
||||
continue
|
||||
if item not in normalized:
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
def has_explicit_feedback_intent(original_user_request: str) -> bool:
|
||||
"""判断用户原话里是否出现明确要求提 Issue 的意图。"""
|
||||
if not original_user_request:
|
||||
return False
|
||||
normalized = original_user_request.lower().strip()
|
||||
if any(phrase in normalized for phrase in _FEEDBACK_STANDALONE_PHRASES):
|
||||
return True
|
||||
if any(pattern.search(normalized) for pattern in _FEEDBACK_REGEX_PATTERNS):
|
||||
return True
|
||||
has_verb = any(phrase in normalized for phrase in _FEEDBACK_VERB_PHRASES)
|
||||
has_target = any(token in normalized for token in _FEEDBACK_TARGET_TOKENS)
|
||||
return has_verb and has_target
|
||||
|
||||
|
||||
def normalize_window(time_window_minutes: int) -> int:
|
||||
"""把传入的时间窗限制到 5 到 1440 分钟之间。"""
|
||||
try:
|
||||
window = int(time_window_minutes or _DEFAULT_TIME_WINDOW_MINUTES)
|
||||
except (TypeError, ValueError):
|
||||
window = _DEFAULT_TIME_WINDOW_MINUTES
|
||||
return max(_MIN_TIME_WINDOW_MINUTES, min(_MAX_TIME_WINDOW_MINUTES, window))
|
||||
|
||||
|
||||
def parse_line_timestamp(line: str) -> Optional[datetime]:
|
||||
"""从一行日志开头提取时间戳;提取不到返回 None。"""
|
||||
match = _LOG_TIMESTAMP_RE.search(line[:64])
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(match.group(1), _LOG_TIMESTAMP_FORMAT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_meta_noise(line: str) -> bool:
|
||||
"""判断日志行是否来自 Agent 自身的工具调度或消息框架噪音。"""
|
||||
match = _LOG_MODULE_RE.match(line)
|
||||
if not match:
|
||||
return False
|
||||
return match.group(1).strip() in _META_NOISE_MODULES
|
||||
|
||||
|
||||
def filter_lines(
|
||||
text: str,
|
||||
keywords: list[str],
|
||||
max_lines: int,
|
||||
window_start: datetime,
|
||||
) -> list[str]:
|
||||
"""按时间窗、模块噪音和关键词筛选日志行。"""
|
||||
candidates: list[str] = []
|
||||
last_seen_in_window: Optional[bool] = None
|
||||
last_seen_was_meta = False
|
||||
for line in text.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
timestamp = parse_line_timestamp(line)
|
||||
if timestamp is not None:
|
||||
in_window = timestamp >= window_start
|
||||
meta = is_meta_noise(line)
|
||||
last_seen_was_meta = meta
|
||||
last_seen_in_window = in_window and not meta
|
||||
if in_window and not meta:
|
||||
candidates.append(line)
|
||||
elif last_seen_in_window and not last_seen_was_meta:
|
||||
candidates.append(line)
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
if keywords:
|
||||
lowered_keywords = [item.lower() for item in keywords]
|
||||
matched: list[str] = []
|
||||
keep_block = False
|
||||
for line in candidates:
|
||||
has_timestamp = parse_line_timestamp(line) is not None
|
||||
if has_timestamp:
|
||||
keep_block = any(keyword in line.lower() for keyword in lowered_keywords)
|
||||
if keep_block:
|
||||
matched.append(line)
|
||||
elif keep_block:
|
||||
matched.append(line)
|
||||
if matched:
|
||||
return matched[-max_lines:]
|
||||
return candidates[-max_lines:]
|
||||
|
||||
|
||||
def collect_diagnostics(
|
||||
*,
|
||||
original_user_request: str,
|
||||
keywords: list[str],
|
||||
max_lines: int,
|
||||
time_window_minutes: int,
|
||||
) -> dict:
|
||||
"""读取日志、筛选、脱敏并写入运行时诊断文件。"""
|
||||
if not has_explicit_feedback_intent(original_user_request):
|
||||
return {
|
||||
"success": False,
|
||||
"reason": "no_explicit_feedback_intent",
|
||||
"message": (
|
||||
"用户原话里没有明确要求向上游反馈 Issue 的短语,"
|
||||
"请先回到常规诊断路径;只有明确说出反馈 issue / 提 issue / 报 bug "
|
||||
"等意图时才运行 feedback-issue 流程。"
|
||||
),
|
||||
}
|
||||
|
||||
normalized_max_lines = min(max(int(max_lines or 80), 20), 200)
|
||||
window_minutes = normalize_window(time_window_minutes)
|
||||
window_start = datetime.now() - timedelta(minutes=window_minutes)
|
||||
normalized_keywords = normalize_keywords(keywords)
|
||||
collected: list[str] = []
|
||||
source_files: list[str] = []
|
||||
|
||||
for path in candidate_log_files():
|
||||
text = read_tail(path)
|
||||
if not text:
|
||||
continue
|
||||
lines = filter_lines(
|
||||
text=text,
|
||||
keywords=normalized_keywords,
|
||||
max_lines=normalized_max_lines,
|
||||
window_start=window_start,
|
||||
)
|
||||
if not lines:
|
||||
continue
|
||||
source_files.append(str(path))
|
||||
collected.append(f"### {path.name}\n" + "\n".join(lines))
|
||||
|
||||
logs = sanitize_logs("\n\n".join(collected), MAX_LOGS_CHARS)
|
||||
diagnostics_file = runtime_file("diagnostics", ".json")
|
||||
diagnostics = {
|
||||
"original_user_request": original_user_request,
|
||||
"keywords": normalized_keywords,
|
||||
"found": bool(logs.strip()),
|
||||
"logs": logs,
|
||||
"source_files": source_files,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
write_json_file(diagnostics_file, diagnostics)
|
||||
return {
|
||||
"success": True,
|
||||
"found": diagnostics["found"],
|
||||
"diagnostics_file": str(diagnostics_file),
|
||||
"runtime_dir": str(feedback_runtime_dir()),
|
||||
"source_files": source_files,
|
||||
"log_bytes": len(logs.encode("utf-8", errors="replace")),
|
||||
"log_lines": len(logs.splitlines()) if logs else 0,
|
||||
"message": (
|
||||
"已收集并写入反馈诊断日志文件。"
|
||||
if logs
|
||||
else "已完成诊断日志收集,但未找到明显相关日志。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""解析命令行参数。"""
|
||||
parser = argparse.ArgumentParser(description="收集 MoviePilot 反馈 Issue 诊断日志")
|
||||
parser.add_argument("--original-user-request", required=True, help="触发反馈的用户原话")
|
||||
parser.add_argument("--keyword", action="append", default=[], help="用于过滤日志的具体关键词,可重复")
|
||||
parser.add_argument("--max-lines", type=int, default=80, help="最多保留的日志行数")
|
||||
parser.add_argument(
|
||||
"--time-window-minutes",
|
||||
type=int,
|
||||
default=_DEFAULT_TIME_WINDOW_MINUTES,
|
||||
help="只收集最近 N 分钟日志,默认 30",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""脚本入口:输出 JSON 结果给 Agent 解析。"""
|
||||
args = parse_args()
|
||||
result = collect_diagnostics(
|
||||
original_user_request=args.original_user_request,
|
||||
keywords=args.keyword,
|
||||
max_lines=args.max_lines,
|
||||
time_window_minutes=args.time_window_minutes,
|
||||
)
|
||||
print(result_payload(**result))
|
||||
return 0 if result.get("success") else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
494
skills/feedback-issue/scripts/feedback_issue_common.py
Normal file
494
skills/feedback-issue/scripts/feedback_issue_common.py
Normal file
@@ -0,0 +1,494 @@
|
||||
"""feedback-issue skill 脚本共享逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""从当前工作目录和脚本路径向上查找 MoviePilot 仓库根目录。"""
|
||||
script_path = Path(__file__).resolve()
|
||||
candidates = [Path.cwd().resolve(), *Path.cwd().resolve().parents]
|
||||
candidates.extend([script_path.parent, *script_path.parents])
|
||||
for candidate in candidates:
|
||||
if (candidate / "app" / "core" / "config.py").is_file():
|
||||
return candidate
|
||||
return script_path.parents[3]
|
||||
|
||||
|
||||
REPO_ROOT = _find_repo_root()
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
|
||||
FEEDBACK_REPO_OWNER = "jxxghp"
|
||||
FEEDBACK_REPO_NAME = "MoviePilot"
|
||||
FEEDBACK_REPO = f"{FEEDBACK_REPO_OWNER}/{FEEDBACK_REPO_NAME}"
|
||||
FEEDBACK_ISSUE_API = f"https://api.github.com/repos/{FEEDBACK_REPO}/issues"
|
||||
FEEDBACK_ISSUE_NEW_URL = f"https://github.com/{FEEDBACK_REPO}/issues/new"
|
||||
FEEDBACK_ISSUE_TEMPLATE = "bug_report.yml"
|
||||
FEEDBACK_REQUEST_TIMEOUT = 15
|
||||
|
||||
ALLOWED_ENVIRONMENTS = ("Docker", "Windows")
|
||||
ALLOWED_ISSUE_TYPES = ("主程序运行问题", "插件问题", "其他问题")
|
||||
|
||||
MAX_TITLE_CHARS = 256
|
||||
MAX_BODY_CHARS = 60 * 1024
|
||||
MAX_LOGS_CHARS = 8 * 1024
|
||||
MAX_URL_LOGS_CHARS = 3 * 1024
|
||||
MAX_PREVIEW_LOGS_CHARS = 3 * 1024
|
||||
|
||||
DEDUP_TTL_SECONDS = 60
|
||||
USER_COOLDOWN_SECONDS = 30 * 60
|
||||
USER_DAILY_QUOTA = 10
|
||||
USER_DAILY_WINDOW_SECONDS = 24 * 60 * 60
|
||||
MAX_USER_SUBMISSIONS_BUCKETS = 200
|
||||
|
||||
MIN_TITLE_BODY_CHARS = 8
|
||||
MIN_DESCRIPTION_CHARS = 50
|
||||
TITLE_PREFIX = "[错误报告]:"
|
||||
|
||||
_QUALITY_BLOCKLIST = (
|
||||
"测试issue", "测试 issue", "test issue",
|
||||
"test123", "testtest", "测试测试",
|
||||
"测试一下", "测试提交", "测试请求", "测试反馈",
|
||||
"看能否跑通", "能否跑通", "跑通流程", "链路测试",
|
||||
"模拟问题", "模拟问题描述", "模拟描述", "模拟 bug", "模拟bug",
|
||||
"编造", "虚假 bug", "虚假bug",
|
||||
"asdf", "asdfasdf", "qwer", "qwerty", "qweqwe",
|
||||
"占位", "占个坑", "随便", "随便写",
|
||||
"abcabc", "xxxxxx", "xxx xxx",
|
||||
"hello world", "你好世界",
|
||||
"lorem ipsum", "dolor sit amet",
|
||||
)
|
||||
|
||||
_FABRICATED_LOG_PHRASES = (
|
||||
"无相关日志", "没有相关日志", "未捕获到相关日志",
|
||||
"这是模拟", "模拟问题", "模拟描述", "用户反馈",
|
||||
)
|
||||
|
||||
_DESCRIPTION_REQUIRED_SIGNALS = (
|
||||
("现象", ("现象", "报错", "错误", "无法", "失败", "异常")),
|
||||
("复现步骤", ("复现", "步骤", "触发", "操作", "调用", "点击")),
|
||||
("期望行为", ("期望", "应该", "预期", "正常")),
|
||||
)
|
||||
|
||||
_REPEAT_GIBBERISH = re.compile(r"([^\s=\-_*#~`./\\+|])\1{7,}", re.UNICODE)
|
||||
|
||||
_REDACTED = "<REDACTED>"
|
||||
_REDACTED_PATH = "/<USER>/"
|
||||
_REDACTED_EMAIL = "<EMAIL>"
|
||||
_REDACTED_IP = "<IP>"
|
||||
|
||||
_SENSITIVE_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
|
||||
(re.compile(r"(?i)(Cookie\s*:\s*)[^\r\n]+"), rf"\1{_REDACTED}"),
|
||||
(re.compile(r"(?i)(Set-Cookie\s*:\s*)[^\r\n]+"), rf"\1{_REDACTED}"),
|
||||
(
|
||||
re.compile(r"(?i)(Authorization\s*:\s*)(Bearer|Basic|Token)\s+\S+"),
|
||||
rf"\1\2 {_REDACTED}",
|
||||
),
|
||||
(re.compile(r"(?i)(X-(?:Api-Key|Auth-Token|Access-Token)\s*:\s*)\S+"), rf"\1{_REDACTED}"),
|
||||
(re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"), _REDACTED),
|
||||
(re.compile(r"\bgho_[A-Za-z0-9]{20,}\b"), _REDACTED),
|
||||
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), _REDACTED),
|
||||
(re.compile(r"\b(sk|xoxb|xoxp|xoxa)-[A-Za-z0-9-]{12,}\b"), _REDACTED),
|
||||
(re.compile(r"\buser_\d{4,}_\d+\b"), _REDACTED),
|
||||
(re.compile(r"(?i)\b(passkey|rsskey|authkey|access_key)=[A-Za-z0-9]{8,}"), rf"\1={_REDACTED}"),
|
||||
(
|
||||
re.compile(
|
||||
r"https?://(qyapi\.weixin\.qq\.com|oapi\.dingtalk\.com|open\.feishu\.cn|"
|
||||
r"hooks\.slack\.com|discord(?:app)?\.com/api/webhooks)/\S+"
|
||||
),
|
||||
rf"\1/{_REDACTED}",
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r"(?i)\b("
|
||||
r"api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|"
|
||||
r"client[_-]?secret|client[_-]?id|app[_-]?secret|app[_-]?key|"
|
||||
r"corp[_-]?secret|corp[_-]?id|agent[_-]?id|"
|
||||
r"password|secret|token|auth|credential|"
|
||||
r"chat[_-]?id|webhook|api[_-]?token|bot[_-]?token|"
|
||||
r"user[_-]?id|userid|username|user[_-]?name|"
|
||||
r"session[_-]?id|sessionid|"
|
||||
r"open[_-]?id|openid|union[_-]?id|unionid"
|
||||
r")(\s*[:=]\s*)['\"]?[^\s'\"&\r\n]{2,}"
|
||||
),
|
||||
rf"\1\2{_REDACTED}",
|
||||
),
|
||||
(
|
||||
re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
|
||||
_REDACTED_EMAIL,
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r"\b(?!(?:127|10)\.)"
|
||||
r"(?!172\.(?:1[6-9]|2\d|3[01])\.)"
|
||||
r"(?!192\.168\.)"
|
||||
r"(?:\d{1,3}\.){3}\d{1,3}\b"
|
||||
),
|
||||
_REDACTED_IP,
|
||||
),
|
||||
(re.compile(r"/Users/[^/\s]+/"), _REDACTED_PATH),
|
||||
(re.compile(r"/home/[^/\s]+/"), _REDACTED_PATH),
|
||||
(re.compile(r"C:\\Users\\[^\\\s]+\\", re.IGNORECASE), r"C:\\Users\\<USER>\\"),
|
||||
)
|
||||
|
||||
|
||||
def feedback_runtime_dir() -> Path:
|
||||
"""返回 feedback-issue 脚本使用的运行时目录并确保存在。"""
|
||||
runtime_dir = settings.TEMP_PATH / "feedback-issue"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
return runtime_dir
|
||||
|
||||
|
||||
def runtime_file(prefix: str, suffix: str) -> Path:
|
||||
"""在运行时目录下生成一个随机文件路径。"""
|
||||
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "feedback"
|
||||
return feedback_runtime_dir() / f"{safe_prefix}-{uuid.uuid4().hex[:12]}{suffix}"
|
||||
|
||||
|
||||
def ensure_runtime_file(path: str | Path) -> Path:
|
||||
"""校验脚本间传递的文件必须位于 feedback-issue 运行时目录内。"""
|
||||
candidate = Path(path).expanduser().resolve()
|
||||
runtime_dir = feedback_runtime_dir().resolve()
|
||||
if not candidate.is_relative_to(runtime_dir):
|
||||
raise ValueError(f"只允许读取 feedback-issue 运行时目录内的文件: {candidate}")
|
||||
return candidate
|
||||
|
||||
|
||||
def read_json_file(path: str | Path) -> dict[str, Any]:
|
||||
"""读取 JSON 文件并确保顶层对象是 dict。"""
|
||||
json_path = Path(path).expanduser()
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"JSON 顶层必须是对象: {json_path}")
|
||||
return data
|
||||
|
||||
|
||||
def write_json_file(path: str | Path, payload: dict[str, Any]) -> Path:
|
||||
"""把 JSON 对象写入文件并返回实际路径。"""
|
||||
json_path = Path(path)
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return json_path
|
||||
|
||||
|
||||
def validate_enum(value: str, allowed: tuple[str, ...], field_name: str) -> Optional[str]:
|
||||
"""校验枚举字段,返回错误信息;通过时返回 None。"""
|
||||
if value not in allowed:
|
||||
return (
|
||||
f"{field_name} 必须是以下之一:{', '.join(allowed)};"
|
||||
f"当前传入:{value!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def redact_logs(raw: str) -> str:
|
||||
"""对日志文本做统一脱敏,覆盖常见 token、Cookie、PII 和本机路径。"""
|
||||
out = raw
|
||||
for pattern, replacement in _SENSITIVE_PATTERNS:
|
||||
out = pattern.sub(replacement, out)
|
||||
return out
|
||||
|
||||
|
||||
def truncate(text: str, limit: int, marker: str = "\n...(已截断)") -> str:
|
||||
"""按字符数截断文本并附加截断标记。"""
|
||||
if not text or len(text) <= limit:
|
||||
return text
|
||||
return text[: max(0, limit - len(marker))] + marker
|
||||
|
||||
|
||||
def sanitize_logs(logs: Optional[str], limit: int) -> str:
|
||||
"""清洗日志:去空白、脱敏并按指定长度截断。"""
|
||||
if not logs or not logs.strip():
|
||||
return ""
|
||||
return truncate(redact_logs(logs.strip()), limit)
|
||||
|
||||
|
||||
def build_issue_body(
|
||||
*,
|
||||
version: str,
|
||||
environment: str,
|
||||
issue_type: str,
|
||||
description: str,
|
||||
logs: Optional[str],
|
||||
) -> str:
|
||||
"""构造与上游 bug_report.yml 表单渲染接近的 Issue Markdown 正文。"""
|
||||
log_block = sanitize_logs(logs, MAX_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
|
||||
body = (
|
||||
"### 确认\n\n"
|
||||
"- [x] 我的版本是最新版本,我的版本号与 "
|
||||
"[version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。\n"
|
||||
"- [x] 我已经 [issue](https://github.com/jxxghp/MoviePilot/issues) "
|
||||
"中搜索过,确认我的问题没有被提出过。\n"
|
||||
"- [x] 我已经 [Telegram频道](https://t.me/moviepilot_channel) "
|
||||
"中搜索过,确认我的问题没有被提出过。\n"
|
||||
"- [x] 我已经修改标题,将标题中的 描述 替换为我遇到的问题。\n\n"
|
||||
f"### 当前程序版本\n\n{version}\n\n"
|
||||
f"### 运行环境\n\n{environment}\n\n"
|
||||
f"### 问题类型\n\n{issue_type}\n\n"
|
||||
f"### 问题描述\n\n{description.strip()}\n\n"
|
||||
"### 发生问题时系统日志和配置文件\n\n"
|
||||
f"```bash\n{log_block}\n```\n"
|
||||
"\n---\n"
|
||||
"_本 Issue 由 MoviePilot Agent 协助用户提交。_"
|
||||
)
|
||||
return truncate(body, MAX_BODY_CHARS)
|
||||
|
||||
|
||||
def build_prefill_url(
|
||||
*,
|
||||
title: str,
|
||||
version: str,
|
||||
environment: str,
|
||||
issue_type: str,
|
||||
description: str,
|
||||
logs: Optional[str],
|
||||
) -> str:
|
||||
"""生成 GitHub Issue Forms 预填 URL,供无 token 或 API 失败时手动提交。"""
|
||||
params = {
|
||||
"template": FEEDBACK_ISSUE_TEMPLATE,
|
||||
"title": title,
|
||||
"version": version,
|
||||
"environment": environment,
|
||||
"type": issue_type,
|
||||
"what-happened": description,
|
||||
"logs": sanitize_logs(logs, MAX_URL_LOGS_CHARS),
|
||||
}
|
||||
encoded = "&".join(
|
||||
f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items()
|
||||
)
|
||||
return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}"
|
||||
|
||||
|
||||
def classify_failure(status_code: Optional[int], headers: Optional[dict] = None) -> str:
|
||||
"""把 GitHub API HTTP 状态码映射成脚本输出的稳定失败原因。"""
|
||||
headers = headers or {}
|
||||
if status_code == 401:
|
||||
return "no_permission"
|
||||
if status_code == 403:
|
||||
remaining = headers.get("X-RateLimit-Remaining") or headers.get(
|
||||
"x-ratelimit-remaining"
|
||||
)
|
||||
if remaining == "0":
|
||||
return "rate_limited"
|
||||
return "no_permission"
|
||||
if status_code == 404:
|
||||
return "no_permission"
|
||||
if status_code == 422:
|
||||
return "invalid_payload"
|
||||
if status_code is not None and status_code >= 500:
|
||||
return "github_unavailable"
|
||||
return "api_error"
|
||||
|
||||
|
||||
def safe_response_dict(response: Any) -> dict[str, Any]:
|
||||
"""安全解析 HTTP 响应 JSON,非 dict 或解析失败时返回空 dict。"""
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return {}
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return {}
|
||||
|
||||
|
||||
def check_content_quality(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
original_user_request: str,
|
||||
logs: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""检查 Issue 内容质量,拦截测试、占位、乱码和结构缺失的提交。"""
|
||||
original_stripped = (original_user_request or "").strip()
|
||||
if not original_stripped:
|
||||
return (
|
||||
"缺少原始用户请求,无法判断本次提交是否来自真实故障。"
|
||||
"请传入触发反馈的用户原话,不能只传改写后的 Issue 草稿。"
|
||||
)
|
||||
|
||||
title_body = title.strip()
|
||||
if title_body.startswith(TITLE_PREFIX):
|
||||
title_body = title_body[len(TITLE_PREFIX):].strip()
|
||||
if len(title_body) < MIN_TITLE_BODY_CHARS:
|
||||
return (
|
||||
f"标题正文太短(剔除 {TITLE_PREFIX!r} 前缀后只有 {len(title_body)} 字,"
|
||||
f"至少 {MIN_TITLE_BODY_CHARS} 字)。请用一句完整的话概括症状。"
|
||||
)
|
||||
|
||||
desc_stripped = description.strip()
|
||||
if len(desc_stripped) < MIN_DESCRIPTION_CHARS:
|
||||
return (
|
||||
f"问题描述太短({len(desc_stripped)} 字,至少 {MIN_DESCRIPTION_CHARS} 字)。"
|
||||
"请补充:现象 / 复现步骤 / 期望行为。"
|
||||
)
|
||||
|
||||
missing_signals = [
|
||||
label
|
||||
for label, choices in _DESCRIPTION_REQUIRED_SIGNALS
|
||||
if not any(choice in desc_stripped for choice in choices)
|
||||
]
|
||||
if missing_signals:
|
||||
return (
|
||||
"问题描述缺少可复现 bug 所需的结构信息:"
|
||||
f"{' / '.join(missing_signals)}。请补充真实现象、触发步骤和期望行为。"
|
||||
)
|
||||
|
||||
haystack = "\n".join(
|
||||
part for part in (title, description, original_stripped) if part
|
||||
).lower()
|
||||
for phrase in _QUALITY_BLOCKLIST:
|
||||
if phrase.lower() in haystack:
|
||||
return (
|
||||
f"原始请求、标题或描述命中明显占位/测试关键词「{phrase}」,"
|
||||
"已拒绝提交。如果是真实问题,请用正常的中文描述具体现象。"
|
||||
)
|
||||
|
||||
match = (
|
||||
_REPEAT_GIBBERISH.search(title)
|
||||
or _REPEAT_GIBBERISH.search(description)
|
||||
or _REPEAT_GIBBERISH.search(original_stripped)
|
||||
)
|
||||
if match:
|
||||
return (
|
||||
f"标题或描述里出现疑似乱码片段「{match.group(0)[:12]}...」,"
|
||||
"请用正常文字描述问题。"
|
||||
)
|
||||
|
||||
log_text = (logs or "").strip()
|
||||
if log_text:
|
||||
lowered_logs = log_text.lower()
|
||||
for phrase in _FABRICATED_LOG_PHRASES:
|
||||
if phrase.lower() in lowered_logs and len(log_text) < 200:
|
||||
return (
|
||||
f"日志字段疑似填入了叙述性占位内容「{phrase}」,"
|
||||
"请只提交真实日志;没有日志时留空。"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_username(username: Optional[str]) -> str:
|
||||
"""归一化用户名,作为脚本级提交频率限制的桶 key。"""
|
||||
return (username or "").strip().lower()
|
||||
|
||||
|
||||
def load_submission_state() -> dict[str, Any]:
|
||||
"""读取脚本持久化的短期提交状态。"""
|
||||
state_file = feedback_runtime_dir() / "submission-state.json"
|
||||
if not state_file.exists():
|
||||
return {"recent_submissions": {}, "user_submissions": {}}
|
||||
try:
|
||||
state = read_json_file(state_file)
|
||||
except Exception:
|
||||
return {"recent_submissions": {}, "user_submissions": {}}
|
||||
state.setdefault("recent_submissions", {})
|
||||
state.setdefault("user_submissions", {})
|
||||
return state
|
||||
|
||||
|
||||
def save_submission_state(state: dict[str, Any]) -> None:
|
||||
"""写回脚本持久化的短期提交状态。"""
|
||||
write_json_file(feedback_runtime_dir() / "submission-state.json", state)
|
||||
|
||||
|
||||
def check_recent_duplicate(title: str, body: str, state: dict[str, Any]) -> Optional[str]:
|
||||
"""检查 60 秒内是否提交过同 title + body 的内容。"""
|
||||
now = time.time()
|
||||
recent = state.setdefault("recent_submissions", {})
|
||||
for key, ts in list(recent.items()):
|
||||
if now - float(ts or 0) > DEDUP_TTL_SECONDS:
|
||||
recent.pop(key, None)
|
||||
key = hashlib.sha256(f"{title}\x00{body}".encode("utf-8", errors="replace")).hexdigest()
|
||||
if key in recent:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def record_submission(title: str, body: str, state: dict[str, Any]) -> None:
|
||||
"""记录一次提交内容摘要,供短时间去重使用。"""
|
||||
key = hashlib.sha256(f"{title}\x00{body}".encode("utf-8", errors="replace")).hexdigest()
|
||||
state.setdefault("recent_submissions", {})[key] = time.time()
|
||||
|
||||
|
||||
def evict_user_submissions_if_needed(state: dict[str, Any]) -> None:
|
||||
"""限制用户提交状态桶数量,避免运行时文件无限增长。"""
|
||||
buckets = state.setdefault("user_submissions", {})
|
||||
if len(buckets) <= MAX_USER_SUBMISSIONS_BUCKETS:
|
||||
return
|
||||
excess = len(buckets) - MAX_USER_SUBMISSIONS_BUCKETS
|
||||
oldest_keys = sorted(
|
||||
buckets.items(),
|
||||
key=lambda kv: kv[1][-1] if kv[1] else 0,
|
||||
)[:excess]
|
||||
for key, _ in oldest_keys:
|
||||
buckets.pop(key, None)
|
||||
|
||||
|
||||
def check_user_rate_limit(username: str, state: dict[str, Any]) -> Optional[str]:
|
||||
"""检查单用户 30 分钟冷却和 24 小时提交配额。"""
|
||||
key = normalize_username(username)
|
||||
if not key:
|
||||
return "无法识别调用用户身份,rate limit 拒绝以防误用。"
|
||||
now = time.time()
|
||||
buckets = state.setdefault("user_submissions", {})
|
||||
timestamps = buckets.get(key, [])
|
||||
active = [float(ts) for ts in timestamps if now - float(ts or 0) < USER_DAILY_WINDOW_SECONDS]
|
||||
if active:
|
||||
buckets[key] = active
|
||||
else:
|
||||
buckets.pop(key, None)
|
||||
if active:
|
||||
since_last = now - active[-1]
|
||||
if since_last < USER_COOLDOWN_SECONDS:
|
||||
remaining = int(USER_COOLDOWN_SECONDS - since_last)
|
||||
minutes, seconds = divmod(remaining, 60)
|
||||
return (
|
||||
f"为避免给上游刷屏,同一管理员两次提交之间至少间隔 "
|
||||
f"{USER_COOLDOWN_SECONDS // 60} 分钟。请等 {minutes} 分 {seconds} 秒后再试。"
|
||||
)
|
||||
if len(active) >= USER_DAILY_QUOTA:
|
||||
recover_in = int(USER_DAILY_WINDOW_SECONDS - (now - active[0]))
|
||||
hours, remainder = divmod(recover_in, 3600)
|
||||
minutes = remainder // 60
|
||||
return (
|
||||
f"你今日已提交 {USER_DAILY_QUOTA} 个 Issue,已达 24 小时配额上限。"
|
||||
f"最早一条将在 {hours} 小时 {minutes} 分钟后过期,请到时再提。"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def record_user_submission(username: str, state: dict[str, Any]) -> None:
|
||||
"""记录一次用户级提交时间戳,供冷却和配额检查使用。"""
|
||||
key = normalize_username(username)
|
||||
if not key:
|
||||
return
|
||||
state.setdefault("user_submissions", {}).setdefault(key, []).append(time.time())
|
||||
evict_user_submissions_if_needed(state)
|
||||
|
||||
|
||||
def load_diagnostics_logs(diagnostics_file: str | Path) -> tuple[str, dict[str, Any]]:
|
||||
"""读取诊断文件中的日志正文并返回日志与诊断元数据。"""
|
||||
path = ensure_runtime_file(diagnostics_file)
|
||||
data = read_json_file(path)
|
||||
logs = sanitize_logs(str(data.get("logs") or ""), MAX_LOGS_CHARS)
|
||||
return logs, data
|
||||
|
||||
|
||||
def result_payload(**fields: Any) -> str:
|
||||
"""把脚本结果格式化为 Agent 容易解析的 JSON 字符串。"""
|
||||
return json.dumps(fields, ensure_ascii=False, indent=2)
|
||||
159
skills/feedback-issue/scripts/prepare_feedback_issue.py
Normal file
159
skills/feedback-issue/scripts/prepare_feedback_issue.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""校验并生成 feedback-issue 提交前的预览与 payload 文件。"""
|
||||
|
||||
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,
|
||||
MAX_PREVIEW_LOGS_CHARS,
|
||||
MAX_TITLE_CHARS,
|
||||
build_issue_body,
|
||||
check_content_quality,
|
||||
load_diagnostics_logs,
|
||||
read_json_file,
|
||||
result_payload,
|
||||
runtime_file,
|
||||
sanitize_logs,
|
||||
truncate,
|
||||
validate_enum,
|
||||
write_json_file,
|
||||
)
|
||||
|
||||
|
||||
REQUIRED_DRAFT_FIELDS = (
|
||||
"title",
|
||||
"version",
|
||||
"environment",
|
||||
"issue_type",
|
||||
"description",
|
||||
"original_user_request",
|
||||
"diagnostics_file",
|
||||
)
|
||||
|
||||
|
||||
def normalize_draft(raw: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""规范化草稿字段并返回缺失字段列表。"""
|
||||
draft = {key: str(raw.get(key) or "").strip() for key in REQUIRED_DRAFT_FIELDS}
|
||||
missing = [key for key, value in draft.items() if not value]
|
||||
draft["title"] = truncate(draft["title"], MAX_TITLE_CHARS, marker="...")
|
||||
return draft, missing
|
||||
|
||||
|
||||
def validate_draft(draft: dict[str, Any], logs: str) -> Optional[str]:
|
||||
"""校验草稿枚举和内容质量,返回错误信息或 None。"""
|
||||
for value, allowed, field_name in (
|
||||
(draft["environment"], ALLOWED_ENVIRONMENTS, "environment"),
|
||||
(draft["issue_type"], ALLOWED_ISSUE_TYPES, "issue_type"),
|
||||
):
|
||||
error = validate_enum(value, allowed, field_name)
|
||||
if error:
|
||||
return error
|
||||
return check_content_quality(
|
||||
title=draft["title"],
|
||||
description=draft["description"],
|
||||
original_user_request=draft["original_user_request"],
|
||||
logs=logs,
|
||||
)
|
||||
|
||||
|
||||
def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str, Any]) -> str:
|
||||
"""构造给用户确认的 Markdown 预览文本。"""
|
||||
preview_logs = sanitize_logs(logs, MAX_PREVIEW_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
|
||||
source_files = diagnostics.get("source_files") or []
|
||||
sources = "\n".join(f"- {item}" for item in source_files) or "- 未命中具体日志文件"
|
||||
return (
|
||||
"请确认是否提交以下问题反馈:\n\n"
|
||||
f"标题:{draft['title']}\n"
|
||||
f"版本:{draft['version']}\n"
|
||||
f"环境:{draft['environment']}\n"
|
||||
f"类型:{draft['issue_type']}\n\n"
|
||||
"诊断来源:\n"
|
||||
f"{sources}\n\n"
|
||||
"问题描述:\n"
|
||||
f"{draft['description'].strip()}\n\n"
|
||||
"日志预览(已脱敏):\n"
|
||||
f"```bash\n{preview_logs}\n```\n\n"
|
||||
"如内容无误,请回复「确认」;如需调整,请回复「修改:...」。"
|
||||
)
|
||||
|
||||
|
||||
def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
|
||||
"""读取草稿 JSON,校验后写出 payload 与 preview 文件。"""
|
||||
raw = read_json_file(draft_file)
|
||||
draft, missing = normalize_draft(raw)
|
||||
if missing:
|
||||
return {
|
||||
"success": False,
|
||||
"reason": "missing_fields",
|
||||
"message": f"草稿缺少必填字段:{', '.join(missing)}",
|
||||
}
|
||||
|
||||
try:
|
||||
logs, diagnostics = load_diagnostics_logs(draft["diagnostics_file"])
|
||||
except Exception as err:
|
||||
return {
|
||||
"success": False,
|
||||
"reason": "diagnostics_missing",
|
||||
"message": f"无法读取诊断日志文件:{err}",
|
||||
}
|
||||
|
||||
error = validate_draft(draft, logs)
|
||||
if error:
|
||||
return {
|
||||
"success": False,
|
||||
"reason": "invalid_draft",
|
||||
"message": error,
|
||||
}
|
||||
|
||||
payload = {
|
||||
**draft,
|
||||
"diagnostics_file": str(draft["diagnostics_file"]),
|
||||
}
|
||||
payload_file = runtime_file("payload", ".json")
|
||||
preview_file = runtime_file("preview", ".md")
|
||||
write_json_file(payload_file, payload)
|
||||
preview_text = build_preview_text(draft, logs, diagnostics)
|
||||
preview_file.write_text(preview_text, encoding="utf-8")
|
||||
|
||||
body_preview = build_issue_body(
|
||||
version=draft["version"],
|
||||
environment=draft["environment"],
|
||||
issue_type=draft["issue_type"],
|
||||
description=draft["description"],
|
||||
logs=logs,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"payload_file": str(payload_file),
|
||||
"preview_file": str(preview_file),
|
||||
"body_chars": len(body_preview),
|
||||
"log_bytes": len(logs.encode("utf-8", errors="replace")),
|
||||
"log_lines": len(logs.splitlines()) if logs else 0,
|
||||
"message": (
|
||||
"已生成 Issue 预览和提交 payload。请把 preview_file 内容完整展示给用户,"
|
||||
"等待明确「确认」后再调用 submit_feedback_issue.py。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""解析命令行参数。"""
|
||||
parser = argparse.ArgumentParser(description="生成 MoviePilot 反馈 Issue 预览")
|
||||
parser.add_argument("--draft-file", required=True, help="包含 Issue 草稿字段的 JSON 文件")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""脚本入口:校验草稿并输出 JSON 结果。"""
|
||||
args = parse_args()
|
||||
result = prepare_issue(args.draft_file)
|
||||
print(result_payload(**result))
|
||||
return 0 if result.get("success") else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
263
skills/feedback-issue/scripts/submit_feedback_issue.py
Normal file
263
skills/feedback-issue/scripts/submit_feedback_issue.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""提交 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())
|
||||
Reference in New Issue
Block a user