mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Route GitHub Project edits by view source
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -271,3 +271,4 @@ Done means: reviewers and future agents can understand the vision, implementatio
|
||||
- [x] 2026-06-12: Routed GitHub drawer label/assignee/reviewer metadata reads through the same task source context as issue/PR mutations, including slug-addressed Project view rows and reviewer request/remove actions, so same-repo multi-host drawers do not fall back to the repo owner or focused runtime. Verified with a focused source-boundary test, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
|
||||
- [x] 2026-06-13: Tightened runtime project-host setup mutations so older remote servers that advertise `project-host-setup.v1` but not `workspace-run-context.v1` are blocked before setup/clone/create/update/delete RPCs mutate server state; read-only hydration still degrades to repo-derived compatibility rows. Verified with focused repo capability tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
|
||||
- [x] 2026-06-13: Routed the full-page Pull Request metadata popovers and mention suggestions through the PR repo owner host instead of ambient focused runtime state, so reviewer, label, assignee, and comment-composer reads align with the existing repo-owned mutation routing. Verified with a focused PR host-boundary test, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
|
||||
- [x] 2026-06-13: Routed GitHub Project view field edits, slug-only row edits, issue-type lookups, label/assignee pickers, and slug dialog detail reads through the Project view source host instead of the ambient focused runtime when no registered repo owner is available. Verified with GitHub slice source-routing regressions, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
GitHubProjectUser,
|
||||
ListIssueTypesBySlugResult
|
||||
} from '../../../../shared/github-project-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Props = {
|
||||
@@ -42,6 +43,7 @@ type Props = {
|
||||
onEditLabels?: (add: string[], remove: string[]) => void
|
||||
onEditIssueType?: (issueType: GitHubIssueType | null) => void
|
||||
onOpenDialog?: () => void
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
}
|
||||
|
||||
export default function ProjectCell({
|
||||
@@ -52,7 +54,8 @@ export default function ProjectCell({
|
||||
onEditAssignees,
|
||||
onEditLabels,
|
||||
onEditIssueType,
|
||||
onOpenDialog
|
||||
onOpenDialog,
|
||||
sourceSettings
|
||||
}: Props): React.JSX.Element {
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
const isRedacted = row.itemType === 'REDACTED'
|
||||
@@ -63,15 +66,36 @@ export default function ProjectCell({
|
||||
}
|
||||
if (field.dataType === TYPE_FIELD_DATA_TYPE) {
|
||||
const editableHere = editable && !isRedacted && row.itemType === 'ISSUE'
|
||||
return <TypeCell row={row} editable={editableHere} onEditIssueType={onEditIssueType} />
|
||||
return (
|
||||
<TypeCell
|
||||
row={row}
|
||||
editable={editableHere}
|
||||
sourceSettings={sourceSettings}
|
||||
onEditIssueType={onEditIssueType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.dataType === 'ASSIGNEES') {
|
||||
const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE'
|
||||
return <AssigneesCell row={row} editable={editableHere} onEditAssignees={onEditAssignees} />
|
||||
return (
|
||||
<AssigneesCell
|
||||
row={row}
|
||||
editable={editableHere}
|
||||
sourceSettings={sourceSettings}
|
||||
onEditAssignees={onEditAssignees}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.dataType === 'LABELS') {
|
||||
const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE'
|
||||
return <LabelsCell row={row} editable={editableHere} onEditLabels={onEditLabels} />
|
||||
return (
|
||||
<LabelsCell
|
||||
row={row}
|
||||
editable={editableHere}
|
||||
sourceSettings={sourceSettings}
|
||||
onEditLabels={onEditLabels}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.dataType === 'REPOSITORY') {
|
||||
return (
|
||||
@@ -239,17 +263,26 @@ function TitleCell({
|
||||
function TypeCell({
|
||||
row,
|
||||
editable,
|
||||
sourceSettings,
|
||||
onEditIssueType
|
||||
}: {
|
||||
row: GitHubProjectRow
|
||||
editable: boolean
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onEditIssueType?: (issueType: GitHubIssueType | null) => void
|
||||
}): React.JSX.Element {
|
||||
// Why: for issues we surface the repo's `issueType` (Bug/Feature/Task etc)
|
||||
// when set — that's the editable taxonomy. PR/Draft/Restricted rows render
|
||||
// the static itemType glyph because there's no equivalent editable type.
|
||||
if (row.itemType === 'ISSUE') {
|
||||
return <IssueTypeCell row={row} editable={editable} onEditIssueType={onEditIssueType} />
|
||||
return (
|
||||
<IssueTypeCell
|
||||
row={row}
|
||||
editable={editable}
|
||||
sourceSettings={sourceSettings}
|
||||
onEditIssueType={onEditIssueType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const meta =
|
||||
row.itemType === 'PULL_REQUEST'
|
||||
@@ -278,17 +311,18 @@ function TypeCell({
|
||||
function IssueTypeCell({
|
||||
row,
|
||||
editable,
|
||||
sourceSettings,
|
||||
onEditIssueType
|
||||
}: {
|
||||
row: GitHubProjectRow
|
||||
editable: boolean
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onEditIssueType?: (issueType: GitHubIssueType | null) => void
|
||||
}): React.JSX.Element {
|
||||
const issueType = row.content.issueType
|
||||
const [open, setOpen] = useState(false)
|
||||
const [options, setOptions] = useState<GitHubIssueType[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const [owner, repo] = (row.content.repository ?? '').split('/')
|
||||
const { lookupSlug } = useRepoSlugIndex()
|
||||
const matchedRepo = useMemo(
|
||||
@@ -305,7 +339,7 @@ function IssueTypeCell({
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
const target = getActiveRuntimeTarget(matchedRepo ? ownerSettings : settings)
|
||||
const target = getActiveRuntimeTarget(matchedRepo ? ownerSettings : sourceSettings)
|
||||
const request =
|
||||
target.kind === 'environment'
|
||||
? callRuntimeRpc<ListIssueTypesBySlugResult>(
|
||||
@@ -332,7 +366,7 @@ function IssueTypeCell({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [matchedRepo, open, owner, ownerSettings, repo, settings])
|
||||
}, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings])
|
||||
|
||||
const trigger = (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
@@ -784,15 +818,16 @@ function UserChip({ user }: { user: GitHubProjectUser }): React.JSX.Element {
|
||||
function AssigneesCell({
|
||||
row,
|
||||
editable,
|
||||
sourceSettings,
|
||||
onEditAssignees
|
||||
}: {
|
||||
row: GitHubProjectRow
|
||||
editable: boolean
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onEditAssignees?: (add: string[], remove: string[]) => void
|
||||
}): React.JSX.Element {
|
||||
const assignees = row.content.assignees
|
||||
const [open, setOpen] = useState(false)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
const [owner, repo] = (row.content.repository ?? '').split('/')
|
||||
|
||||
@@ -814,7 +849,7 @@ function AssigneesCell({
|
||||
open ? owner : null,
|
||||
open ? repo : null,
|
||||
seedKey ? seedKey.split(',') : [],
|
||||
settings
|
||||
sourceSettings
|
||||
)
|
||||
|
||||
const labelContent =
|
||||
@@ -898,18 +933,19 @@ function AssigneesCell({
|
||||
function LabelsCell({
|
||||
row,
|
||||
editable,
|
||||
sourceSettings,
|
||||
onEditLabels
|
||||
}: {
|
||||
row: GitHubProjectRow
|
||||
editable: boolean
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onEditLabels?: (add: string[], remove: string[]) => void
|
||||
}): React.JSX.Element {
|
||||
const labels = row.content.labels
|
||||
const [open, setOpen] = useState(false)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
const [owner, repo] = (row.content.repository ?? '').split('/')
|
||||
const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings)
|
||||
const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings)
|
||||
|
||||
const labelContent =
|
||||
labels.length === 0 ? null : labels.map((l) => <LabelChip key={l.name} label={l} />)
|
||||
|
||||
@@ -12,15 +12,18 @@ import { VisuallyHidden } from 'radix-ui'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
|
||||
import { SlugDialogBody } from './slug-dialog/SlugDialogBody'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Props = {
|
||||
projectOrigin: GitHubItemDialogProjectOrigin | null
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ProjectItemSlugDialog({
|
||||
projectOrigin,
|
||||
sourceSettings,
|
||||
onClose
|
||||
}: Props): React.JSX.Element {
|
||||
const open = projectOrigin !== null
|
||||
@@ -49,7 +52,13 @@ export default function ProjectItemSlugDialog({
|
||||
)}
|
||||
</SheetDescription>
|
||||
</VisuallyHidden.Root>
|
||||
{projectOrigin ? <SlugDialogBody projectOrigin={projectOrigin} onClose={onClose} /> : null}
|
||||
{projectOrigin ? (
|
||||
<SlugDialogBody
|
||||
projectOrigin={projectOrigin}
|
||||
sourceSettings={sourceSettings}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
GitHubProjectFieldMutationValue,
|
||||
GitHubProjectRow as GitHubProjectRowType
|
||||
} from '../../../../shared/github-project-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const PROJECT_FROZEN_COLUMN_SURFACE_CLASS =
|
||||
@@ -33,6 +34,7 @@ type Props = {
|
||||
onEditIssueType?: (issueType: GitHubIssueType | null) => void
|
||||
onStartWork?: () => void
|
||||
onOpenInBrowser?: () => void
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
}
|
||||
|
||||
export default function ProjectRow({
|
||||
@@ -48,7 +50,8 @@ export default function ProjectRow({
|
||||
onEditLabels,
|
||||
onEditIssueType,
|
||||
onStartWork,
|
||||
onOpenInBrowser
|
||||
onOpenInBrowser,
|
||||
sourceSettings
|
||||
}: Props): React.JSX.Element {
|
||||
const disabled = row.itemType === 'REDACTED'
|
||||
// Why: design doc §Row actions — draft-issue rows have no URL or number, so
|
||||
@@ -97,6 +100,7 @@ export default function ProjectRow({
|
||||
onEditLabels={onEditLabels}
|
||||
onEditIssueType={onEditIssueType}
|
||||
onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined}
|
||||
sourceSettings={sourceSettings}
|
||||
/>
|
||||
</div>
|
||||
{next ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
GitHubProjectSortDirection,
|
||||
GitHubProjectTable
|
||||
} from '../../../../shared/github-project-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection }
|
||||
@@ -57,6 +58,7 @@ type Props = {
|
||||
onEditIssueType?: (row: GitHubProjectRow, issueType: GitHubIssueType | null) => void
|
||||
onStartWork?: (row: GitHubProjectRow) => void
|
||||
onOpenInBrowser?: (row: GitHubProjectRow) => void
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
}
|
||||
|
||||
export default function ProjectViewList({
|
||||
@@ -67,7 +69,8 @@ export default function ProjectViewList({
|
||||
onEditLabels,
|
||||
onEditIssueType,
|
||||
onStartWork,
|
||||
onOpenInBrowser
|
||||
onOpenInBrowser,
|
||||
sourceSettings
|
||||
}: Props): React.JSX.Element {
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set())
|
||||
// Why: column-header clicks override the view's saved sortByFields locally
|
||||
@@ -255,6 +258,7 @@ export default function ProjectViewList({
|
||||
onEditIssueType={(issueType) => onEditIssueType?.(row, issueType)}
|
||||
onStartWork={() => onStartWork?.(row)}
|
||||
onOpenInBrowser={() => onOpenInBrowser?.(row)}
|
||||
sourceSettings={sourceSettings}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -807,6 +807,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
}
|
||||
}}
|
||||
onStartWork={handleStartWork}
|
||||
sourceSettings={settings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -847,6 +848,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
button here would only confuse the user. */}
|
||||
<ProjectItemSlugDialog
|
||||
projectOrigin={resolvedMissingRepoDialogs.slugDialog?.origin ?? null}
|
||||
sourceSettings={settings}
|
||||
onClose={() => setSlugDialog(null)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { GlobalSettings } from '../../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function AssigneesEditor({
|
||||
@@ -10,16 +10,17 @@ export function AssigneesEditor({
|
||||
repo,
|
||||
selected,
|
||||
disabled,
|
||||
sourceSettings,
|
||||
onChange
|
||||
}: {
|
||||
owner: string
|
||||
repo: string
|
||||
selected: string[]
|
||||
disabled?: boolean
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onChange: (add: string[], remove: string[]) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
// Why: stabilize the assignee seed identity. `selected` is a fresh array on
|
||||
// every parent render — depending on it directly would refire the IPC for
|
||||
// every unrelated re-render while the popover is open.
|
||||
@@ -28,7 +29,7 @@ export function AssigneesEditor({
|
||||
open ? owner : null,
|
||||
open ? repo : null,
|
||||
seedKey ? seedKey.split(',') : [],
|
||||
settings
|
||||
sourceSettings
|
||||
)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { GlobalSettings } from '../../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function LabelsEditor({
|
||||
@@ -10,17 +10,18 @@ export function LabelsEditor({
|
||||
repo,
|
||||
selected,
|
||||
disabled,
|
||||
sourceSettings,
|
||||
onChange
|
||||
}: {
|
||||
owner: string
|
||||
repo: string
|
||||
selected: string[]
|
||||
disabled?: boolean
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onChange: (add: string[], remove: string[]) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings)
|
||||
const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type { GitHubWorkItemDetails } from '../../../../../shared/types'
|
||||
import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
|
||||
import type { GlobalSettings } from '../../../../../shared/types'
|
||||
import { LabelsEditor } from './LabelsEditor'
|
||||
import { AssigneesEditor } from './AssigneesEditor'
|
||||
import { CommentsList, NewCommentForm } from './Comments'
|
||||
@@ -15,9 +17,11 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function SlugDialogBody({
|
||||
projectOrigin,
|
||||
sourceSettings,
|
||||
onClose
|
||||
}: {
|
||||
projectOrigin: GitHubItemDialogProjectOrigin
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const { owner, repo, number, type, cacheKey } = projectOrigin
|
||||
@@ -52,8 +56,19 @@ export function SlugDialogBody({
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setDetails(null)
|
||||
window.api.gh
|
||||
.projectWorkItemDetailsBySlug({ owner, repo, number, type })
|
||||
const target = getActiveRuntimeTarget(sourceSettings)
|
||||
const request =
|
||||
target.kind === 'environment'
|
||||
? callRuntimeRpc<
|
||||
{ ok: true; details: GitHubWorkItemDetails } | { ok: false; error: { message: string } }
|
||||
>(
|
||||
target,
|
||||
'github.project.workItemDetailsBySlug',
|
||||
{ owner, repo, number, type },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.projectWorkItemDetailsBySlug({ owner, repo, number, type })
|
||||
request
|
||||
.then((res) => {
|
||||
if (rid !== requestIdRef.current) {
|
||||
return
|
||||
@@ -76,7 +91,7 @@ export function SlugDialogBody({
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [owner, repo, number, type])
|
||||
}, [owner, repo, number, type, sourceSettings])
|
||||
|
||||
const title = row?.content.title ?? details?.item.title ?? ''
|
||||
const url = row?.content.url ?? details?.item.url ?? null
|
||||
@@ -208,6 +223,7 @@ export function SlugDialogBody({
|
||||
repo={repo}
|
||||
selected={labels}
|
||||
disabled={!row}
|
||||
sourceSettings={sourceSettings}
|
||||
onChange={async (add, remove) => {
|
||||
// Why: bail rather than call the helper with an empty id —
|
||||
// see commitTitle above. Trigger is also disabled when !row.
|
||||
@@ -228,6 +244,7 @@ export function SlugDialogBody({
|
||||
repo={repo}
|
||||
selected={assignees}
|
||||
disabled={!row}
|
||||
sourceSettings={sourceSettings}
|
||||
onChange={async (add, remove) => {
|
||||
if (!row) {
|
||||
return
|
||||
|
||||
@@ -14,8 +14,12 @@ type RepoOwnerState = {
|
||||
export function settingsForProjectRowOwner(
|
||||
state: RepoOwnerState,
|
||||
owner: string,
|
||||
repo: string
|
||||
repo: string,
|
||||
fallbackSettings:
|
||||
| Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
|
||||
| null
|
||||
| undefined = state.settings
|
||||
): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined {
|
||||
const matchedRepo = lookupReposBySlugFromCache(state.repos, state.settings, `${owner}/${repo}`)[0]
|
||||
return matchedRepo ? getSettingsForRepoRuntimeOwner(state, matchedRepo.id) : state.settings
|
||||
return matchedRepo ? getSettingsForRepoRuntimeOwner(state, matchedRepo.id) : fallbackSettings
|
||||
}
|
||||
|
||||
@@ -46,7 +46,12 @@ const mockApi = {
|
||||
resolveReviewThread: vi.fn(),
|
||||
listWorkItems: vi.fn(),
|
||||
countWorkItems: vi.fn().mockResolvedValue(0),
|
||||
getProjectViewTable: vi.fn()
|
||||
getProjectViewTable: vi.fn(),
|
||||
updateProjectItemField: vi.fn(),
|
||||
clearProjectItemField: vi.fn(),
|
||||
updateIssueBySlug: vi.fn(),
|
||||
updatePullRequestBySlug: vi.fn(),
|
||||
updateIssueTypeBySlug: vi.fn()
|
||||
},
|
||||
hostedReview: {
|
||||
forBranch: vi.fn().mockResolvedValue(null),
|
||||
@@ -4683,6 +4688,177 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
||||
).toBe('project-local')
|
||||
})
|
||||
|
||||
it('routes project field mutations through the source encoded in the cache key', async () => {
|
||||
const store = createTestStore()
|
||||
const cacheKey = projectViewCacheKey(
|
||||
'organization',
|
||||
'acme',
|
||||
1,
|
||||
'view-1',
|
||||
undefined,
|
||||
'runtime:env-project'
|
||||
)
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-focused' },
|
||||
projectViewCache: {
|
||||
[cacheKey]: {
|
||||
fetchedAt: 1,
|
||||
data: {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
number: 1,
|
||||
title: 'Roadmap',
|
||||
url: 'https://github.com/orgs/acme/projects/1'
|
||||
},
|
||||
selectedView: {
|
||||
id: 'view-1',
|
||||
number: 1,
|
||||
name: 'Table',
|
||||
layout: 'TABLE_LAYOUT',
|
||||
filter: '',
|
||||
fields: [{ id: 'field-1', name: 'Notes', dataType: 'TEXT', kind: 'text' }],
|
||||
groupByFields: [],
|
||||
sortByFields: []
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
id: 'row-1',
|
||||
itemType: 'ISSUE',
|
||||
content: {
|
||||
repository: 'acme/repo',
|
||||
number: 12,
|
||||
title: 'Issue',
|
||||
body: '',
|
||||
url: 'https://github.com/acme/repo/issues/12',
|
||||
state: 'OPEN',
|
||||
labels: [],
|
||||
assignees: [],
|
||||
issueType: null,
|
||||
parentIssue: null
|
||||
},
|
||||
fieldValuesByFieldId: {}
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
parentFieldDropped: false
|
||||
}
|
||||
}
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-field',
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
const result = await store
|
||||
.getState()
|
||||
.updateProjectFieldValue(cacheKey, 'row-1', 'field-1', { kind: 'text', text: 'next' })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(mockApi.gh.updateProjectItemField).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-project',
|
||||
method: 'github.project.updateItemField',
|
||||
params: {
|
||||
projectId: 'project-1',
|
||||
itemId: 'row-1',
|
||||
fieldId: 'field-1',
|
||||
value: { kind: 'text', text: 'next' }
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes slug-only project row mutations through the source encoded in the cache key', async () => {
|
||||
const store = createTestStore()
|
||||
const cacheKey = projectViewCacheKey(
|
||||
'organization',
|
||||
'acme',
|
||||
1,
|
||||
'view-1',
|
||||
undefined,
|
||||
'runtime:env-project'
|
||||
)
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-focused' },
|
||||
repos: [],
|
||||
projectViewCache: {
|
||||
[cacheKey]: {
|
||||
fetchedAt: 1,
|
||||
data: {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
number: 1,
|
||||
title: 'Roadmap',
|
||||
url: 'https://github.com/orgs/acme/projects/1'
|
||||
},
|
||||
selectedView: {
|
||||
id: 'view-1',
|
||||
number: 1,
|
||||
name: 'Table',
|
||||
layout: 'TABLE_LAYOUT',
|
||||
filter: '',
|
||||
fields: [],
|
||||
groupByFields: [],
|
||||
sortByFields: []
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
id: 'row-1',
|
||||
itemType: 'ISSUE',
|
||||
content: {
|
||||
repository: 'acme/repo',
|
||||
number: 12,
|
||||
title: 'Issue',
|
||||
body: '',
|
||||
url: 'https://github.com/acme/repo/issues/12',
|
||||
state: 'OPEN',
|
||||
labels: [],
|
||||
assignees: [],
|
||||
issueType: null,
|
||||
parentIssue: null
|
||||
},
|
||||
fieldValuesByFieldId: {}
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
parentFieldDropped: false
|
||||
}
|
||||
}
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-issue',
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
const result = await store
|
||||
.getState()
|
||||
.patchProjectIssueOrPr(cacheKey, 'row-1', { addLabels: ['bug'] })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(mockApi.gh.updateIssueBySlug).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-project',
|
||||
method: 'github.project.updateIssueBySlug',
|
||||
params: {
|
||||
owner: 'acme',
|
||||
repo: 'repo',
|
||||
number: 12,
|
||||
updates: { addLabels: ['bug'] }
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds project view table cache entries across many projects', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@@ -358,6 +358,17 @@ function projectViewSourceScope(settings: AppState['settings']): string {
|
||||
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
|
||||
}
|
||||
|
||||
function settingsForProjectViewCacheKey(
|
||||
settings: AppState['settings'],
|
||||
cacheKey: string
|
||||
): Pick<NonNullable<AppState['settings']>, 'activeRuntimeEnvironmentId'> {
|
||||
const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey)
|
||||
if (runtimeMatch) {
|
||||
return { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] }
|
||||
}
|
||||
return { ...settings, activeRuntimeEnvironmentId: null }
|
||||
}
|
||||
|
||||
// Why: module-scope inflight map — must mirror `inflightWorkItemsRequests`
|
||||
// (dedup + force-refresh semantics). Reuses the work-item concurrency gate:
|
||||
// the gate exists to bound `gh` subprocess pressure at the renderer boundary,
|
||||
@@ -1794,7 +1805,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
}
|
||||
applyRowPatch(set, cacheKey, rowId, optimisticRow)
|
||||
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
|
||||
const result =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<GitHubProjectMutationResult>(
|
||||
@@ -1852,7 +1863,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
}
|
||||
applyRowPatch(set, cacheKey, rowId, optimisticRow)
|
||||
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
|
||||
const result =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<GitHubProjectMutationResult>(
|
||||
@@ -1953,9 +1964,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
// PRs goes through updatePullRequestBySlug; for issues through
|
||||
// updateIssueBySlug. We dispatch both as needed.
|
||||
let envelope: GitHubProjectMutationResult = { ok: true }
|
||||
// Why: route the row edit to the matched repo's owner host so it lands where
|
||||
// the repo lives; fall back to focused settings when no repo matches the slug.
|
||||
const target = getActiveRuntimeTarget(settingsForProjectRowOwner(get(), owner, repo))
|
||||
// Why: Project rows may be slug-only and have no registered Orca repo.
|
||||
// Fall back to the view source encoded in the cache key, not focused host.
|
||||
const target = getActiveRuntimeTarget(
|
||||
settingsForProjectRowOwner(
|
||||
get(),
|
||||
owner,
|
||||
repo,
|
||||
settingsForProjectViewCacheKey(get().settings, cacheKey)
|
||||
)
|
||||
)
|
||||
if (
|
||||
previousRow.itemType === 'PULL_REQUEST' &&
|
||||
(updates.title !== undefined || updates.body !== undefined)
|
||||
@@ -2074,9 +2092,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
content: { ...previousRow.content, issueType }
|
||||
}
|
||||
applyRowPatch(set, cacheKey, rowId, optimistic)
|
||||
// Why: route the issue-type edit to the matched repo's owner host; fall back
|
||||
// to focused settings when no repo matches the slug.
|
||||
const target = getActiveRuntimeTarget(settingsForProjectRowOwner(get(), owner, repo))
|
||||
// Why: slug-only Project rows still belong to the source host that loaded
|
||||
// the view; focused host may have changed after the table was fetched.
|
||||
const target = getActiveRuntimeTarget(
|
||||
settingsForProjectRowOwner(
|
||||
get(),
|
||||
owner,
|
||||
repo,
|
||||
settingsForProjectViewCacheKey(get().settings, cacheKey)
|
||||
)
|
||||
)
|
||||
const args = {
|
||||
owner,
|
||||
repo,
|
||||
|
||||
Reference in New Issue
Block a user