Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { isHeicLikeFile, isUnstableDropPath } from '@renderer/lib/pty/terminal-i
import { useAgents } from '@renderer/lib/stores/use-agents';
import { Button } from '@renderer/lib/ui/button';
import { log } from '@renderer/utils/logger';
import type { LinkedIssue } from '@shared/core/linked-issue';
import { linkedIssueMentionName, type LinkedIssue } from '@shared/core/linked-issue';
import type { AcpChatStore, AcpPromptAttachment } from './acp-chat-store';
import type { AcpChatTabResource } from './acp-chat-tab-resource';
import { chatViewCommandForShortcut, executeChatViewCommand } from './acp-chat-view-commands';
Expand Down Expand Up @@ -78,7 +78,7 @@ function toIssueMentionItem(issue: LinkedIssue): MentionItem {
return {
id: token,
label: token,
name: issue.displayIdentifier ?? issue.identifier,
name: linkedIssueMentionName(issue),
kind: 'issue',
description: issue.title,
icon: <IntegrationIcon provider={issue.provider} size={13} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,18 @@ function IntegrationSetupForm({
</div>
))}
{method.help || method.helpUrl ? (
<div>
{method.help ? (
<p className="text-xs text-foreground-muted">
{method.help}
{method.helpUrl ? (
<Button
variant="link"
size="xs"
className="h-auto"
onClick={() => window.open(method.helpUrl, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="ml-1 h-3 w-3" />
</Button>
) : null}
</p>
<div className="flex items-start justify-between gap-2">
{method.help ? <p className="text-xs text-foreground-muted">{method.help}</p> : null}
{method.helpUrl ? (
<Button
variant="link"
size="icon-xs"
className="mt-0.5 size-4 shrink-0 p-0"
aria-label={`Open ${metadata.name} setup guide`}
onClick={() => window.open(method.helpUrl, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="h-3 w-3" />
</Button>
) : null}
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/lib/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip';
import { cn } from '@renderer/utils/utils';
import type { LinkedIssue } from '@shared/core/linked-issue';
import { linkedIssueDisplayIdentifier, type LinkedIssue } from '@shared/core/linked-issue';
import { getLinkedIssueMap, type LinkedIssueInfo } from './use-linked-issue-urls';
import { useIssueSearch } from './useIssueSearch';

Expand All @@ -39,8 +39,7 @@ export function IssueIdentifier({
issue: Pick<LinkedIssue, 'identifier' | 'displayIdentifier'>;
className?: string;
}) {
const identifier =
issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier);
const identifier = linkedIssueDisplayIdentifier(issue);
if (!identifier) return null;

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip';
import { formatDiffLineCount } from '@renderer/utils/format-diff-line-count';
import { cn } from '@renderer/utils/utils';
import type { LinkedIssue } from '@shared/core/linked-issue';
import { linkedIssueDisplayIdentifier, type LinkedIssue } from '@shared/core/linked-issue';
import { AutomationRunPill } from './components/automation-run-pill';
import { IssueSelector, ProviderLogo } from './components/issue-selector/issue-selector';
import { PreviewServerPills } from './components/preview-servers/preview-server-pills';
Expand Down Expand Up @@ -416,8 +416,7 @@ const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({
});

function LinkedIssueBadge({ issue }: { issue: LinkedIssue }) {
const displayIdentifier =
issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier);
const displayIdentifier = linkedIssueDisplayIdentifier(issue);

return (
<Tooltip>
Expand Down
41 changes: 41 additions & 0 deletions apps/emdash-desktop/src/shared/core/linked-issue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { linkedIssueDisplayIdentifier, linkedIssueMentionName } from './linked-issue';

describe('linked issue display helpers', () => {
it('uses displayIdentifier for issue mentions when available', () => {
expect(
linkedIssueMentionName({
identifier: 'internal-id',
displayIdentifier: 'ENG-123',
title: 'Fix issue mentions',
})
).toBe('ENG-123');
});

it('uses title for issue mentions when the provider hides internal identifiers', () => {
expect(
linkedIssueMentionName({
identifier: '37818d1b-a831-812e-8ca0-c115c72de662',
displayIdentifier: null,
title: 'ai health paper website',
})
).toBe('ai health paper website');
});

it('keeps raw identifiers visible only when displayIdentifier is unspecified', () => {
const issue = { identifier: '#42', title: 'Fix login' };

expect(linkedIssueDisplayIdentifier(issue)).toBe('#42');
expect(linkedIssueMentionName(issue)).toBe('#42');
});

it('uses a generic mention name when both display identifier and title are hidden', () => {
expect(
linkedIssueMentionName({
identifier: 'internal-id',
displayIdentifier: null,
title: '',
})
).toBe('Linked issue');
});
});
13 changes: 13 additions & 0 deletions apps/emdash-desktop/src/shared/core/linked-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const v0Schema = z.object({
'featurebase',
'asana',
'monday',
'notion',
'trello',
]),
url: z.string(),
Expand Down Expand Up @@ -54,3 +55,15 @@ export const linkedIssueSchema = linkedIssue.schema;

/** The TypeScript type for a linked issue. */
export type LinkedIssue = typeof linkedIssue.Type;

export function linkedIssueDisplayIdentifier(
issue: Pick<LinkedIssue, 'identifier' | 'displayIdentifier'>
): string | null {
return issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier);
}

export function linkedIssueMentionName(
issue: Pick<LinkedIssue, 'identifier' | 'displayIdentifier' | 'title'>
): string {
return linkedIssueDisplayIdentifier(issue) ?? (issue.title || 'Linked issue');
}
1 change: 1 addition & 0 deletions packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@llamaduck/forgejo-ts": "^14.0.3",
"@makeplane/plane-node-sdk": "^0.2.11",
"@mondaydotcomorg/api": "^14.0.0",
"@notionhq/client": "^5.22.0",
"@octokit/rest": "^22.0.1",
"@team-plain/graphql": "^1.0.1",
"asana": "^3.1.12",
Expand Down
43 changes: 43 additions & 0 deletions packages/plugins/src/integrations/impl/notion/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { err, ok, type Result } from '@emdash/shared';
import { Client } from '@notionhq/client';
import { parseCredentials } from '../../helpers/credentials';
import type { IntegrationCredentials } from '../../host';
import type { IntegrationError } from '../../types';
import { toNotionIntegrationError } from './error';
import {
type NotionClient,
type NotionCredentials,
notionCredentialsSchema,
type NotionVerifiedConnection,
} from './types';

export function readNotionCredentials(
credentials: IntegrationCredentials
): Result<NotionCredentials, IntegrationError> {
return parseCredentials(notionCredentialsSchema, credentials);
}

export function createNotionClient(credentials: NotionCredentials): NotionClient {
return new Client({ auth: credentials.apiToken });
}

export async function verifyNotionCredentials(
rawCredentials: IntegrationCredentials
): Promise<Result<NotionVerifiedConnection, IntegrationError>> {
const credentials = readNotionCredentials(rawCredentials);
if (!credentials.success) return err(credentials.error);

const client = createNotionClient(credentials.data);
try {
const user = await client.users.me({});
const displayName = user.name ?? (user.type === 'bot' ? 'Notion bot' : 'Notion user');
const displayDetail = user.type === 'person' ? user.person.email : undefined;
return ok({
displayName,
...(displayDetail ? { displayDetail } : {}),
credentials: credentials.data,
});
} catch (error) {
return err(toNotionIntegrationError(error, 'Failed to validate Notion token.'));
}
}
51 changes: 51 additions & 0 deletions packages/plugins/src/integrations/impl/notion/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { APIErrorCode, ClientErrorCode, isNotionClientError } from '@notionhq/client';
import type { IntegrationError } from '../../types';

export function toNotionIntegrationError(
error: unknown,
fallback = 'Notion request failed.'
): IntegrationError {
if (!isNotionClientError(error)) {
if (error instanceof Error) return { type: 'generic', message: error.message || fallback };
return { type: 'generic', message: fallback };
}

switch (error.code) {
case APIErrorCode.Unauthorized:
return {
type: 'auth_failed',
message: 'Notion authentication failed. Check your integration token.',
};
case APIErrorCode.RestrictedResource:
return {
type: 'auth_failed',
message: 'Notion token is missing the required capabilities or page access.',
};
case APIErrorCode.ObjectNotFound:
return {
type: 'not_found_or_no_access',
message: 'Notion resource was not found or the integration does not have access.',
};
case APIErrorCode.RateLimited:
return {
type: 'rate_limited',
message: 'Notion API rate limit exceeded. Please try again shortly.',
};
case APIErrorCode.InternalServerError:
case APIErrorCode.ServiceUnavailable:
case APIErrorCode.GatewayTimeout:
case ClientErrorCode.RequestTimeout:
return {
type: 'host_unreachable',
message: 'Notion API is temporarily unavailable. Please try again.',
};
case APIErrorCode.InvalidJSON:
case APIErrorCode.InvalidRequestURL:
case APIErrorCode.InvalidRequest:
case APIErrorCode.ValidationError:
case APIErrorCode.ConflictError:
case ClientErrorCode.InvalidPathParameter:
case ClientErrorCode.ResponseError:
return { type: 'generic', message: error.message || fallback };
}
}
15 changes: 15 additions & 0 deletions packages/plugins/src/integrations/impl/notion/icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { PluginIconAsset } from '@emdash/shared/plugins';

const svg =
'<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" viewBox="0 0 256 268"><path fill="#FFF" d="M16.092 11.538 164.09.608c18.179-1.56 22.85-.508 34.28 7.801l47.243 33.282C253.406 47.414 256 48.975 256 55.207v182.527c0 11.439-4.155 18.205-18.696 19.24L65.44 267.378c-10.913.517-16.11-1.043-21.825-8.327L8.826 213.814C2.586 205.487 0 199.254 0 191.97V29.726c0-9.352 4.155-17.153 16.092-18.188Z"/><path d="M164.09.608 16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608ZM69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095l-1.819.125Zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921ZM212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585 52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404l35.84-2.087Z"/></svg>';

export const icon: PluginIconAsset = {
kind: 'svg',
alt: 'Notion',
variants: [
{
minSize: 0,
light: svg,
},
],
};
100 changes: 100 additions & 0 deletions packages/plugins/src/integrations/impl/notion/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Logger } from '@emdash/shared/logger';
import { APIErrorCode, APIResponseError } from '@notionhq/client';
import type * as NotionSdk from '@notionhq/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { IntegrationHostContext } from '../../host';
import { provider } from './index';

const notionSdk = vi.hoisted(() => ({
constructor: vi.fn(),
me: vi.fn(),
}));

vi.mock('@notionhq/client', async (importOriginal) => {
const actual = await importOriginal<typeof NotionSdk>();
return {
...actual,
Client: class {
constructor(config: unknown) {
notionSdk.constructor(config);
}

users = {
me: notionSdk.me,
};
},
};
});

const logger: Logger = {
level: 'error',
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
child: () => logger,
};

const auth = provider.behavior.auth;
if (!auth) throw new Error('Notion auth behavior is not registered.');

const host: IntegrationHostContext = { log: logger };

afterEach(() => {
notionSdk.constructor.mockReset();
notionSdk.me.mockReset();
});

describe('notion integration verify', () => {
it('validates the integration token and returns normalized credentials', async () => {
notionSdk.me.mockResolvedValueOnce({
object: 'user',
id: 'bot-1',
type: 'bot',
name: 'Emdash',
avatar_url: null,
bot: {},
});

const result = await auth.verify(host, { apiToken: ' ntn_valid ' });

expect(result).toEqual({
connected: true,
displayName: 'Emdash',
credentials: { apiToken: 'ntn_valid' },
});
expect(notionSdk.constructor).toHaveBeenCalledWith({ auth: 'ntn_valid' });
expect(notionSdk.me).toHaveBeenCalledWith({});
});

it('returns an error for an empty token', async () => {
const result = await auth.verify(host, { apiToken: ' ' });

expect(result).toEqual({
connected: false,
error: 'Notion integration token is required.',
});
expect(notionSdk.constructor).not.toHaveBeenCalled();
});

it('maps Notion unauthorized errors to a helpful auth message', async () => {
notionSdk.me.mockRejectedValueOnce(
new APIResponseError({
code: APIErrorCode.Unauthorized,
status: 401,
message: 'API token is invalid.',
headers: new Headers(),
rawBodyText: '{}',
additional_data: undefined,
request_id: undefined,
})
);

const result = await auth.verify(host, { apiToken: 'bad-token' });

expect(result).toEqual({
connected: false,
error: 'Notion authentication failed. Check your integration token.',
});
});
});
Loading
Loading