@pennyfarthing/core 11.1.1 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
- package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
- package/packages/core/dist/server/otlp-receiver.js +185 -24
- package/packages/core/dist/server/otlp-receiver.js.map +1 -1
- package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
- package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
- package/packages/core/dist/server/otlp-receiver.test.js +446 -0
- package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
- package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
- package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
- package/packages/core/dist/shared/portrait-resolver.js +27 -0
- package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
- package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
- package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
- package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
- package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
- package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
- package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
- package/pennyfarthing-dist/agents/dev.md +1 -1
- package/pennyfarthing-dist/agents/reviewer.md +1 -1
- package/pennyfarthing-dist/agents/sm-setup.md +1 -1
- package/pennyfarthing-dist/agents/sm.md +2 -2
- package/pennyfarthing-dist/agents/tea.md +1 -1
- package/pennyfarthing-dist/agents/testing-runner.md +2 -1
- package/pennyfarthing-dist/commands/pf-chore.md +2 -2
- package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
- package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
- package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
- package/pennyfarthing-dist/guides/bikerack.md +3 -3
- package/pennyfarthing-dist/guides/hooks.md +1 -1
- package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
- package/pennyfarthing-dist/guides/xml-tags.md +2 -2
- package/pennyfarthing-dist/scripts/README.md +1 -1
- package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
- package/pennyfarthing-dist/scripts/git/README.md +24 -14
- package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
- package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
- package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
- package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
- package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
- package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
- package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
- package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
- package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
- package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
- package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
- package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
- package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
- package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
- package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
- package/pennyfarthing_scripts/CLAUDE.md +26 -4
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/cli.py +3 -5
- package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
- package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
- package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
- package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
- package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
- package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
- package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
- package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
- package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
- package/pennyfarthing_scripts/bikerack/tui.py +336 -30
- package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
- package/pennyfarthing_scripts/cli.py +37 -65
- package/pennyfarthing_scripts/consultation/__init__.py +1 -0
- package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/consultation/cli.py +149 -0
- package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
- package/pennyfarthing_scripts/context.py +3 -3
- package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__init__.py +12 -1
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/create_branches.py +3 -4
- package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
- package/pennyfarthing_scripts/git/repos.py +196 -0
- package/pennyfarthing_scripts/git/status_all.py +27 -11
- package/pennyfarthing_scripts/git/worktree.py +302 -0
- package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git_group/cli.py +143 -40
- package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
- package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
- package/pennyfarthing_scripts/hooks.py +3 -17
- package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
- package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/heatmap.py +655 -0
- package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/session_start_hook.py +1 -1
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/loader.py +15 -1
- package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
- package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
- package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
- package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
- package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
- package/pennyfarthing_scripts/validate/cli.py +17 -5
- package/pennyfarthing_scripts/workflow/__init__.py +40 -0
- package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/cli.py +1099 -0
- package/pennyfarthing_scripts/workflow/helpers.py +241 -0
- package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
- package/pennyfarthing_scripts/workflow/state.py +112 -0
- package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
- package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
- package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
- package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
- package/pennyfarthing-dist/skills/pf-workflow/scripts/workflow-status.sh +0 -167
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""ProgressPanel — Unified story progress view for BikeRack TUI.
|
|
2
|
+
|
|
3
|
+
Combines story context, workflow phase, acceptance criteria, todos, and
|
|
4
|
+
git status into a single at-a-glance panel. Subscribes to 4 WS channels:
|
|
5
|
+
/ws/story, /ws/todos, /ws/git, /ws/sprint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Group
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from pennyfarthing_scripts.bikerack.base_panel import (
|
|
16
|
+
PANEL_ICONS,
|
|
17
|
+
BasePanel,
|
|
18
|
+
render_progress_bar,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProgressPanel(BasePanel):
|
|
23
|
+
"""Unified story progress panel.
|
|
24
|
+
|
|
25
|
+
Subscribes to ``story``, ``todos``, ``git``, and ``sprint`` channels.
|
|
26
|
+
Renders a compact overview combining story header, workflow phase,
|
|
27
|
+
AC progress, todo progress, and git summary.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
channel: str = "story" # primary channel
|
|
31
|
+
panel_name: str = "Progress"
|
|
32
|
+
icon: str = PANEL_ICONS.get("progress", ("\uf200", "P"))[0]
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: Any = None, **kwargs: Any) -> None:
|
|
35
|
+
super().__init__(client=client, **kwargs)
|
|
36
|
+
self._story_data: dict[str, Any] | None = None
|
|
37
|
+
self._todos_data: dict[str, Any] | None = None
|
|
38
|
+
self._git_data: dict[str, Any] | None = None
|
|
39
|
+
self._sprint_data: dict[str, Any] | None = None
|
|
40
|
+
|
|
41
|
+
def on_mount(self) -> None:
|
|
42
|
+
"""Subscribe to all 4 channels."""
|
|
43
|
+
self._mounted = True
|
|
44
|
+
if self._client is not None:
|
|
45
|
+
self._client.subscribe("story", self._handle_story)
|
|
46
|
+
self._client.subscribe("todos", self._handle_todos)
|
|
47
|
+
self._client.subscribe("git", self._handle_git)
|
|
48
|
+
self._client.subscribe("sprint", self._handle_sprint)
|
|
49
|
+
|
|
50
|
+
def _handle_story(self, message: dict[str, Any] | None) -> None:
|
|
51
|
+
if message is None:
|
|
52
|
+
return
|
|
53
|
+
self._story_data = message
|
|
54
|
+
self._rerender()
|
|
55
|
+
|
|
56
|
+
def _handle_todos(self, message: dict[str, Any] | None) -> None:
|
|
57
|
+
if message is None:
|
|
58
|
+
return
|
|
59
|
+
self._todos_data = message
|
|
60
|
+
self._rerender()
|
|
61
|
+
|
|
62
|
+
def _handle_git(self, message: dict[str, Any] | None) -> None:
|
|
63
|
+
if message is None:
|
|
64
|
+
return
|
|
65
|
+
self._git_data = message
|
|
66
|
+
self._rerender()
|
|
67
|
+
|
|
68
|
+
def _handle_sprint(self, message: dict[str, Any] | None) -> None:
|
|
69
|
+
if message is None:
|
|
70
|
+
return
|
|
71
|
+
self._sprint_data = message
|
|
72
|
+
self._rerender()
|
|
73
|
+
|
|
74
|
+
def _rerender(self) -> None:
|
|
75
|
+
"""Re-render with latest data from all channels."""
|
|
76
|
+
rendered = self.render_panel({})
|
|
77
|
+
try:
|
|
78
|
+
self.update(rendered)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def render_panel(self, payload: dict[str, Any]) -> Any:
|
|
83
|
+
"""Render unified progress view."""
|
|
84
|
+
parts: list[Any] = []
|
|
85
|
+
|
|
86
|
+
# --- Story Header ---
|
|
87
|
+
story_header = self._render_story_header()
|
|
88
|
+
if story_header is None:
|
|
89
|
+
return Text(
|
|
90
|
+
"No active story \u2014 start with /sprint work",
|
|
91
|
+
style="dim italic",
|
|
92
|
+
)
|
|
93
|
+
parts.append(story_header)
|
|
94
|
+
parts.append(Text("\u2500" * 35, style="dim"))
|
|
95
|
+
|
|
96
|
+
# --- Workflow Phase ---
|
|
97
|
+
workflow = self._render_workflow()
|
|
98
|
+
if workflow is not None:
|
|
99
|
+
parts.append(workflow)
|
|
100
|
+
parts.append(Text("\u2500" * 35, style="dim"))
|
|
101
|
+
|
|
102
|
+
# --- Acceptance Criteria ---
|
|
103
|
+
ac = self._render_ac()
|
|
104
|
+
if ac is not None:
|
|
105
|
+
parts.append(ac)
|
|
106
|
+
parts.append(Text("\u2500" * 35, style="dim"))
|
|
107
|
+
|
|
108
|
+
# --- Todos ---
|
|
109
|
+
todos = self._render_todos()
|
|
110
|
+
if todos is not None:
|
|
111
|
+
parts.append(todos)
|
|
112
|
+
parts.append(Text("\u2500" * 35, style="dim"))
|
|
113
|
+
|
|
114
|
+
# --- Git Summary ---
|
|
115
|
+
git = self._render_git()
|
|
116
|
+
if git is not None:
|
|
117
|
+
parts.append(git)
|
|
118
|
+
|
|
119
|
+
return Group(*parts)
|
|
120
|
+
|
|
121
|
+
def _render_story_header(self) -> Text | None:
|
|
122
|
+
"""Render story ID, title, points, epic, assignee."""
|
|
123
|
+
story = self._story_data or {}
|
|
124
|
+
sprint = self._sprint_data or {}
|
|
125
|
+
|
|
126
|
+
# Try sprint data for current story context
|
|
127
|
+
current = sprint.get("sprint", {}).get("currentStory")
|
|
128
|
+
if isinstance(current, str) and current:
|
|
129
|
+
story_id = current
|
|
130
|
+
else:
|
|
131
|
+
story_id = story.get("id", "")
|
|
132
|
+
|
|
133
|
+
title = story.get("title", "")
|
|
134
|
+
points = story.get("points", "")
|
|
135
|
+
epic = story.get("epic", "")
|
|
136
|
+
assignee = story.get("assignee", "")
|
|
137
|
+
|
|
138
|
+
# Also try to extract from sprint epics
|
|
139
|
+
if not title and sprint:
|
|
140
|
+
for ep in sprint.get("epics", []):
|
|
141
|
+
for s in ep.get("stories", []):
|
|
142
|
+
if s.get("id") == story_id:
|
|
143
|
+
title = s.get("title", "")
|
|
144
|
+
points = s.get("points", "")
|
|
145
|
+
epic = ep.get("id", "")
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
if not story_id and not title:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
header = Text()
|
|
152
|
+
if story_id:
|
|
153
|
+
header.append(story_id, style="bold cyan")
|
|
154
|
+
header.append(" ")
|
|
155
|
+
if title:
|
|
156
|
+
header.append(title, style="bold")
|
|
157
|
+
if points:
|
|
158
|
+
header.append(f" {points}pt", style="dim")
|
|
159
|
+
|
|
160
|
+
# Second line: epic + assignee
|
|
161
|
+
meta_parts: list[str] = []
|
|
162
|
+
if epic:
|
|
163
|
+
meta_parts.append(f"Epic {epic}")
|
|
164
|
+
if assignee:
|
|
165
|
+
meta_parts.append(assignee)
|
|
166
|
+
if meta_parts:
|
|
167
|
+
header.append("\n")
|
|
168
|
+
header.append(" \u00b7 ".join(meta_parts), style="dim")
|
|
169
|
+
|
|
170
|
+
return header
|
|
171
|
+
|
|
172
|
+
def _render_workflow(self) -> Text | None:
|
|
173
|
+
"""Render workflow type badge and phase dots."""
|
|
174
|
+
story = self._story_data or {}
|
|
175
|
+
workflow = story.get("workflow", "")
|
|
176
|
+
phases = story.get("workflowPhases", [])
|
|
177
|
+
current_phase = story.get("phase", "")
|
|
178
|
+
|
|
179
|
+
if not phases:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
line = Text()
|
|
183
|
+
|
|
184
|
+
# Workflow type badge
|
|
185
|
+
if workflow:
|
|
186
|
+
line.append(f"[{workflow}]", style="bold")
|
|
187
|
+
line.append(" ")
|
|
188
|
+
|
|
189
|
+
# Phase dots
|
|
190
|
+
for i, phase in enumerate(phases):
|
|
191
|
+
phase_name = phase if isinstance(phase, str) else phase.get("name", "")
|
|
192
|
+
phase_status = ""
|
|
193
|
+
if isinstance(phase, dict):
|
|
194
|
+
phase_status = phase.get("status", "")
|
|
195
|
+
|
|
196
|
+
# Determine phase state
|
|
197
|
+
if phase_status == "done" or (current_phase and phase_name != current_phase and _phase_before(phase_name, current_phase, phases)):
|
|
198
|
+
line.append("\u2713", style="green")
|
|
199
|
+
elif phase_name == current_phase:
|
|
200
|
+
line.append("\u25cf", style="bold yellow")
|
|
201
|
+
else:
|
|
202
|
+
line.append("\u25cb", style="dim")
|
|
203
|
+
|
|
204
|
+
line.append(f" {phase_name}", style="bold" if phase_name == current_phase else "dim")
|
|
205
|
+
|
|
206
|
+
if i < len(phases) - 1:
|
|
207
|
+
line.append(" \u2192 ", style="dim")
|
|
208
|
+
|
|
209
|
+
return line
|
|
210
|
+
|
|
211
|
+
def _render_ac(self) -> Text | None:
|
|
212
|
+
"""Render acceptance criteria progress bar."""
|
|
213
|
+
story = self._story_data or {}
|
|
214
|
+
criteria = story.get("criteria", [])
|
|
215
|
+
if not criteria:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
total = len(criteria)
|
|
219
|
+
done = sum(1 for c in criteria if isinstance(c, dict) and c.get("met"))
|
|
220
|
+
|
|
221
|
+
if total == 0:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
pct = int(done / total * 100)
|
|
225
|
+
line = Text()
|
|
226
|
+
line.append("AC ", style="bold")
|
|
227
|
+
line.append_text(render_progress_bar(pct, width=10))
|
|
228
|
+
line.append(f" {done}/{total}")
|
|
229
|
+
return line
|
|
230
|
+
|
|
231
|
+
def _render_todos(self) -> Text | None:
|
|
232
|
+
"""Render todo progress bar with active task."""
|
|
233
|
+
data = self._todos_data or {}
|
|
234
|
+
todos = data.get("todos", [])
|
|
235
|
+
if not todos:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
total = len(todos)
|
|
239
|
+
done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "done")
|
|
240
|
+
active = None
|
|
241
|
+
for t in todos:
|
|
242
|
+
if isinstance(t, dict) and t.get("status") in ("in-progress", "active", "running"):
|
|
243
|
+
active = t.get("description", t.get("title", ""))
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if total == 0:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
pct = int(done / total * 100)
|
|
250
|
+
line = Text()
|
|
251
|
+
line.append("Todo ", style="bold")
|
|
252
|
+
line.append_text(render_progress_bar(pct, width=10))
|
|
253
|
+
line.append(f" {done}/{total}")
|
|
254
|
+
if active:
|
|
255
|
+
line.append(f" \u25cf {active}", style="yellow")
|
|
256
|
+
return line
|
|
257
|
+
|
|
258
|
+
def _render_git(self) -> Text | None:
|
|
259
|
+
"""Render git summary: branch, dirty counts, ahead/behind."""
|
|
260
|
+
data = self._git_data or {}
|
|
261
|
+
repos = data.get("repos", [])
|
|
262
|
+
if not repos:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
line = Text()
|
|
266
|
+
line.append("Git ", style="bold")
|
|
267
|
+
|
|
268
|
+
repo_parts: list[str] = []
|
|
269
|
+
for repo in repos:
|
|
270
|
+
if not isinstance(repo, dict):
|
|
271
|
+
continue
|
|
272
|
+
branch = repo.get("branch", "")
|
|
273
|
+
dirty_files = repo.get("dirtyFiles", [])
|
|
274
|
+
ahead = repo.get("ahead", 0)
|
|
275
|
+
behind = repo.get("behind", 0)
|
|
276
|
+
|
|
277
|
+
# Count file types
|
|
278
|
+
modified = 0
|
|
279
|
+
untracked = 0
|
|
280
|
+
for f in dirty_files:
|
|
281
|
+
if not isinstance(f, dict):
|
|
282
|
+
continue
|
|
283
|
+
status = f.get("status", " ")
|
|
284
|
+
if status.startswith("?"):
|
|
285
|
+
untracked += 1
|
|
286
|
+
else:
|
|
287
|
+
modified += 1
|
|
288
|
+
|
|
289
|
+
part = Text()
|
|
290
|
+
if branch:
|
|
291
|
+
part.append(branch, style="cyan")
|
|
292
|
+
part.append(f" {modified}M", style="yellow" if modified else "dim")
|
|
293
|
+
part.append(f" {untracked}U", style="dim")
|
|
294
|
+
part.append(f" \u2191{ahead}", style="green" if ahead else "dim")
|
|
295
|
+
part.append(f" \u2193{behind}", style="red" if behind else "dim")
|
|
296
|
+
|
|
297
|
+
line.append_text(part)
|
|
298
|
+
|
|
299
|
+
# Only show first repo on main line, rest on separate lines
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
return line
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _phase_before(phase: str, current: str, phases: list) -> bool:
|
|
306
|
+
"""Check if phase comes before current in the phases list."""
|
|
307
|
+
phase_idx = -1
|
|
308
|
+
current_idx = -1
|
|
309
|
+
for i, p in enumerate(phases):
|
|
310
|
+
name = p if isinstance(p, str) else p.get("name", "")
|
|
311
|
+
if name == phase:
|
|
312
|
+
phase_idx = i
|
|
313
|
+
if name == current:
|
|
314
|
+
current_idx = i
|
|
315
|
+
return phase_idx >= 0 and current_idx >= 0 and phase_idx < current_idx
|
|
@@ -9,10 +9,26 @@ from __future__ import annotations
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from rich.console import Group
|
|
12
|
-
from rich.
|
|
12
|
+
from rich.padding import Padding
|
|
13
13
|
from rich.text import Text
|
|
14
14
|
|
|
15
|
-
from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
|
|
15
|
+
from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel, render_progress_bar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _status_badge(status: str) -> Text:
|
|
19
|
+
"""Convert status string to styled Rich Text badge."""
|
|
20
|
+
s = status.lower().strip() if status else ""
|
|
21
|
+
if s == "done":
|
|
22
|
+
return Text("\u2713 done", style="green")
|
|
23
|
+
if s == "in-progress":
|
|
24
|
+
return Text("\u27f3 in-progress", style="yellow")
|
|
25
|
+
if s == "backlog":
|
|
26
|
+
return Text("\u25ef backlog", style="dim")
|
|
27
|
+
if s == "blocked":
|
|
28
|
+
return Text("! blocked", style="bold red")
|
|
29
|
+
if s == "review":
|
|
30
|
+
return Text("\u25ce review", style="cyan")
|
|
31
|
+
return Text(status or "\u2014", style="dim")
|
|
16
32
|
|
|
17
33
|
|
|
18
34
|
class SprintPanel(BasePanel):
|
|
@@ -26,15 +42,83 @@ class SprintPanel(BasePanel):
|
|
|
26
42
|
panel_name: str = "Sprint"
|
|
27
43
|
icon: str = PANEL_ICONS["sprint"][0]
|
|
28
44
|
|
|
29
|
-
def
|
|
30
|
-
|
|
45
|
+
def __init__(self, client: Any = None, **kwargs: Any) -> None:
|
|
46
|
+
super().__init__(client=client, **kwargs)
|
|
47
|
+
self._selected_epic: int = 0
|
|
48
|
+
self._toggled: dict[str, bool] = {} # epic_id -> user override
|
|
49
|
+
|
|
50
|
+
def next_epic(self) -> None:
|
|
51
|
+
"""Move selection to the next epic."""
|
|
52
|
+
epic_count = self._epic_count()
|
|
53
|
+
if epic_count == 0:
|
|
54
|
+
return
|
|
55
|
+
self._selected_epic = (self._selected_epic + 1) % epic_count
|
|
56
|
+
self._rerender()
|
|
57
|
+
|
|
58
|
+
def prev_epic(self) -> None:
|
|
59
|
+
"""Move selection to the previous epic."""
|
|
60
|
+
epic_count = self._epic_count()
|
|
61
|
+
if epic_count == 0:
|
|
62
|
+
return
|
|
63
|
+
self._selected_epic = (self._selected_epic - 1) % epic_count
|
|
64
|
+
self._rerender()
|
|
65
|
+
|
|
66
|
+
def toggle_epic(self) -> None:
|
|
67
|
+
"""Toggle expand/collapse on the selected epic."""
|
|
68
|
+
if self._last_payload is None:
|
|
69
|
+
return
|
|
70
|
+
epics = self._last_payload.get("epics", [])
|
|
71
|
+
if not epics or self._selected_epic >= len(epics):
|
|
72
|
+
return
|
|
73
|
+
epic_id = epics[self._selected_epic].get("id", "")
|
|
74
|
+
if epic_id:
|
|
75
|
+
self._toggled[epic_id] = not self._is_expanded(epics[self._selected_epic])
|
|
76
|
+
self._rerender()
|
|
77
|
+
|
|
78
|
+
def _rerender(self) -> None:
|
|
79
|
+
if self._last_payload is not None:
|
|
80
|
+
rendered = self.render_panel(self._last_payload)
|
|
81
|
+
try:
|
|
82
|
+
self.update(rendered)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def _epic_count(self) -> int:
|
|
87
|
+
if self._last_payload is None:
|
|
88
|
+
return 0
|
|
89
|
+
return len(self._last_payload.get("epics", []))
|
|
90
|
+
|
|
91
|
+
def _is_expanded(self, epic: dict[str, Any]) -> bool:
|
|
92
|
+
"""Check if an epic should be expanded."""
|
|
93
|
+
epic_id = epic.get("id", "")
|
|
94
|
+
if epic_id in self._toggled:
|
|
95
|
+
return self._toggled[epic_id]
|
|
96
|
+
# Default: expand if has incomplete work
|
|
97
|
+
stories = epic.get("stories", [])
|
|
98
|
+
total_pts = 0
|
|
99
|
+
done_pts = 0
|
|
100
|
+
has_in_progress = False
|
|
101
|
+
for story in stories:
|
|
102
|
+
pts = story.get("points", 0)
|
|
103
|
+
if isinstance(pts, (int, float)):
|
|
104
|
+
total_pts += pts
|
|
105
|
+
status = (story.get("status") or "").lower().strip()
|
|
106
|
+
if status == "done":
|
|
107
|
+
done_pts += pts
|
|
108
|
+
if status == "in-progress":
|
|
109
|
+
has_in_progress = True
|
|
110
|
+
return has_in_progress or done_pts < total_pts
|
|
31
111
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"""
|
|
112
|
+
def render_panel(self, payload: dict[str, Any]) -> Any:
|
|
113
|
+
"""Render sprint data with epic grouping and progress bars."""
|
|
35
114
|
sprint = payload.get("sprint", {})
|
|
36
115
|
metrics = payload.get("metrics", {})
|
|
37
116
|
epics = payload.get("epics", [])
|
|
117
|
+
current_story_id = sprint.get("currentStory", "")
|
|
118
|
+
|
|
119
|
+
# Clamp selection
|
|
120
|
+
if epics and self._selected_epic >= len(epics):
|
|
121
|
+
self._selected_epic = len(epics) - 1
|
|
38
122
|
|
|
39
123
|
# Sprint metrics header
|
|
40
124
|
sprint_num = sprint.get("number", "")
|
|
@@ -51,22 +135,70 @@ class SprintPanel(BasePanel):
|
|
|
51
135
|
f"Velocity: {velocity}"
|
|
52
136
|
)
|
|
53
137
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
story.get("
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
hint = Text.from_markup("[dim]j/k:navigate e:expand/collapse[/dim]")
|
|
139
|
+
parts: list[Any] = [header, hint, Text("")]
|
|
140
|
+
|
|
141
|
+
for i, epic in enumerate(epics):
|
|
142
|
+
epic_id = epic.get("id", "")
|
|
143
|
+
epic_title = epic.get("title", "")
|
|
144
|
+
stories = epic.get("stories", [])
|
|
145
|
+
|
|
146
|
+
# Calculate epic progress
|
|
147
|
+
total_pts = 0
|
|
148
|
+
done_pts = 0
|
|
149
|
+
for story in stories:
|
|
150
|
+
pts = story.get("points", 0)
|
|
151
|
+
if isinstance(pts, (int, float)):
|
|
152
|
+
total_pts += pts
|
|
153
|
+
status = (story.get("status") or "").lower().strip()
|
|
154
|
+
if status == "done":
|
|
155
|
+
done_pts += pts
|
|
156
|
+
|
|
157
|
+
expanded = self._is_expanded(epic)
|
|
158
|
+
selected = i == self._selected_epic
|
|
159
|
+
|
|
160
|
+
# Epic header: selector arrow epic-id progress-bar pts title
|
|
161
|
+
arrow = "▼" if expanded else "▶"
|
|
162
|
+
epic_line = Text(no_wrap=True, overflow="ellipsis")
|
|
163
|
+
if selected:
|
|
164
|
+
epic_line.append("› ", style="bold yellow")
|
|
165
|
+
epic_line.append(f"{arrow} ", style="bold")
|
|
166
|
+
epic_line.append(f"{epic_id}", style="bold cyan")
|
|
167
|
+
epic_line.append(" ")
|
|
168
|
+
|
|
169
|
+
if total_pts > 0:
|
|
170
|
+
pct = int(done_pts / total_pts * 100)
|
|
171
|
+
epic_line.append_text(render_progress_bar(pct, width=10))
|
|
172
|
+
epic_line.append(f" {done_pts}/{total_pts} pts", style="dim")
|
|
173
|
+
else:
|
|
174
|
+
epic_line.append("0 pts", style="dim")
|
|
175
|
+
|
|
176
|
+
epic_line.append(f" {epic_title}", style="bold")
|
|
177
|
+
|
|
178
|
+
parts.append(epic_line)
|
|
179
|
+
|
|
180
|
+
# Show stories if expanded
|
|
181
|
+
if expanded:
|
|
182
|
+
for story in stories:
|
|
183
|
+
story_id = story.get("id", "")
|
|
184
|
+
title = story.get("title", "")
|
|
185
|
+
pts = story.get("points", "")
|
|
186
|
+
jira = story.get("jiraKey") or "—"
|
|
187
|
+
badge = _status_badge(story.get("status", ""))
|
|
188
|
+
|
|
189
|
+
# Fixed-width fields first, title last (truncates)
|
|
190
|
+
story_line = Text(no_wrap=True, overflow="ellipsis")
|
|
191
|
+
story_line.append_text(badge)
|
|
192
|
+
story_line.append(f" {story_id}", style="cyan" if story_id != current_story_id else "bold cyan")
|
|
193
|
+
story_line.append(f" {jira}", style="dim")
|
|
194
|
+
story_line.append(f" {pts}", style="dim")
|
|
195
|
+
story_line.append(f" {title}")
|
|
196
|
+
|
|
197
|
+
if story_id == current_story_id:
|
|
198
|
+
story_line.stylize("bold")
|
|
199
|
+
|
|
200
|
+
parts.append(Padding(story_line, (0, 0, 0, 4)))
|
|
201
|
+
|
|
202
|
+
parts.append(Text("")) # spacer between epics
|
|
203
|
+
|
|
204
|
+
return Group(*parts)
|