argus-code-review 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.
- argus/__init__.py +1 -0
- argus/cli.py +673 -0
- argus/config.py +102 -0
- argus/coverage.py +11 -0
- argus/dotenv_utils.py +88 -0
- argus/github_client.py +1006 -0
- argus/graph.py +2788 -0
- argus/helpers.py +143 -0
- argus/llm/models.py +126 -0
- argus/llm/output_models.py +492 -0
- argus/models.py +197 -0
- argus/openai_client.py +286 -0
- argus/pipeline_models.py +291 -0
- argus/prompts/__init__.py +8 -0
- argus/prompts/pr-review-blocking-validator.md +53 -0
- argus/prompts/pr-review-coverage-check.md +15 -0
- argus/prompts/pr-review-cross-cutting.md +78 -0
- argus/prompts/pr-review-feedback-verifier.md +31 -0
- argus/prompts/pr-review-lite.md +50 -0
- argus/prompts/pr-review-planner.md +193 -0
- argus/prompts/pr-review-preflight-router.md +24 -0
- argus/prompts/pr-review-prior-art.md +25 -0
- argus/prompts/pr-review-specialist-deployment.md +52 -0
- argus/prompts/pr-review-specialist-frontend.md +90 -0
- argus/prompts/pr-review-specialist-infra.md +33 -0
- argus/prompts/pr-review-specialist-llm-patterns.md +53 -0
- argus/prompts/pr-review-specialist-observability.md +47 -0
- argus/prompts/pr-review-specialist-orchestration.md +62 -0
- argus/prompts/pr-review-specialist-security.md +41 -0
- argus/prompts/pr-review-specialist-slackbot.md +47 -0
- argus/prompts/pr-review-specialist-sql.md +34 -0
- argus/prompts/pr-review-subagent.md +120 -0
- argus/prompts/pr-review-tests-and-docs.md +34 -0
- argus/prompts/pr-review-writer.md +268 -0
- argus/prompts_runtime.py +237 -0
- argus/repo_provision.py +445 -0
- argus/runners.py +1136 -0
- argus/storage/__init__.py +51 -0
- argus/storage/http.py +243 -0
- argus/storage/models.py +115 -0
- argus/storage/resolver.py +379 -0
- argus/storage/session.py +73 -0
- argus/storage/sql.py +488 -0
- argus/storage/sqlite.py +588 -0
- argus_code_review-0.1.0.dist-info/METADATA +405 -0
- argus_code_review-0.1.0.dist-info/RECORD +49 -0
- argus_code_review-0.1.0.dist-info/WHEEL +4 -0
- argus_code_review-0.1.0.dist-info/entry_points.txt +2 -0
- argus_code_review-0.1.0.dist-info/licenses/LICENSE +193 -0
argus/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Orchestrated PR review service using Claude Agent SDK."""
|
argus/cli.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local runner for the Argus v3 review agent.
|
|
3
|
+
|
|
4
|
+
Configuration is env-var / ``.env`` only — no AWS, no SSM. If required
|
|
5
|
+
secrets are missing after the load, the runner exits with a clear error.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Review a PR (positional repo, or --repo — mutually exclusive, not both)
|
|
9
|
+
uv run argus review owner/repo --pr 3124
|
|
10
|
+
uv run argus review --repo owner/repo --pr 3124
|
|
11
|
+
|
|
12
|
+
# Review a specific SHA against main
|
|
13
|
+
uv run argus review owner/repo --sha abc123 --base-ref main
|
|
14
|
+
|
|
15
|
+
# Write review comment to a file instead of just printing
|
|
16
|
+
uv run argus review owner/repo --pr 3124 -o review.md
|
|
17
|
+
|
|
18
|
+
# Also post/update the review as a PR comment, and set a commit status
|
|
19
|
+
uv run argus review owner/repo --pr 3124 --post --commit-status
|
|
20
|
+
|
|
21
|
+
# Dismiss a finding before running the next round
|
|
22
|
+
uv run argus review owner/repo --pr 3124 --dismiss "B2 -- pre-existing, not from this PR"
|
|
23
|
+
|
|
24
|
+
# List / export the packaged prompts for customization
|
|
25
|
+
uv run argus prompts list
|
|
26
|
+
uv run argus prompts export ./my-prompts
|
|
27
|
+
|
|
28
|
+
# Also runnable as a flat legacy invocation (no subcommand) or via -m:
|
|
29
|
+
uv run python -m argus.cli --repo owner/repo --pr 3124
|
|
30
|
+
|
|
31
|
+
Environment variables (HTTP-mode opt-in):
|
|
32
|
+
ARGUS_STORAGE_READ_URL / ARGUS_STORAGE_WRITE_URL / ARGUS_STORAGE_AUTH
|
|
33
|
+
Defaults for the corresponding ``--storage-*`` flags. Both
|
|
34
|
+
READ_URL and WRITE_URL must be set together (AUTH is optional)
|
|
35
|
+
to arm the HTTP shim and let the runner work inside sandboxes
|
|
36
|
+
that can't reach Postgres on 5432.
|
|
37
|
+
|
|
38
|
+
ARGUS_SQLITE_CHECKPOINT_PATH
|
|
39
|
+
Applies whenever no Postgres URL is configured (the SQLite
|
|
40
|
+
checkpointer is the default; ``ARGUS_DB_URL`` opts into
|
|
41
|
+
Postgres). When unset (default), the runner writes the
|
|
42
|
+
LangGraph checkpoint to ``/tmp/argus-checkpoint-<PID>.db`` and
|
|
43
|
+
unlinks it on exit. Set to a fixed path to preserve the
|
|
44
|
+
checkpoint across runs (e.g. for post-mortem inspection of a
|
|
45
|
+
failed pipeline). The runner will not delete an operator-pinned
|
|
46
|
+
path.
|
|
47
|
+
|
|
48
|
+
No storage env vars are required: with neither ``ARGUS_DB_URL`` nor the
|
|
49
|
+
HTTP-shim URLs set, round history and checkpoints default to local SQLite.
|
|
50
|
+
Required secrets are only ANTHROPIC_API_KEY, GITHUB_TOKEN_RO,
|
|
51
|
+
and OPENAI_API_KEY.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import asyncio
|
|
58
|
+
import contextlib
|
|
59
|
+
import json
|
|
60
|
+
import logging
|
|
61
|
+
import os
|
|
62
|
+
import sys
|
|
63
|
+
import threading
|
|
64
|
+
import time
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from typing import TYPE_CHECKING
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from argus.config import Settings
|
|
70
|
+
from argus.models import ReviewRequest, ReviewResponse
|
|
71
|
+
|
|
72
|
+
logging.basicConfig(
|
|
73
|
+
level=logging.INFO,
|
|
74
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
75
|
+
datefmt="%H:%M:%S",
|
|
76
|
+
)
|
|
77
|
+
logger = logging.getLogger("argus_review_local")
|
|
78
|
+
|
|
79
|
+
# Hard wall-clock backstop: if the entire review has not completed within this
|
|
80
|
+
# many seconds, the watchdog thread force-exits the process via os._exit. This
|
|
81
|
+
# is the only reliable cap locally, because a deadlocked event loop (e.g. the
|
|
82
|
+
# macOS child-watcher misreaping subprocess grandchildren) cannot be interrupted
|
|
83
|
+
# by asyncio-level timeouts. 60 minutes is well above a healthy review (8-20 min).
|
|
84
|
+
_WATCHDOG_TIMEOUT_S = 3600
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sweep_stale_argus_tempdirs() -> None:
|
|
88
|
+
"""Best-effort removal of Argus temp artifacts the forced exit would orphan."""
|
|
89
|
+
import glob
|
|
90
|
+
import shutil
|
|
91
|
+
import tempfile
|
|
92
|
+
|
|
93
|
+
tmp = tempfile.gettempdir()
|
|
94
|
+
for path in glob.glob(os.path.join(tmp, "argus-worktree-*")):
|
|
95
|
+
with contextlib.suppress(Exception):
|
|
96
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
97
|
+
for path in glob.glob(os.path.join(tmp, "argus-gitcfg-*")):
|
|
98
|
+
with contextlib.suppress(Exception):
|
|
99
|
+
os.unlink(path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _start_watchdog(repo: str, pr: object) -> threading.Thread:
|
|
103
|
+
"""Start the hard wall-clock backstop as a daemon thread.
|
|
104
|
+
|
|
105
|
+
If the review has not finished within ``_WATCHDOG_TIMEOUT_S``, the thread
|
|
106
|
+
sweeps stale Argus temp artifacts (best-effort) and force-exits via
|
|
107
|
+
``os._exit`` - the only reliable cap when a deadlocked event loop cannot be
|
|
108
|
+
interrupted by asyncio-level timeouts. ``repo`` / ``pr`` are captured so the
|
|
109
|
+
log line names WHAT timed out.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def _watchdog() -> None:
|
|
113
|
+
time.sleep(_WATCHDOG_TIMEOUT_S)
|
|
114
|
+
logger.critical(
|
|
115
|
+
"Watchdog: review of %s PR #%s exceeded hard timeout of %ds; force-exiting",
|
|
116
|
+
repo,
|
|
117
|
+
pr,
|
|
118
|
+
_WATCHDOG_TIMEOUT_S,
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
_sweep_stale_argus_tempdirs()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
os._exit(1)
|
|
125
|
+
|
|
126
|
+
thread = threading.Thread(target=_watchdog, daemon=True, name="argus-watchdog")
|
|
127
|
+
thread.start()
|
|
128
|
+
return thread
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _load_settings() -> "Settings":
|
|
132
|
+
"""Load settings from .env / shell.
|
|
133
|
+
|
|
134
|
+
Clears the cached Settings singleton first so a fresh process env
|
|
135
|
+
(e.g. a `.env` loaded moments ago) is picked up.
|
|
136
|
+
"""
|
|
137
|
+
from argus.dotenv_utils import load_dotenv_early
|
|
138
|
+
|
|
139
|
+
# Pin to '.env' explicitly — the docs reference '.env', and the default
|
|
140
|
+
# in load_dotenv_early would otherwise pick '.env.local' (or
|
|
141
|
+
# '.env.production') based on ENVIRONMENT, leading to silent no-op loads.
|
|
142
|
+
env_path = load_dotenv_early(start=Path.cwd(), env_filename=".env")
|
|
143
|
+
if env_path:
|
|
144
|
+
logger.info("Loaded local env vars from %s", env_path)
|
|
145
|
+
|
|
146
|
+
from argus.config import clear_cache, get_settings
|
|
147
|
+
|
|
148
|
+
# Clear the settings cache so it picks up any just-loaded .env values.
|
|
149
|
+
clear_cache()
|
|
150
|
+
|
|
151
|
+
settings = get_settings()
|
|
152
|
+
|
|
153
|
+
# Propagate any loaded keys back into os.environ so child code that reads
|
|
154
|
+
# them directly picks up the right values.
|
|
155
|
+
for attr in (
|
|
156
|
+
"ANTHROPIC_API_KEY",
|
|
157
|
+
"OPENAI_API_KEY",
|
|
158
|
+
"GITHUB_TOKEN_RO",
|
|
159
|
+
"LANGSMITH_API_KEY",
|
|
160
|
+
"LANGSMITH_PROJECT",
|
|
161
|
+
"CONTEXT7_API_KEY",
|
|
162
|
+
):
|
|
163
|
+
val = getattr(settings, attr, None)
|
|
164
|
+
if val:
|
|
165
|
+
os.environ[attr] = val
|
|
166
|
+
if settings.db_url:
|
|
167
|
+
os.environ.setdefault("SUPABASE_DB_URL", settings.db_url)
|
|
168
|
+
|
|
169
|
+
return settings
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_settings(settings: "Settings") -> None:
|
|
173
|
+
"""Validate that critical secrets were loaded, fail loudly otherwise.
|
|
174
|
+
|
|
175
|
+
Only the three API credentials are required: ``ANTHROPIC_API_KEY``
|
|
176
|
+
(Agent SDK + LangChain), ``GITHUB_TOKEN_RO`` (diff fetch + clone), and
|
|
177
|
+
``OPENAI_API_KEY`` (plan-extraction path). No storage configuration is
|
|
178
|
+
required — with neither ``ARGUS_DB_URL`` nor the HTTP-shim URLs set,
|
|
179
|
+
history and checkpoints default to local SQLite.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
settings: Resolved settings object.
|
|
183
|
+
"""
|
|
184
|
+
missing = []
|
|
185
|
+
if not settings.ANTHROPIC_API_KEY:
|
|
186
|
+
missing.append("ANTHROPIC_API_KEY")
|
|
187
|
+
if not settings.GITHUB_TOKEN_RO:
|
|
188
|
+
missing.append("GITHUB_TOKEN_RO")
|
|
189
|
+
if not settings.OPENAI_API_KEY:
|
|
190
|
+
missing.append("OPENAI_API_KEY")
|
|
191
|
+
if missing:
|
|
192
|
+
logger.error(
|
|
193
|
+
"Missing required secrets: %s. Add them to your shell or .env file.",
|
|
194
|
+
", ".join(missing),
|
|
195
|
+
)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def run(request: "ReviewRequest") -> "ReviewResponse":
|
|
200
|
+
"""Run the review pipeline (storage backend resolved automatically:
|
|
201
|
+
Postgres if ``ARGUS_DB_URL`` is set, HTTP shim if the storage URLs are
|
|
202
|
+
set, else local SQLite)."""
|
|
203
|
+
from argus.graph import run_review
|
|
204
|
+
|
|
205
|
+
return await run_review(request, flow_run_id=None)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _add_review_args(parser: argparse.ArgumentParser) -> None:
|
|
209
|
+
repo_group = parser.add_mutually_exclusive_group()
|
|
210
|
+
repo_group.add_argument(
|
|
211
|
+
"repo_positional",
|
|
212
|
+
nargs="?",
|
|
213
|
+
default=None,
|
|
214
|
+
metavar="repo",
|
|
215
|
+
help="GitHub repo (owner/repo), positional form: 'argus review owner/repo --pr N'",
|
|
216
|
+
)
|
|
217
|
+
repo_group.add_argument(
|
|
218
|
+
"--repo",
|
|
219
|
+
dest="repo_flag",
|
|
220
|
+
default=None,
|
|
221
|
+
help="GitHub repo (owner/repo). Mutually exclusive with the positional form.",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument("--pr", type=int, default=0, help="PR number to review")
|
|
224
|
+
parser.add_argument("--sha", default=None, help="Specific commit SHA to review")
|
|
225
|
+
parser.add_argument("--base-ref", default=None, help="Base ref for SHA mode (e.g. 'main')")
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"--dismiss",
|
|
228
|
+
action="append",
|
|
229
|
+
default=[],
|
|
230
|
+
help='Dismiss a prior finding: --dismiss "B2 -- pre-existing, not from this PR"',
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
"--storage-read-url",
|
|
234
|
+
default=os.environ.get("ARGUS_STORAGE_READ_URL"),
|
|
235
|
+
help=(
|
|
236
|
+
"URL template for prior-rounds GET. When set (along "
|
|
237
|
+
"with --storage-write-url), Argus's storage I/O routes through "
|
|
238
|
+
"your own HTTP backend over HTTPS instead of direct Postgres — for "
|
|
239
|
+
"in-sandbox Argus runs that can't reach Postgres on port 5432. "
|
|
240
|
+
"Template supports {owner}, {repo}, {pr}."
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--storage-write-url",
|
|
245
|
+
default=os.environ.get("ARGUS_STORAGE_WRITE_URL"),
|
|
246
|
+
help="URL template for new-round POST. See --storage-read-url.",
|
|
247
|
+
)
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
"--storage-auth",
|
|
250
|
+
default=os.environ.get("ARGUS_STORAGE_AUTH"),
|
|
251
|
+
help=(
|
|
252
|
+
"API key value for the ``X-API-Key`` header on storage requests "
|
|
253
|
+
"your HTTP backend can validate. Optional."
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
parser.add_argument("-o", "--output", default=None, help="Write review markdown to file")
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--post",
|
|
259
|
+
action="store_true",
|
|
260
|
+
help=(
|
|
261
|
+
"Upsert the finished review as a PR comment (updates Argus's prior "
|
|
262
|
+
"comment on re-run instead of stacking a new one). Requires --pr. "
|
|
263
|
+
"Uses GITHUB_TOKEN if set, else falls back to GITHUB_TOKEN_RO "
|
|
264
|
+
"(which may lack write scope)."
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
parser.add_argument(
|
|
268
|
+
"--commit-status",
|
|
269
|
+
action="store_true",
|
|
270
|
+
dest="commit_status",
|
|
271
|
+
help=(
|
|
272
|
+
"Set a commit status on the head SHA (context 'argus/review', "
|
|
273
|
+
"success iff verdict is APPROVE). Same token rules as --post."
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"--no-prompt-overrides",
|
|
278
|
+
action="store_true",
|
|
279
|
+
dest="no_prompt_overrides",
|
|
280
|
+
help=(
|
|
281
|
+
"Ignore every prompt override directory (ARGUS_PROMPTS_DIR, "
|
|
282
|
+
"./.argus/prompts/, ~/.config/argus/prompts/) and force the "
|
|
283
|
+
"packaged prompts only. For CI/official runs that must not pick "
|
|
284
|
+
"up a developer's local override by accident."
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _package_version() -> str:
|
|
290
|
+
"""Resolve the installed ``argus-code-review`` package version.
|
|
291
|
+
|
|
292
|
+
Falls back to ``"unknown"`` rather than raising — ``--version`` should
|
|
293
|
+
never be the thing that crashes, even in an unusual install (e.g. a
|
|
294
|
+
partially-built editable checkout with no dist-info yet). Called
|
|
295
|
+
eagerly from ``_build_parser()`` (the ``version=`` kwarg is an f-string,
|
|
296
|
+
evaluated at ``add_argument()`` time), so this dist-info lookup runs on
|
|
297
|
+
every ``argus`` invocation, not only when ``--version`` is passed.
|
|
298
|
+
"""
|
|
299
|
+
import importlib.metadata
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
return importlib.metadata.version("argus-code-review")
|
|
303
|
+
except importlib.metadata.PackageNotFoundError:
|
|
304
|
+
return "unknown"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
308
|
+
parser = argparse.ArgumentParser(
|
|
309
|
+
prog="argus",
|
|
310
|
+
description="Run the Argus v3 review agent locally (no external orchestrator/CI needed)",
|
|
311
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
312
|
+
epilog=__doc__,
|
|
313
|
+
)
|
|
314
|
+
parser.add_argument(
|
|
315
|
+
"--version",
|
|
316
|
+
action="version",
|
|
317
|
+
version=f"%(prog)s {_package_version()}",
|
|
318
|
+
)
|
|
319
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
320
|
+
review_parser = subparsers.add_parser(
|
|
321
|
+
"review",
|
|
322
|
+
help="Run a PR or SHA review",
|
|
323
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
324
|
+
)
|
|
325
|
+
_add_review_args(review_parser)
|
|
326
|
+
|
|
327
|
+
prompts_parser = subparsers.add_parser(
|
|
328
|
+
"prompts",
|
|
329
|
+
help="Inspect or export the packaged prompt files",
|
|
330
|
+
)
|
|
331
|
+
prompts_subparsers = prompts_parser.add_subparsers(dest="prompts_command")
|
|
332
|
+
prompts_subparsers.add_parser(
|
|
333
|
+
"list",
|
|
334
|
+
help="List prompt names and whether each resolves from the packaged "
|
|
335
|
+
"files or an ARGUS_PROMPTS_DIR override",
|
|
336
|
+
)
|
|
337
|
+
export_parser = prompts_subparsers.add_parser(
|
|
338
|
+
"export",
|
|
339
|
+
help="Copy the packaged prompt files into a directory for customization",
|
|
340
|
+
)
|
|
341
|
+
export_parser.add_argument("dir", help="Target directory")
|
|
342
|
+
export_parser.add_argument(
|
|
343
|
+
"--force",
|
|
344
|
+
action="store_true",
|
|
345
|
+
help="Overwrite files if the target directory is non-empty",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return parser
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _resolve_repo(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str:
|
|
352
|
+
"""Resolve the target repo from the positional or --repo form.
|
|
353
|
+
|
|
354
|
+
The mutually-exclusive group in ``_add_review_args`` already rejects
|
|
355
|
+
supplying both; this only needs to reject supplying neither.
|
|
356
|
+
"""
|
|
357
|
+
repo: str | None = args.repo_positional or args.repo_flag
|
|
358
|
+
if not repo:
|
|
359
|
+
parser.error(
|
|
360
|
+
"Must provide repo as a positional argument (argus review owner/repo) or via --repo"
|
|
361
|
+
)
|
|
362
|
+
assert repo is not None # parser.error() above always raises SystemExit
|
|
363
|
+
return repo
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _validate_review_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
367
|
+
"""Cross-flag validation that argparse's own declarative options can't express."""
|
|
368
|
+
if args.post and args.sha and not args.pr:
|
|
369
|
+
parser.error("--post requires --pr (a PR to comment on); --sha alone has nowhere to post")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _check_prerequisites() -> None:
|
|
373
|
+
"""Verify runtime prerequisites are on PATH, before any network call.
|
|
374
|
+
|
|
375
|
+
``git`` is needed for repo provisioning and the ``claude`` CLI is spawned
|
|
376
|
+
as a subprocess by the Agent SDK — both must be resolvable on PATH before
|
|
377
|
+
Argus burns any API calls. Missing settings are checked separately by
|
|
378
|
+
``_check_settings`` (which also fails loudly, after settings load).
|
|
379
|
+
"""
|
|
380
|
+
import shutil
|
|
381
|
+
|
|
382
|
+
missing = [name for name in ("git", "claude") if shutil.which(name) is None]
|
|
383
|
+
if not missing:
|
|
384
|
+
return
|
|
385
|
+
for name in missing:
|
|
386
|
+
if name == "git":
|
|
387
|
+
logger.error(
|
|
388
|
+
"`git` was not found on PATH. Install git "
|
|
389
|
+
"(https://git-scm.com/downloads) and ensure it's on PATH."
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
logger.error(
|
|
393
|
+
"`claude` CLI was not found on PATH. Install the Claude Code "
|
|
394
|
+
"CLI (https://docs.claude.com/en/docs/claude-code) — the Agent "
|
|
395
|
+
"SDK spawns it as a subprocess to run the review."
|
|
396
|
+
)
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def render_summary_block(response: "ReviewResponse", elapsed: float) -> str:
|
|
401
|
+
"""Render the frozen stderr summary block (Round/Verdict/Risk/Findings/Cost/Elapsed).
|
|
402
|
+
|
|
403
|
+
This is a **frozen public contract**: the
|
|
404
|
+
``argus-review-loop`` skill screen-parses this exact block. Do not change
|
|
405
|
+
the field order, labels, or spacing without updating the skill in lockstep.
|
|
406
|
+
"""
|
|
407
|
+
blocking = sum(1 for f in response.findings if f.severity.value == "BLOCKING")
|
|
408
|
+
suggestion = sum(1 for f in response.findings if f.severity.value == "SUGGESTION")
|
|
409
|
+
round_label = (
|
|
410
|
+
f"{response.review_round} (Lite Mode)" if response.lite_mode else str(response.review_round)
|
|
411
|
+
)
|
|
412
|
+
lines = [
|
|
413
|
+
"=" * 60,
|
|
414
|
+
f"Round: {round_label}",
|
|
415
|
+
f"Verdict: {response.verdict.value}",
|
|
416
|
+
f"Risk: {response.risk_level.value}",
|
|
417
|
+
f"Findings: {blocking} blocking, {suggestion} suggestions",
|
|
418
|
+
f"Cost: ${response.usage.cost_usd:.2f}",
|
|
419
|
+
f"Elapsed: {elapsed:.0f}s",
|
|
420
|
+
"=" * 60,
|
|
421
|
+
]
|
|
422
|
+
return "\n".join(lines)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def render_review_output(response: "ReviewResponse", elapsed: float) -> str:
|
|
426
|
+
"""Render the full stdout block: review comment + summary.
|
|
427
|
+
|
|
428
|
+
Byte-identical to the pre-refactor sequence of ``print()`` calls (see the
|
|
429
|
+
output-contract freeze note above); kept as a pure function so it can be
|
|
430
|
+
golden-file tested without driving the whole pipeline.
|
|
431
|
+
"""
|
|
432
|
+
return "\n\n" + response.review_comment + "\n\n" + render_summary_block(response, elapsed)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _post_review(repo: str, args: argparse.Namespace, response: "ReviewResponse") -> None:
|
|
436
|
+
"""Handle ``--post`` / ``--commit-status``: upsert PR comment and/or commit status."""
|
|
437
|
+
from argus.github_client import GitHubClient, GitHubClientError
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
client = GitHubClient.for_writes()
|
|
441
|
+
except GitHubClientError as exc:
|
|
442
|
+
logger.error("Cannot post to GitHub: %s", exc)
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
|
|
445
|
+
if args.post:
|
|
446
|
+
if not args.pr:
|
|
447
|
+
logger.error("--post requires --pr; nothing to comment on")
|
|
448
|
+
sys.exit(1)
|
|
449
|
+
try:
|
|
450
|
+
client.upsert_pr_comment(repo, args.pr, response.review_comment)
|
|
451
|
+
except Exception as exc: # noqa: BLE001 — surface any GitHub API error clearly
|
|
452
|
+
logger.error("Failed to post PR comment: %s", exc)
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
if args.commit_status:
|
|
456
|
+
sha = args.sha
|
|
457
|
+
if not sha and args.pr:
|
|
458
|
+
pr = client.get_pull_request(repo, args.pr)
|
|
459
|
+
sha = pr["head_sha"]
|
|
460
|
+
if not sha:
|
|
461
|
+
logger.error("--commit-status could not resolve a head SHA (need --pr or --sha)")
|
|
462
|
+
sys.exit(1)
|
|
463
|
+
state = "success" if response.verdict.value == "APPROVE" else "failure"
|
|
464
|
+
description = f"Argus: {response.verdict.value} ({response.risk_level.value} risk)"
|
|
465
|
+
try:
|
|
466
|
+
client.set_commit_status(repo, sha, state, description)
|
|
467
|
+
except Exception as exc: # noqa: BLE001 — surface any GitHub API error clearly
|
|
468
|
+
logger.error("Failed to set commit status: %s", exc)
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _run_review(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
473
|
+
repo = _resolve_repo(parser, args)
|
|
474
|
+
|
|
475
|
+
http_mode = bool(args.storage_read_url and args.storage_write_url)
|
|
476
|
+
if bool(args.storage_read_url) != bool(args.storage_write_url):
|
|
477
|
+
sys.exit(
|
|
478
|
+
"--storage-read-url and --storage-write-url must be set together "
|
|
479
|
+
"(or neither; storage then uses ARGUS_DB_URL if set, else local SQLite)"
|
|
480
|
+
)
|
|
481
|
+
if http_mode:
|
|
482
|
+
from argus.storage.http import install_http_storage
|
|
483
|
+
|
|
484
|
+
install_http_storage(
|
|
485
|
+
read_url=args.storage_read_url,
|
|
486
|
+
write_url=args.storage_write_url,
|
|
487
|
+
auth=args.storage_auth,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if not args.pr and not args.sha:
|
|
491
|
+
sys.exit("Must provide either --pr or --sha")
|
|
492
|
+
if args.pr and args.sha:
|
|
493
|
+
sys.exit("Provide either --pr or --sha, not both")
|
|
494
|
+
|
|
495
|
+
if args.no_prompt_overrides:
|
|
496
|
+
os.environ["ARGUS_NO_PROMPT_OVERRIDES"] = "1"
|
|
497
|
+
|
|
498
|
+
_check_prerequisites()
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
settings = _load_settings()
|
|
502
|
+
except Exception as exc: # pydantic ValidationError for missing required vars
|
|
503
|
+
logger.error("Failed to load settings: %s", exc)
|
|
504
|
+
sys.exit(1)
|
|
505
|
+
_check_settings(settings)
|
|
506
|
+
|
|
507
|
+
from argus.models import ReviewRequest
|
|
508
|
+
|
|
509
|
+
request = ReviewRequest(
|
|
510
|
+
repo=repo,
|
|
511
|
+
pr_number=args.pr,
|
|
512
|
+
sha=args.sha,
|
|
513
|
+
base_ref=args.base_ref,
|
|
514
|
+
dismissals=args.dismiss,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
target = f"SHA {args.sha[:12]}" if args.sha else f"PR #{args.pr}"
|
|
518
|
+
logger.info("Starting local review: %s %s", repo, target)
|
|
519
|
+
|
|
520
|
+
_start_watchdog(repo, args.pr)
|
|
521
|
+
|
|
522
|
+
start = time.monotonic()
|
|
523
|
+
response = asyncio.run(run(request))
|
|
524
|
+
|
|
525
|
+
elapsed = time.monotonic() - start
|
|
526
|
+
|
|
527
|
+
print(render_review_output(response, elapsed))
|
|
528
|
+
|
|
529
|
+
# Write to file if requested
|
|
530
|
+
if args.output:
|
|
531
|
+
Path(args.output).write_text(response.review_comment, encoding="utf-8")
|
|
532
|
+
json_path = str(Path(args.output).with_suffix(".json"))
|
|
533
|
+
Path(json_path).write_text(
|
|
534
|
+
json.dumps(response.model_dump(), indent=2, default=str),
|
|
535
|
+
encoding="utf-8",
|
|
536
|
+
)
|
|
537
|
+
logger.info("Written to %s and %s", args.output, json_path)
|
|
538
|
+
|
|
539
|
+
if args.post or args.commit_status:
|
|
540
|
+
_post_review(repo, args, response)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _packaged_prompts_dir() -> Path:
|
|
544
|
+
"""Directory containing the packaged (verbatim) prompt ``.md`` files."""
|
|
545
|
+
import importlib.resources
|
|
546
|
+
|
|
547
|
+
return Path(str(importlib.resources.files("argus").joinpath("prompts")))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _packaged_prompt_names() -> list[str]:
|
|
551
|
+
return sorted(p.stem for p in _packaged_prompts_dir().glob("*.md"))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# The exact case-insensitive string set pydantic-settings v2 accepts for a
|
|
555
|
+
# ``bool`` field (verified against ``ARGUS_NO_PROMPT_OVERRIDES: bool`` in
|
|
556
|
+
# argus.config.Settings). Kept in sync here so `argus prompts list`/`export`
|
|
557
|
+
# (deliberately Settings-free, see below) treats the env var identically to
|
|
558
|
+
# `argus review`, which resolves it through Settings.
|
|
559
|
+
_PYDANTIC_TRUTHY_STRINGS = frozenset({"1", "true", "t", "yes", "y", "on"})
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _is_truthy_env_value(value: str) -> bool:
|
|
563
|
+
return value.strip().lower() in _PYDANTIC_TRUTHY_STRINGS
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _prompts_list_override_dirs() -> list[Path]:
|
|
567
|
+
"""Same three-directory search chain as
|
|
568
|
+
``argus.prompts_runtime.override_dirs``, computed directly from
|
|
569
|
+
``os.environ`` rather than ``argus.config.Settings``.
|
|
570
|
+
|
|
571
|
+
``argus prompts list``/``export`` are deliberately implemented without
|
|
572
|
+
going through ``Settings`` (see ``tests/test_cli_prompts.py``'s module
|
|
573
|
+
docstring) so they work before the three required API keys are set —
|
|
574
|
+
a first-time user should be able to inspect the packaged prompts
|
|
575
|
+
without any credentials configured yet.
|
|
576
|
+
"""
|
|
577
|
+
if _is_truthy_env_value(os.environ.get("ARGUS_NO_PROMPT_OVERRIDES", "")):
|
|
578
|
+
return []
|
|
579
|
+
|
|
580
|
+
dirs: list[Path] = []
|
|
581
|
+
explicit = os.environ.get("ARGUS_PROMPTS_DIR")
|
|
582
|
+
if explicit:
|
|
583
|
+
dirs.append(Path(explicit))
|
|
584
|
+
|
|
585
|
+
dirs.append(Path.cwd() / ".argus" / "prompts")
|
|
586
|
+
|
|
587
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
588
|
+
config_home = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
|
|
589
|
+
dirs.append(config_home / "argus" / "prompts")
|
|
590
|
+
|
|
591
|
+
return dirs
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _cmd_prompts_list() -> None:
|
|
595
|
+
"""``argus prompts list``: print each packaged prompt name and its source."""
|
|
596
|
+
override_dirs = _prompts_list_override_dirs()
|
|
597
|
+
for name in _packaged_prompt_names():
|
|
598
|
+
source = "packaged"
|
|
599
|
+
for override_dir in override_dirs:
|
|
600
|
+
candidate = override_dir / f"{name}.md"
|
|
601
|
+
if candidate.is_file():
|
|
602
|
+
source = f"override ({candidate})"
|
|
603
|
+
break
|
|
604
|
+
print(f"{name}\t{source}")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _cmd_prompts_export(target_dir: str, force: bool) -> None:
|
|
608
|
+
"""``argus prompts export <dir>``: copy packaged prompt files into ``target_dir``."""
|
|
609
|
+
import shutil as shutil_mod
|
|
610
|
+
|
|
611
|
+
target = Path(target_dir)
|
|
612
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
613
|
+
if any(target.iterdir()) and not force:
|
|
614
|
+
sys.exit(
|
|
615
|
+
f"{target} is not empty. Use --force to export anyway "
|
|
616
|
+
"(existing files with matching names will be overwritten)."
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
packaged = _packaged_prompts_dir()
|
|
620
|
+
count = 0
|
|
621
|
+
for src in sorted(packaged.glob("*.md")):
|
|
622
|
+
shutil_mod.copyfile(src, target / src.name)
|
|
623
|
+
count += 1
|
|
624
|
+
logger.info("Exported %d prompt files to %s", count, target)
|
|
625
|
+
print(f"Exported {count} prompt files to {target}")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _run_prompts(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
629
|
+
if args.prompts_command == "list":
|
|
630
|
+
_cmd_prompts_list()
|
|
631
|
+
elif args.prompts_command == "export":
|
|
632
|
+
_cmd_prompts_export(args.dir, args.force)
|
|
633
|
+
else:
|
|
634
|
+
parser.error("Usage: argus prompts {list,export}")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def main() -> None:
|
|
638
|
+
# Load .env BEFORE building the argparse parser so the
|
|
639
|
+
# ``default=os.environ.get(...)`` lookups for ARGUS_STORAGE_* below
|
|
640
|
+
# see values supplied via .env, not just the shell env. The full
|
|
641
|
+
# settings load still happens inside ``_load_settings`` after args
|
|
642
|
+
# are parsed — this is just to make .env-only env vars visible to
|
|
643
|
+
# argparse defaults.
|
|
644
|
+
from argus.dotenv_utils import load_dotenv_early
|
|
645
|
+
|
|
646
|
+
load_dotenv_early(start=Path.cwd(), env_filename=".env")
|
|
647
|
+
|
|
648
|
+
parser = _build_parser()
|
|
649
|
+
|
|
650
|
+
# Accept both the "argus review ..." subcommand form and the flat
|
|
651
|
+
# legacy form ("argus --repo ..." / "python -m argus.cli --repo ...")
|
|
652
|
+
# without a subcommand — but leave a bare "-h"/"--help"/"--version"
|
|
653
|
+
# (and the "prompts" subcommand) alone so they route correctly.
|
|
654
|
+
argv = sys.argv[1:]
|
|
655
|
+
if argv and argv[0] not in ("review", "prompts", "-h", "--help", "--version"):
|
|
656
|
+
argv = ["review", *argv]
|
|
657
|
+
|
|
658
|
+
args = parser.parse_args(argv)
|
|
659
|
+
|
|
660
|
+
if args.command == "prompts":
|
|
661
|
+
_run_prompts(parser, args)
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
if args.command != "review":
|
|
665
|
+
parser.print_help()
|
|
666
|
+
sys.exit(1)
|
|
667
|
+
|
|
668
|
+
_validate_review_args(parser, args)
|
|
669
|
+
_run_review(parser, args)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
if __name__ == "__main__":
|
|
673
|
+
main()
|