From 2d81f9f06ac7860a4320262ec8118a4162b4a597 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:19:39 -0700 Subject: [PATCH] fix(agent): respect dry-run for tracker auto-apply --- src/integrations/project-tracker-adapter.ts | 21 ++++++++++++--------- src/queue/processors.ts | 1 + test/unit/project-tracker-adapter.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/integrations/project-tracker-adapter.ts b/src/integrations/project-tracker-adapter.ts index 24871ca93c..937531be72 100644 --- a/src/integrations/project-tracker-adapter.ts +++ b/src/integrations/project-tracker-adapter.ts @@ -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"; @@ -31,8 +32,8 @@ export type ProjectTrackerAttachResult = { export interface ProjectTrackerAdapter { listOpenProjects(ctx: ProjectTrackerContext): Promise; listOpenMilestones(ctx: ProjectTrackerContext): Promise; - attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise; - attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise; + attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string, mode?: AgentActionMode): Promise; + attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string, mode?: AgentActionMode): Promise; } function parseRepoFullName(repoFullName: string): { owner: string; repo: string } { @@ -95,12 +96,12 @@ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter { return { attached: false }; } - async attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise { + async attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string, mode: AgentActionMode = "live"): Promise { 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, @@ -226,11 +227,11 @@ export class GitHubProjectsAdapter implements ProjectTrackerAdapter { return []; } - async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise { + async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string, mode: AgentActionMode = "live"): Promise { 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( @@ -441,6 +442,7 @@ export async function maybeAutoApplyProjectOrMilestoneMatch( backend: ProjectMilestoneMatchBackendInput, prUrl: string, threshold: number = DEFAULT_AUTO_APPLY_MIN_SCORE, + actionMode: AgentActionMode = "live", ): Promise { const matches = await resolveTrackerMatches(ctx, backend, prTitle, prBody, prUrl); const isLinear = backend === "linear"; @@ -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 }; } @@ -479,6 +481,7 @@ export async function maybeSuggestMilestoneMatchForPr(args: { deliveryId: string; eventName: string; action: string | undefined; + actionMode?: AgentActionMode | undefined; }): Promise { if (!shouldSuggestProjectTrackerForWebhook(args.eventName, args.action)) return; if (!args.installationId) return; @@ -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", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index bc7db47578..c1414b42f4 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -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 diff --git a/test/unit/project-tracker-adapter.test.ts b/test/unit/project-tracker-adapter.test.ts index 30acd2d9af..066a2ec9bc 100644 --- a/test/unit/project-tracker-adapter.test.ts +++ b/test/unit/project-tracker-adapter.test.ts @@ -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) => {