diff --git a/app/components/layout/app-sidebar.tsx b/app/components/layout/app-sidebar.tsx index ade8a278..424b93cd 100644 --- a/app/components/layout/app-sidebar.tsx +++ b/app/components/layout/app-sidebar.tsx @@ -10,6 +10,7 @@ import { type MemberRole, isOrgAdmin } from '~/app/libs/member-role' import { NavGroup } from './nav-group' import { NavUser } from './nav-user' import { OrgSwitcher } from './org-switcher' +import { TeamSwitcher } from './team-switcher' interface AppSidebarProps extends React.ComponentProps { user: { @@ -30,6 +31,8 @@ interface AppSidebarProps extends React.ComponentProps { slug: string | null }> memberRole: MemberRole + teams: Array<{ id: string; name: string }> + selectedTeamId: string | null } export function AppSidebar({ @@ -37,6 +40,8 @@ export function AppSidebar({ organization, organizations, memberRole, + teams, + selectedTeamId, ...props }: AppSidebarProps) { const orgSlug = organization.slug ?? organization.id @@ -49,6 +54,7 @@ export function AppSidebar({ + {navGroups.map((group) => ( diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx new file mode 100644 index 00000000..d45270b5 --- /dev/null +++ b/app/components/layout/team-switcher.tsx @@ -0,0 +1,64 @@ +import { UsersIcon } from 'lucide-react' +import { useRevalidator } from 'react-router' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/app/components/ui/select' +import { TEAM_COOKIE_MAX_AGE, TEAM_COOKIE_NAME } from '~/app/libs/team-cookie' + +interface Team { + id: string + name: string +} + +function setTeamCookie(teamId: string) { + // biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx + document.cookie = `${TEAM_COOKIE_NAME}=${encodeURIComponent(teamId)}; path=/; max-age=${TEAM_COOKIE_MAX_AGE}; samesite=lax` +} + +function clearTeamCookie() { + // biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx + document.cookie = `${TEAM_COOKIE_NAME}=; path=/; max-age=0; samesite=lax` +} + +export function TeamSwitcher({ + teams, + selectedTeamId, +}: { + teams: Team[] + selectedTeamId: string | null +}) { + const revalidator = useRevalidator() + + if (teams.length === 0) return null + + return ( + + ) +} diff --git a/app/libs/team-cookie.server.ts b/app/libs/team-cookie.server.ts new file mode 100644 index 00000000..abd93191 --- /dev/null +++ b/app/libs/team-cookie.server.ts @@ -0,0 +1,14 @@ +import { TEAM_COOKIE_NAME } from '~/app/libs/team-cookie' + +export function getSelectedTeam(request: Request): string | null { + const cookieHeader = request.headers.get('Cookie') ?? '' + const prefix = `${TEAM_COOKIE_NAME}=` + const match = cookieHeader.split('; ').find((c) => c.startsWith(prefix)) + if (!match) return null + const rawValue = match.slice(prefix.length) + try { + return decodeURIComponent(rawValue) || null + } catch { + return null + } +} diff --git a/app/libs/team-cookie.ts b/app/libs/team-cookie.ts new file mode 100644 index 00000000..2f48ced2 --- /dev/null +++ b/app/libs/team-cookie.ts @@ -0,0 +1,2 @@ +export const TEAM_COOKIE_NAME = 'selected_team' +export const TEAM_COOKIE_MAX_AGE = 2592000 // 30 days diff --git a/app/middleware/context.ts b/app/middleware/context.ts index 27f8338f..38ceb98e 100644 --- a/app/middleware/context.ts +++ b/app/middleware/context.ts @@ -3,3 +3,5 @@ import type { OrgContext } from '~/app/libs/auth.server' export const orgContext = createContext() export const timezoneContext = createContext() +/** Resolved team ID from URL ?team or cookie, validated against org's teams list. null = all teams. */ +export const teamContext = createContext() diff --git a/app/middleware/org-member.ts b/app/middleware/org-member.ts index b21b1029..9ef9ae54 100644 --- a/app/middleware/org-member.ts +++ b/app/middleware/org-member.ts @@ -1,7 +1,9 @@ import { requireOrgMember } from '~/app/libs/auth.server' +import { getSelectedTeam } from '~/app/libs/team-cookie.server' import { getOrganizationTimezone } from '~/app/libs/timezone.server' +import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import type { Route } from '../routes/$orgSlug/+types/_layout' -import { orgContext, timezoneContext } from './context' +import { orgContext, teamContext, timezoneContext } from './context' export const orgMemberMiddleware: Route.MiddlewareFunction = async ( { request, params, context }, @@ -11,5 +13,16 @@ export const orgMemberMiddleware: Route.MiddlewareFunction = async ( context.set(orgContext, org) const timezone = await getOrganizationTimezone(org.organization.id) context.set(timezoneContext, timezone) + + const url = new URL(request.url) + const teams = await listTeams(org.organization.id) + const teamFromUrl = url.searchParams.get('team') + const teamFromCookie = getSelectedTeam(request) + const selectedTeamId = + [teamFromUrl, teamFromCookie].find( + (id) => id && teams.some((t) => t.id === id), + ) ?? null + context.set(teamContext, selectedTeamId) + return next() } diff --git a/app/routes/$orgSlug/_layout.tsx b/app/routes/$orgSlug/_layout.tsx index ba329d69..15087f05 100644 --- a/app/routes/$orgSlug/_layout.tsx +++ b/app/routes/$orgSlug/_layout.tsx @@ -6,8 +6,13 @@ import { SidebarProvider } from '~/app/components/ui/sidebar' import { useBreadcrumbs } from '~/app/hooks/use-breadcrumbs' import { getUserOrganizations } from '~/app/libs/auth.server' import { cn } from '~/app/libs/utils' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { orgMemberMiddleware } from '~/app/middleware/org-member' +import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import type { Route } from './+types/_layout' export interface RouteHandle { @@ -38,6 +43,7 @@ export const middleware = [orgMemberMiddleware] export const loader = async ({ request, context }: Route.LoaderArgs) => { const { user, organization, membership } = context.get(orgContext) const timezone = context.get(timezoneContext) + const selectedTeamId = context.get(teamContext) const organizations = await getUserOrganizations(user.id) const cookieHeader = request.headers.get('Cookie') ?? '' @@ -47,6 +53,8 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ?.split('=')[1] const defaultOpen = sidebarState !== 'false' + const teams = await listTeams(organization.id) + return { user, organization, @@ -54,11 +62,21 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { organizations, defaultOpen, timezone, + teams, + selectedTeamId, } } export default function OrgLayout({ - loaderData: { user, organization, membership, organizations, defaultOpen }, + loaderData: { + user, + organization, + membership, + organizations, + defaultOpen, + teams, + selectedTeamId, + }, }: Route.ComponentProps) { const { Breadcrumbs } = useBreadcrumbs() const matches = useMatches() @@ -74,6 +92,8 @@ export default function OrgLayout({ organization={organization} organizations={organizations} memberRole={membership.role} + teams={teams} + selectedTeamId={selectedTeamId} />
{ const timezone = context.get(timezoneContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') || undefined + const teamParam = context.get(teamContext) ?? undefined const periodParam = url.searchParams.get('period') const periodMonths = periodParam === 'all' @@ -80,7 +82,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sortOrder = (url.searchParams.get('sort_order') as 'asc' | 'desc') || 'desc' - const [feedbackResult, summary, teams] = await Promise.all([ + const [feedbackResult, summary] = await Promise.all([ listFilteredFeedbacks({ organizationId: organization.id, teamId: teamParam, @@ -95,20 +97,18 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { teamId: teamParam, sinceDate, }), - listTeams(organization.id), ]) return { feedbacks: feedbackResult.data, pagination: feedbackResult.pagination, summary, - teams, periodMonths, } } export default function FeedbacksPage({ - loaderData: { feedbacks, pagination, summary, teams, periodMonths }, + loaderData: { feedbacks, pagination, summary, periodMonths }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() const { sort, updateSort } = useDataTableState() @@ -152,7 +152,6 @@ export default function FeedbacksPage({ ))} - diff --git a/app/routes/$orgSlug/analysis/inventory/index.tsx b/app/routes/$orgSlug/analysis/inventory/index.tsx index 1549892d..a4890fe8 100644 --- a/app/routes/$orgSlug/analysis/inventory/index.tsx +++ b/app/routes/$orgSlug/analysis/inventory/index.tsx @@ -6,7 +6,6 @@ import { PageHeaderHeading, PageHeaderTitle, } from '~/app/components/layout/page-header' -import { TeamFilter } from '~/app/components/team-filter' import { HStack, Label, Stack } from '~/app/components/ui' import { Select, @@ -18,8 +17,11 @@ import { import { Switch } from '~/app/components/ui/switch' import { calcSinceDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' -import { orgContext, timezoneContext } from '~/app/middleware/context' -import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { getOrgCachedData } from '~/app/services/cache.server' import { OpenPRInventoryChart } from './+components/open-pr-inventory-chart' import { aggregateWeeklyOpenPRInventory } from './+functions/aggregate' @@ -44,7 +46,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const timezone = context.get(timezoneContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') || null + const teamParam = context.get(teamContext) const periodParam = url.searchParams.get('period') || null const periodMonths = VALID_PERIODS.includes( Number(periodParam) as (typeof VALID_PERIODS)[number], @@ -59,10 +61,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const now = dayjs.utc().toISOString() - const teams = await listTeams(organization.id) - - // unreviewedOnly はクライアント集計で判定するため、キャッシュキーには含めない - // (同じ raw データから unreviewedOnly ON/OFF 両方の集計が可能) const cacheKey = `inventory:${teamParam ?? 'all'}:${periodMonths}:${excludeBots ? 'exclude-bots' : 'include-bots'}` const FIVE_MINUTES = 5 * 60 * 1000 @@ -82,7 +80,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ) return { - teams, rawRows, sinceDate, now, @@ -97,7 +94,6 @@ export const clientLoader = async ({ serverLoader, }: Route.ClientLoaderArgs) => { const { - teams, rawRows, sinceDate, now, @@ -108,7 +104,6 @@ export const clientLoader = async ({ } = await serverLoader() return { - teams, inventory: aggregateWeeklyOpenPRInventory( rawRows, sinceDate, @@ -137,7 +132,7 @@ export function HydrateFallback() { } export default function InventoryPage({ - loaderData: { teams, inventory, periodMonths, excludeBots, unreviewedOnly }, + loaderData: { inventory, periodMonths, excludeBots, unreviewedOnly }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() @@ -176,7 +171,6 @@ export default function InventoryPage({ ))} - { const timezone = context.get(timezoneContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') + const teamParam = context.get(teamContext) const periodParam = url.searchParams.get('period') const VALID_PERIODS = [1, 3, 6, 12] const periodMonths = @@ -65,8 +67,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sinceDate = calcSinceDate(periodMonths, timezone) - const teams = await listTeams(organization.id) - const cacheKey = `reviews:${teamParam ?? 'all'}:${periodMonths}` const FIVE_MINUTES = 5 * 60 * 1000 @@ -83,7 +83,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ) return { - teams, queueHistoryRaw, wipCycleRaw, prSizesRaw, @@ -95,19 +94,12 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { export const clientLoader = async ({ serverLoader, }: Route.ClientLoaderArgs) => { - const { - teams, - queueHistoryRaw, - wipCycleRaw, - prSizesRaw, - sinceDate, - periodMonths, - } = await serverLoader() + const { queueHistoryRaw, wipCycleRaw, prSizesRaw, sinceDate, periodMonths } = + await serverLoader() const wipCounts = computeWipCounts(wipCycleRaw) return { - teams, queueTrend: aggregateWeeklyQueueTrend( queueHistoryRaw.filter( (r): r is typeof r & { requestedAt: string } => r.requestedAt !== null, @@ -138,7 +130,6 @@ export function HydrateFallback() { export default function ReviewsPage({ loaderData: { - teams, queueTrend, wipCycle, wipCycleLabeled, @@ -179,7 +170,6 @@ export default function ReviewsPage({ ))} - diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index 6f4c8573..6a93ba40 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -3,12 +3,10 @@ import { useSearchParams } from 'react-router' import { AppDataTable } from '~/app/components' import { PageHeader, - PageHeaderActions, PageHeaderDescription, PageHeaderHeading, PageHeaderTitle, } from '~/app/components/layout/page-header' -import { TeamFilter } from '~/app/components/team-filter' import { Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, @@ -18,8 +16,11 @@ import WeeklyCalendar from '~/app/components/week-calendar' import { useTimezone } from '~/app/hooks/use-timezone' import { getEndOfWeek, getStartOfWeek, parseDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' -import { orgContext, timezoneContext } from '~/app/middleware/context' -import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { DiffBadge } from '../+components/diff-badge' import { StatCard } from '../+components/stat-card' import { calcStats } from '../+functions/calc-stats' @@ -43,7 +44,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const url = new URL(request.url) const fromParam = url.searchParams.get('from') const toParam = url.searchParams.get('to') - const teamParam = url.searchParams.get('team') + const teamParam = context.get(teamContext) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' let from: dayjs.Dayjs @@ -59,8 +60,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const prevFrom = from.subtract(7, 'day') const prevTo = to.subtract(7, 'day') - const [teams, pullRequests, prevPullRequests] = await Promise.all([ - listTeams(organization.id), + const [pullRequests, prevPullRequests] = await Promise.all([ getDeployedPullRequestReport( organization.id, from.utc().toISOString(), @@ -88,7 +88,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { objective, ...stats, prev, - teams, businessDaysOnly, } } @@ -102,7 +101,6 @@ export default function DeployedPage({ achievementRate, median, prev, - teams, businessDaysOnly, }, params: { orgSlug }, @@ -123,9 +121,6 @@ export default function DeployedPage({ Pull requests deployed this week with cycle time metrics. - - - { const url = new URL(request.url) const fromParam = url.searchParams.get('from') const toParam = url.searchParams.get('to') - const teamParam = url.searchParams.get('team') + const teamParam = context.get(teamContext) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' let from: dayjs.Dayjs @@ -59,8 +60,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const prevFrom = from.subtract(7, 'day') const prevTo = to.subtract(7, 'day') - const [teams, pullRequests, prevPullRequests] = await Promise.all([ - listTeams(organization.id), + const [pullRequests, prevPullRequests] = await Promise.all([ getMergedPullRequestReport( organization.id, from.utc().toISOString(), @@ -88,7 +88,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { objective, ...stats, prev, - teams, businessDaysOnly, } } @@ -102,7 +101,6 @@ export default function OrganizationIndex({ achievementRate, median, prev, - teams, businessDaysOnly, }, params: { orgSlug }, @@ -123,9 +121,6 @@ export default function OrganizationIndex({ Pull requests merged this week with cycle time metrics. - - - { const { organization } = context.get(orgContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') + const teamParam = context.get(teamContext) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' - const teams = await listTeams(organization.id) - const pullRequests = await getOngoingPullRequestReport( organization.id, null, @@ -56,11 +51,11 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { .filter((v): v is number => v !== null) const median = calcMedian(ages) - return { pullRequests, median, teams, businessDaysOnly } + return { pullRequests, median, businessDaysOnly } } export default function OngoingPage({ - loaderData: { pullRequests, median, teams, businessDaysOnly }, + loaderData: { pullRequests, median, businessDaysOnly }, params: { orgSlug }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() @@ -79,9 +74,6 @@ export default function OngoingPage({ Pull requests currently in progress. - - - { +export const loader = async ({ context }: Route.LoaderArgs) => { const { organization } = context.get(orgContext) - const url = new URL(request.url) - const teamParam = url.searchParams.get('team') + const teamParam = context.get(teamContext) const teams = await listTeams(organization.id) const selectedTeam = teamParam @@ -39,7 +36,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ]) return { - teams, openPRs, pendingReviews, personalLimit, @@ -49,10 +45,9 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { export const clientLoader = async ({ serverLoader, }: Route.ClientLoaderArgs) => { - const { teams, openPRs, pendingReviews, personalLimit } = await serverLoader() + const { openPRs, pendingReviews, personalLimit } = await serverLoader() return { - teams, teamStacks: aggregateTeamStacks(openPRs, pendingReviews, personalLimit), } } @@ -74,7 +69,7 @@ export function HydrateFallback() { } export default function ReviewStacksPage({ - loaderData: { teams, teamStacks }, + loaderData: { teamStacks }, }: Route.ComponentProps) { return ( @@ -85,9 +80,6 @@ export default function ReviewStacksPage({ Monitor review workload balance across team members. - - - diff --git a/scripts/poc-repo-add-api.ts b/scripts/poc-repo-add-api.ts deleted file mode 100644 index def09c37..00000000 --- a/scripts/poc-repo-add-api.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * PoC: リポジトリ追加画面で使う API が Installation Token で動くか検証 - */ -import { createAppAuth } from '@octokit/auth-app' -import fs from 'node:fs' -import { Octokit } from 'octokit' - -const privateKey = fs.readFileSync( - process.env.GITHUB_APP_PRIVATE_KEY_PATH!, - 'utf-8', -) -const octokit = new Octokit({ - authStrategy: createAppAuth, - auth: { - appId: Number(process.env.GITHUB_APP_ID), - privateKey, - installationId: Number(process.env.GITHUB_APP_INSTALLATION_ID), - }, -}) - -async function main() { - // Test 1: GET /user/repos (現在のリポ追加画面が使ってる API) - console.log('=== GET /user/repos (PAT用、App で動くか?) ===') - try { - const res = await octokit.rest.repos.listForAuthenticatedUser({ - per_page: 5, - affiliation: 'owner,collaborator,organization_member', - }) - console.log('✅ 動いた:', res.data.length, 'repos') - for (const r of res.data) console.log(' ', r.full_name) - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } - - // Test 2: GET /installation/repositories (App 用の代替) - console.log('\n=== GET /installation/repositories (App用の代替) ===') - try { - const res = await octokit.rest.apps.listReposAccessibleToInstallation({ - per_page: 100, - }) - console.log('✅ 動いた:', res.data.total_count, 'repos') - for (const r of res.data.repositories.slice(0, 5)) - console.log(' ', r.full_name) - - // owner 抽出(getUniqueOwners の代替) - const owners = [...new Set(res.data.repositories.map((r) => r.owner.login))] - console.log(' → owners:', owners.join(', ')) - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } - - // Test 3: Search API (現在のリポ検索) - console.log('\n=== Search API (キーワード検索) ===') - try { - const res = await octokit.rest.search.repos({ - q: 'user:techtalkjp', - per_page: 5, - }) - console.log('✅ 動いた:', res.data.total_count, 'repos') - for (const r of res.data.items.slice(0, 5)) console.log(' ', r.full_name) - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } - - // Test 4: Search API でインストール外のリポが見えないか確認 - console.log('\n=== Search API (スコープ外 org の検索) ===') - try { - const res = await octokit.rest.search.repos({ - q: 'user:facebook react', - per_page: 3, - }) - if (res.data.total_count > 0) { - console.log( - '⚠️ スコープ外のリポが見える:', - res.data.total_count, - 'repos', - ) - for (const r of res.data.items.slice(0, 3)) console.log(' ', r.full_name) - console.log( - ' → Search API は Installation Token でもスコープされない。GET /installation/repositories + フィルタが必要', - ) - } else { - console.log('✅ スコープ外のリポは見えない') - } - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } -} - -main().catch(console.error)