Add Report Issue button to tool details panel
- Client: add submit_issue() method for POST /tools/{owner}/{name}/issues
- New IssueDialog with type (bug/compatibility/security), severity, title,
and description fields with character count and validation
- Report Issue button in rating bar beside Rate Tool, visible for registry
tools only. Submits via background SubmitIssueWorker thread.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a486bb6d45
commit
23631ff4e7
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Issue report dialog for registry tools."""
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QLineEdit, QPlainTextEdit, QComboBox,
|
||||||
|
QFormLayout, QMessageBox
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
# Maps display labels to API values
|
||||||
|
ISSUE_TYPES = [
|
||||||
|
("Bug", "bug"),
|
||||||
|
("Compatibility Issue", "compatibility"),
|
||||||
|
("Security Issue", "security"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SEVERITY_LEVELS = [
|
||||||
|
("Low", "low"),
|
||||||
|
("Medium", "medium"),
|
||||||
|
("High", "high"),
|
||||||
|
("Critical", "critical"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueDialog(QDialog):
|
||||||
|
"""Dialog for reporting an issue on a registry tool."""
|
||||||
|
|
||||||
|
def __init__(self, parent, owner: str, name: str):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._owner = owner
|
||||||
|
self._name = name
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
self.setWindowTitle(f"Report Issue - {self._owner}/{self._name}")
|
||||||
|
self.setMinimumSize(450, 400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(14)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title = QLabel(f"Report an issue with {self._owner}/{self._name}")
|
||||||
|
title.setStyleSheet("font-size: 15px; font-weight: 600;")
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
# Form
|
||||||
|
form = QFormLayout()
|
||||||
|
form.setSpacing(10)
|
||||||
|
|
||||||
|
self.type_combo = QComboBox()
|
||||||
|
self.type_combo.setMinimumHeight(28)
|
||||||
|
for label, _ in ISSUE_TYPES:
|
||||||
|
self.type_combo.addItem(label)
|
||||||
|
form.addRow("Issue type:", self.type_combo)
|
||||||
|
|
||||||
|
self.severity_combo = QComboBox()
|
||||||
|
self.severity_combo.setMinimumHeight(28)
|
||||||
|
for label, _ in SEVERITY_LEVELS:
|
||||||
|
self.severity_combo.addItem(label)
|
||||||
|
self.severity_combo.setCurrentIndex(1) # Default: Medium
|
||||||
|
form.addRow("Severity:", self.severity_combo)
|
||||||
|
|
||||||
|
self.title_input = QLineEdit()
|
||||||
|
self.title_input.setMaxLength(200)
|
||||||
|
self.title_input.setMinimumHeight(28)
|
||||||
|
self.title_input.setPlaceholderText("Brief description of the issue")
|
||||||
|
form.addRow("Title:", self.title_input)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
# Description
|
||||||
|
desc_label = QLabel("Description (optional):")
|
||||||
|
desc_label.setStyleSheet("font-weight: 500;")
|
||||||
|
layout.addWidget(desc_label)
|
||||||
|
|
||||||
|
self.desc_input = QPlainTextEdit()
|
||||||
|
self.desc_input.setPlaceholderText("Provide details about the issue, steps to reproduce, etc.")
|
||||||
|
self.desc_input.setMinimumHeight(120)
|
||||||
|
self.desc_input.setMaximumHeight(200)
|
||||||
|
layout.addWidget(self.desc_input)
|
||||||
|
|
||||||
|
# Character count
|
||||||
|
self._char_count = QLabel("0 / 5000")
|
||||||
|
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||||
|
self._char_count.setAlignment(Qt.AlignRight)
|
||||||
|
self.desc_input.textChanged.connect(self._update_char_count)
|
||||||
|
layout.addWidget(self._char_count)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
btn_layout.addStretch()
|
||||||
|
|
||||||
|
btn_cancel = QPushButton("Cancel")
|
||||||
|
btn_cancel.setObjectName("secondary")
|
||||||
|
btn_cancel.clicked.connect(self.reject)
|
||||||
|
btn_layout.addWidget(btn_cancel)
|
||||||
|
|
||||||
|
self.btn_submit = QPushButton("Submit")
|
||||||
|
self.btn_submit.clicked.connect(self._on_submit)
|
||||||
|
btn_layout.addWidget(self.btn_submit)
|
||||||
|
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
def _update_char_count(self):
|
||||||
|
count = len(self.desc_input.toPlainText())
|
||||||
|
self._char_count.setText(f"{count} / 5000")
|
||||||
|
if count > 5000:
|
||||||
|
self._char_count.setStyleSheet("color: #e53e3e; font-size: 11px;")
|
||||||
|
else:
|
||||||
|
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||||
|
|
||||||
|
def _on_submit(self):
|
||||||
|
title = self.title_input.text().strip()
|
||||||
|
if not title:
|
||||||
|
QMessageBox.warning(self, "Validation", "Title is required.")
|
||||||
|
return
|
||||||
|
|
||||||
|
desc = self.desc_input.toPlainText().strip()
|
||||||
|
if len(desc) > 5000:
|
||||||
|
QMessageBox.warning(self, "Validation", "Description must be 5000 characters or less.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def get_issue_data(self) -> Dict:
|
||||||
|
"""Get the issue data entered by the user."""
|
||||||
|
type_idx = self.type_combo.currentIndex()
|
||||||
|
severity_idx = self.severity_combo.currentIndex()
|
||||||
|
return {
|
||||||
|
"issue_type": ISSUE_TYPES[type_idx][1],
|
||||||
|
"severity": SEVERITY_LEVELS[severity_idx][1],
|
||||||
|
"title": self.title_input.text().strip(),
|
||||||
|
"description": self.desc_input.toPlainText().strip()[:5000],
|
||||||
|
}
|
||||||
|
|
@ -316,6 +316,38 @@ class DeleteReviewWorker(QThread):
|
||||||
self.error.emit(str(e))
|
self.error.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitIssueWorker(QThread):
|
||||||
|
"""Background worker to submit an issue."""
|
||||||
|
finished = Signal(str)
|
||||||
|
error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, owner: str, name: str, issue_type: str,
|
||||||
|
severity: str, title: str, description: str):
|
||||||
|
super().__init__()
|
||||||
|
self.owner = owner
|
||||||
|
self.name = name
|
||||||
|
self.issue_type = issue_type
|
||||||
|
self.severity = severity
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
from ...registry_client import RegistryClient, RegistryError
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
client = RegistryClient()
|
||||||
|
client.token = config.registry.token
|
||||||
|
client.submit_issue(
|
||||||
|
self.owner, self.name, self.issue_type,
|
||||||
|
self.severity, self.title, self.description
|
||||||
|
)
|
||||||
|
self.finished.emit("Issue reported successfully")
|
||||||
|
except RegistryError as e:
|
||||||
|
self.error.emit(e.message)
|
||||||
|
except Exception as e:
|
||||||
|
self.error.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
class ToolsPage(QWidget):
|
class ToolsPage(QWidget):
|
||||||
"""Main tools management page."""
|
"""Main tools management page."""
|
||||||
|
|
||||||
|
|
@ -330,6 +362,7 @@ class ToolsPage(QWidget):
|
||||||
self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review}
|
self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review}
|
||||||
self._rating_worker = None
|
self._rating_worker = None
|
||||||
self._review_worker = None
|
self._review_worker = None
|
||||||
|
self._issue_worker = None
|
||||||
self._my_slug: Optional[str] = None # Cached current user slug
|
self._my_slug: Optional[str] = None # Cached current user slug
|
||||||
self._my_slug_fetched = False
|
self._my_slug_fetched = False
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
@ -413,6 +446,11 @@ class ToolsPage(QWidget):
|
||||||
self.btn_rate.setToolTip("Local tools can't be rated")
|
self.btn_rate.setToolTip("Local tools can't be rated")
|
||||||
rating_bar_layout.addWidget(self.btn_rate)
|
rating_bar_layout.addWidget(self.btn_rate)
|
||||||
|
|
||||||
|
self.btn_report_issue = QPushButton("Report Issue")
|
||||||
|
self.btn_report_issue.setObjectName("secondary")
|
||||||
|
self.btn_report_issue.clicked.connect(self._report_issue)
|
||||||
|
rating_bar_layout.addWidget(self.btn_report_issue)
|
||||||
|
|
||||||
self.rating_bar.setVisible(False)
|
self.rating_bar.setVisible(False)
|
||||||
info_layout.addWidget(self.rating_bar)
|
info_layout.addWidget(self.rating_bar)
|
||||||
|
|
||||||
|
|
@ -1130,6 +1168,37 @@ class ToolsPage(QWidget):
|
||||||
"""Handle review action error."""
|
"""Handle review action error."""
|
||||||
self.main_window.show_status(f"Review error: {error}")
|
self.main_window.show_status(f"Review error: {error}")
|
||||||
|
|
||||||
|
def _report_issue(self):
|
||||||
|
"""Open the issue report dialog for the selected tool."""
|
||||||
|
if not self._current_tool:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_name = self._get_qualified_name() or self._current_tool.name
|
||||||
|
registry_info = get_tool_registry_info(tool_name, self._my_slug)
|
||||||
|
if not registry_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
owner, name = registry_info
|
||||||
|
|
||||||
|
from ..dialogs.issue_dialog import IssueDialog
|
||||||
|
dialog = IssueDialog(self, owner, name)
|
||||||
|
if not dialog.exec():
|
||||||
|
return
|
||||||
|
|
||||||
|
data = dialog.get_issue_data()
|
||||||
|
|
||||||
|
self._issue_worker = SubmitIssueWorker(
|
||||||
|
owner, name, data["issue_type"], data["severity"],
|
||||||
|
data["title"], data["description"]
|
||||||
|
)
|
||||||
|
self._issue_worker.finished.connect(
|
||||||
|
lambda msg: self.main_window.show_status(msg)
|
||||||
|
)
|
||||||
|
self._issue_worker.error.connect(
|
||||||
|
lambda err: self.main_window.show_status(f"Issue report failed: {err}")
|
||||||
|
)
|
||||||
|
self._issue_worker.start()
|
||||||
|
|
||||||
def _sync_tool_status(self):
|
def _sync_tool_status(self):
|
||||||
"""Sync the moderation status of the selected tool from the registry."""
|
"""Sync the moderation status of the selected tool from the registry."""
|
||||||
if not self._current_tool:
|
if not self._current_tool:
|
||||||
|
|
|
||||||
|
|
@ -953,6 +953,46 @@ class RegistryClient:
|
||||||
|
|
||||||
return response.json().get("data", {})
|
return response.json().get("data", {})
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Issues
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def submit_issue(
|
||||||
|
self, owner: str, name: str, issue_type: str, severity: str,
|
||||||
|
title: str, description: str = ""
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Submit an issue for a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Tool owner
|
||||||
|
name: Tool name
|
||||||
|
issue_type: One of 'bug', 'security', 'compatibility'
|
||||||
|
severity: One of 'low', 'medium', 'high', 'critical'
|
||||||
|
title: Issue title (max 200 chars)
|
||||||
|
description: Issue description (max 5000 chars)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with issue id and data
|
||||||
|
"""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"issue_type": issue_type,
|
||||||
|
"severity": severity,
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
|
if description:
|
||||||
|
payload["description"] = description
|
||||||
|
|
||||||
|
response = self._request(
|
||||||
|
"POST", f"/tools/{owner}/{name}/issues",
|
||||||
|
json_data=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
return response.json().get("data", {})
|
||||||
|
|
||||||
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
||||||
"""
|
"""
|
||||||
Get most popular tools.
|
Get most popular tools.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue