diff --git a/src/cmdforge/gui/dialogs/issue_dialog.py b/src/cmdforge/gui/dialogs/issue_dialog.py new file mode 100644 index 0000000..588485a --- /dev/null +++ b/src/cmdforge/gui/dialogs/issue_dialog.py @@ -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], + } diff --git a/src/cmdforge/gui/pages/tools_page.py b/src/cmdforge/gui/pages/tools_page.py index dc75206..d7e2242 100644 --- a/src/cmdforge/gui/pages/tools_page.py +++ b/src/cmdforge/gui/pages/tools_page.py @@ -316,6 +316,38 @@ class DeleteReviewWorker(QThread): 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): """Main tools management page.""" @@ -330,6 +362,7 @@ class ToolsPage(QWidget): self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review} self._rating_worker = None self._review_worker = None + self._issue_worker = None self._my_slug: Optional[str] = None # Cached current user slug self._my_slug_fetched = False self._setup_ui() @@ -413,6 +446,11 @@ class ToolsPage(QWidget): self.btn_rate.setToolTip("Local tools can't be rated") 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) info_layout.addWidget(self.rating_bar) @@ -1130,6 +1168,37 @@ class ToolsPage(QWidget): """Handle review action 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): """Sync the moderation status of the selected tool from the registry.""" if not self._current_tool: diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index 365cc36..87e183d 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -953,6 +953,46 @@ class RegistryClient: 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]: """ Get most popular tools.