@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,512 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isDuplicateClusterWinnerByClaim } from "../signals/duplicate-winner.js";
|
|
3
|
+
import { nowIso } from "../utils/json.js";
|
|
4
|
+
import { LOOPOVER_GATE_CHECK_NAME } from "../review/check-names.js";
|
|
5
|
+
import { CLA_CHECK_UNRESOLVED_CODE, CLA_CONSENT_MISSING_CODE } from "../review/cla-check.js";
|
|
6
|
+
import { REVIEW_THREAD_BLOCKER_CODE } from "../review/review-thread-findings.js";
|
|
7
|
+
import { labelMatchesPattern } from "../scoring/label-match.js";
|
|
8
|
+
const CHECK_RUN_FORBIDDEN_TERMS = /\b(?:rewards?|payouts?|farming|estimated\s+scores?|raw\s+trust\s+scores?|trust\s+scores?|score\s+estimates?|reward\s+estimates?|wallets?|hotkeys?|coldkeys?|reviewability|scoreability|private\s+signals?)\b/gi;
|
|
9
|
+
function sanitizeForCheckRun(text) {
|
|
10
|
+
return text.replace(CHECK_RUN_FORBIDDEN_TERMS, "[context]").replace(/\s+/g, " ").trim();
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE = 0.93;
|
|
13
|
+
const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;
|
|
14
|
+
export function buildPullRequestAdvisory(repo, pr, context = {}) {
|
|
15
|
+
const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown";
|
|
16
|
+
const targetKey = pr ? `${repoFullName}#${pr.number}` : `${repoFullName}#unknown`;
|
|
17
|
+
const findings = [];
|
|
18
|
+
if (!repo) {
|
|
19
|
+
findings.push({
|
|
20
|
+
code: "repo_not_registered",
|
|
21
|
+
severity: "warning",
|
|
22
|
+
title: "Repository registration is unknown",
|
|
23
|
+
detail: "Gittensory cannot evaluate repo-specific rules until registry data is available.",
|
|
24
|
+
action: "Refresh the Gittensor registry snapshot.",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
addRepoFindings(repo, findings);
|
|
29
|
+
}
|
|
30
|
+
if (!pr) {
|
|
31
|
+
findings.push({
|
|
32
|
+
code: "pr_not_cached",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
title: "Pull request is not cached",
|
|
35
|
+
detail: "The GitHub webhook or manual fetch has not recorded this pull request yet.",
|
|
36
|
+
action: "Re-deliver the webhook or wait for the next sync.",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? [], Boolean(context.confirmedNoOpenLinkedIssue));
|
|
41
|
+
}
|
|
42
|
+
return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined);
|
|
43
|
+
}
|
|
44
|
+
function addRepoFindings(repo, findings) {
|
|
45
|
+
if (!repo.isRegistered) {
|
|
46
|
+
findings.push({
|
|
47
|
+
code: "repo_unregistered",
|
|
48
|
+
severity: "warning",
|
|
49
|
+
title: "Repository is not registered in the latest snapshot",
|
|
50
|
+
detail: "This repository is installed in Gittensory, but the latest registry snapshot does not include it.",
|
|
51
|
+
action: "Verify repository registration before relying on Gittensor-specific signals.",
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!repo.registryConfig) {
|
|
56
|
+
findings.push({
|
|
57
|
+
code: "repo_config_missing",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
title: "Repository config was not parsed",
|
|
60
|
+
detail: "The repository appears in the registry, but its config was not available in normalized form.",
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const issueShare = repo.registryConfig.issueDiscoveryShare;
|
|
65
|
+
if (issueShare === 0) {
|
|
66
|
+
findings.push({
|
|
67
|
+
code: "issue_discovery_disabled",
|
|
68
|
+
severity: "info",
|
|
69
|
+
title: "Issue discovery is disabled for this repo",
|
|
70
|
+
detail: "The current Gittensor registry config routes this repository away from issue-discovery work.",
|
|
71
|
+
publicText: "This repo is configured for direct contribution review rather than issue-discovery flow.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else if (issueShare === 1) {
|
|
75
|
+
findings.push({
|
|
76
|
+
code: "direct_pr_pool_disabled",
|
|
77
|
+
severity: "info",
|
|
78
|
+
title: "Direct PR scoring is disabled for this repo",
|
|
79
|
+
detail: "The current Gittensor registry config routes this repository fully toward issue-discovery work.",
|
|
80
|
+
publicText: "This repo is configured around issue-discovery flow. Maintainers should review PR expectations manually.",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (repo.registryConfig.maintainerCut > 0) {
|
|
84
|
+
findings.push({
|
|
85
|
+
code: "maintainer_cut_enabled",
|
|
86
|
+
severity: "info",
|
|
87
|
+
title: "Maintainer allocation is configured",
|
|
88
|
+
detail: "This repo has a maintainer allocation configured in the registry.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function addPullRequestFindings(repo, pr, findings, otherOpenPullRequests, requireLinkedIssue, duplicateWinnerEnabled, linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue) {
|
|
93
|
+
if (pr.state !== "open") {
|
|
94
|
+
findings.push({
|
|
95
|
+
code: "pr_not_open",
|
|
96
|
+
severity: "info",
|
|
97
|
+
title: "Pull request is not open",
|
|
98
|
+
detail: `The pull request state is ${pr.state}.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const noLinkedIssueCited = pr.linkedIssues.length === 0;
|
|
102
|
+
if ((noLinkedIssueCited || confirmedNoOpenLinkedIssue) && requireLinkedIssue) {
|
|
103
|
+
findings.push({
|
|
104
|
+
code: "missing_linked_issue",
|
|
105
|
+
severity: "warning",
|
|
106
|
+
title: "No linked issue detected",
|
|
107
|
+
detail: noLinkedIssueCited
|
|
108
|
+
? "No closing reference or linked issue number was found in the PR metadata/body."
|
|
109
|
+
: "The PR cites an issue number, but it could not be verified as a currently open issue.",
|
|
110
|
+
action: "If this PR is intended to solve an issue, link it explicitly in the PR body.",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const overlappingPrs = otherOpenPullRequests.filter((otherPr) => otherPr.linkedIssues.some((issueNumber) => pr.linkedIssues.includes(issueNumber)));
|
|
115
|
+
// Duplicate-winner adjudication (#dup-winner): when the flag is ON and this PR is the earliest observed
|
|
116
|
+
// linked-issue claimant, SKIP the duplicate finding — suppressing it suppresses the gate failure, so the
|
|
117
|
+
// winner survives while later claimants keep the finding. Sparse legacy rows fail closed instead of
|
|
118
|
+
// suppressing duplicate evidence with arbitrary PR-number ordering.
|
|
119
|
+
// Flag-OFF (default) short-circuits ⇒ the finding is pushed exactly as before (byte-identical).
|
|
120
|
+
if (overlappingPrs.length > 0 && !(duplicateWinnerEnabled && isDuplicateClusterWinnerByClaim(pr, overlappingPrs))) {
|
|
121
|
+
findings.push({
|
|
122
|
+
code: "duplicate_pr_risk",
|
|
123
|
+
severity: "warning",
|
|
124
|
+
title: "Linked issue overlaps another open PR",
|
|
125
|
+
detail: `Other open pull requests reference the same linked issue set: ${overlappingPrs.map((otherPr) => `#${otherPr.number}`).join(", ")}.`,
|
|
126
|
+
action: "Review the related PRs before spending reviewer time on duplicate work.",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Self-authored linked-issue detection: the PR author also filed the linked issue. Raised when at least
|
|
131
|
+
// one linked issue's author login is a case-insensitive match for the PR author. Gated by
|
|
132
|
+
// selfAuthoredLinkedIssueGateMode — advisory by default so this never blocks without maintainer opt-in.
|
|
133
|
+
// Absent/null issue author logins are treated as unknown and never trigger the finding (fail-open).
|
|
134
|
+
if (pr.linkedIssues.length > 0 && pr.authorLogin) {
|
|
135
|
+
const prAuthor = pr.authorLogin.toLowerCase();
|
|
136
|
+
const selfAuthored = linkedIssueAuthorLogins.some((login) => login != null && login.toLowerCase() === prAuthor);
|
|
137
|
+
if (selfAuthored) {
|
|
138
|
+
findings.push({
|
|
139
|
+
code: "self_authored_linked_issue",
|
|
140
|
+
severity: "warning",
|
|
141
|
+
title: "PR author also opened the linked issue",
|
|
142
|
+
detail: "The contributor who opened this PR also filed the linked issue. This pattern can indicate artificial issue-discovery work rather than solving an independently discovered problem.",
|
|
143
|
+
action: "Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (otherOpenPullRequests.length >= 10) {
|
|
148
|
+
findings.push({
|
|
149
|
+
code: "busy_pr_queue",
|
|
150
|
+
severity: "info",
|
|
151
|
+
title: "Review queue is busy",
|
|
152
|
+
detail: `Gittensory has ${otherOpenPullRequests.length} other open pull requests cached for this repository.`,
|
|
153
|
+
publicText: "This repo has a busy review queue in the local Gittensory cache.",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const multiplierPatterns = Object.keys(repo?.registryConfig?.labelMultipliers ?? {});
|
|
157
|
+
const matchedLabels = pr.labels.filter((label) => multiplierPatterns.some((pattern) => labelMatchesPattern(label, pattern)));
|
|
158
|
+
if (matchedLabels.length > 0) {
|
|
159
|
+
findings.push({
|
|
160
|
+
code: "label_context_found",
|
|
161
|
+
severity: "info",
|
|
162
|
+
title: "Configured label context found",
|
|
163
|
+
detail: `Matched configured labels: ${matchedLabels.join(", ")}.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (pr.authorAssociation && ["OWNER", "MEMBER", "COLLABORATOR"].includes(pr.authorAssociation)) {
|
|
167
|
+
findings.push({
|
|
168
|
+
code: "maintainer_authored_pr",
|
|
169
|
+
severity: "info",
|
|
170
|
+
title: "PR author has maintainer association",
|
|
171
|
+
detail: "GitHub marks this PR author as owner, member, or collaborator for the repository.",
|
|
172
|
+
publicText: "This PR appears to come from a maintainer-associated account.",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function advisory(targetType, targetKey, repoFullName, findings, fallbackSummary, pullNumber, issueNumber, headSha) {
|
|
177
|
+
const severity = highestSeverity(findings);
|
|
178
|
+
const conclusion = conclusionForSeverity(severity, findings);
|
|
179
|
+
const title = conclusion === "success" ? "LoopOver advisory passed" : "LoopOver advisory available";
|
|
180
|
+
return {
|
|
181
|
+
id: randomUUID(),
|
|
182
|
+
targetType,
|
|
183
|
+
targetKey,
|
|
184
|
+
repoFullName,
|
|
185
|
+
...(pullNumber === undefined ? {} : { pullNumber }),
|
|
186
|
+
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
187
|
+
...(headSha === undefined ? {} : { headSha }),
|
|
188
|
+
conclusion,
|
|
189
|
+
severity,
|
|
190
|
+
title,
|
|
191
|
+
summary: findings.length > 0 ? `${findings.length} advisory finding${findings.length === 1 ? "" : "s"} generated.` : fallbackSummary,
|
|
192
|
+
findings,
|
|
193
|
+
generatedAt: nowIso(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function highestSeverity(findings) {
|
|
197
|
+
if (findings.some((finding) => finding.severity === "critical"))
|
|
198
|
+
return "critical";
|
|
199
|
+
if (findings.some((finding) => finding.severity === "warning"))
|
|
200
|
+
return "warning";
|
|
201
|
+
return "info";
|
|
202
|
+
}
|
|
203
|
+
function conclusionForSeverity(severity, findings) {
|
|
204
|
+
if (findings.some((finding) => finding.code === "repo_unregistered" || finding.code === "repo_not_seen"))
|
|
205
|
+
return "action_required";
|
|
206
|
+
if (severity === "warning")
|
|
207
|
+
return "neutral";
|
|
208
|
+
if (severity === "critical")
|
|
209
|
+
return "action_required";
|
|
210
|
+
return "success";
|
|
211
|
+
}
|
|
212
|
+
const SIZE_HOLD_DEFAULT_MAX_FILES = 10;
|
|
213
|
+
const SIZE_HOLD_DEFAULT_MAX_LINES = 1000;
|
|
214
|
+
/** Oversized-PR manual-review HOLD finding (#gate-size), or null when the size gate is off or the PR is within both
|
|
215
|
+
* thresholds. A HOLD (→ neutral gate → "manual" verdict), never a hard blocker, so it is dry-run/advisory friendly. */
|
|
216
|
+
function buildSizeHoldFinding(policy) {
|
|
217
|
+
if (!policy.sizeGateMode || policy.sizeGateMode === "off")
|
|
218
|
+
return null;
|
|
219
|
+
let files = policy.changedFileCount;
|
|
220
|
+
if (files === undefined || files === null)
|
|
221
|
+
files = 0;
|
|
222
|
+
let lines = policy.changedLineCount;
|
|
223
|
+
if (lines === undefined || lines === null)
|
|
224
|
+
lines = 0;
|
|
225
|
+
if (files < SIZE_HOLD_DEFAULT_MAX_FILES &&
|
|
226
|
+
lines < SIZE_HOLD_DEFAULT_MAX_LINES)
|
|
227
|
+
return null;
|
|
228
|
+
return {
|
|
229
|
+
code: "oversized_pr",
|
|
230
|
+
severity: "warning",
|
|
231
|
+
title: "Large change — held for manual review",
|
|
232
|
+
detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${SIZE_HOLD_DEFAULT_MAX_FILES} files or ${SIZE_HOLD_DEFAULT_MAX_LINES} lines).`,
|
|
233
|
+
action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function buildGuardrailHoldFinding(matches = []) {
|
|
237
|
+
const detail = matches.length > 0
|
|
238
|
+
? `This PR changes guardrail-protected path(s): ${matches
|
|
239
|
+
.slice(0, 5)
|
|
240
|
+
.map((match) => `\`${match.path}\` (matched \`${match.glob}\`)`)
|
|
241
|
+
.join(", ")}${matches.length > 5 ? `, and ${matches.length - 5} more` : ""}.`
|
|
242
|
+
: "This PR changes a guardrail-protected path, or the changed-file list could not be verified while guardrails are configured.";
|
|
243
|
+
return {
|
|
244
|
+
code: "guardrail_hold",
|
|
245
|
+
severity: "warning",
|
|
246
|
+
title: "Touches a guarded path — held for manual review",
|
|
247
|
+
detail,
|
|
248
|
+
action: "A maintainer must review and merge this change.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function promoteAdvisoryToBlock(policy) {
|
|
252
|
+
// #disposition-redesign: the dry-run "would-be" verdict must reflect the REAL disposition model — a CLOSE is driven by
|
|
253
|
+
// the AI reviewer's confidence + genuine hard blockers (secret/CI/banned) ONLY. The advisory signals — missing linked
|
|
254
|
+
// issue, readiness/quality, slop, duplicates, manifest policy, self-authored issue — are NEVER close drivers, so they
|
|
255
|
+
// are deliberately NOT promoted here. Only the AI sub-gate is promoted, so an `advisory` AI defect still previews its
|
|
256
|
+
// would-be close while a missing linked issue or a low readiness score can never render a "close" verdict.
|
|
257
|
+
const block = (mode) => (mode === "advisory" ? "block" : mode);
|
|
258
|
+
return {
|
|
259
|
+
...policy,
|
|
260
|
+
dryRun: false,
|
|
261
|
+
aiReviewGateMode: block(policy.aiReviewGateMode),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export function evaluateGateCheck(advisoryResult, policy = {}) {
|
|
265
|
+
const result = evaluateGateCheckCore(advisoryResult, policy);
|
|
266
|
+
if (!policy.dryRun)
|
|
267
|
+
return result;
|
|
268
|
+
const wouldBe = evaluateGateCheckCore(advisoryResult, promoteAdvisoryToBlock(policy));
|
|
269
|
+
return { ...result, displayConclusion: wouldBe.conclusion };
|
|
270
|
+
}
|
|
271
|
+
function evaluateGateCheckCore(advisoryResult, policy = {}) {
|
|
272
|
+
const warnings = advisoryResult.findings.filter((finding) => finding.severity === "warning");
|
|
273
|
+
// App/infra state (repo not synced yet, PR not cached): gittensory cannot evaluate this PR yet, so the
|
|
274
|
+
// gate is NEUTRAL (non-blocking) and re-evaluates automatically on the next sync/webhook. Never block a
|
|
275
|
+
// contributor on the app's OWN state.
|
|
276
|
+
if (advisoryResult.findings.some((finding) => isEvaluationBlocker(finding.code, policy))) {
|
|
277
|
+
return {
|
|
278
|
+
enabled: true,
|
|
279
|
+
conclusion: "neutral",
|
|
280
|
+
title: `${LOOPOVER_GATE_CHECK_NAME} — not evaluated yet`,
|
|
281
|
+
summary: "LoopOver has not finished syncing this repo/PR. The gate stays advisory and re-evaluates automatically; no action is needed.",
|
|
282
|
+
blockers: [],
|
|
283
|
+
warnings,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// Merge-readiness composite (#551): when set, escalate enforceable sub-gates to its mode so they roll into one
|
|
287
|
+
// pass/fail. Readiness/quality stays advisory-only.
|
|
288
|
+
const effective = applyMergeReadinessGate(policy);
|
|
289
|
+
const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding, effective));
|
|
290
|
+
const qualityWarning = buildQualityGateWarning(effective);
|
|
291
|
+
const slopBlocker = buildSlopGateBlocker(effective);
|
|
292
|
+
const blockers = [...configuredBlockers, ...(slopBlocker ? [slopBlocker] : [])];
|
|
293
|
+
const gateWarnings = qualityWarning ? [...warnings, qualityWarning] : warnings;
|
|
294
|
+
// Non-confirmed contributors are gated NORMALLY (real blockers → failure → one-shot close; clean → success →
|
|
295
|
+
// merge), the SAME as confirmed contributors: the review + CI + guardrail vet every PR, and confirmed-status
|
|
296
|
+
// affects only on-chain SCORING, never the merge/close decision. (#gate-nonconfirmed) The old blanket
|
|
297
|
+
// "never block a non-confirmed contributor" forced every non-confirmed PR with a blocker to a neutral → HELD
|
|
298
|
+
// state, burying the maintainer in manual review. The old first-time-contributor grace path also softened
|
|
299
|
+
// blockers; that is intentionally no longer applied because blocker findings must remain closure/rejection
|
|
300
|
+
// outcomes for normal contributors. Owner/automation close exemptions live in the disposition planner instead.
|
|
301
|
+
if (blockers.length === 0) {
|
|
302
|
+
// Fail-CLOSED AI hold (#ai-fail-closed, #audit-3.5): with NO deterministic blocker, a block-mode AI review
|
|
303
|
+
// that could not return a usable verdict HOLDS the gate (neutral) for a human rather than passing
|
|
304
|
+
// automatically — NEVER a failure, so a contributor PR is never auto-CLOSED because a model hiccupped. This
|
|
305
|
+
// is evaluated AFTER the deterministic blockers above, so a real violation (secret_leak, duplicate,
|
|
306
|
+
// missing-issue, slop, quality) still blocks: an inconclusive AI can no longer bury a blocked PR in a hold.
|
|
307
|
+
if (advisoryResult.findings.some((finding) => finding.code === "ai_review_inconclusive")) {
|
|
308
|
+
return {
|
|
309
|
+
enabled: true,
|
|
310
|
+
conclusion: "neutral",
|
|
311
|
+
title: `${LOOPOVER_GATE_CHECK_NAME} — held for human review`,
|
|
312
|
+
summary: "The AI review could not be completed for this change, so the gate is held for a human reviewer rather than passed automatically. It re-evaluates on the next update.",
|
|
313
|
+
blockers: [],
|
|
314
|
+
warnings: gateWarnings,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// Manual-review HOLD (#gate-size / #gate-guardrail): a PR that would otherwise PASS but is oversized or touches
|
|
318
|
+
// a guarded path is HELD for a human (neutral → "manual" verdict) rather than auto-approved — never a failure,
|
|
319
|
+
// so neutral never blocks the merge (dry-run/advisory friendly) and a contributor PR is never auto-closed for size.
|
|
320
|
+
const sizeHold = buildSizeHoldFinding(effective);
|
|
321
|
+
const guardrailHold = effective.guardrailHit ? buildGuardrailHoldFinding(effective.guardrailMatches) : null;
|
|
322
|
+
const holds = [sizeHold, guardrailHold].filter((f) => f !== null);
|
|
323
|
+
if (holds.length > 0) {
|
|
324
|
+
return {
|
|
325
|
+
enabled: true,
|
|
326
|
+
conclusion: "neutral",
|
|
327
|
+
title: `${LOOPOVER_GATE_CHECK_NAME} — held for manual review`,
|
|
328
|
+
summary: holds.map((h) => sanitizeForCheckRun(h.title)).join("; "),
|
|
329
|
+
blockers: [],
|
|
330
|
+
warnings: [...gateWarnings, ...holds],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
enabled: true,
|
|
335
|
+
conclusion: "success",
|
|
336
|
+
title: `${LOOPOVER_GATE_CHECK_NAME} passed`,
|
|
337
|
+
summary: "No configured hard blocker was found. Advisory findings, if any, stay advisory.",
|
|
338
|
+
blockers,
|
|
339
|
+
warnings: gateWarnings,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// Name the exact blocker(s) + fix in the title so the contributor sees WHY at a glance.
|
|
343
|
+
const firstBlocker = blockers[0];
|
|
344
|
+
const titleDetail = blockers.length === 1 && firstBlocker ? sanitizeForCheckRun(firstBlocker.title) : `${blockers.length} blockers`;
|
|
345
|
+
return {
|
|
346
|
+
enabled: true,
|
|
347
|
+
conclusion: "failure",
|
|
348
|
+
title: `${LOOPOVER_GATE_CHECK_NAME}: ${titleDetail}`,
|
|
349
|
+
summary: blockers
|
|
350
|
+
.map((finding) => `${sanitizeForCheckRun(finding.title)}${finding.action ? ` — ${sanitizeForCheckRun(finding.action)}` : ""}`)
|
|
351
|
+
.join("; "),
|
|
352
|
+
blockers,
|
|
353
|
+
warnings: [...advisoryResult.findings.filter((finding) => finding.severity === "warning" && !blockers.includes(finding)), ...(qualityWarning ? [qualityWarning] : [])],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function isEvaluationBlocker(code, policy) {
|
|
357
|
+
// pre_merge_check_unresolved: an enforced path-gated pre-merge check whose changed-file set could not be
|
|
358
|
+
// resolved — gittensory cannot evaluate it yet, so the gate is NEUTRAL (held) and re-evaluates on the next
|
|
359
|
+
// sync, rather than auto-merging past the unverified requirement or hard-closing on a transient miss. (#review-audit)
|
|
360
|
+
if (code === "repo_not_registered" || code === "repo_not_seen" || code === "pr_not_cached" || code === "pre_merge_check_unresolved")
|
|
361
|
+
return true;
|
|
362
|
+
// cla_check_unresolved (#2564): the CLA-bot check-run's conclusion could not be resolved. Unlike the codes
|
|
363
|
+
// above (which are never mode-gated), evaluateClaCheck runs for BOTH claGateMode "advisory" and "block" (so
|
|
364
|
+
// the finding surfaces either way) — only "block" should ever HOLD the gate on an unresolved check-run.
|
|
365
|
+
// "advisory" mode's whole contract is "surface findings, never affect the verdict"; unconditionally holding
|
|
366
|
+
// here would violate that for any advisory-mode repo using check-run-only detection (#2564 gate-review
|
|
367
|
+
// finding). advisory mode still gets the finding in the panel via the normal warnings path below.
|
|
368
|
+
if (code === CLA_CHECK_UNRESOLVED_CODE)
|
|
369
|
+
return policy.claGateMode === "block";
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
function gatePolicyBlocks(mode, defaultMode) {
|
|
373
|
+
return gateMode(mode ?? defaultMode) === "block";
|
|
374
|
+
}
|
|
375
|
+
function isConfiguredGateBlocker(finding, policy) {
|
|
376
|
+
const code = finding.code;
|
|
377
|
+
// Missing linked issue defaults to ADVISORY — issues aren't always available, so it only blocks when a
|
|
378
|
+
// repo explicitly opts in with linkedIssueGateMode: "block". Duplicates still default to blocking.
|
|
379
|
+
if (code === "missing_linked_issue")
|
|
380
|
+
return gatePolicyBlocks(policy.linkedIssueGateMode, "advisory");
|
|
381
|
+
if (code === "duplicate_pr_risk")
|
|
382
|
+
return gatePolicyBlocks(policy.duplicatePrGateMode, "block");
|
|
383
|
+
// A dual-model AI consensus defect blocks ONLY when the maintainer opted into aiReview: block. It is the
|
|
384
|
+
// most conservative AI signal (two independent models) but still confirmed-contributor gated by
|
|
385
|
+
// evaluateGateCheck, and advisory by default.
|
|
386
|
+
// A consensus defect (both reviewers) OR a SPLIT (one reviewer flagged a blocker the other did not) both block
|
|
387
|
+
// when aiReviewGateMode is `block`. (#ai-review-split) The close-confidence floor + disposition (#4603, mirrors
|
|
388
|
+
// the host copy in src/rules/advisory.ts -- this predictor package doesn't thread aiReviewLowConfidenceDisposition
|
|
389
|
+
// through predicted-gate.ts's own policy-building call yet, same deliberate partial-wiring precedent as
|
|
390
|
+
// linkedIssueSatisfactionGateMode, so this branch only ever sees the default `hold_for_review` here today) decide
|
|
391
|
+
// what happens to a SUB-floor finding: `one_shot`/`hold_for_review` both still block here; only `advisory_only`
|
|
392
|
+
// demotes a sub-floor finding to a non-blocker.
|
|
393
|
+
if (code === "ai_consensus_defect" || code === "ai_review_split") {
|
|
394
|
+
if (!gatePolicyBlocks(policy.aiReviewGateMode, "advisory"))
|
|
395
|
+
return false;
|
|
396
|
+
if ((policy.aiReviewLowConfidenceDisposition ?? "hold_for_review") === "advisory_only") {
|
|
397
|
+
const floor = policy.aiReviewCloseConfidence ?? DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE;
|
|
398
|
+
const confidence = finding.confidence ?? 1;
|
|
399
|
+
if (confidence < floor)
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
if (code === REVIEW_THREAD_BLOCKER_CODE)
|
|
405
|
+
return true;
|
|
406
|
+
// A leaked-secret finding (`secret_leak`) ALWAYS hard-blocks: a committed credential must be removed and
|
|
407
|
+
// rotated before merge, with no opt-in. This finding is produced ONLY by the flag-gated safety scan
|
|
408
|
+
// (GITTENSORY_REVIEW_SAFETY); when the flag is off the finding never exists, so this branch is unreachable and the
|
|
409
|
+
// gate verdict is byte-identical to today.
|
|
410
|
+
if (code === "secret_leak")
|
|
411
|
+
return true;
|
|
412
|
+
// A maintainer pre-merge check (#review-pre-merge-checks) marked `enforce: true` produces this DETERMINISTIC
|
|
413
|
+
// finding when it fails (a required title/description phrase or label is missing). It always blocks: the
|
|
414
|
+
// per-check `enforce` flag in `.gittensory.yml` IS the opt-in (mirroring secret_leak — the finding only exists
|
|
415
|
+
// when the maintainer configured an enforced check). The advisory variant (`pre_merge_check_failed`) is a plain
|
|
416
|
+
// warning and is never blocked here. No AI judgment is involved, so this can never cause an AI false-close.
|
|
417
|
+
if (code === "pre_merge_check_required")
|
|
418
|
+
return true;
|
|
419
|
+
// Focus-manifest missing-tests policy (#555): blocks ONLY when the maintainer opts into manifestPolicy:
|
|
420
|
+
// block. Path holds are intentionally separate and configured via hardGuardrailGlobs.
|
|
421
|
+
if (code === "manifest_missing_tests") {
|
|
422
|
+
return gatePolicyBlocks(policy.manifestPolicyGateMode, "off");
|
|
423
|
+
}
|
|
424
|
+
// Focus-manifest linked-issue policy (#555, #4618): blocks when EITHER the manifest-policy gate OR the
|
|
425
|
+
// linked-issue gate is opted into block. resolveEffectiveSettings promotes linkedIssueGateMode to "block"
|
|
426
|
+
// whenever the yml-only `linkedIssuePolicy: required` knob is set (mirroring the requireLinkedIssue
|
|
427
|
+
// promotion), so this finding's own escalation must honor that gate too -- not just manifestPolicyGateMode
|
|
428
|
+
// -- or the promotion would have no actual blocking effect and the config-surface-reduction fix would be a
|
|
429
|
+
// no-op.
|
|
430
|
+
if (code === "manifest_linked_issue_required") {
|
|
431
|
+
return gatePolicyBlocks(policy.manifestPolicyGateMode, "off") || gatePolicyBlocks(policy.linkedIssueGateMode, "advisory");
|
|
432
|
+
}
|
|
433
|
+
// Self-authored linked-issue gate: blocks only when the maintainer opts in with `block`. Defaults to
|
|
434
|
+
// advisory — the finding surfaces in the panel without ever closing the PR unless explicitly configured.
|
|
435
|
+
if (code === "self_authored_linked_issue")
|
|
436
|
+
return gatePolicyBlocks(policy.selfAuthoredLinkedIssueGateMode, "advisory");
|
|
437
|
+
// Lockfile-tamper-risk gate (#2563): blocks only when the maintainer opts in with `block`. Defaults to `off`
|
|
438
|
+
// (the finding is never even produced — see maybeAddLockfileTamperFinding's mode gate in queue/processors.ts),
|
|
439
|
+
// so this branch only matters once a repo has explicitly turned the scan on.
|
|
440
|
+
if (code === "lockfile_tamper_risk")
|
|
441
|
+
return gatePolicyBlocks(policy.lockfileIntegrityGateMode, "off");
|
|
442
|
+
// CLA / license-compatibility gate (#2564): blocks only when the maintainer opts into claMode: block.
|
|
443
|
+
// Defaults to off (evaluateClaCheck never even runs for an off repo, so the finding does not exist).
|
|
444
|
+
if (code === CLA_CONSENT_MISSING_CODE)
|
|
445
|
+
return gatePolicyBlocks(policy.claGateMode, "off");
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
function buildQualityGateWarning(policy) {
|
|
449
|
+
if (gateMode(policy.qualityGateMode) === "off")
|
|
450
|
+
return null;
|
|
451
|
+
const score = normalizeScore(policy.readinessScore);
|
|
452
|
+
const minScore = normalizeScore(policy.qualityGateMinScore);
|
|
453
|
+
if (score === null || minScore === null || score >= minScore)
|
|
454
|
+
return null;
|
|
455
|
+
return {
|
|
456
|
+
code: "readiness_score_below_threshold",
|
|
457
|
+
severity: "warning",
|
|
458
|
+
title: "Readiness score is below the configured threshold",
|
|
459
|
+
detail: `The public readiness score is ${score}/100, below the repository threshold of ${minScore}/100.`,
|
|
460
|
+
action: "Use the readiness panel as advisory maintainer context; the score does not block this PR.",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function buildSlopGateBlocker(policy) {
|
|
464
|
+
if (gateMode(policy.slopGateMode) !== "block")
|
|
465
|
+
return null;
|
|
466
|
+
const risk = normalizeScore(policy.slopRisk);
|
|
467
|
+
if (risk === null)
|
|
468
|
+
return null;
|
|
469
|
+
const minScore = normalizeScore(policy.slopGateMinScore) ?? DEFAULT_SLOP_BLOCK_THRESHOLD;
|
|
470
|
+
if (risk < minScore)
|
|
471
|
+
return null;
|
|
472
|
+
return {
|
|
473
|
+
code: "slop_risk_above_threshold",
|
|
474
|
+
severity: "warning",
|
|
475
|
+
title: "Slop risk is above the configured threshold",
|
|
476
|
+
detail: `The deterministic slop risk is ${risk}/100, at or above the repository threshold of ${minScore}/100.`,
|
|
477
|
+
action: "Reduce whitespace-only churn, add test evidence, or describe the change, then re-run the gate.",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function gateMode(value) {
|
|
481
|
+
return value === "off" || value === "block" ? value : "advisory";
|
|
482
|
+
}
|
|
483
|
+
function applyMergeReadinessGate(policy) {
|
|
484
|
+
const composite = gateMode(policy.mergeReadinessGateMode ?? "off");
|
|
485
|
+
if (composite === "off")
|
|
486
|
+
return policy;
|
|
487
|
+
return {
|
|
488
|
+
...policy,
|
|
489
|
+
linkedIssueGateMode: composite,
|
|
490
|
+
duplicatePrGateMode: composite,
|
|
491
|
+
slopGateMode: composite,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function normalizeScore(value) {
|
|
495
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
496
|
+
return null;
|
|
497
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
498
|
+
}
|
|
499
|
+
/** @internal Exported for unit tests of advisory severity wiring. */
|
|
500
|
+
export const gateAdvisoryInternals = {
|
|
501
|
+
advisory,
|
|
502
|
+
highestSeverity,
|
|
503
|
+
conclusionForSeverity,
|
|
504
|
+
buildSizeHoldFinding,
|
|
505
|
+
buildGuardrailHoldFinding,
|
|
506
|
+
promoteAdvisoryToBlock,
|
|
507
|
+
isConfiguredGateBlocker,
|
|
508
|
+
buildQualityGateWarning,
|
|
509
|
+
buildSlopGateBlocker,
|
|
510
|
+
gateMode,
|
|
511
|
+
gatePolicyBlocks,
|
|
512
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export type AiPolicySource = "AI-USAGE.md" | "CONTRIBUTING.md" | "none";
|
|
2
|
+
export type AiPolicyFatigueLevel = "none" | "watch" | "deprioritize" | "defer";
|
|
3
|
+
export type AiPolicyPriorityAdjustment = "none" | "deprioritize" | "defer";
|
|
4
|
+
export type AiPolicyFatigueEvidenceKind = "terse_ai_attributed_rejection_cluster" | "recent_ai_doc_language" | "ai_attributed_closed_pr" | "cache_fresh" | "formal_ban_overrides";
|
|
5
|
+
export type AiPolicyFatigueEvidence = {
|
|
6
|
+
kind: AiPolicyFatigueEvidenceKind;
|
|
7
|
+
weight: number;
|
|
8
|
+
summary: string;
|
|
9
|
+
observedAt: string | null;
|
|
10
|
+
};
|
|
11
|
+
export type AiPolicyFatigueVerdict = {
|
|
12
|
+
level: AiPolicyFatigueLevel;
|
|
13
|
+
priorityAdjustment: AiPolicyPriorityAdjustment;
|
|
14
|
+
score: number;
|
|
15
|
+
recheckAfterHours: number;
|
|
16
|
+
evidence: AiPolicyFatigueEvidence[];
|
|
17
|
+
};
|
|
18
|
+
export type AiPolicyVerdict = {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
matchedPhrase: string | null;
|
|
21
|
+
source: AiPolicySource;
|
|
22
|
+
fatigue?: AiPolicyFatigueVerdict | undefined;
|
|
23
|
+
};
|
|
24
|
+
export type AiFatiguePullRequestMetadata = {
|
|
25
|
+
id?: string | number | undefined;
|
|
26
|
+
state: "open" | "closed" | "merged" | string;
|
|
27
|
+
authorLogin?: string | undefined;
|
|
28
|
+
title?: string | undefined;
|
|
29
|
+
labels?: readonly string[] | undefined;
|
|
30
|
+
createdAt?: string | Date | null | undefined;
|
|
31
|
+
closedAt?: string | Date | null | undefined;
|
|
32
|
+
mergedAt?: string | Date | null | undefined;
|
|
33
|
+
reviewDecision?: "approved" | "changes_requested" | "commented" | "none" | string | undefined;
|
|
34
|
+
closeReason?: "not_planned" | "completed" | "duplicate" | "spam" | string | undefined;
|
|
35
|
+
maintainerResponse?: "terse_rejection" | "template_rejection" | "neutral" | "helpful" | string | undefined;
|
|
36
|
+
};
|
|
37
|
+
export type AiFatigueDocLanguageChange = {
|
|
38
|
+
path: "AI-USAGE.md" | "CONTRIBUTING.md" | string;
|
|
39
|
+
changedAt?: string | Date | null | undefined;
|
|
40
|
+
addedText?: string | undefined;
|
|
41
|
+
addedPhrases?: readonly string[] | undefined;
|
|
42
|
+
};
|
|
43
|
+
export type AiPolicyFatigueCacheEntry = {
|
|
44
|
+
repoFullName: string;
|
|
45
|
+
computedAt: string | Date;
|
|
46
|
+
verdict: AiPolicyFatigueVerdict;
|
|
47
|
+
};
|
|
48
|
+
export type AiPolicyFatigueCacheState = {
|
|
49
|
+
repoFullName: string;
|
|
50
|
+
computedAt: string | null;
|
|
51
|
+
expiresAt: string | null;
|
|
52
|
+
ageHours: number | null;
|
|
53
|
+
fresh: boolean;
|
|
54
|
+
};
|
|
55
|
+
export type AiPolicyFatigueInput = {
|
|
56
|
+
repoFullName: string;
|
|
57
|
+
docs: {
|
|
58
|
+
aiUsage: string | null | undefined;
|
|
59
|
+
contributing: string | null | undefined;
|
|
60
|
+
};
|
|
61
|
+
pullRequests?: readonly AiFatiguePullRequestMetadata[] | undefined;
|
|
62
|
+
docChanges?: readonly AiFatigueDocLanguageChange[] | undefined;
|
|
63
|
+
now?: string | Date | undefined;
|
|
64
|
+
cache?: AiPolicyFatigueCacheEntry | null | undefined;
|
|
65
|
+
};
|
|
66
|
+
export type AiPolicyFatigueRankInput = {
|
|
67
|
+
potential: number;
|
|
68
|
+
feasibility: number;
|
|
69
|
+
laneFit: number;
|
|
70
|
+
freshness: number;
|
|
71
|
+
dupRisk: number;
|
|
72
|
+
};
|
|
73
|
+
export type AiPolicyFatigueRankAdjustment = AiPolicyFatigueRankInput & {
|
|
74
|
+
fatigueLevel: AiPolicyFatigueLevel;
|
|
75
|
+
priorityAdjustment: AiPolicyPriorityAdjustment;
|
|
76
|
+
fatigueMultiplier: number;
|
|
77
|
+
deferUntilHours: number | null;
|
|
78
|
+
reasons: string[];
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Conservative by design (#2305): explicit ban phrases deny a repo, but ambiguous or absent policy text stays
|
|
82
|
+
* allowed. False negatives can be tightened with new literal phrases; false positives would hide valid work.
|
|
83
|
+
*/
|
|
84
|
+
export declare function scanAiPolicyText(content: string | null | undefined, source: AiPolicySource): AiPolicyVerdict;
|
|
85
|
+
export declare function resolveAiPolicyVerdict(docs: {
|
|
86
|
+
aiUsage: string | null | undefined;
|
|
87
|
+
contributing: string | null | undefined;
|
|
88
|
+
}): AiPolicyVerdict;
|
|
89
|
+
export declare function resolveAiPolicyFatigueVerdict(input: AiPolicyFatigueInput): AiPolicyVerdict;
|
|
90
|
+
export declare function renderAiPolicyFatigueMarkdown(verdict: AiPolicyVerdict): string;
|
|
91
|
+
export declare function applyAiPolicyFatigueToRankInput(rankInput: AiPolicyFatigueRankInput, verdict: AiPolicyVerdict): AiPolicyFatigueRankAdjustment;
|
|
92
|
+
export declare function createAiPolicyFatigueCacheEntry(input: {
|
|
93
|
+
repoFullName: string;
|
|
94
|
+
verdict: AiPolicyFatigueVerdict;
|
|
95
|
+
computedAt?: string | Date | undefined;
|
|
96
|
+
}): AiPolicyFatigueCacheEntry;
|
|
97
|
+
export declare function describeAiPolicyFatigueCache(cache: AiPolicyFatigueCacheEntry | null | undefined, now?: string | Date | undefined): AiPolicyFatigueCacheState;
|