elspais 0.9.3__py3-none-any.whl → 0.11.1__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 (73) hide show
  1. elspais/cli.py +141 -10
  2. elspais/commands/hash_cmd.py +72 -26
  3. elspais/commands/reformat_cmd.py +458 -0
  4. elspais/commands/trace.py +157 -3
  5. elspais/commands/validate.py +44 -16
  6. elspais/core/models.py +2 -0
  7. elspais/core/parser.py +68 -24
  8. elspais/reformat/__init__.py +50 -0
  9. elspais/reformat/detector.py +119 -0
  10. elspais/reformat/hierarchy.py +246 -0
  11. elspais/reformat/line_breaks.py +220 -0
  12. elspais/reformat/prompts.py +123 -0
  13. elspais/reformat/transformer.py +264 -0
  14. elspais/sponsors/__init__.py +432 -0
  15. elspais/trace_view/__init__.py +54 -0
  16. elspais/trace_view/coverage.py +183 -0
  17. elspais/trace_view/generators/__init__.py +12 -0
  18. elspais/trace_view/generators/base.py +329 -0
  19. elspais/trace_view/generators/csv.py +122 -0
  20. elspais/trace_view/generators/markdown.py +175 -0
  21. elspais/trace_view/html/__init__.py +31 -0
  22. elspais/trace_view/html/generator.py +1006 -0
  23. elspais/trace_view/html/templates/base.html +283 -0
  24. elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
  25. elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
  26. elspais/trace_view/html/templates/components/legend_modal.html +69 -0
  27. elspais/trace_view/html/templates/components/review_panel.html +118 -0
  28. elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
  29. elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
  30. elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
  31. elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
  32. elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
  33. elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
  34. elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
  35. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
  36. elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
  37. elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
  38. elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
  39. elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
  40. elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
  41. elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
  42. elspais/trace_view/html/templates/partials/scripts.js +1741 -0
  43. elspais/trace_view/html/templates/partials/styles.css +1756 -0
  44. elspais/trace_view/models.py +353 -0
  45. elspais/trace_view/review/__init__.py +60 -0
  46. elspais/trace_view/review/branches.py +1149 -0
  47. elspais/trace_view/review/models.py +1205 -0
  48. elspais/trace_view/review/position.py +609 -0
  49. elspais/trace_view/review/server.py +1056 -0
  50. elspais/trace_view/review/status.py +470 -0
  51. elspais/trace_view/review/storage.py +1367 -0
  52. elspais/trace_view/scanning.py +213 -0
  53. elspais/trace_view/specs/README.md +84 -0
  54. elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
  55. elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
  56. elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
  57. elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
  58. elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
  59. elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
  60. elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
  61. elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
  62. elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
  63. elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
  64. elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
  65. elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
  66. elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
  67. elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
  68. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/METADATA +36 -18
  69. elspais-0.11.1.dist-info/RECORD +101 -0
  70. elspais-0.9.3.dist-info/RECORD +0 -40
  71. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/WHEEL +0 -0
  72. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/entry_points.txt +0 -0
  73. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1056 @@
