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
21 changes: 12 additions & 9 deletions src/integrations/project-tracker-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createInstallationToken } from "../github/app";
import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "../github/client";
import type { AgentActionMode } from "../settings/agent-execution";
import { createIssueComment } from "../github/pr-actions";
import { findLinearNativeLink, LinearAdapter } from "./linear-adapter";
import { termOverlap, tokenize, type CollisionTerms } from "../signals/engine";
Expand Down Expand Up @@ -31,8 +32,8 @@ export type ProjectTrackerAttachResult = {
export interface ProjectTrackerAdapter {
listOpenProjects(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]>;
listOpenMilestones(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]>;
attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise<ProjectTrackerAttachResult>;
attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise<ProjectTrackerAttachResult>;
attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string, mode?: AgentActionMode): Promise<ProjectTrackerAttachResult>;
attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string, mode?: AgentActionMode): Promise<ProjectTrackerAttachResult>;
}

function parseRepoFullName(repoFullName: string): { owner: string; repo: string } {
Expand Down Expand Up @@ -95,12 +96,12 @@ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter {
return { attached: false };
}

async attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise<ProjectTrackerAttachResult> {
async attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string, mode: AgentActionMode = "live"): Promise<ProjectTrackerAttachResult> {
const milestoneNumber = parsePositiveIntegerId(milestoneId);
if (milestoneNumber === null) return { attached: 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 octokit = makeInstallationOctokit(ctx.env, token, mode, githubRateLimitAdmissionKeyForInstallation(ctx.installationId));
await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", {
owner,
repo,
Expand Down Expand Up @@ -226,11 +227,11 @@ export class GitHubProjectsAdapter implements ProjectTrackerAdapter {
return [];
}

async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise<ProjectTrackerAttachResult> {
async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string, mode: AgentActionMode = "live"): Promise<ProjectTrackerAttachResult> {
if (typeof projectId !== "string" || projectId.trim().length === 0) return { attached: 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 octokit = makeInstallationOctokit(ctx.env, token, mode, 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>(
Expand Down Expand Up @@ -441,6 +442,7 @@ export async function maybeAutoApplyProjectOrMilestoneMatch(
backend: ProjectMilestoneMatchBackendInput,
prUrl: string,
threshold: number = DEFAULT_AUTO_APPLY_MIN_SCORE,
actionMode: AgentActionMode = "live",
): Promise<ProjectMilestoneAutoApplyResult> {
const matches = await resolveTrackerMatches(ctx, backend, prTitle, prBody, prUrl);
const isLinear = backend === "linear";
Expand All @@ -449,10 +451,10 @@ export async function maybeAutoApplyProjectOrMilestoneMatch(
let attachedMilestone = false;
let attachedProject = false;
if (matches.milestone && matches.milestone.score >= threshold) {
attachedMilestone = (await milestoneAdapter.attachToMilestone(ctx, pullNumber, matches.milestone.item.id)).attached;
attachedMilestone = (await milestoneAdapter.attachToMilestone(ctx, pullNumber, matches.milestone.item.id, actionMode)).attached;
}
if (matches.project && matches.project.score >= threshold) {
attachedProject = (await projectAdapter.attachToProject(ctx, pullNumber, matches.project.item.id)).attached;
attachedProject = (await projectAdapter.attachToProject(ctx, pullNumber, matches.project.item.id, actionMode)).attached;
}
return { attachedMilestone, attachedProject };
}
Expand All @@ -479,6 +481,7 @@ export async function maybeSuggestMilestoneMatchForPr(args: {
deliveryId: string;
eventName: string;
action: string | undefined;
actionMode?: AgentActionMode | undefined;
}): Promise<void> {
if (!shouldSuggestProjectTrackerForWebhook(args.eventName, args.action)) return;
if (!args.installationId) return;
Expand All @@ -488,7 +491,7 @@ export async function maybeSuggestMilestoneMatchForPr(args: {
if (args.mode === "auto") {
// "auto": actually attach the high-confidence match(es) instead of only commenting (#3185). Best-effort --
// an attach failure is logged and swallowed, never blocking the maintenance step, same as suggest mode.
await maybeAutoApplyProjectOrMilestoneMatch(ctx, args.pullNumber, args.prTitle, args.prBody, args.backend, args.prUrl ?? "").catch((error) => {
await maybeAutoApplyProjectOrMilestoneMatch(ctx, args.pullNumber, args.prTitle, args.prBody, args.backend, args.prUrl ?? "", DEFAULT_AUTO_APPLY_MIN_SCORE, args.actionMode ?? "live").catch((error) => {
console.error(
JSON.stringify({
level: "warn",
Expand Down
1 change: 1 addition & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5538,6 +5538,7 @@ async function handlePullRequestWebhookEvent(
deliveryId,
eventName,
action: payload.action,
actionMode: await resolveRepoActionMode(env, settings),
});
// Review-evasion protection (#review-evasion-protection): a contributor closing their OWN PR while
// gittensory has an ACTIVE review pass running is dodging the one-shot review, not making an ordinary
Expand Down
15 changes: 15 additions & 0 deletions test/unit/project-tracker-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,21 @@ describe("maybeAutoApplyProjectOrMilestoneMatch (#3185)", () => {
expect(record.patchedMilestone).toBe(20);
});

it("suppresses auto-applied milestone writes when the repo action mode is dry-run (regression: pause/dry-run bypass)", async () => {
const record: MilestoneAttachRecord = { patchCalled: false };
const context = ctx();
vi.stubGlobal("fetch", milestoneAttachFetch(record));
const result = await maybeAutoApplyProjectOrMilestoneMatch(context, 4, STRONG_TITLE, null, "github", PR_URL, DEFAULT_AUTO_APPLY_MIN_SCORE, "dry_run");
const audit = await context.env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ? and target_key = ?")
.bind("github.write.suppressed", "/repos/{owner}/{repo}/issues/{issue_number}")
.first<{ outcome: string; metadata_json: string }>();

expect(result).toEqual({ attachedMilestone: true, attachedProject: false });
expect(record.patchCalled).toBe(false);
expect(audit?.outcome).toBe("completed");
expect(JSON.parse(audit?.metadata_json ?? "{}")).toMatchObject({ method: "PATCH", mode: "dry_run" });
});

it("attaches a matching Projects v2 item via GraphQL when it clears the threshold", async () => {
let mutationVariables: unknown;
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
Expand Down