mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
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:
1 parent
03d1b10897
commit
2168b2188b
14 files changed
+796
-12
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "パラメーターを隠す",
|
||||
|
||||
@@ -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": "Скрыть параметры",
|
||||
|
||||
@@ -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": "隱藏參數",
|
||||
|
||||
@@ -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": "隐藏参数",
|
||||
|
||||
Reference in new issue
Block a user