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,83 @@
|
|
|
1
|
+
"""JSON serialization for analysis results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from gitrelevance.models import AnalysisResult, EvidenceItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_datetime(dt: datetime | None) -> str | None:
|
|
13
|
+
"""Format datetime to ISO 8601 string if present."""
|
|
14
|
+
return dt.isoformat() if dt is not None else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _evidence_item_to_dict(item: EvidenceItem) -> dict[str, Any]:
|
|
18
|
+
"""Convert an EvidenceItem to a JSON-serializable dictionary."""
|
|
19
|
+
return {
|
|
20
|
+
"category": item.category,
|
|
21
|
+
"description": item.description,
|
|
22
|
+
"source_ref": item.source_ref,
|
|
23
|
+
"weight": item.weight,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _analysis_result_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
|
28
|
+
"""Convert an AnalysisResult to a JSON-serializable dictionary."""
|
|
29
|
+
return {
|
|
30
|
+
"classification": result.classification.value,
|
|
31
|
+
"confidence": result.confidence,
|
|
32
|
+
"confidence_percentage": int(round(result.confidence * 100)),
|
|
33
|
+
"evidence": [_evidence_item_to_dict(e) for e in result.evidence],
|
|
34
|
+
"issue": {
|
|
35
|
+
"closed_at": _format_datetime(result.issue.closed_at),
|
|
36
|
+
"created_at": _format_datetime(result.issue.created_at),
|
|
37
|
+
"labels": list(result.issue.labels),
|
|
38
|
+
"linked_pr_numbers": list(result.issue.linked_pr_numbers),
|
|
39
|
+
"number": result.issue.number,
|
|
40
|
+
"state": result.issue.state,
|
|
41
|
+
"title": result.issue.title,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_json(
|
|
47
|
+
results: list[AnalysisResult],
|
|
48
|
+
repo_info: Mapping[str, Any] | Any,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Serialize the full analysis results and repo info to a JSON string.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
results: List of AnalysisResult objects.
|
|
54
|
+
repo_info: Mapping or object containing repository metadata.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
JSON string with stable key ordering and 2-space indentation.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(repo_info, Mapping):
|
|
60
|
+
repo_dict = {
|
|
61
|
+
"branch": repo_info.get("branch"),
|
|
62
|
+
"owner": repo_info.get("owner"),
|
|
63
|
+
"remote_url": repo_info.get("remote_url"),
|
|
64
|
+
"repo": repo_info.get("repo", repo_info.get("name")),
|
|
65
|
+
"short_sha": str(repo_info.get("short_sha", repo_info.get("head_sha", "")))[:7],
|
|
66
|
+
}
|
|
67
|
+
else:
|
|
68
|
+
repo_dict = {
|
|
69
|
+
"branch": getattr(repo_info, "branch", None),
|
|
70
|
+
"owner": getattr(repo_info, "owner", None),
|
|
71
|
+
"remote_url": getattr(repo_info, "remote_url", None),
|
|
72
|
+
"repo": getattr(repo_info, "repo", getattr(repo_info, "name", None)),
|
|
73
|
+
"short_sha": str(getattr(repo_info, "short_sha", getattr(repo_info, "head_sha", "")))[:7],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
payload = {
|
|
77
|
+
"disclaimer": "Confidence is a heuristic evidence-strength score, not a statistical probability.",
|
|
78
|
+
"repository": repo_dict,
|
|
79
|
+
"results": [_analysis_result_to_dict(r) for r in results],
|
|
80
|
+
"total_issues": len(results),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Terminal renderer using Rich for formatted CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from gitrelevance.models import AnalysisResult, Classification, EvidenceItem
|
|
10
|
+
|
|
11
|
+
# Display ordering for classifications
|
|
12
|
+
CLASSIFICATION_ORDER: list[Classification] = [
|
|
13
|
+
Classification.RESOLVED,
|
|
14
|
+
Classification.PROBABLY_RESOLVED,
|
|
15
|
+
Classification.STILL_RELEVANT,
|
|
16
|
+
Classification.OBSOLETE,
|
|
17
|
+
Classification.UNKNOWN,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Color styles for each classification header
|
|
21
|
+
CLASSIFICATION_STYLES: dict[Classification, str] = {
|
|
22
|
+
Classification.RESOLVED: "bold green",
|
|
23
|
+
Classification.PROBABLY_RESOLVED: "bold cyan",
|
|
24
|
+
Classification.STILL_RELEVANT: "bold yellow",
|
|
25
|
+
Classification.OBSOLETE: "bold red",
|
|
26
|
+
Classification.UNKNOWN: "bold magenta",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Inline label styles for streaming mode (used per-result, not per-group)
|
|
30
|
+
_CLASSIFICATION_LABEL_STYLES: dict[Classification, str] = {
|
|
31
|
+
Classification.RESOLVED: "green",
|
|
32
|
+
Classification.PROBABLY_RESOLVED: "cyan",
|
|
33
|
+
Classification.STILL_RELEVANT: "yellow",
|
|
34
|
+
Classification.OBSOLETE: "red",
|
|
35
|
+
Classification.UNKNOWN: "magenta",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TerminalRenderer:
|
|
40
|
+
"""Renders issue analysis results to the terminal using Rich."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
43
|
+
"""Initialize renderer with an optional Rich Console.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
console: Rich Console instance, or None to use default.
|
|
47
|
+
"""
|
|
48
|
+
self.console = console or Console()
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Streaming API (called incrementally as results arrive)
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _extract_repo_info(repo_info: Mapping[str, Any] | Any) -> dict[str, str]:
|
|
56
|
+
"""Normalise repo_info into a plain dict with known keys."""
|
|
57
|
+
if isinstance(repo_info, Mapping):
|
|
58
|
+
return {
|
|
59
|
+
"owner": repo_info.get("owner", "unknown"),
|
|
60
|
+
"repo": repo_info.get("repo", repo_info.get("name", "unknown")),
|
|
61
|
+
"branch": repo_info.get("branch", "unknown"),
|
|
62
|
+
"short_sha": str(repo_info.get("short_sha", repo_info.get("head_sha", "unknown")))[:7],
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
"owner": getattr(repo_info, "owner", "unknown"),
|
|
66
|
+
"repo": getattr(repo_info, "repo", getattr(repo_info, "name", "unknown")),
|
|
67
|
+
"branch": getattr(repo_info, "branch", "unknown"),
|
|
68
|
+
"short_sha": str(getattr(repo_info, "short_sha", getattr(repo_info, "head_sha", "unknown")))[:7],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def render_header(self, repo_info: Mapping[str, Any] | Any, total_issues: int) -> None:
|
|
72
|
+
"""Print the repository header before streaming begins.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
repo_info: Mapping or object containing repo metadata.
|
|
76
|
+
total_issues: Total number of issues to be analysed.
|
|
77
|
+
"""
|
|
78
|
+
info = self._extract_repo_info(repo_info)
|
|
79
|
+
self.console.print("[bold]GitRelevance[/bold]\n")
|
|
80
|
+
self.console.print(f"Repository: github.com/{info['owner']}/{info['repo']}")
|
|
81
|
+
self.console.print(f"Branch: {info['branch']}")
|
|
82
|
+
self.console.print(f"HEAD: {info['short_sha']}\n")
|
|
83
|
+
self.console.print(
|
|
84
|
+
f"Analyzing {total_issues} issue{'s' if total_issues != 1 else ''}...\n"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def render_result(self, result: AnalysisResult) -> None:
|
|
88
|
+
"""Render a single AnalysisResult inline (streaming mode).
|
|
89
|
+
|
|
90
|
+
Each result is printed with its classification as an inline label
|
|
91
|
+
rather than being grouped by classification.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
result: A single AnalysisResult to render.
|
|
95
|
+
"""
|
|
96
|
+
style = _CLASSIFICATION_LABEL_STYLES.get(result.classification, "white")
|
|
97
|
+
conf_pct = int(round(result.confidence * 100))
|
|
98
|
+
|
|
99
|
+
self.console.print(
|
|
100
|
+
f"[bold]#{result.issue.number} {result.issue.title}[/bold]"
|
|
101
|
+
f" [{style}]{result.classification.value}[/{style}]"
|
|
102
|
+
f" [dim]{conf_pct}%[/dim]"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if result.evidence:
|
|
106
|
+
for ev in result.evidence:
|
|
107
|
+
line = self._format_evidence_item(ev)
|
|
108
|
+
self.console.print(f" [green]✓[/green] {line}")
|
|
109
|
+
else:
|
|
110
|
+
self.console.print(" [dim]No evidence[/dim]")
|
|
111
|
+
self.console.print("")
|
|
112
|
+
|
|
113
|
+
def render_footer(self) -> None:
|
|
114
|
+
"""Print the trailing disclaimer after all results have been streamed."""
|
|
115
|
+
separator = "━" * 40
|
|
116
|
+
self.console.print(f"[dim]{separator}[/dim]\n")
|
|
117
|
+
self.console.print(
|
|
118
|
+
"[dim]* Confidence is a heuristic evidence-strength score (0–100%), not a statistical probability.[/dim]"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Batch API (backward-compatible, used by tests and --json fallback)
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def render(
|
|
126
|
+
self,
|
|
127
|
+
results: list[AnalysisResult],
|
|
128
|
+
repo_info: Mapping[str, Any] | Any,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Render the complete analysis report to the console.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
results: List of AnalysisResult objects.
|
|
134
|
+
repo_info: Mapping or object containing repo metadata:
|
|
135
|
+
owner, repo (or name), branch, short_sha (or head_sha).
|
|
136
|
+
"""
|
|
137
|
+
# Extract repo metadata
|
|
138
|
+
info = self._extract_repo_info(repo_info)
|
|
139
|
+
|
|
140
|
+
# Header
|
|
141
|
+
self.console.print("[bold]GitRelevance[/bold]\n")
|
|
142
|
+
self.console.print(f"Repository: github.com/{info['owner']}/{info['repo']}")
|
|
143
|
+
self.console.print(f"Branch: {info['branch']}")
|
|
144
|
+
self.console.print(f"HEAD: {info['short_sha']}\n")
|
|
145
|
+
self.console.print(f"Analyzing {len(results)} issue{'s' if len(results) != 1 else ''}...\n")
|
|
146
|
+
|
|
147
|
+
# Group results by classification
|
|
148
|
+
grouped: dict[Classification, list[AnalysisResult]] = {c: [] for c in CLASSIFICATION_ORDER}
|
|
149
|
+
for res in results:
|
|
150
|
+
if res.classification in grouped:
|
|
151
|
+
grouped[res.classification].append(res)
|
|
152
|
+
else:
|
|
153
|
+
grouped.setdefault(res.classification, []).append(res)
|
|
154
|
+
|
|
155
|
+
# Print each group that has results
|
|
156
|
+
separator = "━" * 40
|
|
157
|
+
for classification in CLASSIFICATION_ORDER:
|
|
158
|
+
items = grouped.get(classification, [])
|
|
159
|
+
if not items:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
self.console.print(f"[dim]{separator}[/dim]\n")
|
|
163
|
+
style = CLASSIFICATION_STYLES.get(classification, "bold white")
|
|
164
|
+
self.console.print(f"[{style}]{classification.value}[/{style}]\n")
|
|
165
|
+
|
|
166
|
+
for res in items:
|
|
167
|
+
# Issue title & number
|
|
168
|
+
self.console.print(f"[bold]#{res.issue.number} {res.issue.title}[/bold]")
|
|
169
|
+
# Confidence percentage
|
|
170
|
+
conf_pct = int(round(res.confidence * 100))
|
|
171
|
+
self.console.print(f"Confidence: {conf_pct}%\n")
|
|
172
|
+
|
|
173
|
+
# Evidence items
|
|
174
|
+
if res.evidence:
|
|
175
|
+
self.console.print("Evidence:")
|
|
176
|
+
for ev in res.evidence:
|
|
177
|
+
line = self._format_evidence_item(ev)
|
|
178
|
+
self.console.print(f" [green]✓[/green] {line}")
|
|
179
|
+
else:
|
|
180
|
+
self.console.print("Evidence: None")
|
|
181
|
+
self.console.print("")
|
|
182
|
+
|
|
183
|
+
self.console.print(f"[dim]{separator}[/dim]\n")
|
|
184
|
+
self.console.print(
|
|
185
|
+
"[dim]* Confidence is a heuristic evidence-strength score (0–100%), not a statistical probability.[/dim]"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _format_evidence_item(self, item: EvidenceItem) -> str:
|
|
189
|
+
"""Format an individual evidence item for terminal display.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
item: EvidenceItem to format.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Human-readable string representation with source_ref if applicable.
|
|
196
|
+
"""
|
|
197
|
+
desc = item.description
|
|
198
|
+
if item.source_ref:
|
|
199
|
+
# If source_ref is already mentioned in description, don't repeat
|
|
200
|
+
if item.source_ref not in desc:
|
|
201
|
+
return f"{desc} ({item.source_ref})"
|
|
202
|
+
return desc
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Provider interfaces and implementations for issue trackers."""
|
|
2
|
+
|
|
3
|
+
from gitrelevance.providers.base import GitRelevanceError, Provider, RateLimitExceededError
|
|
4
|
+
from gitrelevance.providers.github import GitHubProvider
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Provider",
|
|
8
|
+
"GitHubProvider",
|
|
9
|
+
"GitRelevanceError",
|
|
10
|
+
"RateLimitExceededError",
|
|
11
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Base provider protocol and provider-related exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from gitrelevance.issues.models import Issue, PullRequest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitRelevanceError(Exception):
|
|
12
|
+
"""Base exception for all GitRelevance errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RateLimitExceededError(GitRelevanceError):
|
|
16
|
+
"""Raised when provider API rate limit is exceeded."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, reset_at: datetime | int | float | str | None = None, message: str | None = None) -> None:
|
|
19
|
+
self.reset_at = reset_at
|
|
20
|
+
if message:
|
|
21
|
+
msg = message
|
|
22
|
+
elif reset_at is not None:
|
|
23
|
+
msg = f"API rate limit exceeded. Reset time: {reset_at}"
|
|
24
|
+
else:
|
|
25
|
+
msg = "API rate limit exceeded."
|
|
26
|
+
super().__init__(msg)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class Provider(Protocol):
|
|
31
|
+
"""Protocol defining the interface for issue tracker providers."""
|
|
32
|
+
|
|
33
|
+
def parse_remote(self, remote_url: str) -> tuple[str, str] | None:
|
|
34
|
+
"""Parse a remote URL into (owner, repo) pair.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
remote_url: Remote URL string (git@..., https://..., etc.)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (owner, repo) if valid for this provider, None otherwise.
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def get_issues(self, state: Literal["open", "closed", "all"] = "all") -> list[Issue]:
|
|
45
|
+
"""Fetch repository issues.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
state: Issue state filter ("open", "closed", or "all")
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of Issue dataclasses.
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def get_pull_requests(self, state: Literal["open", "closed", "all"] = "all") -> list[PullRequest]:
|
|
56
|
+
"""Fetch repository pull requests.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
state: Pull request state filter ("open", "closed", or "all")
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of PullRequest dataclasses.
|
|
63
|
+
"""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def get_issue_comments(self, issue_number: int) -> list[str]:
|
|
67
|
+
"""Fetch comment bodies for a given issue.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
issue_number: Issue number
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of comment body strings.
|
|
74
|
+
"""
|
|
75
|
+
...
|