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,419 @@
1
+ """GitHub provider implementation using PyGithub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ import tempfile
9
+ import time
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from datetime import datetime
12
+ from typing import Literal
13
+
14
+ from diskcache import Cache
15
+ from github import Auth, Github, GithubException, RateLimitExceededException
16
+
17
+ from gitrelevance.config import load_github_token
18
+ from gitrelevance.issues.models import Issue, PullRequest
19
+ from gitrelevance.providers.base import Provider, RateLimitExceededError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Regex pattern to extract closing issue numbers from PR body/title
24
+ CLOSING_KEYWORD_PATTERN = re.compile(
25
+ r"(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+(?:#|gh-)(\d+)",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+
30
+ class GitHubProvider:
31
+ """GitHub provider implementing the Provider protocol.
32
+
33
+ Provides access to GitHub issues, pull requests, and comments with disk caching
34
+ and rate limit error handling.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ owner: str,
40
+ repo: str,
41
+ token: str | None = None,
42
+ cache_dir: str | None = None,
43
+ ttl: int = 900,
44
+ timeout: int = 15,
45
+ ) -> None:
46
+ """Initialize the GitHub provider.
47
+
48
+ Args:
49
+ owner: Repository owner or organization name
50
+ repo: Repository name
51
+ token: Optional GitHub Personal Access Token. If None, loaded from GITHUB_TOKEN env var.
52
+ cache_dir: Directory for diskcache storage. If None, uses a temporary directory.
53
+ ttl: Cache TTL in seconds (default: 900 / 15 minutes).
54
+ timeout: HTTP request timeout in seconds (default: 15).
55
+ """
56
+ self.owner = owner
57
+ self.repo_name = repo
58
+ self._token = token or load_github_token()
59
+ self.ttl = ttl
60
+ self.timeout = timeout
61
+
62
+ if cache_dir is None:
63
+ cache_dir = os.path.join(tempfile.gettempdir(), "gitrelevance_cache")
64
+ self._cache = Cache(cache_dir)
65
+
66
+ # Explicit timeout and retry=0 to prevent indefinite socket hangs and silent backoff sleeping on 403
67
+ if self._token:
68
+ auth = Auth.Token(self._token)
69
+ self._client = Github(
70
+ auth=auth,
71
+ timeout=self.timeout,
72
+ retry=0,
73
+ per_page=100,
74
+ )
75
+ else:
76
+ self._client = Github(
77
+ timeout=self.timeout,
78
+ retry=0,
79
+ per_page=100,
80
+ )
81
+
82
+ @staticmethod
83
+ def parse_remote(remote_url: str) -> tuple[str, str] | None:
84
+ """Parse a remote URL into (owner, repo) pair for GitHub repositories.
85
+
86
+ Supported URL formats:
87
+ - git@github.com:owner/repo.git
88
+ - https://github.com/owner/repo.git
89
+ - https://github.com/owner/repo
90
+ - github.com/owner/repo
91
+
92
+ Args:
93
+ remote_url: Remote URL string
94
+
95
+ Returns:
96
+ Tuple of (owner, repo) if valid GitHub URL, None otherwise.
97
+ """
98
+ if not remote_url:
99
+ return None
100
+
101
+ url = remote_url.strip()
102
+
103
+ patterns = [
104
+ r"^git@github\.com:([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?$",
105
+ r"^https?://github\.com/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?/?$",
106
+ r"^github\.com/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?/?$",
107
+ ]
108
+
109
+ for pattern in patterns:
110
+ match = re.match(pattern, url)
111
+ if match:
112
+ owner, repo = match.group(1), match.group(2)
113
+ return owner, repo
114
+
115
+ return None
116
+
117
+ def _get_github_repo(self) -> object:
118
+ """Get PyGithub Repository instance."""
119
+ return self._client.get_repo(f"{self.owner}/{self.repo_name}")
120
+
121
+ def get_issues(
122
+ self,
123
+ state: Literal["open", "closed", "all"] = "all",
124
+ force_refresh: bool = False,
125
+ ) -> list[Issue]:
126
+ """Fetch repository issues.
127
+
128
+ Args:
129
+ state: Issue state filter ("open", "closed", or "all")
130
+ force_refresh: If True, bypass cache and fetch fresh data.
131
+
132
+ Returns:
133
+ List of Issue dataclasses.
134
+
135
+ Raises:
136
+ RateLimitExceededError: If GitHub API rate limit is exceeded.
137
+ """
138
+ cache_key = (self.owner, self.repo_name, "issues", state)
139
+ if not force_refresh and self.ttl > 0:
140
+ cached_data = self._cache.get(cache_key)
141
+ if cached_data is not None:
142
+ logger.info("Loaded %d issues from disk cache for %s/%s", len(cached_data), self.owner, self.repo_name)
143
+ return cached_data
144
+
145
+ logger.info("Fetching issues from GitHub API for %s/%s (state=%s)...", self.owner, self.repo_name, state)
146
+ try:
147
+ gh_repo = self._get_github_repo()
148
+ gh_issues = gh_repo.get_issues(state=state)
149
+
150
+ issues: list[Issue] = []
151
+ for gh_issue in gh_issues:
152
+ # Filter out PRs (PyGithub get_issues returns issues + PRs unless filtered)
153
+ is_pr = False
154
+ raw_data = getattr(gh_issue, "_rawData", None)
155
+ if isinstance(raw_data, dict):
156
+ is_pr = bool(raw_data.get("pull_request"))
157
+ else:
158
+ try:
159
+ is_pr = getattr(gh_issue, "pull_request", None) is not None
160
+ except (RateLimitExceededException, KeyboardInterrupt):
161
+ raise
162
+ except Exception:
163
+ is_pr = False
164
+
165
+ if is_pr:
166
+ continue
167
+
168
+ linked_prs: list[int] = []
169
+ # Check timeline events if available
170
+ if hasattr(gh_issue, "get_timeline"):
171
+ try:
172
+ timeline = gh_issue.get_timeline()
173
+ for event in timeline:
174
+ event_name = getattr(event, "event", None)
175
+ if event_name in ("cross-referenced", "connected", "referenced"):
176
+ source = getattr(event, "source", None)
177
+ if source and isinstance(source, dict):
178
+ issue_info = source.get("issue")
179
+ if issue_info and isinstance(issue_info, dict):
180
+ if "pull_request" in issue_info or issue_info.get("pull_request") is not None:
181
+ pr_num = issue_info.get("number")
182
+ if pr_num is not None and int(pr_num) not in linked_prs:
183
+ linked_prs.append(int(pr_num))
184
+ elif source:
185
+ issue_info = getattr(source, "issue", None)
186
+ if issue_info:
187
+ if getattr(issue_info, "pull_request", None) is not None:
188
+ pr_num = getattr(issue_info, "number", None)
189
+ if pr_num is not None and int(pr_num) not in linked_prs:
190
+ linked_prs.append(int(pr_num))
191
+ except (RateLimitExceededException, KeyboardInterrupt):
192
+ raise
193
+ except Exception as e:
194
+ logger.debug("Could not fetch timeline for issue #%s: %s", gh_issue.number, e)
195
+
196
+ issue = Issue(
197
+ number=gh_issue.number,
198
+ title=gh_issue.title,
199
+ body=gh_issue.body or "",
200
+ state="closed" if gh_issue.state == "closed" else "open",
201
+ created_at=gh_issue.created_at,
202
+ closed_at=gh_issue.closed_at,
203
+ labels=tuple(label.name for label in gh_issue.labels),
204
+ linked_pr_numbers=tuple(linked_prs),
205
+ )
206
+ issues.append(issue)
207
+
208
+ logger.info("Successfully fetched %d issues for %s/%s", len(issues), self.owner, self.repo_name)
209
+ if self.ttl > 0:
210
+ self._cache.set(cache_key, issues, expire=self.ttl)
211
+ return issues
212
+
213
+ except RateLimitExceededException as e:
214
+ reset = getattr(e, "reset", None)
215
+ logger.warning("GitHub API rate limit exceeded for %s/%s", self.owner, self.repo_name)
216
+ raise RateLimitExceededError(reset_at=reset) from e
217
+ except GithubException as e:
218
+ if e.status in (403, 429) and "rate limit" in str(e).lower():
219
+ reset = getattr(e, "reset", None)
220
+ logger.warning("GitHub API rate limit exceeded (HTTP %d) for %s/%s", e.status, self.owner, self.repo_name)
221
+ raise RateLimitExceededError(reset_at=reset) from e
222
+ raise
223
+
224
+ def get_pull_requests(
225
+ self,
226
+ state: Literal["open", "closed", "all"] = "all",
227
+ force_refresh: bool = False,
228
+ ) -> list[PullRequest]:
229
+ """Fetch repository pull requests.
230
+
231
+ Args:
232
+ state: Pull request state filter ("open", "closed", or "all")
233
+ force_refresh: If True, bypass cache and fetch fresh data.
234
+
235
+ Returns:
236
+ List of PullRequest dataclasses.
237
+
238
+ Raises:
239
+ RateLimitExceededError: If GitHub API rate limit is exceeded.
240
+ """
241
+ cache_key = (self.owner, self.repo_name, "pull_requests", state)
242
+ if not force_refresh and self.ttl > 0:
243
+ cached_data = self._cache.get(cache_key)
244
+ if cached_data is not None:
245
+ logger.info("Loaded %d pull requests from disk cache for %s/%s", len(cached_data), self.owner, self.repo_name)
246
+ return cached_data
247
+
248
+ logger.info("Fetching pull requests from GitHub API for %s/%s (state=%s)...", self.owner, self.repo_name, state)
249
+ t0 = time.perf_counter()
250
+ try:
251
+ gh_repo = self._get_github_repo()
252
+
253
+ # Materialize the paginated PR list up-front so we can time it separately
254
+ # from the per-PR metadata extraction. get_pulls() returns a lazy
255
+ # PaginatedList; each iteration may trigger a page-fetch API call.
256
+ logger.info(" Phase 0: fetching paginated PR list...")
257
+ t_p0 = time.perf_counter()
258
+ gh_prs_list: list[object] = list(gh_repo.get_pulls(state=state))
259
+ t_p1 = time.perf_counter()
260
+ logger.info(" Phase 0: got %d PR objects from API in %.2fs.", len(gh_prs_list), t_p1 - t_p0)
261
+
262
+ # ------------------------------------------------------------------
263
+ # Phase 1: Iterate PRs, parse metadata, decide which need file fetch
264
+ # ------------------------------------------------------------------
265
+ # IMPORTANT: Use _rawData dict to read PR properties instead of
266
+ # PyGithub object attributes. PyGithub's lazy-loading triggers a
267
+ # separate API call for each attribute access (e.g. gh_pr.body)
268
+ # when the PaginatedList did not include it, causing ~1s per PR.
269
+ # Reading from _rawData avoids these per-PR API calls entirely.
270
+ pr_metadata: list[tuple[object, list[int], bool]] = [] # (gh_pr, closes_issues, should_fetch_files)
271
+ for gh_pr in gh_prs_list:
272
+ closes_issues: list[int] = []
273
+
274
+ # Read from _rawData to avoid per-PR lazy-load API calls.
275
+ # NOTE: The list endpoint does NOT include a "merged" boolean;
276
+ # it includes "merged_at" (timestamp or null). We compute
277
+ # "merged" from that.
278
+ raw = getattr(gh_pr, "_rawData", None) or {}
279
+ pr_title = raw.get("title", "") or ""
280
+ pr_body = raw.get("body", "") or ""
281
+ text_to_search = f"{pr_title} {pr_body}"
282
+ for match in CLOSING_KEYWORD_PATTERN.findall(text_to_search):
283
+ try:
284
+ num = int(match)
285
+ if num not in closes_issues:
286
+ closes_issues.append(num)
287
+ except ValueError:
288
+ pass
289
+
290
+ is_merged = raw.get("merged_at") is not None
291
+ # Optimization: Skip get_files() for closed unmerged PRs that don't close any issues
292
+ # (avoids thousands of unnecessary API calls for spam/rejected PRs on public repos)
293
+ should_fetch_files = (
294
+ is_merged
295
+ or raw.get("state", "open") == "open"
296
+ or len(closes_issues) > 0
297
+ )
298
+ pr_metadata.append((gh_pr, closes_issues, should_fetch_files))
299
+
300
+ t_metadata = time.perf_counter()
301
+ logger.info(
302
+ "Enumerated %d PRs in %.2fs (metadata phase). %d need file changes fetched.",
303
+ len(pr_metadata),
304
+ t_metadata - t_p1,
305
+ sum(1 for _, _, sf in pr_metadata if sf),
306
+ )
307
+
308
+ # ------------------------------------------------------------------
309
+ # Phase 2: Parallel fetch of changed-file lists
310
+ # ------------------------------------------------------------------
311
+ def _fetch_pr_files(pr_obj: object) -> tuple[str, ...]:
312
+ """Fetch the file list for a single PR (I/O-bound, GIL-releasing)."""
313
+ try:
314
+ return tuple(f.filename for f in pr_obj.get_files())
315
+ except (RateLimitExceededException, KeyboardInterrupt):
316
+ raise
317
+ except Exception as exc:
318
+ logger.debug("Could not fetch changed files for PR #%s: %s", pr_obj.number, exc)
319
+ return ()
320
+
321
+ # Build file results map: PR number -> files_changed
322
+ files_map: dict[int, tuple[str, ...]] = {}
323
+ fetchable = [(gh_pr, idx) for idx, (gh_pr, _, sf) in enumerate(pr_metadata) if sf]
324
+
325
+ if fetchable:
326
+ max_workers = min(len(fetchable), 8)
327
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
328
+ future_to_pr_num = {
329
+ executor.submit(_fetch_pr_files, gh_pr): gh_pr.number
330
+ for gh_pr, _ in fetchable
331
+ }
332
+ for future in as_completed(future_to_pr_num):
333
+ pr_num = future_to_pr_num[future]
334
+ files_map[pr_num] = future.result()
335
+
336
+ t_files = time.perf_counter()
337
+ logger.info(
338
+ "Fetched file lists for %d PRs in %.2fs (parallel, max_workers=%d).",
339
+ len(files_map),
340
+ t_files - t_metadata,
341
+ min(len(fetchable), 8) if fetchable else 0,
342
+ )
343
+
344
+ # ------------------------------------------------------------------
345
+ # Phase 3: Assemble PullRequest dataclass objects
346
+ # ------------------------------------------------------------------
347
+ prs: list[PullRequest] = []
348
+ for gh_pr, closes_issues, should_fetch in pr_metadata:
349
+ files_changed = files_map.get(gh_pr.number, ()) if should_fetch else ()
350
+ raw = getattr(gh_pr, "_rawData", None) or {}
351
+ pr = PullRequest(
352
+ number=raw.get("number", gh_pr.number),
353
+ title=raw.get("title", ""),
354
+ merged=raw.get("merged_at") is not None,
355
+ merge_commit_sha=raw.get("merge_commit_sha"),
356
+ closes_issue_numbers=tuple(closes_issues),
357
+ files_changed=files_changed,
358
+ )
359
+ prs.append(pr)
360
+
361
+ logger.info(
362
+ "Successfully fetched %d pull requests for %s/%s in %.2fs total.",
363
+ len(prs), self.owner, self.repo_name, time.perf_counter() - t0,
364
+ )
365
+ if self.ttl > 0:
366
+ self._cache.set(cache_key, prs, expire=self.ttl)
367
+ return prs
368
+
369
+ except RateLimitExceededException as e:
370
+ reset = getattr(e, "reset", None)
371
+ logger.warning("GitHub API rate limit exceeded when fetching PRs for %s/%s", self.owner, self.repo_name)
372
+ raise RateLimitExceededError(reset_at=reset) from e
373
+ except GithubException as e:
374
+ if e.status in (403, 429) and "rate limit" in str(e).lower():
375
+ reset = getattr(e, "reset", None)
376
+ logger.warning("GitHub API rate limit exceeded (HTTP %d) when fetching PRs for %s/%s", e.status, self.owner, self.repo_name)
377
+ raise RateLimitExceededError(reset_at=reset) from e
378
+ raise
379
+
380
+ def get_issue_comments(
381
+ self,
382
+ issue_number: int,
383
+ force_refresh: bool = False,
384
+ ) -> list[str]:
385
+ """Fetch comment bodies for a given issue.
386
+
387
+ Args:
388
+ issue_number: Issue number
389
+ force_refresh: If True, bypass cache and fetch fresh data.
390
+
391
+ Returns:
392
+ List of comment body strings.
393
+
394
+ Raises:
395
+ RateLimitExceededError: If GitHub API rate limit is exceeded.
396
+ """
397
+ cache_key = (self.owner, self.repo_name, "comments", issue_number)
398
+ if not force_refresh and self.ttl > 0:
399
+ cached_data = self._cache.get(cache_key)
400
+ if cached_data is not None:
401
+ return cached_data
402
+
403
+ try:
404
+ gh_repo = self._get_github_repo()
405
+ gh_issue = gh_repo.get_issue(issue_number)
406
+ comments = [comment.body for comment in gh_issue.get_comments() if comment.body]
407
+
408
+ if self.ttl > 0:
409
+ self._cache.set(cache_key, comments, expire=self.ttl)
410
+ return comments
411
+
412
+ except RateLimitExceededException as e:
413
+ reset = getattr(e, "reset", None)
414
+ raise RateLimitExceededError(reset_at=reset) from e
415
+ except GithubException as e:
416
+ if e.status in (403, 429) and "rate limit" in str(e).lower():
417
+ reset = getattr(e, "reset", None)
418
+ raise RateLimitExceededError(reset_at=reset) from e
419
+ raise
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.5
2
+ Name: gitrelevance
3
+ Version: 0.1.0
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/gitrelevance/gitrelevance
6
+ Project-URL: Repository, https://github.com/gitrelevance/gitrelevance
7
+ Project-URL: Issues, https://github.com/gitrelevance/gitrelevance/issues
8
+ Author: GitRelevance Contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Version Control :: Git
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: diskcache>=5.6.0
23
+ Requires-Dist: gitpython>=3.1.0
24
+ Requires-Dist: pygithub>=2.0.0
25
+ Requires-Dist: python-dotenv>=1.0.0
26
+ Requires-Dist: rich>=13.0.0
27
+ Requires-Dist: typer>=0.9.0
28
+ Requires-Dist: typing-extensions; python_version < '3.11'
29
+ Provides-Extra: dev
30
+ Requires-Dist: build>=1.0.0; extra == 'dev'
31
+ Requires-Dist: pytest>=7.0; extra == 'dev'
32
+ Requires-Dist: responses>=0.23.0; extra == 'dev'
33
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # GitRelevance
37
+
38
+ > Analyze whether historical GitHub issues are still relevant to a codebase, using Git history as evidence.
39
+
40
+ [![CI](https://github.com/gitrelevance/gitrelevance/actions/workflows/ci.yml/badge.svg)](https://github.com/gitrelevance/gitrelevance/actions/workflows/ci.yml)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
42
+ [![Python: >=3.10](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
43
+
44
+ ---
45
+
46
+ ## What is GitRelevance?
47
+
48
+ In long-lived repositories, issue trackers often accumulate hundreds of historical bug reports and feature requests. Over time, code is refactored, features are deprecated, or fixes are merged without closing the original issue.
49
+
50
+ **GitRelevance** audits your open and closed GitHub issues against your local Git history. It automatically correlates commit messages, pull request merges, file renames, deletions, and reverts to determine whether an issue is:
51
+ - **`RESOLVED`** — A fix commit exists in `HEAD` history and was not reverted.
52
+ - **`PROBABLY_RESOLVED`** — A fixing PR was merged or substantial fix evidence was detected.
53
+ - **`STILL_RELEVANT`** — The issue is open and related code still actively exists in the tree.
54
+ - **`OBSOLETE`** — The files or feature referenced by the issue were deleted without replacement.
55
+ - **`UNKNOWN`** — No correlating commits or evidence exist in local Git history.
56
+
57
+ Every classification is backed by an explicit, human-readable list of evidence items and a confidence score.
58
+
59
+ ---
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install gitrelevance
65
+ ```
66
+
67
+ For development:
68
+ ```bash
69
+ git clone https://github.com/gitrelevance/gitrelevance.git
70
+ cd gitrelevance
71
+ pip install -e ".[dev]"
72
+ ```
73
+
74
+ Requires Python ≥ 3.10.
75
+
76
+ ---
77
+
78
+ ## Quick Start (CLI)
79
+
80
+ Run `gitrelevance analyze` from inside any local Git clone of a GitHub repository:
81
+
82
+ ```bash
83
+ # Analyze all repository issues
84
+ gitrelevance analyze
85
+
86
+ # Filter by state (open, closed, all)
87
+ gitrelevance analyze --state open
88
+
89
+ # Filter by issue creation date
90
+ gitrelevance analyze --since 2024-01-01
91
+
92
+ # Output structured JSON for automation or CI pipelines
93
+ gitrelevance analyze --json
94
+ ```
95
+
96
+ ### Example Terminal Output
97
+
98
+ ```
99
+ GitRelevance
100
+
101
+ Repository: github.com/owner/my-repo
102
+ Branch: main
103
+ HEAD: a81f23c
104
+
105
+ Analyzing 24 issues...
106
+
107
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
108
+
109
+ RESOLVED
110
+
111
+ #21 Login crashes after token expiration
112
+ Confidence: 96%
113
+
114
+ Evidence:
115
+ ✓ Fix commit is present in HEAD history (a81f23c)
116
+ ✓ Issue referenced in commit message (a81f23c)
117
+ ✓ All related files exist at HEAD
118
+ ✓ No revert of fix commit detected (a81f23c)
119
+
120
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
+
122
+ * Confidence is a heuristic evidence-strength score (0–100%), not a statistical probability.
123
+ ```
124
+
125
+ ---
126
+
127
+ ## How Classification Works
128
+
129
+ GitRelevance evaluates evidence through a multi-stage deterministic pipeline:
130
+
131
+ ```
132
+ ┌─────────────────┐ ┌─────────────────────┐
133
+ │ Local Git Repo │ │ GitHub API Provider │
134
+ └────────┬────────┘ └──────────┬──────────┘
135
+ │ │
136
+ ▼ ▼
137
+ ┌───────────────────────────────────────┐
138
+ │ Matcher (Correlates commits & PRs) │
139
+ └──────────────────┬────────────────────┘
140
+ │
141
+ ▼
142
+ ┌───────────────────────────────────────┐
143
+ │ Current-State Analysis (HEAD status) │
144
+ └──────────────────┬────────────────────┘
145
+ │
146
+ ▼
147
+ ┌───────────────────────────────────────┐
148
+ │ Evidence Collection (Signed weights) │
149
+ └──────────────────┬────────────────────┘
150
+ │
151
+ ▼
152
+ ┌───────────────────────────────────────┐
153
+ │ Confidence & Classifier Decision Tree │
154
+ └──────────────────┬────────────────────┘
155
+ │
156
+ ▼
157
+ ┌───────────────────────────────────────┐
158
+ │ Output (Terminal with Rich or JSON) │
159
+ └───────────────────────────────────────┘
160
+ ```
161
+
162
+ ### Evidence Rules & Signed Weights
163
+ - **Fix commit in HEAD history** (`+3`): A commit referencing `#N` or `GH-N` is an ancestor of `HEAD`.
164
+ - **Linked PR merged** (`+2`): A pull request linked to or closing issue `#N` was merged into the repository.
165
+ - **Related files exist at HEAD** (`+2`): All files modified by the issue's commits/PRs still exist.
166
+ - **No revert detected** (`+1`): The fix commit was not undone by a subsequent `git revert`.
167
+ - **Related files deleted without replacement** (`-3`): Referenced files were deleted in git history and not renamed.
168
+ - **Fix commit later reverted** (`-3`): Git's native revert trailer (`This reverts commit <sha>.`) indicates the fix was undone.
169
+
170
+ ### Confidence Score (Evidence Strength)
171
+
172
+ Confidence is computed as an **evidence-strength heuristic** normalized and clamped to `[0.05, 0.98]`.
173
+
174
+ ```text
175
+ Confidence = 0.50 + (sum(Weights) / (2 × MAX_ABS_WEIGHT))
176
+ ```
177
+ > **Important Note:** Confidence is **NOT** a calibrated Bayesian probability. A confidence of 95% indicates an overwhelming accumulation of strong supporting evidence in the Git tree, not that the classification is statistically 95% likely to be correct.
178
+
179
+ ---
180
+
181
+ ## GitHub Authentication & Rate Limits
182
+
183
+ GitRelevance works unauthenticated for public repositories out of the box.
184
+
185
+ To analyze private repositories or avoid GitHub's unauthenticated API rate limits (60 requests/hour), supply a personal access token via the `GITHUB_TOKEN` environment variable or a local `.env` file:
186
+
187
+ ```bash
188
+ export GITHUB_TOKEN="ghp_yourPersonalAccessTokenHere"
189
+ gitrelevance analyze
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Python API Usage
195
+
196
+ GitRelevance can also be integrated directly into Python scripts:
197
+
198
+ ```python
199
+ from gitrelevance.git.repository import GitRepository
200
+ from gitrelevance.providers.github import GitHubProvider
201
+ from gitrelevance.analysis.engine import AnalysisEngine
202
+ from gitrelevance.output.terminal import TerminalRenderer
203
+ from gitrelevance.output.json import to_json
204
+
205
+ # 1. Initialize Git repository and GitHub provider
206
+ repo = GitRepository(".")
207
+ owner, repo_name = GitHubProvider.parse_remote(repo.remote_url("origin"))
208
+ provider = GitHubProvider(owner=owner, repo=repo_name)
209
+
210
+ # 2. Run analysis
211
+ engine = AnalysisEngine(repo, provider)
212
+ results = engine.analyze(state="open")
213
+
214
+ # 3. Render results
215
+ for result in results:
216
+ print(f"#{result.issue.number} {result.issue.title} -> {result.classification.value} ({result.confidence:.0%})")
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Running Tests
222
+
223
+ ```bash
224
+ # Run complete test suite
225
+ pytest -v
226
+
227
+ # Run individual test layers
228
+ pytest tests/git/ # Git abstraction layer
229
+ pytest tests/providers/ # Provider layer
230
+ pytest tests/analysis/ # Evidence & classification unit tests
231
+ pytest tests/output/ # Terminal & JSON rendering tests
232
+ pytest tests/cli/ # Typer CLI tests
233
+ pytest tests/scenarios/ # End-to-end repository scenarios
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Adding a Scenario Test
239
+
240
+ To add a new end-to-end scenario test:
241
+ 1. Create `tests/scenarios/test_scenario_<name>.py`.
242
+ 2. Import `FakeProvider`, `make_issue`, `make_pr`, and assertion helpers from `tests.scenarios.conftest`.
243
+ 3. Use `RepoBuilder` to construct a real temporary Git repository.
244
+ 4. Execute `AnalysisEngine(repo, provider).analyze()` and assert on the `AnalysisResult` fields.
245
+
246
+ ```python
247
+ from gitrelevance.analysis.engine import AnalysisEngine
248
+ from gitrelevance.git.repository import GitRepository
249
+ from gitrelevance.models import Classification
250
+ from tests.fixtures.repo_builder import RepoBuilder
251
+ from tests.scenarios.conftest import FakeProvider, make_issue
252
+
253
+ def test_my_custom_scenario():
254
+ builder = RepoBuilder().commit("Initial", files={"app.py": "code"})
255
+ path = builder.build()
256
+ try:
257
+ repo = GitRepository(path)
258
+ issue = make_issue(number=99, title="Custom issue", state="closed")
259
+ results = AnalysisEngine(repo, FakeProvider(issues=[issue])).analyze()
260
+ assert results[0].classification == Classification.UNKNOWN
261
+ finally:
262
+ builder.cleanup()
263
+ ```
264
+
265
+ ---
266
+
267
+ ## License
268
+
269
+ This project is licensed under the [MIT License](LICENSE).