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
gitrelevance/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from gitrelevance.analysis.classifier import classify
|
|
2
|
+
from gitrelevance.analysis.confidence import compute_confidence
|
|
3
|
+
from gitrelevance.analysis.current_state import CurrentStateFacts, analyze_current_state
|
|
4
|
+
from gitrelevance.analysis.engine import AnalysisEngine
|
|
5
|
+
from gitrelevance.analysis.evidence import collect_evidence
|
|
6
|
+
from gitrelevance.analysis.matcher import MatchSet, build_all_match_sets, build_match_set
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MatchSet",
|
|
10
|
+
"build_match_set",
|
|
11
|
+
"build_all_match_sets",
|
|
12
|
+
"CurrentStateFacts",
|
|
13
|
+
"analyze_current_state",
|
|
14
|
+
"collect_evidence",
|
|
15
|
+
"compute_confidence",
|
|
16
|
+
"classify",
|
|
17
|
+
"AnalysisEngine",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Classification decision engine for correlating issue relevance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from gitrelevance.analysis.evidence import WEIGHT_FIX_LATER_REVERTED
|
|
6
|
+
from gitrelevance.issues.models import Issue
|
|
7
|
+
from gitrelevance.models import Classification, EvidenceItem
|
|
8
|
+
|
|
9
|
+
# Threshold constants for classification decisions
|
|
10
|
+
RESOLVED_THRESHOLD = 3
|
|
11
|
+
PROBABLE_THRESHOLD = 2
|
|
12
|
+
OBSOLETE_THRESHOLD = -3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def classify(issue: Issue, evidence: tuple[EvidenceItem, ...]) -> Classification:
|
|
16
|
+
"""Classify an issue's current relevance based on collected evidence items.
|
|
17
|
+
|
|
18
|
+
Decision Order:
|
|
19
|
+
1. Strong evidence >= RESOLVED_THRESHOLD and no unresolved revert evidence -> RESOLVED
|
|
20
|
+
2. Strong evidence >= PROBABLE_THRESHOLD and no unresolved revert evidence -> PROBABLY_RESOLVED
|
|
21
|
+
3. Obsolescence evidence <= OBSOLETE_THRESHOLD (and no strong fix) -> OBSOLETE
|
|
22
|
+
4. Open issue with any evidence present -> STILL_RELEVANT
|
|
23
|
+
5. Fallback -> UNKNOWN (covers no evidence or inconclusive evidence)
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
issue: The Issue being classified.
|
|
27
|
+
evidence: Tuple of collected EvidenceItem instances.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Classification decision enum value.
|
|
31
|
+
"""
|
|
32
|
+
strong_score = sum(item.weight for item in evidence if item.category == "strong")
|
|
33
|
+
obsolescence_score = sum(item.weight for item in evidence if item.category == "obsolescence")
|
|
34
|
+
|
|
35
|
+
has_unresolved_revert = any(
|
|
36
|
+
item.weight == WEIGHT_FIX_LATER_REVERTED or "reverted" in item.description.lower()
|
|
37
|
+
for item in evidence
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# 1. Resolved: Strong evidence threshold met and no fix-revert detected
|
|
41
|
+
if strong_score >= RESOLVED_THRESHOLD and not has_unresolved_revert:
|
|
42
|
+
return Classification.RESOLVED
|
|
43
|
+
|
|
44
|
+
# 2. Probably Resolved: Moderate strong evidence threshold met and no fix-revert detected
|
|
45
|
+
if strong_score >= PROBABLE_THRESHOLD and not has_unresolved_revert:
|
|
46
|
+
return Classification.PROBABLY_RESOLVED
|
|
47
|
+
|
|
48
|
+
# 3. Obsolete: Obsolescence evidence threshold met (e.g. deleted files without replacement)
|
|
49
|
+
# Note: If a fix commit was reverted, it invalidates strong fix evidence. If the issue is closed
|
|
50
|
+
# and feature code was deleted, it falls into OBSOLETE; otherwise if open, into STILL_RELEVANT.
|
|
51
|
+
if obsolescence_score <= OBSOLETE_THRESHOLD and not (issue.state == "open" and has_unresolved_revert):
|
|
52
|
+
return Classification.OBSOLETE
|
|
53
|
+
|
|
54
|
+
# 4. Still Relevant: Open issue with active related code or unresolved evidence
|
|
55
|
+
if issue.state == "open" and len(evidence) > 0:
|
|
56
|
+
return Classification.STILL_RELEVANT
|
|
57
|
+
|
|
58
|
+
# 5. Fallback: Closed issue without fix commits or inconclusive evidence
|
|
59
|
+
return Classification.UNKNOWN
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Confidence score calculation for evidence sets.
|
|
2
|
+
|
|
3
|
+
Calculates an evidence-strength heuristic score between 0.05 and 0.98.
|
|
4
|
+
Note: This score is a relative evidence-strength heuristic, NOT a calibrated
|
|
5
|
+
statistical probability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from gitrelevance.models import EvidenceItem
|
|
11
|
+
|
|
12
|
+
# Named constants for normalization and clamping
|
|
13
|
+
MAX_ABS_WEIGHT = 20.0
|
|
14
|
+
MIN_CONFIDENCE = 0.05
|
|
15
|
+
MAX_CONFIDENCE = 0.98
|
|
16
|
+
DEFAULT_CONFIDENCE = 0.50
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_confidence(evidence: tuple[EvidenceItem, ...]) -> float:
|
|
20
|
+
"""Compute an evidence-strength score normalized and clamped to [0.05, 0.98].
|
|
21
|
+
|
|
22
|
+
The formula maps evidence weight sums around a baseline of 0.50:
|
|
23
|
+
confidence = 0.5 + sum(weights) / (2 * MAX_ABS_WEIGHT)
|
|
24
|
+
|
|
25
|
+
If no evidence items exist, returns the default neutral score (0.50).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
evidence: Tuple of EvidenceItem instances.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Float confidence score clamped to [MIN_CONFIDENCE, MAX_CONFIDENCE].
|
|
32
|
+
"""
|
|
33
|
+
if not evidence:
|
|
34
|
+
return DEFAULT_CONFIDENCE
|
|
35
|
+
|
|
36
|
+
total_weight = sum(item.weight for item in evidence)
|
|
37
|
+
raw_score = 0.5 + (total_weight / (2.0 * MAX_ABS_WEIGHT))
|
|
38
|
+
|
|
39
|
+
# Clamp to [MIN_CONFIDENCE, MAX_CONFIDENCE]
|
|
40
|
+
clamped_score = max(MIN_CONFIDENCE, min(MAX_CONFIDENCE, raw_score))
|
|
41
|
+
return round(clamped_score, 4)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Current-state analysis helpers for evaluating Git repository status against MatchSets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from gitrelevance.analysis.matcher import MatchSet
|
|
8
|
+
from gitrelevance.git.commits import Commit
|
|
9
|
+
from gitrelevance.git.files import get_file_operations
|
|
10
|
+
from gitrelevance.git.history import default_revert_detector
|
|
11
|
+
from gitrelevance.git.repository import GitRepository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class CurrentStateFacts:
|
|
16
|
+
"""Summarizes current repository state facts for a MatchSet.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
fix_commit_in_head: First referencing or PR commit present in HEAD history, or None.
|
|
20
|
+
all_related_files_exist: True if all related files exist at HEAD (False if empty).
|
|
21
|
+
deleted_files: Tuple of related file paths that were deleted and do not exist at HEAD.
|
|
22
|
+
renamed_files: Tuple of (old_path, new_path) rename pairs found for related files.
|
|
23
|
+
reverts_of_fix: Tuple of commits reverting fix_commit_in_head (empty if no fix commit).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
fix_commit_in_head: Commit | None
|
|
27
|
+
all_related_files_exist: bool
|
|
28
|
+
deleted_files: tuple[str, ...]
|
|
29
|
+
renamed_files: tuple[tuple[str, str], ...]
|
|
30
|
+
reverts_of_fix: tuple[Commit, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def analyze_current_state(match_set: MatchSet, repo: GitRepository) -> CurrentStateFacts:
|
|
34
|
+
"""Analyze current repository state relative to a MatchSet.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
match_set: The MatchSet containing issue evidence.
|
|
38
|
+
repo: The local Git repository wrapper.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
CurrentStateFacts summarizing current HEAD state.
|
|
42
|
+
"""
|
|
43
|
+
ops = get_file_operations(repo)
|
|
44
|
+
|
|
45
|
+
# 1. Determine fix_commit_in_head (ignoring revert commits)
|
|
46
|
+
fix_commit_in_head: Commit | None = None
|
|
47
|
+
try:
|
|
48
|
+
head = repo.head_commit()
|
|
49
|
+
head_sha = head.sha
|
|
50
|
+
candidate_commits = list(match_set.referencing_commits) + list(match_set.pr_commits)
|
|
51
|
+
for commit in candidate_commits:
|
|
52
|
+
# Skip revert commits when looking for the fix commit
|
|
53
|
+
if "This reverts commit " in commit.message:
|
|
54
|
+
continue
|
|
55
|
+
if repo.is_ancestor(commit.sha, head_sha):
|
|
56
|
+
fix_commit_in_head = commit
|
|
57
|
+
break
|
|
58
|
+
except Exception:
|
|
59
|
+
fix_commit_in_head = None
|
|
60
|
+
|
|
61
|
+
# 2. Renamed files and deleted files
|
|
62
|
+
renamed: list[tuple[str, str]] = []
|
|
63
|
+
seen_renames: set[tuple[str, str]] = set()
|
|
64
|
+
|
|
65
|
+
for path in match_set.related_files:
|
|
66
|
+
renames_for_path = ops.find_renames(path)
|
|
67
|
+
for pair in renames_for_path:
|
|
68
|
+
if pair not in seen_renames:
|
|
69
|
+
seen_renames.add(pair)
|
|
70
|
+
renamed.append(pair)
|
|
71
|
+
|
|
72
|
+
deleted: list[str] = []
|
|
73
|
+
for path in match_set.related_files:
|
|
74
|
+
if not ops.file_exists_at_head(path):
|
|
75
|
+
# A file is considered deleted only if it does not exist at HEAD,
|
|
76
|
+
# was marked as deleted/historical, and was NOT renamed.
|
|
77
|
+
if ops.was_file_deleted(path) and not ops.find_renames(path):
|
|
78
|
+
deleted.append(path)
|
|
79
|
+
|
|
80
|
+
# 3. all_related_files_exist
|
|
81
|
+
if not match_set.related_files:
|
|
82
|
+
all_exist = False
|
|
83
|
+
else:
|
|
84
|
+
all_exist = all(ops.file_exists_at_head(path) for path in match_set.related_files)
|
|
85
|
+
|
|
86
|
+
# 4. Reverts of fix commit
|
|
87
|
+
reverts: list[Commit] = []
|
|
88
|
+
if fix_commit_in_head is not None:
|
|
89
|
+
detector = default_revert_detector()
|
|
90
|
+
reverts = detector.find_reverts_of(repo, fix_commit_in_head.sha)
|
|
91
|
+
|
|
92
|
+
return CurrentStateFacts(
|
|
93
|
+
fix_commit_in_head=fix_commit_in_head,
|
|
94
|
+
all_related_files_exist=all_exist,
|
|
95
|
+
deleted_files=tuple(deleted),
|
|
96
|
+
renamed_files=tuple(renamed),
|
|
97
|
+
reverts_of_fix=tuple(reverts),
|
|
98
|
+
)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""AnalysisEngine for orchestrating Git and Provider analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from typing import Iterator, Literal
|
|
9
|
+
|
|
10
|
+
from gitrelevance.analysis.confidence import compute_confidence
|
|
11
|
+
from gitrelevance.analysis.current_state import analyze_current_state
|
|
12
|
+
from gitrelevance.analysis.evidence import collect_evidence
|
|
13
|
+
from gitrelevance.analysis.classifier import classify
|
|
14
|
+
from gitrelevance.analysis.matcher import MatchSet, build_all_match_sets
|
|
15
|
+
from gitrelevance.git.history import build_commit_reference_index
|
|
16
|
+
from gitrelevance.git.repository import GitRepository
|
|
17
|
+
from gitrelevance.models import AnalysisResult
|
|
18
|
+
from gitrelevance.providers.base import Provider
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Default number of worker threads for parallel per-issue analysis.
|
|
23
|
+
# Conservative to avoid overwhelming git subprocess and GitHub API.
|
|
24
|
+
DEFAULT_MAX_WORKERS = 8
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnalysisEngine:
|
|
28
|
+
"""Orchestrates end-to-end analysis of issues against Git repository state.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
repo: Local Git repository wrapper.
|
|
32
|
+
provider: Issue tracker provider instance.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, repo: GitRepository, provider: Provider) -> None:
|
|
36
|
+
"""Initialize the AnalysisEngine.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
repo: GitRepository instance.
|
|
40
|
+
provider: Provider instance implementing the Provider protocol.
|
|
41
|
+
"""
|
|
42
|
+
self.repo = repo
|
|
43
|
+
self.provider = provider
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Internal helpers
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _analyze_single_issue(
|
|
51
|
+
issue, # Issue (avoiding import for circular reasons in staticmethod)
|
|
52
|
+
match_set: MatchSet,
|
|
53
|
+
repo: GitRepository,
|
|
54
|
+
) -> AnalysisResult:
|
|
55
|
+
"""Compute the AnalysisResult for a single issue (CPU/git-bound).
|
|
56
|
+
|
|
57
|
+
This is the per-issue hot path factored out so it can be called from
|
|
58
|
+
a worker thread.
|
|
59
|
+
"""
|
|
60
|
+
facts = analyze_current_state(match_set, repo)
|
|
61
|
+
evidence = collect_evidence(match_set, facts)
|
|
62
|
+
confidence = compute_confidence(evidence)
|
|
63
|
+
classification = classify(issue, evidence)
|
|
64
|
+
return AnalysisResult(
|
|
65
|
+
issue=issue,
|
|
66
|
+
classification=classification,
|
|
67
|
+
confidence=confidence,
|
|
68
|
+
evidence=evidence,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _prepare(
|
|
72
|
+
self, state: Literal["open", "closed", "all"]
|
|
73
|
+
) -> tuple[list, dict[int, MatchSet], float]:
|
|
74
|
+
"""Fetch issues & PRs and build match sets (I/O-bound phase).
|
|
75
|
+
|
|
76
|
+
Also ensures the commit-reference index is built once up-front so
|
|
77
|
+
every subsequent per-issue lookup is a dict hit.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
(issues_sorted, match_sets, elapsed_seconds)
|
|
81
|
+
"""
|
|
82
|
+
t0 = time.perf_counter()
|
|
83
|
+
|
|
84
|
+
logger.info("Fetching issues (state=%s)...", state)
|
|
85
|
+
issues = self.provider.get_issues(state=state)
|
|
86
|
+
issues_sorted = sorted(issues, key=lambda i: i.number)
|
|
87
|
+
logger.info("Found %d issues to analyze.", len(issues_sorted))
|
|
88
|
+
|
|
89
|
+
logger.info("Fetching pull requests and building correlation match sets...")
|
|
90
|
+
match_sets = build_all_match_sets(issues_sorted, self.repo, self.provider)
|
|
91
|
+
logger.info("Correlation match sets built for %d issues.", len(match_sets))
|
|
92
|
+
|
|
93
|
+
# Build the commit-reference index once so per-issue lookups are instant
|
|
94
|
+
logger.info("Building commit-reference index...")
|
|
95
|
+
build_commit_reference_index(self.repo)
|
|
96
|
+
|
|
97
|
+
elapsed = time.perf_counter() - t0
|
|
98
|
+
logger.info("Preparation (issues + PRs + match sets) took %.2fs.", elapsed)
|
|
99
|
+
return issues_sorted, match_sets, elapsed
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Streaming generator (Goal 1 + Goal 2)
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def analyze_streaming(
|
|
106
|
+
self,
|
|
107
|
+
state: Literal["open", "closed", "all"] = "all",
|
|
108
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
109
|
+
) -> Iterator[AnalysisResult]:
|
|
110
|
+
"""Yield AnalysisResult objects as each issue finishes processing.
|
|
111
|
+
|
|
112
|
+
Issues are analysed concurrently via a thread pool (git subprocess
|
|
113
|
+
calls release the GIL). Results are yielded in completion order
|
|
114
|
+
(not issue-number order) for minimum latency.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
state: Issue state filter.
|
|
118
|
+
max_workers: Maximum concurrent worker threads.
|
|
119
|
+
|
|
120
|
+
Yields:
|
|
121
|
+
AnalysisResult for each issue, as soon as it is ready.
|
|
122
|
+
"""
|
|
123
|
+
issues_sorted, match_sets, _prep_elapsed = self._prepare(state)
|
|
124
|
+
total = len(issues_sorted)
|
|
125
|
+
|
|
126
|
+
if total == 0:
|
|
127
|
+
logger.info("No issues to analyze.")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
logger.info(
|
|
131
|
+
"Analyzing %d issues in parallel (max_workers=%d)...",
|
|
132
|
+
total,
|
|
133
|
+
max_workers,
|
|
134
|
+
)
|
|
135
|
+
t_analysis = time.perf_counter()
|
|
136
|
+
|
|
137
|
+
def _process(issue):
|
|
138
|
+
return self._analyze_single_issue(issue, match_sets[issue.number], self.repo)
|
|
139
|
+
|
|
140
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
141
|
+
future_to_issue = {
|
|
142
|
+
executor.submit(_process, issue): issue for issue in issues_sorted
|
|
143
|
+
}
|
|
144
|
+
completed = 0
|
|
145
|
+
for future in as_completed(future_to_issue):
|
|
146
|
+
result = future.result() # propagates exceptions
|
|
147
|
+
completed += 1
|
|
148
|
+
logger.debug(
|
|
149
|
+
"Completed issue #%d (%d/%d)",
|
|
150
|
+
result.issue.number,
|
|
151
|
+
completed,
|
|
152
|
+
total,
|
|
153
|
+
)
|
|
154
|
+
yield result
|
|
155
|
+
|
|
156
|
+
elapsed_analysis = time.perf_counter() - t_analysis
|
|
157
|
+
logger.info(
|
|
158
|
+
"Analysis completed for %d issues in %.2fs.",
|
|
159
|
+
total,
|
|
160
|
+
elapsed_analysis,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# Legacy batch API (backward-compatible)
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def analyze(self, state: Literal["open", "closed", "all"] = "all") -> list[AnalysisResult]:
|
|
168
|
+
"""Analyze repository issues for relevance against local Git history.
|
|
169
|
+
|
|
170
|
+
Collects all results from the streaming generator and returns them
|
|
171
|
+
sorted by issue number for backward compatibility.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
state: Issue state filter ("open", "closed", or "all").
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of AnalysisResult objects sorted by issue number.
|
|
178
|
+
"""
|
|
179
|
+
results = list(self.analyze_streaming(state=state))
|
|
180
|
+
results.sort(key=lambda r: r.issue.number)
|
|
181
|
+
return results
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Evidence collection rules and weights for issue analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from gitrelevance.analysis.current_state import CurrentStateFacts
|
|
8
|
+
from gitrelevance.analysis.matcher import MatchSet
|
|
9
|
+
from gitrelevance.models import EvidenceItem
|
|
10
|
+
|
|
11
|
+
# Weight constants for scoring
|
|
12
|
+
WEIGHT_ISSUE_REFERENCED_BY_FIX_COMMIT = 3
|
|
13
|
+
WEIGHT_FIX_COMMIT_IN_HEAD = 3
|
|
14
|
+
WEIGHT_FIXING_PR_MERGED = 2
|
|
15
|
+
WEIGHT_FILES_STILL_EXIST = 2
|
|
16
|
+
WEIGHT_NO_REVERT_DETECTED = 1
|
|
17
|
+
WEIGHT_ISSUE_NUM_IN_COMMIT_MSG = 1
|
|
18
|
+
WEIGHT_FILES_SUBSTANTIALLY_MODIFIED = 1
|
|
19
|
+
WEIGHT_FILES_DELETED_NO_REPLACEMENT = -3
|
|
20
|
+
WEIGHT_FEATURE_NO_LONGER_PRESENT = -2
|
|
21
|
+
WEIGHT_FIX_LATER_REVERTED = -3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_fix_in_head(match_set: MatchSet, facts: CurrentStateFacts) -> EvidenceItem | None:
|
|
25
|
+
"""Check if the fix commit is present in HEAD ancestry."""
|
|
26
|
+
if facts.fix_commit_in_head is not None:
|
|
27
|
+
return EvidenceItem(
|
|
28
|
+
description="Fix commit is present in HEAD history",
|
|
29
|
+
weight=WEIGHT_FIX_COMMIT_IN_HEAD,
|
|
30
|
+
category="strong",
|
|
31
|
+
source_ref=facts.fix_commit_in_head.short_sha,
|
|
32
|
+
)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_pr_merged(match_set: MatchSet, facts: CurrentStateFacts) -> EvidenceItem | None:
|
|
37
|
+
"""Check if any linked pull request was merged."""
|
|
38
|
+
for pr in match_set.linked_prs:
|
|
39
|
+
if pr.merged:
|
|
40
|
+
return EvidenceItem(
|
|
41
|
+
description=f"Fixing PR #{pr.number} merged",
|
|
42
|
+
weight=WEIGHT_FIXING_PR_MERGED,
|
|
43
|
+
category="strong",
|
|
44
|
+
source_ref=f"PR #{pr.number}",
|
|
45
|
+
)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_issue_referenced_by_fix_commit(
|
|
50
|
+
match_set: MatchSet, facts: CurrentStateFacts
|
|
51
|
+
) -> EvidenceItem | None:
|
|
52
|
+
"""Check if commits directly reference the issue."""
|
|
53
|
+
if match_set.referencing_commits:
|
|
54
|
+
commit = match_set.referencing_commits[0]
|
|
55
|
+
return EvidenceItem(
|
|
56
|
+
description="Issue referenced in commit message",
|
|
57
|
+
weight=WEIGHT_ISSUE_REFERENCED_BY_FIX_COMMIT,
|
|
58
|
+
category="strong",
|
|
59
|
+
source_ref=commit.short_sha,
|
|
60
|
+
)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_files_exist(match_set: MatchSet, facts: CurrentStateFacts) -> EvidenceItem | None:
|
|
65
|
+
"""Check if all related files exist at HEAD."""
|
|
66
|
+
if facts.all_related_files_exist and match_set.related_files:
|
|
67
|
+
return EvidenceItem(
|
|
68
|
+
description="All related files exist at HEAD",
|
|
69
|
+
weight=WEIGHT_FILES_STILL_EXIST,
|
|
70
|
+
category="medium",
|
|
71
|
+
source_ref=None,
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_no_revert(match_set: MatchSet, facts: CurrentStateFacts) -> EvidenceItem | None:
|
|
77
|
+
"""Check if no revert commit was detected for the fix commit."""
|
|
78
|
+
if facts.fix_commit_in_head is not None and not facts.reverts_of_fix:
|
|
79
|
+
return EvidenceItem(
|
|
80
|
+
description="No revert of fix commit detected",
|
|
81
|
+
weight=WEIGHT_NO_REVERT_DETECTED,
|
|
82
|
+
category="medium",
|
|
83
|
+
source_ref=facts.fix_commit_in_head.short_sha,
|
|
84
|
+
)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_issue_num_in_commit_msg(
|
|
89
|
+
match_set: MatchSet, facts: CurrentStateFacts
|
|
90
|
+
) -> EvidenceItem | None:
|
|
91
|
+
"""Check if issue number is mentioned in commit message."""
|
|
92
|
+
if match_set.referencing_commits:
|
|
93
|
+
commit = match_set.referencing_commits[0]
|
|
94
|
+
return EvidenceItem(
|
|
95
|
+
description="Issue number mentioned in commit message",
|
|
96
|
+
weight=WEIGHT_ISSUE_NUM_IN_COMMIT_MSG,
|
|
97
|
+
category="medium",
|
|
98
|
+
source_ref=commit.short_sha,
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_files_substantially_modified(
|
|
104
|
+
match_set: MatchSet, facts: CurrentStateFacts
|
|
105
|
+
) -> EvidenceItem | None:
|
|
106
|
+
"""Check if related files were renamed or substantially modified."""
|
|
107
|
+
if facts.renamed_files:
|
|
108
|
+
return EvidenceItem(
|
|
109
|
+
description=f"Files modified or renamed ({len(facts.renamed_files)} renamed)",
|
|
110
|
+
weight=WEIGHT_FILES_SUBSTANTIALLY_MODIFIED,
|
|
111
|
+
category="medium",
|
|
112
|
+
source_ref=None,
|
|
113
|
+
)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_files_deleted(match_set: MatchSet, facts: CurrentStateFacts) -> EvidenceItem | None:
|
|
118
|
+
"""Check if related files were deleted without replacement."""
|
|
119
|
+
if facts.deleted_files:
|
|
120
|
+
files_str = ", ".join(facts.deleted_files)
|
|
121
|
+
return EvidenceItem(
|
|
122
|
+
description=f"Related files deleted without replacement: {files_str}",
|
|
123
|
+
weight=WEIGHT_FILES_DELETED_NO_REPLACEMENT,
|
|
124
|
+
category="obsolescence",
|
|
125
|
+
source_ref=None,
|
|
126
|
+
)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_feature_no_longer_present(
|
|
131
|
+
match_set: MatchSet, facts: CurrentStateFacts
|
|
132
|
+
) -> EvidenceItem | None:
|
|
133
|
+
"""Check if feature code was deleted and issue closed without a fix commit."""
|
|
134
|
+
if match_set.issue.state == "closed" and facts.fix_commit_in_head is None and facts.deleted_files:
|
|
135
|
+
return EvidenceItem(
|
|
136
|
+
description="Feature files deleted and issue closed without fix commit",
|
|
137
|
+
weight=WEIGHT_FEATURE_NO_LONGER_PRESENT,
|
|
138
|
+
category="obsolescence",
|
|
139
|
+
source_ref=None,
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_revert_of_fix(match_set: MatchSet, facts: CurrentStateFacts) -> EvidenceItem | None:
|
|
145
|
+
"""Check if the fix commit was later reverted."""
|
|
146
|
+
if facts.reverts_of_fix:
|
|
147
|
+
revert_commit = facts.reverts_of_fix[0]
|
|
148
|
+
return EvidenceItem(
|
|
149
|
+
description=f"Fix commit was reverted by {revert_commit.short_sha}",
|
|
150
|
+
weight=WEIGHT_FIX_LATER_REVERTED,
|
|
151
|
+
category="obsolescence",
|
|
152
|
+
source_ref=revert_commit.short_sha,
|
|
153
|
+
)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Registered list of evidence rules
|
|
158
|
+
ALL_RULES: list[Callable[[MatchSet, CurrentStateFacts], EvidenceItem | None]] = [
|
|
159
|
+
check_fix_in_head,
|
|
160
|
+
check_pr_merged,
|
|
161
|
+
check_issue_referenced_by_fix_commit,
|
|
162
|
+
check_files_exist,
|
|
163
|
+
check_no_revert,
|
|
164
|
+
check_issue_num_in_commit_msg,
|
|
165
|
+
check_files_substantially_modified,
|
|
166
|
+
check_files_deleted,
|
|
167
|
+
check_feature_no_longer_present,
|
|
168
|
+
check_revert_of_fix,
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def collect_evidence(match_set: MatchSet, facts: CurrentStateFacts) -> tuple[EvidenceItem, ...]:
|
|
173
|
+
"""Collect all applicable evidence items for a given MatchSet and CurrentStateFacts.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
match_set: The MatchSet containing issue correlation data.
|
|
177
|
+
facts: CurrentStateFacts describing repository state.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Tuple of collected EvidenceItem instances.
|
|
181
|
+
"""
|
|
182
|
+
items: list[EvidenceItem] = []
|
|
183
|
+
for rule in ALL_RULES:
|
|
184
|
+
item = rule(match_set, facts)
|
|
185
|
+
if item is not None:
|
|
186
|
+
items.append(item)
|
|
187
|
+
return tuple(items)
|