Skip to content

Commit 851d500

Browse files
committed
feat(pr): workspace setting to auto-keep new authored PRs mergeable by default
Adds a per-workspace 'Auto-keep new PRs mergeable' toggle (Settings → Workspace). When on, a newly-tracked open PR the viewer AUTHORED gets auto-keep-mergeable armed automatically on first sighting, so the watcher keeps it mergeable without a manual per-PR toggle. Scoped to authored PRs on purpose: arming auto-keep-mergeable dispatches cloud fix runs that PUSH COMMITS, so it must never auto-fire on someone else's review-requested PR. The default is applied only in upsertRow's INSERT branch (genuine first sighting, authored + open) — never on the hot update path, so it can't retroactively arm an existing PR, and the extra settings read (projected to the small settings jsonb, never the logo blob) is off the hot path. - shared: WorkspaceSettings.defaultAutoKeepMergeable - backend: prCache.upsertRow reads the setting on insert, arms autoKeepMergeable + autoMergeState { attempts: 0, accounted: true } (mirrors the toggle route), and rides it on the insert broadcast so the desktop reflects it immediately - desktop: AutoKeepMergeableDefaultToggle in the Workspace settings section - tests: authored-arms / reviewer-does-not-arm / default-off / update-path-never-arms
1 parent 38ec3fe commit 851d500

4 files changed

Lines changed: 185 additions & 0 deletions

File tree

apps/desktop/src/renderer/components/panels/SettingsPanel.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,8 @@ function WorkspaceSettings() {
636636
)}
637637
</Card>
638638

639+
<AutoKeepMergeableDefaultToggle />
640+
639641
<Card className="p-4 border-destructive/30">
640642
<h4 className="font-medium mb-1">Delete workspace</h4>
641643
<p className="text-sm text-muted-foreground mb-3">
@@ -1038,6 +1040,63 @@ export function ProviderConnectCards() {
10381040
* back to Auto). Always shown so the default is discoverable even with one (or
10391041
* zero) providers connected. Persists to `workspace.settings.defaultCloudProvider`.
10401042
*/
1043+
/**
1044+
* Workspace default: auto-arm "auto-keep mergeable" on every new PR the viewer
1045+
* authored. Persists to `workspace.settings.defaultAutoKeepMergeable`; the
1046+
* backend applies it only to authored PRs on first sighting (never a PR you're
1047+
* only reviewing). Individual PRs stay hand-toggleable in the PR detail sheet.
1048+
*/
1049+
function AutoKeepMergeableDefaultToggle() {
1050+
const currentWorkspaceId = useWorkspaceStore((s) => s.currentWorkspaceId);
1051+
const workspaces = useWorkspaceStore((s) => s.workspaces);
1052+
const setWorkspaces = useWorkspaceStore((s) => s.setWorkspaces);
1053+
const [saving, setSaving] = useState(false);
1054+
1055+
const workspace = workspaces.find((w) => w.id === currentWorkspaceId);
1056+
const enabled = workspace?.settings?.defaultAutoKeepMergeable === true;
1057+
1058+
const onToggle = async (next: boolean) => {
1059+
if (!currentWorkspaceId) return;
1060+
setSaving(true);
1061+
try {
1062+
const settings = { defaultAutoKeepMergeable: next } as Workspace['settings'];
1063+
await api.workspaces.update(currentWorkspaceId, { settings });
1064+
setWorkspaces(
1065+
workspaces.map((w) =>
1066+
w.id === currentWorkspaceId
1067+
? { ...w, settings: { ...w.settings, ...settings } as Workspace['settings'] }
1068+
: w,
1069+
),
1070+
);
1071+
} finally {
1072+
setSaving(false);
1073+
}
1074+
};
1075+
1076+
return (
1077+
<Card className="p-4">
1078+
<label className="flex items-start gap-3 cursor-pointer">
1079+
<input
1080+
type="checkbox"
1081+
checked={enabled}
1082+
disabled={saving || !currentWorkspaceId}
1083+
onChange={(e) => void onToggle(e.target.checked)}
1084+
className="mt-1"
1085+
/>
1086+
<div className="flex-1">
1087+
<div className="font-medium text-sm">Auto-keep new PRs mergeable</div>
1088+
<p className="text-xs text-muted-foreground mt-1">
1089+
When a PR you authored first shows up in this workspace, automatically turn on
1090+
“Auto-keep mergeable” — a cloud agent fixes conflicts, CI, and review comments to keep
1091+
it mergeable until it merges. Applies only to PRs you opened, never ones you’re just
1092+
reviewing. You can still toggle any individual PR from its detail panel.
1093+
</p>
1094+
</div>
1095+
</label>
1096+
</Card>
1097+
);
1098+
}
1099+
10411100
function CloudProviderDefaultSelector() {
10421101
const currentWorkspaceId = useWorkspaceStore((s) => s.currentWorkspaceId);
10431102
const workspaces = useWorkspaceStore((s) => s.workspaces);

packages/backend/src/__tests__/prCache.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,84 @@ describe('prCache — DB integration', () => {
413413
).toBe('Renamed');
414414
});
415415

416+
describe('auto-keep mergeable default', () => {
417+
async function setDefault(on: boolean): Promise<void> {
418+
await db
419+
.update(workspacesTable)
420+
.set({ settings: { defaultAutoKeepMergeable: on } })
421+
.where(eq(workspacesTable.id, 'ws1'));
422+
}
423+
async function flags(number: number) {
424+
const rows = await db
425+
.select({
426+
autoKeepMergeable: pullRequestsTable.autoKeepMergeable,
427+
autoMergeState: pullRequestsTable.autoMergeState,
428+
})
429+
.from(pullRequestsTable)
430+
.where(eq(pullRequestsTable.number, number));
431+
return rows[0];
432+
}
433+
434+
it('arms a new AUTHORED open PR when the workspace default is on', async () => {
435+
await setDefault(true);
436+
await upsertFromBatchResult({
437+
workspaceId: 'ws1',
438+
repositoryId: 'repo1',
439+
summary: makeSummary({ number: 10 }),
440+
authored: true,
441+
});
442+
const f = await flags(10);
443+
expect(f.autoKeepMergeable).toBe(true);
444+
expect(f.autoMergeState).toEqual({ attempts: 0, accounted: true });
445+
});
446+
447+
it('does NOT arm a PR the viewer only reviews (not authored), even with the default on', async () => {
448+
await setDefault(true);
449+
await upsertFromBatchResult({
450+
workspaceId: 'ws1',
451+
repositoryId: 'repo1',
452+
summary: makeSummary({ number: 11 }),
453+
authored: false,
454+
reviewRequested: true,
455+
});
456+
const f = await flags(11);
457+
expect(f.autoKeepMergeable).toBe(false);
458+
expect(f.autoMergeState).toBeNull();
459+
});
460+
461+
it('leaves a new authored PR off when the workspace default is off', async () => {
462+
await setDefault(false);
463+
await upsertFromBatchResult({
464+
workspaceId: 'ws1',
465+
repositoryId: 'repo1',
466+
summary: makeSummary({ number: 12 }),
467+
authored: true,
468+
});
469+
const f = await flags(12);
470+
expect(f.autoKeepMergeable).toBe(false);
471+
expect(f.autoMergeState).toBeNull();
472+
});
473+
474+
it('never arms retroactively on the update path — only on first insert', async () => {
475+
await setDefault(false);
476+
await upsertFromBatchResult({
477+
workspaceId: 'ws1',
478+
repositoryId: 'repo1',
479+
summary: makeSummary({ number: 13 }),
480+
authored: true,
481+
});
482+
// Flip the default on, then re-upsert the SAME PR (update path).
483+
await setDefault(true);
484+
await upsertFromBatchResult({
485+
workspaceId: 'ws1',
486+
repositoryId: 'repo1',
487+
summary: makeSummary({ number: 13, title: 'Updated' }),
488+
authored: true,
489+
});
490+
expect((await flags(13)).autoKeepMergeable).toBe(false);
491+
});
492+
});
493+
416494
describe('linkTaskToPullRequest', () => {
417495
async function seedTask(): Promise<string> {
418496
const id = `task-${Math.random().toString(36).slice(2, 8)}`;

packages/backend/src/services/prCache.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getDbClient, type Database } from '../db/client.js';
44
import {
55
pullRequests as pullRequestsTable,
66
repositories as repositoriesTable,
7+
workspaces as workspacesTable,
78
} from '../db/schema.js';
89
import { emitPullRequestUpdated } from './websocket.js';
910
import {
@@ -547,6 +548,25 @@ function isFresh(lastPolledAt: Date, ttlMs: number): boolean {
547548
return Date.now() - lastPolledAt.getTime() < ttlMs;
548549
}
549550

551+
/**
552+
* The workspace's "auto-keep mergeable by default" setting. Read only when a
553+
* brand-new authored PR is being inserted (a rare event — never the hot update
554+
* path), and projected to the small `settings` jsonb so the large `logo` column
555+
* never ships.
556+
*/
557+
async function workspaceDefaultAutoKeepMergeable(
558+
db: Database,
559+
workspaceId: string
560+
): Promise<boolean> {
561+
const rows = await db
562+
.select({ settings: workspacesTable.settings })
563+
.from(workspacesTable)
564+
.where(eq(workspacesTable.id, workspaceId))
565+
.limit(1);
566+
const settings = (rows[0]?.settings as { defaultAutoKeepMergeable?: boolean } | null) ?? {};
567+
return settings.defaultAutoKeepMergeable === true;
568+
}
569+
550570
async function upsertRow(
551571
db: Database,
552572
opts: {
@@ -592,6 +612,19 @@ async function upsertRow(
592612
}
593613
const lastSummary = summaryToJsonb(summary);
594614
const cursors = nextCursors(summary);
615+
// A newly-tracked open PR the viewer AUTHORED inherits the workspace's
616+
// "auto-keep mergeable by default" setting. Scoped to authored PRs on purpose:
617+
// arming it dispatches cloud fix runs that PUSH COMMITS, so it must never
618+
// auto-fire on someone else's review-requested PR. Only computed on a genuine
619+
// insert of an authored open PR — never on updates or non-authored rows.
620+
let autoKeepMergeable = false;
621+
let autoMergeState: { attempts: number; accounted: boolean } | null = null;
622+
if (!opts.existingId && opts.authored === true && summary.state === 'open') {
623+
if (await workspaceDefaultAutoKeepMergeable(db, opts.workspaceId)) {
624+
autoKeepMergeable = true;
625+
autoMergeState = { attempts: 0, accounted: true };
626+
}
627+
}
595628
if (opts.existingId) {
596629
// Update path — leave taskId alone (set on first insert only).
597630
await db
@@ -635,6 +668,8 @@ async function upsertRow(
635668
lastReviewCommentId: cursors.lastReviewCommentId,
636669
lastCommentId: cursors.lastCommentId,
637670
lastCheckDigest: cursors.lastCheckDigest,
671+
autoKeepMergeable,
672+
autoMergeState,
638673
createdAt: now,
639674
updatedAt: now,
640675
});
@@ -652,6 +687,11 @@ async function upsertRow(
652687
lastSummary,
653688
...(opts.reviewRequested === undefined ? {} : { reviewRequested: opts.reviewRequested }),
654689
...(opts.authored === undefined ? {} : { authored: opts.authored }),
690+
// Surface the auto-armed state on first insert so the toggle reflects it
691+
// without waiting for a full fetch (matches the toggle route's emit shape).
692+
...(autoKeepMergeable
693+
? { autoKeepMergeable: true, autoMergeState: { attempts: 0, paused: false } }
694+
: {}),
655695
});
656696
return id;
657697
}

packages/shared/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ export interface WorkspaceSettings {
118118
claudeModel?: ClaudeModelId;
119119
/** Which model PostHog Code runs use. Unset = {@link DEFAULT_POSTHOG_CODE_MODEL_ID}. */
120120
posthogCodeModel?: PostHogCodeModelId;
121+
/**
122+
* When on, a newly-tracked open PR the viewer AUTHORED gets "auto-keep
123+
* mergeable" armed automatically (the watcher fires cloud fix runs to keep it
124+
* mergeable until it merges). Scoped to authored PRs on purpose — arming it
125+
* pushes commits, so it must never auto-fire on someone else's
126+
* review-requested PR. Individual PRs can still be toggled by hand. Unset = off.
127+
*/
128+
defaultAutoKeepMergeable?: boolean;
121129
}
122130

123131
export interface ContinuousBuildSettings {

0 commit comments

Comments
 (0)