Repository navigation
feat(github): connect multiple GitHub organizations to one Paradev organization #9396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
66f73c0
24fe3e8
5b949c7
de42b58
cf369ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { saveInstallation } from "./saveInstallation"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { db } from "@superset/db/client"; | ||
| import { githubInstallations } from "@superset/db/schema"; | ||
| import { eq } from "drizzle-orm"; | ||
|
|
||
| interface SaveInstallationInput { | ||
| organizationId: string; | ||
| userId: string; | ||
| installationId: string; | ||
| accountLogin: string; | ||
| accountType: string; | ||
| permissions: Record<string, string>; | ||
| } | ||
|
|
||
| /** Undefined when another organization owns the installation. */ | ||
| export async function saveInstallation({ | ||
| organizationId, | ||
| userId, | ||
| installationId, | ||
| ...account | ||
| }: SaveInstallationInput) { | ||
| const fields = { | ||
| ...account, | ||
| connectedByUserId: userId, | ||
| suspended: false, | ||
| suspendedAt: null, | ||
| }; | ||
| const [saved] = await db | ||
| .insert(githubInstallations) | ||
| .values({ ...fields, organizationId, installationId }) | ||
| .onConflictDoUpdate({ | ||
| target: [githubInstallations.installationId], | ||
| set: { ...fields, updatedAt: new Date() }, | ||
| setWhere: eq(githubInstallations.organizationId, organizationId), | ||
| }) | ||
| .returning(); | ||
| return saved; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,7 @@ export async function POST(request: Request) { | |
| .onConflictDoUpdate({ | ||
| target: [githubRepositories.repoId], | ||
| set: { | ||
| installationId: installation.id, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Syncing a repository shared by two installations reassigns its single row to the last installation, so disconnecting that account cascades the repository and pull requests even while the other account still provides access. Preserve or reassign shared repositories before deleting an installation. Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| organizationId, | ||
| owner: repo.owner.login, | ||
| name: repo.name, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,32 +120,40 @@ export function IntegrationsSettings({ | |
| { enabled: !!activeOrganizationId }, | ||
| ); | ||
|
|
||
| const [githubInstallation, setGithubInstallation] = | ||
| useState<GithubInstallation | null>(null); | ||
| const [githubInstallations, setGithubInstallations] = useState< | ||
| GithubInstallation[] | ||
| >([]); | ||
| const [isLoadingGithub, setIsLoadingGithub] = useState(true); | ||
|
|
||
| const fetchGithubInstallation = useCallback(async () => { | ||
| const fetchGithubInstallations = useCallback(async () => { | ||
| if (!activeOrganizationId) { | ||
| setIsLoadingGithub(false); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const result = | ||
| await apiTrpcClient.integration.github.getInstallation.query({ | ||
| await apiTrpcClient.integration.github.listInstallations.query({ | ||
| organizationId: activeOrganizationId, | ||
| }); | ||
| setGithubInstallation(result); | ||
| setGithubInstallations(result); | ||
| } catch (err) { | ||
| console.error("[integrations] Failed to fetch GitHub installation:", err); | ||
| console.error( | ||
| "[integrations] Failed to fetch GitHub installations:", | ||
| err, | ||
| ); | ||
| } finally { | ||
| setIsLoadingGithub(false); | ||
| } | ||
| }, [activeOrganizationId]); | ||
|
|
||
| useEffect(() => { | ||
| fetchGithubInstallation(); | ||
| }, [fetchGithubInstallation]); | ||
| fetchGithubInstallations(); | ||
| }, [fetchGithubInstallations]); | ||
|
|
||
| const activeGithubInstallations = githubInstallations.filter( | ||
| (installation) => !installation.suspended, | ||
| ); | ||
|
|
||
| const slackConnection = integrations?.find((i) => i.provider === "slack"); | ||
|
|
||
|
|
@@ -156,8 +164,11 @@ export function IntegrationsSettings({ | |
| isLoading: isLinearPending, | ||
| }, | ||
| github: { | ||
| isConnected: !!githubInstallation && !githubInstallation.suspended, | ||
| connectedOrgName: githubInstallation?.accountLogin, | ||
| isConnected: activeGithubInstallations.length > 0, | ||
| connectedOrgName: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The desktop page only concatenates account names into one status string and still exposes one generic Manage action, so users cannot disconnect a selected GitHub account from desktop. Render per-installation disconnect controls or pass the selected installation to account-specific management. Prompt for AI agents |
||
| activeGithubInstallations | ||
| .map((installation) => installation.accountLogin) | ||
| .join(", ") || null, | ||
| isLoading: isLoadingGithub, | ||
| }, | ||
| slack: { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,100 +1,50 @@ | ||
| "use client"; | ||
|
|
||
| import { Trans } from "@lingui/react/macro"; | ||
| import { | ||
| AlertDialog, | ||
| AlertDialogAction, | ||
| AlertDialogCancel, | ||
| AlertDialogContent, | ||
| AlertDialogDescription, | ||
| AlertDialogFooter, | ||
| AlertDialogHeader, | ||
| AlertDialogTitle, | ||
| AlertDialogTrigger, | ||
| } from "@superset/ui/alert-dialog"; | ||
| import { Button } from "@superset/ui/button"; | ||
| import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| import { Unplug } from "lucide-react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { Plus } from "lucide-react"; | ||
| import { env } from "@/env"; | ||
| import { useTRPC } from "@/trpc/react"; | ||
| import { InstallationRow } from "./components/InstallationRow"; | ||
|
|
||
| interface ConnectionControlsProps { | ||
| organizationId: string; | ||
| isConnected: boolean; | ||
| installations: Array<{ | ||
| id: string; | ||
| accountLogin: string; | ||
| accountType: string; | ||
| suspended: boolean; | ||
| }>; | ||
| } | ||
|
|
||
| export function ConnectionControls({ | ||
| organizationId, | ||
| isConnected, | ||
| installations, | ||
| }: ConnectionControlsProps) { | ||
| const trpc = useTRPC(); | ||
| const router = useRouter(); | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| const disconnectMutation = useMutation( | ||
| trpc.integration.github.disconnect.mutationOptions({ | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ | ||
| queryKey: trpc.integration.github.getInstallation.queryKey({ | ||
| organizationId, | ||
| }), | ||
| }); | ||
| router.refresh(); | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const handleConnect = () => { | ||
| window.location.href = `${env.NEXT_PUBLIC_API_URL}/api/github/install?organizationId=${organizationId}`; | ||
| }; | ||
|
|
||
| const handleDisconnect = () => { | ||
| disconnectMutation.mutate({ organizationId }); | ||
| }; | ||
|
|
||
| if (isConnected) { | ||
| if (installations.length === 0) { | ||
| return ( | ||
| <AlertDialog> | ||
| <AlertDialogTrigger asChild> | ||
| <Button variant="outline" disabled={disconnectMutation.isPending}> | ||
| <Unplug className="mr-2 size-4" /> | ||
| {disconnectMutation.isPending ? ( | ||
| <Trans>Disconnecting...</Trans> | ||
| ) : ( | ||
| <Trans>Disconnect</Trans> | ||
| )} | ||
| </Button> | ||
| </AlertDialogTrigger> | ||
| <AlertDialogContent> | ||
| <AlertDialogHeader> | ||
| <AlertDialogTitle> | ||
| <Trans>Disconnect GitHub?</Trans> | ||
| </AlertDialogTitle> | ||
| <AlertDialogDescription> | ||
| <Trans> | ||
| This will disconnect GitHub from your organization. The GitHub | ||
| App will remain installed but will no longer sync data. You can | ||
| reconnect at any time. | ||
| </Trans> | ||
| </AlertDialogDescription> | ||
| </AlertDialogHeader> | ||
| <AlertDialogFooter> | ||
| <AlertDialogCancel> | ||
| <Trans>Cancel</Trans> | ||
| </AlertDialogCancel> | ||
| <AlertDialogAction onClick={handleDisconnect}> | ||
| <Trans>Disconnect</Trans> | ||
| </AlertDialogAction> | ||
| </AlertDialogFooter> | ||
| </AlertDialogContent> | ||
| </AlertDialog> | ||
| <Button onClick={handleConnect}> | ||
| <Trans>Install GitHub App</Trans> | ||
| </Button> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Button onClick={handleConnect}> | ||
| <Trans>Install GitHub App</Trans> | ||
| </Button> | ||
| <div className="space-y-3"> | ||
| {installations.map((installation) => ( | ||
| <InstallationRow | ||
| key={installation.id} | ||
| organizationId={organizationId} | ||
| installation={installation} | ||
| /> | ||
| ))} | ||
| <Button variant="outline" onClick={handleConnect}> | ||
| <Plus className="mr-2 size-4" /> | ||
| <Trans>Add GitHub organization</Trans> | ||
| </Button> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: ParadevAI/Paradev
Length of output: 3675
Script executed:
Repository: ParadevAI/Paradev
Length of output: 23500
Web query:
PostgreSQL official documentation INSERT ON CONFLICT DO UPDATE WHERE condition false RETURNING rows not returnedResult:
Script executed:
Repository: ParadevAI/Paradev
Length of output: 9842
Web query:
PostgreSQL 16 documentation INSERT returning ON CONFLICT DO UPDATE WHERE condition not satisfied row not returnedResult:
Script executed:
Repository: ParadevAI/Paradev
Length of output: 10917
Web query:
site:postgresql.org/docs/17/sql-insert.html "WHERE clause condition was not satisfied" "row will not be returned"Result:
Report the ownership conflict after a failed upsert.
If another organization saves the installation after the pre-check,
saveInstallationreturns no row. The user then sees a retry message instead of the ownership-conflict instructions. Returnalready_connectedfor this result.Suggested fix
Committable suggestion