diff --git a/.gittensory.yml.example b/.gittensory.yml.example index b597787547..9673aec886 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -636,6 +636,8 @@ settings: # Auto-assign a merged/linked-issue PR to a matching GitHub Project/Milestone (#3186). off | suggest | # auto. Default: off. "suggest" posts an advisory note only; "auto" applies the match directly. # autoProjectMilestoneMatch: off + # Confidence floor (0-100) for fuzzy matches in auto mode. Default: 65 (same bar as suggest-mode). + # autoProjectMilestoneMatchThreshold: 65 # Which backend the match runs against. github | linear. Default: github. "linear" matches against a # Linear workspace via a per-repo encrypted API key (set via the dashboard, never here). # autoProjectMilestoneMatchBackend: github diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index ecf9b9bfaa..ae0f5a3eac 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -3811,6 +3811,12 @@ "items": { "type": "string" } + }, + "autoProjectMilestoneMatchThreshold": { + "type": "integer", + "nullable": true, + "minimum": 0, + "maximum": 100 } }, "required": [ @@ -9522,6 +9528,12 @@ }, "agentGlobalFreezeOverride": { "type": "boolean" + }, + "autoProjectMilestoneMatchThreshold": { + "type": "integer", + "nullable": true, + "minimum": 0, + "maximum": 100 } }, "required": [ @@ -9647,6 +9659,12 @@ }, "autoLabelEnabled": { "type": "boolean" + }, + "autoProjectMilestoneMatchThreshold": { + "type": "integer", + "nullable": true, + "minimum": 0, + "maximum": 100 } }, "required": [ @@ -10259,6 +10277,12 @@ "defaultAllowed", "commandOverrides" ] + }, + "autoProjectMilestoneMatchThreshold": { + "type": "integer", + "nullable": true, + "minimum": 0, + "maximum": 100 } }, "required": [ diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 75fa9c41ae..2312901a75 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -649,6 +649,8 @@ settings: # Auto-assign a merged/linked-issue PR to a matching GitHub Project/Milestone (#3186). off | suggest | # auto. Default: off. "suggest" posts an advisory note only; "auto" applies the match directly. # autoProjectMilestoneMatch: off + # Confidence floor (0-100) for fuzzy matches in auto mode. Default: 65 (same bar as suggest-mode). + # autoProjectMilestoneMatchThreshold: 65 # Which backend the match runs against. github | linear. Default: github. "linear" matches against a # Linear workspace via a per-repo encrypted API key (set via the dashboard, never here). # autoProjectMilestoneMatchBackend: github diff --git a/migrations/0139_auto_project_milestone_match_threshold.sql b/migrations/0139_auto_project_milestone_match_threshold.sql new file mode 100644 index 0000000000..4b5ffe9803 --- /dev/null +++ b/migrations/0139_auto_project_milestone_match_threshold.sql @@ -0,0 +1,4 @@ +-- Auto-project/milestone matching (#3185): per-repo confidence floor for auto-apply mode. NULL = use the +-- built-in default (65, matching the suggest-mode fuzzy-match bar). Opt-in repos can raise this before +-- flipping autoProjectMilestoneMatch to "auto". +ALTER TABLE repository_settings ADD COLUMN auto_project_milestone_match_threshold INTEGER; diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 46c278def5..1160d8772e 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -325,6 +325,7 @@ export type FocusManifestSettings = Partial< | "reviewCheckMode" | "autoProjectMilestoneMatch" | "autoProjectMilestoneMatchBackend" + | "autoProjectMilestoneMatchThreshold" | "linkedIssueGateMode" | "duplicatePrGateMode" | "selfAuthoredLinkedIssueGateMode" @@ -1694,6 +1695,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[], if (autoProjectMilestoneMatch !== null) out.autoProjectMilestoneMatch = autoProjectMilestoneMatch; const autoProjectMilestoneMatchBackend = normalizeOptionalEnum(r.autoProjectMilestoneMatchBackend, "settings.autoProjectMilestoneMatchBackend", ["github", "linear"] as const, warnings); if (autoProjectMilestoneMatchBackend !== null) out.autoProjectMilestoneMatchBackend = autoProjectMilestoneMatchBackend; + const autoProjectMilestoneMatchThreshold = normalizeOptionalScore(r.autoProjectMilestoneMatchThreshold, "settings.autoProjectMilestoneMatchThreshold", warnings); + if (autoProjectMilestoneMatchThreshold !== null) out.autoProjectMilestoneMatchThreshold = autoProjectMilestoneMatchThreshold; const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings); if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode; const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings); diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index 72fd36c668..7effd9c7e1 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -160,6 +160,9 @@ export type RepositorySettings = { * Always populated by the DB layer (default `"github"`); optional so existing settings fixtures/callers need * not be touched. */ autoProjectMilestoneMatchBackend?: ProjectMilestoneMatchBackend | undefined; + /** Fuzzy-match confidence floor (0-100) for auto-apply mode (#3185). Always populated by the DB layer as + * null when unset (built-in default 65); optional so existing settings fixtures/callers need not be touched. */ + autoProjectMilestoneMatchThreshold?: number | null | undefined; /** Policy pack the gate evaluates under (#692). Default `gittensor` (registry-aware; threads confirmed * status for scoring only). `oss-anti-slop` runs the deterministic rules against any author on any repo. */ gatePack: GatePolicyPack; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 871997fa81..ee948b6df2 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -516,6 +516,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise reviewCheckMode: "disabled", autoProjectMilestoneMatch: "off", autoProjectMilestoneMatchBackend: "github", + autoProjectMilestoneMatchThreshold: null, gatePack: "gittensor", linkedIssueGateMode: "advisory", duplicatePrGateMode: "block", @@ -596,6 +597,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise reviewCheckMode: parseReviewCheckMode(row.reviewCheckMode), autoProjectMilestoneMatch: parseProjectMilestoneMatchMode(row.projectMilestoneMatchMode), autoProjectMilestoneMatchBackend: parseProjectMilestoneMatchBackend(row.autoProjectMilestoneMatchBackend), + autoProjectMilestoneMatchThreshold: normalizeQualityGateMinScore(row.autoProjectMilestoneMatchThreshold), gatePack: parseGatePack(row.gatePack), linkedIssueGateMode: parseGateRuleMode(row.linkedIssueGateMode), duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode), @@ -719,6 +721,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial= thresholdPercent; +} + export type ProjectTrackerMatch = { item: ProjectTrackerRef; // "native" (#3186): a CONFIRMED link (e.g. Linear's own GitHub integration already linked this PR), not a @@ -289,6 +304,7 @@ export function matchOpenTrackerItems(prTitle: string, prBody: string | null | u } export const PROJECT_TRACKER_SUGGEST_COMMENT_MARKER = ""; +export const PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER = ""; /** Code-formats a maintainer-authored title for safe Markdown embedding: backticks strip any literal backtick * from the title (so it can't break out of the code span) rather than escaping them, since a broken-out title @@ -311,6 +327,19 @@ function describeMatch(match: ProjectTrackerMatch, noun: "milestone" | "project" return `This PR looks like it's part of a matching${title} ${noun}${confidence}.`; } +function renderAutoApplyComment(attached: ProjectTrackerMatches, revealTitles: boolean): string { + const lines = [PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER]; + if (attached.milestone) { + const title = revealTitles ? ` ${codeFormat(attached.milestone.item.title)}` : ""; + lines.push(`Attached this PR to the${title} milestone.`); + } + if (attached.project) { + const title = revealTitles ? ` ${codeFormat(attached.project.item.title)}` : ""; + lines.push(`Added this PR to the${title} project.`); + } + return lines.join("\n"); +} + function renderSuggestionComment(matches: ProjectTrackerMatches, revealTitles: boolean): string { const lines = [PROJECT_TRACKER_SUGGEST_COMMENT_MARKER]; if (matches.milestone) lines.push(describeMatch(matches.milestone, "milestone", revealTitles)); @@ -362,12 +391,91 @@ async function resolveTrackerMatches(ctx: ProjectTrackerContext, backend: Projec }; } +async function hasExistingProjectTrackerBotComment(ctx: ProjectTrackerContext, pullNumber: number, marker: string): Promise { + const { owner, repo } = parseRepoFullName(ctx.repoFullName); + const token = await createInstallationToken(ctx.env, ctx.installationId); + const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); + const botLogin = `${ctx.env.GITHUB_APP_SLUG}[bot]`; + for (let page = 1; page <= GITHUB_LIST_PAGE_LIMIT; page += 1) { + const existing = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + page, + }); + const batch = existing.data as IssueComment[]; + const found = batch.some( + (comment) => comment.user?.type === "Bot" && comment.user.login?.toLowerCase() === botLogin.toLowerCase() && comment.body?.includes(marker), + ); + if (found) return true; + if (batch.length < 100) break; + } + return false; +} + +function trackerAdaptersForBackend(backend: ProjectMilestoneMatchBackendInput): { milestones: ProjectTrackerAdapter; projects: ProjectTrackerAdapter } { + if (backend === "linear") { + const adapter = new LinearAdapter(); + return { milestones: adapter, projects: adapter }; + } + return { milestones: new GitHubMilestonesAdapter(), projects: new GitHubProjectsAdapter() }; +} + +export function filterMatchesForAutoApply(matches: ProjectTrackerMatches, thresholdPercent: number): ProjectTrackerMatches { + return { + milestone: matches.milestone && matchPassesAutoApplyThreshold(matches.milestone, thresholdPercent) ? matches.milestone : null, + project: matches.project && matchPassesAutoApplyThreshold(matches.project, thresholdPercent) ? matches.project : null, + }; +} + +/** + * Best-effort auto-apply (#3185): resolves matches against the repo's configured backend, attaches milestone + * and/or project when the match clears {@link resolveAutoProjectMilestoneMatchThreshold}, and posts ONE + * confirmation comment ONCE per PR. Never throws -- attach failures are swallowed individually so the gate + * is never blocked. + */ +export async function maybeAutoApplyProjectOrMilestoneMatch( + ctx: ProjectTrackerContext, + pullNumber: number, + prTitle: string, + prBody: string | null | undefined, + backend: ProjectMilestoneMatchBackendInput, + prUrl: string, + thresholdPercent: number, +): Promise<{ applied: boolean }> { + const matches = filterMatchesForAutoApply(await resolveTrackerMatches(ctx, backend, prTitle, prBody, prUrl), thresholdPercent); + if (!matches.milestone && !matches.project) return { applied: false }; + if (await hasExistingProjectTrackerBotComment(ctx, pullNumber, PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER)) return { applied: false }; + + const adapters = trackerAdaptersForBackend(backend); + const attached: ProjectTrackerMatches = { milestone: null, project: null }; + if (matches.milestone) { + const result = await adapters.milestones.attachToMilestone(ctx, pullNumber, matches.milestone.item.id).catch(() => ({ attached: false })); + if (result.attached) attached.milestone = matches.milestone; + } + if (matches.project) { + const result = await adapters.projects.attachToProject(ctx, pullNumber, matches.project.item.id).catch(() => ({ attached: false })); + if (result.attached) attached.project = matches.project; + } + if (!attached.milestone && !attached.project) return { applied: false }; + + await createIssueComment( + ctx.env, + ctx.installationId, + ctx.repoFullName, + pullNumber, + renderAutoApplyComment(attached, backend !== "linear"), + ); + return { applied: true }; +} + /** * Best-effort, idempotent suggest-mode comment (#3183/#3184/#3186): resolves matches against the repo's * configured backend (GitHub by default, Linear when opted in) and posts ONE comment naming whichever * matched, ONCE per PR (never updates or reposts), so a repeated sweep/webhook pass never spams the thread. - * Never calls attachToMilestone/attachToProject -- suggest mode only ever comments; #3185 wires the real - * attach path behind "auto". + * Never calls attachToMilestone/attachToProject -- suggest mode only ever comments; auto mode lives in + * {@link maybeAutoApplyProjectOrMilestoneMatch} (#3185). */ export async function maybeSuggestProjectOrMilestoneMatch( ctx: ProjectTrackerContext, @@ -379,25 +487,7 @@ export async function maybeSuggestProjectOrMilestoneMatch( ): Promise<{ suggested: boolean }> { const matches = await resolveTrackerMatches(ctx, backend, prTitle, prBody, prUrl); if (!matches.milestone && !matches.project) return { suggested: false }; - - const { owner, repo } = parseRepoFullName(ctx.repoFullName); - const token = await createInstallationToken(ctx.env, ctx.installationId); - const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); - const botLogin = `${ctx.env.GITHUB_APP_SLUG}[bot]`; - let alreadyPosted = false; - for (let page = 1; page <= GITHUB_LIST_PAGE_LIMIT && !alreadyPosted; page += 1) { - const existing = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", { - owner, - repo, - issue_number: pullNumber, - per_page: 100, - page, - }); - const batch = existing.data as IssueComment[]; - alreadyPosted = batch.some((comment) => comment.user?.type === "Bot" && comment.user.login?.toLowerCase() === botLogin.toLowerCase() && comment.body?.includes(PROJECT_TRACKER_SUGGEST_COMMENT_MARKER)); - if (batch.length < 100) break; - } - if (alreadyPosted) return { suggested: false }; + if (await hasExistingProjectTrackerBotComment(ctx, pullNumber, PROJECT_TRACKER_SUGGEST_COMMENT_MARKER)) return { suggested: false }; // Linear API keys are workspace-scoped, so project/milestone names may be internal even when the GitHub // repository is public. Keep the public suggestion useful without echoing Linear tracker titles (#3290). @@ -424,6 +514,7 @@ export async function maybeSuggestMilestoneMatchForPr(args: { prUrl: string | null | undefined; mode: ProjectMilestoneMatchModeInput; backend: ProjectMilestoneMatchBackendInput; + autoApplyThreshold?: number | null | undefined; deliveryId: string; eventName: string; action: string | undefined; @@ -432,14 +523,32 @@ export async function maybeSuggestMilestoneMatchForPr(args: { if (!args.installationId) return; if (args.prState !== "open") return; if (!args.mode || args.mode === "off") return; - await maybeSuggestProjectOrMilestoneMatch( - { env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }, - args.pullNumber, - args.prTitle, - args.prBody, - args.backend, - args.prUrl ?? "", - ).catch((error) => { + const ctx = { env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }; + const prUrl = args.prUrl ?? ""; + if (args.mode === "auto") { + await maybeAutoApplyProjectOrMilestoneMatch( + ctx, + args.pullNumber, + args.prTitle, + args.prBody, + args.backend, + prUrl, + resolveAutoProjectMilestoneMatchThreshold(args.autoApplyThreshold), + ).catch((error) => { + console.error( + JSON.stringify({ + level: "warn", + event: "milestone_auto_apply_failed", + deliveryId: args.deliveryId, + repoFullName: args.repoFullName, + pullNumber: args.pullNumber, + error: errorMessage(error), + }), + ); + }); + return; + } + await maybeSuggestProjectOrMilestoneMatch(ctx, args.pullNumber, args.prTitle, args.prBody, args.backend, prUrl).catch((error) => { console.error( JSON.stringify({ level: "warn", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index b7c1032718..cfcf267910 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -638,6 +638,7 @@ export const RepositorySettingsSchema = z reviewCheckMode: z.enum(["required", "visible", "disabled"]), autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(), autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(), + autoProjectMilestoneMatchThreshold: z.number().int().min(0).max(100).nullable().optional(), gatePack: z.enum(["gittensor", "oss-anti-slop"]), linkedIssueGateMode: z.enum(["off", "advisory", "block"]), duplicatePrGateMode: z.enum(["off", "advisory", "block"]), @@ -816,6 +817,7 @@ export const RepoSettingsPreviewSchema = z reviewCheckMode: z.enum(["required", "visible", "disabled"]), autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(), autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(), + autoProjectMilestoneMatchThreshold: z.number().int().min(0).max(100).nullable().optional(), gatePack: z.enum(["gittensor", "oss-anti-slop"]), linkedIssueGateMode: z.enum(["off", "advisory", "block"]), duplicatePrGateMode: z.enum(["off", "advisory", "block"]), @@ -1198,6 +1200,7 @@ export const InstallationRepairSchema = z reviewCheckMode: z.enum(["required", "visible", "disabled"]), autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(), autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(), + autoProjectMilestoneMatchThreshold: z.number().int().min(0).max(100).nullable().optional(), autoLabelEnabled: z.boolean(), }), }), @@ -2113,6 +2116,7 @@ export const RegistrationReadinessSchema = z reviewCheckMode: z.enum(["required", "visible", "disabled"]), autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(), autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(), + autoProjectMilestoneMatchThreshold: z.number().int().min(0).max(100).nullable().optional(), quietByDefault: z.boolean(), behavior: z.string(), warnings: z.array(z.string()), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e8737e9934..eed88876db 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -6145,6 +6145,7 @@ async function processGitHubWebhook( prUrl: pr.htmlUrl, mode: settings.autoProjectMilestoneMatch, backend: settings.autoProjectMilestoneMatchBackend, + autoApplyThreshold: settings.autoProjectMilestoneMatchThreshold, deliveryId, eventName, action: payload.action, diff --git a/src/types.ts b/src/types.ts index 6692e73cb8..8783095ff6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -617,12 +617,10 @@ export type CopycatGateMode = "off" | "warn" | "label" | "block"; * cannot do this on the operator's behalf -- it is a GitHub branch-protection setting). */ export type ReviewCheckMode = "required" | "visible" | "disabled"; -/** Auto-project/milestone matching (#3183): detects when a PR is likely part of an open GitHub Milestone even - * with no closing-keyword issue link, and posts a bot-comment suggestion. `"off"` (default) runs no matching - * at all; `"suggest"` matches and posts a single advisory comment, never mutating the PR; `"auto"` is accepted - * by config today but behaves identically to `"suggest"` until #3185 wires real milestone attachment -- no - * attach/auto-apply code exists yet, so treating it as inert-but-silent would be a worse failure mode than - * degrading to the safe, visible suggest behavior. */ +/** Auto-project/milestone matching (#3183/#3185): detects when a PR is likely part of an open GitHub Milestone + * or Project even with no closing-keyword issue link. `"off"` (default) runs no matching at all; `"suggest"` + * matches and posts a single advisory comment, never mutating the PR; `"auto"` matches and best-effort attaches + * via the configured backend when the match clears {@link RepositorySettings.autoProjectMilestoneMatchThreshold}. */ export type ProjectMilestoneMatchMode = "off" | "suggest" | "auto"; /** Which backend {@link ProjectMilestoneMatchMode} matches against (#3186). `"github"` (default) uses the @@ -702,6 +700,9 @@ export type RepositorySettings = { * Always populated by the DB layer (default `"github"`); optional so existing settings fixtures/callers need * not be touched. */ autoProjectMilestoneMatchBackend?: ProjectMilestoneMatchBackend | undefined; + /** Fuzzy-match confidence floor (0-100) for {@link ProjectMilestoneMatchMode} `"auto"` (#3185). Native/confirmed + * links always auto-apply regardless. NULL/absent = built-in default (65, the same bar suggest-mode uses). */ + autoProjectMilestoneMatchThreshold?: number | null | undefined; /** Policy pack the gate evaluates under (#692). Default `gittensor` (registry-aware; threads confirmed * status for scoring only). `oss-anti-slop` runs the deterministic rules against any author on any repo. */ gatePack: GatePolicyPack; diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 65fafcedc7..eac5f253c6 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -307,6 +307,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { reviewCheckMode: "checkMode:", // `gate.checkMode` above documents the same underlying knob. autoProjectMilestoneMatch: "autoProjectMilestoneMatch:", autoProjectMilestoneMatchBackend: "autoProjectMilestoneMatchBackend:", + autoProjectMilestoneMatchThreshold: "autoProjectMilestoneMatchThreshold:", closeOwnerAuthors: "closeOwnerAuthors:", autoLabelEnabled: "autoLabelEnabled:", typeLabelsEnabled: "typeLabelsEnabled:", @@ -2469,6 +2470,24 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = }); }); + describe("autoProjectMilestoneMatchThreshold precedence (#3185)", () => { + it("parses settings.autoProjectMilestoneMatchThreshold and drops an invalid value with a warning", () => { + const m = parseFocusManifest({ settings: { autoProjectMilestoneMatchThreshold: 80 } }); + expect(m.settings.autoProjectMilestoneMatchThreshold).toBe(80); + const invalid = parseFocusManifest({ settings: { autoProjectMilestoneMatchThreshold: "high" as never } }); + expect(invalid.settings.autoProjectMilestoneMatchThreshold).toBeUndefined(); + expect(invalid.warnings.some((w) => /settings\.autoProjectMilestoneMatchThreshold/.test(w))).toBe(true); + }); + + it("settings.autoProjectMilestoneMatchThreshold overlays the DB value when set", () => { + const overridden = resolveEffectiveSettings( + { autoProjectMilestoneMatchThreshold: null } as unknown as RepositorySettings, + parseFocusManifest({ settings: { autoProjectMilestoneMatchThreshold: 90 } }), + ); + expect(overridden.autoProjectMilestoneMatchThreshold).toBe(90); + }); + }); + it("an EXPLICIT yml null force-clears a DB-configured cap, distinct from an omitted key (regression, gate finding on #2467)", () => { // Omitted key preserves the DB value (already covered above); an explicit `null` must ALSO be able to // override a DB-configured cap back to "no cap" — the documented `yml > DB > null` precedence otherwise diff --git a/test/unit/project-tracker-adapter.test.ts b/test/unit/project-tracker-adapter.test.ts index dda2006352..feb7b6a576 100644 --- a/test/unit/project-tracker-adapter.test.ts +++ b/test/unit/project-tracker-adapter.test.ts @@ -1,17 +1,28 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; import { + DEFAULT_AUTO_PROJECT_MILESTONE_MATCH_THRESHOLD, GitHubMilestonesAdapter, GitHubProjectsAdapter, + PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER, PROJECT_TRACKER_SUGGEST_COMMENT_MARKER, + maybeAutoApplyProjectOrMilestoneMatch, maybeSuggestMilestoneMatchForPr, maybeSuggestProjectOrMilestoneMatch, matchOpenTrackerItems, + matchPassesAutoApplyThreshold, + filterMatchesForAutoApply, + resolveAutoProjectMilestoneMatchThreshold, resolveProjectV2Fields, type ProjectTrackerRef, } from "../../src/integrations/project-tracker-adapter"; +import { upsertRepositoryLinearKey } from "../../src/db/repositories"; +import { LinearAdapter } from "../../src/integrations/linear-adapter"; import { createTestEnv } from "../helpers/d1"; +const LINEAR_TEST_SECRET = "example-unit-test-encryption-secret-32-bytes-long"; +const LINEAR_PR_URL = "https://github.com/JSONbored/gittensory/pull/4"; + function generateRsaPrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); @@ -721,6 +732,375 @@ describe("maybeSuggestProjectOrMilestoneMatch (#3183/#3184)", () => { }); }); +describe("auto-apply threshold helpers (#3185)", () => { + it("defaults to 65 when unset or malformed", () => { + expect(resolveAutoProjectMilestoneMatchThreshold(null)).toBe(DEFAULT_AUTO_PROJECT_MILESTONE_MATCH_THRESHOLD); + expect(resolveAutoProjectMilestoneMatchThreshold(undefined)).toBe(DEFAULT_AUTO_PROJECT_MILESTONE_MATCH_THRESHOLD); + expect(resolveAutoProjectMilestoneMatchThreshold(Number.NaN)).toBe(DEFAULT_AUTO_PROJECT_MILESTONE_MATCH_THRESHOLD); + }); + + it("clamps configured thresholds to 0-100", () => { + expect(resolveAutoProjectMilestoneMatchThreshold(80.4)).toBe(80); + expect(resolveAutoProjectMilestoneMatchThreshold(150)).toBe(100); + }); + + it("always allows native links and gates fuzzy matches on the threshold", () => { + expect(matchPassesAutoApplyThreshold({ item: { id: "1", title: "x" }, source: "native", score: 1, shared: 0 }, 100)).toBe(true); + expect(matchPassesAutoApplyThreshold({ item: { id: "1", title: "x" }, source: "fuzzy", score: 0.64, shared: 3 }, 65)).toBe(false); + expect(matchPassesAutoApplyThreshold({ item: { id: "1", title: "x" }, source: "fuzzy", score: 0.66, shared: 3 }, 65)).toBe(true); + }); + + it("filters fuzzy matches below the configured threshold before attach", () => { + const matches = { + milestone: { item: { id: "14", title: "Self-host reliability roadmap" }, source: "fuzzy" as const, score: 0.66, shared: 3 }, + project: { item: { id: "PVT_1", title: "Self-host reliability roadmap" }, source: "fuzzy" as const, score: 0.66, shared: 3 }, + }; + expect(filterMatchesForAutoApply(matches, 65).milestone).not.toBeNull(); + expect(filterMatchesForAutoApply(matches, 65).project).not.toBeNull(); + expect(filterMatchesForAutoApply(matches, 67).milestone).toBeNull(); + expect(filterMatchesForAutoApply(matches, 67).project).toBeNull(); + expect(filterMatchesForAutoApply({ ...matches, milestone: { ...matches.milestone, source: "native" } }, 100).milestone).not.toBeNull(); + expect(filterMatchesForAutoApply({ milestone: null, project: matches.project }, 67).project).toBeNull(); + }); +}); + +describe("maybeAutoApplyProjectOrMilestoneMatch (#3185)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("PATCHes the milestone and posts a confirmation comment when a match clears the threshold", async () => { + let patched = false; + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4") && method === "PATCH") { + patched = true; + return Response.json({ number: 4, milestone: { number: 14 } }); + } + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "github", + "https://github.com/JSONbored/gittensory/pull/4", + 65, + ); + expect(result).toEqual({ applied: true }); + expect(patched).toBe(true); + expect(posted).toHaveLength(1); + expect(posted[0]).toContain(PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER); + expect(posted[0]).toContain("milestone"); + }); + + it("does not throw when attach fails (best-effort, gate never blocked)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4") && method === "PATCH") return new Response("boom", { status: 500 }); + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await expect( + maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "github", + "https://github.com/JSONbored/gittensory/pull/4", + 65, + ), + ).resolves.toEqual({ applied: false }); + }); + + it("auto-applies a project match when no milestone matches", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([]); + if (url.includes("/pulls/4") && method === "GET") return Response.json({ number: 4, node_id: "PR_kwABC" }); + if (url.endsWith("/graphql")) { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; + if (body.query?.includes("addProjectV2ItemById")) { + return Response.json({ data: { addProjectV2ItemById: { item: { id: "ITEM_1" } } } }); + } + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap", closed: false, public: true }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "github", + "https://github.com/some-org/gittensory/pull/4", + 65, + ); + expect(result).toEqual({ applied: true }); + expect(posted).toHaveLength(1); + expect(posted[0]).toContain(PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER); + expect(posted[0]).toContain("Added this PR to the"); + expect(posted[0]).toContain("project"); + expect(posted[0]).not.toContain("Attached this PR to the"); + }); + + it("auto-applies both milestone and project when each independently matches", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.includes("/pulls/4") && method === "GET") return Response.json({ number: 4, node_id: "PR_kwABC" }); + if (url.endsWith("/graphql")) { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; + if (body.query?.includes("addProjectV2ItemById")) { + return Response.json({ data: { addProjectV2ItemById: { item: { id: "ITEM_1" } } } }); + } + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap", closed: false, public: true }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4") && method === "PATCH") return Response.json({ number: 4, milestone: { number: 14 } }); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "github", + "https://github.com/some-org/gittensory/pull/4", + 65, + ); + expect(result).toEqual({ applied: true }); + expect(posted[0]).toContain("milestone"); + expect(posted[0]).toContain("project"); + }); + + it("returns applied:false when the configured threshold blocks the fuzzy match", async () => { + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + posted = true; + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "unrelated typo fix", + null, + "github", + "https://github.com/JSONbored/gittensory/pull/4", + 100, + ); + expect(result).toEqual({ applied: false }); + expect(posted).toBe(false); + }); + + it("is idempotent when the auto-apply marker comment already exists", async () => { + let patched = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments") && method === "GET") { + return Response.json([{ body: PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER, user: { type: "Bot", login: "gittensory[bot]" } }]); + } + if (url.includes("/issues/4") && method === "PATCH") { + patched = true; + return Response.json({ number: 4, milestone: { number: 14 } }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "github", + "https://github.com/JSONbored/gittensory/pull/4", + 65, + ); + expect(result).toEqual({ applied: false }); + expect(patched).toBe(false); + }); + + it("swallows a project attach failure and still succeeds when the milestone attach worked", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.includes("/pulls/4") && method === "GET") return Response.json({ number: 4, node_id: "PR_kwABC" }); + if (url.endsWith("/graphql")) { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; + if (body.query?.includes("addProjectV2ItemById")) return new Response("boom", { status: 500 }); + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap", closed: false, public: true }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4") && method === "PATCH") return Response.json({ number: 4, milestone: { number: 14 } }); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "github", + "https://github.com/some-org/gittensory/pull/4", + 65, + ); + expect(result).toEqual({ applied: true }); + expect(posted[0]).toContain("milestone"); + expect(posted[0]).not.toContain("project"); + }); + + it("routes linear backends through Linear adapters (inert attach, applied:false)", async () => { + const env = createTestEnv({ + TOKEN_ENCRYPTION_SECRET: LINEAR_TEST_SECRET, + GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), + GITHUB_APP_SLUG: "gittensory", + }); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url === "https://api.linear.app/graphql") { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("attachmentsForURL")) { + return Response.json({ data: { attachmentsForURL: { nodes: [{ issue: { project: { id: "proj-1", name: "Self-host reliability roadmap" }, projectMilestone: { id: "mile-1", name: "Stealth Launch M3" } } }] } } }); + } + return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + posted = true; + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "any title", + null, + "linear", + LINEAR_PR_URL, + 65, + ); + expect(result).toEqual({ applied: false }); + expect(posted).toBe(false); + }); + + it("redacts tracker titles in the auto-apply confirmation comment on linear backends", async () => { + vi.spyOn(LinearAdapter.prototype, "attachToMilestone").mockResolvedValue({ attached: true }); + vi.spyOn(LinearAdapter.prototype, "attachToProject").mockResolvedValue({ attached: true }); + const env = createTestEnv({ + TOKEN_ENCRYPTION_SECRET: LINEAR_TEST_SECRET, + GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), + GITHUB_APP_SLUG: "gittensory", + }); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url === "https://api.linear.app/graphql") { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("attachmentsForURL")) { + return Response.json({ data: { attachmentsForURL: { nodes: [{ issue: { project: { id: "proj-1", name: "Self-host reliability roadmap" }, projectMilestone: { id: "mile-1", name: "Stealth Launch M3" } } }] } } }); + } + return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const result = await maybeAutoApplyProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "any title", + null, + "linear", + LINEAR_PR_URL, + 65, + ); + expect(result).toEqual({ applied: true }); + expect(posted[0]).toContain("Attached this PR to the milestone."); + expect(posted[0]).toContain("Added this PR to the project."); + expect(posted[0]).not.toContain("Self-host reliability roadmap"); + expect(posted[0]).not.toContain("Stealth Launch M3"); + }); +}); + describe("maybeSuggestMilestoneMatchForPr (#3183 webhook-level gating)", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -847,24 +1227,45 @@ describe("maybeSuggestMilestoneMatchForPr (#3183 webhook-level gating)", () => { expect(milestonesFetched).toBe(true); }); - it("runs the match when mode is auto (identical to suggest until #3185)", async () => { - let milestonesFetched = false; - vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + it("auto-applies when mode is auto and every gate passes", async () => { + let patched = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); + const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/milestones")) { - milestonesFetched = true; - return Response.json([]); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4") && method === "PATCH") { + patched = true; + return Response.json({ number: 4, milestone: { number: 14 } }); } + if (url.includes("/issues/4/comments") && method === "POST") return Response.json({ id: 1 }); + return new Response("unexpected", { status: 500 }); + }); + await maybeSuggestMilestoneMatchForPr(baseArgs({ mode: "auto", autoApplyThreshold: 65 })); + expect(patched).toBe(true); + }); + + it("logs auto-apply failures instead of throwing", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments") && method === "GET") return new Response("boom", { status: 500 }); return new Response("unexpected", { status: 500 }); }); - await maybeSuggestMilestoneMatchForPr(baseArgs({ mode: "auto" })); - expect(milestonesFetched).toBe(true); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect(maybeSuggestMilestoneMatchForPr(baseArgs({ mode: "auto", deliveryId: "delivery-auto" }))).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalledTimes(1); + const logged = JSON.parse(String(consoleError.mock.calls[0]?.[0])); + expect(logged).toMatchObject({ event: "milestone_auto_apply_failed", deliveryId: "delivery-auto" }); + consoleError.mockRestore(); }); - it("logs a failure instead of throwing", async () => { - // The milestone/project lookups themselves are fail-open (#3183/#3184 fail-open fix) and can no longer + it("logs a suggest failure instead of throwing", async () => { // reach this outer catch -- so this test drives a real match (milestone lookup succeeds) and fails the // still-unprotected comment-marker search instead, to prove the outer best-effort catch is still live. vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { diff --git a/test/unit/repository-settings-project-milestone-match.test.ts b/test/unit/repository-settings-project-milestone-match.test.ts index 29e1261d14..96dfcbaf24 100644 --- a/test/unit/repository-settings-project-milestone-match.test.ts +++ b/test/unit/repository-settings-project-milestone-match.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest"; import { getRepositorySettings, upsertRepositorySettings } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; -// #3183: autoProjectMilestoneMatch is the tri-state config for auto-project/milestone matching, mirroring the -// reviewCheckMode template (#2852). "off" is the conservative, opt-in default; "suggest" and "auto" currently -// behave identically (post a comment) since real milestone attachment isn't wired until #3185. +// #3183/#3185: autoProjectMilestoneMatch is the tri-state config for auto-project/milestone matching. describe("repository_settings: autoProjectMilestoneMatch default + round-trip (#3183)", () => { it("getRepositorySettings returns off for a repo with no DB row at all (conservative, opt-in default)", async () => { const env = createTestEnv(); @@ -47,6 +45,30 @@ describe("repository_settings: autoProjectMilestoneMatch default + round-trip (# }); }); +describe("repository_settings: autoProjectMilestoneMatchThreshold default + round-trip (#3185)", () => { + it("getRepositorySettings returns null for a repo with no DB row (built-in default 65 at apply time)", async () => { + const env = createTestEnv(); + const settings = await getRepositorySettings(env, "acme/brand-new-repo"); + expect(settings.autoProjectMilestoneMatchThreshold).toBeNull(); + }); + + it("an explicit threshold round-trips through a re-upsert", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "acme/threshold", autoProjectMilestoneMatchThreshold: 80 }); + const settings = await getRepositorySettings(env, "acme/threshold"); + expect(settings.autoProjectMilestoneMatchThreshold).toBe(80); + await upsertRepositorySettings(env, { ...settings, repoFullName: "acme/threshold" }); + expect((await getRepositorySettings(env, "acme/threshold")).autoProjectMilestoneMatchThreshold).toBe(80); + }); + + it("clamps malformed persisted values on read", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "acme/clamped" }); + await env.DB.prepare("UPDATE repository_settings SET auto_project_milestone_match_threshold = ? WHERE repo_full_name = ?").bind(150, "acme/clamped").run(); + expect((await getRepositorySettings(env, "acme/clamped")).autoProjectMilestoneMatchThreshold).toBe(100); + }); +}); + // #3186: autoProjectMilestoneMatchBackend selects which tracker the match/attach logic queries -- "github" // (Milestones + Projects v2, the conservative default) or "linear" (an opted-in per-repo API key). describe("repository_settings: autoProjectMilestoneMatchBackend default + round-trip (#3186)", () => {