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,6 @@
1
+ from gitrelevance.models import AnalysisResult, Classification, EvidenceItem
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["Classification", "EvidenceItem", "AnalysisResult"]
6
+
@@ -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)