diffly-cli 0.4.0__tar.gz → 1.0.0__tar.gz

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.
Files changed (65) hide show
  1. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/CHANGELOG.md +19 -0
  2. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/PKG-INFO +10 -9
  3. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/README.md +9 -8
  4. diffly_cli-1.0.0/assets/logo-v2.svg +35 -0
  5. diffly_cli-1.0.0/assets/logo.png +0 -0
  6. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/phase-2-contract.md +1 -1
  7. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/pyproject.toml +1 -1
  8. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/__init__.py +1 -1
  9. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/cli.py +157 -55
  10. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/explainer.py +67 -3
  11. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/github.py +23 -1
  12. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/triage.py +38 -17
  13. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_explainer.py +7 -5
  14. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_local.py +4 -4
  15. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_regressions.py +65 -0
  16. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_triage.py +28 -1
  17. diffly_cli-0.4.0/assets/logo.png +0 -0
  18. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.github/workflows/ci.yml +0 -0
  19. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.github/workflows/example-diffly.yml +0 -0
  20. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.github/workflows/publish.yml +0 -0
  21. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.gitignore +0 -0
  22. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/CONTRIBUTING.md +0 -0
  23. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/LICENSE +0 -0
  24. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/SECURITY.md +0 -0
  25. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/action/format_comment.py +0 -0
  26. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/action/post_comment.py +0 -0
  27. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/action.yml +0 -0
  28. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/diffly-cli-demo.gif +0 -0
  29. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/real-captures/kubernetes-141413.ansi +0 -0
  30. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/real-captures/ruff-27808.ansi +0 -0
  31. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/real-captures/vscode-330848.ansi +0 -0
  32. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/screenshots/kubernetes-141413.png +0 -0
  33. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/screenshots/ruff-27808.png +0 -0
  34. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/screenshots/vscode-330848.png +0 -0
  35. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/kubernetes-141413-phase2.md +0 -0
  36. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/kubernetes-141413.md +0 -0
  37. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/ruff-27808-phase2.md +0 -0
  38. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/ruff-27808.md +0 -0
  39. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/vscode-330848.md +0 -0
  40. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/audit-report.md +0 -0
  41. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/benchmarks.md +0 -0
  42. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/reddit-opensource.md +0 -0
  43. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/reddit-programming.md +0 -0
  44. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/show-hn.md +0 -0
  45. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/x-thread.md +0 -0
  46. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/live-model-catalog.json +0 -0
  47. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/phase-0-research.md +0 -0
  48. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/visual-verification.md +0 -0
  49. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/homebrew/diffly-cli.rb +0 -0
  50. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/install.sh +0 -0
  51. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/capture_real_sessions.sh +0 -0
  52. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/discover_benchmarks.py +0 -0
  53. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/render_real_captures.py +0 -0
  54. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/run_benchmarks.py +0 -0
  55. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/validate_action.py +0 -0
  56. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/write_benchmarks_doc.py +0 -0
  57. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/__main__.py +0 -0
  58. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/astmap.py +0 -0
  59. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/diffparse.py +0 -0
  60. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/local.py +0 -0
  61. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/models.py +0 -0
  62. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/redact.py +0 -0
  63. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/update.py +0 -0
  64. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_action.py +0 -0
  65. {diffly_cli-0.4.0 → diffly_cli-1.0.0}/upgrade.sh +0 -0
@@ -4,6 +4,25 @@ All notable changes to diffly are documented here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.0.0] - 2026-08-22
8
+
9
+ Diffly 1.0.0 is the first production-ready release of the deterministic pull-request triage workflow. It stabilizes the command-line experience, interactive review, local analysis, GitHub Action, and explanation behavior around a clear three-outcome policy: healthy pull requests pass, focused review gates quarantine, and severe failures block.
10
+
11
+ ### Added
12
+
13
+ - Reliable opted-in explanations: Diffly uses the configured AI provider when available and otherwise renders a clearly labelled local explanation from deterministic review facts.
14
+ - Automatic reuse of an authenticated `gh` session when `GITHUB_TOKEN` is not set.
15
+ - A centred terminal experience across the guided wizard, loading transition, interactive review, diagnostics, and focused report.
16
+ - Local analysis, stable JSON output, interactive review, GitHub Action integration, an update workflow, diagnostics, and guided setup as supported 1.0.0 workflows.
17
+
18
+ ### Changed
19
+
20
+ - Rebalanced verdicts so `PASS` is the normal healthy outcome. Missing obvious test coverage, unavailable checks, and incomplete repository-tree hints remain visible as review notes instead of automatically forcing `QUARANTINE`.
21
+ - `QUARANTINE` is reserved for concrete review gates: security-sensitive code, database schema or migrations, dependency changes, and pending required checks.
22
+ - `BLOCK` is reserved for failed required checks and high-confidence credential exposure in a changed hunk.
23
+ - The guided prompts and recovery messages now give clearer next steps.
24
+ - Updated Diffly’s visual identity with the refreshed logo and terminal loading mark.
25
+
7
26
  ### Fixed
8
27
 
9
28
  - Interactive mode no longer breaks imports on platforms without POSIX terminal support; it now falls back to the standard report.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffly-cli
3
- Version: 0.4.0
3
+ Version: 1.0.0
4
4
  Summary: Deterministic triage for large GitHub pull requests
5
5
  Author: Vivaan Dhawan
6
6
  License: MIT License — diffly
@@ -128,7 +128,7 @@ Requires-Dist: pyyaml>=6; extra == 'dev'
128
128
  Description-Content-Type: text/markdown
129
129
 
130
130
  <p align="center">
131
- <img src="assets/logo.png" alt="diffly logo" width="140">
131
+ <img src="assets/logo-v2.svg" alt="diffly logo" width="280">
132
132
  </p>
133
133
 
134
134
  # ⚡ diffly
@@ -141,7 +141,7 @@ Description-Content-Type: text/markdown
141
141
 
142
142
  <p align="center">
143
143
  <a href="https://github.com/VIVAAN-DHAWAN/diffly-cli/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/CI-passing-brightgreen?style=for-the-badge&logo=githubactions" alt="CI"></a>
144
- <a href="https://github.com/VIVAAN-DHAWAN/diffly-cli/releases/latest"><img src="https://img.shields.io/badge/release-v0.4.0-blue?style=for-the-badge" alt="Release"></a>
144
+ <a href="https://github.com/VIVAAN-DHAWAN/diffly-cli/releases/latest"><img src="https://img.shields.io/badge/release-v1.0.0-blue?style=for-the-badge" alt="Release"></a>
145
145
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
146
146
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="License: MIT"></a>
147
147
  </p>
@@ -161,7 +161,6 @@ curl -fsSL https://raw.githubusercontent.com/VIVAAN-DHAWAN/diffly-cli/main/insta
161
161
  ```bash
162
162
  pip install diffly-cli
163
163
  ```
164
-
165
164
  **uv**
166
165
  ```bash
167
166
  uv tool install diffly-cli
@@ -270,6 +269,8 @@ For automation prefer `--json`: successful triage exits `0` regardless of verdic
270
269
 
271
270
  ### Optional AI explanation
272
271
 
272
+ Every opted-in explanation is shown in the review. With an AI key, Diffly produces a bounded, redacted AI narrative; without one—or if the provider is unavailable—it creates a clearly labelled local explanation from deterministic review facts instead. Neither path can alter the verdict.
273
+
273
274
  ```bash
274
275
  export DIFFLY_LLM_API_KEY="your-key"
275
276
  export DIFFLY_LLM_BASE_URL="https://api.openai.com/v1" # omit for the default endpoint
@@ -282,11 +283,11 @@ Default model is `gpt-5-mini`; override with `DIFFLY_LLM_MODEL` or `--llm-model`
282
283
 
283
284
  | Verdict | Rule |
284
285
  | --- | --- |
