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.
- plaibook/__init__.py +8 -0
- plaibook/_setup_share.py +86 -0
- plaibook/cli.py +538 -0
- plaibook/collections.py +604 -0
- plaibook/config.py +231 -0
- plaibook/cursor_http2_proxy.py +278 -0
- plaibook/hashed/build-backend-requirements.txt +11 -0
- plaibook/hashed/build-backend.in +4 -0
- plaibook/hashed/claude-requirements.txt +788 -0
- plaibook/hashed/claude.in +4 -0
- plaibook/hashed/cursor-requirements.txt +33 -0
- plaibook/hashed/cursor.in +3 -0
- plaibook/hashed/gemini-requirements.txt +617 -0
- plaibook/hashed/gemini.in +3 -0
- plaibook/hashed/openai-requirements.txt +282 -0
- plaibook/hashed/openai.in +3 -0
- plaibook/hashed/openshell-requirements.txt +99 -0
- plaibook/hashed/openshell.in +3 -0
- plaibook/hashed/sandbox-runtime-requirements.txt +357 -0
- plaibook/hashed/sandbox-runtime.in +7 -0
- plaibook/openshell_sdk.py +726 -0
- plaibook/pip_hashed.py +54 -0
- plaibook/playbook.py +274 -0
- plaibook/progress.py +271 -0
- plaibook/provider_sdk.py +178 -0
- plaibook/share/action_plugins/build_review_frontmatter.py +77 -0
- plaibook/share/action_plugins/categorize_diff_files.py +66 -0
- plaibook/share/action_plugins/check_diff_completeness.py +60 -0
- plaibook/share/action_plugins/check_evidence_provenance.py +199 -0
- plaibook/share/action_plugins/check_neutralization_references.py +91 -0
- plaibook/share/action_plugins/coerce_findings_encoding.py +146 -0
- plaibook/share/action_plugins/compute_pipeline_cost.py +188 -0
- plaibook/share/action_plugins/compute_review_scores.py +140 -0
- plaibook/share/action_plugins/compute_suggested_severity.py +153 -0
- plaibook/share/action_plugins/dedupe_findings.py +164 -0
- plaibook/share/action_plugins/detect_boolean_stringification_hallucination.py +157 -0
- plaibook/share/action_plugins/detect_diff_domains.py +257 -0
- plaibook/share/action_plugins/detect_project_type.py +120 -0
- plaibook/share/action_plugins/filter_self_refuted_findings.py +83 -0
- plaibook/share/action_plugins/find_latest_prior_review.py +57 -0
- plaibook/share/action_plugins/merge_verify_result.py +342 -0
- plaibook/share/action_plugins/parse_diff_stat.py +83 -0
- plaibook/share/action_plugins/prepare_findings_for_verification.py +153 -0
- plaibook/share/action_plugins/resolve_continuity_claim.py +107 -0
- plaibook/share/action_plugins/sanitize_response_content_for_request.py +93 -0
- plaibook/share/action_plugins/strip_verify_index.py +65 -0
- plaibook/share/action_plugins/trace_reachability.py +282 -0
- plaibook/share/ansible.cfg +99 -0
- plaibook/share/callback_plugins/plaibook_progress.py +199 -0
- plaibook/share/collections-requirements.yml +49 -0
- plaibook/share/filter_plugins/gemini_conversation.py +175 -0
- plaibook/share/filter_plugins/gemini_schema.py +88 -0
- plaibook/share/filter_plugins/openai_conversation.py +128 -0
- plaibook/share/filter_plugins/scan_input.py +71 -0
- plaibook/share/library/run_checklist.py +375 -0
- plaibook/share/review.yml +480 -0
- plaibook/share/roles/review/defaults/main.yml +694 -0
- plaibook/share/roles/review/files/guardian-ai-guardian-requirements.txt +11 -0
- plaibook/share/roles/review/files/guardian-requests-requirements.txt +16 -0
- plaibook/share/roles/review/files/pr_review/fetch_pr_context.py +488 -0
- plaibook/share/roles/review/files/pr_review/post_review_comment.py +194 -0
- plaibook/share/roles/review/tasks/append_cursor_lens_attempt_usage.yml +35 -0
- plaibook/share/roles/review/tasks/assert_cursor_prompt_nonce.yml +18 -0
- plaibook/share/roles/review/tasks/bind_cursor_named_lens.yml +45 -0
- plaibook/share/roles/review/tasks/briefing.yml +429 -0
- plaibook/share/roles/review/tasks/check_pr_ci_preflight.yml +59 -0
- plaibook/share/roles/review/tasks/copy_to_sandbox.yml +21 -0
- plaibook/share/roles/review/tasks/dispatch_cursor_lens_attempt.yml +100 -0
- plaibook/share/roles/review/tasks/dispatch_explore_tool.yml +452 -0
- plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_claude.yml +93 -0
- plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_claude_cli.yml +100 -0
- plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_cursor.yml +138 -0
- plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_gemini.yml +109 -0
- plaibook/share/roles/review/tasks/dispatch_explore_turn_attempt_openai.yml +115 -0
- plaibook/share/roles/review/tasks/dispatch_lens_claude.yml +109 -0
- plaibook/share/roles/review/tasks/dispatch_lens_claude_cli.yml +218 -0
- plaibook/share/roles/review/tasks/dispatch_lens_cursor.yml +195 -0
- plaibook/share/roles/review/tasks/dispatch_lens_gemini.yml +160 -0
- plaibook/share/roles/review/tasks/dispatch_lens_openai.yml +122 -0
- plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_claude.yml +64 -0
- plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_claude_cli.yml +169 -0
- plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_cursor.yml +123 -0
- plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_gemini.yml +130 -0
- plaibook/share/roles/review/tasks/dispatch_verify_turn_attempt_openai.yml +92 -0
- plaibook/share/roles/review/tasks/dump_debug_prompt.yml +26 -0
- plaibook/share/roles/review/tasks/ensure_runtime_deps.yml +70 -0
- plaibook/share/roles/review/tasks/exchange_sandbox_token.yml +56 -0
- plaibook/share/roles/review/tasks/explore.yml +105 -0
- plaibook/share/roles/review/tasks/explore_turn.yml +142 -0
- plaibook/share/roles/review/tasks/fail_if_sandbox_unreachable.yml +35 -0
- plaibook/share/roles/review/tasks/fetch_k8s_secret_tls.yml +30 -0
- plaibook/share/roles/review/tasks/full_review.yml +145 -0
- plaibook/share/roles/review/tasks/generate_prompt_nonce.yml +13 -0
- plaibook/share/roles/review/tasks/guardian_scan.yml +653 -0
- plaibook/share/roles/review/tasks/guardian_scan_enforce_verdict.yml +30 -0
- plaibook/share/roles/review/tasks/lenses.yml +273 -0
- plaibook/share/roles/review/tasks/main.yml +14 -0
- plaibook/share/roles/review/tasks/merge.yml +199 -0
- plaibook/share/roles/review/tasks/parse_pr_target.yml +118 -0
- plaibook/share/roles/review/tasks/persist.yml +261 -0
- plaibook/share/roles/review/tasks/pipeline_stats.yml +96 -0
- plaibook/share/roles/review/tasks/provider_preflight.yml +76 -0
- plaibook/share/roles/review/tasks/resolve_branch.yml +22 -0
- plaibook/share/roles/review/tasks/resolve_target_branch.yml +143 -0
- plaibook/share/roles/review/tasks/resolve_target_commit.yml +118 -0
- plaibook/share/roles/review/tasks/resolve_target_pr.yml +231 -0
- plaibook/share/roles/review/tasks/retry_lens_dispatch.yml +83 -0
- plaibook/share/roles/review/tasks/run_review_engine.yml +22 -0
- plaibook/share/roles/review/tasks/same_commit_fast_path.yml +61 -0
- plaibook/share/roles/review/tasks/select_cursor_named_lens.yml +59 -0
- plaibook/share/roles/review/tasks/setup_sandbox.yml +86 -0
- plaibook/share/roles/review/tasks/setup_sandbox_and_mtls.yml +27 -0
- plaibook/share/roles/review/tasks/teardown_sandbox.yml +20 -0
- plaibook/share/roles/review/tasks/validate_lens_dispatch.yml +78 -0
- plaibook/share/roles/review/tasks/verify.yml +160 -0
- plaibook/share/roles/review/tasks/verify_continuity_audit.yml +244 -0
- plaibook/share/roles/review/tasks/verify_continuity_audit_claude_cli.yml +35 -0
- plaibook/share/roles/review/tasks/verify_finding.yml +176 -0
- plaibook/share/roles/review/tasks/verify_target_freshness.yml +42 -0
- plaibook/share/roles/review/tasks/verify_turn.yml +155 -0
- plaibook/share/roles/review/tasks/write_last_run_files.yml +213 -0
- plaibook/share/roles/review/tasks/write_last_run_summary.yml +35 -0
- plaibook/share/roles/review/templates/ci_preflight_skipped.md.j2 +13 -0
- plaibook/share/roles/review/templates/commit_context.j2 +6 -0
- plaibook/share/roles/review/templates/dispatch_canary.j2 +11 -0
- plaibook/share/roles/review/templates/domain_steering/ansible.md.j2 +14 -0
- plaibook/share/roles/review/templates/domain_steering/api-endpoints.md.j2 +10 -0
- plaibook/share/roles/review/templates/domain_steering/ci-pipeline.md.j2 +9 -0
- plaibook/share/roles/review/templates/domain_steering/cli-entrypoint.md.j2 +8 -0
- plaibook/share/roles/review/templates/domain_steering/container.md.j2 +9 -0
- plaibook/share/roles/review/templates/domain_steering/database.md.j2 +9 -0
- plaibook/share/roles/review/templates/domain_steering/go-project.md.j2 +10 -0
- plaibook/share/roles/review/templates/domain_steering/k8s-operator.md.j2 +8 -0
- plaibook/share/roles/review/templates/domain_steering/new-repo.md.j2 +6 -0
- plaibook/share/roles/review/templates/domain_steering/openshift.md.j2 +14 -0
- plaibook/share/roles/review/templates/domain_steering/patternfly-ui.md.j2 +12 -0
- plaibook/share/roles/review/templates/domain_steering/performance.md.j2 +9 -0
- plaibook/share/roles/review/templates/domain_steering/python-code.md.j2 +32 -0
- plaibook/share/roles/review/templates/domain_steering/python-packaging.md.j2 +7 -0
- plaibook/share/roles/review/templates/domain_steering/shell-bash.md.j2 +15 -0
- plaibook/share/roles/review/templates/domain_steering/subprocess.md.j2 +6 -0
- plaibook/share/roles/review/templates/explore_agent_prompt.j2 +115 -0
- plaibook/share/roles/review/templates/findings.md.j2 +213 -0
- plaibook/share/roles/review/templates/pr_context.j2 +37 -0
- plaibook/share/roles/review/templates/review_agent_prompt.j2 +219 -0
- plaibook/share/roles/review/templates/security_agent_prompt.j2 +205 -0
- plaibook/share/roles/review/templates/verify_agent_prompt.j2 +181 -0
- plaibook/share/roles/review/templates/verify_agent_prompt_claude_cli.j2 +174 -0
- plaibook/share/roles/review/templates/verify_continuity_audit_prompt.j2 +56 -0
- plaibook/share/roles/review/vars/main.yml +284 -0
- plaibook/summary.py +387 -0
- plaibook/test_cli.py +1695 -0
- plaibook/test_collections.py +1113 -0
- plaibook/test_cursor_http2_proxy.py +108 -0
- plaibook/test_openshell_sdk.py +644 -0
- plaibook/test_pip_hashed.py +56 -0
- plaibook/test_progress.py +165 -0
- plaibook/test_provider_sdk.py +125 -0
- plaibook/test_scan_input.py +217 -0
- plaibook/wait.py +161 -0
- plaibook-0.1.25.dist-info/METADATA +540 -0
- plaibook-0.1.25.dist-info/RECORD +166 -0
- plaibook-0.1.25.dist-info/WHEEL +5 -0
- plaibook-0.1.25.dist-info/entry_points.txt +3 -0
- plaibook-0.1.25.dist-info/licenses/LICENSE +201 -0
- plaibook-0.1.25.dist-info/top_level.txt +1 -0
plaibook/__init__.py
ADDED
plaibook/_setup_share.py
ADDED
|
@@ -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())
|