Files
multica/packages/views/settings/components/settings-page.tsx
Bohan Jiang caeb146bac feat(github): GitHub App integration for PR ↔ issue linking (#1817)
* feat(github): GitHub App backend for PR ↔ issue linking

- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.

Co-authored-by: multica-agent <github@multica.ai>

* feat(github): UI for connecting GitHub and viewing linked PRs

- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast

- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): i18n the new GitHub UI strings to satisfy lint

CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 13:49:03 +08:00

165 lines
5.7 KiB
TypeScript

"use client";
import React from "react";
import {
User,
SlidersHorizontal,
Key,
Settings,
Users,
FolderGit2,
FlaskConical,
Bell,
Plug,
} from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useNavigation } from "../../navigation";
import { AccountTab } from "./account-tab";
import { PreferencesTab } from "./preferences-tab";
import { TokensTab } from "./tokens-tab";
import { WorkspaceTab } from "./workspace-tab";
import { MembersTab } from "./members-tab";
import { RepositoriesTab } from "./repositories-tab";
import { IntegrationsTab } from "./integrations-tab";
import { LabsTab } from "./labs-tab";
import { NotificationsTab } from "./notifications-tab";
import { useT } from "../../i18n";
const ACCOUNT_TAB_KEYS = ["profile", "preferences", "notifications", "tokens"] as const;
const ACCOUNT_TAB_ICONS = {
profile: User,
preferences: SlidersHorizontal,
notifications: Bell,
tokens: Key,
} as const;
const WORKSPACE_TAB_KEYS = ["general", "repositories", "integrations", "labs", "members"] as const;
const WORKSPACE_TAB_VALUES = {
general: "workspace",
repositories: "repositories",
integrations: "integrations",
labs: "labs",
members: "members",
} as const;
const WORKSPACE_TAB_ICONS = {
general: Settings,
repositories: FolderGit2,
integrations: Plug,
labs: FlaskConical,
members: Users,
} as const;
const DEFAULT_TAB = "profile";
const TAB_QUERY_KEY = "tab";
export interface ExtraSettingsTab {
value: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
content: React.ReactNode;
}
interface SettingsPageProps {
/** Additional tabs injected by platform (e.g. desktop daemon settings) */
extraAccountTabs?: ExtraSettingsTab[];
}
export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
const { t } = useT("settings");
const workspaceName = useCurrentWorkspace()?.name;
const navigation = useNavigation();
// Whitelist of valid tab values; unknown ?tab=… values silently fall back to
// the default. Whitelisting also blocks junk like ?tab=<script> from
// surfacing in the DOM via Radix Tabs internals.
const validTabs = React.useMemo(
() =>
new Set<string>([
...ACCOUNT_TAB_KEYS,
...Object.values(WORKSPACE_TAB_VALUES),
...(extraAccountTabs?.map((tab) => tab.value) ?? []),
]),
[extraAccountTabs],
);
const tabFromUrl = navigation.searchParams.get(TAB_QUERY_KEY);
const activeTab =
tabFromUrl && validTabs.has(tabFromUrl) ? tabFromUrl : DEFAULT_TAB;
// replace (not push) so settings tab switches don't pollute browser history.
// Preserve any other query params the page may carry.
const handleTabChange = (next: string) => {
const params = new URLSearchParams(navigation.searchParams);
params.set(TAB_QUERY_KEY, next);
navigation.replace(`${navigation.pathname}?${params.toString()}`);
};
return (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
orientation="vertical"
className="flex-1 min-h-0 gap-0 flex flex-col md:flex-row md:overflow-hidden overflow-y-auto"
>
{/* Left nav (stacks on top on mobile, sidebar on md+) */}
<div className="shrink-0 md:w-52 border-b md:border-b-0 md:border-r md:overflow-y-auto p-3 md:p-4">
<h1 className="text-sm font-semibold mb-4 px-2">{t(($) => $.page.title)}</h1>
<TabsList variant="line" className="flex-col items-stretch w-full">
{/* My Account group */}
<span className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
{t(($) => $.page.my_account)}
</span>
{ACCOUNT_TAB_KEYS.map((key) => {
const Icon = ACCOUNT_TAB_ICONS[key];
return (
<TabsTrigger key={key} value={key}>
<Icon className="h-4 w-4" />
{t(($) => $.page.tabs[key])}
</TabsTrigger>
);
})}
{extraAccountTabs?.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
{/* Workspace group */}
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
{workspaceName ?? t(($) => $.page.workspace_fallback)}
</span>
{WORKSPACE_TAB_KEYS.map((key) => {
const Icon = WORKSPACE_TAB_ICONS[key];
return (
<TabsTrigger key={key} value={WORKSPACE_TAB_VALUES[key]}>
<Icon className="h-4 w-4" />
{t(($) => $.page.tabs[key])}
</TabsTrigger>
);
})}
</TabsList>
</div>
{/* Right content */}
<div className="flex-1 min-w-0 md:overflow-y-auto">
<div className="w-full max-w-3xl mx-auto p-4 md:p-6">
<TabsContent value="profile"><AccountTab /></TabsContent>
<TabsContent value="preferences"><PreferencesTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="integrations"><IntegrationsTab /></TabsContent>
<TabsContent value="labs"><LabsTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
{extraAccountTabs?.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>{tab.content}</TabsContent>
))}
</div>
</div>
</Tabs>
);
}