Refresh Jira and Linear status after credential errors (#5169)

* fix: address pr-bug-scan validated finding from #4683

Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces

* Refresh Jira and Linear status to clear stale credential errors

Ensure stale credential decryption errors are cleared from the store
status once a successful API read completes. By updating the check in
shouldRefreshStatusAfterRead to trigger when a credentialError is
currently set, successful issue or list fetches will trigger a status
check and remove stale error flags.

---------

Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
This commit is contained in:
Jinjing 2026-06-11 00:11:22 -07:00 committed by GitHub
parent fcc0afa8cd
commit 845ea2c68c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 287 additions and 14 deletions

View File

@ -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()
})
})
})

View File

@ -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<AppState, [], [], JiraSlice> = (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<AppState, [], [], JiraSlice> = (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<AppState, [], [], JiraSlice> = (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<AppState, [], [], JiraSlice> = (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<AppState, [], [], JiraSlice> = (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<AppState, [], [], JiraSlice> = (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)

View File

@ -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 () => {

View File

@ -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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (s
get().settings
)
) {
if (!shouldRefreshStatusAfterRead(workspaceId)) {
if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) {
void get().checkLinearConnection(true)
}
return []
@ -976,7 +990,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
inflightSearchRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(workspaceId) &&
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
canWriteLinearReadResult(
contextKey,
requestCacheGeneration,
@ -1059,7 +1073,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
get().settings
)
) {
if (!shouldRefreshStatusAfterRead(workspaceId)) {
if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) {
void get().checkLinearConnection(true)
}
return emptyLinearCollection<LinearIssue>()
@ -1071,7 +1085,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
inflightListRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(workspaceId) &&
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
canWriteLinearReadResult(
contextKey,
requestCacheGeneration,
@ -1153,7 +1167,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
get().settings
)
) {
if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) {
if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) {
void get().checkLinearConnection(true)
}
return []
@ -1165,7 +1179,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
inflightTeamRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) &&
canWriteLinearReadResult(
contextKey,
requestCacheGeneration,
@ -1261,7 +1275,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
inflightProjectRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) &&
canWriteLinearReadResult(
contextKey,
requestCacheGeneration,
@ -1353,6 +1367,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (s
inflightCustomViewRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) &&
canWriteLinearReadResult(
contextKey,
requestCacheGeneration,
@ -1627,6 +1663,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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 = {