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
1 change: 1 addition & 0 deletions src/scoring/pending-pr-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
13 changes: 13 additions & 0 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export type QueueHealth = {
openPullRequests: number;
unlinkedPullRequests: number;
stalePullRequests: number;
draftPullRequests: number;
maintainerAuthoredPullRequests: number;
collisionClusters: number;
ageBuckets: {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions test/unit/contributor-open-pr-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
1 change: 1 addition & 0 deletions test/unit/open-pr-pressure-scenarios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function queueHealth(level: QueueHealth["level"], overrides: Partial<QueueHealth
openPullRequests: level === "low" ? 1 : 12,
unlinkedPullRequests: 0,
stalePullRequests: level === "high" || level === "critical" ? 4 : 0,
draftPullRequests: 0,
maintainerAuthoredPullRequests: 0,
collisionClusters: 0,
ageBuckets: { under7Days: 1, days7To30: 0, over30Days: 0 },
Expand Down
1 change: 1 addition & 0 deletions test/unit/queue-trends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("queue trend windows", () => {
openPullRequests: 12,
unlinkedPullRequests: 2,
stalePullRequests: 5,
draftPullRequests: 0,
maintainerAuthoredPullRequests: 1,
collisionClusters: 4,
ageBuckets: { under7Days: 2, days7To30: 6, over30Days: 4 },
Expand Down
1 change: 1 addition & 0 deletions test/unit/repo-policy-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function queue(overrides: Partial<QueueHealth> = {}): QueueHealth {
openPullRequests: 1,
unlinkedPullRequests: 0,
stalePullRequests: 0,
draftPullRequests: 0,
maintainerAuthoredPullRequests: 0,
collisionClusters: 0,
ageBuckets: { under7Days: 1, days7To30: 0, over30Days: 0 },
Expand Down
1 change: 1 addition & 0 deletions test/unit/scenario-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions test/unit/self-dogfood-registration-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ function readinessFixture(overrides: Partial<RegistrationReadinessReport> = {}):
openPullRequests: 6,
unlinkedPullRequests: 1,
stalePullRequests: 2,
draftPullRequests: 0,
maintainerAuthoredPullRequests: 0,
collisionClusters: 0,
ageBuckets: { under7Days: 2, days7To30: 3, over30Days: 1 },
Expand Down
25 changes: 25 additions & 0 deletions test/unit/signals-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegistryRepoConfig> = {}): RepositoryRecord {
Expand Down Expand Up @@ -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 },
Expand Down
Loading