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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-16 22:45:42 -04:00
parent 4756ea906c
commit 16de3e371c
3 changed files with 263 additions and 7 deletions

View File

@ -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)

View File

@ -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,
}
})

View File

@ -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: