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
239 changes: 195 additions & 44 deletions src/integrations/project-tracker-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export type ProjectTrackerAttachResult = {
};

/**
* Pluggable project/milestone tracker backend (#3183). `GitHubMilestonesAdapter` below implements the
* milestone half now; Projects v2 (#3184) and a Linear backend (#3186) implement the same interface without
* reshaping the matching/suggestion logic that calls it.
* Pluggable project/milestone tracker backend (#3183). `GitHubMilestonesAdapter` implements the milestone half;
* `GitHubProjectsAdapter` (#3184) implements the Projects v2 half; a Linear backend (#3186) implements the same
* interface without reshaping the matching/suggestion logic that calls it.
*/
export interface ProjectTrackerAdapter {
listOpenProjects(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]>;
Expand All @@ -49,9 +49,9 @@ type GitHubMilestone = {
title: string;
};

// Bounded pagination for both the milestone list and the comment-marker search below (mirrors
// src/github/comments.ts's COMMENT_SEARCH_PAGE_LIMIT): 3 pages * 100 = 300 items is generously above any
// realistic open-milestone or PR-comment count, while still bounding worst-case GitHub API calls per PR event.
// Bounded pagination for milestones, PR comments (marker search), and Projects v2 (GraphQL cursor pages) below
// (mirrors src/github/comments.ts's COMMENT_SEARCH_PAGE_LIMIT): 3 pages * 100 = 300 items is generously above
// any realistic open-milestone/open-project/PR-comment count, while still bounding worst-case API calls.
const GITHUB_LIST_PAGE_LIMIT = 3;

/** A positive-integer milestone/issue number as a string, or null if `value` isn't one. Guards against a
Expand All @@ -62,10 +62,9 @@ function parsePositiveIntegerId(value: string): number | null {
}

/** GitHub REST implementation of {@link ProjectTrackerAdapter}. Only the Milestone half is real (#3183) --
* Projects v2 is GraphQL-only and needs a separate `organization_projects` App permission not yet granted, so
* those two methods are inert placeholders until #3184. */
* Projects v2 lives in the separate {@link GitHubProjectsAdapter} (#3184), so those two methods are inert here. */
export class GitHubMilestonesAdapter implements ProjectTrackerAdapter {
// Inert placeholder until #3184 (Projects v2 needs GraphQL + a separate App permission).
// Inert here -- see GitHubProjectsAdapter.
async listOpenProjects(): Promise<ProjectTrackerRef[]> {
return [];
}
Expand All @@ -90,7 +89,7 @@ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter {
return milestones.map((milestone) => ({ id: String(milestone.number), title: milestone.title }));
}

// Inert placeholder until #3184 (Projects v2 needs GraphQL + a separate App permission).
// Inert here -- see GitHubProjectsAdapter.
async attachToProject(): Promise<ProjectTrackerAttachResult> {
return { attached: false };
}
Expand All @@ -111,13 +110,150 @@ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter {
}
}

// ---------------------------------------------------------------------------------------------------------
// GitHub Projects v2 (#3184)
// ---------------------------------------------------------------------------------------------------------
//
// CONFIRMED PLATFORM LIMITATION (researched during #3184, not merely "unconfirmed community report"):
// GitHub Apps cannot read/write Projects v2 owned by a USER account at all, by design -- only
// ORGANIZATION-owned Projects v2 are reachable via an App's installation token. There is no App permission
// that substitutes for this; the only way to automate a user-owned board is a personal-access-token or
// user-to-server OAuth token acting AS that user, which is a materially different feature (secret storage,
// consent flow) out of scope here. Sources: docs.github.com/en/rest/authentication/permissions-required-for-github-apps
// (the "User permissions" category has no Projects entry at all), and community reports of GitHub Support
// confirming this (github.com/orgs/community/discussions/46681, /64849, /148529).
//
// listOpenProjects below therefore queries `repositoryOwner(login){ __typename ... on Organization { ... } }`
// in ONE GraphQL call: if the repo's owner is a User, `__typename` is "User" and the inline fragment simply
// contributes nothing (no projects, no error) -- this repo's owner not being an Organization degrades to the
// same "no open projects" result as an Organization with zero open projects, never a thrown error. A separate,
// SEPARATE known gap (community-reported, not independently re-verified here since it requires a real private
// Projects v2 board to test against): a GitHub App's Bot actor may be unable to read items in a PRIVATE
// Projects v2 board even when the Organization-level permission is granted (github.com/orgs/community/discussions/148529).
// Both gaps degrade the SAME way -- an empty projects list, never a crash or a wrong match -- so no special
// handling is needed to stay safe; only visibility (this comment + the PR notes) documents the gap.

type ProjectV2Node = { id: string; title: string; closed: boolean };

type ListOpenProjectsGraphQlResponse = {
repositoryOwner: {
__typename: string;
projectsV2?: { nodes: ProjectV2Node[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } };
} | null;
};

type ProjectV2FieldOption = { id: string; name: string };
type ProjectV2Field = { id: string; name: string; options?: ProjectV2FieldOption[] };

type ProjectFieldsGraphQlResponse = {
node: {
fields?: { nodes: ProjectV2Field[] };
} | null;
};

type AddProjectV2ItemGraphQlResponse = {
addProjectV2ItemById: { item: { id: string } | null } | null;
};

type PullRequestNodeIdResponse = {
node_id: string;
};

/**
* A single Projects v2 field's resolved options (#3184 deliverable: field/option-ID resolution, exposed for
* #3185's auto-apply status-setting, not yet called by attachToProject -- adding a project item and setting a
* custom field are two independent GraphQL mutations, and this PR only needs the first).
*/
export async function resolveProjectV2Fields(ctx: ProjectTrackerContext, projectId: string): Promise<ProjectV2Field[]> {
const token = await createInstallationToken(ctx.env, ctx.installationId);
const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId));
const response = await octokit.graphql<ProjectFieldsGraphQlResponse>(
`query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50) {
nodes {
... on ProjectV2FieldCommon { id name }
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}`,
{ projectId },
);
return response.node?.fields?.nodes ?? [];
}

/** GraphQL implementation of {@link ProjectTrackerAdapter} for GitHub Projects v2 (#3184). Only the Project
* half is real -- Milestones live in the separate {@link GitHubMilestonesAdapter} (#3183), so those two
* methods are inert here. See the module-level comment above for the user-vs-organization-owner limitation. */
export class GitHubProjectsAdapter implements ProjectTrackerAdapter {
async listOpenProjects(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
const { owner } = parseRepoFullName(ctx.repoFullName);
const token = await createInstallationToken(ctx.env, ctx.installationId);
const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId));
const projects: ProjectV2Node[] = [];
let after: string | null = null;
for (let page = 1; page <= GITHUB_LIST_PAGE_LIMIT; page += 1) {
const response: ListOpenProjectsGraphQlResponse = await octokit.graphql(
`query($login: String!, $after: String) {
repositoryOwner(login: $login) {
__typename
... on Organization {
projectsV2(first: 100, after: $after, orderBy: {field: TITLE, direction: ASC}) {
nodes { id title closed }
pageInfo { hasNextPage endCursor }
}
}
}
}`,
{ login: owner, after },
);
const projectsV2 = response.repositoryOwner?.projectsV2;
if (!projectsV2) break; // owner is a User (or has zero projects) -- see the module-level comment above.
projects.push(...projectsV2.nodes.filter((project) => !project.closed));
if (!projectsV2.pageInfo.hasNextPage) break;
after = projectsV2.pageInfo.endCursor;
}
return projects.map((project) => ({ id: project.id, title: project.title }));
}

// Inert here -- see GitHubMilestonesAdapter.
async listOpenMilestones(): Promise<ProjectTrackerRef[]> {
return [];
}

async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise<ProjectTrackerAttachResult> {
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 pr = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", { owner, repo, pull_number: pullNumber });
const contentId = (pr.data as PullRequestNodeIdResponse).node_id;
const response = await octokit.graphql<AddProjectV2ItemGraphQlResponse>(
`mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}`,
{ projectId, contentId },
);
return { attached: response.addProjectV2ItemById?.item != null };
}

// Inert here -- see GitHubMilestonesAdapter.
async attachToMilestone(): Promise<ProjectTrackerAttachResult> {
return { attached: false };
}
}

// Stricter than the duplicate-PR collision gate's 0.58/2 (src/signals/engine.ts) -- misattaching a PR to the
// wrong milestone corrupts tracked progress, whereas a missed duplicate just skips an advisory note.
const MILESTONE_MATCH_MIN_SCORE = 0.65;
const MILESTONE_MATCH_MIN_SHARED = 3;
// wrong tracker item corrupts tracked progress, whereas a missed duplicate just skips an advisory note.
const TRACKER_MATCH_MIN_SCORE = 0.65;
const TRACKER_MATCH_MIN_SHARED = 3;

export type ProjectTrackerMatch = {
milestone: ProjectTrackerRef;
item: ProjectTrackerRef;
score: number;
shared: number;
};
Expand All @@ -128,25 +264,25 @@ function termsFor(value: string): CollisionTerms {
}

/**
* Match PR title+body text against a list of open milestones (#3183), reusing the same tokenize/termOverlap
* heuristic as duplicate-PR collision detection. Returns null on no match -- AND on an ambiguous multi-match
* (more than one milestone clears the threshold): guessing between two plausible milestones is worse than
* suggesting neither, since a maintainer can always link one manually.
* Match PR title+body text against a list of open tracker items (milestones OR projects -- #3183/#3184),
* reusing the same tokenize/termOverlap heuristic as duplicate-PR collision detection. Returns null on no
* match -- AND on an ambiguous multi-match (more than one item clears the threshold): guessing between two
* plausible items is worse than suggesting neither, since a maintainer can always link one manually.
*/
export function matchOpenMilestones(prTitle: string, prBody: string | null | undefined, milestones: ProjectTrackerRef[]): ProjectTrackerMatch | null {
if (milestones.length === 0) return null;
export function matchOpenTrackerItems(prTitle: string, prBody: string | null | undefined, items: ProjectTrackerRef[]): ProjectTrackerMatch | null {
if (items.length === 0) return null;
const prTerms = termsFor([prTitle, prBody ?? ""].join(" "));
const candidates = milestones
.map((milestone) => ({ milestone, ...termOverlap(prTerms, termsFor(milestone.title)) }))
.filter((candidate) => candidate.score >= MILESTONE_MATCH_MIN_SCORE && candidate.shared >= MILESTONE_MATCH_MIN_SHARED);
const candidates = items
.map((item) => ({ item, ...termOverlap(prTerms, termsFor(item.title)) }))
.filter((candidate) => candidate.score >= TRACKER_MATCH_MIN_SCORE && candidate.shared >= TRACKER_MATCH_MIN_SHARED);
if (candidates.length !== 1) return null;
const best = candidates[0];
/* v8 ignore next -- defensive: candidates.length === 1 above guarantees index 0 exists. */
if (!best) return null;
return { milestone: best.milestone, score: best.score, shared: best.shared };
return { item: best.item, score: best.score, shared: best.shared };
}

export const MILESTONE_SUGGEST_COMMENT_MARKER = "<!-- gittensory-milestone-suggest:v1 -->";
export const PROJECT_TRACKER_SUGGEST_COMMENT_MARKER = "<!-- gittensory-milestone-suggest: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 @@ -155,14 +291,21 @@ function codeFormat(title: string): string {
return `\`${title.replace(/`/g, "")}\``;
}

function renderSuggestionComment(match: ProjectTrackerMatch): string {
const confidencePercent = Math.round(match.score * 100);
return [
MILESTONE_SUGGEST_COMMENT_MARKER,
`This PR looks like it's part of the ${codeFormat(match.milestone.title)} milestone (${confidencePercent}% title/body term overlap).`,
"",
"This is an advisory suggestion only — nothing has been attached automatically.",
].join("\n");
type ProjectTrackerMatches = {
milestone: ProjectTrackerMatch | null;
project: ProjectTrackerMatch | null;
};

function renderSuggestionComment(matches: ProjectTrackerMatches): string {
const lines = [PROJECT_TRACKER_SUGGEST_COMMENT_MARKER];
if (matches.milestone) {
lines.push(`This PR looks like it's part of the ${codeFormat(matches.milestone.item.title)} milestone (${Math.round(matches.milestone.score * 100)}% title/body term overlap).`);
}
if (matches.project) {
lines.push(`This PR looks like it's part of the ${codeFormat(matches.project.item.title)} project (${Math.round(matches.project.score * 100)}% title/body term overlap).`);
}
lines.push("", "This is an advisory suggestion only — nothing has been attached automatically.");
return lines.join("\n");
}

type IssueComment = {
Expand All @@ -171,15 +314,23 @@ type IssueComment = {
};

/**
* Best-effort, idempotent suggest-mode comment (#3183): posts ONCE per PR (never updates or reposts), so a
* repeated sweep/webhook pass never spams the thread. Never calls attachToMilestone -- suggest mode only ever
* comments; #3185 wires the real attach path behind the "auto" config value.
* Best-effort, idempotent suggest-mode comment (#3183/#3184): checks BOTH open Milestones and open Projects v2
* (independently, via each adapter) 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".
*/
export async function maybeSuggestMilestoneMatch(ctx: ProjectTrackerContext, pullNumber: number, prTitle: string, prBody: string | null | undefined): Promise<{ suggested: boolean }> {
const adapter = new GitHubMilestonesAdapter();
const milestones = await adapter.listOpenMilestones(ctx);
const match = matchOpenMilestones(prTitle, prBody, milestones);
if (!match) return { suggested: false };
export async function maybeSuggestProjectOrMilestoneMatch(ctx: ProjectTrackerContext, pullNumber: number, prTitle: string, prBody: string | null | undefined): Promise<{ suggested: boolean }> {
const milestonesAdapter = new GitHubMilestonesAdapter();
const projectsAdapter = new GitHubProjectsAdapter();
// Fail-open, independently, for each tracker type (mirrors this repo's established best-effort pattern):
// a transient milestone REST error must never suppress a valid Projects v2 match, and vice versa -- either
// lookup degrading to an empty list is a missed suggestion, not a broken one, matching the doc comment above.
const [milestones, projects] = await Promise.all([milestonesAdapter.listOpenMilestones(ctx).catch(() => []), projectsAdapter.listOpenProjects(ctx).catch(() => [])]);
const matches: ProjectTrackerMatches = {
milestone: matchOpenTrackerItems(prTitle, prBody, milestones),
project: matchOpenTrackerItems(prTitle, prBody, projects),
};
if (!matches.milestone && !matches.project) return { suggested: false };

const { owner, repo } = parseRepoFullName(ctx.repoFullName);
const token = await createInstallationToken(ctx.env, ctx.installationId);
Expand All @@ -195,12 +346,12 @@ export async function maybeSuggestMilestoneMatch(ctx: ProjectTrackerContext, pul
page,
});
const batch = existing.data as IssueComment[];
alreadyPosted = batch.some((comment) => comment.user?.type === "Bot" && comment.user.login?.toLowerCase() === botLogin.toLowerCase() && comment.body?.includes(MILESTONE_SUGGEST_COMMENT_MARKER));
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 };

await createIssueComment(ctx.env, ctx.installationId, ctx.repoFullName, pullNumber, renderSuggestionComment(match));
await createIssueComment(ctx.env, ctx.installationId, ctx.repoFullName, pullNumber, renderSuggestionComment(matches));
return { suggested: true };
}

Expand All @@ -225,7 +376,7 @@ export async function maybeSuggestMilestoneMatchForPr(args: {
if (!args.installationId) return;
if (args.prState !== "open") return;
if (!args.mode || args.mode === "off") return;
await maybeSuggestMilestoneMatch({ env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }, args.pullNumber, args.prTitle, args.prBody).catch((error) => {
await maybeSuggestProjectOrMilestoneMatch({ env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }, args.pullNumber, args.prTitle, args.prBody).catch((error) => {
console.error(
JSON.stringify({
level: "warn",
Expand Down
Loading
Loading