plaibook 0.1.25__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 (166) hide show
  1. plaibook/__init__.py +8 -0
  2. plaibook/_setup_share.py +86 -0
  3. plaibook/cli.py +538 -0
  4. plaibook/collections.py +604 -0
  5. plaibook/config.py +231 -0
  6. plaibook/cursor_http2_proxy.py +278 -0
  7. plaibook/hashed/build-backend-requirements.txt +11 -0
  8. plaibook/hashed/build-backend.in +4 -0
  9. plaibook/hashed/claude-requirements.txt +788 -0
  10. plaibook/hashed/claude.in +4 -0
  11. plaibook/hashed/cursor-requirements.txt +33 -0
  12. plaibook/hashed/cursor.in +3 -0
  13. plaibook/hashed/gemini-requirements.txt +617 -0
  14. plaibook/hashed/gemini.in +3 -0
  15. plaibook/hashed/openai-requirements.txt +282 -0
  16. plaibook/hashed/openai.in +3 -0
  17. plaibook/hashed/openshell-requirements.txt +99 -0
  18. plaibook/hashed/openshell.in +3 -0
  19. plaibook/hashed/sandbox-runtime-requirements.txt +357 -0
  20. plaibook/hashed/sandbox-runtime.in +7 -0
  21. plaibook/openshell_sdk.py +726 -0
  22. plaibook/pip_hashed.py +54 -0
  23. plaibook/playbook.py +274 -0
  24. plaibook/progress.py +271 -0
  25. plaibook/provider_sdk.py +178 -0
  26. plaibook/share/action_plugins/build_review_frontmatter.py +77 -0
  27. plaibook/share/action_plugins/categorize_diff_files.py +66 -0
  28. plaibook/share/action_plugins/check_diff_completeness.py +60 -0
  29. plaibook/share/action_plugins/check_evidence_provenance.py +199 -0
  30. plaibook/share/action_plugins/check_neutralization_references.py +91 -0
  31. plaibook/share/action_plugins/coerce_findings_encoding.py +146 -0
  32. plaibook/share/action_plugins/compute_pipeline_cost.py +188 -0
  33. plaibook/share/action_plugins/compute_review_scores.py +140 -0
  34. plaibook/share/action_plugins/compute_suggested_severity.py +153 -0
  35. plaibook/share/action_plugins/dedupe_findings.py +164 -0
  36. plaibook/share/action_plugins/detect_boolean_stringification_hallucination.py +157 -0
  37. plaibook/share/action_plugins/detect_diff_domains.py +257 -0
  38. plaibook/share/action_plugins/detect_project_type.py +120 -0
  39. plaibook/share/action_plugins/filter_self_refuted_findings.py +83 -0
  40. plaibook/share/action_plugins/find_latest_prior_review.py +57 -0
  41. plaibook/share/action_plugins/merge_verify_result.py +342 -0
  42. plaibook/share/action_plugins/parse_diff_stat.py +83 -0
  43. plaibook/share/action_plugins/prepare_findings_for_verification.py +153 -0
  44. plaibook/share/action_plugins/resolve_continuity_claim.py +107 -0
  45. plaibook/share/action_plugins/sanitize_response_content_for_request.py +93 -0
  46. plaibook/share/action_plugins/strip_verify_index.py +65 -0
  47. plaibook/share/action_plugins/trace_reachability.py +282 -0
  48. plaibook/share/ansible.cfg +99 -0
  49. plaibook/share/callback_plugins/plaibook_progress.py +199 -0
  50. plaibook/share/collections-requirements.yml +49 -0
  51. plaibook/share/filter_plugins/gemini_conversation.py +175 -0
  52. plaibook/share/filter_plugins/gemini_schema.py +88 -0
  53. plaibook/share/filter_plugins/openai_conversation.py +128 -0
  54. plaibook/share/filter_plugins/scan_input.py +71 -0
  55. plaibook/share/library/run_checklist.py +375 -0
  56. plaibook/share/review.yml +480 -0
  57. plaibook/share/roles/review/defaults/main.yml +694 -0
  58. plaibook/share/roles/review/files/guardian-ai-guardian-requirements.txt +11 -0
  59. plaibook/share/roles/review/files/guardian-requests-requirements.txt +16 -0
  60. plaibook/share/roles/review/files/pr_review/fetch_pr_context.py +488 -0
  61. plaibook/share/roles/review/files/pr_review/post_review_comment.py +194 -0
  62. plaibook/share/roles/review/tasks/append_cursor_lens_attempt_usage.yml +35 -0
  63. plaibook/share/roles/review/tasks/assert_cursor_prompt_nonce.yml +18 -0
  64. plaibook/share/roles/review/tasks/bind_cursor_named_lens.yml +45 -0
  65. plaibook/share/roles/review/tasks/briefing.yml +429 -0
  66. plaibook/share/roles/review/tasks/check_pr_ci_preflight.yml +59 -0
  67. plaibook/share/roles/review/tasks/copy_to_sandbox.yml +21 -0
  68. plaibook/share/roles/review/tasks/dispatch_cursor_lens_attempt.yml +100 -0
  69. plaibook/share/roles/review/tasks/dispatch_explore_tool.yml +452 -0
  70. plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_claude.yml +93 -0
  71. plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_claude_cli.yml +100 -0
  72. plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_cursor.yml +138 -0
  73. plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_gemini.yml +109 -0
  74. plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_openai.yml +115 -0
  75. plaibook/share/roles/review/tasks/dispatch_lens_claude.yml +109 -0
  76. plaibook/share/roles/review/tasks/dispatch_lens_claude_cli.yml +218 -0
  77. plaibook/share/roles/review/tasks/dispatch_lens_cursor.yml +195 -0
  78. plaibook/share/roles/review/tasks/dispatch_lens_gemini.yml +160 -0
  79. plaibook/share/roles/review/tasks/dispatch_lens_openai.yml +122 -0
  80. plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_claude.yml +64 -0
  81. plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_claude_cli.yml +169 -0
  82. plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_cursor.yml +123 -0
  83. plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_gemini.yml +130 -0
  84. plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_openai.yml +92 -0
  85. plaibook/share/roles/review/tasks/dump_debug_prompt.yml +26 -0
  86. plaibook/share/roles/review/tasks/ensure_runtime_deps.yml +70 -0
  87. plaibook/share/roles/review/tasks/exchange_sandbox_token.yml +56 -0
  88. plaibook/share/roles/review/tasks/explore.yml +105 -0
  89. plaibook/share/roles/review/tasks/explore_turn.yml +142 -0
  90. plaibook/share/roles/review/tasks/fail_if_sandbox_unreachable.yml +35 -0
  91. plaibook/share/roles/review/tasks/fetch_k8s_secret_tls.yml +30 -0
  92. plaibook/share/roles/review/tasks/full_review.yml +145 -0
  93. plaibook/share/roles/review/tasks/generate_prompt_nonce.yml +13 -0
  94. plaibook/share/roles/review/tasks/guardian_scan.yml +653 -0
  95. plaibook/share/roles/review/tasks/guardian_scan_enforce_verdict.yml +30 -0
  96. plaibook/share/roles/review/tasks/lenses.yml +273 -0
  97. plaibook/share/roles/review/tasks/main.yml +14 -0
  98. plaibook/share/roles/review/tasks/merge.yml +199 -0
  99. plaibook/share/roles/review/tasks/parse_pr_target.yml +118 -0
  100. plaibook/share/roles/review/tasks/persist.yml +261 -0
  101. plaibook/share/roles/review/tasks/pipeline_stats.yml +96 -0
  102. plaibook/share/roles/review/tasks/provider_preflight.yml +76 -0
  103. plaibook/share/roles/review/tasks/resolve_branch.yml +22 -0
  104. plaibook/share/roles/review/tasks/resolve_target_branch.yml +143 -0
  105. plaibook/share/roles/review/tasks/resolve_target_commit.yml +118 -0
  106. plaibook/share/roles/review/tasks/resolve_target_pr.yml +231 -0
  107. plaibook/share/roles/review/tasks/retry_lens_dispatch.yml +83 -0
  108. plaibook/share/roles/review/tasks/run_review_engine.yml +22 -0
  109. plaibook/share/roles/review/tasks/same_commit_fast_path.yml +61 -0
  110. plaibook/share/roles/review/tasks/select_cursor_named_lens.yml +59 -0
  111. plaibook/share/roles/review/tasks/setup_sandbox.yml +86 -0
  112. plaibook/share/roles/review/tasks/setup_sandbox_and_mtls.yml +27 -0
  113. plaibook/share/roles/review/tasks/teardown_sandbox.yml +20 -0
  114. plaibook/share/roles/review/tasks/validate_lens_dispatch.yml +78 -0
  115. plaibook/share/roles/review/tasks/verify.yml +160 -0
  116. plaibook/share/roles/review/tasks/verify_continuity_audit.yml +244 -0
  117. plaibook/share/roles/review/tasks/verify_continuity_audit_claude_cli.yml +35 -0
  118. plaibook/share/roles/review/tasks/verify_finding.yml +176 -0
  119. plaibook/share/roles/review/tasks/verify_target_freshness.yml +42 -0
  120. plaibook/share/roles/review/tasks/verify_turn.yml +155 -0
  121. plaibook/share/roles/review/tasks/write_last_run_files.yml +213 -0
  122. plaibook/share/roles/review/tasks/write_last_run_summary.yml +35 -0
  123. plaibook/share/roles/review/templates/ci_preflight_skipped.md.j2 +13 -0
  124. plaibook/share/roles/review/templates/commit_context.j2 +6 -0
  125. plaibook/share/roles/review/templates/dispatch_canary.j2 +11 -0
  126. plaibook/share/roles/review/templates/domain_steering/ansible.md.j2 +14 -0
  127. plaibook/share/roles/review/templates/domain_steering/api-endpoints.md.j2 +10 -0
  128. plaibook/share/roles/review/templates/domain_steering/ci-pipeline.md.j2 +9 -0
  129. plaibook/share/roles/review/templates/domain_steering/cli-entrypoint.md.j2 +8 -0
  130. plaibook/share/roles/review/templates/domain_steering/container.md.j2 +9 -0
  131. plaibook/share/roles/review/templates/domain_steering/database.md.j2 +9 -0
  132. plaibook/share/roles/review/templates/domain_steering/go-project.md.j2 +10 -0
  133. plaibook/share/roles/review/templates/domain_steering/k8s-operator.md.j2 +8 -0
  134. plaibook/share/roles/review/templates/domain_steering/new-repo.md.j2 +6 -0
  135. plaibook/share/roles/review/templates/domain_steering/openshift.md.j2 +14 -0
  136. plaibook/share/roles/review/templates/domain_steering/patternfly-ui.md.j2 +12 -0
  137. plaibook/share/roles/review/templates/domain_steering/performance.md.j2 +9 -0
  138. plaibook/share/roles/review/templates/domain_steering/python-code.md.j2 +32 -0
  139. plaibook/share/roles/review/templates/domain_steering/python-packaging.md.j2 +7 -0
  140. plaibook/share/roles/review/templates/domain_steering/shell-bash.md.j2 +15 -0
  141. plaibook/share/roles/review/templates/domain_steering/subprocess.md.j2 +6 -0
  142. plaibook/share/roles/review/templates/explore_agent_prompt.j2 +115 -0
  143. plaibook/share/roles/review/templates/findings.md.j2 +213 -0
  144. plaibook/share/roles/review/templates/pr_context.j2 +37 -0
  145. plaibook/share/roles/review/templates/review_agent_prompt.j2 +219 -0
  146. plaibook/share/roles/review/templates/security_agent_prompt.j2 +205 -0
  147. plaibook/share/roles/review/templates/verify_agent_prompt.j2 +181 -0
  148. plaibook/share/roles/review/templates/verify_agent_prompt_claude_cli.j2 +174 -0
  149. plaibook/share/roles/review/templates/verify_continuity_audit_prompt.j2 +56 -0
  150. plaibook/share/roles/review/vars/main.yml +284 -0
  151. plaibook/summary.py +387 -0
  152. plaibook/test_cli.py +1695 -0
  153. plaibook/test_collections.py +1113 -0
  154. plaibook/test_cursor_http2_proxy.py +108 -0
  155. plaibook/test_openshell_sdk.py +644 -0
  156. plaibook/test_pip_hashed.py +56 -0
  157. plaibook/test_progress.py +165 -0
  158. plaibook/test_provider_sdk.py +125 -0
  159. plaibook/test_scan_input.py +217 -0
  160. plaibook/wait.py +161 -0
  161. plaibook-0.1.25.dist-info/METADATA +540 -0
  162. plaibook-0.1.25.dist-info/RECORD +166 -0
  163. plaibook-0.1.25.dist-info/WHEEL +5 -0
  164. plaibook-0.1.25.dist-info/entry_points.txt +3 -0
  165. plaibook-0.1.25.dist-info/licenses/LICENSE +201 -0
  166. plaibook-0.1.25.dist-info/top_level.txt +1 -0
