elspais 0.11.1__py3-none-any.whl → 0.11.2__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 (53) hide show
  1. elspais/__init__.py +1 -1
  2. elspais/cli.py +29 -10
  3. elspais/commands/analyze.py +5 -6
  4. elspais/commands/changed.py +2 -6
  5. elspais/commands/config_cmd.py +4 -4
  6. elspais/commands/edit.py +32 -36
  7. elspais/commands/hash_cmd.py +24 -18
  8. elspais/commands/index.py +8 -7
  9. elspais/commands/init.py +4 -4
  10. elspais/commands/reformat_cmd.py +32 -43
  11. elspais/commands/rules_cmd.py +6 -2
  12. elspais/commands/trace.py +23 -19
  13. elspais/commands/validate.py +8 -10
  14. elspais/config/defaults.py +7 -1
  15. elspais/core/content_rules.py +0 -1
  16. elspais/core/git.py +4 -10
  17. elspais/core/parser.py +55 -56
  18. elspais/core/patterns.py +2 -6
  19. elspais/core/rules.py +10 -15
  20. elspais/mcp/__init__.py +2 -0
  21. elspais/mcp/context.py +1 -0
  22. elspais/mcp/serializers.py +1 -1
  23. elspais/mcp/server.py +54 -39
  24. elspais/reformat/__init__.py +13 -13
  25. elspais/reformat/detector.py +9 -16
  26. elspais/reformat/hierarchy.py +8 -7
  27. elspais/reformat/line_breaks.py +36 -38
  28. elspais/reformat/prompts.py +22 -12
  29. elspais/reformat/transformer.py +43 -41
  30. elspais/sponsors/__init__.py +0 -2
  31. elspais/testing/__init__.py +1 -1
  32. elspais/testing/result_parser.py +25 -21
  33. elspais/trace_view/__init__.py +4 -3
  34. elspais/trace_view/coverage.py +5 -5
  35. elspais/trace_view/generators/__init__.py +1 -1
  36. elspais/trace_view/generators/base.py +17 -12
  37. elspais/trace_view/generators/csv.py +2 -6
  38. elspais/trace_view/generators/markdown.py +3 -8
  39. elspais/trace_view/html/__init__.py +4 -2
  40. elspais/trace_view/html/generator.py +423 -289
  41. elspais/trace_view/models.py +25 -0
  42. elspais/trace_view/review/__init__.py +21 -18
  43. elspais/trace_view/review/branches.py +114 -121
  44. elspais/trace_view/review/models.py +232 -237
  45. elspais/trace_view/review/position.py +53 -71
  46. elspais/trace_view/review/server.py +264 -288
  47. elspais/trace_view/review/status.py +43 -58
  48. elspais/trace_view/review/storage.py +48 -72
  49. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/METADATA +1 -1
  50. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
  51. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
  52. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
  53. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
@@ -21,19 +21,20 @@ IMPLEMENTS REQUIREMENTS:
21
21
 
22
22
  import re
23
23
  import uuid
24
- from dataclasses import dataclass, field, asdict
24
+ from dataclasses import asdict, dataclass, field
25
25
  from datetime import datetime, timezone
26
26
  from enum import Enum
27
27
  from typing import Any, Dict, List, Optional, Tuple
28
28
 
29
-
30
29
  # =============================================================================
31
30
  # Enums and Constants
32
31
  # REQ-tv-d00010-B: String enums for JSON compatibility
33
32
  # =============================================================================
34
33
 
34
+
35
35
  class PositionType(str, Enum):
36
36
  """Type of comment position anchor"""
37
+
37
38
  LINE = "line"
38
39
  BLOCK = "block"
39
40
  WORD = "word"
@@ -42,6 +43,7 @@ class PositionType(str, Enum):
42
43
 
43
44
  class RequestState(str, Enum):
44
45
  """State of a status change request"""
46
+
45
47
  PENDING = "pending"
46
48
  APPROVED = "approved"
47
49
  REJECTED = "rejected"
@@ -50,6 +52,7 @@ class RequestState(str, Enum):
50
52
 
51
53
  class ApprovalDecision(str, Enum):
52
54
  """Approval decision type"""
55
+
53
56
  APPROVE = "approve"
54
57
  REJECT = "reject"
55
58
 
@@ -61,7 +64,7 @@ VALID_REQ_STATUSES = {"Draft", "Active", "Deprecated"}
61
64
  DEFAULT_APPROVAL_RULES: Dict[str, List[str]] = {
62
65
  "Draft->Active": ["product_owner", "tech_lead"],
63
66
  "Active->Deprecated": ["product_owner"],
64
- "Draft->Deprecated": ["product_owner"]
67
+ "Draft->Deprecated": ["product_owner"],
65
68
  }
66
69
 
67
70
 
