From 1068c413da92168772b7b35bdc2ad0432be680c9 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 8 Apr 2026 14:42:45 +0800 Subject: [PATCH] feat(github): add GitHub App integration for PR-issue linking - Add database migration for github_installation and issue_pull_request tables - Implement GitHub webhook handler with HMAC-SHA256 signature validation - Auto-link PRs to issues via branch name and PR title/body identifiers - Auto-transition issue status to "done" when linked PR is merged - Add GitHub App installation OAuth callback flow - Add REST APIs for listing installations and pull requests - Add PR status card component in issue detail sidebar - Add Integrations settings tab with Connect GitHub flow - Real-time PR status updates via WebSocket events --- .../settings/_components/integrations-tab.tsx | 161 ++++++ apps/web/app/(dashboard)/settings/page.tsx | 5 +- .../issues/components/issue-detail.tsx | 4 + .../issues/components/pr-status-card.tsx | 93 ++++ apps/web/shared/api/client.ts | 19 + apps/web/shared/types/events.ts | 6 +- apps/web/shared/types/github.ts | 25 + apps/web/shared/types/index.ts | 1 + server/cmd/server/router.go | 13 + server/internal/handler/github.go | 526 ++++++++++++++++++ .../032_github_integration.down.sql | 2 + .../migrations/032_github_integration.up.sql | 38 ++ server/pkg/db/generated/github.sql.go | 393 +++++++++++++ server/pkg/db/generated/models.go | 27 + server/pkg/db/queries/github.sql | 61 ++ server/pkg/protocol/events.go | 6 + 16 files changed, 1378 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(dashboard)/settings/_components/integrations-tab.tsx create mode 100644 apps/web/features/issues/components/pr-status-card.tsx create mode 100644 apps/web/shared/types/github.ts create mode 100644 server/internal/handler/github.go create mode 100644 server/migrations/032_github_integration.down.sql create mode 100644 server/migrations/032_github_integration.up.sql create mode 100644 server/pkg/db/generated/github.sql.go create mode 100644 server/pkg/db/queries/github.sql diff --git a/apps/web/app/(dashboard)/settings/_components/integrations-tab.tsx b/apps/web/app/(dashboard)/settings/_components/integrations-tab.tsx new file mode 100644 index 000000000..1b3625953 --- /dev/null +++ b/apps/web/app/(dashboard)/settings/_components/integrations-tab.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { GitBranch, Trash2, ExternalLink, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { api } from "@/shared/api"; +import type { GitHubInstallation } from "@/shared/types"; +import { useWSEvent } from "@/features/realtime"; + +export function IntegrationsTab() { + const user = useAuthStore((s) => s.user); + const workspace = useWorkspaceStore((s) => s.workspace); + const members = useWorkspaceStore((s) => s.members); + + const [installations, setInstallations] = useState([]); + const [loading, setLoading] = useState(true); + + const currentMember = members.find((m) => m.user_id === user?.id) ?? null; + const canManage = currentMember?.role === "owner" || currentMember?.role === "admin"; + + const fetchInstallations = useCallback(async () => { + try { + const data = await api.listGitHubInstallations(); + setInstallations(data); + } catch { + // Silently handle — workspace may not have any installations + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchInstallations(); + }, [fetchInstallations]); + + // Real-time updates + useWSEvent( + "github_installation:created", + useCallback(() => fetchInstallations(), [fetchInstallations]), + ); + useWSEvent( + "github_installation:deleted", + useCallback(() => fetchInstallations(), [fetchInstallations]), + ); + + const handleConnect = () => { + const appSlug = process.env.NEXT_PUBLIC_GITHUB_APP_SLUG; + if (!appSlug) { + toast.error("GitHub App not configured. Set NEXT_PUBLIC_GITHUB_APP_SLUG."); + return; + } + // The callback URL is configured in the GitHub App settings. + // We pass workspace_id as state so the backend knows which workspace to link. + const callbackUrl = `${window.location.origin}/api/github/callback`; + const installUrl = `https://github.com/apps/${appSlug}/installations/new?state=${workspace?.id}`; + window.open(installUrl, "_blank", "noopener,noreferrer"); + }; + + const handleDisconnect = async (installation: GitHubInstallation) => { + try { + await api.deleteGitHubInstallation(installation.installation_id); + setInstallations((prev) => prev.filter((i) => i.id !== installation.id)); + toast.success(`Disconnected ${installation.account_login}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to disconnect"); + } + }; + + if (!workspace) return null; + + return ( +
+
+

GitHub Integration

+ + + +

+ Connect your GitHub repositories to automatically link pull requests to issues. + When a PR branch or description contains an issue identifier (e.g. MUL-82), + the PR status will appear on the issue and merging will mark it as done. +

+ + {loading ? ( +
Loading...
+ ) : installations.length > 0 ? ( +
+ {installations.map((gi) => ( +
+
+ +
+
{gi.account_login}
+
+ {gi.account_type} · Connected{" "} + {new Date(gi.created_at).toLocaleDateString()} +
+
+
+
+ + + + {canManage && ( + + )} +
+
+ ))} +
+ ) : ( +
+ +

+ No GitHub accounts connected +

+

+ Install the Multica GitHub App to link PRs with issues automatically. +

+
+ )} + + {canManage && ( +
+ +
+ )} + + {!canManage && ( +

+ Only admins and owners can manage integrations. +

+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 7042ba808..7173f2126 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react"; +import { User, Palette, Key, Settings, Users, FolderGit2, Plug } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { useWorkspaceStore } from "@/features/workspace"; import { AccountTab } from "./_components/account-tab"; @@ -9,6 +9,7 @@ import { TokensTab } from "./_components/tokens-tab"; import { WorkspaceTab } from "./_components/workspace-tab"; import { MembersTab } from "./_components/members-tab"; import { RepositoriesTab } from "./_components/repositories-tab"; +import { IntegrationsTab } from "./_components/integrations-tab"; const accountTabs = [ { value: "profile", label: "Profile", icon: User }, @@ -19,6 +20,7 @@ const accountTabs = [ const workspaceTabs = [ { value: "workspace", label: "General", icon: Settings }, { value: "repositories", label: "Repositories", icon: FolderGit2 }, + { value: "integrations", label: "Integrations", icon: Plug }, { value: "members", label: "Members", icon: Users }, ]; @@ -63,6 +65,7 @@ export default function SettingsPage() { + diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 43c388850..80cde2f6f 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -63,6 +63,7 @@ import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; +import { PRStatusCard } from "./pr-status-card"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; @@ -1030,6 +1031,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo } + {/* Pull Requests section */} + + {/* Details section */}