285
- | **BLOCK** | A required check failed, or the PR touches authentication, credentials, secrets, or security-sensitive files. |
286
- | **QUARANTINE** | Database schema/migrations, dependency changes, missing obvious test coverage for production files, or unavailable/pending checks. |
287
- | **PASS** | No rule fired and observed checks passed. `SHIP` remains accepted as a legacy alias. |
286
+ | **BLOCK** | A required check failed, or the changed hunk appears to add a credential-like value. |
287
+ | **QUARANTINE** | Security-sensitive code, database schema/migrations, dependency changes, or still-pending checks need focused review. |
288
+ | **PASS** | No blocking or quarantine rule fired. Missing obvious tests and unavailable checks stay visible as review notes, but do not turn an otherwise healthy PR into `QUARANTINE`. `SHIP` remains accepted as a legacy alias. |
288
289
 
289
- Deliberately conservative: a verdict is a review gate, not a claim that a PR is correct or safe in every context.
290
+ `PASS` is the normal healthy outcome. A verdict is a review signal, not a claim that a PR is correct or safe in every context.
290
291
 
291
292
  ## Real examples
292
293
 
@@ -304,7 +305,7 @@ Live AI-explainer reports (deterministic verdict preserved): [ruff phase 2](demo
304
305
 
305
306
  ## Current limitations
306
307
 
307
- The Phase 2 explainer requires an OpenAI-compatible API key and never influences the verdict. Tree-sitter parsing covers symbols and direct calls visible in changed hunks, not a full repository-wide call graph. Test-coverage detection is heuristic (filenames + repository tree). Unavailable checks are quarantined rather than assumed passing. Model context is bounded and may truncate on very large PRs.
308
+ Tree-sitter parsing covers symbols and direct calls visible in changed hunks, not a full repository-wide call graph. Test-coverage detection is heuristic (filenames + repository tree), so it is presented as a review note rather than a verdict gate. AI model context is bounded and may truncate on very large PRs. A local explanation is used when AI generation is not available.
308
309
 
309
310
  ## Roadmap
310
311
 
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="assets/logo.png" alt="diffly logo" width="140">
2
+ <img src="assets/logo-v2.svg" alt="diffly logo" width="280">
3
3
  </p>
4
4
 
5
5
  # ⚡ diffly
@@ -12,7 +12,7 @@
12
12
 
13
13
  <p align="center">
14
14
  <a href="https://github.com/VIVAAN-DHAWAN/diffly-cli/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/CI-passing-brightgreen?style=for-the-badge&logo=githubactions" alt="CI"></a>
15
- <a href="https://github.com/VIVAAN-DHAWAN/diffly-cli/releases/latest"><img src="https://img.shields.io/badge/release-v0.4.0-blue?style=for-the-badge" alt="Release"></a>
15
+ <a href="https://github.com/VIVAAN-DHAWAN/diffly-cli/releases/latest"><img src="https://img.shields.io/badge/release-v1.0.0-blue?style=for-the-badge" alt="Release"></a>
16
16
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
17
17
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="License: MIT"></a>
18
18
  </p>
@@ -32,7 +32,6 @@ curl -fsSL https://raw.githubusercontent.com/VIVAAN-DHAWAN/diffly-cli/main/insta
32
32
  ```bash
33
33
  pip install diffly-cli
34
34
  ```
35
-
36
35
  **uv**
37
36
  ```bash
38
37
  uv tool install diffly-cli
@@ -141,6 +140,8 @@ For automation prefer `--json`: successful triage exits `0` regardless of verdic
141
140
 
142
141
  ### Optional AI explanation
143
142
 
143
+ Every opted-in explanation is shown in the review. With an AI key, Diffly produces a bounded, redacted AI narrative; without one—or if the provider is unavailable—it creates a clearly labelled local explanation from deterministic review facts instead. Neither path can alter the verdict.
144
+
144
145
  ```bash
145
146
  export DIFFLY_LLM_API_KEY="your-key"
146
147
  export DIFFLY_LLM_BASE_URL="https://api.openai.com/v1" # omit for the default endpoint
@@ -153,11 +154,11 @@ Default model is `gpt-5-mini`; override with `DIFFLY_LLM_MODEL` or `--llm-model`
153
154
 
154
155
  | Verdict | Rule |
155
156
  | --- | --- |
156
- | **BLOCK** | A required check failed, or the PR touches authentication, credentials, secrets, or security-sensitive files. |
157
- | **QUARANTINE** | Database schema/migrations, dependency changes, missing obvious test coverage for production files, or unavailable/pending checks. |
158
- | **PASS** | No rule fired and observed checks passed. `SHIP` remains accepted as a legacy alias. |
157
+ | **BLOCK** | A required check failed, or the changed hunk appears to add a credential-like value. |
158
+ | **QUARANTINE** | Security-sensitive code, database schema/migrations, dependency changes, or still-pending checks need focused review. |
159
+ | **PASS** | No blocking or quarantine rule fired. Missing obvious tests and unavailable checks stay visible as review notes, but do not turn an otherwise healthy PR into `QUARANTINE`. `SHIP` remains accepted as a legacy alias. |
159
160
 
160
- Deliberately conservative: a verdict is a review gate, not a claim that a PR is correct or safe in every context.
161
+ `PASS` is the normal healthy outcome. A verdict is a review signal, not a claim that a PR is correct or safe in every context.
161
162
 
162
163
  ## Real examples
163
164
 
@@ -175,7 +176,7 @@ Live AI-explainer reports (deterministic verdict preserved): [ruff phase 2](demo
175
176
 
176
177
  ## Current limitations
177
178
 
178
- The Phase 2 explainer requires an OpenAI-compatible API key and never influences the verdict. Tree-sitter parsing covers symbols and direct calls visible in changed hunks, not a full repository-wide call graph. Test-coverage detection is heuristic (filenames + repository tree). Unavailable checks are quarantined rather than assumed passing. Model context is bounded and may truncate on very large PRs.
179
+ Tree-sitter parsing covers symbols and direct calls visible in changed hunks, not a full repository-wide call graph. Test-coverage detection is heuristic (filenames + repository tree), so it is presented as a review note rather than a verdict gate. AI model context is bounded and may truncate on very large PRs. A local explanation is used when AI generation is not available.
179
180
 
180
181
  ## Roadmap
181
182
 
@@ -0,0 +1,35 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
2
+ <title id="title">Diffly logo</title>
3
+ <description id="description">A mint hexagonal code-diff mark with plus, slash, and minus symbols.</description>
4
+ <defs>
5
+ <linearGradient id="mint" x1="104" y1="88" x2="416" y2="424" gradientUnits="userSpaceOnUse">
6
+ <stop stop-color="#B9FFCB"/>
7
+ <stop offset=".45" stop-color="#68E5A4"/>
8
+ <stop offset="1" stop-color="#2EB67D"/>
9
+ </linearGradient>
10
+ <linearGradient id="core" x1="138" y1="112" x2="375" y2="405" gradientUnits="userSpaceOnUse">
11
+ <stop stop-color="#16211F"/>
12
+ <stop offset="1" stop-color="#0A1114"/>
13
+ </linearGradient>
14
+ <filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
15
+ <feGaussianBlur stdDeviation="8" result="blur"/>
16
+ <feMerge>
17
+ <feMergeNode in="blur"/>
18
+ <feMergeNode in="SourceGraphic"/>
19
+ </feMerge>
20
+ </filter>
21
+ </defs>
22
+
23
+ <path d="M256 52 431 153v206L256 460 81 359V153L256 52Z" fill="#0A1114"/>
24
+ <path d="M256 52 431 153v206L256 460 81 359V153L256 52Z" fill="none" stroke="url(#mint)" stroke-width="18" stroke-linejoin="round"/>
25
+ <path d="M256 91 397 172v168L256 421 115 340V172L256 91Z" fill="url(#core)" stroke="#6FE9AA" stroke-opacity=".56" stroke-width="4" stroke-linejoin="round"/>
26
+ <path d="M256 116 375 185v142L256 396 137 327V185L256 116Z" fill="none" stroke="#B9FFCB" stroke-opacity=".12" stroke-width="2" stroke-linejoin="round"/>
27
+
28
+ <g fill="url(#mint)" filter="url(#glow)">
29
+ <rect x="154" y="215" width="82" height="22" rx="11"/>
30
+ <rect x="184" y="185" width="22" height="82" rx="11"/>
31
+ <rect x="304" y="274" width="72" height="22" rx="11"/>
32
+ <path d="M271 177h25l-58 158h-25l58-158Z"/>
33
+ </g>
34
+
35
+ </svg>
Binary file
@@ -28,4 +28,4 @@ The explainer receives PR metadata, deterministic flags and verdict, changed-fil
28
28
 
29
29
  ## Testing target
30
30
 
31
- Phase 2 is complete only when redaction, schema validation, prompt construction, API failure fallback, deterministic-verdict immutability, CLI integration, and at least one live public pull-request explanation are verified.
31
+ Phase 2 is complete only when redaction, schema validation, prompt construction, a clearly labelled local fallback, deterministic-verdict immutability, CLI integration, and at least one live public pull-request explanation are verified.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "diffly-cli"
7
- version = "0.4.0"
7
+ version = "1.0.0"
8
8
  description = "Deterministic triage for large GitHub pull requests"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Deterministic triage for large GitHub pull requests."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "1.0.0"
@@ -9,6 +9,7 @@ import re
9
9
  import select
10
10
  import shutil
11
11
  import sys
12
+ import time
12
13
  from typing import Any
13
14
 
14
15
  from rich.align import Align
@@ -24,7 +25,7 @@ from . import update
24
25
  from .astmap import analyze_files
25
26
  from .explainer import ExplanationResult, generate_explanation
26
27
  from .diffparse import files_from_unified_diff
27
- from .github import GitHubClient, GitHubError
28
+ from .github import GitHubClient, GitHubError, github_auth_token
28
29
  from .local import LocalAnalysisError, build_local_result, resolve_repository_root
29
30
  from .models import ChangedFile, PRMetadata, TriageResult
30
31
  from .triage import compute_flags, verdict_for
@@ -32,6 +33,12 @@ from .triage import compute_flags, verdict_for
32
33
  console = Console()
33
34
  VERDICT_STYLES = {"PASS": "bold green", "SHIP": "bold green", "QUARANTINE": "bold yellow", "BLOCK": "bold red"}
34
35
  CONTENT_WIDTH = 100
36
+ INTERACTIVE_SECTIONS = (
37
+ ("verdict", "Verdict", "The decision and the reasons behind it"),
38
+ ("checks", "Checks", "The latest CI and commit-status results"),
39
+ ("risks", "Risk flags", "Changes that need extra review"),
40
+ ("files", "Changed files", "The scope of the pull request"),
41
+ )
35
42
 
36
43
 
37
44
  def center(renderable: Any) -> Any:
@@ -39,6 +46,57 @@ def center(renderable: Any) -> Any:
39
46
  return Align.center(renderable, style="")
40
47
 
41
48
 
49
+ def center_screen(renderable: Any, *, estimated_height: int) -> None:
50
+ """Clear the terminal and place a small interactive screen near its centre."""
51
+ console.clear()
52
+ top_padding = max((console.size.height - estimated_height) // 2, 0)
53
+ if top_padding:
54
+ console.print("\n" * top_padding, end="")
55
+ console.print(center(renderable))
56
+
57
+
58
+ def _loading_mark(turn: str) -> Text:
59
+ """Render a terminal-safe stand-in for the existing Diffly logo.
60
+
61
+ Rich targets ordinary text terminals, where raster-image rotation is not
62
+ portable. The repository logo remains unchanged in ``assets/logo.png``;
63
+ this small + / − mark mirrors it for the short terminal animation.
64
+ """
65
+ mark = Text(justify="center")
66
+ mark.append("⬡ ", style="bold cyan")
67
+ mark.append("+ / −", style="bold white")
68
+ mark.append(f" {turn}", style="bold cyan")
69
+ return mark
70
+
71
+
72
+ def show_loading_screen(message: str) -> None:
73
+ """Play a short, reduced-motion-friendly loading transition for TTY users."""
74
+ if not sys.stdout.isatty():
75
+ return
76
+ # Enter centre quickly, turn clockwise, pause, then settle upright.
77
+ frames = (("", 0.08), ("↻", 0.10), ("↻", 0.30), ("", 0.10))
78
+ for turn, duration in frames:
79
+ screen = Panel.fit(
80
+ Text.assemble(
81
+ _loading_mark(turn),
82
+ "\n\n",
83
+ (message, "dim"),
84
+ ),
85
+ border_style="cyan",
86
+ padding=(1, 4),
87
+ )
88
+ center_screen(screen, estimated_height=8)
89
+ time.sleep(duration)
90
+
91
+
92
+ def interactive_sections(explanation: ExplanationResult | None) -> list[tuple[str, str, str]]:
93
+ """Return the report sections shown in the keyboard-driven review menu."""
94
+ sections = list(INTERACTIVE_SECTIONS)
95
+ if explanation is not None:
96
+ sections.append(("explain", "Explanation", "Optional generated context; never changes the verdict"))
97
+ return sections
98
+
99
+
42
100
  @dataclass(frozen=True)
43
101
  class RepoRef:
44
102
  """A repository reference, optionally carrying a PR number from a URL."""
@@ -137,15 +195,21 @@ def _risk_badge(severity: str) -> str:
137
195
 
138
196
  def render_explanation(explanation: ExplanationResult) -> list[str]:
139
197
  lines = ["## Literate diff — generated explanation", ""]
140
- if explanation.error:
198
+ if explanation.error and explanation.explanation is None:
141
199
  lines.extend([
142
200
  f"> Generated explanation unavailable: {explanation.error}",
143
201
  f"> Redactions applied before any model call: {explanation.redactions}",
144
202
  "",
145
203
  ])
146
204
  return lines
205
+ if explanation.source == "local":
206
+ lines.extend([
207
+ "> Generated locally from Diffly's deterministic review facts; no code was sent to an AI service.",
208
+ *( [f"> Note: {explanation.warning}"] if explanation.warning else [] ),
209
+ ])
210
+ else:
211
+ lines.append(f"> Generated by `{explanation.model}` from bounded, redacted context. This prose cannot change the deterministic verdict.")
147
212
  lines.extend([
148
- f"> Generated by `{explanation.model}` from bounded, redacted context. This prose cannot change the deterministic verdict.",
149
213
  f"> Redactions applied before the model call: {explanation.redactions}",
150
214
  "",
151
215
  "### Background",
@@ -236,7 +300,7 @@ def render_markdown(result: TriageResult, explanation: ExplanationResult | None
236
300
  for file in result.files:
237
301
  symbols = ", ".join(file.touched_symbols) if file.touched_symbols else "—"
238
302
  lines.append(f"| `{file.path}` | {file.status} | {file.additions} | {file.deletions} | {symbols} |")
239
- 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._"]
303
+ lines += ["", "## Deterministic policy", "", "- `BLOCK`: failed checks or a credential-like value added to a changed hunk.", "- `QUARANTINE`: security-sensitive code, database schema or migration changes, dependency changes, or still-pending checks.", "- `PASS`: no blocking or quarantine signal fired. Missing obvious tests or unavailable checks remain visible as review notes, not verdict gates.", "", "_Generated by diffly. Deterministic triage is authoritative; any literate-diff prose is optional generated explanation._"]
240
304
  return "\n".join(lines) + "\n"
241
305
 
242
306
 
@@ -249,13 +313,20 @@ def _progress(message: str):
249
313
 
250
314
  def _explanation_lines(explanation: ExplanationResult) -> list[str]:
251
315
  """Render the literate-diff explanation as compact lines for the interactive view."""
252
- if explanation.error:
316
+ if explanation.error and explanation.explanation is None:
253
317
  hint = ""
254
318
  if "API key" in explanation.error:
255
319
  hint = "\n[dim]Enable it: export DIFFLY_LLM_API_KEY=... (optional — deterministic triage is unaffected)[/]"
256
320
  return [f"[yellow]Generated explanation unavailable:[/] {explanation.error}", hint or "[dim]Deterministic triage above remains authoritative.[/]", ""]
257
321
  data = explanation.explanation
258
- lines = [f"[dim]Generated by {explanation.model} from bounded, redacted context · cannot change the verdict[/]", "", f"[bold]Background.[/] {data['background']}", f"[bold]Intent.[/] {data['intent']}", "", "[bold]Narrative steps[/]"]
322
+ if explanation.source == "local":
323
+ provenance = "[dim]Generated locally from deterministic review facts · no code was sent to an AI service[/]"
324
+ else:
325
+ provenance = f"[dim]Generated by {explanation.model} from bounded, redacted context · cannot change the verdict[/]"
326
+ lines = [provenance]
327
+ if explanation.warning:
328
+ lines.append(f"[yellow]{explanation.warning}[/]")
329
+ lines += ["", f"[bold]Background.[/] {data['background']}", f"[bold]Intent.[/] {data['intent']}", "", "[bold]Narrative steps[/]"]
259
330
  for index, step in enumerate(data["narrative"], start=1):
260
331
  lines.append(f" {index}. [cyan]{step['title']}[/] — {step['explanation']}")
261
332
  if step["files"]:
@@ -290,47 +361,43 @@ def _section_lines(result: TriageResult, section: str, explanation: ExplanationR
290
361
 
291
362
 
292
363
  def interactive_view(result: TriageResult, explanation: ExplanationResult | None = None) -> None:
293
- """Let a reviewer toggle report sections with up/down arrows and space."""
364
+ """Let a reviewer choose the report sections to include with the keyboard."""
294
365
  if not sys.stdin.isatty() or not sys.stdout.isatty():
295
- console.print("[yellow]Interactive mode needs a terminal; showing the standard report instead.[/]")
366
+ console.print("[yellow]Interactive review needs a terminal. Showing the full report instead.[/]")
296
367
  console.print(Markdown(render_markdown(result, explanation)))
297
368
  return
298
369
  try:
299
370
  import termios
300
371
  import tty
301
372
  except ImportError:
302
- console.print("[yellow]Interactive mode is unavailable on this platform; showing the standard report instead.[/]")
373
+ console.print("[yellow]Interactive review is unavailable on this platform. Showing the full report instead.[/]")
303
374
  console.print(Markdown(render_markdown(result, explanation)))
304
375
  return
305
- labels = [("verdict", "Verdict"), ("checks", "Checks"), ("risks", "Risk flags"), ("files", "Changed files")]
306
- if explanation is not None:
307
- labels.append(("explain", "Explanation"))
308
- try:
309
- import termios
310
- import tty
311
- except ImportError:
312
- console.print("[yellow]Interactive mode is unavailable on this platform; showing the standard report instead.[/]")
313
- console.print(Markdown(render_markdown(result)))
314
- return
315
- labels = [("verdict", "Verdict"), ("checks", "Checks"), ("risks", "Risk flags"), ("files", "Changed files")]
316
- enabled = {key: True for key, _ in labels}
376
+ labels = interactive_sections(explanation)
377
+ enabled = {key: True for key, _, _ in labels}
317
378
  cursor = 0
318
379
  fd = sys.stdin.fileno()
319
380
  old = termios.tcgetattr(fd)
320
381
  try:
321
382
  tty.setcbreak(fd)
322
383
  while True:
323
- console.clear()
324
- console.print(center(Panel.fit("[bold cyan]diffly interactive review[/] [dim]↑/↓ move · space toggle · enter apply · q quit[/]", border_style="cyan")))
325
- menu = Table(show_header=False, box=None, padding=(0, 2))
326
- menu.add_column("", width=3)
327
- menu.add_column("Section")
328
- for index, (key, label) in enumerate(labels):
329
- marker = "[cyan]›[/]" if index == cursor else " "
330
- state = "[green]on[/]" if enabled[key] else "[dim]off[/]"
331
- menu.add_row(marker, f"{label:<18} {state}")
332
- console.print(center(menu))
333
- console.print(center(Text.from_markup("[dim]Toggle sections to keep the review focused.[/]")))
384
+ menu = Table(show_header=False, box=None, padding=(0, 1), expand=False)
385
+ menu.add_column("", width=2)
386
+ menu.add_column("Section", min_width=16)
387
+ menu.add_column("Description", min_width=32)
388
+ menu.add_column("", justify="right", width=9)
389
+ for index, (key, label, description) in enumerate(labels):
390
+ marker = "[bold cyan]›[/]" if index == cursor else " "
391
+ state = "[bold green]Included[/]" if enabled[key] else "[dim]Hidden[/]"
392
+ menu.add_row(marker, f"[bold]{label}[/]", f"[dim]{description}[/]", state)
393
+ screen = Panel(
394
+ menu,
395
+ title="[bold cyan]DIFFLY[/] [bold]Build your review[/]",
396
+ subtitle="[dim]↑ ↓ move · Space include or hide · Enter show review · q quit[/]",
397
+ border_style="cyan",
398
+ padding=(1, 2),
399
+ )
400
+ center_screen(screen, estimated_height=len(labels) + 7)
334
401
  key = sys.stdin.read(1)
335
402
  if key in {"q", "Q"}:
336
403
  return
@@ -345,16 +412,21 @@ def interactive_view(result: TriageResult, explanation: ExplanationResult | None
345
412
  cursor = (cursor - 1) % len(labels)
346
413
  elif sequence == "[B":
347
414
  cursor = (cursor + 1) % len(labels)
348
- console.clear()
349
- result_table = Table(title=f"{result.metadata.owner}/{result.metadata.repo}#{result.metadata.number}", box=None, padding=(0, 1))
415
+ result_table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
350
416
  result_table.add_column("Section", style="cyan")
351
- result_table.add_column("Details")
352
- for key, label in labels:
417
+ result_table.add_column("Review details", max_width=CONTENT_WIDTH - 22)
418
+ for key, label, _ in labels:
353
419
  if enabled[key]:
354
420
  details = "\n".join(_section_lines(result, key, explanation))
355
421
  result_table.add_row(label, details)
356
- console.print(center(result_table))
357
- console.print()
422
+ screen = Panel(
423
+ result_table,
424
+ title=f"[bold cyan]DIFFLY REVIEW[/] [bold]{result.metadata.owner}/{result.metadata.repo}#{result.metadata.number}[/]",
425
+ subtitle="[dim]Focused review generated from your selected sections[/]",
426
+ border_style="green",
427
+ padding=(1, 2),
428
+ )
429
+ center_screen(screen, estimated_height=min(console.size.height, len(labels) * 5 + 7))
358
430
  finally:
359
431
  termios.tcsetattr(fd, termios.TCSADRAIN, old)
360
432
 
@@ -408,6 +480,8 @@ def result_payload(result: TriageResult, explanation: ExplanationResult | None)
408
480
  "redactions": explanation.redactions,
409
481
  "model": explanation.model,
410
482
  "error": explanation.error,
483
+ "source": explanation.source,
484
+ "warning": explanation.warning,
411
485
  } if explanation is not None else None),
412
486
  }
413
487
 
@@ -430,6 +504,22 @@ def _emit_report(result: TriageResult, explanation: ExplanationResult | None, ar
430
504
  return 0
431
505
 
432
506
 
507
+ def render_pr_error(ref: RepoRef, number: int, error: GitHubError) -> None:
508
+ """Show a concise recovery message for a failed pull-request lookup."""
509
+ if "GitHub API 404" in str(error):
510
+ body = (
511
+ f"[bold red]Pull request not found[/]\n"
512
+ f"[dim]{ref.slug}#{number} is unavailable. Check the repository, pull-request number, and your access.[/]\n\n"
513
+ "[dim]Private repositories require GITHUB_TOKEN. You can also paste the full pull-request URL to avoid entering the number manually.[/]"
514
+ )
515
+ else:
516
+ body = (
517
+ f"[bold red]Unable to inspect pull request[/]\n{error}\n\n"
518
+ "[dim]Check GITHUB_TOKEN, repository access, and your network connection. Run `diffly doctor` for diagnostics.[/]"
519
+ )
520
+ console.print(center(Panel.fit(body, border_style="red", padding=(1, 2))))
521
+
522
+
433
523
  def run_pr(args: argparse.Namespace) -> int:
434
524
  ref: RepoRef = args.repository
435
525
  number = args.number if args.number is not None else ref.pr_number
@@ -439,10 +529,12 @@ def run_pr(args: argparse.Namespace) -> int:
439
529
  owner, repo = ref.owner, ref.repo
440
530
  client = GitHubClient(token=args.token)
441
531
  try:
532
+ if getattr(args, "interactive", False):
533
+ show_loading_screen("Preparing your review…")
442
534
  with _progress(f"[cyan]Analyzing [bold]{ref.slug}#{number}[/] — fetching metadata, diffs, symbols, and checks…[/]"):
443
535
  result = build_result(client, owner, repo, number)
444
536
  except GitHubError as exc:
445
- console.print(center(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")))
537
+ render_pr_error(ref, number, exc)
446
538
  return 2
447
539
  if args.explain:
448
540
  with _progress("[cyan]Generating literate-diff explanation…[/]"):
@@ -469,6 +561,8 @@ def run_local(args: argparse.Namespace) -> int:
469
561
  console.print(center(Panel.fit(f"[bold red]Cannot analyze folder[/]\n{exc}\n\n[dim]Local mode works on any git checkout — including private or deleted repositories you still have on disk.[/]", border_style="red")))
470
562
  return 2
471
563
  try:
564
+ if getattr(args, "interactive", False):
565
+ show_loading_screen("Preparing your local review…")
472
566
  with _progress(f"[cyan]Analyzing [bold]{root.name}[/] ({scope}) — reading diffs and symbols locally…[/]"):
473
567
  result = build_local_result(str(root), base=args.base)
474
568
  except LocalAnalysisError as exc:
@@ -497,7 +591,9 @@ def run_doctor(_: argparse.Namespace) -> int:
497
591
  table.add_column("Status")
498
592
  pref = update.get_update_preference()
499
593
  update_status = {"auto": "auto-update enabled", "manual": "manual updates"}.get(pref, "not set (will prompt on startup)")
500
- 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"), ("Update preference", update_status)]
594
+ github_token = github_auth_token()
595
+ github_status = "GITHUB_TOKEN configured" if os.environ.get("GITHUB_TOKEN") else ("GitHub CLI sign-in available" if github_token else "not set (public API only)")
596
+ checks = [("Python", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"), ("GitHub access", github_status), ("Terminal", "interactive" if sys.stdin.isatty() else "non-interactive"), ("diffly executable", shutil.which("diffly") or "not on PATH"), ("Update preference", update_status)]
501
597
  for name, value in checks:
502
598
  table.add_row(name, value)
503
599
  console.print()
@@ -645,15 +741,15 @@ def run_wizard(parser: argparse.ArgumentParser) -> int:
645
741
  console.print()
646
742
  console.print(center(Panel.fit(
647
743
  "[bold cyan]diffly[/] [dim]Deterministic pull-request triage[/]\n"
648
- "[dim]Paste a repository or pull-request URL, answer two prompts, get a focused review.[/]",
744
+ "[bold]Start a focused review[/] by pasting a GitHub repository or pull-request URL.",
649
745
  border_style="cyan",
650
746
  padding=(1, 4),
651
747
  )))
652
- console.print(center(Text.from_markup("[dim]Tip: scripted usage stays available with `diffly pr OWNER/REPO NUMBER --json`.\n[/]")))
653
- console.print(center(Text.from_markup("[dim]Format: owner/repo · or paste any GitHub repository / pull-request URL[/]\n")))
748
+ console.print(center(Text.from_markup("[dim]Tip: for scripts and CI, use `diffly pr OWNER/REPO NUMBER --json`.\n[/]")))
749
+ console.print(center(Text.from_markup("[dim]Accepted: owner/repo, a GitHub repository URL, or a full pull-request URL.[/]\n")))
654
750
  while True:
655
751
  raw_repository = Prompt.ask(
656
- "[cyan]Repository URL[/]",
752
+ "[cyan]Repository or pull-request URL[/]",
657
753
  default=os.environ.get("DIFFLY_REPOSITORY", ""),
658
754
  show_default=False,
659
755
  )
@@ -661,30 +757,36 @@ def run_wizard(parser: argparse.ArgumentParser) -> int:
661
757
  reference = parse_repo(raw_repository)
662
758
  break
663
759
  except argparse.ArgumentTypeError as exc:
664
- console.print(f"[red]Invalid repository:[/] {exc} — use owner/repo or paste a GitHub URL.")
760
+ console.print(f"[red]That repository address is not valid.[/] {exc}\n[dim]Use owner/repo or paste a GitHub repository or pull-request URL.[/]")
665
761
  if reference.pr_number is not None:
666
762
  number = reference.pr_number
667
- console.print(f"[dim]Using pull-request #{number} from the URL you entered.[/]")
763
+ console.print(f"[dim]Pull request #{number} was found in the URL.[/]")
668
764
  else:
669
765
  while True:
670
- raw_number = Prompt.ask("[cyan]Pull request number[/]")
766
+ raw_number = Prompt.ask(f"[cyan]Pull-request number for {reference.slug}[/]")
671
767
  try:
672
768
  number = positive_pr_number(raw_number)
673
769
  break
674
770
  except argparse.ArgumentTypeError as exc:
675
- console.print(f"[red]Invalid PR number:[/] {exc}")
676
- token = os.environ.get("GITHUB_TOKEN")
771
+ console.print(f"[red]That pull-request number is not valid.[/] {exc}")
772
+ token = github_auth_token()
677
773
  if not token:
678
- console.print("[dim]No GITHUB_TOKEN found. Public repositories still work with lower API limits.[/]")
679
- explain = Confirm.ask("[cyan]Add an optional AI-generated explanation?[/]", default=False)
774
+ console.print("[dim]No GitHub token found. Sign in with `gh auth login` for private repositories and higher API limits.[/]")
775
+ elif not os.environ.get("GITHUB_TOKEN"):
776
+ console.print("[dim]Using your existing GitHub CLI sign-in.[/]")
777
+ explain = Confirm.ask(
778
+ "[cyan]Include a review explanation?[/] "
779
+ "[dim]With an AI key it uses AI; otherwise Diffly creates a local explanation. The verdict never changes.[/]",
780
+ default=True,
781
+ )
680
782
  if not explain:
681
- console.print("[dim]Deterministic modenothing leaves your machine.[/]")
783
+ console.print("[dim]Deterministic review only no code is sent to an AI service.[/]")
682
784
  console.print(center(Panel.fit(
683
- f"[bold]Ready[/] {reference.slug}#{number}\n"
684
- f"[dim]Mode: {'deterministic + explanation' if explain else 'deterministic'} · output: interactive[/]",
785
+ f"[bold]Review ready[/] {reference.slug}#{number}\n"
786
+ f"[dim]Mode: {'deterministic + explanation' if explain else 'deterministic only'} · interactive output[/]",
685
787
  border_style="green",
686
788
  )))
687
- console.print(center(Text.from_markup("[dim]Crunching the diff your focused review will appear shortly…\n[/]")))
789
+ console.print(center(Text.from_markup("[dim]Reading the pull request and preparing your review…\n[/]")))
688
790
  args = parser.parse_args(["pr", reference.slug, str(number)])
689
791
  args.token = token
690
792
  args.explain = explain
@@ -23,6 +23,8 @@ class ExplanationResult:
23
23
  redactions: int
24
24
  model: str | None
25
25
  error: str | None = None
26
+ source: str = "ai"
27
+ warning: str | None = None
26
28
 
27
29
 
28
30
  EXPLANATION_SCHEMA: dict[str, Any] = {
@@ -197,6 +199,56 @@ def validate_explanation(value: Any, allowed_files: set[str] | None = None) -> d
197
199
  return value
198
200
 
199
201
 
202
+ def local_explanation(result: TriageResult, *, redactions: int, warning: str | None = None) -> ExplanationResult:
203
+ """Build a useful, offline explanation when an AI provider is unavailable.
204
+
205
+ This makes the requested explanation dependable: it is always based on the
206
+ same deterministic facts as the verdict and never sends code off-machine.
207
+ """
208
+ metadata = result.metadata
209
+ narrative: list[dict[str, Any]] = []
210
+ for file in result.files[:8]:
211
+ symbols = ", ".join(file.touched_symbols[:4]) or "no named symbols detected"
212
+ narrative.append(
213
+ {
214
+ "title": f"{file.status.title()} {file.path}",
215
+ "files": [file.path],
216
+ "explanation": (
217
+ f"This file has {file.status} changes (+{file.additions}/-{file.deletions}). "
218
+ f"Diffly detected {symbols}."
219
+ ),
220
+ "evidence": [f"{file.path}: {file.status}, +{file.additions}/-{file.deletions}"],
221
+ "snippet": "",
222
+ }
223
+ )
224
+ if not narrative:
225
+ narrative.append(
226
+ {
227
+ "title": "Review metadata",
228
+ "files": [],
229
+ "explanation": "No changed-file details were returned, so this explanation is limited to pull-request metadata and checks.",
230
+ "evidence": [f"Changed files reported: {metadata.changed_files}"],
231
+ "snippet": "",
232
+ }
233
+ )
234
+ questions = [flag.message for flag in result.flags[:8]] or ["Does the implementation match the pull request's intended behavior?"]
235
+ uncertainties: list[str] = []
236
+ check_state = result.checks.get("state", "unknown")
237
+ if check_state in {"pending", "unknown"}:
238
+ uncertainties.append(f"CI check state is {check_state}; review again after checks finish.")
239
+ explanation = {
240
+ "background": (
241
+ f"{metadata.owner}/{metadata.repo}#{metadata.number} changes {metadata.changed_files} file(s) "
242
+ f"with +{metadata.additions}/-{metadata.deletions} lines. Diffly's deterministic verdict is {result.verdict}."
243
+ ),
244
+ "intent": f"The pull request is titled “{metadata.title or 'Untitled pull request'}”. The steps below describe the changed files and review signals available locally.",
245
+ "narrative": narrative,
246
+ "review_questions": questions,
247
+ "uncertainties": uncertainties,
248
+ }
249
+ return ExplanationResult(explanation, redactions, None, source="local", warning=warning)
250
+
251
+
200
252
  def generate_explanation(result: TriageResult, *, client: ChatClient | None = None, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> ExplanationResult:
201
253
  context, redaction_count = build_context(result)
202
254
  selected_model = model or os.environ.get("DIFFLY_LLM_MODEL", DEFAULT_MODEL)
@@ -204,11 +256,19 @@ def generate_explanation(result: TriageResult, *, client: ChatClient | None = No
204
256
  selected_base = base_url or os.environ.get("DIFFLY_LLM_BASE_URL") or os.environ.get("OPENAI_API_BASE")
205
257
  if client is None:
206
258
  if not selected_key:
207
- return ExplanationResult(None, redaction_count, selected_model, "No LLM API key configured; deterministic triage remains available.")
259
+ return local_explanation(
260
+ result,
261
+ redactions=redaction_count,
262
+ warning="No AI API key is configured, so Diffly generated this explanation locally.",
263
+ )
208
264
  try:
209
265
  client = OpenAIChatClient(selected_key, selected_base)
210
266
  except Exception as exc:
211
- return ExplanationResult(None, redaction_count, selected_model, f"Could not initialize LLM client: {exc}")
267
+ return local_explanation(
268
+ result,
269
+ redactions=redaction_count,
270
+ warning=f"AI explanation could not start ({exc}); Diffly generated this explanation locally.",
271
+ )
212
272
  try:
213
273
  token_limit_key, token_limit = _token_limit_for_model(selected_model)
214
274
  raw = client.chat(
@@ -224,4 +284,8 @@ def generate_explanation(result: TriageResult, *, client: ChatClient | None = No
224
284
  explanation = validate_explanation(json.loads(safe_raw.text), allowed_files)
225
285
  return ExplanationResult(explanation, redaction_count, selected_model)
226
286
  except Exception as exc:
227
- return ExplanationResult(None, redaction_count, selected_model, f"Literate-diff generation failed safely: {exc}")
287
+ return local_explanation(
288
+ result,
289
+ redactions=redaction_count,
290
+ warning=f"AI explanation failed safely ({exc}); Diffly generated this explanation locally.",
291
+ )
@@ -2,10 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import subprocess
5
6
  import urllib.error
6
7
  import urllib.parse
7
8
  import urllib.request
8
9
  from dataclasses import dataclass
10
+ from functools import lru_cache
9
11
  from typing import Any
10
12
 
11
13
  from . import __version__
@@ -17,6 +19,26 @@ class GitHubError(RuntimeError):
17
19
  pass
18
20
 
19
21
 
22
+ @lru_cache(maxsize=1)
23
+ def github_auth_token() -> str | None:
24
+ """Reuse an existing GitHub CLI sign-in without ever printing its token."""
25
+ configured = os.environ.get("GITHUB_TOKEN")
26
+ if configured:
27
+ return configured
28
+ try:
29
+ completed = subprocess.run(
30
+ ["gh", "auth", "token"],
31
+ check=False,
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=3,
35
+ )
36
+ except (FileNotFoundError, subprocess.SubprocessError):
37
+ return None
38
+ token = completed.stdout.strip()
39
+ return token if completed.returncode == 0 and token else None
40
+
41
+
20
42
  @dataclass(frozen=True)
21
43
  class RepositoryTreeResult:
22
44
  paths: list[str]
@@ -25,7 +47,7 @@ class RepositoryTreeResult:
25
47
 
26
48
  class GitHubClient:
27
49
  def __init__(self, token: str | None = None, api_url: str = "https://api.github.com") -> None:
28
- self.token = token or os.environ.get("GITHUB_TOKEN")
50
+ self.token = token or github_auth_token()
29
51
  self.api_url = api_url.rstrip("/")
30
52
 
31
53
  def request(self, path: str, *, accept: str = "application/vnd.github+json", params: dict[str, Any] | None = None) -> Any:
@@ -6,12 +6,10 @@ from collections import defaultdict
6
6
  from typing import Any
7
7
 
8
8
  from .models import ChangedFile, PRMetadata, RiskFlag
9
+ from .redact import redact_secrets
9
10
 
10
- AUTH_PATTERNS = [
11
- "*auth*", "*login*", "*oauth*", "*credential*", "*secret*", "*.pem", "*.key",
12
- ".env", ".env.*", "*token*", "*password*", "*security*", "*iam*",
13
- ]
14
- DB_PATTERNS = ["*migration*", "*migrations*", "*schema*", "*alembic*", "*prisma*", "*.sql", "*db*model*"]
11
+ AUTH_PATTERNS = ["*auth*", "*login*", "*oauth*", "*credential*", "*secret*", "*.pem", "*.key", ".env", ".env.*", "*password*", "*security*", "*iam*"]
12
+ DB_PATTERNS = ["*migration*", "*migrations*", "*schema*", "*alembic*", "*prisma*", "*.sql"]
15
13
  TEST_PATTERNS = ["test*", "tests*", "spec*", "*_test.*", "*.test.*", "*.spec.*"]
16
14
  DEPENDENCY_FILES = {
17
15
  "package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
@@ -63,17 +61,31 @@ def _covered_by_test(file: ChangedFile, test_paths: list[str]) -> list[str]:
63
61
  return matches
64
62
 
65
63
 
64
+ def _has_exposed_secret(file: ChangedFile) -> bool:
65
+ """Detect a credential-like value added in the changed hunk, not just its path."""
66
+ added_lines = "\n".join(line[1:] for line in file.patch.splitlines() if line.startswith("+") and not line.startswith("+++"))
67
+ # A generic ``token = os.getenv(...)`` assignment is common application
68
+ # code, not evidence of an exposed credential. Block only on high-confidence
69
+ # credential formats (keys, bearer tokens, private keys, connection URLs).
70
+ redacted = redact_secrets(added_lines)
71
+ return any(label != "secret_assignment" for label in redacted.labels)
72
+
73
+
66
74
  def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[str, Any], repo_paths: list[str] | None = None) -> list[RiskFlag]:
67
75
  flags: list[RiskFlag] = []
68
76
  tree_complete = bool(checks.get("repository_tree_complete", repo_paths is not None))
69
77
  test_paths = [path for path in (repo_paths or []) if _matches(path, TEST_PATTERNS)] if tree_complete else []
70
78
  changed_test_paths = _test_files(files)
71
79
  if not tree_complete:
72
- flags.append(RiskFlag("REPOSITORY_TREE_INCOMPLETE", "medium", "Repository file listing was unavailable or truncated; repository-wide test coverage could not be established.", ["repository tree incomplete"]))
80
+ flags.append(RiskFlag("REPOSITORY_TREE_INCOMPLETE", "low", "Repository file listing was unavailable or truncated; test-coverage hints may be incomplete.", ["repository tree incomplete"]))
81
+
82
+ exposed_secrets = [file.path for file in files if _has_exposed_secret(file)]
83
+ if exposed_secrets:
84
+ flags.append(RiskFlag("EXPOSED_SECRET", "critical", "Adds a credential-like value in the changed code or configuration.", exposed_secrets))
73
85
 
74
- auth_files = [file.path for file in files if _matches(file.path, AUTH_PATTERNS)]
86
+ auth_files = [file.path for file in files if not _matches(file.path, TEST_PATTERNS) and _matches(file.path, AUTH_PATTERNS)]
75
87
  if auth_files:
76
- flags.append(RiskFlag("AUTH_OR_SECRET", "high", "Touches authentication, credentials, secrets, or security-sensitive files.", auth_files))
88
+ flags.append(RiskFlag("AUTH_OR_SECRET", "high", "Touches authentication, credentials, secrets, or security-sensitive code that deserves focused review.", auth_files))
77
89
 
78
90
  db_files = [file.path for file in files if _matches(file.path, DB_PATTERNS)]
79
91
  if db_files:
@@ -98,7 +110,7 @@ def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[s
98
110
  if _is_production_file(file.path) and not coverage:
99
111
  untested.append(file.path)
100
112
  if untested and tree_complete:
101
- flags.append(RiskFlag("NO_TEST_COVERAGE", "medium", "Changed production files have no obvious neighboring or repository test coverage.", untested[:50]))
113
+ flags.append(RiskFlag("NO_TEST_COVERAGE", "low", "Changed production files have no obvious neighboring or repository test coverage; this is a review hint, not a verdict gate.", untested[:50]))
102
114
 
103
115
  check_state = str(checks.get("state", "unknown"))
104
116
  if check_state == "not_applicable":
@@ -107,9 +119,9 @@ def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[s
107
119
  if check_state == "failure":
108
120
  flags.append(RiskFlag("CHECKS_FAILED", "critical", "One or more required status checks failed.", list(checks.get("failed", []))))
109
121
  elif check_state == "pending":
110
- flags.append(RiskFlag("CHECKS_PENDING", "low", "Required status checks are still pending.", list(checks.get("pending", [])) or [check_state]))
122
+ flags.append(RiskFlag("CHECKS_PENDING", "medium", "Required status checks are still pending.", list(checks.get("pending", [])) or [check_state]))
111
123
  elif check_state != "success":
112
- flags.append(RiskFlag("CHECKS_UNKNOWN", "medium", "Required status checks are missing, pending, or unavailable.", [check_state]))
124
+ flags.append(RiskFlag("CHECKS_UNKNOWN", "low", "Status checks are missing or unavailable; this is informational, not a failure.", [check_state]))
113
125
 
114
126
  return flags
115
127
 
@@ -120,21 +132,30 @@ def verdict_for(flags: list[RiskFlag], checks: dict[str, Any]) -> tuple[str, lis
120
132
  if "CHECKS_FAILED" in codes:
121
133
  reasoning.append("BLOCK because at least one status check failed.")
122
134
  return "BLOCK", reasoning
123
- if "AUTH_OR_SECRET" in codes:
124
- reasoning.append("BLOCK because the pull request touches authentication, credentials, secrets, or security-sensitive files.")
135
+ if "EXPOSED_SECRET" in codes:
136
+ reasoning.append("BLOCK because the pull request appears to add a credential-like value.")
125
137
  return "BLOCK", reasoning
138
+ if "AUTH_OR_SECRET" in codes:
139
+ reasoning.append("QUARANTINE because authentication or security-sensitive code changed and needs focused review.")
126
140
  if "DATABASE_CHANGE" in codes:
127
141
  reasoning.append("QUARANTINE because database schema or migration changes require an explicit review gate.")
128
142
  if "NEW_DEPENDENCY" in codes:
129
143
  reasoning.append("QUARANTINE because dependency changes expand the supply-chain and runtime surface.")
130
- if "NO_TEST_COVERAGE" in codes:
131
- reasoning.append("QUARANTINE because at least one changed production file lacks obvious test coverage.")
132
- if "CHECKS_UNKNOWN" in codes:
133
- reasoning.append("QUARANTINE because the pull request does not have a confirmed passing check result.")
134
144
  if "CHECKS_PENDING" in codes:
135
145
  reasoning.append("QUARANTINE because required checks are still running.")
136
146
  if reasoning:
137
147
  return "QUARANTINE", reasoning
148
+ observations: list[str] = []
149
+ if "NO_TEST_COVERAGE" in codes:
150
+ observations.append("no obvious test coverage was found for one or more production files")
151
+ if "CHECKS_UNKNOWN" in codes:
152
+ observations.append("status checks were unavailable")
153
+ if "REPOSITORY_TREE_INCOMPLETE" in codes:
154
+ observations.append("repository-wide test hints were incomplete")
155
+ if observations:
156
+ local_note = " local analysis has no CI checks." if str(checks.get("state")) == "not_applicable" else ""
157
+ reasoning.append(f"PASS with review notes: {'; '.join(observations)}.{local_note}")
158
+ return "PASS", reasoning
138
159
  if str(checks.get("state")) == "not_applicable":
139
160
  reasoning.append("PASS because no blocking or quarantine rule fired (local analysis has no CI checks).")
140
161
  else:
@@ -110,16 +110,18 @@ def test_model_output_is_redacted_before_validation():
110
110
  assert explanation.redactions >= 1
111
111
 
112
112
 
113
- def test_no_key_falls_back_without_calling_model(monkeypatch):
113
+ def test_no_key_creates_a_local_explanation(monkeypatch):
114
114
  monkeypatch.delenv("DIFFLY_LLM_API_KEY", raising=False)
115
115
  monkeypatch.delenv("OPENAI_API_KEY", raising=False)
116
116
  explanation = generate_explanation(result())
117
- assert explanation.explanation is None
118
- assert "No LLM API key configured" in explanation.error
117
+ assert explanation.explanation["narrative"]
118
+ assert explanation.source == "local"
119
+ assert "No AI API key" in explanation.warning
119
120
 
120
121
 
121
122
  def test_model_failure_is_safe():
122
123
  fake = FakeClient("not json")
123
124
  explanation = generate_explanation(result(), client=fake)
124
- assert explanation.explanation is None
125
- assert "failed safely" in explanation.error
125
+ assert explanation.explanation["narrative"]
126
+ assert explanation.source == "local"
127
+ assert "failed safely" in explanation.warning
@@ -44,12 +44,12 @@ def test_clean_tree_passes_locally(repo: Path) -> None:
44
44
  assert result.source.startswith("Local git")
45
45
 
46
46
 
47
- def test_working_tree_change_without_tests_quarantines(repo: Path) -> None:
47
+ def test_working_tree_change_without_tests_passes_with_a_review_note(repo: Path) -> None:
48
48
  (repo / "src" / "orphan_module.py").write_text("def lonely():\n return 1\n")
49
49
  result = build_local_result(str(repo))
50
50
  codes = {flag.code for flag in result.flags}
51
51
  assert "NO_TEST_COVERAGE" in codes
52
- assert result.verdict == "QUARANTINE"
52
+ assert result.verdict == "PASS"
53
53
  assert result.metadata.changed_files == 1
54
54
 
55
55
 
@@ -63,12 +63,12 @@ def test_untracked_files_are_included(repo: Path) -> None:
63
63
  assert new_file.touched_symbols == ["fresh"]
64
64
 
65
65
 
66
- def test_auth_change_blocks_locally(repo: Path) -> None:
66
+ def test_auth_change_quarantines_locally(repo: Path) -> None:
67
67
  (repo / "src" / "credentials.py").write_text("TOKEN = 'value'\n")
68
68
  result = build_local_result(str(repo))
69
69
  codes = {flag.code for flag in result.flags}
70
70
  assert "AUTH_OR_SECRET" in codes
71
- assert result.verdict == "BLOCK"
71
+ assert result.verdict == "QUARANTINE"
72
72
 
73
73
 
74
74
  def test_base_ref_compares_branch_commits_only(repo: Path) -> None:
@@ -198,6 +198,71 @@ def test_cli_exposes_interactive_and_diagnostics_commands():
198
198
  assert parser.parse_args(["setup"]).command == "setup"
199
199
 
200
200
 
201
+ def test_interactive_menu_keeps_the_generated_explanation_section():
202
+ import diffly_cli.cli as cli
203
+ from diffly_cli.explainer import ExplanationResult
204
+
205
+ sections = cli.interactive_sections(ExplanationResult({"intent": "example"}, 0, "gpt-5-mini"))
206
+
207
+ assert [key for key, _, _ in sections] == ["verdict", "checks", "risks", "files", "explain"]
208
+ assert sections[-1][1] == "Explanation"
209
+
210
+
211
+ def test_wizard_offers_a_local_explanation_when_no_key_is_configured(monkeypatch):
212
+ import diffly_cli.cli as cli
213
+
214
+ captured: dict[str, argparse.Namespace] = {}
215
+ monkeypatch.delenv("DIFFLY_LLM_API_KEY", raising=False)
216
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
217
+ monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
218
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True)
219
+ monkeypatch.setattr(cli, "_check_and_prompt_update", lambda: None)
220
+ monkeypatch.setattr(cli, "show_loading_screen", lambda message: None)
221
+ answers = iter(["acme/demo", "42"])
222
+ monkeypatch.setattr(cli.Prompt, "ask", staticmethod(lambda *a, **k: next(answers)))
223
+ monkeypatch.setattr(cli.Confirm, "ask", staticmethod(lambda *a, **k: True))
224
+
225
+ def fake_run_pr(args):
226
+ captured["args"] = args
227
+ return 0
228
+
229
+ monkeypatch.setattr(cli, "run_pr", fake_run_pr)
230
+
231
+ assert cli.run_wizard(build_parser()) == 0
232
+ assert captured["args"].explain is True
233
+
234
+
235
+ def test_pr_not_found_error_explains_how_to_recover(monkeypatch):
236
+ import diffly_cli.cli as cli
237
+ from rich.console import Console
238
+
239
+ recorded = Console(record=True, width=100)
240
+ monkeypatch.setattr(cli, "console", recorded)
241
+ cli.render_pr_error(cli.RepoRef("acme", "demo"), 42, GitHubError("GitHub API 404 for /repos/acme/demo/pulls/42"))
242
+
243
+ output = recorded.export_text()
244
+ assert "Pull request not found" in output
245
+ assert "acme/demo#42" in output
246
+ assert "paste the full pull-request URL" in output
247
+
248
+
249
+ def test_github_client_reuses_an_authenticated_gh_session(monkeypatch):
250
+ import diffly_cli.github as github
251
+
252
+ github.github_auth_token.cache_clear()
253
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
254
+
255
+ class Completed:
256
+ returncode = 0
257
+ stdout = "gho_example_token\n"
258
+
259
+ monkeypatch.setattr(github.subprocess, "run", lambda *args, **kwargs: Completed())
260
+ try:
261
+ assert github.GitHubClient().token == "gho_example_token"
262
+ finally:
263
+ github.github_auth_token.cache_clear()
264
+
265
+
201
266
  def test_zero_argument_invocation_uses_wizard(monkeypatch):
202
267
  import diffly_cli.cli as cli
203
268
 
@@ -19,12 +19,21 @@ def test_parse_hunks_tracks_ranges_and_lines():
19
19
  assert "+new" in hunks[0].lines
20
20
 
21
21
 
22
- def test_auth_change_blocks():
22
+ def test_auth_change_quarantines_for_focused_review():
23
23
  file = ChangedFile("src/auth.py", "modified", 1, 0, 1, "+def login():\n")
24
24
  flags = compute_flags(metadata(), [file], {"state": "success", "count": 1}, ["src/auth.py", "tests/test_auth.py"])
25
25
  verdict, _ = verdict_for(flags, {"state": "success"})
26
26
  assert "AUTH_OR_SECRET" in {flag.code for flag in flags}
27
+ assert verdict == "QUARANTINE"
28
+
29
+
30
+ def test_exposed_credential_blocks():
31
+ file = ChangedFile("src/settings.py", "modified", 1, 0, 1, "+API_KEY = 'ghp_abcdefghijklmnopqrstuvwxyz123456'\n")
32
+ flags = compute_flags(metadata(), [file], {"state": "success", "count": 1}, ["src/settings.py", "tests/test_settings.py"])
33
+ verdict, reasoning = verdict_for(flags, {"state": "success"})
34
+ assert "EXPOSED_SECRET" in {flag.code for flag in flags}
27
35
  assert verdict == "BLOCK"
36
+ assert "credential-like value" in reasoning[0]
28
37
 
29
38
 
30
39
  def test_dependency_and_missing_tests_quarantines():
@@ -47,6 +56,24 @@ def test_all_clear_ships():
47
56
  assert verdict == "PASS"
48
57
 
49
58
 
59
+ def test_missing_obvious_test_coverage_is_a_pass_with_review_note():
60
+ file = ChangedFile("src/new_module.py", "added", 3, 0, 3, "+def run():\n+ return 1\n")
61
+ flags = compute_flags(metadata(), [file], {"state": "success", "count": 1}, ["src/new_module.py"])
62
+ verdict, reasoning = verdict_for(flags, {"state": "success"})
63
+ assert "NO_TEST_COVERAGE" in {flag.code for flag in flags}
64
+ assert verdict == "PASS"
65
+ assert "review notes" in reasoning[0]
66
+
67
+
68
+ def test_unknown_checks_do_not_downgrade_an_otherwise_healthy_pr():
69
+ file = ChangedFile("tests/test_math.py", "modified", 1, 0, 1, "+def test_math():\n")
70
+ flags = compute_flags(metadata(), [file], {"state": "unknown", "count": 0}, ["tests/test_math.py"])
71
+ verdict, reasoning = verdict_for(flags, {"state": "unknown"})
72
+ assert "CHECKS_UNKNOWN" in {flag.code for flag in flags}
73
+ assert verdict == "PASS"
74
+ assert "status checks were unavailable" in reasoning[0]
75
+
76
+
50
77
  def test_pending_checks_quarantine():
51
78
  flags = compute_flags(metadata(), [], {"state": "pending", "count": 1}, [])
52
79
  verdict, reasoning = verdict_for(flags, {"state": "pending"})
Binary file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes