From 1763fa21bcaf0d357276166eb45e38b08cc3eb5c Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 6 Apr 2026 16:51:54 -0700 Subject: [PATCH] fix(ownership): Handle ownership rule owners without an id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend can return ownership rule owners with just a name and type but no id — this happens with unresolved codeowner entries. The frontend was treating `id` as always present which broke avatar rendering and the "my teams" filter. Make `id` optional on `OwnershipRuleOwner`, skip avatar stack rendering for owners missing an id, and filter them out of the owner selector. Co-Authored-By: Claude Opus 4.6 --- static/app/types/group.tsx | 11 +++++++- .../ownershipRulesTable.spec.tsx | 26 ++++++++++++++++++ .../projectOwnership/ownershipRulesTable.tsx | 27 ++++++++++++++----- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx index 8cd5a8d7fde37e..7a837dd4ec4863 100644 --- a/static/app/types/group.tsx +++ b/static/app/types/group.tsx @@ -523,9 +523,18 @@ type SuggestedOwner = { type: SuggestedOwnerReason; }; +/** + * Mirrors OwnershipRuleOwnerResponse from the backend + */ +interface OwnershipRuleOwner { + name: string; + type: 'user' | 'team'; + id?: string; +} + export interface ParsedOwnershipRule { matcher: {pattern: string; type: string}; - owners: Actor[]; + owners: OwnershipRuleOwner[]; } export type IssueOwnership = { diff --git a/static/app/views/settings/project/projectOwnership/ownershipRulesTable.spec.tsx b/static/app/views/settings/project/projectOwnership/ownershipRulesTable.spec.tsx index e6ef8ee998bac5..31fcf86747c448 100644 --- a/static/app/views/settings/project/projectOwnership/ownershipRulesTable.spec.tsx +++ b/static/app/views/settings/project/projectOwnership/ownershipRulesTable.spec.tsx @@ -168,6 +168,32 @@ describe('OwnershipRulesTable', () => { expect(screen.getByRole('button', {name: 'Everyone'})).toBeInTheDocument(); }); + it('should render owners without an id', async () => { + const rules: ParsedOwnershipRule[] = [ + { + matcher: {pattern: 'src/app/*', type: 'path'}, + owners: [{type: 'user', name: 'unresolved-user@example.com'}], + }, + { + matcher: {pattern: 'src/utils/*', type: 'path'}, + owners: [ + {type: 'user', id: user1.id, name: user1.name}, + {type: 'team', name: 'backend'}, + ], + }, + ]; + + render(); + + // Clear the "My Teams" filter to see all rules + await userEvent.click(screen.getByRole('button', {name: 'My Teams'})); + await userEvent.click(screen.getByRole('button', {name: 'Clear'})); + + expect(screen.getByText('src/app/*')).toBeInTheDocument(); + expect(screen.getByText('unresolved-user@example.com')).toBeInTheDocument(); + expect(screen.getByText('src/utils/*')).toBeInTheDocument(); + }); + it('should paginate results', async () => { const owners: Actor[] = [{type: 'user', id: user1.id, name: user1.name}]; const rules: ParsedOwnershipRule[] = new Array(100).fill(0).map((_, i) => ({ diff --git a/static/app/views/settings/project/projectOwnership/ownershipRulesTable.tsx b/static/app/views/settings/project/projectOwnership/ownershipRulesTable.tsx index bc14bcda65d4a0..bd66c67f659339 100644 --- a/static/app/views/settings/project/projectOwnership/ownershipRulesTable.tsx +++ b/static/app/views/settings/project/projectOwnership/ownershipRulesTable.tsx @@ -16,8 +16,10 @@ import {IconChevron} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import {MemberListStore} from 'sentry/stores/memberListStore'; import {TeamStore} from 'sentry/stores/teamStore'; +import type {Actor} from 'sentry/types/core'; import type {ParsedOwnershipRule} from 'sentry/types/group'; import type {CodeOwner} from 'sentry/types/integrations'; +import {defined} from 'sentry/utils'; import {useTeams} from 'sentry/utils/useTeams'; import {useUser} from 'sentry/utils/useUser'; import {OwnershipOwnerFilter} from 'sentry/views/settings/project/projectOwnership/ownershipOwnerFilter'; @@ -84,6 +86,10 @@ export function OwnershipRulesTable({ const myTeams = useMemo(() => { const memberTeamsIds = teams.filter(team => team.isMember).map(team => team.id); return allActors.filter(actor => { + if (!defined(actor.id)) { + return false; + } + if (actor.type === 'user') { return actor.id === user.id; } @@ -140,7 +146,7 @@ export function OwnershipRulesTable({ defined(actor.id))} selectedTeams={selectedActors ?? []} handleChangeFilter={handleChangeFilter} isMyTeams={ @@ -166,7 +172,11 @@ export function OwnershipRulesTable({ emptyMessage={t('No ownership rules found')} > {chunkedRules[page]?.map((rule, index) => { + const hasUnknownOwners = rule.owners.some(owner => !defined(owner.id)); const ownerNames = rule.owners.map(owner => { + if (!owner.id) { + return owner.name; + } if (owner.type === 'team') { const team = TeamStore.getById(owner.id); return team?.slug ? `#${team.slug}` : owner.name; @@ -185,12 +195,15 @@ export function OwnershipRulesTable({ {rule.matcher.pattern} - + {/* Avoid attempting to render the avatar stack if there are broken owners */} + {!hasUnknownOwners && ( + + )} {name} {rule.owners.length > 1 &&