gitrelevance 0.1.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.
@@ -0,0 +1,200 @@
1
+ """Matcher for correlating Git history data with issue tracker data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from gitrelevance.git.commits import Commit
10
+ from gitrelevance.git.history import commits_referencing
11
+ from gitrelevance.git.repository import GitRepository
12
+ from gitrelevance.issues.models import Issue, PullRequest
13
+
14
+ if TYPE_CHECKING:
15
+ from gitrelevance.providers.base import Provider
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Temporary debug: issue numbers to trace in detail
20
+ _TRACE_ISSUES: set[int] = {34}
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class MatchSet:
25
+ """Encapsulates all Git and PR evidence related to a specific Issue.
26
+
27
+ Attributes:
28
+ issue: The issue being analyzed.
29
+ referencing_commits: Commits directly referencing the issue (e.g., #123, GH-123).
30
+ linked_prs: Pull requests connected or closing the issue.
31
+ pr_commits: Resolved merge commits from linked PRs present in local Git history.
32
+ related_files: Deterministic sorted tuple of all files touched by referencing commits,
33
+ PR merge commits, or linked PR file changes.
34
+ """
35
+
36
+ issue: Issue
37
+ referencing_commits: tuple[Commit, ...]
38
+ linked_prs: tuple[PullRequest, ...]
39
+ pr_commits: tuple[Commit, ...]
40
+ related_files: tuple[str, ...]
41
+
42
+
43
+ def build_match_set(
44
+ issue: Issue,
45
+ repo: GitRepository,
46
+ provider_prs: list[PullRequest],
47
+ ) -> MatchSet:
48
+ """Build a MatchSet describing everything in Git history related to an issue.
49
+
50
+ Args:
51
+ issue: The issue to build the match set for.
52
+ repo: The local Git repository wrapper.
53
+ provider_prs: List of pull requests fetched from the provider.
54
+
55
+ Returns:
56
+ A MatchSet containing referencing commits, linked PRs, PR merge commits,
57
+ and affected file paths.
58
+ """
59
+ _trace = issue.number in _TRACE_ISSUES
60
+
61
+ # 1. Referencing commits using the cached single-pass commit index
62
+ ref_commits = commits_referencing(repo, issue.number)
63
+
64
+ if _trace:
65
+ logger.warning(
66
+ "[TRACE] === build_match_set for issue #%d (title=%r) ===",
67
+ issue.number, issue.title,
68
+ )
69
+ logger.warning("[TRACE] issue.state=%s, issue.linked_pr_numbers=%s",
70
+ issue.state, issue.linked_pr_numbers)
71
+ if ref_commits:
72
+ logger.warning("[TRACE] (a) commits_referencing(%d) found %d commit(s):",
73
+ issue.number, len(ref_commits))
74
+ for c in ref_commits:
75
+ logger.warning("[TRACE] sha=%s message=%r files_changed=%s",
76
+ c.short_sha, c.message, c.files_changed)
77
+ else:
78
+ logger.warning("[TRACE] (a) commits_referencing(%d) found 0 commits.", issue.number)
79
+
80
+ # 2. Linked PRs: union of PRs closing this issue or linked in the issue model
81
+ matched_prs: list[PullRequest] = []
82
+ seen_pr_numbers: set[int] = set()
83
+ for pr in provider_prs:
84
+ closes_match = issue.number in pr.closes_issue_numbers
85
+ linked_match = pr.number in issue.linked_pr_numbers
86
+ if closes_match or linked_match:
87
+ if pr.number not in seen_pr_numbers:
88
+ seen_pr_numbers.add(pr.number)
89
+ matched_prs.append(pr)
90
+ if _trace:
91
+ reason = []
92
+ if closes_match:
93
+ reason.append(f"closes_issue_numbers={pr.closes_issue_numbers}")
94
+ if linked_match:
95
+ reason.append(f"linked_pr_numbers match (PR#{pr.number} in issue.linked_pr_numbers)")
96
+ logger.warning(
97
+ "[TRACE] (b) matched PR #%d (title=%r, merged=%s, files_changed=%d items):",
98
+ pr.number, pr.title, pr.merged, len(pr.files_changed),
99
+ )
100
+ logger.warning("[TRACE] reason: %s", " + ".join(reason))
101
+ logger.warning("[TRACE] files_changed=%s", pr.files_changed)
102
+
103
+ if _trace and not matched_prs:
104
+ logger.warning("[TRACE] (b) No PRs matched for issue #%d.", issue.number)
105
+
106
+ # 3. Resolve merge commits for linked PRs in local git history (skip if not found)
107
+ resolved_pr_commits: list[Commit] = []
108
+ seen_commit_shas: set[str] = set()
109
+ # Track which PRs had their merge commit resolved locally, so we can
110
+ # skip their pr.files_changed in step 4. When the merge commit IS
111
+ # in the local repo, its files_changed (from the actual git diff) is
112
+ # already included via resolved_pr_commits and is more accurate than
113
+ # the GitHub API file list, which may reflect a huge bundled changeset
114
+ # unrelated to the specific issue.
115
+ prs_with_resolved_commits: set[int] = set()
116
+ for pr in matched_prs:
117
+ if pr.merge_commit_sha:
118
+ commit = repo.get_commit(pr.merge_commit_sha)
119
+ if commit is not None and commit.sha not in seen_commit_shas:
120
+ seen_commit_shas.add(commit.sha)
121
+ resolved_pr_commits.append(commit)
122
+ prs_with_resolved_commits.add(pr.number)
123
+ if _trace:
124
+ logger.warning(
125
+ "[TRACE] (c) resolved merge commit for PR #%d: sha=%s files_changed=%s",
126
+ pr.number, commit.short_sha, commit.files_changed,
127
+ )
128
+ elif _trace:
129
+ logger.warning(
130
+ "[TRACE] (c) PR #%d merge_commit_sha=%s NOT found in local repo",
131
+ pr.number, pr.merge_commit_sha,
132
+ )
133
+
134
+ # 4. Related files: union across referencing_commits, pr_commits, and linked_prs
135
+ files: set[str] = set()
136
+ for c in ref_commits:
137
+ files.update(c.files_changed)
138
+ for c in resolved_pr_commits:
139
+ files.update(c.files_changed)
140
+ # Add pr.files_changed only for PRs where it's safe:
141
+ #
142
+ # - Unmerged PRs (WIP / review): always include — pr.files_changed
143
+ # is the only source of file info and drives the STILL_RELEVANT signal.
144
+ #
145
+ # - Merged PRs whose merge commit IS in the local repo: skip — the
146
+ # merge commit's actual git diff (already added above) is more
147
+ # accurate than the GitHub API file list.
148
+ #
149
+ # - Merged PRs whose merge commit is NOT in the local repo (shallow
150
+ # clone): skip — pr.files_changed may reflect a huge bundled
151
+ # squashed changeset (e.g. 27 files for a CODE_OF_CONDUCT PR)
152
+ # that injects unrelated server/ files into related_files,
153
+ # causing false OBSOLETE evidence when those files are deleted.
154
+ for pr in matched_prs:
155
+ if pr.number in prs_with_resolved_commits:
156
+ # Merge commit found locally — its files are already included above
157
+ continue
158
+ if pr.merged:
159
+ # Merged but merge commit missing (shallow clone) — skip
160
+ # pr.files_changed to avoid cross-contamination from broad
161
+ # bundled changesets that are unrelated to the specific issue.
162
+ continue
163
+ # Unmerged PR — include files_changed for STILL_RELEVANT detection
164
+ files.update(pr.files_changed)
165
+
166
+ if _trace:
167
+ server_files = [f for f in files if f.startswith("server/")]
168
+ logger.warning(
169
+ "[TRACE] (d) final related_files: %d total files, %d server/* files",
170
+ len(files), len(server_files),
171
+ )
172
+ if server_files:
173
+ logger.warning("[TRACE] server files: %s", server_files)
174
+
175
+ return MatchSet(
176
+ issue=issue,
177
+ referencing_commits=tuple(ref_commits),
178
+ linked_prs=tuple(matched_prs),
179
+ pr_commits=tuple(resolved_pr_commits),
180
+ related_files=tuple(sorted(files)),
181
+ )
182
+
183
+
184
+ def build_all_match_sets(
185
+ issues: list[Issue],
186
+ repo: GitRepository,
187
+ provider: Provider,
188
+ ) -> dict[int, MatchSet]:
189
+ """Build match sets for all given issues, fetching pull requests only once.
190
+
191
+ Args:
192
+ issues: List of issues to correlate.
193
+ repo: The local Git repository wrapper.
194
+ provider: Issue tracker provider implementing Provider protocol.
195
+
196
+ Returns:
197
+ Dictionary mapping issue number to its corresponding MatchSet.
198
+ """
199
+ prs = provider.get_pull_requests(state="all")
200
+ return {issue.number: build_match_set(issue, repo, prs) for issue in issues}
@@ -0,0 +1,7 @@
1
+ """CLI module for GitRelevance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gitrelevance.cli.commands import app
6
+
7
+ __all__ = ["app"]
@@ -0,0 +1,262 @@
1
+ """CLI commands implementation for GitRelevance using Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from typing import Callable, Literal, Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
14
+
15
+ from gitrelevance import config
16
+ from gitrelevance.analysis.engine import AnalysisEngine
17
+ from gitrelevance.git.repository import GitRepository, NotAGitRepositoryError
18
+ from gitrelevance.output.json import to_json
19
+ from gitrelevance.output.terminal import TerminalRenderer
20
+ from gitrelevance.providers.base import Provider, RateLimitExceededError
21
+ from gitrelevance.providers.github import GitHubProvider
22
+
23
+ app = typer.Typer(
24
+ name="gitrelevance",
25
+ help="Analyze whether historical GitHub issues are still relevant using Git history as evidence.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+
30
+ @app.callback()
31
+ def callback(
32
+ verbose: bool = typer.Option(
33
+ False,
34
+ "--verbose",
35
+ "-v",
36
+ help="Enable verbose debug logging.",
37
+ ),
38
+ ) -> None:
39
+ """GitRelevance: Analyze historical GitHub issues using Git history."""
40
+ level = logging.DEBUG if verbose else logging.INFO
41
+ logging.basicConfig(
42
+ level=level,
43
+ format="%(asctime)s [%(levelname)s] %(message)s",
44
+ datefmt="%H:%M:%S",
45
+ )
46
+
47
+
48
+ # Optional seam for dependency injection in tests
49
+ _provider_override: Provider | None = None
50
+ _provider_factory: Callable[[str, str, str | None], Provider] | None = None
51
+
52
+
53
+ def set_provider_override(provider: Provider | None) -> None:
54
+ """Set a global provider override for testing."""
55
+ global _provider_override
56
+ _provider_override = provider
57
+
58
+
59
+ def set_provider_factory(factory: Callable[[str, str, str | None], Provider] | None) -> None:
60
+ """Set a global provider factory for testing."""
61
+ global _provider_factory
62
+ _provider_factory = factory
63
+
64
+
65
+ @app.command()
66
+ def analyze(
67
+ state: str = typer.Option(
68
+ "all",
69
+ "--state",
70
+ "-s",
71
+ help="Filter issue state: open, closed, or all.",
72
+ ),
73
+ since: Optional[str] = typer.Option(
74
+ None,
75
+ "--since",
76
+ help="Filter issues created on or after date (YYYY-MM-DD).",
77
+ ),
78
+ json_output: bool = typer.Option(
79
+ False,
80
+ "--json",
81
+ help="Output raw JSON instead of formatted terminal tables.",
82
+ ),
83
+ path: str = typer.Option(
84
+ ".",
85
+ "--path",
86
+ "-p",
87
+ help="Path to local Git repository (defaults to current directory).",
88
+ hidden=True,
89
+ ),
90
+ ) -> None:
91
+ """Analyze repository issues against local Git history."""
92
+ err_console = Console(stderr=True)
93
+
94
+ # 1. Detect local Git repository
95
+ try:
96
+ repo = GitRepository(path)
97
+ except NotAGitRepositoryError:
98
+ err_console.print(
99
+ f"[bold red]Error:[/bold red] Directory '{path}' is not a Git repository. "
100
+ "Please run gitrelevance from within a git repository or pass a valid repository path."
101
+ )
102
+ raise typer.Exit(code=1)
103
+
104
+ # 2. Detect remote URL and parse owner/repo
105
+ remote_url = repo.remote_url("origin")
106
+ if not remote_url:
107
+ err_console.print(
108
+ "[bold red]Error:[/bold red] No 'origin' remote found in the repository. "
109
+ "GitRelevance requires a GitHub remote to correlate issues."
110
+ )
111
+ raise typer.Exit(code=1)
112
+
113
+ # Parse GitHub owner/repo
114
+ parsed = GitHubProvider.parse_remote(remote_url)
115
+ if not parsed:
116
+ err_console.print(
117
+ f"[bold red]Error:[/bold red] Remote URL '{remote_url}' is not a recognized GitHub URL. "
118
+ "Currently only GitHub-hosted repositories are supported."
119
+ )
120
+ raise typer.Exit(code=1)
121
+
122
+ owner, repo_name = parsed
123
+
124
+ # 3. Construct provider
125
+ token = config.load_github_token()
126
+ if _provider_override is not None:
127
+ provider = _provider_override
128
+ elif _provider_factory is not None:
129
+ provider = _provider_factory(owner, repo_name, token)
130
+ else:
131
+ if not token and not json_output:
132
+ err_console.print(
133
+ "[yellow]Note:[/yellow] Running in unauthenticated mode (GITHUB_TOKEN not set). "
134
+ "GitHub API rate limit is 60 requests/hour."
135
+ )
136
+ provider = GitHubProvider(owner=owner, repo=repo_name, token=token)
137
+
138
+ # Validate state option
139
+ state_normalized = state.lower()
140
+ if state_normalized not in ("open", "closed", "all"):
141
+ err_console.print(
142
+ f"[bold red]Error:[/bold red] Invalid state '{state}'. Must be 'open', 'closed', or 'all'."
143
+ )
144
+ raise typer.Exit(code=1)
145
+
146
+ # 4. Parse --since filter if supplied
147
+ since_dt: datetime | None = None
148
+ if since:
149
+ try:
150
+ parsed_date = datetime.strptime(since.strip(), "%Y-%m-%d")
151
+ since_dt = parsed_date.replace(tzinfo=timezone.utc)
152
+ except ValueError:
153
+ err_console.print(
154
+ f"[bold red]Error:[/bold red] Invalid date format for --since: '{since}'. "
155
+ "Expected format: YYYY-MM-DD (e.g. 2024-01-01)."
156
+ )
157
+ raise typer.Exit(code=1)
158
+
159
+ # 5. Run AnalysisEngine
160
+ engine = AnalysisEngine(repo, provider)
161
+
162
+ # Gather repository summary information (needed for both output modes)
163
+ try:
164
+ branch = repo.current_branch()
165
+ except Exception:
166
+ branch = "detached"
167
+
168
+ try:
169
+ head_commit = repo.head_commit()
170
+ short_sha = head_commit.short_sha
171
+ except Exception:
172
+ short_sha = "unknown"
173
+
174
+ repo_info = {
175
+ "branch": branch,
176
+ "owner": owner,
177
+ "remote_url": remote_url,
178
+ "repo": repo_name,
179
+ "short_sha": short_sha,
180
+ }
181
+
182
+ t_start = time.perf_counter()
183
+
184
+ try:
185
+ if json_output:
186
+ # ---- JSON mode: collect all results, serialize at the end ----
187
+ results = list(engine.analyze_streaming(state=state_normalized)) # type: ignore[arg-type]
188
+
189
+ # Apply client-side --since filter
190
+ if since_dt is not None:
191
+ results = [r for r in results if r.issue.created_at >= since_dt]
192
+
193
+ results.sort(key=lambda r: r.issue.number)
194
+
195
+ json_str = to_json(results, repo_info)
196
+ typer.echo(json_str)
197
+
198
+ else:
199
+ # ---- Terminal mode: stream results with live progress ----
200
+ out_console = Console()
201
+ renderer = TerminalRenderer(out_console)
202
+
203
+ # Pre-fetch issue count for progress bar (cached after first call)
204
+ all_issues = provider.get_issues(state=state_normalized) # type: ignore[arg-type]
205
+ if since_dt is not None:
206
+ visible_issues = [i for i in all_issues if i.created_at >= since_dt]
207
+ else:
208
+ visible_issues = all_issues
209
+ total = len(visible_issues)
210
+
211
+ renderer.render_header(repo_info, total)
212
+
213
+ with Progress(
214
+ SpinnerColumn(),
215
+ TextColumn("[progress.description]{task.description}"),
216
+ BarColumn(),
217
+ TaskProgressColumn(),
218
+ console=err_console,
219
+ transient=True,
220
+ ) as progress:
221
+ task = progress.add_task("Analyzing issues...", total=total)
222
+
223
+ for result in engine.analyze_streaming(state=state_normalized): # type: ignore[arg-type]
224
+ # Apply client-side --since filter
225
+ if since_dt is not None and result.issue.created_at < since_dt:
226
+ progress.advance(task)
227
+ continue
228
+
229
+ progress.advance(task)
230
+ renderer.render_result(result)
231
+
232
+ renderer.render_footer()
233
+
234
+ elapsed = time.perf_counter() - t_start
235
+ err_console.print(
236
+ f"[dim]Analysis completed in {elapsed:.1f}s ({len(visible_issues)} issues shown).[/dim]"
237
+ )
238
+
239
+ except RateLimitExceededError as e:
240
+ reset_info = f" Reset time: {e.reset_at}" if getattr(e, "reset_at", None) else ""
241
+ err_console.print(
242
+ f"\n[bold red]GitHub API Rate Limit Exceeded:[/bold red]{reset_info}\n"
243
+ "To resolve this, set your GitHub personal access token:\n"
244
+ " [bold]export GITHUB_TOKEN='ghp_yourTokenHere'[/bold]\n"
245
+ "Authenticated requests receive 5,000 requests/hour."
246
+ )
247
+ raise typer.Exit(code=1)
248
+ except KeyboardInterrupt:
249
+ err_console.print("\n[yellow]Analysis interrupted by user.[/yellow]")
250
+ raise typer.Exit(code=130)
251
+ except Exception as e:
252
+ err_console.print(f"[bold red]Analysis failed:[/bold red] {e}")
253
+ raise typer.Exit(code=1)
254
+
255
+
256
+ def main() -> None:
257
+ """CLI entry point function."""
258
+ app()
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
gitrelevance/config.py ADDED
@@ -0,0 +1,27 @@
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()
@@ -0,0 +1,23 @@
1
+ """Git analysis modules for GitRelevance."""
2
+
3
+ from gitrelevance.git.commits import Commit
4
+ from gitrelevance.git.files import FileChange
5
+ from gitrelevance.git.repository import GitRepository
6
+ from gitrelevance.git.history import (
7
+ build_commit_reference_index,
8
+ commits_referencing,
9
+ RevertDetector,
10
+ GitNativeRevertDetector,
11
+ default_revert_detector,
12
+ )
13
+
14
+ __all__ = [
15
+ "Commit",
16
+ "FileChange",
17
+ "GitRepository",
18
+ "build_commit_reference_index",
19
+ "commits_referencing",
20
+ "RevertDetector",
21
+ "GitNativeRevertDetector",
22
+ "default_revert_detector",
23
+ ]
@@ -0,0 +1,62 @@
1
+ """Commit dataclass representing a Git commit."""
2
+
3
+ from datetime import datetime, timezone
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Commit:
9
+ """Represents a Git commit with its metadata.
10
+
11
+ Attributes:
12
+ sha: Full commit SHA hash
13
+ short_sha: Abbreviated SHA (first 7 characters)
14
+ message: Full commit message
15
+ author: Author name
16
+ date: Commit date as timezone-aware datetime
17
+ files_changed: Tuple of file paths changed in this commit
18
+ """
19
+
20
+ sha: str
21
+ short_sha: str
22
+ message: str
23
+ author: str
24
+ date: datetime
25
+ files_changed: tuple[str, ...]
26
+
27
+ @classmethod
28
+ def from_gitpython(cls, git_commit: object) -> "Commit":
29
+ """Create a Commit from a GitPython Commit object.
30
+
31
+ Args:
32
+ git_commit: A GitPython commit object
33
+
34
+ Returns:
35
+ Commit instance with converted data
36
+ """
37
+ # Ensure timezone-aware datetime
38
+ commit_date = git_commit.committed_date
39
+ if isinstance(commit_date, (int, float)):
40
+ dt = datetime.fromtimestamp(commit_date, tz=timezone.utc)
41
+ elif hasattr(commit_date, "tzinfo"):
42
+ if commit_date.tzinfo is None:
43
+ dt = commit_date.replace(tzinfo=timezone.utc)
44
+ else:
45
+ dt = commit_date
46
+ else:
47
+ dt = datetime.now(tz=timezone.utc)
48
+
49
+ # Get files changed via commit stats (works for all commits without extra diff walk)
50
+ try:
51
+ files_changed: tuple[str, ...] = tuple(git_commit.stats.files.keys())
52
+ except Exception:
53
+ files_changed = ()
54
+
55
+ return cls(
56
+ sha=git_commit.hexsha,
57
+ short_sha=git_commit.hexsha[:7],
58
+ message=git_commit.message.rstrip("\n"),
59
+ author=str(git_commit.author),
60
+ date=dt,
61
+ files_changed=files_changed,
62
+ )