Improve feedback issue routing and labels

This commit is contained in:
jxxghp
2026-06-15 12:47:52 +08:00
parent 8dc1cf53eb
commit d2803bed1e
7 changed files with 640 additions and 66 deletions

View File

@@ -13,6 +13,7 @@ from typing import Optional
from feedback_issue_common import (
MAX_LOGS_CHARS,
format_log_selection,
feedback_runtime_dir,
result_payload,
runtime_file,
@@ -62,31 +63,39 @@ _VAGUE_KEYWORDS = frozenset({
_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",
"report upstream", "feature request", "submit a feature request",
"open a feature request",
)
_FEEDBACK_TARGET_TOKENS: tuple[str, ...] = (
"issue", "bug", "问题", "错误报告",
"上游", "mp", "moviepilot",
"上游", "mp", "moviepilot", "需求", "功能", "feature",
)
_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",
"report upstream", "feature request", "submit a feature request",
"open a feature request",
"新建 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}(需求|功能请求|feature request)", re.IGNORECASE),
re.compile(r"提交.{0,6}(需求|功能请求|feature request)", re.IGNORECASE),
re.compile(r"报.{0,6}(bug|issue|错误报告)", re.IGNORECASE),
re.compile(r"反馈.{0,8}(issue|bug|问题|上游|错误)", re.IGNORECASE),
re.compile(r"反馈.{0,8}(需求|功能请求|feature request)", re.IGNORECASE),
re.compile(r"开.{0,4}(issue|bug)", re.IGNORECASE),
re.compile(r"开.{0,8}(需求|功能请求|feature request)", re.IGNORECASE),
re.compile(r"上报.{0,6}(bug|issue|问题|错误)", re.IGNORECASE),
)
@@ -234,7 +243,7 @@ def filter_lines(
keywords: list[str],
max_lines: int,
window_start: datetime,
) -> list[str]:
) -> tuple[list[str], list[str]]:
"""按时间窗、模块噪音和关键词筛选日志行。"""
candidates: list[str] = []
last_seen_in_window: Optional[bool] = None
@@ -254,22 +263,30 @@ def filter_lines(
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:
return [], []
if not keywords:
return [], []
lowered_keywords = [item.lower() for item in keywords]
matched: list[str] = []
matched_keywords: set[str] = set()
keep_block = False
for line in candidates:
has_timestamp = parse_line_timestamp(line) is not None
if has_timestamp:
line_keywords = [
keyword for keyword, lowered in zip(keywords, lowered_keywords)
if lowered in line.lower()
]
keep_block = bool(line_keywords)
if keep_block:
matched_keywords.update(line_keywords)
matched.append(line)
if matched:
return matched[-max_lines:]
return candidates[-max_lines:]
elif keep_block:
matched.append(line)
if matched:
return matched[-max_lines:], sorted(matched_keywords)
return [], []
def collect_diagnostics(
@@ -297,12 +314,13 @@ def collect_diagnostics(
normalized_keywords = normalize_keywords(keywords)
collected: list[str] = []
source_files: list[str] = []
matched_files: list[dict] = []
for path in candidate_log_files():
text = read_tail(path)
if not text:
continue
lines = filter_lines(
lines, matched_keywords = filter_lines(
text=text,
keywords=normalized_keywords,
max_lines=normalized_max_lines,
@@ -311,15 +329,33 @@ def collect_diagnostics(
if not lines:
continue
source_files.append(str(path))
matched_files.append({
"path": str(path),
"matched_keywords": matched_keywords,
"line_count": len(lines),
})
collected.append(f"### {path.name}\n" + "\n".join(lines))
logs = sanitize_logs("\n\n".join(collected), MAX_LOGS_CHARS)
log_selection = {
"strategy": "time_window_and_keyword_block_match",
"time_window_minutes": window_minutes,
"window_start": window_start.isoformat(timespec="seconds"),
"keywords": normalized_keywords,
"max_lines_per_file": normalized_max_lines,
"matched_files": matched_files,
"warning": (
"未提供具体关键词,已跳过日志正文收集以避免误带无关日志。"
if not normalized_keywords else ""
),
}
diagnostics_file = runtime_file("diagnostics", ".json")
diagnostics = {
"original_user_request": original_user_request,
"keywords": normalized_keywords,
"found": bool(logs.strip()),
"logs": logs,
"log_selection": log_selection,
"doctor": collect_doctor_report(),
"source_files": source_files,
"created_at": datetime.now().isoformat(timespec="seconds"),
@@ -331,6 +367,7 @@ def collect_diagnostics(
"diagnostics_file": str(diagnostics_file),
"runtime_dir": str(feedback_runtime_dir()),
"source_files": source_files,
"log_selection_summary": format_log_selection(log_selection),
"log_bytes": len(logs.encode("utf-8", errors="replace")),
"log_lines": len(logs.splitlines()) if logs else 0,
"doctor_collected": bool(diagnostics["doctor"].get("success")),

View File

@@ -10,7 +10,7 @@ import time
import uuid
from pathlib import Path
from typing import Any, Optional
from urllib.parse import quote
from urllib.parse import quote, urlparse
def _find_repo_root() -> Path:
@@ -34,13 +34,14 @@ 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
_GITHUB_REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
ALLOWED_ENVIRONMENTS = ("Docker", "Windows")
ALLOWED_ISSUE_TYPES = ("主程序运行问题", "插件问题", "其他问题")
FEATURE_ISSUE_TYPE = "功能请求"
ALLOWED_ISSUE_TYPES = ("主程序运行问题", "插件问题", FEATURE_ISSUE_TYPE, "其他问题")
MAX_TITLE_CHARS = 256
MAX_BODY_CHARS = 60 * 1024
@@ -58,6 +59,7 @@ MAX_USER_SUBMISSIONS_BUCKETS = 200
MIN_TITLE_BODY_CHARS = 8
MIN_DESCRIPTION_CHARS = 50
TITLE_PREFIX = "[错误报告]:"
TITLE_PREFIXES = (TITLE_PREFIX, "[功能请求]:")
_QUALITY_BLOCKLIST = (
"测试issue", "测试 issue", "test issue",
@@ -83,6 +85,11 @@ _DESCRIPTION_REQUIRED_SIGNALS = (
("复现步骤", ("复现", "步骤", "触发", "操作", "调用", "点击")),
("期望行为", ("期望", "应该", "预期", "正常")),
)
_FEATURE_DESCRIPTION_REQUIRED_SIGNALS = (
("需求背景", ("需求背景", "背景", "痛点", "原因", "为什么", "场景")),
("使用场景", ("使用场景", "场景", "用户", "当我", "希望在", "需要在")),
("期望能力", ("期望", "希望", "支持", "能够", "可以", "新增", "功能")),
)
_REPEAT_GIBBERISH = re.compile(r"([^\s=\-_*#~`./\\+|])\1{7,}", re.UNICODE)
@@ -198,6 +205,54 @@ def validate_enum(value: str, allowed: tuple[str, ...], field_name: str) -> Opti
return None
def normalize_target_repo(target_repo: Optional[str]) -> str:
"""把目标仓库规范化为 GitHub 的 owner/repo 形式。"""
repo = (target_repo or FEEDBACK_REPO).strip()
if not repo:
return FEEDBACK_REPO
repo = repo.removesuffix(".git").strip("/")
if repo.startswith(("http://", "https://")):
parsed = urlparse(repo)
if (parsed.hostname or "").lower() not in {"github.com", "www.github.com"}:
raise ValueError(f"目标仓库只支持 GitHub 地址:{target_repo}")
parts = [part for part in parsed.path.strip("/").split("/") if part]
if len(parts) < 2:
raise ValueError(f"GitHub 仓库地址缺少 owner/repo{target_repo}")
repo = f"{parts[0]}/{parts[1].removesuffix('.git')}"
if not _GITHUB_REPO_PATTERN.fullmatch(repo):
raise ValueError(f"目标仓库必须是 owner/repo 或 GitHub 仓库 URL{target_repo}")
return repo
def issue_api_url(target_repo: Optional[str]) -> str:
"""返回指定仓库的 GitHub Issues API 地址。"""
return f"https://api.github.com/repos/{normalize_target_repo(target_repo)}/issues"
def issue_new_url(target_repo: Optional[str]) -> str:
"""返回指定仓库的新建 Issue 页面地址。"""
return f"https://github.com/{normalize_target_repo(target_repo)}/issues/new"
def validate_target_repo_for_issue(issue_type: str, target_repo: str) -> Optional[str]:
"""校验 Issue 类型与目标仓库是否匹配,避免插件问题误投主仓库。"""
if issue_type == "插件问题" and target_repo == FEEDBACK_REPO:
return (
"issue_type 为「插件问题」时必须把 target_repo 设置为插件所属 GitHub 仓库,"
f"不能提交到主仓库 {FEEDBACK_REPO}"
)
return None
def issue_labels(issue_type: str, target_repo: Optional[str]) -> list[str]:
"""返回提交 Issue 时应使用的标签列表。"""
if issue_type == FEATURE_ISSUE_TYPE:
return ["feature request"]
if normalize_target_repo(target_repo) == FEEDBACK_REPO:
return ["bug"]
return []
def redact_logs(raw: str) -> str:
"""对日志文本做统一脱敏,覆盖常见 token、Cookie、PII 和本机路径。"""
out = raw
@@ -227,14 +282,31 @@ def build_issue_body(
issue_type: str,
description: str,
logs: Optional[str],
target_repo: Optional[str] = None,
) -> str:
"""构造与上游 bug_report.yml 表单渲染接近的 Issue Markdown 正文。"""
repo = normalize_target_repo(target_repo)
log_block = sanitize_logs(logs, MAX_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
if issue_type == FEATURE_ISSUE_TYPE:
body = (
"### 需求类型\n\n"
f"{FEATURE_ISSUE_TYPE}\n\n"
f"### 当前程序版本\n\n{version}\n\n"
f"### 运行环境\n\n{environment}\n\n"
f"### 目标仓库\n\n{repo}\n\n"
f"### 需求描述\n\n{description.strip()}\n\n"
"### 补充诊断信息\n\n"
f"```text\n{log_block}\n```\n"
"\n---\n"
"_本 Issue 由 MoviePilot Agent 协助用户提交。_"
)
return truncate(body, MAX_BODY_CHARS)
body = (
"### 确认\n\n"
"- [x] 我的版本是最新版本,我的版本号与 "
"[version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。\n"
"- [x] 我已经 [issue](https://github.com/jxxghp/MoviePilot/issues) "
f"- [x] 我已经 [issue](https://github.com/{repo}/issues) "
"中搜索过,确认我的问题没有被提出过。\n"
"- [x] 我已经 [Telegram频道](https://t.me/moviepilot_channel) "
"中搜索过,确认我的问题没有被提出过。\n"
@@ -259,8 +331,31 @@ def build_prefill_url(
issue_type: str,
description: str,
logs: Optional[str],
target_repo: Optional[str] = None,
) -> str:
"""生成 GitHub Issue Forms 预填 URL供无 token 或 API 失败时手动提交。"""
repo = normalize_target_repo(target_repo)
labels = issue_labels(issue_type, repo)
if repo != FEEDBACK_REPO or issue_type == FEATURE_ISSUE_TYPE:
body = build_issue_body(
version=version,
environment=environment,
issue_type=issue_type,
description=description,
logs=sanitize_logs(logs, MAX_URL_LOGS_CHARS),
target_repo=repo,
)
params = {
"title": title,
"body": body,
}
if labels:
params["labels"] = ",".join(labels)
encoded = "&".join(
f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items()
)
return f"{issue_new_url(repo)}?{encoded}"
params = {
"template": FEEDBACK_ISSUE_TEMPLATE,
"title": title,
@@ -273,7 +368,7 @@ def build_prefill_url(
encoded = "&".join(
f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items()
)
return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}"
return f"{issue_new_url(repo)}?{encoded}"
def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
@@ -323,6 +418,43 @@ def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
return truncate("\n".join(lines), MAX_DOCTOR_SUMMARY_CHARS)
def format_log_selection(selection: Optional[dict[str, Any]]) -> str:
"""把日志筛选依据格式化为便于用户确认的摘要。"""
if not isinstance(selection, dict):
return "未记录日志筛选依据。"
keywords = selection.get("keywords") or []
keyword_text = "".join(str(item) for item in keywords) if keywords else "未提供具体关键词"
lines = [
f"策略:{selection.get('strategy') or '时间窗口 + 模块噪音过滤 + 关键词块匹配'}",
f"时间窗口:最近 {selection.get('time_window_minutes') or '?'} 分钟",
f"窗口起点:{selection.get('window_start') or '未知'}",
f"关键词:{keyword_text}",
f"单文件最多保留:{selection.get('max_lines_per_file') or '?'}",
]
warning = str(selection.get("warning") or "").strip()
if warning:
lines.append(f"提示:{warning}")
matched_files = selection.get("matched_files") or []
if not matched_files:
lines.append("命中文件:无")
return truncate("\n".join(lines), MAX_DOCTOR_SUMMARY_CHARS)
lines.append("命中文件:")
for item in matched_files[:8]:
if not isinstance(item, dict):
continue
matched_keywords = item.get("matched_keywords") or []
matched_text = "".join(str(keyword) for keyword in matched_keywords) or "仅按时间窗口"
lines.append(
f"- {item.get('path') or '未知文件'}"
f"命中关键词:{matched_text}"
f"行数:{item.get('line_count') or 0}"
)
return truncate("\n".join(lines), MAX_DOCTOR_SUMMARY_CHARS)
def classify_failure(status_code: Optional[int], headers: Optional[dict] = None) -> str:
"""把 GitHub API HTTP 状态码映射成脚本输出的稳定失败原因。"""
headers = headers or {}
@@ -361,6 +493,7 @@ def check_content_quality(
description: str,
original_user_request: str,
logs: Optional[str] = None,
issue_type: str = "主程序运行问题",
) -> Optional[str]:
"""检查 Issue 内容质量,拦截测试、占位、乱码和结构缺失的提交。"""
original_stripped = (original_user_request or "").strip()
@@ -371,11 +504,13 @@ def check_content_quality(
)
title_body = title.strip()
if title_body.startswith(TITLE_PREFIX):
title_body = title_body[len(TITLE_PREFIX):].strip()
for prefix in TITLE_PREFIXES:
if title_body.startswith(prefix):
title_body = title_body[len(prefix):].strip()
break
if len(title_body) < MIN_TITLE_BODY_CHARS:
return (
f"标题正文太短(剔除 {TITLE_PREFIX!r} 前缀后只有 {len(title_body)} 字,"
f"标题正文太短(剔除标题前缀后只有 {len(title_body)} 字,"
f"至少 {MIN_TITLE_BODY_CHARS} 字)。请用一句完整的话概括症状。"
)
@@ -386,14 +521,19 @@ def check_content_quality(
"请补充:现象 / 复现步骤 / 期望行为。"
)
required_signals = (
_FEATURE_DESCRIPTION_REQUIRED_SIGNALS
if issue_type == FEATURE_ISSUE_TYPE else _DESCRIPTION_REQUIRED_SIGNALS
)
missing_signals = [
label
for label, choices in _DESCRIPTION_REQUIRED_SIGNALS
for label, choices in required_signals
if not any(choice in desc_stripped for choice in choices)
]
if missing_signals:
content_name = "功能请求" if issue_type == FEATURE_ISSUE_TYPE else "可复现 bug"
return (
"问题描述缺少可复现 bug 所需的结构信息:"
f"问题描述缺少{content_name}所需的结构信息:"
f"{' / '.join(missing_signals)}。请补充真实现象、触发步骤和期望行为。"
)
@@ -454,22 +594,34 @@ 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]:
def check_recent_duplicate(
title: str,
body: str,
state: dict[str, Any],
target_repo: Optional[str] = None,
) -> 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()
repo = normalize_target_repo(target_repo)
key = hashlib.sha256(f"{repo}\x00{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:
def record_submission(
title: str,
body: str,
state: dict[str, Any],
target_repo: Optional[str] = None,
) -> None:
"""记录一次提交内容摘要,供短时间去重使用。"""
key = hashlib.sha256(f"{title}\x00{body}".encode("utf-8", errors="replace")).hexdigest()
repo = normalize_target_repo(target_repo)
key = hashlib.sha256(f"{repo}\x00{title}\x00{body}".encode("utf-8", errors="replace")).hexdigest()
state.setdefault("recent_submissions", {})[key] = time.time()

View File

@@ -9,18 +9,22 @@ from typing import Any, Optional
from feedback_issue_common import (
ALLOWED_ENVIRONMENTS,
ALLOWED_ISSUE_TYPES,
FEEDBACK_REPO,
MAX_PREVIEW_LOGS_CHARS,
MAX_TITLE_CHARS,
build_issue_body,
check_content_quality,
format_doctor_summary,
format_log_selection,
load_diagnostics_logs,
normalize_target_repo,
read_json_file,
result_payload,
runtime_file,
sanitize_logs,
truncate,
validate_enum,
validate_target_repo_for_issue,
write_json_file,
)
@@ -41,6 +45,7 @@ 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="...")
draft["target_repo"] = str(raw.get("target_repo") or FEEDBACK_REPO).strip()
return draft, missing
@@ -53,11 +58,15 @@ def validate_draft(draft: dict[str, Any], logs: str) -> Optional[str]:
error = validate_enum(value, allowed, field_name)
if error:
return error
repo_error = validate_target_repo_for_issue(draft["issue_type"], draft["target_repo"])
if repo_error:
return repo_error
return check_content_quality(
title=draft["title"],
description=draft["description"],
original_user_request=draft["original_user_request"],
logs=logs,
issue_type=draft["issue_type"],
)
@@ -65,11 +74,13 @@ def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str,
"""构造给用户确认的 Markdown 预览文本。"""
preview_logs = sanitize_logs(logs, MAX_PREVIEW_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
doctor_summary = format_doctor_summary(diagnostics.get("doctor"))
log_selection_summary = format_log_selection(diagnostics.get("log_selection"))
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['target_repo']}\n"
f"版本:{draft['version']}\n"
f"环境:{draft['environment']}\n"
f"类型:{draft['issue_type']}\n\n"
@@ -77,6 +88,8 @@ def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str,
f"{sources}\n\n"
"Doctor 摘要:\n"
f"```text\n{doctor_summary}\n```\n\n"
"日志筛选依据:\n"
f"```text\n{log_selection_summary}\n```\n\n"
"问题描述:\n"
f"{draft['description'].strip()}\n\n"
"日志预览(已脱敏):\n"
@@ -95,6 +108,14 @@ def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
"reason": "missing_fields",
"message": f"草稿缺少必填字段:{', '.join(missing)}",
}
try:
draft["target_repo"] = normalize_target_repo(draft["target_repo"])
except ValueError as err:
return {
"success": False,
"reason": "invalid_target_repo",
"message": str(err),
}
try:
logs, diagnostics = load_diagnostics_logs(draft["diagnostics_file"])
@@ -126,6 +147,7 @@ def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
combined_logs = "\n\n".join(
part for part in (
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
f"### 日志筛选依据\n{format_log_selection(diagnostics.get('log_selection'))}",
logs,
) if part
)
@@ -135,9 +157,11 @@ def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
issue_type=draft["issue_type"],
description=draft["description"],
logs=combined_logs,
target_repo=draft["target_repo"],
)
return {
"success": True,
"target_repo": draft["target_repo"],
"payload_file": str(payload_file),
"preview_file": str(preview_file),
"body_chars": len(body_preview),

View File

@@ -1,4 +1,4 @@
"""提交 feedback-issue payload 到 MoviePilot 上游 GitHub 仓库。"""
"""提交 feedback-issue payload 到目标 GitHub 仓库。"""
from __future__ import annotations
@@ -9,7 +9,6 @@ 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,
@@ -20,8 +19,12 @@ from feedback_issue_common import (
check_user_rate_limit,
classify_failure,
format_doctor_summary,
format_log_selection,
issue_api_url,
issue_labels,
load_diagnostics_logs,
load_submission_state,
normalize_target_repo,
read_json_file,
record_submission,
record_user_submission,
@@ -31,6 +34,7 @@ from feedback_issue_common import (
settings,
truncate,
validate_enum,
validate_target_repo_for_issue,
)
from app.utils.http import RequestUtils
@@ -51,6 +55,7 @@ def normalize_payload(raw: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
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="...")
payload["target_repo"] = str(raw.get("target_repo") or FEEDBACK_REPO).strip()
return payload, missing
@@ -63,11 +68,15 @@ def validate_payload(payload: dict[str, Any], logs: str) -> Optional[str]:
error = validate_enum(value, allowed, field_name)
if error:
return error
repo_error = validate_target_repo_for_issue(payload["issue_type"], payload["target_repo"])
if repo_error:
return repo_error
return check_content_quality(
title=payload["title"],
description=payload["description"],
original_user_request=payload["original_user_request"],
logs=logs,
issue_type=payload["issue_type"],
)
@@ -80,11 +89,12 @@ def build_no_token_result(payload: dict[str, Any], logs: str) -> dict[str, Any]:
issue_type=payload["issue_type"],
description=payload["description"],
logs=logs,
target_repo=payload["target_repo"],
)
return {
"success": False,
"reason": "no_token",
"repo": FEEDBACK_REPO,
"repo": payload["target_repo"],
"prefill_url": prefill_url,
"message": (
"MoviePilot 未配置可写入的 GitHub Token无法自动提交 Issue。"
@@ -104,13 +114,15 @@ def post_github_issue(payload: dict[str, Any], body: str) -> Any:
request_payload = {
"title": payload["title"],
"body": body,
"labels": ["bug"],
}
labels = issue_labels(payload["issue_type"], payload["target_repo"])
if labels:
request_payload["labels"] = labels
return RequestUtils(
proxies=settings.PROXY,
headers=request_headers,
timeout=FEEDBACK_REQUEST_TIMEOUT,
).post(FEEDBACK_ISSUE_API, json=request_payload)
).post(issue_api_url(payload["target_repo"]), json=request_payload)
def build_api_failure_result(
@@ -128,11 +140,12 @@ def build_api_failure_result(
issue_type=payload["issue_type"],
description=payload["description"],
logs=logs,
target_repo=payload["target_repo"],
)
return {
"success": False,
"reason": reason,
"repo": FEEDBACK_REPO,
"repo": payload["target_repo"],
"prefill_url": prefill_url,
"github_message": github_message,
"message": "GitHub API 未能自动创建 Issue请把 prefill_url 原样发给用户手动提交。",
@@ -149,6 +162,14 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
"reason": "missing_fields",
"message": f"payload 缺少必填字段:{', '.join(missing)}",
}
try:
payload["target_repo"] = normalize_target_repo(payload["target_repo"])
except ValueError as err:
return {
"success": False,
"reason": "invalid_target_repo",
"message": str(err),
}
try:
logs, diagnostics = load_diagnostics_logs(payload["diagnostics_file"])
@@ -170,6 +191,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
combined_logs = "\n\n".join(
part for part in (
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
f"### 日志筛选依据\n{format_log_selection(diagnostics.get('log_selection'))}",
logs,
) if part
)
@@ -179,9 +201,10 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
issue_type=payload["issue_type"],
description=payload["description"],
logs=combined_logs,
target_repo=payload["target_repo"],
)
state = load_submission_state()
if check_recent_duplicate(payload["title"], body, state):
if check_recent_duplicate(payload["title"], body, state, payload["target_repo"]):
return {
"success": False,
"reason": "duplicate",
@@ -204,7 +227,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
save_submission_state(state)
return build_no_token_result(payload, combined_logs)
record_submission(payload["title"], body, state)
record_submission(payload["title"], body, state, payload["target_repo"])
save_submission_state(state)
try:
response = post_github_issue(payload, body)
@@ -227,10 +250,10 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
data = safe_response_dict(response)
return {
"success": True,
"repo": FEEDBACK_REPO,
"repo": payload["target_repo"],
"issue_number": data.get("number"),
"issue_url": data.get("html_url"),
"message": "Issue 已成功提交到 MoviePilot 上游仓库。",
"message": f"Issue 已成功提交到 {payload['target_repo']} 仓库。",
}
reason = classify_failure(response.status_code, headers=dict(response.headers or {}))