Skip to content

Commit 2265a64

Browse files
committed
feat(inbox): configurable base branch for inbox auto-PRs
1 parent bf90d93 commit 2265a64

6 files changed

Lines changed: 305 additions & 5 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2227,9 +2227,12 @@ export class PostHogAPIClient {
22272227
return (await response.json()) as SignalTeamConfig;
22282228
}
22292229

2230-
async updateSignalTeamConfig(updates: {
2231-
default_autostart_priority: string;
2232-
}): Promise<SignalTeamConfig> {
2230+
async updateSignalTeamConfig(
2231+
updates: Partial<{
2232+
default_autostart_priority: string;
2233+
autostart_base_branches: Record<string, string>;
2234+
}>,
2235+
): Promise<SignalTeamConfig> {
22332236
const teamId = await this.getTeamId();
22342237
const url = new URL(
22352238
`${this.api.baseUrl}/api/projects/${teamId}/signals/config/`,

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
} from "../../task-detail/service/service";
1919
import { buildCreatePrReportPrompt } from "../utils/buildCreatePrReportPrompt";
2020
import { resolveDefaultModel } from "../utils/resolveDefaultModel";
21+
import { useSignalTeamConfig } from "./useSignalTeamConfig";
2122

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

@@ -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,18 @@ export function useCreatePrReport({
100102
return;
101103
}
102104

105+
// Honor the team's per-repository base branch override for inbox PRs, so the
106+
// manual "Create PR" flow targets the same branch as autonomous auto-starts.
107+
// Keys are stored lowercased server-side and `cloudRepository` arrives
108+
// lowercased, but match case-insensitively to stay robust. Unset (or no
109+
// match) falls back to the repo default branch.
110+
const baseBranchOverrides = teamConfig?.autostart_base_branches ?? {};
111+
const targetRepo = cloudRepository.toLowerCase();
112+
const baseBranch =
113+
Object.entries(baseBranchOverrides).find(
114+
([repo]) => repo.toLowerCase() === targetRepo,
115+
)?.[1] ?? null;
116+
103117
const input: TaskCreationInput = {
104118
content: prompt,
105119
taskDescription: prompt,
@@ -109,6 +123,7 @@ export function useCreatePrReport({
109123
executionMode: "auto",
110124
adapter,
111125
model,
126+
branch: baseBranch,
112127
reasoningLevel: settings.lastUsedReasoningEffort ?? undefined,
113128
cloudPrAuthorshipMode: "user",
114129
cloudRunSource: "signal_report",
@@ -129,7 +144,7 @@ export function useCreatePrReport({
129144
created_from: "command-menu",
130145
repository_provider: "github",
131146
workspace_mode: "cloud",
132-
has_branch: false,
147+
has_branch: baseBranch != null,
133148
cloud_run_source: "signal_report",
134149
cloud_pr_authorship_mode: "user",
135150
signal_report_id: reportId,
@@ -168,6 +183,7 @@ export function useCreatePrReport({
168183
getUserIntegrationIdForRepo,
169184
invalidateTasks,
170185
navigateToTask,
186+
teamConfig,
171187
]);
172188

173189
return { createPrReport, isCreatingPr };

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

Lines changed: 46 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

@@ -502,6 +504,47 @@ export function useSignalSourceManager() {
502504
[client, queryClient],
503505
);
504506

507+
const handleUpdateAutostartBaseBranches = useCallback(
508+
async (branches: Record<string, string>) => {
509+
if (!client) return;
510+
511+
// Base branch overrides live on the team config — inbox PRs are
512+
// bot-authored through the team GitHub integration, so the target branch
513+
// is a team-level property, not a personal preference.
514+
const queryKey = ["signals", "team-config"];
515+
const previous = queryClient.getQueryData<SignalTeamConfig | null>(
516+
queryKey,
517+
);
518+
519+
// Optimistic update: reflect the new mapping immediately, preserving the
520+
// other team-config fields (default_autostart_priority, Slack defaults).
521+
if (previous) {
522+
queryClient.setQueryData<SignalTeamConfig | null>(queryKey, {
523+
...previous,
524+
autostart_base_branches: branches,
525+
});
526+
}
527+
528+
try {
529+
const fresh = await client.updateSignalTeamConfig({
530+
autostart_base_branches: branches,
531+
});
532+
queryClient.setQueryData<SignalTeamConfig | null>(queryKey, fresh);
533+
} catch (error: unknown) {
534+
queryClient.setQueryData<SignalTeamConfig | null>(
535+
queryKey,
536+
previous ?? null,
537+
);
538+
const message =
539+
error instanceof Error
540+
? error.message
541+
: "Failed to update base branch setting";
542+
toast.error(message);
543+
}
544+
},
545+
[client, queryClient],
546+
);
547+
505548
const handleUpdateSlackNotifications = useCallback(
506549
async (updates: {
507550
integrationId?: number | null;
@@ -590,10 +633,12 @@ export function useSignalSourceManager() {
590633
evaluationsUrl,
591634
handleToggleEvaluation,
592635
teamConfig,
636+
teamConfigLoading,
593637
handleUpdateAutostartPriority,
594638
userAutonomyConfig,
595639
userAutonomyConfigLoading,
596640
handleUpdateUserAutonomyPriority,
641+
handleUpdateAutostartBaseBranches,
597642
handleUpdateSlackNotifications,
598643
};
599644
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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, so
26+
* an empty mapping reproduces today's behaviour.
27+
*/
28+
export function AutostartBaseBranchesSettings({
29+
branches,
30+
onChange,
31+
isLoading = false,
32+
}: AutostartBaseBranchesSettingsProps) {
33+
const {
34+
repositories: allRepositories,
35+
getIntegrationIdForRepo,
36+
isLoadingRepos,
37+
isRefreshingRepos,
38+
refreshRepositories,
39+
hasGithubIntegration,
40+
} = useRepositoryIntegration();
41+
const disabled = !hasGithubIntegration;
42+
43+
const [isRepoPickerOpen, setIsRepoPickerOpen] = useState(false);
44+
const [repoSearch, setRepoSearch] = useState("");
45+
const [pendingRepo, setPendingRepo] = useState<string | null>(null);
46+
47+
const repoPage = useGithubRepositories(repoSearch, isRepoPickerOpen);
48+
49+
const selectableRepositories = (
50+
isRepoPickerOpen ? repoPage.repositories : allRepositories
51+
).filter((repo) => !(repo in branches));
52+
53+
const entries = Object.entries(branches);
54+
55+
const commit = (repo: string, branch: string) => {
56+
onChange({ ...branches, [repo]: branch });
57+
};
58+
59+
const remove = (repo: string) => {
60+
const next = { ...branches };
61+
delete next[repo];
62+
onChange(next);
63+
};
64+
65+
return (
66+
<Flex
67+
direction="column"
68+
gap="2"
69+
pt="3"
70+
style={{ borderTop: "1px dashed var(--gray-5)" }}
71+
>
72+
<Flex direction="column" gap="1">
73+
<Text className="font-medium text-(--gray-12) text-sm">
74+
Base branch for auto-PRs
75+
</Text>
76+
<Text className="text-(--gray-11) text-[13px]">
77+
Point auto-started inbox PRs at a specific branch per repository.
78+
Repositories without an override target their default branch.
79+
</Text>
80+
</Flex>
81+
82+
{isLoading ? (
83+
<Box className="h-[32px] w-[320px] animate-pulse rounded bg-gray-3" />
84+
) : (
85+
<Flex direction="column" gap="2">
86+
{entries.map(([repo, branch]) => (
87+
<BaseBranchRow
88+
key={repo}
89+
repo={repo}
90+
value={branch}
91+
integrationId={getIntegrationIdForRepo(repo)}
92+
disabled={disabled}
93+
onCommit={commit}
94+
onRemove={remove}
95+
/>
96+
))}
97+
98+
<Flex align="center" gap="2">
99+
<Box className="min-w-[220px] max-w-[280px]">
100+
<GitHubRepoPicker
101+
value={pendingRepo}
102+
onChange={setPendingRepo}
103+
repositories={selectableRepositories}
104+
isLoading={
105+
isLoadingRepos || (isRepoPickerOpen && repoPage.isPending)
106+
}
107+
isRefreshing={isRefreshingRepos}
108+
onRefresh={refreshRepositories}
109+
open={isRepoPickerOpen}
110+
onOpenChange={setIsRepoPickerOpen}
111+
searchQuery={repoSearch}
112+
onSearchQueryChange={setRepoSearch}
113+
hasMore={repoPage.hasMore}
114+
onLoadMore={repoPage.loadMore}
115+
disabled={disabled}
116+
placeholder="Add a repository…"
117+
size="2"
118+
/>
119+
</Box>
120+
{pendingRepo ? (
121+
<BaseBranchRow
122+
key={`add-${pendingRepo}`}
123+
repo={pendingRepo}
124+
value={undefined}
125+
integrationId={getIntegrationIdForRepo(pendingRepo)}
126+
disabled={disabled}
127+
onCommit={(repo, branch) => {
128+
commit(repo, branch);
129+
setPendingRepo(null);
130+
setRepoSearch("");
131+
}}
132+
/>
133+
) : null}
134+
</Flex>
135+
</Flex>
136+
)}
137+
</Flex>
138+
);
139+
}
140+
141+
interface BaseBranchRowProps {
142+
repo: string;
143+
value: string | undefined;
144+
integrationId: number | undefined;
145+
disabled?: boolean;
146+
onCommit: (repo: string, branch: string) => void;
147+
onRemove?: (repo: string) => void;
148+
}
149+
150+
function BaseBranchRow({
151+
repo,
152+
value,
153+
integrationId,
154+
disabled = false,
155+
onCommit,
156+
onRemove,
157+
}: BaseBranchRowProps) {
158+
const isAdd = value === undefined;
159+
const [search, setSearch] = useState("");
160+
const [draft, setDraft] = useState<string | null>(null);
161+
162+
const branchQuery = useGithubBranches(integrationId, repo, search, true);
163+
const hasIntegrationId = !!integrationId;
164+
165+
const selectedBranch = isAdd ? draft : (value ?? null);
166+
167+
return (
168+
<Flex align="center" gap="2">
169+
{!isAdd ? (
170+
<Text className="min-w-[220px] max-w-[280px] truncate text-(--gray-12) text-sm">
171+
{repo}
172+
</Text>
173+
) : null}
174+
<BranchSelector
175+
repoPath={repo}
176+
currentBranch={null}
177+
defaultBranch={branchQuery.data?.defaultBranch ?? null}
178+
workspaceMode="cloud"
179+
disabled={disabled || !hasIntegrationId}
180+
selectedBranch={selectedBranch}
181+
onBranchSelect={(branch) => {
182+
if (isAdd) {
183+
setDraft(branch);
184+
} else if (branch) {
185+
onCommit(repo, branch);
186+
}
187+
}}
188+
cloudBranches={branchQuery.data?.branches}
189+
cloudBranchesLoading={branchQuery.isPending}
190+
isRefreshing={branchQuery.isRefreshing}
191+
cloudBranchesFetchingMore={branchQuery.isFetchingMore}
192+
cloudBranchesHasMore={branchQuery.hasMore}
193+
cloudSearchQuery={search}
194+
onCloudSearchChange={setSearch}
195+
onCloudLoadMore={branchQuery.loadMore}
196+
/>
197+
{isAdd ? (
198+
<Button
199+
size="sm"
200+
disabled={disabled || !draft}
201+
onClick={() => {
202+
if (draft) onCommit(repo, draft);
203+
}}
204+
>
205+
Add
206+
</Button>
207+
) : (
208+
<IconButton
209+
variant="ghost"
210+
color="gray"
211+
aria-label={`Remove base branch override for ${repo}`}
212+
disabled={disabled}
213+
onClick={() => onRemove?.(repo)}
214+
>
215+
<X size={14} />
216+
</IconButton>
217+
)}
218+
</Flex>
219+
);
220+
}

0 commit comments

Comments
 (0)