From 468ebef1a083d07127e0b4b4bc823b31e3141585 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Thu, 22 Oct 2026 17:12:54 -0500 Subject: [PATCH 01/12] feat(jira): connect Jira Data Center with a personal access token The Jira integration gains a third sign-in option, "Personal access token (Data Center)", taking a site URL and a token. Data Center serves only REST v2, so these accounts verify against the site's own /myself and list and search issues through /rest/api/2/search instead of Cloud's enhanced search. Data Center users have no accountId, so the account is keyed by the user key with the username as its login. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../src/integrations/impl/jira/client.test.ts | 47 ++++++++++++++++++- .../src/integrations/impl/jira/client.ts | 33 ++++++++++--- .../src/integrations/impl/jira/index.ts | 23 +++++++++ .../src/integrations/impl/jira/types.ts | 15 ++++-- .../src/issues/impl/jira/index.test.ts | 38 +++++++++++++++ .../plugins/src/issues/impl/jira/index.ts | 32 +++++++++---- 7 files changed, 169 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 734a8dccc1..eabd615a16 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ already use: Claude Code, Codex, OpenCode, Amp, and more. - Run multiple coding agents at once without juggling terminals. - Keep every agent isolated in its own Git worktree and branch. -- Send issues and tickets from Linear, GitHub, Jira, GitLab, Asana, Featurebase, - Monday.com, Forgejo, Plain, or YouTrack into an agent. +- Send issues and tickets from Linear, GitHub, Jira (Cloud or Data Center), GitLab, + Asana, Featurebase, Monday.com, Forgejo, Plain, or YouTrack into an agent. - Review diffs, create pull requests, inspect CI checks, and merge from one place. - Work locally or on your own remote machines over SSH/SFTP. diff --git a/packages/plugins/src/integrations/impl/jira/client.test.ts b/packages/plugins/src/integrations/impl/jira/client.test.ts index 0ab9820bc3..bce6b24f4a 100644 --- a/packages/plugins/src/integrations/impl/jira/client.test.ts +++ b/packages/plugins/src/integrations/impl/jira/client.test.ts @@ -3,8 +3,18 @@ import { createJiraClient, verifyJiraCredentials } from './client'; import { provider as integration } from './index'; import { jiraCredentialsSchema } from './types'; -const mocks = vi.hoisted(() => ({ createClient: vi.fn(), getCurrentUser: vi.fn() })); +const mocks = vi.hoisted(() => ({ + createClient: vi.fn(), + createV2Client: vi.fn(), + getCurrentUser: vi.fn(), +})); vi.mock('jira.js', () => ({ + Version2Client: class { + myself = { getCurrentUser: mocks.getCurrentUser }; + constructor(config: unknown) { + mocks.createV2Client(config); + } + }, Version3Client: class { myself = { getCurrentUser: mocks.getCurrentUser }; constructor(config: unknown) { @@ -90,7 +100,7 @@ describe('Jira connection verification', () => { 'https://api.atlassian.com/oauth/token/accessible-resources', { headers: { Authorization: 'Bearer scoped-token' } } ); - expect(integration.capabilities.auth.methods).toHaveLength(2); + expect(integration.capabilities.auth.methods).toHaveLength(3); }); it('rejects a bearer token that cannot access the selected Jira site', async () => { @@ -185,6 +195,39 @@ describe('Jira connection verification', () => { }); }); + it('verifies a Data Center personal access token against the site itself', async () => { + mocks.getCurrentUser.mockResolvedValueOnce({ + key: 'JIRAUSER10100', + name: 'ada', + displayName: 'Ada Lovelace', + }); + + const result = await verifyJiraCredentials( + { siteUrl: 'https://jira.example.com/', personalAccessToken: 'dc-token' }, + 'pat' + ); + + expect(fetch).not.toHaveBeenCalled(); + expect(mocks.createClient).not.toHaveBeenCalled(); + expect(mocks.createV2Client).toHaveBeenCalledWith({ + host: 'https://jira.example.com', + authentication: { oauth2: { accessToken: 'dc-token' } }, + }); + expect(result).toEqual({ + success: true, + data: { + account: { id: 'JIRAUSER10100', login: 'ada', host: 'jira.example.com' }, + displayName: 'Ada Lovelace', + displayDetail: 'Personal access token · jira.example.com', + credentials: { + authMethod: 'pat', + siteUrl: 'https://jira.example.com', + personalAccessToken: 'dc-token', + }, + }, + }); + }); + it('rejects an unknown selected method before calling either API', async () => { const result = await verifyJiraCredentials( { diff --git a/packages/plugins/src/integrations/impl/jira/client.ts b/packages/plugins/src/integrations/impl/jira/client.ts index 1eeb991b72..7a9aa80eaa 100644 --- a/packages/plugins/src/integrations/impl/jira/client.ts +++ b/packages/plugins/src/integrations/impl/jira/client.ts @@ -1,5 +1,5 @@ import { err, ok, type Result } from '@emdash/shared'; -import { Version3Client } from 'jira.js'; +import { Version2Client, Version3Client } from 'jira.js'; import { parseCredentials } from '../../helpers/credentials'; import { toIntegrationError } from '../../helpers/error'; import type { IntegrationCredentials } from '../../host'; @@ -27,6 +27,14 @@ export function createJiraClient(credentials: JiraCredentials): JiraClient { }); } + if (credentials.authMethod === 'pat') { + // Data Center reads a personal access token as a plain Bearer header, which oauth2 sends. + return new Version2Client({ + host: credentials.siteUrl, + authentication: { oauth2: { accessToken: credentials.personalAccessToken } }, + }); + } + return new Version3Client({ host: credentials.siteUrl, authentication: { @@ -60,26 +68,37 @@ export async function verifyJiraCredentials( } : credentials.data; const client = createJiraClient(verifiedCredentials); - const user = await client.myself.getCurrentUser(); + const user = + verifiedCredentials.authMethod === 'pat' + ? await (client as Version2Client).myself.getCurrentUser() + : await (client as Version3Client).myself.getCurrentUser(); const host = new URL(verifiedCredentials.siteUrl).host; - const isBearer = verifiedCredentials.authMethod === 'bearer'; const email = verifiedCredentials.authMethod === 'basic' ? verifiedCredentials.email : undefined; + // Data Center users have no accountId; their user key is the stable identifier. + const accountId = user.accountId ?? user.key; + const login = email ?? (verifiedCredentials.authMethod === 'pat' ? user.name : undefined); return ok({ - ...(user.accountId + ...(accountId ? { account: { - id: user.accountId, - ...(email ? { login: email } : {}), + id: accountId, + ...(login ? { login } : {}), host, }, } : {}), displayName: user.displayName, - displayDetail: `${isBearer ? 'Bearer token' : email} · ${host}`, + displayDetail: `${authLabel(verifiedCredentials, email)} · ${host}`, credentials: verifiedCredentials, }); } catch (error) { return err(toIntegrationError(error, 'Jira')); } } + +function authLabel(credentials: JiraCredentials, email: string | undefined): string { + if (credentials.authMethod === 'bearer') return 'Bearer token'; + if (credentials.authMethod === 'pat') return 'Personal access token'; + return email ?? ''; +} diff --git a/packages/plugins/src/integrations/impl/jira/index.ts b/packages/plugins/src/integrations/impl/jira/index.ts index 84a51e8dde..5a35890121 100644 --- a/packages/plugins/src/integrations/impl/jira/index.ts +++ b/packages/plugins/src/integrations/impl/jira/index.ts @@ -63,6 +63,29 @@ const plugin = defineIntegrationPlugin( ], help: 'Use an OAuth 2.0 access token for Jira Cloud. Atlassian API tokens are not supported by this method.', }, + { + kind: 'form', + id: 'pat', + label: 'Personal access token (Data Center)', + fields: [ + { + id: 'siteUrl', + label: 'Site URL', + required: true, + placeholder: 'https://jira.example.com', + }, + { + id: 'personalAccessToken', + label: 'Personal access token', + secret: true, + required: true, + placeholder: 'Jira personal access token', + }, + ], + help: 'For Jira Data Center or Server 8.14+. Create a token under your Jira profile, Personal Access Tokens.', + helpUrl: + 'https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html', + }, ], }, }, diff --git a/packages/plugins/src/integrations/impl/jira/types.ts b/packages/plugins/src/integrations/impl/jira/types.ts index f098a0f661..29f4d1b1f8 100644 --- a/packages/plugins/src/integrations/impl/jira/types.ts +++ b/packages/plugins/src/integrations/impl/jira/types.ts @@ -1,4 +1,4 @@ -import type { Version3Client, Version3Models } from 'jira.js'; +import type { Version2Client, Version2Models, Version3Client, Version3Models } from 'jira.js'; import z from 'zod'; import type { VerifiedAccountIdentity } from '../../capabilities/auth'; import { credentialString } from '../../helpers/credentials'; @@ -20,9 +20,16 @@ const bearerJiraConnectionInputSchema = z.object({ accessToken: credentialString('Jira OAuth 2.0 access token is required.'), }); +const patJiraCredentialsSchema = z.object({ + authMethod: z.string().trim().pipe(z.literal('pat')), + siteUrl: jiraSiteUrlSchema, + personalAccessToken: credentialString('Jira personal access token is required.'), +}); + export const jiraConnectionInputSchema = z.union([ basicJiraCredentialsSchema, bearerJiraConnectionInputSchema, + patJiraCredentialsSchema, ]); const bearerJiraCredentialsSchema = bearerJiraConnectionInputSchema.extend({ @@ -32,13 +39,15 @@ const bearerJiraCredentialsSchema = bearerJiraConnectionInputSchema.extend({ export const jiraCredentialsSchema = z.union([ basicJiraCredentialsSchema, bearerJiraCredentialsSchema, + patJiraCredentialsSchema, ]); export type JiraCredentials = z.infer; -export type JiraClient = Version3Client; +/** Cloud speaks REST v3; Data Center only serves v2, so personal access tokens get a v2 client. */ +export type JiraClient = Version3Client | Version2Client; -export type JiraIssue = Version3Models.Issue; +export type JiraIssue = Version3Models.Issue | Version2Models.Issue; export type JiraVerifiedConnection = { account?: VerifiedAccountIdentity; diff --git a/packages/plugins/src/issues/impl/jira/index.test.ts b/packages/plugins/src/issues/impl/jira/index.test.ts index afd74108f5..9be42c9e1d 100644 --- a/packages/plugins/src/issues/impl/jira/index.test.ts +++ b/packages/plugins/src/issues/impl/jira/index.test.ts @@ -170,6 +170,44 @@ describe('jira issues plugin', () => { }); }); + it('lists Data Center issues through the v2 search with plain-text descriptions', async () => { + const mockV2Search = vi.fn().mockResolvedValueOnce({ + issues: [ + { + ...jiraIssue('OPS-7', 'Renew certificate'), + fields: { + ...jiraIssue('OPS-7', 'Renew certificate').fields, + description: 'h3. Steps\n* renew the cert', + }, + }, + ], + }); + mockCreateJiraClient.mockReturnValue({ + issueSearch: { searchForIssuesUsingJqlPost: mockV2Search }, + } as unknown as JiraClient); + const host = makeHost(); + host.credentials = { + authMethod: 'pat', + siteUrl: 'https://jira.example.com', + personalAccessToken: 'dc-token', + }; + + const result = await issues.listIssues(host, { limit: 10 }); + + expect(result).toEqual({ + success: true, + data: [ + expect.objectContaining({ + identifier: 'OPS-7', + title: 'Renew certificate', + url: 'https://jira.example.com/browse/OPS-7', + description: 'h3. Steps\n* renew the cert', + }), + ], + }); + expect(mockSearchForIssues).not.toHaveBeenCalled(); + }); + it('returns invalid input for incomplete stored bearer credentials before constructing a client', async () => { const host = makeHost(); host.credentials = { diff --git a/packages/plugins/src/issues/impl/jira/index.ts b/packages/plugins/src/issues/impl/jira/index.ts index 7e26959380..4444d16ae3 100644 --- a/packages/plugins/src/issues/impl/jira/index.ts +++ b/packages/plugins/src/issues/impl/jira/index.ts @@ -1,8 +1,9 @@ import { err, ok } from '@emdash/shared'; +import type { Version2Client, Version3Client } from 'jira.js'; import { toIntegrationError } from '../../../integrations/helpers/error'; import type { ConnectedIntegrationHostContext } from '../../../integrations/host'; import { createJiraClient, readJiraCredentials } from '../../../integrations/impl/jira/client'; -import type { JiraClient, JiraIssue } from '../../../integrations/impl/jira/types'; +import type { JiraClient, JiraCredentials, JiraIssue } from '../../../integrations/impl/jira/types'; import { clampIssueLimit, normalizeSearchTerm } from '../../helpers/provider-inputs'; import { defineIssuesPlugin, registerIssuesPluginBehavior } from '../../plugin'; import type { IssueListResult, IssueQueryOpts, IssueSearchOpts } from '../../types'; @@ -23,7 +24,7 @@ export async function listIssues( const sanitizedLimit = clampIssueLimit(opts.limit, 50, 500); try { - const issues = await searchJql(client, LIST_JQL, sanitizedLimit); + const issues = await searchJql(client, parsedCredentials.data, LIST_JQL, sanitizedLimit); return ok(issues.map((issue) => toIssueData(issue, parsedCredentials.data.siteUrl))); } catch (error) { host.log.warn('Jira listIssues failed', { error }); @@ -48,7 +49,12 @@ export async function searchIssues( const jql = JIRA_KEY_PATTERN.test(term) ? `(key = "${escapedTerm}" OR text ~ "${escapedTerm}") ORDER BY updated DESC` : `text ~ "${escapedTerm}" ORDER BY updated DESC`; - const issues = await searchJql(client, jql, clampIssueLimit(opts.limit, 20, 500)); + const issues = await searchJql( + client, + parsedCredentials.data, + jql, + clampIssueLimit(opts.limit, 20, 500) + ); return ok(issues.map((issue) => toIssueData(issue, parsedCredentials.data.siteUrl))); } catch (error) { @@ -57,12 +63,20 @@ export async function searchIssues( } } -async function searchJql(client: JiraClient, jql: string, limit: number): Promise { - const response = await client.issueSearch.searchForIssuesUsingJqlEnhancedSearchPost({ - jql, - maxResults: limit, - fields: SEARCH_FIELDS, - }); +async function searchJql( + client: JiraClient, + credentials: JiraCredentials, + jql: string, + limit: number +): Promise { + const params = { jql, maxResults: limit, fields: SEARCH_FIELDS }; + // Data Center has no enhanced search; createJiraClient pairs 'pat' with a v2 client. + const response = + credentials.authMethod === 'pat' + ? await (client as Version2Client).issueSearch.searchForIssuesUsingJqlPost(params) + : await (client as Version3Client).issueSearch.searchForIssuesUsingJqlEnhancedSearchPost( + params + ); return response.issues ?? []; } From 0984fd70ad98ec4ef1ead8c40dd2c9cb9a425721 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Thu, 22 Oct 2026 17:35:08 -0500 Subject: [PATCH 02/12] feat(jira): scope a Jira account's issues with an optional JQL filter Each Jira sign-in method gains an optional "Issue filter (JQL)" field, such as `project = WEB`. When set, the issue list shows what the filter matches instead of the last 90 days of activity, and searches are ANDed with it. A filter containing ORDER BY is rejected at connect time, since issues are always sorted by last update. Co-Authored-By: Claude Opus 5.5 --- .../src/integrations/impl/jira/client.test.ts | 13 ++++++++ .../src/integrations/impl/jira/index.ts | 18 +++++++++++ .../src/integrations/impl/jira/types.ts | 11 ++++++- .../src/issues/impl/jira/index.test.ts | 22 +++++++++++++ .../plugins/src/issues/impl/jira/index.ts | 31 ++++++++++++++++--- 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/plugins/src/integrations/impl/jira/client.test.ts b/packages/plugins/src/integrations/impl/jira/client.test.ts index bce6b24f4a..679603dc2d 100644 --- a/packages/plugins/src/integrations/impl/jira/client.test.ts +++ b/packages/plugins/src/integrations/impl/jira/client.test.ts @@ -228,6 +228,19 @@ describe('Jira connection verification', () => { }); }); + it('rejects an issue filter that brings its own ORDER BY', async () => { + const result = await verifyJiraCredentials( + { + siteUrl: 'https://jira.example.com', + personalAccessToken: 'dc-token', + jqlFilter: 'project = WEB ORDER BY created', + }, + 'pat' + ); + expect(result).toMatchObject({ success: false, error: { type: 'invalid_input' } }); + expect(mocks.createV2Client).not.toHaveBeenCalled(); + }); + it('rejects an unknown selected method before calling either API', async () => { const result = await verifyJiraCredentials( { diff --git a/packages/plugins/src/integrations/impl/jira/index.ts b/packages/plugins/src/integrations/impl/jira/index.ts index 5a35890121..e9c1bd7d19 100644 --- a/packages/plugins/src/integrations/impl/jira/index.ts +++ b/packages/plugins/src/integrations/impl/jira/index.ts @@ -38,6 +38,12 @@ const plugin = defineIntegrationPlugin( required: true, placeholder: 'Jira API token', }, + { + id: 'jqlFilter', + label: 'Issue filter (JQL, optional)', + required: false, + placeholder: 'project = WEB', + }, ], help: 'Create an API token from your Atlassian account security settings.', helpUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens', @@ -60,6 +66,12 @@ const plugin = defineIntegrationPlugin( required: true, placeholder: 'Jira OAuth 2.0 access token', }, + { + id: 'jqlFilter', + label: 'Issue filter (JQL, optional)', + required: false, + placeholder: 'project = WEB', + }, ], help: 'Use an OAuth 2.0 access token for Jira Cloud. Atlassian API tokens are not supported by this method.', }, @@ -81,6 +93,12 @@ const plugin = defineIntegrationPlugin( required: true, placeholder: 'Jira personal access token', }, + { + id: 'jqlFilter', + label: 'Issue filter (JQL, optional)', + required: false, + placeholder: 'project = WEB', + }, ], help: 'For Jira Data Center or Server 8.14+. Create a token under your Jira profile, Personal Access Tokens.', helpUrl: diff --git a/packages/plugins/src/integrations/impl/jira/types.ts b/packages/plugins/src/integrations/impl/jira/types.ts index 29f4d1b1f8..2fbf002904 100644 --- a/packages/plugins/src/integrations/impl/jira/types.ts +++ b/packages/plugins/src/integrations/impl/jira/types.ts @@ -1,29 +1,38 @@ import type { Version2Client, Version2Models, Version3Client, Version3Models } from 'jira.js'; import z from 'zod'; import type { VerifiedAccountIdentity } from '../../capabilities/auth'; -import { credentialString } from '../../helpers/credentials'; +import { credentialString, optionalCredentialString } from '../../helpers/credentials'; const jiraSiteUrlSchema = credentialString('Jira site URL is required.') .refine(isHttpUrl, 'Jira site URL must be a valid HTTP(S) URL.') .transform((value) => value.replace(/\/+$/, '')); +// The filter is ANDed with other clauses and Emdash appends its own sort. +const jiraJqlFilterSchema = optionalCredentialString().refine( + (value) => value === undefined || !/\border\s+by\b/i.test(value), + 'Leave ORDER BY out of the Jira issue filter; issues are sorted by last update.' +); + const basicJiraCredentialsSchema = z.object({ authMethod: z.string().trim().pipe(z.literal('basic')).default('basic'), siteUrl: jiraSiteUrlSchema, email: credentialString('Jira email is required.'), apiToken: credentialString('Jira API token is required.'), + jqlFilter: jiraJqlFilterSchema, }); const bearerJiraConnectionInputSchema = z.object({ authMethod: z.string().trim().pipe(z.literal('bearer')), siteUrl: jiraSiteUrlSchema, accessToken: credentialString('Jira OAuth 2.0 access token is required.'), + jqlFilter: jiraJqlFilterSchema, }); const patJiraCredentialsSchema = z.object({ authMethod: z.string().trim().pipe(z.literal('pat')), siteUrl: jiraSiteUrlSchema, personalAccessToken: credentialString('Jira personal access token is required.'), + jqlFilter: jiraJqlFilterSchema, }); export const jiraConnectionInputSchema = z.union([ diff --git a/packages/plugins/src/issues/impl/jira/index.test.ts b/packages/plugins/src/issues/impl/jira/index.test.ts index 9be42c9e1d..0af1a50fc8 100644 --- a/packages/plugins/src/issues/impl/jira/index.test.ts +++ b/packages/plugins/src/issues/impl/jira/index.test.ts @@ -170,6 +170,28 @@ describe('jira issues plugin', () => { }); }); + it('scopes the list to the account filter instead of the recent-activity window', async () => { + mockSearchForIssues.mockResolvedValueOnce({ issues: [] }); + const host = makeHost(); + host.credentials = { ...host.credentials, jqlFilter: 'project = WEB' }; + + await issues.listIssues(host, { limit: 10 }); + + expect(searchParams(0)).toMatchObject({ jql: 'project = WEB ORDER BY updated DESC' }); + }); + + it('ANDs the account filter with the search term', async () => { + mockSearchForIssues.mockResolvedValueOnce({ issues: [] }); + const host = makeHost(); + host.credentials = { ...host.credentials, jqlFilter: 'project = WEB OR project = IT' }; + + await issues.searchIssues(host, { searchTerm: 'certificate', limit: 5 }); + + expect(searchParams(0)).toMatchObject({ + jql: '(project = WEB OR project = IT) AND (text ~ "certificate") ORDER BY updated DESC', + }); + }); + it('lists Data Center issues through the v2 search with plain-text descriptions', async () => { const mockV2Search = vi.fn().mockResolvedValueOnce({ issues: [ diff --git a/packages/plugins/src/issues/impl/jira/index.ts b/packages/plugins/src/issues/impl/jira/index.ts index 4444d16ae3..857e801462 100644 --- a/packages/plugins/src/issues/impl/jira/index.ts +++ b/packages/plugins/src/issues/impl/jira/index.ts @@ -11,7 +11,7 @@ import { toIssueData } from './mapper'; const SEARCH_FIELDS = ['summary', 'description', 'updated', 'project', 'status', 'assignee']; const JIRA_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/; -const LIST_JQL = 'updated >= -90d ORDER BY updated DESC'; +const RECENT_CLAUSE = 'updated >= -90d'; export async function listIssues( host: ConnectedIntegrationHostContext, @@ -24,7 +24,12 @@ export async function listIssues( const sanitizedLimit = clampIssueLimit(opts.limit, 50, 500); try { - const issues = await searchJql(client, parsedCredentials.data, LIST_JQL, sanitizedLimit); + const issues = await searchJql( + client, + parsedCredentials.data, + listJql(issueFilters(parsedCredentials.data)), + sanitizedLimit + ); return ok(issues.map((issue) => toIssueData(issue, parsedCredentials.data.siteUrl))); } catch (error) { host.log.warn('Jira listIssues failed', { error }); @@ -46,9 +51,10 @@ export async function searchIssues( try { const escapedTerm = term.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const jql = JIRA_KEY_PATTERN.test(term) - ? `(key = "${escapedTerm}" OR text ~ "${escapedTerm}") ORDER BY updated DESC` - : `text ~ "${escapedTerm}" ORDER BY updated DESC`; + const termClause = JIRA_KEY_PATTERN.test(term) + ? `(key = "${escapedTerm}" OR text ~ "${escapedTerm}")` + : `text ~ "${escapedTerm}"`; + const jql = scopedJql([...issueFilters(parsedCredentials.data), termClause]); const issues = await searchJql( client, parsedCredentials.data, @@ -63,6 +69,21 @@ export async function searchIssues( } } +function issueFilters(credentials: JiraCredentials): string[] { + return credentials.jqlFilter ? [credentials.jqlFilter] : []; +} + +/** A filter is the user's chosen scope, so the recent-activity window only applies without one. */ +function listJql(filters: string[]): string { + return scopedJql(filters.length > 0 ? filters : [RECENT_CLAUSE]); +} + +/** ANDs the clauses, parenthesizing each only when there is more than one. */ +function scopedJql(clauses: string[]): string { + const where = clauses.length === 1 ? clauses[0] : clauses.map((c) => `(${c})`).join(' AND '); + return `${where} ORDER BY updated DESC`; +} + async function searchJql( client: JiraClient, credentials: JiraCredentials, From 7cc5df9ee1d220e032fa0f798f597a7233ee2583 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Thu, 22 Oct 2026 17:45:06 -0500 Subject: [PATCH 03/12] feat(issues): narrow a project's Jira issues with a per-project JQL filter Project settings show a "JQL filter" field under the Jira account row, such as `"Epic Link" = WEB-123`. It is ANDed with the account's own filter, so an account scoped to `project = WEB` can be narrowed per repository. A blank filter clears it. Issue plugins opt in by declaring an `issueFilter` field in their issues descriptor; the host stores filters per provider in the project's durable settings and passes the project's filter to list and search as `issueFilter`. Jira is the only provider that declares one. Co-Authored-By: Claude Opus 5.5 --- .../api/node/plugin-issue-provider.ts | 19 +++++---- .../node/integration-payload-builder.ts | 2 + .../node/plugin-issue-provider.test.ts | 34 +++++++++++++++ .../issues/api/node/plugin-issue-adapter.ts | 8 +++- .../api/node/settings/effective-settings.ts | 14 +++++++ .../settings/project-settings-service.test.ts | 1 + .../node/settings/project-settings-service.ts | 2 + .../projects/api/project-settings-page.ts | 13 ++++++ .../project-settings-form-model.test.ts | 10 +++++ .../project-settings-form-model.ts | 11 +++++ .../settings-view/project-settings-form.tsx | 2 + .../base-project-settings-section.tsx | 7 ++++ ...egration-accounts-section.browser.test.tsx | 8 ++++ .../sections/integration-accounts-section.tsx | 42 ++++++++++++++++++- .../settings-view/settings-panel.tsx | 1 + .../use-project-settings-form.test.ts | 2 + .../use-project-settings-form.ts | 7 ++++ .../settings/durable-project-settings.test.ts | 30 +++++++++++++ .../node/settings/durable-project-settings.ts | 30 ++++++++++++- .../issue-providers/api/issue-providers.ts | 2 + .../project-settings/api/project-settings.ts | 9 ++++ .../main/bootstrap/boot/phases/services.ts | 7 +++- .../src/integrations/impl/jira/types.ts | 7 +++- .../plugins/src/issues/capabilities/issues.ts | 7 ++++ .../src/issues/impl/jira/index.test.ts | 23 ++++++++++ .../plugins/src/issues/impl/jira/index.ts | 40 ++++++++++++++---- packages/plugins/src/issues/types.ts | 2 + 27 files changed, 318 insertions(+), 22 deletions(-) diff --git a/apps/emdash-desktop/src/core/features/integrations/api/node/plugin-issue-provider.ts b/apps/emdash-desktop/src/core/features/integrations/api/node/plugin-issue-provider.ts index 90d9684908..13be0cf5ce 100644 --- a/apps/emdash-desktop/src/core/features/integrations/api/node/plugin-issue-provider.ts +++ b/apps/emdash-desktop/src/core/features/integrations/api/node/plugin-issue-provider.ts @@ -32,6 +32,8 @@ import type { ProjectIntegrationAccountResolver } from './project-integration-ac export type PluginIssueProviderDependencies = { resolveProjectIntegrationAccount: ProjectIntegrationAccountResolver; + /** The project's stored issue filter for a provider, if any. */ + getProjectIssueFilter?: (projectId: string, provider: string) => Promise; }; export function createPluginIssueProvider( @@ -150,13 +152,16 @@ export function createPluginIssueProvider( ...createIssueListOperations(plugin, async (opts) => { const account = await getConnectedAccount(opts); - return account.success - ? ok({ - host: { log: pluginLog, credentials: account.data.credentials }, - accountId: account.data.accountId, - repositoryUrl: repositoryUrl(opts), - }) - : account; + if (!account.success) return account; + const issueFilter = opts.projectId + ? await dependencies.getProjectIssueFilter?.(opts.projectId, provider) + : undefined; + return ok({ + host: { log: pluginLog, credentials: account.data.credentials }, + accountId: account.data.accountId, + repositoryUrl: repositoryUrl(opts), + ...(issueFilter ? { issueFilter } : {}), + }); }), getIssueContext: plugin.behavior.issues?.getIssue diff --git a/apps/emdash-desktop/src/core/features/integrations/node/integration-payload-builder.ts b/apps/emdash-desktop/src/core/features/integrations/node/integration-payload-builder.ts index dfbef68779..000e3707f3 100644 --- a/apps/emdash-desktop/src/core/features/integrations/node/integration-payload-builder.ts +++ b/apps/emdash-desktop/src/core/features/integrations/node/integration-payload-builder.ts @@ -9,9 +9,11 @@ import type { IntegrationProviderDescriptor } from '../api/contract'; function issueCapabilities(integrationId: string): IssueProviderCapabilities { const plugin = issuesPluginRegistry.get(integrationId); const requiredInputs = plugin?.capabilities.issues.requiredInputs ?? []; + const issueFilter = plugin?.capabilities.issues.issueFilter; return { requiresRepositoryUrl: requiredInputs.includes('repositoryUrl'), supportsIssueContext: !!plugin?.behavior.issues?.getIssue, + ...(issueFilter ? { issueFilter } : {}), }; } diff --git a/apps/emdash-desktop/src/core/features/integrations/node/plugin-issue-provider.test.ts b/apps/emdash-desktop/src/core/features/integrations/node/plugin-issue-provider.test.ts index c6964dc260..1399912173 100644 --- a/apps/emdash-desktop/src/core/features/integrations/node/plugin-issue-provider.test.ts +++ b/apps/emdash-desktop/src/core/features/integrations/node/plugin-issue-provider.test.ts @@ -260,6 +260,40 @@ describe('createPluginIssueProvider', () => { expect(mockGetCredentials).toHaveBeenCalledWith('linear', 'linear:acct-2'); }); + it("hands the project's issue filter to the plugin, and none without a project", async () => { + const listIssues = vi.fn(async () => ok([])); + const getProjectIssueFilter = vi.fn(async () => '"Epic Link" = WEB-123'); + const provider = createPluginIssueProvider(makePlugin({ listIssues }), { + resolveProjectIntegrationAccount: vi.fn(async () => ({ + value: { + providerId: 'linear', + accountId: 'linear:acct-2', + displayName: 'Second workspace', + isDefault: false, + }, + provenance: { kind: 'set' as const }, + accounts: [], + contextKey: '', + })), + getProjectIssueFilter, + }); + + await provider.listIssues({ projectId: 'p1' }); + await provider.listIssues({}); + + expect(getProjectIssueFilter).toHaveBeenCalledWith('p1', 'linear'); + expect(listIssues).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ issueFilter: '"Epic Link" = WEB-123' }) + ); + expect(listIssues).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.not.objectContaining({ issueFilter: expect.anything() }) + ); + }); + it('fails closed when the integration is explicitly disabled for the project', async () => { const provider = createPluginIssueProvider(makePlugin({}), { resolveProjectIntegrationAccount: vi.fn(async () => ({ diff --git a/apps/emdash-desktop/src/core/features/issues/api/node/plugin-issue-adapter.ts b/apps/emdash-desktop/src/core/features/issues/api/node/plugin-issue-adapter.ts index 5d6eb4a33e..ddc288f911 100644 --- a/apps/emdash-desktop/src/core/features/issues/api/node/plugin-issue-adapter.ts +++ b/apps/emdash-desktop/src/core/features/issues/api/node/plugin-issue-adapter.ts @@ -16,6 +16,7 @@ type ResolvedIssueAccess = { host: ConnectedIntegrationHostContext; accountId: string; repositoryUrl?: string; + issueFilter?: string; }; /** Provider adapters prepare access; list/search execution has one policy and result mapping. */ @@ -28,7 +29,7 @@ export function createIssueListOperations( async function invoke(opts: IssueQueryOpts, searchTerm?: string): Promise { const context = await prepare(opts); if (!context.success) return context; - const { repositoryUrl, host } = context.data; + const { repositoryUrl, host, issueFilter } = context.data; if (capabilities.requiresRepositoryUrl && !repositoryUrl) { return err({ type: 'invalid_input', message: 'Repository URL is required.' }); } @@ -37,10 +38,12 @@ export function createIssueListOperations( ? await plugin.behavior.issues?.listIssues(host, { limit: clampIssueProviderLimit(opts.limit, DEFAULT_LIST_LIMIT), repositoryUrl, + issueFilter, }) : await plugin.behavior.issues?.searchIssues(host, { limit: clampIssueProviderLimit(opts.limit, DEFAULT_SEARCH_LIMIT), repositoryUrl, + issueFilter, searchTerm, }); if (!result) return ok([]); @@ -68,10 +71,11 @@ export function clampIssueProviderLimit(limit: number | undefined, fallback: num export function toIssueProviderCapabilities( plugin: IssuesPluginProvider ): IssueProviderCapabilities { - const requiredInputs = plugin.capabilities.issues.requiredInputs; + const { requiredInputs, issueFilter } = plugin.capabilities.issues; return { requiresRepositoryUrl: requiredInputs.includes('repositoryUrl'), supportsIssueContext: !!plugin.behavior.issues?.getIssue, + ...(issueFilter ? { issueFilter } : {}), }; } diff --git a/apps/emdash-desktop/src/core/features/projects/api/node/settings/effective-settings.ts b/apps/emdash-desktop/src/core/features/projects/api/node/settings/effective-settings.ts index ce29b2b6b9..db26489211 100644 --- a/apps/emdash-desktop/src/core/features/projects/api/node/settings/effective-settings.ts +++ b/apps/emdash-desktop/src/core/features/projects/api/node/settings/effective-settings.ts @@ -7,6 +7,7 @@ import { type RepoFacts, type StoredProjectGitSettings, type StoredIntegrationAccounts, + type StoredIssueFilters, } from '@core/primitives/project-settings/api'; import type { AppDb } from '@core/services/app-db/node/db'; import { projectSettings } from '@core/services/app-db/node/schema'; @@ -90,6 +91,7 @@ export function storedGitSettingsFromRow( ): StoredProjectGitSettings { const { integrationAccounts: _accounts, + issueFilters: _issueFilters, tmux: _tmux, tmuxDefaultMigrated: _migration, ...git @@ -136,3 +138,15 @@ export async function loadStoredIntegrationAccounts( .limit(1); return row ? (readStoredProjectSettings(row.base).integrationAccounts ?? {}) : {}; } + +export async function loadStoredIssueFilters( + db: AppDb, + projectId: string +): Promise { + const [row] = await db + .select({ base: projectSettings.baseProjectSettingsJson }) + .from(projectSettings) + .where(eq(projectSettings.projectId, projectId)) + .limit(1); + return row ? (readStoredProjectSettings(row.base).issueFilters ?? {}) : {}; +} diff --git a/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.test.ts b/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.test.ts index 6ef1275b64..1b4b23c022 100644 --- a/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.test.ts +++ b/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.test.ts @@ -123,6 +123,7 @@ function fixture() { const readDurable = vi.fn(async () => ok({ integrationAccounts: { stored: {} }, + issueFilters: { stored: {} }, gitIdentity: { stored: { baseRemote: 'origin', diff --git a/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.ts b/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.ts index 38110ec5ee..7f38c02ff2 100644 --- a/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.ts +++ b/apps/emdash-desktop/src/core/features/projects/api/node/settings/project-settings-service.ts @@ -133,11 +133,13 @@ export class ProjectSettingsService implements Hookable { if ( patch.gitIdentity || patch.integrationAccounts || + patch.issueFilters || patch.placement?.stored.tmux !== undefined ) { const durable = await this.durableSettings.patch(projectId, { ...(patch.gitIdentity ? { gitIdentity: patch.gitIdentity } : {}), ...(patch.integrationAccounts ? { integrationAccounts: patch.integrationAccounts } : {}), + ...(patch.issueFilters ? { issueFilters: patch.issueFilters } : {}), ...(patch.placement && Object.hasOwn(patch.placement.stored, 'tmux') ? { placement: { stored: { tmux: patch.placement.stored.tmux } } } : {}), diff --git a/apps/emdash-desktop/src/core/features/projects/api/project-settings-page.ts b/apps/emdash-desktop/src/core/features/projects/api/project-settings-page.ts index 0046251f90..d576618d20 100644 --- a/apps/emdash-desktop/src/core/features/projects/api/project-settings-page.ts +++ b/apps/emdash-desktop/src/core/features/projects/api/project-settings-page.ts @@ -13,6 +13,7 @@ import type { StoredDefaultBranch, StoredIntegrationAccount, StoredIntegrationAccounts, + StoredIssueFilters, StoredProjectGitSettings, } from '@core/primitives/project-settings/api'; import type { UpdateProjectSettingsError } from '@core/primitives/projects/api'; @@ -71,6 +72,10 @@ export type ProjectIntegrationAccountsDomainSnapshot = { stored: StoredIntegrationAccounts; }; +export type ProjectIssueFiltersDomainSnapshot = { + stored: StoredIssueFilters; +}; + export type ProjectPlacementDomainSnapshot = { stored: { worktreeRoot?: string; @@ -89,12 +94,14 @@ export type ProjectSettingsDomains = { environment: ProjectEnvironmentDomainSnapshot; gitIdentity: ProjectGitIdentityDomainSnapshot; integrationAccounts: ProjectIntegrationAccountsDomainSnapshot; + issueFilters: ProjectIssueFiltersDomainSnapshot; placement: ProjectPlacementDomainSnapshot; }; export type ProjectDurableSettingsDomains = { gitIdentity: ProjectGitIdentityDomainSnapshot; integrationAccounts: ProjectIntegrationAccountsDomainSnapshot; + issueFilters: ProjectIssueFiltersDomainSnapshot; placement: Pick; }; @@ -119,6 +126,9 @@ export type ProjectIntegrationAccountsPatch = Partial< Record >; +/** Values set one provider's filter; null clears it; absent keys are untouched. */ +export type ProjectIssueFiltersPatch = Partial>; + export type ProjectPlacementStoredPatch = { worktreeRoot?: string | null; tmux?: boolean | null; @@ -140,6 +150,9 @@ export type ProjectSettingsDomainPatch = { integrationAccounts?: { stored: ProjectIntegrationAccountsPatch; }; + issueFilters?: { + stored: ProjectIssueFiltersPatch; + }; placement?: { stored: ProjectPlacementStoredPatch; }; diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.test.ts b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.test.ts index bc3f77de15..a0c4bcd1fb 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.test.ts +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.test.ts @@ -53,6 +53,7 @@ function makeForm(overrides: FormOverrides = {}): FormState { ...overrides.gitIdentity, }, integrationAccounts: overrides.integrationAccounts ?? {}, + issueFilters: {}, placement: { tmux: undefined, worktreeDirectory: '', ...overrides.placement }, }; } @@ -86,6 +87,7 @@ function domains(): ProjectSettingsDomains { }, gitIdentity: { stored: { baseRemote: 'origin' } }, integrationAccounts: { stored: {} }, + issueFilters: { stored: {} }, placement: { stored: { tmux: false }, layers: { @@ -120,6 +122,14 @@ describe('project settings form model', () => { }); }); + it('patches only the issue filter that was edited', () => { + const form = projectSettingsDomainsToForm(domains(), [origin]); + form.issueFilters = { jira: '"Epic Link" = WEB-123', youtrack: 'for: me' }; + expect( + formToProjectSettingsDomainPatch(form, new Set(['issueFilters.jira'])) + ).toEqual({ issueFilters: { stored: { jira: '"Epic Link" = WEB-123' } } }); + }); + it('clears only the reset provider even when another provider has a stored pin', () => { const form = projectSettingsDomainsToForm(domains(), [origin]); form.integrationAccounts = { diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.ts b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.ts index e92b88d0b3..14a831d4b7 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.ts +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form-model.ts @@ -49,6 +49,9 @@ export type EnvironmentFormState = { */ export type IntegrationAccountsFormState = Partial>; +/** Per-provider issue filter text; blank clears the stored filter on save. */ +export type IssueFiltersFormState = Partial>; + export type GitIdentityFormState = { defaultBranch: GitBranchRef | null; baseRemote: string; @@ -68,6 +71,7 @@ export type FormState = { environment: EnvironmentFormState; gitIdentity: GitIdentityFormState; integrationAccounts: IntegrationAccountsFormState; + issueFilters: IssueFiltersFormState; placement: PlacementFormState; }; @@ -157,6 +161,7 @@ export function projectSettingsDomainsToForm( environment: environmentToForm(domains.environment), gitIdentity: gitIdentityToForm(domains.gitIdentity, remotes), integrationAccounts: domains.integrationAccounts.stored, + issueFilters: domains.issueFilters.stored, placement: placementToForm(domains.placement), }; } @@ -276,6 +281,11 @@ export function formToProjectSettingsDomainPatch( isTouched(touchedFields, `integrationAccounts.${providerId}`) ) ); + const issueFilters = Object.fromEntries( + Object.entries(form.issueFilters).filter(([providerId]) => + isTouched(touchedFields, `issueFilters.${providerId}`) + ) + ); const placement = placementToPatch(form.placement, touchedFields); return { ...(lifecycle ? { lifecycle } : {}), @@ -285,6 +295,7 @@ export function formToProjectSettingsDomainPatch( ...(Object.keys(integrationAccounts).length > 0 ? { integrationAccounts: { stored: integrationAccounts } } : {}), + ...(Object.keys(issueFilters).length > 0 ? { issueFilters: { stored: issueFilters } } : {}), ...(placement ? { placement } : {}), }; } diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form.tsx index 62ac8870bd..d8e216a943 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/project-settings-form.tsx @@ -82,6 +82,7 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ projectId={projectId} gitIdentityForm={formModel.form.gitIdentity} integrationAccountsForm={formModel.form.integrationAccounts} + issueFiltersForm={formModel.form.issueFilters} placementForm={formModel.form.placement} placement={domains.placement} projectType={projectType} @@ -89,6 +90,7 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ worktreeDirectoryError={formModel.worktreeDirectoryError} updateGitIdentity={formModel.updateGitIdentity} updateIntegrationAccounts={formModel.updateIntegrationAccounts} + updateIssueFilters={formModel.updateIssueFilters} updatePlacement={formModel.updatePlacement} hostActionReason={hostActionReason} hostObservationKind={hostObservationKind} diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/base-project-settings-section.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/base-project-settings-section.tsx index 23939c980a..718f4bcc08 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/base-project-settings-section.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/base-project-settings-section.tsx @@ -38,6 +38,7 @@ import { type FormUpdate, type GitIdentityFormState, type IntegrationAccountsFormState, + type IssueFiltersFormState, type PlacementFormState, } from '../project-settings-form-model'; import { IntegrationAccountsSection } from './integration-accounts-section'; @@ -52,6 +53,7 @@ type BaseProjectSettingsSectionProps = { projectId: string; gitIdentityForm: GitIdentityFormState; integrationAccountsForm: IntegrationAccountsFormState; + issueFiltersForm: IssueFiltersFormState; placementForm: PlacementFormState; placement: ProjectPlacementDomainSnapshot; projectType: Project['type']; @@ -59,6 +61,7 @@ type BaseProjectSettingsSectionProps = { worktreeDirectoryError: string | null; updateGitIdentity: FormUpdate; updateIntegrationAccounts: FormUpdate; + updateIssueFilters: FormUpdate; updatePlacement: FormUpdate; hostActionReason: string | null; hostObservationKind: 'fresh' | 'stale' | 'unavailable'; @@ -126,6 +129,7 @@ export const BaseProjectSettingsSection = observer(function BaseProjectSettingsS projectId, gitIdentityForm, integrationAccountsForm, + issueFiltersForm, placementForm, placement, projectType, @@ -133,6 +137,7 @@ export const BaseProjectSettingsSection = observer(function BaseProjectSettingsS worktreeDirectoryError, updateGitIdentity, updateIntegrationAccounts, + updateIssueFilters, updatePlacement, hostActionReason, hostObservationKind, @@ -192,6 +197,8 @@ export const BaseProjectSettingsSection = observer(function BaseProjectSettingsS remote.name === effective.baseRemote.value) diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx index d1d5124388..c88e1a2b97 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx @@ -69,6 +69,8 @@ describe('project integration account rows', () => { ) @@ -102,6 +104,8 @@ describe('project integration account rows', () => { ); @@ -142,6 +146,8 @@ describe('project integration account rows', () => { ); @@ -172,6 +178,8 @@ describe('project integration account rows', () => { ); diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.tsx index 90531e6546..2c50bd4970 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.tsx @@ -1,4 +1,4 @@ -import { Field, Select, Separator } from '@emdash/ui/react/primitives'; +import { Field, Input, Select, Separator } from '@emdash/ui/react/primitives'; import { Plus } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import type { ReactNode } from 'react'; @@ -22,7 +22,11 @@ import { } from '@core/primitives/provider-accounts/api'; import { ProviderAccountLabel } from '@core/primitives/provider-accounts/browser/account-label'; import { cn } from '@core/primitives/styling/browser/cn'; -import type { FormUpdate, IntegrationAccountsFormState } from '../project-settings-form-model'; +import type { + FormUpdate, + IntegrationAccountsFormState, + IssueFiltersFormState, +} from '../project-settings-form-model'; /** File-local Select option encodings; never stored or exported. */ const NO_ACCOUNT_OPTION = '__no_provider_account__'; @@ -32,10 +36,14 @@ const CONNECT_OPTION = '__connect_provider_account__'; export const IntegrationAccountsSection = observer(function IntegrationAccountsSection({ integrationAccountsForm, updateIntegrationAccounts, + issueFiltersForm, + updateIssueFilters, repositoryHost, }: { integrationAccountsForm: IntegrationAccountsFormState; updateIntegrationAccounts: FormUpdate; + issueFiltersForm: IssueFiltersFormState; + updateIssueFilters: FormUpdate; /** Effective base-remote host; undefined while repository facts are loading. */ repositoryHost: string | null | undefined; }) { @@ -85,6 +93,15 @@ export const IntegrationAccountsSection = observer(function IntegrationAccountsS fallbackIcon={} loadError={accountsQuery.isError} onOverrideChange={(value) => updateIntegrationAccounts(integration.id, value)} + filter={ + integration.issueCapabilities.issueFilter + ? { + ...integration.issueCapabilities.issueFilter, + value: issueFiltersForm[integration.id] ?? '', + onChange: (value) => updateIssueFilters(integration.id, value), + } + : undefined + } onConnect={() => void openIntegrationSetup({ integration: integration.id })} unresolvableHint={ integration.issueCapabilities.requiresRepositoryUrl @@ -110,6 +127,7 @@ const ProviderAccountRow = observer(function ProviderAccountRow({ unresolvableHint, loadError, fallbackIcon, + filter, }: { name: string; accounts: ProviderAccountSummary[]; @@ -121,6 +139,13 @@ const ProviderAccountRow = observer(function ProviderAccountRow({ onOverrideChange: (value: StoredIntegrationAccount | null) => void; onConnect: () => void; unresolvableHint?: string; + /** Per-project issue filter, shown when the provider supports one. */ + filter?: { + label: string; + placeholder?: string; + value: string; + onChange: (value: string) => void; + }; }) { const unresolvable = resolution?.provenance.kind === 'unresolvable'; const isExplicit = override !== undefined; @@ -206,6 +231,19 @@ const ProviderAccountRow = observer(function ProviderAccountRow({ `The account set for this project is no longer connected. ${name} stays paused until you pick an account or reset.`} ) : null} + {filter ? ( +
+ {filter.label} + filter.onChange(event.target.value)} + /> +
+ ) : null} ); }); diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/settings-panel.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/settings-panel.tsx index 7ade954112..c53bb18253 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/settings-panel.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/settings-panel.tsx @@ -84,6 +84,7 @@ function unavailableSettingsDomains( return { gitIdentity: durable.gitIdentity, integrationAccounts: durable.integrationAccounts, + issueFilters: durable.issueFilters, placement: { ...durable.placement, layers: { diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.test.ts b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.test.ts index 446a3b73c1..0d0e7b5fad 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.test.ts +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.test.ts @@ -44,6 +44,7 @@ function domains(setup: string): ProjectSettingsDomains { }, gitIdentity: { stored: {} }, integrationAccounts: { stored: {} }, + issueFilters: { stored: {} }, placement: { stored: {}, layers: { @@ -92,6 +93,7 @@ describe('useProjectSettingsForm', () => { durable: { gitIdentity: saved.gitIdentity, integrationAccounts: saved.integrationAccounts, + issueFilters: saved.issueFilters, placement: { stored: saved.placement.stored }, }, host: { diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.ts b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.ts index 232fc83c4b..283a18accd 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.ts +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/use-project-settings-form.ts @@ -32,6 +32,7 @@ import { type FormUpdate, type GitIdentityFormState, type IntegrationAccountsFormState, + type IssueFiltersFormState, type LifecycleFormState, type PlacementFormState, } from './project-settings-form-model'; @@ -158,6 +159,10 @@ export function useProjectSettingsForm({ (providerId, value) => updateSection('integrationAccounts', providerId, value), [updateSection] ); + const updateIssueFilters = useCallback>( + (providerId, value) => updateSection('issueFilters', providerId, value), + [updateSection] + ); const getOverrideSources = useCallback( (field: ShareableProjectSettingsWriteField) => { @@ -311,6 +316,7 @@ export function useProjectSettingsForm({ updateEnvironment, updateGitIdentity, updateIntegrationAccounts, + updateIssueFilters, updatePlacement, getOverrideSources, handleSave, @@ -331,6 +337,7 @@ function mergeProjectSettingsPage( environment: host.environment, gitIdentity: page.durable.gitIdentity, integrationAccounts: page.durable.integrationAccounts, + issueFilters: page.durable.issueFilters, placement: { ...host.placement, ...page.durable.placement, diff --git a/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.test.ts b/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.test.ts index 8b348bf237..cf81b3ec43 100644 --- a/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.test.ts +++ b/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.test.ts @@ -80,6 +80,36 @@ describe('DesktopProjectSettingsAuthority', () => { expect(JSON.parse(row.baseProjectSettingsJson)).not.toHaveProperty('baseRemote'); }); + it('stores per-provider issue filters, clearing one saved blank', async () => { + const row: StoredProjectSettings = { + baseProjectSettingsJson: JSON.stringify({ issueFilters: { jira: 'project = WEB' } }), + shareableProjectSettingsJson: '{}', + legacyConfigMigratedAt: null, + }; + const storage: ProjectSettingsStorage = { + get: vi.fn(async () => row), + insertIfMissing: vi.fn(), + mutate: vi.fn(async (_projectId, patch) => { + Object.assign(row, patch(row)); + return row; + }), + }; + const authority = new DesktopProjectSettingsAuthority(storage); + + await authority.patch('project-1', { + issueFilters: { stored: { jira: ' "Epic Link" = WEB-123 ', youtrack: 'for: me' } }, + }); + await expect(authority.read('project-1')).resolves.toMatchObject({ + success: true, + data: { + issueFilters: { stored: { jira: '"Epic Link" = WEB-123', youtrack: 'for: me' } }, + }, + }); + + await authority.patch('project-1', { issueFilters: { stored: { jira: '', youtrack: null } } }); + expect(JSON.parse(row.baseProjectSettingsJson)).not.toHaveProperty('issueFilters'); + }); + it('preserves lifecycle migration sources during a durable patch', async () => { const shareable = JSON.stringify({ preservePatterns: ['.env.local'], diff --git a/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.ts b/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.ts index e4bd369aeb..3aed6fa944 100644 --- a/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.ts +++ b/apps/emdash-desktop/src/core/features/projects/node/settings/durable-project-settings.ts @@ -3,6 +3,7 @@ import { log } from '@emdash/shared/logger'; import type { ProjectDurableSettingsDomains, ProjectIntegrationAccountsPatch, + ProjectIssueFiltersPatch, ProjectSettingsDomainPatch, } from '@core/features/projects/api/project-settings-page'; import type { StoredBaseProjectSettings } from '@core/primitives/project-settings/api'; @@ -19,7 +20,10 @@ export interface DurableProjectSettingsAuthority { ): Promise>; patch( projectId: string, - patch: Pick + patch: Pick< + ProjectSettingsDomainPatch, + 'gitIdentity' | 'integrationAccounts' | 'issueFilters' | 'placement' + > ): Promise>; } @@ -40,7 +44,10 @@ export class DesktopProjectSettingsAuthority implements DurableProjectSettingsAu async patch( projectId: string, - patch: Pick + patch: Pick< + ProjectSettingsDomainPatch, + 'gitIdentity' | 'integrationAccounts' | 'issueFilters' | 'placement' + > ): Promise> { try { await this.storage.insertIfMissing(projectId, { @@ -65,6 +72,7 @@ export class DesktopProjectSettingsAuthority implements DurableProjectSettingsAu } } applyIntegrationAccountsPatch(next, patch.integrationAccounts?.stored); + applyIssueFiltersPatch(next, patch.issueFilters?.stored); const tmux = patch.placement?.stored.tmux; if (patch.placement && Object.hasOwn(patch.placement.stored, 'tmux')) { next.tmuxDefaultMigrated = true; @@ -118,17 +126,35 @@ export function applyIntegrationAccountsPatch( else next.integrationAccounts = merged; } +/** Same merge semantics as the account patch; a blank filter clears like null. */ +export function applyIssueFiltersPatch( + next: StoredBaseProjectSettings, + patch: ProjectIssueFiltersPatch | undefined +): void { + if (patch === undefined) return; + const merged = { ...(next.issueFilters ?? {}) }; + for (const [providerId, value] of Object.entries(patch)) { + const filter = value?.trim(); + if (filter) merged[providerId] = filter; + else delete merged[providerId]; + } + if (Object.keys(merged).length === 0) delete next.issueFilters; + else next.issueFilters = merged; +} + function durableDomains(stored: StoredBaseProjectSettings): ProjectDurableSettingsDomains { const { worktreeRoot, tmux, integrationAccounts, + issueFilters, tmuxDefaultMigrated: _migration, ...gitIdentity } = stored; return { gitIdentity: { stored: gitIdentity }, integrationAccounts: { stored: integrationAccounts ?? {} }, + issueFilters: { stored: issueFilters ?? {} }, placement: { stored: { ...(worktreeRoot !== undefined ? { worktreeRoot } : {}), diff --git a/apps/emdash-desktop/src/core/primitives/issue-providers/api/issue-providers.ts b/apps/emdash-desktop/src/core/primitives/issue-providers/api/issue-providers.ts index f77416d2d6..e8c08ef221 100644 --- a/apps/emdash-desktop/src/core/primitives/issue-providers/api/issue-providers.ts +++ b/apps/emdash-desktop/src/core/primitives/issue-providers/api/issue-providers.ts @@ -8,6 +8,8 @@ export type IssueProviderType = LinkedIssue['provider']; export type IssueProviderCapabilities = { requiresRepositoryUrl: boolean; supportsIssueContext: boolean; + /** Present when the provider accepts a per-project filter, e.g. JQL for Jira. */ + issueFilter?: { label: string; placeholder?: string }; }; export type ConnectionStatus = { diff --git a/apps/emdash-desktop/src/core/primitives/project-settings/api/project-settings.ts b/apps/emdash-desktop/src/core/primitives/project-settings/api/project-settings.ts index a9370659da..b6d2b8ae48 100644 --- a/apps/emdash-desktop/src/core/primitives/project-settings/api/project-settings.ts +++ b/apps/emdash-desktop/src/core/primitives/project-settings/api/project-settings.ts @@ -47,6 +47,14 @@ export const storedIntegrationAccountsSchema = z.record(z.string(), storedIntegr export type StoredIntegrationAccounts = z.infer; +/** + * Per-integration issue filters keyed by provider id, in that provider's query + * language (JQL for Jira). Narrows the account's own filter for this project. + */ +export const storedIssueFiltersSchema = z.record(z.string(), z.string().trim().min(1)); + +export type StoredIssueFilters = z.infer; + export const storedBaseProjectSettingsSchema = z.object({ /** Renamed from the legacy `worktreeDirectory` key. */ worktreeRoot: z.string().trim().optional(), @@ -54,6 +62,7 @@ export const storedBaseProjectSettingsSchema = z.object({ baseRemote: z.string().optional(), pushRemote: z.string().optional(), integrationAccounts: storedIntegrationAccountsSchema.optional(), + issueFilters: storedIssueFiltersSchema.optional(), agentGitCredentials: agentGitCredentialsSettingSchema.optional(), tmux: z.boolean().optional(), /** Lazy-migration marker; not a user setting. */ diff --git a/apps/emdash-desktop/src/main/bootstrap/boot/phases/services.ts b/apps/emdash-desktop/src/main/bootstrap/boot/phases/services.ts index f6d9de0343..58e776b870 100644 --- a/apps/emdash-desktop/src/main/bootstrap/boot/phases/services.ts +++ b/apps/emdash-desktop/src/main/bootstrap/boot/phases/services.ts @@ -69,6 +69,7 @@ import { projectEvents } from '@core/features/projects/api/node/project-events'; import { loadStoredGitSettings, loadStoredIntegrationAccounts, + loadStoredIssueFilters, } from '@core/features/projects/api/node/settings/effective-settings'; import { ProjectSettingsService } from '@core/features/projects/api/node/settings/project-settings-service'; import type { ProjectDeletionDependencies } from '@core/features/projects/node/operations/deleteProject'; @@ -596,7 +597,11 @@ export async function bootServices( channels: gitCredentialServer, logger: log, }); - const issueProviders = createIssueProviderRegistry({ resolveProjectIntegrationAccount }); + const issueProviders = createIssueProviderRegistry({ + resolveProjectIntegrationAccount, + getProjectIssueFilter: async (projectId, provider) => + (await loadStoredIssueFilters(db, projectId))[provider], + }); const pullRequestsRegistration = new PullRequestsRegistration({ getClient: getPullRequestsRuntimeClient, onResume: (handler) => { diff --git a/packages/plugins/src/integrations/impl/jira/types.ts b/packages/plugins/src/integrations/impl/jira/types.ts index 2fbf002904..8c463ee550 100644 --- a/packages/plugins/src/integrations/impl/jira/types.ts +++ b/packages/plugins/src/integrations/impl/jira/types.ts @@ -9,7 +9,7 @@ const jiraSiteUrlSchema = credentialString('Jira site URL is required.') // The filter is ANDed with other clauses and Emdash appends its own sort. const jiraJqlFilterSchema = optionalCredentialString().refine( - (value) => value === undefined || !/\border\s+by\b/i.test(value), + (value) => value === undefined || !jqlHasOrderBy(value), 'Leave ORDER BY out of the Jira issue filter; issues are sorted by last update.' ); @@ -65,6 +65,11 @@ export type JiraVerifiedConnection = { credentials: JiraCredentials; }; +/** Filters are ANDed with other clauses, so one carrying its own sort would break the query. */ +export function jqlHasOrderBy(jql: string): boolean { + return /\border\s+by\b/i.test(jql); +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value); diff --git a/packages/plugins/src/issues/capabilities/issues.ts b/packages/plugins/src/issues/capabilities/issues.ts index eacb32262a..9ae7cb1242 100644 --- a/packages/plugins/src/issues/capabilities/issues.ts +++ b/packages/plugins/src/issues/capabilities/issues.ts @@ -18,8 +18,15 @@ export const issueRequiredInputSchema = z.enum(['repositoryUrl']); export type IssueRequiredInput = z.infer; +/** A project-level filter field the host shows in project settings, passed back as `issueFilter`. */ +const issueFilterDescriptorSchema = z.object({ + label: z.string().trim().min(1), + placeholder: z.string().optional(), +}); + const issuesDescriptorSchema = z.object({ requiredInputs: z.array(issueRequiredInputSchema).default([]), + issueFilter: issueFilterDescriptorSchema.optional(), }); export type IssuesDescriptor = z.infer; diff --git a/packages/plugins/src/issues/impl/jira/index.test.ts b/packages/plugins/src/issues/impl/jira/index.test.ts index 0af1a50fc8..3cf48a4c5c 100644 --- a/packages/plugins/src/issues/impl/jira/index.test.ts +++ b/packages/plugins/src/issues/impl/jira/index.test.ts @@ -192,6 +192,29 @@ describe('jira issues plugin', () => { }); }); + it('narrows the account filter with the project filter', async () => { + mockSearchForIssues.mockResolvedValueOnce({ issues: [] }); + const host = makeHost(); + host.credentials = { ...host.credentials, jqlFilter: 'project = WEB' }; + + await issues.listIssues(host, { limit: 10, issueFilter: '"Epic Link" = WEB-123' }); + + expect(searchParams(0)).toMatchObject({ + jql: '(project = WEB) AND ("Epic Link" = WEB-123) ORDER BY updated DESC', + }); + }); + + it('rejects a project filter that brings its own ORDER BY without querying Jira', async () => { + const result = await issues.searchIssues(makeHost(), { + searchTerm: 'cert', + limit: 5, + issueFilter: 'project = WEB order by created', + }); + + expect(result).toMatchObject({ success: false, error: { type: 'invalid_input' } }); + expect(mockSearchForIssues).not.toHaveBeenCalled(); + }); + it('lists Data Center issues through the v2 search with plain-text descriptions', async () => { const mockV2Search = vi.fn().mockResolvedValueOnce({ issues: [ diff --git a/packages/plugins/src/issues/impl/jira/index.ts b/packages/plugins/src/issues/impl/jira/index.ts index 857e801462..deb8c6160a 100644 --- a/packages/plugins/src/issues/impl/jira/index.ts +++ b/packages/plugins/src/issues/impl/jira/index.ts @@ -1,9 +1,15 @@ -import { err, ok } from '@emdash/shared'; +import { err, ok, type Result } from '@emdash/shared'; import type { Version2Client, Version3Client } from 'jira.js'; import { toIntegrationError } from '../../../integrations/helpers/error'; import type { ConnectedIntegrationHostContext } from '../../../integrations/host'; import { createJiraClient, readJiraCredentials } from '../../../integrations/impl/jira/client'; -import type { JiraClient, JiraCredentials, JiraIssue } from '../../../integrations/impl/jira/types'; +import { + jqlHasOrderBy, + type JiraClient, + type JiraCredentials, + type JiraIssue, +} from '../../../integrations/impl/jira/types'; +import type { IntegrationError } from '../../../integrations/types'; import { clampIssueLimit, normalizeSearchTerm } from '../../helpers/provider-inputs'; import { defineIssuesPlugin, registerIssuesPluginBehavior } from '../../plugin'; import type { IssueListResult, IssueQueryOpts, IssueSearchOpts } from '../../types'; @@ -20,6 +26,9 @@ export async function listIssues( const parsedCredentials = readJiraCredentials(host.credentials); if (!parsedCredentials.success) return err(parsedCredentials.error); + const filters = issueFilters(parsedCredentials.data, opts.issueFilter); + if (!filters.success) return filters; + const client = createJiraClient(parsedCredentials.data); const sanitizedLimit = clampIssueLimit(opts.limit, 50, 500); @@ -27,7 +36,7 @@ export async function listIssues( const issues = await searchJql( client, parsedCredentials.data, - listJql(issueFilters(parsedCredentials.data)), + listJql(filters.data), sanitizedLimit ); return ok(issues.map((issue) => toIssueData(issue, parsedCredentials.data.siteUrl))); @@ -46,6 +55,8 @@ export async function searchIssues( const parsedCredentials = readJiraCredentials(host.credentials); if (!parsedCredentials.success) return err(parsedCredentials.error); + const filters = issueFilters(parsedCredentials.data, opts.issueFilter); + if (!filters.success) return filters; const client = createJiraClient(parsedCredentials.data); @@ -54,7 +65,7 @@ export async function searchIssues( const termClause = JIRA_KEY_PATTERN.test(term) ? `(key = "${escapedTerm}" OR text ~ "${escapedTerm}")` : `text ~ "${escapedTerm}"`; - const jql = scopedJql([...issueFilters(parsedCredentials.data), termClause]); + const jql = scopedJql([...filters.data, termClause]); const issues = await searchJql( client, parsedCredentials.data, @@ -69,8 +80,19 @@ export async function searchIssues( } } -function issueFilters(credentials: JiraCredentials): string[] { - return credentials.jqlFilter ? [credentials.jqlFilter] : []; +/** The account's filter, narrowed by the project's; both are optional. */ +function issueFilters( + credentials: JiraCredentials, + projectFilter: string | undefined +): Result { + if (projectFilter && jqlHasOrderBy(projectFilter)) { + return err({ + type: 'invalid_input', + message: + "Remove ORDER BY from this project's Jira issue filter; issues are sorted by last update.", + }); + } + return ok([credentials.jqlFilter, projectFilter].filter((filter): filter is string => !!filter)); } /** A filter is the user's chosen scope, so the recent-activity window only applies without one. */ @@ -101,7 +123,11 @@ async function searchJql( return response.issues ?? []; } -const plugin = defineIssuesPlugin({ integrationId: 'jira' }, { issues: {} }, {}); +const plugin = defineIssuesPlugin( + { integrationId: 'jira' }, + { issues: { issueFilter: { label: 'JQL filter', placeholder: '"Epic Link" = WEB-123' } } }, + {} +); export const provider = registerIssuesPluginBehavior(plugin, { issues: { listIssues, searchIssues }, diff --git a/packages/plugins/src/issues/types.ts b/packages/plugins/src/issues/types.ts index 2fa3778ff1..447a2eca0c 100644 --- a/packages/plugins/src/issues/types.ts +++ b/packages/plugins/src/issues/types.ts @@ -26,6 +26,8 @@ export type IssueQueryOpts = { limit: number; /** Resolved repository URL, present when the descriptor requires it. */ repositoryUrl?: string; + /** The project's filter for this provider, in its own query language (JQL for Jira). */ + issueFilter?: string; }; export type IssueSearchOpts = IssueQueryOpts & { From 0b5d8aeac96f8405daa27862592abe8697064664 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Mon, 26 Oct 2026 08:42:25 -0500 Subject: [PATCH 04/12] fix(jira): refuse to send Jira tokens to an http:// site Connecting Jira with an http:// site URL now fails with a message asking for https://, since the API token, OAuth token or personal access token travels as a request header and would cross the network unencrypted. A Jira on localhost is still allowed. Co-Authored-By: Claude Opus 5.5 --- .../src/integrations/impl/jira/client.test.ts | 18 ++++++++++++++++++ .../src/integrations/impl/jira/client.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/plugins/src/integrations/impl/jira/client.test.ts b/packages/plugins/src/integrations/impl/jira/client.test.ts index 679603dc2d..a7b9e79354 100644 --- a/packages/plugins/src/integrations/impl/jira/client.test.ts +++ b/packages/plugins/src/integrations/impl/jira/client.test.ts @@ -228,6 +228,24 @@ describe('Jira connection verification', () => { }); }); + it('refuses to send a token to a plain http:// site', async () => { + const result = await verifyJiraCredentials( + { siteUrl: 'http://jira.example.com', personalAccessToken: 'dc-token' }, + 'pat' + ); + expect(result).toMatchObject({ success: false, error: { type: 'invalid_input' } }); + expect(mocks.createV2Client).not.toHaveBeenCalled(); + }); + + it('allows http:// for a Jira running on this machine', async () => { + mocks.getCurrentUser.mockResolvedValueOnce({ key: 'JIRAUSER1', displayName: 'Ada' }); + const result = await verifyJiraCredentials( + { siteUrl: 'http://localhost:8080', personalAccessToken: 'dc-token' }, + 'pat' + ); + expect(result).toMatchObject({ success: true }); + }); + it('rejects an issue filter that brings its own ORDER BY', async () => { const result = await verifyJiraCredentials( { diff --git a/packages/plugins/src/integrations/impl/jira/client.ts b/packages/plugins/src/integrations/impl/jira/client.ts index 7a9aa80eaa..1d984ed975 100644 --- a/packages/plugins/src/integrations/impl/jira/client.ts +++ b/packages/plugins/src/integrations/impl/jira/client.ts @@ -55,6 +55,12 @@ export async function verifyJiraCredentials( methodId === undefined ? rawCredentials : { ...rawCredentials, authMethod: methodId } ); if (!credentials.success) return err(credentials.error); + if (!sendsTokensSecurely(credentials.data.siteUrl)) { + return err({ + type: 'invalid_input', + message: 'Use an https:// Jira site URL; over http:// your token would be sent unencrypted.', + }); + } try { const verifiedCredentials: JiraCredentials = @@ -102,3 +108,9 @@ function authLabel(credentials: JiraCredentials, email: string | undefined): str if (credentials.authMethod === 'pat') return 'Personal access token'; return email ?? ''; } + +/** Tokens travel as request headers, so only TLS or the local machine keeps them private. */ +function sendsTokensSecurely(siteUrl: string): boolean { + const { protocol, hostname } = new URL(siteUrl); + return protocol === 'https:' || ['localhost', '127.0.0.1', '[::1]'].includes(hostname); +} From b7f922f3b714a97780359b62311a1a942e6aa34a Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Fri, 30 Oct 2026 13:46:09 -0500 Subject: [PATCH 05/12] feat(jira): leave completed issues out of the issue list and search Co-Authored-By: Claude Sonnet 5.5 --- .../src/issues/impl/jira/index.test.ts | 25 ++++++++++++++----- .../plugins/src/issues/impl/jira/index.ts | 6 +++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/plugins/src/issues/impl/jira/index.test.ts b/packages/plugins/src/issues/impl/jira/index.test.ts index 3cf48a4c5c..dc59e3d751 100644 --- a/packages/plugins/src/issues/impl/jira/index.test.ts +++ b/packages/plugins/src/issues/impl/jira/index.test.ts @@ -93,7 +93,7 @@ describe('jira issues plugin', () => { }); expect(mockSearchForIssues).toHaveBeenCalledTimes(1); expect(searchParams(0)).toEqual({ - jql: 'updated >= -90d ORDER BY updated DESC', + jql: '(updated >= -90d) AND (statusCategory != Done) ORDER BY updated DESC', maxResults: 2, fields: ['summary', 'description', 'updated', 'project', 'status', 'assignee'], }); @@ -115,7 +115,7 @@ describe('jira issues plugin', () => { }); expect(mockSearchForIssues).toHaveBeenCalledTimes(1); expect(searchParams(0)).toEqual({ - jql: '(key = "ENG-976" OR text ~ "ENG-976") ORDER BY updated DESC', + jql: '((key = "ENG-976" OR text ~ "ENG-976")) AND (statusCategory != Done) ORDER BY updated DESC', maxResults: 5, fields: ['summary', 'description', 'updated', 'project', 'status', 'assignee'], }); @@ -137,7 +137,7 @@ describe('jira issues plugin', () => { }); expect(mockSearchForIssues).toHaveBeenCalledTimes(1); expect(searchParams(0)).toEqual({ - jql: 'text ~ "deprecated \\"search\\" endpoint" ORDER BY updated DESC', + jql: '(text ~ "deprecated \\"search\\" endpoint") AND (statusCategory != Done) ORDER BY updated DESC', maxResults: 5, fields: ['summary', 'description', 'updated', 'project', 'status', 'assignee'], }); @@ -177,7 +177,9 @@ describe('jira issues plugin', () => { await issues.listIssues(host, { limit: 10 }); - expect(searchParams(0)).toMatchObject({ jql: 'project = WEB ORDER BY updated DESC' }); + expect(searchParams(0)).toMatchObject({ + jql: '(project = WEB) AND (statusCategory != Done) ORDER BY updated DESC', + }); }); it('ANDs the account filter with the search term', async () => { @@ -188,7 +190,7 @@ describe('jira issues plugin', () => { await issues.searchIssues(host, { searchTerm: 'certificate', limit: 5 }); expect(searchParams(0)).toMatchObject({ - jql: '(project = WEB OR project = IT) AND (text ~ "certificate") ORDER BY updated DESC', + jql: '(project = WEB OR project = IT) AND (text ~ "certificate") AND (statusCategory != Done) ORDER BY updated DESC', }); }); @@ -200,10 +202,21 @@ describe('jira issues plugin', () => { await issues.listIssues(host, { limit: 10, issueFilter: '"Epic Link" = WEB-123' }); expect(searchParams(0)).toMatchObject({ - jql: '(project = WEB) AND ("Epic Link" = WEB-123) ORDER BY updated DESC', + jql: '(project = WEB) AND ("Epic Link" = WEB-123) AND (statusCategory != Done) ORDER BY updated DESC', }); }); + it('leaves completed issues out of lists and searches, even under a status filter', async () => { + const host = makeHost(); + host.credentials = { ...host.credentials, jqlFilter: 'status = Done' }; + + await issues.listIssues(host, { limit: 5 }); + await issues.searchIssues(host, { searchTerm: 'certificate', limit: 5 }); + + expect(searchParams(0).jql).toContain('(statusCategory != Done)'); + expect(searchParams(1).jql).toContain('(statusCategory != Done)'); + }); + it('rejects a project filter that brings its own ORDER BY without querying Jira', async () => { const result = await issues.searchIssues(makeHost(), { searchTerm: 'cert', diff --git a/packages/plugins/src/issues/impl/jira/index.ts b/packages/plugins/src/issues/impl/jira/index.ts index deb8c6160a..c5543be229 100644 --- a/packages/plugins/src/issues/impl/jira/index.ts +++ b/packages/plugins/src/issues/impl/jira/index.ts @@ -18,6 +18,8 @@ import { toIssueData } from './mapper'; const SEARCH_FIELDS = ['summary', 'description', 'updated', 'project', 'status', 'assignee']; const JIRA_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/; const RECENT_CLAUSE = 'updated >= -90d'; +// Resolved work is not something to start a task from; this holds even under a user's own filter. +const NOT_DONE_CLAUSE = 'statusCategory != Done'; export async function listIssues( host: ConnectedIntegrationHostContext, @@ -100,9 +102,9 @@ function listJql(filters: string[]): string { return scopedJql(filters.length > 0 ? filters : [RECENT_CLAUSE]); } -/** ANDs the clauses, parenthesizing each only when there is more than one. */ +/** ANDs the clauses with the completed-issue exclusion, parenthesizing each. */ function scopedJql(clauses: string[]): string { - const where = clauses.length === 1 ? clauses[0] : clauses.map((c) => `(${c})`).join(' AND '); + const where = [...clauses, NOT_DONE_CLAUSE].map((c) => `(${c})`).join(' AND '); return `${where} ORDER BY updated DESC`; } From e084e7dd6b8beb04fa19d6e45e27b84260a70d7c Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Fri, 30 Oct 2026 19:13:26 -0500 Subject: [PATCH 06/12] chore(jira): cover the project JQL filter field in the settings row Co-Authored-By: Claude Sonnet 5.5 --- ...egration-accounts-section.browser.test.tsx | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx index c88e1a2b97..b68df05ae7 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx @@ -8,6 +8,7 @@ import { IntegrationAccountsSection } from './integration-accounts-section'; const state = vi.hoisted(() => ({ accounts: undefined as ProviderAccountsByProvider | undefined, openModal: vi.fn(), + jiraIssueFilter: undefined as { label: string; placeholder?: string } | undefined, })); vi.mock('@core/features/integrations/api/browser/use-provider-accounts', () => ({ useAccounts: () => ({ data: state.accounts, isError: false }), @@ -16,7 +17,11 @@ vi.mock('@core/features/integrations/contributions/browser/integrations-provider useIntegrationsContext: () => ({ integrations: [ { id: 'github', name: 'GitHub', issueCapabilities: { requiresRepositoryUrl: true } }, - { id: 'jira', name: 'Jira', issueCapabilities: { requiresRepositoryUrl: false } }, + { + id: 'jira', + name: 'Jira', + issueCapabilities: { requiresRepositoryUrl: false, issueFilter: state.jiraIssueFilter }, + }, { id: 'gitlab', name: 'GitLab', issueCapabilities: { requiresRepositoryUrl: true } }, { id: 'forgejo', name: 'Forgejo', issueCapabilities: { requiresRepositoryUrl: true } }, ], @@ -44,6 +49,7 @@ describe('project integration account rows', () => { root = createRoot(host); state.accounts = undefined; state.openModal.mockClear(); + state.jiraIssueFilter = undefined; }); afterEach(async () => { await act(async () => root.unmount()); @@ -98,6 +104,70 @@ describe('project integration account rows', () => { expect(update).toHaveBeenLastCalledWith('jira', { kind: 'none' }); }); + it("edits the project's JQL filter for a provider that declares one", async () => { + state.jiraIssueFilter = { label: 'JQL filter', placeholder: '"Epic Link" = WEB-123' }; + state.accounts = { + jira: [ + { + providerId: 'jira', + accountId: 'jira:a', + displayName: 'Alice', + isDefault: true, + credentialSource: 'secure_storage', + }, + ], + }; + const updateFilters = vi.fn(); + await act(async () => + root.render( + + ) + ); + const input = host.querySelector('input[aria-label="Jira JQL filter"]'); + expect(input?.value).toBe('project = WEB'); + + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + await act(async () => { + setValue.call(input, 'project = IT'); + input!.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(updateFilters).toHaveBeenLastCalledWith('jira', 'project = IT'); + }); + + it('shows no filter field for a provider that declares none', async () => { + state.accounts = { + jira: [ + { + providerId: 'jira', + accountId: 'jira:a', + displayName: 'Alice', + isDefault: true, + credentialSource: 'secure_storage', + }, + ], + }; + await act(async () => + root.render( + + ) + ); + expect(host.querySelector('input[aria-label="Jira JQL filter"]')).toBeNull(); + }); + it('does not call a pin unavailable before inventory arrives', async () => { const render = () => root.render( From 4d3764b439e1a9bbf341ac5c251157fcd453bd7f Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Fri, 30 Oct 2026 19:29:02 -0500 Subject: [PATCH 07/12] fix(unreleased): reject Jira issue filters with unbalanced parentheses Co-Authored-By: Claude Sonnet 5.5 --- .../src/integrations/impl/jira/client.test.ts | 25 ++++++++++++++ .../src/integrations/impl/jira/types.ts | 33 ++++++++++++++++--- .../src/issues/impl/jira/index.test.ts | 10 ++++++ .../plugins/src/issues/impl/jira/index.ts | 7 ++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/plugins/src/integrations/impl/jira/client.test.ts b/packages/plugins/src/integrations/impl/jira/client.test.ts index a7b9e79354..2e8715c3ab 100644 --- a/packages/plugins/src/integrations/impl/jira/client.test.ts +++ b/packages/plugins/src/integrations/impl/jira/client.test.ts @@ -259,6 +259,31 @@ describe('Jira connection verification', () => { expect(mocks.createV2Client).not.toHaveBeenCalled(); }); + it.each(['project = A) OR (project = B', '(project = A', 'summary ~ "a)" OR (x'])( + 'rejects the issue filter %s that would escape its own clause', + async (jqlFilter) => { + const result = await verifyJiraCredentials( + { siteUrl: 'https://jira.example.com', personalAccessToken: 'dc-token', jqlFilter }, + 'pat' + ); + expect(result).toMatchObject({ success: false, error: { type: 'invalid_input' } }); + expect(mocks.createV2Client).not.toHaveBeenCalled(); + } + ); + + it('accepts an issue filter with balanced parentheses, including inside quotes', async () => { + mocks.getCurrentUser.mockResolvedValueOnce({ key: 'JIRAUSER1', displayName: 'Ada' }); + const result = await verifyJiraCredentials( + { + siteUrl: 'https://jira.example.com', + personalAccessToken: 'dc-token', + jqlFilter: '(project = A OR project = B) AND summary ~ "fix )"', + }, + 'pat' + ); + expect(result).toMatchObject({ success: true }); + }); + it('rejects an unknown selected method before calling either API', async () => { const result = await verifyJiraCredentials( { diff --git a/packages/plugins/src/integrations/impl/jira/types.ts b/packages/plugins/src/integrations/impl/jira/types.ts index 8c463ee550..b413c153f6 100644 --- a/packages/plugins/src/integrations/impl/jira/types.ts +++ b/packages/plugins/src/integrations/impl/jira/types.ts @@ -8,10 +8,15 @@ const jiraSiteUrlSchema = credentialString('Jira site URL is required.') .transform((value) => value.replace(/\/+$/, '')); // The filter is ANDed with other clauses and Emdash appends its own sort. -const jiraJqlFilterSchema = optionalCredentialString().refine( - (value) => value === undefined || !jqlHasOrderBy(value), - 'Leave ORDER BY out of the Jira issue filter; issues are sorted by last update.' -); +const jiraJqlFilterSchema = optionalCredentialString() + .refine( + (value) => value === undefined || !jqlHasOrderBy(value), + 'Leave ORDER BY out of the Jira issue filter; issues are sorted by last update.' + ) + .refine( + (value) => value === undefined || jqlParenthesesBalanced(value), + 'Close every parenthesis in the Jira issue filter.' + ); const basicJiraCredentialsSchema = z.object({ authMethod: z.string().trim().pipe(z.literal('basic')).default('basic'), @@ -70,6 +75,26 @@ export function jqlHasOrderBy(jql: string): boolean { return /\border\s+by\b/i.test(jql); } +/** Each filter is wrapped in parentheses, so one that closes early would escape its own clause. */ +export function jqlParenthesesBalanced(jql: string): boolean { + let depth = 0; + let quote: string | undefined; + for (let i = 0; i < jql.length; i++) { + const char = jql[i]; + if (quote) { + if (char === '\\') i++; + else if (char === quote) quote = undefined; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === '(') { + depth++; + } else if (char === ')' && --depth < 0) { + return false; + } + } + return depth === 0 && quote === undefined; +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value); diff --git a/packages/plugins/src/issues/impl/jira/index.test.ts b/packages/plugins/src/issues/impl/jira/index.test.ts index dc59e3d751..302b094032 100644 --- a/packages/plugins/src/issues/impl/jira/index.test.ts +++ b/packages/plugins/src/issues/impl/jira/index.test.ts @@ -228,6 +228,16 @@ describe('jira issues plugin', () => { expect(mockSearchForIssues).not.toHaveBeenCalled(); }); + it('rejects a project filter that closes its own parenthesis early without querying Jira', async () => { + const result = await issues.listIssues(makeHost(), { + limit: 5, + issueFilter: 'project = WEB) OR (project = IT', + }); + + expect(result).toMatchObject({ success: false, error: { type: 'invalid_input' } }); + expect(mockSearchForIssues).not.toHaveBeenCalled(); + }); + it('lists Data Center issues through the v2 search with plain-text descriptions', async () => { const mockV2Search = vi.fn().mockResolvedValueOnce({ issues: [ diff --git a/packages/plugins/src/issues/impl/jira/index.ts b/packages/plugins/src/issues/impl/jira/index.ts index c5543be229..c23160df06 100644 --- a/packages/plugins/src/issues/impl/jira/index.ts +++ b/packages/plugins/src/issues/impl/jira/index.ts @@ -5,6 +5,7 @@ import type { ConnectedIntegrationHostContext } from '../../../integrations/host import { createJiraClient, readJiraCredentials } from '../../../integrations/impl/jira/client'; import { jqlHasOrderBy, + jqlParenthesesBalanced, type JiraClient, type JiraCredentials, type JiraIssue, @@ -94,6 +95,12 @@ function issueFilters( "Remove ORDER BY from this project's Jira issue filter; issues are sorted by last update.", }); } + if (projectFilter && !jqlParenthesesBalanced(projectFilter)) { + return err({ + type: 'invalid_input', + message: "Close every parenthesis in this project's Jira issue filter.", + }); + } return ok([credentials.jqlFilter, projectFilter].filter((filter): filter is string => !!filter)); } From a5b1fca7db64f229d5aa2a21ec14903182e02190 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Fri, 30 Oct 2026 22:08:15 -0500 Subject: [PATCH 08/12] chore(jira): drop the unused projectId prop from the settings row tests Co-Authored-By: Claude Sonnet 5.5 --- .../sections/integration-accounts-section.browser.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx index b68df05ae7..1922615519 100644 --- a/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx +++ b/apps/emdash-desktop/src/core/features/projects/browser/components/settings-view/sections/integration-accounts-section.browser.test.tsx @@ -123,7 +123,6 @@ describe('project integration account rows', () => { { Date: Fri, 30 Oct 2026 22:08:15 -0500 Subject: [PATCH 09/12] fix(unreleased): keep Data Center sites that share a host apart by their full URL Co-Authored-By: Claude Sonnet 5.5 --- .../src/integrations/impl/jira/client.test.ts | 32 ++++++++++++++++++- .../src/integrations/impl/jira/client.ts | 4 +++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/plugins/src/integrations/impl/jira/client.test.ts b/packages/plugins/src/integrations/impl/jira/client.test.ts index 2e8715c3ab..fb9fa81962 100644 --- a/packages/plugins/src/integrations/impl/jira/client.test.ts +++ b/packages/plugins/src/integrations/impl/jira/client.test.ts @@ -216,7 +216,12 @@ describe('Jira connection verification', () => { expect(result).toEqual({ success: true, data: { - account: { id: 'JIRAUSER10100', login: 'ada', host: 'jira.example.com' }, + account: { + id: 'JIRAUSER10100', + login: 'ada', + host: 'jira.example.com', + scope: 'https://jira.example.com', + }, displayName: 'Ada Lovelace', displayDetail: 'Personal access token · jira.example.com', credentials: { @@ -271,6 +276,31 @@ describe('Jira connection verification', () => { } ); + it('tells apart two Data Center sites that share a host and a user key', async () => { + mocks.getCurrentUser.mockResolvedValue({ key: 'JIRAUSER1', displayName: 'Ada' }); + const [a, b] = await Promise.all( + ['https://jira.example.com/jira-a', 'https://jira.example.com/jira-b'].map((siteUrl) => + verifyJiraCredentials({ siteUrl, personalAccessToken: 'dc-token' }, 'pat') + ) + ); + const scope = (result: typeof a) => (result.success ? result.data.account?.scope : undefined); + expect(scope(a)).toBe('https://jira.example.com/jira-a'); + expect(scope(b)).toBe('https://jira.example.com/jira-b'); + }); + + it('accepts an issue filter that quotes the words order by', async () => { + mocks.getCurrentUser.mockResolvedValueOnce({ key: 'JIRAUSER1', displayName: 'Ada' }); + const result = await verifyJiraCredentials( + { + siteUrl: 'https://jira.example.com', + personalAccessToken: 'dc-token', + jqlFilter: 'summary ~ "order by"', + }, + 'pat' + ); + expect(result).toMatchObject({ success: true }); + }); + it('accepts an issue filter with balanced parentheses, including inside quotes', async () => { mocks.getCurrentUser.mockResolvedValueOnce({ key: 'JIRAUSER1', displayName: 'Ada' }); const result = await verifyJiraCredentials( diff --git a/packages/plugins/src/integrations/impl/jira/client.ts b/packages/plugins/src/integrations/impl/jira/client.ts index 1d984ed975..ffda60fedd 100644 --- a/packages/plugins/src/integrations/impl/jira/client.ts +++ b/packages/plugins/src/integrations/impl/jira/client.ts @@ -91,6 +91,10 @@ export async function verifyJiraCredentials( id: accountId, ...(login ? { login } : {}), host, + // Data Center sites can share a host under different context paths. + ...(verifiedCredentials.authMethod === 'pat' + ? { scope: verifiedCredentials.siteUrl } + : {}), }, } : {}), From b6010828f2a286468553fc2adbef1c300d696352 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Fri, 30 Oct 2026 22:08:15 -0500 Subject: [PATCH 10/12] fix(unreleased): allow quoted text such as "order by" in a Jira issue filter Co-Authored-By: Claude Sonnet 5.5 --- packages/plugins/src/integrations/impl/jira/types.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugins/src/integrations/impl/jira/types.ts b/packages/plugins/src/integrations/impl/jira/types.ts index b413c153f6..35e713debf 100644 --- a/packages/plugins/src/integrations/impl/jira/types.ts +++ b/packages/plugins/src/integrations/impl/jira/types.ts @@ -72,7 +72,12 @@ export type JiraVerifiedConnection = { /** Filters are ANDed with other clauses, so one carrying its own sort would break the query. */ export function jqlHasOrderBy(jql: string): boolean { - return /\border\s+by\b/i.test(jql); + return /\border\s+by\b/i.test(withoutQuotedText(jql)); +} + +/** Quoted values are data, so `summary ~ "order by"` is not a sort clause. */ +function withoutQuotedText(jql: string): string { + return jql.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); } /** Each filter is wrapped in parentheses, so one that closes early would escape its own clause. */ From ce94a3e7ade464bcc0c380414d5437a257806141 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Fri, 30 Oct 2026 22:08:15 -0500 Subject: [PATCH 11/12] fix(unreleased): refresh the issue list when a project's JQL filter changes Co-Authored-By: Claude Sonnet 5.5 --- .../integrations/api/browser/use-issues.ts | 6 +++++- .../integrations/browser/use-issues.test.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts b/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts index 568129c6af..5eed1d00ec 100644 --- a/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts +++ b/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts @@ -56,12 +56,14 @@ export function useIssues( const [debouncedTerm, setDebouncedTerm] = useState(''); const accountInventory = useAccounts(); const projectAccountContext = useObserver(() => { - if (!projectId) return { ready: true, choice: undefined, error: null }; + if (!projectId) return { ready: true, choice: undefined, filter: '', error: null }; const settings = getProjectSettingsStore(projectId); const accounts = settings?.durableDomains?.integrationAccounts; return { ready: accounts !== undefined, choice: provider ? accounts?.stored[provider] : undefined, + // The server reads the saved filter itself; it is keyed here so a save refreshes the lists. + filter: (provider && settings?.durableDomains?.issueFilters?.stored[provider]) || '', error: settings?.pageData.error ?? null, }; }); @@ -76,6 +78,7 @@ export function useIssues( repositoryUrl ?? '', searchLimit, accountKey, + projectAccountContext.filter, ]); useEffect(() => { @@ -103,6 +106,7 @@ export function useIssues( repositoryUrl ?? '', initialLimit, accountKey, + projectAccountContext.filter, ], queryFn: async () => { if (!provider) return { success: true as const, data: [] as LinkedIssue[] }; diff --git a/apps/emdash-desktop/src/core/features/integrations/browser/use-issues.test.ts b/apps/emdash-desktop/src/core/features/integrations/browser/use-issues.test.ts index 03543f5b0a..ccdb4c94d7 100644 --- a/apps/emdash-desktop/src/core/features/integrations/browser/use-issues.test.ts +++ b/apps/emdash-desktop/src/core/features/integrations/browser/use-issues.test.ts @@ -180,6 +180,26 @@ describe('useIssues', () => { expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('0'); }); + it('fetches again when the project saves a new issue filter', async () => { + mocks.listIssues.mockResolvedValueOnce({ success: true, data: [{ identifier: 'A-1' }] }); + await renderProbe(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('1'); + mocks.listIssues.mockReturnValue(new Promise(() => {})); + mocks.getProjectSettingsStore.mockReturnValue({ + durableDomains: { + integrationAccounts: { stored: { github: { kind: 'account', accountId: 'a' } } }, + issueFilters: { stored: { github: 'label = bug' } }, + }, + pageData: { error: null }, + }); + await renderProbe(); + expect(mocks.listIssues).toHaveBeenCalledTimes(2); + expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('0'); + }); + it('does not retain another account search results as placeholder data', async () => { mocks.searchIssues.mockResolvedValueOnce({ success: true, data: [{ identifier: 'A-1' }] }); await renderProbe(); From 3de041fc3bbaae01406dc1e674ddb402807d92d8 Mon Sep 17 00:00:00 2001 From: Brennan Goewert Date: Tue, 3 Nov 2026 11:50:54 -0600 Subject: [PATCH 12/12] fix(unreleased): keep the account last in the issue list query keys The project filter was appended after the account, which broke the account-bound issue cache tests that read the account from the last key element. Co-Authored-By: Claude Sonnet 5.5 --- .../src/core/features/integrations/api/browser/use-issues.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts b/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts index 5eed1d00ec..a37953855b 100644 --- a/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts +++ b/apps/emdash-desktop/src/core/features/integrations/api/browser/use-issues.ts @@ -77,8 +77,8 @@ export function useIssues( projectPath ?? '', repositoryUrl ?? '', searchLimit, - accountKey, projectAccountContext.filter, + accountKey, ]); useEffect(() => { @@ -105,8 +105,8 @@ export function useIssues( projectPath ?? '', repositoryUrl ?? '', initialLimit, - accountKey, projectAccountContext.filter, + accountKey, ], queryFn: async () => { if (!provider) return { success: true as const, data: [] as LinkedIssue[] };