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
29 changes: 23 additions & 6 deletions packages/loopover-engine/src/settings/pr-type-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function normalizeTypeLabelSet(input: unknown, warnings: string[]): PrTyp
export type PrTypeLabelDecision = {
applyLabels: string[];
removeLabels: string[];
source: "propagation_exclusive" | "propagation_additive" | "title";
source: "propagation_exclusive" | "propagation_additive" | "title" | "propagation_unmatched";
};

/**
Expand All @@ -125,11 +125,18 @@ export type PrTypeLabelDecision = {
* - `removeOtherTypeLabels: false` (additive) — the mapped label is applied ALONGSIDE the
* normal title-based bug/feature label, which is left untouched (e.g. a generic
* `customer:vip` → `triage:vip` triage marker that has nothing to do with bug/feature/priority).
* 2. Otherwise, feature (feat/feature) / bug (everything else) by the conventional-commit title prefix
* -- ONLY when `labels` actually has a name registered for that built-in category; a configured set
* that omits `bug`/`feature` entirely (a self-hoster who only wants custom, propagation-driven
* categories, or an explicit `typeLabels: {}` resolved to zero categories) applies nothing for that
* branch rather than inventing a label name (#label-modularity).
* 2. When propagation is enabled but produced NEITHER an exclusive nor an additive match for this pass
* (the linked issue hasn't been triaged with a mapped label yet, or carries none), no label is applied
* at all (`source: "propagation_unmatched"`) -- #9077: falling back to a title guess here would silently
* reintroduce the exact author-controlled-free-text classification the repo opted OUT of by turning
* propagation on in the first place. This is a REPO-level opt-in consequence only: it never fires for a
* repo that hasn't configured `linkedIssueLabelPropagation.enabled` (see case 3 below), so it changes
* nothing for the shipped default.
* 3. Otherwise (propagation disabled/unconfigured), feature (feat/feature) / bug (everything else) by the
* conventional-commit title prefix -- ONLY when `labels` actually has a name registered for that
* built-in category; a configured set that omits `bug`/`feature` entirely (a self-hoster who only
* wants custom, propagation-driven categories, or an explicit `typeLabels: {}` resolved to zero
* categories) applies nothing for that branch rather than inventing a label name (#label-modularity).
* `removeLabels` is always "every member of the configured type-label set that isn't one of
* `applyLabels`" — generic and total over however many categories are configured, and safe even if a
* misconfigured additive mapping's `prLabel` happens to collide with a type-label-set name (it is
Expand Down Expand Up @@ -177,6 +184,16 @@ export function resolvePrTypeLabel(input: {
const applyLabels = [exclusiveMatch ? exclusiveMatch.prLabel : titleLabel, ...additiveMatches.map((mapping) => mapping.prLabel)];
return decide(applyLabels, exclusiveMatch ? "propagation_exclusive" : "propagation_additive");
}
// #9077: this repo has opted OUT of title-derived classification for its reward-bearing categories by
// turning propagation on -- neither an exclusive nor an additive mapping matched this pass (the linked
// issue has no mapped label yet, or none at all), so there is no confirmed evidence to classify from.
// Falling through to `deriveKindFromTitle` here would defeat the entire point of enabling propagation:
// a contributor's own title wording would silently become the reward-multiplier source again, exactly
// the author-controlled-free-text problem propagation exists to remove. No type label is applied instead
// -- `removeLabels` below still clears every configured category, since "unclassified" must never leave
// a STALE label (e.g. from before the linked issue's labels changed, or before propagation was enabled)
// sitting on the PR looking like a confirmed classification.
return decide([], "propagation_unmatched");
}
return decide([titleLabel], "title");
}
36 changes: 29 additions & 7 deletions packages/loopover-engine/src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3829,15 +3829,29 @@ export function indexBountiesByIssue(bounties: BountyRecord[]): Map<string, Boun
return map;
}

// #9080: every alternative below is a deliberate PREFIX match (so "cancel" also catches "cancelled",
// "reward" also catches "rewarding", etc.) -- but unanchored, a prefix match is also a SUBSTRING match
// anywhere in the string, which false-positived on real upstream statuses: "unclaimed" (contains
// "claimed") and "not_completed" (contains "complete") both classified as completed; "avoid" (contains
// "void") classified as cancelled; "renewed" (contains "new") classified as active. A leading `\b`
// requires a genuine WORD START immediately before the match -- still matches "cancelled"/"completed"/
// "rewarded" (nothing but whitespace/string-start precedes the base word there), but no longer matches
// a base word buried mid-token behind a letter or underscore (`\b` treats `_` as a word character, so
// "not_completed" still correctly fails to match "complete" the same way "notcompleted" would).
const BOUNTY_CANCELLED_STATUS_RE = /\b(cancel|void|expired|withdrawn|rejected|abandon)/;
// Only past-tense payout phrasing (rewarded/awarded) marks completion; a bounty that merely
// advertises a "reward"/"award" is an active offer, not already-completed work.
const BOUNTY_COMPLETED_STATUS_RE = /\b(complete|paid|resolved|rewarded|awarded|fulfil|merged|claimed|done)/;
const BOUNTY_HISTORICAL_STATUS_RE = /\b(historical|archived|closed)/;
const BOUNTY_ACTIVE_LOOKING_STATUS_RE = /\b(open|active|live|available|ready|funded|reward|award|in[\s_-]?progress|todo|new)/;

export function classifyBountyLifecycle(bounty: BountyRecord, issue: IssueRecord | null): BountyLifecycle {
const status = bounty.status.trim().toLowerCase();
if (!status) return "unknown";
if (/cancel|void|expired|withdrawn|rejected|abandon/.test(status)) return "cancelled";
// Only past-tense payout phrasing (rewarded/awarded) marks completion; a bounty that merely
// advertises a "reward"/"award" is an active offer, not already-completed work.
if (/complete|paid|resolved|rewarded|awarded|fulfil|merged|claimed|done/.test(status)) return "completed";
if (/historical|archived|closed/.test(status)) return "historical";
const looksActive = /open|active|live|available|ready|funded|reward|award|in[\s_-]?progress|todo|new/.test(status);
if (BOUNTY_CANCELLED_STATUS_RE.test(status)) return "cancelled";
if (BOUNTY_COMPLETED_STATUS_RE.test(status)) return "completed";
if (BOUNTY_HISTORICAL_STATUS_RE.test(status)) return "historical";
const looksActive = BOUNTY_ACTIVE_LOOKING_STATUS_RE.test(status);
if (!looksActive) return "ambiguous";
// Active-looking status: reconcile against the linked issue and freshness so dead context is not treated as live.
if (issue && issue.state !== "open") return "ambiguous";
Expand Down Expand Up @@ -3942,8 +3956,16 @@ export function buildBountyAdvisory(
const lifecycle = classifyBountyLifecycle(bounty, issue);
const target = bounty.payload.target_bounty ?? bounty.payload.target_alpha;
const amount = bounty.payload.bounty_amount ?? bounty.payload.bounty_alpha;
// #9080: the old check (`amount && amount !== 0 && amount !== "0.0000"`) compared against exactly ONE
// hardcoded zero-string literal -- upstream sending "0", "0.0", or "0.00" (any zero string other than
// that one exact literal) was a non-empty, truthy string that survived every guard and reported
// "funded". `amount !== 0` was also dead for a numeric amount (numeric 0 is already falsy, so `amount &&`
// alone already excludes it). Parsing to a real number and requiring it to be positive treats every
// zero-ish spelling (string or numeric) the same, and a malformed/non-numeric amount degrades to
// target-only/unknown instead of reporting funded.
const numericAmount = Number(amount);
/* v8 ignore next -- Unknown funding is a sparse-cache fallback; funded and target-only states are covered. */
const fundingStatus = amount && amount !== 0 && amount !== "0.0000" ? "funded" : target ? "target_only" : "unknown";
const fundingStatus = Number.isFinite(numericAmount) && numericAmount > 0 ? "funded" : target ? "target_only" : "unknown";
const source = buildBountySourceContext(bounty);
const linkedPrs = buildBountyLinkedPrs(issue, pullRequests, recentMergedPullRequests);
const findings: SignalFinding[] = [];
Expand Down
52 changes: 36 additions & 16 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,6 @@ import {
} from "../signals/registration-readiness";
import { fileUpstreamDriftIssues, loadUpstreamStatus, refreshUpstreamDrift, registryHyperparameterDriftWarningsForRepo, resolveAutoFileDriftIssuesManifestOverride } from "../upstream/ruleset";
import type {
BountyLifecycleEventRecord,
ControlPanelRoleName,
ContributorEvidenceRecord,
DataQuality,
Expand Down Expand Up @@ -5091,24 +5090,45 @@ export function createApp() {
app.post("/v1/internal/bounties/import", async (c) => {
const body = await c.req.json().catch(() => null);
const bounties = normalizeGittBountySnapshot(body);
const events: BountyLifecycleEventRecord[] = [];
let imported = 0;
let lifecycleEvents = 0;
for (const bounty of bounties) {
const existing = await getBounty(c.env, bounty.id);
await upsertBounty(c.env, bounty);
if (!existing || existing.status !== bounty.status) {
events.push({
id: crypto.randomUUID(),
bountyId: bounty.id,
repoFullName: bounty.repoFullName,
issueNumber: bounty.issueNumber,
status: bounty.status,
payload: { previousStatus: existing?.status ?? null, source: "gitt_import" },
generatedAt: nowIso(),
});
// #9080: the lifecycle event for THIS bounty is now written in the SAME step as its own upsert, and
// each item is individually try/caught -- previously every event was collected into one array and
// persisted only AFTER the whole loop finished, so a mid-loop failure (a bad row further down the
// batch) left every bounty upserted so far already advanced in `bounties` with ZERO lifecycle rows
// recorded for any of them, and the transition is unrecoverable once the next import's diff runs
// against the now-already-updated row (the same "state advanced, audit trail didn't" shape as #8997).
// A single bad item can no longer take the rest of the batch down with it, either.
try {
const existing = await getBounty(c.env, bounty.id);
await upsertBounty(c.env, bounty);
imported += 1;
if (!existing || existing.status !== bounty.status) {
await persistBountyLifecycleEvent(c.env, {
id: crypto.randomUUID(),
bountyId: bounty.id,
repoFullName: bounty.repoFullName,
issueNumber: bounty.issueNumber,
status: bounty.status,
payload: { previousStatus: existing?.status ?? null, source: "gitt_import" },
generatedAt: nowIso(),
});
lifecycleEvents += 1;
}
} catch (error) {
console.warn(
JSON.stringify({
event: "bounty_import_item_failed",
bountyId: bounty.id,
repoFullName: bounty.repoFullName,
issueNumber: bounty.issueNumber,
message: errorMessage(error).slice(0, 160),
}),
);
}
}
await Promise.all(events.map((event) => persistBountyLifecycleEvent(c.env, event)));
return c.json({ ok: true, imported: bounties.length, lifecycleEvents: events.length });
return c.json({ ok: true, imported, lifecycleEvents });
});

app.post("/v1/internal/queue-intelligence", async (c) => {
Expand Down
44 changes: 22 additions & 22 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5211,30 +5211,30 @@ export async function getBounty(env: Env, id: string): Promise<BountyRecord | nu

export async function upsertBounty(env: Env, bounty: BountyRecord): Promise<void> {
const db = getDb(env.DB);
const set = {
repoFullName: bounty.repoFullName,
issueNumber: bounty.issueNumber,
status: bounty.status,
amountText: bounty.amountText,
sourceUrl: bounty.sourceUrl,
payloadJson: jsonString(bounty.payload),
updatedAt: nowIso(),
};
await db
.insert(bounties)
.values({
id: bounty.id,
repoFullName: bounty.repoFullName,
issueNumber: bounty.issueNumber,
status: bounty.status,
amountText: bounty.amountText,
sourceUrl: bounty.sourceUrl,
payloadJson: jsonString(bounty.payload),
updatedAt: nowIso(),
})
.onConflictDoUpdate({
target: bounties.id,
set: {
repoFullName: bounty.repoFullName,
issueNumber: bounty.issueNumber,
status: bounty.status,
amountText: bounty.amountText,
sourceUrl: bounty.sourceUrl,
payloadJson: jsonString(bounty.payload),
updatedAt: nowIso(),
},
});
.values({ id: bounty.id, ...set })
// #9080: TWO independent unique constraints can each conflict with this insert -- the primary key
// `id` (a re-import of the SAME upstream bounty) and `(repo_full_name, issue_number)`
// (`bounties_repo_issue_unique`, upstream re-issuing a bounty on the SAME issue under a NEW id, e.g.
// after cancelling and refunding the old one). Before this, only `id` was handled: a re-issued bounty
// conflicted on the unhandled unique index, the insert THREW, and — since nothing here caught it —
// the whole import batch 500'd and never advanced past that row on any subsequent import either
// (a permanent wedge). Chaining a second onConflictDoUpdate matches SQLite's own native multi-target
// upsert syntax, so both conflicts resolve to an update instead of one of them throwing. Neither
// clause touches `id`: the surviving row always keeps whichever id it already has, so
// bounty_lifecycle_events rows already keyed to it are never orphaned by a later re-issue.
.onConflictDoUpdate({ target: bounties.id, set })
.onConflictDoUpdate({ target: [bounties.repoFullName, bounties.issueNumber], set });
}

export async function persistAdvisory(env: Env, advisory: Advisory): Promise<void> {
Expand Down
28 changes: 24 additions & 4 deletions src/gittensor/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ export type GittensorContributorSnapshot = {
issueLabels: string[];
};

export async function fetchGittensorContributorSnapshot(login: string): Promise<GittensorContributorSnapshot | null> {
export async function fetchGittensorContributorSnapshot(login: string, githubId?: number | string | null): Promise<GittensorContributorSnapshot | null> {
try {
const detection = await fetchOfficialGittensorMiner(login);
const detection = await fetchOfficialGittensorMiner(login, githubId);
return detection.status === "confirmed" ? detection.snapshot : null;
} catch {
/* v8 ignore next -- fetchOfficialGittensorMiner converts network failures into an unavailable status; this is a last-resort guard. */
Expand All @@ -178,11 +178,31 @@ export type OfficialGittensorMinerDetection =
| { status: "not_found" }
| { status: "unavailable"; error: string };

export async function fetchOfficialGittensorMiner(login: string): Promise<OfficialGittensorMinerDetection> {
/**
* #9079: `login` alone used to be the ENTIRE match key against the upstream `/miners` list — but a GitHub
* login is renameable, and a freed username is reclaimable by anyone the instant its prior owner renames or
* deletes their account. If the upstream list still carries the old username (refresh cadence is upstream's,
* not ours), a squatter who claims it inherits `confirmed_miner` — a genuinely elevated role (command
* self-retrigger, the miner-scoped open-PR cap, the unlinked-issue-guardrail velocity exception). `githubId`
* is GitHub's own immutable numeric user id (present on every webhook payload's `user.id`, threaded through as
* `PullRequestRecord`/`IssueRecord.authorGithubId` since #9125) and CANNOT be reassigned by a rename or a
* reclaimed login, so it is checked FIRST whenever the caller has one. `login` is now purely a display/lookup
* fallback: still used verbatim when a caller genuinely has no stored id yet (a PR/issue predating #9125, or
* a call site not yet threading it through), which is a strict narrowing of the old always-username-only
* behavior, never a widening of it.
*/
export async function fetchOfficialGittensorMiner(login: string, githubId?: number | string | null): Promise<OfficialGittensorMinerDetection> {
try {
const miners = await fetchJson<GittensorMinerSummaryResponse[]>(`${GITTENSOR_API_BASE}/miners`);
const normalizedTargetGithubId = githubId === null || githubId === undefined ? null : String(githubId);
const normalizedLogin = login.toLowerCase();
const miner = miners.find((candidate) => candidate.githubUsername?.toLowerCase() === normalizedLogin);
const miner = normalizedTargetGithubId
? (miners.find((candidate) => candidate.githubId === normalizedTargetGithubId) ??
// A caller-supplied id that the upstream list doesn't (yet) carry is NOT "confirmed via username" --
// that would reopen exactly the login-only hole this id-first match exists to close. Only fall back to
// a username match when the id itself was never known at all.
undefined)
: miners.find((candidate) => candidate.githubUsername?.toLowerCase() === normalizedLogin);
if (!miner?.githubId || !miner.githubUsername) return { status: "not_found" };
return { status: "confirmed", snapshot: await buildGittensorContributorSnapshot({ ...miner, githubId: miner.githubId, githubUsername: miner.githubUsername }) };
} catch (error) {
Expand Down
8 changes: 5 additions & 3 deletions src/gittensor/miner-detection-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { fetchOfficialGittensorMiner } from "./api";
const OFFICIAL_MINER_DETECTION_TTL_MS = 5 * 60 * 1000;
const OFFICIAL_MINER_DETECTION_UNAVAILABLE_TTL_MS = 60 * 1000;

/** Fail-safe: any lookup failure resolves to "not a confirmed miner," never the reverse. */
export async function isConfirmedOfficialMiner(env: Env, login: string): Promise<boolean> {
/** Fail-safe: any lookup failure resolves to "not a confirmed miner," never the reverse. #9079: matches on
* `githubId` (the author's immutable numeric id) when the caller has one -- `login` is only ever the
* fallback/cache key, since a GitHub username is renameable and a freed one is reclaimable by anyone. */
export async function isConfirmedOfficialMiner(env: Env, login: string, githubId?: number | null): Promise<boolean> {
const cached = await getFreshOfficialMinerDetection(env, login).catch(() => null);
if (cached) return cached.status === "confirmed";
// fetchOfficialGittensorMiner already converts every failure into a returned {status: "unavailable"}
// value rather than rejecting -- nothing to catch here.
const detection = await fetchOfficialGittensorMiner(login);
const detection = await fetchOfficialGittensorMiner(login, githubId);
// A cache-write failure must never block the caller from using the freshly-fetched (just uncached)
// detection -- worst case, the next call re-fetches instead of hitting the cache.
const cacheable = await upsertOfficialMinerDetection(
Expand Down
Loading
Loading