From 8aecd6b4f1de1ac4ba92c34565a93fecf82acaa3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 13 Jun 2026 00:35:01 -0700 Subject: [PATCH] Route GitHub Project edits by view source Co-authored-by: Orca --- .../host-context-implementation-checklist.md | 1 + .../components/github-project/ProjectCell.tsx | 60 ++++-- .../github-project/ProjectItemSlugDialog.tsx | 11 +- .../components/github-project/ProjectRow.tsx | 6 +- .../github-project/ProjectViewList.tsx | 6 +- .../github-project/ProjectViewWrapper.tsx | 2 + .../slug-dialog/AssigneesEditor.tsx | 7 +- .../slug-dialog/LabelsEditor.tsx | 7 +- .../slug-dialog/SlugDialogBody.tsx | 23 ++- .../store/slices/github-project-row-owner.ts | 8 +- src/renderer/src/store/slices/github.test.ts | 178 +++++++++++++++++- src/renderer/src/store/slices/github.ts | 41 +++- 12 files changed, 315 insertions(+), 35 deletions(-) diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index d10ac563da8..b5765f62a63 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -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`. diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 238f97b3681..b9642d0c2e8 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -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 | 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 + return ( + + ) } if (field.dataType === 'ASSIGNEES') { const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE' - return + return ( + + ) } if (field.dataType === 'LABELS') { const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE' - return + return ( + + ) } if (field.dataType === 'REPOSITORY') { return ( @@ -239,17 +263,26 @@ function TitleCell({ function TypeCell({ row, editable, + sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick | 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 + return ( + + ) } const meta = row.itemType === 'PULL_REQUEST' @@ -278,17 +311,18 @@ function TypeCell({ function IssueTypeCell({ row, editable, + sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick | null | undefined onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { const issueType = row.content.issueType const [open, setOpen] = useState(false) const [options, setOptions] = useState([]) 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( @@ -332,7 +366,7 @@ function IssueTypeCell({ return () => { cancelled = true } - }, [matchedRepo, open, owner, ownerSettings, repo, settings]) + }, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings]) const trigger = ( @@ -784,15 +818,16 @@ function UserChip({ user }: { user: GitHubProjectUser }): React.JSX.Element { function AssigneesCell({ row, editable, + sourceSettings, onEditAssignees }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick | 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 | 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) => ) diff --git a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx index 51db853fc1b..2b778e29c10 100644 --- a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx +++ b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx @@ -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 | 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({ )} - {projectOrigin ? : null} + {projectOrigin ? ( + + ) : null} ) diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 1513f275da9..55e01e52f99 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -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 | 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} /> {next ? ( diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index ebaaba0b7c4..76752d3c314 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -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 | 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>(() => 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} diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index cf17adfd719..c46d8120aba 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -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. */} setSlugDialog(null)} /> diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx index 1b9e3a2b028..ded043e3d68 100644 --- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -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 | null | undefined onChange: (add: string[], remove: string[]) => void | Promise }): 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 ( !disabled && setOpen(o)}> diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx index a2e64a8e968..1c87fc3fd28 100644 --- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx @@ -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 | null | undefined onChange: (add: string[], remove: string[]) => void | Promise }): 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 ( !disabled && setOpen(o)}> diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index 29534bfdccb..98d3759496b 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -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 | 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 diff --git a/src/renderer/src/store/slices/github-project-row-owner.ts b/src/renderer/src/store/slices/github-project-row-owner.ts index f025e5feca7..936818c8df2 100644 --- a/src/renderer/src/store/slices/github-project-row-owner.ts +++ b/src/renderer/src/store/slices/github-project-row-owner.ts @@ -14,8 +14,12 @@ type RepoOwnerState = { export function settingsForProjectRowOwner( state: RepoOwnerState, owner: string, - repo: string + repo: string, + fallbackSettings: + | Pick + | null + | undefined = state.settings ): Pick | 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 } diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index f111650d9d9..d663bf21338 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -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) + 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) + 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() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index bce6140d89d..0ecfce8df20 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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, '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 = (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( @@ -1852,7 +1863,7 @@ export const createGitHubSlice: StateCreator = (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( @@ -1953,9 +1964,16 @@ export const createGitHubSlice: StateCreator = (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 = (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,