@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,145 @@
|
|
|
1
|
+
import { hasUnsafeWildcardCount } from "../signals/change-guardrail.js";
|
|
2
|
+
export function labelMatchesPattern(label, pattern) {
|
|
3
|
+
return labelPatternToRegExp(pattern.toLowerCase()).test(label.toLowerCase());
|
|
4
|
+
}
|
|
5
|
+
// Compiled fnmatch→RegExp matchers are memoized by pattern. The same small,
|
|
6
|
+
// config-derived set of label keys is matched on every scored PR/issue, so the
|
|
7
|
+
// per-call recompile inside the nested label loops in engine.ts is pure waste.
|
|
8
|
+
// Keys come from a repo's registryConfig.labelMultipliers, sourced from the externally-fetched gittensor
|
|
9
|
+
// registry (registry/sync.ts + registry/normalize.ts, not a value this repo's own maintainer directly controls
|
|
10
|
+
// via .gittensory.yml) — so the pattern SET is small per repo, but individual pattern CONTENT is untrusted, not
|
|
11
|
+
// literally attacker-supplied-per-request the way GitHub PR content is. The wildcard-count cap below (#2456)
|
|
12
|
+
// bounds a single pattern's compile cost; this cache is additionally bounded to a fixed max entry count and
|
|
13
|
+
// evicted LRU, so a long-running isolate that observes many distinct registry snapshots over its life still
|
|
14
|
+
// can't grow the cache unboundedly. The compiled RegExp carries only the "i" flag (no global/sticky `lastIndex`
|
|
15
|
+
// state), so sharing one instance across calls is safe and byte-identical to recompiling on every call.
|
|
16
|
+
export const LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES = 256;
|
|
17
|
+
const labelPatternRegExpCache = new Map();
|
|
18
|
+
// A RegExp that never matches any input — mirrors change-guardrail.ts's identical NEVER_MATCHES fallback for an
|
|
19
|
+
// over-complex pattern, so a pathological registry entry degrades to "this label multiplier never applies"
|
|
20
|
+
// instead of hanging the scoring path that evaluates it.
|
|
21
|
+
const LABEL_PATTERN_NEVER_MATCHES = /^(?!)$/;
|
|
22
|
+
// Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a
|
|
23
|
+
// literal string: `fnmatch(label.lower(), pattern.lower())` in
|
|
24
|
+
// gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or
|
|
25
|
+
// `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The
|
|
26
|
+
// preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the
|
|
27
|
+
// neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch
|
|
28
|
+
// pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in
|
|
29
|
+
// change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any
|
|
30
|
+
// run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a
|
|
31
|
+
// pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically.
|
|
32
|
+
function labelPatternToRegExp(pattern) {
|
|
33
|
+
const cached = labelPatternRegExpCache.get(pattern);
|
|
34
|
+
if (cached !== undefined) {
|
|
35
|
+
// Refresh recency on hit so the cache behaves as an LRU: the most-recently-matched patterns
|
|
36
|
+
// survive eviction, not just the most-recently-inserted ones.
|
|
37
|
+
labelPatternRegExpCache.delete(pattern);
|
|
38
|
+
labelPatternRegExpCache.set(pattern, cached);
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
// Reuses change-guardrail.ts's wildcard-GROUP counting (a `*` here matches the same "any run of chars"
|
|
42
|
+
// semantics as that glob compiler's `*`, so the same catastrophic-backtracking risk and the same empirically-
|
|
43
|
+
// safe threshold apply) — an over-complex registry-sourced label_multipliers key degrades to a safe never-match
|
|
44
|
+
// instead of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public
|
|
45
|
+
// score-preview API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise
|
|
46
|
+
// hang scoring for every PR on that repo.
|
|
47
|
+
if (hasUnsafeWildcardCount(pattern)) {
|
|
48
|
+
setLabelPatternRegExpCacheEntry(pattern, LABEL_PATTERN_NEVER_MATCHES);
|
|
49
|
+
return LABEL_PATTERN_NEVER_MATCHES;
|
|
50
|
+
}
|
|
51
|
+
let regex = "";
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < pattern.length) {
|
|
54
|
+
const char = pattern.charAt(i);
|
|
55
|
+
i += 1;
|
|
56
|
+
if (char === "*") {
|
|
57
|
+
regex += ".*";
|
|
58
|
+
}
|
|
59
|
+
else if (char === "?") {
|
|
60
|
+
regex += ".";
|
|
61
|
+
}
|
|
62
|
+
else if (char === "[") {
|
|
63
|
+
const close = pattern.indexOf("]", i);
|
|
64
|
+
if (close === -1) {
|
|
65
|
+
// No closing bracket: fnmatch treats the `[` as a literal character.
|
|
66
|
+
regex += "\\[";
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const rawBody = pattern.slice(i, close);
|
|
70
|
+
if (rawBody === "" || rawBody === "!") {
|
|
71
|
+
// Empty classes and bare `[!]` stay literal in Python fnmatch instead of compiling as classes.
|
|
72
|
+
regex += `\\[${escapeRegExpLiteral(rawBody)}\\]`;
|
|
73
|
+
}
|
|
74
|
+
else if (hasDescendingCharacterRange(rawBody)) {
|
|
75
|
+
// Python fnmatch treats invalid ranges like `[z-a]` as a never-match pattern; RegExp throws.
|
|
76
|
+
regex += "(?!)";
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
let body = rawBody.replace(/\\/g, "\\\\");
|
|
80
|
+
// `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`.
|
|
81
|
+
if (body.startsWith("!"))
|
|
82
|
+
body = `^${body.slice(1)}`;
|
|
83
|
+
else if (body.startsWith("^"))
|
|
84
|
+
body = `\\${body}`;
|
|
85
|
+
regex += `[${body}]`;
|
|
86
|
+
}
|
|
87
|
+
i = close + 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (/[.+^${}()|\]\\]/.test(char)) {
|
|
91
|
+
regex += `\\${char}`;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
regex += char;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const compiled = new RegExp(`^${regex}$`, "i");
|
|
98
|
+
setLabelPatternRegExpCacheEntry(pattern, compiled);
|
|
99
|
+
return compiled;
|
|
100
|
+
}
|
|
101
|
+
// Inserts a new (never-before-cached) entry, evicting the least-recently-used entry first if the
|
|
102
|
+
// cache is already at its bound. Callers must only use this for keys not already present — refreshing
|
|
103
|
+
// an existing key's recency on a cache hit is handled inline above via delete+set.
|
|
104
|
+
function setLabelPatternRegExpCacheEntry(pattern, compiled) {
|
|
105
|
+
if (labelPatternRegExpCache.size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES) {
|
|
106
|
+
// Map iteration order is insertion order, so the first key is always the least-recently-used
|
|
107
|
+
// one (recency is refreshed via delete+set on every hit/insert). The map is non-empty here
|
|
108
|
+
// because size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES (a positive constant), so the loop body
|
|
109
|
+
// always runs exactly once.
|
|
110
|
+
for (const oldestPattern of labelPatternRegExpCache.keys()) {
|
|
111
|
+
labelPatternRegExpCache.delete(oldestPattern);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
labelPatternRegExpCache.set(pattern, compiled);
|
|
116
|
+
}
|
|
117
|
+
export function clearLabelPatternRegExpCacheForTest() {
|
|
118
|
+
labelPatternRegExpCache.clear();
|
|
119
|
+
}
|
|
120
|
+
export function labelPatternRegExpCacheKeysForTest() {
|
|
121
|
+
return [...labelPatternRegExpCache.keys()];
|
|
122
|
+
}
|
|
123
|
+
function escapeRegExpLiteral(value) {
|
|
124
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
125
|
+
}
|
|
126
|
+
function hasDescendingCharacterRange(body) {
|
|
127
|
+
const start = body.startsWith("!") ? 1 : 0;
|
|
128
|
+
// Walk the class left-to-right, consuming each `X-Y` range as a unit so a range endpoint can't be
|
|
129
|
+
// misread as the start of a spurious second range. Only a genuinely inverted range like `[z-a]` — the
|
|
130
|
+
// case JS `RegExp` actually throws on — must degrade the class to never-match; a literal `-` that
|
|
131
|
+
// follows a completed range (as in `[a-z-9]`, a valid class) must NOT be suppressed. The prior scan
|
|
132
|
+
// flagged any `-` whose left neighbor outranked its right neighbor, so it wrongly killed `[a-z-9]`.
|
|
133
|
+
let i = start;
|
|
134
|
+
while (i < body.length) {
|
|
135
|
+
if (i + 2 < body.length && body.charAt(i + 1) === "-") {
|
|
136
|
+
if (body.charCodeAt(i) > body.charCodeAt(i + 2))
|
|
137
|
+
return true;
|
|
138
|
+
i += 3;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
i += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ScoringModelSnapshotRecord } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5;
|
|
3
|
+
export declare const DEFAULT_SCORING_CONSTANTS: Record<string, number>;
|
|
4
|
+
export declare const DEFAULT_GITTENSOR_UPSTREAM_REPO = "entrius/gittensor";
|
|
5
|
+
export declare const DEFAULT_GITTENSOR_UPSTREAM_REF = "test";
|
|
6
|
+
export declare const SCORING_CONSTANT_NAMES: Set<string>;
|
|
7
|
+
export declare const SCORING_SNAPSHOT_STALE_MS: number;
|
|
8
|
+
export declare function scoringSnapshotStalenessWarning(snapshot: Pick<ScoringModelSnapshotRecord, "fetchedAt">, now?: number): string | null;
|
|
9
|
+
export declare function parsePythonNumberConstants(source: string, options?: {
|
|
10
|
+
knownOnly?: boolean;
|
|
11
|
+
}): Record<string, number>;
|
|
12
|
+
/**
|
|
13
|
+
* Numeric constant names upstream gittensor defines that gittensory's scoring engine does NOT model.
|
|
14
|
+
* The normal parse is `knownOnly` (it keeps only constants we already encode), which silently hides
|
|
15
|
+
* upstream ADDITIONS — e.g. a newly-introduced time-decay constant. Surfacing these makes scoring
|
|
16
|
+
* staleness visible: if upstream adds a scoring dimension, an operator sees it instead of the gate
|
|
17
|
+
* silently drifting behind. Detection only — it does not change any score.
|
|
18
|
+
*/
|
|
19
|
+
export declare function findUnmodeledConstantKeys(allConstants: Record<string, number>): string[];
|
|
20
|
+
export declare function findUnmodeledUpstreamConstants(source: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Owner-controlled global gate for applying upstream time-decay to score previews (#703). Default OFF: the
|
|
23
|
+
* roadmap deferral requires the owner to review a before/after ranking diff before enabling. Even when on,
|
|
24
|
+
* a fresh PR is unaffected (decay 1.0), so it only changes aged-PR projections.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isTimeDecayEnabled(env: {
|
|
27
|
+
SCORING_TIME_DECAY_ENABLED?: string | null | undefined;
|
|
28
|
+
}): boolean;
|
|
29
|
+
export declare function detectActiveModel(constants: Record<string, number>): ScoringModelSnapshotRecord["activeModel"];
|
|
30
|
+
export declare function hasSaturationConstants(constants: Record<string, number>): boolean;
|
|
31
|
+
export declare function hasDensityConstants(constants: Record<string, number>): boolean;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Deterministic subset of the backend's `src/scoring/model.ts`, extracted so a miner can run the same
|
|
2
|
+
// score-preview constants/classification logic locally (#2282). The upstream-fetching, D1-persisting half
|
|
3
|
+
// of that file (`refreshScoringModelSnapshot`, `getOrCreateScoringModelSnapshot`, and their private fetch
|
|
4
|
+
// helpers) is Cloudflare/D1-bound and stays in `src/scoring/model.ts`, which imports the pure pieces below
|
|
5
|
+
// back from this package.
|
|
6
|
+
export const DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5;
|
|
7
|
+
export const DEFAULT_SCORING_CONSTANTS = {
|
|
8
|
+
OSS_EMISSION_SHARE: 0.9,
|
|
9
|
+
// Upstream name is ISSUES_TREASURY_EMISSION_SHARE (plural). The prior singular spelling never matched
|
|
10
|
+
// upstream, freezing this at the local default and showing up as a false "unmodeled" drift warning (#806).
|
|
11
|
+
ISSUES_TREASURY_EMISSION_SHARE: 0.1,
|
|
12
|
+
// Lookback window used upstream for PR history; stored so it syncs and does not surface as unmodeled drift.
|
|
13
|
+
PR_LOOKBACK_DAYS: 30,
|
|
14
|
+
MERGED_PR_BASE_SCORE: 25,
|
|
15
|
+
// Upstream MAX_CONTRIBUTION_BONUS is 5. This local value is only the fetch-failure fallback; keeping it at
|
|
16
|
+
// 25 silently 5x-inflated the contribution bonus whenever the upstream fetch failed (#807).
|
|
17
|
+
MAX_CONTRIBUTION_BONUS: 5,
|
|
18
|
+
CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500,
|
|
19
|
+
// Applied in preview.ts when computing totalTokenScore from components (#808).
|
|
20
|
+
TEST_FILE_CONTRIBUTION_WEIGHT: 0.05,
|
|
21
|
+
// Upstream-enforced eligibility floors for PR and issue-discovery history (#808).
|
|
22
|
+
// These gate whether a validator counts a contributor's submissions, not the per-PR/issue score itself.
|
|
23
|
+
// Stored here so they sync from upstream and no longer appear as unmodeled drift warnings.
|
|
24
|
+
MIN_VALID_MERGED_PRS: 3,
|
|
25
|
+
MIN_CREDIBILITY: 0.8,
|
|
26
|
+
MIN_VALID_SOLVED_ISSUES: 3,
|
|
27
|
+
MIN_ISSUE_CREDIBILITY: 0.8,
|
|
28
|
+
MIN_TOKEN_SCORE_FOR_VALID_ISSUE: 5,
|
|
29
|
+
// Open-issue spam gate constants — wired into the issue-discovery scoring lane in preview.ts (#808).
|
|
30
|
+
OPEN_ISSUE_SPAM_BASE_THRESHOLD: 2,
|
|
31
|
+
OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT: 300,
|
|
32
|
+
MAX_OPEN_ISSUE_THRESHOLD: 30,
|
|
33
|
+
OPEN_PR_COLLATERAL_PERCENT: 0.2,
|
|
34
|
+
MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER: 2.0,
|
|
35
|
+
MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300,
|
|
36
|
+
DEFAULT_ISSUE_DISCOVERY_SHARE,
|
|
37
|
+
REVIEW_PENALTY_RATE: 0.15,
|
|
38
|
+
STANDARD_ISSUE_MULTIPLIER: 1.33,
|
|
39
|
+
MAINTAINER_ISSUE_MULTIPLIER: 1.66,
|
|
40
|
+
EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2,
|
|
41
|
+
OPEN_PR_THRESHOLD_TOKEN_SCORE: 300,
|
|
42
|
+
MAX_OPEN_PR_THRESHOLD: 30,
|
|
43
|
+
SRC_TOK_SATURATION_SCALE: 58,
|
|
44
|
+
// Density-era constants (#812): upstream is on the saturation model, but `current_density_model` is still
|
|
45
|
+
// a supported `activeModel` (types.ts union, the public OpenAPI schema, the DB parser, ~20 test fixtures,
|
|
46
|
+
// and src/services/score-breakdown.ts). The density branch in preview.ts is therefore NOT dead — it is the
|
|
47
|
+
// supported alternate/fallback model. Single-sourcing these fallbacks HERE (instead of as silent hardcoded
|
|
48
|
+
// literals at every constant() call site) closes the duplicate-source-of-truth gap without a breaking
|
|
49
|
+
// removal of a still-supported model.
|
|
50
|
+
MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5,
|
|
51
|
+
MAX_CODE_DENSITY_MULTIPLIER: 1.15,
|
|
52
|
+
// Upstream time-decay (#703): a merged PR's score decays on a sigmoid after a grace period. Modeled here
|
|
53
|
+
// so they no longer surface as unmodeled drift (#690); APPLICATION is opt-in + default-off (see preview).
|
|
54
|
+
TIME_DECAY_GRACE_PERIOD_HOURS: 12,
|
|
55
|
+
TIME_DECAY_SIGMOID_MIDPOINT: 10,
|
|
56
|
+
TIME_DECAY_SIGMOID_STEEPNESS_SCALAR: 0.4,
|
|
57
|
+
TIME_DECAY_MIN_MULTIPLIER: 0.05,
|
|
58
|
+
};
|
|
59
|
+
export const DEFAULT_GITTENSOR_UPSTREAM_REPO = "entrius/gittensor";
|
|
60
|
+
export const DEFAULT_GITTENSOR_UPSTREAM_REF = "test";
|
|
61
|
+
// Single source of truth (#812): every recognized upstream constant name is a key of
|
|
62
|
+
// DEFAULT_SCORING_CONSTANTS, so the known-only parser, the unmodeled-drift detector, and the preview-side
|
|
63
|
+
// fallbacks all derive from one place. The density-era constants are included because the density model is
|
|
64
|
+
// still a supported activeModel (see comment above). Exported so the D1-bound refresh in
|
|
65
|
+
// `src/scoring/model.ts` can reuse it for its `recognizedCount` sanity floor without duplicating the Set.
|
|
66
|
+
export const SCORING_CONSTANT_NAMES = new Set(Object.keys(DEFAULT_SCORING_CONSTANTS));
|
|
67
|
+
// Mirror Pipeline B's UPSTREAM_STALE_MS (upstream/ruleset.ts): a served scoring snapshot older than this
|
|
68
|
+
// window means the last upstream refresh failed or has not run, so previews are quietly using last-good (or
|
|
69
|
+
// DEFAULT) constants with no other staleness signal on the scoring side (#810).
|
|
70
|
+
export const SCORING_SNAPSHOT_STALE_MS = 2 * 60 * 60 * 1000;
|
|
71
|
+
export function scoringSnapshotStalenessWarning(snapshot, now = Date.now()) {
|
|
72
|
+
if (Date.parse(snapshot.fetchedAt) + SCORING_SNAPSHOT_STALE_MS >= now)
|
|
73
|
+
return null;
|
|
74
|
+
return "Scoring constants snapshot is stale: the last upstream refresh is older than the freshness window, so scoring may be using last-good or default constants and be behind upstream.";
|
|
75
|
+
}
|
|
76
|
+
export function parsePythonNumberConstants(source, options = { knownOnly: true }) {
|
|
77
|
+
const constants = {};
|
|
78
|
+
for (const line of source.split("\n")) {
|
|
79
|
+
// Match Python numeric literals including underscore separators in integer and fractional parts
|
|
80
|
+
// (1_000_000, 0.000_001, 3.14_15), floats, and exponents (1e-9, 5.8e1). The previous regex only
|
|
81
|
+
// allowed `_` in the integer part, truncating 0.000_001 -> 0 and 3.14_15 -> 3.14 (#992).
|
|
82
|
+
const match = line.match(/^([A-Z][A-Z0-9_]+)\s*=\s*([-+]?(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][-+]?\d+)?)/);
|
|
83
|
+
if (!match)
|
|
84
|
+
continue;
|
|
85
|
+
const name = match[1];
|
|
86
|
+
const raw = match[2];
|
|
87
|
+
if (options.knownOnly !== false && !SCORING_CONSTANT_NAMES.has(name))
|
|
88
|
+
continue;
|
|
89
|
+
// Number() rejects underscore separators, so strip them before parsing.
|
|
90
|
+
constants[name] = Number(raw.replace(/_/g, ""));
|
|
91
|
+
}
|
|
92
|
+
return constants;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Upstream operational/infra constants gittensory intentionally does not model in score previews.
|
|
96
|
+
* They are not scoring dimensions — surfacing them as "unmodeled drift" is noise (#809).
|
|
97
|
+
*/
|
|
98
|
+
const NON_SCORING_UPSTREAM_CONSTANT_NAMES = new Set([
|
|
99
|
+
"SECONDS_PER_DAY",
|
|
100
|
+
"SECONDS_PER_HOUR",
|
|
101
|
+
"GITHUB_HTTP_TIMEOUT_SECONDS",
|
|
102
|
+
"MIRROR_HTTP_TIMEOUT_SECONDS",
|
|
103
|
+
"MIRROR_MAX_ATTEMPTS",
|
|
104
|
+
"TREE_SITTER_PARSE_TIMEOUT_MICROS",
|
|
105
|
+
"SCORING_SUBPROCESS_BUDGET_S",
|
|
106
|
+
"MAX_FILE_SIZE_BYTES",
|
|
107
|
+
"RECYCLE_UID",
|
|
108
|
+
"ISSUES_TREASURY_UID",
|
|
109
|
+
"MAX_ISSUE_ID",
|
|
110
|
+
// Floating-point epsilon for the registry emission_share-sum validation
|
|
111
|
+
// (load_weights.py: `total_share > 1.0 + EMISSION_SHARE_TOLERANCE`), not a scoring dimension — without
|
|
112
|
+
// this entry the parser (which reads exponent literals like `1e-9`, #992) flagged it as a permanent
|
|
113
|
+
// false-positive unmodeled-scoring-drift warning (#809).
|
|
114
|
+
"EMISSION_SHARE_TOLERANCE",
|
|
115
|
+
// Fallback weight applied by the validator's load_programming_language_weights() for extensions absent from
|
|
116
|
+
// the JSON file; gittensory reads the JSON directly and does not track this loader default (#1692).
|
|
117
|
+
"DEFAULT_PROGRAMMING_LANGUAGE_WEIGHT",
|
|
118
|
+
]);
|
|
119
|
+
/**
|
|
120
|
+
* Numeric constant names upstream gittensor defines that gittensory's scoring engine does NOT model.
|
|
121
|
+
* The normal parse is `knownOnly` (it keeps only constants we already encode), which silently hides
|
|
122
|
+
* upstream ADDITIONS — e.g. a newly-introduced time-decay constant. Surfacing these makes scoring
|
|
123
|
+
* staleness visible: if upstream adds a scoring dimension, an operator sees it instead of the gate
|
|
124
|
+
* silently drifting behind. Detection only — it does not change any score.
|
|
125
|
+
*/
|
|
126
|
+
export function findUnmodeledConstantKeys(allConstants) {
|
|
127
|
+
return Object.keys(allConstants)
|
|
128
|
+
.filter((name) => !SCORING_CONSTANT_NAMES.has(name) && !NON_SCORING_UPSTREAM_CONSTANT_NAMES.has(name))
|
|
129
|
+
.sort();
|
|
130
|
+
}
|
|
131
|
+
export function findUnmodeledUpstreamConstants(source) {
|
|
132
|
+
return findUnmodeledConstantKeys(parsePythonNumberConstants(source, { knownOnly: false }));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Owner-controlled global gate for applying upstream time-decay to score previews (#703). Default OFF: the
|
|
136
|
+
* roadmap deferral requires the owner to review a before/after ranking diff before enabling. Even when on,
|
|
137
|
+
* a fresh PR is unaffected (decay 1.0), so it only changes aged-PR projections.
|
|
138
|
+
*/
|
|
139
|
+
export function isTimeDecayEnabled(env) {
|
|
140
|
+
return /^(1|true|yes|on)$/i.test(env.SCORING_TIME_DECAY_ENABLED ?? "");
|
|
141
|
+
}
|
|
142
|
+
export function detectActiveModel(constants) {
|
|
143
|
+
if (hasSaturationConstants(constants))
|
|
144
|
+
return "pending_saturation_model";
|
|
145
|
+
if (hasDensityConstants(constants)) {
|
|
146
|
+
return "current_density_model";
|
|
147
|
+
}
|
|
148
|
+
return "unknown";
|
|
149
|
+
}
|
|
150
|
+
// Exported (unlike the backend original, where these stay private) so `src/scoring/model.ts`'s
|
|
151
|
+
// D1-bound `refreshScoringModelSnapshot`/`activeModelWarnings` can reuse them without duplication.
|
|
152
|
+
export function hasSaturationConstants(constants) {
|
|
153
|
+
return Number.isFinite(constants.SRC_TOK_SATURATION_SCALE);
|
|
154
|
+
}
|
|
155
|
+
export function hasDensityConstants(constants) {
|
|
156
|
+
return Number.isFinite(constants.MAX_CODE_DENSITY_MULTIPLIER) && Number.isFinite(constants.MIN_TOKEN_SCORE_FOR_BASE_SCORE);
|
|
157
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CheckSummaryRecord, PullRequestRecord, PullRequestReviewRecord, RoleContext } from "./types.js";
|
|
2
|
+
import type { ScorePreviewInput } from "./preview.js";
|
|
3
|
+
export type OpenPrPendingClass = "merge_ready" | "stale_likely_close" | "draft" | "blocked" | "maintainer_lane" | "open_other";
|
|
4
|
+
export type ClassifiedOpenPullRequest = {
|
|
5
|
+
repoFullName: string;
|
|
6
|
+
number: number;
|
|
7
|
+
title: string;
|
|
8
|
+
classification: OpenPrPendingClass;
|
|
9
|
+
reasons: string[];
|
|
10
|
+
};
|
|
11
|
+
export type PendingPrScenarioDetection = {
|
|
12
|
+
source: "github_observed" | "user_supplied";
|
|
13
|
+
pendingMergedPrCount: number;
|
|
14
|
+
pendingClosedPrCount: number;
|
|
15
|
+
approvedPrCount: number;
|
|
16
|
+
expectedOpenPrCountAfterMerge?: number | undefined;
|
|
17
|
+
scenarioNotes: string[];
|
|
18
|
+
classified: ClassifiedOpenPullRequest[];
|
|
19
|
+
};
|
|
20
|
+
export type ContributorRepoOpenPrSignals = {
|
|
21
|
+
reviewsByPullNumber: Map<number, PullRequestReviewRecord[]>;
|
|
22
|
+
checksByPullNumber: Map<number, CheckSummaryRecord[]>;
|
|
23
|
+
};
|
|
24
|
+
export declare const DRAFT_TITLE_PATTERN: RegExp;
|
|
25
|
+
export declare function detectPendingPrScenario(args: {
|
|
26
|
+
login: string;
|
|
27
|
+
repoFullName: string;
|
|
28
|
+
pullRequests: PullRequestRecord[];
|
|
29
|
+
roleContext: RoleContext;
|
|
30
|
+
openPrCount?: number | undefined;
|
|
31
|
+
reviewsByPullNumber?: Map<number, PullRequestReviewRecord[]> | undefined;
|
|
32
|
+
checksByPullNumber?: Map<number, CheckSummaryRecord[]> | undefined;
|
|
33
|
+
excludePullNumbers?: number[] | undefined;
|
|
34
|
+
userSupplied?: Pick<ScorePreviewInput, "pendingMergedPrCount" | "pendingClosedPrCount" | "approvedPrCount" | "expectedOpenPrCountAfterMerge" | "projectedCredibility" | "scenarioNotes"> | undefined;
|
|
35
|
+
}): PendingPrScenarioDetection | null;
|
|
36
|
+
export declare function classifyOpenPullRequest(args: {
|
|
37
|
+
pr: PullRequestRecord;
|
|
38
|
+
roleContext: RoleContext;
|
|
39
|
+
reviews: PullRequestReviewRecord[];
|
|
40
|
+
checks: CheckSummaryRecord[];
|
|
41
|
+
duplicateProne?: boolean | undefined;
|
|
42
|
+
missingTests?: boolean | undefined;
|
|
43
|
+
}): ClassifiedOpenPullRequest;
|
|
44
|
+
export declare function applyPendingPrDetectionToScoreInput(input: ScorePreviewInput, detection: PendingPrScenarioDetection | null): ScorePreviewInput;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const STALE_DAYS = 14;
|
|
2
|
+
// Real draft markers only — "[draft]", "Draft:", "Draft -"; the delimiter keeps "Drafting" and
|
|
3
|
+
// "draft-js" from matching. Trailing \s* lets the same pattern also strip the marker for dedup keys.
|
|
4
|
+
export const DRAFT_TITLE_PATTERN = /^(?:\[\s*draft\s*\]|draft(?:\s*:|\s+-))\s*/i;
|
|
5
|
+
// Mirrors `src/github/commands.ts`'s `isMaintainerAssociation`/`MAINTAINER_ASSOCIATIONS` exactly. Duplicated
|
|
6
|
+
// here (rather than imported) because this package cannot reach into `src/`; keep the two in sync by hand.
|
|
7
|
+
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
8
|
+
function isMaintainerAssociation(association) {
|
|
9
|
+
return Boolean(association && MAINTAINER_ASSOCIATIONS.has(association));
|
|
10
|
+
}
|
|
11
|
+
export function detectPendingPrScenario(args) {
|
|
12
|
+
const user = args.userSupplied;
|
|
13
|
+
const hasUserCounts = user?.pendingMergedPrCount !== undefined ||
|
|
14
|
+
user?.pendingClosedPrCount !== undefined ||
|
|
15
|
+
user?.approvedPrCount !== undefined ||
|
|
16
|
+
user?.expectedOpenPrCountAfterMerge !== undefined;
|
|
17
|
+
if (hasUserCounts) {
|
|
18
|
+
return {
|
|
19
|
+
source: "user_supplied",
|
|
20
|
+
pendingMergedPrCount: nonNegative(user?.pendingMergedPrCount),
|
|
21
|
+
pendingClosedPrCount: nonNegative(user?.pendingClosedPrCount),
|
|
22
|
+
approvedPrCount: nonNegative(user?.approvedPrCount),
|
|
23
|
+
...(user?.expectedOpenPrCountAfterMerge !== undefined ? { expectedOpenPrCountAfterMerge: nonNegative(user.expectedOpenPrCountAfterMerge) } : {}),
|
|
24
|
+
scenarioNotes: user?.scenarioNotes ?? [],
|
|
25
|
+
classified: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const excluded = new Set(args.excludePullNumbers ?? []);
|
|
29
|
+
const contributorOpen = args.pullRequests.filter((pr) => sameRepoFullName(pr.repoFullName, args.repoFullName) &&
|
|
30
|
+
pr.state === "open" &&
|
|
31
|
+
sameLogin(pr.authorLogin, args.login) &&
|
|
32
|
+
!excluded.has(pr.number));
|
|
33
|
+
if (contributorOpen.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
const classified = contributorOpen.map((pr) => classifyOpenPullRequest({
|
|
36
|
+
pr,
|
|
37
|
+
roleContext: args.roleContext,
|
|
38
|
+
reviews: args.reviewsByPullNumber?.get(pr.number) ?? [],
|
|
39
|
+
checks: args.checksByPullNumber?.get(pr.number) ?? [],
|
|
40
|
+
}));
|
|
41
|
+
const mergeReady = classified.filter((entry) => entry.classification === "merge_ready");
|
|
42
|
+
const staleLikelyClose = classified.filter((entry) => entry.classification === "stale_likely_close");
|
|
43
|
+
const pendingMergedPrCount = mergeReady.length;
|
|
44
|
+
const pendingClosedPrCount = staleLikelyClose.length;
|
|
45
|
+
if (pendingMergedPrCount === 0 && pendingClosedPrCount === 0)
|
|
46
|
+
return null;
|
|
47
|
+
const currentOpen = args.openPrCount ?? contributorOpen.length;
|
|
48
|
+
const expectedOpenPrCountAfterMerge = Math.max(0, currentOpen - pendingMergedPrCount - pendingClosedPrCount);
|
|
49
|
+
const scenarioNotes = [
|
|
50
|
+
"GitHub-observed open PR state from cached reviews, checks, and activity timestamps (estimate only).",
|
|
51
|
+
...(pendingMergedPrCount > 0
|
|
52
|
+
? [`${pendingMergedPrCount} open PR(s) look merge-ready (approved, no changes requested, no failing checks, not draft/stale).`]
|
|
53
|
+
: []),
|
|
54
|
+
...(pendingClosedPrCount > 0 ? [`${pendingClosedPrCount} open PR(s) look stale and may be closed instead of merged.`] : []),
|
|
55
|
+
...classified
|
|
56
|
+
.filter((entry) => entry.classification === "draft" || entry.classification === "blocked" || entry.classification === "maintainer_lane")
|
|
57
|
+
.map((entry) => `PR #${entry.number} treated as ${entry.classification.replace(/_/g, " ")} for this projection.`),
|
|
58
|
+
];
|
|
59
|
+
return {
|
|
60
|
+
source: "github_observed",
|
|
61
|
+
pendingMergedPrCount,
|
|
62
|
+
pendingClosedPrCount,
|
|
63
|
+
approvedPrCount: mergeReady.length,
|
|
64
|
+
expectedOpenPrCountAfterMerge,
|
|
65
|
+
scenarioNotes,
|
|
66
|
+
classified,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function classifyOpenPullRequest(args) {
|
|
70
|
+
const reasons = [];
|
|
71
|
+
if (args.roleContext.maintainerLane) {
|
|
72
|
+
reasons.push("Maintainer-lane context for this repo; not counted as outside-contributor pending reward work.");
|
|
73
|
+
return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons };
|
|
74
|
+
}
|
|
75
|
+
if (isMaintainerAssociation(args.pr.authorAssociation)) {
|
|
76
|
+
reasons.push("Author association indicates maintainer-authored work.");
|
|
77
|
+
return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons };
|
|
78
|
+
}
|
|
79
|
+
if (isDraftPullRequest(args.pr)) {
|
|
80
|
+
reasons.push("Draft PRs are not treated as likely to land.");
|
|
81
|
+
return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "draft", reasons };
|
|
82
|
+
}
|
|
83
|
+
const approvalCount = args.reviews.filter((review) => review.state.toUpperCase() === "APPROVED").length;
|
|
84
|
+
const changeRequestCount = args.reviews.filter((review) => review.state.toUpperCase() === "CHANGES_REQUESTED").length;
|
|
85
|
+
const checkFailureCount = args.checks.filter((check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled").length;
|
|
86
|
+
const ageDays = daysSince(args.pr.updatedAt ?? args.pr.createdAt);
|
|
87
|
+
if (args.duplicateProne)
|
|
88
|
+
reasons.push("Overlapping open work detected in the same repo (possible duplicate or WIP collision).");
|
|
89
|
+
if (args.missingTests)
|
|
90
|
+
reasons.push("Cached file list shows code changes without matching test files.");
|
|
91
|
+
if (changeRequestCount > 0)
|
|
92
|
+
reasons.push(`${changeRequestCount} changes-requested review(s).`);
|
|
93
|
+
if (checkFailureCount > 0)
|
|
94
|
+
reasons.push(`${checkFailureCount} failing or cancelled check(s).`);
|
|
95
|
+
if (approvalCount === 0)
|
|
96
|
+
reasons.push("No approved review in cache.");
|
|
97
|
+
if (changeRequestCount > 0 || checkFailureCount > 0 || approvalCount === 0) {
|
|
98
|
+
return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "blocked", reasons };
|
|
99
|
+
}
|
|
100
|
+
if (ageDays >= STALE_DAYS) {
|
|
101
|
+
reasons.push(`No meaningful update in at least ${STALE_DAYS} days; may be closed instead of merged.`);
|
|
102
|
+
return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "stale_likely_close", reasons };
|
|
103
|
+
}
|
|
104
|
+
reasons.push("Approved with passing checks and recent activity; treated as likely to merge (estimate).");
|
|
105
|
+
return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "merge_ready", reasons };
|
|
106
|
+
}
|
|
107
|
+
export function applyPendingPrDetectionToScoreInput(input, detection) {
|
|
108
|
+
if (!detection || detection.source === "user_supplied")
|
|
109
|
+
return input;
|
|
110
|
+
return {
|
|
111
|
+
...input,
|
|
112
|
+
pendingMergedPrCount: detection.pendingMergedPrCount,
|
|
113
|
+
pendingClosedPrCount: detection.pendingClosedPrCount,
|
|
114
|
+
approvedPrCount: detection.approvedPrCount,
|
|
115
|
+
expectedOpenPrCountAfterMerge: detection.expectedOpenPrCountAfterMerge,
|
|
116
|
+
scenarioNotes: [...(input.scenarioNotes ?? []), ...detection.scenarioNotes],
|
|
117
|
+
pendingScenarioObserved: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function isDraftPullRequest(pr) {
|
|
121
|
+
if (pr.isDraft)
|
|
122
|
+
return true;
|
|
123
|
+
if (DRAFT_TITLE_PATTERN.test(pr.title.trim()))
|
|
124
|
+
return true;
|
|
125
|
+
return pr.labels.some((label) => label.toLowerCase() === "draft" || label.toLowerCase() === "wip");
|
|
126
|
+
}
|
|
127
|
+
function sameRepoFullName(left, right) {
|
|
128
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
function sameLogin(value, login) {
|
|
131
|
+
return Boolean(value && value.toLowerCase() === login.toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
function daysSince(value) {
|
|
134
|
+
if (!value)
|
|
135
|
+
return Number.POSITIVE_INFINITY;
|
|
136
|
+
const parsed = Date.parse(value);
|
|
137
|
+
if (!Number.isFinite(parsed))
|
|
138
|
+
return Number.POSITIVE_INFINITY;
|
|
139
|
+
return Math.max(0, (Date.now() - parsed) / 86_400_000);
|
|
140
|
+
}
|
|
141
|
+
function nonNegative(value) {
|
|
142
|
+
if (value === undefined || !Number.isFinite(value))
|
|
143
|
+
return 0;
|
|
144
|
+
return Math.max(0, Math.trunc(value));
|
|
145
|
+
}
|