Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand All @@ -16,20 +16,21 @@ 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

Large AI-generated pull requests can be difficult to review because file-by-file diffs hide the affected symbols, adjacent tests, dependency changes, and high-risk surfaces. diffly-cli makes the deterministic part of that review visible before an LLM is introduced.

## 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:

Expand Down Expand Up @@ -59,30 +60,34 @@ To enable the optional explainer in CI, add `DIFFLY_LLM_API_KEY: ${{ secrets.DIF
## Usage

```bash
diffly-cli pr <owner/repo> <pr-number>
diffly pr <owner/repo> <pr-number>
```

Add the optional literate-diff explanation with an OpenAI-compatible BYOK endpoint:

```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 <owner/repo> <pr-number> --explain
diffly pr <owner/repo> <pr-number> --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.

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

Expand Down
8 changes: 5 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> <pr-number>\n'
printf '\nInstalled diffly.\n'
printf 'Run: diffly pr <owner/repo> <pr-number>\n'
case ":${PATH}:" in
*":${DIFFLY_BIN}:"*) ;;
*) printf 'If the command is not found, add %s to PATH.\n' "${DIFFLY_BIN}" ;;
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies = [
]

[project.scripts]
diffly = "diffly_cli.cli:main"
diffly-cli = "diffly_cli.cli:main"

[project.optional-dependencies]
Expand Down
112 changes: 109 additions & 3 deletions src/diffly_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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="<owner/repo>")
Expand All @@ -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


Expand Down
9 changes: 9 additions & 0 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading