diff --git a/README.md b/README.md index 95c95ff..8ff64e1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# diffly-cli +# diffly [![CI](https://github.com/VIVAAN-DHAWAN/diffly-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/VIVAAN-DHAWAN/diffly-cli/actions/workflows/ci.yml) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB.svg)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Latest release](https://img.shields.io/github/v/release/VIVAAN-DHAWAN/diffly-cli)](https://github.com/VIVAAN-DHAWAN/diffly-cli/releases/latest) -**diffly-cli** is a deterministic, terminal-first triage tool for large GitHub pull requests. It fetches the pull request metadata, changed files, unified diff, commit metadata, status checks, and repository tree; parses supported source changes with Tree-sitter; identifies a conservative blast-radius map; applies fixed risk rules; and emits a one-page Markdown review with a `SHIP`, `QUARANTINE`, or `BLOCK` verdict. +**diffly** is a deterministic, terminal-first triage tool for large GitHub pull requests. It fetches the pull request metadata, changed files, unified diff, commit metadata, status checks, and repository tree; parses supported source changes with Tree-sitter; identifies a conservative blast-radius map; applies fixed risk rules; and emits a one-page Markdown review with a `SHIP`, `QUARANTINE`, or `BLOCK` verdict. This release includes **Phase 1 deterministic triage** plus the optional **Phase 2 literate-diff explainer**. Deterministic facts and verdicts remain authoritative; generated prose is clearly labeled and cannot change `SHIP`, `QUARANTINE`, or `BLOCK`. @@ -16,6 +16,7 @@ This release includes **Phase 1 deterministic triage** plus the optional **Phase - **CI ready:** run it as a CLI, consume stable JSON, or add the bundled GitHub Action. - **LLM optional:** generated explanation is isolated from the authoritative verdict and fails closed to deterministic output. - **No GitHub CLI dependency:** it talks directly to the GitHub REST API. +- **Interactive terminal UI:** use arrow keys and the space bar to focus the report on what matters. ## Why it exists @@ -23,13 +24,13 @@ Large AI-generated pull requests can be difficult to review because file-by-file ## Install -Requirements are Python 3.10 or newer. The installer creates an isolated environment under `~/.local/share/diffly-cli`, installs the package and its dependencies, and links the command into `~/.local/bin`. +Requirements are Python 3.10 or newer. The installer creates an isolated environment under `~/.local/share/diffly-cli`, installs the package and its dependencies, and links `diffly` into `~/.local/bin`. ```bash curl -fsSL https://raw.githubusercontent.com/VIVAAN-DHAWAN/diffly-cli/main/install.sh | sh ``` -That is the complete installation. If `diffly-cli` is not immediately found afterward, add `~/.local/bin` to your `PATH` and open a new shell. No GitHub CLI installation is required. +That is the complete installation. If `diffly` is not immediately found afterward, add `~/.local/bin` to your `PATH` and open a new shell. No GitHub CLI installation is required. The older `diffly-cli` executable remains as a compatibility alias. A GitHub token is optional for light public use, but authenticated requests provide much higher API limits: @@ -59,7 +60,7 @@ To enable the optional explainer in CI, add `DIFFLY_LLM_API_KEY: ${{ secrets.DIF ## Usage ```bash -diffly-cli pr +diffly pr ``` Add the optional literate-diff explanation with an OpenAI-compatible BYOK endpoint: @@ -67,7 +68,7 @@ Add the optional literate-diff explanation with an OpenAI-compatible BYOK endpoi ```bash export DIFFLY_LLM_API_KEY="your-key" export DIFFLY_LLM_BASE_URL="https://api.openai.com/v1" # omit for the configured default endpoint -diffly-cli pr --explain +diffly pr --explain ``` The default model is `gpt-5-mini`; override it with `DIFFLY_LLM_MODEL` or `--llm-model`. The explainer sends bounded, redacted context, requires strict JSON output, rejects citations to files outside the changed-file set, and fails safely back to deterministic triage when no key is configured or the model response is invalid. Use `--json` with `--explain` to receive the structured literate-diff object. @@ -75,14 +76,18 @@ The default model is `gpt-5-mini`; override it with `DIFFLY_LLM_MODEL` or `--llm Examples: ```bash -diffly-cli pr pallets/urllib3 3456 -diffly-cli pr pallets/urllib3 3456 --output triage.md -diffly-cli pr pallets/urllib3 3456 --json +diffly pr pallets/urllib3 3456 +diffly pr pallets/urllib3 3456 --interactive +diffly pr pallets/urllib3 3456 --output triage.md +diffly pr pallets/urllib3 3456 --json +diffly help +diffly doctor +diffly version ``` The command prints clean terminal Markdown by default. `--json` is provided for CI integration and `--output` saves the Markdown report to a file. -Run `diffly-cli pr --help` for the complete option reference. Pull-request numbers must be positive integers. For automation, prefer `--json`; operational errors exit with status `2` and successful triage exits with status `0` regardless of the verdict, so callers should inspect the `verdict` field when enforcing policy. +Run `diffly --help` or `diffly pr --help` for the complete option reference. `--interactive` opens a keyboard-driven selector: use up/down arrows to move, space to toggle sections, Enter to render, and `q` to quit. Pull-request numbers must be positive integers. For automation, prefer `--json`; operational errors exit with status `2` and successful triage exits with status `0` regardless of the verdict, so callers should inspect the `verdict` field when enforcing policy. ## Deterministic policy diff --git a/install.sh b/install.sh index e285946..a93b266 100755 --- a/install.sh +++ b/install.sh @@ -14,10 +14,12 @@ python3 -m venv "${DIFFLY_HOME}" "${DIFFLY_HOME}/bin/python" -m pip install --upgrade pip >/dev/null "${DIFFLY_HOME}/bin/python" -m pip install --upgrade "${DIFFLY_SOURCE}" mkdir -p "${DIFFLY_BIN}" -ln -sf "${DIFFLY_HOME}/bin/diffly-cli" "${DIFFLY_BIN}/diffly-cli" +ln -sf "${DIFFLY_HOME}/bin/diffly" "${DIFFLY_BIN}/diffly" +# Keep the old executable as a compatibility alias for existing scripts. +ln -sf "${DIFFLY_HOME}/bin/diffly" "${DIFFLY_BIN}/diffly-cli" -printf '\nInstalled diffly-cli.\n' -printf 'Run: diffly-cli pr \n' +printf '\nInstalled diffly.\n' +printf 'Run: diffly pr \n' case ":${PATH}:" in *":${DIFFLY_BIN}:"*) ;; *) printf 'If the command is not found, add %s to PATH.\n' "${DIFFLY_BIN}" ;; diff --git a/pyproject.toml b/pyproject.toml index 67f4519..3a129bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ ] [project.scripts] +diffly = "diffly_cli.cli:main" diffly-cli = "diffly_cli.cli:main" [project.optional-dependencies] diff --git a/src/diffly_cli/cli.py b/src/diffly_cli/cli.py index 48d97d2..02e54b0 100644 --- a/src/diffly_cli/cli.py +++ b/src/diffly_cli/cli.py @@ -6,12 +6,16 @@ import os import re import sys -from collections import Counter +import termios +import tty from typing import Any from rich.console import Console from rich.markdown import Markdown +from rich.panel import Panel +from rich.table import Table +from . import __version__ from .astmap import analyze_files from .explainer import ExplanationResult, generate_explanation from .diffparse import files_from_unified_diff @@ -20,6 +24,7 @@ from .triage import compute_flags, verdict_for console = Console() +VERDICT_STYLES = {"SHIP": "bold green", "QUARANTINE": "bold yellow", "BLOCK": "bold red"} def positive_pr_number(value: str) -> int: @@ -189,6 +194,74 @@ def render_markdown(result: TriageResult, explanation: ExplanationResult | None return "\n".join(lines) + "\n" +def _section_lines(result: TriageResult, section: str) -> list[str]: + """Build a compact Rich-friendly section for the interactive view.""" + if section == "verdict": + style = VERDICT_STYLES.get(result.verdict, "bold") + return [f"[{style}]{result.verdict}[/]", *result.reasoning] + if section == "checks": + checks = result.checks + state = checks.get("state", "unknown").upper() + return [f"State: {state}", f"Observed: {checks.get('count', 0)}", *[f"Failed: {x}" for x in checks.get("failed", [])], *[f"Pending: {x}" for x in checks.get("pending", [])]] + if section == "risks": + if not result.flags: + return ["No deterministic risk flags fired."] + return [f"{flag.code} ({flag.severity}): {flag.message}" for flag in result.flags] + return [f"{item.path} +{item.additions}/-{item.deletions}" for item in result.files[:80]] or ["No changed files returned."] + + +def interactive_view(result: TriageResult) -> None: + """Let a reviewer toggle report sections with up/down arrows and space.""" + if not sys.stdin.isatty() or not sys.stdout.isatty(): + console.print("[yellow]Interactive mode needs a terminal; showing the standard report instead.[/]") + console.print(Markdown(render_markdown(result))) + return + labels = [("verdict", "Verdict"), ("checks", "Checks"), ("risks", "Risk flags"), ("files", "Changed files")] + enabled = {key: True for key, _ in labels} + cursor = 0 + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + while True: + console.clear() + console.print(Panel.fit("[bold cyan]diffly interactive review[/] [dim]↑/↓ move · space toggle · enter apply · q quit[/]", border_style="cyan")) + menu = Table(show_header=False, box=None, padding=(0, 1)) + menu.add_column("", width=3) + menu.add_column("Section") + for index, (key, label) in enumerate(labels): + marker = "[cyan]›[/]" if index == cursor else " " + state = "[green]on[/]" if enabled[key] else "[dim]off[/]" + menu.add_row(marker, f"{label:<18} {state}") + console.print(menu) + console.print("\n[dim]Toggle sections to keep the review focused.[/]") + key = sys.stdin.read(1) + if key in {"q", "Q"}: + return + if key in {"\r", "\n"}: + break + if key == " ": + selected = labels[cursor][0] + enabled[selected] = not enabled[selected] + elif key == "\x1b": + sequence = sys.stdin.read(2) + if sequence == "[A": + cursor = (cursor - 1) % len(labels) + elif sequence == "[B": + cursor = (cursor + 1) % len(labels) + console.clear() + result_table = Table(title=f"{result.metadata.owner}/{result.metadata.repo}#{result.metadata.number}", box=None) + result_table.add_column("Section", style="cyan") + result_table.add_column("Details") + for key, label in labels: + if enabled[key]: + details = "\n".join(_section_lines(result, key)) + result_table.add_row(label, details) + console.print(result_table) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + def build_result(client: GitHubClient, owner: str, repo: str, number: int) -> TriageResult: metadata = client.pull_request(owner, repo, number) files = client.pull_request_files(owner, repo, number) @@ -229,13 +302,16 @@ def run_pr(args: argparse.Namespace) -> int: try: result = build_result(client, owner, repo, args.number) except GitHubError as exc: - console.print(f"[red]Error:[/red] {exc}") + console.print(Panel.fit(f"[bold red]Unable to inspect pull request[/]\n{exc}\n\n[dim]Check GITHUB_TOKEN, repository access, and network connectivity. Run `diffly doctor` for diagnostics.[/]", border_style="red")) return 2 explanation = generate_explanation( result, model=args.llm_model, base_url=args.llm_base_url, ) if args.explain else None + if args.interactive and not args.json and not args.output: + interactive_view(result) + return 0 output = render_markdown(result, explanation) if args.json: payload = { @@ -263,8 +339,31 @@ def run_pr(args: argparse.Namespace) -> int: return 0 +def run_doctor(_: argparse.Namespace) -> int: + """Print actionable local diagnostics without making a GitHub request.""" + import shutil + table = Table(title="diffly doctor", box=None) + table.add_column("Check", style="cyan") + table.add_column("Status") + checks = [("Python", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"), ("GitHub token", "configured" if os.environ.get("GITHUB_TOKEN") else "not set (public API only)"), ("Terminal", "interactive" if sys.stdin.isatty() else "non-interactive"), ("diffly executable", shutil.which("diffly") or "not on PATH")] + for name, value in checks: + table.add_row(name, value) + console.print(table) + return 0 + + +def run_help(args: argparse.Namespace) -> int: + args.root_parser.print_help() + return 0 + + +def run_version(_: argparse.Namespace) -> int: + console.print(f"[bold cyan]diffly[/] {__version__}") + return 0 + + def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="diffly-cli", description="Deterministic triage for large GitHub pull requests") + parser = argparse.ArgumentParser(prog="diffly", description="Deterministic triage for large GitHub pull requests", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Examples:\n diffly pr pallets/urllib3 3456\n diffly pr pallets/urllib3 3456 --interactive\n diffly doctor") subparsers = parser.add_subparsers(dest="command", required=True) pr = subparsers.add_parser("pr", help="Analyze one GitHub pull request") pr.add_argument("repository", type=parse_repo, metavar="") @@ -275,7 +374,14 @@ def build_parser() -> argparse.ArgumentParser: pr.add_argument("--explain", action="store_true", help="Add an optional LLM-generated literate-diff explanation") pr.add_argument("--llm-model", default=None, help="Override DIFFLY_LLM_MODEL for --explain") pr.add_argument("--llm-base-url", default=None, help="Override DIFFLY_LLM_BASE_URL for --explain") + pr.add_argument("--interactive", action="store_true", help="Open a keyboard-driven report view (arrows, space, enter)") pr.set_defaults(func=run_pr) + doctor = subparsers.add_parser("doctor", help="Diagnose local installation and environment") + doctor.set_defaults(func=run_doctor) + version = subparsers.add_parser("version", help="Print the installed diffly version") + version.set_defaults(func=run_version) + help_command = subparsers.add_parser("help", help="Show commands, options, and examples") + help_command.set_defaults(func=run_help, root_parser=parser) return parser diff --git a/tests/test_regressions.py b/tests/test_regressions.py index ccf7314..ee160de 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -116,3 +116,12 @@ def test_cli_rejects_non_positive_pull_request_numbers(): parser.parse_args(["pr", "acme/demo", "0"]) with pytest.raises(SystemExit): parser.parse_args(["pr", "acme/demo", "-1"]) + + +def test_cli_exposes_interactive_and_diagnostics_commands(): + parser = build_parser() + args = parser.parse_args(["pr", "acme/demo", "1", "--interactive"]) + assert args.interactive is True + assert parser.parse_args(["doctor"]).command == "doctor" + assert parser.parse_args(["version"]).command == "version" + assert parser.parse_args(["help"]).command == "help"