Skip to content

Commit ff99183

Browse files
authored
feat(inbox): configurable base branch for inbox auto-PRs (#2480)
1 parent 9151bc2 commit ff99183

6 files changed

Lines changed: 283 additions & 2 deletions

File tree

apps/code/src/renderer/api/posthogClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2302,6 +2302,7 @@ export class PostHogAPIClient {
23022302
updates: Partial<{
23032303
default_autostart_priority: string;
23042304
default_slack_notification_channel: string | null;
2305+
autostart_base_branches: Record<string, string>;
23052306
}>,
23062307
): Promise<SignalTeamConfig> {
23072308
const teamId = await this.getTeamId();

apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "../../task-detail/service/service";
2020
import { buildCreatePrReportPrompt } from "../utils/buildCreatePrReportPrompt";
2121
import { resolveDefaultModel } from "../utils/resolveDefaultModel";
22+
import { useSignalTeamConfig } from "./useSignalTeamConfig";
2223

2324
const log = logger.scope("create-pr-report");
2425

@@ -52,6 +53,7 @@ export function useCreatePrReport({
5253
const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration();
5354
const { invalidateTasks } = useCreateTask();
5455
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
56+
const { data: teamConfig } = useSignalTeamConfig();
5557

5658
const createPrReport = useCallback(async () => {
5759
if (isCreatingPr) return;
@@ -100,6 +102,13 @@ export function useCreatePrReport({
100102
return;
101103
}
102104

105+
const baseBranchOverrides = teamConfig?.autostart_base_branches ?? {};
106+
const targetRepo = cloudRepository.toLowerCase();
107+
const baseBranch =
108+
Object.entries(baseBranchOverrides).find(
109+
([repo]) => repo.toLowerCase() === targetRepo,
110+
)?.[1] ?? null;
111+
103112
const input: TaskCreationInput = {
104113
content: prompt,
105114
taskDescription: prompt,
@@ -109,6 +118,7 @@ export function useCreatePrReport({
109118
executionMode: "auto",
110119
adapter,
111120
model,
121+
branch: baseBranch,
112122
reasoningLevel: settings.lastUsedReasoningEffort ?? undefined,
113123
cloudPrAuthorshipMode: "user",
114124
cloudRunSource: "signal_report",
@@ -129,7 +139,7 @@ export function useCreatePrReport({
129139
created_from: "command-menu",
130140
repository_provider: "github",
131141
workspace_mode: "cloud",
132-
has_branch: false,
142+
has_branch: baseBranch != null,
133143
cloud_run_source: "signal_report",
134144
cloud_pr_authorship_mode: "user",
135145
signal_report_id: reportId,
@@ -170,6 +180,7 @@ export function useCreatePrReport({
170180
reportTitle,
171181
getUserIntegrationIdForRepo,
172182
invalidateTasks,
183+
teamConfig,
173184
]);
174185

175186
return { createPrReport, isCreatingPr };

apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from "@renderer/api/posthogClient";
88
import type {
99
SignalReportPriority,
10+
SignalTeamConfig,
1011
SignalUserAutonomyConfig,
1112
} from "@shared/types";
1213
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
@@ -113,7 +114,8 @@ export function useSignalSourceManager() {
113114
const { data: externalSources, isLoading: sourcesLoading } =
114115
useExternalDataSources();
115116
const { data: evaluations } = useEvaluations();
116-
const { data: teamConfig } = useSignalTeamConfig();
117+
const { data: teamConfig, isLoading: teamConfigLoading } =
118+
useSignalTeamConfig();
117119
const { data: userAutonomyConfig, isLoading: userAutonomyConfigLoading } =
118120
useSignalUserAutonomyConfig();
119121

@@ -523,6 +525,42 @@ export function useSignalSourceManager() {
523525
[client, queryClient],
524526
);
525527

528+
const handleUpdateAutostartBaseBranches = useCallback(
529+
async (branches: Record<string, string>) => {
530+
if (!client) return;
531+
532+
const queryKey = ["signals", "team-config"];
533+
const previous = queryClient.getQueryData<SignalTeamConfig | null>(
534+
queryKey,
535+
);
536+
537+
if (previous) {
538+
queryClient.setQueryData<SignalTeamConfig | null>(queryKey, {
539+
...previous,
540+
autostart_base_branches: branches,
541+
});
542+
}
543+
544+
try {
545+
const fresh = await client.updateSignalTeamConfig({
546+
autostart_base_branches: branches,
547+
});
548+
queryClient.setQueryData<SignalTeamConfig | null>(queryKey, fresh);
549+
} catch (error: unknown) {
550+
queryClient.setQueryData<SignalTeamConfig | null>(
551+
queryKey,
552+
previous ?? null,
553+
);
554+
const message =
555+
error instanceof Error
556+
? error.message
557+
: "Failed to update base branch setting";
558+
toast.error(message);
559+
}
560+
},
561+
[client, queryClient],
562+
);
563+
526564
const handleUpdateSlackNotifications = useCallback(
527565
async (updates: {
528566
integrationId?: number | null;
@@ -611,11 +649,13 @@ export function useSignalSourceManager() {
611649
evaluationsUrl,
612650
handleToggleEvaluation,
613651
teamConfig,
652+
teamConfigLoading,
614653
handleUpdateAutostartPriority,
615654
handleUpdateTeamSlackChannel,
616655
userAutonomyConfig,
617656
userAutonomyConfigLoading,
618657
handleUpdateUserAutonomyPriority,
658+
handleUpdateAutostartBaseBranches,
619659
handleUpdateSlackNotifications,
620660
};
621661
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker";
2+
import { BranchSelector } from "@features/git-interaction/components/BranchSelector";
3+
import {
4+
useGithubBranches,
5+
useGithubRepositories,
6+
useRepositoryIntegration,
7+
} from "@hooks/useIntegrations";
8+
import { X } from "@phosphor-icons/react";
9+
import { Button } from "@posthog/quill";
10+
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
11+
import { useState } from "react";
12+
13+
interface AutostartBaseBranchesSettingsProps {
14+
/** Current `org/repo` → base branch overrides. */
15+
branches: Record<string, string>;
16+
/** Persist the full next mapping (the caller diffs/optimistically updates). */
17+
onChange: (next: Record<string, string>) => void;
18+
isLoading?: boolean;
19+
}
20+
21+
/**
22+
* Per-repository base branch overrides for auto-started inbox PRs.
23+
*
24+
* Each configured repo opens its auto-PRs against the chosen branch instead of
25+
* the repo default. Repos without an entry keep targeting the repo default
26+
*/
27+
export function AutostartBaseBranchesSettings({
28+
branches,
29+
onChange,
30+
isLoading = false,
31+
}: AutostartBaseBranchesSettingsProps) {
32+
const {
33+
repositories: allRepositories,
34+
getIntegrationIdForRepo,
35+
isLoadingRepos,
36+
isRefreshingRepos,
37+
refreshRepositories,
38+
hasGithubIntegration,
39+
} = useRepositoryIntegration();
40+
const disabled = !hasGithubIntegration;
41+
42+
const [isRepoPickerOpen, setIsRepoPickerOpen] = useState(false);
43+
const [repoSearch, setRepoSearch] = useState("");
44+
const [pendingRepo, setPendingRepo] = useState<string | null>(null);
45+
46+
const repoPage = useGithubRepositories(repoSearch, isRepoPickerOpen);
47+
48+
const selectableRepositories = (
49+
isRepoPickerOpen ? repoPage.repositories : allRepositories
50+
).filter((repo) => !(repo in branches));
51+
52+
const entries = Object.entries(branches);
53+
54+
const commit = (repo: string, branch: string) => {
55+
onChange({ ...branches, [repo]: branch });
56+
};
57+
58+
const remove = (repo: string) => {
59+
const next = { ...branches };
60+
delete next[repo];
61+
onChange(next);
62+
};
63+
64+
return (
65+
<Flex
66+
direction="column"
67+
gap="2"
68+
pt="3"
69+
style={{ borderTop: "1px dashed var(--gray-5)" }}
70+
>
71+
<Flex direction="column" gap="1">
72+
<Text className="font-medium text-(--gray-12) text-sm">
73+
Base branch for auto-PRs
74+
</Text>
75+
<Text className="text-(--gray-11) text-[13px]">
76+
Point auto-started inbox PRs at a specific branch per repository.
77+
Repositories without an override target their default branch.
78+
</Text>
79+
</Flex>
80+
81+
{isLoading ? (
82+
<Box className="h-[32px] w-[320px] animate-pulse rounded bg-gray-3" />
83+
) : (
84+
<Flex direction="column" gap="2">
85+
{entries.map(([repo, branch]) => (
86+
<BaseBranchRow
87+
key={repo}
88+
repo={repo}
89+
value={branch}
90+
integrationId={getIntegrationIdForRepo(repo)}
91+
disabled={disabled}
92+
onCommit={commit}
93+
onRemove={remove}
94+
/>
95+
))}
96+
97+
<Flex align="center" gap="2">
98+
<Box className="min-w-[220px] max-w-[280px]">
99+
<GitHubRepoPicker
100+
value={pendingRepo}
101+
onChange={setPendingRepo}
102+
repositories={selectableRepositories}
103+
isLoading={
104+
isLoadingRepos || (isRepoPickerOpen && repoPage.isPending)
105+
}
106+
isRefreshing={isRefreshingRepos}
107+
onRefresh={refreshRepositories}
108+
open={isRepoPickerOpen}
109+
onOpenChange={setIsRepoPickerOpen}
110+
searchQuery={repoSearch}
111+
onSearchQueryChange={setRepoSearch}
112+
hasMore={repoPage.hasMore}
113+
onLoadMore={repoPage.loadMore}
114+
disabled={disabled}
115+
placeholder="Add a repository…"
116+
size="2"
117+
/>
118+
</Box>
119+
{pendingRepo ? (
120+
<BaseBranchRow
121+
key={`add-${pendingRepo}`}
122+
repo={pendingRepo}
123+
value={undefined}
124+
integrationId={getIntegrationIdForRepo(pendingRepo)}
125+
disabled={disabled}
126+
onCommit={(repo, branch) => {
127+
commit(repo, branch);
128+
setPendingRepo(null);
129+
setRepoSearch("");
130+
}}
131+
/>
132+
) : null}
133+
</Flex>
134+
</Flex>
135+
)}
136+
</Flex>
137+
);
138+
}
139+
140+
interface BaseBranchRowProps {
141+
repo: string;
142+
value: string | undefined;
143+
integrationId: number | undefined;
144+
disabled?: boolean;
145+
onCommit: (repo: string, branch: string) => void;
146+
onRemove?: (repo: string) => void;
147+
}
148+
149+
function BaseBranchRow({
150+
repo,
151+
value,
152+
integrationId,
153+
disabled = false,
154+
onCommit,
155+
onRemove,
156+
}: BaseBranchRowProps) {
157+
const isAdd = value === undefined;
158+
const [search, setSearch] = useState("");
159+
const [draft, setDraft] = useState<string | null>(null);
160+
161+
const branchQuery = useGithubBranches(integrationId, repo, search, true);
162+
const hasIntegrationId = !!integrationId;
163+
164+
const selectedBranch = isAdd ? draft : (value ?? null);
165+
166+
return (
167+
<Flex align="center" gap="2">
168+
{!isAdd ? (
169+
<Text className="min-w-[220px] max-w-[280px] truncate text-(--gray-12) text-sm">
170+
{repo}
171+
</Text>
172+
) : null}
173+
<BranchSelector
174+
repoPath={repo}
175+
currentBranch={null}
176+
defaultBranch={branchQuery.data?.defaultBranch ?? null}
177+
workspaceMode="cloud"
178+
disabled={disabled || !hasIntegrationId}
179+
selectedBranch={selectedBranch}
180+
onBranchSelect={(branch) => {
181+
if (isAdd) {
182+
setDraft(branch);
183+
} else if (branch) {
184+
onCommit(repo, branch);
185+
}
186+
}}
187+
cloudBranches={branchQuery.data?.branches}
188+
cloudBranchesLoading={branchQuery.isPending}
189+
isRefreshing={branchQuery.isRefreshing}
190+
cloudBranchesFetchingMore={branchQuery.isFetchingMore}
191+
cloudBranchesHasMore={branchQuery.hasMore}
192+
cloudSearchQuery={search}
193+
onCloudSearchChange={setSearch}
194+
onCloudLoadMore={branchQuery.loadMore}
195+
/>
196+
{isAdd ? (
197+
<Button
198+
size="sm"
199+
disabled={disabled || !draft}
200+
onClick={() => {
201+
if (draft) onCommit(repo, draft);
202+
}}
203+
>
204+
Add
205+
</Button>
206+
) : (
207+
<IconButton
208+
variant="ghost"
209+
color="gray"
210+
aria-label={`Remove base branch override for ${repo}`}
211+
disabled={disabled}
212+
onClick={() => onRemove?.(repo)}
213+
>
214+
<X size={14} />
215+
</IconButton>
216+
)}
217+
</Flex>
218+
);
219+
}

apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@features/inbox/components/SignalSourceToggles";
66
import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager";
77
import { SettingsOptionSelect } from "@features/settings/components/SettingsOptionSelect";
8+
import { AutostartBaseBranchesSettings } from "@features/settings/components/sections/AutostartBaseBranchesSettings";
89
import { GitHubIntegrationSection } from "@features/settings/components/sections/GitHubIntegrationSection";
910
import { SlackInboxNotificationsSettings } from "@features/settings/components/sections/SlackInboxNotificationsSettings";
1011
import { openSettings } from "@features/settings/hooks/useOpenSettings";
@@ -55,6 +56,9 @@ export function SignalSourcesSettings({
5556
userAutonomyConfig,
5657
userAutonomyConfigLoading,
5758
handleUpdateUserAutonomyPriority,
59+
teamConfig,
60+
teamConfigLoading,
61+
handleUpdateAutostartBaseBranches,
5862
} = useSignalSourceManager();
5963

6064
const { hasGithubIntegration, isLoadingIntegrations } =
@@ -149,6 +153,11 @@ export function SignalSourcesSettings({
149153
/>
150154
)}
151155
</Flex>
156+
<AutostartBaseBranchesSettings
157+
branches={teamConfig?.autostart_base_branches ?? {}}
158+
onChange={(next) => void handleUpdateAutostartBaseBranches(next)}
159+
isLoading={teamConfigLoading}
160+
/>
152161
{showSlackNotifications ? (
153162
<SlackInboxNotificationsSettings
154163
channelComboboxModal={slackNotificationsInModal}

apps/code/src/shared/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ export interface SignalTeamConfig {
541541
default_autostart_priority: SignalReportPriority;
542542
/** Team-wide default `channel_id|#channel-name` target for inbox notifications. `null` = no team default. */
543543
default_slack_notification_channel?: string | null;
544+
autostart_base_branches?: Record<string, string> | null;
544545
created_at: string;
545546
updated_at: string;
546547
}

0 commit comments

Comments
 (0)