From 2a7ee8e6611b1a1d1eb4699fec49c2550c614b78 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:56:36 -0700 Subject: [PATCH] feat(scoring): add draft-PR awareness to classification and queue health isDraftPullRequest() now checks pr.isDraft first, matching every other draft-detection site in the codebase (local-branch.ts, engine.ts, commands.ts). GitHub-native draft PRs without a draft title or label were previously misclassified as blocked. buildQueueHealth() gains a draftPullRequests signal and surfaces an inactive_draft_prs finding when any draft PR has not updated in 14+ days, letting maintainers distinguish abandoned drafts from stale reviewable work. Co-Authored-By: Claude Sonnet 4.6 --- src/scoring/pending-pr-scenarios.ts | 1 + src/signals/engine.ts | 13 ++++++++++ test/unit/contributor-open-pr-monitor.test.ts | 10 ++++++++ test/unit/open-pr-pressure-scenarios.test.ts | 1 + test/unit/queue-trends.test.ts | 1 + test/unit/repo-policy-readiness.test.ts | 1 + test/unit/scenario-summary.test.ts | 1 + .../self-dogfood-registration-pack.test.ts | 1 + test/unit/signals-coverage.test.ts | 25 +++++++++++++++++++ 9 files changed, 54 insertions(+) diff --git a/src/scoring/pending-pr-scenarios.ts b/src/scoring/pending-pr-scenarios.ts index ed68d009fa..67368cf7cd 100644 --- a/src/scoring/pending-pr-scenarios.ts +++ b/src/scoring/pending-pr-scenarios.ts @@ -220,6 +220,7 @@ export function applyPendingPrDetectionToScoreInput( } function isDraftPullRequest(pr: PullRequestRecord): boolean { + if (pr.isDraft) return true; const title = pr.title.trim(); if (/^\[?\s*draft\s*\]?/i.test(title) || /^draft:/i.test(title)) return true; return pr.labels.some((label) => label.toLowerCase() === "draft" || label.toLowerCase() === "wip"); diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 2fafea02b5..a413ae86d5 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -85,6 +85,7 @@ export type QueueHealth = { openPullRequests: number; unlinkedPullRequests: number; stalePullRequests: number; + draftPullRequests: number; maintainerAuthoredPullRequests: number; collisionClusters: number; ageBuckets: { @@ -913,6 +914,7 @@ export function buildQueueHealth( countOverrides.likelyReviewablePullRequests !== undefined ? "authoritative" : openPullRequestCount > openPullRequests.length ? "sampled_cache" : "cache"; const unlinkedPullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length === 0); const stalePullRequests = openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14); + const draftPullRequests = openPullRequests.filter((pr) => pr.isDraft); const maintainerAuthoredPullRequests = openPullRequests.filter((pr) => isMaintainerAssociation(pr.authorAssociation)); const cachedLikelyReviewablePullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length > 0 && daysSince(pr.updatedAt ?? pr.createdAt) < 30).length; const likelyReviewablePullRequests = Math.min(openPullRequestCount, Math.max(cachedLikelyReviewablePullRequests, countOverrides.likelyReviewablePullRequests ?? 0)); @@ -963,6 +965,16 @@ export function buildQueueHealth( detail: `${stalePullRequests.length} open pull request(s) have not updated in at least 14 days.`, }); } + const inactiveDraftPullRequests = draftPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14); + if (inactiveDraftPullRequests.length > 0) { + findings.push({ + code: "inactive_draft_prs", + severity: "info", + title: "Draft PRs have been open without recent activity", + detail: `${inactiveDraftPullRequests.length} draft pull request(s) have not updated in at least 14 days — they may be abandoned or blocked.`, + action: "Mark as ready for review when work resumes, or close if the approach has been abandoned.", + }); + } return { repoFullName, generatedAt: nowIso(), @@ -974,6 +986,7 @@ export function buildQueueHealth( openPullRequests: openPullRequestCount, unlinkedPullRequests: unlinkedPullRequests.length, stalePullRequests: stalePullRequests.length, + draftPullRequests: draftPullRequests.length, maintainerAuthoredPullRequests: maintainerAuthoredPullRequests.length, collisionClusters: collisions.summary.clusterCount, ageBuckets, diff --git a/test/unit/contributor-open-pr-monitor.test.ts b/test/unit/contributor-open-pr-monitor.test.ts index eb3d16703e..57919c25b7 100644 --- a/test/unit/contributor-open-pr-monitor.test.ts +++ b/test/unit/contributor-open-pr-monitor.test.ts @@ -144,6 +144,16 @@ describe("contributor open PR monitor", () => { ).toBe("reviewable"); }); + it("classifies a GitHub-native draft PR as draft even without draft in title or labels", () => { + const nativeDraft = classifyOpenPullRequest({ + pr: pr({ number: 20, title: "Add cursor pagination", isDraft: true, labels: [] }), + roleContext: outsideContributorRole, + reviews: [], + checks: [], + }); + expect(mapPendingClassToWorkClassification(nativeDraft, { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false })).toBe("draft"); + }); + it("builds contributor-wide monitor answer from registered repos only", async () => { const env = createTestEnv(); vi.spyOn(repositories, "listRepositories").mockResolvedValue([ diff --git a/test/unit/open-pr-pressure-scenarios.test.ts b/test/unit/open-pr-pressure-scenarios.test.ts index ed1994a205..49243b156a 100644 --- a/test/unit/open-pr-pressure-scenarios.test.ts +++ b/test/unit/open-pr-pressure-scenarios.test.ts @@ -18,6 +18,7 @@ function queueHealth(level: QueueHealth["level"], overrides: Partial { openPullRequests: 12, unlinkedPullRequests: 2, stalePullRequests: 5, + draftPullRequests: 0, maintainerAuthoredPullRequests: 1, collisionClusters: 4, ageBuckets: { under7Days: 2, days7To30: 6, over30Days: 4 }, diff --git a/test/unit/repo-policy-readiness.test.ts b/test/unit/repo-policy-readiness.test.ts index bdd7675011..7336e34f6a 100644 --- a/test/unit/repo-policy-readiness.test.ts +++ b/test/unit/repo-policy-readiness.test.ts @@ -67,6 +67,7 @@ function queue(overrides: Partial = {}): QueueHealth { openPullRequests: 1, unlinkedPullRequests: 0, stalePullRequests: 0, + draftPullRequests: 0, maintainerAuthoredPullRequests: 0, collisionClusters: 0, ageBuckets: { under7Days: 1, days7To30: 0, over30Days: 0 }, diff --git a/test/unit/scenario-summary.test.ts b/test/unit/scenario-summary.test.ts index 4e74be0330..c351bbb99e 100644 --- a/test/unit/scenario-summary.test.ts +++ b/test/unit/scenario-summary.test.ts @@ -63,6 +63,7 @@ function queueHealth(level: QueueHealth["level"]): QueueHealth { openPullRequests: 1, unlinkedPullRequests: 0, stalePullRequests: 0, + draftPullRequests: 0, maintainerAuthoredPullRequests: 0, collisionClusters: 0, ageBuckets: { under7Days: 1, days7To30: 0, over30Days: 0 }, diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index 44bab4b884..12bb61612a 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -146,6 +146,7 @@ function readinessFixture(overrides: Partial = {}): openPullRequests: 6, unlinkedPullRequests: 1, stalePullRequests: 2, + draftPullRequests: 0, maintainerAuthoredPullRequests: 0, collisionClusters: 0, ageBuckets: { under7Days: 2, days7To30: 3, over30Days: 1 }, diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 626b8083c0..1b3246b785 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1565,6 +1565,30 @@ describe("signal coverage edge cases", () => { expect(fitOf(matched)).toBe((fitOf(offLanguage) ?? 0) + 10); expect(fitOf(unknownLanguage)).toBe(fitOf(offLanguage)); }); + it("buildQueueHealth counts draft PRs and fires inactive_draft_prs finding when stale", () => { + const directRepo = repo("owner/draft-test"); + const collisions = buildCollisionReport(directRepo.fullName, [], []); + const staleDate = new Date(Date.now() - 20 * 86_400_000).toISOString(); + const recentDate = new Date().toISOString(); + + // A stale draft PR triggers the finding; a recent draft does not. + const staleDraftPr = pr(directRepo.fullName, 10, "Draft: refactor auth", { isDraft: true, updatedAt: staleDate }); + const recentDraftPr = pr(directRepo.fullName, 11, "Draft: add pagination", { isDraft: true, updatedAt: recentDate }); + const nonDraftPr = pr(directRepo.fullName, 12, "Fix login redirect", { isDraft: false }); + + const withStaleDraft = buildQueueHealth(directRepo, [], [staleDraftPr, nonDraftPr], collisions); + expect(withStaleDraft.signals.draftPullRequests).toBe(1); + expect(withStaleDraft.findings.some((f) => f.code === "inactive_draft_prs")).toBe(true); + + const withRecentDraft = buildQueueHealth(directRepo, [], [recentDraftPr, nonDraftPr], collisions); + expect(withRecentDraft.signals.draftPullRequests).toBe(1); + expect(withRecentDraft.findings.some((f) => f.code === "inactive_draft_prs")).toBe(false); + + // No drafts: signal is zero and finding is absent. + const noDrafts = buildQueueHealth(directRepo, [], [nonDraftPr], collisions); + expect(noDrafts.signals.draftPullRequests).toBe(0); + expect(noDrafts.findings.some((f) => f.code === "inactive_draft_prs")).toBe(false); + }); }); function repo(fullName: string, overrides: Partial = {}): RepositoryRecord { @@ -1680,6 +1704,7 @@ function queueHealthFixture(repoFullName: string, level: QueueHealth["level"]): openPullRequests: level === "low" ? 1 : level === "medium" ? 4 : level === "high" ? 9 : 16, unlinkedPullRequests: 0, stalePullRequests: 0, + draftPullRequests: 0, maintainerAuthoredPullRequests: 0, collisionClusters: 0, ageBuckets: { under7Days: 0, days7To30: 0, over30Days: 0 },