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,1343 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Review Storage Operations Module for trace_view
4
-
5
- CRUD operations for the review system:
6
- - Config operations (load/save)
7
- - Review flag operations (load/save)
8
- - Thread operations (load/save/add/resolve/unresolve)
9
- - Status request operations (load/save/create/approve/apply)
10
- - Package operations (load/save/create/update/delete)
11
- - Merge operations for combining multiple user branches
12
-
13
- IMPLEMENTS REQUIREMENTS:
14
- REQ-tv-d00011: Review Storage Operations
15
- """
16
-
17
- import json
18
- import os
19
- import re
20
- import tempfile
21
- from pathlib import Path
22
- from typing import Any, Dict, List, Optional
23
-
24
- from .models import (
25
- Approval,
26
- Comment,
27
- PackagesFile,
28
- ReviewConfig,
29
- ReviewFlag,
30
- ReviewPackage,
31
- StatusFile,
32
- StatusRequest,
33
- Thread,
34
- ThreadsFile,
35
- parse_iso_datetime,
36
- )
37
-
38
- # =============================================================================
39
- # Helper Functions
40
- # REQ-tv-d00011-A: Atomic write operations
41
- # =============================================================================
42
-
43
-
44
- def atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
45
- """
46
- Atomically write JSON data to a file.
47
-
48
- REQ-tv-d00011-A: Uses temp file + rename pattern to ensure file is either
49
- fully written or not changed at all.
50
-
51
- Args:
52
- path: Target file path
53
- data: JSON-serializable dictionary
54
- """
55
- # Ensure parent directories exist
56
- path.parent.mkdir(parents=True, exist_ok=True)
57
-
58
- # Write to temp file in same directory (for atomic rename)
59
- fd, temp_path = tempfile.mkstemp(suffix=".json", prefix=".tmp_", dir=path.parent)
60
- try:
61
- with os.fdopen(fd, "w") as f:
62
- json.dump(data, f, indent=2)
63
- # Atomic rename
64
- os.rename(temp_path, path)
65
- except Exception:
66
- # Clean up temp file on failure
67
- if os.path.exists(temp_path):
68
- os.unlink(temp_path)
69
- raise
70
-
71
-
72
- def read_json(path: Path) -> Dict[str, Any]:
73
- """
74
- Read JSON file and return dictionary.
75
-
76
- Args:
77
- path: Path to JSON file
78
-
79
- Returns:
80
- Parsed JSON as dictionary
81
-
82
- Raises:
83
- FileNotFoundError: If file doesn't exist
84
- json.JSONDecodeError: If file contains invalid JSON
85
- """
86
- with open(path) as f:
87
- return json.load(f)
88
-
89
-
90
- # =============================================================================
91
- # Path Functions
92
- # REQ-tv-d00011-H: Storage paths convention
93
- # REQ-tv-d00011-I: Requirement ID normalization
94
- # =============================================================================
95
-
96
-
97
- def normalize_req_id(req_id: str) -> str:
98
- """
99
- Normalize requirement ID for use in file paths.
100
-
101
- REQ-tv-d00011-I: Replace colons and slashes with underscores.
102
-
103
- Args:
104
- req_id: Original requirement ID
105
-
106
- Returns:
107
- Normalized requirement ID safe for file paths
108
- """
109
- return re.sub(r"[:/]", "_", req_id)
110
-
111
-
112
- def get_reviews_root(repo_root: Path) -> Path:
113
- """
114
- Get the root directory for review storage.
115
-
116
- REQ-tv-d00011-H: Returns .reviews directory.
117
-
118
- Args:
119
- repo_root: Repository root path
120
-
121
- Returns:
122
- Path to .reviews directory
123
- """
124
- return repo_root / ".reviews"
125
-
126
-
127
- def get_req_dir(repo_root: Path, req_id: str) -> Path:
128
- """
129
- Get the directory for a specific requirement's review data.
130
-
131
- REQ-tv-d00011-H: Returns .reviews/reqs/{normalized-req-id}/
132
-
133
- Args:
134
- repo_root: Repository root path
135
- req_id: Requirement ID
136
-
137
- Returns:
138
- Path to requirement's review directory
139
- """
140
- normalized = normalize_req_id(req_id)
141
- return get_reviews_root(repo_root) / "reqs" / normalized
142
-
143
-
144
- def get_threads_path(repo_root: Path, req_id: str) -> Path:
145
- """
146
- Get path to threads.json file for a requirement.
147
-
148
- REQ-tv-d00011-H: Returns .reviews/reqs/{normalized-req-id}/threads.json
149
-
150
- Args:
151
- repo_root: Repository root path
152
- req_id: Requirement ID
153
-
154
- Returns:
155
- Path to threads.json
156
- """
157
- return get_req_dir(repo_root, req_id) / "threads.json"
158
-
159
-
160
- def get_status_path(repo_root: Path, req_id: str) -> Path:
161
- """
162
- Get path to status.json file for a requirement.
163
-
164
- REQ-tv-d00011-H: Returns .reviews/reqs/{normalized-req-id}/status.json
165
-
166
- Args:
167
- repo_root: Repository root path
168
- req_id: Requirement ID
169
-
170
- Returns:
171
- Path to status.json
172
- """
173
- return get_req_dir(repo_root, req_id) / "status.json"
174
-
175
-
176
- def get_review_flag_path(repo_root: Path, req_id: str) -> Path:
177
- """
178
- Get path to flag.json file for a requirement.
179
-
180
- REQ-tv-d00011-H: Returns .reviews/reqs/{normalized-req-id}/flag.json
181
-
182
- Args:
183
- repo_root: Repository root path
184
- req_id: Requirement ID
185
-
186
- Returns:
187
- Path to flag.json
188
- """
189
- return get_req_dir(repo_root, req_id) / "flag.json"
190
-
191
-
192
- def get_config_path(repo_root: Path) -> Path:
193
- """
194
- Get path to config.json file.
195
-
196
- REQ-tv-d00011-H: Returns .reviews/config.json
197
-
198
- Args:
199
- repo_root: Repository root path
200
-
201
- Returns:
202
- Path to config.json
203
- """
204
- return get_reviews_root(repo_root) / "config.json"
205
-
206
-
207
- def get_packages_path(repo_root: Path) -> Path:
208
- """
209
- Get path to packages.json file (v1 format).
210
-
211
- REQ-tv-d00011-H: Returns .reviews/packages.json
212
-
213
- Args:
214
- repo_root: Repository root path
215
-
216
- Returns:
217
- Path to packages.json
218
- """
219
- return get_reviews_root(repo_root) / "packages.json"
220
-
221
-
222
- # =============================================================================
223
- # V2 Path Functions (Package-Centric Storage)
224
- # REQ-d00096: Review Storage Architecture
225
- # =============================================================================
226
-
227
-
228
- def get_index_path(repo_root: Path) -> Path:
229
- """
230
- Get path to index.json file (v2 format).
231
-
232
- REQ-d00096-D: Returns .reviews/index.json
233
-
234
- Args:
235
- repo_root: Repository root path
236
-
237
- Returns:
238
- Path to index.json
239
- """
240
- return get_reviews_root(repo_root) / "index.json"
241
-
242
-
243
- def get_package_dir(repo_root: Path, package_id: str) -> Path:
244
- """
245
- Get the directory for a specific package (v2 format).
246
-
247
- REQ-d00096-A: Returns .reviews/packages/{pkg-id}/
248
-
249
- Args:
250
- repo_root: Repository root path
251
- package_id: Package UUID
252
-
253
- Returns:
254
- Path to package directory
255
- """
256
- return get_reviews_root(repo_root) / "packages" / package_id
257
-
258
-
259
- def get_package_metadata_path(repo_root: Path, package_id: str) -> Path:
260
- """
261
- Get path to package.json file for a package (v2 format).
262
-
263
- REQ-d00096-B: Returns .reviews/packages/{pkg-id}/package.json
264
-
265
- Args:
266
- repo_root: Repository root path
267
- package_id: Package UUID
268
-
269
- Returns:
270
- Path to package.json
271
- """
272
- return get_package_dir(repo_root, package_id) / "package.json"
273
-
274
-
275
- def get_package_threads_path(repo_root: Path, package_id: str, req_id: str) -> Path:
276
- """
277
- Get path to threads.json file for a requirement within a package (v2 format).
278
-
279
- REQ-d00096-C: Returns .reviews/packages/{pkg-id}/reqs/{req-id}/threads.json
280
-
281
- Args:
282
- repo_root: Repository root path
283
- package_id: Package UUID
284
- req_id: Requirement ID
285
-
286
- Returns:
287
- Path to threads.json
288
- """
289
- normalized = normalize_req_id(req_id)
290
- return get_package_dir(repo_root, package_id) / "reqs" / normalized / "threads.json"
291
-
292
-
293
- def get_archive_dir(repo_root: Path) -> Path:
294
- """
295
- Get the root directory for archived packages.
296
-
297
- REQ-d00097-A: Returns .reviews/archive/
298
-
299
- Args:
300
- repo_root: Repository root path
301
-
302
- Returns:
303
- Path to archive directory
304
- """
305
- return get_reviews_root(repo_root) / "archive"
306
-
307
-
308
- def get_archived_package_dir(repo_root: Path, package_id: str) -> Path:
309
- """
310
- Get the directory for an archived package.
311
-
312
- REQ-d00097-A: Returns .reviews/archive/{pkg-id}/
313
-
314
- Args:
315
- repo_root: Repository root path
316
- package_id: Package UUID
317
-
318
- Returns:
319
- Path to archived package directory
320
- """
321
- return get_archive_dir(repo_root) / package_id
322
-
323
-
324
- def get_archived_package_metadata_path(repo_root: Path, package_id: str) -> Path:
325
- """
326
- Get path to package.json file for an archived package.
327
-
328
- REQ-d00097-B: Returns .reviews/archive/{pkg-id}/package.json
329
-
330
- Args:
331
- repo_root: Repository root path
332
- package_id: Package UUID
333
-
334
- Returns:
335
- Path to archived package.json
336
- """
337
- return get_archived_package_dir(repo_root, package_id) / "package.json"
338
-
339
-
340
- def get_archived_package_threads_path(repo_root: Path, package_id: str, req_id: str) -> Path:
341
- """
342
- Get path to threads.json file for a requirement within an archived package.
343
-
344
- REQ-d00097-B: Returns .reviews/archive/{pkg-id}/reqs/{req-id}/threads.json
345
-
346
- Args:
347
- repo_root: Repository root path
348
- package_id: Package UUID
349
- req_id: Requirement ID
350
-
351
- Returns:
352
- Path to archived threads.json
353
- """
354
- normalized = normalize_req_id(req_id)
355
- return get_archived_package_dir(repo_root, package_id) / "reqs" / normalized / "threads.json"
356
-
357
-
358
- # =============================================================================
359
- # Config Operations
360
- # REQ-tv-d00011-F: Config storage operations
361
- # =============================================================================
362
-
363
-
364
- def load_config(repo_root: Path) -> ReviewConfig:
365
- """
366
- Load review system configuration.
367
-
368
- REQ-tv-d00011-F: Returns default config if file doesn't exist.
369
-
370
- Args:
371
- repo_root: Repository root path
372
-
373
- Returns:
374
- ReviewConfig instance
375
- """
376
- config_path = get_config_path(repo_root)
377
- if not config_path.exists():
378
- return ReviewConfig.default()
379
- data = read_json(config_path)
380
- return ReviewConfig.from_dict(data)
381
-
382
-
383
- def save_config(repo_root: Path, config: ReviewConfig) -> None:
384
- """
385
- Save review system configuration.
386
-
387
- REQ-tv-d00011-F: Uses atomic write for safety.
388
-
389
- Args:
390
- repo_root: Repository root path
391
- config: ReviewConfig instance to save
392
- """
393
- config_path = get_config_path(repo_root)
394
- atomic_write_json(config_path, config.to_dict())
395
-
396
-
397
- # =============================================================================
398
- # Review Flag Operations
399
- # REQ-tv-d00011-D: Review flag storage operations
400
- # =============================================================================
401
-
402
-
403
- def load_review_flag(repo_root: Path, req_id: str) -> ReviewFlag:
404
- """
405
- Load review flag for a requirement.
406
-
407
- REQ-tv-d00011-D: Returns cleared flag if file doesn't exist.
408
-
409
- Args:
410
- repo_root: Repository root path
411
- req_id: Requirement ID
412
-
413
- Returns:
414
- ReviewFlag instance
415
- """
416
- flag_path = get_review_flag_path(repo_root, req_id)
417
- if not flag_path.exists():
418
- return ReviewFlag.cleared()
419
- data = read_json(flag_path)
420
- return ReviewFlag.from_dict(data)
421
-
422
-
423
- def save_review_flag(repo_root: Path, req_id: str, flag: ReviewFlag) -> None:
424
- """
425
- Save review flag for a requirement.
426
-
427
- REQ-tv-d00011-D: Uses atomic write for safety.
428
-
429
- Args:
430
- repo_root: Repository root path
431
- req_id: Requirement ID
432
- flag: ReviewFlag instance to save
433
- """
434
- flag_path = get_review_flag_path(repo_root, req_id)
435
- atomic_write_json(flag_path, flag.to_dict())
436
-
437
-
438
- # =============================================================================
439
- # Thread Operations
440
- # REQ-tv-d00011-B: Thread storage operations
441
- # =============================================================================
442
-
443
-
444
- def load_threads(repo_root: Path, req_id: str) -> ThreadsFile:
445
- """
446
- Load threads for a requirement.
447
-
448
- REQ-tv-d00011-B: Returns empty threads file if doesn't exist.
449
-
450
- Args:
451
- repo_root: Repository root path
452
- req_id: Requirement ID
453
-
454
- Returns:
455
- ThreadsFile instance
456
- """
457
- normalized_id = normalize_req_id(req_id)
458
- threads_path = get_threads_path(repo_root, req_id)
459
- if not threads_path.exists():
460
- return ThreadsFile(reqId=normalized_id, threads=[])
461
- data = read_json(threads_path)
462
- return ThreadsFile.from_dict(data)
463
-
464
-
465
- def save_threads(repo_root: Path, req_id: str, threads_file: ThreadsFile) -> None:
466
- """
467
- Save threads file for a requirement.
468
-
469
- REQ-tv-d00011-B: Uses atomic write for safety.
470
-
471
- Args:
472
- repo_root: Repository root path
473
- req_id: Requirement ID
474
- threads_file: ThreadsFile instance to save
475
- """
476
- threads_path = get_threads_path(repo_root, req_id)
477
- atomic_write_json(threads_path, threads_file.to_dict())
478
-
479
-
480
- def add_thread(repo_root: Path, req_id: str, thread: Thread) -> Thread:
481
- """
482
- Add a new thread to a requirement.
483
-
484
- REQ-tv-d00011-B: Creates file if needed and appends thread.
485
-
486
- Args:
487
- repo_root: Repository root path
488
- req_id: Requirement ID
489
- thread: Thread to add
490
-
491
- Returns:
492
- The added thread
493
- """
494
- threads_file = load_threads(repo_root, req_id)
495
- threads_file.threads.append(thread)
496
- save_threads(repo_root, req_id, threads_file)
497
- return thread
498
-
499
-
500
- def add_comment_to_thread(
501
- repo_root: Path, req_id: str, thread_id: str, author: str, body: str
502
- ) -> Comment:
503
- """
504
- Add a comment to an existing thread.
505
-
506
- REQ-tv-d00011-B: Persists comment and returns it.
507
-
508
- Args:
509
- repo_root: Repository root path
510
- req_id: Requirement ID
511
- thread_id: Thread UUID
512
- author: Comment author username
513
- body: Comment body text
514
-
515
- Returns:
516
- The created comment
517
-
518
- Raises:
519
- ValueError: If thread not found
520
- """
521
- threads_file = load_threads(repo_root, req_id)
522
-
523
- # Find the thread
524
- thread = None
525
- for t in threads_file.threads:
526
- if t.threadId == thread_id:
527
- thread = t
528
- break
529
-
530
- if thread is None:
531
- raise ValueError(f"Thread not found: {thread_id}")
532
-
533
- comment = thread.add_comment(author, body)
534
- save_threads(repo_root, req_id, threads_file)
535
- return comment
536
-
537
-
538
- def resolve_thread(repo_root: Path, req_id: str, thread_id: str, user: str) -> bool:
539
- """
540
- Mark a thread as resolved.
541
-
542
- REQ-tv-d00011-B: Persists resolution state.
543
-
544
- Args:
545
- repo_root: Repository root path
546
- req_id: Requirement ID
547
- thread_id: Thread UUID
548
- user: Username resolving the thread
549
-
550
- Returns:
551
- True if resolved, False if thread not found
552
- """
553
- threads_file = load_threads(repo_root, req_id)
554
-
555
- for thread in threads_file.threads:
556
- if thread.threadId == thread_id:
557
- thread.resolve(user)
558
- save_threads(repo_root, req_id, threads_file)
559
- return True
560
-
561
- return False
562
-
563
-
564
- def unresolve_thread(repo_root: Path, req_id: str, thread_id: str) -> bool:
565
- """
566
- Mark a thread as unresolved.
567
-
568
- REQ-tv-d00011-B: Persists unresolved state.
569
-
570
- Args:
571
- repo_root: Repository root path
572
- req_id: Requirement ID
573
- thread_id: Thread UUID
574
-
575
- Returns:
576
- True if unresolved, False if thread not found
577
- """
578
- threads_file = load_threads(repo_root, req_id)
579
-
580
- for thread in threads_file.threads:
581
- if thread.threadId == thread_id:
582
- thread.unresolve()
583
- save_threads(repo_root, req_id, threads_file)
584
- return True
585
-
586
- return False
587
-
588
-
589
- # =============================================================================
590
- # Status Request Operations
591
- # REQ-tv-d00011-C: Status request storage operations
592
- # =============================================================================
593
-
594
-
595
- def load_status_requests(repo_root: Path, req_id: str) -> StatusFile:
596
- """
597
- Load status requests for a requirement.
598
-
599
- REQ-tv-d00011-C: Returns empty status file if doesn't exist.
600
-
601
- Args:
602
- repo_root: Repository root path
603
- req_id: Requirement ID
604
-
605
- Returns:
606
- StatusFile instance
607
- """
608
- normalized_id = normalize_req_id(req_id)
609
- status_path = get_status_path(repo_root, req_id)
610
- if not status_path.exists():
611
- return StatusFile(reqId=normalized_id, requests=[])
612
- data = read_json(status_path)
613
- return StatusFile.from_dict(data)
614
-
615
-
616
- def save_status_requests(repo_root: Path, req_id: str, status_file: StatusFile) -> None:
617
- """
618
- Save status requests file for a requirement.
619
-
620
- REQ-tv-d00011-C: Uses atomic write for safety.
621
-
622
- Args:
623
- repo_root: Repository root path
624
- req_id: Requirement ID
625
- status_file: StatusFile instance to save
626
- """
627
- status_path = get_status_path(repo_root, req_id)
628
- atomic_write_json(status_path, status_file.to_dict())
629
-
630
-
631
- def create_status_request(repo_root: Path, req_id: str, request: StatusRequest) -> StatusRequest:
632
- """
633
- Create a new status change request.
634
-
635
- REQ-tv-d00011-C: Persists request and returns it.
636
-
637
- Args:
638
- repo_root: Repository root path
639
- req_id: Requirement ID
640
- request: StatusRequest to create
641
-
642
- Returns:
643
- The created request
644
- """
645
- status_file = load_status_requests(repo_root, req_id)
646
- status_file.requests.append(request)
647
- save_status_requests(repo_root, req_id, status_file)
648
- return request
649
-
650
-
651
- def add_approval(
652
- repo_root: Path,
653
- req_id: str,
654
- request_id: str,
655
- user: str,
656
- decision: str,
657
- comment: Optional[str] = None,
658
- ) -> Approval:
659
- """
660
- Add an approval to a status request.
661
-
662
- REQ-tv-d00011-C: Persists approval and returns it.
663
-
664
- Args:
665
- repo_root: Repository root path
666
- req_id: Requirement ID
667
- request_id: Request UUID
668
- user: Approving user
669
- decision: "approve" or "reject"
670
- comment: Optional comment
671
-
672
- Returns:
673
- The created approval
674
-
675
- Raises:
676
- ValueError: If request not found
677
- """
678
- status_file = load_status_requests(repo_root, req_id)
679
-
680
- # Find the request
681
- request = None
682
- for r in status_file.requests:
683
- if r.requestId == request_id:
684
- request = r
685
- break
686
-
687
- if request is None:
688
- raise ValueError(f"Status request not found: {request_id}")
689
-
690
- approval = request.add_approval(user, decision, comment)
691
- save_status_requests(repo_root, req_id, status_file)
692
- return approval
693
-
694
-
695
- def mark_request_applied(repo_root: Path, req_id: str, request_id: str) -> bool:
696
- """
697
- Mark a status request as applied.
698
-
699
- REQ-tv-d00011-C: Persists applied state.
700
-
701
- Args:
702
- repo_root: Repository root path
703
- req_id: Requirement ID
704
- request_id: Request UUID
705
-
706
- Returns:
707
- True if marked applied, False if not found
708
-
709
- Raises:
710
- ValueError: If request is not in approved state
711
- """
712
- status_file = load_status_requests(repo_root, req_id)
713
-
714
- for request in status_file.requests:
715
- if request.requestId == request_id:
716
- request.mark_applied() # This raises ValueError if not approved
717
- save_status_requests(repo_root, req_id, status_file)
718
- return True
719
-
720
- return False
721
-
722
-
723
- # =============================================================================
724
- # Package Operations
725
- # REQ-tv-d00011-E: Package storage operations
726
- # =============================================================================
727
-
728
-
729
- def load_packages(repo_root: Path) -> PackagesFile:
730
- """
731
- Load packages file.
732
-
733
- REQ-tv-d00011-E: Returns file with default package if doesn't exist.
734
-
735
- Args:
736
- repo_root: Repository root path
737
-
738
- Returns:
739
- PackagesFile instance
740
- """
741
- packages_path = get_packages_path(repo_root)
742
- if not packages_path.exists():
743
- # Create default package
744
- default_pkg = ReviewPackage.create_default()
745
- return PackagesFile(packages=[default_pkg])
746
- data = read_json(packages_path)
747
- packages_file = PackagesFile.from_dict(data)
748
-
749
- # Ensure default package exists
750
- if packages_file.get_default() is None:
751
- default_pkg = ReviewPackage.create_default()
752
- packages_file.packages.insert(0, default_pkg)
753
-
754
- return packages_file
755
-
756
-
757
- def save_packages(repo_root: Path, packages_file: PackagesFile) -> None:
758
- """
759
- Save packages file.
760
-
761
- REQ-tv-d00011-E: Uses atomic write for safety.
762
-
763
- Args:
764
- repo_root: Repository root path
765
- packages_file: PackagesFile instance to save
766
- """
767
- packages_path = get_packages_path(repo_root)
768
- atomic_write_json(packages_path, packages_file.to_dict())
769
-
770
-
771
- def create_package(repo_root: Path, package: ReviewPackage) -> ReviewPackage:
772
- """
773
- Create a new package.
774
-
775
- REQ-tv-d00011-E: Persists package and returns it.
776
-
777
- Args:
778
- repo_root: Repository root path
779
- package: ReviewPackage to create
780
-
781
- Returns:
782
- The created package
783
- """
784
- packages_file = load_packages(repo_root)
785
- packages_file.packages.append(package)
786
- save_packages(repo_root, packages_file)
787
- return package
788
-
789
-
790
- def update_package(repo_root: Path, package: ReviewPackage) -> bool:
791
- """
792
- Update an existing package.
793
-
794
- REQ-tv-d00011-E: Persists updated package.
795
-
796
- Args:
797
- repo_root: Repository root path
798
- package: ReviewPackage with updated data
799
-
800
- Returns:
801
- True if updated, False if package not found
802
- """
803
- packages_file = load_packages(repo_root)
804
-
805
- for i, p in enumerate(packages_file.packages):
806
- if p.packageId == package.packageId:
807
- packages_file.packages[i] = package
808
- save_packages(repo_root, packages_file)
809
- return True
810
-
811
- return False
812
-
813
-
814
- def delete_package(repo_root: Path, package_id: str) -> bool:
815
- """
816
- Delete a package by ID.
817
-
818
- REQ-tv-d00011-E: Removes package and persists change.
819
-
820
- Args:
821
- repo_root: Repository root path
822
- package_id: Package UUID
823
-
824
- Returns:
825
- True if deleted, False if package not found
826
- """
827
- packages_file = load_packages(repo_root)
828
-
829
- for i, p in enumerate(packages_file.packages):
830
- if p.packageId == package_id:
831
- del packages_file.packages[i]
832
- save_packages(repo_root, packages_file)
833
- return True
834
-
835
- return False
836
-
837
-
838
- def add_req_to_package(repo_root: Path, package_id: str, req_id: str) -> bool:
839
- """
840
- Add a requirement ID to a package.
841
-
842
- REQ-tv-d00011-E: Prevents duplicates.
843
-
844
- Args:
845
- repo_root: Repository root path
846
- package_id: Package UUID
847
- req_id: Requirement ID to add
848
-
849
- Returns:
850
- True if added, False if package not found
851
- """
852
- packages_file = load_packages(repo_root)
853
-
854
- for package in packages_file.packages:
855
- if package.packageId == package_id:
856
- if req_id not in package.reqIds:
857
- package.reqIds.append(req_id)
858
- save_packages(repo_root, packages_file)
859
- return True
860
-
861
- return False
862
-
863
-
864
- def remove_req_from_package(repo_root: Path, package_id: str, req_id: str) -> bool:
865
- """
866
- Remove a requirement ID from a package.
867
-
868
- REQ-tv-d00011-E: Persists change.
869
-
870
- Args:
871
- repo_root: Repository root path
872
- package_id: Package UUID
873
- req_id: Requirement ID to remove
874
-
875
- Returns:
876
- True if removed, False if package not found
877
- """
878
- packages_file = load_packages(repo_root)
879
-
880
- for package in packages_file.packages:
881
- if package.packageId == package_id:
882
- if req_id in package.reqIds:
883
- package.reqIds.remove(req_id)
884
- save_packages(repo_root, packages_file)
885
- return True
886
-
887
- return False
888
-
889
-
890
- # =============================================================================
891
- # Merge Operations
892
- # REQ-tv-d00011-G: Merge operations
893
- # REQ-tv-d00011-J: Deduplication and timestamp-based conflict resolution
894
- # =============================================================================
895
-
896
-
897
- def merge_threads(local: ThreadsFile, remote: ThreadsFile) -> ThreadsFile:
898
- """
899
- Merge thread files from local and remote.
900
-
901
- REQ-tv-d00011-G: Combines data from multiple user branches.
902
- REQ-tv-d00011-J: Deduplicates by ID and uses timestamp-based conflict resolution.
903
-
904
- Strategy:
905
- - Unique threads (by threadId) are combined
906
- - Matching threads merge their comments (by comment id)
907
- - Resolution state: if either is resolved, keep resolved
908
-
909
- Args:
910
- local: Local threads file
911
- remote: Remote threads file
912
-
913
- Returns:
914
- Merged ThreadsFile
915
- """
916
- # Build map of local threads by ID
917
- local_map: Dict[str, Thread] = {t.threadId: t for t in local.threads}
918
-
919
- merged_threads: List[Thread] = []
920
-
921
- # Process all remote threads
922
- for remote_thread in remote.threads:
923
- if remote_thread.threadId in local_map:
924
- # Merge the threads
925
- local_thread = local_map.pop(remote_thread.threadId)
926
- merged_thread = _merge_single_thread(local_thread, remote_thread)
927
- merged_threads.append(merged_thread)
928
- else:
929
- # Only in remote
930
- merged_threads.append(remote_thread)
931
-
932
- # Add remaining local-only threads
933
- for local_thread in local_map.values():
934
- merged_threads.append(local_thread)
935
-
936
- return ThreadsFile(reqId=local.reqId, threads=merged_threads)
937
-
938
-
939
- def _merge_single_thread(local: Thread, remote: Thread) -> Thread:
940
- """
941
- Merge two versions of the same thread.
942
-
943
- REQ-tv-d00011-J: Deduplicates comments by ID and sorts by timestamp.
944
- """
945
- # Merge comments by ID
946
- local_comment_map = {c.id: c for c in local.comments}
947
- remote_comment_map = {c.id: c for c in remote.comments}
948
-
949
- all_comment_ids = set(local_comment_map.keys()) | set(remote_comment_map.keys())
950
- merged_comments = []
951
-
952
- for comment_id in all_comment_ids:
953
- if comment_id in local_comment_map:
954
- merged_comments.append(local_comment_map[comment_id])
955
- else:
956
- merged_comments.append(remote_comment_map[comment_id])
957
-
958
- # Sort comments by timestamp
959
- merged_comments.sort(key=lambda c: parse_iso_datetime(c.timestamp))
960
-
961
- # Resolution: if either resolved, keep resolved (prefer whichever has the state)
962
- resolved = local.resolved or remote.resolved
963
- resolved_by = remote.resolvedBy if remote.resolved else local.resolvedBy
964
- resolved_at = remote.resolvedAt if remote.resolved else local.resolvedAt
965
-
966
- return Thread(
967
- threadId=local.threadId,
968
- reqId=local.reqId,
969
- createdBy=local.createdBy,
970
- createdAt=local.createdAt,
971
- position=local.position, # Use local position
972
- resolved=resolved,
973
- resolvedBy=resolved_by,
974
- resolvedAt=resolved_at,
975
- comments=merged_comments,
976
- )
977
-
978
-
979
- def merge_status_files(local: StatusFile, remote: StatusFile) -> StatusFile:
980
- """
981
- Merge status files from local and remote.
982
-
983
- REQ-tv-d00011-G: Combines data from multiple user branches.
984
- REQ-tv-d00011-J: Deduplicates by ID and uses timestamp-based conflict resolution.
985
-
986
- Strategy:
987
- - Unique requests (by requestId) are combined
988
- - Matching requests merge their approvals
989
- - State is recalculated based on merged approvals
990
-
991
- Args:
992
- local: Local status file
993
- remote: Remote status file
994
-
995
- Returns:
996
- Merged StatusFile
997
- """
998
- # Build map of local requests by ID
999
- local_map: Dict[str, StatusRequest] = {r.requestId: r for r in local.requests}
1000
-
1001
- merged_requests: List[StatusRequest] = []
1002
-
1003
- # Process all remote requests
1004
- for remote_request in remote.requests:
1005
- if remote_request.requestId in local_map:
1006
- # Merge the requests
1007
- local_request = local_map.pop(remote_request.requestId)
1008
- merged_request = _merge_single_request(local_request, remote_request)
1009
- merged_requests.append(merged_request)
1010
- else:
1011
- # Only in remote
1012
- merged_requests.append(remote_request)
1013
-
1014
- # Add remaining local-only requests
1015
- for local_request in local_map.values():
1016
- merged_requests.append(local_request)
1017
-
1018
- return StatusFile(reqId=local.reqId, requests=merged_requests)
1019
-
1020
-
1021
- def _merge_single_request(local: StatusRequest, remote: StatusRequest) -> StatusRequest:
1022
- """
1023
- Merge two versions of the same status request.
1024
-
1025
- REQ-tv-d00011-J: Uses timestamp-based conflict resolution for approvals.
1026
- """
1027
- # Merge approvals by user (later approval wins)
1028
- local_approval_map = {a.user: a for a in local.approvals}
1029
- remote_approval_map = {a.user: a for a in remote.approvals}
1030
-
1031
- all_users = set(local_approval_map.keys()) | set(remote_approval_map.keys())
1032
- merged_approvals = []
1033
-
1034
- for user in all_users:
1035
- local_approval = local_approval_map.get(user)
1036
- remote_approval = remote_approval_map.get(user)
1037
-
1038
- if local_approval and remote_approval:
1039
- # Take the later one (timestamp-based conflict resolution)
1040
- local_time = parse_iso_datetime(local_approval.at)
1041
- remote_time = parse_iso_datetime(remote_approval.at)
1042
- if remote_time >= local_time:
1043
- merged_approvals.append(remote_approval)
1044
- else:
1045
- merged_approvals.append(local_approval)
1046
- elif local_approval:
1047
- merged_approvals.append(local_approval)
1048
- else:
1049
- merged_approvals.append(remote_approval)
1050
-
1051
- # Create merged request
1052
- merged = StatusRequest(
1053
- requestId=local.requestId,
1054
- reqId=local.reqId,
1055
- type=local.type,
1056
- fromStatus=local.fromStatus,
1057
- toStatus=local.toStatus,
1058
- requestedBy=local.requestedBy,
1059
- requestedAt=local.requestedAt,
1060
- justification=local.justification,
1061
- approvals=merged_approvals,
1062
- requiredApprovers=local.requiredApprovers,
1063
- state=local.state, # Will be recalculated
1064
- )
1065
-
1066
- # Recalculate state based on merged approvals
1067
- merged._update_state()
1068
-
1069
- return merged
1070
-
1071
-
1072
- def merge_review_flags(local: ReviewFlag, remote: ReviewFlag) -> ReviewFlag:
1073
- """
1074
- Merge review flags from local and remote.
1075
-
1076
- REQ-tv-d00011-G: Combines data from multiple user branches.
1077
- REQ-tv-d00011-J: Uses timestamp-based conflict resolution.
1078
-
1079
- Strategy:
1080
- - If neither flagged, return unflagged
1081
- - If only one flagged, return that one
1082
- - If both flagged, take newer flag but merge scopes
1083
-
1084
- Args:
1085
- local: Local review flag
1086
- remote: Remote review flag
1087
-
1088
- Returns:
1089
- Merged ReviewFlag
1090
- """
1091
- # Neither flagged
1092
- if not local.flaggedForReview and not remote.flaggedForReview:
1093
- return ReviewFlag.cleared()
1094
-
1095
- # Only one flagged
1096
- if not local.flaggedForReview:
1097
- return remote
1098
- if not remote.flaggedForReview:
1099
- return local
1100
-
1101
- # Both flagged - take newer but merge scopes
1102
- local_time = parse_iso_datetime(local.flaggedAt)
1103
- remote_time = parse_iso_datetime(remote.flaggedAt)
1104
-
1105
- # Merge scopes (unique values)
1106
- merged_scope = list(set(local.scope) | set(remote.scope))
1107
-
1108
- if remote_time >= local_time:
1109
- # Remote is newer
1110
- return ReviewFlag(
1111
- flaggedForReview=True,
1112
- flaggedBy=remote.flaggedBy,
1113
- flaggedAt=remote.flaggedAt,
1114
- reason=remote.reason,
1115
- scope=merged_scope,
1116
- )
1117
- else:
1118
- # Local is newer
1119
- return ReviewFlag(
1120
- flaggedForReview=True,
1121
- flaggedBy=local.flaggedBy,
1122
- flaggedAt=local.flaggedAt,
1123
- reason=local.reason,
1124
- scope=merged_scope,
1125
- )
1126
-
1127
-
1128
- # =============================================================================
1129
- # Archive Operations
1130
- # REQ-d00097: Review Package Archival
1131
- # =============================================================================
1132
-
1133
-
1134
- def archive_package(repo_root: Path, package_id: str, reason: str, user: str) -> bool:
1135
- """
1136
- Archive a package by moving it to the archive directory.
1137
-
1138
- REQ-d00097-D: Archive SHALL be triggered by resolution, deletion, or manual action.
1139
- REQ-d00097-E: Deleting a package SHALL move it to archive rather than destroying.
1140
- REQ-d00097-C: Archive metadata SHALL be added to package.json.
1141
-
1142
- Args:
1143
- repo_root: Repository root path
1144
- package_id: Package UUID
1145
- reason: Archive reason - one of "resolved", "deleted", "manual"
1146
- user: Username who triggered the archive
1147
-
1148
- Returns:
1149
- True if archived successfully, False if package not found or already archived
1150
-
1151
- Raises:
1152
- ValueError: If reason is not valid
1153
- """
1154
- from .models import (
1155
- ARCHIVE_REASON_DELETED,
1156
- ARCHIVE_REASON_MANUAL,
1157
- ARCHIVE_REASON_RESOLVED,
1158
- )
1159
-
1160
- valid_reasons = {ARCHIVE_REASON_RESOLVED, ARCHIVE_REASON_DELETED, ARCHIVE_REASON_MANUAL}
1161
- if reason not in valid_reasons:
1162
- raise ValueError(f"Invalid archive reason: {reason}. Must be one of: {valid_reasons}")
1163
-
1164
- # Load packages and find the one to archive
1165
- packages_file = load_packages(repo_root)
1166
- package = None
1167
- package_index = None
1168
-
1169
- for i, p in enumerate(packages_file.packages):
1170
- if p.packageId == package_id:
1171
- package = p
1172
- package_index = i
1173
- break
1174
-
1175
- if package is None:
1176
- return False
1177
-
1178
- # Archive the package using its archive() method
1179
- package.archive(user, reason)
1180
-
1181
- # Create archive directory structure
1182
- archive_pkg_dir = get_archived_package_dir(repo_root, package_id)
1183
- archive_pkg_dir.mkdir(parents=True, exist_ok=True)
1184
-
1185
- # Save archived package metadata
1186
- archive_metadata_path = get_archived_package_metadata_path(repo_root, package_id)
1187
- atomic_write_json(archive_metadata_path, package.to_dict())
1188
-
1189
- # Copy thread files to archive (v1 format - from .reviews/reqs/{req-id}/)
1190
- for req_id in package.reqIds:
1191
- source_threads_path = get_threads_path(repo_root, req_id)
1192
- if source_threads_path.exists():
1193
- dest_threads_path = get_archived_package_threads_path(repo_root, package_id, req_id)
1194
- dest_threads_path.parent.mkdir(parents=True, exist_ok=True)
1195
- # Copy the data
1196
- threads_data = read_json(source_threads_path)
1197
- atomic_write_json(dest_threads_path, threads_data)
1198
-
1199
- # Remove package from active packages
1200
- del packages_file.packages[package_index]
1201
- save_packages(repo_root, packages_file)
1202
-
1203
- return True
1204
-
1205
-
1206
- def list_archived_packages(repo_root: Path) -> List[ReviewPackage]:
1207
- """
1208
- List all archived packages.
1209
-
1210
- REQ-d00097: Provides read access to archived packages for the archive viewer.
1211
-
1212
- Args:
1213
- repo_root: Repository root path
1214
-
1215
- Returns:
1216
- List of archived ReviewPackage instances
1217
- """
1218
- archive_root = get_archive_dir(repo_root)
1219
- if not archive_root.exists():
1220
- return []
1221
-
1222
- packages = []
1223
- for pkg_dir in archive_root.iterdir():
1224
- if pkg_dir.is_dir():
1225
- metadata_path = pkg_dir / "package.json"
1226
- if metadata_path.exists():
1227
- try:
1228
- data = read_json(metadata_path)
1229
- package = ReviewPackage.from_dict(data)
1230
- packages.append(package)
1231
- except (json.JSONDecodeError, KeyError):
1232
- # Skip invalid package files
1233
- continue
1234
-
1235
- # Sort by archive date (most recent first)
1236
- packages.sort(key=lambda p: p.archivedAt or "", reverse=True)
1237
-
1238
- return packages
1239
-
1240
-
1241
- def get_archived_package(repo_root: Path, package_id: str) -> Optional[ReviewPackage]:
1242
- """
1243
- Get a specific archived package by ID.
1244
-
1245
- REQ-d00097: Provides read access to archived package details.
1246
-
1247
- Args:
1248
- repo_root: Repository root path
1249
- package_id: Package UUID
1250
-
1251
- Returns:
1252
- ReviewPackage if found, None otherwise
1253
- """
1254
- metadata_path = get_archived_package_metadata_path(repo_root, package_id)
1255
- if not metadata_path.exists():
1256
- return None
1257
-
1258
- try:
1259
- data = read_json(metadata_path)
1260
- return ReviewPackage.from_dict(data)
1261
- except (json.JSONDecodeError, KeyError):
1262
- return None
1263
-
1264
-
1265
- def load_archived_threads(repo_root: Path, package_id: str, req_id: str) -> Optional[ThreadsFile]:
1266
- """
1267
- Load threads for a requirement from an archived package.
1268
-
1269
- REQ-d00097-F: Archived data SHALL be read-only.
1270
-
1271
- Args:
1272
- repo_root: Repository root path
1273
- package_id: Archived package UUID
1274
- req_id: Requirement ID
1275
-
1276
- Returns:
1277
- ThreadsFile if found, None otherwise
1278
- """
1279
- threads_path = get_archived_package_threads_path(repo_root, package_id, req_id)
1280
- if not threads_path.exists():
1281
- return None
1282
-
1283
- try:
1284
- data = read_json(threads_path)
1285
- return ThreadsFile.from_dict(data)
1286
- except (json.JSONDecodeError, KeyError):
1287
- return None
1288
-
1289
-
1290
- def check_auto_archive(repo_root: Path, package_id: str, user: str) -> bool:
1291
- """
1292
- Check if a package should be auto-archived (all threads resolved) and archive if so.
1293
-
1294
- REQ-d00097-D: Resolving all threads in a package SHALL trigger auto-archive.
1295
-
1296
- Args:
1297
- repo_root: Repository root path
1298
- package_id: Package UUID
1299
- user: Username who resolved the last thread
1300
-
1301
- Returns:
1302
- True if package was auto-archived, False otherwise
1303
- """
1304
- from .models import ARCHIVE_REASON_RESOLVED
1305
-
1306
- # Load packages and find the one to check
1307
- packages_file = load_packages(repo_root)
1308
- package = None
1309
-
1310
- for p in packages_file.packages:
1311
- if p.packageId == package_id:
1312
- package = p
1313
- break
1314
-
1315
- if package is None:
1316
- return False
1317
-
1318
- # Don't auto-archive default package
1319
- if package.isDefault:
1320
- return False
1321
-
1322
- # Check if all threads in all reqs are resolved
1323
- all_resolved = True
1324
- has_threads = False
1325
-
1326
- for req_id in package.reqIds:
1327
- threads_file = load_threads(repo_root, req_id)
1328
- for thread in threads_file.threads:
1329
- # Only count threads belonging to this package (if packageId is set)
1330
- if thread.packageId is None or thread.packageId == package_id:
1331
- has_threads = True
1332
- if not thread.resolved:
1333
- all_resolved = False
1334
- break
1335
-
1336
- if not all_resolved:
1337
- break
1338
-
1339
- # Archive if all threads are resolved and there was at least one thread
1340
- if has_threads and all_resolved:
1341
- return archive_package(repo_root, package_id, ARCHIVE_REASON_RESOLVED, user)
1342
-
1343
- return False