Merge branch 'main' of https://github.com/stablyai/orca into auto-e2e-tests-autofix-scheduled-ci-1h-run-28-20260831T0700

This commit is contained in:
Jinjing
2026-08-31 19:03:56 -07:00
5 changed files with 228 additions and 19 deletions
@@ -79,6 +79,7 @@ import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
import type { JiraIssue, JiraSite } from '../../../../shared/jira-types'
import type { LinearIssue } from '../../../../shared/linear/issue-types'
import { linearWorkspaceScopeSignature } from '../../../../shared/linear/workspace-types'
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value'
import { isComposerFieldToFieldFocus } from './smart-workspace-source-popover-focus'
@@ -425,6 +426,14 @@ export default function SmartWorkspaceNameField({
const showJiraSiteContext = mode === 'jira' && jiraConnectionStatus?.selectedSiteId === 'all'
const jiraStatusId = React.useId()
const linearStatusId = React.useId()
// Read the latest metadata for URL resolution without making the search effect depend on object identity.
const linearStatusRef = useRef(linearStatus)
linearStatusRef.current = linearStatus
// Store action references can change with wiring; reads should only rerun for query/scope changes.
const linearReadMethodsRef = useRef({ fetchLinearIssue, listLinearIssues, searchLinearIssues })
linearReadMethodsRef.current = { fetchLinearIssue, listLinearIssues, searchLinearIssues }
const linearConnected = linearStatus.connected === true
const linearScopeSignature = linearWorkspaceScopeSignature(linearStatus)
useEffect(() => {
onActiveSourceModeChange?.(mode)
@@ -1018,7 +1027,7 @@ export default function SmartWorkspaceNameField({
}, [branchSearchRequest, selectedRepoOwnerSettings])
useEffect(() => {
if (disabled || !shouldQueryLinear || !linearStatus.connected) {
if (disabled || !shouldQueryLinear || !linearConnected) {
setLinearIssues([])
setLinearLoading(false)
setSettledLinearUrlQuery(null)
@@ -1035,18 +1044,24 @@ export default function SmartWorkspaceNameField({
const request = linearUrlIntent
? lookupLinearIssueUrl({
intent: linearUrlIntent,
knownStatus: linearStatus,
knownStatus: linearStatusRef.current,
sourceContext: linearSourceContext,
fetchLinearIssue
fetchLinearIssue: linearReadMethodsRef.current.fetchLinearIssue
}).then((issue) => (issue ? [issue] : []))
: trimmed
? searchLinearIssues(getSmartWorkspaceLinearSearchQuery(trimmed), RESULT_LIMIT, {
sourceContext: linearSourceContext
})
: listLinearIssues(
{ kind: 'list', filter: 'assigned', limit: RESULT_LIMIT },
{ sourceContext: linearSourceContext }
).then((result) => result.items)
? linearReadMethodsRef.current.searchLinearIssues(
getSmartWorkspaceLinearSearchQuery(trimmed),
RESULT_LIMIT,
{
sourceContext: linearSourceContext
}
)
: linearReadMethodsRef.current
.listLinearIssues(
{ kind: 'list', filter: 'assigned', limit: RESULT_LIMIT },
{ sourceContext: linearSourceContext }
)
.then((result) => result.items)
void request
.then((issues) => {
if (!stale) {
@@ -1068,8 +1083,15 @@ export default function SmartWorkspaceNameField({
stale = true
}
// Why: list/search are stable store methods; depending on them would refetch on unrelated store writes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled, linearQuery, linearSourceContext, linearStatus, linearUrlIntent, shouldQueryLinear])
}, [
disabled,
linearConnected,
linearQuery,
linearScopeSignature,
linearSourceContext,
linearUrlIntent,
shouldQueryLinear
])
useEffect(() => {
if (!shouldQueryJira || !jiraSourceContext || !jiraSearchJql) {
@@ -86,6 +86,40 @@ describe('createLinearSlice', () => {
expect(store.getState().linearStatusChecked).toBe(true)
})
it('preserves status identity when a connection probe leaves its scope unchanged', async () => {
const store = createTestStore()
const currentStatus: LinearConnectionStatus = {
connected: true,
viewer: {
displayName: 'Test User',
email: 'test@example.com',
organizationName: 'Test Org'
},
selectedWorkspaceId: 'workspace-1',
activeWorkspaceId: 'workspace-1',
workspaces: [
{
id: 'workspace-1',
organizationId: 'workspace-1',
organizationName: 'Test Org',
displayName: 'Test User',
email: 'test@example.com'
}
]
}
store.setState({
linearStatus: currentStatus,
linearStatusChecked: true,
linearStatusContextKey: 'local#0'
})
linearTestConnection.mockResolvedValueOnce({ ok: true, viewer: currentStatus.viewer })
linearStatus.mockResolvedValueOnce({ ...currentStatus, viewer: { ...currentStatus.viewer } })
await store.getState().testLinearConnection()
expect(store.getState().linearStatus).toBe(currentStatus)
})
it('ignores stale forced connection checks when a newer forced check finishes first', async () => {
const staleCheck = deferred<LinearConnectionStatus>()
const freshCheck = deferred<LinearConnectionStatus>()
+5 -7
View File
@@ -236,6 +236,7 @@ function linearWorkspaceSignature(workspace: LinearWorkspace): string {
].join('\u001f')
}
/** Cache-invalidation key: broader than `linearWorkspaceScopeSignature`, hashes full viewer and workspace metadata. */
function linearStatusScopeSignature(status: LinearConnectionStatus): string {
return JSON.stringify({
connected: status.connected,
@@ -607,7 +608,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) {
return inflightStatusRequest.promise
}
if (get().linearStatusContextKey !== contextKey) {
if (get().linearStatusContextKey !== contextKey && get().linearStatusChecked) {
set({ linearStatusChecked: false })
}
@@ -732,12 +733,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearStatusChecked: true,
linearStatusContextKey: contextKey
})
} else {
set({
linearStatus: status,
linearStatusChecked: true,
linearStatusContextKey: contextKey
})
} else if (!get().linearStatusChecked || get().linearStatusContextKey !== contextKey) {
// Preserve the status reference when the probe did not change scope.
set({ linearStatusChecked: true, linearStatusContextKey: contextKey })
}
}
return result
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { linearWorkspaceScopeSignature, type LinearConnectionStatus } from './workspace-types'
describe('linearWorkspaceScopeSignature', () => {
it('ignores status metadata and workspace ordering', () => {
const first: LinearConnectionStatus = {
connected: true,
viewer: { displayName: 'Ada', email: 'ada@example.com', organizationName: 'Alpha' },
selectedWorkspaceId: 'workspace-1',
workspaces: [
{
id: 'workspace-1',
organizationId: 'org-1',
organizationName: 'Alpha',
displayName: 'Ada',
email: 'ada@example.com'
},
{
id: 'workspace-2',
organizationId: 'org-2',
organizationName: 'Beta',
displayName: 'Ada',
email: 'ada@example.com'
}
]
}
const second: LinearConnectionStatus = {
...first,
viewer: { ...first.viewer!, organizationName: 'Renamed' },
workspaces: [
{ ...first.workspaces![1], organizationName: 'Renamed Beta' },
{ ...first.workspaces![0], organizationName: 'Renamed Alpha' }
]
}
expect(linearWorkspaceScopeSignature(second)).toBe(linearWorkspaceScopeSignature(first))
})
it('changes when the selected workspace changes', () => {
const first: LinearConnectionStatus = {
connected: true,
viewer: null,
selectedWorkspaceId: 'workspace-1'
}
const second = { ...first, selectedWorkspaceId: 'workspace-2' }
expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first))
})
it('changes when the active workspace changes while all workspaces are selected', () => {
const first: LinearConnectionStatus = {
connected: true,
viewer: null,
selectedWorkspaceId: 'all',
activeWorkspaceId: 'workspace-1'
}
const second = { ...first, activeWorkspaceId: 'workspace-2' }
expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first))
})
it('changes when a workspace credential revision changes', () => {
const first: LinearConnectionStatus = {
connected: true,
viewer: null,
workspaces: [
{
id: 'workspace-1',
organizationId: 'org-1',
organizationName: 'Alpha',
displayName: 'Ada',
email: 'ada@example.com',
credentialRevision: 1
}
]
}
const second: LinearConnectionStatus = {
...first,
workspaces: [{ ...first.workspaces![0], credentialRevision: 2 }]
}
expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first))
})
it('changes when a workspace or viewer organization url key changes', () => {
const first: LinearConnectionStatus = {
connected: true,
viewer: {
displayName: 'Ada',
email: 'ada@example.com',
organizationName: 'Alpha',
organizationUrlKey: 'alpha'
},
workspaces: [
{
id: 'workspace-1',
organizationId: 'org-1',
organizationName: 'Alpha',
displayName: 'Ada',
email: 'ada@example.com',
organizationUrlKey: 'alpha'
}
]
}
const renamedWorkspaceKey: LinearConnectionStatus = {
...first,
workspaces: [{ ...first.workspaces![0], organizationUrlKey: 'alpha-renamed' }]
}
const renamedViewerKey: LinearConnectionStatus = {
...first,
viewer: { ...first.viewer!, organizationUrlKey: 'alpha-renamed' }
}
expect(linearWorkspaceScopeSignature(renamedWorkspaceKey)).not.toBe(
linearWorkspaceScopeSignature(first)
)
expect(linearWorkspaceScopeSignature(renamedViewerKey)).not.toBe(
linearWorkspaceScopeSignature(first)
)
})
})
+34
View File
@@ -41,6 +41,40 @@ export type LinearConnectionStatus = {
credentialError?: string
}
/**
* Stable dependency key for Linear reads: only the fields that change what a read returns.
* Not interchangeable with the store's `linearStatusScopeSignature`, which hashes full
* viewer and workspace metadata for cache invalidation and so churns on read-irrelevant edits.
*/
export function linearWorkspaceScopeSignature(
status: Pick<
LinearConnectionStatus,
| 'connected'
| 'credentialError'
| 'activeWorkspaceId'
| 'selectedWorkspaceId'
| 'workspaces'
| 'viewer'
>
): string {
return JSON.stringify({
connected: status.connected === true,
credentialError: status.credentialError ?? null,
workspaceId: status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null,
// Why: under 'all', URL lookup still falls back to the active workspace, so it must key reads too.
activeWorkspaceId: status.activeWorkspaceId ?? null,
// Why: URL resolution routes by organizationUrlKey, and credentialRevision changes what a read returns.
viewerOrganizationUrlKey: status.viewer?.organizationUrlKey ?? null,
workspaces: (status.workspaces ?? [])
.map((workspace) =>
[workspace.id, workspace.organizationUrlKey ?? '', workspace.credentialRevision ?? 0].join(
'\u001f'
)
)
.sort()
})
}
export type LinearWorkflowState = {
id: string
name: string