1
+ # Implements: REQ-tv-d00014 (Review API Server)
2
+ """
3
+ Review API Server for trace_view
4
+
5
+ Flask-based API server for the review system that handles:
6
+ - Thread creation and comment persistence
7
+ - Status change requests and approvals
8
+ - Review flag management
9
+ - Package management
10
+ - Git sync operations
11
+
12
+ IMPLEMENTS REQUIREMENTS:
13
+ REQ-tv-d00014: Review API Server
14
+ """
15
+
16
+ from pathlib import Path
17
+ from typing import Optional, Tuple
18
+
19
+ from flask import Flask, request, jsonify, send_from_directory
20
+ from flask_cors import CORS
21
+
22
+ from .models import (
23
+ Thread,
24
+ Comment,
25
+ CommentPosition,
26
+ ThreadsFile,
27
+ StatusFile,
28
+ StatusRequest,
29
+ ReviewFlag,
30
+ ReviewConfig,
31
+ ReviewPackage,
32
+ PackagesFile,
33
+ Approval,
34
+ now_iso,
35
+ )
36
+ from .storage import (
37
+ load_threads,
38
+ save_threads,
39
+ add_thread,
40
+ add_comment_to_thread,
41
+ resolve_thread,
42
+ unresolve_thread,
43
+ load_status_requests,
44
+ save_status_requests,
45
+ create_status_request,
46
+ add_approval,
47
+ mark_request_applied,
48
+ load_review_flag,
49
+ save_review_flag,
50
+ load_packages,
51
+ save_packages,
52
+ create_package,
53
+ update_package,
54
+ delete_package,
55
+ add_req_to_package,
56
+ remove_req_from_package,
57
+ load_config,
58
+ normalize_req_id,
59
+ # REQ-d00097: Archive operations
60
+ archive_package,
61
+ list_archived_packages,
62
+ get_archived_package,
63
+ load_archived_threads,
64
+ check_auto_archive,
65
+ )
66
+ from .branches import (
67
+ commit_and_push_reviews,
68
+ fetch_package_branches,
69
+ get_current_package_context,
70
+ list_package_branches,
71
+ has_reviews_changes,
72
+ # REQ-d00098: Git audit trail
73
+ get_git_context,
74
+ get_head_commit_hash,
75
+ )
76
+ from .status import get_req_status, change_req_status
77
+
78
+
79
+ def create_app(
80
+ repo_root: Path,
81
+ static_dir: Optional[Path] = None,
82
+ auto_sync: bool = True,
83
+ register_static_routes: bool = True
84
+ ) -> Flask:
85
+ """
86
+ Create Flask app with review API endpoints.
87
+
88
+ REQ-tv-d00014-A: The API server SHALL be implemented as a Flask
89
+ application with a `create_app(repo_root, static_dir)` factory function.
90
+
91
+ Args:
92
+ repo_root: Repository root path for .reviews/ storage
93
+ static_dir: Optional directory to serve static files from
94
+ auto_sync: Whether to auto-commit and push after write operations
95
+ register_static_routes: Whether to register default static file routes
96
+ (set to False if caller will define custom static routes)
97
+
98
+ Returns:
99
+ Flask application
100
+ """
101
+ app = Flask(__name__)
102
+ # REQ-tv-d00014-F: Enable CORS for cross-origin requests
103
+ CORS(app)
104
+
105
+ # Store configuration in app config
106
+ app.config['REPO_ROOT'] = repo_root
107
+ app.config['STATIC_DIR'] = static_dir or repo_root
108
+ app.config['AUTO_SYNC'] = auto_sync
109
+
110
+ def trigger_auto_sync(message: str, user: str = 'system') -> Optional[dict]:
111
+ """
112
+ Trigger auto-sync if enabled.
113
+
114
+ REQ-tv-d00014-H: All write endpoints SHALL optionally trigger
115
+ auto-sync based on configuration.
116
+
117
+ Args:
118
+ message: Commit message describing the change
119
+ user: Username for commit attribution
120
+
121
+ Returns:
122
+ dict with sync result, or None if auto-sync disabled
123
+ """
124
+ if not app.config.get('AUTO_SYNC'):
125
+ return None
126
+
127
+ repo = app.config['REPO_ROOT']
128
+ success, msg = commit_and_push_reviews(repo, message, user)
129
+ return {'success': success, 'message': msg}
130
+
131
+ # ==========================================================================
132
+ # Static File Serving
133
+ # REQ-tv-d00014-G: Serve static files from the configured static directory
134
+ # ==========================================================================
135
+
136
+ if register_static_routes:
137
+ @app.route('/')
138
+ def index():
139
+ """Serve index from static directory"""
140
+ return send_from_directory(
141
+ app.config['STATIC_DIR'],
142
+ 'index.html'
143
+ )
144
+
145
+ @app.route('/<path:path>')
146
+ def serve_static(path):
147
+ """Serve static files from configured static directory"""
148
+ return send_from_directory(app.config['STATIC_DIR'], path)
149
+
150
+ # ==========================================================================
151
+ # Health Check
152
+ # REQ-tv-d00014-J: Provide /api/health endpoint for health checks
153
+ # ==========================================================================
154
+
155
+ @app.route('/api/health', methods=['GET'])
156
+ def health_check():
157
+ """Health check endpoint"""
158
+ return jsonify({
159
+ 'status': 'ok',
160
+ 'repo_root': str(app.config['REPO_ROOT']),
161
+ 'reviews_dir': str(app.config['REPO_ROOT'] / '.reviews')
162
+ })
163
+
164
+ # ==========================================================================
165
+ # File Content API (for external repo files)
166
+ # ==========================================================================
167
+
168
+ @app.route('/api/files', methods=['GET'])
169
+ def get_file_content():
170
+ """
171
+ Fetch file content for viewing in the browser.
172
+
173
+ Query params:
174
+ path: Absolute path to the file
175
+
176
+ Returns file content as text. Only allows reading files in spec/
177
+ directories for security.
178
+ """
179
+ file_path = request.args.get('path')
180
+ if not file_path:
181
+ return jsonify({'error': 'Missing path parameter'}), 400
182
+
183
+ # Security: only allow reading spec files
184
+ path = Path(file_path)
185
+ if not path.is_absolute():
186
+ return jsonify({'error': 'Path must be absolute'}), 400
187
+
188
+ # Check that path contains /spec/ for security
189
+ if '/spec/' not in str(path):
190
+ return jsonify({'error': 'Only spec files can be read'}), 403
191
+
192
+ if not path.exists():
193
+ return jsonify({'error': f'File not found: {path}'}), 404
194
+
195
+ if not path.is_file():
196
+ return jsonify({'error': 'Path is not a file'}), 400
197
+
198
+ try:
199
+ content = path.read_text(encoding='utf-8')
200
+ return content, 200, {'Content-Type': 'text/plain; charset=utf-8'}
201
+ except Exception as e:
202
+ return jsonify({'error': f'Failed to read file: {e}'}), 500
203
+
204
+ # ==========================================================================
205
+ # Thread API
206
+ # REQ-tv-d00014-B: Thread endpoints for create, comment, resolve, unresolve
207
+ # ==========================================================================
208
+
209
+ @app.route('/api/reviews/reqs/<req_id>/threads', methods=['POST'])
210
+ def create_thread_endpoint(req_id):
211
+ """
212
+ Create a new comment thread.
213
+
214
+ REQ-tv-d00014-B: POST create thread endpoint.
215
+ """
216
+ repo = app.config['REPO_ROOT']
217
+ normalized_id = normalize_req_id(req_id)
218
+ data = request.get_json(silent=True)
219
+
220
+ if not data:
221
+ return jsonify({'error': 'No data provided'}), 400
222
+
223
+ try:
224
+ thread = Thread.from_dict(data)
225
+ add_thread(repo, normalized_id, thread)
226
+
227
+ # REQ-tv-d00014-H: Auto-sync after write operation
228
+ user = thread.createdBy or 'system'
229
+ sync_result = trigger_auto_sync(f"New thread on REQ-{normalized_id}", user)
230
+
231
+ response = {'success': True, 'thread': thread.to_dict()}
232
+ if sync_result:
233
+ response['sync'] = sync_result
234
+
235
+ return jsonify(response), 201
236
+ except Exception as e:
237
+ return jsonify({'error': str(e)}), 400
238
+
239
+ @app.route('/api/reviews/reqs/<req_id>/threads/<thread_id>/comments', methods=['POST'])
240
+ def add_comment_endpoint(req_id, thread_id):
241
+ """
242
+ Add a comment to an existing thread.
243
+
244
+ REQ-tv-d00014-B: POST add comment endpoint.
245
+ """
246
+ repo = app.config['REPO_ROOT']
247
+ normalized_id = normalize_req_id(req_id)
248
+ data = request.get_json(silent=True)
249
+
250
+ if not data:
251
+ return jsonify({'error': 'No data provided'}), 400
252
+
253
+ try:
254
+ author = data.get('author')
255
+ body = data.get('body')
256
+
257
+ if not author:
258
+ return jsonify({'error': 'Comment author is required'}), 400
259
+ if not body:
260
+ return jsonify({'error': 'Comment body is required'}), 400
261
+
262
+ comment = add_comment_to_thread(repo, normalized_id, thread_id, author, body)
263
+
264
+ # REQ-tv-d00014-H: Auto-sync after write operation
265
+ sync_result = trigger_auto_sync(f"Comment on REQ-{normalized_id}", author)
266
+
267
+ response = {'success': True, 'comment': comment.to_dict()}
268
+ if sync_result:
269
+ response['sync'] = sync_result
270
+
271
+ return jsonify(response), 201
272
+ except Exception as e:
273
+ return jsonify({'error': str(e)}), 400
274
+
275
+ @app.route('/api/reviews/reqs/<req_id>/threads/<thread_id>/resolve', methods=['POST'])
276
+ def resolve_thread_endpoint(req_id, thread_id):
277
+ """
278
+ Resolve a thread.
279
+
280
+ REQ-tv-d00014-B: POST resolve endpoint.
281
+ REQ-d00097-D: Resolving all threads in a package SHALL trigger auto-archive.
282
+ """
283
+ repo = app.config['REPO_ROOT']
284
+ normalized_id = normalize_req_id(req_id)
285
+ data = request.get_json(silent=True) or {}
286
+ user = data.get('user', 'anonymous')
287
+ package_id = data.get('packageId') # Optional: for auto-archive check
288
+
289
+ try:
290
+ resolve_thread(repo, normalized_id, thread_id, user)
291
+
292
+ # REQ-tv-d00014-H: Auto-sync after write operation
293
+ sync_result = trigger_auto_sync(f"Resolved thread on REQ-{normalized_id}", user)
294
+
295
+ response = {'success': True}
296
+ if sync_result:
297
+ response['sync'] = sync_result
298
+
299
+ # REQ-d00097-D: Check for auto-archive if packageId provided
300
+ if package_id:
301
+ was_archived = check_auto_archive(repo, package_id, user)
302
+ if was_archived:
303
+ response['packageArchived'] = True
304
+ response['archiveReason'] = 'resolved'
305
+
306
+ return jsonify(response), 200
307
+ except Exception as e:
308
+ return jsonify({'error': str(e)}), 400
309
+
310
+ @app.route('/api/reviews/reqs/<req_id>/threads/<thread_id>/unresolve', methods=['POST'])
311
+ def unresolve_thread_endpoint(req_id, thread_id):
312
+ """
313
+ Unresolve a thread.
314
+
315
+ REQ-tv-d00014-B: POST unresolve endpoint.
316
+ """
317
+ repo = app.config['REPO_ROOT']
318
+ normalized_id = normalize_req_id(req_id)
319
+ data = request.get_json(silent=True) or {}
320
+ user = data.get('user', 'anonymous')
321
+
322
+ try:
323
+ unresolve_thread(repo, normalized_id, thread_id)
324
+
325
+ # REQ-tv-d00014-H: Auto-sync after write operation
326
+ sync_result = trigger_auto_sync(f"Unresolved thread on REQ-{normalized_id}", user)
327
+
328
+ response = {'success': True}
329
+ if sync_result:
330
+ response['sync'] = sync_result
331
+
332
+ return jsonify(response), 200
333
+ except Exception as e:
334
+ return jsonify({'error': str(e)}), 400
335
+
336
+ # ==========================================================================
337
+ # Review Flag API
338
+ # ==========================================================================
339
+
340
+ @app.route('/api/reviews/reqs/<req_id>/flag', methods=['GET'])
341
+ def get_flag(req_id):
342
+ """Get review flag for a requirement"""
343
+ repo = app.config['REPO_ROOT']
344
+ normalized_id = normalize_req_id(req_id)
345
+ flag = load_review_flag(repo, normalized_id)
346
+ return jsonify(flag.to_dict())
347
+
348
+ @app.route('/api/reviews/reqs/<req_id>/flag', methods=['POST'])
349
+ def set_flag(req_id):
350
+ """Set review flag for a requirement"""
351
+ repo = app.config['REPO_ROOT']
352
+ normalized_id = normalize_req_id(req_id)
353
+ data = request.get_json(silent=True)
354
+
355
+ if not data:
356
+ return jsonify({'error': 'No data provided'}), 400
357
+
358
+ try:
359
+ flag = ReviewFlag.from_dict(data)
360
+ save_review_flag(repo, normalized_id, flag)
361
+
362
+ # REQ-tv-d00014-H: Auto-sync after write operation
363
+ user = flag.flaggedBy or 'system'
364
+ sync_result = trigger_auto_sync(f"Flagged REQ-{normalized_id} for review", user)
365
+
366
+ response = {'success': True, 'flag': flag.to_dict()}
367
+ if sync_result:
368
+ response['sync'] = sync_result
369
+
370
+ return jsonify(response), 200
371
+ except Exception as e:
372
+ return jsonify({'error': str(e)}), 400
373
+
374
+ @app.route('/api/reviews/reqs/<req_id>/flag', methods=['DELETE'])
375
+ def clear_flag(req_id):
376
+ """Clear review flag for a requirement"""
377
+ repo = app.config['REPO_ROOT']
378
+ normalized_id = normalize_req_id(req_id)
379
+ data = request.get_json(silent=True) or {}
380
+ user = data.get('user', 'anonymous')
381
+
382
+ flag = ReviewFlag.cleared()
383
+ save_review_flag(repo, normalized_id, flag)
384
+
385
+ # REQ-tv-d00014-H: Auto-sync after write operation
386
+ sync_result = trigger_auto_sync(f"Cleared flag on REQ-{normalized_id}", user)
387
+
388
+ response = {'success': True}
389
+ if sync_result:
390
+ response['sync'] = sync_result
391
+
392
+ return jsonify(response), 200
393
+
394
+ # ==========================================================================
395
+ # Status Request API
396
+ # REQ-tv-d00014-C: Status endpoints for GET/POST requests and approvals
397
+ # ==========================================================================
398
+
399
+ @app.route('/api/reviews/reqs/<req_id>/status', methods=['GET'])
400
+ def get_status(req_id):
401
+ """
402
+ Get the current status of a requirement from the spec file.
403
+
404
+ REQ-tv-d00014-C: GET status endpoint.
405
+ """
406
+ repo = app.config['REPO_ROOT']
407
+ normalized_id = normalize_req_id(req_id)
408
+
409
+ status = get_req_status(repo, normalized_id)
410
+ if status is None:
411
+ return jsonify({'error': f'REQ-{normalized_id} not found'}), 404
412
+
413
+ return jsonify({'reqId': normalized_id, 'status': status})
414
+
415
+ @app.route('/api/reviews/reqs/<req_id>/status', methods=['POST'])
416
+ def set_status(req_id):
417
+ """
418
+ Change the status of a requirement in its spec file.
419
+
420
+ REQ-tv-d00014-C: POST change status endpoint.
421
+ """
422
+ repo = app.config['REPO_ROOT']
423
+ normalized_id = normalize_req_id(req_id)
424
+ data = request.get_json(silent=True)
425
+
426
+ if not data:
427
+ return jsonify({'error': 'No data provided'}), 400
428
+
429
+ new_status = data.get('newStatus')
430
+ if not new_status:
431
+ return jsonify({'error': 'newStatus is required'}), 400
432
+
433
+ user = data.get('user', 'api')
434
+
435
+ success, message = change_req_status(repo, normalized_id, new_status, user)
436
+
437
+ if success:
438
+ # REQ-tv-d00014-H: Auto-sync after write operation
439
+ sync_result = trigger_auto_sync(
440
+ f"Changed REQ-{normalized_id} status to {new_status}",
441
+ user
442
+ )
443
+
444
+ response = {'success': True, 'message': message}
445
+ if sync_result:
446
+ response['sync'] = sync_result
447
+
448
+ return jsonify(response), 200
449
+ else:
450
+ return jsonify({'success': False, 'error': message}), 400
451
+
452
+ @app.route('/api/reviews/reqs/<req_id>/requests', methods=['GET'])
453
+ def get_status_requests(req_id):
454
+ """
455
+ Get status change requests for a requirement.
456
+
457
+ REQ-tv-d00014-C: GET requests endpoint.
458
+ """
459
+ repo = app.config['REPO_ROOT']
460
+ normalized_id = normalize_req_id(req_id)
461
+ status_file = load_status_requests(repo, normalized_id)
462
+ return jsonify([r.to_dict() for r in status_file.requests])
463
+
464
+ @app.route('/api/reviews/reqs/<req_id>/requests', methods=['POST'])
465
+ def create_status_request_endpoint(req_id):
466
+ """
467
+ Create a status change request.
468
+
469
+ REQ-tv-d00014-C: POST requests endpoint.
470
+ """
471
+ repo = app.config['REPO_ROOT']
472
+ normalized_id = normalize_req_id(req_id)
473
+ data = request.get_json(silent=True)
474
+
475
+ if not data:
476
+ return jsonify({'error': 'No data provided'}), 400
477
+
478
+ try:
479
+ status_request = StatusRequest.from_dict(data)
480
+ create_status_request(repo, normalized_id, status_request)
481
+
482
+ # REQ-tv-d00014-H: Auto-sync after write operation
483
+ user = status_request.requestedBy or 'system'
484
+ sync_result = trigger_auto_sync(
485
+ f"Status change request for REQ-{normalized_id}: "
486
+ f"{status_request.fromStatus} -> {status_request.toStatus}",
487
+ user
488
+ )
489
+
490
+ response = {'success': True, 'request': status_request.to_dict()}
491
+ if sync_result:
492
+ response['sync'] = sync_result
493
+
494
+ return jsonify(response), 201
495
+ except Exception as e:
496
+ return jsonify({'error': str(e)}), 400
497
+
498
+ @app.route('/api/reviews/reqs/<req_id>/requests/<request_id>/approvals', methods=['POST'])
499
+ def add_approval_endpoint(req_id, request_id):
500
+ """
501
+ Add an approval to a status change request.
502
+
503
+ REQ-tv-d00014-C: POST approvals endpoint.
504
+ """
505
+ repo = app.config['REPO_ROOT']
506
+ normalized_id = normalize_req_id(req_id)
507
+ data = request.get_json(silent=True)
508
+
509
+ if not data:
510
+ return jsonify({'error': 'No data provided'}), 400
511
+
512
+ try:
513
+ approval = Approval.from_dict(data)
514
+ add_approval(repo, normalized_id, request_id, approval.user, approval.decision, approval.comment)
515
+
516
+ # REQ-tv-d00014-H: Auto-sync after write operation
517
+ user = approval.user or 'system'
518
+ sync_result = trigger_auto_sync(
519
+ f"Approval on REQ-{normalized_id} status request: {approval.decision}",
520
+ user
521
+ )
522
+
523
+ response = {'success': True, 'approval': approval.to_dict()}
524
+ if sync_result:
525
+ response['sync'] = sync_result
526
+
527
+ return jsonify(response), 201
528
+ except Exception as e:
529
+ return jsonify({'error': str(e)}), 400
530
+
531
+ # ==========================================================================
532
+ # Review Packages API
533
+ # REQ-tv-d00014-D: Package endpoints for CRUD and membership
534
+ # ==========================================================================
535
+
536
+ @app.route('/api/reviews/packages', methods=['GET'])
537
+ def get_packages():
538
+ """
539
+ Get all review packages.
540
+
541
+ REQ-tv-d00014-D: GET packages endpoint.
542
+ """
543
+ repo = app.config['REPO_ROOT']
544
+ pf = load_packages(repo)
545
+ return jsonify({
546
+ 'packages': [p.to_dict() for p in pf.packages],
547
+ 'activePackageId': pf.activePackageId
548
+ })
549
+
550
+ @app.route('/api/reviews/packages', methods=['POST'])
551
+ def create_package_endpoint():
552
+ """
553
+ Create a new review package.
554
+
555
+ REQ-tv-d00014-D: POST packages endpoint.
556
+ REQ-d00098-A: Package SHALL record branchName when created.
557
+ REQ-d00098-B: Package SHALL record creationCommitHash when created.
558
+ """
559
+ repo = app.config['REPO_ROOT']
560
+ data = request.get_json(silent=True)
561
+
562
+ if not data:
563
+ return jsonify({'error': 'No data provided'}), 400
564
+
565
+ name = data.get('name')
566
+ if not name:
567
+ return jsonify({'error': 'name is required'}), 400
568
+
569
+ description = data.get('description', '')
570
+ user = data.get('user', 'api')
571
+
572
+ pkg = ReviewPackage.create(name, description, user)
573
+
574
+ # REQ-d00098: Add git context for audit trail
575
+ git_context = get_git_context(repo)
576
+ pkg.branchName = git_context.get('branchName')
577
+ pkg.creationCommitHash = git_context.get('commitHash')
578
+ pkg.lastReviewedCommitHash = git_context.get('commitHash')
579
+
580
+ create_package(repo, pkg)
581
+
582
+ # REQ-tv-d00014-H: Auto-sync after write operation
583
+ sync_result = trigger_auto_sync(f"Created package: {name}", user)
584
+
585
+ response = {'success': True, 'package': pkg.to_dict()}
586
+ if sync_result:
587
+ response['sync'] = sync_result
588
+
589
+ return jsonify(response), 201
590
+
591
+ @app.route('/api/reviews/packages/<package_id>', methods=['GET'])
592
+ def get_package_endpoint(package_id):
593
+ """
594
+ Get a specific package.
595
+
596
+ REQ-tv-d00014-D: GET package by ID endpoint.
597
+ """
598
+ repo = app.config['REPO_ROOT']
599
+ pf = load_packages(repo)
600
+ pkg = pf.get_by_id(package_id)
601
+
602
+ if not pkg:
603
+ return jsonify({'error': 'Package not found'}), 404
604
+
605
+ return jsonify(pkg.to_dict())
606
+
607
+ @app.route('/api/reviews/packages/<package_id>', methods=['PUT'])
608
+ def update_package_endpoint(package_id):
609
+ """
610
+ Update a package.
611
+
612
+ REQ-tv-d00014-D: PUT package endpoint.
613
+ """
614
+ repo = app.config['REPO_ROOT']
615
+ data = request.get_json(silent=True)
616
+
617
+ if not data:
618
+ return jsonify({'error': 'No data provided'}), 400
619
+
620
+ user = data.get('user', 'api')
621
+
622
+ # Load existing package
623
+ pf = load_packages(repo)
624
+ pkg = pf.get_by_id(package_id)
625
+
626
+ if not pkg:
627
+ return jsonify({'error': 'Package not found'}), 404
628
+
629
+ # Update fields
630
+ if 'name' in data:
631
+ pkg.name = data['name']
632
+ if 'description' in data:
633
+ pkg.description = data['description']
634
+
635
+ success = update_package(repo, pkg)
636
+
637
+ if success:
638
+ # REQ-tv-d00014-H: Auto-sync after write operation
639
+ sync_result = trigger_auto_sync(f"Updated package: {pkg.name}", user)
640
+
641
+ response = {'success': True, 'package': pkg.to_dict()}
642
+ if sync_result:
643
+ response['sync'] = sync_result
644
+
645
+ return jsonify(response)
646
+ else:
647
+ return jsonify({'error': 'Package not found'}), 404
648
+
649
+ @app.route('/api/reviews/packages/<package_id>', methods=['DELETE'])
650
+ def delete_package_endpoint(package_id):
651
+ """
652
+ Delete a package (archives it instead of destroying).
653
+
654
+ REQ-tv-d00014-D: DELETE package endpoint.
655
+ REQ-d00097-E: Deleting a package SHALL move it to archive rather than destroying.
656
+ """
657
+ from .models import ARCHIVE_REASON_DELETED
658
+
659
+ repo = app.config['REPO_ROOT']
660
+ data = request.get_json(silent=True) or {}
661
+ user = data.get('user', 'api')
662
+
663
+ # Get package name before archiving
664
+ pf = load_packages(repo)
665
+ pkg = pf.get_by_id(package_id)
666
+
667
+ if not pkg:
668
+ return jsonify({'error': 'Package not found'}), 404
669
+
670
+ # Don't allow deleting default package
671
+ if pkg.isDefault:
672
+ return jsonify({'error': 'Cannot delete default package'}), 400
673
+
674
+ pkg_name = pkg.name
675
+
676
+ # REQ-d00097-E: Archive instead of delete
677
+ try:
678
+ success = archive_package(repo, package_id, ARCHIVE_REASON_DELETED, user)
679
+
680
+ if success:
681
+ # REQ-tv-d00014-H: Auto-sync after write operation
682
+ sync_result = trigger_auto_sync(f"Archived (deleted) package: {pkg_name}", user)
683
+
684
+ response = {'success': True, 'archived': True}
685
+ if sync_result:
686
+ response['sync'] = sync_result
687
+
688
+ return jsonify(response)
689
+ else:
690
+ return jsonify({'error': 'Failed to archive package'}), 400
691
+ except ValueError as e:
692
+ return jsonify({'error': str(e)}), 400
693
+
694
+ @app.route('/api/reviews/packages/<package_id>/reqs/<req_id>', methods=['POST'])
695
+ def add_req_to_package_endpoint(package_id, req_id):
696
+ """
697
+ Add a REQ to a package.
698
+
699
+ REQ-tv-d00014-D: POST membership endpoint.
700
+ """
701
+ repo = app.config['REPO_ROOT']
702
+ data = request.get_json(silent=True) or {}
703
+ user = data.get('user', 'api')
704
+
705
+ normalized_id = normalize_req_id(req_id)
706
+ success = add_req_to_package(repo, package_id, normalized_id)
707
+
708
+ if success:
709
+ # REQ-tv-d00014-H: Auto-sync after write operation
710
+ sync_result = trigger_auto_sync(f"Added REQ-{normalized_id} to package", user)
711
+
712
+ response = {'success': True}
713
+ if sync_result:
714
+ response['sync'] = sync_result
715
+
716
+ return jsonify(response)
717
+ else:
718
+ return jsonify({'error': 'Package not found'}), 404
719
+
720
+ @app.route('/api/reviews/packages/<package_id>/reqs/<req_id>', methods=['DELETE'])
721
+ def remove_req_from_package_endpoint(package_id, req_id):
722
+ """
723
+ Remove a REQ from a package.
724
+
725
+ REQ-tv-d00014-D: DELETE membership endpoint.
726
+ """
727
+ repo = app.config['REPO_ROOT']
728
+ data = request.get_json(silent=True) or {}
729
+ user = data.get('user', 'api')
730
+
731
+ normalized_id = normalize_req_id(req_id)
732
+ success = remove_req_from_package(repo, package_id, normalized_id)
733
+
734
+ if success:
735
+ # REQ-tv-d00014-H: Auto-sync after write operation
736
+ sync_result = trigger_auto_sync(f"Removed REQ-{normalized_id} from package", user)
737
+
738
+ response = {'success': True}
739
+ if sync_result:
740
+ response['sync'] = sync_result
741
+
742
+ return jsonify(response)
743
+ else:
744
+ return jsonify({'error': 'Package not found'}), 404
745
+
746
+ @app.route('/api/reviews/packages/active', methods=['GET'])
747
+ def get_active_package_endpoint():
748
+ """
749
+ Get the currently active package.
750
+
751
+ REQ-tv-d00014-D: GET active endpoint.
752
+ """
753
+ repo = app.config['REPO_ROOT']
754
+ pf = load_packages(repo)
755
+ pkg = pf.get_active()
756
+
757
+ if pkg:
758
+ return jsonify(pkg.to_dict())
759
+ else:
760
+ return jsonify(None)
761
+
762
+ @app.route('/api/reviews/packages/active', methods=['PUT'])
763
+ def set_active_package_endpoint():
764
+ """
765
+ Set the active package.
766
+
767
+ REQ-tv-d00014-D: PUT active endpoint.
768
+ """
769
+ repo = app.config['REPO_ROOT']
770
+ data = request.get_json(silent=True) or {}
771
+ user = data.get('user', 'api')
772
+
773
+ package_id = data.get('packageId')
774
+
775
+ # Load packages
776
+ pf = load_packages(repo)
777
+
778
+ # Validate package exists if setting active
779
+ if package_id and not pf.get_by_id(package_id):
780
+ return jsonify({'error': 'Package not found'}), 404
781
+
782
+ # Set active package
783
+ pf.activePackageId = package_id
784
+ save_packages(repo, pf)
785
+
786
+ # REQ-tv-d00014-H: Auto-sync after write operation
787
+ msg = f"Set active package: {package_id}" if package_id else "Cleared active package"
788
+ sync_result = trigger_auto_sync(msg, user)
789
+
790
+ response = {'success': True, 'activePackageId': package_id}
791
+ if sync_result:
792
+ response['sync'] = sync_result
793
+
794
+ return jsonify(response)
795
+
796
+ # ==========================================================================
797
+ # Archive API
798
+ # REQ-d00097: Review Package Archival
799
+ # REQ-d00099: Review Archive Viewer
800
+ # ==========================================================================
801
+
802
+ @app.route('/api/reviews/packages/<package_id>/archive', methods=['POST'])
803
+ def archive_package_endpoint(package_id):
804
+ """
805
+ Manually archive a package.
806
+
807
+ REQ-d00097-D: Archive SHALL be triggered by manual action (reason: "manual").
808
+ """
809
+ from .models import ARCHIVE_REASON_MANUAL
810
+
811
+ repo = app.config['REPO_ROOT']
812
+ data = request.get_json(silent=True) or {}
813
+ user = data.get('user', 'api')
814
+
815
+ # Get package name for response
816
+ pf = load_packages(repo)
817
+ pkg = pf.get_by_id(package_id)
818
+
819
+ if not pkg:
820
+ return jsonify({'error': 'Package not found'}), 404
821
+
822
+ # Don't allow archiving default package
823
+ if pkg.isDefault:
824
+ return jsonify({'error': 'Cannot archive default package'}), 400
825
+
826
+ pkg_name = pkg.name
827
+
828
+ try:
829
+ success = archive_package(repo, package_id, ARCHIVE_REASON_MANUAL, user)
830
+
831
+ if success:
832
+ # REQ-tv-d00014-H: Auto-sync after write operation
833
+ sync_result = trigger_auto_sync(f"Archived package: {pkg_name}", user)
834
+
835
+ response = {'success': True, 'archived': True, 'packageId': package_id}
836
+ if sync_result:
837
+ response['sync'] = sync_result
838
+
839
+ return jsonify(response)
840
+ else:
841
+ return jsonify({'error': 'Failed to archive package'}), 400
842
+ except ValueError as e:
843
+ return jsonify({'error': str(e)}), 400
844
+
845
+ @app.route('/api/reviews/archive', methods=['GET'])
846
+ def list_archived_packages_endpoint():
847
+ """
848
+ List all archived packages.
849
+
850
+ REQ-d00099-A: The UI SHALL display a list of archived packages.
851
+ """
852
+ repo = app.config['REPO_ROOT']
853
+ packages = list_archived_packages(repo)
854
+ return jsonify({
855
+ 'packages': [p.to_dict() for p in packages]
856
+ })
857
+
858
+ @app.route('/api/reviews/archive/<package_id>', methods=['GET'])
859
+ def get_archived_package_endpoint(package_id):
860
+ """
861
+ Get a specific archived package.
862
+
863
+ REQ-d00099-B: Archived packages SHALL open in read-only mode.
864
+ """
865
+ repo = app.config['REPO_ROOT']
866
+ pkg = get_archived_package(repo, package_id)
867
+
868
+ if not pkg:
869
+ return jsonify({'error': 'Archived package not found'}), 404
870
+
871
+ return jsonify(pkg.to_dict())
872
+
873
+ @app.route('/api/reviews/archive/<package_id>/reqs/<req_id>/threads', methods=['GET'])
874
+ def get_archived_threads_endpoint(package_id, req_id):
875
+ """
876
+ Get threads for a requirement from an archived package.
877
+
878
+ REQ-d00099-B: Archived packages SHALL open in read-only mode.
879
+ REQ-d00097-F: Archived data SHALL be read-only.
880
+ """
881
+ repo = app.config['REPO_ROOT']
882
+ normalized_id = normalize_req_id(req_id)
883
+
884
+ threads_file = load_archived_threads(repo, package_id, normalized_id)
885
+
886
+ if not threads_file:
887
+ return jsonify({'error': 'Threads not found in archived package'}), 404
888
+
889
+ return jsonify(threads_file.to_dict())
890
+
891
+ # ==========================================================================
892
+ # Git Sync API
893
+ # REQ-tv-d00014-E: Sync endpoints for status, push, fetch, fetch-all-package
894
+ # ==========================================================================
895
+
896
+ @app.route('/api/reviews/sync/status', methods=['GET'])
897
+ def get_sync_status_endpoint():
898
+ """
899
+ Get the current sync status.
900
+
901
+ REQ-tv-d00014-E: GET status endpoint.
902
+ """
903
+ repo = app.config['REPO_ROOT']
904
+
905
+ # Get basic status info
906
+ has_changes = has_reviews_changes(repo)
907
+ context = get_current_package_context(repo)
908
+
909
+ status = {
910
+ 'has_changes': has_changes,
911
+ 'package_id': context[0] if context else None,
912
+ 'user': context[1] if context else None,
913
+ 'auto_sync_enabled': app.config.get('AUTO_SYNC', True)
914
+ }
915
+
916
+ return jsonify(status)
917
+
918
+ @app.route('/api/reviews/sync/push', methods=['POST'])
919
+ def sync_push():
920
+ """
921
+ Manually trigger a sync (commit and push).
922
+
923
+ REQ-tv-d00014-E: POST push endpoint.
924
+ """
925
+ repo = app.config['REPO_ROOT']
926
+ data = request.get_json(silent=True) or {}
927
+ user = data.get('user', 'manual')
928
+ message = data.get('message', 'Manual sync')
929
+
930
+ success, msg = commit_and_push_reviews(repo, message, user)
931
+ return jsonify({'success': success, 'message': msg})
932
+
933
+ @app.route('/api/reviews/sync/fetch', methods=['POST'])
934
+ def sync_fetch():
935
+ """
936
+ Fetch latest review data from remote.
937
+
938
+ REQ-tv-d00014-E: POST fetch endpoint.
939
+ """
940
+ repo = app.config['REPO_ROOT']
941
+
942
+ # Get current package context
943
+ context = get_current_package_context(repo)
944
+ if context[0]:
945
+ branches = fetch_package_branches(repo, context[0])
946
+ return jsonify({
947
+ 'success': True,
948
+ 'package_id': context[0],
949
+ 'branches_fetched': branches
950
+ })
951
+ else:
952
+ return jsonify({
953
+ 'success': True,
954
+ 'message': 'Not on a review branch',
955
+ 'branches_fetched': []
956
+ })
957
+
958
+ @app.route('/api/reviews/sync/fetch-all-package', methods=['POST'])
959
+ def sync_fetch_all_package():
960
+ """
961
+ Fetch and merge review data from all users' branches for the current package.
962
+
963
+ REQ-tv-d00014-E: POST fetch-all-package endpoint.
964
+ """
965
+ repo = app.config['REPO_ROOT']
966
+
967
+ # Get current package context from branch name
968
+ context = get_current_package_context(repo)
969
+ if not context[0]:
970
+ # Not on a review branch - return empty data
971
+ return jsonify({
972
+ 'threads': {},
973
+ 'flags': {},
974
+ 'contributors': [],
975
+ 'error': 'Not on a review branch'
976
+ })
977
+
978
+ package_id = context[0]
979
+
980
+ # Fetch remote branches (if remote exists)
981
+ branches = fetch_package_branches(repo, package_id)
982
+
983
+ # Return information about fetched branches
984
+ return jsonify({
985
+ 'success': True,
986
+ 'package_id': package_id,
987
+ 'branches': branches
988
+ })
989
+
990
+ return app
991
+
992
+
993
+ def main():
994
+ """Run the review server"""
995
+ import argparse
996
+
997
+ parser = argparse.ArgumentParser(description='Review API Server')
998
+ parser.add_argument('--port', type=int, default=8080, help='Port to run on')
999
+ parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
1000
+ parser.add_argument('--repo', type=Path, default=Path.cwd(), help='Repository root')
1001
+ parser.add_argument('--static', type=Path, default=None, help='Static files directory')
1002
+ parser.add_argument('--debug', action='store_true', help='Enable debug mode')
1003
+ parser.add_argument('--no-auto-sync', action='store_true',
1004
+ help='Disable automatic git commit/push after changes')
1005
+
1006
+ args = parser.parse_args()
1007
+
1008
+ auto_sync = not args.no_auto_sync
1009
+ static_dir = args.static or args.repo.resolve()
1010
+ app = create_app(args.repo.resolve(), static_dir=static_dir, auto_sync=auto_sync)
1011
+
1012
+ sync_status = "ENABLED" if auto_sync else "DISABLED"
1013
+ print(f"""
1014
+ ======================================
1015
+ Review API Server
1016
+ ======================================
1017
+
1018
+ Repository: {args.repo.resolve()}
1019
+ Server: http://{args.host}:{args.port}
1020
+ Auto-Sync: {sync_status}
1021
+
1022
+ API Endpoints:
1023
+ GET /api/health - Health check
1024
+ GET /api/reviews/reqs/<id>/flag - Get review flag
1025
+ POST /api/reviews/reqs/<id>/flag - Set review flag
1026
+ POST /api/reviews/reqs/<id>/threads - Create thread
1027
+ POST /api/reviews/reqs/<id>/threads/<tid>/comments - Add comment
1028
+ POST /api/reviews/reqs/<id>/threads/<tid>/resolve - Resolve thread
1029
+ POST /api/reviews/reqs/<id>/threads/<tid>/unresolve - Unresolve thread
1030
+ GET /api/reviews/reqs/<id>/status - Get REQ status
1031
+ POST /api/reviews/reqs/<id>/status - Change REQ status
1032
+ GET /api/reviews/reqs/<id>/requests - Get status requests
1033
+ POST /api/reviews/reqs/<id>/requests - Create status request
1034
+ POST /api/reviews/reqs/<id>/requests/<rid>/approvals - Add approval
1035
+ GET /api/reviews/packages - List packages
1036
+ POST /api/reviews/packages - Create package
1037
+ GET /api/reviews/packages/<id> - Get package
1038
+ PUT /api/reviews/packages/<id> - Update package
1039
+ DELETE /api/reviews/packages/<id> - Delete package
1040
+ POST /api/reviews/packages/<id>/reqs/<req_id> - Add REQ to package
1041
+ DELETE /api/reviews/packages/<id>/reqs/<req_id> - Remove REQ from package
1042
+ GET /api/reviews/packages/active - Get active package
1043
+ PUT /api/reviews/packages/active - Set active package
1044
+ GET /api/reviews/sync/status - Get sync status
1045
+ POST /api/reviews/sync/push - Manual sync
1046
+ POST /api/reviews/sync/fetch - Fetch from remote
1047
+ POST /api/reviews/sync/fetch-all-package - Fetch all package branches
1048
+
1049
+ Press Ctrl+C to stop
1050
+ """)
1051
+
1052
+ app.run(host=args.host, port=args.port, debug=args.debug)
1053
+
1054
+
1055
+ if __name__ == '__main__':
1056
+ main()