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.
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/CHANGELOG.md +19 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/PKG-INFO +10 -9
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/README.md +9 -8
- diffly_cli-1.0.0/assets/logo-v2.svg +35 -0
- diffly_cli-1.0.0/assets/logo.png +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/phase-2-contract.md +1 -1
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/pyproject.toml +1 -1
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/__init__.py +1 -1
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/cli.py +157 -55
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/explainer.py +67 -3
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/github.py +23 -1
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/triage.py +38 -17
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_explainer.py +7 -5
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_local.py +4 -4
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_regressions.py +65 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_triage.py +28 -1
- diffly_cli-0.4.0/assets/logo.png +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.github/workflows/ci.yml +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.github/workflows/example-diffly.yml +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.github/workflows/publish.yml +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/.gitignore +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/CONTRIBUTING.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/LICENSE +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/SECURITY.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/action/format_comment.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/action/post_comment.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/action.yml +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/diffly-cli-demo.gif +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/real-captures/kubernetes-141413.ansi +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/real-captures/ruff-27808.ansi +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/real-captures/vscode-330848.ansi +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/screenshots/kubernetes-141413.png +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/screenshots/ruff-27808.png +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/assets/screenshots/vscode-330848.png +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/kubernetes-141413-phase2.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/kubernetes-141413.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/ruff-27808-phase2.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/ruff-27808.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/demo/vscode-330848.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/audit-report.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/benchmarks.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/reddit-opensource.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/reddit-programming.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/show-hn.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/launch/x-thread.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/live-model-catalog.json +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/phase-0-research.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/docs/visual-verification.md +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/homebrew/diffly-cli.rb +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/install.sh +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/capture_real_sessions.sh +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/discover_benchmarks.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/render_real_captures.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/run_benchmarks.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/validate_action.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/scripts/write_benchmarks_doc.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/__main__.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/astmap.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/diffparse.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/local.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/models.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/redact.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/src/diffly_cli/update.py +0 -0
- {diffly_cli-0.4.0 → diffly_cli-1.0.0}/tests/test_action.py +0 -0
- {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.
|
|
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.
|
|
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-
|
|
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
|
|
286
|
-
| **QUARANTINE** |
|
|
287
|
-
| **PASS** | No rule fired and
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|
|
157
|
-
| **QUARANTINE** |
|
|
158
|
-
| **PASS** | No rule fired and
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
306
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
menu
|
|
326
|
-
menu.add_column("",
|
|
327
|
-
menu.add_column("
|
|
328
|
-
for index, (key, label) in enumerate(labels):
|
|
329
|
-
marker = "[cyan]›[/]" if index == cursor else " "
|
|
330
|
-
state = "[green]
|
|
331
|
-
menu.add_row(marker, f"{label
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"[
|
|
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:
|
|
653
|
-
console.print(center(Text.from_markup("[dim]
|
|
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]
|
|
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]
|
|
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
|
|
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]
|
|
676
|
-
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
|
|
679
|
-
|
|
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
|
|
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]
|
|
684
|
-
f"[dim]Mode: {'deterministic + explanation' if explain else 'deterministic'} · output
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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 "
|
|
124
|
-
reasoning.append("BLOCK because the pull request
|
|
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
|
|
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
|
|
118
|
-
assert
|
|
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
|
|
125
|
-
assert
|
|
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
|
|
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 == "
|
|
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
|
|
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 == "
|
|
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
|
|
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"})
|
diffly_cli-0.4.0/assets/logo.png
DELETED
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|