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,185 @@
|
|
|
1
|
+
"""File change tracking and file history operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import git as gitmodule
|
|
9
|
+
|
|
10
|
+
from gitrelevance.git.commits import Commit
|
|
11
|
+
from gitrelevance.git.repository import GitRepository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class FileChange:
|
|
16
|
+
"""Represents a change to a file in a commit.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
path: Current path of the file
|
|
20
|
+
change_type: Type of change (added, modified, deleted, renamed)
|
|
21
|
+
old_path: Previous path if the file was renamed, None otherwise
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
change_type: Literal["added", "modified", "deleted", "renamed"]
|
|
26
|
+
old_path: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileOperations:
|
|
30
|
+
"""Provides file-related operations for GitRepository.
|
|
31
|
+
|
|
32
|
+
This class wraps a GitRepository to add file-related functionality
|
|
33
|
+
without modifying the main class.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, repo: GitRepository) -> None:
|
|
37
|
+
"""Initialize with a GitRepository instance.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
repo: The GitRepository to operate on
|
|
41
|
+
"""
|
|
42
|
+
self._repo = repo
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def _git_repo(self) -> gitmodule.Repo:
|
|
46
|
+
"""Access the underlying GitPython repo object."""
|
|
47
|
+
return self._repo._repo # type: ignore[return-value]
|
|
48
|
+
|
|
49
|
+
def file_exists_at_head(self, path: str) -> bool:
|
|
50
|
+
"""Check if a file exists at HEAD.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path: Path to the file relative to repository root
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if the file exists at HEAD
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
self._git_repo.head.commit.tree[path]
|
|
60
|
+
return True
|
|
61
|
+
except (KeyError, Exception):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def file_history(self, path: str) -> list[Commit]:
|
|
65
|
+
"""Get the history of a file, following renames.
|
|
66
|
+
|
|
67
|
+
Uses ``git log --follow`` (not ``git rev-list --follow``, which is
|
|
68
|
+
unsupported) so that rename tracking works correctly.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: Current path to the file
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of commits that touched this file, newest first
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
# git log --follow --format=%H emits one sha per commit, newest first.
|
|
78
|
+
# We must call git log (not rev-list); --follow is only a log option.
|
|
79
|
+
output: str = self._git_repo.git.log(
|
|
80
|
+
"--follow",
|
|
81
|
+
"--format=%H",
|
|
82
|
+
"--",
|
|
83
|
+
path,
|
|
84
|
+
)
|
|
85
|
+
except gitmodule.GitCommandError:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
if not output.strip():
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
commits: list[Commit] = []
|
|
92
|
+
for sha in output.strip().splitlines():
|
|
93
|
+
sha = sha.strip()
|
|
94
|
+
if sha:
|
|
95
|
+
try:
|
|
96
|
+
git_commit = self._git_repo.commit(sha)
|
|
97
|
+
commits.append(Commit.from_gitpython(git_commit))
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
return commits
|
|
101
|
+
|
|
102
|
+
def was_file_deleted(self, path: str) -> bool:
|
|
103
|
+
"""Check if a file was deleted (does not exist at HEAD).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: Path to check
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the file does not exist at HEAD but existed in history
|
|
110
|
+
"""
|
|
111
|
+
if self.file_exists_at_head(path):
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
# Check whether the path ever appeared in commit history
|
|
115
|
+
try:
|
|
116
|
+
output: str = self._git_repo.git.log(
|
|
117
|
+
"--format=%H",
|
|
118
|
+
"--max-count=1",
|
|
119
|
+
"--diff-filter=D",
|
|
120
|
+
"--",
|
|
121
|
+
path,
|
|
122
|
+
)
|
|
123
|
+
# If git log --diff-filter=D finds anything, the file was deleted
|
|
124
|
+
if output.strip():
|
|
125
|
+
return True
|
|
126
|
+
# Also check if it was ever added at all (it might be renamed out)
|
|
127
|
+
output2: str = self._git_repo.git.log(
|
|
128
|
+
"--follow",
|
|
129
|
+
"--format=%H",
|
|
130
|
+
"--max-count=1",
|
|
131
|
+
"--",
|
|
132
|
+
path,
|
|
133
|
+
)
|
|
134
|
+
return bool(output2.strip())
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def find_renames(self, path: str) -> list[tuple[str, str]]:
|
|
139
|
+
"""Find all renames of a file, returning (old_path, new_path) pairs.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
path: Current or historical path of the file
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of (old_path, new_path) tuples showing the rename chain,
|
|
146
|
+
in chronological order (oldest rename first).
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
# -M enables rename detection across repository commits
|
|
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
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Convenience function to get file operations for a repository
|
|
176
|
+
def get_file_operations(repo: GitRepository) -> FileOperations:
|
|
177
|
+
"""Get a FileOperations instance for a repository.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
repo: The GitRepository to get file operations for
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
FileOperations instance
|
|
184
|
+
"""
|
|
185
|
+
return FileOperations(repo)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Commit reference index and revert detection for issue tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from gitrelevance.git.commits import Commit
|
|
9
|
+
from gitrelevance.git.repository import GitRepository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Cache key for the commit reference index on GitRepository instances
|
|
13
|
+
_INDEX_CACHE_KEY = "_gitrelevance_commit_reference_index"
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Issue-number extraction helpers (private)
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
# Matches any #123-style number in a message body
|
|
20
|
+
_HASH_ISSUE_RE = re.compile(r"#(\d+)")
|
|
21
|
+
|
|
22
|
+
# Matches GH-123-style references (unambiguous, never a PR number)
|
|
23
|
+
_GH_ISSUE_RE = re.compile(r"GH-(\d+)", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
# Detects PR-number contexts like "pull request #42" or "PR #42".
|
|
26
|
+
# We check whether the 30 characters *before* the # match this pattern.
|
|
27
|
+
_PR_CONTEXT_RE = re.compile(r"(?:pull\s+request|PR)\s+$", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_issue_numbers(message: str) -> list[int]:
|
|
31
|
+
"""Extract genuine issue numbers from a commit message.
|
|
32
|
+
|
|
33
|
+
Matches ``#123`` and ``GH-123`` patterns, but **excludes** numbers that
|
|
34
|
+
appear in PR-number contexts (e.g. ``pull request #42``, ``PR #42``).
|
|
35
|
+
Those are GitHub pull-request identifiers, *not* issue references, and
|
|
36
|
+
attributing a commit's file changes to a PR number causes evidence
|
|
37
|
+
cross-contamination across unrelated issues.
|
|
38
|
+
|
|
39
|
+
Returns a de-duplicated list of issue numbers in first-seen order.
|
|
40
|
+
"""
|
|
41
|
+
numbers: list[int] = []
|
|
42
|
+
seen: set[int] = set()
|
|
43
|
+
|
|
44
|
+
# --- #NNN references ---
|
|
45
|
+
for match in _HASH_ISSUE_RE.finditer(message):
|
|
46
|
+
num = int(match.group(1))
|
|
47
|
+
if num in seen:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
start = match.start()
|
|
51
|
+
|
|
52
|
+
# Require a word boundary before '#': whitespace or start-of-string.
|
|
53
|
+
# This prevents matching inside URLs, file paths, or parenthetical
|
|
54
|
+
# PR-title patterns like "Feature (#42)".
|
|
55
|
+
if start > 0 and not message[start - 1].isspace():
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Filter PR-number false positives.
|
|
59
|
+
# In squash-merge / merge-commit messages the subject line often
|
|
60
|
+
# reads "Merge pull request #42 from …". The #42 there is a PR
|
|
61
|
+
# number, not an issue number. We check the ~30 characters of
|
|
62
|
+
# context preceding the # to decide.
|
|
63
|
+
prefix = message[max(0, start - 30) : start]
|
|
64
|
+
if _PR_CONTEXT_RE.search(prefix):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
seen.add(num)
|
|
68
|
+
numbers.append(num)
|
|
69
|
+
|
|
70
|
+
# --- GH-NNN references (always unambiguous) ---
|
|
71
|
+
for match in _GH_ISSUE_RE.finditer(message):
|
|
72
|
+
num = int(match.group(1))
|
|
73
|
+
if num not in seen:
|
|
74
|
+
seen.add(num)
|
|
75
|
+
numbers.append(num)
|
|
76
|
+
|
|
77
|
+
return numbers
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]:
|
|
81
|
+
"""Build an index mapping issue numbers to commits that reference them.
|
|
82
|
+
|
|
83
|
+
This function walks the commit log exactly once and caches the result
|
|
84
|
+
on the repository instance for reuse. This is critical for performance
|
|
85
|
+
with large repositories (10,000+ commits).
|
|
86
|
+
|
|
87
|
+
Issue references are detected by patterns like #123 or GH-123 in commit
|
|
88
|
+
messages, where N is an issue number. PR-number references (e.g.
|
|
89
|
+
``pull request #42``, ``PR #42``) are explicitly excluded to prevent
|
|
90
|
+
evidence cross-contamination.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
repo: The GitRepository to build the index for
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dictionary mapping issue numbers to lists of commits referencing them
|
|
97
|
+
"""
|
|
98
|
+
# Check if index already cached on repo instance
|
|
99
|
+
if hasattr(repo, _INDEX_CACHE_KEY):
|
|
100
|
+
return getattr(repo, _INDEX_CACHE_KEY)
|
|
101
|
+
|
|
102
|
+
# Build the index by walking commits once
|
|
103
|
+
index: dict[int, list[Commit]] = {}
|
|
104
|
+
|
|
105
|
+
for commit in repo.commits_since(None):
|
|
106
|
+
issue_nums = _extract_issue_numbers(commit.message)
|
|
107
|
+
for num in issue_nums:
|
|
108
|
+
if num not in index:
|
|
109
|
+
index[num] = []
|
|
110
|
+
index[num].append(commit)
|
|
111
|
+
|
|
112
|
+
# Cache the index on the repo instance
|
|
113
|
+
setattr(repo, _INDEX_CACHE_KEY, index)
|
|
114
|
+
|
|
115
|
+
return index
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def commits_referencing(repo: GitRepository, issue_number: int) -> list[Commit]:
|
|
119
|
+
"""Get all commits that reference a specific issue.
|
|
120
|
+
|
|
121
|
+
Uses the cached index instead of scanning history itself.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
repo: The GitRepository to search
|
|
125
|
+
issue_number: The issue number to find commits for
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of commits referencing the issue, newest first
|
|
129
|
+
"""
|
|
130
|
+
index = build_commit_reference_index(repo)
|
|
131
|
+
return index.get(issue_number, [])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RevertDetector(Protocol):
|
|
135
|
+
"""Protocol for detecting reverts of commits.
|
|
136
|
+
|
|
137
|
+
Future implementations could use inverse-diff comparison,
|
|
138
|
+
message heuristics, patch similarity, or semantic analysis.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def find_reverts_of(self, repo: GitRepository, commit_sha: str) -> list[Commit]:
|
|
142
|
+
"""Find commits that revert the specified commit.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
repo: The GitRepository to search
|
|
146
|
+
commit_sha: SHA of the commit to find reverts of
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of commits that revert the specified commit
|
|
150
|
+
"""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class GitNativeRevertDetector:
|
|
155
|
+
"""MVP implementation: detects only commits Git's own revert machinery produced.
|
|
156
|
+
|
|
157
|
+
This detector uses Git's native revert detection, which relies on
|
|
158
|
+
the parent/trailer relationship that 'git revert' creates. It does
|
|
159
|
+
NOT use free-text commit-message pattern matching or inverse-diff
|
|
160
|
+
comparison.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def find_reverts_of(self, repo: GitRepository, commit_sha: str) -> list[Commit]:
|
|
164
|
+
"""Find commits that revert the specified commit using Git's native detection.
|
|
165
|
+
|
|
166
|
+
``git revert`` always inserts exactly this sentence into the commit
|
|
167
|
+
message::
|
|
168
|
+
|
|
169
|
+
This reverts commit <full-sha>.
|
|
170
|
+
|
|
171
|
+
That line is generated by Git's own revert machinery and is the
|
|
172
|
+
authoritative signal we use. We deliberately do NOT match commits
|
|
173
|
+
that merely mention "revert" in their message (the fake-revert test
|
|
174
|
+
exercises this).
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
repo: The GitRepository to search
|
|
178
|
+
commit_sha: SHA of the commit to find reverts of
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of commits that revert the specified commit, or empty list
|
|
182
|
+
"""
|
|
183
|
+
# Resolve the full SHA so partial SHAs compare correctly
|
|
184
|
+
target_commit = repo.get_commit(commit_sha)
|
|
185
|
+
if target_commit is None:
|
|
186
|
+
return []
|
|
187
|
+
full_sha = target_commit.sha
|
|
188
|
+
|
|
189
|
+
reverts: list[Commit] = []
|
|
190
|
+
for commit in repo.commits_since(None):
|
|
191
|
+
# Look for the exact Git-generated trailer line.
|
|
192
|
+
# git revert writes: "This reverts commit <sha>."
|
|
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)
|
|
196
|
+
|
|
197
|
+
return reverts
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Default detector instance (module-level singleton)
|
|
201
|
+
_default_detector: GitNativeRevertDetector | None = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def default_revert_detector() -> RevertDetector:
|
|
205
|
+
"""Get the default revert detector.
|
|
206
|
+
|
|
207
|
+
Returns the singleton instance of GitNativeRevertDetector.
|
|
208
|
+
This can be swapped out later by reassigning the module-level variable
|
|
209
|
+
or by providing an alternative factory.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The default RevertDetector implementation
|
|
213
|
+
"""
|
|
214
|
+
global _default_detector
|
|
215
|
+
if _default_detector is None:
|
|
216
|
+
_default_detector = GitNativeRevertDetector()
|
|
217
|
+
return _default_detector
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""GitRepository class for accessing Git repository data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
import git
|
|
9
|
+
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
|
10
|
+
|
|
11
|
+
from gitrelevance.git.commits import Commit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotAGitRepositoryError(Exception):
|
|
15
|
+
"""Raised when the provided path is not a valid Git repository."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: str) -> None:
|
|
18
|
+
"""Initialize the exception.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: The path that was not a valid Git repository
|
|
22
|
+
"""
|
|
23
|
+
self.path = path
|
|
24
|
+
super().__init__(f"Not a Git repository: {path}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitRepository:
|
|
28
|
+
"""A read-only wrapper around a GitPython repository.
|
|
29
|
+
|
|
30
|
+
This class provides a clean interface for reading Git repository data
|
|
31
|
+
without modifying the .git directory.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
path: Absolute path to the repository root
|
|
35
|
+
_repo: The underlying GitPython Repo object
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path: str) -> None:
|
|
39
|
+
"""Initialize the GitRepository.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
path: Path to the Git repository root (or a directory within it)
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
NotAGitRepositoryError: If the path is not a valid Git repository
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
self._repo = git.Repo(path, search_parent_directories=True)
|
|
49
|
+
self.path = str(self._repo.working_dir)
|
|
50
|
+
except (InvalidGitRepositoryError, NoSuchPathError, git.exc.GitError) as e:
|
|
51
|
+
raise NotAGitRepositoryError(path) from e
|
|
52
|
+
|
|
53
|
+
def current_branch(self) -> str:
|
|
54
|
+
"""Get the name of the current branch.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Name of the current branch (without 'refs/heads/' prefix)
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
RuntimeError: If in detached HEAD state
|
|
61
|
+
"""
|
|
62
|
+
if self._repo.head.is_detached:
|
|
63
|
+
raise RuntimeError("Repository is in detached HEAD state")
|
|
64
|
+
return self._repo.active_branch.name
|
|
65
|
+
|
|
66
|
+
def head_commit(self) -> Commit:
|
|
67
|
+
"""Get the commit at HEAD.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Commit object for HEAD
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
RuntimeError: If there are no commits yet
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
return Commit.from_gitpython(self._repo.head.commit)
|
|
77
|
+
except (TypeError, AttributeError):
|
|
78
|
+
raise RuntimeError("Repository has no commits yet")
|
|
79
|
+
|
|
80
|
+
def remote_url(self, name: str = "origin") -> str | None:
|
|
81
|
+
"""Get the URL of a remote.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
name: Name of the remote (default: "origin")
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Remote URL if the remote exists, None otherwise
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
remote = self._repo.remote(name)
|
|
91
|
+
if remote.refs:
|
|
92
|
+
return str(remote.url)
|
|
93
|
+
return str(remote.url) if remote.url else None
|
|
94
|
+
except (ValueError, AttributeError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def get_commit(self, sha: str) -> Commit | None:
|
|
98
|
+
"""Get a commit by its SHA.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
sha: Full or abbreviated commit SHA
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Commit object if found, None otherwise
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
commit = self._repo.commit(sha)
|
|
108
|
+
return Commit.from_gitpython(commit)
|
|
109
|
+
except (git.BadName, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def commit_exists(self, sha: str) -> bool:
|
|
113
|
+
"""Check if a commit exists.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
sha: Full or abbreviated commit SHA
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if the commit exists, False otherwise
|
|
120
|
+
"""
|
|
121
|
+
return self.get_commit(sha) is not None
|
|
122
|
+
|
|
123
|
+
def is_ancestor(self, ancestor_sha: str, descendant_sha: str) -> bool:
|
|
124
|
+
"""Check if one commit is an ancestor of another.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
ancestor_sha: Potential ancestor commit SHA
|
|
128
|
+
descendant_sha: Potential descendant commit SHA
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if ancestor_sha is an ancestor of descendant_sha
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
git.GitCommandError: If either commit doesn't exist
|
|
135
|
+
"""
|
|
136
|
+
# Use git merge-base --is-ancestor which returns 0 if true, 1 if false
|
|
137
|
+
try:
|
|
138
|
+
self._repo.git.merge_base("--is-ancestor", ancestor_sha, descendant_sha)
|
|
139
|
+
return True # git command succeeded, so it is an ancestor
|
|
140
|
+
except git.GitCommandError:
|
|
141
|
+
return False # exit code 1 means not an ancestor
|
|
142
|
+
|
|
143
|
+
def commits_since(self, since: datetime | None = None) -> Iterator[Commit]:
|
|
144
|
+
"""Get commits since a given datetime.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
since: Optional datetime to filter commits from. If None, returns all commits.
|
|
148
|
+
|
|
149
|
+
Yields:
|
|
150
|
+
Commit objects in reverse chronological order
|
|
151
|
+
"""
|
|
152
|
+
kwargs: dict[str, object] = {"reverse": False} # newest first
|
|
153
|
+
|
|
154
|
+
if since is not None:
|
|
155
|
+
# Ensure we have timezone-aware datetime
|
|
156
|
+
if since.tzinfo is None:
|
|
157
|
+
since = since.replace(tzinfo=timezone.utc)
|
|
158
|
+
kwargs["since"] = since
|
|
159
|
+
|
|
160
|
+
for commit in self._repo.iter_commits(**kwargs):
|
|
161
|
+
yield Commit.from_gitpython(commit)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Issue and Pull Request data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Issue:
|
|
12
|
+
"""Represents a GitHub issue.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
number: Issue number
|
|
16
|
+
title: Issue title
|
|
17
|
+
body: Issue body text
|
|
18
|
+
state: State of issue ("open" or "closed")
|
|
19
|
+
created_at: Datetime issue was created
|
|
20
|
+
closed_at: Datetime issue was closed, or None if open
|
|
21
|
+
labels: Tuple of label names attached to the issue
|
|
22
|
+
linked_pr_numbers: Tuple of PR numbers linked to this issue
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
number: int
|
|
26
|
+
title: str
|
|
27
|
+
body: str
|
|
28
|
+
state: Literal["open", "closed"]
|
|
29
|
+
created_at: datetime
|
|
30
|
+
closed_at: datetime | None
|
|
31
|
+
labels: tuple[str, ...]
|
|
32
|
+
linked_pr_numbers: tuple[int, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class PullRequest:
|
|
37
|
+
"""Represents a GitHub pull request.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
number: Pull request number
|
|
41
|
+
title: Pull request title
|
|
42
|
+
merged: Whether the pull request was merged
|
|
43
|
+
merge_commit_sha: SHA of the merge commit, if merged
|
|
44
|
+
closes_issue_numbers: Tuple of issue numbers closed by this PR
|
|
45
|
+
files_changed: Tuple of file paths changed in this PR
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
number: int
|
|
49
|
+
title: str
|
|
50
|
+
merged: bool
|
|
51
|
+
merge_commit_sha: str | None
|
|
52
|
+
closes_issue_numbers: tuple[int, ...]
|
|
53
|
+
files_changed: tuple[str, ...]
|
gitrelevance/models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared core data models for GitRelevance analysis and reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from gitrelevance.issues.models import Issue
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Classification(str, Enum):
|
|
13
|
+
"""Classification of issue relevance relative to current Git repository state."""
|
|
14
|
+
|
|
15
|
+
RESOLVED = "RESOLVED"
|
|
16
|
+
PROBABLY_RESOLVED = "PROBABLY_RESOLVED"
|
|
17
|
+
STILL_RELEVANT = "STILL_RELEVANT"
|
|
18
|
+
OBSOLETE = "OBSOLETE"
|
|
19
|
+
UNKNOWN = "UNKNOWN"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class EvidenceItem:
|
|
24
|
+
"""Individual piece of evidence supporting or opposing issue relevance.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
description: Human-readable explanation of the evidence.
|
|
28
|
+
weight: Signed integer weight indicating evidence strength (positive = resolved, negative = obsolete/unresolved).
|
|
29
|
+
category: Evidence category ("strong", "medium", or "obsolescence").
|
|
30
|
+
source_ref: Optional reference string (e.g. commit SHA or PR number).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
description: str
|
|
34
|
+
weight: int
|
|
35
|
+
category: Literal["strong", "medium", "obsolescence"]
|
|
36
|
+
source_ref: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class AnalysisResult:
|
|
41
|
+
"""Complete analysis result for a single issue.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
issue: The issue being analyzed.
|
|
45
|
+
classification: Final classification decision.
|
|
46
|
+
confidence: Evidence-strength score clamped to [0.05, 0.98].
|
|
47
|
+
This is a relative evidence-strength heuristic, NOT a calibrated
|
|
48
|
+
statistical probability. A higher value means more and stronger
|
|
49
|
+
evidence supports the classification, not that the classification
|
|
50
|
+
is more likely to be correct in a Bayesian sense.
|
|
51
|
+
evidence: Tuple of collected evidence items explaining the decision.
|
|
52
|
+
Each item carries a human-readable description, a signed weight,
|
|
53
|
+
a category, and an optional source_ref (short commit SHA or
|
|
54
|
+
"PR #N") so the result is self-contained for display.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
issue: Issue
|
|
58
|
+
classification: Classification
|
|
59
|
+
confidence: float
|
|
60
|
+
evidence: tuple[EvidenceItem, ...]
|