Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3811,6 +3811,12 @@
"items": {
"type": "string"
}
},
"autoProjectMilestoneMatchThreshold": {
"type": "integer",
"nullable": true,
"minimum": 0,
"maximum": 100
}
},
"required": [
Expand Down Expand Up @@ -9522,6 +9528,12 @@
},
"agentGlobalFreezeOverride": {
"type": "boolean"
},
"autoProjectMilestoneMatchThreshold": {
"type": "integer",
"nullable": true,
"minimum": 0,
"maximum": 100
}
},
"required": [
Expand Down Expand Up @@ -9647,6 +9659,12 @@
},
"autoLabelEnabled": {
"type": "boolean"
},
"autoProjectMilestoneMatchThreshold": {
"type": "integer",
"nullable": true,
"minimum": 0,
"maximum": 100
}
},
"required": [
Expand Down Expand Up @@ -10259,6 +10277,12 @@
"defaultAllowed",
"commandOverrides"
]
},
"autoProjectMilestoneMatchThreshold": {
"type": "integer",
"nullable": true,
"minimum": 0,
"maximum": 100
}
},
"required": [
Expand Down
2 changes: 2 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions migrations/0139_auto_project_milestone_match_threshold.sql
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions packages/gittensory-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export type FocusManifestSettings = Partial<
| "reviewCheckMode"
| "autoProjectMilestoneMatch"
| "autoProjectMilestoneMatchBackend"
| "autoProjectMilestoneMatchThreshold"
| "linkedIssueGateMode"
| "duplicatePrGateMode"
| "selfAuthoredLinkedIssueGateMode"
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -719,6 +721,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewCheckMode: settings.reviewCheckMode ?? (settings.gateCheckMode === "enabled" ? "required" : "disabled"),
autoProjectMilestoneMatch: settings.autoProjectMilestoneMatch ?? "off",
autoProjectMilestoneMatchBackend: settings.autoProjectMilestoneMatchBackend ?? "github",
autoProjectMilestoneMatchThreshold: normalizeQualityGateMinScore(settings.autoProjectMilestoneMatchThreshold),
gatePack: parseGatePack(settings.gatePack),
linkedIssueGateMode: settings.linkedIssueGateMode ?? "advisory",
duplicatePrGateMode: settings.duplicatePrGateMode ?? "block",
Expand Down Expand Up @@ -800,6 +803,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewCheckMode: resolved.reviewCheckMode,
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: resolved.autoProjectMilestoneMatchBackend,
autoProjectMilestoneMatchThreshold: resolved.autoProjectMilestoneMatchThreshold,
gatePack: resolved.gatePack,
linkedIssueGateMode: resolved.linkedIssueGateMode,
duplicatePrGateMode: resolved.duplicatePrGateMode,
Expand Down Expand Up @@ -887,6 +891,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewCheckMode: resolved.reviewCheckMode,
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: resolved.autoProjectMilestoneMatchBackend,
autoProjectMilestoneMatchThreshold: resolved.autoProjectMilestoneMatchThreshold,
gatePack: resolved.gatePack,
linkedIssueGateMode: resolved.linkedIssueGateMode,
duplicatePrGateMode: resolved.duplicatePrGateMode,
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const repositorySettings = sqliteTable("repository_settings", {
reviewCheckMode: text("review_check_mode").notNull().default("disabled"),
projectMilestoneMatchMode: text("project_milestone_match_mode").notNull().default("off"),
autoProjectMilestoneMatchBackend: text("auto_project_milestone_match_backend").notNull().default("github"),
// Fuzzy-match confidence floor (0-100) for auto-apply mode (#3185). NULL = built-in default (65).
autoProjectMilestoneMatchThreshold: integer("auto_project_milestone_match_threshold"),
gatePack: text("gate_pack").notNull().default("gittensor"),
// Missing a linked issue is advisory-only by default -- issues aren't always available, so it only
// blocks when a repo explicitly opts in (linkedIssueGateMode: "block" or the requireLinkedIssue toggle;
Expand Down
167 changes: 138 additions & 29 deletions src/integrations/project-tracker-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,21 @@ export class GitHubProjectsAdapter implements ProjectTrackerAdapter {
const TRACKER_MATCH_MIN_SCORE = 0.65;
const TRACKER_MATCH_MIN_SHARED = 3;

/** Default auto-apply confidence floor (0-100), matching {@link TRACKER_MATCH_MIN_SCORE}. Suggest-mode (#3183/#3184)
* uses the same fuzzy bar; production suggest-mode telemetry showed ~0% false-positive rate at this threshold
* before auto-apply shipped (#3185). */
export const DEFAULT_AUTO_PROJECT_MILESTONE_MATCH_THRESHOLD = 65;

export function resolveAutoProjectMilestoneMatchThreshold(configured: number | null | undefined): number {
if (typeof configured !== "number" || !Number.isFinite(configured)) return DEFAULT_AUTO_PROJECT_MILESTONE_MATCH_THRESHOLD;
return Math.max(0, Math.min(100, Math.round(configured)));
}

export function matchPassesAutoApplyThreshold(match: ProjectTrackerMatch, thresholdPercent: number): boolean {
if (match.source === "native") return true;
return Math.round(match.score * 100) >= thresholdPercent;
}

export type ProjectTrackerMatch = {
item: ProjectTrackerRef;
// "native" (#3186): a CONFIRMED link (e.g. Linear's own GitHub integration already linked this PR), not a
Expand Down Expand Up @@ -289,6 +304,7 @@ export function matchOpenTrackerItems(prTitle: string, prBody: string | null | u
}

export const PROJECT_TRACKER_SUGGEST_COMMENT_MARKER = "<!-- gittensory-milestone-suggest:v1 -->";
export const PROJECT_TRACKER_AUTO_APPLY_COMMENT_MARKER = "<!-- gittensory-milestone-auto-apply:v1 -->";

/** 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
Expand All @@ -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));
Expand Down Expand Up @@ -362,12 +391,91 @@ async function resolveTrackerMatches(ctx: ProjectTrackerContext, backend: Projec
};
}

async function hasExistingProjectTrackerBotComment(ctx: ProjectTrackerContext, pullNumber: number, marker: string): Promise<boolean> {
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,
Expand All @@ -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).
Expand All @@ -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;
Expand All @@ -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",
Expand Down
Loading
Loading