gitrelevance 0.1.2__tar.gz → 0.1.4__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.
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/PKG-INFO +6 -6
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/README.md +1 -1
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/pyproject.toml +5 -5
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/matcher.py +31 -1
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/cli/commands.py +35 -5
- gitrelevance-0.1.4/src/gitrelevance/config.py +56 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/files.py +66 -26
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/history.py +21 -10
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/repository.py +13 -2
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/cli/test_commands.py +30 -0
- gitrelevance-0.1.2/src/gitrelevance/config.py +0 -27
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/.github/workflows/ci.yml +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/.gitignore +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/LICENSE +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/pytest +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/classifier.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/confidence.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/current_state.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/engine.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/evidence.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/cli/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/commits.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/issues/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/issues/models.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/models.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/output/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/output/json.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/output/terminal.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/providers/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/providers/base.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/providers/github.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_classifier.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_confidence.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_current_state.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_evidence.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_explainability.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_matcher.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/fixtures/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/fixtures/repo_builder.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/test_files.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/test_history.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/test_repository.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/output/test_json.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/output/test_terminal.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/providers/test_github.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/__init__.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/conftest.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_cross_contamination.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_multiple_prs.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_obsolete.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_renamed_not_deleted.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_resolved.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_reverted.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_still_relevant.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_unknown.py +0 -0
- {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenarios.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitrelevance
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Analyze whether historical GitHub issues are still relevant to a codebase, using Git history as evidence
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
8
|
-
Author:
|
|
5
|
+
Project-URL: Homepage, https://github.com/Aditya30ag/gitrelevance
|
|
6
|
+
Project-URL: Repository, https://github.com/Aditya30ag/gitrelevance
|
|
7
|
+
Project-URL: Issues, https://github.com/Aditya30ag/gitrelevance/issues
|
|
8
|
+
Author: Aditya30ag
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -66,7 +66,7 @@ pip install gitrelevance
|
|
|
66
66
|
|
|
67
67
|
For development:
|
|
68
68
|
```bash
|
|
69
|
-
git clone https://github.com/
|
|
69
|
+
git clone https://github.com/Aditya30ag/gitrelevance.git
|
|
70
70
|
cd gitrelevance
|
|
71
71
|
pip install -e ".[dev]"
|
|
72
72
|
```
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitrelevance"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "Analyze whether historical GitHub issues are still relevant to a codebase, using Git history as evidence"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
authors = [
|
|
13
|
-
{ name = "
|
|
13
|
+
{ name = "Aditya30ag" },
|
|
14
14
|
]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
@@ -46,9 +46,9 @@ dev = [
|
|
|
46
46
|
gitrelevance = "gitrelevance.cli.commands:app"
|
|
47
47
|
|
|
48
48
|
[project.urls]
|
|
49
|
-
Homepage = "https://github.com/
|
|
50
|
-
Repository = "https://github.com/
|
|
51
|
-
Issues = "https://github.com/
|
|
49
|
+
Homepage = "https://github.com/Aditya30ag/gitrelevance"
|
|
50
|
+
Repository = "https://github.com/Aditya30ag/gitrelevance"
|
|
51
|
+
Issues = "https://github.com/Aditya30ag/gitrelevance/issues"
|
|
52
52
|
|
|
53
53
|
[tool.hatch.build.targets.wheel]
|
|
54
54
|
packages = ["src/gitrelevance"]
|
|
@@ -188,6 +188,9 @@ def build_all_match_sets(
|
|
|
188
188
|
) -> dict[int, MatchSet]:
|
|
189
189
|
"""Build match sets for all given issues, fetching pull requests only once.
|
|
190
190
|
|
|
191
|
+
Pre-indexes pull requests by closing issue numbers and PR numbers to correlate
|
|
192
|
+
issues in O(I + P) time rather than O(I * P).
|
|
193
|
+
|
|
191
194
|
Args:
|
|
192
195
|
issues: List of issues to correlate.
|
|
193
196
|
repo: The local Git repository wrapper.
|
|
@@ -197,4 +200,31 @@ def build_all_match_sets(
|
|
|
197
200
|
Dictionary mapping issue number to its corresponding MatchSet.
|
|
198
201
|
"""
|
|
199
202
|
prs = provider.get_pull_requests(state="all")
|
|
200
|
-
|
|
203
|
+
|
|
204
|
+
# Invert PR index for O(1) issue-to-PR lookups
|
|
205
|
+
issue_to_closing_prs: dict[int, list[PullRequest]] = {}
|
|
206
|
+
pr_by_number: dict[int, PullRequest] = {}
|
|
207
|
+
for pr in prs:
|
|
208
|
+
pr_by_number[pr.number] = pr
|
|
209
|
+
for issue_num in pr.closes_issue_numbers:
|
|
210
|
+
issue_to_closing_prs.setdefault(issue_num, []).append(pr)
|
|
211
|
+
|
|
212
|
+
match_sets: dict[int, MatchSet] = {}
|
|
213
|
+
for issue in issues:
|
|
214
|
+
# Collect candidate PRs for this issue
|
|
215
|
+
candidate_prs: list[PullRequest] = []
|
|
216
|
+
seen_cand_nums: set[int] = set()
|
|
217
|
+
|
|
218
|
+
for pr in issue_to_closing_prs.get(issue.number, []):
|
|
219
|
+
if pr.number not in seen_cand_nums:
|
|
220
|
+
seen_cand_nums.add(pr.number)
|
|
221
|
+
candidate_prs.append(pr)
|
|
222
|
+
|
|
223
|
+
for pr_num in issue.linked_pr_numbers:
|
|
224
|
+
if pr_num in pr_by_number and pr_num not in seen_cand_nums:
|
|
225
|
+
seen_cand_nums.add(pr_num)
|
|
226
|
+
candidate_prs.append(pr_by_number[pr_num])
|
|
227
|
+
|
|
228
|
+
match_sets[issue.number] = build_match_set(issue, repo, candidate_prs)
|
|
229
|
+
|
|
230
|
+
return match_sets
|
|
@@ -75,6 +75,13 @@ def analyze(
|
|
|
75
75
|
"--since",
|
|
76
76
|
help="Filter issues created on or after date (YYYY-MM-DD).",
|
|
77
77
|
),
|
|
78
|
+
token: Optional[str] = typer.Option(
|
|
79
|
+
None,
|
|
80
|
+
"--token",
|
|
81
|
+
"-t",
|
|
82
|
+
"--api-key",
|
|
83
|
+
help="GitHub Personal Access Token or API key for authenticated requests.",
|
|
84
|
+
),
|
|
78
85
|
json_output: bool = typer.Option(
|
|
79
86
|
False,
|
|
80
87
|
"--json",
|
|
@@ -122,18 +129,18 @@ def analyze(
|
|
|
122
129
|
owner, repo_name = parsed
|
|
123
130
|
|
|
124
131
|
# 3. Construct provider
|
|
125
|
-
|
|
132
|
+
resolved_token = token or config.load_github_token()
|
|
126
133
|
if _provider_override is not None:
|
|
127
134
|
provider = _provider_override
|
|
128
135
|
elif _provider_factory is not None:
|
|
129
|
-
provider = _provider_factory(owner, repo_name,
|
|
136
|
+
provider = _provider_factory(owner, repo_name, resolved_token)
|
|
130
137
|
else:
|
|
131
|
-
if not
|
|
138
|
+
if not resolved_token and not json_output:
|
|
132
139
|
err_console.print(
|
|
133
|
-
"[yellow]Note:[/yellow] Running in unauthenticated mode (GITHUB_TOKEN not set). "
|
|
140
|
+
"[yellow]Note:[/yellow] Running in unauthenticated mode (GITHUB_TOKEN / --token not set). "
|
|
134
141
|
"GitHub API rate limit is 60 requests/hour."
|
|
135
142
|
)
|
|
136
|
-
provider = GitHubProvider(owner=owner, repo=repo_name, token=
|
|
143
|
+
provider = GitHubProvider(owner=owner, repo=repo_name, token=resolved_token)
|
|
137
144
|
|
|
138
145
|
# Validate state option
|
|
139
146
|
state_normalized = state.lower()
|
|
@@ -253,6 +260,29 @@ def analyze(
|
|
|
253
260
|
raise typer.Exit(code=1)
|
|
254
261
|
|
|
255
262
|
|
|
263
|
+
@app.command(name="auth")
|
|
264
|
+
def auth(
|
|
265
|
+
token: Optional[str] = typer.Option(
|
|
266
|
+
None,
|
|
267
|
+
"--token",
|
|
268
|
+
"-t",
|
|
269
|
+
"--api-key",
|
|
270
|
+
help="GitHub Personal Access Token or API key to store.",
|
|
271
|
+
),
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Configure and store GitHub Personal Access Token / API key."""
|
|
274
|
+
console = Console()
|
|
275
|
+
if not token:
|
|
276
|
+
token = typer.prompt("Enter your GitHub Personal Access Token", hide_input=True)
|
|
277
|
+
|
|
278
|
+
if token and token.strip():
|
|
279
|
+
saved_path = config.save_github_token(token.strip())
|
|
280
|
+
console.print(f"[green]✓ Token saved successfully to {saved_path}[/green]")
|
|
281
|
+
else:
|
|
282
|
+
console.print("[red]Error: Token cannot be empty.[/red]")
|
|
283
|
+
raise typer.Exit(code=1)
|
|
284
|
+
|
|
285
|
+
|
|
256
286
|
def main() -> None:
|
|
257
287
|
"""CLI entry point function."""
|
|
258
288
|
app()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configuration and environment loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".gitrelevance"
|
|
9
|
+
TOKEN_FILE = CONFIG_DIR / "token"
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from dotenv import load_dotenv # type: ignore[import-not-found]
|
|
13
|
+
|
|
14
|
+
# Try loading .env if python-dotenv is installed
|
|
15
|
+
load_dotenv()
|
|
16
|
+
except ImportError:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_github_token() -> str | None:
|
|
21
|
+
"""Load GitHub Personal Access Token or API key.
|
|
22
|
+
|
|
23
|
+
Checks environment variables (GITHUB_TOKEN, GH_TOKEN, GITHUB_API_KEY)
|
|
24
|
+
and falls back to persistent token file (~/.gitrelevance/token).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The token string if found, None otherwise.
|
|
28
|
+
"""
|
|
29
|
+
for var in ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_API_KEY"):
|
|
30
|
+
token = os.getenv(var)
|
|
31
|
+
if token and token.strip():
|
|
32
|
+
return token.strip()
|
|
33
|
+
|
|
34
|
+
if TOKEN_FILE.is_file():
|
|
35
|
+
try:
|
|
36
|
+
content = TOKEN_FILE.read_text(encoding="utf-8").strip()
|
|
37
|
+
if content:
|
|
38
|
+
return content
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_github_token(token: str) -> Path:
|
|
46
|
+
"""Save GitHub Personal Access Token or API key to user config directory.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
token: The token/API key string to save.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Path to the saved token file.
|
|
53
|
+
"""
|
|
54
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
TOKEN_FILE.write_text(token.strip(), encoding="utf-8")
|
|
56
|
+
return TOKEN_FILE
|
|
@@ -46,6 +46,54 @@ class FileOperations:
|
|
|
46
46
|
"""Access the underlying GitPython repo object."""
|
|
47
47
|
return self._repo._repo # type: ignore[return-value]
|
|
48
48
|
|
|
49
|
+
def _get_head_files(self) -> set[str]:
|
|
50
|
+
"""Get or compute cached set of all file paths present at HEAD."""
|
|
51
|
+
if not hasattr(self._repo, "_head_files_cache"):
|
|
52
|
+
head_files: set[str] = set()
|
|
53
|
+
try:
|
|
54
|
+
head_commit = self._git_repo.head.commit
|
|
55
|
+
for item in head_commit.tree.traverse():
|
|
56
|
+
if item.type == "blob":
|
|
57
|
+
head_files.add(item.path)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
setattr(self._repo, "_head_files_cache", head_files)
|
|
61
|
+
return getattr(self._repo, "_head_files_cache")
|
|
62
|
+
|
|
63
|
+
def _get_rename_index(self) -> dict[str, list[tuple[str, str]]]:
|
|
64
|
+
"""Get or compute cached repository-wide rename index."""
|
|
65
|
+
if not hasattr(self._repo, "_rename_index_cache"):
|
|
66
|
+
rename_index: dict[str, list[tuple[str, str]]] = {}
|
|
67
|
+
all_renames: list[tuple[str, str]] = []
|
|
68
|
+
try:
|
|
69
|
+
output: str = self._git_repo.git.log(
|
|
70
|
+
"-M",
|
|
71
|
+
"--diff-filter=R",
|
|
72
|
+
"--name-status",
|
|
73
|
+
"--format=",
|
|
74
|
+
)
|
|
75
|
+
for line in output.splitlines():
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if not line or not line.startswith("R"):
|
|
78
|
+
continue
|
|
79
|
+
parts = line.split("\t")
|
|
80
|
+
if len(parts) >= 3:
|
|
81
|
+
old, new = parts[1].strip(), parts[2].strip()
|
|
82
|
+
all_renames.append((old, new))
|
|
83
|
+
except gitmodule.GitCommandError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# git log returns newest-first; reverse to get chronological order
|
|
87
|
+
all_renames.reverse()
|
|
88
|
+
|
|
89
|
+
for old, new in all_renames:
|
|
90
|
+
rename_index.setdefault(old, []).append((old, new))
|
|
91
|
+
if new != old:
|
|
92
|
+
rename_index.setdefault(new, []).append((old, new))
|
|
93
|
+
|
|
94
|
+
setattr(self._repo, "_rename_index_cache", rename_index)
|
|
95
|
+
return getattr(self._repo, "_rename_index_cache")
|
|
96
|
+
|
|
49
97
|
def file_exists_at_head(self, path: str) -> bool:
|
|
50
98
|
"""Check if a file exists at HEAD.
|
|
51
99
|
|
|
@@ -55,6 +103,9 @@ class FileOperations:
|
|
|
55
103
|
Returns:
|
|
56
104
|
True if the file exists at HEAD
|
|
57
105
|
"""
|
|
106
|
+
head_files = self._get_head_files()
|
|
107
|
+
if head_files:
|
|
108
|
+
return path in head_files
|
|
58
109
|
try:
|
|
59
110
|
self._git_repo.head.commit.tree[path]
|
|
60
111
|
return True
|
|
@@ -111,6 +162,14 @@ class FileOperations:
|
|
|
111
162
|
if self.file_exists_at_head(path):
|
|
112
163
|
return False
|
|
113
164
|
|
|
165
|
+
deleted_cache = getattr(self._repo, "_was_deleted_cache", None)
|
|
166
|
+
if deleted_cache is None:
|
|
167
|
+
deleted_cache = {}
|
|
168
|
+
setattr(self._repo, "_was_deleted_cache", deleted_cache)
|
|
169
|
+
|
|
170
|
+
if path in deleted_cache:
|
|
171
|
+
return deleted_cache[path]
|
|
172
|
+
|
|
114
173
|
# Check whether the path ever appeared in commit history
|
|
115
174
|
try:
|
|
116
175
|
output: str = self._git_repo.git.log(
|
|
@@ -122,6 +181,7 @@ class FileOperations:
|
|
|
122
181
|
)
|
|
123
182
|
# If git log --diff-filter=D finds anything, the file was deleted
|
|
124
183
|
if output.strip():
|
|
184
|
+
deleted_cache[path] = True
|
|
125
185
|
return True
|
|
126
186
|
# Also check if it was ever added at all (it might be renamed out)
|
|
127
187
|
output2: str = self._git_repo.git.log(
|
|
@@ -131,8 +191,11 @@ class FileOperations:
|
|
|
131
191
|
"--",
|
|
132
192
|
path,
|
|
133
193
|
)
|
|
134
|
-
|
|
194
|
+
res = bool(output2.strip())
|
|
195
|
+
deleted_cache[path] = res
|
|
196
|
+
return res
|
|
135
197
|
except Exception:
|
|
198
|
+
deleted_cache[path] = False
|
|
136
199
|
return False
|
|
137
200
|
|
|
138
201
|
def find_renames(self, path: str) -> list[tuple[str, str]]:
|
|
@@ -145,31 +208,8 @@ class FileOperations:
|
|
|
145
208
|
List of (old_path, new_path) tuples showing the rename chain,
|
|
146
209
|
in chronological order (oldest rename first).
|
|
147
210
|
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
output: str = self._git_repo.git.log(
|
|
151
|
-
"-M",
|
|
152
|
-
"--diff-filter=R",
|
|
153
|
-
"--name-status",
|
|
154
|
-
"--format=",
|
|
155
|
-
)
|
|
156
|
-
except gitmodule.GitCommandError:
|
|
157
|
-
return []
|
|
158
|
-
|
|
159
|
-
renames: list[tuple[str, str]] = []
|
|
160
|
-
for line in output.splitlines():
|
|
161
|
-
line = line.strip()
|
|
162
|
-
if not line or not line.startswith("R"):
|
|
163
|
-
continue
|
|
164
|
-
parts = line.split("\t")
|
|
165
|
-
if len(parts) >= 3:
|
|
166
|
-
old, new = parts[1].strip(), parts[2].strip()
|
|
167
|
-
if path == old or path == new:
|
|
168
|
-
renames.append((old, new))
|
|
169
|
-
|
|
170
|
-
# git log returns newest-first; reverse to get chronological order
|
|
171
|
-
renames.reverse()
|
|
172
|
-
return renames
|
|
211
|
+
index = self._get_rename_index()
|
|
212
|
+
return list(index.get(path, []))
|
|
173
213
|
|
|
174
214
|
|
|
175
215
|
# Convenience function to get file operations for a repository
|
|
@@ -11,6 +11,7 @@ from gitrelevance.git.repository import GitRepository
|
|
|
11
11
|
|
|
12
12
|
# Cache key for the commit reference index on GitRepository instances
|
|
13
13
|
_INDEX_CACHE_KEY = "_gitrelevance_commit_reference_index"
|
|
14
|
+
_REVERT_INDEX_CACHE_KEY = "_gitrelevance_revert_index"
|
|
14
15
|
|
|
15
16
|
# ---------------------------------------------------------------------------
|
|
16
17
|
# Issue-number extraction helpers (private)
|
|
@@ -22,6 +23,9 @@ _HASH_ISSUE_RE = re.compile(r"#(\d+)")
|
|
|
22
23
|
# Matches GH-123-style references (unambiguous, never a PR number)
|
|
23
24
|
_GH_ISSUE_RE = re.compile(r"GH-(\d+)", re.IGNORECASE)
|
|
24
25
|
|
|
26
|
+
# Matches standard git revert trailer: "This reverts commit <sha>."
|
|
27
|
+
_REVERT_TRAILER_RE = re.compile(r"This reverts commit ([0-9a-fA-F]{40})\.")
|
|
28
|
+
|
|
25
29
|
# Detects PR-number contexts like "pull request #42" or "PR #42".
|
|
26
30
|
# We check whether the 30 characters *before* the # match this pattern.
|
|
27
31
|
_PR_CONTEXT_RE = re.compile(r"(?:pull\s+request|PR)\s+$", re.IGNORECASE)
|
|
@@ -89,6 +93,8 @@ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]
|
|
|
89
93
|
``pull request #42``, ``PR #42``) are explicitly excluded to prevent
|
|
90
94
|
evidence cross-contamination.
|
|
91
95
|
|
|
96
|
+
Also populates the revert index mapping reverted SHAs to revert commits.
|
|
97
|
+
|
|
92
98
|
Args:
|
|
93
99
|
repo: The GitRepository to build the index for
|
|
94
100
|
|
|
@@ -99,8 +105,9 @@ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]
|
|
|
99
105
|
if hasattr(repo, _INDEX_CACHE_KEY):
|
|
100
106
|
return getattr(repo, _INDEX_CACHE_KEY)
|
|
101
107
|
|
|
102
|
-
# Build the
|
|
108
|
+
# Build the indices by walking commits once
|
|
103
109
|
index: dict[int, list[Commit]] = {}
|
|
110
|
+
revert_index: dict[str, list[Commit]] = {}
|
|
104
111
|
|
|
105
112
|
for commit in repo.commits_since(None):
|
|
106
113
|
issue_nums = _extract_issue_numbers(commit.message)
|
|
@@ -109,8 +116,15 @@ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]
|
|
|
109
116
|
index[num] = []
|
|
110
117
|
index[num].append(commit)
|
|
111
118
|
|
|
112
|
-
|
|
119
|
+
for match in _REVERT_TRAILER_RE.finditer(commit.message):
|
|
120
|
+
reverted_sha = match.group(1)
|
|
121
|
+
if reverted_sha not in revert_index:
|
|
122
|
+
revert_index[reverted_sha] = []
|
|
123
|
+
revert_index[reverted_sha].append(commit)
|
|
124
|
+
|
|
125
|
+
# Cache the indices on the repo instance
|
|
113
126
|
setattr(repo, _INDEX_CACHE_KEY, index)
|
|
127
|
+
setattr(repo, _REVERT_INDEX_CACHE_KEY, revert_index)
|
|
114
128
|
|
|
115
129
|
return index
|
|
116
130
|
|
|
@@ -186,15 +200,12 @@ class GitNativeRevertDetector:
|
|
|
186
200
|
return []
|
|
187
201
|
full_sha = target_commit.sha
|
|
188
202
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
# where <sha> is the full 40-character hex SHA.
|
|
194
|
-
if f"This reverts commit {full_sha}." in commit.message:
|
|
195
|
-
reverts.append(commit)
|
|
203
|
+
revert_index = getattr(repo, _REVERT_INDEX_CACHE_KEY, None)
|
|
204
|
+
if revert_index is None:
|
|
205
|
+
build_commit_reference_index(repo)
|
|
206
|
+
revert_index = getattr(repo, _REVERT_INDEX_CACHE_KEY, {})
|
|
196
207
|
|
|
197
|
-
return
|
|
208
|
+
return list(revert_index.get(full_sha, []))
|
|
198
209
|
|
|
199
210
|
|
|
200
211
|
# Default detector instance (module-level singleton)
|
|
@@ -47,6 +47,7 @@ class GitRepository:
|
|
|
47
47
|
try:
|
|
48
48
|
self._repo = git.Repo(path, search_parent_directories=True)
|
|
49
49
|
self.path = str(self._repo.working_dir)
|
|
50
|
+
self._ancestor_cache: dict[tuple[str, str], bool] = {}
|
|
50
51
|
except (InvalidGitRepositoryError, NoSuchPathError, git.exc.GitError) as e:
|
|
51
52
|
raise NotAGitRepositoryError(path) from e
|
|
52
53
|
|
|
@@ -133,12 +134,22 @@ class GitRepository:
|
|
|
133
134
|
Raises:
|
|
134
135
|
git.GitCommandError: If either commit doesn't exist
|
|
135
136
|
"""
|
|
137
|
+
cache = getattr(self, "_ancestor_cache", None)
|
|
138
|
+
if cache is not None:
|
|
139
|
+
cached = cache.get((ancestor_sha, descendant_sha))
|
|
140
|
+
if cached is not None:
|
|
141
|
+
return cached
|
|
142
|
+
|
|
136
143
|
# Use git merge-base --is-ancestor which returns 0 if true, 1 if false
|
|
137
144
|
try:
|
|
138
145
|
self._repo.git.merge_base("--is-ancestor", ancestor_sha, descendant_sha)
|
|
139
|
-
|
|
146
|
+
res = True # git command succeeded, so it is an ancestor
|
|
140
147
|
except git.GitCommandError:
|
|
141
|
-
|
|
148
|
+
res = False # exit code 1 means not an ancestor
|
|
149
|
+
|
|
150
|
+
if cache is not None:
|
|
151
|
+
cache[(ancestor_sha, descendant_sha)] = res
|
|
152
|
+
return res
|
|
142
153
|
|
|
143
154
|
def commits_since(self, since: datetime | None = None) -> Iterator[Commit]:
|
|
144
155
|
"""Get commits since a given datetime.
|
|
@@ -160,5 +160,35 @@ def test_cli_since_filter() -> None:
|
|
|
160
160
|
data = json.loads(result.output)
|
|
161
161
|
assert data["total_issues"] == 1
|
|
162
162
|
assert data["results"][0]["issue"]["number"] == 2
|
|
163
|
+
finally:
|
|
164
|
+
builder.cleanup()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_cli_token_option_and_auth_command(tmp_path, monkeypatch) -> None:
|
|
168
|
+
"""--token flag and auth command work properly."""
|
|
169
|
+
# Test auth command
|
|
170
|
+
custom_token_file = tmp_path / "token"
|
|
171
|
+
monkeypatch.setattr("gitrelevance.config.TOKEN_FILE", custom_token_file)
|
|
172
|
+
monkeypatch.setattr("gitrelevance.config.CONFIG_DIR", tmp_path)
|
|
173
|
+
|
|
174
|
+
result = runner.invoke(app, ["auth", "--token", "ghp_mock_token_12345"])
|
|
175
|
+
assert result.exit_code == 0
|
|
176
|
+
assert "Token saved successfully" in result.output
|
|
177
|
+
assert custom_token_file.read_text(encoding="utf-8").strip() == "ghp_mock_token_12345"
|
|
178
|
+
|
|
179
|
+
# Test analyze with --token
|
|
180
|
+
builder = (
|
|
181
|
+
RepoBuilder()
|
|
182
|
+
.commit("Initial commit", files={"README.md": "readme"})
|
|
183
|
+
.add_remote("origin", "https://github.com/testowner/testrepo.git")
|
|
184
|
+
)
|
|
185
|
+
path = builder.build()
|
|
186
|
+
try:
|
|
187
|
+
issue = make_issue(number=1, title="Test", state="closed")
|
|
188
|
+
fake_provider = FakeCLIProvider(issues=[issue])
|
|
189
|
+
set_provider_override(fake_provider)
|
|
190
|
+
|
|
191
|
+
res = runner.invoke(app, ["analyze", "--path", path, "--token", "ghp_custom_token", "--json"])
|
|
192
|
+
assert res.exit_code == 0
|
|
163
193
|
finally:
|
|
164
194
|
builder.cleanup()
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""Configuration and environment loading."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
|
-
try:
|
|
8
|
-
from dotenv import load_dotenv # type: ignore[import-not-found]
|
|
9
|
-
|
|
10
|
-
# Try loading .env if python-dotenv is installed
|
|
11
|
-
load_dotenv()
|
|
12
|
-
except ImportError:
|
|
13
|
-
pass
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def load_github_token() -> str | None:
|
|
17
|
-
"""Load GitHub Personal Access Token from environment.
|
|
18
|
-
|
|
19
|
-
Reads GITHUB_TOKEN environment variable. Returns None if not set.
|
|
20
|
-
|
|
21
|
-
Returns:
|
|
22
|
-
The token string if set in environment, None otherwise.
|
|
23
|
-
"""
|
|
24
|
-
token = os.getenv("GITHUB_TOKEN")
|
|
25
|
-
if not token or not token.strip():
|
|
26
|
-
return None
|
|
27
|
-
return token.strip()
|
|
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
|
{gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_cross_contamination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_renamed_not_deleted.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|