From d8516740e01c58d87d54ddf06f2971a71b3f25ec Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan <150339722+VIVAAN-DHAWAN@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:56:16 +0300 Subject: [PATCH 1/5] Add interactive diffly terminal experience --- README.md | 25 +++++---- install.sh | 8 ++- pyproject.toml | 1 + src/diffly_cli/cli.py | 112 +++++++++++++++++++++++++++++++++++++- tests/test_regressions.py | 9 +++ 5 files changed, 139 insertions(+), 16 deletions(-) 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" From 50cd20ea2496a032949de4f3112e8a9d1dd9ebc5 Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan <150339722+VIVAAN-DHAWAN@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:19:01 +0300 Subject: [PATCH 2/5] Launch diffly with a guided review wizard --- README.md | 8 ++++++ src/diffly_cli/cli.py | 54 ++++++++++++++++++++++++++++++++++++++- tests/test_regressions.py | 16 +++++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ff64e1..be8ce62 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ To enable the optional explainer in CI, add `DIFFLY_LLM_API_KEY: ${{ secrets.DIF ## Usage +The fastest way to start is simply: + +```bash +diffly +``` + +This opens the guided terminal flow. Enter `owner/repo` (or a GitHub URL), the pull-request number, and whether you want an optional generated explanation. The flow validates inputs before making requests, never echoes tokens, and ends in the same keyboard-driven review screen as `--interactive`. + ```bash diffly pr ``` diff --git a/src/diffly_cli/cli.py b/src/diffly_cli/cli.py index 02e54b0..df1f227 100644 --- a/src/diffly_cli/cli.py +++ b/src/diffly_cli/cli.py @@ -13,6 +13,7 @@ from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel +from rich.prompt import Confirm, Prompt from rich.table import Table from . import __version__ @@ -362,6 +363,54 @@ def run_version(_: argparse.Namespace) -> int: return 0 +def run_wizard(parser: argparse.ArgumentParser) -> int: + """Run the zero-argument first-use flow for human reviewers.""" + console.clear() + console.print(Panel.fit( + "[bold cyan]diffly[/] [dim]Pull request intelligence, without the noise[/]\n" + "[dim]Enter a repository and PR, then use the focused review screen.[/]", + border_style="cyan", + padding=(1, 2), + )) + console.print("[dim]Tip: scripted usage remains available with `diffly pr OWNER/REPO NUMBER --json`.\n[/]") + while True: + raw_repository = Prompt.ask("[cyan]Repository[/]", default=os.environ.get("DIFFLY_REPOSITORY", "")) + try: + repository = parse_repo(raw_repository) + break + except argparse.ArgumentTypeError as exc: + console.print(f"[red]Invalid repository:[/] {exc}") + while True: + raw_number = Prompt.ask("[cyan]Pull request number[/]") + try: + number = positive_pr_number(raw_number) + break + except argparse.ArgumentTypeError as exc: + console.print(f"[red]Invalid PR number:[/] {exc}") + token = os.environ.get("GITHUB_TOKEN") + if not token: + console.print("[dim]No GITHUB_TOKEN found. Public repositories still work with lower API limits.[/]") + explain = Confirm.ask("[cyan]Add an optional generated explanation?[/]", default=False) + owner, repo = repository + console.print(Panel.fit( + f"[bold]Ready[/] {owner}/{repo}#{number}\n" + f"[dim]Mode: {'deterministic + explanation' if explain else 'deterministic'} · output: interactive[/]", + border_style="green", + )) + args = argparse.Namespace( + repository=repository, + number=number, + token=token, + output=None, + json=False, + explain=explain, + llm_model=None, + llm_base_url=None, + interactive=True, + ) + return run_pr(args) + + def build_parser() -> argparse.ArgumentParser: 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) @@ -387,7 +436,10 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: parser = build_parser() - args = parser.parse_args(argv) + effective_argv = sys.argv[1:] if argv is None else argv + if not effective_argv: + return run_wizard(parser) + args = parser.parse_args(effective_argv) return args.func(args) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index ee160de..72605de 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -4,7 +4,7 @@ import pytest -from diffly_cli.cli import build_parser, build_result, parse_repo, summarize_checks +from diffly_cli.cli import build_parser, build_result, parse_repo, run_wizard, summarize_checks from diffly_cli.github import GitHubClient, GitHubError, RepositoryTreeResult from diffly_cli.models import ChangedFile, PRMetadata @@ -125,3 +125,17 @@ def test_cli_exposes_interactive_and_diagnostics_commands(): assert parser.parse_args(["doctor"]).command == "doctor" assert parser.parse_args(["version"]).command == "version" assert parser.parse_args(["help"]).command == "help" + + +def test_zero_argument_invocation_uses_wizard(monkeypatch): + import diffly_cli.cli as cli + + called = {} + + def fake_wizard(parser): + called["parser"] = parser + return 0 + + monkeypatch.setattr(cli, "run_wizard", fake_wizard) + assert cli.main([]) == 0 + assert "parser" in called From 8d108f2e0a72fd5075740c553fe8deefdbee0ceb Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan <150339722+VIVAAN-DHAWAN@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:21:31 +0300 Subject: [PATCH 3/5] Add guided diffly setup tutorial --- README.md | 3 +++ src/diffly_cli/cli.py | 25 +++++++++++++++++++++++++ tests/test_regressions.py | 1 + 3 files changed, 29 insertions(+) diff --git a/README.md b/README.md index be8ce62..ecab878 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,13 @@ diffly pr pallets/urllib3 3456 --interactive diffly pr pallets/urllib3 3456 --output triage.md diffly pr pallets/urllib3 3456 --json diffly help +diffly setyo diffly doctor diffly version ``` +New to Diffly? Run `diffly setyo` for a short guided tutorial covering PR analysis, interactive controls, automation, troubleshooting, credentials, and privacy. The final step can launch the real PR wizard so you can immediately try what you learned. + 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 --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. diff --git a/src/diffly_cli/cli.py b/src/diffly_cli/cli.py index df1f227..dd8ac31 100644 --- a/src/diffly_cli/cli.py +++ b/src/diffly_cli/cli.py @@ -411,6 +411,29 @@ def run_wizard(parser: argparse.ArgumentParser) -> int: return run_pr(args) +def run_setyo(args: argparse.Namespace) -> int: + """Teach the core Diffly workflow through a short terminal walkthrough.""" + pages = [ + ("Welcome", "[bold cyan]diffly[/] turns a GitHub pull request into a deterministic review gate.\n\nRun [bold]diffly[/] with no arguments whenever you want the guided PR wizard."), + ("Review a PR", "[bold]diffly pr OWNER/REPO NUMBER[/]\n\nAdd [cyan]--interactive[/] for the keyboard view, [cyan]--explain[/] for optional generated context, or [cyan]--output report.md[/] to save Markdown."), + ("Interactive controls", "[cyan]↑ / ↓[/] move between report sections\n[cyan]Space[/] enables or disables a section\n[cyan]Enter[/] renders the focused report\n[cyan]q[/] exits"), + ("Automation", "Use [bold]diffly pr OWNER/REPO NUMBER --json[/] in scripts and CI.\n\nThe command exits 0 after successful analysis; enforce policy by reading the JSON [cyan]verdict[/] field."), + ("Troubleshooting", "Run [bold]diffly doctor[/] to check Python, terminal support, token configuration, and PATH setup.\n\nRun [bold]diffly help[/] for the complete command list and [bold]diffly version[/] when reporting an issue."), + ("Credentials and privacy", "Set [bold]GITHUB_TOKEN[/] for private repositories and higher API limits. Tokens are never shown by the wizard.\n\nDeterministic mode sends no code to an LLM. [cyan]--explain[/] uses your configured model endpoint."), + ] + for index, (title, body) in enumerate(pages, start=1): + console.clear() + console.print(Panel(body, title=f"[bold]{index}/{len(pages)} · {title}[/]", subtitle="[dim]Enter next · q quit[/]", border_style="cyan", padding=(1, 2))) + choice = Prompt.ask("", default="", show_default=False) + if choice.strip().lower() == "q": + return 0 + console.clear() + console.print(Panel.fit("[bold green]Setup complete[/]\n[dim]You can rerun this guide anytime with `diffly setyo`.[/]", border_style="green", padding=(1, 2))) + if Confirm.ask("[cyan]Try the guided PR wizard now?[/]", default=True): + return run_wizard(args.root_parser) + return 0 + + def build_parser() -> argparse.ArgumentParser: 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) @@ -431,6 +454,8 @@ def build_parser() -> argparse.ArgumentParser: 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) + setyo = subparsers.add_parser("setyo", help="Learn Diffly through a guided terminal tutorial") + setyo.set_defaults(func=run_setyo, root_parser=parser) return parser diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 72605de..ebdc7ca 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -125,6 +125,7 @@ def test_cli_exposes_interactive_and_diagnostics_commands(): assert parser.parse_args(["doctor"]).command == "doctor" assert parser.parse_args(["version"]).command == "version" assert parser.parse_args(["help"]).command == "help" + assert parser.parse_args(["setyo"]).command == "setyo" def test_zero_argument_invocation_uses_wizard(monkeypatch): From bdeac1fcc6b7c22e294522c6666f42c3ea7878d7 Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan <150339722+VIVAAN-DHAWAN@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:27:25 +0300 Subject: [PATCH 4/5] Rename onboarding command to diffly setup --- README.md | 4 ++-- src/diffly_cli/cli.py | 8 ++++---- tests/test_regressions.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ecab878..b7c9544 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,12 @@ diffly pr pallets/urllib3 3456 --interactive diffly pr pallets/urllib3 3456 --output triage.md diffly pr pallets/urllib3 3456 --json diffly help -diffly setyo +diffly setup diffly doctor diffly version ``` -New to Diffly? Run `diffly setyo` for a short guided tutorial covering PR analysis, interactive controls, automation, troubleshooting, credentials, and privacy. The final step can launch the real PR wizard so you can immediately try what you learned. +New to Diffly? Run `diffly setup` for a short guided tutorial covering PR analysis, interactive controls, automation, troubleshooting, credentials, and privacy. The final step can launch the real PR wizard so you can immediately try what you learned. The command prints clean terminal Markdown by default. `--json` is provided for CI integration and `--output` saves the Markdown report to a file. diff --git a/src/diffly_cli/cli.py b/src/diffly_cli/cli.py index dd8ac31..6eee469 100644 --- a/src/diffly_cli/cli.py +++ b/src/diffly_cli/cli.py @@ -411,7 +411,7 @@ def run_wizard(parser: argparse.ArgumentParser) -> int: return run_pr(args) -def run_setyo(args: argparse.Namespace) -> int: +def run_setup(args: argparse.Namespace) -> int: """Teach the core Diffly workflow through a short terminal walkthrough.""" pages = [ ("Welcome", "[bold cyan]diffly[/] turns a GitHub pull request into a deterministic review gate.\n\nRun [bold]diffly[/] with no arguments whenever you want the guided PR wizard."), @@ -428,7 +428,7 @@ def run_setyo(args: argparse.Namespace) -> int: if choice.strip().lower() == "q": return 0 console.clear() - console.print(Panel.fit("[bold green]Setup complete[/]\n[dim]You can rerun this guide anytime with `diffly setyo`.[/]", border_style="green", padding=(1, 2))) + console.print(Panel.fit("[bold green]Setup complete[/]\n[dim]You can rerun this guide anytime with `diffly setup`.[/]", border_style="green", padding=(1, 2))) if Confirm.ask("[cyan]Try the guided PR wizard now?[/]", default=True): return run_wizard(args.root_parser) return 0 @@ -454,8 +454,8 @@ def build_parser() -> argparse.ArgumentParser: 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) - setyo = subparsers.add_parser("setyo", help="Learn Diffly through a guided terminal tutorial") - setyo.set_defaults(func=run_setyo, root_parser=parser) + setup = subparsers.add_parser("setup", help="Learn Diffly through a guided terminal tutorial") + setup.set_defaults(func=run_setup, root_parser=parser) return parser diff --git a/tests/test_regressions.py b/tests/test_regressions.py index ebdc7ca..a14114e 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -125,7 +125,7 @@ def test_cli_exposes_interactive_and_diagnostics_commands(): assert parser.parse_args(["doctor"]).command == "doctor" assert parser.parse_args(["version"]).command == "version" assert parser.parse_args(["help"]).command == "help" - assert parser.parse_args(["setyo"]).command == "setyo" + assert parser.parse_args(["setup"]).command == "setup" def test_zero_argument_invocation_uses_wizard(monkeypatch): From b0ef9d91e5c5ca21c131e88c6d71147e7a6c83ce Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan <150339722+VIVAAN-DHAWAN@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:35:33 +0300 Subject: [PATCH 5/5] Improve verdict judging and action resilience --- README.md | 6 +++--- action/post_comment.py | 20 ++++++++++++++------ src/diffly_cli/cli.py | 5 +++-- src/diffly_cli/triage.py | 22 ++++++++++++++++++---- tests/test_triage.py | 10 +++++++++- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b7c9544..88463d7 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ [![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** 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 `PASS`, `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`. +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 `PASS`, `QUARANTINE`, or `BLOCK`. ## At a glance @@ -106,7 +106,7 @@ Run `diffly --help` or `diffly pr --help` for the complete option reference. `-- | --- | --- | | **BLOCK** | A required check failed, or the pull request touches authentication, credentials, secrets, or security-sensitive files. | | **QUARANTINE** | The pull request changes database schema or migrations, adds or changes dependencies, lacks obvious test coverage for a production file, or has unavailable/pending checks. | -| **SHIP** | No deterministic rule fired and observed checks passed. | +| **PASS** | No deterministic rule fired and observed checks passed. `SHIP` remains accepted as a legacy name in older integrations. | The policy is deliberately conservative. A verdict is a review gate, not a claim that a pull request is correct or safe in every context. diff --git a/action/post_comment.py b/action/post_comment.py index a361c62..6bb4a7c 100644 --- a/action/post_comment.py +++ b/action/post_comment.py @@ -27,6 +27,8 @@ def request(url: str, token: str, method: str = "GET", payload: dict | None = No raw = response.read().decode() return json.loads(raw) if raw else {} except urllib.error.HTTPError as exc: + if exc.code == 404 and method == "GET": + return [] detail = exc.read().decode(errors="replace") raise SystemExit(f"GitHub API {exc.code}: {detail[:500]}") from exc @@ -50,12 +52,18 @@ def main() -> int: break page += 1 match = next((item for item in existing if MARKER in (item.get("body") or "")), None) - if match: - request(f"{base}/{match['id']}", token, "PATCH", {"body": body}) - print(f"Updated Diffly comment {match['id']}") - else: - created = request(base, token, "POST", {"body": body}) - print(f"Created Diffly comment {created.get('id', 'unknown')}") + try: + if match: + request(f"{base}/{match['id']}", token, "PATCH", {"body": body}) + print(f"Updated Diffly comment {match['id']}") + else: + created = request(base, token, "POST", {"body": body}) + print(f"Created Diffly comment {created.get('id', 'unknown')}") + except SystemExit as exc: + if "GitHub API 404" in str(exc): + print(f"Warning: Diffly could not publish its PR comment (GitHub returned 404; check pull-request write permissions).") + else: + raise return 0 diff --git a/src/diffly_cli/cli.py b/src/diffly_cli/cli.py index 6eee469..3fff47e 100644 --- a/src/diffly_cli/cli.py +++ b/src/diffly_cli/cli.py @@ -25,7 +25,7 @@ from .triage import compute_flags, verdict_for console = Console() -VERDICT_STYLES = {"SHIP": "bold green", "QUARANTINE": "bold yellow", "BLOCK": "bold red"} +VERDICT_STYLES = {"PASS": "bold green", "SHIP": "bold green", "QUARANTINE": "bold yellow", "BLOCK": "bold red"} def positive_pr_number(value: str) -> int: @@ -191,7 +191,7 @@ def render_markdown(result: TriageResult, explanation: ExplanationResult | None for file in result.files: symbols = ", ".join(file.touched_symbols) if file.touched_symbols else "—" lines.append(f"| `{file.path}` | {file.status} | {file.additions} | {file.deletions} | {symbols} |") - lines += ["", "## Deterministic policy", "", "- `BLOCK`: failed checks or authentication/secrets/security-sensitive changes.", "- `QUARANTINE`: database changes, dependency changes, missing obvious test coverage, or unavailable/pending checks.", "- `SHIP`: no rules fired and observed checks passed.", "", "_Generated by diffly-cli. Deterministic triage is authoritative; any literate-diff prose is optional generated explanation._"] + lines += ["", "## Deterministic policy", "", "- `BLOCK`: failed checks or authentication/secrets/security-sensitive changes.", "- `QUARANTINE`: database changes, dependency changes, missing obvious production test coverage, or unavailable/pending checks.", "- `PASS`: no blocking or quarantine rule fired and observed checks passed.", "", "_Generated by diffly. Deterministic triage is authoritative; any literate-diff prose is optional generated explanation._"] return "\n".join(lines) + "\n" @@ -320,6 +320,7 @@ def run_pr(args: argparse.Namespace) -> int: "files": [asdict(file) for file in result.files], "flags": [asdict(flag) for flag in result.flags], "verdict": result.verdict, + "legacy_verdict": "SHIP" if result.verdict == "PASS" else result.verdict, "reasoning": result.reasoning, "checks": result.checks, "source": result.source, diff --git a/src/diffly_cli/triage.py b/src/diffly_cli/triage.py index 581227f..6516c48 100644 --- a/src/diffly_cli/triage.py +++ b/src/diffly_cli/triage.py @@ -25,6 +25,16 @@ def _matches(path: str, patterns: list[str]) -> bool: return any(fnmatch.fnmatch(lowered, pattern.lower()) or fnmatch.fnmatch(lowered.split("/")[-1], pattern.lower()) for pattern in patterns) +def _is_production_file(path: str) -> bool: + lowered = path.lower() + parts = lowered.split("/") + if any(part in {"docs", "examples", "fixtures", "generated", "tests", "test"} for part in parts): + return False + if _matches(path, TEST_PATTERNS): + return False + return not lowered.endswith((".md", ".txt", ".json")) and lowered != "install.sh" + + def _added_dependency_names(file: ChangedFile) -> list[str]: if file.path.rsplit("/", 1)[-1] not in DEPENDENCY_FILES: return [] @@ -85,7 +95,7 @@ def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[s continue coverage = _covered_by_test(file, test_paths + changed_test_paths) file.tests_found = coverage - if not coverage: + if _is_production_file(file.path) and not coverage: untested.append(file.path) if untested and tree_complete: flags.append(RiskFlag("NO_TEST_COVERAGE", "medium", "Changed production files have no obvious neighboring or repository test coverage.", untested[:50])) @@ -93,6 +103,8 @@ def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[s check_state = str(checks.get("state", "unknown")) if check_state == "failure": flags.append(RiskFlag("CHECKS_FAILED", "critical", "One or more required status checks failed.", list(checks.get("failed", [])))) + elif check_state == "pending": + flags.append(RiskFlag("CHECKS_PENDING", "low", "Required status checks are still pending.", [check_state])) elif check_state != "success": flags.append(RiskFlag("CHECKS_UNKNOWN", "medium", "Required status checks are missing, pending, or unavailable.", [check_state])) @@ -115,8 +127,10 @@ def verdict_for(flags: list[RiskFlag], checks: dict[str, Any]) -> tuple[str, lis if "NO_TEST_COVERAGE" in codes: reasoning.append("QUARANTINE because at least one changed production file lacks obvious test coverage.") if "CHECKS_UNKNOWN" in codes: - reasoning.append("QUARANTINE because the affected pull request does not have a confirmed passing check result.") + reasoning.append("QUARANTINE because the pull request does not have a confirmed passing check result.") + if "CHECKS_PENDING" in codes: + reasoning.append("QUARANTINE because required checks are still running.") if reasoning: return "QUARANTINE", reasoning - reasoning.append("SHIP because no deterministic risk rule fired and all observed checks passed.") - return "SHIP", reasoning + reasoning.append("PASS because no blocking or quarantine rule fired and all observed checks passed.") + return "PASS", reasoning diff --git a/tests/test_triage.py b/tests/test_triage.py index a281b22..d4574de 100644 --- a/tests/test_triage.py +++ b/tests/test_triage.py @@ -44,4 +44,12 @@ def test_all_clear_ships(): flags = compute_flags(metadata(), [file], {"state": "success", "count": 1}, ["tests/test_math.py"]) verdict, _ = verdict_for(flags, {"state": "success"}) assert not flags - assert verdict == "SHIP" + assert verdict == "PASS" + + +def test_pending_checks_quarantine(): + flags = compute_flags(metadata(), [], {"state": "pending", "count": 1}, []) + verdict, reasoning = verdict_for(flags, {"state": "pending"}) + assert "CHECKS_PENDING" in {flag.code for flag in flags} + assert verdict == "QUARANTINE" + assert any("still running" in item for item in reasoning)