patchrail 0.1.0__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 (47) hide show
  1. patchrail/__init__.py +7 -0
  2. patchrail/__main__.py +7 -0
  3. patchrail/ci/__init__.py +7 -0
  4. patchrail/ci/classify.py +888 -0
  5. patchrail/cli.py +8566 -0
  6. patchrail/funded_issues/__init__.py +138 -0
  7. patchrail/funded_issues/algora_board.py +240 -0
  8. patchrail/funded_issues/blocklist.py +112 -0
  9. patchrail/funded_issues/discovery.py +4091 -0
  10. patchrail/funded_issues/importers.py +316 -0
  11. patchrail/funded_issues/source_noise.py +349 -0
  12. patchrail/funded_issues/store.py +459 -0
  13. patchrail/queue/__init__.py +75 -0
  14. patchrail/queue/server.py +273 -0
  15. patchrail/queue/status.py +756 -0
  16. patchrail/queue/store.py +600 -0
  17. patchrail/reviewer_quick_check.py +650 -0
  18. patchrail/schemas/__init__.py +1 -0
  19. patchrail/schemas/application-dossier.v1.schema.json +305 -0
  20. patchrail/schemas/ci-benchmark.v1.schema.json +174 -0
  21. patchrail/schemas/ci-fixture-check.v1.schema.json +122 -0
  22. patchrail/schemas/ci-pilot-metrics.v1.schema.json +164 -0
  23. patchrail/schemas/ci-pilot-summary.v1.schema.json +146 -0
  24. patchrail/schemas/ci-result.v1.schema.json +133 -0
  25. patchrail/schemas/funded-issues-client-report.v1.schema.json +524 -0
  26. patchrail/schemas/funded-issues-recheck-queue.v1.schema.json +333 -0
  27. patchrail/schemas/funded-issues-recheck-summary.v1.schema.json +136 -0
  28. patchrail/schemas/funded-issues-report.v1.schema.json +836 -0
  29. patchrail/schemas/funded-issues-shortlist.v1.schema.json +953 -0
  30. patchrail/schemas/funded-issues-store-status.v1.schema.json +96 -0
  31. patchrail/schemas/funded-issues-store.v1.schema.json +117 -0
  32. patchrail/schemas/queue-audit-event.v1.schema.json +44 -0
  33. patchrail/schemas/queue-audit-summary.v1.schema.json +169 -0
  34. patchrail/schemas/queue-gate-report.v1.schema.json +158 -0
  35. patchrail/schemas/queue-policy-resolution.v1.schema.json +188 -0
  36. patchrail/schemas/queue-policy-scan.v1.schema.json +175 -0
  37. patchrail/schemas/queue-proposal.v1.schema.json +61 -0
  38. patchrail/schemas/queue-review.v1.schema.json +218 -0
  39. patchrail/schemas/queue-status.v1.schema.json +179 -0
  40. patchrail/schemas/queue-work-item.v1.schema.json +64 -0
  41. patchrail/schemas/reviewer-quick-check-artifacts.v1.schema.json +104 -0
  42. patchrail/web_metrics.py +649 -0
  43. patchrail-0.1.0.dist-info/METADATA +279 -0
  44. patchrail-0.1.0.dist-info/RECORD +47 -0
  45. patchrail-0.1.0.dist-info/WHEEL +4 -0
  46. patchrail-0.1.0.dist-info/entry_points.txt +2 -0
  47. patchrail-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,756 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Any
8
+
9
+ from patchrail import __version__
10
+ from patchrail.queue.store import (
11
+ DEFAULT_QUEUE_PATH,
12
+ export_audit_events,
13
+ init_queue,
14
+ list_proposals,
15
+ list_work_items,
16
+ reject_proposal,
17
+ skip_work_item,
18
+ )
19
+
20
+ QUEUE_STATUS_SCHEMA_VERSION = "patchrail.queue_status.v1"
21
+ QUEUE_AUDIT_SUMMARY_SCHEMA_VERSION = "patchrail.queue_audit_summary.v1"
22
+ QUEUE_BUNDLE_SCHEMA_VERSION = "patchrail.queue_bundle.v1"
23
+ QUEUE_GATE_REPORT_SCHEMA_VERSION = "patchrail.queue_gate_report.v1"
24
+ QUEUE_REVIEW_SCHEMA_VERSION = "patchrail.queue_review.v1"
25
+ QUEUE_POLICY_SCAN_SCHEMA_VERSION = "patchrail.queue_policy_scan.v1"
26
+ QUEUE_POLICY_RESOLUTION_SCHEMA_VERSION = "patchrail.queue_policy_resolution.v1"
27
+ DEFAULT_POLICY_RESOLUTION_REASON = "revenue-safety policy: local-only human approval required"
28
+
29
+ DEFAULT_REQUIRED_AUDIT_EVENTS = [
30
+ "work_item_added",
31
+ "proposal_added",
32
+ "proposal_approved",
33
+ "proposal_rejected",
34
+ "work_item_approved",
35
+ "work_item_rejected",
36
+ "work_items_exported",
37
+ ]
38
+
39
+ SAFE_QUEUE_REQUIREMENTS = {
40
+ "billing_required": False,
41
+ "external_model_required": False,
42
+ "network_required": False,
43
+ "github_write_permission_required": False,
44
+ "write_actions_allowed_by_default": False,
45
+ }
46
+
47
+ SAFE_QUEUE_STATUS = {
48
+ **SAFE_QUEUE_REQUIREMENTS,
49
+ "approval_records_execute_actions": False,
50
+ }
51
+
52
+ LOCAL_PATH_PATTERN = re.compile(
53
+ r"(/Volumes|/Users|/home|/tmp|/private/tmp|/var/folders|/private/var)/[^\s\"'`]+"
54
+ )
55
+
56
+ POLICY_SCAN_RULES = {
57
+ "automatic_pull_request": [
58
+ "automatic pr",
59
+ "automatic pull request",
60
+ "auto pr",
61
+ "auto-submit",
62
+ "auto submit",
63
+ "open pr automatically",
64
+ "open pull request automatically",
65
+ ],
66
+ "automatic_issue_comment": [
67
+ "automatic issue comment",
68
+ "comment automatically",
69
+ "mass comment",
70
+ "post a comment",
71
+ "post comment",
72
+ ],
73
+ "outbound_contact": [
74
+ "contact maintainers",
75
+ "devrel",
76
+ "lead",
77
+ "outbound",
78
+ "sales",
79
+ "send email",
80
+ ],
81
+ "funding_or_claim": [
82
+ "bounty",
83
+ "claim reward",
84
+ "funding claim",
85
+ "invoice",
86
+ "paid pilot",
87
+ "payment link",
88
+ "payout",
89
+ "pricing",
90
+ ],
91
+ "identity_or_money_gate": [
92
+ "bank",
93
+ "card",
94
+ "government id",
95
+ "kyc",
96
+ "phone",
97
+ "postal address",
98
+ "tax form",
99
+ ],
100
+ "external_write": [
101
+ "create release",
102
+ "create tag",
103
+ "external repo",
104
+ "force-push",
105
+ "gh issue comment",
106
+ "gh pr create",
107
+ "gh release create",
108
+ "git push",
109
+ "merge pull request",
110
+ "open issue comment",
111
+ "publish release",
112
+ "push to main",
113
+ "tag release",
114
+ "third-party",
115
+ ],
116
+ "package_publish": [
117
+ "cargo publish",
118
+ "npm publish",
119
+ "publish package",
120
+ "publish to npm",
121
+ "publish to pypi",
122
+ "twine upload",
123
+ ],
124
+ "external_application_submission": [
125
+ "application form",
126
+ "send application",
127
+ "submit application",
128
+ "submit external form",
129
+ "submit form",
130
+ ],
131
+ }
132
+
133
+
134
+ def _redact_local_paths(value: Any) -> Any:
135
+ if isinstance(value, dict):
136
+ return {key: _redact_local_paths(item) for key, item in value.items()}
137
+ if isinstance(value, list):
138
+ return [_redact_local_paths(item) for item in value]
139
+ if isinstance(value, str):
140
+ return LOCAL_PATH_PATTERN.sub(
141
+ lambda match: f"<local-path>/{Path(match.group(0)).name}",
142
+ value,
143
+ )
144
+ return value
145
+
146
+
147
+ def _policy_scan_text(value: Any) -> str:
148
+ if value is None:
149
+ return ""
150
+ if isinstance(value, str):
151
+ return value
152
+ return json.dumps(value, sort_keys=True, ensure_ascii=True)
153
+
154
+
155
+ def _policy_matches(text: str) -> list[dict[str, str]]:
156
+ normalized = " ".join(text.lower().replace("_", " ").replace("-", " ").split())
157
+ matches: list[dict[str, str]] = []
158
+ for category, terms in POLICY_SCAN_RULES.items():
159
+ for term in terms:
160
+ normalized_term = " ".join(term.lower().replace("_", " ").replace("-", " ").split())
161
+ if normalized_term in normalized:
162
+ matches.append({"category": category, "term": term})
163
+ return matches
164
+
165
+
166
+ def _policy_scan_work_item(item: dict[str, Any]) -> dict[str, Any] | None:
167
+ if item["status"] == "skipped" or item["approval_state"] == "rejected":
168
+ return None
169
+ haystack = "\n".join(
170
+ [
171
+ _policy_scan_text(item.get("kind")),
172
+ _policy_scan_text(item.get("title")),
173
+ _policy_scan_text(item.get("source")),
174
+ _policy_scan_text(item.get("payload")),
175
+ _policy_scan_text(item.get("decision_note")),
176
+ ]
177
+ )
178
+ matches = _policy_matches(haystack)
179
+ if not matches:
180
+ return None
181
+ return {
182
+ "record_type": "work_item",
183
+ "id": item["id"],
184
+ "title": item["title"],
185
+ "status": item["status"],
186
+ "approval_state": item["approval_state"],
187
+ "source": item["source"],
188
+ "matched_categories": sorted({match["category"] for match in matches}),
189
+ "matched_terms": sorted({match["term"] for match in matches}),
190
+ "recommended_action": "reject_or_skip_before_handoff",
191
+ }
192
+
193
+
194
+ def _policy_scan_proposal(proposal: dict[str, Any]) -> dict[str, Any] | None:
195
+ if proposal["approval_state"] == "rejected":
196
+ return None
197
+ haystack = "\n".join(
198
+ [
199
+ _policy_scan_text(proposal.get("title")),
200
+ _policy_scan_text(proposal.get("summary")),
201
+ _policy_scan_text(proposal.get("patch_plan")),
202
+ _policy_scan_text(proposal.get("decision_note")),
203
+ ]
204
+ )
205
+ matches = _policy_matches(haystack)
206
+ if not matches:
207
+ return None
208
+ return {
209
+ "record_type": "proposal",
210
+ "id": proposal["id"],
211
+ "work_item_id": proposal["work_item_id"],
212
+ "title": proposal["title"],
213
+ "risk_level": proposal["risk_level"],
214
+ "approval_state": proposal["approval_state"],
215
+ "matched_categories": sorted({match["category"] for match in matches}),
216
+ "matched_terms": sorted({match["term"] for match in matches}),
217
+ "recommended_action": "reject_before_handoff",
218
+ }
219
+
220
+
221
+ def queue_status_payload(
222
+ db_path: Path = DEFAULT_QUEUE_PATH,
223
+ *,
224
+ include_api_compat: bool = False,
225
+ ) -> dict[str, Any]:
226
+ init_result = init_queue(db_path)
227
+ work_items = _redact_local_paths([item.to_dict() for item in list_work_items(db_path=db_path)])
228
+ proposals = _redact_local_paths(
229
+ [proposal.to_dict() for proposal in list_proposals(db_path=db_path)]
230
+ )
231
+ audit_events = _redact_local_paths(export_audit_events(db_path=db_path)["audit_events"])
232
+ work_item_approval_counts = Counter(item["approval_state"] for item in work_items)
233
+ work_item_status_counts = Counter(item["status"] for item in work_items)
234
+ proposal_approval_counts = Counter(proposal["approval_state"] for proposal in proposals)
235
+ pending_work_items = work_item_approval_counts.get("pending", 0)
236
+ pending_proposals = proposal_approval_counts.get("pending", 0)
237
+ total_pending_decisions = pending_work_items + pending_proposals
238
+ latest_audit_event = audit_events[-1] if audit_events else None
239
+ payload: dict[str, Any] = {
240
+ "schema_version": QUEUE_STATUS_SCHEMA_VERSION,
241
+ "queue_schema_version": init_result["schema_version"],
242
+ "patchrail_version": __version__,
243
+ "db_path": _redact_local_paths(str(db_path)),
244
+ "local_first": True,
245
+ "host_boundary": "127.0.0.1 only by default",
246
+ "counts": {
247
+ "work_items_total": len(work_items),
248
+ "work_items_by_status": dict(sorted(work_item_status_counts.items())),
249
+ "work_items_by_approval_state": dict(sorted(work_item_approval_counts.items())),
250
+ "proposals_total": len(proposals),
251
+ "proposals_by_approval_state": dict(sorted(proposal_approval_counts.items())),
252
+ "audit_events_total": len(audit_events),
253
+ },
254
+ "human_gate_summary": {
255
+ "status": (
256
+ "awaiting_human_review" if total_pending_decisions > 0 else "no_pending_decisions"
257
+ ),
258
+ "pending_work_items": pending_work_items,
259
+ "pending_proposals": pending_proposals,
260
+ "total_pending_decisions": total_pending_decisions,
261
+ "approved_work_items": work_item_approval_counts.get("approved", 0),
262
+ "rejected_work_items": work_item_approval_counts.get("rejected", 0),
263
+ "approved_proposals": proposal_approval_counts.get("approved", 0),
264
+ "rejected_proposals": proposal_approval_counts.get("rejected", 0),
265
+ "write_actions_unlocked": False,
266
+ },
267
+ "latest_audit_event": latest_audit_event,
268
+ "safety": SAFE_QUEUE_STATUS,
269
+ }
270
+ if include_api_compat:
271
+ payload["requirements"] = SAFE_QUEUE_REQUIREMENTS
272
+ payload["queue"] = {
273
+ "schema_version": init_result["schema_version"],
274
+ "work_items": len(work_items),
275
+ "pending_work_items": work_item_approval_counts.get("pending", 0),
276
+ "proposals": len(proposals),
277
+ "pending_proposals": proposal_approval_counts.get("pending", 0),
278
+ }
279
+ return payload
280
+
281
+
282
+ def queue_audit_summary_payload(
283
+ db_path: Path = DEFAULT_QUEUE_PATH,
284
+ *,
285
+ required_events: list[str] | None = None,
286
+ ) -> dict[str, Any]:
287
+ init_result = init_queue(db_path)
288
+ audit_events = export_audit_events(db_path=db_path)["audit_events"]
289
+ work_items = [item.to_dict() for item in list_work_items(db_path=db_path)]
290
+ proposals = [proposal.to_dict() for proposal in list_proposals(db_path=db_path)]
291
+ event_counts = Counter(event["event_type"] for event in audit_events)
292
+ required = required_events or DEFAULT_REQUIRED_AUDIT_EVENTS
293
+ missing_required_events = [event for event in required if event_counts.get(event, 0) == 0]
294
+ affected_work_items = sorted(
295
+ {
296
+ str(event["work_item_id"])
297
+ for event in audit_events
298
+ if event.get("work_item_id") is not None
299
+ }
300
+ )
301
+ gates = {
302
+ "work_item_approval_gate_exercised": event_counts.get("work_item_approved", 0) > 0,
303
+ "work_item_rejection_gate_exercised": event_counts.get("work_item_rejected", 0) > 0,
304
+ "work_item_skip_gate_exercised": event_counts.get("work_item_skipped", 0) > 0,
305
+ "proposal_approval_gate_exercised": event_counts.get("proposal_approved", 0) > 0,
306
+ "proposal_rejection_gate_exercised": event_counts.get("proposal_rejected", 0) > 0,
307
+ "queue_export_recorded": event_counts.get("work_items_exported", 0) > 0,
308
+ }
309
+ return {
310
+ "schema_version": QUEUE_AUDIT_SUMMARY_SCHEMA_VERSION,
311
+ "queue_schema_version": init_result["schema_version"],
312
+ "patchrail_version": __version__,
313
+ "db_path": _redact_local_paths(str(db_path)),
314
+ "local_first": True,
315
+ "status": "human_gates_exercised"
316
+ if not missing_required_events
317
+ else "needs_more_audit_evidence",
318
+ "counts": {
319
+ "audit_events_total": len(audit_events),
320
+ "event_types": dict(sorted(event_counts.items())),
321
+ "work_items_total": len(work_items),
322
+ "proposals_total": len(proposals),
323
+ "affected_work_items": len(affected_work_items),
324
+ },
325
+ "required_events": required,
326
+ "missing_required_events": missing_required_events,
327
+ "gates": gates,
328
+ "affected_work_item_ids": affected_work_items,
329
+ "latest_audit_event": audit_events[-1] if audit_events else None,
330
+ "safety": SAFE_QUEUE_STATUS,
331
+ }
332
+
333
+
334
+ def queue_bundle_payload(
335
+ db_path: Path = DEFAULT_QUEUE_PATH,
336
+ *,
337
+ required_events: list[str] | None = None,
338
+ ) -> dict[str, Any]:
339
+ status = _redact_local_paths(queue_status_payload(db_path))
340
+ audit_summary = _redact_local_paths(
341
+ queue_audit_summary_payload(db_path, required_events=required_events)
342
+ )
343
+ work_items = _redact_local_paths([item.to_dict() for item in list_work_items(db_path=db_path)])
344
+ proposals = _redact_local_paths(
345
+ [proposal.to_dict() for proposal in list_proposals(db_path=db_path)]
346
+ )
347
+ audit_events = _redact_local_paths(export_audit_events(db_path=db_path)["audit_events"])
348
+ ready = audit_summary["status"] == "human_gates_exercised"
349
+ gate_summary = status["human_gate_summary"]
350
+ remaining_gate_gaps = audit_summary["missing_required_events"]
351
+ reviewer_summary = {
352
+ "status": "ready_for_reviewer_handoff" if ready else "needs_more_gate_evidence",
353
+ "ready_for_handoff": ready,
354
+ "human_gates_complete": ready,
355
+ "pending_decisions": gate_summary["total_pending_decisions"],
356
+ "approved_work_items": gate_summary["approved_work_items"],
357
+ "rejected_work_items": gate_summary["rejected_work_items"],
358
+ "approved_proposals": gate_summary["approved_proposals"],
359
+ "rejected_proposals": gate_summary["rejected_proposals"],
360
+ "remaining_gate_gaps": remaining_gate_gaps,
361
+ "review_steps": [
362
+ "Inspect work_items for local CI evidence and write_actions_allowed=false.",
363
+ "Inspect proposals for approved low-risk plans and rejected risky plans.",
364
+ "Inspect audit_summary for required human gate events.",
365
+ "Inspect safety to confirm the bundle is read-only and local paths are redacted.",
366
+ ],
367
+ "execution_allowed": False,
368
+ }
369
+ return {
370
+ "schema_version": QUEUE_BUNDLE_SCHEMA_VERSION,
371
+ "queue_schema_version": status["queue_schema_version"],
372
+ "patchrail_version": __version__,
373
+ "db_path": _redact_local_paths(str(db_path)),
374
+ "local_first": True,
375
+ "status": "ready_for_handoff" if ready else "needs_more_gate_evidence",
376
+ "counts": {
377
+ "work_items_total": len(work_items),
378
+ "proposals_total": len(proposals),
379
+ "audit_events_total": len(audit_events),
380
+ },
381
+ "status_summary": status,
382
+ "audit_summary": audit_summary,
383
+ "work_items": work_items,
384
+ "proposals": proposals,
385
+ "audit_events": audit_events,
386
+ "reviewer_summary": reviewer_summary,
387
+ "safety": {
388
+ **SAFE_QUEUE_STATUS,
389
+ "bundle_is_read_only": True,
390
+ "bundle_records_audit_event": False,
391
+ "local_paths_redacted": True,
392
+ },
393
+ "remaining_gate_gaps": remaining_gate_gaps,
394
+ }
395
+
396
+
397
+ def queue_gate_report_payload(
398
+ db_path: Path = DEFAULT_QUEUE_PATH,
399
+ *,
400
+ required_events: list[str] | None = None,
401
+ ) -> dict[str, Any]:
402
+ status = _redact_local_paths(queue_status_payload(db_path))
403
+ audit_summary = _redact_local_paths(
404
+ queue_audit_summary_payload(db_path, required_events=required_events)
405
+ )
406
+ gate_summary = status["human_gate_summary"]
407
+ missing_required_events = audit_summary["missing_required_events"]
408
+ pending_decisions = gate_summary["total_pending_decisions"]
409
+ ready = pending_decisions == 0 and not missing_required_events
410
+ reviewer_actions: list[str] = []
411
+ if pending_decisions:
412
+ reviewer_actions.append("Review or reject all pending work items and proposals.")
413
+ if missing_required_events:
414
+ reviewer_actions.append("Exercise the missing local human-gate audit events.")
415
+ if not reviewer_actions:
416
+ reviewer_actions.append("Inspect the queue bundle before any separate write action.")
417
+ return {
418
+ "schema_version": QUEUE_GATE_REPORT_SCHEMA_VERSION,
419
+ "queue_schema_version": status["queue_schema_version"],
420
+ "patchrail_version": __version__,
421
+ "db_path": _redact_local_paths(str(db_path)),
422
+ "local_first": True,
423
+ "status": "ready_for_reviewer_handoff" if ready else "needs_reviewer_decisions",
424
+ "ready_for_reviewer_handoff": ready,
425
+ "pending_decisions": pending_decisions,
426
+ "missing_required_events": missing_required_events,
427
+ "decision_counts": {
428
+ "pending_work_items": gate_summary["pending_work_items"],
429
+ "pending_proposals": gate_summary["pending_proposals"],
430
+ "approved_work_items": gate_summary["approved_work_items"],
431
+ "rejected_work_items": gate_summary["rejected_work_items"],
432
+ "approved_proposals": gate_summary["approved_proposals"],
433
+ "rejected_proposals": gate_summary["rejected_proposals"],
434
+ },
435
+ "audit_counts": audit_summary["counts"],
436
+ "gates": audit_summary["gates"],
437
+ "reviewer_actions": reviewer_actions,
438
+ "safety": {
439
+ **SAFE_QUEUE_STATUS,
440
+ "report_is_read_only": True,
441
+ "report_records_audit_event": False,
442
+ "local_paths_redacted": True,
443
+ "execution_allowed": False,
444
+ },
445
+ }
446
+
447
+
448
+ def _compact_work_item(item: dict[str, Any]) -> dict[str, Any]:
449
+ payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
450
+ return {
451
+ "id": item["id"],
452
+ "kind": item["kind"],
453
+ "title": item["title"],
454
+ "source": item["source"],
455
+ "status": item["status"],
456
+ "approval_state": item["approval_state"],
457
+ "write_actions_allowed": item["write_actions_allowed"],
458
+ "decision_note": item.get("decision_note"),
459
+ "payload_keys": sorted(str(key) for key in payload),
460
+ }
461
+
462
+
463
+ def _compact_proposal(proposal: dict[str, Any]) -> dict[str, Any]:
464
+ return {
465
+ "id": proposal["id"],
466
+ "work_item_id": proposal["work_item_id"],
467
+ "title": proposal["title"],
468
+ "risk_level": proposal["risk_level"],
469
+ "approval_state": proposal["approval_state"],
470
+ "decision_note": proposal.get("decision_note"),
471
+ }
472
+
473
+
474
+ def _queue_review_handoff_checklist(
475
+ *,
476
+ pending_work_items: list[dict[str, Any]],
477
+ pending_proposals: list[dict[str, Any]],
478
+ ) -> list[dict[str, str]]:
479
+ checklist: list[dict[str, str]] = []
480
+ if pending_work_items:
481
+ checklist.extend(
482
+ [
483
+ {
484
+ "state": "pending_work_items",
485
+ "command": "patchrail queue --db <queue.sqlite> approve <work-item-id>",
486
+ "purpose": "Approve a reviewed local work item without enabling execution.",
487
+ },
488
+ {
489
+ "state": "pending_work_items",
490
+ "command": "patchrail queue --db <queue.sqlite> reject <work-item-id>",
491
+ "purpose": "Reject a local work item that should not move forward.",
492
+ },
493
+ {
494
+ "state": "pending_work_items",
495
+ "command": (
496
+ "patchrail queue --db <queue.sqlite> skip <work-item-id> "
497
+ '--reason "revenue-safety policy: local-only human approval required"'
498
+ ),
499
+ "purpose": "Skip retired or out-of-scope work while preserving history.",
500
+ },
501
+ ]
502
+ )
503
+ if pending_proposals:
504
+ checklist.extend(
505
+ [
506
+ {
507
+ "state": "pending_proposals",
508
+ "command": (
509
+ "patchrail queue --db <queue.sqlite> proposal approve <proposal-id>"
510
+ ),
511
+ "purpose": "Approve a reviewed local patch plan for maintainer handoff.",
512
+ },
513
+ {
514
+ "state": "pending_proposals",
515
+ "command": (
516
+ "patchrail queue --db <queue.sqlite> proposal reject <proposal-id>"
517
+ ),
518
+ "purpose": "Reject a patch plan that is risky, stale, or out of scope.",
519
+ },
520
+ ]
521
+ )
522
+ if not checklist:
523
+ checklist.extend(
524
+ [
525
+ {
526
+ "state": "clear_for_handoff",
527
+ "command": "patchrail queue --db <queue.sqlite> policy-scan --format markdown",
528
+ "purpose": "Confirm no active policy-blocking records remain before handoff.",
529
+ },
530
+ {
531
+ "state": "clear_for_handoff",
532
+ "command": "patchrail queue --db <queue.sqlite> gate-report --format markdown",
533
+ "purpose": "Verify human gates and required local audit evidence.",
534
+ },
535
+ {
536
+ "state": "clear_for_handoff",
537
+ "command": "patchrail queue --db <queue.sqlite> bundle --format markdown",
538
+ "purpose": "Generate the read-only reviewer handoff packet.",
539
+ },
540
+ ]
541
+ )
542
+ return checklist
543
+
544
+
545
+ def queue_review_payload(db_path: Path = DEFAULT_QUEUE_PATH) -> dict[str, Any]:
546
+ status = queue_status_payload(db_path)
547
+ work_items = _redact_local_paths([item.to_dict() for item in list_work_items(db_path=db_path)])
548
+ proposals = _redact_local_paths(
549
+ [proposal.to_dict() for proposal in list_proposals(db_path=db_path)]
550
+ )
551
+ pending_work_items = [
552
+ _compact_work_item(item) for item in work_items if item["approval_state"] == "pending"
553
+ ]
554
+ pending_proposals = [
555
+ _compact_proposal(proposal)
556
+ for proposal in proposals
557
+ if proposal["approval_state"] == "pending"
558
+ ]
559
+ approved_work_items = [
560
+ _compact_work_item(item) for item in work_items if item["approval_state"] == "approved"
561
+ ]
562
+ approved_proposals = [
563
+ _compact_proposal(proposal)
564
+ for proposal in proposals
565
+ if proposal["approval_state"] == "approved"
566
+ ]
567
+ rejected_work_items = [
568
+ _compact_work_item(item) for item in work_items if item["approval_state"] == "rejected"
569
+ ]
570
+ rejected_proposals = [
571
+ _compact_proposal(proposal)
572
+ for proposal in proposals
573
+ if proposal["approval_state"] == "rejected"
574
+ ]
575
+ pending_decisions = len(pending_work_items) + len(pending_proposals)
576
+ reviewer_actions: list[str] = []
577
+ if pending_work_items:
578
+ reviewer_actions.append("Review pending work items, then approve, reject, or skip them.")
579
+ if pending_proposals:
580
+ reviewer_actions.append("Review pending proposals, then approve or reject each local plan.")
581
+ if not reviewer_actions:
582
+ reviewer_actions.append("No pending decisions remain; inspect gate-report or bundle next.")
583
+ handoff_checklist = _queue_review_handoff_checklist(
584
+ pending_work_items=pending_work_items,
585
+ pending_proposals=pending_proposals,
586
+ )
587
+ return {
588
+ "schema_version": QUEUE_REVIEW_SCHEMA_VERSION,
589
+ "queue_schema_version": status["queue_schema_version"],
590
+ "patchrail_version": __version__,
591
+ "db_path": _redact_local_paths(str(db_path)),
592
+ "local_first": True,
593
+ "status": "awaiting_human_review" if pending_decisions else "clear_for_handoff",
594
+ "ready_for_reviewer_handoff": pending_decisions == 0,
595
+ "pending_decisions": pending_decisions,
596
+ "counts": status["counts"],
597
+ "review_groups": {
598
+ "pending_work_items": pending_work_items,
599
+ "pending_proposals": pending_proposals,
600
+ "approved_work_items": approved_work_items,
601
+ "approved_proposals": approved_proposals,
602
+ "rejected_work_items": rejected_work_items,
603
+ "rejected_proposals": rejected_proposals,
604
+ },
605
+ "reviewer_actions": reviewer_actions,
606
+ "handoff_checklist": handoff_checklist,
607
+ "safety": {
608
+ **SAFE_QUEUE_STATUS,
609
+ "review_is_read_only": True,
610
+ "review_records_audit_event": False,
611
+ "local_paths_redacted": True,
612
+ "execution_allowed": False,
613
+ },
614
+ }
615
+
616
+
617
+ def queue_policy_scan_payload(db_path: Path = DEFAULT_QUEUE_PATH) -> dict[str, Any]:
618
+ status = queue_status_payload(db_path)
619
+ work_items = _redact_local_paths([item.to_dict() for item in list_work_items(db_path=db_path)])
620
+ proposals = _redact_local_paths(
621
+ [proposal.to_dict() for proposal in list_proposals(db_path=db_path)]
622
+ )
623
+ matches = [
624
+ *(match for item in work_items if (match := _policy_scan_work_item(item)) is not None),
625
+ *(
626
+ match
627
+ for proposal in proposals
628
+ if (match := _policy_scan_proposal(proposal)) is not None
629
+ ),
630
+ ]
631
+ reviewer_actions = (
632
+ [
633
+ "Reject or skip matching local records before any handoff.",
634
+ "Keep historical records visible; do not delete queue data to hide policy drift.",
635
+ ]
636
+ if matches
637
+ else ["No policy-blocking queue records found; continue with gate-report or bundle."]
638
+ )
639
+ return {
640
+ "schema_version": QUEUE_POLICY_SCAN_SCHEMA_VERSION,
641
+ "queue_schema_version": status["queue_schema_version"],
642
+ "patchrail_version": __version__,
643
+ "db_path": _redact_local_paths(str(db_path)),
644
+ "local_first": True,
645
+ "status": "blocked_records_present" if matches else "policy_clear",
646
+ "blocked_records_count": len(matches),
647
+ "scanned_counts": {
648
+ "work_items_total": len(work_items),
649
+ "proposals_total": len(proposals),
650
+ "audit_events_total": status["counts"]["audit_events_total"],
651
+ },
652
+ "policy_categories": sorted(POLICY_SCAN_RULES),
653
+ "matches": matches,
654
+ "reviewer_actions": reviewer_actions,
655
+ "safety": {
656
+ **SAFE_QUEUE_STATUS,
657
+ "scan_is_read_only": True,
658
+ "scan_records_audit_event": False,
659
+ "local_paths_redacted": True,
660
+ "execution_allowed": False,
661
+ },
662
+ }
663
+
664
+
665
+ def queue_policy_resolution_payload(
666
+ db_path: Path = DEFAULT_QUEUE_PATH,
667
+ *,
668
+ reason: str = DEFAULT_POLICY_RESOLUTION_REASON,
669
+ ) -> dict[str, Any]:
670
+ before = queue_policy_scan_payload(db_path)
671
+ resolved_records: list[dict[str, Any]] = []
672
+ work_items_skipped = 0
673
+ proposals_rejected = 0
674
+ audit_before = before["scanned_counts"]["audit_events_total"]
675
+
676
+ for match in before["matches"]:
677
+ if match["record_type"] == "work_item":
678
+ item = skip_work_item(
679
+ db_path=db_path,
680
+ item_id=match["id"],
681
+ decision_note=reason,
682
+ ).to_dict()
683
+ item = _redact_local_paths(item)
684
+ work_items_skipped += 1
685
+ resolved_records.append(
686
+ {
687
+ "record_type": "work_item",
688
+ "id": match["id"],
689
+ "title": match["title"],
690
+ "action": "skipped",
691
+ "approval_state_after": item["approval_state"],
692
+ "status_after": item["status"],
693
+ "matched_categories": match["matched_categories"],
694
+ "matched_terms": match["matched_terms"],
695
+ }
696
+ )
697
+ elif match["record_type"] == "proposal":
698
+ proposal = reject_proposal(
699
+ db_path=db_path,
700
+ proposal_id=match["id"],
701
+ decision_note=reason,
702
+ ).to_dict()
703
+ proposal = _redact_local_paths(proposal)
704
+ proposals_rejected += 1
705
+ resolved_records.append(
706
+ {
707
+ "record_type": "proposal",
708
+ "id": match["id"],
709
+ "work_item_id": match["work_item_id"],
710
+ "title": match["title"],
711
+ "action": "rejected",
712
+ "approval_state_after": proposal["approval_state"],
713
+ "status_after": "rejected",
714
+ "matched_categories": match["matched_categories"],
715
+ "matched_terms": match["matched_terms"],
716
+ }
717
+ )
718
+
719
+ after = queue_policy_scan_payload(db_path)
720
+ audit_after = after["scanned_counts"]["audit_events_total"]
721
+ return {
722
+ "schema_version": QUEUE_POLICY_RESOLUTION_SCHEMA_VERSION,
723
+ "queue_schema_version": before["queue_schema_version"],
724
+ "patchrail_version": __version__,
725
+ "db_path": _redact_local_paths(str(db_path)),
726
+ "local_first": True,
727
+ "status": "resolved_blocked_records" if resolved_records else "no_blocked_records",
728
+ "reason": reason,
729
+ "resolved_records_count": len(resolved_records),
730
+ "resolved_counts": {
731
+ "work_items_skipped": work_items_skipped,
732
+ "proposals_rejected": proposals_rejected,
733
+ "audit_events_added": audit_after - audit_before,
734
+ },
735
+ "resolved_records": resolved_records,
736
+ "before_policy_status": before["status"],
737
+ "after_policy_status": after["status"],
738
+ "remaining_blocked_records_count": after["blocked_records_count"],
739
+ "reviewer_actions": [
740
+ "Run policy-scan again before handoff.",
741
+ "Inspect audit events to confirm skipped work items and rejected proposals remain visible.",
742
+ ]
743
+ if resolved_records
744
+ else ["No policy-blocking queue records were active."],
745
+ "safety": {
746
+ **SAFE_QUEUE_STATUS,
747
+ "resolution_is_local_only": True,
748
+ "resolution_records_audit_event": True,
749
+ "local_paths_redacted": True,
750
+ "execution_allowed": False,
751
+ "github_write_performed": False,
752
+ "network_performed": False,
753
+ "proposals_executed": False,
754
+ "work_items_deleted": False,
755
+ },
756
+ }