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.
This commit is contained in:
arc53-machine committed 2026-09-29 10:41:03 +01:00
1 parent 489ef567a3
commit 2a6c40d45a
22 files changed
+1306 -80

No files matched your search

+8 -2
View File
@@ -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`,
@@ -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(
+5 -1
View File
@@ -292,8 +292,12 @@ const userService = {
provider: string,
token: string | null,
connectionId?: string,
install?: boolean,
): Promise<any> =>
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,
@@ -0,0 +1 @@
<svg fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 .3a10 10 0 0 0-3.16 19.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02A9.6 9.6 0 0 1 10 5.14c.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85v2.74c0 .27.16.59.67.5A10 10 0 0 0 10 .3Z"/></svg>

After

Width:  |  Height:  |  Size: 608 B

@@ -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();
@@ -64,6 +64,14 @@ vi.mock('../components/FilePicker', () => ({
),
}));
vi.mock('./RepoPicker', () => ({
default: ({ onChange }: { onChange: (name: string) => void }) => (
<button type="button" onClick={() => onChange('octocat/private')}>
pick-repo
</button>
),
}));
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<HTMLInputElement>(
'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<HTMLInputElement>(
'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<HTMLButtonElement>('[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',
);
});
});
});
+162 -28
View File
@@ -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<CreatedSource[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
// A connector that asks about its tools (GitHub) offers them switched on.
const [toolsOn, setToolsOn] = useState(true);
const [chosenMethod, setChosenMethod] = useState<ConnectorAuthKind | null>(
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<string, unknown> =>
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({
</a>
</Button>
)}
{connector.auth_kind === 'oauth' || isMcpPreset ? null : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`connect-${connector.key}`}
fields={connector.credential_fields}
values={credentials}
onChange={setCredentials}
/>
{mode !== 'reconnect' && methods.length > 1 && (
<ToggleGroup
type="single"
value={method}
onValueChange={(value) =>
value && setChosenMethod(value as ConnectorAuthKind)
}
aria-label={t('settings.connectors.wizard.methodLabel')}
>
{methods.map((value) => (
<ToggleGroupItem key={value} value={value}>
{value === 'oauth'
? t('settings.connectors.wizard.methodOauth', {
name,
interpolation: { escapeValue: false },
})
: t('settings.connectors.wizard.methodToken')}
</ToggleGroupItem>
))}
</ToggleGroup>
)}
{usesOAuth || isMcpPreset ? null : (
<>
{connector.key === 'github' && (
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">
{t('settings.connectors.github.tokenHint')}
</p>
<Button variant="link" size="inline" asChild className="w-fit">
<a
href={GITHUB_TOKEN_URL}
target="_blank"
rel="noopener noreferrer"
>
{t('settings.connectors.github.createToken')}
<ExternalLink />
</a>
</Button>
</div>
)}
<CredentialForm
connectorKey={connector.key}
idPrefix={`connect-${connector.key}`}
fields={connector.credential_fields}
values={credentials}
onChange={setCredentials}
/>
</>
)}
</div>
);
@@ -412,7 +506,41 @@ export default function ConnectWizard({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{connector.key === 'google_drive' && envVar('VITE_GOOGLE_CLIENT_ID') ? (
{offerTools && (
<SettingRows>
<SettingRow
label={t('settings.connectors.wizard.addTools', {
name,
interpolation: { escapeValue: false },
})}
description={t(
`settings.connectors.wizard.addToolsDescription.${
connector.capabilities.includes('write') ? 'readWrite' : 'read'
}`,
)}
htmlFor={`tools-${connector.key}`}
alignStart
>
<Switch
id={`tools-${connector.key}`}
checked={toolsOn}
onCheckedChange={(checked) => setToolsOn(checked === true)}
/>
</SettingRow>
</SettingRows>
)}
{isRepoPicker && connectionId ? (
<RepoPicker
connectionId={connectionId}
token={token}
value={selectedRepo}
onChange={(fullName) => {
setSelectedRepo(fullName);
if (!nameTouched) setSourceName(fullName);
}}
/>
) : connector.key === 'google_drive' &&
envVar('VITE_GOOGLE_CLIENT_ID') ? (
<GoogleDrivePicker
token={token}
connectionId={connectionId}
@@ -516,7 +644,9 @@ export default function ConnectWizard({
interpolation: { escapeValue: false },
})
: step === 'setup'
? t('settings.connectors.wizard.chooseWhatToSync')
? offerTools
? t('settings.connectors.wizard.chooseWhatToSetUp')
: t('settings.connectors.wizard.chooseWhatToSync')
: t('settings.connectors.wizard.doneTitle', {
name,
interpolation: { escapeValue: false },
@@ -524,7 +654,7 @@ export default function ConnectWizard({
const footer =
step === 'signin' ? (
connector.auth_kind === 'oauth' || isMcpPreset ? (
usesOAuth || isMcpPreset ? (
<ModalActions
cancelLabel={t('cancel')}
onCancel={() => {
@@ -558,7 +688,11 @@ export default function ConnectWizard({
<ModalActions
cancelLabel={t('settings.connectors.wizard.skip')}
onCancel={() => (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}
@@ -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(
<Provider store={store}>
<ConnectionDrawer
connector={DRIVE}
connector={connector}
onClose={vi.fn()}
onConnect={vi.fn()}
/>
@@ -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,
);
});
});
});
+68 -4
View File
@@ -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<boolean>;
onSyncNow: (source: ConnectionSource) => void;
/** Creates the tools a connection skipped while it was set up (GitHub's). */
onAddTools: (detail: ConnectionDetail) => Promise<void>;
}) {
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({
)}
</section>
)}
{canAddTools && (
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="xs"
title={t('settings.connectors.detail.tools')}
actions={
<Button
type="button"
variant="link"
size="sm"
className="-mr-3"
loading={addingTools}
onClick={() => {
setAddingTools(true);
onAddTools(detail).finally(() => setAddingTools(false));
}}
>
<Plus />
{t('settings.connectors.detail.addTools')}
</Button>
}
/>
<EmptyState
size="xs"
illustration="none"
title={t('settings.connectors.detail.noTools')}
/>
</section>
)}
{detail.tools.length > 0 && (
<section className="flex flex-col gap-2">
<SectionHeader
@@ -617,6 +658,27 @@ export default function ConnectionDrawer({
}
};
const addTools = async (detail: ConnectionDetail) => {
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}
/>
))}
</div>
@@ -799,6 +862,7 @@ export default function ConnectionDrawer({
onRefreshTools={refreshTools}
onToggleTool={toggleTool}
onSyncNow={syncNow}
onAddTools={addTools}
/>
))}
</section>
+164
View File
@@ -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<string, unknown>) =>
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(
<RepoPicker
connectionId="conn-1"
token={null}
value={value}
onChange={onChange}
/>,
);
});
return onChange;
};
const radios = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[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<HTMLInputElement>('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',
);
});
});
+216
View File
@@ -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<GitHubRepository[]>([]);
const [installUrl, setInstallUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<LoadError>(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 ? (
<Button
type="button"
variant="link"
size="inline"
className="w-fit"
onClick={chooseRepositories}
>
<ExternalLink />
{t('settings.connectors.github.chooseRepositories')}
</Button>
) : null;
if (loading && repositories.length === 0)
return <LoadingState fill="block" />;
if (error) {
return (
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={
error === 'reconnect'
? t('settings.connectors.detail.expired')
: t('settings.connectors.github.loadFailed')
}
action={
error === 'failed' ? (
<Button variant="outline" size="sm" shape="pill" onClick={load}>
{t('retry')}
</Button>
) : undefined
}
/>
);
}
if (repositories.length === 0) {
return (
<EmptyState
size="xs"
illustration="none"
title={
installUrl
? t('settings.connectors.github.noAppRepositories')
: t('settings.connectors.github.noTokenRepositories')
}
action={chooseButton ?? undefined}
/>
);
}
return (
<div className="flex flex-col gap-3">
<SearchInput
label={t('settings.connectors.github.searchRepositories')}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{visible.length === 0 ? (
<EmptyState
size="xs"
illustration="none"
title={t('settings.connectors.noMatches')}
/>
) : (
<Card
variant="subtle"
padding="none"
className="max-h-80 overflow-y-auto"
>
<div
role="radiogroup"
aria-label={t('settings.connectors.github.repositories')}
>
<ListRows>
{visible.map((repo) => {
const picked = repo.full_name === value;
return (
<ListRow
key={repo.full_name}
interactive
asChild
leading={
<span className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md">
{repo.private ? (
<Lock className="size-4" aria-hidden />
) : (
<BookMarked className="size-4" aria-hidden />
)}
</span>
}
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 ? (
<Check
className="text-primary size-4 shrink-0"
aria-hidden
/>
) : undefined
}
>
<button
type="button"
role="radio"
aria-checked={picked}
onClick={() => onChange(repo.full_name)}
/>
</ListRow>
);
})}
</ListRows>
</div>
</Card>
)}
{chooseButton}
</div>
);
}
+9
View File
@@ -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<string, string> = {
telegram: 'tool_telegram',
ntfy: 'tool_ntfy',
postgres: 'tool_postgres',
github: 'github',
};
/**
+15
View File
@@ -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[];
+41 -6
View File
@@ -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",
+41 -6
View File
@@ -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",
+41 -6
View File
@@ -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",
+41 -6
View File
@@ -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キーを作成",
+41 -6
View File
@@ -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 ключ",
+41 -6
View File
@@ -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 金鑰",
+41 -6
View File
@@ -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 密钥",
+75
View File
@@ -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 = [
{
+50 -1
View File
@@ -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 ? (
<Alert variant="info" role="note">
<Lock />
<AlertDescription>
{githubAccount
? t('modals.uploadDoc.github.connectedHint', {
account: githubAccount.account_label,
interpolation: { escapeValue: false },
})
: t('modals.uploadDoc.github.privateHint')}
</AlertDescription>
<div className="mt-2">
<Button
type="button"
size="sm"
shape="pill"
variant="outline"
onClick={() => {
setHandedOver(true);
launch(
githubConnector,
githubAccount
? { mode: 'sync', connectionId: githubAccount.id }
: {},
);
}}
>
{githubAccount
? t('modals.uploadDoc.github.pickRepository')
: t('modals.uploadDoc.github.connect')}
</Button>
</div>
</Alert>
) : null;
const renderSetupNotice = () =>
selectedConnector && needsSetup ? (
<ConnectorSetupNotice connector={selectedConnector} />
@@ -1373,6 +1421,7 @@ function Upload({
className="w-full"
/>
{needsSetup ? renderSetupNotice() : renderFormFields()}
{ingestor.type === 'github' && renderGitHubHandOver()}
{ingestor.type !== 'wiki' && !needsSetup && (
<RetrievalOptions
value={retrievalOptions}