diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index c6a7aeed..451bf22a 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -123,6 +123,8 @@ const endpoints = { `/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/permissions`, CONNECTION_REPOSITORIES: (id: string) => `/api/connections/${encodeURIComponent(id)}/repositories`, + CONNECTION_LINEAR: (id: string) => + `/api/connections/${encodeURIComponent(id)}/linear`, CONNECTION_TOOL_PARAMETERS: (id: string, toolId: string) => `/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/parameters`, CONNECTION_WRITES: (id: string) => diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts index 8c3ddd11..f8c3d1f4 100644 --- a/frontend/src/api/services/connectorsService.ts +++ b/frontend/src/api/services/connectorsService.ts @@ -95,6 +95,9 @@ const connectorsService = { json( await apiClient.get(endpoints.USER.CONNECTION_REPOSITORIES(id), token), ), + /** Linear: the teams and projects a connection can sync. */ + linearWorkspace: async (id: string, token: string | null) => + json(await apiClient.get(endpoints.USER.CONNECTION_LINEAR(id), token)), refreshTools: async (id: string, token: string | null) => json( await apiClient.post( diff --git a/frontend/src/connectors/ConnectWizard.test.tsx b/frontend/src/connectors/ConnectWizard.test.tsx index d04aceca..bee1ec3e 100644 --- a/frontend/src/connectors/ConnectWizard.test.tsx +++ b/frontend/src/connectors/ConnectWizard.test.tsx @@ -73,6 +73,32 @@ vi.mock('./RepoPicker', () => ({ ), })); +vi.mock('./LinearPicker', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: ({ + value, + onChange, + }: { + value: import('./LinearPicker').LinearSelection; + onChange: (selection: import('./LinearPicker').LinearSelection) => void; + }) => ( + + ), + }; +}); + import notificationsReducer, { sseEventReceived, } from '../notifications/notificationsSlice'; @@ -190,7 +216,11 @@ describe('ConnectWizard', () => { container.remove(); }); - const render = async (connector: ConnectorDefinition, onClose = vi.fn()) => { + const render = async ( + connector: ConnectorDefinition, + onClose = vi.fn(), + props: Partial[0]> = {}, + ) => { const store = configureStore({ reducer: { connectors: connectorsReducer, @@ -207,7 +237,11 @@ describe('ConnectWizard', () => { + } /> NEW_CHAT} /> @@ -665,4 +699,127 @@ describe('ConnectWizard', () => { ); }); }); + + describe('Linear', () => { + const linear: ConnectorDefinition = { + ...base, + key: 'mcp:linear', + name: 'Linear', + icon: 'linear', + category: 'projects', + auth_kind: 'mcp_oauth', + capabilities: ['sync', 'read', 'write'], + credential_fields: [], + sync_ingestor: 'linear', + tool_templates: ['mcp_tool'], + setup: { tools: 'auto', sync: 'ask' }, + mcp_url: 'https://mcp.linear.app/mcp', + publisher: 'preset', + }; + + const signIn = async (props = {}) => { + const popup = { closed: false, close: vi.fn(), location: { href: '' } }; + const open = vi.spyOn(window, 'open').mockReturnValue(popup as never); + mcpApi.testMCPConnection.mockResolvedValue({ + json: async () => ({ requires_oauth: true, task_id: 'task-1' }), + }); + mcpApi.saveMCPServer.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, id: 'tool-9' }), + }); + service.listConnections.mockResolvedValue({ + success: true, + connections: [ + { + id: 'conn-lin', + connector_key: 'mcp:linear', + updated_at: '2026-09-28', + }, + ], + }); + service.getConnection.mockResolvedValue({ + success: true, + connection: { tools: [{ ...TELEGRAM_TOOL, display_name: 'Linear' }] }, + }); + const store = await render(linear, vi.fn(), props); + await click('settings.connectors.wizard.signIn'); + await act(async () => { + store.dispatch( + sseEventReceived({ + id: 'ev-2', + type: 'mcp.oauth.completed', + scope: { kind: 'mcp_oauth', id: 'task-1' }, + payload: { tools: [] }, + }), + ); + }); + open.mockRestore(); + }; + + it('signs in once, then picks teams to sync with the same connection', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [{ id: 'src-1', name: 'Linear · Engineering' }], + }); + await signIn(); + // The tools came with the sign-in; syncing is the next question. + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSync', + ); + await click('pick-team'); + await click('modals.uploadDoc.train'); + const [id, body] = service.setup.mock.calls[0]; + expect(id).toBe('conn-lin'); + expect(body).toEqual({ + create_tools: false, + sync: { + items: { + teams: [{ id: 't1', key: 'ENG', name: 'Engineering' }], + projects: [], + include_comments: true, + include_documents: false, + }, + frequency: 'weekly', + name: 'Linear · Engineering', + }, + }); + // The summary still lists the tools from the sign-in. + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsHeading:1', + ); + }); + + it('can skip syncing and keep only the tools', async () => { + await signIn(); + const train = Array.from( + document.body.querySelectorAll('button'), + ).find((b) => b.textContent?.trim() === 'modals.uploadDoc.train'); + // Nothing picked yet: nothing to sync. + expect(train?.disabled).toBe(true); + await click('settings.connectors.wizard.skip'); + expect(service.setup).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsHeading:1', + ); + }); + + it('goes straight to the summary after signing in again', async () => { + await signIn({ mode: 'reconnect', connectionId: 'conn-lin' }); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + }); + + it('opens on the picker to sync more from the drawer', async () => { + await render(linear, vi.fn(), { + mode: 'sync', + connectionId: 'conn-lin', + }); + expect(document.body.textContent).toContain('pick-team'); + }); + }); }); diff --git a/frontend/src/connectors/ConnectWizard.tsx b/frontend/src/connectors/ConnectWizard.tsx index b8cd065e..992d6139 100644 --- a/frontend/src/connectors/ConnectWizard.tsx +++ b/frontend/src/connectors/ConnectWizard.tsx @@ -49,6 +49,12 @@ import ConnectorIcon from './ConnectorIcon'; import CredentialForm, { credentialsComplete } from './CredentialForm'; import { loadConnectors, selectConnections } from './connectorsSlice'; import { connectorDescription, connectorName, isKeyHint } from './i18n'; +import LinearPicker, { + EMPTY_LINEAR_SELECTION, + linearSourceName, + linearSyncItems, + type LinearSelection, +} from './LinearPicker'; import RepoPicker from './RepoPicker'; import ToolPermissions from './ToolPermissions'; import useMcpOAuth, { type McpOAuthConfig } from './useMcpOAuth'; @@ -118,6 +124,9 @@ export default function ConnectWizard({ const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolders, setSelectedFolders] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); + const [linearSelection, setLinearSelection] = useState( + EMPTY_LINEAR_SELECTION, + ); // A connector that asks about its tools (GitHub) offers them switched on. const [toolsOn, setToolsOn] = useState(true); // Changes (GitHub's issues, comments, pull requests) are an opt-in on top. @@ -239,7 +248,8 @@ export default function ConnectWizard({ const detail = await connectorsService.getConnection(saved.id, token); if (detail?.success) setTools(detail.connection.tools ?? []); } - setStep('done'); + // One sign-in also syncs (Linear): choosing what to sync comes next. + setStep(canSync && saved && mode === 'connect' ? 'setup' : 'done'); } catch (err) { setError( (err instanceof Error && err.message) || @@ -309,18 +319,23 @@ export default function ConnectWizard({ }; const isRepoPicker = connector.key === 'github'; + const isLinearPicker = connector.sync_ingestor === 'linear'; const syncItems = (): Record => PICKER_CONNECTORS.has(connector.key) ? { file_ids: selectedFiles, folder_ids: selectedFolders } : isRepoPicker ? { repo_url: selectedRepo } - : setupValues; + : isLinearPicker + ? linearSyncItems(linearSelection) + : setupValues; const hasSyncSelection = PICKER_CONNECTORS.has(connector.key) ? selectedFiles.length + selectedFolders.length > 0 : isRepoPicker ? !!selectedRepo - : credentialsComplete(connector.setup_fields, setupValues); + : isLinearPicker + ? linearSelection.teams.length + linearSelection.projects.length > 0 + : credentialsComplete(connector.setup_fields, setupValues); const wantsTools = offerTools && toolsOn; const offerWrites = wantsTools && !!connector.writes_allowed; const wantsWrites = offerWrites && writesOn; @@ -593,6 +608,16 @@ export default function ConnectWizard({ if (!nameTouched) setSourceName(fullName); }} /> + ) : isLinearPicker && connectionId ? ( + { + setLinearSelection(selection); + if (!nameTouched) setSourceName(linearSourceName(selection)); + }} + /> ) : connector.key === 'google_drive' && envVar('VITE_GOOGLE_CLIENT_ID') ? ( ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && 'count' in opts ? `${key}:${opts.count}` : key, + }), +})); + +const service = vi.hoisted(() => ({ linearWorkspace: vi.fn() })); +vi.mock('../api/services/connectorsService', () => ({ default: service })); + +import LinearPicker, { + EMPTY_LINEAR_SELECTION, + linearSourceName, + type LinearSelection, +} from './LinearPicker'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const WORKSPACE = { + success: true, + teams: [ + { id: 't1', key: 'ENG', name: 'Engineering' }, + { id: 't2', key: 'DES', name: 'Design' }, + ], + projects: [ + { id: 'p1', name: 'Launch', state: 'Started', teams: ['Engineering'] }, + ], +}; + +describe('LinearPicker', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + service.linearWorkspace.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: LinearSelection = EMPTY_LINEAR_SELECTION, + onChange = vi.fn(), + ) => { + await act(async () => { + root.render( + , + ); + }); + return onChange; + }; + + const checkboxes = () => + Array.from( + container.querySelectorAll('[role="checkbox"]'), + ); + const switches = () => + Array.from( + container.querySelectorAll('[role="switch"]'), + ); + + it('lists the teams and projects to pick', async () => { + service.linearWorkspace.mockResolvedValue(WORKSPACE); + await render(); + expect(service.linearWorkspace).toHaveBeenCalledWith('conn-1', null); + expect(container.textContent).toContain('Engineering'); + expect(container.textContent).toContain('ENG'); + expect(container.textContent).toContain('Launch'); + expect(checkboxes()).toHaveLength(3); + }); + + it('picks a team with what the source needs to name it', async () => { + service.linearWorkspace.mockResolvedValue(WORKSPACE); + const onChange = await render(); + await act(async () => checkboxes()[0].click()); + expect(onChange).toHaveBeenCalledWith({ + ...EMPTY_LINEAR_SELECTION, + teams: [{ id: 't1', key: 'ENG', name: 'Engineering' }], + }); + }); + + it('unpicks a picked project', async () => { + service.linearWorkspace.mockResolvedValue(WORKSPACE); + const picked = { + ...EMPTY_LINEAR_SELECTION, + projects: [{ id: 'p1', name: 'Launch' }], + }; + const onChange = await render(picked); + const project = checkboxes()[2]; + expect(project.getAttribute('aria-checked')).toBe('true'); + await act(async () => project.click()); + expect(onChange).toHaveBeenCalledWith({ + ...picked, + projects: [], + }); + }); + + it('keeps comments on by default and offers documents once a project is picked', async () => { + service.linearWorkspace.mockResolvedValue(WORKSPACE); + const onChange = await render(); + const [comments, documents] = switches(); + expect(comments.getAttribute('aria-checked')).toBe('true'); + expect(documents.disabled).toBe(true); + await act(async () => comments.click()); + expect(onChange).toHaveBeenCalledWith({ + ...EMPTY_LINEAR_SELECTION, + includeComments: false, + }); + await render({ + ...EMPTY_LINEAR_SELECTION, + projects: [{ id: 'p1', name: 'Launch' }], + }); + expect(switches()[1].disabled).toBe(false); + }); + + it('filters by the search text', async () => { + service.linearWorkspace.mockResolvedValue({ + ...WORKSPACE, + teams: Array.from({ length: 9 }, (_, n) => ({ + id: `t${n}`, + key: `T${n}`, + name: n === 4 ? 'Platform' : `Team ${n}`, + })), + }); + await render(); + const input = container.querySelector('input')!; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + await act(async () => { + setter.call(input, 'platf'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(checkboxes()).toHaveLength(1); + expect(container.textContent).toContain('Platform'); + }); + + it('asks to reconnect when the sign-in stopped working', async () => { + service.linearWorkspace.mockResolvedValue({ + success: false, + code: 'reconnect', + }); + await render(); + expect(container.textContent).toContain( + 'settings.connectors.detail.expired', + ); + }); + + it('offers a retry when Linear did not answer', async () => { + service.linearWorkspace.mockResolvedValueOnce({ success: false }); + await render(); + expect(container.textContent).toContain( + 'settings.connectors.linear.loadFailed', + ); + service.linearWorkspace.mockResolvedValueOnce(WORKSPACE); + const retry = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'retry', + )!; + await act(async () => retry.click()); + expect(checkboxes()).toHaveLength(3); + }); + + it('names a source after what it syncs', () => { + expect(linearSourceName(EMPTY_LINEAR_SELECTION)).toBe(''); + expect( + linearSourceName({ + ...EMPTY_LINEAR_SELECTION, + teams: [{ id: 't1', key: 'ENG', name: 'Engineering' }], + projects: [{ id: 'p1', name: 'Launch' }], + }), + ).toBe('Linear · Engineering, Launch'); + }); +}); diff --git a/frontend/src/connectors/LinearPicker.tsx b/frontend/src/connectors/LinearPicker.tsx new file mode 100644 index 00000000..9b2c0c7e --- /dev/null +++ b/frontend/src/connectors/LinearPicker.tsx @@ -0,0 +1,295 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import connectorsService from '../api/services/connectorsService'; +import SearchInput from '../components/SearchInput'; +import { Button } from '../components/ui/button'; +import { Card } from '../components/ui/card'; +import { Checkbox } from '../components/ui/checkbox'; +import { EmptyState } from '../components/ui/empty-state'; +import { ListRow, ListRows } from '../components/ui/list-row'; +import { LoadingState } from '../components/ui/loading-state'; +import { SectionHeader } from '../components/ui/section-header'; +import { SettingRow, SettingRows } from '../components/ui/setting-row'; +import { Switch } from '../components/ui/switch'; +import type { LinearProject, LinearTeam } from './types'; + +/** What a Linear source syncs: the picked teams' and projects' issues. */ +export type LinearSelection = { + teams: LinearTeam[]; + projects: Pick[]; + includeComments: boolean; + /** The picked projects' Linear documents. */ + includeDocuments: boolean; +}; + +export const EMPTY_LINEAR_SELECTION: LinearSelection = { + teams: [], + projects: [], + includeComments: true, + includeDocuments: false, +}; + +/** `Linear · Engineering, Launch`: a source's name from what it syncs. */ +export const linearSourceName = (selection: LinearSelection) => { + const names = [...selection.teams, ...selection.projects].map((p) => p.name); + return names.length ? `Linear · ${names.join(', ')}` : ''; +}; + +/** The wizard's choice as the setup endpoint takes it. */ +export const linearSyncItems = (selection: LinearSelection) => ({ + teams: selection.teams, + projects: selection.projects, + include_comments: selection.includeComments, + include_documents: selection.includeDocuments && !!selection.projects.length, +}); + +type LoadError = 'reconnect' | 'failed' | null; + +// More rows than this and a search field helps. +const SEARCH_FROM = 8; + +/** + * Pick the Linear teams and projects whose issues a source syncs, and + * whether it brings comments and the projects' documents. The lists come + * from Linear's MCP server, read with the connection's sign-in. + */ +export default function LinearPicker({ + connectionId, + token, + value, + onChange, +}: { + connectionId: string; + token: string | null; + value: LinearSelection; + onChange: (selection: LinearSelection) => void; +}) { + const { t } = useTranslation(); + const [teams, setTeams] = useState([]); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(''); + const requestRef = useRef(0); + + const load = useCallback(async () => { + const request = ++requestRef.current; + setLoading(true); + setError(null); + try { + const data = await connectorsService.linearWorkspace(connectionId, token); + if (request !== requestRef.current) return; + if (!data?.success) { + setError(data?.code === 'reconnect' ? 'reconnect' : 'failed'); + return; + } + setTeams(data.teams ?? []); + setProjects(data.projects ?? []); + } catch { + if (request === requestRef.current) setError('failed'); + } finally { + if (request === requestRef.current) setLoading(false); + } + }, [connectionId, token]); + + useEffect(() => { + load(); + }, [load]); + + // A few hundred rows at most, so filtering on each render is cheap. + const needle = query.trim().toLowerCase(); + const matches = (...texts: string[]) => + !needle || texts.some((text) => text.toLowerCase().includes(needle)); + const visibleTeams = teams.filter((team) => matches(team.name, team.key)); + const visibleProjects = projects.filter((project) => matches(project.name)); + + const pickedTeams = new Set(value.teams.map((team) => team.id)); + const pickedProjects = new Set(value.projects.map((project) => project.id)); + + const toggleTeam = (team: LinearTeam, on: boolean) => + onChange({ + ...value, + teams: on + ? [...value.teams, { id: team.id, key: team.key, name: team.name }] + : value.teams.filter((picked) => picked.id !== team.id), + }); + const toggleProject = (project: LinearProject, on: boolean) => + onChange({ + ...value, + projects: on + ? [...value.projects, { id: project.id, name: project.name }] + : value.projects.filter((picked) => picked.id !== project.id), + }); + + if (loading && teams.length + projects.length === 0) + return ; + if (error) { + return ( + + {t('retry')} + + ) : undefined + } + /> + ); + } + if (teams.length + projects.length === 0) { + return ( + + ); + } + + const row = ( + id: string, + checked: boolean, + title: string, + description: string, + onToggle: (on: boolean) => void, + ) => ( + onToggle(state === true)} + /> + } + title={title} + description={description || undefined} + > + + ); + + const list = (children: ReactNode) => ( + + {children} + + ); + + return ( +
+ {teams.length + projects.length > SEARCH_FROM && ( + setQuery(e.target.value)} + /> + )} + {visibleTeams.length + visibleProjects.length === 0 ? ( + + ) : ( + <> + {visibleTeams.length > 0 && ( +
+ + {list( + visibleTeams.map((team) => + row( + team.id, + pickedTeams.has(team.id), + team.name, + team.key, + (on) => toggleTeam(team, on), + ), + ), + )} +
+ )} + {visibleProjects.length > 0 && ( +
+ + {list( + visibleProjects.map((project) => + row( + project.id, + pickedProjects.has(project.id), + project.name, + [project.state, project.teams.join(', ')] + .filter(Boolean) + .join(' · '), + (on) => toggleProject(project, on), + ), + ), + )} +
+ )} + + )} + + + + onChange({ ...value, includeComments: checked === true }) + } + /> + + + 0} + disabled={value.projects.length === 0} + onCheckedChange={(checked) => + onChange({ ...value, includeDocuments: checked === true }) + } + /> + + +
+ ); +} diff --git a/frontend/src/connectors/types.ts b/frontend/src/connectors/types.ts index 50257a6f..4727480c 100644 --- a/frontend/src/connectors/types.ts +++ b/frontend/src/connectors/types.ts @@ -150,6 +150,23 @@ export type GitHubRepository = { html_url: string; }; +/** A Linear team a Linear connection can sync, for the sync picker. */ +export type LinearTeam = { + id: string; + /** The issue prefix, `ENG` in `ENG-123`. */ + key: string; + name: string; +}; + +/** A Linear project a Linear connection can sync, for the sync picker. */ +export type LinearProject = { + id: string; + name: string; + state: string; + /** Names of the teams it belongs to. */ + teams: string[]; +}; + export type ConnectionDetail = Connection & { sources: ConnectionSource[]; tools: ConnectionTool[]; diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 2ec4053e..ec259fd4 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1156,7 +1156,7 @@ "custom_mcp": "Einen beliebigen Remote-Server für das Model Context Protocol verbinden.", "custom_openapi": "Eine OpenAPI-Spezifikation importieren und ihre Endpunkte als Werkzeuge aufrufen.", "mcp_notion": "Notion-Seiten und -Datenbanken durchsuchen, lesen und aktualisieren.", - "mcp_linear": "Linear-Issues und -Projekte finden, anlegen und aktualisieren.", + "mcp_linear": "Issues und Dokumente in Wissen synchronisieren und Agenten Issues finden, anlegen und aktualisieren lassen.", "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.", @@ -1353,6 +1353,20 @@ "updated": "Aktualisiert {{date}}", "loadFailed": "Deine Repositorys konnten nicht geladen werden." }, + "linear": { + "teams": "Teams", + "teamsHint": "Die Issues dieser Teams synchronisieren.", + "projects": "Projekte", + "projectsHint": "Die Issues dieser Projekte synchronisieren.", + "search": "Teams und Projekte durchsuchen", + "noMatches": "Keine Teams oder Projekte passen zu deiner Suche.", + "includeComments": "Kommentare einbeziehen", + "includeCommentsDescription": "Jedem Issue seine Kommentare hinzufügen.", + "includeDocuments": "Projektdokumente einbeziehen", + "includeDocumentsDescription": "Die Linear-Dokumente der ausgewählten Projekte synchronisieren.", + "loadFailed": "Deine Linear-Teams und -Projekte konnten nicht geladen werden.", + "empty": "Dieses Linear-Konto hat keine Teams oder Projekte zum Synchronisieren." + }, "parameters": { "show": "Parameter", "hide": "Parameter ausblenden", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 65d2c403..bf959768 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1162,7 +1162,7 @@ "custom_mcp": "Connect any remote Model Context Protocol server.", "custom_openapi": "Import an OpenAPI spec and call its endpoints as tools.", "mcp_notion": "Search, read and update Notion pages and databases.", - "mcp_linear": "Find, create and update Linear issues and projects.", + "mcp_linear": "Sync issues and documents into Knowledge, and let agents find, create and update issues.", "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.", @@ -1359,6 +1359,20 @@ "updated": "Updated {{date}}", "loadFailed": "Could not load your repositories." }, + "linear": { + "teams": "Teams", + "teamsHint": "Sync the issues of these teams.", + "projects": "Projects", + "projectsHint": "Sync the issues of these projects.", + "search": "Search teams and projects", + "noMatches": "No teams or projects match your search.", + "includeComments": "Include comments", + "includeCommentsDescription": "Add each issue's comments to it.", + "includeDocuments": "Include project documents", + "includeDocumentsDescription": "Sync the Linear documents of the projects you pick.", + "loadFailed": "Could not load your Linear teams and projects.", + "empty": "This Linear account has no teams or projects to sync." + }, "parameters": { "show": "Parameters", "hide": "Hide parameters", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index be9e68fb..3145a51d 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1156,7 +1156,7 @@ "custom_mcp": "Conecta cualquier servidor remoto de Model Context Protocol.", "custom_openapi": "Importa una especificación OpenAPI y usa sus endpoints como herramientas.", "mcp_notion": "Busca, lee y actualiza páginas y bases de datos de Notion.", - "mcp_linear": "Encuentra, crea y actualiza incidencias y proyectos de Linear.", + "mcp_linear": "Sincroniza incidencias y documentos con Conocimiento y deja que los agentes busquen, creen y actualicen incidencias.", "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.", @@ -1353,6 +1353,20 @@ "updated": "Actualizado {{date}}", "loadFailed": "No se pudieron cargar tus repositorios." }, + "linear": { + "teams": "Equipos", + "teamsHint": "Sincroniza las incidencias de estos equipos.", + "projects": "Proyectos", + "projectsHint": "Sincroniza las incidencias de estos proyectos.", + "search": "Buscar equipos y proyectos", + "noMatches": "Ningún equipo ni proyecto coincide con tu búsqueda.", + "includeComments": "Incluir comentarios", + "includeCommentsDescription": "Añade a cada incidencia sus comentarios.", + "includeDocuments": "Incluir documentos de proyecto", + "includeDocumentsDescription": "Sincroniza los documentos de Linear de los proyectos que elijas.", + "loadFailed": "No se pudieron cargar tus equipos y proyectos de Linear.", + "empty": "Esta cuenta de Linear no tiene equipos ni proyectos que sincronizar." + }, "parameters": { "show": "Parámetros", "hide": "Ocultar parámetros", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 0fa45dca..68e0f62a 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1147,7 +1147,7 @@ "custom_mcp": "任意のリモート Model Context Protocol サーバーに接続します。", "custom_openapi": "OpenAPI 仕様をインポートし、そのエンドポイントをツールとして呼び出します。", "mcp_notion": "Notion のページやデータベースを検索、閲覧、更新します。", - "mcp_linear": "Linear の課題やプロジェクトを検索、作成、更新します。", + "mcp_linear": "課題とドキュメントをナレッジに同期し、エージェントが課題を検索、作成、更新できるようにします。", "mcp_atlassian": "Jira の課題と Confluence のページを検索、更新します。", "mcp_sentry": "Sentry の課題、イベント、リリースを調べます。", "mcp_asana": "Asana のタスクやプロジェクトを検索、更新します。", @@ -1335,6 +1335,20 @@ "updated": "{{date}} に更新", "loadFailed": "リポジトリを読み込めませんでした。" }, + "linear": { + "teams": "チーム", + "teamsHint": "これらのチームの課題を同期します。", + "projects": "プロジェクト", + "projectsHint": "これらのプロジェクトの課題を同期します。", + "search": "チームとプロジェクトを検索", + "noMatches": "検索に一致するチームやプロジェクトはありません。", + "includeComments": "コメントを含める", + "includeCommentsDescription": "各課題にそのコメントを追加します。", + "includeDocuments": "プロジェクトのドキュメントを含める", + "includeDocumentsDescription": "選択したプロジェクトの Linear ドキュメントを同期します。", + "loadFailed": "Linear のチームとプロジェクトを読み込めませんでした。", + "empty": "この Linear アカウントには同期できるチームやプロジェクトがありません。" + }, "parameters": { "show": "パラメーター", "hide": "パラメーターを隠す", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 2da1190a..657ea528 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1212,7 +1212,7 @@ "custom_mcp": "Подключает любой удалённый сервер Model Context Protocol.", "custom_openapi": "Импортирует спецификацию OpenAPI и вызывает её эндпоинты как инструменты.", "mcp_notion": "Поиск, чтение и обновление страниц и баз данных Notion.", - "mcp_linear": "Поиск, создание и обновление задач и проектов Linear.", + "mcp_linear": "Синхронизируйте задачи и документы в базу знаний и дайте агентам находить, создавать и обновлять задачи.", "mcp_atlassian": "Поиск и обновление задач Jira и страниц Confluence.", "mcp_sentry": "Просмотр проблем, событий и релизов Sentry.", "mcp_asana": "Поиск и обновление задач и проектов Asana.", @@ -1427,6 +1427,20 @@ "updated": "Обновлён {{date}}", "loadFailed": "Не удалось загрузить ваши репозитории." }, + "linear": { + "teams": "Команды", + "teamsHint": "Синхронизировать задачи этих команд.", + "projects": "Проекты", + "projectsHint": "Синхронизировать задачи этих проектов.", + "search": "Поиск команд и проектов", + "noMatches": "Нет команд или проектов, подходящих под запрос.", + "includeComments": "Включать комментарии", + "includeCommentsDescription": "Добавлять к каждой задаче её комментарии.", + "includeDocuments": "Включать документы проектов", + "includeDocumentsDescription": "Синхронизировать документы Linear выбранных проектов.", + "loadFailed": "Не удалось загрузить ваши команды и проекты Linear.", + "empty": "В этом аккаунте Linear нет команд или проектов для синхронизации." + }, "parameters": { "show": "Параметры", "hide": "Скрыть параметры", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 97e8d3e4..a0103dfd 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1147,7 +1147,7 @@ "custom_mcp": "連接任何遠端 Model Context Protocol 伺服器。", "custom_openapi": "匯入 OpenAPI 規格,並將其端點作為工具呼叫。", "mcp_notion": "搜尋、閱讀和更新 Notion 頁面與資料庫。", - "mcp_linear": "尋找、建立和更新 Linear 議題與專案。", + "mcp_linear": "將議題和文件同步到知識庫,並讓代理尋找、建立和更新議題。", "mcp_atlassian": "搜尋和更新 Jira 議題與 Confluence 頁面。", "mcp_sentry": "查看 Sentry 問題、事件和發行版本。", "mcp_asana": "尋找和更新 Asana 任務與專案。", @@ -1335,6 +1335,20 @@ "updated": "更新於 {{date}}", "loadFailed": "無法載入你的儲存庫。" }, + "linear": { + "teams": "團隊", + "teamsHint": "同步這些團隊的議題。", + "projects": "專案", + "projectsHint": "同步這些專案的議題。", + "search": "搜尋團隊和專案", + "noMatches": "沒有符合搜尋的團隊或專案。", + "includeComments": "包含留言", + "includeCommentsDescription": "將每個議題的留言一併加入。", + "includeDocuments": "包含專案文件", + "includeDocumentsDescription": "同步所選專案的 Linear 文件。", + "loadFailed": "無法載入你的 Linear 團隊和專案。", + "empty": "此 Linear 帳戶沒有可同步的團隊或專案。" + }, "parameters": { "show": "參數", "hide": "隱藏參數", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index ad86c1f1..794e16c6 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1147,7 +1147,7 @@ "custom_mcp": "连接任意远程 Model Context Protocol 服务器。", "custom_openapi": "导入 OpenAPI 规范,并将其端点作为工具调用。", "mcp_notion": "搜索、阅读和更新 Notion 页面与数据库。", - "mcp_linear": "查找、创建和更新 Linear 问题与项目。", + "mcp_linear": "将问题和文档同步到知识库,并让智能体查找、创建和更新问题。", "mcp_atlassian": "搜索和更新 Jira 问题与 Confluence 页面。", "mcp_sentry": "查看 Sentry 问题、事件和发布版本。", "mcp_asana": "查找和更新 Asana 任务与项目。", @@ -1335,6 +1335,20 @@ "updated": "更新于 {{date}}", "loadFailed": "无法加载你的仓库。" }, + "linear": { + "teams": "团队", + "teamsHint": "同步这些团队的问题。", + "projects": "项目", + "projectsHint": "同步这些项目的问题。", + "search": "搜索团队和项目", + "noMatches": "没有与搜索匹配的团队或项目。", + "includeComments": "包含评论", + "includeCommentsDescription": "将每个问题的评论一并加入。", + "includeDocuments": "包含项目文档", + "includeDocumentsDescription": "同步所选项目的 Linear 文档。", + "loadFailed": "无法加载你的 Linear 团队和项目。", + "empty": "此 Linear 账户没有可同步的团队或项目。" + }, "parameters": { "show": "参数", "hide": "隐藏参数",