Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions src/main/core/linear/linear-connection-service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { LinearClient } from '@linear/sdk';
import { AuthenticationLinearError, ForbiddenLinearError, LinearClient } from '@linear/sdk';
import { ISSUE_PROVIDER_CAPABILITIES, type ConnectionStatus } from '@shared/issue-providers';
import { encryptedAppSecretsStore } from '@main/core/secrets/encrypted-app-secrets-store';
import { log } from '@main/lib/logger';
import { telemetryService } from '@main/lib/telemetry';

function isAuthFailure(error: unknown): boolean {
return error instanceof AuthenticationLinearError || error instanceof ForbiddenLinearError;
}

export class LinearConnectionService {
private readonly LINEAR_TOKEN_SECRET_KEY = 'emdash-linear-token';

private cachedToken: string | null | undefined = undefined;
private client: LinearClient | null = null;
private clientToken: string | null = null;
/** `null` = no successful verification yet; `undefined` = verified, name unavailable. */
private lastVerifiedDisplayName: string | undefined | null = null;

async saveToken(
token: string
Expand All @@ -20,16 +26,15 @@ export class LinearConnectionService {
return { success: false, error: 'Linear token cannot be empty.' };
}

const client = this.getClientForToken(clean);
const viewer = await client.viewer;
const org = await viewer.organization;
const displayName = await this.fetchViewerDisplayName(clean);

await this.storeToken(clean);
this.lastVerifiedDisplayName = displayName;
telemetryService.capture('integration_connected', { provider: 'linear' });

return {
success: true,
workspaceName: org?.name ?? viewer.displayName ?? undefined,
workspaceName: displayName,
};
} catch (error) {
const message =
Expand All @@ -46,6 +51,7 @@ export class LinearConnectionService {
this.cachedToken = null;
this.client = null;
this.clientToken = null;
this.lastVerifiedDisplayName = null;
telemetryService.capture('integration_disconnected', { provider: 'linear' });
return { success: true };
} catch (error) {
Expand All @@ -58,30 +64,46 @@ export class LinearConnectionService {
}

async checkConnection(): Promise<ConnectionStatus> {
const token = await this.getStoredToken();
if (!token) {
this.lastVerifiedDisplayName = null;
return {
connected: false,
capabilities: ISSUE_PROVIDER_CAPABILITIES.linear,
};
}

try {
const token = await this.getStoredToken();
if (!token) {
const displayName = await this.fetchViewerDisplayName(token);
this.lastVerifiedDisplayName = displayName;
return {
connected: true,
displayName,
capabilities: ISSUE_PROVIDER_CAPABILITIES.linear,
};
} catch (error) {
if (isAuthFailure(error)) {
this.lastVerifiedDisplayName = null;
const message = error instanceof Error ? error.message : 'Linear token rejected.';
return {
connected: false,
error: message,
capabilities: ISSUE_PROVIDER_CAPABILITIES.linear,
};
}

const client = this.getClientForToken(token);
const viewer = await client.viewer;
const org = await viewer.organization;
if (this.lastVerifiedDisplayName === null) {
return {
connected: false,
error: 'Unable to verify Linear connection. Please try again.',
capabilities: ISSUE_PROVIDER_CAPABILITIES.linear,
};
}

log.warn('Linear connection check failed transiently; keeping connected:', error);
return {
connected: true,
displayName: org?.name ?? viewer.displayName ?? undefined,
capabilities: ISSUE_PROVIDER_CAPABILITIES.linear,
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to verify Linear connection.';
return {
connected: false,
error: message,
displayName: this.lastVerifiedDisplayName,
capabilities: ISSUE_PROVIDER_CAPABILITIES.linear,
};
}
Expand All @@ -96,6 +118,13 @@ export class LinearConnectionService {
return this.getClientForToken(token);
}

private async fetchViewerDisplayName(token: string): Promise<string | undefined> {
const client = this.getClientForToken(token);
const viewer = await client.viewer;
const organization = await viewer.organization;
return organization?.name ?? viewer.displayName ?? undefined;
}

private getClientForToken(token: string): LinearClient {
if (!this.client || this.clientToken !== token) {
this.client = new LinearClient({ apiKey: token });
Expand Down
39 changes: 26 additions & 13 deletions src/main/core/linear/linear-issue-provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import type * as LinearSdk from '@linear/sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { linearConnectionService } from './linear-connection-service';
import { linearIssueProvider } from './linear-issue-provider';

const mockRawRequest = vi.hoisted(() => vi.fn());

vi.mock('@linear/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof LinearSdk>();
return {
...actual,
LinearGraphQLClient: vi.fn().mockImplementation(function MockLinearGraphQLClient() {
return { rawRequest: mockRawRequest };
}),
};
});

vi.mock('./linear-connection-service', () => ({
linearConnectionService: {
getClient: vi.fn(),
Expand All @@ -10,17 +23,17 @@ vi.mock('./linear-connection-service', () => ({

const mockGetClient = vi.mocked(linearConnectionService.getClient);

function makeLinearClient(rawRequest: ReturnType<typeof vi.fn>) {
return {
client: {
rawRequest,
},
};
function makeLinearClient() {
return { options: { apiUrl: 'https://api.linear.app/graphql' } };
}

describe('linearIssueProvider', () => {
beforeEach(() => {
mockRawRequest.mockReset();
});

it('maps branchName from listed Linear issues', async () => {
const rawRequest = vi.fn().mockResolvedValue({
mockRawRequest.mockResolvedValue({
data: {
issues: {
nodes: [
Expand All @@ -41,11 +54,11 @@ describe('linearIssueProvider', () => {
},
},
});
mockGetClient.mockResolvedValue(makeLinearClient(rawRequest) as never);
mockGetClient.mockResolvedValue(makeLinearClient() as never);

const result = await linearIssueProvider.listIssues({ limit: 10 });

expect(rawRequest).toHaveBeenCalledWith(expect.stringContaining('branchName'), {
expect(mockRawRequest).toHaveBeenCalledWith(expect.stringContaining('branchName'), {
limit: 10,
});
expect(result).toEqual({
Expand All @@ -61,7 +74,7 @@ describe('linearIssueProvider', () => {
});

it('maps branchName from searched Linear issues', async () => {
const rawRequest = vi.fn().mockResolvedValue({
mockRawRequest.mockResolvedValue({
data: {
searchIssues: {
nodes: [
Expand All @@ -82,14 +95,14 @@ describe('linearIssueProvider', () => {
},
},
});
mockGetClient.mockResolvedValue(makeLinearClient(rawRequest) as never);
mockGetClient.mockResolvedValue(makeLinearClient() as never);

const result = await linearIssueProvider.searchIssues({
searchTerm: 'GEN-626',
limit: 5,
});

expect(rawRequest).toHaveBeenCalledWith(
expect(mockRawRequest).toHaveBeenCalledWith(
expect.stringContaining('branchName'),
expect.objectContaining({ term: 'GEN-626', limit: 5 })
);
Expand Down
15 changes: 13 additions & 2 deletions src/main/core/linear/linear-issue-provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LinearGraphQLClient, type LinearClient, type LinearRawResponse } from '@linear/sdk';
import { ISSUE_PROVIDER_CAPABILITIES, type IssueListResult } from '@shared/issue-providers';
import type { Issue } from '@shared/tasks';
import { clampIssueLimit, normalizeSearchTerm } from '@main/core/issues/helpers/provider-inputs';
Expand Down Expand Up @@ -63,6 +64,16 @@ const SEARCH_QUERY = `
}
`;

type RawRequest = <Data, Variables extends Record<string, unknown>>(
query: string,
variables?: Variables
) => Promise<LinearRawResponse<Data>>;

function createRawRequest(client: LinearClient): RawRequest {
const rawClient = new LinearGraphQLClient(client.options.apiUrl, client.options);
return rawClient.rawRequest.bind(rawClient);
}

function toIssue(raw: LinearIssueNode): Issue {
return {
provider: 'linear',
Expand Down Expand Up @@ -90,7 +101,7 @@ async function listIssues(limit = 50): Promise<IssueListResult> {
const sanitizedLimit = clampIssueLimit(limit, 50, 200);

try {
const { data } = await client.client.rawRequest<
const { data } = await createRawRequest(client)<
{ issues: { nodes: LinearIssueNode[] } },
{ limit: number }
>(ISSUES_QUERY, { limit: sanitizedLimit });
Expand Down Expand Up @@ -119,7 +130,7 @@ async function searchIssues(searchTerm: string, limit = 20): Promise<IssueListRe
const sanitizedLimit = clampIssueLimit(limit, 20, 200);

try {
const { data } = await client.client.rawRequest<
const { data } = await createRawRequest(client)<
{ searchIssues: { nodes: LinearIssueNode[] } },
{ term: string; limit: number }
>(SEARCH_QUERY, {
Expand Down
Loading