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.
- gitrelevance/__init__.py +6 -0
- gitrelevance/analysis/__init__.py +20 -0
- gitrelevance/analysis/classifier.py +59 -0
- gitrelevance/analysis/confidence.py +41 -0
- gitrelevance/analysis/current_state.py +98 -0
- gitrelevance/analysis/engine.py +181 -0
- gitrelevance/analysis/evidence.py +187 -0
- gitrelevance/analysis/matcher.py +200 -0
- gitrelevance/cli/__init__.py +7 -0
- gitrelevance/cli/commands.py +262 -0
- gitrelevance/config.py +27 -0
- gitrelevance/git/__init__.py +23 -0
- gitrelevance/git/commits.py +62 -0
- gitrelevance/git/files.py +185 -0
- gitrelevance/git/history.py +217 -0
- gitrelevance/git/repository.py +161 -0
- gitrelevance/issues/__init__.py +5 -0
- gitrelevance/issues/models.py +53 -0
- gitrelevance/models.py +60 -0
- gitrelevance/output/__init__.py +8 -0
- gitrelevance/output/json.py +83 -0
- gitrelevance/output/terminal.py +202 -0
- gitrelevance/providers/__init__.py +11 -0
- gitrelevance/providers/base.py +75 -0
- gitrelevance/providers/github.py +419 -0
- gitrelevance-0.1.0.dist-info/METADATA +269 -0
- gitrelevance-0.1.0.dist-info/RECORD +30 -0
- gitrelevance-0.1.0.dist-info/WHEEL +4 -0
- gitrelevance-0.1.0.dist-info/entry_points.txt +2 -0
- gitrelevance-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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
|
+
)
|