@@ -69,6 +72,7 @@ DEFAULT_APPROVAL_RULES: Dict[str, List[str]] = {
69
72
  # Utility Functions
70
73
  # =============================================================================
71
74
 
75
+
72
76
  def generate_uuid() -> str:
73
77
  """Generate a new UUID string"""
74
78
  return str(uuid.uuid4())
@@ -86,8 +90,8 @@ def now_iso() -> str:
86
90
  def parse_iso_datetime(iso_str: str) -> datetime:
87
91
  """Parse ISO 8601 datetime string to datetime object"""
88
92
  # Handle both with and without timezone, and Z suffix
89
- if iso_str.endswith('Z'):
90
- iso_str = iso_str[:-1] + '+00:00'
93
+ if iso_str.endswith("Z"):
94
+ iso_str = iso_str[:-1] + "+00:00"
91
95
  return datetime.fromisoformat(iso_str)
92
96
 
93
97
 
@@ -104,7 +108,7 @@ def validate_req_id(req_id: str) -> bool:
104
108
  if not req_id:
105
109
  return False
106
110
  # Negative lookahead to reject REQ- prefix; only sponsor prefixes allowed
107
- pattern = r'^(?!REQ-)(?:[A-Z]{2,4}-)?[pod]\d{5}$'
111
+ pattern = r"^(?!REQ-)(?:[A-Z]{2,4}-)?[pod]\d{5}$"
108
112
  return bool(re.match(pattern, req_id))
109
113
 
110
114
 
@@ -112,7 +116,7 @@ def validate_hash(hash_value: str) -> bool:
112
116
  """Validate 8-character hex hash format"""
113
117
  if not hash_value:
114
118
  return False
115
- return bool(re.match(r'^[a-fA-F0-9]{8}$', hash_value))
119
+ return bool(re.match(r"^[a-fA-F0-9]{8}$", hash_value))
116
120
 
117
121
 
118
122
  # =============================================================================
@@ -123,6 +127,7 @@ def validate_hash(hash_value: str) -> bool:
123
127
  # REQ-tv-d00010-E: Each dataclass implements validate()
124
128
  # =============================================================================
125
129
 
130
+
126
131
  @dataclass
127
132
  class CommentPosition:
128
133
  """
@@ -138,6 +143,7 @@ class CommentPosition:
138
143
 
139
144
  The hashWhenCreated allows detection of content drift.
140
145
  """
146
+
141
147
  type: str # PositionType value as string for JSON compatibility
142
148
  hashWhenCreated: str # 8-char REQ hash when comment was created
143
149
  lineNumber: Optional[int] = None
@@ -152,46 +158,46 @@ class CommentPosition:
152
158
  self.type = self.type.value
153
159
 
154
160
  @classmethod
155
- def create_line(cls, hash_value: str, line_number: int,
156
- context: Optional[str] = None) -> 'CommentPosition':
161
+ def create_line(
162
+ cls, hash_value: str, line_number: int, context: Optional[str] = None
163
+ ) -> "CommentPosition":
157
164
  """Factory for line-anchored position"""
158
165
  return cls(
159
166
  type=PositionType.LINE.value,
160
167
  hashWhenCreated=hash_value,
161
168
  lineNumber=line_number,
162
- fallbackContext=context
169
+ fallbackContext=context,
163
170
  )
164
171
 
165
172
  @classmethod
166
- def create_block(cls, hash_value: str, start_line: int, end_line: int,
167
- context: Optional[str] = None) -> 'CommentPosition':
173
+ def create_block(
174
+ cls, hash_value: str, start_line: int, end_line: int, context: Optional[str] = None
175
+ ) -> "CommentPosition":
168
176
  """Factory for block-anchored position"""
169
177
  return cls(
170
178
  type=PositionType.BLOCK.value,
171
179
  hashWhenCreated=hash_value,
172
180
  lineRange=(start_line, end_line),
173
- fallbackContext=context
181
+ fallbackContext=context,
174
182
  )
175
183
 
176
184
  @classmethod
177
- def create_word(cls, hash_value: str, keyword: str, occurrence: int = 1,
178
- context: Optional[str] = None) -> 'CommentPosition':
185
+ def create_word(
186
+ cls, hash_value: str, keyword: str, occurrence: int = 1, context: Optional[str] = None
187
+ ) -> "CommentPosition":
179
188
  """Factory for word-anchored position"""
180
189
  return cls(
181
190
  type=PositionType.WORD.value,
182
191
  hashWhenCreated=hash_value,
183
192
  keyword=keyword,
184
193
  keywordOccurrence=occurrence,
185
- fallbackContext=context
194
+ fallbackContext=context,
186
195
  )
187
196
 
188
197
  @classmethod
189
- def create_general(cls, hash_value: str) -> 'CommentPosition':
198
+ def create_general(cls, hash_value: str) -> "CommentPosition":
190
199
  """Factory for general (whole REQ) position"""
191
- return cls(
192
- type=PositionType.GENERAL.value,
193
- hashWhenCreated=hash_value
194
- )
200
+ return cls(type=PositionType.GENERAL.value, hashWhenCreated=hash_value)
195
201
 
196
202
  def validate(self) -> Tuple[bool, List[str]]:
197
203
  """
@@ -236,40 +242,37 @@ class CommentPosition:
236
242
 
237
243
  REQ-tv-d00010-C: Returns JSON-serializable dict.
238
244
  """
239
- result: Dict[str, Any] = {
240
- 'type': self.type,
241
- 'hashWhenCreated': self.hashWhenCreated
242
- }
245
+ result: Dict[str, Any] = {"type": self.type, "hashWhenCreated": self.hashWhenCreated}
243
246
  if self.lineNumber is not None:
244
- result['lineNumber'] = self.lineNumber
247
+ result["lineNumber"] = self.lineNumber
245
248
  if self.lineRange is not None:
246
- result['lineRange'] = list(self.lineRange) # Tuple to list for JSON
249
+ result["lineRange"] = list(self.lineRange) # Tuple to list for JSON
247
250
  if self.keyword is not None:
248
- result['keyword'] = self.keyword
251
+ result["keyword"] = self.keyword
249
252
  if self.keywordOccurrence is not None:
250
- result['keywordOccurrence'] = self.keywordOccurrence
253
+ result["keywordOccurrence"] = self.keywordOccurrence
251
254
  if self.fallbackContext is not None:
252
- result['fallbackContext'] = self.fallbackContext
255
+ result["fallbackContext"] = self.fallbackContext
253
256
  return result
254
257
 
255
258
  @classmethod
256
- def from_dict(cls, data: Dict[str, Any]) -> 'CommentPosition':
259
+ def from_dict(cls, data: Dict[str, Any]) -> "CommentPosition":
257
260
  """
258
261
  Create from dictionary (JSON deserialization).
259
262
 
260
263
  REQ-tv-d00010-D: Deserializes from dictionaries.
261
264
  """
262
- line_range = data.get('lineRange')
265
+ line_range = data.get("lineRange")
263
266
  if line_range is not None:
264
267
  line_range = tuple(line_range) # List to tuple
265
268
  return cls(
266
- type=data['type'],
267
- hashWhenCreated=data['hashWhenCreated'],
268
- lineNumber=data.get('lineNumber'),
269
+ type=data["type"],
270
+ hashWhenCreated=data["hashWhenCreated"],
271
+ lineNumber=data.get("lineNumber"),
269
272
  lineRange=line_range,
270
- keyword=data.get('keyword'),
271
- keywordOccurrence=data.get('keywordOccurrence'),
272
- fallbackContext=data.get('fallbackContext')
273
+ keyword=data.get("keyword"),
274
+ keywordOccurrence=data.get("keywordOccurrence"),
275
+ fallbackContext=data.get("fallbackContext"),
273
276
  )
274
277
 
275
278
 
@@ -281,25 +284,21 @@ class Comment:
281
284
  Comments are immutable once created - edits create new comments
282
285
  with references to the original.
283
286
  """
287
+
284
288
  id: str # UUID
285
289
  author: str # Username
286
290
  timestamp: str # ISO 8601 datetime
287
291
  body: str # Markdown content
288
292
 
289
293
  @classmethod
290
- def create(cls, author: str, body: str) -> 'Comment':
294
+ def create(cls, author: str, body: str) -> "Comment":
291
295
  """
292
296
  Factory for creating new comment with auto-generated fields.
293
297
 
294
298
  REQ-tv-d00010-F: Auto-generates IDs and timestamps.
295
299
  REQ-tv-d00010-J: Uses UTC timestamps.
296
300
  """
297
- return cls(
298
- id=generate_uuid(),
299
- author=author,
300
- timestamp=now_iso(),
301
- body=body
302
- )
301
+ return cls(id=generate_uuid(), author=author, timestamp=now_iso(), body=body)
303
302
 
304
303
  def validate(self) -> Tuple[bool, List[str]]:
305
304
  """Validate comment fields"""
@@ -328,13 +327,10 @@ class Comment:
328
327
  return asdict(self)
329
328
 
330
329
  @classmethod
331
- def from_dict(cls, data: Dict[str, Any]) -> 'Comment':
330
+ def from_dict(cls, data: Dict[str, Any]) -> "Comment":
332
331
  """Create from dictionary"""
333
332
  return cls(
334
- id=data['id'],
335
- author=data['author'],
336
- timestamp=data['timestamp'],
337
- body=data['body']
333
+ id=data["id"], author=data["author"], timestamp=data["timestamp"], body=data["body"]
338
334
  )
339
335
 
340
336
 
@@ -348,6 +344,7 @@ class Thread:
348
344
 
349
345
  REQ-d00094-A: Thread model with packageId for package ownership.
350
346
  """
347
+
351
348
  threadId: str # UUID
352
349
  reqId: str # Requirement ID (e.g., "d00027")
353
350
  createdBy: str # Username who started thread
@@ -361,9 +358,14 @@ class Thread:
361
358
  packageId: Optional[str] = None
362
359
 
363
360
  @classmethod
364
- def create(cls, req_id: str, creator: str, position: CommentPosition,
365
- initial_comment: Optional[str] = None,
366
- package_id: Optional[str] = None) -> 'Thread':
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":
367
369
  """
368
370
  Factory for creating new thread.
369
371
 
@@ -383,7 +385,7 @@ class Thread:
383
385
  createdBy=creator,
384
386
  createdAt=now_iso(),
385
387
  position=position,
386
- packageId=package_id
388
+ packageId=package_id,
387
389
  )
388
390
  if initial_comment:
389
391
  thread.add_comment(creator, initial_comment)
@@ -439,36 +441,36 @@ class Thread:
439
441
  def to_dict(self) -> Dict[str, Any]:
440
442
  """Convert to JSON-serializable dictionary"""
441
443
  result = {
442
- 'threadId': self.threadId,
443
- 'reqId': self.reqId,
444
- 'createdBy': self.createdBy,
445
- 'createdAt': self.createdAt,
446
- 'position': self.position.to_dict(),
447
- 'resolved': self.resolved,
448
- 'resolvedBy': self.resolvedBy,
449
- 'resolvedAt': self.resolvedAt,
450
- 'comments': [c.to_dict() for c in self.comments]
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],
451
453
  }
452
454
  # REQ-d00094-A: Include packageId if set
453
455
  if self.packageId is not None:
454
- result['packageId'] = self.packageId
456
+ result["packageId"] = self.packageId
455
457
  return result
456
458
 
457
459
  @classmethod
458
- def from_dict(cls, data: Dict[str, Any]) -> 'Thread':
460
+ def from_dict(cls, data: Dict[str, Any]) -> "Thread":
459
461
  """Create from dictionary"""
460
462
  return cls(
461
- threadId=data['threadId'],
462
- reqId=data['reqId'],
463
- createdBy=data['createdBy'],
464
- createdAt=data['createdAt'],
465
- position=CommentPosition.from_dict(data['position']),
466
- resolved=data.get('resolved', False),
467
- resolvedBy=data.get('resolvedBy'),
468
- resolvedAt=data.get('resolvedAt'),
469
- comments=[Comment.from_dict(c) for c in data.get('comments', [])],
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", [])],
470
472
  # REQ-d00094-A: Package ownership (optional for backward compatibility)
471
- packageId=data.get('packageId')
473
+ packageId=data.get("packageId"),
472
474
  )
473
475
 
474
476
 
@@ -480,6 +482,7 @@ class ReviewFlag:
480
482
  When a requirement is flagged, it signals that reviewers in the
481
483
  specified scope should examine it.
482
484
  """
485
+
483
486
  flaggedForReview: bool
484
487
  flaggedBy: str # Username
485
488
  flaggedAt: str # ISO 8601 datetime
@@ -487,33 +490,23 @@ class ReviewFlag:
487
490
  scope: List[str] # List of roles/users who should review
488
491
 
489
492
  @classmethod
490
- def create(cls, user: str, reason: str, scope: List[str]) -> 'ReviewFlag':
493
+ def create(cls, user: str, reason: str, scope: List[str]) -> "ReviewFlag":
491
494
  """Factory for creating new review flag"""
492
495
  return cls(
493
- flaggedForReview=True,
494
- flaggedBy=user,
495
- flaggedAt=now_iso(),
496
- reason=reason,
497
- scope=scope
496
+ flaggedForReview=True, flaggedBy=user, flaggedAt=now_iso(), reason=reason, scope=scope
498
497
  )
499
498
 
500
499
  @classmethod
501
- def cleared(cls) -> 'ReviewFlag':
500
+ def cleared(cls) -> "ReviewFlag":
502
501
  """Factory for an unflagged state"""
503
- return cls(
504
- flaggedForReview=False,
505
- flaggedBy='',
506
- flaggedAt='',
507
- reason='',
508
- scope=[]
509
- )
502
+ return cls(flaggedForReview=False, flaggedBy="", flaggedAt="", reason="", scope=[])
510
503
 
511
504
  def clear(self) -> None:
512
505
  """Clear the review flag"""
513
506
  self.flaggedForReview = False
514
- self.flaggedBy = ''
515
- self.flaggedAt = ''
516
- self.reason = ''
507
+ self.flaggedBy = ""
508
+ self.flaggedAt = ""
509
+ self.reason = ""
517
510
  self.scope = []
518
511
 
519
512
  def validate(self) -> Tuple[bool, List[str]]:
@@ -537,14 +530,14 @@ class ReviewFlag:
537
530
  return asdict(self)
538
531
 
539
532
  @classmethod
540
- def from_dict(cls, data: Dict[str, Any]) -> 'ReviewFlag':
533
+ def from_dict(cls, data: Dict[str, Any]) -> "ReviewFlag":
541
534
  """Create from dictionary"""
542
535
  return cls(
543
- flaggedForReview=data['flaggedForReview'],
544
- flaggedBy=data.get('flaggedBy', ''),
545
- flaggedAt=data.get('flaggedAt', ''),
546
- reason=data.get('reason', ''),
547
- scope=data.get('scope', [])
536
+ flaggedForReview=data["flaggedForReview"],
537
+ flaggedBy=data.get("flaggedBy", ""),
538
+ flaggedAt=data.get("flaggedAt", ""),
539
+ reason=data.get("reason", ""),
540
+ scope=data.get("scope", []),
548
541
  )
549
542
 
550
543
 
@@ -553,21 +546,16 @@ class Approval:
553
546
  """
554
547
  Single approval on a status change request.
555
548
  """
549
+
556
550
  user: str # Username
557
551
  decision: str # ApprovalDecision value
558
552
  at: str # ISO 8601 datetime
559
553
  comment: Optional[str] = None
560
554
 
561
555
  @classmethod
562
- def create(cls, user: str, decision: str,
563
- comment: Optional[str] = None) -> 'Approval':
556
+ def create(cls, user: str, decision: str, comment: Optional[str] = None) -> "Approval":
564
557
  """Factory for creating new approval"""
565
- return cls(
566
- user=user,
567
- decision=decision,
568
- at=now_iso(),
569
- comment=comment
570
- )
558
+ return cls(user=user, decision=decision, at=now_iso(), comment=comment)
571
559
 
572
560
  def validate(self) -> Tuple[bool, List[str]]:
573
561
  """Validate approval"""
@@ -584,23 +572,16 @@ class Approval:
584
572
 
585
573
  def to_dict(self) -> Dict[str, Any]:
586
574
  """Convert to JSON-serializable dictionary"""
587
- result: Dict[str, Any] = {
588
- 'user': self.user,
589
- 'decision': self.decision,
590
- 'at': self.at
591
- }
575
+ result: Dict[str, Any] = {"user": self.user, "decision": self.decision, "at": self.at}
592
576
  if self.comment is not None:
593
- result['comment'] = self.comment
577
+ result["comment"] = self.comment
594
578
  return result
595
579
 
596
580
  @classmethod
597
- def from_dict(cls, data: Dict[str, Any]) -> 'Approval':
581
+ def from_dict(cls, data: Dict[str, Any]) -> "Approval":
598
582
  """Create from dictionary"""
599
583
  return cls(
600
- user=data['user'],
601
- decision=data['decision'],
602
- at=data['at'],
603
- comment=data.get('comment')
584
+ user=data["user"], decision=data["decision"], at=data["at"], comment=data.get("comment")
604
585
  )
605
586
 
606
587
 
@@ -617,6 +598,7 @@ class StatusRequest:
617
598
  - Active -> Deprecated (requires product_owner)
618
599
  - Draft -> Deprecated (requires product_owner)
619
600
  """
601
+
620
602
  requestId: str # UUID
621
603
  reqId: str # Requirement ID
622
604
  type: str # Always "status_change"
@@ -630,9 +612,15 @@ class StatusRequest:
630
612
  state: str # RequestState value
631
613
 
632
614
  @classmethod
633
- def create(cls, req_id: str, from_status: str, to_status: str,
634
- requested_by: str, justification: str,
635
- required_approvers: Optional[List[str]] = None) -> 'StatusRequest':
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":
636
624
  """
637
625
  Factory for creating new status change request.
638
626
 
@@ -649,9 +637,7 @@ class StatusRequest:
649
637
  # Determine required approvers from defaults if not provided
650
638
  if required_approvers is None:
651
639
  transition_key = f"{from_status}->{to_status}"
652
- required_approvers = DEFAULT_APPROVAL_RULES.get(
653
- transition_key, ["product_owner"]
654
- )
640
+ required_approvers = DEFAULT_APPROVAL_RULES.get(transition_key, ["product_owner"])
655
641
 
656
642
  return cls(
657
643
  requestId=generate_uuid(),
@@ -664,11 +650,10 @@ class StatusRequest:
664
650
  justification=justification,
665
651
  approvals=[],
666
652
  requiredApprovers=required_approvers,
667
- state=RequestState.PENDING.value
653
+ state=RequestState.PENDING.value,
668
654
  )
669
655
 
670
- def add_approval(self, user: str, decision: str,
671
- comment: Optional[str] = None) -> Approval:
656
+ def add_approval(self, user: str, decision: str, comment: Optional[str] = None) -> Approval:
672
657
  """Add an approval to the request"""
673
658
  approval = Approval.create(user, decision, comment)
674
659
  self.approvals.append(approval)
@@ -692,15 +677,11 @@ class StatusRequest:
692
677
 
693
678
  # Check if all required approvers have approved
694
679
  approved_users = {
695
- a.user for a in self.approvals
696
- if a.decision == ApprovalDecision.APPROVE.value
680
+ a.user for a in self.approvals if a.decision == ApprovalDecision.APPROVE.value
697
681
  }
698
682
 
699
683
  # Check if required approvers are satisfied
700
- all_approved = all(
701
- approver in approved_users
702
- for approver in self.requiredApprovers
703
- )
684
+ all_approved = all(approver in approved_users for approver in self.requiredApprovers)
704
685
 
705
686
  if all_approved:
706
687
  self.state = RequestState.APPROVED.value
@@ -746,34 +727,34 @@ class StatusRequest:
746
727
  def to_dict(self) -> Dict[str, Any]:
747
728
  """Convert to JSON-serializable dictionary"""
748
729
  return {
749
- 'requestId': self.requestId,
750
- 'reqId': self.reqId,
751
- 'type': self.type,
752
- 'fromStatus': self.fromStatus,
753
- 'toStatus': self.toStatus,
754
- 'requestedBy': self.requestedBy,
755
- 'requestedAt': self.requestedAt,
756
- 'justification': self.justification,
757
- 'approvals': [a.to_dict() for a in self.approvals],
758
- 'requiredApprovers': self.requiredApprovers,
759
- 'state': self.state
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,
760
741
  }
761
742
 
762
743
  @classmethod
763
- def from_dict(cls, data: Dict[str, Any]) -> 'StatusRequest':
744
+ def from_dict(cls, data: Dict[str, Any]) -> "StatusRequest":
764
745
  """Create from dictionary"""
765
746
  return cls(
766
- requestId=data['requestId'],
767
- reqId=data['reqId'],
768
- type=data['type'],
769
- fromStatus=data['fromStatus'],
770
- toStatus=data['toStatus'],
771
- requestedBy=data['requestedBy'],
772
- requestedAt=data['requestedAt'],
773
- justification=data['justification'],
774
- approvals=[Approval.from_dict(a) for a in data.get('approvals', [])],
775
- requiredApprovers=data.get('requiredApprovers', []),
776
- state=data['state']
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"],
777
758
  )
778
759
 
779
760
 
@@ -784,6 +765,7 @@ class ReviewSession:
784
765
 
785
766
  Sessions help organize reviews and track progress over time.
786
767
  """
768
+
787
769
  sessionId: str # UUID
788
770
  user: str # Username
789
771
  name: str # Session name (e.g., "Sprint 23 Review")
@@ -791,15 +773,14 @@ class ReviewSession:
791
773
  description: Optional[str] = None
792
774
 
793
775
  @classmethod
794
- def create(cls, user: str, name: str,
795
- description: Optional[str] = None) -> 'ReviewSession':
776
+ def create(cls, user: str, name: str, description: Optional[str] = None) -> "ReviewSession":
796
777
  """Factory for creating new session"""
797
778
  return cls(
798
779
  sessionId=generate_uuid(),
799
780
  user=user,
800
781
  name=name,
801
782
  createdAt=now_iso(),
802
- description=description
783
+ description=description,
803
784
  )
804
785
 
805
786
  def validate(self) -> Tuple[bool, List[str]]:
@@ -820,24 +801,24 @@ class ReviewSession:
820
801
  def to_dict(self) -> Dict[str, Any]:
821
802
  """Convert to JSON-serializable dictionary"""
822
803
  result: Dict[str, Any] = {
823
- 'sessionId': self.sessionId,
824
- 'user': self.user,
825
- 'name': self.name,
826
- 'createdAt': self.createdAt
804
+ "sessionId": self.sessionId,
805
+ "user": self.user,
806
+ "name": self.name,
807
+ "createdAt": self.createdAt,
827
808
  }
828
809
  if self.description is not None:
829
- result['description'] = self.description
810
+ result["description"] = self.description
830
811
  return result
831
812
 
832
813
  @classmethod
833
- def from_dict(cls, data: Dict[str, Any]) -> 'ReviewSession':
814
+ def from_dict(cls, data: Dict[str, Any]) -> "ReviewSession":
834
815
  """Create from dictionary"""
835
816
  return cls(
836
- sessionId=data['sessionId'],
837
- user=data['user'],
838
- name=data['name'],
839
- createdAt=data['createdAt'],
840
- description=data.get('description')
817
+ sessionId=data["sessionId"],
818
+ user=data["user"],
819
+ name=data["name"],
820
+ createdAt=data["createdAt"],
821
+ description=data.get("description"),
841
822
  )
842
823
 
843
824
 
@@ -846,17 +827,16 @@ class ReviewConfig:
846
827
  """
847
828
  System configuration for the review system.
848
829
  """
830
+
849
831
  approvalRules: Dict[str, List[str]] # Status transition -> required approvers
850
832
  pushOnComment: bool = True # Auto git push when adding comments
851
833
  autoFetchOnOpen: bool = True # Auto git fetch when opening reviews
852
834
 
853
835
  @classmethod
854
- def default(cls) -> 'ReviewConfig':
836
+ def default(cls) -> "ReviewConfig":
855
837
  """Factory for default configuration"""
856
838
  return cls(
857
- approvalRules=DEFAULT_APPROVAL_RULES.copy(),
858
- pushOnComment=True,
859
- autoFetchOnOpen=True
839
+ approvalRules=DEFAULT_APPROVAL_RULES.copy(), pushOnComment=True, autoFetchOnOpen=True
860
840
  )
861
841
 
862
842
  def get_required_approvers(self, from_status: str, to_status: str) -> List[str]:
@@ -870,7 +850,7 @@ class ReviewConfig:
870
850
 
871
851
  # Validate approval rules
872
852
  for transition, approvers in self.approvalRules.items():
873
- parts = transition.split('->')
853
+ parts = transition.split("->")
874
854
  if len(parts) != 2:
875
855
  errors.append(f"Invalid transition format: {transition}")
876
856
  continue
@@ -887,18 +867,18 @@ class ReviewConfig:
887
867
  def to_dict(self) -> Dict[str, Any]:
888
868
  """Convert to JSON-serializable dictionary"""
889
869
  return {
890
- 'approvalRules': self.approvalRules,
891
- 'pushOnComment': self.pushOnComment,
892
- 'autoFetchOnOpen': self.autoFetchOnOpen
870
+ "approvalRules": self.approvalRules,
871
+ "pushOnComment": self.pushOnComment,
872
+ "autoFetchOnOpen": self.autoFetchOnOpen,
893
873
  }
894
874
 
895
875
  @classmethod
896
- def from_dict(cls, data: Dict[str, Any]) -> 'ReviewConfig':
876
+ def from_dict(cls, data: Dict[str, Any]) -> "ReviewConfig":
897
877
  """Create from dictionary"""
898
878
  return cls(
899
- approvalRules=data.get('approvalRules', DEFAULT_APPROVAL_RULES.copy()),
900
- pushOnComment=data.get('pushOnComment', True),
901
- autoFetchOnOpen=data.get('autoFetchOnOpen', True)
879
+ approvalRules=data.get("approvalRules", DEFAULT_APPROVAL_RULES.copy()),
880
+ pushOnComment=data.get("pushOnComment", True),
881
+ autoFetchOnOpen=data.get("autoFetchOnOpen", True),
902
882
  )
903
883
 
904
884
 
@@ -920,6 +900,7 @@ class ReviewPackage:
920
900
  REQ-d00097: Review Package Archival
921
901
  REQ-d00098: Review Git Audit Trail
922
902
  """
903
+
923
904
  packageId: str # UUID
924
905
  name: str
925
906
  description: str
@@ -941,9 +922,14 @@ class ReviewPackage:
941
922
  isDefault: bool = False
942
923
 
943
924
  @classmethod
944
- def create(cls, name: str, description: str, created_by: str,
945
- branch_name: Optional[str] = None,
946
- commit_hash: Optional[str] = None) -> 'ReviewPackage':
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":
947
933
  """
948
934
  Factory for creating new package.
949
935
 
@@ -967,11 +953,11 @@ class ReviewPackage:
967
953
  branchName=branch_name,
968
954
  creationCommitHash=commit_hash,
969
955
  lastReviewedCommitHash=commit_hash,
970
- isDefault=False
956
+ isDefault=False,
971
957
  )
972
958
 
973
959
  @classmethod
974
- def create_default(cls) -> 'ReviewPackage':
960
+ def create_default(cls) -> "ReviewPackage":
975
961
  """
976
962
  Create the default package.
977
963
 
@@ -979,10 +965,11 @@ class ReviewPackage:
979
965
  This method is kept for backward compatibility during migration.
980
966
  """
981
967
  import warnings
968
+
982
969
  warnings.warn(
983
970
  "create_default() is deprecated. Packages should be explicitly created.",
984
971
  DeprecationWarning,
985
- stacklevel=2
972
+ stacklevel=2,
986
973
  )
987
974
  return cls(
988
975
  packageId="default",
@@ -991,7 +978,7 @@ class ReviewPackage:
991
978
  reqIds=[],
992
979
  createdBy="system",
993
980
  createdAt=now_iso(),
994
- isDefault=True
981
+ isDefault=True,
995
982
  )
996
983
 
997
984
  def update_last_reviewed_commit(self, commit_hash: str) -> None:
@@ -1042,7 +1029,11 @@ class ReviewPackage:
1042
1029
  errors.append("archivedBy is required when archivedAt is set")
1043
1030
  if not self.archiveReason:
1044
1031
  errors.append("archiveReason is required when archivedAt is set")
1045
- elif self.archiveReason not in (ARCHIVE_REASON_RESOLVED, ARCHIVE_REASON_DELETED, ARCHIVE_REASON_MANUAL):
1032
+ elif self.archiveReason not in (
1033
+ ARCHIVE_REASON_RESOLVED,
1034
+ ARCHIVE_REASON_DELETED,
1035
+ ARCHIVE_REASON_MANUAL,
1036
+ ):
1046
1037
  errors.append(f"Invalid archiveReason: {self.archiveReason}")
1047
1038
 
1048
1039
  return len(errors) == 0, errors
@@ -1050,54 +1041,54 @@ class ReviewPackage:
1050
1041
  def to_dict(self) -> Dict[str, Any]:
1051
1042
  """Convert to JSON-serializable dictionary"""
1052
1043
  result: Dict[str, Any] = {
1053
- 'packageId': self.packageId,
1054
- 'name': self.name,
1055
- 'description': self.description,
1056
- 'reqIds': self.reqIds.copy(),
1057
- 'createdBy': self.createdBy,
1058
- 'createdAt': self.createdAt,
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,
1059
1050
  }
1060
1051
 
1061
1052
  # REQ-d00098: Git audit trail (only include if set)
1062
1053
  if self.branchName is not None:
1063
- result['branchName'] = self.branchName
1054
+ result["branchName"] = self.branchName
1064
1055
  if self.creationCommitHash is not None:
1065
- result['creationCommitHash'] = self.creationCommitHash
1056
+ result["creationCommitHash"] = self.creationCommitHash
1066
1057
  if self.lastReviewedCommitHash is not None:
1067
- result['lastReviewedCommitHash'] = self.lastReviewedCommitHash
1058
+ result["lastReviewedCommitHash"] = self.lastReviewedCommitHash
1068
1059
 
1069
1060
  # REQ-d00097: Archive metadata (only include if archived)
1070
1061
  if self.archivedAt is not None:
1071
- result['archivedAt'] = self.archivedAt
1072
- result['archivedBy'] = self.archivedBy
1073
- result['archiveReason'] = self.archiveReason
1062
+ result["archivedAt"] = self.archivedAt
1063
+ result["archivedBy"] = self.archivedBy
1064
+ result["archiveReason"] = self.archiveReason
1074
1065
 
1075
1066
  # Deprecated field - still included for backward compatibility
1076
1067
  if self.isDefault:
1077
- result['isDefault'] = self.isDefault
1068
+ result["isDefault"] = self.isDefault
1078
1069
 
1079
1070
  return result
1080
1071
 
1081
1072
  @classmethod
1082
- def from_dict(cls, data: Dict[str, Any]) -> 'ReviewPackage':
1073
+ def from_dict(cls, data: Dict[str, Any]) -> "ReviewPackage":
1083
1074
  """Create from dictionary"""
1084
1075
  return cls(
1085
- packageId=data.get('packageId', ''),
1086
- name=data.get('name', ''),
1087
- description=data.get('description', ''),
1088
- reqIds=data.get('reqIds', []).copy(),
1089
- createdBy=data.get('createdBy', ''),
1090
- createdAt=data.get('createdAt', ''),
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", ""),
1091
1082
  # REQ-d00098: Git audit trail
1092
- branchName=data.get('branchName'),
1093
- creationCommitHash=data.get('creationCommitHash'),
1094
- lastReviewedCommitHash=data.get('lastReviewedCommitHash'),
1083
+ branchName=data.get("branchName"),
1084
+ creationCommitHash=data.get("creationCommitHash"),
1085
+ lastReviewedCommitHash=data.get("lastReviewedCommitHash"),
1095
1086
  # REQ-d00097: Archive metadata
1096
- archivedAt=data.get('archivedAt'),
1097
- archivedBy=data.get('archivedBy'),
1098
- archiveReason=data.get('archiveReason'),
1087
+ archivedAt=data.get("archivedAt"),
1088
+ archivedBy=data.get("archivedBy"),
1089
+ archiveReason=data.get("archiveReason"),
1099
1090
  # Deprecated field
1100
- isDefault=data.get('isDefault', False)
1091
+ isDefault=data.get("isDefault", False),
1101
1092
  )
1102
1093
 
1103
1094
 
@@ -1106,9 +1097,11 @@ class ReviewPackage:
1106
1097
  # REQ-tv-d00010-G: Container classes with version tracking
1107
1098
  # =============================================================================
1108
1099
 
1100
+
1109
1101
  @dataclass
1110
1102
  class ThreadsFile:
1111
1103
  """Container for threads.json file contents"""
1104
+
1112
1105
  reqId: str
1113
1106
  threads: List[Thread]
1114
1107
  version: str = "1.0"
@@ -1116,24 +1109,25 @@ class ThreadsFile:
1116
1109
  def to_dict(self) -> Dict[str, Any]:
1117
1110
  """Convert to JSON-serializable dictionary"""
1118
1111
  return {
1119
- 'version': self.version,
1120
- 'reqId': self.reqId,
1121
- 'threads': [t.to_dict() for t in self.threads]
1112
+ "version": self.version,
1113
+ "reqId": self.reqId,
1114
+ "threads": [t.to_dict() for t in self.threads],
1122
1115
  }
1123
1116
 
1124
1117
  @classmethod
1125
- def from_dict(cls, data: Dict[str, Any]) -> 'ThreadsFile':
1118
+ def from_dict(cls, data: Dict[str, Any]) -> "ThreadsFile":
1126
1119
  """Create from dictionary"""
1127
1120
  return cls(
1128
- version=data.get('version', '1.0'),
1129
- reqId=data['reqId'],
1130
- threads=[Thread.from_dict(t) for t in data.get('threads', [])]
1121
+ version=data.get("version", "1.0"),
1122
+ reqId=data["reqId"],
1123
+ threads=[Thread.from_dict(t) for t in data.get("threads", [])],
1131
1124
  )
1132
1125
 
1133
1126
 
1134
1127
  @dataclass
1135
1128
  class StatusFile:
1136
1129
  """Container for status.json file contents"""
1130
+
1137
1131
  reqId: str
1138
1132
  requests: List[StatusRequest]
1139
1133
  version: str = "1.0"
@@ -1141,24 +1135,25 @@ class StatusFile:
1141
1135
  def to_dict(self) -> Dict[str, Any]:
1142
1136
  """Convert to JSON-serializable dictionary"""
1143
1137
  return {
1144
- 'version': self.version,
1145
- 'reqId': self.reqId,
1146
- 'requests': [r.to_dict() for r in self.requests]
1138
+ "version": self.version,
1139
+ "reqId": self.reqId,
1140
+ "requests": [r.to_dict() for r in self.requests],
1147
1141
  }
1148
1142
 
1149
1143
  @classmethod
1150
- def from_dict(cls, data: Dict[str, Any]) -> 'StatusFile':
1144
+ def from_dict(cls, data: Dict[str, Any]) -> "StatusFile":
1151
1145
  """Create from dictionary"""
1152
1146
  return cls(
1153
- version=data.get('version', '1.0'),
1154
- reqId=data['reqId'],
1155
- requests=[StatusRequest.from_dict(r) for r in data.get('requests', [])]
1147
+ version=data.get("version", "1.0"),
1148
+ reqId=data["reqId"],
1149
+ requests=[StatusRequest.from_dict(r) for r in data.get("requests", [])],
1156
1150
  )
1157
1151
 
1158
1152
 
1159
1153
  @dataclass
1160
1154
  class PackagesFile:
1161
1155
  """Container for packages.json file contents"""
1156
+
1162
1157
  packages: List[ReviewPackage]
1163
1158
  activePackageId: Optional[str] = None
1164
1159
  version: str = "1.0"
@@ -1166,19 +1161,19 @@ class PackagesFile:
1166
1161
  def to_dict(self) -> Dict[str, Any]:
1167
1162
  """Convert to JSON-serializable dictionary"""
1168
1163
  return {
1169
- 'version': self.version,
1170
- 'packages': [p.to_dict() for p in self.packages],
1171
- 'activePackageId': self.activePackageId
1164
+ "version": self.version,
1165
+ "packages": [p.to_dict() for p in self.packages],
1166
+ "activePackageId": self.activePackageId,
1172
1167
  }
1173
1168
 
1174
1169
  @classmethod
1175
- def from_dict(cls, data: Dict[str, Any]) -> 'PackagesFile':
1170
+ def from_dict(cls, data: Dict[str, Any]) -> "PackagesFile":
1176
1171
  """Create from dictionary"""
1177
- packages = [ReviewPackage.from_dict(p) for p in data.get('packages', [])]
1172
+ packages = [ReviewPackage.from_dict(p) for p in data.get("packages", [])]
1178
1173
  return cls(
1179
- version=data.get('version', '1.0'),
1174
+ version=data.get("version", "1.0"),
1180
1175
  packages=packages,
1181
- activePackageId=data.get('activePackageId')
1176
+ activePackageId=data.get("activePackageId"),
1182
1177
  )
1183
1178
 
1184
1179
  def get_default(self) -> Optional[ReviewPackage]: