Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions src/scoring/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,13 +756,55 @@ function deltaExplanationFor(core: ScoreCore, blockedBy: ScoreGateBlocker[]): st
}

function selectLabelMultiplier(labels: string[], multipliers: Record<string, number>, fallback: number): number {
const normalized = new Set(labels.map((label) => label.toLowerCase()));
const matched = Object.entries(multipliers).flatMap(([label, multiplier]) =>
normalized.has(label.toLowerCase()) ? [multiplier] : [],
);
const normalized = labels.map((label) => label.toLowerCase());
const matched = Object.entries(multipliers).flatMap(([pattern, multiplier]) => {
const matcher = labelPatternToRegExp(pattern.toLowerCase());
return normalized.some((label) => matcher.test(label)) ? [multiplier] : [];
});
return matched.length > 0 ? Math.max(...matched) : fallback || 1;
}

// Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a
// literal string: `fnmatch(label.lower(), pattern.lower())` in
// gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or
// `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The
// preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the
// neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch
// pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in
// change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any
// run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a
// pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically.
function labelPatternToRegExp(pattern: string): RegExp {
let regex = "";
let i = 0;
while (i < pattern.length) {
const char = pattern.charAt(i);
i += 1;
if (char === "*") {
regex += ".*";
} else if (char === "?") {
regex += ".";
} else if (char === "[") {
const close = pattern.indexOf("]", i);
if (close === -1) {
// No closing bracket: fnmatch treats the `[` as a literal character.
regex += "\\[";
} else {
let body = pattern.slice(i, close).replace(/\\/g, "\\\\");
// `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`.
if (body.startsWith("!")) body = `^${body.slice(1)}`;
regex += `[${body}]`;
i = close + 1;
}
} else if (/[.+^${}()|\]\\]/.test(char)) {
regex += `\\${char}`;
} else {
regex += char;
}
}
return new RegExp(`^${regex}$`, "i");
}

function decideLinkedIssueMultiplier(
mode: "none" | "standard" | "maintainer",
context: LinkedIssueMultiplierContext | undefined,
Expand Down
38 changes: 38 additions & 0 deletions test/unit/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,44 @@ NOVELTY_BONUS_SCALAR = 3
);
});

it("matches configured label keys as fnmatch globs, mirroring the upstream validator", () => {
const baseInput: ScorePreviewInput = {
repoFullName: repo.fullName,
sourceTokenScore: 60,
totalTokenScore: 90,
sourceLines: 50,
openPrCount: 0,
credibility: 1,
linkedIssueMode: "none",
};
const labelMultiplierFor = (labelMultipliers: Record<string, number>, labels: string[], defaultLabelMultiplier = 1): number =>
buildScorePreview({
repo: { ...repo, registryConfig: { ...repo.registryConfig!, defaultLabelMultiplier, labelMultipliers } },
snapshot,
input: { ...baseInput, labels },
}).scoreEstimate.labelMultiplier;

// `*` spans any run of characters (including `/` and `:` — labels are flat strings, not paths).
expect(labelMultiplierFor({ "kind/*": 1.5 }, ["kind/bug"])).toBe(1.5);
expect(labelMultiplierFor({ "type:*": 1.1 }, ["type:bug-fix"])).toBe(1.1);
// `?` matches exactly one character: it matches `priority:1` but not the two-digit `priority:10`.
expect(labelMultiplierFor({ "priority:?": 2 }, ["priority:1"])).toBe(2);
expect(labelMultiplierFor({ "priority:?": 2 }, ["priority:10"])).toBe(1);
// `[seq]` / `[!seq]` character classes.
expect(labelMultiplierFor({ "[bf]ug": 1.4 }, ["bug"])).toBe(1.4);
expect(labelMultiplierFor({ "[!x]ug": 1.3 }, ["bug"])).toBe(1.3);
// A `[` with no closing bracket is a literal, not a class.
expect(labelMultiplierFor({ "a[b": 0.7 }, ["a[b"])).toBe(0.7);
// Regex metacharacters in a literal key stay literal: `.` matches only a dot, not any char.
expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1.0"])).toBe(1.1);
expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1x0"])).toBe(1);
// When several patterns match, the highest multiplier wins (mirrors upstream `max(...)`).
expect(labelMultiplierFor({ "kind/*": 1.1, "*/bug": 1.6 }, ["kind/bug"])).toBe(1.6);
// Literal keys are unchanged — exact match, parity-preserving for every existing config.
expect(labelMultiplierFor({ bug: 1.2 }, ["bug"])).toBe(1.2);
expect(labelMultiplierFor({ bug: 1.2 }, ["feature"])).toBe(1);
});

it("gates linked-issue assumptions with branch eligibility evidence", () => {
const baseInput = {
repoFullName: repo.fullName,
Expand Down
Loading