Skip to content

Commit 1fac8c5

Browse files
OAGrclaude
andauthored
feat(pr): draft PRs by default, auto-undraft in pr-patrol (#1794)
* fix(ci): increase scheduled-maintenance max-turns and timeout Previous run (2026-03-05T07:59:11Z) failed with error_max_turns after 31 turns — Claude ran out with max-turns=30 on a weekly sweep. Also increase timeout-minutes from 30 to 60 to give sufficient wall-clock headroom for weekly/monthly cadences. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pr): draft PRs by default, auto-undraft in pr-patrol - `crux pr create` now creates draft PRs by default (use --no-draft to override) - New `crux pr ready` command validates eligibility (CI green, no conflicts, no unresolved threads, no unchecked items) before converting draft to ready - PR Patrol auto-undrafts draft PRs labeled `ready-to-merge` when all other eligibility checks pass, then merges in the same cycle - PR Patrol skips draft PRs from the fix queue (no point fixing drafts) - Added `isDraft` to GraphQL PR query and `GqlPrNode` interface - `is-draft` added as a new `MergeBlockReason` - 4 new tests covering draft eligibility checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review findings — is-draft self-block, REST fallback, failed undraft tracking - Filter out 'is-draft' from block reasons in `crux pr ready` (it's the whole point of the command, not a blocker) - Replace REST-then-GraphQL fallback with direct GraphQL for undrafting (REST API doesn't support undrafting, so the REST call always fails) - Track which PRs were successfully undrafted before attempting merge (prevents merge attempts on PRs where undraft API call failed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(pr): address CodeRabbit review feedback - Validate --pr option: return error on malformed values instead of silently falling back to branch autodetection - Keep ci-pending as a blocker for `crux pr ready` to match PR Patrol auto-undraft behavior (only is-draft is filtered out) - Simulate undraft in dry-run mode so merge phase also reports what would happen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 03463bb commit 1fac8c5

File tree

3 files changed

+247
-8
lines changed

3 files changed

+247
-8
lines changed

crux/commands/pr.ts

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,8 @@ async function detect(_args: string[], options: CommandOptions): Promise<Command
197197
* --body="PR body" Body as inline string (vulnerable to shell expansion).
198198
* --body-file=<path> Body from a file (safe for markdown with backticks).
199199
* --base=main Base branch (default: main).
200-
* --draft Create as draft PR.
200+
* --draft Create as draft PR (default: true).
201+
* --no-draft Create as ready PR (not draft).
201202
*
202203
* If a PR already exists for this branch, reports it instead of creating a duplicate.
203204
*/
@@ -210,7 +211,8 @@ async function create(_args: string[], options: CommandOptions): Promise<Command
210211
const bodyFile = (options.bodyFile ?? options['body-file']) as string | undefined;
211212
let body = options.body as string | undefined;
212213
const base = (options.base as string) || 'main';
213-
const draft = Boolean(options.draft);
214+
// Default to draft unless --no-draft is explicitly passed
215+
const draft = options.noDraft === true || options['no-draft'] === true ? false : true;
214216

215217
// --body-file takes precedence (avoids shell expansion of backticks in markdown)
216218
if (bodyFile) {
@@ -602,12 +604,108 @@ async function resolveConflictsCmd(_args: string[], options: CommandOptions): Pr
602604
return { output, exitCode: failed > 0 ? 1 : 0 };
603605
}
604606

607+
/**
608+
* Mark the current branch's PR as ready for review.
609+
*
610+
* Validates eligibility (CI green, no conflicts, no unresolved threads,
611+
* no unchecked checkboxes) before converting from draft to ready.
612+
*
613+
* Options:
614+
* --pr=N Target a specific PR number instead of auto-detecting.
615+
* --force Skip eligibility checks and mark as ready anyway.
616+
*/
617+
async function ready(_args: string[], options: CommandOptions): Promise<CommandResult> {
618+
const log = createLogger(Boolean(options.ci));
619+
const c = log.colors;
620+
const force = Boolean(options.force);
621+
622+
let prNum: number | null = null;
623+
if (options.pr !== undefined) {
624+
const rawPr = String(options.pr);
625+
const parsed = parseInt(rawPr, 10);
626+
if (!Number.isInteger(parsed) || parsed <= 0) {
627+
return {
628+
output: `${c.red}Invalid --pr value: ${rawPr}${c.reset}\n`,
629+
exitCode: 1,
630+
};
631+
}
632+
prNum = parsed;
633+
}
634+
635+
if (!prNum) {
636+
const branch = currentBranch();
637+
const prs = await githubApi<GitHubPR[]>(
638+
`/repos/${REPO}/pulls?head=quantified-uncertainty:${branch}&state=open`
639+
);
640+
if (!prs.length) {
641+
return {
642+
output: `${c.red}No open PR found for branch ${currentBranch()}.${c.reset}\n`,
643+
exitCode: 1,
644+
};
645+
}
646+
prNum = prs[0].number;
647+
}
648+
649+
// Fetch full PR details via GraphQL for eligibility checks
650+
const { checkMergeEligibility, fetchSinglePr } = await import('../pr-patrol/index.ts');
651+
const prNode = await fetchSinglePr(prNum);
652+
653+
if (!prNode) {
654+
return {
655+
output: `${c.red}Could not fetch PR #${prNum} details.${c.reset}\n`,
656+
exitCode: 1,
657+
};
658+
}
659+
660+
// Check eligibility (unless --force)
661+
if (!force) {
662+
const eligibility = checkMergeEligibility(prNode);
663+
// Ignore only is-draft: this command exists to remove that block.
664+
// CI must still be green — matching PR Patrol auto-undraft behavior.
665+
const blockReasons = eligibility.blockReasons.filter(
666+
(r: string) => r !== 'is-draft',
667+
);
668+
669+
if (blockReasons.length > 0) {
670+
let output = `${c.red}✗ PR #${prNum} is not eligible to be marked ready:${c.reset}\n`;
671+
for (const reason of blockReasons) {
672+
output += ` - ${reason}\n`;
673+
}
674+
output += `\n Use --force to mark ready anyway.\n`;
675+
return { output, exitCode: 1 };
676+
}
677+
}
678+
679+
// Convert from draft to ready via GraphQL mutation
680+
// (GitHub REST API doesn't support undrafting — only GraphQL works)
681+
try {
682+
const { githubGraphQL } = await import('../lib/github.ts');
683+
const prData = await githubApi<{ node_id: string }>(`/repos/${REPO}/pulls/${prNum}`);
684+
await githubGraphQL(
685+
`mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`,
686+
{ id: prData.node_id },
687+
);
688+
} catch (e: unknown) {
689+
const msg = e instanceof Error ? e.message : String(e);
690+
return {
691+
output: `${c.red}Failed to mark PR #${prNum} as ready: ${msg}${c.reset}\n`,
692+
exitCode: 1,
693+
};
694+
}
695+
696+
let output = `${c.green}${c.reset} PR #${prNum} marked as ready for review.\n`;
697+
if (force) output += ` ${c.yellow}(eligibility checks skipped with --force)${c.reset}\n`;
698+
699+
return { output, exitCode: 0 };
700+
}
701+
605702
// ---------------------------------------------------------------------------
606703
// Domain entry point (required by crux.mjs dispatch)
607704
// ---------------------------------------------------------------------------
608705

609706
export const commands = {
610707
create,
708+
ready,
611709
detect,
612710
'fix-body': fixBody,
613711
'rebase-all': rebaseAll,
@@ -620,7 +718,8 @@ export function getHelp(): string {
620718
PR Domain — GitHub Pull Request utilities
621719
622720
Commands:
623-
create Create a PR for the current branch (corruption-safe).
721+
create Create a draft PR for the current branch (corruption-safe).
722+
ready [--pr=N] Mark PR as ready (validates eligibility, removes draft status).
624723
detect Detect open PR for current branch (returns URL + number).
625724
fix-body [--pr=N] Detect and repair literal \\n in the current branch's PR body.
626725
rebase-all Rebase all open non-draft PRs onto main (used by CI).
@@ -632,11 +731,15 @@ Options (create):
632731
--body="..." PR body (inline — avoid for multi-line bodies; use --body-file or stdin).
633732
--body-file=<path> PR body from file (safe for markdown with backticks).
634733
--base=main Base branch (default: main).
635-
--draft Create as draft PR.
734+
--no-draft Create as ready PR (default: draft).
636735
--allow-empty-body Allow creating PR without a description (not recommended).
637736
--skip-test-plan Skip test plan validation (not recommended).
638737
(stdin) If --body and --body-file are absent and stdin is a pipe, body is read from stdin.
639738
739+
Options (ready):
740+
--pr=N Target a specific PR number instead of auto-detecting.
741+
--force Skip eligibility checks and mark as ready anyway.
742+
640743
Options (detect):
641744
--ci JSON output.
642745

crux/pr-patrol/index.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ function makePrNode(overrides: Partial<GqlPrNode> = {}): GqlPrNode {
1414
title: 'Test PR',
1515
headRefName: 'claude/test',
1616
mergeable: 'MERGEABLE',
17+
isDraft: false,
1718
createdAt: '2026-01-01T00:00:00Z',
1819
updatedAt: '2026-03-05T00:00:00Z',
1920
body: '## Summary\n\n- [x] Task done\n\n## Test plan\n\n- [x] Tests pass\n\nCloses #1',
@@ -283,6 +284,20 @@ describe('checkMergeEligibility', () => {
283284
expect(result.blockReasons).toContain('claude-working');
284285
});
285286

287+
it('blocks when PR is a draft', () => {
288+
const result = checkMergeEligibility(
289+
makePrNode({ isDraft: true }),
290+
);
291+
expect(result.eligible).toBe(false);
292+
expect(result.blockReasons).toContain('is-draft');
293+
});
294+
295+
it('does NOT block when PR is not a draft', () => {
296+
const result = checkMergeEligibility(makePrNode({ isDraft: false }));
297+
expect(result.eligible).toBe(true);
298+
expect(result.blockReasons).not.toContain('is-draft');
299+
});
300+
286301
it('returns multiple block reasons when several checks fail', () => {
287302
const result = checkMergeEligibility(
288303
makePrNode({
@@ -349,6 +364,23 @@ describe('findMergeCandidates', () => {
349364
expect(result.find((c) => c.number === 1)?.eligible).toBe(true);
350365
expect(result.find((c) => c.number === 2)?.eligible).toBe(false);
351366
});
367+
368+
it('marks draft PRs as blocked with is-draft reason', () => {
369+
const draftPr = makePrNode({ number: 1, isDraft: true });
370+
const result = findMergeCandidates([draftPr]);
371+
expect(result).toHaveLength(1);
372+
expect(result[0].eligible).toBe(false);
373+
expect(result[0].blockReasons).toContain('is-draft');
374+
});
375+
376+
it('draft PR with only is-draft block is undraft-eligible', () => {
377+
const draftPr = makePrNode({ number: 1, isDraft: true });
378+
const result = findMergeCandidates([draftPr]);
379+
const undraftEligible = result.filter(
380+
(c) => !c.eligible && c.blockReasons.length === 1 && c.blockReasons[0] === 'is-draft',
381+
);
382+
expect(undraftEligible).toHaveLength(1);
383+
});
352384
});
353385

354386
// ── detectIssues (regression test after refactor) ────────────────────────────

crux/pr-patrol/index.ts

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export type MergeBlockReason =
5858
| 'ci-pending'
5959
| 'unresolved-threads'
6060
| 'unchecked-items'
61-
| 'claude-working';
61+
| 'claude-working'
62+
| 'is-draft';
6263

6364
export interface MergeCandidate {
6465
number: number;
@@ -486,7 +487,7 @@ const PR_QUERY = `query($owner: String!, $name: String!) {
486487
repository(owner: $owner, name: $name) {
487488
pullRequests(first: 50, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
488489
nodes {
489-
number title headRefName mergeable createdAt updatedAt body
490+
number title headRefName mergeable isDraft createdAt updatedAt body
490491
labels(first: 20) { nodes { name } }
491492
commits(last: 1) { nodes { commit { statusCheckRollup {
492493
contexts(first: 50) { nodes {
@@ -525,6 +526,7 @@ export interface GqlPrNode {
525526
title: string;
526527
headRefName: string;
527528
mergeable: string;
529+
isDraft: boolean;
528530
createdAt: string;
529531
updatedAt: string;
530532
body: string | null;
@@ -625,6 +627,42 @@ export async function fetchOpenPrs(config: PatrolConfig): Promise<GqlPrNode[]> {
625627
return prs;
626628
}
627629

630+
const SINGLE_PR_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
631+
repository(owner: $owner, name: $name) {
632+
pullRequest(number: $number) {
633+
number title headRefName mergeable isDraft createdAt updatedAt body
634+
labels(first: 20) { nodes { name } }
635+
commits(last: 1) { nodes { commit { statusCheckRollup {
636+
contexts(first: 50) { nodes {
637+
... on CheckRun { conclusion }
638+
... on StatusContext { state }
639+
}}
640+
}}}}
641+
reviewThreads(first: 50) { nodes {
642+
isResolved isOutdated path line startLine
643+
comments(first: 3) { nodes {
644+
author { login }
645+
body
646+
}}
647+
}}
648+
}
649+
}
650+
}`;
651+
652+
/** Fetch a single PR by number. Used by `crux pr ready` for eligibility checks. */
653+
export async function fetchSinglePr(prNumber: number): Promise<GqlPrNode | null> {
654+
const [owner, name] = REPO.split('/');
655+
try {
656+
const data = await githubGraphQL<{
657+
repository: { pullRequest: GqlPrNode | null };
658+
}>(SINGLE_PR_QUERY, { owner, name, number: prNumber });
659+
return data.repository.pullRequest;
660+
} catch (e) {
661+
log(`Warning: could not fetch PR #${prNumber}: ${e instanceof Error ? e.message : String(e)}`);
662+
return null;
663+
}
664+
}
665+
628666
function detectAllPrIssuesFromNodes(
629667
prs: GqlPrNode[],
630668
config: PatrolConfig,
@@ -635,6 +673,8 @@ function detectAllPrIssuesFromNodes(
635673
.filter((pr) => {
636674
const labels = pr.labels.nodes.map((l) => l.name);
637675
if (labels.includes('claude-working')) return false;
676+
// Skip draft PRs — they're not ready for automated fixes
677+
if (pr.isDraft) return false;
638678
return true;
639679
})
640680
.map((pr) => {
@@ -688,6 +728,10 @@ export function checkMergeEligibility(pr: GqlPrNode): MergeCandidate {
688728
const blockReasons: MergeBlockReason[] = [];
689729
const labels = pr.labels.nodes.map((l) => l.name);
690730

731+
if (pr.isDraft) {
732+
blockReasons.push('is-draft');
733+
}
734+
691735
if (labels.includes('claude-working')) {
692736
blockReasons.push('claude-working');
693737
}
@@ -759,6 +803,41 @@ export function findMergeCandidates(prs: GqlPrNode[]): MergeCandidate[] {
759803
);
760804
}
761805

806+
// ── Undraft execution ────────────────────────────────────────────────────────
807+
808+
async function undraftPr(prNum: number, config: PatrolConfig): Promise<boolean> {
809+
log(`→ Undrafting PR #${prNum} (all eligibility checks pass)`);
810+
811+
try {
812+
// GitHub REST API doesn't support undrafting — must use GraphQL mutation
813+
const prData = await githubApi<{ node_id: string }>(
814+
`/repos/${config.repo}/pulls/${prNum}`,
815+
);
816+
await githubGraphQL(
817+
`mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`,
818+
{ id: prData.node_id },
819+
);
820+
821+
log(`✓ PR #${prNum} marked as ready for review`);
822+
appendJsonl(JSONL_FILE, {
823+
type: 'undraft_result',
824+
pr_num: prNum,
825+
outcome: 'undrafted',
826+
});
827+
return true;
828+
} catch (e) {
829+
const msg = e instanceof Error ? e.message : String(e);
830+
log(`✗ Failed to undraft PR #${prNum}: ${msg}`);
831+
appendJsonl(JSONL_FILE, {
832+
type: 'undraft_result',
833+
pr_num: prNum,
834+
outcome: 'error',
835+
reason: msg,
836+
});
837+
return false;
838+
}
839+
}
840+
762841
// ── Merge execution ─────────────────────────────────────────────────────────
763842

764843
async function mergePr(
@@ -1298,9 +1377,34 @@ async function runCheckCycle(
12981377
}
12991378
}
13001379

1301-
// ── Merge phase ────────────────────────────────────────────────────
1380+
// ── Undraft phase ──────────────────────────────────────────────────
1381+
// Auto-undraft draft PRs that are otherwise eligible for merge.
1382+
// A PR is auto-undrafted when its only block reason is 'is-draft'.
13021383

1303-
const mergeCandidates = findMergeCandidates(allPrs);
1384+
const draftCandidates = findMergeCandidates(allPrs).filter(
1385+
(c) => !c.eligible && c.blockReasons.length === 1 && c.blockReasons[0] === 'is-draft',
1386+
);
1387+
1388+
const undraftedNumbers = new Set<number>();
1389+
for (const candidate of draftCandidates) {
1390+
if (config.dryRun) {
1391+
log(` [DRY RUN] Would undraft PR #${candidate.number} (all other checks pass)`);
1392+
undraftedNumbers.add(candidate.number);
1393+
} else {
1394+
const success = await undraftPr(candidate.number, config);
1395+
if (success) undraftedNumbers.add(candidate.number);
1396+
}
1397+
}
1398+
1399+
// ── Merge phase ────────────────────────────────────────────────────
1400+
// Re-evaluate after undrafting (only successfully undrafted PRs become eligible)
1401+
const mergeCandidates = findMergeCandidates(allPrs).map((c) => {
1402+
if (undraftedNumbers.has(c.number)) {
1403+
const updated = { ...c, blockReasons: c.blockReasons.filter((r) => r !== 'is-draft') };
1404+
return { ...updated, eligible: updated.blockReasons.length === 0 };
1405+
}
1406+
return c;
1407+
});
13041408
const eligibleForMerge = mergeCandidates.filter((c) => c.eligible);
13051409
const blockedForMerge = mergeCandidates.filter((c) => !c.eligible);
13061410
let mergedPr: number | null = null;

0 commit comments

Comments
 (0)