From 2a6c40d45ae7cf356bb542338af6b381d8df0e43 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:31:17 +0100 Subject: [PATCH] Connect GitHub with a token or Sign in with GitHub, then pick repos and tools The connect wizard offers both sign-ins when the GitHub App is set up (token only otherwise, with a link to create a fine-grained token), and says when GitHub refuses a token. After sign-in, one step turns on the read-only GitHub tools and picks a repository to sync. The repository picker searches what the connection can read; a GitHub App sign-in can choose more repositories on GitHub and the list reloads. A token connection is shown by its GitHub login rather than a key hint, and a connection set up without tools can add them from its page. The GitHub upload tile keeps reading public repositories by URL, and points to the GitHub connection for private ones. --- frontend/src/api/endpoints.ts | 10 +- .../src/api/services/connectorsService.ts | 4 + frontend/src/api/services/userService.ts | 6 +- frontend/src/assets/connectors/github.svg | 1 + frontend/src/components/ConnectorAuth.tsx | 7 + .../src/connectors/ConnectWizard.test.tsx | 178 +++++++++++++++ frontend/src/connectors/ConnectWizard.tsx | 190 ++++++++++++--- .../src/connectors/ConnectionDrawer.test.tsx | 59 ++++- frontend/src/connectors/ConnectionDrawer.tsx | 72 +++++- frontend/src/connectors/RepoPicker.test.tsx | 164 +++++++++++++ frontend/src/connectors/RepoPicker.tsx | 216 ++++++++++++++++++ frontend/src/connectors/i18n.ts | 9 + frontend/src/connectors/types.ts | 15 ++ frontend/src/locale/de.json | 47 +++- frontend/src/locale/en.json | 47 +++- frontend/src/locale/es.json | 47 +++- frontend/src/locale/jp.json | 47 +++- frontend/src/locale/ru.json | 47 +++- frontend/src/locale/zh-TW.json | 47 +++- frontend/src/locale/zh.json | 47 +++- frontend/src/upload/Upload.test.tsx | 75 ++++++ frontend/src/upload/Upload.tsx | 51 ++++- 22 files changed, 1306 insertions(+), 80 deletions(-) create mode 100644 frontend/src/assets/connectors/github.svg create mode 100644 frontend/src/connectors/RepoPicker.test.tsx create mode 100644 frontend/src/connectors/RepoPicker.tsx diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 40d996d6..5b5376f9 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -95,10 +95,14 @@ const endpoints = { DELETE_TOOL: '/api/delete_tool', PARSE_SPEC: '/api/parse_spec', SYNC_CONNECTOR: '/api/connectors/sync', - CONNECTOR_AUTH: (provider: string, connectionId?: string) => + CONNECTOR_AUTH: ( + provider: string, + connectionId?: string, + install?: boolean, + ) => `/api/connectors/auth?provider=${encodeURIComponent(provider)}${ connectionId ? `&connection_id=${encodeURIComponent(connectionId)}` : '' - }`, + }${install ? '&install=1' : ''}`, CONNECTOR_FILES: '/api/connectors/files', CONNECTOR_VALIDATE_SESSION: '/api/connectors/validate-session', CONNECTOR_DISCONNECT: '/api/connectors/disconnect', @@ -117,6 +121,8 @@ const endpoints = { `/api/connections/${encodeURIComponent(id)}/refresh-tools`, CONNECTION_TOOL_PERMISSIONS: (id: string, toolId: string) => `/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/permissions`, + CONNECTION_REPOSITORIES: (id: string) => + `/api/connections/${encodeURIComponent(id)}/repositories`, CONNECTIONS_CLAIM: '/api/connections/claim', TOOL_CREDENTIAL_MODE: (toolId: string) => `/api/connections/tools/${encodeURIComponent(toolId)}/credential-mode`, diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts index ebe7c7bd..56356801 100644 --- a/frontend/src/api/services/connectorsService.ts +++ b/frontend/src/api/services/connectorsService.ts @@ -86,6 +86,10 @@ const connectorsService = { token, ), ), + repositories: async (id: string, token: string | null) => + json( + await apiClient.get(endpoints.USER.CONNECTION_REPOSITORIES(id), token), + ), refreshTools: async (id: string, token: string | null) => json( await apiClient.post( diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index cc979a83..54a1e9fa 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -292,8 +292,12 @@ const userService = { provider: string, token: string | null, connectionId?: string, + install?: boolean, ): Promise => - apiClient.get(endpoints.USER.CONNECTOR_AUTH(provider, connectionId), token), + apiClient.get( + endpoints.USER.CONNECTOR_AUTH(provider, connectionId, install), + token, + ), getConnectorFiles: ( data: any, token: string | null, diff --git a/frontend/src/assets/connectors/github.svg b/frontend/src/assets/connectors/github.svg new file mode 100644 index 00000000..ffa82ef8 --- /dev/null +++ b/frontend/src/assets/connectors/github.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ConnectorAuth.tsx b/frontend/src/components/ConnectorAuth.tsx index cc98a9bd..b3e03a80 100644 --- a/frontend/src/components/ConnectorAuth.tsx +++ b/frontend/src/components/ConnectorAuth.tsx @@ -13,6 +13,11 @@ interface ConnectorAuthOptions { provider: string; /** Reconnect this connection (the same account) rather than add one. */ connectionId?: string; + /** + * GitHub: open the GitHub App's installation page (where the repositories + * it can read are chosen) instead of the sign-in page. + */ + install?: boolean; onSuccess: (data: { connection_id: string; user_email: string }) => void; onError: (error: string) => void; } @@ -35,6 +40,7 @@ interface ConnectorAuthProps extends ConnectorAuthOptions { export function useConnectorAuth({ provider, connectionId, + install = false, onSuccess, onError, }: ConnectorAuthOptions): () => void { @@ -129,6 +135,7 @@ export function useConnectorAuth({ provider, token, connectionId, + install, ); if (!mountedRef.current) { authWindow.close(); diff --git a/frontend/src/connectors/ConnectWizard.test.tsx b/frontend/src/connectors/ConnectWizard.test.tsx index 472163e4..b8e0a698 100644 --- a/frontend/src/connectors/ConnectWizard.test.tsx +++ b/frontend/src/connectors/ConnectWizard.test.tsx @@ -64,6 +64,14 @@ vi.mock('../components/FilePicker', () => ({ ), })); +vi.mock('./RepoPicker', () => ({ + default: ({ onChange }: { onChange: (name: string) => void }) => ( + + ), +})); + import notificationsReducer, { sseEventReceived, } from '../notifications/notificationsSlice'; @@ -118,6 +126,32 @@ const drive: ConnectorDefinition = { setup: { tools: 'off', sync: 'ask' }, }; +const github: ConnectorDefinition = { + ...base, + key: 'github', + name: 'GitHub', + icon: 'github', + category: 'dev', + auth_kind: 'api_key', + capabilities: ['sync', 'read'], + credential_fields: [ + { + key: 'access_token', + label: 'Personal access token', + secret: true, + required: true, + }, + ], + setup_fields: [ + { key: 'repo_url', label: 'Repository', secret: false, required: true }, + ], + sync_ingestor: 'github', + tool_templates: ['mcp_tool'], + setup: { tools: 'ask', sync: 'ask' }, + mcp_url: 'https://api.githubcopilot.com/mcp/readonly', + sign_in_methods: ['api_key'], +}; + const TELEGRAM_TOOL = { id: 'tool-1', name: 'telegram', @@ -389,4 +423,148 @@ describe('ConnectWizard', () => { ); open.mockRestore(); }); + + describe('GitHub', () => { + const connectWithToken = async () => { + service.createConnection.mockResolvedValue({ + success: true, + connection: { id: 'conn-gh' }, + }); + await typeInto( + document.body.querySelector( + 'input[type="password"]', + )!, + 'github_pat_abc', + ); + await click('settings.connectors.status.connect'); + }; + + it('connects with a token when no GitHub App is set up', async () => { + await render(github); + // One way in: no method switch, a token field and how to make one. + expect(document.body.querySelector('[role="radiogroup"]')).toBeNull(); + expect(document.body.textContent).toContain( + 'settings.connectors.github.tokenHint', + ); + await connectWithToken(); + expect(service.createConnection).toHaveBeenCalledWith( + { + connector_key: 'github', + credentials: { access_token: 'github_pat_abc' }, + }, + null, + ); + // Tools are asked about, not created on sign-in. + expect(service.setup).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSetUp', + ); + }); + + it('offers Sign in with GitHub first, and a token instead', async () => { + await render({ ...github, sign_in_methods: ['oauth', 'api_key'] }); + expect(document.body.querySelector('input[type="password"]')).toBeNull(); + await click('settings.connectors.wizard.methodToken'); + expect( + document.body.querySelector('input[type="password"]'), + ).not.toBeNull(); + await click('settings.connectors.wizard.methodOauth'); + await click('settings.connectors.wizard.signIn'); + // The OAuth pop-up (mocked) reported the connection. + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSetUp', + ); + }); + + it('says when GitHub refused the token', async () => { + service.createConnection.mockResolvedValue({ + success: false, + code: 'invalid_credentials', + }); + await render(github); + await typeInto( + document.body.querySelector( + 'input[type="password"]', + )!, + 'nope', + ); + await click('settings.connectors.status.connect'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.credentialsRejected', + ); + }); + + it('adds the read-only tools and syncs the picked repository in one step', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [{ ...TELEGRAM_TOOL, name: 'mcp_tool', display_name: 'GitHub' }], + sources: [{ id: 'src-1', name: 'octocat/private' }], + }); + await render(github); + await connectWithToken(); + await click('pick-repo'); + await click('settings.connectors.wizard.continue'); + const [id, body] = service.setup.mock.calls[0]; + expect(id).toBe('conn-gh'); + expect(body).toEqual({ + create_tools: true, + sync: { + items: { repo_url: 'octocat/private' }, + frequency: 'weekly', + name: 'octocat/private', + }, + }); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsHeading:1', + ); + }); + + it('can add only the tools', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [], + }); + await render(github); + await connectWithToken(); + await click('settings.connectors.wizard.continue'); + expect(service.setup.mock.calls[0][1]).toEqual({ create_tools: true }); + }); + + it('can sync without tools', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [], + }); + await render(github); + await connectWithToken(); + const toolSwitch = + document.body.querySelector('[role="switch"]')!; + expect(toolSwitch.getAttribute('aria-checked')).toBe('true'); + await act(async () => toolSwitch.click()); + await click('pick-repo'); + await click('settings.connectors.wizard.continue'); + expect(service.setup.mock.calls[0][1].create_tools).toBe(false); + expect(service.setup.mock.calls[0][1].sync.items).toEqual({ + repo_url: 'octocat/private', + }); + }); + + it('shows why the tools could not be added', async () => { + service.setup.mockResolvedValue({ + success: false, + code: 'tools_unavailable', + }); + await render(github); + await connectWithToken(); + await click('settings.connectors.wizard.continue'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsUnavailable', + ); + }); + }); }); diff --git a/frontend/src/connectors/ConnectWizard.tsx b/frontend/src/connectors/ConnectWizard.tsx index 547095f1..bfe6e186 100644 --- a/frontend/src/connectors/ConnectWizard.tsx +++ b/frontend/src/connectors/ConnectWizard.tsx @@ -1,4 +1,4 @@ -import { CircleAlert } from 'lucide-react'; +import { CircleAlert, ExternalLink } from 'lucide-react'; import { nanoid } from '@reduxjs/toolkit'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -30,6 +30,9 @@ import { SelectTrigger, SelectValue, } from '../components/ui/select'; +import { SettingRow, SettingRows } from '../components/ui/setting-row'; +import { Switch } from '../components/ui/switch'; +import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; import { setConversation, updateConversationId, @@ -44,10 +47,15 @@ import { formatCount } from '../utils/dateTimeUtils'; import ConnectorIcon from './ConnectorIcon'; import CredentialForm, { credentialsComplete } from './CredentialForm'; import { loadConnectors, selectConnections } from './connectorsSlice'; -import { connectorDescription, connectorName } from './i18n'; +import { connectorDescription, connectorName, isKeyHint } from './i18n'; +import RepoPicker from './RepoPicker'; import ToolPermissions from './ToolPermissions'; import useMcpOAuth, { type McpOAuthConfig } from './useMcpOAuth'; -import type { ConnectionTool, ConnectorDefinition } from './types'; +import type { + ConnectionTool, + ConnectorAuthKind, + ConnectorDefinition, +} from './types'; export type WizardMode = 'connect' | 'reconnect' | 'sync' | 'done'; @@ -57,6 +65,9 @@ const PICKER_CONNECTORS = new Set([ 'share_point', 'confluence', ]); +// Where a GitHub user makes a fine-grained token (Contents and Metadata: read). +const GITHUB_TOKEN_URL = + 'https://github.com/settings/personal-access-tokens/new'; type CreatedSource = { id: string; name: string }; @@ -103,6 +114,12 @@ export default function ConnectWizard({ const [sources, setSources] = useState([]); const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolders, setSelectedFolders] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + // A connector that asks about its tools (GitHub) offers them switched on. + const [toolsOn, setToolsOn] = useState(true); + const [chosenMethod, setChosenMethod] = useState( + null, + ); const [sourceName, setSourceName] = useState(''); const [nameTouched, setNameTouched] = useState(false); const [frequency, setFrequency] = useState( @@ -113,6 +130,21 @@ export default function ConnectWizard({ const connection = connections.find((c) => c.id === connectionId); const canSync = connector.setup.sync !== 'off' && !!connector.sync_ingestor; + // Tools the user opts into while choosing what to sync, not on sign-in. + const offerTools = + connector.setup.tools === 'ask' && + connector.tool_templates.length > 0 && + mode !== 'sync'; + // GitHub signs in two ways: Sign in with GitHub (when an admin set up the + // GitHub App) or a token. Reconnecting keeps the connection's own way. + const methods = connector.sign_in_methods?.length + ? connector.sign_in_methods + : [connector.auth_kind]; + const method: ConnectorAuthKind = + mode === 'reconnect' && connection?.auth_kind + ? connection.auth_kind + : (chosenMethod ?? methods[0]); + const usesOAuth = method === 'oauth'; // A finished MCP save or a reconnect lands here with only the connection id. useEffect(() => { @@ -139,7 +171,7 @@ export default function ConnectWizard({ ); if (setup?.success) setTools(setup.tools ?? []); } - setStep(canSync ? 'setup' : 'done'); + setStep(canSync || offerTools ? 'setup' : 'done'); }; // MCP presets (Notion, Linear…) sign in over MCP OAuth: no URL or auth @@ -236,7 +268,12 @@ export default function ConnectWizard({ setError( data?.code === 'encryption_key_default' ? t('settings.connectors.error.defaultKey') - : t('settings.connectors.wizard.connectFailed'), + : data?.code === 'invalid_credentials' + ? t('settings.connectors.wizard.credentialsRejected', { + name, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.wizard.connectFailed'), ); return; } @@ -252,14 +289,21 @@ export default function ConnectWizard({ if (!nameTouched && picked) setSourceName((current) => current || picked); }; + const isRepoPicker = connector.key === 'github'; const syncItems = (): Record => PICKER_CONNECTORS.has(connector.key) ? { file_ids: selectedFiles, folder_ids: selectedFolders } - : setupValues; + : isRepoPicker + ? { repo_url: selectedRepo } + : setupValues; - const canAddSource = PICKER_CONNECTORS.has(connector.key) + const hasSyncSelection = PICKER_CONNECTORS.has(connector.key) ? selectedFiles.length + selectedFolders.length > 0 - : credentialsComplete(connector.setup_fields, setupValues); + : isRepoPicker + ? !!selectedRepo + : credentialsComplete(connector.setup_fields, setupValues); + const wantsTools = offerTools && toolsOn; + const canAddSource = hasSyncSelection || wantsTools; const addSource = async () => { if (!connectionId) return; @@ -269,20 +313,30 @@ export default function ConnectWizard({ const data = await connectorsService.setup( connectionId, { - create_tools: false, - sync: { - items: syncItems(), - frequency, - name: sourceName.trim() || undefined, - }, + create_tools: wantsTools, + ...(hasSyncSelection && { + sync: { + items: syncItems(), + frequency, + name: sourceName.trim() || undefined, + }, + }), }, token, idempotencyKey, ); if (!data?.success) { - setError(data?.error || t('settings.connectors.wizard.syncFailed')); + setError( + data?.code === 'tools_unavailable' + ? t('settings.connectors.wizard.toolsUnavailable', { + name, + interpolation: { escapeValue: false }, + }) + : data?.error || t('settings.connectors.wizard.syncFailed'), + ); return; } + if (wantsTools) setTools(data.tools ?? []); setSources(data.sources ?? []); refresh(); setStep('done'); @@ -325,7 +379,7 @@ export default function ConnectWizard({ const summary = useMemo(() => { const account = connection?.account_label ?? ''; const accountLine = - connection?.auth_kind === 'api_key' + connection?.auth_kind === 'api_key' && isKeyHint(account) ? t('settings.connectors.wizard.connectedWithKey', { hint: account, interpolation: { escapeValue: false }, @@ -392,14 +446,54 @@ export default function ConnectWizard({ )} - {connector.auth_kind === 'oauth' || isMcpPreset ? null : ( - + {mode !== 'reconnect' && methods.length > 1 && ( + + value && setChosenMethod(value as ConnectorAuthKind) + } + aria-label={t('settings.connectors.wizard.methodLabel')} + > + {methods.map((value) => ( + + {value === 'oauth' + ? t('settings.connectors.wizard.methodOauth', { + name, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.wizard.methodToken')} + + ))} + + )} + {usesOAuth || isMcpPreset ? null : ( + <> + {connector.key === 'github' && ( +
+

+ {t('settings.connectors.github.tokenHint')} +

+ +
+ )} + + )} ); @@ -412,7 +506,41 @@ export default function ConnectWizard({ {error} )} - {connector.key === 'google_drive' && envVar('VITE_GOOGLE_CLIENT_ID') ? ( + {offerTools && ( + + + setToolsOn(checked === true)} + /> + + + )} + {isRepoPicker && connectionId ? ( + { + setSelectedRepo(fullName); + if (!nameTouched) setSourceName(fullName); + }} + /> + ) : connector.key === 'google_drive' && + envVar('VITE_GOOGLE_CLIENT_ID') ? ( { @@ -558,7 +688,11 @@ export default function ConnectWizard({ (mode === 'sync' ? onClose() : setStep('done'))} - submitLabel={t('modals.uploadDoc.train')} + submitLabel={ + offerTools + ? t('settings.connectors.wizard.continue') + : t('modals.uploadDoc.train') + } onSubmit={addSource} pending={pending} disabled={!canAddSource} diff --git a/frontend/src/connectors/ConnectionDrawer.test.tsx b/frontend/src/connectors/ConnectionDrawer.test.tsx index e2ecf524..01ccf1b0 100644 --- a/frontend/src/connectors/ConnectionDrawer.test.tsx +++ b/frontend/src/connectors/ConnectionDrawer.test.tsx @@ -11,6 +11,7 @@ const connectors = vi.hoisted(() => ({ getConnection: vi.fn(), getCatalog: vi.fn(async () => ({ success: true, connectors: [] })), listConnections: vi.fn(async () => ({ success: true, connections: [] })), + setup: vi.fn(), })); vi.mock('../api/services/connectorsService', () => ({ default: connectors })); @@ -99,6 +100,7 @@ describe('ConnectionDrawer', () => { connector_key: 'google_drive', status: 'connected', }, + { id: 'conn-gh', connector_key: 'github', status: 'connected' }, ], }, preference: { token: null }, @@ -121,13 +123,13 @@ describe('ConnectionDrawer', () => { container.remove(); }); - const render = async () => { + const render = async (connector: ConnectorDefinition = DRIVE) => { store = makeStore(); await act(async () => { root.render( @@ -216,4 +218,57 @@ describe('ConnectionDrawer', () => { 'settings.connectors.publisher', ); }); + + describe('GitHub', () => { + const GITHUB = { + ...DRIVE, + key: 'github', + name: 'GitHub', + icon: 'github', + auth_kind: 'api_key', + capabilities: ['sync', 'read'], + setup: { tools: 'ask', sync: 'ask' }, + sync_ingestor: 'github', + tool_templates: ['mcp_tool'], + } as unknown as ConnectorDefinition; + const TOKEN_DETAIL = { + ...DETAIL, + connector_key: 'github', + auth_kind: 'api_key', + account_label: 'octocat', + sources: [], + tools: [], + }; + + beforeEach(() => { + connectors.setup.mockReset(); + connectors.getConnection.mockResolvedValue({ + success: true, + connection: TOKEN_DETAIL, + }); + }); + + it('names a token connection by its account, not as a key hint', async () => { + await render(GITHUB); + expect(document.body.textContent).toContain( + 'settings.connectors.detail.connectedAs', + ); + expect(document.body.textContent).not.toContain( + 'settings.connectors.detail.keyEnding', + ); + }); + + it('adds the tools a connection skipped during setup', async () => { + connectors.setup.mockResolvedValue({ success: true, tools: [] }); + await render(GITHUB); + await act(async () => + button('settings.connectors.detail.addTools').click(), + ); + expect(connectors.setup).toHaveBeenCalledWith( + 'conn-1', + { create_tools: true }, + null, + ); + }); + }); }); diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx index 62df6b91..fb995378 100644 --- a/frontend/src/connectors/ConnectionDrawer.tsx +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -41,7 +41,7 @@ import { CapabilityBadges } from './ConnectorCard'; import ConnectorIcon from './ConnectorIcon'; import ConnectorSetupNotice from './ConnectorSetupNotice'; import { loadConnectors, selectConnections } from './connectorsSlice'; -import { connectorDescription, connectorName } from './i18n'; +import { connectorDescription, connectorName, isKeyHint } from './i18n'; import ToolPermissions from './ToolPermissions'; import type { ConnectionDetail, @@ -64,13 +64,14 @@ const STATUS_VARIANT: Record< }; /** - * "Connected as …", or the key hint for pasted credentials. A connection that - * is not working names its account without claiming it is connected. + * "Connected as …", or the key hint for pasted credentials (a GitHub token + * is named after its account instead). A connection that is not working + * names its account without claiming it is connected. */ function useAccountTitle() { const { t } = useTranslation(); return (detail: ConnectionDetail) => - detail.auth_kind === 'api_key' + detail.auth_kind === 'api_key' && isKeyHint(detail.account_label) ? t('settings.connectors.detail.keyEnding', { hint: detail.account_label, interpolation: { escapeValue: false }, @@ -247,6 +248,7 @@ function AccountSection({ onRefreshTools, onToggleTool, onSyncNow, + onAddTools, }: { connector: ConnectorDefinition; detail: ConnectionDetail; @@ -258,11 +260,20 @@ function AccountSection({ /** Turns a tool of this connection on or off for agents and chat. */ onToggleTool: (toolId: string, on: boolean) => Promise; onSyncNow: (source: ConnectionSource) => void; + /** Creates the tools a connection skipped while it was set up (GitHub's). */ + onAddTools: (detail: ConnectionDetail) => Promise; }) { const { t } = useTranslation(); const accountTitle = useAccountTitle(); const problem = useProblem(); const [refreshing, setRefreshing] = useState(false); + const [addingTools, setAddingTools] = useState(false); + const canAddTools = + connector.setup.tools === 'ask' && + (connector.tool_templates?.length ?? 0) > 0 && + connector.publisher !== 'custom' && + detail.tools.length === 0 && + detail.status === 'connected'; const canSync = connector.setup.sync !== 'off' && !!connector.sync_ingestor; const isMcp = detail.tools.some((tool) => tool.name === 'mcp_tool'); const menu: MenuOption[] = []; @@ -416,6 +427,36 @@ function AccountSection({ )} )} + {canAddTools && ( +
+ { + setAddingTools(true); + onAddTools(detail).finally(() => setAddingTools(false)); + }} + > + + {t('settings.connectors.detail.addTools')} + + } + /> + +
+ )} {detail.tools.length > 0 && (
{ + const data = await connectorsService + .setup(detail.id, { create_tools: true }, token) + .catch(() => null); + if (!data?.success) { + dispatch( + showActionToast({ + variant: 'destructive', + message: + data?.code === 'tools_unavailable' + ? t('settings.connectors.wizard.toolsUnavailable', { + name: connector ? connectorName(t, connector) : detail.name, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.detail.addToolsFailed'), + }), + ); + } + refresh(); + }; + const refreshTools = async (detail: ConnectionDetail) => { const data = await connectorsService.refreshTools(detail.id, token); dispatch( @@ -754,6 +816,7 @@ export default function ConnectionDrawer({ onRefreshTools={refreshTools} onToggleTool={toggleTool} onSyncNow={syncNow} + onAddTools={addTools} /> ))} @@ -799,6 +862,7 @@ export default function ConnectionDrawer({ onRefreshTools={refreshTools} onToggleTool={toggleTool} onSyncNow={syncNow} + onAddTools={addTools} /> ))}
diff --git a/frontend/src/connectors/RepoPicker.test.tsx b/frontend/src/connectors/RepoPicker.test.tsx new file mode 100644 index 00000000..39f9d33c --- /dev/null +++ b/frontend/src/connectors/RepoPicker.test.tsx @@ -0,0 +1,164 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && 'count' in opts ? `${key}:${opts.count}` : key, + }), +})); + +const service = vi.hoisted(() => ({ repositories: vi.fn() })); +vi.mock('../api/services/connectorsService', () => ({ default: service })); + +const install = vi.hoisted(() => ({ + options: null as null | { install?: boolean; onSuccess: () => void }, + start: vi.fn(), +})); +vi.mock('../components/ConnectorAuth', () => ({ + useConnectorAuth: (options: { install?: boolean; onSuccess: () => void }) => { + install.options = options; + return install.start; + }, +})); + +import RepoPicker from './RepoPicker'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const REPOS = [ + { + full_name: 'octocat/private', + private: true, + description: 'Secret stuff', + default_branch: 'main', + updated_at: '2026-09-01T00:00:00Z', + html_url: 'https://github.com/octocat/private', + }, + { + full_name: 'octocat/hello-world', + private: false, + description: '', + default_branch: 'main', + updated_at: '2026-08-01T00:00:00Z', + html_url: 'https://github.com/octocat/hello-world', + }, +]; + +describe('RepoPicker', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + service.repositories.mockReset(); + install.start.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (value: string | null, onChange = vi.fn()) => { + await act(async () => { + root.render( + , + ); + }); + return onChange; + }; + + const radios = () => + Array.from(container.querySelectorAll('[role="radio"]')); + + it('lists the repositories and picks one', async () => { + service.repositories.mockResolvedValue({ + success: true, + repositories: REPOS, + install_url: null, + }); + const onChange = await render(null); + expect(service.repositories).toHaveBeenCalledWith('conn-1', null); + expect(radios().map((r) => r.textContent)).toEqual([ + expect.stringContaining('octocat/private'), + expect.stringContaining('octocat/hello-world'), + ]); + await act(async () => radios()[1].click()); + expect(onChange).toHaveBeenCalledWith('octocat/hello-world'); + }); + + it('marks the picked repository', async () => { + service.repositories.mockResolvedValue({ + success: true, + repositories: REPOS, + install_url: null, + }); + await render('octocat/private'); + expect(radios()[0].getAttribute('aria-checked')).toBe('true'); + expect(radios()[1].getAttribute('aria-checked')).toBe('false'); + }); + + it('filters by the search text', async () => { + service.repositories.mockResolvedValue({ + success: true, + repositories: REPOS, + install_url: null, + }); + await render(null); + const input = container.querySelector('input')!; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + await act(async () => { + setter.call(input, 'hello'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(radios()).toHaveLength(1); + expect(radios()[0].textContent).toContain('octocat/hello-world'); + }); + + it('sends a GitHub App sign-in to choose repositories, then reloads', async () => { + service.repositories.mockResolvedValueOnce({ + success: true, + repositories: [], + install_url: 'https://github.com/apps/docsgpt/installations/new', + }); + await render(null); + expect(container.textContent).toContain( + 'settings.connectors.github.noAppRepositories', + ); + const choose = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('settings.connectors.github.chooseRepositories'), + )!; + await act(async () => choose.click()); + expect(install.start).toHaveBeenCalled(); + expect(install.options?.install).toBe(true); + service.repositories.mockResolvedValueOnce({ + success: true, + repositories: REPOS, + install_url: 'https://github.com/apps/docsgpt/installations/new', + }); + await act(async () => install.options!.onSuccess()); + expect(radios()).toHaveLength(2); + }); + + it('asks to reconnect when the token stopped working', async () => { + service.repositories.mockResolvedValue({ + success: false, + code: 'reconnect', + }); + await render(null); + expect(container.textContent).toContain( + 'settings.connectors.detail.expired', + ); + }); +}); diff --git a/frontend/src/connectors/RepoPicker.tsx b/frontend/src/connectors/RepoPicker.tsx new file mode 100644 index 00000000..5f290437 --- /dev/null +++ b/frontend/src/connectors/RepoPicker.tsx @@ -0,0 +1,216 @@ +import { BookMarked, Check, ExternalLink, Lock } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import connectorsService from '../api/services/connectorsService'; +import { useConnectorAuth } from '../components/ConnectorAuth'; +import SearchInput from '../components/SearchInput'; +import { Button } from '../components/ui/button'; +import { Card } from '../components/ui/card'; +import { EmptyState } from '../components/ui/empty-state'; +import { ListRow, ListRows } from '../components/ui/list-row'; +import { LoadingState } from '../components/ui/loading-state'; +import { formatDateTime } from '../utils/dateTimeUtils'; +import type { GitHubRepository } from './types'; + +type LoadError = 'reconnect' | 'failed' | null; + +/** + * Pick one repository a GitHub connection can read. A token lists what it + * was granted; a GitHub App sign-in lists its installations' repositories, + * and more are chosen on GitHub (the app's installation page), after which + * the list reloads. + */ +export default function RepoPicker({ + connectionId, + token, + value, + onChange, +}: { + connectionId: string; + token: string | null; + /** The picked repository's `owner/name`. */ + value: string | null; + onChange: (fullName: string) => void; +}) { + const { t } = useTranslation(); + const [repositories, setRepositories] = useState([]); + const [installUrl, setInstallUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(''); + // Only the latest load may land: a reload after choosing repositories + // must not be overwritten by a slower earlier one. + const requestRef = useRef(0); + + const load = useCallback(async () => { + const request = ++requestRef.current; + setLoading(true); + setError(null); + try { + const data = await connectorsService.repositories(connectionId, token); + if (request !== requestRef.current) return; + if (!data?.success) { + setError(data?.code === 'reconnect' ? 'reconnect' : 'failed'); + return; + } + setRepositories(data.repositories ?? []); + setInstallUrl(data.install_url ?? null); + } catch { + if (request === requestRef.current) setError('failed'); + } finally { + if (request === requestRef.current) setLoading(false); + } + }, [connectionId, token]); + + useEffect(() => { + load(); + }, [load]); + + // The popup reports success when the app asks for authorization during + // installation; otherwise the user closes it. Reload either way. + const chooseRepositories = useConnectorAuth({ + provider: 'github', + connectionId, + install: true, + onSuccess: () => load(), + onError: () => load(), + }); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return repositories; + return repositories.filter( + (repo) => + repo.full_name.toLowerCase().includes(needle) || + repo.description.toLowerCase().includes(needle), + ); + }, [repositories, query]); + + const chooseButton = installUrl ? ( + + ) : null; + + if (loading && repositories.length === 0) + return ; + if (error) { + return ( + + {t('retry')} + + ) : undefined + } + /> + ); + } + if (repositories.length === 0) { + return ( + + ); + } + + return ( +
+ setQuery(e.target.value)} + /> + {visible.length === 0 ? ( + + ) : ( + +
+ + {visible.map((repo) => { + const picked = repo.full_name === value; + return ( + + {repo.private ? ( + + ) : ( + + )} + + } + title={repo.full_name} + description={ + repo.description || + (repo.updated_at + ? t('settings.connectors.github.updated', { + date: formatDateTime(repo.updated_at), + interpolation: { escapeValue: false }, + }) + : undefined) + } + trailing={ + picked ? ( + + ) : undefined + } + > +
+
+ )} + {chooseButton} +
+ ); +} diff --git a/frontend/src/connectors/i18n.ts b/frontend/src/connectors/i18n.ts index 057a8178..5ca4aed4 100644 --- a/frontend/src/connectors/i18n.ts +++ b/frontend/src/connectors/i18n.ts @@ -17,6 +17,14 @@ export const connectorDescription = ( defaultValue: connector.description, }); +/** + * Whether an API-key connection's account label is a hint of the key + * (`…abcd`) rather than an account name (GitHub names a token connection + * after its login). + */ +export const isKeyHint = (label: string | null | undefined) => + !!label && label.startsWith('…'); + /** An action's name in words: `get_triage_responsibility` → "Get triage responsibility". */ export const actionTitle = (name: string) => { const words = name.replace(/[_-]+/g, ' ').trim(); @@ -44,6 +52,7 @@ const BUILT_IN_ICONS: Record = { telegram: 'tool_telegram', ntfy: 'tool_ntfy', postgres: 'tool_postgres', + github: 'github', }; /** diff --git a/frontend/src/connectors/types.ts b/frontend/src/connectors/types.ts index 9b8396f8..33ed0311 100644 --- a/frontend/src/connectors/types.ts +++ b/frontend/src/connectors/types.ts @@ -55,6 +55,11 @@ export type ConnectorDefinition = { oauth_scopes: string[]; /** Shown under this connector's card (one service offered two ways). */ part_of?: string | null; + /** + * How a user can connect, preferred first. GitHub offers `oauth` (Sign in + * with GitHub, once an admin sets up the GitHub App) before `api_key`. + */ + sign_in_methods?: ConnectorAuthKind[]; available: boolean; disabled: boolean; needs_setup: boolean; @@ -112,6 +117,16 @@ export type ConnectionSource = { sync_state: 'active' | 'paused_reconnect'; }; +/** A repository a GitHub connection can read, for the sync picker. */ +export type GitHubRepository = { + full_name: string; + private: boolean; + description: string; + default_branch: string; + updated_at: string | null; + html_url: string; +}; + export type ConnectionDetail = Connection & { sources: ConnectionSource[]; tools: ConnectionTool[]; diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index f7491271..4368a131 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1160,7 +1160,8 @@ "mcp_atlassian": "Jira-Issues und Confluence-Seiten durchsuchen und aktualisieren.", "mcp_sentry": "Sentry-Issues, -Events und -Releases nachschlagen.", "mcp_asana": "Asana-Aufgaben und -Projekte finden und aktualisieren.", - "mcp_stripe": "Kunden, Zahlungen und Abonnements in Stripe nachschlagen." + "mcp_stripe": "Kunden, Zahlungen und Abonnements in Stripe nachschlagen.", + "github": "Repositorys in Wissen synchronisieren und Agenten Code, Issues und Pull Requests lesen lassen." }, "empty": "Noch nichts verbunden. Verbinde einen Dienst, um seine Inhalte zu synchronisieren oder Agenten neue Werkzeuge zu geben.", "noMatches": "Keine Konnektoren passen zu deiner Suche.", @@ -1197,7 +1198,10 @@ "toolSwitchFailed": "Das Werkzeug konnte nicht geändert werden. Versuche es erneut.", "account": "Konto: {{account}}", "expired": "Die Anmeldung ist abgelaufen. Verbinde neu, um diese Verbindung weiter zu nutzen.", - "broken": "Diese Verbindung funktioniert nicht mehr. Verbinde neu, um es erneut zu versuchen." + "broken": "Diese Verbindung funktioniert nicht mehr. Verbinde neu, um es erneut zu versuchen.", + "addTools": "Werkzeuge hinzufügen", + "noTools": "Noch keine Werkzeuge aus dieser Verbindung.", + "addToolsFailed": "Die Werkzeuge konnten nicht hinzugefügt werden. Versuche es erneut." }, "permission": { "always": "Erlauben", @@ -1236,7 +1240,9 @@ "telegram_token": "Bot-Token", "ntfy_token": "Zugriffstoken", "postgres_token": "Verbindungszeichenfolge", - "brave_token": "API-Schlüssel" + "brave_token": "API-Schlüssel", + "github_access_token": "Persönliches Zugriffstoken", + "repo_url": "Repository" }, "wizard": { "connectTitle": "{{name}} verbinden", @@ -1262,7 +1268,19 @@ "doneSources": "{{sources}} werden ins Wissen synchronisiert.", "doneTools": "{{tools}} bereit.", "waiting": "Schließe die Anmeldung bei {{name}} im geöffneten Fenster ab.", - "openSignIn": "Anmeldung bei {{name}} öffnen" + "openSignIn": "Anmeldung bei {{name}} öffnen", + "chooseWhatToSetUp": "Auswählen, was eingerichtet wird", + "continue": "Weiter", + "methodLabel": "Art der Verbindung", + "methodOauth": "Mit {{name}} anmelden", + "methodToken": "Zugriffstoken verwenden", + "credentialsRejected": "{{name}} hat diese Zugangsdaten nicht akzeptiert. Prüfe sie und versuche es erneut.", + "toolsUnavailable": "Die Werkzeuge von {{name}} waren nicht erreichbar. Versuche es erneut oder schalte sie aus, um fortzufahren.", + "addTools": "Agenten {{name}} nutzen lassen", + "addToolsDescription": { + "read": "Agenten können Dinge nachschlagen. Sie können nichts ändern.", + "readWrite": "Agenten können Dinge nachschlagen und ändern. Änderungen brauchen deine Freigabe." + } }, "remove": { "title": "{{name}} entfernen?", @@ -1315,7 +1333,18 @@ "read": "Schlägt nach", "write": "Führt Aktionen aus" }, - "manageConnection": "Verbindung verwalten" + "manageConnection": "Verbindung verwalten", + "github": { + "tokenHint": "Erstelle ein feingranulares Token mit Lesezugriff auf „Contents“ und „Metadata“ für die Repositorys, die du nutzen möchtest.", + "createToken": "Token auf GitHub erstellen", + "chooseRepositories": "Repositorys auf GitHub auswählen", + "noAppRepositories": "DocsGPT sieht noch keine Repositorys. Wähle auf GitHub aus, welche es lesen darf.", + "noTokenRepositories": "Dieses Token kann keine Repositorys lesen. Gib ihm auf GitHub Zugriff und verbinde dann neu.", + "searchRepositories": "Repositorys durchsuchen", + "repositories": "Repositorys", + "updated": "Aktualisiert {{date}}", + "loadFailed": "Deine Repositorys konnten nicht geladen werden." + } } }, "modals": { @@ -1475,7 +1504,13 @@ "groupConnection": "Aus einer Verbindung", "tileConnectedAs": "Verbunden als {{account}}", "newCredentials": "Neue Zugangsdaten eingeben", - "tileNotConnected": "Nicht verbunden" + "tileNotConnected": "Nicht verbunden", + "github": { + "privateHint": "Privates Repository? Verbinde dein GitHub-Konto, um es zu synchronisieren.", + "connectedHint": "Private Repositorys werden über deine GitHub-Verbindung ({{account}}) synchronisiert.", + "connect": "GitHub verbinden", + "pickRepository": "Repository auswählen" + } }, "createAPIKey": { "label": "Neuen API-Schlüssel erstellen", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index bd1a1212..69b598df 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1166,7 +1166,8 @@ "mcp_atlassian": "Search and update Jira issues and Confluence pages.", "mcp_sentry": "Look up Sentry issues, events and releases.", "mcp_asana": "Find and update Asana tasks and projects.", - "mcp_stripe": "Look up customers, payments and subscriptions in Stripe." + "mcp_stripe": "Look up customers, payments and subscriptions in Stripe.", + "github": "Sync repositories into Knowledge and let agents read code, issues and pull requests." }, "empty": "Nothing connected yet. Connect a service to sync its content or give agents new tools.", "noMatches": "No connectors match your search.", @@ -1203,7 +1204,10 @@ "toolSwitchFailed": "Could not change the tool. Try again.", "account": "Account: {{account}}", "expired": "The sign-in expired. Reconnect to keep using this connection.", - "broken": "This connection stopped working. Reconnect to try again." + "broken": "This connection stopped working. Reconnect to try again.", + "addTools": "Add tools", + "noTools": "No tools from this connection yet.", + "addToolsFailed": "Could not add the tools. Try again." }, "permission": { "always": "Allow", @@ -1242,7 +1246,9 @@ "telegram_token": "Bot token", "ntfy_token": "Access token", "postgres_token": "Connection string", - "brave_token": "API key" + "brave_token": "API key", + "github_access_token": "Personal access token", + "repo_url": "Repository" }, "wizard": { "connectTitle": "Connect {{name}}", @@ -1268,7 +1274,19 @@ "doneSources": "{{sources}} syncing into Knowledge.", "doneTools": "{{tools}} ready.", "waiting": "Finish signing in to {{name}} in the window that opened.", - "openSignIn": "Open the {{name}} sign-in" + "openSignIn": "Open the {{name}} sign-in", + "chooseWhatToSetUp": "Choose what to set up", + "continue": "Continue", + "methodLabel": "How to connect", + "methodOauth": "Sign in with {{name}}", + "methodToken": "Use an access token", + "credentialsRejected": "{{name}} did not accept these credentials. Check them and try again.", + "toolsUnavailable": "{{name}}'s tools could not be reached. Try again, or turn them off to continue.", + "addTools": "Let agents use {{name}}", + "addToolsDescription": { + "read": "Agents can look things up. They cannot change anything.", + "readWrite": "Agents can look things up and make changes. Changes need your approval." + } }, "remove": { "title": "Remove {{name}}?", @@ -1321,7 +1339,18 @@ "read": "Looks things up", "write": "Takes actions" }, - "manageConnection": "Manage connection" + "manageConnection": "Manage connection", + "github": { + "tokenHint": "Create a fine-grained token with read access to Contents and Metadata on the repositories you want to use.", + "createToken": "Create a token on GitHub", + "chooseRepositories": "Choose repositories on GitHub", + "noAppRepositories": "DocsGPT cannot see any repositories yet. Choose which ones it may read on GitHub.", + "noTokenRepositories": "This token cannot read any repositories. Give it access on GitHub, then reconnect.", + "searchRepositories": "Search repositories", + "repositories": "Repositories", + "updated": "Updated {{date}}", + "loadFailed": "Could not load your repositories." + } } }, "modals": { @@ -1481,7 +1510,13 @@ "groupConnection": "From a connection", "tileConnectedAs": "Connected as {{account}}", "newCredentials": "Enter new credentials", - "tileNotConnected": "Not connected" + "tileNotConnected": "Not connected", + "github": { + "privateHint": "Private repository? Connect your GitHub account to sync it.", + "connectedHint": "Private repositories sync from your GitHub connection ({{account}}).", + "connect": "Connect GitHub", + "pickRepository": "Pick a repository" + } }, "createAPIKey": { "label": "Create New API Key", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index f345bf5b..cb360df7 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1160,7 +1160,8 @@ "mcp_atlassian": "Busca y actualiza incidencias de Jira y páginas de Confluence.", "mcp_sentry": "Consulta incidencias, eventos y versiones de Sentry.", "mcp_asana": "Encuentra y actualiza tareas y proyectos de Asana.", - "mcp_stripe": "Consulta clientes, pagos y suscripciones en Stripe." + "mcp_stripe": "Consulta clientes, pagos y suscripciones en Stripe.", + "github": "Sincroniza repositorios con Conocimiento y deja que los agentes lean código, issues y pull requests." }, "empty": "Aún no hay nada conectado. Conecta un servicio para sincronizar su contenido o dar nuevas herramientas a los agentes.", "noMatches": "Ningún conector coincide con tu búsqueda.", @@ -1197,7 +1198,10 @@ "toolSwitchFailed": "No se pudo cambiar la herramienta. Inténtalo de nuevo.", "account": "Cuenta: {{account}}", "expired": "El inicio de sesión caducó. Reconecta para seguir usando esta conexión.", - "broken": "Esta conexión dejó de funcionar. Reconecta para intentarlo de nuevo." + "broken": "Esta conexión dejó de funcionar. Reconecta para intentarlo de nuevo.", + "addTools": "Añadir herramientas", + "noTools": "Todavía no hay herramientas de esta conexión.", + "addToolsFailed": "No se pudieron añadir las herramientas. Inténtalo de nuevo." }, "permission": { "always": "Permitir", @@ -1236,7 +1240,9 @@ "telegram_token": "Token del bot", "ntfy_token": "Token de acceso", "postgres_token": "Cadena de conexión", - "brave_token": "Clave de API" + "brave_token": "Clave de API", + "github_access_token": "Token de acceso personal", + "repo_url": "Repositorio" }, "wizard": { "connectTitle": "Conectar {{name}}", @@ -1262,7 +1268,19 @@ "doneSources": "{{sources}} sincronizándose en Conocimiento.", "doneTools": "{{tools}} listas.", "waiting": "Termina de iniciar sesión en {{name}} en la ventana que se abrió.", - "openSignIn": "Abrir el inicio de sesión de {{name}}" + "openSignIn": "Abrir el inicio de sesión de {{name}}", + "chooseWhatToSetUp": "Elige qué configurar", + "continue": "Continuar", + "methodLabel": "Cómo conectar", + "methodOauth": "Iniciar sesión con {{name}}", + "methodToken": "Usar un token de acceso", + "credentialsRejected": "{{name}} no aceptó estas credenciales. Revísalas e inténtalo de nuevo.", + "toolsUnavailable": "No se pudo acceder a las herramientas de {{name}}. Inténtalo de nuevo o desactívalas para continuar.", + "addTools": "Permitir que los agentes usen {{name}}", + "addToolsDescription": { + "read": "Los agentes pueden consultar información. No pueden cambiar nada.", + "readWrite": "Los agentes pueden consultar información y hacer cambios. Los cambios necesitan tu aprobación." + } }, "remove": { "title": "¿Eliminar {{name}}?", @@ -1315,7 +1333,18 @@ "read": "Consulta datos", "write": "Realiza acciones" }, - "manageConnection": "Gestionar conexión" + "manageConnection": "Gestionar conexión", + "github": { + "tokenHint": "Crea un token detallado con acceso de lectura a Contents y Metadata en los repositorios que quieras usar.", + "createToken": "Crear un token en GitHub", + "chooseRepositories": "Elegir repositorios en GitHub", + "noAppRepositories": "DocsGPT aún no ve ningún repositorio. Elige en GitHub cuáles puede leer.", + "noTokenRepositories": "Este token no puede leer ningún repositorio. Dale acceso en GitHub y vuelve a conectar.", + "searchRepositories": "Buscar repositorios", + "repositories": "Repositorios", + "updated": "Actualizado {{date}}", + "loadFailed": "No se pudieron cargar tus repositorios." + } } }, "modals": { @@ -1475,7 +1504,13 @@ "groupConnection": "Desde una conexión", "tileConnectedAs": "Conectado como {{account}}", "newCredentials": "Introducir credenciales nuevas", - "tileNotConnected": "Sin conectar" + "tileNotConnected": "Sin conectar", + "github": { + "privateHint": "¿Repositorio privado? Conecta tu cuenta de GitHub para sincronizarlo.", + "connectedHint": "Los repositorios privados se sincronizan desde tu conexión de GitHub ({{account}}).", + "connect": "Conectar GitHub", + "pickRepository": "Elegir un repositorio" + } }, "createAPIKey": { "label": "Crear Nueva Clave de API", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 77e50412..99395e84 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1151,7 +1151,8 @@ "mcp_atlassian": "Jira の課題と Confluence のページを検索、更新します。", "mcp_sentry": "Sentry の課題、イベント、リリースを調べます。", "mcp_asana": "Asana のタスクやプロジェクトを検索、更新します。", - "mcp_stripe": "Stripe の顧客、支払い、サブスクリプションを調べます。" + "mcp_stripe": "Stripe の顧客、支払い、サブスクリプションを調べます。", + "github": "リポジトリをナレッジに同期し、エージェントがコード、Issue、プルリクエストを読めるようにします。" }, "empty": "まだ何も接続されていません。サービスを接続すると、コンテンツを同期したり、エージェントに新しいツールを提供したりできます。", "noMatches": "検索に一致するコネクタはありません。", @@ -1188,7 +1189,10 @@ "toolSwitchFailed": "ツールを変更できませんでした。もう一度お試しください。", "account": "アカウント: {{account}}", "expired": "サインインの有効期限が切れました。この接続を使い続けるには再接続してください。", - "broken": "この接続は動作しなくなりました。再接続してもう一度お試しください。" + "broken": "この接続は動作しなくなりました。再接続してもう一度お試しください。", + "addTools": "ツールを追加", + "noTools": "この接続のツールはまだありません。", + "addToolsFailed": "ツールを追加できませんでした。もう一度お試しください。" }, "permission": { "always": "許可", @@ -1224,7 +1228,9 @@ "telegram_token": "ボットトークン", "ntfy_token": "アクセストークン", "postgres_token": "接続文字列", - "brave_token": "API キー" + "brave_token": "API キー", + "github_access_token": "個人用アクセストークン", + "repo_url": "リポジトリ" }, "wizard": { "connectTitle": "{{name}} に接続", @@ -1248,7 +1254,19 @@ "doneSources": "{{sources}} をナレッジに同期中です。", "doneTools": "{{tools}}を利用できます。", "waiting": "開いたウィンドウで {{name}} へのサインインを完了してください。", - "openSignIn": "{{name}} のサインインを開く" + "openSignIn": "{{name}} のサインインを開く", + "chooseWhatToSetUp": "設定する内容を選択", + "continue": "続行", + "methodLabel": "接続方法", + "methodOauth": "{{name}} でサインイン", + "methodToken": "アクセストークンを使用", + "credentialsRejected": "{{name}} がこの認証情報を受け付けませんでした。確認してもう一度お試しください。", + "toolsUnavailable": "{{name}} のツールに接続できませんでした。もう一度試すか、オフにして続行してください。", + "addTools": "エージェントに {{name}} の使用を許可", + "addToolsDescription": { + "read": "エージェントは情報を参照できます。変更はできません。", + "readWrite": "エージェントは情報の参照と変更ができます。変更にはあなたの承認が必要です。" + } }, "remove": { "title": "{{name}} を削除しますか?", @@ -1297,7 +1315,18 @@ "read": "情報を調べる", "write": "操作を行う" }, - "manageConnection": "接続を管理" + "manageConnection": "接続を管理", + "github": { + "tokenHint": "使用するリポジトリに対して Contents と Metadata の読み取り権限を持つ fine-grained トークンを作成してください。", + "createToken": "GitHub でトークンを作成", + "chooseRepositories": "GitHub でリポジトリを選択", + "noAppRepositories": "DocsGPT からはまだリポジトリが見えません。読み取りを許可するリポジトリを GitHub で選択してください。", + "noTokenRepositories": "このトークンで読み取れるリポジトリがありません。GitHub でアクセス権を付与してから再接続してください。", + "searchRepositories": "リポジトリを検索", + "repositories": "リポジトリ", + "updated": "{{date}} に更新", + "loadFailed": "リポジトリを読み込めませんでした。" + } } }, "modals": { @@ -1457,7 +1486,13 @@ "groupConnection": "接続から", "tileConnectedAs": "{{account}} として接続中", "newCredentials": "新しい認証情報を入力", - "tileNotConnected": "未接続" + "tileNotConnected": "未接続", + "github": { + "privateHint": "プライベートリポジトリですか?同期するには GitHub アカウントを接続してください。", + "connectedHint": "プライベートリポジトリは GitHub 接続({{account}})から同期されます。", + "connect": "GitHub を接続", + "pickRepository": "リポジトリを選択" + } }, "createAPIKey": { "label": "新しいAPIキーを作成", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index a808207b..c8c8ad9f 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1216,7 +1216,8 @@ "mcp_atlassian": "Поиск и обновление задач Jira и страниц Confluence.", "mcp_sentry": "Просмотр проблем, событий и релизов Sentry.", "mcp_asana": "Поиск и обновление задач и проектов Asana.", - "mcp_stripe": "Просмотр клиентов, платежей и подписок в Stripe." + "mcp_stripe": "Просмотр клиентов, платежей и подписок в Stripe.", + "github": "Синхронизируйте репозитории в базу знаний и дайте агентам читать код, issues и pull requests." }, "empty": "Пока ничего не подключено. Подключите сервис, чтобы синхронизировать его контент или дать агентам новые инструменты.", "noMatches": "Нет коннекторов, подходящих под запрос.", @@ -1253,7 +1254,10 @@ "toolSwitchFailed": "Не удалось изменить инструмент. Попробуйте ещё раз.", "account": "Аккаунт: {{account}}", "expired": "Срок входа истёк. Переподключите, чтобы продолжить пользоваться этим подключением.", - "broken": "Это подключение перестало работать. Переподключите, чтобы попробовать снова." + "broken": "Это подключение перестало работать. Переподключите, чтобы попробовать снова.", + "addTools": "Добавить инструменты", + "noTools": "У этого подключения пока нет инструментов.", + "addToolsFailed": "Не удалось добавить инструменты. Попробуйте ещё раз." }, "permission": { "always": "Разрешать", @@ -1298,7 +1302,9 @@ "telegram_token": "Токен бота", "ntfy_token": "Токен доступа", "postgres_token": "Строка подключения", - "brave_token": "API-ключ" + "brave_token": "API-ключ", + "github_access_token": "Персональный токен доступа", + "repo_url": "Репозиторий" }, "wizard": { "connectTitle": "Подключить {{name}}", @@ -1328,7 +1334,19 @@ "doneSources": "Синхронизируется в знания: {{sources}}.", "doneTools": "Готово: {{tools}}.", "waiting": "Завершите вход в {{name}} в открывшемся окне.", - "openSignIn": "Открыть вход в {{name}}" + "openSignIn": "Открыть вход в {{name}}", + "chooseWhatToSetUp": "Выберите, что настроить", + "continue": "Продолжить", + "methodLabel": "Способ подключения", + "methodOauth": "Войти через {{name}}", + "methodToken": "Использовать токен доступа", + "credentialsRejected": "{{name}} не принял эти учётные данные. Проверьте их и попробуйте ещё раз.", + "toolsUnavailable": "Инструменты {{name}} недоступны. Попробуйте ещё раз или отключите их, чтобы продолжить.", + "addTools": "Разрешить агентам использовать {{name}}", + "addToolsDescription": { + "read": "Агенты могут находить информацию. Изменять ничего не могут.", + "readWrite": "Агенты могут находить информацию и вносить изменения. Изменения требуют вашего одобрения." + } }, "remove": { "title": "Удалить {{name}}?", @@ -1389,7 +1407,18 @@ "read": "Ищет информацию", "write": "Выполняет действия" }, - "manageConnection": "Управлять подключением" + "manageConnection": "Управлять подключением", + "github": { + "tokenHint": "Создайте детализированный токен с доступом на чтение к Contents и Metadata для нужных репозиториев.", + "createToken": "Создать токен на GitHub", + "chooseRepositories": "Выбрать репозитории на GitHub", + "noAppRepositories": "DocsGPT пока не видит ни одного репозитория. Выберите на GitHub, какие ему можно читать.", + "noTokenRepositories": "Этот токен не может читать ни одного репозитория. Дайте ему доступ на GitHub и переподключите.", + "searchRepositories": "Поиск репозиториев", + "repositories": "Репозитории", + "updated": "Обновлён {{date}}", + "loadFailed": "Не удалось загрузить ваши репозитории." + } } }, "modals": { @@ -1549,7 +1578,13 @@ "groupConnection": "Из подключения", "tileConnectedAs": "Подключено как {{account}}", "newCredentials": "Ввести новые учётные данные", - "tileNotConnected": "Не подключено" + "tileNotConnected": "Не подключено", + "github": { + "privateHint": "Приватный репозиторий? Подключите аккаунт GitHub, чтобы синхронизировать его.", + "connectedHint": "Приватные репозитории синхронизируются через ваше подключение GitHub ({{account}}).", + "connect": "Подключить GitHub", + "pickRepository": "Выбрать репозиторий" + } }, "createAPIKey": { "label": "Создать новый API ключ", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index d3688e99..c4b13609 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1151,7 +1151,8 @@ "mcp_atlassian": "搜尋和更新 Jira 議題與 Confluence 頁面。", "mcp_sentry": "查看 Sentry 問題、事件和發行版本。", "mcp_asana": "尋找和更新 Asana 任務與專案。", - "mcp_stripe": "查看 Stripe 中的客戶、付款和訂閱。" + "mcp_stripe": "查看 Stripe 中的客戶、付款和訂閱。", + "github": "將儲存庫同步到知識庫,並讓代理讀取程式碼、Issue 和提取要求。" }, "empty": "尚未連線任何服務。連線一個服務以同步其內容,或為代理提供新工具。", "noMatches": "沒有符合搜尋的連接器。", @@ -1188,7 +1189,10 @@ "toolSwitchFailed": "無法變更工具。請再試一次。", "account": "帳號:{{account}}", "expired": "登入已過期。重新連線以繼續使用此連線。", - "broken": "此連線已停止運作。重新連線以再試一次。" + "broken": "此連線已停止運作。重新連線以再試一次。", + "addTools": "新增工具", + "noTools": "此連線還沒有工具。", + "addToolsFailed": "無法新增工具。請再試一次。" }, "permission": { "always": "允許", @@ -1224,7 +1228,9 @@ "telegram_token": "機器人權杖", "ntfy_token": "存取權杖", "postgres_token": "連線字串", - "brave_token": "API 金鑰" + "brave_token": "API 金鑰", + "github_access_token": "個人存取權杖", + "repo_url": "儲存庫" }, "wizard": { "connectTitle": "連線 {{name}}", @@ -1248,7 +1254,19 @@ "doneSources": "{{sources}} 正在同步到知識庫。", "doneTools": "{{tools}}已就緒。", "waiting": "請在開啟的視窗中完成 {{name}} 登入。", - "openSignIn": "開啟 {{name}} 登入" + "openSignIn": "開啟 {{name}} 登入", + "chooseWhatToSetUp": "選擇要設定的內容", + "continue": "繼續", + "methodLabel": "連線方式", + "methodOauth": "使用 {{name}} 登入", + "methodToken": "使用存取權杖", + "credentialsRejected": "{{name}} 未接受這些憑證。請檢查後再試一次。", + "toolsUnavailable": "無法存取 {{name}} 的工具。請再試一次,或將其關閉以繼續。", + "addTools": "允許代理使用 {{name}}", + "addToolsDescription": { + "read": "代理可以查詢資訊,但無法做任何變更。", + "readWrite": "代理可以查詢資訊並進行變更。變更需要你的核准。" + } }, "remove": { "title": "要移除 {{name}} 嗎?", @@ -1297,7 +1315,18 @@ "read": "查詢資訊", "write": "執行操作" }, - "manageConnection": "管理連線" + "manageConnection": "管理連線", + "github": { + "tokenHint": "為要使用的儲存庫建立細部權杖,並授予 Contents 和 Metadata 的讀取權限。", + "createToken": "在 GitHub 上建立權杖", + "chooseRepositories": "在 GitHub 上選擇儲存庫", + "noAppRepositories": "DocsGPT 還看不到任何儲存庫。請在 GitHub 上選擇允許它讀取的儲存庫。", + "noTokenRepositories": "此權杖無法讀取任何儲存庫。請在 GitHub 上授予存取權,然後重新連線。", + "searchRepositories": "搜尋儲存庫", + "repositories": "儲存庫", + "updated": "更新於 {{date}}", + "loadFailed": "無法載入你的儲存庫。" + } } }, "modals": { @@ -1457,7 +1486,13 @@ "groupConnection": "來自連線", "tileConnectedAs": "已以 {{account}} 連線", "newCredentials": "輸入新的憑證", - "tileNotConnected": "未連線" + "tileNotConnected": "未連線", + "github": { + "privateHint": "私人儲存庫?連結你的 GitHub 帳號即可同步。", + "connectedHint": "私人儲存庫透過你的 GitHub 連線({{account}})同步。", + "connect": "連結 GitHub", + "pickRepository": "選擇儲存庫" + } }, "createAPIKey": { "label": "建立新的 API 金鑰", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 1fd8a47b..991e2844 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1151,7 +1151,8 @@ "mcp_atlassian": "搜索和更新 Jira 问题与 Confluence 页面。", "mcp_sentry": "查看 Sentry 问题、事件和发布版本。", "mcp_asana": "查找和更新 Asana 任务与项目。", - "mcp_stripe": "查看 Stripe 中的客户、付款和订阅。" + "mcp_stripe": "查看 Stripe 中的客户、付款和订阅。", + "github": "将仓库同步到知识库,并让智能体读取代码、Issue 和拉取请求。" }, "empty": "尚未连接任何服务。连接一个服务以同步其内容,或为智能体提供新工具。", "noMatches": "没有与搜索匹配的连接器。", @@ -1188,7 +1189,10 @@ "toolSwitchFailed": "无法更改工具。请重试。", "account": "账号:{{account}}", "expired": "登录已过期。重新连接以继续使用此连接。", - "broken": "此连接已停止工作。重新连接以再试一次。" + "broken": "此连接已停止工作。重新连接以再试一次。", + "addTools": "添加工具", + "noTools": "此连接还没有工具。", + "addToolsFailed": "无法添加工具。请重试。" }, "permission": { "always": "允许", @@ -1224,7 +1228,9 @@ "telegram_token": "机器人令牌", "ntfy_token": "访问令牌", "postgres_token": "连接字符串", - "brave_token": "API 密钥" + "brave_token": "API 密钥", + "github_access_token": "个人访问令牌", + "repo_url": "仓库" }, "wizard": { "connectTitle": "连接 {{name}}", @@ -1248,7 +1254,19 @@ "doneSources": "{{sources}} 正在同步到知识库。", "doneTools": "{{tools}}已就绪。", "waiting": "请在打开的窗口中完成 {{name}} 登录。", - "openSignIn": "打开 {{name}} 登录" + "openSignIn": "打开 {{name}} 登录", + "chooseWhatToSetUp": "选择要设置的内容", + "continue": "继续", + "methodLabel": "连接方式", + "methodOauth": "使用 {{name}} 登录", + "methodToken": "使用访问令牌", + "credentialsRejected": "{{name}} 未接受这些凭据。请检查后重试。", + "toolsUnavailable": "无法访问 {{name}} 的工具。请重试,或将其关闭以继续。", + "addTools": "允许智能体使用 {{name}}", + "addToolsDescription": { + "read": "智能体可以查询信息,但不能做任何更改。", + "readWrite": "智能体可以查询信息并进行更改。更改需要你的批准。" + } }, "remove": { "title": "移除 {{name}}?", @@ -1297,7 +1315,18 @@ "read": "查找信息", "write": "执行操作" }, - "manageConnection": "管理连接" + "manageConnection": "管理连接", + "github": { + "tokenHint": "为要使用的仓库创建一个细粒度令牌,并授予 Contents 和 Metadata 的读取权限。", + "createToken": "在 GitHub 上创建令牌", + "chooseRepositories": "在 GitHub 上选择仓库", + "noAppRepositories": "DocsGPT 还看不到任何仓库。请在 GitHub 上选择允许它读取的仓库。", + "noTokenRepositories": "此令牌无法读取任何仓库。请在 GitHub 上为其授予访问权限,然后重新连接。", + "searchRepositories": "搜索仓库", + "repositories": "仓库", + "updated": "更新于 {{date}}", + "loadFailed": "无法加载你的仓库。" + } } }, "modals": { @@ -1457,7 +1486,13 @@ "groupConnection": "来自连接", "tileConnectedAs": "已作为 {{account}} 连接", "newCredentials": "输入新的凭据", - "tileNotConnected": "未连接" + "tileNotConnected": "未连接", + "github": { + "privateHint": "私有仓库?连接你的 GitHub 账号即可同步。", + "connectedHint": "私有仓库通过你的 GitHub 连接({{account}})同步。", + "connect": "连接 GitHub", + "pickRepository": "选择仓库" + } }, "createAPIKey": { "label": "创建新的 API 密钥", diff --git a/frontend/src/upload/Upload.test.tsx b/frontend/src/upload/Upload.test.tsx index 50cd1b08..e533a21c 100644 --- a/frontend/src/upload/Upload.test.tsx +++ b/frontend/src/upload/Upload.test.tsx @@ -242,6 +242,81 @@ describe('Upload source-type tiles', () => { connectorsState.connections = []; }); + describe('GitHub', () => { + const githubConnector = { + key: 'github', + icon: 'github', + sync_ingestor: 'github', + auth_kind: 'api_key', + available: true, + missing_settings: [], + }; + const openGitHub = async () => { + await render(); + const tile = tiles().find((t) => + t.textContent?.includes('modals.uploadDoc.ingestors.github.label'), + )!; + await act(async () => tile.click()); + }; + const handOver = () => + Array.from(document.body.querySelectorAll('button')).find((b) => + b.textContent?.includes('modals.uploadDoc.github.'), + ); + + afterEach(() => { + connectorsState.catalog = []; + connectorsState.connections = []; + }); + + it('stays one public-repository tile, with a way to private ones', async () => { + launch.mockClear(); + connectorsState.catalog = [githubConnector]; + await render(); + const githubTiles = tiles().filter((t) => + t.textContent?.includes('modals.uploadDoc.ingestors.github.label'), + ); + expect(githubTiles).toHaveLength(1); + await openGitHub(); + // The repository URL form still works without an account. + expect(document.body.textContent).toContain( + 'modals.uploadDoc.ingestors.github.heading', + ); + expect(document.body.textContent).toContain( + 'modals.uploadDoc.github.privateHint', + ); + await act(async () => handOver()!.click()); + expect(launch).toHaveBeenCalledWith(githubConnector, {}); + }); + + it('goes straight to picking a repository with a connected account', async () => { + launch.mockClear(); + connectorsState.catalog = [githubConnector]; + connectorsState.connections = [ + { + id: 'gh-1', + connector_key: 'github', + status: 'connected', + account_label: 'octocat', + }, + ]; + await openGitHub(); + expect(document.body.textContent).toContain( + 'modals.uploadDoc.github.connectedHint', + ); + await act(async () => handOver()!.click()); + expect(launch).toHaveBeenCalledWith(githubConnector, { + mode: 'sync', + connectionId: 'gh-1', + }); + }); + + it('offers no hand-over when GitHub connections are off', async () => { + connectorsState.catalog = []; + await openGitHub(); + expect(handOver()).toBeUndefined(); + }); + }); + it('picks the saved account once connections finish loading', async () => { connectorsState.catalog = [ { diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 0b533402..44f2a66f 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, FileText } from 'lucide-react'; +import { ChevronLeft, FileText, Lock } from 'lucide-react'; import { envVar } from '@/env'; import { cn } from '@/lib/utils'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,6 +11,7 @@ import type { RootState } from '../store'; import userService from '../api/services/userService'; import modelService from '../api/services/modelService'; import type { Model } from '../models/types'; +import { Alert, AlertDescription } from '../components/ui/alert'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { FormField as UiFormField } from '../components/ui/form-field'; @@ -1300,6 +1301,53 @@ function Upload({ ); }; + // The GitHub tile reads public repositories by URL with no account. A + // private one needs the user's own GitHub connection: hand over to the + // connect wizard, which picks from the account's repositories. + const githubConnector = connectorsEnabled + ? connectorCatalog.find((c) => c.key === 'github' && c.available) + : undefined; + const githubAccount = githubConnector + ? connections.find( + (c) => c.connector_key === 'github' && c.status === 'connected', + ) + : undefined; + const renderGitHubHandOver = () => + githubConnector ? ( + + + + {githubAccount + ? t('modals.uploadDoc.github.connectedHint', { + account: githubAccount.account_label, + interpolation: { escapeValue: false }, + }) + : t('modals.uploadDoc.github.privateHint')} + +
+ +
+
+ ) : null; + const renderSetupNotice = () => selectedConnector && needsSetup ? ( @@ -1373,6 +1421,7 @@ function Upload({ className="w-full" /> {needsSetup ? renderSetupNotice() : renderFormFields()} + {ingestor.type === 'github' && renderGitHubHandOver()} {ingestor.type !== 'wiki' && !needsSetup && (