diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 2d6addc79..6b320ca9c 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -226,4 +226,78 @@ describe('createJiraSlice credential errors', () => { await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toBeNull() expect(jiraStatus).toHaveBeenCalled() }) + + it('refreshes status after all-site Jira list reads partially succeed', async () => { + const store = createTestStore() + const error = new Error(credentialDecryptionMessage('Jira')) + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' } + }) + jiraListIssues.mockResolvedValueOnce([issue('ALP-1')]) + jiraStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedSiteId: 'all', + credentialError: error.message + }) + + await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([ + { key: 'ALP-1' } + ]) + await vi.waitFor(() => { + expect(store.getState().jiraStatus.credentialError).toBe(error.message) + }) + }) + + it('clears stale Jira credential errors after successful site list reads', async () => { + const store = createTestStore() + const staleError = credentialDecryptionMessage('Jira') + store.setState({ + jiraStatus: { + connected: true, + viewer: null, + selectedSiteId: 'site-1', + credentialError: staleError + } + }) + jiraListIssues.mockResolvedValueOnce([issue('ALP-1')]) + jiraStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedSiteId: 'site-1' + }) + + await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([ + { key: 'ALP-1' } + ]) + await vi.waitFor(() => { + expect(store.getState().jiraStatus.credentialError).toBeUndefined() + }) + }) + + it('clears stale Jira credential errors after successful issue detail reads', async () => { + const store = createTestStore() + const staleError = credentialDecryptionMessage('Jira') + store.setState({ + jiraStatus: { + connected: true, + viewer: null, + selectedSiteId: 'site-1', + credentialError: staleError + } + }) + jiraGetIssue.mockResolvedValueOnce(issue('ALP-1')) + jiraStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedSiteId: 'site-1' + }) + + await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toMatchObject({ + key: 'ALP-1' + }) + await vi.waitFor(() => { + expect(store.getState().jiraStatus.credentialError).toBeUndefined() + }) + }) }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 1ee48d0de..b34d894f5 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -69,6 +69,15 @@ function getSelectedSiteId(status: JiraConnectionStatus): JiraSiteSelection | nu return status.selectedSiteId ?? status.activeSiteId ?? null } +function shouldRefreshStatusAfterRead( + siteId: JiraSiteSelection | null | undefined, + status: JiraConnectionStatus +): boolean { + // Why: 'all' reads can hide per-site decrypt failures, and a visible + // credential error may have been cleared by a successful credential read. + return siteId === 'all' || status.credentialError !== undefined +} + function clearJiraInflight(): void { inflightIssueRequests.clear() inflightSearchRequests.clear() @@ -324,7 +333,9 @@ export const createJiraSlice: StateCreator = (set, isIntegrationCredentialDecryptionError(error) && canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) ) { - void get().checkJiraConnection() + if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { + void get().checkJiraConnection() + } } else if ( looksLikeAuthError(error) && canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) @@ -337,6 +348,12 @@ export const createJiraSlice: StateCreator = (set, if (inflightIssueRequests.get(issueCacheKey) === entry) { inflightIssueRequests.delete(issueCacheKey) } + if ( + shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { + void get().checkJiraConnection() + } }) entry = { promise, contextKey, mutationGeneration: requestMutationGeneration } inflightIssueRequests.set(issueCacheKey, entry) @@ -382,7 +399,9 @@ export const createJiraSlice: StateCreator = (set, isIntegrationCredentialDecryptionError(error) && canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) ) { - void get().checkJiraConnection() + if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { + void get().checkJiraConnection() + } } else if ( looksLikeAuthError(error) && canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) @@ -395,6 +414,12 @@ export const createJiraSlice: StateCreator = (set, if (inflightSearchRequests.get(cacheKey) === entry) { inflightSearchRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { + void get().checkJiraConnection() + } }) entry = { promise, contextKey, mutationGeneration: requestMutationGeneration } inflightSearchRequests.set(cacheKey, entry) @@ -440,7 +465,9 @@ export const createJiraSlice: StateCreator = (set, isIntegrationCredentialDecryptionError(error) && canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) ) { - void get().checkJiraConnection() + if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { + void get().checkJiraConnection() + } } else if ( looksLikeAuthError(error) && canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) @@ -453,6 +480,12 @@ export const createJiraSlice: StateCreator = (set, if (inflightListRequests.get(cacheKey) === entry) { inflightListRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { + void get().checkJiraConnection() + } }) entry = { promise, contextKey, mutationGeneration: requestMutationGeneration } inflightListRequests.set(cacheKey, entry) diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 60a39897e..725470840 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -235,6 +235,86 @@ describe('createLinearSlice caching', () => { expect(linearStatus).toHaveBeenCalled() }) + it('clears stale Linear credential errors after successful workspace list reads', async () => { + const store = createTestStore() + const staleError = credentialDecryptionMessage('Linear') + store.setState({ + linearStatus: { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1', + credentialError: staleError + } + }) + linearListIssues.mockResolvedValueOnce({ items: [issue('LIN-OK')] }) + linearStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + }) + + await expect( + store.getState().listLinearIssues('all', 36, { force: true }) + ).resolves.toMatchObject({ items: [{ id: 'LIN-OK' }] }) + await vi.waitFor(() => { + expect(store.getState().linearStatus.credentialError).toBeUndefined() + }) + }) + + it('clears stale Linear credential errors after successful issue detail reads', async () => { + const store = createTestStore() + const staleError = credentialDecryptionMessage('Linear') + store.setState({ + linearStatus: { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1', + credentialError: staleError + } + }) + linearGetIssue.mockResolvedValueOnce(issue('LIN-OK')) + linearStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + }) + + await expect(store.getState().fetchLinearIssue('LIN-OK', 'workspace-1')).resolves.toMatchObject( + { + id: 'LIN-OK' + } + ) + await vi.waitFor(() => { + expect(store.getState().linearStatus.credentialError).toBeUndefined() + }) + }) + + it('clears stale Linear credential errors after successful scoped collection reads', async () => { + const store = createTestStore() + const staleError = credentialDecryptionMessage('Linear') + store.setState({ + linearStatus: { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1', + credentialError: staleError + } + }) + linearListProjectIssues.mockResolvedValueOnce({ items: [issue('LIN-OK')] }) + linearStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + }) + + await expect( + store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true }) + ).resolves.toMatchObject({ items: [{ id: 'LIN-OK' }] }) + await vi.waitFor(() => { + expect(store.getState().linearStatus.credentialError).toBeUndefined() + }) + }) + it('surfaces scoped project issue failures alongside cached rows', async () => { const store = createTestStore() store.setState({ @@ -484,14 +564,28 @@ describe('createLinearSlice caching', () => { expect(linearListCustomViews.mock.calls[0][4]).toEqual({ force: true }) }) - it('fetches custom views by exact id for saved-context restore', async () => { + it('fetches custom views by exact id and clears stale credential errors', async () => { const store = createTestStore() + const staleError = credentialDecryptionMessage('Linear') + store.setState({ + linearStatus: { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1', + credentialError: staleError + } + }) linearGetCustomView.mockResolvedValueOnce({ id: 'view-1', name: 'Burn views', model: 'project', workspaceId: 'workspace-1' }) + linearStatus.mockResolvedValueOnce({ + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + }) await expect( store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true }) @@ -500,6 +594,9 @@ describe('createLinearSlice caching', () => { expect(linearGetCustomView).toHaveBeenCalledWith(null, 'view-1', 'project', 'workspace-1', { force: true }) + await vi.waitFor(() => { + expect(store.getState().linearStatus.credentialError).toBeUndefined() + }) }) it('fails forced exact custom-view validation instead of reopening stale cache', async () => { diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 27fa90cde..8d37980a5 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -250,9 +250,12 @@ function invalidateLinearCaches(): void { } function shouldRefreshStatusAfterRead( - workspaceId: LinearWorkspaceSelection | null | undefined + workspaceId: LinearWorkspaceSelection | null | undefined, + status: LinearConnectionStatus ): boolean { - return workspaceId === 'all' + // Why: 'all' reads can hide per-workspace decrypt failures, and a visible + // credential error may have been cleared by a successful credential read. + return workspaceId === 'all' || status.credentialError !== undefined } function linearCollectionCacheKey( @@ -851,6 +854,17 @@ export const createLinearSlice: StateCreator = (s if (inflightIssueRequests.get(issueCacheKey) === entry) { inflightIssueRequests.delete(issueCacheKey) } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { + void get().checkLinearConnection(true) + } }) entry = { @@ -964,7 +978,7 @@ export const createLinearSlice: StateCreator = (s get().settings ) ) { - if (!shouldRefreshStatusAfterRead(workspaceId)) { + if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { void get().checkLinearConnection(true) } return [] @@ -976,7 +990,7 @@ export const createLinearSlice: StateCreator = (s inflightSearchRequests.delete(cacheKey) } if ( - shouldRefreshStatusAfterRead(workspaceId) && + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && canWriteLinearReadResult( contextKey, requestCacheGeneration, @@ -1059,7 +1073,7 @@ export const createLinearSlice: StateCreator = (s get().settings ) ) { - if (!shouldRefreshStatusAfterRead(workspaceId)) { + if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { void get().checkLinearConnection(true) } return emptyLinearCollection() @@ -1071,7 +1085,7 @@ export const createLinearSlice: StateCreator = (s inflightListRequests.delete(cacheKey) } if ( - shouldRefreshStatusAfterRead(workspaceId) && + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && canWriteLinearReadResult( contextKey, requestCacheGeneration, @@ -1153,7 +1167,7 @@ export const createLinearSlice: StateCreator = (s get().settings ) ) { - if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) { + if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { void get().checkLinearConnection(true) } return [] @@ -1165,7 +1179,7 @@ export const createLinearSlice: StateCreator = (s inflightTeamRequests.delete(cacheKey) } if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId) && + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && canWriteLinearReadResult( contextKey, requestCacheGeneration, @@ -1261,7 +1275,7 @@ export const createLinearSlice: StateCreator = (s inflightProjectRequests.delete(cacheKey) } if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId) && + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && canWriteLinearReadResult( contextKey, requestCacheGeneration, @@ -1353,6 +1367,17 @@ export const createLinearSlice: StateCreator = (s if (inflightProjectDetailRequests.get(cacheKey) === entry) { inflightProjectDetailRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { + void get().checkLinearConnection(true) + } }) entry = { @@ -1449,6 +1474,17 @@ export const createLinearSlice: StateCreator = (s if (inflightProjectIssueRequests.get(cacheKey) === entry) { inflightProjectIssueRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { + void get().checkLinearConnection(true) + } }) entry = { @@ -1535,7 +1571,7 @@ export const createLinearSlice: StateCreator = (s inflightCustomViewRequests.delete(cacheKey) } if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId) && + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && canWriteLinearReadResult( contextKey, requestCacheGeneration, @@ -1627,6 +1663,17 @@ export const createLinearSlice: StateCreator = (s if (inflightCustomViewDetailRequests.get(cacheKey) === entry) { inflightCustomViewDetailRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { + void get().checkLinearConnection(true) + } }) entry = { @@ -1723,6 +1770,17 @@ export const createLinearSlice: StateCreator = (s if (inflightCustomViewIssueRequests.get(cacheKey) === entry) { inflightCustomViewIssueRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { + void get().checkLinearConnection(true) + } }) entry = { @@ -1801,6 +1859,17 @@ export const createLinearSlice: StateCreator = (s if (inflightCustomViewProjectRequests.get(cacheKey) === entry) { inflightCustomViewProjectRequests.delete(cacheKey) } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { + void get().checkLinearConnection(true) + } }) entry = {