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:
parent
4756ea906c
commit
16de3e371c
|
|
@ -1,9 +1,10 @@
|
||||||
"""Publish tool dialog."""
|
"""Publish tool dialog."""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||||
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
||||||
QProgressBar, QMessageBox, QComboBox
|
QProgressBar, QMessageBox, QComboBox, QFrame, QWidget
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import QThread, Signal
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
|
|
@ -12,6 +13,72 @@ from ...registry_client import RegistryClient, RegistryError
|
||||||
from ...config import load_config
|
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):
|
class PublishWorker(QThread):
|
||||||
"""Background worker for publishing."""
|
"""Background worker for publishing."""
|
||||||
success = Signal(dict)
|
success = Signal(dict)
|
||||||
|
|
@ -40,10 +107,114 @@ class PublishDialog(QDialog):
|
||||||
def __init__(self, parent, tool: Tool):
|
def __init__(self, parent, tool: Tool):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Publish Tool")
|
self.setWindowTitle("Publish Tool")
|
||||||
self.setMinimumSize(500, 400)
|
self.setMinimumSize(500, 450)
|
||||||
self._tool = tool
|
self._tool = tool
|
||||||
self._worker = None
|
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._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):
|
def _setup_ui(self):
|
||||||
"""Set up the UI."""
|
"""Set up the UI."""
|
||||||
|
|
@ -55,15 +226,76 @@ class PublishDialog(QDialog):
|
||||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||||
layout.addWidget(title)
|
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
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setSpacing(12)
|
form.setSpacing(12)
|
||||||
|
|
||||||
# Version
|
# Version input with bump buttons
|
||||||
|
version_row = QHBoxLayout()
|
||||||
self.version_input = QLineEdit()
|
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")
|
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
|
# Category
|
||||||
self.category_combo = QComboBox()
|
self.category_combo = QComboBox()
|
||||||
|
|
@ -154,6 +386,17 @@ class PublishDialog(QDialog):
|
||||||
if tags:
|
if tags:
|
||||||
tool_config["tags"] = 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
|
# Convert to YAML string
|
||||||
config_yaml = yaml.dump(tool_config, default_flow_style=False, sort_keys=False)
|
config_yaml = yaml.dump(tool_config, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1777,6 +1777,10 @@ def create_app() -> Flask:
|
||||||
# Create a minimal source_json for consistency
|
# Create a minimal source_json for consistency
|
||||||
source_json = json.dumps({"type": "imported", "original_tool": source, "url": source_url})
|
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:
|
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")
|
return error_response("VALIDATION_ERROR", "Invalid tool name")
|
||||||
if not version or Semver.parse(version) is None:
|
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,
|
owner, name, version, description, category, tags, config_yaml, readme,
|
||||||
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
||||||
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
||||||
config_hash, visibility, moderation_status, published_at
|
config_hash, visibility, moderation_status, forked_from, forked_version,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
published_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
[
|
[
|
||||||
owner,
|
owner,
|
||||||
|
|
@ -1943,6 +1948,8 @@ def create_app() -> Flask:
|
||||||
config_hash,
|
config_hash,
|
||||||
visibility,
|
visibility,
|
||||||
moderation_status,
|
moderation_status,
|
||||||
|
forked_from,
|
||||||
|
forked_version,
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -1957,6 +1964,8 @@ def create_app() -> Flask:
|
||||||
"pr_url": "",
|
"pr_url": "",
|
||||||
"status": moderation_status,
|
"status": moderation_status,
|
||||||
"visibility": visibility,
|
"visibility": visibility,
|
||||||
|
"forked_from": forked_from,
|
||||||
|
"forked_version": forked_version,
|
||||||
"suggestions": suggestions,
|
"suggestions": suggestions,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS tools (
|
||||||
moderation_note TEXT,
|
moderation_note TEXT,
|
||||||
moderated_by TEXT,
|
moderated_by TEXT,
|
||||||
moderated_at TIMESTAMP,
|
moderated_at TIMESTAMP,
|
||||||
|
forked_from TEXT,
|
||||||
|
forked_version TEXT,
|
||||||
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(owner, name, version)
|
UNIQUE(owner, name, version)
|
||||||
);
|
);
|
||||||
|
|
@ -455,6 +457,8 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||||
("moderation_note", "TEXT", "NULL"),
|
("moderation_note", "TEXT", "NULL"),
|
||||||
("moderated_by", "TEXT", "NULL"),
|
("moderated_by", "TEXT", "NULL"),
|
||||||
("moderated_at", "TIMESTAMP", "NULL"),
|
("moderated_at", "TIMESTAMP", "NULL"),
|
||||||
|
("forked_from", "TEXT", "NULL"),
|
||||||
|
("forked_version", "TEXT", "NULL"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for col_name, col_type, default in tools_migrations:
|
for col_name, col_type, default in tools_migrations:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue