diffly-cli 0.4.0__py3-none-any.whl

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/models.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class PRMetadata:
9
+ owner: str
10
+ repo: str
11
+ number: int
12
+ title: str
13
+ body: str
14
+ state: str
15
+ author: str
16
+ base_ref: str
17
+ head_ref: str
18
+ base_sha: str
19
+ head_sha: str
20
+ mergeable_state: str
21
+ additions: int
22
+ deletions: int
23
+ changed_files: int
24
+ commits: int
25
+ html_url: str
26
+
27
+
28
+ @dataclass
29
+ class Hunk:
30
+ header: str
31
+ old_start: int
32
+ old_count: int
33
+ new_start: int
34
+ new_count: int
35
+ lines: list[str] = field(default_factory=list)
36
+
37
+
38
+ @dataclass
39
+ class ChangedFile:
40
+ path: str
41
+ status: str
42
+ additions: int
43
+ deletions: int
44
+ changes: int
45
+ patch: str = ""
46
+ hunks: list[Hunk] = field(default_factory=list)
47
+ touched_symbols: list[str] = field(default_factory=list)
48
+ callers: list[str] = field(default_factory=list)
49
+ tests_found: list[str] = field(default_factory=list)
50
+
51
+
52
+ @dataclass
53
+ class RiskFlag:
54
+ code: str
55
+ severity: str
56
+ message: str
57
+ evidence: list[str] = field(default_factory=list)
58
+
59
+
60
+ @dataclass
61
+ class TriageResult:
62
+ metadata: PRMetadata
63
+ files: list[ChangedFile]
64
+ flags: list[RiskFlag]
65
+ verdict: str
66
+ reasoning: list[str]
67
+ checks: dict[str, Any]
68
+ source: str
diffly_cli/redact.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class RedactionResult:
9
+ text: str
10
+ count: int
11
+ labels: tuple[str, ...]
12
+
13
+
14
+ _PATTERNS: tuple[tuple[str, re.Pattern[str], str], ...] = (
15
+ (
16
+ "private_key",
17
+ re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", re.DOTALL),
18
+ "[REDACTED_PRIVATE_KEY]",
19
+ ),
20
+ (
21
+ "github_token",
22
+ re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_\-]{20,}|github_pat_[A-Za-z0-9_\-]{20,})\b"),
23
+ "[REDACTED_GITHUB_TOKEN]",
24
+ ),
25
+ (
26
+ "aws_access_key",
27
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
28
+ "[REDACTED_AWS_ACCESS_KEY]",
29
+ ),
30
+ (
31
+ "bearer_token",
32
+ re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9_\-.=+/]{16,}"),
33
+ r"\1[REDACTED_BEARER_TOKEN]",
34
+ ),
35
+ (
36
+ "secret_assignment",
37
+ re.compile(
38
+ r"(?i)(\b(?:api[_-]?key|access[_-]?key|secret|password|passwd|token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*)([\"']?)[^\s,;\"'}]+(\2)"
39
+ ),
40
+ r"\1\2[REDACTED_SECRET]\3",
41
+ ),
42
+ (
43
+ "connection_string",
44
+ re.compile(r"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://[^\s\"']+"),
45
+ "[REDACTED_CONNECTION_STRING]",
46
+ ),
47
+ )
48
+
49
+
50
+ def redact_secrets(text: str) -> RedactionResult:
51
+ labels: list[str] = []
52
+ count = 0
53
+ redacted = text
54
+ for label, pattern, replacement in _PATTERNS:
55
+ redacted, substitutions = pattern.subn(replacement, redacted)
56
+ if substitutions:
57
+ count += substitutions
58
+ labels.extend([label] * substitutions)
59
+ return RedactionResult(redacted, count, tuple(labels))
diffly_cli/triage.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import re
5
+ from collections import defaultdict
6
+ from typing import Any
7
+
8
+ from .models import ChangedFile, PRMetadata, RiskFlag
9
+
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*"]
15
+ TEST_PATTERNS = ["test*", "tests*", "spec*", "*_test.*", "*.test.*", "*.spec.*"]
16
+ DEPENDENCY_FILES = {
17
+ "package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
18
+ "pyproject.toml", "poetry.lock", "requirements.txt", "requirements-dev.txt", "Pipfile", "Pipfile.lock",
19
+ "go.mod", "go.sum", "Cargo.toml", "Cargo.lock", "Gemfile", "Gemfile.lock", "pom.xml", "build.gradle",
20
+ }
21
+
22
+
23
+ def _matches(path: str, patterns: list[str]) -> bool:
24
+ lowered = path.lower()
25
+ return any(fnmatch.fnmatch(lowered, pattern.lower()) or fnmatch.fnmatch(lowered.split("/")[-1], pattern.lower()) for pattern in patterns)
26
+
27
+
28
+ def _is_production_file(path: str) -> bool:
29
+ lowered = path.lower()
30
+ parts = lowered.split("/")
31
+ if any(part in {"docs", "examples", "fixtures", "generated", "tests", "test"} for part in parts):
32
+ return False
33
+ if _matches(path, TEST_PATTERNS):
34
+ return False
35
+ return not lowered.endswith((".md", ".txt", ".json")) and lowered != "install.sh"
36
+
37
+
38
+ def _added_dependency_names(file: ChangedFile) -> list[str]:
39
+ if file.path.rsplit("/", 1)[-1] not in DEPENDENCY_FILES:
40
+ return []
41
+ values: list[str] = []
42
+ for line in file.patch.splitlines():
43
+ if line.startswith("+") and not line.startswith("+++"):
44
+ match = re.search(r"[\"']([@A-Za-z0-9_./-]+)[\"']\s*[:=]", line)
45
+ if match:
46
+ values.append(match.group(1))
47
+ elif re.search(r"^[+]\s*[A-Za-z0-9_.-]+[=<>~]", line):
48
+ values.append(line[1:].strip().split()[0])
49
+ return sorted(dict.fromkeys(values))
50
+
51
+
52
+ def _test_files(files: list[ChangedFile]) -> list[str]:
53
+ return [file.path for file in files if _matches(file.path, TEST_PATTERNS)]
54
+
55
+
56
+ def _covered_by_test(file: ChangedFile, test_paths: list[str]) -> list[str]:
57
+ stem = file.path.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
58
+ matches = []
59
+ for test_path in test_paths:
60
+ name = test_path.rsplit("/", 1)[-1].lower()
61
+ if stem in name or file.path.rsplit("/", 1)[0].lower() in test_path.lower():
62
+ matches.append(test_path)
63
+ return matches
64
+
65
+
66
+ def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[str, Any], repo_paths: list[str] | None = None) -> list[RiskFlag]:
67
+ flags: list[RiskFlag] = []
68
+ tree_complete = bool(checks.get("repository_tree_complete", repo_paths is not None))
69
+ test_paths = [path for path in (repo_paths or []) if _matches(path, TEST_PATTERNS)] if tree_complete else []
70
+ changed_test_paths = _test_files(files)
71
+ 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"]))
73
+
74
+ auth_files = [file.path for file in files if _matches(file.path, AUTH_PATTERNS)]
75
+ if auth_files:
76
+ flags.append(RiskFlag("AUTH_OR_SECRET", "high", "Touches authentication, credentials, secrets, or security-sensitive files.", auth_files))
77
+
78
+ db_files = [file.path for file in files if _matches(file.path, DB_PATTERNS)]
79
+ if db_files:
80
+ flags.append(RiskFlag("DATABASE_CHANGE", "high", "Touches database schema, models, migrations, or SQL.", db_files))
81
+
82
+ dependency_evidence: list[str] = []
83
+ for file in files:
84
+ names = _added_dependency_names(file)
85
+ if names:
86
+ dependency_evidence.append(f"{file.path}: {', '.join(names)}")
87
+ elif file.path.rsplit("/", 1)[-1] in DEPENDENCY_FILES and file.additions > 0:
88
+ dependency_evidence.append(file.path)
89
+ if dependency_evidence:
90
+ flags.append(RiskFlag("NEW_DEPENDENCY", "medium", "Adds or changes a dependency manifest or lockfile.", dependency_evidence))
91
+
92
+ untested: list[str] = []
93
+ for file in files:
94
+ if _matches(file.path, TEST_PATTERNS):
95
+ continue
96
+ coverage = _covered_by_test(file, test_paths + changed_test_paths)
97
+ file.tests_found = coverage
98
+ if _is_production_file(file.path) and not coverage:
99
+ untested.append(file.path)
100
+ 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]))
102
+
103
+ check_state = str(checks.get("state", "unknown"))
104
+ if check_state == "not_applicable":
105
+ # Local mode has no CI; skip check-derived flags entirely.
106
+ return flags
107
+ if check_state == "failure":
108
+ flags.append(RiskFlag("CHECKS_FAILED", "critical", "One or more required status checks failed.", list(checks.get("failed", []))))
109
+ elif check_state == "pending":
110
+ flags.append(RiskFlag("CHECKS_PENDING", "low", "Required status checks are still pending.", list(checks.get("pending", [])) or [check_state]))
111
+ elif check_state != "success":
112
+ flags.append(RiskFlag("CHECKS_UNKNOWN", "medium", "Required status checks are missing, pending, or unavailable.", [check_state]))
113
+
114
+ return flags
115
+
116
+
117
+ def verdict_for(flags: list[RiskFlag], checks: dict[str, Any]) -> tuple[str, list[str]]:
118
+ codes = {flag.code for flag in flags}
119
+ reasoning: list[str] = []
120
+ if "CHECKS_FAILED" in codes:
121
+ reasoning.append("BLOCK because at least one status check failed.")
122
+ 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.")
125
+ return "BLOCK", reasoning
126
+ if "DATABASE_CHANGE" in codes:
127
+ reasoning.append("QUARANTINE because database schema or migration changes require an explicit review gate.")
128
+ if "NEW_DEPENDENCY" in codes:
129
+ 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
+ if "CHECKS_PENDING" in codes:
135
+ reasoning.append("QUARANTINE because required checks are still running.")
136
+ if reasoning:
137
+ return "QUARANTINE", reasoning
138
+ if str(checks.get("state")) == "not_applicable":
139
+ reasoning.append("PASS because no blocking or quarantine rule fired (local analysis has no CI checks).")
140
+ else:
141
+ reasoning.append("PASS because no blocking or quarantine rule fired and all observed checks passed.")
142
+ return "PASS", reasoning
diffly_cli/update.py ADDED
@@ -0,0 +1,161 @@
1
+ """Check for and install updates to diffly-cli.
2
+
3
+ Version checks use the PyPI JSON API so the lookup is fast and does not
4
+ require git or GitHub API authentication. The actual upgrade command is
5
+ chosen based on how diffly was originally installed:
6
+
7
+ - ``curl | install.sh`` → re-runs the install script
8
+ - ``brew install`` → runs ``brew upgrade diffly-cli``
9
+ - ``pip install`` → runs ``pip install --upgrade diffly-cli``
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import __version__
23
+
24
+ CONFIG_DIR = Path.home() / ".diffly"
25
+ CONFIG_FILE = CONFIG_DIR / "config.json"
26
+ PYPI_URL = "https://pypi.org/pypi/diffly-cli/json"
27
+ INSTALL_SCRIPT_URL = (
28
+ "https://raw.githubusercontent.com/VIVAAN-DHAWAN/diffly-cli/main/install.sh"
29
+ )
30
+ INSTALLER_VENV = Path.home() / ".local" / "share" / "diffly-cli"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Version helpers
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def _parse_version(version_str: str) -> tuple[int, ...]:
38
+ """Parse a version string like '0.3.0' into a comparable tuple."""
39
+ return tuple(int(part) for part in version_str.split(".") if part.isdigit())
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Config persistence (~/.diffly/config.json)
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _load_config() -> dict[str, Any]:
47
+ """Load the diffly user configuration, returning defaults when absent."""
48
+ if not CONFIG_FILE.exists():
49
+ return {}
50
+ try:
51
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
52
+ except (json.JSONDecodeError, OSError):
53
+ return {}
54
+
55
+
56
+ def _save_config(config: dict[str, Any]) -> None:
57
+ """Persist diffly user configuration to disk."""
58
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
59
+ CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
60
+
61
+
62
+ def get_update_preference() -> str | None:
63
+ """Return the stored update preference: 'auto', 'manual', or None if never set."""
64
+ return _load_config().get("update_preference")
65
+
66
+
67
+ def set_update_preference(preference: str) -> None:
68
+ """Persist the user's update preference ('auto' or 'manual')."""
69
+ config = _load_config()
70
+ config["update_preference"] = preference
71
+ _save_config(config)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # PyPI lookup
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def _fetch_pypi_info() -> dict[str, Any] | None:
79
+ """Fetch the latest release metadata from PyPI."""
80
+ import urllib.request
81
+ import urllib.error
82
+
83
+ try:
84
+ req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"})
85
+ with urllib.request.urlopen(req, timeout=5) as resp:
86
+ return json.loads(resp.read().decode("utf-8"))
87
+ except (urllib.error.URLError, OSError, json.JSONDecodeError):
88
+ return None
89
+
90
+
91
+ def check_for_update() -> str | None:
92
+ """Return the latest version string if a newer release exists on PyPI.
93
+
94
+ Returns ``None`` when the installed version is current or when the PyPI
95
+ lookup cannot be completed (network error, timeout, etc.).
96
+ """
97
+ info = _fetch_pypi_info()
98
+ if info is None:
99
+ return None
100
+ try:
101
+ latest = info["info"]["version"]
102
+ except (KeyError, TypeError):
103
+ return None
104
+ if _parse_version(latest) > _parse_version(__version__):
105
+ return latest
106
+ return None
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Installation detection
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def _detect_install_method() -> str:
114
+ """Return 'installer', 'brew', or 'pip' based on how diffly was installed."""
115
+ if INSTALLER_VENV.is_dir():
116
+ return "installer"
117
+ if shutil.which("brew") is not None:
118
+ try:
119
+ result = subprocess.run(
120
+ ["brew", "list", "diffly-cli"],
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ )
125
+ if result.returncode == 0:
126
+ return "brew"
127
+ except (subprocess.TimeoutExpired, OSError):
128
+ pass
129
+ return "pip"
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Update installation
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def install_update() -> bool:
137
+ """Upgrade diffly using the same method it was originally installed with.
138
+
139
+ Detects whether diffly was installed via the curl installer script,
140
+ Homebrew, or pip, and runs the matching upgrade command.
141
+ """
142
+ method = _detect_install_method()
143
+
144
+ if method == "installer":
145
+ cmd = f"curl -fsSL {INSTALL_SCRIPT_URL} | sh"
146
+ args = ["sh", "-c", cmd]
147
+ elif method == "brew":
148
+ args = ["brew", "upgrade", "diffly-cli"]
149
+ else:
150
+ args = [sys.executable, "-m", "pip", "install", "--upgrade", "diffly-cli"]
151
+
152
+ try:
153
+ result = subprocess.run(
154
+ args,
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=120,
158
+ )
159
+ return result.returncode == 0
160
+ except (subprocess.TimeoutExpired, OSError):
161
+ return False