@pennyfarthing/core 7.8.1 → 7.8.4
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 +1 -1
- package/package.json +2 -1
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +7 -6
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
- package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.js +25 -0
- package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
- package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
- package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
- package/pennyfarthing-dist/scripts/core/run.sh +5 -5
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
- package/pennyfarthing-dist/scripts/git/release.sh +2 -2
- package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
- package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
- package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
- package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
- package/pennyfarthing_scripts/__init__.py +17 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bellmode_hook.py +154 -0
- package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
- package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/cli.py +131 -0
- package/pennyfarthing_scripts/brownfield/discover.py +753 -0
- package/pennyfarthing_scripts/common/__init__.py +49 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/config.py +65 -0
- package/pennyfarthing_scripts/common/output.py +180 -0
- package/pennyfarthing_scripts/config.py +21 -0
- package/pennyfarthing_scripts/git/__init__.py +29 -0
- 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__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/create_branches.py +439 -0
- package/pennyfarthing_scripts/git/status_all.py +310 -0
- package/pennyfarthing_scripts/hooks.py +455 -0
- package/pennyfarthing_scripts/jira/__init__.py +93 -0
- package/pennyfarthing_scripts/jira/__main__.py +10 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
- package/pennyfarthing_scripts/jira/claim.py +211 -0
- package/pennyfarthing_scripts/jira/cli.py +150 -0
- package/pennyfarthing_scripts/jira/client.py +613 -0
- package/pennyfarthing_scripts/jira/epic.py +176 -0
- package/pennyfarthing_scripts/jira/story.py +219 -0
- package/pennyfarthing_scripts/jira/sync.py +350 -0
- package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
- package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
- package/pennyfarthing_scripts/jira_sync.py +36 -0
- package/pennyfarthing_scripts/jira_sync_story.py +30 -0
- package/pennyfarthing_scripts/output.py +37 -0
- package/pennyfarthing_scripts/preflight/__init__.py +17 -0
- package/pennyfarthing_scripts/preflight/__main__.py +10 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/cli.py +141 -0
- package/pennyfarthing_scripts/preflight/finish.py +382 -0
- package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
- package/pennyfarthing_scripts/prime/__init__.py +38 -0
- package/pennyfarthing_scripts/prime/__main__.py +8 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +220 -0
- package/pennyfarthing_scripts/prime/loader.py +239 -0
- package/pennyfarthing_scripts/sprint/__init__.py +66 -0
- package/pennyfarthing_scripts/sprint/__main__.py +10 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/archive.py +108 -0
- package/pennyfarthing_scripts/sprint/cli.py +124 -0
- package/pennyfarthing_scripts/sprint/loader.py +193 -0
- package/pennyfarthing_scripts/sprint/status.py +122 -0
- package/pennyfarthing_scripts/sprint/validator.py +405 -0
- package/pennyfarthing_scripts/sprint/work.py +192 -0
- package/pennyfarthing_scripts/story/__init__.py +67 -0
- package/pennyfarthing_scripts/story/__main__.py +10 -0
- package/pennyfarthing_scripts/story/cli.py +105 -0
- package/pennyfarthing_scripts/story/create.py +167 -0
- package/pennyfarthing_scripts/story/size.py +113 -0
- package/pennyfarthing_scripts/story/template.py +151 -0
- package/pennyfarthing_scripts/swebench.py +216 -0
- package/pennyfarthing_scripts/tests/__init__.py +1 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/conftest.py +106 -0
- package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
- package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
- package/pennyfarthing_scripts/tests/test_common.py +180 -0
- package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
- package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
- package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
- package/pennyfarthing_scripts/tests/test_prime.py +397 -0
- package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
- package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
- package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
- package/pennyfarthing_scripts/welcome_hook.py +157 -0
- package/pennyfarthing_scripts/workflow.py +183 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint and Story YAML validators for Pennyfarthing.
|
|
3
|
+
|
|
4
|
+
Story: MSSCI-12394 - Sprint and Story YAML validators
|
|
5
|
+
|
|
6
|
+
This module provides validation for:
|
|
7
|
+
- Sprint-level structure and required fields
|
|
8
|
+
- Story-level required fields and values
|
|
9
|
+
- Epic-level validation with story references
|
|
10
|
+
- Archived sprint validation
|
|
11
|
+
|
|
12
|
+
All functions raise NO errors - they return ValidationResult objects
|
|
13
|
+
with success status and error messages.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationSeverity(Enum):
|
|
26
|
+
"""Severity level for validation errors."""
|
|
27
|
+
|
|
28
|
+
ERROR = "error"
|
|
29
|
+
WARNING = "warning"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ValidationError:
|
|
34
|
+
"""A single validation error."""
|
|
35
|
+
|
|
36
|
+
message: str
|
|
37
|
+
path: str # JSON path to the error location (e.g., "epics[0].stories[1].status")
|
|
38
|
+
severity: ValidationSeverity = ValidationSeverity.ERROR
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ValidationResult:
|
|
43
|
+
"""Result of validation with errors and warnings."""
|
|
44
|
+
|
|
45
|
+
valid: bool
|
|
46
|
+
errors: list[ValidationError] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def add_error(
|
|
49
|
+
self, message: str, path: str, severity: ValidationSeverity = ValidationSeverity.ERROR
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Add an error to the result."""
|
|
52
|
+
self.errors.append(ValidationError(message, path, severity))
|
|
53
|
+
if severity == ValidationSeverity.ERROR:
|
|
54
|
+
self.valid = False
|
|
55
|
+
|
|
56
|
+
def merge(self, other: "ValidationResult") -> None:
|
|
57
|
+
"""Merge another result into this one."""
|
|
58
|
+
self.errors.extend(other.errors)
|
|
59
|
+
if not other.valid:
|
|
60
|
+
self.valid = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# Constants
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
VALID_SPRINT_STATUSES = {"active", "closed"}
|
|
68
|
+
VALID_STORY_STATUSES = {"backlog", "ready", "in_progress", "done", "canceled"}
|
|
69
|
+
JIRA_KEY_PATTERN = re.compile(r"^MSSCI-\d{5}$")
|
|
70
|
+
ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
71
|
+
|
|
72
|
+
# Required fields for sprint section
|
|
73
|
+
REQUIRED_SPRINT_FIELDS = {"number", "jira_sprint_id", "goal", "start_date", "end_date", "status"}
|
|
74
|
+
|
|
75
|
+
# Required fields for story
|
|
76
|
+
REQUIRED_STORY_FIELDS = {"id", "title", "status", "points"}
|
|
77
|
+
|
|
78
|
+
# Required fields for epic
|
|
79
|
+
REQUIRED_EPIC_FIELDS = {"id", "title"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# =============================================================================
|
|
83
|
+
# Validation Functions
|
|
84
|
+
# =============================================================================
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_sprint(data: dict[str, Any]) -> ValidationResult:
|
|
88
|
+
"""Validate sprint-level structure and fields.
|
|
89
|
+
|
|
90
|
+
Validates:
|
|
91
|
+
- Required fields present (number, jira_sprint_id, goal, start_date, end_date, status)
|
|
92
|
+
- status is valid value (active, closed)
|
|
93
|
+
- dates are ISO format
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
data: Sprint YAML data (full document)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
ValidationResult with any errors found
|
|
100
|
+
"""
|
|
101
|
+
result = ValidationResult(valid=True)
|
|
102
|
+
|
|
103
|
+
# Check for sprint section
|
|
104
|
+
if "sprint" not in data:
|
|
105
|
+
result.add_error("Missing required 'sprint' section", "sprint")
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
sprint = data["sprint"]
|
|
109
|
+
|
|
110
|
+
# Check required fields
|
|
111
|
+
for field_name in REQUIRED_SPRINT_FIELDS:
|
|
112
|
+
if field_name not in sprint:
|
|
113
|
+
result.add_error(
|
|
114
|
+
f"Missing required field: {field_name}",
|
|
115
|
+
f"sprint.{field_name}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Validate status if present
|
|
119
|
+
if "status" in sprint:
|
|
120
|
+
status = sprint["status"]
|
|
121
|
+
if status not in VALID_SPRINT_STATUSES:
|
|
122
|
+
result.add_error(
|
|
123
|
+
f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_SPRINT_STATUSES))}",
|
|
124
|
+
"sprint.status",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Validate date formats
|
|
128
|
+
for date_field in ["start_date", "end_date"]:
|
|
129
|
+
if date_field in sprint:
|
|
130
|
+
date_val = str(sprint[date_field])
|
|
131
|
+
if not ISO_DATE_PATTERN.match(date_val):
|
|
132
|
+
result.add_error(
|
|
133
|
+
f"Invalid date format for {date_field}: '{date_val}'. Expected YYYY-MM-DD",
|
|
134
|
+
f"sprint.{date_field}",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_story(story: dict[str, Any], epic_id: str, story_index: int = 0) -> ValidationResult:
|
|
141
|
+
"""Validate a single story's structure and fields.
|
|
142
|
+
|
|
143
|
+
Validates:
|
|
144
|
+
- Required fields present (id, title, status, points)
|
|
145
|
+
- status is valid value (backlog, ready, in_progress, done, canceled)
|
|
146
|
+
- points is numeric
|
|
147
|
+
- jira key follows pattern MSSCI-NNNNN if present
|
|
148
|
+
- branch follows convention if present
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
story: Story dict from YAML
|
|
152
|
+
epic_id: Parent epic ID for path generation
|
|
153
|
+
story_index: Index of story in epic's stories array
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
ValidationResult with any errors found
|
|
157
|
+
"""
|
|
158
|
+
result = ValidationResult(valid=True)
|
|
159
|
+
base_path = f"{epic_id}.stories[{story_index}]"
|
|
160
|
+
|
|
161
|
+
# Check required fields
|
|
162
|
+
for field_name in REQUIRED_STORY_FIELDS:
|
|
163
|
+
if field_name not in story:
|
|
164
|
+
result.add_error(
|
|
165
|
+
f"Missing required field: {field_name}",
|
|
166
|
+
f"{base_path}.{field_name}",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Validate status if present
|
|
170
|
+
if "status" in story:
|
|
171
|
+
status = story["status"]
|
|
172
|
+
if status not in VALID_STORY_STATUSES:
|
|
173
|
+
result.add_error(
|
|
174
|
+
f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_STORY_STATUSES))}",
|
|
175
|
+
f"{base_path}.status",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Validate points is numeric
|
|
179
|
+
if "points" in story:
|
|
180
|
+
points = story["points"]
|
|
181
|
+
if not isinstance(points, (int, float)):
|
|
182
|
+
result.add_error(
|
|
183
|
+
f"Invalid points value '{points}'. Must be numeric",
|
|
184
|
+
f"{base_path}.points",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Validate jira key format if present
|
|
188
|
+
if "jira" in story:
|
|
189
|
+
jira_key = str(story["jira"])
|
|
190
|
+
if not JIRA_KEY_PATTERN.match(jira_key):
|
|
191
|
+
result.add_error(
|
|
192
|
+
f"Invalid Jira key format '{jira_key}'. Expected MSSCI-NNNNN",
|
|
193
|
+
f"{base_path}.jira",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def validate_epic(epic: dict[str, Any], all_story_ids: set[str], epic_index: int = 0) -> ValidationResult:
|
|
200
|
+
"""Validate an epic's structure and story references.
|
|
201
|
+
|
|
202
|
+
Validates:
|
|
203
|
+
- Required fields present (id, title)
|
|
204
|
+
- All story IDs are unique within epic
|
|
205
|
+
- No orphaned story references (if stories present)
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
epic: Epic dict from YAML
|
|
209
|
+
all_story_ids: Set of all story IDs in the sprint (for uniqueness check)
|
|
210
|
+
epic_index: Index of epic for path generation
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
ValidationResult with any errors found
|
|
214
|
+
"""
|
|
215
|
+
result = ValidationResult(valid=True)
|
|
216
|
+
base_path = f"epics[{epic_index}]"
|
|
217
|
+
epic_id = epic.get("id", f"epics[{epic_index}]")
|
|
218
|
+
|
|
219
|
+
# Check required fields
|
|
220
|
+
for field_name in REQUIRED_EPIC_FIELDS:
|
|
221
|
+
if field_name not in epic:
|
|
222
|
+
result.add_error(
|
|
223
|
+
f"Missing required field: {field_name}",
|
|
224
|
+
f"{base_path}.{field_name}",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Validate stories if present
|
|
228
|
+
if "stories" in epic:
|
|
229
|
+
seen_in_epic: set[str] = set()
|
|
230
|
+
for idx, story in enumerate(epic["stories"]):
|
|
231
|
+
story_id = story.get("id")
|
|
232
|
+
if story_id:
|
|
233
|
+
# Check for duplicates within this epic
|
|
234
|
+
if story_id in seen_in_epic:
|
|
235
|
+
result.add_error(
|
|
236
|
+
f"Duplicate story ID '{story_id}' within epic",
|
|
237
|
+
f"{base_path}.stories[{idx}].id",
|
|
238
|
+
)
|
|
239
|
+
# Check for duplicates across epics
|
|
240
|
+
elif story_id in all_story_ids:
|
|
241
|
+
result.add_error(
|
|
242
|
+
f"Duplicate story ID '{story_id}' - already exists in another epic",
|
|
243
|
+
f"{base_path}.stories[{idx}].id",
|
|
244
|
+
)
|
|
245
|
+
seen_in_epic.add(story_id)
|
|
246
|
+
|
|
247
|
+
# Validate story structure
|
|
248
|
+
story_result = validate_story(story, epic_id, idx)
|
|
249
|
+
result.merge(story_result)
|
|
250
|
+
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def validate_full_sprint(data: dict[str, Any]) -> ValidationResult:
|
|
255
|
+
"""Validate complete sprint YAML including all epics and stories.
|
|
256
|
+
|
|
257
|
+
Validates:
|
|
258
|
+
- Sprint-level structure
|
|
259
|
+
- All epics and their stories
|
|
260
|
+
- Cross-cutting constraints (no duplicate story IDs)
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
data: Full sprint YAML data
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Combined ValidationResult with all errors
|
|
267
|
+
"""
|
|
268
|
+
result = ValidationResult(valid=True)
|
|
269
|
+
|
|
270
|
+
# Validate sprint section
|
|
271
|
+
sprint_result = validate_sprint(data)
|
|
272
|
+
result.merge(sprint_result)
|
|
273
|
+
|
|
274
|
+
# Validate epics
|
|
275
|
+
if "epics" in data:
|
|
276
|
+
all_story_ids: set[str] = set()
|
|
277
|
+
for idx, epic in enumerate(data["epics"]):
|
|
278
|
+
epic_result = validate_epic(epic, all_story_ids, idx)
|
|
279
|
+
result.merge(epic_result)
|
|
280
|
+
|
|
281
|
+
# Collect story IDs for cross-epic duplicate detection
|
|
282
|
+
if "stories" in epic:
|
|
283
|
+
for story in epic["stories"]:
|
|
284
|
+
story_id = story.get("id")
|
|
285
|
+
if story_id:
|
|
286
|
+
all_story_ids.add(story_id)
|
|
287
|
+
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def validate_archived_sprint(data: dict[str, Any]) -> ValidationResult:
|
|
292
|
+
"""Validate an archived sprint file.
|
|
293
|
+
|
|
294
|
+
Archived sprints have the same structure as current sprints
|
|
295
|
+
but allow done/canceled status for all stories.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
data: Archived sprint YAML data
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
ValidationResult with any errors found
|
|
302
|
+
"""
|
|
303
|
+
# For archived sprints, use the same validation as full sprint
|
|
304
|
+
# The key difference is that all story statuses are valid
|
|
305
|
+
# (done/canceled are expected in archived sprints)
|
|
306
|
+
return validate_full_sprint(data)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def validate_sprint_file(file_path: Path) -> ValidationResult:
|
|
310
|
+
"""Validate a sprint YAML file from disk.
|
|
311
|
+
|
|
312
|
+
Loads the file and validates its contents.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
file_path: Path to sprint YAML file
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
ValidationResult with any errors (including load errors)
|
|
319
|
+
"""
|
|
320
|
+
result = ValidationResult(valid=True)
|
|
321
|
+
|
|
322
|
+
# Check file exists
|
|
323
|
+
if not file_path.exists():
|
|
324
|
+
result.add_error(
|
|
325
|
+
f"File not found: {file_path}",
|
|
326
|
+
str(file_path),
|
|
327
|
+
)
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
# Try to load YAML
|
|
331
|
+
try:
|
|
332
|
+
with open(file_path) as f:
|
|
333
|
+
data = yaml.safe_load(f)
|
|
334
|
+
except yaml.YAMLError as e:
|
|
335
|
+
result.add_error(
|
|
336
|
+
f"Failed to parse YAML: {e}",
|
|
337
|
+
str(file_path),
|
|
338
|
+
)
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
# Validate loaded data
|
|
342
|
+
return validate_full_sprint(data)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def format_validation_errors(result: ValidationResult) -> str:
|
|
346
|
+
"""Format validation errors for human-readable output.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
result: ValidationResult to format
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Multi-line string with formatted errors
|
|
353
|
+
"""
|
|
354
|
+
if not result.errors:
|
|
355
|
+
return "No validation errors"
|
|
356
|
+
|
|
357
|
+
lines = []
|
|
358
|
+
for error in result.errors:
|
|
359
|
+
severity_label = error.severity.value.upper()
|
|
360
|
+
lines.append(f"[{severity_label}] {error.path}: {error.message}")
|
|
361
|
+
|
|
362
|
+
return "\n".join(lines)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# =============================================================================
|
|
366
|
+
# CLI Entry Point
|
|
367
|
+
# =============================================================================
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main() -> int:
|
|
371
|
+
"""CLI entry point for sprint validation.
|
|
372
|
+
|
|
373
|
+
Usage:
|
|
374
|
+
python -m pennyfarthing_scripts.sprint.validator [file_path]
|
|
375
|
+
|
|
376
|
+
If no file_path is provided, validates sprint/current-sprint.yaml.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
0 if valid, 1 if invalid
|
|
380
|
+
"""
|
|
381
|
+
import sys
|
|
382
|
+
|
|
383
|
+
from pennyfarthing_scripts.common.config import get_project_root
|
|
384
|
+
|
|
385
|
+
# Determine file to validate
|
|
386
|
+
if len(sys.argv) > 1:
|
|
387
|
+
file_path = Path(sys.argv[1])
|
|
388
|
+
else:
|
|
389
|
+
file_path = get_project_root() / "sprint" / "current-sprint.yaml"
|
|
390
|
+
|
|
391
|
+
print(f"Validating: {file_path}")
|
|
392
|
+
result = validate_sprint_file(file_path)
|
|
393
|
+
|
|
394
|
+
if result.valid:
|
|
395
|
+
print("✓ Sprint YAML is valid")
|
|
396
|
+
return 0
|
|
397
|
+
else:
|
|
398
|
+
print(f"✗ Found {len(result.errors)} validation error(s):\n")
|
|
399
|
+
print(format_validation_errors(result))
|
|
400
|
+
return 1
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
if __name__ == "__main__":
|
|
404
|
+
import sys
|
|
405
|
+
sys.exit(main())
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint work session management.
|
|
3
|
+
|
|
4
|
+
Provides functions for starting and managing work on stories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pennyfarthing_scripts.sprint.loader import (
|
|
10
|
+
find_epic,
|
|
11
|
+
find_story,
|
|
12
|
+
get_stories_by_status,
|
|
13
|
+
get_story_by_id,
|
|
14
|
+
load_sprint,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_story(story_id: str) -> dict[str, Any]:
|
|
19
|
+
"""Check if a story is available for work.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
story_id: Story ID or Jira key
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Dict with availability status and story details
|
|
26
|
+
"""
|
|
27
|
+
story = get_story_by_id(story_id)
|
|
28
|
+
|
|
29
|
+
if not story:
|
|
30
|
+
return {
|
|
31
|
+
"available": False,
|
|
32
|
+
"error": f"Story '{story_id}' not found",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
status = story.get("status", "backlog")
|
|
36
|
+
assigned = story.get("assigned_to")
|
|
37
|
+
|
|
38
|
+
# Check if already in progress
|
|
39
|
+
if status == "in_progress":
|
|
40
|
+
return {
|
|
41
|
+
"available": False,
|
|
42
|
+
"type": "story",
|
|
43
|
+
"story": story,
|
|
44
|
+
"reason": "Already in progress",
|
|
45
|
+
"assigned_to": assigned,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Check if done
|
|
49
|
+
if status in ("done", "completed"):
|
|
50
|
+
return {
|
|
51
|
+
"available": False,
|
|
52
|
+
"type": "story",
|
|
53
|
+
"story": story,
|
|
54
|
+
"reason": "Already completed",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"available": True,
|
|
59
|
+
"type": "story",
|
|
60
|
+
"story": story,
|
|
61
|
+
"title": story.get("title"),
|
|
62
|
+
"points": story.get("points"),
|
|
63
|
+
"workflow": story.get("workflow", "tdd"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_next_story() -> dict[str, Any]:
|
|
68
|
+
"""Get the highest priority available story.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Dict with next story details or error
|
|
72
|
+
"""
|
|
73
|
+
backlog = get_stories_by_status("backlog")
|
|
74
|
+
|
|
75
|
+
if not backlog:
|
|
76
|
+
return {
|
|
77
|
+
"available": False,
|
|
78
|
+
"error": "No stories in backlog",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Sort by priority (P0 > P1 > P2 > P3)
|
|
82
|
+
priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
83
|
+
sorted_stories = sorted(
|
|
84
|
+
backlog,
|
|
85
|
+
key=lambda s: priority_order.get(s.get("priority", "P2"), 2),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
next_story = sorted_stories[0]
|
|
89
|
+
return {
|
|
90
|
+
"available": True,
|
|
91
|
+
"type": "next",
|
|
92
|
+
"story": next_story,
|
|
93
|
+
"title": next_story.get("title"),
|
|
94
|
+
"points": next_story.get("points"),
|
|
95
|
+
"priority": next_story.get("priority", "P2"),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def start_work(story_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
|
100
|
+
"""Start work on a story.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
story_id: Story ID to start
|
|
104
|
+
dry_run: If True, don't make changes
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Dict with success status and details
|
|
108
|
+
"""
|
|
109
|
+
# Check availability
|
|
110
|
+
check = check_story(story_id)
|
|
111
|
+
if not check.get("available"):
|
|
112
|
+
return {
|
|
113
|
+
"success": False,
|
|
114
|
+
"error": check.get("reason") or check.get("error"),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
story = check.get("story")
|
|
118
|
+
|
|
119
|
+
if dry_run:
|
|
120
|
+
return {
|
|
121
|
+
"success": True,
|
|
122
|
+
"dry_run": True,
|
|
123
|
+
"story": story,
|
|
124
|
+
"message": f"Would start work on {story_id}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# In a real implementation, this would:
|
|
128
|
+
# 1. Create/update session file
|
|
129
|
+
# 2. Claim in Jira
|
|
130
|
+
# 3. Create branch
|
|
131
|
+
# For now, return success with story info
|
|
132
|
+
return {
|
|
133
|
+
"success": True,
|
|
134
|
+
"story": story,
|
|
135
|
+
"message": f"Ready to start work on {story_id}",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(args: list[str] | None = None) -> int:
|
|
140
|
+
"""CLI entry point for sprint work.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
args: Command line arguments
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Exit code
|
|
147
|
+
"""
|
|
148
|
+
import argparse
|
|
149
|
+
import sys
|
|
150
|
+
|
|
151
|
+
parser = argparse.ArgumentParser(description="Start work on a story")
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"story_id",
|
|
154
|
+
nargs="?",
|
|
155
|
+
help="Story ID (or 'next' for highest priority)",
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--dry-run",
|
|
159
|
+
action="store_true",
|
|
160
|
+
help="Show what would be done",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
parsed = parser.parse_args(args)
|
|
164
|
+
|
|
165
|
+
if not parsed.story_id:
|
|
166
|
+
# Show backlog
|
|
167
|
+
backlog = get_stories_by_status("backlog")
|
|
168
|
+
print(f"Available stories: {len(backlog)}")
|
|
169
|
+
for story in backlog[:10]:
|
|
170
|
+
print(f" {story.get('id')}: {story.get('title')} [{story.get('points', '?')}pts]")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if parsed.story_id == "next":
|
|
174
|
+
result = get_next_story()
|
|
175
|
+
else:
|
|
176
|
+
result = check_story(parsed.story_id)
|
|
177
|
+
|
|
178
|
+
if result.get("available"):
|
|
179
|
+
story = result.get("story", {})
|
|
180
|
+
print(f"Story: {story.get('id')}")
|
|
181
|
+
print(f"Title: {story.get('title')}")
|
|
182
|
+
print(f"Points: {story.get('points')}")
|
|
183
|
+
print(f"Status: Available")
|
|
184
|
+
return 0
|
|
185
|
+
else:
|
|
186
|
+
print(f"Not available: {result.get('error') or result.get('reason')}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
import sys
|
|
192
|
+
sys.exit(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Story management package for Pennyfarthing scripts.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- size: Story sizing guidelines
|
|
6
|
+
- template: Story templates
|
|
7
|
+
- create: Story creation
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# Use the modules
|
|
11
|
+
from pennyfarthing_scripts.story import get_sizing_guidelines, get_template
|
|
12
|
+
|
|
13
|
+
# Use CLI
|
|
14
|
+
python -m pennyfarthing_scripts.story <subcommand> [args]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Re-export common functions
|
|
18
|
+
from pennyfarthing_scripts.story.size import (
|
|
19
|
+
SIZING_GUIDELINES,
|
|
20
|
+
format_size_info,
|
|
21
|
+
get_sizing_guidelines,
|
|
22
|
+
)
|
|
23
|
+
from pennyfarthing_scripts.story.template import (
|
|
24
|
+
TEMPLATES,
|
|
25
|
+
format_template,
|
|
26
|
+
get_all_templates,
|
|
27
|
+
get_template,
|
|
28
|
+
)
|
|
29
|
+
from pennyfarthing_scripts.story.create import (
|
|
30
|
+
create_story,
|
|
31
|
+
generate_story_yaml,
|
|
32
|
+
validate_points,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Import submodules
|
|
36
|
+
from pennyfarthing_scripts.story import (
|
|
37
|
+
create,
|
|
38
|
+
size,
|
|
39
|
+
template,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# CLI entry point - import module, not function, so "from story import cli" gets the module
|
|
43
|
+
from pennyfarthing_scripts.story import cli
|
|
44
|
+
from pennyfarthing_scripts.story.cli import main
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Size
|
|
48
|
+
"SIZING_GUIDELINES",
|
|
49
|
+
"format_size_info",
|
|
50
|
+
"get_sizing_guidelines",
|
|
51
|
+
# Template
|
|
52
|
+
"TEMPLATES",
|
|
53
|
+
"format_template",
|
|
54
|
+
"get_all_templates",
|
|
55
|
+
"get_template",
|
|
56
|
+
# Create
|
|
57
|
+
"create_story",
|
|
58
|
+
"generate_story_yaml",
|
|
59
|
+
"validate_points",
|
|
60
|
+
# Submodules
|
|
61
|
+
"create",
|
|
62
|
+
"size",
|
|
63
|
+
"template",
|
|
64
|
+
# CLI
|
|
65
|
+
"cli",
|
|
66
|
+
"main",
|
|
67
|
+
]
|