plaibook/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+ """plaibook CLI package. Wraps a bundled (or checkout) review.yml; not a Galaxy collection."""
3
+
4
+ from __future__ import annotations
5
+
6
+ __all__ = ["__version__"]
7
+
8
+ __version__ = "0.1.25"
@@ -0,0 +1,86 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Copy the playbook tree into plaibook/share so wheels do not need a checkout."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ SHARE_DIRNAME = "share"
10
+ SHARE_FILES = (
11
+ "review.yml",
12
+ "ansible.cfg",
13
+ "collections-requirements.yml",
14
+ )
15
+ SHARE_TREES = (
16
+ "roles",
17
+ "action_plugins",
18
+ "library",
19
+ "filter_plugins",
20
+ "callback_plugins",
21
+ )
22
+ _IGNORE = shutil.ignore_patterns(
23
+ "__pycache__",
24
+ "*.pyc",
25
+ "*.pyo",
26
+ ".pytest_cache",
27
+ "*.retry",
28
+ ".git",
29
+ "test_*.py",
30
+ "tests",
31
+ )
32
+
33
+
34
+ def share_destination(package_dir: Path | None = None) -> Path:
35
+ root = package_dir if package_dir is not None else Path(__file__).resolve().parent
36
+ return root / SHARE_DIRNAME
37
+
38
+
39
+ def materialize_playbook_share(repo_root: Path, dest: Path | None = None) -> Path:
40
+ """Copy review.yml and the Ansible plugin/role trees into *dest*.
41
+
42
+ Called from setuptools ``build_py`` so ``pip install plaibook`` ships a
43
+ runnable playbook, not just the CLI wrapper.
44
+ """
45
+ source_root = Path(repo_root).resolve()
46
+ target = Path(dest).resolve() if dest is not None else share_destination()
47
+ if target.exists():
48
+ shutil.rmtree(target)
49
+ target.mkdir(parents=True)
50
+
51
+ missing: list[str] = []
52
+ for name in SHARE_FILES:
53
+ src = source_root / name
54
+ if not src.is_file():
55
+ missing.append(name)
56
+ continue
57
+ shutil.copy2(src, target / name)
58
+ for name in SHARE_TREES:
59
+ src = source_root / name
60
+ if not src.is_dir():
61
+ missing.append(name)
62
+ continue
63
+ shutil.copytree(src, target / name, ignore=_IGNORE)
64
+
65
+ if missing:
66
+ raise FileNotFoundError(
67
+ "Cannot vendor the plaibook playbook into the wheel; missing "
68
+ + ", ".join(missing)
69
+ + f" under {source_root}"
70
+ )
71
+ if not (target / "review.yml").is_file() or not (target / "ansible.cfg").is_file():
72
+ raise FileNotFoundError(f"Vendored playbook at {target} is incomplete")
73
+ return target
74
+
75
+
76
+ try:
77
+ from setuptools.command.build_py import build_py as _build_py
78
+ except ImportError: # pragma: no cover - setuptools is the build backend
79
+ _build_py = object # type: ignore[misc, assignment]
80
+
81
+
82
+ class build_py(_build_py): # type: ignore[valid-type, misc]
83
+ def run(self) -> None:
84
+ repo_root = Path(__file__).resolve().parent.parent
85
+ materialize_playbook_share(repo_root, share_destination())
86
+ super().run()
plaibook/cli.py ADDED
@@ -0,0 +1,538 @@
1
+ # -*- coding: utf-8 -*-
2
+ """plaibook CLI (also installed as `plai`). Thin wrapper around review.yml."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+
12
+ from plaibook import __version__
13
+ from plaibook.collections import CollectionInstallError, ensure_collections
14
+ from plaibook.config import (
15
+ FAMILIES,
16
+ ConfigError,
17
+ load_vars,
18
+ openshell_available,
19
+ resolve_family,
20
+ running_inside_openshell,
21
+ )
22
+ from plaibook.openshell_sdk import OpenshellSdkError, reexec_sandbox_runtime
23
+ from plaibook.playbook import (
24
+ PlaybookNotFoundError,
25
+ PlaybookTimeoutError,
26
+ ScratchDirError,
27
+ build_ansible_command,
28
+ find_playbook_root,
29
+ generate_run_id,
30
+ last_run_path,
31
+ run_ansible_playbook,
32
+ runtime_tmp_dir,
33
+ )
34
+ from plaibook.progress import create_progress_file
35
+ from plaibook.provider_sdk import ProviderSdkError, ensure_provider_sdk
36
+ from plaibook.summary import (
37
+ SummaryError,
38
+ dump_json,
39
+ dump_yaml,
40
+ enrich_last_run,
41
+ format_pretty,
42
+ load_json,
43
+ sanitize_display_line,
44
+ )
45
+ from plaibook.wait import WaitSpinner, spinner_enabled
46
+
47
+ USAGE_EPILOG = """\
48
+ plai and plaibook are the same program. The pip/uv distribution name is plaibook
49
+ (not plai, which is taken on PyPI, and not ansible-plaibook).
50
+
51
+ pip install plaibook vendors review.yml into the wheel. plai review with no
52
+ arguments reviews HEAD in the current directory. First run installs Galaxy
53
+ collections into ~/.cache/ansible-plaibook/collections (never ~/.ansible).
54
+ That install is the playbook; --root points at a local checkout instead.
55
+ The CLI shells out
56
+ to ansible-playbook and reads last_run.<run_id>.json. It does not rescore
57
+ findings or scrape playbook stdout. It does not yet run ansible-playbook
58
+ aknochow.plaibook.review (that FQCN lands when plaibook is a collection).
59
+
60
+ Default stdout is a readable review (target, verdict, 0-100 scores,
61
+ Critical/Major with file:line + why). Quiet TTY waits show a spinner on
62
+ stderr (PLAIBOOK_SPINNER=0 to disable), with a second line for the current
63
+ stage (setup, checkout, scan, lenses, merge, explore, verify, persist).
64
+ --json / --yaml emit the structured last_run + summary fields. -v passes
65
+ -v to ansible-playbook (task names). Combined with --json/--yaml, ansible
66
+ output goes to stderr so stdout stays parseable. -vv / --debug passes -vv
67
+ (task names and module args) and skips the spinner. --full (or -v) adds
68
+ the findings.md report. A score line plus finding counts is not a review.
69
+ Same-commit cache hits print that they reused the prior review (why cost
70
+ is $0.00). -f / --force disables that fast path and re-runs the lenses.
71
+ A SKIPPED verdict (CI failing on the PR head) prints why and the failing
72
+ check names; pass `-e review_require_ci_passing=false` to review anyway.
73
+
74
+ First review with no operator config prompts for a provider and writes
75
+ ~/.config/ansible-plaibook/vars.yml. Cursor defaults to gpt-5.6-luna / high.
76
+ PR/branch reviews skip nested OpenShell when this process is already
77
+ inside an OpenShell sandbox. They fail closed if the SDK is not
78
+ importable from this interpreter (--no-sandbox to review on the host,
79
+ --sandbox to require it).
80
+
81
+ AAP / execution-environment jobs keep calling ansible-playbook review.yml.
82
+
83
+ Examples:
84
+ pip install plaibook
85
+ plai review
86
+ plai review org/repo/123
87
+ plai review org/repo/pull/123
88
+ plaibook review org/repo/123
89
+ plai review --commit
90
+ plai review --commit --repo /path/to/repo --sha abc1234
91
+ plai review org/repo/123 --json
92
+ plai review org/repo/123 --yaml
93
+ plai review org/repo/123 -v
94
+ plai review org/repo/123 -vv
95
+ plai review org/repo/123 --debug
96
+ plai review org/repo/123 --full
97
+ plai review org/repo/123 -f
98
+ plai review org/repo/123 --provider cursor
99
+ plai review org/repo/123 --no-sandbox
100
+ """
101
+
102
+
103
+ def _prog_name() -> str:
104
+ name = Path(sys.argv[0]).name
105
+ if not name or name.endswith(".py") or name == "-c":
106
+ return "plaibook"
107
+ return name
108
+
109
+
110
+ def build_parser(prog: str | None = None) -> argparse.ArgumentParser:
111
+ program = prog or _prog_name()
112
+ parser = argparse.ArgumentParser(
113
+ prog=program,
114
+ description="plaibook CLI (also installed as plai).",
115
+ epilog=USAGE_EPILOG,
116
+ formatter_class=argparse.RawDescriptionHelpFormatter,
117
+ )
118
+ parser.add_argument(
119
+ "--version",
120
+ action="version",
121
+ version=f"plaibook {__version__}",
122
+ )
123
+ sub = parser.add_subparsers(dest="command")
124
+
125
+ review = sub.add_parser(
126
+ "review",
127
+ help="Run review.yml (pr / commit / branch).",
128
+ description="Wrap ansible-playbook review.yml and print a readable review.",
129
+ )
130
+ review.add_argument(
131
+ "target",
132
+ nargs="?",
133
+ help="PR/MR identifier: org/repo/N, org/repo/pull/N, gitlab:org/repo/N, or a full GitHub/GitLab URL.",
134
+ )
135
+ mode = review.add_mutually_exclusive_group()
136
+ mode.add_argument(
137
+ "--commit",
138
+ action="store_true",
139
+ help=("review_type=commit. Default when no PR/MR target is given (reviews HEAD in the current directory)."),
140
+ )
141
+ mode.add_argument(
142
+ "--branch",
143
+ metavar="TARGET",
144
+ dest="branch_target",
145
+ help="review_type=branch with this branch_review_target.",
146
+ )
147
+ review.add_argument(
148
+ "--repo",
149
+ dest="repo_path",
150
+ help="Local repo path for --commit (default: current directory).",
151
+ )
152
+ review.add_argument(
153
+ "--sha",
154
+ dest="commit_sha",
155
+ help="Commit SHA for --commit (default: HEAD).",
156
+ )
157
+ review.add_argument(
158
+ "-v",
159
+ "--verbose",
160
+ action="count",
161
+ default=0,
162
+ help=("Pass -v to ansible-playbook (task names). Repeat for more (-vv / --debug shows module args)."),
163
+ )
164
+ review.add_argument(
165
+ "--debug",
166
+ action="store_true",
167
+ help="Debug mode: ansible-playbook -vv (task names and args). No spinner.",
168
+ )
169
+ fmt = review.add_mutually_exclusive_group()
170
+ fmt.add_argument(
171
+ "--json",
172
+ action="store_true",
173
+ dest="as_json",
174
+ help="Write the structured summary to stdout (no pretty block).",
175
+ )
176
+ fmt.add_argument(
177
+ "--yaml",
178
+ action="store_true",
179
+ dest="as_yaml",
180
+ help="Write the structured summary to stdout as YAML (no pretty block).",
181
+ )
182
+ review.add_argument(
183
+ "--full",
184
+ action="store_true",
185
+ help="Include the full findings.md report after the pretty review.",
186
+ )
187
+ review.add_argument(
188
+ "-f",
189
+ "--force",
190
+ action="store_true",
191
+ help=(
192
+ "Force a full review even when this commit was already reviewed "
193
+ "(sets review_same_commit_fast_path_enabled=false)."
194
+ ),
195
+ )
196
+ review.add_argument(
197
+ "--notes",
198
+ dest="review_extra_notes",
199
+ help="Trusted operator notes for this run (JSON extra-vars, colons are safe).",
200
+ )
201
+ review.add_argument(
202
+ "--post",
203
+ action="store_true",
204
+ help="Set post_results=true (write back to the PR/MR).",
205
+ )
206
+ review.add_argument(
207
+ "--fail-on-regressions",
208
+ dest="fail_on_regressions",
209
+ action=argparse.BooleanOptionalAction,
210
+ default=None,
211
+ help="Override fail_on_regressions (commit defaults to true).",
212
+ )
213
+ review.add_argument(
214
+ "--provider",
215
+ dest="provider",
216
+ metavar="FAMILY",
217
+ choices=list(FAMILIES),
218
+ help=(
219
+ "Set agent_family and save it to ~/.config/ansible-plaibook/vars.yml. "
220
+ "cursor saves gpt-5.6-luna / high when those keys are unset."
221
+ ),
222
+ )
223
+ review.add_argument(
224
+ "--sandbox",
225
+ dest="use_sandbox",
226
+ action=argparse.BooleanOptionalAction,
227
+ default=None,
228
+ help=(
229
+ "Run PR/branch briefing in an OpenShell sandbox. Default is on for "
230
+ "pr/branch when this process is not already inside OpenShell. "
231
+ "Python 3.10 switches to ~/.cache/ansible-plaibook/sandbox-runtime "
232
+ "(Python 3.11+). --no-sandbox stays on this interpreter."
233
+ ),
234
+ )
235
+ review.add_argument(
236
+ "-e",
237
+ "--extra-var",
238
+ action="append",
239
+ default=[],
240
+ dest="cli_extra_vars",
241
+ metavar="KEY=VALUE",
242
+ help="Pass extra-vars to ansible-playbook (repeatable). true/false become booleans.",
243
+ )
244
+ review.add_argument(
245
+ "--root",
246
+ dest="playbook_root",
247
+ help="Use a local playbook checkout instead of this pip install.",
248
+ )
249
+ return parser
250
+
251
+
252
+ def _parse_extra_var(item: str) -> tuple[str, object]:
253
+ key, sep, value = item.partition("=")
254
+ key = key.strip()
255
+ if not sep or not key:
256
+ raise ValueError(f"extra-var {item!r} must be KEY=VALUE")
257
+ lowered = value.lower()
258
+ if lowered == "true":
259
+ return key, True
260
+ if lowered == "false":
261
+ return key, False
262
+ return key, value
263
+
264
+
265
+ def extra_vars_from_args(args: argparse.Namespace, run_id: str) -> dict:
266
+ extras: dict = {"last_run_id": run_id}
267
+ if args.commit:
268
+ extras["review_type"] = "commit"
269
+ if args.repo_path:
270
+ extras["repo_path"] = args.repo_path
271
+ if args.commit_sha:
272
+ extras["commit_sha"] = args.commit_sha
273
+ elif args.branch_target:
274
+ extras["review_type"] = "branch"
275
+ extras["branch_review_target"] = args.branch_target
276
+ else:
277
+ extras["review_type"] = "pr"
278
+ extras["review_targets_raw"] = args.target
279
+ if args.review_extra_notes:
280
+ extras["review_extra_notes"] = args.review_extra_notes
281
+ if args.post:
282
+ extras["post_results"] = True
283
+ if args.fail_on_regressions is not None:
284
+ extras["fail_on_regressions"] = bool(args.fail_on_regressions)
285
+ if getattr(args, "use_sandbox", None) is not None:
286
+ extras["use_sandbox"] = bool(args.use_sandbox)
287
+ if getattr(args, "force", False):
288
+ extras["review_same_commit_fast_path_enabled"] = False
289
+ for item in getattr(args, "cli_extra_vars", None) or []:
290
+ key, value = _parse_extra_var(item)
291
+ if key == "last_run_id":
292
+ raise ValueError(
293
+ "last_run_id is owned by the CLI; omit -e last_run_id= (the wrapper already generates one)."
294
+ )
295
+ extras[key] = value
296
+ return extras
297
+
298
+
299
+ def ansible_verbosity(args: argparse.Namespace) -> int:
300
+ """How many -v flags to pass to ansible-playbook.
301
+
302
+ ``--debug`` is at least 2 (ansible -vv). ``-v`` as a boolean (tests)
303
+ counts as 1.
304
+ """
305
+ raw = getattr(args, "verbose", 0)
306
+ if raw is True:
307
+ level = 1
308
+ elif raw is False or raw is None:
309
+ level = 0
310
+ else:
311
+ level = int(raw)
312
+ if getattr(args, "debug", False):
313
+ level = max(level, 2)
314
+ return level
315
+
316
+
317
+ def _validate_review_args(args: argparse.Namespace) -> str | None:
318
+ if args.commit:
319
+ if args.target:
320
+ return "review --commit does not take a PR/MR target"
321
+ return None
322
+ if args.branch_target:
323
+ if args.target:
324
+ return "review --branch does not take a positional PR/MR target"
325
+ return None
326
+ if args.target:
327
+ return None
328
+ args.commit = True
329
+ return None
330
+
331
+
332
+ def _wants_sandbox(extras: dict) -> bool:
333
+ """True when this run will create an OpenShell sandbox."""
334
+ if extras.get("use_sandbox") is False:
335
+ return False
336
+ if running_inside_openshell() and extras.get("use_sandbox") is not True:
337
+ return False
338
+ if extras.get("use_sandbox") is True:
339
+ return True
340
+ return extras.get("review_type") != "commit"
341
+
342
+
343
+ def _apply_sandbox_fallback(args: argparse.Namespace, extras: dict) -> str | None:
344
+ """Fail closed when a PR/branch review cannot create the default sandbox."""
345
+ if extras.get("review_type") == "commit" and "use_sandbox" not in extras:
346
+ return None
347
+ if extras.get("use_sandbox") is True and not openshell_available():
348
+ return (
349
+ "OpenShell SDK is not importable from "
350
+ f"{sys.executable}. Pass --no-sandbox to review on this interpreter."
351
+ )
352
+ if "use_sandbox" in extras:
353
+ return None
354
+ if extras.get("review_type") == "commit":
355
+ return None
356
+ if running_inside_openshell():
357
+ extras["use_sandbox"] = False
358
+ return None
359
+ if openshell_available():
360
+ return None
361
+ return (
362
+ "OpenShell SDK is not importable from "
363
+ f"{sys.executable}. PR/branch reviews run untrusted checklist "
364
+ "commands and require a sandbox. Pass --no-sandbox to review on "
365
+ "this interpreter."
366
+ )
367
+
368
+
369
+ def _emit_summary(document: dict, args: argparse.Namespace) -> None:
370
+ if args.as_json:
371
+ dump_json(document, sys.stdout)
372
+ return
373
+ if args.as_yaml:
374
+ dump_yaml(document, sys.stdout)
375
+ return
376
+ sys.stdout.write(
377
+ format_pretty(
378
+ document,
379
+ full=bool(getattr(args, "full", False) or ansible_verbosity(args)),
380
+ )
381
+ )
382
+
383
+
384
+ def _progress_line(args: argparse.Namespace) -> str:
385
+ if args.commit:
386
+ repo = sanitize_display_line(args.repo_path or ".")
387
+ sha = sanitize_display_line(args.commit_sha or "HEAD")
388
+ return f"Reviewing commit {sha} in {repo}\n"
389
+ if args.branch_target:
390
+ return f"Reviewing branch {sanitize_display_line(args.branch_target)}\n"
391
+ return f"Reviewing {sanitize_display_line(args.target)}\n"
392
+
393
+
394
+ def cmd_review(args: argparse.Namespace) -> int:
395
+ error = _validate_review_args(args)
396
+ if error:
397
+ print(error, file=sys.stderr)
398
+ return 2
399
+
400
+ try:
401
+ root = Path(args.playbook_root).expanduser().resolve() if args.playbook_root else find_playbook_root()
402
+ if args.playbook_root and not ((root / "review.yml").is_file() and (root / "ansible.cfg").is_file()):
403
+ print(
404
+ f"--root {root} does not contain review.yml and ansible.cfg",
405
+ file=sys.stderr,
406
+ )
407
+ return 2
408
+ except PlaybookNotFoundError as exc:
409
+ print(str(exc), file=sys.stderr)
410
+ return 2
411
+
412
+ run_id = generate_run_id()
413
+ try:
414
+ extras = extra_vars_from_args(args, run_id)
415
+ except ValueError as exc:
416
+ print(str(exc), file=sys.stderr)
417
+ return 2
418
+ if _wants_sandbox(extras):
419
+ try:
420
+ reexec_sandbox_runtime(stderr=sys.stderr)
421
+ except OpenshellSdkError as exc:
422
+ print(str(exc), file=sys.stderr)
423
+ return 2
424
+
425
+ try:
426
+ ensure_collections(root, stderr=sys.stderr)
427
+ except CollectionInstallError as exc:
428
+ print(str(exc), file=sys.stderr)
429
+ return 2
430
+ if getattr(args, "provider", None) or "agent_family" not in extras:
431
+ try:
432
+ resolve_family(
433
+ cli_family=getattr(args, "provider", None),
434
+ stdin=sys.stdin,
435
+ stderr=sys.stderr,
436
+ )
437
+ except (ValueError, ConfigError) as exc:
438
+ print(str(exc), file=sys.stderr)
439
+ return 2
440
+ family = extras.get("agent_family") or load_vars().get("agent_family")
441
+ if not family:
442
+ family = (os.environ.get("ANSIBLE_REVIEW_AGENT_FAMILY") or "").strip() or None
443
+ if family:
444
+ try:
445
+ ensure_provider_sdk(str(family), stderr=sys.stderr)
446
+ except ProviderSdkError as exc:
447
+ print(str(exc), file=sys.stderr)
448
+ return 2
449
+ sandbox_error = _apply_sandbox_fallback(args, extras)
450
+ if sandbox_error:
451
+ print(sandbox_error, file=sys.stderr)
452
+ return 2
453
+ try:
454
+ command = build_ansible_command(
455
+ extra_vars=extras,
456
+ playbook_root=root,
457
+ verbosity=ansible_verbosity(args),
458
+ )
459
+ except FileNotFoundError as exc:
460
+ print(str(exc), file=sys.stderr)
461
+ return 2
462
+
463
+ structured = args.as_json or args.as_yaml
464
+ inherit_tty = (not structured) and ansible_verbosity(args) > 0
465
+ try:
466
+ if inherit_tty:
467
+ sys.stderr.write(_progress_line(args))
468
+ sys.stderr.flush()
469
+ result = run_ansible_playbook(command, playbook_root=root, verbose=True)
470
+ elif spinner_enabled(sys.stderr):
471
+ progress_dir, progress_path = create_progress_file(directory=str(runtime_tmp_dir()))
472
+ try:
473
+ with WaitSpinner(
474
+ _progress_line(args).rstrip("\n"),
475
+ stream=sys.stderr,
476
+ progress_file=progress_path,
477
+ ):
478
+ result = run_ansible_playbook(
479
+ command,
480
+ playbook_root=root,
481
+ verbose=False,
482
+ env={"PLAIBOOK_PROGRESS_FILE": progress_path},
483
+ )
484
+ finally:
485
+ Path(progress_path).unlink(missing_ok=True)
486
+ try:
487
+ Path(progress_dir).rmdir()
488
+ except OSError:
489
+ pass
490
+ else:
491
+ if not structured:
492
+ sys.stderr.write(_progress_line(args))
493
+ sys.stderr.flush()
494
+ result = run_ansible_playbook(command, playbook_root=root, verbose=False)
495
+ except (PlaybookTimeoutError, ScratchDirError, ValueError) as exc:
496
+ print(str(exc), file=sys.stderr)
497
+ return 2
498
+ captured = "" if inherit_tty else ((result.stderr or "") + (result.stdout or ""))
499
+ if structured and ansible_verbosity(args) > 0 and captured.strip():
500
+ sys.stderr.write(captured)
501
+ if not captured.endswith("\n"):
502
+ sys.stderr.write("\n")
503
+ captured = ""
504
+ # Always last_run.<run_id>.json — never last_run.json. The playbook
505
+ # always-block writes both (including same-commit cache hits); the
506
+ # canonical path is last-write-wins and can belong to another run.
507
+ summary_file = last_run_path(run_id)
508
+ if not summary_file.is_file():
509
+ if captured.strip():
510
+ sys.stderr.write(captured)
511
+ if not captured.endswith("\n"):
512
+ sys.stderr.write("\n")
513
+ print(
514
+ f"ansible-playbook exited {result.returncode} without writing {summary_file}",
515
+ file=sys.stderr,
516
+ )
517
+ return result.returncode if result.returncode else 2
518
+
519
+ try:
520
+ document = enrich_last_run(load_json(summary_file), last_run_file=summary_file)
521
+ except SummaryError as exc:
522
+ print(str(exc), file=sys.stderr)
523
+ return 2
524
+ _emit_summary(document, args)
525
+ return result.returncode
526
+
527
+
528
+ def main(argv: Sequence[str] | None = None) -> int:
529
+ parser = build_parser()
530
+ args = parser.parse_args(list(argv) if argv is not None else None)
531
+ if args.command != "review":
532
+ parser.print_help()
533
+ return 2
534
+ return cmd_review(args)
535
+
536
+
537
+ if __name__ == "__main__":
538
+ sys.exit(main())