mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Scope Linear optimistic task patches
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -222,6 +222,7 @@ Done means: reviewers and future agents can understand the vision, implementatio
|
||||
- [x] 2026-06-12: Scoped Jira optimistic detail patches to the selected `TaskSourceContext` so a same-key issue cached from another Jira host/site is not visually mutated; verified with `jira.test.ts`, focused oxlint, and full `pnpm run typecheck`.
|
||||
- [x] 2026-06-12: Updated Composer GitHub PR and GitLab MR base-resolution to run against the selected workspace run repo instead of the provider item's source repo, preserving task source identity separately from run-host git commands; verified with a host-context boundary test, focused oxlint, and full `pnpm run typecheck`.
|
||||
- [x] 2026-06-12: Added Jira provider-depth interaction recording when starting workspace creation from a Jira issue, matching GitHub/GitLab/Linear task-provider behavior; verified with the feature-interaction writer-boundary test and focused oxlint.
|
||||
- [x] 2026-06-12: Scoped Linear optimistic issue patches to the selected `TaskSourceContext` so same-id issues cached from another Linear host/workspace are not visually mutated; threaded source context through Linear drawer/project edits and verified with `linear.test.ts`, focused oxlint, and full `pnpm run typecheck`.
|
||||
- [x] 2026-06-12: Threaded GitHub Tasks row-level mutations through `TaskSourceContext` for issue state, assignees, reviewer requests/removals, PR merge/auto-merge, and new issue creation/follow-up reads; verified with focused GitHub/TaskPage tests, focused oxlint, `pnpm run typecheck`, and `git diff --check`.
|
||||
- [x] 2026-06-12: Added provider account-scope diagnostics to GitHub/GitLab CLI integration cards so code-host credential checks show whether they belong to the local desktop client or selected remote server, matching the existing Linear/Jira ownership copy; verified with focused integration-card scope tests and oxlint.
|
||||
- [x] 2026-06-12: Extended task-detail navigation state to GitLab and fixed Linear replay so saved `TaskSourceContext` is restored through back/forward and direct detail reopens; GitLab detail history keys now include source scope so identical MRs from different hosts stay distinct. Verified with focused UI/history/static-boundary tests and oxlint.
|
||||
|
||||
@@ -383,7 +383,7 @@ function LinearIssueSidebarProjectCard({
|
||||
)
|
||||
if (result.ok) {
|
||||
onProjectChanged(project)
|
||||
patchLinearIssue(issue.id, { project })
|
||||
patchLinearIssue(issue.id, { project }, { sourceContext })
|
||||
toast.success(
|
||||
translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated')
|
||||
)
|
||||
@@ -404,7 +404,14 @@ function LinearIssueSidebarProjectCard({
|
||||
setSavingProjectId(null)
|
||||
}
|
||||
},
|
||||
[issue.id, issue.workspaceId, onProjectChanged, patchLinearIssue, providerSettings]
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
onProjectChanged,
|
||||
patchLinearIssue,
|
||||
providerSettings,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -192,11 +192,11 @@ export function LinearIssueEditSection({
|
||||
mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ state: stateValue })
|
||||
patchLinearIssue(issue.id, { state: stateValue })
|
||||
patchLinearIssue(issue.id, { state: stateValue }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ state: prevState })
|
||||
patchLinearIssue(issue.id, { state: prevState })
|
||||
patchLinearIssue(issue.id, { state: prevState }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -212,7 +212,8 @@ export function LinearIssueEditSection({
|
||||
states.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -225,11 +226,11 @@ export function LinearIssueEditSection({
|
||||
linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ priority })
|
||||
patchLinearIssue(issue.id, { priority })
|
||||
patchLinearIssue(issue.id, { priority }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ priority: prevPriority })
|
||||
patchLinearIssue(issue.id, { priority: prevPriority })
|
||||
patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -244,7 +245,8 @@ export function LinearIssueEditSection({
|
||||
providerSettings,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -256,12 +258,12 @@ export function LinearIssueEditSection({
|
||||
linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ estimate })
|
||||
patchLinearIssue(issue.id, { estimate })
|
||||
patchLinearIssue(issue.id, { estimate }, { sourceContext })
|
||||
setEstimatePopoverOpen(false)
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ estimate: prevEstimate })
|
||||
patchLinearIssue(issue.id, { estimate: prevEstimate })
|
||||
patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -276,7 +278,8 @@ export function LinearIssueEditSection({
|
||||
providerSettings,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -314,11 +317,11 @@ export function LinearIssueEditSection({
|
||||
linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ assignee: newAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: newAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ assignee: prevAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: prevAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -334,7 +337,8 @@ export function LinearIssueEditSection({
|
||||
members.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -360,11 +364,19 @@ export function LinearIssueEditSection({
|
||||
),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ labelIds: newLabelIds, labels: newLabels })
|
||||
patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels })
|
||||
patchLinearIssue(
|
||||
issue.id,
|
||||
{ labelIds: newLabelIds, labels: newLabels },
|
||||
{ sourceContext }
|
||||
)
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels })
|
||||
patchLinearIssue(issue.id, { labelIds: prevLabelIds, labels: prevLabels })
|
||||
patchLinearIssue(
|
||||
issue.id,
|
||||
{ labelIds: prevLabelIds, labels: prevLabels },
|
||||
{ sourceContext }
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -381,7 +393,8 @@ export function LinearIssueEditSection({
|
||||
labels.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ import type {
|
||||
LinearTeam,
|
||||
LinearViewer
|
||||
} from '../../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import {
|
||||
getTaskSourceCacheScope,
|
||||
type TaskSourceContext
|
||||
} from '../../../../shared/task-source-context'
|
||||
import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors'
|
||||
import { createLinearSlice } from './linear'
|
||||
|
||||
@@ -836,6 +839,78 @@ describe('createLinearSlice caching', () => {
|
||||
.data?.items[0]?.title
|
||||
).toBe('Updated')
|
||||
})
|
||||
|
||||
it('scopes optimistic issue patches to the selected Linear source context', () => {
|
||||
const store = createTestStore()
|
||||
const localSource = linearSourceContext('local-runtime')
|
||||
const remoteSource = linearSourceContext('remote-runtime')
|
||||
const localScope = getTaskSourceCacheScope(localSource)
|
||||
const remoteScope = getTaskSourceCacheScope(remoteSource)
|
||||
|
||||
store.setState({
|
||||
linearIssueCache: {
|
||||
[`${localScope}::workspace-1::issue-id`]: {
|
||||
data: { ...issue('issue-id'), title: 'Local title' },
|
||||
fetchedAt: Date.now()
|
||||
},
|
||||
[`${remoteScope}::workspace-1::issue-id`]: {
|
||||
data: { ...issue('issue-id'), title: 'Remote title' },
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
},
|
||||
linearSearchCache: {
|
||||
[`${localScope}::workspace-1::search::query::20`]: {
|
||||
data: [{ ...issue('issue-id'), title: 'Local title' }],
|
||||
fetchedAt: Date.now()
|
||||
},
|
||||
[`${remoteScope}::workspace-1::search::query::20`]: {
|
||||
data: [{ ...issue('issue-id'), title: 'Remote title' }],
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
},
|
||||
linearListCache: {
|
||||
[`${localScope}::workspace-1::list::all::36`]: {
|
||||
data: { items: [{ ...issue('issue-id'), title: 'Local title' }] },
|
||||
fetchedAt: Date.now()
|
||||
},
|
||||
[`${remoteScope}::workspace-1::list::all::36`]: {
|
||||
data: { items: [{ ...issue('issue-id'), title: 'Remote title' }] },
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().patchLinearIssue(
|
||||
'issue-id',
|
||||
{ title: 'Patched local title' },
|
||||
{
|
||||
sourceContext: localSource
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
store.getState().linearIssueCache[`${localScope}::workspace-1::issue-id`]?.data?.title
|
||||
).toBe('Patched local title')
|
||||
expect(
|
||||
store.getState().linearIssueCache[`${remoteScope}::workspace-1::issue-id`]?.data?.title
|
||||
).toBe('Remote title')
|
||||
expect(
|
||||
store.getState().linearSearchCache[`${localScope}::workspace-1::search::query::20`]?.data?.[0]
|
||||
?.title
|
||||
).toBe('Patched local title')
|
||||
expect(
|
||||
store.getState().linearSearchCache[`${remoteScope}::workspace-1::search::query::20`]
|
||||
?.data?.[0]?.title
|
||||
).toBe('Remote title')
|
||||
expect(
|
||||
store.getState().linearListCache[`${localScope}::workspace-1::list::all::36`]?.data?.items[0]
|
||||
?.title
|
||||
).toBe('Patched local title')
|
||||
expect(
|
||||
store.getState().linearListCache[`${remoteScope}::workspace-1::list::all::36`]?.data?.items[0]
|
||||
?.title
|
||||
).toBe('Remote title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLinearSlice', () => {
|
||||
|
||||
@@ -328,7 +328,8 @@ function largestCachedCollectionBelowLimit<T>(
|
||||
function patchLinearIssueCollectionCache(
|
||||
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>,
|
||||
issueId: string,
|
||||
patch: Partial<LinearIssue>
|
||||
patch: Partial<LinearIssue>,
|
||||
canPatchCacheKey: (key: string) => boolean
|
||||
): {
|
||||
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
|
||||
changed: boolean
|
||||
@@ -336,7 +337,7 @@ function patchLinearIssueCollectionCache(
|
||||
let changed = false
|
||||
const nextCache = { ...cache }
|
||||
for (const [key, entry] of Object.entries(nextCache)) {
|
||||
if (!entry?.data) {
|
||||
if (!canPatchCacheKey(key) || !entry?.data) {
|
||||
continue
|
||||
}
|
||||
const idx = entry.data.items.findIndex((item) => item.id === issueId)
|
||||
@@ -359,6 +360,7 @@ type LinearIssueReadArgs =
|
||||
| { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number }
|
||||
|
||||
type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null }
|
||||
type LinearPatchOptions = { sourceContext?: TaskSourceContext | null }
|
||||
|
||||
type LinearReadScope = {
|
||||
settings: AppState['settings'] | TaskSourceContext | null
|
||||
@@ -533,7 +535,11 @@ export type LinearSlice = {
|
||||
limit?: number,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void
|
||||
patchLinearIssue: (
|
||||
issueId: string,
|
||||
patch: Partial<LinearIssue>,
|
||||
options?: LinearPatchOptions
|
||||
) => void
|
||||
}
|
||||
|
||||
export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (set, get) => ({
|
||||
@@ -2046,13 +2052,19 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
||||
return promise
|
||||
},
|
||||
|
||||
patchLinearIssue: (issueId, patch) => {
|
||||
patchLinearIssue: (issueId, patch, options) => {
|
||||
const sourceScope =
|
||||
options?.sourceContext?.provider === 'linear'
|
||||
? getTaskSourceCacheScope(options.sourceContext)
|
||||
: null
|
||||
const canPatchCacheKey = (key: string): boolean =>
|
||||
sourceScope === null || key.startsWith(`${sourceScope}::`)
|
||||
set((s) => {
|
||||
let changed = false
|
||||
|
||||
const nextIssueCache = { ...s.linearIssueCache }
|
||||
for (const [key, issueEntry] of Object.entries(nextIssueCache)) {
|
||||
if (issueEntry?.data?.id !== issueId) {
|
||||
if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) {
|
||||
continue
|
||||
}
|
||||
// Why: set fetchedAt to 0 so the next fetchLinearIssue call
|
||||
@@ -2068,7 +2080,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
||||
const nextSearchCache = { ...s.linearSearchCache }
|
||||
for (const key of Object.keys(nextSearchCache)) {
|
||||
const entry = nextSearchCache[key]
|
||||
if (!entry?.data) {
|
||||
if (!canPatchCacheKey(key) || !entry?.data) {
|
||||
continue
|
||||
}
|
||||
const idx = entry.data.findIndex((item) => item.id === issueId)
|
||||
@@ -2081,7 +2093,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
||||
changed = true
|
||||
}
|
||||
|
||||
const nextListCache = patchLinearIssueCollectionCache(s.linearListCache, issueId, patch)
|
||||
const nextListCache = patchLinearIssueCollectionCache(
|
||||
s.linearListCache,
|
||||
issueId,
|
||||
patch,
|
||||
canPatchCacheKey
|
||||
)
|
||||
if (nextListCache.changed) {
|
||||
changed = true
|
||||
}
|
||||
@@ -2089,7 +2106,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
||||
const nextProjectIssueCache = patchLinearIssueCollectionCache(
|
||||
s.linearProjectIssueCache,
|
||||
issueId,
|
||||
patch
|
||||
patch,
|
||||
canPatchCacheKey
|
||||
)
|
||||
if (nextProjectIssueCache.changed) {
|
||||
changed = true
|
||||
@@ -2098,7 +2116,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
||||
const nextCustomViewIssueCache = patchLinearIssueCollectionCache(
|
||||
s.linearCustomViewIssueCache,
|
||||
issueId,
|
||||
patch
|
||||
patch,
|
||||
canPatchCacheKey
|
||||
)
|
||||
if (nextCustomViewIssueCache.changed) {
|
||||
changed = true
|
||||
|
||||
Reference in New Issue
Block a user