From 16de3e371cf13db8f5223b8f83f6934e2be78b41 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 16 Jan 2026 22:45:42 -0400 Subject: [PATCH] Add version display and bump buttons to publish dialog - Show local and registry version in publish dialog - Add +Patch/+Minor/+Major bump buttons - Auto-fetch registry version on dialog open - Suggest next version based on latest registry version - Add fork detection and forked_from metadata support - Update registry schema for forked_from/forked_version fields - Display fork indicator when publishing forked tools Co-Authored-By: Claude Opus 4.5 --- src/cmdforge/gui/dialogs/publish_dialog.py | 253 ++++++++++++++++++++- src/cmdforge/registry/app.py | 13 +- src/cmdforge/registry/db.py | 4 + 3 files changed, 263 insertions(+), 7 deletions(-) diff --git a/src/cmdforge/gui/dialogs/publish_dialog.py b/src/cmdforge/gui/dialogs/publish_dialog.py index 0e0a250..e48069e 100644 --- a/src/cmdforge/gui/dialogs/publish_dialog.py +++ b/src/cmdforge/gui/dialogs/publish_dialog.py @@ -1,9 +1,10 @@ """Publish tool dialog.""" +from typing import Optional, List from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QLineEdit, QTextEdit, QFormLayout, - QProgressBar, QMessageBox, QComboBox + QProgressBar, QMessageBox, QComboBox, QFrame, QWidget ) from PySide6.QtCore import QThread, Signal @@ -12,6 +13,72 @@ from ...registry_client import RegistryClient, RegistryError from ...config import load_config +def parse_version(version: str) -> tuple: + """Parse version string into tuple of ints.""" + try: + parts = version.split(".") + return tuple(int(p) for p in parts[:3]) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def bump_version(version: str, bump_type: str) -> str: + """Bump version by type (patch, minor, major).""" + major, minor, patch = parse_version(version) + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + else: # patch + return f"{major}.{minor}.{patch + 1}" + + +class VersionFetchWorker(QThread): + """Background worker to fetch registry version info.""" + finished = Signal(dict) # Dict with 'my_version' and 'original_versions' + error = Signal(str) + + def __init__(self, name: str, original_owner: Optional[str] = None): + super().__init__() + self.name = name + self.original_owner = original_owner + + def run(self): + try: + config = load_config() + client = RegistryClient() + client.token = config.registry.token + + result = { + "my_version": None, + "my_status": None, + "original_versions": [], + "original_owner": self.original_owner, + } + + # Get my published version of this tool + if config.registry.token: + try: + status = client.get_my_tool_status(self.name) + result["my_version"] = status.get("version") + result["my_status"] = status.get("status") + except RegistryError: + pass # Tool not published by me yet + + # If this was forked from another owner, get their versions too + if self.original_owner: + try: + result["original_versions"] = client.get_tool_versions( + self.original_owner, self.name + ) + except RegistryError: + pass + + self.finished.emit(result) + except Exception as e: + self.error.emit(str(e)) + + class PublishWorker(QThread): """Background worker for publishing.""" success = Signal(dict) @@ -40,10 +107,114 @@ class PublishDialog(QDialog): def __init__(self, parent, tool: Tool): super().__init__(parent) self.setWindowTitle("Publish Tool") - self.setMinimumSize(500, 400) + self.setMinimumSize(500, 450) self._tool = tool self._worker = None + self._version_worker = None + self._my_registry_version: Optional[str] = None + self._my_registry_status: Optional[str] = None + self._original_versions: List[str] = [] + self._local_version = self._get_local_version() + self._original_owner = self._get_original_owner() self._setup_ui() + self._fetch_registry_versions() + + def _get_local_version(self) -> str: + """Get the version from local tool config.""" + if hasattr(self._tool, 'path') and self._tool.path: + try: + import yaml + config = yaml.safe_load(self._tool.path.read_text()) + return config.get("version", "1.0.0") + except Exception: + pass + return "1.0.0" + + def _get_original_owner(self) -> Optional[str]: + """Get the original owner if this tool was installed from registry.""" + if hasattr(self._tool, 'path') and self._tool.path: + try: + import yaml + config = yaml.safe_load(self._tool.path.read_text()) + # Check if it has forked_from or was installed from registry + if config.get("forked_from"): + return config["forked_from"].split("/")[0] + if config.get("installed_from"): + return config["installed_from"].split("/")[0] + except Exception: + pass + return None + + def _fetch_registry_versions(self): + """Fetch versions from registry in background.""" + self._version_worker = VersionFetchWorker(self._tool.name, self._original_owner) + self._version_worker.finished.connect(self._on_versions_fetched) + self._version_worker.error.connect(self._on_versions_error) + self._version_worker.start() + + def _on_versions_fetched(self, result: dict): + """Handle registry versions response.""" + self._my_registry_version = result.get("my_version") + self._my_registry_status = result.get("my_status") + self._original_versions = result.get("original_versions", []) + self._update_version_info() + + def _on_versions_error(self, error: str): + """Handle version fetch error (non-critical).""" + self._my_registry_version = None + self._original_versions = [] + self._update_version_info() + + def _update_version_info(self): + """Update version info display.""" + if self._my_registry_version: + # Already published by me - show my latest version + self.registry_version_label.setText( + f"Registry: v{self._my_registry_version} ({self._my_registry_status or 'unknown'})" + ) + if self._my_registry_status == "approved": + self.registry_version_label.setStyleSheet("color: #38a169;") # Green + elif self._my_registry_status == "pending": + self.registry_version_label.setStyleSheet("color: #d69e2e;") # Yellow + else: + self.registry_version_label.setStyleSheet("color: #4299e1;") # Blue + # Suggest next version + suggested = bump_version(self._my_registry_version, "patch") + self.version_input.setText(suggested) + self._show_bump_buttons(True) + elif self._original_versions: + # This is a fork - show original tool info + latest_original = self._original_versions[0] + self.registry_version_label.setText( + f"Fork of {self._original_owner}/{self._tool.name} v{latest_original}" + ) + self.registry_version_label.setStyleSheet("color: #805ad5;") # Purple for fork + self.version_input.setText("1.0.0") # Start fresh for forks + self._show_bump_buttons(False) + else: + self.registry_version_label.setText("Registry: not published") + self.registry_version_label.setStyleSheet("color: #718096;") + self.version_input.setText(self._local_version) + self._show_bump_buttons(False) + + def _show_bump_buttons(self, show: bool): + """Show or hide version bump buttons.""" + self.bump_widget.setVisible(show) + + def _bump_patch(self): + """Bump patch version.""" + if self._my_registry_version: + self.version_input.setText(bump_version(self._my_registry_version, "patch")) + + def _bump_minor(self): + """Bump minor version.""" + if self._my_registry_version: + self.version_input.setText(bump_version(self._my_registry_version, "minor")) + + def _bump_major(self): + """Bump major version.""" + if self._my_registry_version: + self.version_input.setText(bump_version(self._my_registry_version, "major")) def _setup_ui(self): """Set up the UI.""" @@ -55,15 +226,76 @@ class PublishDialog(QDialog): title.setStyleSheet("font-size: 16px; font-weight: 600;") layout.addWidget(title) + # Version info section + version_frame = QFrame() + version_frame.setStyleSheet(""" + QFrame { + background-color: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px; + } + """) + version_info_layout = QHBoxLayout(version_frame) + version_info_layout.setContentsMargins(12, 8, 12, 8) + + self.local_version_label = QLabel(f"Local: v{self._local_version}") + self.local_version_label.setStyleSheet("color: #2d3748; font-weight: 500;") + version_info_layout.addWidget(self.local_version_label) + + version_info_layout.addSpacing(20) + + self.registry_version_label = QLabel("Registry: checking...") + self.registry_version_label.setStyleSheet("color: #718096;") + version_info_layout.addWidget(self.registry_version_label) + + version_info_layout.addStretch() + layout.addWidget(version_frame) + # Form form = QFormLayout() form.setSpacing(12) - # Version + # Version input with bump buttons + version_row = QHBoxLayout() self.version_input = QLineEdit() - self.version_input.setText("1.0.0") + self.version_input.setText(self._local_version) self.version_input.setPlaceholderText("1.0.0") - form.addRow("Version:", self.version_input) + self.version_input.setMaximumWidth(100) + version_row.addWidget(self.version_input) + + # Bump buttons (hidden until we know registry version) + self.bump_widget = QWidget() + bump_layout = QHBoxLayout(self.bump_widget) + bump_layout.setContentsMargins(8, 0, 0, 0) + bump_layout.setSpacing(4) + + btn_patch = QPushButton("+Patch") + btn_patch.setObjectName("secondary") + btn_patch.setFixedWidth(60) + btn_patch.setToolTip("Bump patch version (bug fixes)") + btn_patch.clicked.connect(self._bump_patch) + bump_layout.addWidget(btn_patch) + + btn_minor = QPushButton("+Minor") + btn_minor.setObjectName("secondary") + btn_minor.setFixedWidth(60) + btn_minor.setToolTip("Bump minor version (new features)") + btn_minor.clicked.connect(self._bump_minor) + bump_layout.addWidget(btn_minor) + + btn_major = QPushButton("+Major") + btn_major.setObjectName("secondary") + btn_major.setFixedWidth(60) + btn_major.setToolTip("Bump major version (breaking changes)") + btn_major.clicked.connect(self._bump_major) + bump_layout.addWidget(btn_major) + + self.bump_widget.setVisible(False) + version_row.addWidget(self.bump_widget) + + version_row.addStretch() + form.addRow("Version:", version_row) # Category self.category_combo = QComboBox() @@ -154,6 +386,17 @@ class PublishDialog(QDialog): if tags: tool_config["tags"] = tags + # Add fork metadata if this tool originated from another owner + if self._original_owner and self._original_versions: + tool_config["forked_from"] = f"{self._original_owner}/{self._tool.name}" + # Record which version we forked from (use local version or latest original) + forked_version = self._local_version + if forked_version in self._original_versions: + tool_config["forked_version"] = forked_version + else: + # Use the latest original version as reference + tool_config["forked_version"] = self._original_versions[0] + # Convert to YAML string config_yaml = yaml.dump(tool_config, default_flow_style=False, sort_keys=False) diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 04971a8..a22bbea 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -1777,6 +1777,10 @@ def create_app() -> Flask: # Create a minimal source_json for consistency source_json = json.dumps({"type": "imported", "original_tool": source, "url": source_url}) + # Extract fork metadata + forked_from = (data.get("forked_from") or "").strip() or None + forked_version = (data.get("forked_version") or "").strip() or None + if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN: return error_response("VALIDATION_ERROR", "Invalid tool name") if not version or Semver.parse(version) is None: @@ -1918,8 +1922,9 @@ def create_app() -> Flask: owner, name, version, description, category, tags, config_yaml, readme, publisher_id, deprecated, deprecated_message, replacement, downloads, scrutiny_status, scrutiny_report, source, source_url, source_json, - config_hash, visibility, moderation_status, published_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + config_hash, visibility, moderation_status, forked_from, forked_version, + published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ owner, @@ -1943,6 +1948,8 @@ def create_app() -> Flask: config_hash, visibility, moderation_status, + forked_from, + forked_version, datetime.utcnow().isoformat(), ], ) @@ -1957,6 +1964,8 @@ def create_app() -> Flask: "pr_url": "", "status": moderation_status, "visibility": visibility, + "forked_from": forked_from, + "forked_version": forked_version, "suggestions": suggestions, } }) diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index 7b83666..c74bd4f 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS tools ( moderation_note TEXT, moderated_by TEXT, moderated_at TIMESTAMP, + forked_from TEXT, + forked_version TEXT, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner, name, version) ); @@ -455,6 +457,8 @@ def migrate_db(conn: sqlite3.Connection) -> None: ("moderation_note", "TEXT", "NULL"), ("moderated_by", "TEXT", "NULL"), ("moderated_at", "TIMESTAMP", "NULL"), + ("forked_from", "TEXT", "NULL"), + ("forked_version", "TEXT", "NULL"), ] for col_name, col_type, default in tools_migrations: