From 7c69594f35a1a070dfc19e95ff8c85fdbb7564a9 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Wed, 10 Jun 2026 04:00:19 -0700 Subject: [PATCH 1/3] feat(issues): respect declined contributor-issue drafts generateContributorIssueDrafts re-proposed (and in create mode re-created) drafts a maintainer had already closed, since dedup only considered open issues. Add a bounded closed-marked-issue lister and findDeclinedContributorDraft: a closed issue carrying a draft's stable marker suppresses re-proposal (skipped_declined) -- indefinitely for wontfix-style labels, otherwise within a 30-day cooldown so genuine regressions can resurface. --- src/db/repositories.ts | 15 +++++++ src/services/contributor-issue-draft.ts | 51 ++++++++++++++++++++++- test/unit/contributor-issue-draft.test.ts | 40 ++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index bfe3dcd735..78e7fcaf39 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -2050,6 +2050,21 @@ export async function listIssues(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db + .select() + .from(issues) + .where(and(eq(issues.repoFullName, fullName), eq(issues.state, "closed"), sql`${issues.payloadJson} LIKE ${`%${markerPrefix}%`}`)) + .orderBy(desc(issues.updatedAt)) + .limit(limit); + return rows.map(toIssueRecordFromRow); +} + export async function listAllIssues(env: Env): Promise { const db = getDb(env.DB); const rows = await db.select().from(issues).limit(2000); diff --git a/src/services/contributor-issue-draft.ts b/src/services/contributor-issue-draft.ts index 6409447a94..b00fb5feda 100644 --- a/src/services/contributor-issue-draft.ts +++ b/src/services/contributor-issue-draft.ts @@ -1,6 +1,7 @@ import { getRepository, getRepositorySettings, + listClosedContributorDraftIssues, listIssueSignalSample, listOpenIssues, listOpenPullRequests, @@ -44,7 +45,7 @@ export type ContributorIssueDraftTopic = | "upstream:registry_drift" | `focus:wanted_path:${string}`; -export type ContributorIssueDraftStatus = "proposed" | "skipped_duplicate" | "skipped_unsafe" | "created" | "skipped_create_failed"; +export type ContributorIssueDraftStatus = "proposed" | "skipped_duplicate" | "skipped_declined" | "skipped_unsafe" | "created" | "skipped_create_failed"; export type ContributorIssueDraft = { fingerprint: string; @@ -54,6 +55,7 @@ export type ContributorIssueDraft = { labels: string[]; status: ContributorIssueDraftStatus; duplicateOf?: { number: number; title: string; reason: "marker" | "title" } | undefined; + declinedBy?: { number: number; title: string; reason: "wontfix" | "cooldown" } | undefined; issue?: { number: number; url: string } | undefined; }; @@ -64,6 +66,7 @@ export type ContributorIssueDraftGenerationResult = { createRequested: boolean; proposed: number; skippedDuplicate: number; + skippedDeclined: number; skippedUnsafe: number; created: number; skippedCreateFailed: number; @@ -88,6 +91,7 @@ type ContributorIssueDraftContext = { contributorIntakeHealth: ContributorIntakeHealth; focusManifest: FocusManifest; openIssues: IssueRecord[]; + declinedIssues?: IssueRecord[] | undefined; upstreamDriftWarnings: string[]; }; @@ -170,6 +174,37 @@ export function findDuplicateContributorDraft( return null; } +export const CONTRIBUTOR_ISSUE_DRAFT_DECLINED_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; +const DECLINED_DRAFT_WONTFIX_LABELS = new Set(["wontfix", "wont-fix", "invalid", "duplicate", "not-planned"]); + +/** + * Detect whether a draft was already declined by a maintainer closing the generated issue. + * Matches by the stable marker fingerprint on a closed issue. A `wontfix`-style label suppresses + * re-proposal indefinitely; otherwise the closure is honored only within the cooldown window, so a + * genuine later regression of the underlying warning can resurface once the cooldown elapses. + */ +export function findDeclinedContributorDraft( + closedIssues: IssueRecord[], + draft: Pick, + options: { now?: number | undefined; cooldownMs?: number | undefined } = {}, +): { number: number; title: string; reason: "wontfix" | "cooldown" } | null { + const marker = contributorIssueDraftMarker(draft.fingerprint); + const nowMs = options.now ?? Date.now(); + const cooldownMs = options.cooldownMs ?? CONTRIBUTOR_ISSUE_DRAFT_DECLINED_COOLDOWN_MS; + for (const issue of closedIssues) { + if (issue.state !== "closed") continue; + if (!issue.body?.includes(marker)) continue; + if (issue.labels.some((label) => DECLINED_DRAFT_WONTFIX_LABELS.has(label.trim().toLowerCase()))) { + return { number: issue.number, title: issue.title, reason: "wontfix" }; + } + const closedAtMs = issue.updatedAt ? Date.parse(issue.updatedAt) : Number.NaN; + if (!Number.isFinite(closedAtMs) || nowMs - closedAtMs < cooldownMs) { + return { number: issue.number, title: issue.title, reason: "cooldown" }; + } + } + return null; +} + export function buildContributorIssueDraftBody(fingerprint: string, sections: ContributorIssueDraftSections): string { const blocks: string[] = [contributorIssueDraftMarker(fingerprint), "", "## Background", "", ...sections.background, "", "## Current Behavior", "", ...sections.currentBehavior, "", "## Desired Behavior", "", ...sections.desiredBehavior, "", "## Implementation Requirements", "", ...sections.implementationRequirements.map((line) => `- ${line}`), "", "## Public/Private Output Boundaries", "", ...sections.publicPrivateBoundaries.map((line) => `- ${line}`), "", "## Acceptance Criteria", "", ...sections.acceptanceCriteria.map((line) => `- ${line}`), "", "## Testing Requirements", "", ...sections.testingRequirements.map((line) => `- ${line}`)]; return blocks.join("\n"); @@ -223,6 +258,7 @@ export async function generateContributorIssueDrafts( const drafts: ContributorIssueDraft[] = []; let proposed = 0; let skippedDuplicate = 0; + let skippedDeclined = 0; let skippedUnsafe = 0; let created = 0; let skippedCreateFailed = 0; @@ -252,6 +288,14 @@ export async function generateContributorIssueDrafts( drafts.push(draft); continue; } + const declined = findDeclinedContributorDraft(context.declinedIssues ?? [], draft); + if (declined) { + draft.status = "skipped_declined"; + draft.declinedBy = declined; + skippedDeclined += 1; + drafts.push(draft); + continue; + } if (!dryRun && createRequested) { const issue = await createGitHubContributorIssue(env, repoFullName, draft); if (issue) { @@ -297,6 +341,7 @@ export async function generateContributorIssueDrafts( createRequested, proposed, skippedDuplicate, + skippedDeclined, skippedUnsafe, created, skippedCreateFailed, @@ -444,10 +489,11 @@ function pathSlug(path: string): string { } async function loadContributorIssueDraftContext(env: Env, repoFullName: string): Promise { - const [repo, settings, openIssues, focusManifest, upstreamReports, issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([ + const [repo, settings, openIssues, declinedIssues, focusManifest, upstreamReports, issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([ getRepository(env, repoFullName), getRepositorySettings(env, repoFullName), listOpenIssues(env, repoFullName), + listClosedContributorDraftIssues(env, repoFullName, `