Route GitHub drawer metadata by source host

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 23:57:44 -07:00
co-authored by Orca
parent 76cfbb7e2e
commit 3c0de1ea0e
4 changed files with 90 additions and 15 deletions
@@ -268,3 +268,4 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-12: Completed Tasks account/host metadata by showing repo-backed account labels in source summaries when GitHub/GitLab accounts differ by Host, while preserving host labels, provider identities, Linear workspace names, Jira site names, and unavailable-host status in labels/titles. Verified with source-summary and repo picker tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Continued the automation project-id migration by changing CLI automation show output to foreground explicit `runContext` fields (`runProjectId`, `runHostId`, `projectHostSetupId`, `runRepoId`, `runPath`) and label the old repo id as `legacyRepoId`. The checklist row remains partial because the shared `Automation.projectId` compatibility field still exists. Verified with CLI format/index tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Aligned Automations manual-run disabled states with the backend run-target resolver so saved run contexts now disable Run Now when the project-host setup is missing or not ready, instead of failing after dispatch. Verified with focused automation availability tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [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`.
@@ -167,7 +167,10 @@ import type {
PRCheckDetail,
PRComment
} from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import {
getTaskSourceRuntimeSettings,
type TaskSourceContext
} from '../../../shared/task-source-context'
import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items'
import { translate } from '@/i18n/i18n'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
@@ -456,10 +459,19 @@ function PRReviewersPanel({
reviewRequests: item.reviewRequests
}))
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const settings = useAppStore((s) => s.settings)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const reviewerPanelMountedRef = useRef(true)
@@ -534,11 +546,12 @@ function PRReviewersPanel({
open && reviewSlug ? reviewSlug.owner : null,
open && reviewSlug ? reviewSlug.repo : null,
reviewerSeedUsers.map((user) => user.login),
settings
sourceSettings
)
const reviewerMetadataByPath = useRepoAssignees(
open && !reviewSlug ? repoPath : null,
open && !reviewSlug ? item.repoId : null
open && !reviewSlug ? item.repoId : null,
sourceSettings
)
const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath
const displayItem = { ...item, reviewRequests: localReviewRequests }
@@ -638,7 +651,7 @@ function PRReviewersPanel({
item.reviewRequests !== undefined ||
item.latestReviews !== undefined
const canRequestReview =
!!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment'
!!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment'
const measureReviewerPickerPlacement = useCallback(() => {
const rect = reviewerInputRef.current?.getBoundingClientRect()
@@ -681,7 +694,7 @@ function PRReviewersPanel({
)
return
}
const target = getActiveRuntimeTarget(repoOwnerSettings)
const target = getActiveRuntimeTarget(sourceSettings)
if (target.kind !== 'environment' && !repoPath) {
toast.error(
translate(
@@ -759,7 +772,7 @@ function PRReviewersPanel({
if (logins.length === 0) {
return
}
const target = getActiveRuntimeTarget(repoOwnerSettings)
const target = getActiveRuntimeTarget(sourceSettings)
if (target.kind !== 'environment' && !repoPath) {
toast.error(
translate(
@@ -4545,6 +4558,19 @@ function GHEditSection({
const assigneesItemKey = `${item.repoId}\0${item.id}`
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const { isPending, run } = useImmediateMutation()
// Why: when the dialog opens from a Project view, mutations route through
// *BySlug IPCs and we must keep `projectViewCache` in sync alongside
@@ -4568,16 +4594,18 @@ function GHEditSection({
const slugRepo = projectOrigin?.repo ?? null
const repoLabelsByPath = useRepoLabels(
projectOrigin ? null : repoPath,
projectOrigin ? null : repoId
projectOrigin ? null : repoId,
sourceSettings
)
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo)
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url])
const repoAssigneesByPath = useRepoAssignees(
projectOrigin ? null : repoPath,
projectOrigin ? null : repoId
projectOrigin ? null : repoId,
sourceSettings
)
const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees)
const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, sourceSettings)
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
const hasAttachedWorkspace =
attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const COMPONENT_ROOT = __dirname
function componentSource(relativePath: string): string {
return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8')
}
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start + startPattern.length)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('GitHubItemDialog source host boundaries', () => {
it('routes reviewer metadata and reviewer mutations through the task source context', () => {
const source = componentSource('GitHubItemDialog.tsx')
const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed')
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
expect(section).toContain('useRepoAssigneesBySlug(')
expect(section).toContain('sourceSettings')
expect(section).toContain('useRepoAssignees(')
expect(section).toContain('sourceSettings')
expect(section).toContain('getActiveRuntimeTarget(sourceSettings)')
})
it('routes edit metadata through the same task source as issue mutations', () => {
const source = componentSource('GitHubItemDialog.tsx')
const section = sourceBetween(source, 'function GHEditSection', 'const hasAttachedWorkspace')
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
expect(section).toContain('useRepoLabels(')
expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)')
expect(section).toContain('useRepoAssignees(')
expect(section).toContain('useRepoAssigneesBySlug(')
expect(section).toContain('sourceSettings')
})
})
+7 -4
View File
@@ -31,6 +31,7 @@ type MetadataState<T> = {
type GitHubMetadataOptions = {
runtimeEnvironmentId?: string | null
activeRuntimeEnvironmentId?: string | null
}
// ─── GitHub ────────────────────────────────────────────────
@@ -54,7 +55,8 @@ export function useRepoLabels(
if (!repoPath && !repoId) {
return
}
const runtimeEnvironmentId = options?.runtimeEnvironmentId?.trim() || null
const runtimeEnvironmentId =
options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null
const repoSelector = repoId ?? repoPath ?? ''
// Why: SSH/runtime metadata must not reuse host-path cache entries; the same
// repo id may resolve through a different credential/runtime boundary.
@@ -107,7 +109,7 @@ export function useRepoLabels(
error: err instanceof Error ? err.message : 'Failed to load labels'
}))
})
}, [repoPath, repoId, options?.runtimeEnvironmentId])
}, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId])
return state
}
@@ -128,7 +130,8 @@ export function useRepoAssignees(
if (!repoPath && !repoId) {
return
}
const runtimeEnvironmentId = options?.runtimeEnvironmentId?.trim() || null
const runtimeEnvironmentId =
options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null
const repoSelector = repoId ?? repoPath ?? ''
// Why: SSH/runtime metadata must not reuse host-path cache entries; the same
// repo id may resolve through a different credential/runtime boundary.
@@ -181,7 +184,7 @@ export function useRepoAssignees(
error: err instanceof Error ? err.message : 'Failed to load assignees'
}))
})
}, [repoPath, repoId, options?.runtimeEnvironmentId])
}, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId])
return state
}