@loopover/engine 1.0.0
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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +661 -0
- package/README.md +676 -0
- package/dist/advisory/gate-advisory.d.ts +151 -0
- package/dist/advisory/gate-advisory.js +512 -0
- package/dist/ai-policy-map.d.ts +97 -0
- package/dist/ai-policy-map.js +377 -0
- package/dist/ams-policy-spec.d.ts +56 -0
- package/dist/ams-policy-spec.js +165 -0
- package/dist/calibration-dashboard.d.ts +23 -0
- package/dist/calibration-dashboard.js +65 -0
- package/dist/calibration-trend.d.ts +40 -0
- package/dist/calibration-trend.js +60 -0
- package/dist/contributor-fit.d.ts +24 -0
- package/dist/contributor-fit.js +50 -0
- package/dist/discovery-index-contract.d.ts +74 -0
- package/dist/discovery-index-contract.js +184 -0
- package/dist/discovery-soft-claim.d.ts +35 -0
- package/dist/discovery-soft-claim.js +50 -0
- package/dist/duplicate-winner.d.ts +57 -0
- package/dist/duplicate-winner.js +98 -0
- package/dist/feasibility.d.ts +27 -0
- package/dist/feasibility.js +86 -0
- package/dist/finding-severity-calibration.d.ts +116 -0
- package/dist/finding-severity-calibration.js +457 -0
- package/dist/fleet-run-manifest.d.ts +41 -0
- package/dist/fleet-run-manifest.js +151 -0
- package/dist/focus-manifest/guidance.d.ts +21 -0
- package/dist/focus-manifest/guidance.js +222 -0
- package/dist/focus-manifest.d.ts +826 -0
- package/dist/focus-manifest.js +2730 -0
- package/dist/gate-verdict-calibration.d.ts +111 -0
- package/dist/gate-verdict-calibration.js +411 -0
- package/dist/github/constants.d.ts +1 -0
- package/dist/github/constants.js +1 -0
- package/dist/github/sanitize-public-comment.d.ts +1 -0
- package/dist/github/sanitize-public-comment.js +26 -0
- package/dist/goal-model.d.ts +7 -0
- package/dist/goal-model.js +87 -0
- package/dist/governor/action-mode.d.ts +55 -0
- package/dist/governor/action-mode.js +74 -0
- package/dist/governor/budget-cap.d.ts +49 -0
- package/dist/governor/budget-cap.js +34 -0
- package/dist/governor/chokepoint.d.ts +58 -0
- package/dist/governor/chokepoint.js +269 -0
- package/dist/governor/kill-switch.d.ts +38 -0
- package/dist/governor/kill-switch.js +62 -0
- package/dist/governor/rate-limit.d.ts +40 -0
- package/dist/governor/rate-limit.js +63 -0
- package/dist/governor/reputation-throttle.d.ts +50 -0
- package/dist/governor/reputation-throttle.js +94 -0
- package/dist/governor/run-halt.d.ts +39 -0
- package/dist/governor/run-halt.js +83 -0
- package/dist/governor/self-plagiarism.d.ts +47 -0
- package/dist/governor/self-plagiarism.js +200 -0
- package/dist/governor/write-rate-limit.d.ts +52 -0
- package/dist/governor/write-rate-limit.js +132 -0
- package/dist/governor-ledger.d.ts +25 -0
- package/dist/governor-ledger.js +91 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +120 -0
- package/dist/issue-plan-decomposition.d.ts +18 -0
- package/dist/issue-plan-decomposition.js +56 -0
- package/dist/issue-rag-query.d.ts +12 -0
- package/dist/issue-rag-query.js +35 -0
- package/dist/local-scorer.d.ts +34 -0
- package/dist/local-scorer.js +36 -0
- package/dist/metadata-best-min-score.d.ts +9 -0
- package/dist/metadata-best-min-score.js +9 -0
- package/dist/metadata-best-pick.d.ts +9 -0
- package/dist/metadata-best-pick.js +9 -0
- package/dist/metadata-min-score.d.ts +9 -0
- package/dist/metadata-min-score.js +13 -0
- package/dist/metadata-top-min-score.d.ts +9 -0
- package/dist/metadata-top-min-score.js +13 -0
- package/dist/metadata-top-pick.d.ts +9 -0
- package/dist/metadata-top-pick.js +13 -0
- package/dist/miner/acceptance-criteria.d.ts +43 -0
- package/dist/miner/acceptance-criteria.js +77 -0
- package/dist/miner/agent-sdk-driver.d.ts +39 -0
- package/dist/miner/agent-sdk-driver.js +162 -0
- package/dist/miner/attempt-log.d.ts +29 -0
- package/dist/miner/attempt-log.js +100 -0
- package/dist/miner/attempt-metering.d.ts +39 -0
- package/dist/miner/attempt-metering.js +67 -0
- package/dist/miner/cli-subprocess-driver.d.ts +68 -0
- package/dist/miner/cli-subprocess-driver.js +272 -0
- package/dist/miner/coding-agent-driver.d.ts +33 -0
- package/dist/miner/coding-agent-driver.js +36 -0
- package/dist/miner/coding-agent-invoke.d.ts +11 -0
- package/dist/miner/coding-agent-invoke.js +80 -0
- package/dist/miner/coding-agent-mode.d.ts +25 -0
- package/dist/miner/coding-agent-mode.js +43 -0
- package/dist/miner/driver-factory.d.ts +82 -0
- package/dist/miner/driver-factory.js +169 -0
- package/dist/miner/harness-submission-trigger.d.ts +37 -0
- package/dist/miner/harness-submission-trigger.js +46 -0
- package/dist/miner/iterate-loop.d.ts +97 -0
- package/dist/miner/iterate-loop.js +278 -0
- package/dist/miner/iterate-policy.d.ts +98 -0
- package/dist/miner/iterate-policy.js +98 -0
- package/dist/miner/lint-guard.d.ts +50 -0
- package/dist/miner/lint-guard.js +68 -0
- package/dist/miner/local-write-tools.d.ts +81 -0
- package/dist/miner/local-write-tools.js +113 -0
- package/dist/miner/loop-reentry-policy.d.ts +36 -0
- package/dist/miner/loop-reentry-policy.js +53 -0
- package/dist/miner/repo-map.d.ts +51 -0
- package/dist/miner/repo-map.js +268 -0
- package/dist/miner/self-review-adapter.d.ts +106 -0
- package/dist/miner/self-review-adapter.js +83 -0
- package/dist/miner/submission-gate.d.ts +40 -0
- package/dist/miner/submission-gate.js +89 -0
- package/dist/miner/worktree-allocator.d.ts +59 -0
- package/dist/miner/worktree-allocator.js +60 -0
- package/dist/miner/worktree-pool.d.ts +55 -0
- package/dist/miner/worktree-pool.js +65 -0
- package/dist/miner-goal-lane-fit.d.ts +18 -0
- package/dist/miner-goal-lane-fit.js +76 -0
- package/dist/miner-goal-spec.d.ts +139 -0
- package/dist/miner-goal-spec.js +281 -0
- package/dist/miner-prediction-metrics.d.ts +16 -0
- package/dist/miner-prediction-metrics.js +61 -0
- package/dist/miner-telemetry.d.ts +32 -0
- package/dist/miner-telemetry.js +87 -0
- package/dist/objective-anchor.d.ts +125 -0
- package/dist/objective-anchor.js +401 -0
- package/dist/opportunity-competition.d.ts +6 -0
- package/dist/opportunity-competition.js +29 -0
- package/dist/opportunity-freshness.d.ts +18 -0
- package/dist/opportunity-freshness.js +55 -0
- package/dist/opportunity-metadata.d.ts +53 -0
- package/dist/opportunity-metadata.js +204 -0
- package/dist/opportunity-ranker.d.ts +46 -0
- package/dist/opportunity-ranker.js +74 -0
- package/dist/pairwise-calibration.d.ts +49 -0
- package/dist/pairwise-calibration.js +106 -0
- package/dist/phase7-calibration-loop.d.ts +136 -0
- package/dist/phase7-calibration-loop.js +437 -0
- package/dist/plan-blocked.d.ts +6 -0
- package/dist/plan-blocked.js +24 -0
- package/dist/plan-completed.d.ts +5 -0
- package/dist/plan-completed.js +6 -0
- package/dist/plan-completion.d.ts +6 -0
- package/dist/plan-completion.js +7 -0
- package/dist/plan-empty.d.ts +5 -0
- package/dist/plan-empty.js +6 -0
- package/dist/plan-export.d.ts +27 -0
- package/dist/plan-export.js +86 -0
- package/dist/plan-failure.d.ts +5 -0
- package/dist/plan-failure.js +6 -0
- package/dist/plan-overall-status.d.ts +6 -0
- package/dist/plan-overall-status.js +25 -0
- package/dist/plan-pending.d.ts +5 -0
- package/dist/plan-pending.js +6 -0
- package/dist/plan-progress-complete.d.ts +7 -0
- package/dist/plan-progress-complete.js +10 -0
- package/dist/plan-ready.d.ts +6 -0
- package/dist/plan-ready.js +12 -0
- package/dist/plan-running.d.ts +5 -0
- package/dist/plan-running.js +6 -0
- package/dist/plan-skipped.d.ts +5 -0
- package/dist/plan-skipped.js +6 -0
- package/dist/plan-step-count.d.ts +5 -0
- package/dist/plan-step-count.js +6 -0
- package/dist/plan-step-stats.d.ts +5 -0
- package/dist/plan-step-stats.js +6 -0
- package/dist/plan-templates.d.ts +25 -0
- package/dist/plan-templates.js +107 -0
- package/dist/plan-terminated.d.ts +6 -0
- package/dist/plan-terminated.js +9 -0
- package/dist/portfolio/non-convergence.d.ts +37 -0
- package/dist/portfolio/non-convergence.js +57 -0
- package/dist/portfolio/queue.d.ts +30 -0
- package/dist/portfolio/queue.js +150 -0
- package/dist/predicted-gate.d.ts +103 -0
- package/dist/predicted-gate.js +218 -0
- package/dist/prompt-packet.d.ts +11 -0
- package/dist/prompt-packet.js +34 -0
- package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-best-min-score.js +9 -0
- package/dist/ranked-opportunity-best-pick.d.ts +8 -0
- package/dist/ranked-opportunity-best-pick.js +9 -0
- package/dist/ranked-opportunity-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-min-score.js +13 -0
- package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-top-min-score.js +13 -0
- package/dist/review/advisory-ai-routing-config.d.ts +8 -0
- package/dist/review/advisory-ai-routing-config.js +40 -0
- package/dist/review/check-names.d.ts +1 -0
- package/dist/review/check-names.js +1 -0
- package/dist/review/cla-check.d.ts +49 -0
- package/dist/review/cla-check.js +71 -0
- package/dist/review/diff-file-priority.d.ts +1 -0
- package/dist/review/diff-file-priority.js +12 -0
- package/dist/review/enrichment-analyzer-names.d.ts +3 -0
- package/dist/review/enrichment-analyzer-names.js +63 -0
- package/dist/review/guardrail-config.d.ts +10 -0
- package/dist/review/guardrail-config.js +62 -0
- package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
- package/dist/review/linked-issue-hard-rules-config.js +79 -0
- package/dist/review/linked-issue-label-propagation.d.ts +9 -0
- package/dist/review/linked-issue-label-propagation.js +114 -0
- package/dist/review/pre-merge-checks.d.ts +28 -0
- package/dist/review/pre-merge-checks.js +66 -0
- package/dist/review/review-thread-findings.d.ts +1 -0
- package/dist/review/review-thread-findings.js +1 -0
- package/dist/review/safe-url.d.ts +5 -0
- package/dist/review/safe-url.js +137 -0
- package/dist/review/screenshot-table-gate.d.ts +87 -0
- package/dist/review/screenshot-table-gate.js +340 -0
- package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
- package/dist/review/unlinked-issue-guardrail-config.js +44 -0
- package/dist/reviewer-consensus-calibration.d.ts +113 -0
- package/dist/reviewer-consensus-calibration.js +493 -0
- package/dist/reward-risk.d.ts +223 -0
- package/dist/reward-risk.js +618 -0
- package/dist/scoring/label-match.d.ts +4 -0
- package/dist/scoring/label-match.js +145 -0
- package/dist/scoring/model.d.ts +31 -0
- package/dist/scoring/model.js +157 -0
- package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
- package/dist/scoring/pending-pr-scenarios.js +145 -0
- package/dist/scoring/preview.d.ts +246 -0
- package/dist/scoring/preview.js +1108 -0
- package/dist/scoring/types.d.ts +131 -0
- package/dist/scoring/types.js +7 -0
- package/dist/settings/auto-close-exempt.d.ts +10 -0
- package/dist/settings/auto-close-exempt.js +58 -0
- package/dist/settings/autonomy.d.ts +33 -0
- package/dist/settings/autonomy.js +80 -0
- package/dist/settings/command-authorization.d.ts +49 -0
- package/dist/settings/command-authorization.js +222 -0
- package/dist/settings/contributor-blacklist.d.ts +17 -0
- package/dist/settings/contributor-blacklist.js +86 -0
- package/dist/settings/moderation-rules.d.ts +51 -0
- package/dist/settings/moderation-rules.js +104 -0
- package/dist/settings/pr-type-label.d.ts +70 -0
- package/dist/settings/pr-type-label.js +149 -0
- package/dist/signals/change-guardrail.d.ts +49 -0
- package/dist/signals/change-guardrail.js +164 -0
- package/dist/signals/check-summary.d.ts +9 -0
- package/dist/signals/check-summary.js +12 -0
- package/dist/signals/contributor-calibration.d.ts +23 -0
- package/dist/signals/contributor-calibration.js +47 -0
- package/dist/signals/duplicate-winner.d.ts +9 -0
- package/dist/signals/duplicate-winner.js +9 -0
- package/dist/signals/path-matchers.d.ts +36 -0
- package/dist/signals/path-matchers.js +371 -0
- package/dist/signals/predicted-gate-engine.d.ts +62 -0
- package/dist/signals/predicted-gate-engine.js +895 -0
- package/dist/signals/preflight-limits.d.ts +14 -0
- package/dist/signals/preflight-limits.js +14 -0
- package/dist/signals/slop.d.ts +56 -0
- package/dist/signals/slop.js +324 -0
- package/dist/signals/test-evidence.d.ts +25 -0
- package/dist/signals/test-evidence.js +108 -0
- package/dist/subprocess-env.d.ts +23 -0
- package/dist/subprocess-env.js +74 -0
- package/dist/track-record-summary.d.ts +92 -0
- package/dist/track-record-summary.js +301 -0
- package/dist/types/manifest-deps-types.d.ts +514 -0
- package/dist/types/manifest-deps-types.js +4 -0
- package/dist/types/predicted-gate-types.d.ts +338 -0
- package/dist/types/predicted-gate-types.js +3 -0
- package/dist/types/reward-risk-types.d.ts +319 -0
- package/dist/types/reward-risk-types.js +11 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +80 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
import { nowIso } from "../utils/json.js";
|
|
2
|
+
import { PREFLIGHT_LIMITS } from "./preflight-limits.js";
|
|
3
|
+
import { hasValidationNote, isTestPath } from "./test-evidence.js";
|
|
4
|
+
import { diffFilePriority } from "../review/diff-file-priority.js";
|
|
5
|
+
const STOPWORDS = new Set([
|
|
6
|
+
"the",
|
|
7
|
+
"and",
|
|
8
|
+
"for",
|
|
9
|
+
"with",
|
|
10
|
+
"from",
|
|
11
|
+
"this",
|
|
12
|
+
"that",
|
|
13
|
+
"when",
|
|
14
|
+
"into",
|
|
15
|
+
"issue",
|
|
16
|
+
"pull",
|
|
17
|
+
"request",
|
|
18
|
+
"add",
|
|
19
|
+
"fix",
|
|
20
|
+
"update",
|
|
21
|
+
"improve",
|
|
22
|
+
]);
|
|
23
|
+
const MAX_COLLISION_PAIRWISE_ISSUES = 80;
|
|
24
|
+
const MAX_COLLISION_PAIRWISE_PULL_REQUESTS = 120;
|
|
25
|
+
const MAX_COLLISION_PAIRWISE_RECENT_MERGES = 40;
|
|
26
|
+
const ISSUE_DISCOVERY_LIFECYCLE_REPORT_CAP = 300;
|
|
27
|
+
const ISSUE_QUALITY_REPORT_CAP = 100;
|
|
28
|
+
const REPO_OUTCOME_STALE_OPEN_DAYS = 30;
|
|
29
|
+
const REPO_OUTCOME_MIN_DECIDED_SAMPLE = 3;
|
|
30
|
+
const REPO_OUTCOME_MERGE_WELL_RATE = 0.7;
|
|
31
|
+
const REPO_OUTCOME_CLOSURE_RISK_RATE = 0.34;
|
|
32
|
+
const REPO_OUTCOME_MAX_PATTERNS = 12;
|
|
33
|
+
export function buildLaneAdvice(repo, fullName) {
|
|
34
|
+
const config = repo?.registryConfig;
|
|
35
|
+
if (!repo || !repo.isRegistered || !config) {
|
|
36
|
+
return {
|
|
37
|
+
lane: "unknown",
|
|
38
|
+
repoFullName: fullName,
|
|
39
|
+
summary: "Repository registration is not available in the local Gittensory cache.",
|
|
40
|
+
contributorGuidance: "Do not assume this repo is ready for Gittensor-specific contribution guidance yet.",
|
|
41
|
+
maintainerGuidance: "Refresh the registry snapshot or install the GitHub App so Gittensory can evaluate the repo.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (config.emissionShare <= 0) {
|
|
45
|
+
return {
|
|
46
|
+
lane: "inactive",
|
|
47
|
+
repoFullName: fullName,
|
|
48
|
+
issueDiscoveryShare: config.issueDiscoveryShare,
|
|
49
|
+
directPrShare: 0,
|
|
50
|
+
summary: "Repository is registered but has no active allocation in the current snapshot.",
|
|
51
|
+
contributorGuidance: "Treat this as normal upstream contribution work unless the registry changes.",
|
|
52
|
+
maintainerGuidance: "Do not expect Gittensor-driven contributor flow from this repo while allocation is zero.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const issueDiscoveryShare = clamp(config.issueDiscoveryShare, 0, 1);
|
|
56
|
+
const directPrShare = 1 - issueDiscoveryShare;
|
|
57
|
+
if (issueDiscoveryShare === 1) {
|
|
58
|
+
return {
|
|
59
|
+
lane: "issue_discovery",
|
|
60
|
+
repoFullName: fullName,
|
|
61
|
+
issueDiscoveryShare,
|
|
62
|
+
directPrShare,
|
|
63
|
+
summary: "Repository is configured for issue-discovery flow.",
|
|
64
|
+
contributorGuidance: "Focus on high-proof issue discovery and avoid self-resolved issue loops.",
|
|
65
|
+
maintainerGuidance: "Prioritize issue quality, duplicate risk, and whether reports are actionable for outside contributors.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (issueDiscoveryShare === 0) {
|
|
69
|
+
return {
|
|
70
|
+
lane: "direct_pr",
|
|
71
|
+
repoFullName: fullName,
|
|
72
|
+
issueDiscoveryShare,
|
|
73
|
+
directPrShare,
|
|
74
|
+
summary: "Repository is configured for direct PR review.",
|
|
75
|
+
contributorGuidance: "Prefer focused PRs with clear evidence, linked context, and low review churn.",
|
|
76
|
+
maintainerGuidance: "Use PR hygiene, duplicate risk, and test evidence as the primary review filters.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
lane: "split",
|
|
81
|
+
repoFullName: fullName,
|
|
82
|
+
issueDiscoveryShare,
|
|
83
|
+
directPrShare,
|
|
84
|
+
summary: "Repository is configured for both issue discovery and direct PR review.",
|
|
85
|
+
contributorGuidance: "Pick one path intentionally: issue discovery for reports, direct PR for implementation.",
|
|
86
|
+
maintainerGuidance: "Check whether each submission is using the right path before reviewing technical detail.",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function buildCollisionReport(repoFullName, issues, pullRequests, recentMergedPullRequests = []) {
|
|
90
|
+
const openIssues = issues.filter((issue) => issue.state === "open");
|
|
91
|
+
const openPullRequests = pullRequests.filter((pr) => pr.state === "open");
|
|
92
|
+
const clusters = new Map();
|
|
93
|
+
const pullRequestsByLinkedIssue = new Map();
|
|
94
|
+
for (const pr of openPullRequests) {
|
|
95
|
+
for (const issueNumber of pr.linkedIssues) {
|
|
96
|
+
const linkedPrs = pullRequestsByLinkedIssue.get(issueNumber) ?? [];
|
|
97
|
+
linkedPrs.push(pr);
|
|
98
|
+
pullRequestsByLinkedIssue.set(issueNumber, linkedPrs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const issue of openIssues) {
|
|
102
|
+
const linkedPrs = pullRequestsByLinkedIssue.get(issue.number) ?? [];
|
|
103
|
+
if (linkedPrs.length === 0)
|
|
104
|
+
continue;
|
|
105
|
+
const items = [issueItem(issue), ...linkedPrs.map(prItem)];
|
|
106
|
+
clusters.set(`issue-${issue.number}`, {
|
|
107
|
+
id: `issue-${issue.number}`,
|
|
108
|
+
risk: linkedPrs.length > 1 || issue.linkedPrs.length > 1 ? "high" : "medium",
|
|
109
|
+
reason: `Open PR work references issue #${issue.number}.`,
|
|
110
|
+
items,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const pairwiseIssues = boundedCollisionIssues(openIssues, openPullRequests);
|
|
114
|
+
const pairwisePullRequests = boundedCollisionPullRequests(openPullRequests);
|
|
115
|
+
const pairwiseRecentMergedPullRequests = recentMergedPullRequests.slice(0, MAX_COLLISION_PAIRWISE_RECENT_MERGES);
|
|
116
|
+
const items = [...pairwiseIssues.map(issueItem), ...pairwisePullRequests.map(prItem), ...pairwiseRecentMergedPullRequests.map(recentMergedItem)];
|
|
117
|
+
const itemTerms = new Map();
|
|
118
|
+
for (const item of items)
|
|
119
|
+
itemTerms.set(itemKey(item), collisionTerms(item));
|
|
120
|
+
for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
|
|
121
|
+
for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) {
|
|
122
|
+
const left = items[leftIndex];
|
|
123
|
+
const right = items[rightIndex];
|
|
124
|
+
/* v8 ignore next -- Sparse array slots are defensive; collision items are built from bounded lists above. */
|
|
125
|
+
if (!left || !right)
|
|
126
|
+
continue;
|
|
127
|
+
/* v8 ignore start -- Collision items always carry linkedIssues arrays; nullish defaults are defensive only. */
|
|
128
|
+
const sharedIssue = (left.linkedIssues ?? []).find((issue) => (right.linkedIssues ?? []).includes(issue));
|
|
129
|
+
/* v8 ignore stop */
|
|
130
|
+
if (sharedIssue) {
|
|
131
|
+
const key = [itemKey(left), itemKey(right)].sort().join("--");
|
|
132
|
+
/* v8 ignore next -- Pairwise shared-issue clusters are covered by buildCollisionReport integration tests. */
|
|
133
|
+
if (!clusters.has(key)) {
|
|
134
|
+
clusters.set(key, {
|
|
135
|
+
id: key,
|
|
136
|
+
risk: right.type === "recent_merged_pull_request" || left.type === "recent_merged_pull_request" ? "medium" : "high",
|
|
137
|
+
reason: `Items reference the same linked issue #${sharedIssue}.`,
|
|
138
|
+
items: [left, right],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
let leftTerms = itemTerms.get(itemKey(left));
|
|
144
|
+
/* v8 ignore next -- Defensive only: every collision item is pre-indexed in itemTerms before this loop. */
|
|
145
|
+
if (leftTerms === undefined)
|
|
146
|
+
leftTerms = collisionTerms(left);
|
|
147
|
+
let rightTerms = itemTerms.get(itemKey(right));
|
|
148
|
+
/* v8 ignore next -- Defensive only: every collision item is pre-indexed in itemTerms before this loop. */
|
|
149
|
+
if (rightTerms === undefined)
|
|
150
|
+
rightTerms = collisionTerms(right);
|
|
151
|
+
const overlap = termOverlap(leftTerms, rightTerms);
|
|
152
|
+
if (overlap.score < 0.58 || overlap.shared < 2)
|
|
153
|
+
continue;
|
|
154
|
+
// Re-score without path terms: tells us whether title/label overlap ALONE already clears the bar
|
|
155
|
+
// (pre-existing behavior, unaffected) or whether changedFiles tokens are what pushed this pair over —
|
|
156
|
+
// the two false-positive shapes that creates are guarded separately below.
|
|
157
|
+
const titleOnlyOverlap = termOverlap(collisionTerms(left, false), collisionTerms(right, false));
|
|
158
|
+
const pathDrivenMatch = titleOnlyOverlap.score < 0.58 || titleOnlyOverlap.shared < 2;
|
|
159
|
+
if (pathDrivenMatch) {
|
|
160
|
+
// A contributor iterating on their own work (e.g. a follow-up PR touching the same file as their
|
|
161
|
+
// still-open prior PR) is not duplicate effort — self-authored path-only overlap is dropped outright.
|
|
162
|
+
/* v8 ignore start -- Self-authored path-only overlap is covered by collision parity tests. */
|
|
163
|
+
if (isPullRequestShapedItem(left) && isPullRequestShapedItem(right) && Boolean(left.authorLogin) && sameLogin(left.authorLogin, right.authorLogin ?? "")) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
/* v8 ignore stop */
|
|
167
|
+
// Different authors: file paths tokenize into directory segments (src, review, test, unit, ...) that
|
|
168
|
+
// recur across nearly every PR in a consistently-organized repo, so shared TOKENS alone are not
|
|
169
|
+
// reliable collision evidence — a repo-wide shadow test found this drove the large majority of
|
|
170
|
+
// path-only matches with zero actual shared files. Require an ACTUAL shared file (ignoring
|
|
171
|
+
// lockfiles/generated artifacts nobody would call a collision over) before clustering.
|
|
172
|
+
if (!sharesMeaningfulFile(left.changedFiles, right.changedFiles))
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const key = [itemKey(left), itemKey(right)].sort().join("--");
|
|
176
|
+
/* v8 ignore next -- Duplicate pairwise keys cannot occur in a single nested-loop pass; this guard is defensive only. */
|
|
177
|
+
if (clusters.has(key))
|
|
178
|
+
continue;
|
|
179
|
+
clusters.set(key, {
|
|
180
|
+
id: key,
|
|
181
|
+
risk: overlap.score >= 0.75 ? "high" : "medium",
|
|
182
|
+
reason: `Titles/paths share ${overlap.shared} meaningful terms.`,
|
|
183
|
+
items: [left, right],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const clusterList = [...clusters.values()].sort((left, right) => riskRank(right.risk) - riskRank(left.risk));
|
|
188
|
+
const report = {
|
|
189
|
+
repoFullName,
|
|
190
|
+
generatedAt: nowIso(),
|
|
191
|
+
summary: {
|
|
192
|
+
clusterCount: clusterList.length,
|
|
193
|
+
highRiskCount: clusterList.filter((cluster) => cluster.risk === "high").length,
|
|
194
|
+
itemsReviewed: openIssues.length + openPullRequests.length + recentMergedPullRequests.length,
|
|
195
|
+
},
|
|
196
|
+
clusters: clusterList,
|
|
197
|
+
};
|
|
198
|
+
collisionReportTermCache.set(report, itemTerms);
|
|
199
|
+
return report;
|
|
200
|
+
}
|
|
201
|
+
export function itemSharesPlannedLinkedIssue(item, plannedLinkedIssues) {
|
|
202
|
+
return (item.linkedIssues ?? []).some((issueNumber) => plannedLinkedIssues.includes(issueNumber));
|
|
203
|
+
}
|
|
204
|
+
export function buildQueueHealth(repo, issues, pullRequests, collisions, countOverrides = {}) {
|
|
205
|
+
const repoFullName = repo?.fullName ?? collisions.repoFullName;
|
|
206
|
+
const openIssues = issues.filter((issue) => issue.state === "open");
|
|
207
|
+
const openPullRequests = pullRequests.filter((pr) => pr.state === "open");
|
|
208
|
+
const openIssueCount = Math.max(openIssues.length, countOverrides.openIssues ?? 0);
|
|
209
|
+
const openPullRequestCount = Math.max(openPullRequests.length, countOverrides.openPullRequests ?? 0);
|
|
210
|
+
const likelyReviewablePullRequestsSource = countOverrides.likelyReviewablePullRequests !== undefined ? "authoritative" : openPullRequestCount > openPullRequests.length ? "sampled_cache" : "cache";
|
|
211
|
+
const unlinkedPullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length === 0);
|
|
212
|
+
const stalePullRequests = openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14);
|
|
213
|
+
const draftPullRequests = openPullRequests.filter((pr) => pr.isDraft);
|
|
214
|
+
const maintainerAuthoredPullRequests = openPullRequests.filter((pr) => isMaintainerAssociation(pr.authorAssociation));
|
|
215
|
+
const cachedLikelyReviewablePullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length > 0 && daysSince(pr.updatedAt ?? pr.createdAt) < 30).length;
|
|
216
|
+
const likelyReviewablePullRequests = Math.min(openPullRequestCount, Math.max(cachedLikelyReviewablePullRequests, countOverrides.likelyReviewablePullRequests ?? 0));
|
|
217
|
+
const ageBuckets = {
|
|
218
|
+
under7Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) < 7).length,
|
|
219
|
+
days7To30: openPullRequests.filter((pr) => {
|
|
220
|
+
const age = daysSince(pr.updatedAt ?? pr.createdAt);
|
|
221
|
+
return age >= 7 && age <= 30;
|
|
222
|
+
}).length,
|
|
223
|
+
over30Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) > 30).length,
|
|
224
|
+
};
|
|
225
|
+
const burdenScore = clamp(openPullRequestCount * 6 +
|
|
226
|
+
openIssueCount +
|
|
227
|
+
unlinkedPullRequests.length * 8 +
|
|
228
|
+
stalePullRequests.length * 6 +
|
|
229
|
+
ageBuckets.over30Days * 4 +
|
|
230
|
+
collisions.summary.clusterCount * 10 -
|
|
231
|
+
likelyReviewablePullRequests * 2, 0, 100);
|
|
232
|
+
let level = "low";
|
|
233
|
+
if (burdenScore >= 80)
|
|
234
|
+
level = "critical";
|
|
235
|
+
else if (burdenScore >= 55)
|
|
236
|
+
level = "high";
|
|
237
|
+
else if (burdenScore >= 25)
|
|
238
|
+
level = "medium";
|
|
239
|
+
const findings = [];
|
|
240
|
+
if (unlinkedPullRequests.length > 0) {
|
|
241
|
+
findings.push({
|
|
242
|
+
code: "unlinked_prs",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
title: "Open PRs are missing linked issue context",
|
|
245
|
+
detail: `${unlinkedPullRequests.length} open pull request(s) in the local cache do not reference a closing issue.`,
|
|
246
|
+
action: "Ask contributors to link relevant issues or explain no-issue PR intent clearly.",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (collisions.summary.clusterCount > 0) {
|
|
250
|
+
findings.push({
|
|
251
|
+
code: "collision_clusters",
|
|
252
|
+
severity: collisions.summary.highRiskCount > 0 ? "warning" : "info",
|
|
253
|
+
title: "Duplicate or overlapping work is visible",
|
|
254
|
+
detail: `${collisions.summary.clusterCount} possible overlap cluster(s) were detected.`,
|
|
255
|
+
action: "Review overlapping submissions before spending detailed review time.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (stalePullRequests.length > 0) {
|
|
259
|
+
findings.push({
|
|
260
|
+
code: "stale_prs",
|
|
261
|
+
severity: "info",
|
|
262
|
+
title: "Some open PRs appear stale",
|
|
263
|
+
detail: `${stalePullRequests.length} open pull request(s) have not updated in at least 14 days.`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const inactiveDraftPullRequests = draftPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14);
|
|
267
|
+
if (inactiveDraftPullRequests.length > 0) {
|
|
268
|
+
findings.push({
|
|
269
|
+
code: "inactive_draft_prs",
|
|
270
|
+
severity: "info",
|
|
271
|
+
title: "Draft PRs have been open without recent activity",
|
|
272
|
+
detail: `${inactiveDraftPullRequests.length} draft pull request(s) have not updated in at least 14 days — they may be abandoned or blocked.`,
|
|
273
|
+
action: "Mark as ready for review when work resumes, or close if the approach has been abandoned.",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
repoFullName,
|
|
278
|
+
generatedAt: nowIso(),
|
|
279
|
+
burdenScore,
|
|
280
|
+
level,
|
|
281
|
+
summary: `Queue burden is ${level} with ${openPullRequestCount} open PR(s), ${openIssueCount} open issue(s), and ${collisions.summary.clusterCount} overlap cluster(s).`,
|
|
282
|
+
signals: {
|
|
283
|
+
openIssues: openIssueCount,
|
|
284
|
+
openPullRequests: openPullRequestCount,
|
|
285
|
+
unlinkedPullRequests: unlinkedPullRequests.length,
|
|
286
|
+
stalePullRequests: stalePullRequests.length,
|
|
287
|
+
draftPullRequests: draftPullRequests.length,
|
|
288
|
+
maintainerAuthoredPullRequests: maintainerAuthoredPullRequests.length,
|
|
289
|
+
collisionClusters: collisions.summary.clusterCount,
|
|
290
|
+
ageBuckets,
|
|
291
|
+
likelyReviewablePullRequests,
|
|
292
|
+
cachedOpenPullRequests: openPullRequests.length,
|
|
293
|
+
likelyReviewablePullRequestsSource,
|
|
294
|
+
},
|
|
295
|
+
findings,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export function buildPreflightResult(input, repo, issues, pullRequests, bounties = [], issueQuality,
|
|
299
|
+
// Default true so every existing caller (which predates this param) keeps its exact prior behavior.
|
|
300
|
+
registryEverSynced = true) {
|
|
301
|
+
const lane = buildLaneAdvice(repo, input.repoFullName);
|
|
302
|
+
const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars), input.repoFullName)])].sort((left, right) => left - right);
|
|
303
|
+
// Flag an existing open-work cluster as a possible duplicate when it shares a
|
|
304
|
+
// linked issue, OR when its title/body meaningfully overlaps the planned
|
|
305
|
+
// contribution. The previous check used `item.title.includes(input.title)`,
|
|
306
|
+
// which only matched when an existing item's title contained the *entire*
|
|
307
|
+
// planned title — so a typical (longer, more descriptive) planned PR title
|
|
308
|
+
// never matched a shorter duplicate issue, silently suppressing the warning,
|
|
309
|
+
// while a short planned title spuriously matched unrelated items. Use the same
|
|
310
|
+
// symmetric term-overlap heuristic `buildCollisionReport` uses between items
|
|
311
|
+
// (>=2 shared meaningful terms), which is direction-independent.
|
|
312
|
+
const plannedTerms = plannedContributionTerms(input);
|
|
313
|
+
const collisionReport = buildCollisionReport(input.repoFullName, issues, pullRequests);
|
|
314
|
+
let cachedItemTerms = collisionReportTermCache.get(collisionReport);
|
|
315
|
+
/* v8 ignore next -- Defensive only: buildCollisionReport always seeds term maps before preflight reads them. */
|
|
316
|
+
if (cachedItemTerms === undefined)
|
|
317
|
+
cachedItemTerms = new Map();
|
|
318
|
+
const itemTerms = cachedItemTerms;
|
|
319
|
+
const collisions = collisionReport.clusters.filter((cluster) => cluster.items.some((item) => {
|
|
320
|
+
if (itemSharesPlannedLinkedIssue(item, linkedIssues)) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
const overlap = termOverlap(plannedTerms, (() => {
|
|
324
|
+
let terms = itemTerms.get(itemKey(item));
|
|
325
|
+
/* v8 ignore next -- Defensive only: collision item terms are cached for every cluster item. */
|
|
326
|
+
if (terms === undefined)
|
|
327
|
+
terms = collisionTerms(item);
|
|
328
|
+
return terms;
|
|
329
|
+
})());
|
|
330
|
+
return overlap.shared >= 2 && overlap.score >= 0.5;
|
|
331
|
+
}));
|
|
332
|
+
const findings = [];
|
|
333
|
+
// An "unknown" lane means "not found in the local registry cache", which is genuinely ambiguous: it's the
|
|
334
|
+
// same result whether this repo simply isn't registered in a WORKING snapshot, or the registry sync has
|
|
335
|
+
// never once succeeded (a self-host connectivity/config problem with no bearing on this PR at all). Only
|
|
336
|
+
// treat "unknown" as a real signal once we know the sync mechanism itself has produced at least one
|
|
337
|
+
// snapshot; "inactive" (zero emission share) is unambiguous either way -- it is only reachable from real
|
|
338
|
+
// synced data.
|
|
339
|
+
const laneUnavailable = (lane.lane === "unknown" && registryEverSynced) || lane.lane === "inactive";
|
|
340
|
+
const maintainerAuthored = isMaintainerAssociation(input.authorAssociation);
|
|
341
|
+
if (laneUnavailable) {
|
|
342
|
+
findings.push({
|
|
343
|
+
code: "lane_not_recommended",
|
|
344
|
+
severity: maintainerAuthored ? "info" : "warning",
|
|
345
|
+
title: maintainerAuthored ? "Repo lane unavailable for contributor scoring" : "Repo lane is not ready for a confident recommendation",
|
|
346
|
+
detail: maintainerAuthored ? `${lane.summary} Maintainer-authored work is treated as repo stewardship, not contributor-lane eligibility.` : lane.summary,
|
|
347
|
+
action: maintainerAuthored ? "No action." : "Refresh registry data or choose a registered active repo.",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (linkedIssues.length === 0 && lane.lane !== "issue_discovery") {
|
|
351
|
+
findings.push({
|
|
352
|
+
code: "missing_linked_issue",
|
|
353
|
+
severity: "warning",
|
|
354
|
+
title: "No linked issue detected",
|
|
355
|
+
detail: "The planned PR does not reference a closing issue or explicit linked issue number.",
|
|
356
|
+
action: "Link the issue being solved, or explicitly explain why this is a no-issue PR.",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (collisions.length > 0) {
|
|
360
|
+
findings.push({
|
|
361
|
+
code: "possible_duplicate_work",
|
|
362
|
+
/* v8 ignore next -- High-risk severity is covered through collision reports; info-only clusters are presentation fallback. */
|
|
363
|
+
severity: collisions.some((cluster) => cluster.risk === "high") ? "warning" : "info",
|
|
364
|
+
title: "Possible duplicate or overlapping work",
|
|
365
|
+
detail: `${collisions.length} related open work cluster(s) were detected.`,
|
|
366
|
+
action: "Check active issues and PRs before submitting.",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const bountyByIssue = indexBountiesByIssue(bounties);
|
|
370
|
+
for (const issueNumber of linkedIssues) {
|
|
371
|
+
const bounty = bountyByIssue.get(bountyIssueKey(input.repoFullName, issueNumber));
|
|
372
|
+
if (!bounty)
|
|
373
|
+
continue;
|
|
374
|
+
const linkedIssue = issues.find((candidate) => candidate.repoFullName.toLowerCase() === input.repoFullName.toLowerCase() && candidate.number === issueNumber) ?? null;
|
|
375
|
+
const lifecycle = classifyBountyLifecycle(bounty, linkedIssue);
|
|
376
|
+
if (isHistoricalBountyLifecycle(lifecycle)) {
|
|
377
|
+
findings.push({
|
|
378
|
+
code: "linked_issue_bounty_historical",
|
|
379
|
+
severity: "info",
|
|
380
|
+
title: "Linked issue bounty is historical",
|
|
381
|
+
detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm the work is still wanted before investing in it.`,
|
|
382
|
+
action: "Verify the bounty and issue are still open upstream.",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
else if (lifecycle === "stale") {
|
|
386
|
+
findings.push({
|
|
387
|
+
code: "linked_issue_bounty_unverified",
|
|
388
|
+
severity: "warning",
|
|
389
|
+
title: "Linked issue bounty needs verification",
|
|
390
|
+
detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm it is still active before relying on it as contribution context.`,
|
|
391
|
+
action: "Re-check the upstream bounty source before submitting.",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
else if (lifecycle === "ambiguous") {
|
|
395
|
+
findings.push({
|
|
396
|
+
code: "linked_issue_bounty_unverified",
|
|
397
|
+
severity: "warning",
|
|
398
|
+
title: "Linked issue bounty needs verification",
|
|
399
|
+
detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm it is still active before relying on it as contribution context.`,
|
|
400
|
+
action: "Re-check the upstream bounty source before submitting.",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
findings.push(...issueQualityFindings(linkedIssues, issueQuality));
|
|
405
|
+
const changedFiles = input.changedFiles ?? [];
|
|
406
|
+
const tests = input.tests ?? [];
|
|
407
|
+
if (changedFiles.some((file) => isCodeFile(file)) && tests.length === 0 && !changedFiles.some((file) => isTestFile(file))) {
|
|
408
|
+
findings.push({
|
|
409
|
+
code: "missing_test_evidence",
|
|
410
|
+
severity: "warning",
|
|
411
|
+
title: "No test evidence supplied",
|
|
412
|
+
detail: "Code files are listed, but no tests or test files were supplied in preflight input.",
|
|
413
|
+
action: "Add focused test evidence or explain why existing coverage is sufficient.",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const reviewBurden = changedFiles.length >= 12 || collisions.length > 0 ? "high" : changedFiles.length >= 5 ? "medium" : "low";
|
|
417
|
+
const hasWarning = findings.some((finding) => finding.severity === "warning" || finding.severity === "critical");
|
|
418
|
+
return {
|
|
419
|
+
repoFullName: input.repoFullName,
|
|
420
|
+
generatedAt: nowIso(),
|
|
421
|
+
status: laneUnavailable && !maintainerAuthored ? "hold" : hasWarning ? "needs_work" : "ready",
|
|
422
|
+
lane,
|
|
423
|
+
reviewBurden,
|
|
424
|
+
linkedIssues,
|
|
425
|
+
findings,
|
|
426
|
+
collisions,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function issueQualityFindings(linkedIssues, issueQuality) {
|
|
430
|
+
if (!issueQuality || linkedIssues.length === 0)
|
|
431
|
+
return [];
|
|
432
|
+
const byIssue = new Map(issueQuality.issues.map((issue) => [issue.number, issue]));
|
|
433
|
+
return linkedIssues.flatMap((issueNumber) => {
|
|
434
|
+
const quality = byIssue.get(issueNumber);
|
|
435
|
+
if (!quality || quality.status === "ready")
|
|
436
|
+
return [];
|
|
437
|
+
const detail = quality.warnings[0] ?? `Issue quality report marks #${issueNumber} as ${quality.status}.`;
|
|
438
|
+
if (quality.status === "do_not_use") {
|
|
439
|
+
return [
|
|
440
|
+
{
|
|
441
|
+
code: "issue_quality_do_not_use",
|
|
442
|
+
severity: "warning",
|
|
443
|
+
title: "Linked issue is already covered or duplicate-prone",
|
|
444
|
+
detail,
|
|
445
|
+
action: "Confirm the linked issue is still actionable before posting public PR context.",
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
if (quality.status === "needs_proof") {
|
|
450
|
+
return [
|
|
451
|
+
{
|
|
452
|
+
code: "issue_quality_needs_proof",
|
|
453
|
+
severity: "warning",
|
|
454
|
+
title: "Linked issue needs stronger proof",
|
|
455
|
+
detail,
|
|
456
|
+
action: "Add concrete reproduction, scope, or maintainer context before proceeding.",
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
}
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
code: "issue_quality_hold",
|
|
463
|
+
severity: "warning",
|
|
464
|
+
title: "Linked issue is on hold",
|
|
465
|
+
detail,
|
|
466
|
+
action: "Choose a clearer candidate or wait for maintainer context.",
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
export const BOUNTY_STALE_DAYS = 45;
|
|
472
|
+
export function bountyIssueKey(repoFullName, issueNumber) {
|
|
473
|
+
return `${repoFullName.toLowerCase()}#${issueNumber}`;
|
|
474
|
+
}
|
|
475
|
+
export function indexBountiesByIssue(bounties) {
|
|
476
|
+
const map = new Map();
|
|
477
|
+
for (const bounty of bounties) {
|
|
478
|
+
map.set(bountyIssueKey(bounty.repoFullName, bounty.issueNumber), bounty);
|
|
479
|
+
}
|
|
480
|
+
return map;
|
|
481
|
+
}
|
|
482
|
+
export function classifyBountyLifecycle(bounty, issue) {
|
|
483
|
+
const status = bounty.status.trim().toLowerCase();
|
|
484
|
+
if (!status)
|
|
485
|
+
return "unknown";
|
|
486
|
+
if (/cancel|void|expired|withdrawn|rejected|abandon/.test(status))
|
|
487
|
+
return "cancelled";
|
|
488
|
+
// Only past-tense payout phrasing (rewarded/awarded) marks completion; a bounty that merely
|
|
489
|
+
// advertises a "reward"/"award" is an active offer, not already-completed work.
|
|
490
|
+
if (/complete|paid|resolved|rewarded|awarded|fulfil|merged|claimed|done/.test(status))
|
|
491
|
+
return "completed";
|
|
492
|
+
if (/historical|archived|closed/.test(status))
|
|
493
|
+
return "historical";
|
|
494
|
+
const looksActive = /open|active|live|available|ready|funded|reward|award|in[\s_-]?progress|todo|new/.test(status);
|
|
495
|
+
if (!looksActive)
|
|
496
|
+
return "ambiguous";
|
|
497
|
+
// Active-looking status: reconcile against the linked issue and freshness so dead context is not treated as live.
|
|
498
|
+
if (issue && issue.state !== "open")
|
|
499
|
+
return "ambiguous";
|
|
500
|
+
if (daysSince(bounty.updatedAt ?? bounty.discoveredAt) > BOUNTY_STALE_DAYS)
|
|
501
|
+
return "stale";
|
|
502
|
+
return "active";
|
|
503
|
+
}
|
|
504
|
+
export function isHistoricalBountyLifecycle(lifecycle) {
|
|
505
|
+
return lifecycle === "historical" || lifecycle === "completed" || lifecycle === "cancelled";
|
|
506
|
+
}
|
|
507
|
+
export function buildPublicReadinessScore(args) {
|
|
508
|
+
const linkedIssues = args.pr.linkedIssues;
|
|
509
|
+
const hasNoIssueRationale = hasClearNoIssueRationale(args.pr);
|
|
510
|
+
const linkedDuplicatePrs = args.linkedDuplicatePrs ?? [];
|
|
511
|
+
const scopedOverlapCount = args.scopedOverlapCount ?? 0;
|
|
512
|
+
const reviewLoadScore = reviewLoadComponentScore(args.preflight.reviewBurden);
|
|
513
|
+
const validation = validationComponent(args.pr, args.preflight);
|
|
514
|
+
const queuePressure = queuePressureComponent(args.queueHealth);
|
|
515
|
+
const components = [
|
|
516
|
+
{
|
|
517
|
+
key: "traceability",
|
|
518
|
+
label: "Traceability",
|
|
519
|
+
score: linkedIssues.length > 0 || hasNoIssueRationale ? 15 : 8,
|
|
520
|
+
max: 15,
|
|
521
|
+
evidence: linkedIssues.length > 0
|
|
522
|
+
? `Linked issue${linkedIssues.length === 1 ? "" : "s"} ${formatIssueRefs(linkedIssues)}.`
|
|
523
|
+
: hasNoIssueRationale
|
|
524
|
+
? "PR body includes a no-issue rationale."
|
|
525
|
+
: "No linked issue or no-issue rationale found.",
|
|
526
|
+
action: linkedIssues.length > 0 || hasNoIssueRationale ? "No action." : "Explain no-issue PR.",
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
key: "related_work",
|
|
530
|
+
label: "Related work",
|
|
531
|
+
score: linkedDuplicatePrs.length > 0 ? 8 : scopedOverlapCount > 0 ? 14 : 20,
|
|
532
|
+
max: 20,
|
|
533
|
+
evidence: linkedDuplicatePrs.length > 0
|
|
534
|
+
? `Same linked issue with ${formatPrRefs(linkedDuplicatePrs)}.`
|
|
535
|
+
: scopedOverlapCount > 0
|
|
536
|
+
? `${Math.min(scopedOverlapCount, 3)} scoped overlap${Math.min(scopedOverlapCount, 3) === 1 ? "" : "s"} found.`
|
|
537
|
+
: "No active overlap found.",
|
|
538
|
+
action: linkedDuplicatePrs.length > 0 ? `Compare ${formatPrRefs(linkedDuplicatePrs)}.` : scopedOverlapCount > 0 ? "Review top overlaps." : "No action.",
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
key: "change_scope",
|
|
542
|
+
label: "Change scope",
|
|
543
|
+
score: reviewLoadScore,
|
|
544
|
+
max: 20,
|
|
545
|
+
evidence: changeScopeEvidence(args.pr, args.preflight.reviewBurden),
|
|
546
|
+
action: reviewLoadScore >= 18 ? "No action." : "Add a concise scope and risk note.",
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
key: "validation",
|
|
550
|
+
label: "Validation posture",
|
|
551
|
+
score: validation.score,
|
|
552
|
+
max: 25,
|
|
553
|
+
evidence: validation.evidence,
|
|
554
|
+
action: validation.action,
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
key: "pr_state",
|
|
558
|
+
label: "PR state",
|
|
559
|
+
score: args.pr.state === "open" && !args.pr.isDraft ? 10 : args.pr.state === "open" ? 6 : 3,
|
|
560
|
+
max: 10,
|
|
561
|
+
evidence: args.pr.isDraft ? "PR is open as draft." : `PR state is ${args.pr.state}.`,
|
|
562
|
+
action: args.pr.state === "open" && !args.pr.isDraft ? "No action." : args.pr.isDraft ? "Mark ready when done." : "No action.",
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
key: "queue_pressure",
|
|
566
|
+
label: "Review queue context",
|
|
567
|
+
score: queuePressure.score,
|
|
568
|
+
max: queuePressure.max,
|
|
569
|
+
evidence: queuePressure.evidence,
|
|
570
|
+
action: queuePressure.action,
|
|
571
|
+
},
|
|
572
|
+
];
|
|
573
|
+
return {
|
|
574
|
+
total: clamp(components.reduce((sum, component) => sum + component.score, 0), 0, 100),
|
|
575
|
+
components,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function pullRequestSpecificCollisionClusters(report, pr) {
|
|
579
|
+
return report.clusters.filter((cluster) => cluster.items.some((item) => item.type === "pull_request" && item.number === pr.number));
|
|
580
|
+
}
|
|
581
|
+
/** Deduplicated union of PR-specific collision clusters and preflight overlap clusters. */
|
|
582
|
+
export function unionScopedOverlapClusters(report, pr, preflightCollisions) {
|
|
583
|
+
const prCollisionClusters = pullRequestSpecificCollisionClusters(report, pr);
|
|
584
|
+
return [...new Map([...prCollisionClusters, ...preflightCollisions].map((cluster) => [cluster.id, cluster])).values()];
|
|
585
|
+
}
|
|
586
|
+
function sanitizePanelText(value) {
|
|
587
|
+
return value.replace(/\s+/g, " ").trim();
|
|
588
|
+
}
|
|
589
|
+
function changeScopeEvidence(pr, reviewBurden) {
|
|
590
|
+
const burden = reviewBurden === "low" ? "Low" : reviewBurden === "medium" ? "Medium" : "High";
|
|
591
|
+
const sizeLabel = pr.labels.find((label) => /^size[:/-]/i.test(label));
|
|
592
|
+
const detailParts = [
|
|
593
|
+
sizeLabel ? `size label ${sanitizePanelText(sizeLabel)}` : undefined,
|
|
594
|
+
pr.isDraft ? "draft PR" : undefined,
|
|
595
|
+
pr.linkedIssues.length > 0 ? `${pr.linkedIssues.length} linked issue${pr.linkedIssues.length === 1 ? "" : "s"}` : "no linked issue context",
|
|
596
|
+
].filter(Boolean);
|
|
597
|
+
return `${burden} review scope from cached public metadata (${detailParts.join("; ")}).`;
|
|
598
|
+
}
|
|
599
|
+
function reviewLoadComponentScore(reviewBurden) {
|
|
600
|
+
if (reviewBurden === "low")
|
|
601
|
+
return 20;
|
|
602
|
+
if (reviewBurden === "medium")
|
|
603
|
+
return 14;
|
|
604
|
+
return 8;
|
|
605
|
+
}
|
|
606
|
+
function validationComponent(pr, preflight) {
|
|
607
|
+
const findingCodes = preflight.findings.map((finding) => finding.code);
|
|
608
|
+
const missingTests = findingCodes.some((code) => /missing.*test|test.*missing|no_test/i.test(code));
|
|
609
|
+
const explicitValidation = hasValidationNote(pr.body ?? "");
|
|
610
|
+
if (preflight.status === "hold") {
|
|
611
|
+
return { score: 5, evidence: "Preflight is holding this PR: the review lane is unavailable, so it is not ready for automated review.", action: "Await review-lane availability." };
|
|
612
|
+
}
|
|
613
|
+
if (missingTests) {
|
|
614
|
+
// A body validation note is an UNBACKED claim when no test files accompany the change. Cap it just above the
|
|
615
|
+
// no-signal floor so a one-line "tested" cannot lift readiness over a configured gate threshold on a
|
|
616
|
+
// zero-test PR — full credit is reserved for actual test evidence in the branch below. (#audit-2.3)
|
|
617
|
+
return explicitValidation
|
|
618
|
+
? { score: 12, evidence: "PR body claims validation but no test files accompany the change.", action: "Add tests covering the change." }
|
|
619
|
+
: { score: 10, evidence: "No cached test files or validation note found.", action: "Add tests or validation evidence." };
|
|
620
|
+
}
|
|
621
|
+
if (explicitValidation) {
|
|
622
|
+
return { score: 25, evidence: "PR body includes validation/test evidence.", action: "No action." };
|
|
623
|
+
}
|
|
624
|
+
if (preflight.status === "ready") {
|
|
625
|
+
return { score: 20, evidence: "Preflight is ready, but the PR body does not name the validation run.", action: "Add validation command/output." };
|
|
626
|
+
}
|
|
627
|
+
return { score: 12, evidence: "Preflight needs author follow-up before maintainer review.", action: "Address findings or add validation evidence." };
|
|
628
|
+
}
|
|
629
|
+
function queuePressureComponent(queueHealth) {
|
|
630
|
+
const signals = queueHealth.signals;
|
|
631
|
+
const openPullRequests = Math.max(0, signals.openPullRequests);
|
|
632
|
+
const cachedOpenPullRequests = Math.max(0, signals.cachedOpenPullRequests ?? signals.ageBuckets.under7Days + signals.ageBuckets.days7To30 + signals.ageBuckets.over30Days);
|
|
633
|
+
const likelyReviewablePullRequests = Math.max(0, Math.min(openPullRequests, signals.likelyReviewablePullRequests));
|
|
634
|
+
const sampledLikelyReviewable = signals.likelyReviewablePullRequestsSource === "sampled_cache" || (signals.likelyReviewablePullRequestsSource === undefined && cachedOpenPullRequests < openPullRequests);
|
|
635
|
+
const score = queuePressureScore(openPullRequests);
|
|
636
|
+
const likelyEvidence = openPullRequests === 0
|
|
637
|
+
? "0 likely reviewable"
|
|
638
|
+
: sampledLikelyReviewable
|
|
639
|
+
? cachedOpenPullRequests > 0
|
|
640
|
+
? `${likelyReviewablePullRequests} likely reviewable in ${cachedOpenPullRequests} cached PR(s); full queue reviewability is sampled`
|
|
641
|
+
: "likely-reviewable count unavailable from cached PR metadata"
|
|
642
|
+
: `${likelyReviewablePullRequests} likely reviewable`;
|
|
643
|
+
const detailParts = [
|
|
644
|
+
`${openPullRequests} open PR(s)`,
|
|
645
|
+
likelyEvidence,
|
|
646
|
+
signals.stalePullRequests > 0 ? `${signals.stalePullRequests} stale` : undefined,
|
|
647
|
+
signals.unlinkedPullRequests > 0 ? `${signals.unlinkedPullRequests} unlinked` : undefined,
|
|
648
|
+
].filter(Boolean);
|
|
649
|
+
return {
|
|
650
|
+
score,
|
|
651
|
+
max: 10,
|
|
652
|
+
evidence: `Repo queue: ${detailParts.join(", ")}.`,
|
|
653
|
+
action: score >= 8 ? "No action." : "Triage stale or unlinked PRs.",
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function queuePressureScore(openPullRequests) {
|
|
657
|
+
if (openPullRequests === 0)
|
|
658
|
+
return 10;
|
|
659
|
+
return queuePressureOpenPullRequestScore(openPullRequests);
|
|
660
|
+
}
|
|
661
|
+
function queuePressureOpenPullRequestScore(openPullRequests) {
|
|
662
|
+
if (openPullRequests <= 4)
|
|
663
|
+
return 10;
|
|
664
|
+
if (openPullRequests <= 8)
|
|
665
|
+
return 8;
|
|
666
|
+
if (openPullRequests <= 13)
|
|
667
|
+
return 5;
|
|
668
|
+
return 3;
|
|
669
|
+
}
|
|
670
|
+
export function hasClearNoIssueRationale(pr) {
|
|
671
|
+
// `docs?[\s-]+only` matches the space form ("docs only") AND the hyphenated "docs-only" / "doc-only"
|
|
672
|
+
// spelling this function's own docstring uses — the dominant GitHub/Conventional-Commits form. A bare
|
|
673
|
+
// `docs? only` missed the hyphen, so a docs-only PR with no linked issue was wrongly denied a clear
|
|
674
|
+
// no-issue rationale and hard-blocked under `linkedIssueGateMode === "block"`.
|
|
675
|
+
// `tests?[\s-]+only` extends the same rule to test-only PRs (regression/coverage-only diffs) — parallel
|
|
676
|
+
// to the docs-only hyphenation fix merged in #1905 and the test-only follow-up in #1993.
|
|
677
|
+
// `ci[\s-]+only` covers CI/workflow-only PRs using the same Conventional Commits spelling.
|
|
678
|
+
// `refactor[\s-]+only` covers internal refactors with no behavior change using the same spelling.
|
|
679
|
+
return /\b(?:no issue\s*(?:because\b|:)|no linked issue\s*(?:because\b|:)|no ticket\s*(?:because\b|:)|(?:maintenance|docs?[\s-]+only|tests?[\s-]+only|ci[\s-]+only|refactor[\s-]+only|typo|chore|cleanup)\b)/i.test([pr.title, pr.body ?? ""].join(" "));
|
|
680
|
+
}
|
|
681
|
+
function formatPrRefs(numbers) {
|
|
682
|
+
return numbers.map((number) => `#${number}`).join(", ");
|
|
683
|
+
}
|
|
684
|
+
function formatIssueRefs(numbers) {
|
|
685
|
+
return numbers.map((number) => `#${number}`).join(", ");
|
|
686
|
+
}
|
|
687
|
+
function issueItem(issue) {
|
|
688
|
+
return {
|
|
689
|
+
type: "issue",
|
|
690
|
+
number: issue.number,
|
|
691
|
+
title: issue.title,
|
|
692
|
+
authorLogin: issue.authorLogin,
|
|
693
|
+
htmlUrl: issue.htmlUrl,
|
|
694
|
+
labels: issue.labels,
|
|
695
|
+
linkedIssues: [issue.number],
|
|
696
|
+
body: issue.body,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function prItem(pr) {
|
|
700
|
+
return {
|
|
701
|
+
type: "pull_request",
|
|
702
|
+
number: pr.number,
|
|
703
|
+
title: pr.title,
|
|
704
|
+
authorLogin: pr.authorLogin,
|
|
705
|
+
htmlUrl: pr.htmlUrl,
|
|
706
|
+
labels: pr.labels,
|
|
707
|
+
linkedIssues: pr.linkedIssues,
|
|
708
|
+
linkedIssueClaimedAt: pr.linkedIssueClaimedAt,
|
|
709
|
+
changedFiles: pr.changedFiles,
|
|
710
|
+
body: pr.body,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function recentMergedItem(pr) {
|
|
714
|
+
return {
|
|
715
|
+
type: "recent_merged_pull_request",
|
|
716
|
+
number: pr.number,
|
|
717
|
+
title: pr.title,
|
|
718
|
+
authorLogin: pr.authorLogin,
|
|
719
|
+
htmlUrl: pr.htmlUrl,
|
|
720
|
+
labels: pr.labels,
|
|
721
|
+
linkedIssues: pr.linkedIssues,
|
|
722
|
+
changedFiles: pr.changedFiles,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
function itemKey(item) {
|
|
726
|
+
return `${item.type}-${item.number}`;
|
|
727
|
+
}
|
|
728
|
+
function boundedCollisionIssues(openIssues, openPullRequests) {
|
|
729
|
+
/* v8 ignore start -- Large-queue sampling is a deterministic guard; standard and linked collision paths are covered above. */
|
|
730
|
+
if (openIssues.length <= MAX_COLLISION_PAIRWISE_ISSUES)
|
|
731
|
+
return openIssues;
|
|
732
|
+
const linkedIssueNumbers = new Set(openPullRequests.flatMap((pr) => pr.linkedIssues));
|
|
733
|
+
const selected = new Map();
|
|
734
|
+
for (const issue of openIssues) {
|
|
735
|
+
if (linkedIssueNumbers.has(issue.number))
|
|
736
|
+
selected.set(issue.number, issue);
|
|
737
|
+
if (selected.size >= MAX_COLLISION_PAIRWISE_ISSUES)
|
|
738
|
+
return [...selected.values()];
|
|
739
|
+
}
|
|
740
|
+
for (const issue of openIssues) {
|
|
741
|
+
selected.set(issue.number, issue);
|
|
742
|
+
if (selected.size >= MAX_COLLISION_PAIRWISE_ISSUES)
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
return [...selected.values()];
|
|
746
|
+
/* v8 ignore stop */
|
|
747
|
+
}
|
|
748
|
+
function boundedCollisionPullRequests(openPullRequests) {
|
|
749
|
+
/* v8 ignore start -- Large-queue PR sampling mirrors boundedCollisionIssues; linked and pairwise collision paths are covered above. */
|
|
750
|
+
if (openPullRequests.length <= MAX_COLLISION_PAIRWISE_PULL_REQUESTS)
|
|
751
|
+
return openPullRequests;
|
|
752
|
+
// Rank linked-issue PRs ahead of unlinked ones, then by recency within each group, so the cap keeps
|
|
753
|
+
// the most-relevant PRs even when linked PRs alone exceed the budget (not just whichever appear
|
|
754
|
+
// first in caller order).
|
|
755
|
+
const ranked = [...openPullRequests].sort((left, right) => Number(left.linkedIssues.length === 0) - Number(right.linkedIssues.length === 0) ||
|
|
756
|
+
(right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") ||
|
|
757
|
+
left.number - right.number);
|
|
758
|
+
return ranked.slice(0, MAX_COLLISION_PAIRWISE_PULL_REQUESTS);
|
|
759
|
+
/* v8 ignore stop */
|
|
760
|
+
}
|
|
761
|
+
const collisionReportTermCache = new WeakMap();
|
|
762
|
+
function collisionTerms(item, includePaths = true) {
|
|
763
|
+
const terms = new Set(tokenize(collisionItemText(item, includePaths)));
|
|
764
|
+
return { terms, size: terms.size };
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Tokenized terms for the planned contribution, used to detect overlap with
|
|
768
|
+
* existing open work. Mirrors `collisionTerms` so the planned PR is compared to
|
|
769
|
+
* collision items with the same term-overlap heuristic `buildCollisionReport`
|
|
770
|
+
* uses between items, rather than a one-direction substring test.
|
|
771
|
+
*/
|
|
772
|
+
function plannedContributionTerms(input) {
|
|
773
|
+
const terms = new Set(tokenize([
|
|
774
|
+
truncateText(input.title, PREFLIGHT_LIMITS.titleChars),
|
|
775
|
+
...boundedTextItems(input.labels, PREFLIGHT_LIMITS.labels, PREFLIGHT_LIMITS.labelChars),
|
|
776
|
+
...boundedTextItems(input.changedFiles, PREFLIGHT_LIMITS.changedFiles, PREFLIGHT_LIMITS.changedFileChars),
|
|
777
|
+
].join(" ")));
|
|
778
|
+
return { terms, size: terms.size };
|
|
779
|
+
}
|
|
780
|
+
export function termOverlap(left, right) {
|
|
781
|
+
if (left.size === 0)
|
|
782
|
+
return { score: 0, shared: 0 };
|
|
783
|
+
if (right.size === 0)
|
|
784
|
+
return { score: 0, shared: 0 };
|
|
785
|
+
let shared = 0;
|
|
786
|
+
const [smaller, larger] = left.size <= right.size ? [left.terms, right.terms] : [right.terms, left.terms];
|
|
787
|
+
for (const term of smaller) {
|
|
788
|
+
if (larger.has(term))
|
|
789
|
+
shared += 1;
|
|
790
|
+
}
|
|
791
|
+
return { score: shared / Math.min(left.size, right.size), shared };
|
|
792
|
+
}
|
|
793
|
+
function collisionItemText(item, includePaths = true) {
|
|
794
|
+
return [
|
|
795
|
+
truncateText(item.title, PREFLIGHT_LIMITS.titleChars),
|
|
796
|
+
...boundedTextItems(item.labels, PREFLIGHT_LIMITS.labels, PREFLIGHT_LIMITS.labelChars),
|
|
797
|
+
...(includePaths ? boundedTextItems(item.changedFiles, PREFLIGHT_LIMITS.changedFiles, PREFLIGHT_LIMITS.changedFileChars) : []),
|
|
798
|
+
]
|
|
799
|
+
.filter(Boolean)
|
|
800
|
+
.join(" ");
|
|
801
|
+
}
|
|
802
|
+
function boundedTextItems(values, maxItems, maxChars) {
|
|
803
|
+
return (values ?? []).slice(0, maxItems).map((value) => truncateText(value, maxChars));
|
|
804
|
+
}
|
|
805
|
+
function truncateText(value, maxChars) {
|
|
806
|
+
if (value.length <= maxChars)
|
|
807
|
+
return value;
|
|
808
|
+
return value.slice(0, maxChars);
|
|
809
|
+
}
|
|
810
|
+
// Exported (#3183) so the project/milestone text matcher (src/integrations/project-tracker-adapter.ts) can
|
|
811
|
+
// reuse the exact same term-overlap heuristic already proven here for duplicate-PR collision detection, rather
|
|
812
|
+
// than re-implementing a second, subtly different tokenizer.
|
|
813
|
+
export function tokenize(value) {
|
|
814
|
+
return value
|
|
815
|
+
.toLowerCase()
|
|
816
|
+
.split(/[^a-z0-9]+/g)
|
|
817
|
+
.filter((term) => term.length > 2 && !STOPWORDS.has(term));
|
|
818
|
+
}
|
|
819
|
+
function extractLinkedIssueNumbers(text, repoFullName) {
|
|
820
|
+
const numbers = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)].map((match) => Number(match[1]));
|
|
821
|
+
// GitHub also auto-closes via the fully-qualified `KEYWORD owner/repo#N` form (e.g. Renovate/Dependabot bodies).
|
|
822
|
+
// Count it only when owner/repo case-insensitively equals THIS repo — a reference to a different repo closes an
|
|
823
|
+
// issue elsewhere, not here, so it must not spoof a same-repo link. Same `\b`-anchored keywords as above (#1988).
|
|
824
|
+
const target = repoFullName.toLowerCase();
|
|
825
|
+
for (const match of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([\w.-]+\/[\w.-]+)#(\d+)\b/gi)) {
|
|
826
|
+
if (match[1].toLowerCase() === target)
|
|
827
|
+
numbers.push(Number(match[2]));
|
|
828
|
+
}
|
|
829
|
+
return [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))];
|
|
830
|
+
}
|
|
831
|
+
function isMaintainerAssociation(value) {
|
|
832
|
+
return value === "OWNER" || value === "MEMBER" || value === "COLLABORATOR";
|
|
833
|
+
}
|
|
834
|
+
function sameLogin(value, login) {
|
|
835
|
+
return value?.toLowerCase() === login.toLowerCase();
|
|
836
|
+
}
|
|
837
|
+
function isPullRequestShapedItem(item) {
|
|
838
|
+
return item.type === "pull_request" || item.type === "recent_merged_pull_request";
|
|
839
|
+
}
|
|
840
|
+
/** True when two changed-file lists share at least one path that isn't a lockfile/generated/vendor artifact
|
|
841
|
+
* (diffFilePriority's least-useful-to-review bucket) — a shared package-lock.json or dist/ output is touched
|
|
842
|
+
* incidentally by unrelated PRs and is not evidence of a real collision. */
|
|
843
|
+
function sharesMeaningfulFile(left, right) {
|
|
844
|
+
if (!left || !right)
|
|
845
|
+
return false;
|
|
846
|
+
if (left.length === 0 || right.length === 0)
|
|
847
|
+
return false;
|
|
848
|
+
const rightSet = new Set(right);
|
|
849
|
+
return left.some((path) => rightSet.has(path) && diffFilePriority(path) < 4);
|
|
850
|
+
}
|
|
851
|
+
function daysSince(value) {
|
|
852
|
+
if (!value)
|
|
853
|
+
return 0;
|
|
854
|
+
const parsed = Date.parse(value);
|
|
855
|
+
/* v8 ignore next -- Invalid provider timestamps normalize to fresh; stale timestamp handling is covered by signal tests. */
|
|
856
|
+
if (!Number.isFinite(parsed))
|
|
857
|
+
return 0;
|
|
858
|
+
return Math.floor((Date.now() - parsed) / 86_400_000);
|
|
859
|
+
}
|
|
860
|
+
function isCodeFile(file) {
|
|
861
|
+
// Mirrors isCodeFile in local-branch.ts — kept in sync (cs/swift/groovy/php and C/C++/Objective-C added
|
|
862
|
+
// so native/C#/Swift/Groovy/PHP source counts as code, matching the test conventions
|
|
863
|
+
// isTestPath already recognizes; vue/svelte/astro match rag.ts, visual paths, and isCodePath;
|
|
864
|
+
// cc/hpp complete the C++ extension set alongside cpp/c/h; dart matches rag.ts and
|
|
865
|
+
// test-evidence's *_test.dart test convention).
|
|
866
|
+
return (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs|kt|scala|java|go|sql|cs|swift|groovy|php|cpp|cc|c|h|hpp|m|vue|svelte|astro|dart)$/i.test(file) && !isTestFile(file));
|
|
867
|
+
}
|
|
868
|
+
function isTestFile(file) {
|
|
869
|
+
// Single-sourced with the canonical matcher (test-evidence.ts isTestPath), mirroring local-branch.ts's
|
|
870
|
+
// isTestFile — so cy/e2e, __snapshots__, and module extensions stay in sync and can't drift.
|
|
871
|
+
return isTestPath(file);
|
|
872
|
+
}
|
|
873
|
+
function riskRank(risk) {
|
|
874
|
+
if (risk === "high")
|
|
875
|
+
return 3;
|
|
876
|
+
/* v8 ignore next -- Low collision rank is the default branch; high/medium sorting behavior is covered by collision tests. */
|
|
877
|
+
if (risk === "medium")
|
|
878
|
+
return 2;
|
|
879
|
+
/* v8 ignore next -- Collision clusters are only assigned medium/high risk today; low is the unreachable default. */
|
|
880
|
+
return 1;
|
|
881
|
+
}
|
|
882
|
+
function clamp(value, min, max) {
|
|
883
|
+
return Math.max(min, Math.min(max, value));
|
|
884
|
+
}
|
|
885
|
+
/** @internal Exported for unit tests of predicted-gate engine helpers. */
|
|
886
|
+
export const predictedGateEngineInternals = {
|
|
887
|
+
sharesMeaningfulFile,
|
|
888
|
+
truncateText,
|
|
889
|
+
extractLinkedIssueNumbers,
|
|
890
|
+
changeScopeEvidence,
|
|
891
|
+
reviewLoadComponentScore,
|
|
892
|
+
validationComponent,
|
|
893
|
+
queuePressureComponent,
|
|
894
|
+
queuePressureOpenPullRequestScore,
|
|
895
|
+
};
|