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.
Files changed (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {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