elspais 0.11.2__py3-none-any.whl → 0.43.5__py3-none-any.whl
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.
- elspais/__init__.py +1 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,1200 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Review Data Models for trace_view
|
|
4
|
-
|
|
5
|
-
Data classes for the review system including:
|
|
6
|
-
- ReviewFlag: Mark REQs for review
|
|
7
|
-
- CommentPosition: Position-aware comment anchoring
|
|
8
|
-
- Comment: Individual comments
|
|
9
|
-
- Thread: Comment threads with position and package ownership
|
|
10
|
-
- StatusRequest: Status change requests with approvals
|
|
11
|
-
- Approval: Individual approvals
|
|
12
|
-
- ReviewSession: Review session metadata
|
|
13
|
-
- ReviewConfig: System configuration
|
|
14
|
-
- ReviewPackage: Named collections of REQs under review with audit trail
|
|
15
|
-
|
|
16
|
-
IMPLEMENTS REQUIREMENTS:
|
|
17
|
-
REQ-tv-d00010: Review Data Models
|
|
18
|
-
REQ-d00094: TraceView Review System Core
|
|
19
|
-
REQ-d00095: Review Package Management
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
import re
|
|
23
|
-
import uuid
|
|
24
|
-
from dataclasses import asdict, dataclass, field
|
|
25
|
-
from datetime import datetime, timezone
|
|
26
|
-
from enum import Enum
|
|
27
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
28
|
-
|
|
29
|
-
# =============================================================================
|
|
30
|
-
# Enums and Constants
|
|
31
|
-
# REQ-tv-d00010-B: String enums for JSON compatibility
|
|
32
|
-
# =============================================================================
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class PositionType(str, Enum):
|
|
36
|
-
"""Type of comment position anchor"""
|
|
37
|
-
|
|
38
|
-
LINE = "line"
|
|
39
|
-
BLOCK = "block"
|
|
40
|
-
WORD = "word"
|
|
41
|
-
GENERAL = "general"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class RequestState(str, Enum):
|
|
45
|
-
"""State of a status change request"""
|
|
46
|
-
|
|
47
|
-
PENDING = "pending"
|
|
48
|
-
APPROVED = "approved"
|
|
49
|
-
REJECTED = "rejected"
|
|
50
|
-
APPLIED = "applied"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class ApprovalDecision(str, Enum):
|
|
54
|
-
"""Approval decision type"""
|
|
55
|
-
|
|
56
|
-
APPROVE = "approve"
|
|
57
|
-
REJECT = "reject"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Valid REQ status values
|
|
61
|
-
VALID_REQ_STATUSES = {"Draft", "Active", "Deprecated"}
|
|
62
|
-
|
|
63
|
-
# Default approval rules for status transitions
|
|
64
|
-
DEFAULT_APPROVAL_RULES: Dict[str, List[str]] = {
|
|
65
|
-
"Draft->Active": ["product_owner", "tech_lead"],
|
|
66
|
-
"Active->Deprecated": ["product_owner"],
|
|
67
|
-
"Draft->Deprecated": ["product_owner"],
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# =============================================================================
|
|
72
|
-
# Utility Functions
|
|
73
|
-
# =============================================================================
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def generate_uuid() -> str:
|
|
77
|
-
"""Generate a new UUID string"""
|
|
78
|
-
return str(uuid.uuid4())
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def now_iso() -> str:
|
|
82
|
-
"""
|
|
83
|
-
Get current UTC timestamp in ISO 8601 format.
|
|
84
|
-
|
|
85
|
-
REQ-tv-d00010-J: All timestamps SHALL be UTC in ISO 8601 format.
|
|
86
|
-
"""
|
|
87
|
-
return datetime.now(timezone.utc).isoformat()
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def parse_iso_datetime(iso_str: str) -> datetime:
|
|
91
|
-
"""Parse ISO 8601 datetime string to datetime object"""
|
|
92
|
-
# Handle both with and without timezone, and Z suffix
|
|
93
|
-
if iso_str.endswith("Z"):
|
|
94
|
-
iso_str = iso_str[:-1] + "+00:00"
|
|
95
|
-
return datetime.fromisoformat(iso_str)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def validate_req_id(req_id: str) -> bool:
|
|
99
|
-
"""
|
|
100
|
-
Validate requirement ID format.
|
|
101
|
-
|
|
102
|
-
Valid formats:
|
|
103
|
-
- d00001, p00042, o00003 (core REQs)
|
|
104
|
-
- CAL-d00001 (sponsor-specific REQs)
|
|
105
|
-
|
|
106
|
-
Does NOT accept REQ- prefix.
|
|
107
|
-
"""
|
|
108
|
-
if not req_id:
|
|
109
|
-
return False
|
|
110
|
-
# Negative lookahead to reject REQ- prefix; only sponsor prefixes allowed
|
|
111
|
-
pattern = r"^(?!REQ-)(?:[A-Z]{2,4}-)?[pod]\d{5}$"
|
|
112
|
-
return bool(re.match(pattern, req_id))
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def validate_hash(hash_value: str) -> bool:
|
|
116
|
-
"""Validate 8-character hex hash format"""
|
|
117
|
-
if not hash_value:
|
|
118
|
-
return False
|
|
119
|
-
return bool(re.match(r"^[a-fA-F0-9]{8}$", hash_value))
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
# =============================================================================
|
|
123
|
-
# Data Classes
|
|
124
|
-
# REQ-tv-d00010-A: All types implemented as dataclasses
|
|
125
|
-
# REQ-tv-d00010-C: Each dataclass implements to_dict()
|
|
126
|
-
# REQ-tv-d00010-D: Each dataclass implements from_dict()
|
|
127
|
-
# REQ-tv-d00010-E: Each dataclass implements validate()
|
|
128
|
-
# =============================================================================
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
@dataclass
|
|
132
|
-
class CommentPosition:
|
|
133
|
-
"""
|
|
134
|
-
Position anchor for a comment within a requirement.
|
|
135
|
-
|
|
136
|
-
REQ-tv-d00010-H: Supports four anchor types: LINE, BLOCK, WORD, GENERAL.
|
|
137
|
-
|
|
138
|
-
Supports multiple anchor types:
|
|
139
|
-
- "line": Specific line number
|
|
140
|
-
- "block": Range of lines (e.g., entire section)
|
|
141
|
-
- "word": Specific keyword occurrence
|
|
142
|
-
- "general": No specific position (applies to whole REQ)
|
|
143
|
-
|
|
144
|
-
The hashWhenCreated allows detection of content drift.
|
|
145
|
-
"""
|
|
146
|
-
|
|
147
|
-
type: str # PositionType value as string for JSON compatibility
|
|
148
|
-
hashWhenCreated: str # 8-char REQ hash when comment was created
|
|
149
|
-
lineNumber: Optional[int] = None
|
|
150
|
-
lineRange: Optional[Tuple[int, int]] = None
|
|
151
|
-
keyword: Optional[str] = None
|
|
152
|
-
keywordOccurrence: Optional[int] = None # 1-based occurrence index
|
|
153
|
-
fallbackContext: Optional[str] = None # Snippet for finding position on hash mismatch
|
|
154
|
-
|
|
155
|
-
def __post_init__(self):
|
|
156
|
-
"""Validate position type matches provided fields"""
|
|
157
|
-
if isinstance(self.type, PositionType):
|
|
158
|
-
self.type = self.type.value
|
|
159
|
-
|
|
160
|
-
@classmethod
|
|
161
|
-
def create_line(
|
|
162
|
-
cls, hash_value: str, line_number: int, context: Optional[str] = None
|
|
163
|
-
) -> "CommentPosition":
|
|
164
|
-
"""Factory for line-anchored position"""
|
|
165
|
-
return cls(
|
|
166
|
-
type=PositionType.LINE.value,
|
|
167
|
-
hashWhenCreated=hash_value,
|
|
168
|
-
lineNumber=line_number,
|
|
169
|
-
fallbackContext=context,
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
@classmethod
|
|
173
|
-
def create_block(
|
|
174
|
-
cls, hash_value: str, start_line: int, end_line: int, context: Optional[str] = None
|
|
175
|
-
) -> "CommentPosition":
|
|
176
|
-
"""Factory for block-anchored position"""
|
|
177
|
-
return cls(
|
|
178
|
-
type=PositionType.BLOCK.value,
|
|
179
|
-
hashWhenCreated=hash_value,
|
|
180
|
-
lineRange=(start_line, end_line),
|
|
181
|
-
fallbackContext=context,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
@classmethod
|
|
185
|
-
def create_word(
|
|
186
|
-
cls, hash_value: str, keyword: str, occurrence: int = 1, context: Optional[str] = None
|
|
187
|
-
) -> "CommentPosition":
|
|
188
|
-
"""Factory for word-anchored position"""
|
|
189
|
-
return cls(
|
|
190
|
-
type=PositionType.WORD.value,
|
|
191
|
-
hashWhenCreated=hash_value,
|
|
192
|
-
keyword=keyword,
|
|
193
|
-
keywordOccurrence=occurrence,
|
|
194
|
-
fallbackContext=context,
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
@classmethod
|
|
198
|
-
def create_general(cls, hash_value: str) -> "CommentPosition":
|
|
199
|
-
"""Factory for general (whole REQ) position"""
|
|
200
|
-
return cls(type=PositionType.GENERAL.value, hashWhenCreated=hash_value)
|
|
201
|
-
|
|
202
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
203
|
-
"""
|
|
204
|
-
Validate position fields based on type.
|
|
205
|
-
|
|
206
|
-
REQ-tv-d00010-E: Returns (is_valid, list_of_error_messages)
|
|
207
|
-
"""
|
|
208
|
-
errors = []
|
|
209
|
-
|
|
210
|
-
if self.type not in [pt.value for pt in PositionType]:
|
|
211
|
-
errors.append(f"Invalid position type: {self.type}")
|
|
212
|
-
return False, errors
|
|
213
|
-
|
|
214
|
-
if not validate_hash(self.hashWhenCreated):
|
|
215
|
-
errors.append(f"Invalid hash format: {self.hashWhenCreated}")
|
|
216
|
-
|
|
217
|
-
if self.type == PositionType.LINE.value:
|
|
218
|
-
if self.lineNumber is None:
|
|
219
|
-
errors.append("lineNumber required for 'line' type")
|
|
220
|
-
elif self.lineNumber < 1:
|
|
221
|
-
errors.append("lineNumber must be positive")
|
|
222
|
-
|
|
223
|
-
elif self.type == PositionType.BLOCK.value:
|
|
224
|
-
if self.lineRange is None:
|
|
225
|
-
errors.append("lineRange required for 'block' type")
|
|
226
|
-
elif len(self.lineRange) != 2:
|
|
227
|
-
errors.append("lineRange must be tuple of (start, end)")
|
|
228
|
-
elif self.lineRange[0] < 1 or self.lineRange[1] < self.lineRange[0]:
|
|
229
|
-
errors.append("Invalid lineRange: start must be >= 1 and end >= start")
|
|
230
|
-
|
|
231
|
-
elif self.type == PositionType.WORD.value:
|
|
232
|
-
if not self.keyword:
|
|
233
|
-
errors.append("keyword required for 'word' type")
|
|
234
|
-
if self.keywordOccurrence is not None and self.keywordOccurrence < 1:
|
|
235
|
-
errors.append("keywordOccurrence must be positive")
|
|
236
|
-
|
|
237
|
-
return len(errors) == 0, errors
|
|
238
|
-
|
|
239
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
240
|
-
"""
|
|
241
|
-
Convert to JSON-serializable dictionary.
|
|
242
|
-
|
|
243
|
-
REQ-tv-d00010-C: Returns JSON-serializable dict.
|
|
244
|
-
"""
|
|
245
|
-
result: Dict[str, Any] = {"type": self.type, "hashWhenCreated": self.hashWhenCreated}
|
|
246
|
-
if self.lineNumber is not None:
|
|
247
|
-
result["lineNumber"] = self.lineNumber
|
|
248
|
-
if self.lineRange is not None:
|
|
249
|
-
result["lineRange"] = list(self.lineRange) # Tuple to list for JSON
|
|
250
|
-
if self.keyword is not None:
|
|
251
|
-
result["keyword"] = self.keyword
|
|
252
|
-
if self.keywordOccurrence is not None:
|
|
253
|
-
result["keywordOccurrence"] = self.keywordOccurrence
|
|
254
|
-
if self.fallbackContext is not None:
|
|
255
|
-
result["fallbackContext"] = self.fallbackContext
|
|
256
|
-
return result
|
|
257
|
-
|
|
258
|
-
@classmethod
|
|
259
|
-
def from_dict(cls, data: Dict[str, Any]) -> "CommentPosition":
|
|
260
|
-
"""
|
|
261
|
-
Create from dictionary (JSON deserialization).
|
|
262
|
-
|
|
263
|
-
REQ-tv-d00010-D: Deserializes from dictionaries.
|
|
264
|
-
"""
|
|
265
|
-
line_range = data.get("lineRange")
|
|
266
|
-
if line_range is not None:
|
|
267
|
-
line_range = tuple(line_range) # List to tuple
|
|
268
|
-
return cls(
|
|
269
|
-
type=data["type"],
|
|
270
|
-
hashWhenCreated=data["hashWhenCreated"],
|
|
271
|
-
lineNumber=data.get("lineNumber"),
|
|
272
|
-
lineRange=line_range,
|
|
273
|
-
keyword=data.get("keyword"),
|
|
274
|
-
keywordOccurrence=data.get("keywordOccurrence"),
|
|
275
|
-
fallbackContext=data.get("fallbackContext"),
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
@dataclass
|
|
280
|
-
class Comment:
|
|
281
|
-
"""
|
|
282
|
-
Single comment in a thread.
|
|
283
|
-
|
|
284
|
-
Comments are immutable once created - edits create new comments
|
|
285
|
-
with references to the original.
|
|
286
|
-
"""
|
|
287
|
-
|
|
288
|
-
id: str # UUID
|
|
289
|
-
author: str # Username
|
|
290
|
-
timestamp: str # ISO 8601 datetime
|
|
291
|
-
body: str # Markdown content
|
|
292
|
-
|
|
293
|
-
@classmethod
|
|
294
|
-
def create(cls, author: str, body: str) -> "Comment":
|
|
295
|
-
"""
|
|
296
|
-
Factory for creating new comment with auto-generated fields.
|
|
297
|
-
|
|
298
|
-
REQ-tv-d00010-F: Auto-generates IDs and timestamps.
|
|
299
|
-
REQ-tv-d00010-J: Uses UTC timestamps.
|
|
300
|
-
"""
|
|
301
|
-
return cls(id=generate_uuid(), author=author, timestamp=now_iso(), body=body)
|
|
302
|
-
|
|
303
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
304
|
-
"""Validate comment fields"""
|
|
305
|
-
errors = []
|
|
306
|
-
|
|
307
|
-
if not self.id:
|
|
308
|
-
errors.append("Comment id is required")
|
|
309
|
-
if not self.author:
|
|
310
|
-
errors.append("Comment author is required")
|
|
311
|
-
if not self.timestamp:
|
|
312
|
-
errors.append("Comment timestamp is required")
|
|
313
|
-
if not self.body or not self.body.strip():
|
|
314
|
-
errors.append("Comment body cannot be empty")
|
|
315
|
-
|
|
316
|
-
# Validate timestamp format
|
|
317
|
-
if self.timestamp:
|
|
318
|
-
try:
|
|
319
|
-
parse_iso_datetime(self.timestamp)
|
|
320
|
-
except ValueError:
|
|
321
|
-
errors.append(f"Invalid timestamp format: {self.timestamp}")
|
|
322
|
-
|
|
323
|
-
return len(errors) == 0, errors
|
|
324
|
-
|
|
325
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
326
|
-
"""Convert to JSON-serializable dictionary"""
|
|
327
|
-
return asdict(self)
|
|
328
|
-
|
|
329
|
-
@classmethod
|
|
330
|
-
def from_dict(cls, data: Dict[str, Any]) -> "Comment":
|
|
331
|
-
"""Create from dictionary"""
|
|
332
|
-
return cls(
|
|
333
|
-
id=data["id"], author=data["author"], timestamp=data["timestamp"], body=data["body"]
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
@dataclass
|
|
338
|
-
class Thread:
|
|
339
|
-
"""
|
|
340
|
-
Comment thread with position anchor.
|
|
341
|
-
|
|
342
|
-
A thread is a collection of comments about a specific location
|
|
343
|
-
in a requirement. Threads can be resolved.
|
|
344
|
-
|
|
345
|
-
REQ-d00094-A: Thread model with packageId for package ownership.
|
|
346
|
-
"""
|
|
347
|
-
|
|
348
|
-
threadId: str # UUID
|
|
349
|
-
reqId: str # Requirement ID (e.g., "d00027")
|
|
350
|
-
createdBy: str # Username who started thread
|
|
351
|
-
createdAt: str # ISO 8601 datetime
|
|
352
|
-
position: CommentPosition
|
|
353
|
-
resolved: bool = False
|
|
354
|
-
resolvedBy: Optional[str] = None
|
|
355
|
-
resolvedAt: Optional[str] = None
|
|
356
|
-
comments: List[Comment] = field(default_factory=list)
|
|
357
|
-
# REQ-d00094-A: Package ownership (optional for backward compatibility)
|
|
358
|
-
packageId: Optional[str] = None
|
|
359
|
-
|
|
360
|
-
@classmethod
|
|
361
|
-
def create(
|
|
362
|
-
cls,
|
|
363
|
-
req_id: str,
|
|
364
|
-
creator: str,
|
|
365
|
-
position: CommentPosition,
|
|
366
|
-
initial_comment: Optional[str] = None,
|
|
367
|
-
package_id: Optional[str] = None,
|
|
368
|
-
) -> "Thread":
|
|
369
|
-
"""
|
|
370
|
-
Factory for creating new thread.
|
|
371
|
-
|
|
372
|
-
REQ-tv-d00010-F: Auto-generates IDs and timestamps.
|
|
373
|
-
REQ-d00094-A: Supports package ownership.
|
|
374
|
-
|
|
375
|
-
Args:
|
|
376
|
-
req_id: Requirement ID this thread is about
|
|
377
|
-
creator: Username of thread creator
|
|
378
|
-
position: Position anchor for the thread
|
|
379
|
-
initial_comment: Optional first comment body
|
|
380
|
-
package_id: Optional package ID that owns this thread
|
|
381
|
-
"""
|
|
382
|
-
thread = cls(
|
|
383
|
-
threadId=generate_uuid(),
|
|
384
|
-
reqId=req_id,
|
|
385
|
-
createdBy=creator,
|
|
386
|
-
createdAt=now_iso(),
|
|
387
|
-
position=position,
|
|
388
|
-
packageId=package_id,
|
|
389
|
-
)
|
|
390
|
-
if initial_comment:
|
|
391
|
-
thread.add_comment(creator, initial_comment)
|
|
392
|
-
return thread
|
|
393
|
-
|
|
394
|
-
def add_comment(self, author: str, body: str) -> Comment:
|
|
395
|
-
"""Add a new comment to the thread"""
|
|
396
|
-
comment = Comment.create(author, body)
|
|
397
|
-
self.comments.append(comment)
|
|
398
|
-
return comment
|
|
399
|
-
|
|
400
|
-
def resolve(self, user: str) -> None:
|
|
401
|
-
"""Mark thread as resolved"""
|
|
402
|
-
self.resolved = True
|
|
403
|
-
self.resolvedBy = user
|
|
404
|
-
self.resolvedAt = now_iso()
|
|
405
|
-
|
|
406
|
-
def unresolve(self) -> None:
|
|
407
|
-
"""Mark thread as unresolved"""
|
|
408
|
-
self.resolved = False
|
|
409
|
-
self.resolvedBy = None
|
|
410
|
-
self.resolvedAt = None
|
|
411
|
-
|
|
412
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
413
|
-
"""Validate thread and all comments"""
|
|
414
|
-
errors = []
|
|
415
|
-
|
|
416
|
-
if not self.threadId:
|
|
417
|
-
errors.append("Thread threadId is required")
|
|
418
|
-
if not validate_req_id(self.reqId):
|
|
419
|
-
errors.append(f"Invalid requirement ID: {self.reqId}")
|
|
420
|
-
if not self.createdBy:
|
|
421
|
-
errors.append("Thread createdBy is required")
|
|
422
|
-
|
|
423
|
-
# Validate position
|
|
424
|
-
pos_valid, pos_errors = self.position.validate()
|
|
425
|
-
errors.extend([f"Position: {e}" for e in pos_errors])
|
|
426
|
-
|
|
427
|
-
# Validate resolution state
|
|
428
|
-
if self.resolved:
|
|
429
|
-
if not self.resolvedBy:
|
|
430
|
-
errors.append("Resolved thread must have resolvedBy")
|
|
431
|
-
if not self.resolvedAt:
|
|
432
|
-
errors.append("Resolved thread must have resolvedAt")
|
|
433
|
-
|
|
434
|
-
# Validate comments
|
|
435
|
-
for i, comment in enumerate(self.comments):
|
|
436
|
-
comment_valid, comment_errors = comment.validate()
|
|
437
|
-
errors.extend([f"Comment[{i}]: {e}" for e in comment_errors])
|
|
438
|
-
|
|
439
|
-
return len(errors) == 0, errors
|
|
440
|
-
|
|
441
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
442
|
-
"""Convert to JSON-serializable dictionary"""
|
|
443
|
-
result = {
|
|
444
|
-
"threadId": self.threadId,
|
|
445
|
-
"reqId": self.reqId,
|
|
446
|
-
"createdBy": self.createdBy,
|
|
447
|
-
"createdAt": self.createdAt,
|
|
448
|
-
"position": self.position.to_dict(),
|
|
449
|
-
"resolved": self.resolved,
|
|
450
|
-
"resolvedBy": self.resolvedBy,
|
|
451
|
-
"resolvedAt": self.resolvedAt,
|
|
452
|
-
"comments": [c.to_dict() for c in self.comments],
|
|
453
|
-
}
|
|
454
|
-
# REQ-d00094-A: Include packageId if set
|
|
455
|
-
if self.packageId is not None:
|
|
456
|
-
result["packageId"] = self.packageId
|
|
457
|
-
return result
|
|
458
|
-
|
|
459
|
-
@classmethod
|
|
460
|
-
def from_dict(cls, data: Dict[str, Any]) -> "Thread":
|
|
461
|
-
"""Create from dictionary"""
|
|
462
|
-
return cls(
|
|
463
|
-
threadId=data["threadId"],
|
|
464
|
-
reqId=data["reqId"],
|
|
465
|
-
createdBy=data["createdBy"],
|
|
466
|
-
createdAt=data["createdAt"],
|
|
467
|
-
position=CommentPosition.from_dict(data["position"]),
|
|
468
|
-
resolved=data.get("resolved", False),
|
|
469
|
-
resolvedBy=data.get("resolvedBy"),
|
|
470
|
-
resolvedAt=data.get("resolvedAt"),
|
|
471
|
-
comments=[Comment.from_dict(c) for c in data.get("comments", [])],
|
|
472
|
-
# REQ-d00094-A: Package ownership (optional for backward compatibility)
|
|
473
|
-
packageId=data.get("packageId"),
|
|
474
|
-
)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
@dataclass
|
|
478
|
-
class ReviewFlag:
|
|
479
|
-
"""
|
|
480
|
-
Marks a requirement for review.
|
|
481
|
-
|
|
482
|
-
When a requirement is flagged, it signals that reviewers in the
|
|
483
|
-
specified scope should examine it.
|
|
484
|
-
"""
|
|
485
|
-
|
|
486
|
-
flaggedForReview: bool
|
|
487
|
-
flaggedBy: str # Username
|
|
488
|
-
flaggedAt: str # ISO 8601 datetime
|
|
489
|
-
reason: str
|
|
490
|
-
scope: List[str] # List of roles/users who should review
|
|
491
|
-
|
|
492
|
-
@classmethod
|
|
493
|
-
def create(cls, user: str, reason: str, scope: List[str]) -> "ReviewFlag":
|
|
494
|
-
"""Factory for creating new review flag"""
|
|
495
|
-
return cls(
|
|
496
|
-
flaggedForReview=True, flaggedBy=user, flaggedAt=now_iso(), reason=reason, scope=scope
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
@classmethod
|
|
500
|
-
def cleared(cls) -> "ReviewFlag":
|
|
501
|
-
"""Factory for an unflagged state"""
|
|
502
|
-
return cls(flaggedForReview=False, flaggedBy="", flaggedAt="", reason="", scope=[])
|
|
503
|
-
|
|
504
|
-
def clear(self) -> None:
|
|
505
|
-
"""Clear the review flag"""
|
|
506
|
-
self.flaggedForReview = False
|
|
507
|
-
self.flaggedBy = ""
|
|
508
|
-
self.flaggedAt = ""
|
|
509
|
-
self.reason = ""
|
|
510
|
-
self.scope = []
|
|
511
|
-
|
|
512
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
513
|
-
"""Validate flag state"""
|
|
514
|
-
errors = []
|
|
515
|
-
|
|
516
|
-
if self.flaggedForReview:
|
|
517
|
-
if not self.flaggedBy:
|
|
518
|
-
errors.append("Flagged review must have flaggedBy")
|
|
519
|
-
if not self.flaggedAt:
|
|
520
|
-
errors.append("Flagged review must have flaggedAt")
|
|
521
|
-
if not self.reason:
|
|
522
|
-
errors.append("Flagged review must have reason")
|
|
523
|
-
if not self.scope:
|
|
524
|
-
errors.append("Flagged review must have non-empty scope")
|
|
525
|
-
|
|
526
|
-
return len(errors) == 0, errors
|
|
527
|
-
|
|
528
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
529
|
-
"""Convert to JSON-serializable dictionary"""
|
|
530
|
-
return asdict(self)
|
|
531
|
-
|
|
532
|
-
@classmethod
|
|
533
|
-
def from_dict(cls, data: Dict[str, Any]) -> "ReviewFlag":
|
|
534
|
-
"""Create from dictionary"""
|
|
535
|
-
return cls(
|
|
536
|
-
flaggedForReview=data["flaggedForReview"],
|
|
537
|
-
flaggedBy=data.get("flaggedBy", ""),
|
|
538
|
-
flaggedAt=data.get("flaggedAt", ""),
|
|
539
|
-
reason=data.get("reason", ""),
|
|
540
|
-
scope=data.get("scope", []),
|
|
541
|
-
)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
@dataclass
|
|
545
|
-
class Approval:
|
|
546
|
-
"""
|
|
547
|
-
Single approval on a status change request.
|
|
548
|
-
"""
|
|
549
|
-
|
|
550
|
-
user: str # Username
|
|
551
|
-
decision: str # ApprovalDecision value
|
|
552
|
-
at: str # ISO 8601 datetime
|
|
553
|
-
comment: Optional[str] = None
|
|
554
|
-
|
|
555
|
-
@classmethod
|
|
556
|
-
def create(cls, user: str, decision: str, comment: Optional[str] = None) -> "Approval":
|
|
557
|
-
"""Factory for creating new approval"""
|
|
558
|
-
return cls(user=user, decision=decision, at=now_iso(), comment=comment)
|
|
559
|
-
|
|
560
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
561
|
-
"""Validate approval"""
|
|
562
|
-
errors = []
|
|
563
|
-
|
|
564
|
-
if not self.user:
|
|
565
|
-
errors.append("Approval user is required")
|
|
566
|
-
if self.decision not in [d.value for d in ApprovalDecision]:
|
|
567
|
-
errors.append(f"Invalid decision: {self.decision}")
|
|
568
|
-
if not self.at:
|
|
569
|
-
errors.append("Approval timestamp (at) is required")
|
|
570
|
-
|
|
571
|
-
return len(errors) == 0, errors
|
|
572
|
-
|
|
573
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
574
|
-
"""Convert to JSON-serializable dictionary"""
|
|
575
|
-
result: Dict[str, Any] = {"user": self.user, "decision": self.decision, "at": self.at}
|
|
576
|
-
if self.comment is not None:
|
|
577
|
-
result["comment"] = self.comment
|
|
578
|
-
return result
|
|
579
|
-
|
|
580
|
-
@classmethod
|
|
581
|
-
def from_dict(cls, data: Dict[str, Any]) -> "Approval":
|
|
582
|
-
"""Create from dictionary"""
|
|
583
|
-
return cls(
|
|
584
|
-
user=data["user"], decision=data["decision"], at=data["at"], comment=data.get("comment")
|
|
585
|
-
)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
@dataclass
|
|
589
|
-
class StatusRequest:
|
|
590
|
-
"""
|
|
591
|
-
Request to change a requirement's status.
|
|
592
|
-
|
|
593
|
-
REQ-tv-d00010-I: Automatically calculates state based on approval votes.
|
|
594
|
-
|
|
595
|
-
Status changes require approvals from designated approvers.
|
|
596
|
-
Valid transitions:
|
|
597
|
-
- Draft -> Active (requires product_owner, tech_lead)
|
|
598
|
-
- Active -> Deprecated (requires product_owner)
|
|
599
|
-
- Draft -> Deprecated (requires product_owner)
|
|
600
|
-
"""
|
|
601
|
-
|
|
602
|
-
requestId: str # UUID
|
|
603
|
-
reqId: str # Requirement ID
|
|
604
|
-
type: str # Always "status_change"
|
|
605
|
-
fromStatus: str
|
|
606
|
-
toStatus: str
|
|
607
|
-
requestedBy: str # Username
|
|
608
|
-
requestedAt: str # ISO 8601 datetime
|
|
609
|
-
justification: str
|
|
610
|
-
approvals: List[Approval]
|
|
611
|
-
requiredApprovers: List[str] # Roles/users required to approve
|
|
612
|
-
state: str # RequestState value
|
|
613
|
-
|
|
614
|
-
@classmethod
|
|
615
|
-
def create(
|
|
616
|
-
cls,
|
|
617
|
-
req_id: str,
|
|
618
|
-
from_status: str,
|
|
619
|
-
to_status: str,
|
|
620
|
-
requested_by: str,
|
|
621
|
-
justification: str,
|
|
622
|
-
required_approvers: Optional[List[str]] = None,
|
|
623
|
-
) -> "StatusRequest":
|
|
624
|
-
"""
|
|
625
|
-
Factory for creating new status change request.
|
|
626
|
-
|
|
627
|
-
REQ-tv-d00010-F: Auto-generates IDs and timestamps.
|
|
628
|
-
|
|
629
|
-
Args:
|
|
630
|
-
req_id: Requirement ID
|
|
631
|
-
from_status: Current status
|
|
632
|
-
to_status: Requested new status
|
|
633
|
-
requested_by: Username making request
|
|
634
|
-
justification: Reason for change
|
|
635
|
-
required_approvers: Override default approvers
|
|
636
|
-
"""
|
|
637
|
-
# Determine required approvers from defaults if not provided
|
|
638
|
-
if required_approvers is None:
|
|
639
|
-
transition_key = f"{from_status}->{to_status}"
|
|
640
|
-
required_approvers = DEFAULT_APPROVAL_RULES.get(transition_key, ["product_owner"])
|
|
641
|
-
|
|
642
|
-
return cls(
|
|
643
|
-
requestId=generate_uuid(),
|
|
644
|
-
reqId=req_id,
|
|
645
|
-
type="status_change",
|
|
646
|
-
fromStatus=from_status,
|
|
647
|
-
toStatus=to_status,
|
|
648
|
-
requestedBy=requested_by,
|
|
649
|
-
requestedAt=now_iso(),
|
|
650
|
-
justification=justification,
|
|
651
|
-
approvals=[],
|
|
652
|
-
requiredApprovers=required_approvers,
|
|
653
|
-
state=RequestState.PENDING.value,
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
def add_approval(self, user: str, decision: str, comment: Optional[str] = None) -> Approval:
|
|
657
|
-
"""Add an approval to the request"""
|
|
658
|
-
approval = Approval.create(user, decision, comment)
|
|
659
|
-
self.approvals.append(approval)
|
|
660
|
-
self._update_state()
|
|
661
|
-
return approval
|
|
662
|
-
|
|
663
|
-
def _update_state(self) -> None:
|
|
664
|
-
"""
|
|
665
|
-
Update state based on approvals.
|
|
666
|
-
|
|
667
|
-
REQ-tv-d00010-I: State automatically calculated from approval votes.
|
|
668
|
-
"""
|
|
669
|
-
if self.state == RequestState.APPLIED.value:
|
|
670
|
-
return # Already applied, don't change
|
|
671
|
-
|
|
672
|
-
# Check for any rejections
|
|
673
|
-
for approval in self.approvals:
|
|
674
|
-
if approval.decision == ApprovalDecision.REJECT.value:
|
|
675
|
-
self.state = RequestState.REJECTED.value
|
|
676
|
-
return
|
|
677
|
-
|
|
678
|
-
# Check if all required approvers have approved
|
|
679
|
-
approved_users = {
|
|
680
|
-
a.user for a in self.approvals if a.decision == ApprovalDecision.APPROVE.value
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
# Check if required approvers are satisfied
|
|
684
|
-
all_approved = all(approver in approved_users for approver in self.requiredApprovers)
|
|
685
|
-
|
|
686
|
-
if all_approved:
|
|
687
|
-
self.state = RequestState.APPROVED.value
|
|
688
|
-
else:
|
|
689
|
-
self.state = RequestState.PENDING.value
|
|
690
|
-
|
|
691
|
-
def mark_applied(self) -> None:
|
|
692
|
-
"""Mark the request as applied"""
|
|
693
|
-
if self.state != RequestState.APPROVED.value:
|
|
694
|
-
raise ValueError("Can only apply approved requests")
|
|
695
|
-
self.state = RequestState.APPLIED.value
|
|
696
|
-
|
|
697
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
698
|
-
"""Validate status request"""
|
|
699
|
-
errors = []
|
|
700
|
-
|
|
701
|
-
if not self.requestId:
|
|
702
|
-
errors.append("requestId is required")
|
|
703
|
-
if not validate_req_id(self.reqId):
|
|
704
|
-
errors.append(f"Invalid requirement ID: {self.reqId}")
|
|
705
|
-
if self.type != "status_change":
|
|
706
|
-
errors.append(f"Invalid type: {self.type}")
|
|
707
|
-
if self.fromStatus not in VALID_REQ_STATUSES:
|
|
708
|
-
errors.append(f"Invalid fromStatus: {self.fromStatus}")
|
|
709
|
-
if self.toStatus not in VALID_REQ_STATUSES:
|
|
710
|
-
errors.append(f"Invalid toStatus: {self.toStatus}")
|
|
711
|
-
if self.fromStatus == self.toStatus:
|
|
712
|
-
errors.append("fromStatus and toStatus must be different")
|
|
713
|
-
if not self.requestedBy:
|
|
714
|
-
errors.append("requestedBy is required")
|
|
715
|
-
if not self.justification:
|
|
716
|
-
errors.append("justification is required")
|
|
717
|
-
if self.state not in [s.value for s in RequestState]:
|
|
718
|
-
errors.append(f"Invalid state: {self.state}")
|
|
719
|
-
|
|
720
|
-
# Validate approvals
|
|
721
|
-
for i, approval in enumerate(self.approvals):
|
|
722
|
-
approval_valid, approval_errors = approval.validate()
|
|
723
|
-
errors.extend([f"Approval[{i}]: {e}" for e in approval_errors])
|
|
724
|
-
|
|
725
|
-
return len(errors) == 0, errors
|
|
726
|
-
|
|
727
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
728
|
-
"""Convert to JSON-serializable dictionary"""
|
|
729
|
-
return {
|
|
730
|
-
"requestId": self.requestId,
|
|
731
|
-
"reqId": self.reqId,
|
|
732
|
-
"type": self.type,
|
|
733
|
-
"fromStatus": self.fromStatus,
|
|
734
|
-
"toStatus": self.toStatus,
|
|
735
|
-
"requestedBy": self.requestedBy,
|
|
736
|
-
"requestedAt": self.requestedAt,
|
|
737
|
-
"justification": self.justification,
|
|
738
|
-
"approvals": [a.to_dict() for a in self.approvals],
|
|
739
|
-
"requiredApprovers": self.requiredApprovers,
|
|
740
|
-
"state": self.state,
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
@classmethod
|
|
744
|
-
def from_dict(cls, data: Dict[str, Any]) -> "StatusRequest":
|
|
745
|
-
"""Create from dictionary"""
|
|
746
|
-
return cls(
|
|
747
|
-
requestId=data["requestId"],
|
|
748
|
-
reqId=data["reqId"],
|
|
749
|
-
type=data["type"],
|
|
750
|
-
fromStatus=data["fromStatus"],
|
|
751
|
-
toStatus=data["toStatus"],
|
|
752
|
-
requestedBy=data["requestedBy"],
|
|
753
|
-
requestedAt=data["requestedAt"],
|
|
754
|
-
justification=data["justification"],
|
|
755
|
-
approvals=[Approval.from_dict(a) for a in data.get("approvals", [])],
|
|
756
|
-
requiredApprovers=data.get("requiredApprovers", []),
|
|
757
|
-
state=data["state"],
|
|
758
|
-
)
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
@dataclass
|
|
762
|
-
class ReviewSession:
|
|
763
|
-
"""
|
|
764
|
-
Groups review activity for a user.
|
|
765
|
-
|
|
766
|
-
Sessions help organize reviews and track progress over time.
|
|
767
|
-
"""
|
|
768
|
-
|
|
769
|
-
sessionId: str # UUID
|
|
770
|
-
user: str # Username
|
|
771
|
-
name: str # Session name (e.g., "Sprint 23 Review")
|
|
772
|
-
createdAt: str # ISO 8601 datetime
|
|
773
|
-
description: Optional[str] = None
|
|
774
|
-
|
|
775
|
-
@classmethod
|
|
776
|
-
def create(cls, user: str, name: str, description: Optional[str] = None) -> "ReviewSession":
|
|
777
|
-
"""Factory for creating new session"""
|
|
778
|
-
return cls(
|
|
779
|
-
sessionId=generate_uuid(),
|
|
780
|
-
user=user,
|
|
781
|
-
name=name,
|
|
782
|
-
createdAt=now_iso(),
|
|
783
|
-
description=description,
|
|
784
|
-
)
|
|
785
|
-
|
|
786
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
787
|
-
"""Validate session"""
|
|
788
|
-
errors = []
|
|
789
|
-
|
|
790
|
-
if not self.sessionId:
|
|
791
|
-
errors.append("sessionId is required")
|
|
792
|
-
if not self.user:
|
|
793
|
-
errors.append("user is required")
|
|
794
|
-
if not self.name:
|
|
795
|
-
errors.append("name is required")
|
|
796
|
-
if not self.createdAt:
|
|
797
|
-
errors.append("createdAt is required")
|
|
798
|
-
|
|
799
|
-
return len(errors) == 0, errors
|
|
800
|
-
|
|
801
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
802
|
-
"""Convert to JSON-serializable dictionary"""
|
|
803
|
-
result: Dict[str, Any] = {
|
|
804
|
-
"sessionId": self.sessionId,
|
|
805
|
-
"user": self.user,
|
|
806
|
-
"name": self.name,
|
|
807
|
-
"createdAt": self.createdAt,
|
|
808
|
-
}
|
|
809
|
-
if self.description is not None:
|
|
810
|
-
result["description"] = self.description
|
|
811
|
-
return result
|
|
812
|
-
|
|
813
|
-
@classmethod
|
|
814
|
-
def from_dict(cls, data: Dict[str, Any]) -> "ReviewSession":
|
|
815
|
-
"""Create from dictionary"""
|
|
816
|
-
return cls(
|
|
817
|
-
sessionId=data["sessionId"],
|
|
818
|
-
user=data["user"],
|
|
819
|
-
name=data["name"],
|
|
820
|
-
createdAt=data["createdAt"],
|
|
821
|
-
description=data.get("description"),
|
|
822
|
-
)
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
@dataclass
|
|
826
|
-
class ReviewConfig:
|
|
827
|
-
"""
|
|
828
|
-
System configuration for the review system.
|
|
829
|
-
"""
|
|
830
|
-
|
|
831
|
-
approvalRules: Dict[str, List[str]] # Status transition -> required approvers
|
|
832
|
-
pushOnComment: bool = True # Auto git push when adding comments
|
|
833
|
-
autoFetchOnOpen: bool = True # Auto git fetch when opening reviews
|
|
834
|
-
|
|
835
|
-
@classmethod
|
|
836
|
-
def default(cls) -> "ReviewConfig":
|
|
837
|
-
"""Factory for default configuration"""
|
|
838
|
-
return cls(
|
|
839
|
-
approvalRules=DEFAULT_APPROVAL_RULES.copy(), pushOnComment=True, autoFetchOnOpen=True
|
|
840
|
-
)
|
|
841
|
-
|
|
842
|
-
def get_required_approvers(self, from_status: str, to_status: str) -> List[str]:
|
|
843
|
-
"""Get required approvers for a status transition"""
|
|
844
|
-
key = f"{from_status}->{to_status}"
|
|
845
|
-
return self.approvalRules.get(key, ["product_owner"])
|
|
846
|
-
|
|
847
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
848
|
-
"""Validate configuration"""
|
|
849
|
-
errors = []
|
|
850
|
-
|
|
851
|
-
# Validate approval rules
|
|
852
|
-
for transition, approvers in self.approvalRules.items():
|
|
853
|
-
parts = transition.split("->")
|
|
854
|
-
if len(parts) != 2:
|
|
855
|
-
errors.append(f"Invalid transition format: {transition}")
|
|
856
|
-
continue
|
|
857
|
-
from_status, to_status = parts
|
|
858
|
-
if from_status not in VALID_REQ_STATUSES:
|
|
859
|
-
errors.append(f"Invalid from status in {transition}: {from_status}")
|
|
860
|
-
if to_status not in VALID_REQ_STATUSES:
|
|
861
|
-
errors.append(f"Invalid to status in {transition}: {to_status}")
|
|
862
|
-
if not approvers:
|
|
863
|
-
errors.append(f"Empty approvers for {transition}")
|
|
864
|
-
|
|
865
|
-
return len(errors) == 0, errors
|
|
866
|
-
|
|
867
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
868
|
-
"""Convert to JSON-serializable dictionary"""
|
|
869
|
-
return {
|
|
870
|
-
"approvalRules": self.approvalRules,
|
|
871
|
-
"pushOnComment": self.pushOnComment,
|
|
872
|
-
"autoFetchOnOpen": self.autoFetchOnOpen,
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
@classmethod
|
|
876
|
-
def from_dict(cls, data: Dict[str, Any]) -> "ReviewConfig":
|
|
877
|
-
"""Create from dictionary"""
|
|
878
|
-
return cls(
|
|
879
|
-
approvalRules=data.get("approvalRules", DEFAULT_APPROVAL_RULES.copy()),
|
|
880
|
-
pushOnComment=data.get("pushOnComment", True),
|
|
881
|
-
autoFetchOnOpen=data.get("autoFetchOnOpen", True),
|
|
882
|
-
)
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
# Archive reason enum values
|
|
886
|
-
ARCHIVE_REASON_RESOLVED = "resolved"
|
|
887
|
-
ARCHIVE_REASON_DELETED = "deleted"
|
|
888
|
-
ARCHIVE_REASON_MANUAL = "manual"
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
@dataclass
|
|
892
|
-
class ReviewPackage:
|
|
893
|
-
"""
|
|
894
|
-
Named collection of REQs under review.
|
|
895
|
-
|
|
896
|
-
Packages group related requirements for coordinated review.
|
|
897
|
-
Supports audit trail and archival metadata.
|
|
898
|
-
|
|
899
|
-
REQ-d00095: Review Package Management
|
|
900
|
-
REQ-d00097: Review Package Archival
|
|
901
|
-
REQ-d00098: Review Git Audit Trail
|
|
902
|
-
"""
|
|
903
|
-
|
|
904
|
-
packageId: str # UUID
|
|
905
|
-
name: str
|
|
906
|
-
description: str
|
|
907
|
-
reqIds: List[str]
|
|
908
|
-
createdBy: str # Username
|
|
909
|
-
createdAt: str # ISO 8601 datetime
|
|
910
|
-
|
|
911
|
-
# REQ-d00098: Git Audit Trail
|
|
912
|
-
branchName: Optional[str] = None # Git branch when package created
|
|
913
|
-
creationCommitHash: Optional[str] = None # HEAD commit when created
|
|
914
|
-
lastReviewedCommitHash: Optional[str] = None # Updated on comment activity
|
|
915
|
-
|
|
916
|
-
# REQ-d00097: Archive metadata
|
|
917
|
-
archivedAt: Optional[str] = None # ISO 8601 datetime when archived
|
|
918
|
-
archivedBy: Optional[str] = None # Username who triggered archive
|
|
919
|
-
archiveReason: Optional[str] = None # "resolved", "deleted", or "manual"
|
|
920
|
-
|
|
921
|
-
# Deprecated: kept for backward compatibility during migration
|
|
922
|
-
isDefault: bool = False
|
|
923
|
-
|
|
924
|
-
@classmethod
|
|
925
|
-
def create(
|
|
926
|
-
cls,
|
|
927
|
-
name: str,
|
|
928
|
-
description: str,
|
|
929
|
-
created_by: str,
|
|
930
|
-
branch_name: Optional[str] = None,
|
|
931
|
-
commit_hash: Optional[str] = None,
|
|
932
|
-
) -> "ReviewPackage":
|
|
933
|
-
"""
|
|
934
|
-
Factory for creating new package.
|
|
935
|
-
|
|
936
|
-
REQ-tv-d00010-F: Auto-generates IDs and timestamps.
|
|
937
|
-
REQ-d00098: Records git context when available.
|
|
938
|
-
|
|
939
|
-
Args:
|
|
940
|
-
name: Package display name
|
|
941
|
-
description: Package description
|
|
942
|
-
created_by: Username of creator
|
|
943
|
-
branch_name: Current git branch (for audit trail)
|
|
944
|
-
commit_hash: Current HEAD commit hash (for audit trail)
|
|
945
|
-
"""
|
|
946
|
-
return cls(
|
|
947
|
-
packageId=generate_uuid(),
|
|
948
|
-
name=name,
|
|
949
|
-
description=description,
|
|
950
|
-
reqIds=[],
|
|
951
|
-
createdBy=created_by,
|
|
952
|
-
createdAt=now_iso(),
|
|
953
|
-
branchName=branch_name,
|
|
954
|
-
creationCommitHash=commit_hash,
|
|
955
|
-
lastReviewedCommitHash=commit_hash,
|
|
956
|
-
isDefault=False,
|
|
957
|
-
)
|
|
958
|
-
|
|
959
|
-
@classmethod
|
|
960
|
-
def create_default(cls) -> "ReviewPackage":
|
|
961
|
-
"""
|
|
962
|
-
Create the default package.
|
|
963
|
-
|
|
964
|
-
DEPRECATED: Default packages are no longer recommended per REQ-d00095-B.
|
|
965
|
-
This method is kept for backward compatibility during migration.
|
|
966
|
-
"""
|
|
967
|
-
import warnings
|
|
968
|
-
|
|
969
|
-
warnings.warn(
|
|
970
|
-
"create_default() is deprecated. Packages should be explicitly created.",
|
|
971
|
-
DeprecationWarning,
|
|
972
|
-
stacklevel=2,
|
|
973
|
-
)
|
|
974
|
-
return cls(
|
|
975
|
-
packageId="default",
|
|
976
|
-
name="Default",
|
|
977
|
-
description="REQs manually set to Review status",
|
|
978
|
-
reqIds=[],
|
|
979
|
-
createdBy="system",
|
|
980
|
-
createdAt=now_iso(),
|
|
981
|
-
isDefault=True,
|
|
982
|
-
)
|
|
983
|
-
|
|
984
|
-
def update_last_reviewed_commit(self, commit_hash: str) -> None:
|
|
985
|
-
"""
|
|
986
|
-
Update the last reviewed commit hash.
|
|
987
|
-
|
|
988
|
-
REQ-d00098-C: Updated on each comment activity.
|
|
989
|
-
"""
|
|
990
|
-
self.lastReviewedCommitHash = commit_hash
|
|
991
|
-
|
|
992
|
-
def archive(self, user: str, reason: str) -> None:
|
|
993
|
-
"""
|
|
994
|
-
Mark package as archived.
|
|
995
|
-
|
|
996
|
-
REQ-d00097-C: Sets archive metadata.
|
|
997
|
-
|
|
998
|
-
Args:
|
|
999
|
-
user: Username who triggered archive
|
|
1000
|
-
reason: One of "resolved", "deleted", "manual"
|
|
1001
|
-
"""
|
|
1002
|
-
if reason not in (ARCHIVE_REASON_RESOLVED, ARCHIVE_REASON_DELETED, ARCHIVE_REASON_MANUAL):
|
|
1003
|
-
raise ValueError(f"Invalid archive reason: {reason}")
|
|
1004
|
-
self.archivedAt = now_iso()
|
|
1005
|
-
self.archivedBy = user
|
|
1006
|
-
self.archiveReason = reason
|
|
1007
|
-
|
|
1008
|
-
@property
|
|
1009
|
-
def is_archived(self) -> bool:
|
|
1010
|
-
"""Check if package is archived."""
|
|
1011
|
-
return self.archivedAt is not None
|
|
1012
|
-
|
|
1013
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
1014
|
-
"""Validate package"""
|
|
1015
|
-
errors = []
|
|
1016
|
-
|
|
1017
|
-
if not self.packageId:
|
|
1018
|
-
errors.append("packageId is required")
|
|
1019
|
-
if not self.name:
|
|
1020
|
-
errors.append("name is required")
|
|
1021
|
-
if not self.createdBy:
|
|
1022
|
-
errors.append("createdBy is required")
|
|
1023
|
-
if not self.createdAt:
|
|
1024
|
-
errors.append("createdAt is required")
|
|
1025
|
-
|
|
1026
|
-
# Validate archive state consistency
|
|
1027
|
-
if self.archivedAt:
|
|
1028
|
-
if not self.archivedBy:
|
|
1029
|
-
errors.append("archivedBy is required when archivedAt is set")
|
|
1030
|
-
if not self.archiveReason:
|
|
1031
|
-
errors.append("archiveReason is required when archivedAt is set")
|
|
1032
|
-
elif self.archiveReason not in (
|
|
1033
|
-
ARCHIVE_REASON_RESOLVED,
|
|
1034
|
-
ARCHIVE_REASON_DELETED,
|
|
1035
|
-
ARCHIVE_REASON_MANUAL,
|
|
1036
|
-
):
|
|
1037
|
-
errors.append(f"Invalid archiveReason: {self.archiveReason}")
|
|
1038
|
-
|
|
1039
|
-
return len(errors) == 0, errors
|
|
1040
|
-
|
|
1041
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
1042
|
-
"""Convert to JSON-serializable dictionary"""
|
|
1043
|
-
result: Dict[str, Any] = {
|
|
1044
|
-
"packageId": self.packageId,
|
|
1045
|
-
"name": self.name,
|
|
1046
|
-
"description": self.description,
|
|
1047
|
-
"reqIds": self.reqIds.copy(),
|
|
1048
|
-
"createdBy": self.createdBy,
|
|
1049
|
-
"createdAt": self.createdAt,
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
# REQ-d00098: Git audit trail (only include if set)
|
|
1053
|
-
if self.branchName is not None:
|
|
1054
|
-
result["branchName"] = self.branchName
|
|
1055
|
-
if self.creationCommitHash is not None:
|
|
1056
|
-
result["creationCommitHash"] = self.creationCommitHash
|
|
1057
|
-
if self.lastReviewedCommitHash is not None:
|
|
1058
|
-
result["lastReviewedCommitHash"] = self.lastReviewedCommitHash
|
|
1059
|
-
|
|
1060
|
-
# REQ-d00097: Archive metadata (only include if archived)
|
|
1061
|
-
if self.archivedAt is not None:
|
|
1062
|
-
result["archivedAt"] = self.archivedAt
|
|
1063
|
-
result["archivedBy"] = self.archivedBy
|
|
1064
|
-
result["archiveReason"] = self.archiveReason
|
|
1065
|
-
|
|
1066
|
-
# Deprecated field - still included for backward compatibility
|
|
1067
|
-
if self.isDefault:
|
|
1068
|
-
result["isDefault"] = self.isDefault
|
|
1069
|
-
|
|
1070
|
-
return result
|
|
1071
|
-
|
|
1072
|
-
@classmethod
|
|
1073
|
-
def from_dict(cls, data: Dict[str, Any]) -> "ReviewPackage":
|
|
1074
|
-
"""Create from dictionary"""
|
|
1075
|
-
return cls(
|
|
1076
|
-
packageId=data.get("packageId", ""),
|
|
1077
|
-
name=data.get("name", ""),
|
|
1078
|
-
description=data.get("description", ""),
|
|
1079
|
-
reqIds=data.get("reqIds", []).copy(),
|
|
1080
|
-
createdBy=data.get("createdBy", ""),
|
|
1081
|
-
createdAt=data.get("createdAt", ""),
|
|
1082
|
-
# REQ-d00098: Git audit trail
|
|
1083
|
-
branchName=data.get("branchName"),
|
|
1084
|
-
creationCommitHash=data.get("creationCommitHash"),
|
|
1085
|
-
lastReviewedCommitHash=data.get("lastReviewedCommitHash"),
|
|
1086
|
-
# REQ-d00097: Archive metadata
|
|
1087
|
-
archivedAt=data.get("archivedAt"),
|
|
1088
|
-
archivedBy=data.get("archivedBy"),
|
|
1089
|
-
archiveReason=data.get("archiveReason"),
|
|
1090
|
-
# Deprecated field
|
|
1091
|
-
isDefault=data.get("isDefault", False),
|
|
1092
|
-
)
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
# =============================================================================
|
|
1096
|
-
# Container Classes for JSON File Contents
|
|
1097
|
-
# REQ-tv-d00010-G: Container classes with version tracking
|
|
1098
|
-
# =============================================================================
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
@dataclass
|
|
1102
|
-
class ThreadsFile:
|
|
1103
|
-
"""Container for threads.json file contents"""
|
|
1104
|
-
|
|
1105
|
-
reqId: str
|
|
1106
|
-
threads: List[Thread]
|
|
1107
|
-
version: str = "1.0"
|
|
1108
|
-
|
|
1109
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
1110
|
-
"""Convert to JSON-serializable dictionary"""
|
|
1111
|
-
return {
|
|
1112
|
-
"version": self.version,
|
|
1113
|
-
"reqId": self.reqId,
|
|
1114
|
-
"threads": [t.to_dict() for t in self.threads],
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
@classmethod
|
|
1118
|
-
def from_dict(cls, data: Dict[str, Any]) -> "ThreadsFile":
|
|
1119
|
-
"""Create from dictionary"""
|
|
1120
|
-
return cls(
|
|
1121
|
-
version=data.get("version", "1.0"),
|
|
1122
|
-
reqId=data["reqId"],
|
|
1123
|
-
threads=[Thread.from_dict(t) for t in data.get("threads", [])],
|
|
1124
|
-
)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
@dataclass
|
|
1128
|
-
class StatusFile:
|
|
1129
|
-
"""Container for status.json file contents"""
|
|
1130
|
-
|
|
1131
|
-
reqId: str
|
|
1132
|
-
requests: List[StatusRequest]
|
|
1133
|
-
version: str = "1.0"
|
|
1134
|
-
|
|
1135
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
1136
|
-
"""Convert to JSON-serializable dictionary"""
|
|
1137
|
-
return {
|
|
1138
|
-
"version": self.version,
|
|
1139
|
-
"reqId": self.reqId,
|
|
1140
|
-
"requests": [r.to_dict() for r in self.requests],
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
@classmethod
|
|
1144
|
-
def from_dict(cls, data: Dict[str, Any]) -> "StatusFile":
|
|
1145
|
-
"""Create from dictionary"""
|
|
1146
|
-
return cls(
|
|
1147
|
-
version=data.get("version", "1.0"),
|
|
1148
|
-
reqId=data["reqId"],
|
|
1149
|
-
requests=[StatusRequest.from_dict(r) for r in data.get("requests", [])],
|
|
1150
|
-
)
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
@dataclass
|
|
1154
|
-
class PackagesFile:
|
|
1155
|
-
"""Container for packages.json file contents"""
|
|
1156
|
-
|
|
1157
|
-
packages: List[ReviewPackage]
|
|
1158
|
-
activePackageId: Optional[str] = None
|
|
1159
|
-
version: str = "1.0"
|
|
1160
|
-
|
|
1161
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
1162
|
-
"""Convert to JSON-serializable dictionary"""
|
|
1163
|
-
return {
|
|
1164
|
-
"version": self.version,
|
|
1165
|
-
"packages": [p.to_dict() for p in self.packages],
|
|
1166
|
-
"activePackageId": self.activePackageId,
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
@classmethod
|
|
1170
|
-
def from_dict(cls, data: Dict[str, Any]) -> "PackagesFile":
|
|
1171
|
-
"""Create from dictionary"""
|
|
1172
|
-
packages = [ReviewPackage.from_dict(p) for p in data.get("packages", [])]
|
|
1173
|
-
return cls(
|
|
1174
|
-
version=data.get("version", "1.0"),
|
|
1175
|
-
packages=packages,
|
|
1176
|
-
activePackageId=data.get("activePackageId"),
|
|
1177
|
-
)
|
|
1178
|
-
|
|
1179
|
-
def get_default(self) -> Optional[ReviewPackage]:
|
|
1180
|
-
"""Get the default package"""
|
|
1181
|
-
for pkg in self.packages:
|
|
1182
|
-
if pkg.isDefault:
|
|
1183
|
-
return pkg
|
|
1184
|
-
return None
|
|
1185
|
-
|
|
1186
|
-
def get_active(self) -> Optional[ReviewPackage]:
|
|
1187
|
-
"""Get the currently active package"""
|
|
1188
|
-
if not self.activePackageId:
|
|
1189
|
-
return None
|
|
1190
|
-
for pkg in self.packages:
|
|
1191
|
-
if pkg.packageId == self.activePackageId:
|
|
1192
|
-
return pkg
|
|
1193
|
-
return None
|
|
1194
|
-
|
|
1195
|
-
def get_by_id(self, package_id: str) -> Optional[ReviewPackage]:
|
|
1196
|
-
"""Get a package by ID"""
|
|
1197
|
-
for pkg in self.packages:
|
|
1198
|
-
if pkg.packageId == package_id:
|
|
1199
|
-
return pkg
|
|
1200
|
-
return None
|