Add dev setup script and restructure dependencies for editable installs
- Add scripts/dev-setup.sh for setting up development environment with editable installs of interdependent projects (cmdforge, ramble, artifact-editor, orchestrated-discussions) - Restructure pyproject.toml dependencies: top-level app specifies git URLs, libraries use name-only deps for compatibility with editable installs - Add artifact-editor as explicit dependency (transitive through discussions) - Various model, parser, and widget enhancements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
20818956b3
commit
de024965a0
|
|
@ -13,9 +13,12 @@ dependencies = [
|
||||||
"PySide6>=6.4.0",
|
"PySide6>=6.4.0",
|
||||||
"pyte>=0.8.0",
|
"pyte>=0.8.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
|
# Git dependencies - top-level app specifies where to get internal packages
|
||||||
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
|
# Libraries use name-only deps so editable installs work during development
|
||||||
"cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git",
|
"cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git",
|
||||||
|
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
|
||||||
|
"artifact-editor @ git+https://gitea.brrd.tech/rob/artifact-editor.git",
|
||||||
|
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Development setup script for development-hub
|
||||||
|
#
|
||||||
|
# This script installs all interdependent projects as editable packages,
|
||||||
|
# allowing you to develop them in parallel without reinstalling.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/dev-setup.sh # Full setup with venv creation
|
||||||
|
# ./scripts/dev-setup.sh --deps # Just reinstall editable deps (faster)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
PROJECTS_ROOT="$(dirname "$PROJECT_DIR")"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}==>${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}==>${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we're just updating deps
|
||||||
|
DEPS_ONLY=false
|
||||||
|
if [ "$1" = "--deps" ]; then
|
||||||
|
DEPS_ONLY=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if [ "$DEPS_ONLY" = false ]; then
|
||||||
|
# Create venv if it doesn't exist
|
||||||
|
if [ ! -d ".venv" ]; then
|
||||||
|
log "Creating virtual environment..."
|
||||||
|
python3 -m venv .venv
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate venv
|
||||||
|
log "Activating virtual environment..."
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
if [ "$DEPS_ONLY" = false ]; then
|
||||||
|
# Upgrade pip
|
||||||
|
log "Upgrading pip..."
|
||||||
|
pip install --upgrade pip
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install editable packages in dependency order (base packages first)
|
||||||
|
# Using --no-deps to avoid git URL conflicts, then installing other deps
|
||||||
|
log "Installing cmdforge (base layer)..."
|
||||||
|
pip install -e "$PROJECTS_ROOT/CmdForge" --no-deps
|
||||||
|
pip install PyYAML requests PySide6 NodeGraphQt setuptools
|
||||||
|
|
||||||
|
log "Installing ramble..."
|
||||||
|
pip install -e "$PROJECTS_ROOT/ramble" --no-deps
|
||||||
|
|
||||||
|
log "Installing artifact-editor..."
|
||||||
|
pip install -e "$PROJECTS_ROOT/artifact-editor" --no-deps
|
||||||
|
pip install QScintilla
|
||||||
|
|
||||||
|
log "Installing orchestrated-discussions[gui]..."
|
||||||
|
pip install -e "$PROJECTS_ROOT/orchestrated-discussions[gui]" --no-deps
|
||||||
|
pip install dearpygui sounddevice numpy urwid
|
||||||
|
|
||||||
|
log "Installing development-hub..."
|
||||||
|
pip install -e "$PROJECT_DIR" --no-deps
|
||||||
|
pip install pyte
|
||||||
|
|
||||||
|
if [ "$DEPS_ONLY" = false ]; then
|
||||||
|
# Install dev dependencies
|
||||||
|
log "Installing dev dependencies..."
|
||||||
|
pip install pytest pytest-qt pytest-cov
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "Development environment ready!"
|
||||||
|
echo ""
|
||||||
|
echo "Installed packages (editable):"
|
||||||
|
pip list | grep -E "(cmdforge|ramble|artifact-editor|orchestrated-discussions|development-hub)" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo "To activate: source .venv/bin/activate"
|
||||||
|
echo "To run: development-hub"
|
||||||
|
|
@ -45,6 +45,23 @@ class Deliverable:
|
||||||
return cls(name=name, status=status)
|
return cls(name=name, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LinkedDocument:
|
||||||
|
"""A document linked to a milestone (plan, spec, notes, etc.)."""
|
||||||
|
path: str # Relative path to document
|
||||||
|
title: str = "" # Display title (derived from path if empty)
|
||||||
|
doc_type: str = "plan" # "plan", "spec", "notes", "discussion"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_title(self) -> str:
|
||||||
|
"""Get display title, falling back to filename."""
|
||||||
|
if self.title:
|
||||||
|
return self.title
|
||||||
|
# Extract filename without extension
|
||||||
|
from pathlib import Path
|
||||||
|
return Path(self.path).stem.replace("-", " ").replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Milestone:
|
class Milestone:
|
||||||
"""A milestone with deliverables and progress tracking."""
|
"""A milestone with deliverables and progress tracking."""
|
||||||
|
|
@ -56,6 +73,8 @@ class Milestone:
|
||||||
deliverables: list[Deliverable] = field(default_factory=list)
|
deliverables: list[Deliverable] = field(default_factory=list)
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
description: str = "" # Free-form description text
|
description: str = "" # Free-form description text
|
||||||
|
plan_path: str | None = None # Path to linked plan document
|
||||||
|
documents: list[LinkedDocument] = field(default_factory=list) # Additional linked docs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class Todo:
|
||||||
tags: list[str] = field(default_factory=list) # from #tag in text
|
tags: list[str] = field(default_factory=list) # from #tag in text
|
||||||
completed_date: str | None = None
|
completed_date: str | None = None
|
||||||
blocker_reason: str | None = None # For blocked items
|
blocker_reason: str | None = None # For blocked items
|
||||||
|
phase: str | None = None # from [Phase 1] prefix or #phase-1 tag
|
||||||
|
notes: str | None = None # Additional context, shown as tooltip
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def priority_order(self) -> int:
|
def priority_order(self) -> int:
|
||||||
|
|
@ -33,9 +35,21 @@ class Todo:
|
||||||
self.blocker_reason = None
|
self.blocker_reason = None
|
||||||
|
|
||||||
def to_markdown(self) -> str:
|
def to_markdown(self) -> str:
|
||||||
"""Convert to markdown checkbox format."""
|
"""Convert to markdown checkbox format.
|
||||||
|
|
||||||
|
Returns a string that may be multiple lines if notes are present:
|
||||||
|
- [ ] Task text @M4
|
||||||
|
> Notes about this task
|
||||||
|
"""
|
||||||
checkbox = "[x]" if self.completed else "[ ]"
|
checkbox = "[x]" if self.completed else "[ ]"
|
||||||
parts = [f"- {checkbox} {self.text}"]
|
|
||||||
|
# Include phase prefix if present
|
||||||
|
if self.phase:
|
||||||
|
text_part = f"[{self.phase}] {self.text}"
|
||||||
|
else:
|
||||||
|
text_part = self.text
|
||||||
|
|
||||||
|
parts = [f"- {checkbox} {text_part}"]
|
||||||
|
|
||||||
if self.milestone:
|
if self.milestone:
|
||||||
parts.append(f"@{self.milestone}")
|
parts.append(f"@{self.milestone}")
|
||||||
|
|
@ -51,7 +65,13 @@ class Todo:
|
||||||
if self.blocker_reason:
|
if self.blocker_reason:
|
||||||
parts.append(f"- {self.blocker_reason}")
|
parts.append(f"- {self.blocker_reason}")
|
||||||
|
|
||||||
return " ".join(parts)
|
result = " ".join(parts)
|
||||||
|
|
||||||
|
# Add notes as indented blockquote line
|
||||||
|
if self.notes:
|
||||||
|
result += f"\n > {self.notes}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,24 @@ class BaseParser:
|
||||||
return date, text.strip()
|
return date, text.strip()
|
||||||
return None, text
|
return None, text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_phase(text: str) -> tuple[str | None, str]:
|
||||||
|
"""Extract [Phase N] or [Phase Name] prefix from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text potentially starting with [Phase 1] or similar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (phase string or None, text without phase prefix)
|
||||||
|
"""
|
||||||
|
# Match [Phase X] at the start of the text
|
||||||
|
match = re.match(r"^\[([^\]]+)\]\s*", text)
|
||||||
|
if match:
|
||||||
|
phase = match.group(1)
|
||||||
|
text = text[match.end():].strip()
|
||||||
|
return phase, text
|
||||||
|
return None, text
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
|
def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
|
||||||
"""Parse a markdown table.
|
"""Parse a markdown table.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from development_hub.models.goal import (
|
||||||
GoalList,
|
GoalList,
|
||||||
Milestone,
|
Milestone,
|
||||||
Deliverable,
|
Deliverable,
|
||||||
|
LinkedDocument,
|
||||||
MilestoneStatus,
|
MilestoneStatus,
|
||||||
DeliverableStatus,
|
DeliverableStatus,
|
||||||
)
|
)
|
||||||
|
|
@ -211,6 +212,8 @@ class MilestonesParser(BaseParser):
|
||||||
deliverables = []
|
deliverables = []
|
||||||
notes = ""
|
notes = ""
|
||||||
description_lines = []
|
description_lines = []
|
||||||
|
plan_path = None
|
||||||
|
documents = []
|
||||||
|
|
||||||
lines = content.split("\n")
|
lines = content.split("\n")
|
||||||
table_lines = []
|
table_lines = []
|
||||||
|
|
@ -242,6 +245,21 @@ class MilestonesParser(BaseParser):
|
||||||
notes = notes_match.group(1).strip()
|
notes = notes_match.group(1).strip()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Parse **Plan**: path/to/file.md
|
||||||
|
plan_match = re.match(r"\*\*Plan\*\*:\s*(.+)", line_stripped)
|
||||||
|
if plan_match:
|
||||||
|
plan_path = plan_match.group(1).strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse **Documents**: path1, path2, ... (optional multi-doc field)
|
||||||
|
docs_match = re.match(r"\*\*Documents\*\*:\s*(.+)", line_stripped)
|
||||||
|
if docs_match:
|
||||||
|
doc_paths = [p.strip() for p in docs_match.group(1).split(",")]
|
||||||
|
for path in doc_paths:
|
||||||
|
if path:
|
||||||
|
documents.append(LinkedDocument(path=path))
|
||||||
|
continue
|
||||||
|
|
||||||
# Parse deliverables table
|
# Parse deliverables table
|
||||||
if line_stripped.startswith("|"):
|
if line_stripped.startswith("|"):
|
||||||
in_table = True
|
in_table = True
|
||||||
|
|
@ -270,6 +288,8 @@ class MilestonesParser(BaseParser):
|
||||||
deliverables=deliverables,
|
deliverables=deliverables,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
description=" ".join(description_lines),
|
description=" ".join(description_lines),
|
||||||
|
plan_path=plan_path,
|
||||||
|
documents=documents,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
||||||
|
|
@ -407,6 +427,15 @@ class MilestonesParser(BaseParser):
|
||||||
if milestone.notes:
|
if milestone.notes:
|
||||||
lines.append(f"**Notes**: {milestone.notes}")
|
lines.append(f"**Notes**: {milestone.notes}")
|
||||||
|
|
||||||
|
# Plan path
|
||||||
|
if milestone.plan_path:
|
||||||
|
lines.append(f"**Plan**: {milestone.plan_path}")
|
||||||
|
|
||||||
|
# Additional documents
|
||||||
|
if milestone.documents:
|
||||||
|
doc_paths = ", ".join(d.path for d in milestone.documents)
|
||||||
|
lines.append(f"**Documents**: {doc_paths}")
|
||||||
|
|
||||||
# Description (after fields, before table)
|
# Description (after fields, before table)
|
||||||
if milestone.description:
|
if milestone.description:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class TodosParser(BaseParser):
|
||||||
Expected format:
|
Expected format:
|
||||||
## Active Tasks / High Priority / Medium Priority / Low Priority
|
## Active Tasks / High Priority / Medium Priority / Low Priority
|
||||||
- [ ] Task description @project #tag
|
- [ ] Task description @project #tag
|
||||||
|
> Optional notes on indented line
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
- [x] Done task (2026-01-06)
|
- [x] Done task (2026-01-06)
|
||||||
|
|
@ -38,10 +39,25 @@ class TodosParser(BaseParser):
|
||||||
current_priority = "medium"
|
current_priority = "medium"
|
||||||
table_lines = []
|
table_lines = []
|
||||||
in_table = False
|
in_table = False
|
||||||
|
pending_todo = None # Track todo waiting for possible notes
|
||||||
|
|
||||||
for line in self.body.split("\n"):
|
lines = self.body.split("\n")
|
||||||
|
for line in lines:
|
||||||
line_stripped = line.strip()
|
line_stripped = line.strip()
|
||||||
|
|
||||||
|
# Check for indented note line (follows a todo)
|
||||||
|
if pending_todo and line.startswith(" ") and line_stripped.startswith(">"):
|
||||||
|
# Extract note text (remove leading > and whitespace)
|
||||||
|
note_text = line_stripped[1:].strip()
|
||||||
|
pending_todo.notes = note_text
|
||||||
|
todo_list.add_todo(pending_todo)
|
||||||
|
pending_todo = None
|
||||||
|
continue
|
||||||
|
elif pending_todo:
|
||||||
|
# Previous line was a todo but this isn't a note - add the todo
|
||||||
|
todo_list.add_todo(pending_todo)
|
||||||
|
pending_todo = None
|
||||||
|
|
||||||
# Detect section headers
|
# Detect section headers
|
||||||
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
|
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
|
||||||
# Save any pending table
|
# Save any pending table
|
||||||
|
|
@ -93,7 +109,12 @@ class TodosParser(BaseParser):
|
||||||
if line_stripped.startswith("- ["):
|
if line_stripped.startswith("- ["):
|
||||||
todo = self._parse_todo_line(line_stripped, current_priority, current_section)
|
todo = self._parse_todo_line(line_stripped, current_priority, current_section)
|
||||||
if todo:
|
if todo:
|
||||||
todo_list.add_todo(todo)
|
# Don't add yet - wait to see if next line has notes
|
||||||
|
pending_todo = todo
|
||||||
|
|
||||||
|
# Handle any remaining pending todo
|
||||||
|
if pending_todo:
|
||||||
|
todo_list.add_todo(pending_todo)
|
||||||
|
|
||||||
# Handle any remaining table
|
# Handle any remaining table
|
||||||
if in_table and table_lines:
|
if in_table and table_lines:
|
||||||
|
|
@ -105,7 +126,7 @@ class TodosParser(BaseParser):
|
||||||
"""Parse a single todo line.
|
"""Parse a single todo line.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
line: Line like "- [ ] Task @M1 @project #tag"
|
line: Line like "- [ ] [Phase 1] Task @M1 @project #tag"
|
||||||
priority: Current priority level
|
priority: Current priority level
|
||||||
section: Current section name
|
section: Current section name
|
||||||
|
|
||||||
|
|
@ -117,6 +138,9 @@ class TodosParser(BaseParser):
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Extract phase prefix first (e.g., [Phase 1])
|
||||||
|
phase, text = self.extract_phase(text)
|
||||||
|
|
||||||
# Extract metadata (milestone first, then project)
|
# Extract metadata (milestone first, then project)
|
||||||
milestone, text = self.extract_milestone_tag(text)
|
milestone, text = self.extract_milestone_tag(text)
|
||||||
project, text = self.extract_project_tag(text)
|
project, text = self.extract_project_tag(text)
|
||||||
|
|
@ -156,6 +180,7 @@ class TodosParser(BaseParser):
|
||||||
tags=tags,
|
tags=tags,
|
||||||
completed_date=date,
|
completed_date=date,
|
||||||
blocker_reason=blocker_reason if section == "blocked" else None,
|
blocker_reason=blocker_reason if section == "blocked" else None,
|
||||||
|
phase=phase,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle blocked items
|
# Handle blocked items
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Background worker for running goals audit."""
|
"""Background worker for running goals audit."""
|
||||||
|
|
||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -14,10 +15,18 @@ class AuditWorker(QObject):
|
||||||
finished = Signal(str, bool) # output, success
|
finished = Signal(str, bool) # output, success
|
||||||
error = Signal(str)
|
error = Signal(str)
|
||||||
|
|
||||||
def __init__(self, project_key: str, project_path: Path | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_name: str,
|
||||||
|
goals_path: Path,
|
||||||
|
milestones_path: Path | None = None,
|
||||||
|
project_dir: Path | None = None,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.project_key = project_key # e.g. "development-hub" or "global"
|
self.project_name = project_name
|
||||||
self.project_path = project_path
|
self.goals_path = goals_path
|
||||||
|
self.milestones_path = milestones_path
|
||||||
|
self.project_dir = project_dir
|
||||||
self._process: subprocess.Popen | None = None
|
self._process: subprocess.Popen | None = None
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
|
|
||||||
|
|
@ -27,6 +36,16 @@ class AuditWorker(QObject):
|
||||||
if not cmdforge_path:
|
if not cmdforge_path:
|
||||||
cmdforge_path = Path("cmdforge")
|
cmdforge_path = Path("cmdforge")
|
||||||
|
|
||||||
|
# Build JSON input for the tool
|
||||||
|
input_data = {
|
||||||
|
"project_name": self.project_name,
|
||||||
|
"goals_path": str(self.goals_path),
|
||||||
|
}
|
||||||
|
if self.milestones_path:
|
||||||
|
input_data["milestones_path"] = str(self.milestones_path)
|
||||||
|
if self.project_dir:
|
||||||
|
input_data["project_dir"] = str(self.project_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
[str(cmdforge_path), "run", "audit-goals"],
|
[str(cmdforge_path), "run", "audit-goals"],
|
||||||
|
|
@ -34,11 +53,11 @@ class AuditWorker(QObject):
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
cwd=str(self.project_path) if self.project_path and self.project_path.exists() else None,
|
cwd=str(self.project_dir) if self.project_dir and self.project_dir.exists() else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pass project key to stdin (tool expects project name, not file content)
|
# Pass JSON input to tool
|
||||||
stdout, stderr = self._process.communicate(input=self.project_key)
|
stdout, stderr = self._process.communicate(input=json.dumps(input_data))
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,9 @@ class MilestoneWidget(QFrame):
|
||||||
todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos
|
todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos
|
||||||
todo_start_discussion = Signal(object) # (todo) - for starting discussion from todo
|
todo_start_discussion = Signal(object) # (todo) - for starting discussion from todo
|
||||||
todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing
|
todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing
|
||||||
|
milestone_start_discussion = Signal(object) # (milestone) - for starting discussion from milestone
|
||||||
|
milestone_import_plan = Signal(object) # (milestone) - for importing a plan to this milestone
|
||||||
|
milestone_view_plan = Signal(object, str) # (milestone, plan_path) - for viewing linked plan
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -848,6 +851,14 @@ class MilestoneWidget(QFrame):
|
||||||
self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
|
self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
|
||||||
content_layout.addWidget(self.target_label)
|
content_layout.addWidget(self.target_label)
|
||||||
|
|
||||||
|
# Plan link (if set)
|
||||||
|
if self.milestone.plan_path:
|
||||||
|
self.plan_link = QLabel(f'📄 <a href="#" style="color: #4a9eff;">View Implementation Plan</a>')
|
||||||
|
self.plan_link.setStyleSheet("font-size: 11px;")
|
||||||
|
self.plan_link.setOpenExternalLinks(False)
|
||||||
|
self.plan_link.linkActivated.connect(self._on_view_plan_clicked)
|
||||||
|
content_layout.addWidget(self.plan_link)
|
||||||
|
|
||||||
# Deliverables list
|
# Deliverables list
|
||||||
self.deliverables_container = QWidget()
|
self.deliverables_container = QWidget()
|
||||||
self.deliverables_layout = QVBoxLayout(self.deliverables_container)
|
self.deliverables_layout = QVBoxLayout(self.deliverables_container)
|
||||||
|
|
@ -1005,7 +1016,9 @@ class MilestoneWidget(QFrame):
|
||||||
""")
|
""")
|
||||||
|
|
||||||
def _load_deliverables(self):
|
def _load_deliverables(self):
|
||||||
"""Load deliverable/todo widgets."""
|
"""Load deliverable/todo widgets, grouped by phase if applicable."""
|
||||||
|
import re
|
||||||
|
|
||||||
# Clear existing
|
# Clear existing
|
||||||
while self.deliverables_layout.count():
|
while self.deliverables_layout.count():
|
||||||
item = self.deliverables_layout.takeAt(0)
|
item = self.deliverables_layout.takeAt(0)
|
||||||
|
|
@ -1014,13 +1027,48 @@ class MilestoneWidget(QFrame):
|
||||||
|
|
||||||
# Show todos if available (preferred mode), otherwise show deliverables
|
# Show todos if available (preferred mode), otherwise show deliverables
|
||||||
if self._todos:
|
if self._todos:
|
||||||
|
# Group todos by phase
|
||||||
|
phases = {}
|
||||||
for todo in self._todos:
|
for todo in self._todos:
|
||||||
# Show priority badge instead of milestone (since milestone is obvious)
|
phase_key = todo.phase or "_ungrouped"
|
||||||
|
phases.setdefault(phase_key, []).append(todo)
|
||||||
|
|
||||||
|
# Sort phase names: "Phase 1" before "Phase 2", then alphabetic
|
||||||
|
def phase_sort_key(name):
|
||||||
|
if name == "_ungrouped":
|
||||||
|
return (999, "") # Ungrouped at end
|
||||||
|
match = re.search(r'\d+', name)
|
||||||
|
return (int(match.group()) if match else 500, name)
|
||||||
|
|
||||||
|
sorted_phases = sorted(phases.keys(), key=phase_sort_key)
|
||||||
|
|
||||||
|
# If there's only one phase (or no phases), don't show phase headers
|
||||||
|
show_phase_headers = len(sorted_phases) > 1 or (
|
||||||
|
len(sorted_phases) == 1 and sorted_phases[0] != "_ungrouped"
|
||||||
|
)
|
||||||
|
|
||||||
|
for phase_name in sorted_phases:
|
||||||
|
phase_todos = phases[phase_name]
|
||||||
|
|
||||||
|
# Add phase header if needed
|
||||||
|
if show_phase_headers and phase_name != "_ungrouped":
|
||||||
|
phase_header = QLabel(f"▸ {phase_name}")
|
||||||
|
phase_header.setStyleSheet(
|
||||||
|
"color: #888888; font-size: 11px; font-weight: bold; "
|
||||||
|
"padding: 4px 0px 2px 0px;"
|
||||||
|
)
|
||||||
|
self.deliverables_layout.addWidget(phase_header)
|
||||||
|
|
||||||
|
# Add todos for this phase
|
||||||
|
for todo in phase_todos:
|
||||||
widget = TodoItemWidget(todo, show_priority=True)
|
widget = TodoItemWidget(todo, show_priority=True)
|
||||||
widget.toggled.connect(self._on_todo_toggled_internal)
|
widget.toggled.connect(self._on_todo_toggled_internal)
|
||||||
widget.deleted.connect(self._on_todo_deleted_internal)
|
widget.deleted.connect(self._on_todo_deleted_internal)
|
||||||
widget.start_discussion.connect(self.todo_start_discussion.emit)
|
widget.start_discussion.connect(self.todo_start_discussion.emit)
|
||||||
widget.edited.connect(self.todo_edited.emit)
|
widget.edited.connect(self.todo_edited.emit)
|
||||||
|
# Set tooltip with notes if available
|
||||||
|
if todo.notes:
|
||||||
|
widget.setToolTip(todo.notes)
|
||||||
self.deliverables_layout.addWidget(widget)
|
self.deliverables_layout.addWidget(widget)
|
||||||
else:
|
else:
|
||||||
# Legacy: Add deliverables
|
# Legacy: Add deliverables
|
||||||
|
|
@ -1078,3 +1126,48 @@ class MilestoneWidget(QFrame):
|
||||||
self._update_progress()
|
self._update_progress()
|
||||||
self._update_status_icon()
|
self._update_status_icon()
|
||||||
self._load_deliverables()
|
self._load_deliverables()
|
||||||
|
|
||||||
|
def _on_view_plan_clicked(self, link):
|
||||||
|
"""Handle view plan link click."""
|
||||||
|
if self.milestone.plan_path:
|
||||||
|
self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path)
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event):
|
||||||
|
"""Show context menu on right-click."""
|
||||||
|
menu = QMenu(self)
|
||||||
|
menu.setStyleSheet("""
|
||||||
|
QMenu {
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
border: 1px solid #3d3d3d;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
QMenu::item {
|
||||||
|
padding: 8px 24px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: #3d6a99;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Start Discussion action
|
||||||
|
discuss_action = menu.addAction("Start Discussion...")
|
||||||
|
discuss_action.triggered.connect(
|
||||||
|
lambda: self.milestone_start_discussion.emit(self.milestone)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import Plan action
|
||||||
|
import_action = menu.addAction("Import Plan...")
|
||||||
|
import_action.triggered.connect(
|
||||||
|
lambda: self.milestone_import_plan.emit(self.milestone)
|
||||||
|
)
|
||||||
|
|
||||||
|
# View Plan action (if plan exists)
|
||||||
|
if self.milestone.plan_path:
|
||||||
|
menu.addSeparator()
|
||||||
|
view_action = menu.addAction("View Plan")
|
||||||
|
view_action.triggered.connect(
|
||||||
|
lambda: self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
menu.exec(event.globalPos())
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue