Skip to content

Commit d48c681

Browse files
authored
feat(agent): auto-match PRs to open GitHub Milestones in suggest-mode (#3183) (#3256)
* feat(agent): auto-match PRs to open GitHub Milestones in suggest-mode (#3183) Adds a ProjectTrackerAdapter interface (src/integrations/project-tracker-adapter.ts) with a GitHubMilestonesAdapter implementation, so #3184 (Projects v2) and #3186 (Linear) slot in without reshaping the matching/suggestion logic. Matching reuses the existing tokenize/termOverlap primitives from the duplicate-PR collision detector (now exported), at a stricter threshold than that gate since misattaching tracked progress is worse than an advisory-only duplicate note. New tri-state autoProjectMilestoneMatch setting (off/suggest/auto, default off), wired through the full config-as-code chain: migration, Drizzle schema, DB resolver, OpenAPI, and .gittensory.yml parity, mirroring the reviewCheckMode template. "auto" behaves like "suggest" until #3185 wires real milestone attachment. Hooked into the main PR-webhook path right after the existing linked-issue evidence gathering, entirely independent of the gate/disposition -- a missed or wrong match never affects CI or merge. * fix(agent): move milestone-suggest failure logging out of the webhook handler The processors.ts call site passed an inline onError closure whose console.error body lived in the huge webhook file and was only reachable through a full pipeline test, leaving it uncovered (codecov/patch: 50% on that file). maybeSuggestMilestoneMatchForPr now takes deliveryId directly and does its own logging internally, where it already has dedicated, isolated test coverage -- the processors.ts call site is now a plain data object literal with no logic of its own. * fix(agent): paginate milestone/comment listing and harden the suggest comment Gate review found a real gap: GitHub milestones and issue comments both paginate at 100 per page, but listOpenMilestones and the suggest comment's marker search only ever read page 1 -- a repo with >100 open milestones, or a busy PR with >100 comments, could silently miss a match or double-post. Both now page up to a bounded limit (mirroring the same pattern already used in src/github/comments.ts). Also: attachToMilestone now rejects a non-positive-integer milestoneId instead of sending NaN to GitHub, and the suggestion comment code-formats the milestone title (stripping literal backticks) so a maintainer-authored title containing @mentions or markdown emphasis can't leak into the rendered comment.
1 parent 15e308c commit d48c681

13 files changed

Lines changed: 834 additions & 3 deletions

apps/gittensory-ui/public/openapi.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3638,6 +3638,14 @@
36383638
"visible",
36393639
"disabled"
36403640
]
3641+
},
3642+
"autoProjectMilestoneMatch": {
3643+
"type": "string",
3644+
"enum": [
3645+
"off",
3646+
"suggest",
3647+
"auto"
3648+
]
36413649
}
36423650
},
36433651
"required": [
@@ -9175,6 +9183,14 @@
91759183
"verifyBeforeClose",
91769184
"closeDelaySeconds"
91779185
]
9186+
},
9187+
"autoProjectMilestoneMatch": {
9188+
"type": "string",
9189+
"enum": [
9190+
"off",
9191+
"suggest",
9192+
"auto"
9193+
]
91789194
}
91799195
},
91809196
"required": [
@@ -9284,6 +9300,14 @@
92849300
"visible",
92859301
"disabled"
92869302
]
9303+
},
9304+
"autoProjectMilestoneMatch": {
9305+
"type": "string",
9306+
"enum": [
9307+
"off",
9308+
"suggest",
9309+
"auto"
9310+
]
92879311
}
92889312
},
92899313
"required": [
@@ -9863,6 +9887,14 @@
98639887
"visible",
98649888
"disabled"
98659889
]
9890+
},
9891+
"autoProjectMilestoneMatch": {
9892+
"type": "string",
9893+
"enum": [
9894+
"off",
9895+
"suggest",
9896+
"auto"
9897+
]
98669898
}
98679899
},
98689900
"required": [
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Auto-project/milestone matching (#3183): detects when a PR is likely part of an open GitHub Milestone even
2+
-- with no closing-keyword issue link, and posts a bot-comment suggestion in "suggest" mode. Defaults to 'off'
3+
-- (opt-in) -- no existing repo should start getting suggestion comments without an explicit choice.
4+
ALTER TABLE repository_settings ADD COLUMN project_milestone_match_mode TEXT NOT NULL DEFAULT 'off';

src/db/repositories.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
480480
checkRunDetailLevel: "minimal",
481481
gateCheckMode: "off",
482482
reviewCheckMode: "disabled",
483+
autoProjectMilestoneMatch: "off",
483484
gatePack: "gittensor",
484485
linkedIssueGateMode: "advisory",
485486
duplicatePrGateMode: "block",
@@ -550,6 +551,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
550551
checkRunDetailLevel: parseCheckRunDetailLevel(row.checkRunDetailLevel),
551552
gateCheckMode: parseGateCheckMode(row.gateCheckMode),
552553
reviewCheckMode: parseReviewCheckMode(row.reviewCheckMode),
554+
autoProjectMilestoneMatch: parseProjectMilestoneMatchMode(row.projectMilestoneMatchMode),
553555
gatePack: parseGatePack(row.gatePack),
554556
linkedIssueGateMode: parseGateRuleMode(row.linkedIssueGateMode),
555557
duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode),
@@ -663,6 +665,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
663665
// calling this, so `settings.reviewCheckMode` is never actually undefined for that path -- this fallback
664666
// only fires for callers that never cared about reviewCheckMode at all.
665667
reviewCheckMode: settings.reviewCheckMode ?? (settings.gateCheckMode === "enabled" ? "required" : "disabled"),
668+
autoProjectMilestoneMatch: settings.autoProjectMilestoneMatch ?? "off",
666669
gatePack: parseGatePack(settings.gatePack),
667670
linkedIssueGateMode: settings.linkedIssueGateMode ?? "advisory",
668671
duplicatePrGateMode: settings.duplicatePrGateMode ?? "block",
@@ -734,6 +737,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
734737
checkRunDetailLevel: resolved.checkRunDetailLevel,
735738
gateCheckMode: resolved.gateCheckMode,
736739
reviewCheckMode: resolved.reviewCheckMode,
740+
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
737741
gatePack: resolved.gatePack,
738742
linkedIssueGateMode: resolved.linkedIssueGateMode,
739743
duplicatePrGateMode: resolved.duplicatePrGateMode,
@@ -804,6 +808,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
804808
checkRunDetailLevel: resolved.checkRunDetailLevel,
805809
gateCheckMode: resolved.gateCheckMode,
806810
reviewCheckMode: resolved.reviewCheckMode,
811+
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
807812
gatePack: resolved.gatePack,
808813
linkedIssueGateMode: resolved.linkedIssueGateMode,
809814
duplicatePrGateMode: resolved.duplicatePrGateMode,
@@ -6247,6 +6252,10 @@ function parseReviewCheckMode(value: string): RepositorySettings["reviewCheckMod
62476252
return value === "required" || value === "visible" ? value : "disabled";
62486253
}
62496254

6255+
function parseProjectMilestoneMatchMode(value: string): RepositorySettings["autoProjectMilestoneMatch"] {
6256+
return value === "suggest" || value === "auto" ? value : "off";
6257+
}
6258+
62506259
function parseGatePack(value: string | null | undefined): RepositorySettings["gatePack"] {
62516260
return value === "oss-anti-slop" ? "oss-anti-slop" : "gittensor";
62526261
}

src/db/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
5252
checkRunDetailLevel: text("check_run_detail_level").notNull().default("minimal"),
5353
gateCheckMode: text("gate_check_mode").notNull().default("off"),
5454
reviewCheckMode: text("review_check_mode").notNull().default("disabled"),
55+
projectMilestoneMatchMode: text("project_milestone_match_mode").notNull().default("off"),
5556
gatePack: text("gate_pack").notNull().default("gittensor"),
5657
// Missing a linked issue is advisory-only by default -- issues aren't always available, so it only
5758
// blocks when a repo explicitly opts in (linkedIssueGateMode: "block" or the requireLinkedIssue toggle;
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { createInstallationToken } from "../github/app";
2+
import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "../github/client";
3+
import { createIssueComment } from "../github/pr-actions";
4+
import { termOverlap, tokenize, type CollisionTerms } from "../signals/engine";
5+
import { errorMessage } from "../utils/json";
6+
7+
/** Repo-scoped context shared by every ProjectTrackerAdapter call (#3183). */
8+
export type ProjectTrackerContext = {
9+
env: Env;
10+
installationId: number;
11+
repoFullName: string;
12+
};
13+
14+
/** A single open Project or Milestone, normalized to a string `id` regardless of the backend's native ID shape
15+
* (a GitHub Milestone's REST `number` vs. a GitHub Projects v2 GraphQL node ID vs. a Linear UUID). */
16+
export type ProjectTrackerRef = {
17+
id: string;
18+
title: string;
19+
};
20+
21+
export type ProjectTrackerAttachResult = {
22+
attached: boolean;
23+
};
24+
25+
/**
26+
* Pluggable project/milestone tracker backend (#3183). `GitHubMilestonesAdapter` below implements the
27+
* milestone half now; Projects v2 (#3184) and a Linear backend (#3186) implement the same interface without
28+
* reshaping the matching/suggestion logic that calls it.
29+
*/
30+
export interface ProjectTrackerAdapter {
31+
listOpenProjects(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]>;
32+
listOpenMilestones(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]>;
33+
attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise<ProjectTrackerAttachResult>;
34+
attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise<ProjectTrackerAttachResult>;
35+
}
36+
37+
function parseRepoFullName(repoFullName: string): { owner: string; repo: string } {
38+
const parts = repoFullName.split("/");
39+
const owner = parts[0];
40+
const repo = parts[1];
41+
if (parts.length !== 2 || !owner || !repo || /\s/.test(repoFullName)) {
42+
throw new Error(`Invalid repository full name: ${repoFullName}`);
43+
}
44+
return { owner, repo };
45+
}
46+
47+
type GitHubMilestone = {
48+
number: number;
49+
title: string;
50+
};
51+
52+
// Bounded pagination for both the milestone list and the comment-marker search below (mirrors
53+
// src/github/comments.ts's COMMENT_SEARCH_PAGE_LIMIT): 3 pages * 100 = 300 items is generously above any
54+
// realistic open-milestone or PR-comment count, while still bounding worst-case GitHub API calls per PR event.
55+
const GITHUB_LIST_PAGE_LIMIT = 3;
56+
57+
/** A positive-integer milestone/issue number as a string, or null if `value` isn't one. Guards against a
58+
* malformed/forged `milestoneId` reaching GitHub's PATCH as `NaN` or a negative/zero number. */
59+
function parsePositiveIntegerId(value: string): number | null {
60+
const parsed = Number(value);
61+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
62+
}
63+
64+
/** GitHub REST implementation of {@link ProjectTrackerAdapter}. Only the Milestone half is real (#3183) --
65+
* Projects v2 is GraphQL-only and needs a separate `organization_projects` App permission not yet granted, so
66+
* those two methods are inert placeholders until #3184. */
67+
export class GitHubMilestonesAdapter implements ProjectTrackerAdapter {
68+
// Inert placeholder until #3184 (Projects v2 needs GraphQL + a separate App permission).
69+
async listOpenProjects(): Promise<ProjectTrackerRef[]> {
70+
return [];
71+
}
72+
73+
async listOpenMilestones(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
74+
const { owner, repo } = parseRepoFullName(ctx.repoFullName);
75+
const token = await createInstallationToken(ctx.env, ctx.installationId);
76+
const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId));
77+
const milestones: GitHubMilestone[] = [];
78+
for (let page = 1; page <= GITHUB_LIST_PAGE_LIMIT; page += 1) {
79+
const response = await octokit.request("GET /repos/{owner}/{repo}/milestones", {
80+
owner,
81+
repo,
82+
state: "open",
83+
per_page: 100,
84+
page,
85+
});
86+
const batch = response.data as GitHubMilestone[];
87+
milestones.push(...batch);
88+
if (batch.length < 100) break;
89+
}
90+
return milestones.map((milestone) => ({ id: String(milestone.number), title: milestone.title }));
91+
}
92+
93+
// Inert placeholder until #3184 (Projects v2 needs GraphQL + a separate App permission).
94+
async attachToProject(): Promise<ProjectTrackerAttachResult> {
95+
return { attached: false };
96+
}
97+
98+
async attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise<ProjectTrackerAttachResult> {
99+
const milestoneNumber = parsePositiveIntegerId(milestoneId);
100+
if (milestoneNumber === null) return { attached: false };
101+
const { owner, repo } = parseRepoFullName(ctx.repoFullName);
102+
const token = await createInstallationToken(ctx.env, ctx.installationId);
103+
const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId));
104+
await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", {
105+
owner,
106+
repo,
107+
issue_number: pullNumber,
108+
milestone: milestoneNumber,
109+
});
110+
return { attached: true };
111+
}
112+
}
113+
114+
// Stricter than the duplicate-PR collision gate's 0.58/2 (src/signals/engine.ts) -- misattaching a PR to the
115+
// wrong milestone corrupts tracked progress, whereas a missed duplicate just skips an advisory note.
116+
const MILESTONE_MATCH_MIN_SCORE = 0.65;
117+
const MILESTONE_MATCH_MIN_SHARED = 3;
118+
119+
export type ProjectTrackerMatch = {
120+
milestone: ProjectTrackerRef;
121+
score: number;
122+
shared: number;
123+
};
124+
125+
function termsFor(value: string): CollisionTerms {
126+
const terms = new Set(tokenize(value));
127+
return { terms, size: terms.size };
128+
}
129+
130+
/**
131+
* Match PR title+body text against a list of open milestones (#3183), reusing the same tokenize/termOverlap
132+
* heuristic as duplicate-PR collision detection. Returns null on no match -- AND on an ambiguous multi-match
133+
* (more than one milestone clears the threshold): guessing between two plausible milestones is worse than
134+
* suggesting neither, since a maintainer can always link one manually.
135+
*/
136+
export function matchOpenMilestones(prTitle: string, prBody: string | null | undefined, milestones: ProjectTrackerRef[]): ProjectTrackerMatch | null {
137+
if (milestones.length === 0) return null;
138+
const prTerms = termsFor([prTitle, prBody ?? ""].join(" "));
139+
const candidates = milestones
140+
.map((milestone) => ({ milestone, ...termOverlap(prTerms, termsFor(milestone.title)) }))
141+
.filter((candidate) => candidate.score >= MILESTONE_MATCH_MIN_SCORE && candidate.shared >= MILESTONE_MATCH_MIN_SHARED);
142+
if (candidates.length !== 1) return null;
143+
const best = candidates[0];
144+
/* v8 ignore next -- defensive: candidates.length === 1 above guarantees index 0 exists. */
145+
if (!best) return null;
146+
return { milestone: best.milestone, score: best.score, shared: best.shared };
147+
}
148+
149+
export const MILESTONE_SUGGEST_COMMENT_MARKER = "<!-- gittensory-milestone-suggest:v1 -->";
150+
151+
/** Code-formats a maintainer-authored title for safe Markdown embedding: backticks strip any literal backtick
152+
* from the title (so it can't break out of the code span) rather than escaping them, since a broken-out title
153+
* could otherwise re-enable `@mentions` or `**`/`_` emphasis the code span exists to neutralize. */
154+
function codeFormat(title: string): string {
155+
return `\`${title.replace(/`/g, "")}\``;
156+
}
157+
158+
function renderSuggestionComment(match: ProjectTrackerMatch): string {
159+
const confidencePercent = Math.round(match.score * 100);
160+
return [
161+
MILESTONE_SUGGEST_COMMENT_MARKER,
162+
`This PR looks like it's part of the ${codeFormat(match.milestone.title)} milestone (${confidencePercent}% title/body term overlap).`,
163+
"",
164+
"This is an advisory suggestion only — nothing has been attached automatically.",
165+
].join("\n");
166+
}
167+
168+
type IssueComment = {
169+
body?: string | null;
170+
user?: { type?: string; login?: string } | null;
171+
};
172+
173+
/**
174+
* Best-effort, idempotent suggest-mode comment (#3183): posts ONCE per PR (never updates or reposts), so a
175+
* repeated sweep/webhook pass never spams the thread. Never calls attachToMilestone -- suggest mode only ever
176+
* comments; #3185 wires the real attach path behind the "auto" config value.
177+
*/
178+
export async function maybeSuggestMilestoneMatch(ctx: ProjectTrackerContext, pullNumber: number, prTitle: string, prBody: string | null | undefined): Promise<{ suggested: boolean }> {
179+
const adapter = new GitHubMilestonesAdapter();
180+
const milestones = await adapter.listOpenMilestones(ctx);
181+
const match = matchOpenMilestones(prTitle, prBody, milestones);
182+
if (!match) return { suggested: false };
183+
184+
const { owner, repo } = parseRepoFullName(ctx.repoFullName);
185+
const token = await createInstallationToken(ctx.env, ctx.installationId);
186+
const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId));
187+
const botLogin = `${ctx.env.GITHUB_APP_SLUG}[bot]`;
188+
let alreadyPosted = false;
189+
for (let page = 1; page <= GITHUB_LIST_PAGE_LIMIT && !alreadyPosted; page += 1) {
190+
const existing = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", {
191+
owner,
192+
repo,
193+
issue_number: pullNumber,
194+
per_page: 100,
195+
page,
196+
});
197+
const batch = existing.data as IssueComment[];
198+
alreadyPosted = batch.some((comment) => comment.user?.type === "Bot" && comment.user.login?.toLowerCase() === botLogin.toLowerCase() && comment.body?.includes(MILESTONE_SUGGEST_COMMENT_MARKER));
199+
if (batch.length < 100) break;
200+
}
201+
if (alreadyPosted) return { suggested: false };
202+
203+
await createIssueComment(ctx.env, ctx.installationId, ctx.repoFullName, pullNumber, renderSuggestionComment(match));
204+
return { suggested: true };
205+
}
206+
207+
/**
208+
* Webhook-level entry point (#3183): folds the "should this even run" gating (installed app, PR still open,
209+
* feature opted in) AND the best-effort error logging into one call, so the PR-webhook handler in
210+
* processors.ts has a single, unconditional call site with no logic/logging body of its own -- everything
211+
* testable lives here, where it already has dedicated, isolated coverage, rather than in an inline closure
212+
* inside the huge webhook file that only a full pipeline test could exercise.
213+
*/
214+
export async function maybeSuggestMilestoneMatchForPr(args: {
215+
env: Env;
216+
installationId: number | null | undefined;
217+
repoFullName: string;
218+
pullNumber: number;
219+
prState: string;
220+
prTitle: string;
221+
prBody: string | null | undefined;
222+
mode: ProjectMilestoneMatchModeInput;
223+
deliveryId: string;
224+
}): Promise<void> {
225+
if (!args.installationId) return;
226+
if (args.prState !== "open") return;
227+
if (!args.mode || args.mode === "off") return;
228+
await maybeSuggestMilestoneMatch({ env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }, args.pullNumber, args.prTitle, args.prBody).catch((error) => {
229+
console.error(
230+
JSON.stringify({
231+
level: "warn",
232+
event: "milestone_suggest_failed",
233+
deliveryId: args.deliveryId,
234+
repoFullName: args.repoFullName,
235+
pullNumber: args.pullNumber,
236+
error: errorMessage(error),
237+
}),
238+
);
239+
});
240+
}
241+
242+
// Kept as a standalone alias (rather than importing RepositorySettings from ../types) so this integrations
243+
// module has no dependency on the settings type -- it only needs to know "off" vs. anything else.
244+
type ProjectMilestoneMatchModeInput = "off" | "suggest" | "auto" | null | undefined;

0 commit comments

Comments
 (0)