mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user