Pick Linear teams and projects to sync after signing in to Linear

Signing in to Linear now goes on to choosing what to sync, as the
drawer's Sync more does: teams and projects to pick, whether to bring
comments (on) and the picked projects' documents. The tools still come
with the sign-in, and Skip keeps only them. A source is named after what
it syncs until the name is edited. Signing in again goes straight to the
summary.
This commit is contained in:
arc53-machine committed 2026-09-29 11:32:40 +01:00
1 parent 03d1b10897
commit 2168b2188b
14 files changed
+796 -12

No files matched your search

+2
View File
@@ -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) =>
@@ -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(
+159 -2
View File
@@ -73,6 +73,32 @@ vi.mock('./RepoPicker', () => ({
),
}));
vi.mock('./LinearPicker', async (importOriginal) => {
const original = await importOriginal<typeof import('./LinearPicker')>();
return {
...original,
default: ({
value,
onChange,
}: {
value: import('./LinearPicker').LinearSelection;
onChange: (selection: import('./LinearPicker').LinearSelection) => void;
}) => (
<button
type="button"
onClick={() =>
onChange({
...value,
teams: [{ id: 't1', key: 'ENG', name: 'Engineering' }],
})
}
>
pick-team
</button>
),
};
});
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<Parameters<typeof ConnectWizard>[0]> = {},
) => {
const store = configureStore({
reducer: {
connectors: connectorsReducer,
@@ -207,7 +237,11 @@ describe('ConnectWizard', () => {
<Route
path="/settings/connectors"
element={
<ConnectWizard connector={connector} onClose={onClose} />
<ConnectWizard
connector={connector}
onClose={onClose}
{...props}
/>
}
/>
<Route path="/c/new" element={<div>NEW_CHAT</div>} />
@@ -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<HTMLButtonElement>('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');
});
});
});
+28 -3
View File
@@ -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<string[]>([]);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
const [linearSelection, setLinearSelection] = useState<LinearSelection>(
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<string, unknown> =>
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 ? (
<LinearPicker
connectionId={connectionId}
token={token}
value={linearSelection}
onChange={(selection) => {
setLinearSelection(selection);
if (!nameTouched) setSourceName(linearSourceName(selection));
}}
/>
) : connector.key === 'google_drive' &&
envVar('VITE_GOOGLE_CLIENT_ID') ? (
<GoogleDrivePicker
@@ -0,0 +1,187 @@
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(() => ({ 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(
<LinearPicker
connectionId="conn-1"
token={null}
value={value}
onChange={onChange}
/>,
);
});
return onChange;
};
const checkboxes = () =>
Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="checkbox"]'),
);
const switches = () =>
Array.from(
container.querySelectorAll<HTMLButtonElement>('[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<HTMLInputElement>('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');
});
});
+295
View File
@@ -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<LinearProject, 'id' | 'name'>[];
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<LinearTeam[]>([]);
const [projects, setProjects] = useState<LinearProject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<LoadError>(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 <LoadingState fill="block" />;
if (error) {
return (
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={
error === 'reconnect'
? t('settings.connectors.detail.expired')
: t('settings.connectors.linear.loadFailed')
}
action={
error === 'failed' ? (
<Button variant="outline" size="sm" shape="pill" onClick={load}>
{t('retry')}
</Button>
) : undefined
}
/>
);
}
if (teams.length + projects.length === 0) {
return (
<EmptyState
size="xs"
illustration="none"
title={t('settings.connectors.linear.empty')}
/>
);
}
const row = (
id: string,
checked: boolean,
title: string,
description: string,
onToggle: (on: boolean) => void,
) => (
<ListRow
key={id}
interactive
asChild
leading={
<Checkbox
id={`linear-${id}`}
checked={checked}
onCheckedChange={(state) => onToggle(state === true)}
/>
}
title={title}
description={description || undefined}
>
<label htmlFor={`linear-${id}`} />
</ListRow>
);
const list = (children: ReactNode) => (
<Card variant="subtle" padding="none" className="max-h-60 overflow-y-auto">
<ListRows>{children}</ListRows>
</Card>
);
return (
<div className="flex flex-col gap-5">
{teams.length + projects.length > SEARCH_FROM && (
<SearchInput
label={t('settings.connectors.linear.search')}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
)}
{visibleTeams.length + visibleProjects.length === 0 ? (
<EmptyState
size="xs"
illustration="none"
title={t('settings.connectors.linear.noMatches')}
/>
) : (
<>
{visibleTeams.length > 0 && (
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="xs"
title={t('settings.connectors.linear.teams')}
description={t('settings.connectors.linear.teamsHint')}
/>
{list(
visibleTeams.map((team) =>
row(
team.id,
pickedTeams.has(team.id),
team.name,
team.key,
(on) => toggleTeam(team, on),
),
),
)}
</section>
)}
{visibleProjects.length > 0 && (
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="xs"
title={t('settings.connectors.linear.projects')}
description={t('settings.connectors.linear.projectsHint')}
/>
{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),
),
),
)}
</section>
)}
</>
)}
<SettingRows>
<SettingRow
label={t('settings.connectors.linear.includeComments')}
description={t(
'settings.connectors.linear.includeCommentsDescription',
)}
htmlFor="linear-include-comments"
alignStart
>
<Switch
id="linear-include-comments"
checked={value.includeComments}
onCheckedChange={(checked) =>
onChange({ ...value, includeComments: checked === true })
}
/>
</SettingRow>
<SettingRow
label={t('settings.connectors.linear.includeDocuments')}
description={t(
'settings.connectors.linear.includeDocumentsDescription',
)}
htmlFor="linear-include-documents"
alignStart
>
<Switch
id="linear-include-documents"
checked={value.includeDocuments && value.projects.length > 0}
disabled={value.projects.length === 0}
onCheckedChange={(checked) =>
onChange({ ...value, includeDocuments: checked === true })
}
/>
</SettingRow>
</SettingRows>
</div>
);
}
+17
View File
@@ -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[];
+15 -1
View File
@@ -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",
+15 -1
View File
@@ -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",
+15 -1
View File
@@ -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",
+15 -1
View File
@@ -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": "パラメーターを隠す",
+15 -1
View File
@@ -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": "Скрыть параметры",
+15 -1
View File
@@ -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": "隱藏參數",
+15 -1
View File
@@ -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": "隐藏参数",