From adf8412afef85ddd96d8d9947fc790630e7a936d Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 20 Mar 2026 14:57:35 -0700 Subject: [PATCH 1/7] ref(nav) add initial pageframe top layout component (#111177) Adds the initial top frame layout to the top of the page and wires up some basic styling https://github.com/user-attachments/assets/f47465d5-cf9e-467a-a550-a7250e2c5340 --------- Co-authored-by: Claude Sonnet 4.6 --- static/app/components/footer.tsx | 16 +- static/app/components/layouts/thirds.tsx | 42 ++++- .../views/navigation/index.desktop.spec.tsx | 165 +++++++++++++++--- .../views/navigation/index.mobile.spec.tsx | 22 ++- static/app/views/navigation/index.tsx | 15 +- .../app/views/navigation/mobileNavigation.tsx | 4 +- .../views/navigation/primary/components.tsx | 35 ++-- static/app/views/navigation/primary/index.tsx | 3 +- .../navigation/secondary/components.spec.tsx | 27 ++- .../views/navigation/secondary/components.tsx | 17 +- .../navigation/secondaryNavigationContext.tsx | 3 +- static/app/views/navigation/topBar.tsx | 69 ++++++++ .../navigation/useHasPageFrameFeature.tsx | 6 + static/app/views/organizationLayout/index.tsx | 13 +- .../settings/components/settingsLayout.tsx | 9 - static/app/views/sharedGroupDetails/index.tsx | 11 +- 16 files changed, 351 insertions(+), 106 deletions(-) create mode 100644 static/app/views/navigation/topBar.tsx create mode 100644 static/app/views/navigation/useHasPageFrameFeature.tsx diff --git a/static/app/components/footer.tsx b/static/app/components/footer.tsx index 82eff4f107cebe..98551a79f63a2a 100644 --- a/static/app/components/footer.tsx +++ b/static/app/components/footer.tsx @@ -1,4 +1,4 @@ -import {Fragment} from 'react'; +import {Fragment, useContext} from 'react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -14,6 +14,8 @@ import {ConfigStore} from 'sentry/stores/configStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import {pulsingIndicatorStyles} from 'sentry/styles/pulsingIndicator'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {SecondaryNavigationContext} from 'sentry/views/navigation/secondaryNavigationContext'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; type SentryLogoProps = SVGIconProps & { /** @@ -41,8 +43,18 @@ function BaseFooter({className}: Props) { const {state: appState} = useFrontendVersion(); const organization = useOrganization({allowNull: true}); + const secondaryNavigation = useContext(SecondaryNavigationContext); + const hasPageFrame = useHasPageFrameFeature(); + return ( - + {isSelfHosted && ( diff --git a/static/app/components/layouts/thirds.tsx b/static/app/components/layouts/thirds.tsx index 3cecec228663eb..39d78237619e95 100644 --- a/static/app/components/layouts/thirds.tsx +++ b/static/app/components/layouts/thirds.tsx @@ -1,18 +1,46 @@ -import type {HTMLAttributes} from 'react'; +import {useContext, type HTMLAttributes} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import {Container} from '@sentry/scraps/layout'; +import {Container, Stack, type FlexProps} from '@sentry/scraps/layout'; import {Tabs} from '@sentry/scraps/tabs'; +import {SecondaryNavigationContext} from 'sentry/views/navigation/secondaryNavigationContext'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; + /** * Main container for a page. */ -export const Page = styled('main')<{withPadding?: boolean}>` - display: flex; - flex-direction: column; - flex: 1; - ${p => p.withPadding && `padding: ${p.theme.space['2xl']} ${p.theme.space['3xl']}`}; +export function Page(props: FlexProps<'main'> & {withPadding?: boolean}) { + const hasPageFrame = useHasPageFrameFeature(); + const secondaryNavigation = useContext(SecondaryNavigationContext); + + const {withPadding, ...rest} = props; + + if (hasPageFrame) { + return ( + + ); + } + + return ( + + ); +} + +const StyledPageFrameStack = styled(Stack)` + > :first-child { + border-top-left-radius: ${p => p.theme.radius.lg}; + } `; /** diff --git a/static/app/views/navigation/index.desktop.spec.tsx b/static/app/views/navigation/index.desktop.spec.tsx index 405d5ff0de7533..fd0b8e8e0d3a66 100644 --- a/static/app/views/navigation/index.desktop.spec.tsx +++ b/static/app/views/navigation/index.desktop.spec.tsx @@ -15,6 +15,7 @@ import { import {ConfigStore} from 'sentry/stores/configStore'; import {Navigation} from 'sentry/views/navigation'; import {NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY} from 'sentry/views/navigation/constants'; +import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; const ALL_AVAILABLE_FEATURES = [ 'insight-modules', @@ -128,10 +129,15 @@ describe('desktop navigation', () => { beforeEach(setupMocks); it('renders user-only navigation when there is no organization', () => { - render(, { - organization: null, - initialRouterConfig: {location: {pathname: '/'}}, - }); + render( + + + , + { + organization: null, + initialRouterConfig: {location: {pathname: '/'}}, + } + ); // Primary nav sidebar renders but contains no nav links const primaryNav = screen.getByRole('navigation', {name: 'Primary Navigation'}); @@ -146,7 +152,9 @@ describe('desktop navigation', () => { describe('HTML structure', () => { it('primary navigation renders a nav landmark with a list of links (nav > ul > li > a)', () => { render( - , + + + , navigationContext({ organization: {features: ALL_AVAILABLE_FEATURES}, }) @@ -163,7 +171,12 @@ describe('desktop navigation', () => { }); it('secondary navigation renders a nav landmark with a list of links (nav > ul > li > a)', () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); const secondaryNav = screen.getByRole('navigation', {name: 'Secondary Navigation'}); within(secondaryNav).getAllByRole('list').forEach(assertValidListHTML); @@ -178,7 +191,12 @@ describe('desktop navigation', () => { describe('accessibility', () => { it('renders a skip link', () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); expect( screen.getByRole('link', {name: 'Skip to main content'}) ).toBeInTheDocument(); @@ -186,7 +204,9 @@ describe('desktop navigation', () => { it('primary navigation links have correct accessible names and hrefs', () => { render( - , + + + , navigationContext({ organization: {features: [...ALL_AVAILABLE_FEATURES, 'workflow-engine-ui']}, }) @@ -211,7 +231,12 @@ describe('desktop navigation', () => { }); it('primary navigation marks exactly one link as active for the current route', () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); const primaryNav = screen.getByRole('navigation', {name: 'Primary Navigation'}); const links = within(primaryNav).getAllByRole('link'); @@ -224,7 +249,12 @@ describe('desktop navigation', () => { }); it('secondary navigation marks exactly one link as active for the current route', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); const secondaryNav = screen.getByRole('navigation', {name: 'Secondary Navigation'}); await within(secondaryNav).findByRole('link', {name: /Starred View 1/}); @@ -246,7 +276,9 @@ describe('desktop navigation', () => { route?: string ) { const {unmount} = render( - , + + + , navigationContext({ organization: {features: ALL_AVAILABLE_FEATURES}, initialRouterConfig: {location: {pathname}, route: route ?? ''}, @@ -326,7 +358,9 @@ describe('desktop navigation', () => { jest.spyOn(Sentry.logger, 'warn'); render( - , + + + , navigationContext({ initialRouterConfig: {location: {pathname: '/unknown-route/'}}, }) @@ -347,7 +381,9 @@ describe('desktop navigation', () => { it('shows admin secondary navigation on /manage/ routes', async () => { render( - , + + + , navigationContext({ initialRouterConfig: {location: {pathname: '/manage/'}}, }) @@ -416,7 +452,12 @@ describe('desktop navigation', () => { describe('interactions', () => { describe('secondary Navigation', () => { it('shows content for the active primary group', () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); const secondaryNav = screen.getByRole('navigation', { name: 'Secondary Navigation', @@ -427,7 +468,12 @@ describe('desktop navigation', () => { }); it('can collapse a section', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); const secondaryNav = screen.getByRole('navigation', { name: 'Secondary Navigation', @@ -448,7 +494,12 @@ describe('desktop navigation', () => { }); it('can expand a collapsed section', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); const secondaryNav = screen.getByRole('navigation', { name: 'Secondary Navigation', @@ -474,7 +525,9 @@ describe('desktop navigation', () => { it('shows organization and account settings links on settings routes', () => { render( - , + + + , navigationContext({ initialRouterConfig: {location: {pathname: '/settings/organization/'}}, }) @@ -504,7 +557,12 @@ describe('desktop navigation', () => { describe('secondary sidebar', () => { it('can collapse the sidebar', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); await userEvent.click(screen.getByRole('button', {name: 'Collapse'})); @@ -512,7 +570,12 @@ describe('desktop navigation', () => { }); it('can expand a collapsed sidebar', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); await userEvent.click(screen.getByRole('button', {name: 'Collapse'})); await userEvent.click(screen.getByRole('button', {name: 'Expand'})); @@ -524,7 +587,12 @@ describe('desktop navigation', () => { describe('persistence', () => { it('defaults to expanded when no localStorage key exists', () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); expect( screen.queryByTestId('collapsed-secondary-sidebar') @@ -535,7 +603,12 @@ describe('desktop navigation', () => { it('restores collapsed state from localStorage on mount', async () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); - render(, navigationContext()); + render( + + + , + navigationContext() + ); expect( await screen.findByTestId('collapsed-secondary-sidebar') @@ -544,7 +617,12 @@ describe('desktop navigation', () => { }); it('persists collapsed state to localStorage when collapsing', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); await userEvent.click(screen.getByRole('button', {name: 'Collapse'})); @@ -556,7 +634,12 @@ describe('desktop navigation', () => { it('does not update localStorage when peek is triggered by hover', async () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); - render(, navigationContext()); + render( + + + , + navigationContext() + ); await screen.findByTestId('collapsed-secondary-sidebar'); @@ -578,7 +661,12 @@ describe('desktop navigation', () => { it('persists expanded state to localStorage when expanding', async () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); - render(, navigationContext()); + render( + + + , + navigationContext() + ); await screen.findByTestId('collapsed-secondary-sidebar'); @@ -594,7 +682,12 @@ describe('desktop navigation', () => { it('shows the sidebar on hover when collapsed', async () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); - render(, navigationContext()); + render( + + + , + navigationContext() + ); await screen.findByTestId('collapsed-secondary-sidebar'); expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute( @@ -617,7 +710,12 @@ describe('desktop navigation', () => { it('hides the sidebar on mouse leave when collapsed', async () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); - render(, navigationContext()); + render( + + + , + navigationContext() + ); await screen.findByTestId('collapsed-secondary-sidebar'); @@ -645,7 +743,12 @@ describe('desktop navigation', () => { it('shows the sidebar when a nav element receives keyboard focus while collapsed', async () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); - render(, navigationContext()); + render( + + + , + navigationContext() + ); await screen.findByTestId('collapsed-secondary-sidebar'); expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute( @@ -667,7 +770,9 @@ describe('desktop navigation', () => { it('shows hovered group content when sidebar is expanded', async () => { render( - , + + + , navigationContext({ initialRouterConfig: { location: {pathname: '/organizations/org-slug/issues/'}, @@ -693,7 +798,9 @@ describe('desktop navigation', () => { localStorage.setItem(NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true'); render( - , + + + , navigationContext({ initialRouterConfig: { location: {pathname: '/organizations/org-slug/issues/'}, diff --git a/static/app/views/navigation/index.mobile.spec.tsx b/static/app/views/navigation/index.mobile.spec.tsx index e958c1ba1b335b..77563d9e30d5ba 100644 --- a/static/app/views/navigation/index.mobile.spec.tsx +++ b/static/app/views/navigation/index.mobile.spec.tsx @@ -12,6 +12,7 @@ import {mockMatchMedia} from 'sentry-test/utils'; import {ConfigStore} from 'sentry/stores/configStore'; import {Navigation} from 'sentry/views/navigation'; +import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; const ALL_AVAILABLE_FEATURES = [ 'insight-modules', @@ -97,7 +98,12 @@ describe('mobile navigation', () => { describe('accessibility', () => { it('does not render a skip link', () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); expect( screen.queryByRole('link', {name: 'Skip to main content'}) ).not.toBeInTheDocument(); @@ -106,7 +112,12 @@ describe('mobile navigation', () => { describe('secondary nav route inference', () => { it('opens secondary navigation by default when on a sub-view', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); await userEvent.click(screen.getByRole('button', {name: 'Open main menu'})); @@ -116,7 +127,12 @@ describe('mobile navigation', () => { }); it('clicking back navigates to primary navigation', async () => { - render(, navigationContext()); + render( + + + , + navigationContext() + ); await userEvent.click(screen.getByRole('button', {name: 'Open main menu'})); diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index dc3899730382d5..30a4723178d25f 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -21,10 +21,7 @@ import { } from 'sentry/views/navigation/navigationTour'; import {PrimaryNavigation} from 'sentry/views/navigation/primary/components'; import {UserDropdown} from 'sentry/views/navigation/primary/userDropdown'; -import { - PrimaryNavigationContextProvider, - usePrimaryNavigation, -} from 'sentry/views/navigation/primaryNavigationContext'; +import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; import {useResetActiveNavigationGroup} from 'sentry/views/navigation/useResetActiveNavigationGroup'; function UserAndOrganizationNavigation() { @@ -98,12 +95,10 @@ export function Navigation() { } return ( - - - - - - + + + + ); } diff --git a/static/app/views/navigation/mobileNavigation.tsx b/static/app/views/navigation/mobileNavigation.tsx index 3d4d5ad782a067..55fe0971f2c384 100644 --- a/static/app/views/navigation/mobileNavigation.tsx +++ b/static/app/views/navigation/mobileNavigation.tsx @@ -21,6 +21,7 @@ import {PrimaryNavigationItems} from 'sentry/views/navigation/primary/index'; import {OrganizationDropdown} from 'sentry/views/navigation/primary/organizationDropdown'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; import {SecondaryNavigationContent} from 'sentry/views/navigation/secondary/content'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; export function MobileNavigation() { const theme = useTheme(); @@ -153,8 +154,7 @@ interface NavigationOverlayPortalProps { function NavigationOverlayPortal(props: NavigationOverlayPortalProps) { const theme = useTheme(); const ref = useRef(null); - const organization = useOrganization({allowNull: true}); - const hasPageFrame = organization?.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); useOnClickOutside(ref, e => { // Without this check the menu will reopen when the click event triggers diff --git a/static/app/views/navigation/primary/components.tsx b/static/app/views/navigation/primary/components.tsx index d2b4ecf87a1a89..675212c9caa650 100644 --- a/static/app/views/navigation/primary/components.tsx +++ b/static/app/views/navigation/primary/components.tsx @@ -37,10 +37,7 @@ import { SIDEBAR_NAVIGATION_SOURCE, } from 'sentry/views/navigation/constants'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; - -function usePrimaryNavigationOrganization() { - return useOrganization({allowNull: true}); -} +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; interface PrimaryNavigationSidebarProps { children: React.ReactNode; @@ -49,8 +46,7 @@ interface PrimaryNavigationSidebarProps { function PrimaryNavigationSidebar({children, ...props}: PrimaryNavigationSidebarProps) { const theme = useTheme(); - const organization = usePrimaryNavigationOrganization(); - const hasPageFrame = organization?.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); return ( , function PrimaryNavigationSidebarHeader(props: PrimaryNavigationSidebarHeaderProps) { const theme = useTheme(); - const organization = usePrimaryNavigationOrganization(); + const organization = useOrganization({allowNull: true}); const showSuperuserWarning = isActiveSuperuser() && !ConfigStore.get('isSelfHosted') && !HookStore.get('component:superuser-warning-excluded')[0]?.(organization); - const hasPageFrame = organization?.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); return ( @@ -90,7 +86,7 @@ function PrimaryNavigationSidebarHeader(props: PrimaryNavigationSidebarHeaderPro align="center" justify="center" position="relative" - borderBottom={hasPageFrame ? 'muted' : undefined} + borderBottom={hasPageFrame ? 'primary' : undefined} width={hasPageFrame ? '100%' : undefined} height={hasPageFrame ? `${PRIMARY_HEADER_HEIGHT}px` : undefined} {...props} @@ -119,8 +115,7 @@ interface PrimaryNavigationListProps extends FlexProps<'ul'> {} function PrimaryNavigationList({children, ...props}: PrimaryNavigationListProps) { const {layout} = usePrimaryNavigation(); - const organization = usePrimaryNavigationOrganization(); - const hasPageFrame = organization?.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); return ( (null); @@ -420,8 +415,7 @@ function NavigationButton(props: DistributedOmit) { } function PrimaryNavigationButtonBar(props: ButtonBarProps) { - const organization = usePrimaryNavigationOrganization(); - const hasPageFrame = organization?.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); return ( @@ -622,8 +616,8 @@ const DesktopNavigationLink = styled((props: LinkProps) => ( `; const DesktopPageFrameNavigationLink = styled((props: LinkProps) => { - const organization = usePrimaryNavigationOrganization(); - const hasPageFrame = organization?.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); + return ( (null); const {layout} = usePrimaryNavigation(); - const hasPageFrame = organization.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); const makeNavigationItemProps = useActivateNavigationGroupOnHover({ref}); diff --git a/static/app/views/navigation/secondary/components.spec.tsx b/static/app/views/navigation/secondary/components.spec.tsx index 7cc088ca82c516..096b9274031dc7 100644 --- a/static/app/views/navigation/secondary/components.spec.tsx +++ b/static/app/views/navigation/secondary/components.spec.tsx @@ -11,6 +11,7 @@ import { NAVIGATION_SIDEBAR_SECONDARY_WIDTH_LOCAL_STORAGE_KEY, SECONDARY_SIDEBAR_WIDTH, } from 'sentry/views/navigation/constants'; +import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/secondaryNavigationContext'; const ALL_AVAILABLE_FEATURES = [ 'insight-modules', @@ -64,10 +65,15 @@ describe('SecondarySidebar', () => { beforeEach(setupMocks); it('uses the default width when no persisted value is in localStorage', () => { - render(, { - organization: OrganizationFixture({features: ALL_AVAILABLE_FEATURES}), - initialRouterConfig: {location: {pathname: '/organizations/org-slug/issues/'}}, - }); + render( + + + , + { + organization: OrganizationFixture({features: ALL_AVAILABLE_FEATURES}), + initialRouterConfig: {location: {pathname: '/organizations/org-slug/issues/'}}, + } + ); const secondaryNav = screen.getByRole('navigation', {name: 'Secondary Navigation'}); const sidebarContainer = secondaryNav.closest( @@ -85,10 +91,15 @@ describe('SecondarySidebar', () => { String(persistedWidth) ); - render(, { - organization: OrganizationFixture({features: ALL_AVAILABLE_FEATURES}), - initialRouterConfig: {location: {pathname: '/organizations/org-slug/issues/'}}, - }); + render( + + + , + { + organization: OrganizationFixture({features: ALL_AVAILABLE_FEATURES}), + initialRouterConfig: {location: {pathname: '/organizations/org-slug/issues/'}}, + } + ); const secondaryNav = screen.getByRole('navigation', {name: 'Secondary Navigation'}); const sidebarContainer = secondaryNav.closest( diff --git a/static/app/views/navigation/secondary/components.tsx b/static/app/views/navigation/secondary/components.tsx index ff64afd8148bd6..9b7e3f4876c00f 100644 --- a/static/app/views/navigation/secondary/components.tsx +++ b/static/app/views/navigation/secondary/components.tsx @@ -69,6 +69,7 @@ import { import {isPrimaryNavigationLinkActive} from 'sentry/views/navigation/primary/components'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; const MotionContainer = motion.create(Container); @@ -165,10 +166,15 @@ function SecondarySidebar({children}: SecondarySidebarProps) { function SecondarySidebarWrapper(props: NavigationTourElementProps) { const theme = useTheme(); + const secondaryNavigation = useSecondaryNavigation(); + const hasPageFrame = useHasPageFrameFeature(); + return ( @@ -256,15 +262,14 @@ interface SecondaryNavigationHeaderProps { function SecondaryNavigationHeader(props: SecondaryNavigationHeaderProps) { const {layout} = usePrimaryNavigation(); const {view, setView} = useSecondaryNavigation(); - const organization = useOrganization(); const isCollapsed = view !== 'expanded'; - const hasPageFrame = organization.features.includes('page-frame'); + const hasPageFrame = useHasPageFrameFeature(); return ( (null); +export const SecondaryNavigationContext = + createContext(null); export function useSecondaryNavigation(): SecondaryNavigationContext { const context = useContext(SecondaryNavigationContext); diff --git a/static/app/views/navigation/topBar.tsx b/static/app/views/navigation/topBar.tsx new file mode 100644 index 00000000000000..695d6a59cfa4fc --- /dev/null +++ b/static/app/views/navigation/topBar.tsx @@ -0,0 +1,69 @@ +import {useContext, useEffect, useRef} from 'react'; +import {useTheme} from '@emotion/react'; + +import {Flex} from '@sentry/scraps/layout'; + +import {SecondaryNavigationContext} from 'sentry/views/navigation/secondaryNavigationContext'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; + +import {PRIMARY_HEADER_HEIGHT} from './constants'; + +export function TopBar() { + const theme = useTheme(); + const secondaryNavigation = useContext(SecondaryNavigationContext); + const flexRef = useRef(null); + const hasPageFrame = useHasPageFrameFeature(); + + useEffect(() => { + if (!flexRef.current) { + return undefined; + } + + if (secondaryNavigation?.view !== 'expanded') { + flexRef.current.style.borderBottom = `1px solid ${theme.tokens.border.primary}`; + return undefined; + } + + const handleScroll = () => { + if (!flexRef.current) { + return; + } + + // @TODO(JonasBadalic): For the nicest transition possible, we should probably lerp the + // alpha color channel of the border color betweeen 0 and border radius distance. This would make the + // two blend nicely together without requiring us to approximate it usign the transition duration. + flexRef.current.style.borderBottom = + window.scrollY > 0 + ? `1px solid ${theme.tokens.border.primary}` + : '1px solid transparent'; + }; + + // Set initial state based on current scroll position + handleScroll(); + window.addEventListener('scroll', handleScroll, {passive: true}); + return () => window.removeEventListener('scroll', handleScroll); + }, [theme.tokens.border.primary, secondaryNavigation?.view]); + + if (!hasPageFrame) { + return null; + } + + return ( + + ); +} diff --git a/static/app/views/navigation/useHasPageFrameFeature.tsx b/static/app/views/navigation/useHasPageFrameFeature.tsx new file mode 100644 index 00000000000000..fd6447e42bca88 --- /dev/null +++ b/static/app/views/navigation/useHasPageFrameFeature.tsx @@ -0,0 +1,6 @@ +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function useHasPageFrameFeature() { + const organization = useOrganization({allowNull: true}); + return organization?.features.includes('page-frame') ?? false; +} diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index 0468ff12b9c2ad..e87436d9f5bb6f 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -21,6 +21,8 @@ import {AppBodyContent} from 'sentry/views/app/appBodyContent'; import {useRegisterDomainViewUsage} from 'sentry/views/insights/common/utils/domainRedirect'; import {Navigation} from 'sentry/views/navigation'; import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; +import {TopBar} from 'sentry/views/navigation/topBar'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; import {OrganizationContainer} from 'sentry/views/organizationContainer'; import {useReleasesDrawer} from 'sentry/views/releases/drawer/useReleasesDrawer'; @@ -67,6 +69,8 @@ function AppDrawers() { } function AppLayout({organization}: LayoutProps) { + const hasPageFrame = useHasPageFrameFeature(); + return ( {/* The `#main` selector is used to make the app content `inert` when an overlay is active */} - + {organization && } + diff --git a/static/app/views/settings/components/settingsLayout.tsx b/static/app/views/settings/components/settingsLayout.tsx index 3138396f868ccb..dcd5e97894649d 100644 --- a/static/app/views/settings/components/settingsLayout.tsx +++ b/static/app/views/settings/components/settingsLayout.tsx @@ -2,7 +2,6 @@ import styled from '@emotion/styled'; import {Flex} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {useParams} from 'sentry/utils/useParams'; import {useRoutes} from 'sentry/utils/useRoutes'; @@ -60,12 +59,4 @@ const Content = styled('div')` @media (max-width: ${p => p.theme.breakpoints.md}) { padding: ${p => p.theme.space.xl}; } - - /** - * Layout.Page is not normally used in settings but uses - * it under the hood. This prevents double padding. - */ - ${Layout.Page} { - padding: 0; - } `; diff --git a/static/app/views/sharedGroupDetails/index.tsx b/static/app/views/sharedGroupDetails/index.tsx index 9010b571333a17..617261754766dc 100644 --- a/static/app/views/sharedGroupDetails/index.tsx +++ b/static/app/views/sharedGroupDetails/index.tsx @@ -1,6 +1,6 @@ import {useLayoutEffect, useMemo} from 'react'; -import styled from '@emotion/styled'; +import {Container} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {NotFound} from 'sentry/components/errors/notFound'; @@ -90,7 +90,10 @@ function SharedGroupDetails() {
- + p.theme.space['3xl']}; -`; - export default SharedGroupDetails; From 5d957ca04a8f97d0d486aef93c0cbd0339f4e63c Mon Sep 17 00:00:00 2001 From: Matt Duncan <14761+mrduncan@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:00:18 -0700 Subject: [PATCH 2/7] chore(issues): Remove duplicate metric (#111253) As far as I can tell this predates the `sentry.events.processed` metric which has a platform tag. The removed metric is not used in any alerts/dashboards/etc as far as I can tell. --- src/sentry/tasks/post_process.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 00f3b1438c6cdb..aece13cb91cff9 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -133,7 +133,6 @@ def _capture_event_stats(event: Event) -> None: platform = format_event_platform(event) tags = {"platform": platform} metrics.incr("events.processed", tags={"platform": platform}, skip_internal=False) - metrics.incr(f"events.processed.{platform}", skip_internal=False) metrics.distribution("events.size.data", event.size, tags=tags, unit="byte") From d5435bb31a9412d18b8b4ad9b6805ed2a7e0b8d7 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 20 Mar 2026 15:01:03 -0700 Subject: [PATCH 3/7] perf(issues): Support collapse=stats on group details endpoint (#111155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group details endpoint (`/organizations/{org}/issues/{id}/`) computes 24h hourly and 30d daily time-series stats via two TSDB queries on every request. These populate the `stats` response field, but the issue details frontend doesn't use this data — it fetches its event graph via Discover queries instead. This adds `collapse=stats` support to the endpoint, matching the existing pattern for `release` and `tags`. When collapsed, the two TSDB queries are skipped entirely. Saves ~350ms on ~1sec trace [link](https://sentry.sentry.io/explore/traces/trace/a23978175568482b821b9fbfa23bd1d9/?fov=5481.543579310185%2C1376.42240858078&node=span-9508e545dba9bfa6&project=1&query=is_transaction%3Atrue%20span.description%3A%EF%80%8DContains%EF%80%8D%22%2Fapi%2F0%2Fissues%7Cgroups%2F%7Bissue_id%7D%2F%22%20organization.slug%3Asentry&source=traces&statsPeriod=24h&targetId=a56ba9d41cb02644×tamp=1773876894) Screenshot 2026-03-19 at 2 25 10 PM --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/issues/endpoints/group_details.py | 7 +++--- .../snuba/api/endpoints/test_group_details.py | 23 +++++-------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/sentry/issues/endpoints/group_details.py b/src/sentry/issues/endpoints/group_details.py index 765cef6e89f888..9dbc8dbcc7b9a6 100644 --- a/src/sentry/issues/endpoints/group_details.py +++ b/src/sentry/issues/endpoints/group_details.py @@ -199,8 +199,6 @@ def get(self, request: Request, group: Group) -> Response: ) ) - hourly_stats, daily_stats = self.__group_hourly_daily_stats(group, environment_ids) - if "inbox" in expand: inbox_map = get_inbox_details([group]) inbox_reason = inbox_map.get(group.id) @@ -277,11 +275,14 @@ def get(self, request: Request, group: Group) -> Response: "pluginIssues": get_available_issue_plugins(group), "pluginContexts": self._get_context_plugins(request, group), "userReportCount": user_reports.count(), - "stats": {"24h": hourly_stats, "30d": daily_stats}, "count": get_group_global_count(group), } ) + if "stats" not in collapse: + hourly_stats, daily_stats = self.__group_hourly_daily_stats(group, environment_ids) + data["stats"] = {"24h": hourly_stats, "30d": daily_stats} + participants = user_service.serialize_many( filter={"user_ids": GroupSubscriptionManager.get_participating_user_ids(group)}, as_user=request.user, diff --git a/tests/snuba/api/endpoints/test_group_details.py b/tests/snuba/api/endpoints/test_group_details.py index 99747b15cc25fa..db16a4e1cd6f57 100644 --- a/tests/snuba/api/endpoints/test_group_details.py +++ b/tests/snuba/api/endpoints/test_group_details.py @@ -276,31 +276,20 @@ def test_assigned_to_unknown(self) -> None: ] } - def test_collapse_stats_does_not_work(self) -> None: - """ - 'collapse' param should hide the stats data and not return anything in the response, but the impl - doesn't seem to respect this param. - - include this test here in-case the endpoint behavior changes in the future. - """ + def test_collapse_stats(self) -> None: self.login_as(user=self.user) - event = self.store_event( data={"timestamp": before_now(minutes=3).isoformat()}, project_id=self.project.id, ) - group = event.group - - url = f"/api/0/organizations/{group.organization.slug}/issues/{group.id}/" + url = f"/api/0/organizations/{event.group.organization.slug}/issues/{event.group.id}/" response = self.client.get(url, {"collapse": ["stats"]}, format="json") assert response.status_code == 200 - assert int(response.data["id"]) == event.group.id - assert response.data["stats"] # key shouldn't be present - assert response.data["count"] is not None # key shouldn't be present - assert response.data["userCount"] is not None # key shouldn't be present - assert response.data["firstSeen"] is not None # key shouldn't be present - assert response.data["lastSeen"] is not None # key shouldn't be present + assert "stats" not in response.data + # Seen stats from the serializer should still be present + assert response.data["firstSeen"] is not None + assert response.data["lastSeen"] is not None def test_issue_type_category(self) -> None: """Test that the issue's type and category is returned in the results""" From 7ef57e77f6b25e54e252a91e8ab0f9abc94a0fa9 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:09:43 -0700 Subject: [PATCH 4/7] fix(seer): Handle missing OrganizationMember in collect_user_org_context (#111255) Guard against `OrganizationMember.DoesNotExist` in `collect_user_org_context` when the user is not a member of the organization. Previously this would raise an unhandled exception; now it returns the same default context (org slug + all org projects) as the anonymous user path. --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/seer/explorer/client_utils.py | 17 ++- .../test_organization_seer_explorer_chat.py | 106 --------------- .../sentry/seer/explorer/test_client_utils.py | 122 +++++++++++++++++- 3 files changed, 137 insertions(+), 108 deletions(-) diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 48e02fcc18e3ab..4ed8424bc3626d 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -205,7 +205,22 @@ def collect_user_org_context( "all_org_projects": all_org_projects, } - member = OrganizationMember.objects.get(organization=organization, user_id=user.id) + try: + member = OrganizationMember.objects.get(organization=organization, user_id=user.id) + except OrganizationMember.DoesNotExist: + # User is not a member of this organization (e.g., superuser accessing foreign org) + logger.warning( + "User attempted to access Seer Explorer for organization they are not a member of", + extra={ + "user_id": user.id, + "organization_id": organization.id, + "organization_slug": organization.slug, + }, + ) + return { + "org_slug": organization.slug, + "all_org_projects": all_org_projects, + } user_teams = [{"id": t.id, "slug": t.slug} for t in member.get_teams()] my_projects = ( Project.objects.filter( diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index 807966a1e35f62..98a193278d3b1d 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -1,12 +1,8 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch -from sentry.models.organizationmember import OrganizationMember -from sentry.seer.explorer.client_utils import collect_user_org_context -from sentry.silo.safety import unguarded_write from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature -from sentry.testutils.requests import make_request @with_feature("organizations:seer-explorer") @@ -228,105 +224,3 @@ def test_override_ce_enable_ignored_without_feature_flag( assert response.status_code == 200 body = mock_chat_request.call_args[0][0] assert body.get("is_context_engine_enabled") is not False - - -class CollectUserOrgContextTest(APITestCase): - """Test the collect_user_org_context helper function""" - - def setUp(self) -> None: - super().setUp() - self.project1 = self.create_project( - organization=self.organization, teams=[self.team], slug="project-1" - ) - self.project2 = self.create_project( - organization=self.organization, teams=[self.team], slug="project-2" - ) - self.other_team = self.create_team(organization=self.organization, slug="other-team") - self.other_project = self.create_project( - organization=self.organization, teams=[self.other_team], slug="other-project" - ) - - def test_collect_context_with_member(self): - """Test context collection for a user who is an organization member""" - context = collect_user_org_context(self.user, self.organization) - - assert context is not None - assert context["org_slug"] == self.organization.slug - assert context.get("user_id") == self.user.id - assert context.get("user_name") == self.user.name - assert context.get("user_email") == self.user.email - assert context.get("user_timezone") is None # No timezone set by default - assert context.get("user_ip") is None # No IP address set by default - - # Should have exactly one team - assert "user_teams" in context - assert len(context["user_teams"]) == 1 - assert context["user_teams"][0]["slug"] == self.team.slug - - # User projects should include project1 and project2 (both on self.team) - assert "user_projects" in context - user_project_slugs = {p["slug"] for p in context["user_projects"]} - assert user_project_slugs == {"project-1", "project-2"} - - # All org projects should include all 3 projects - assert "all_org_projects" in context - all_project_slugs = {p["slug"] for p in context["all_org_projects"]} - assert all_project_slugs == {"project-1", "project-2", "other-project"} - all_project_ids = {p["id"] for p in context["all_org_projects"]} - assert all_project_ids == {self.project1.id, self.project2.id, self.other_project.id} - - def test_collect_context_with_multiple_teams(self): - """Test context collection for a user in multiple teams""" - team2 = self.create_team(organization=self.organization, slug="team-2") - member = OrganizationMember.objects.get( - organization=self.organization, user_id=self.user.id - ) - with unguarded_write(using="default"): - member.teams.add(team2) - - context = collect_user_org_context(self.user, self.organization) - - assert context is not None - assert "user_teams" in context - team_slugs = {t["slug"] for t in context["user_teams"]} - assert team_slugs == {self.team.slug, "team-2"} - - def test_collect_context_with_no_teams(self): - """Test context collection for a member with no team membership""" - member = OrganizationMember.objects.get( - organization=self.organization, user_id=self.user.id - ) - # Remove user from all teams - with unguarded_write(using="default"): - member.teams.clear() - - context = collect_user_org_context(self.user, self.organization) - - assert context is not None - assert context.get("user_teams") == [] - assert context.get("user_projects") == [] - # All org projects should still be present - assert "all_org_projects" in context - all_project_slugs = {p["slug"] for p in context["all_org_projects"]} - assert all_project_slugs == {"project-1", "project-2", "other-project"} - - def test_collect_context_with_timezone(self): - """Test context collection includes user's timezone setting""" - from sentry.users.services.user_option import user_option_service - - user_option_service.set_option( - user_id=self.user.id, key="timezone", value="America/Los_Angeles" - ) - - context = collect_user_org_context(self.user, self.organization) - - assert context is not None - assert context.get("user_timezone") == "America/Los_Angeles" - - def test_collect_context_with_request(self): - """Test context collection includes request metadata like IP address""" - request, _ = make_request() - context = collect_user_org_context(self.user, self.organization, request=request) - - assert context is not None - assert context.get("user_ip") == request.META.get("REMOTE_ADDR") diff --git a/tests/sentry/seer/explorer/test_client_utils.py b/tests/sentry/seer/explorer/test_client_utils.py index 73ec1b17d92faf..a17f80dd98e5d0 100644 --- a/tests/sentry/seer/explorer/test_client_utils.py +++ b/tests/sentry/seer/explorer/test_client_utils.py @@ -1,5 +1,11 @@ -from sentry.seer.explorer.client_utils import has_seer_explorer_access_with_detail +from sentry.models.organizationmember import OrganizationMember +from sentry.seer.explorer.client_utils import ( + collect_user_org_context, + has_seer_explorer_access_with_detail, +) +from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestCase +from sentry.testutils.requests import make_request class TestHasSeerExplorerAccessWithDetail(TestCase): @@ -73,3 +79,117 @@ def test_allow_joinleave_disabled(self): False, "Organization does not have open team membership enabled. Seer requires this to aggregate context across all projects and allow members to ask questions freely.", ) + + +class CollectUserOrgContextTest(TestCase): + """Test the collect_user_org_context helper function""" + + def setUp(self) -> None: + super().setUp() + self.project1 = self.create_project( + organization=self.organization, teams=[self.team], slug="project-1" + ) + self.project2 = self.create_project( + organization=self.organization, teams=[self.team], slug="project-2" + ) + self.other_team = self.create_team(organization=self.organization, slug="other-team") + self.other_project = self.create_project( + organization=self.organization, teams=[self.other_team], slug="other-project" + ) + + def test_collect_context_with_member(self): + """Test context collection for a user who is an organization member""" + context = collect_user_org_context(self.user, self.organization) + + assert context is not None + assert context["org_slug"] == self.organization.slug + assert context.get("user_id") == self.user.id + assert context.get("user_name") == self.user.name + assert context.get("user_email") == self.user.email + assert context.get("user_timezone") is None # No timezone set by default + assert context.get("user_ip") is None # No IP address set by default + + # Should have exactly one team + assert "user_teams" in context + assert len(context["user_teams"]) == 1 + assert context["user_teams"][0]["slug"] == self.team.slug + + # User projects should include project1 and project2 (both on self.team) + assert "user_projects" in context + user_project_slugs = {p["slug"] for p in context["user_projects"]} + assert user_project_slugs == {"project-1", "project-2"} + + # All org projects should include all 3 projects + assert "all_org_projects" in context + all_project_slugs = {p["slug"] for p in context["all_org_projects"]} + assert all_project_slugs == {"project-1", "project-2", "other-project"} + all_project_ids = {p["id"] for p in context["all_org_projects"]} + assert all_project_ids == {self.project1.id, self.project2.id, self.other_project.id} + + def test_collect_context_with_multiple_teams(self): + """Test context collection for a user in multiple teams""" + team2 = self.create_team(organization=self.organization, slug="team-2") + member = OrganizationMember.objects.get( + organization=self.organization, user_id=self.user.id + ) + with unguarded_write(using="default"): + member.teams.add(team2) + + context = collect_user_org_context(self.user, self.organization) + + assert context is not None + assert "user_teams" in context + team_slugs = {t["slug"] for t in context["user_teams"]} + assert team_slugs == {self.team.slug, "team-2"} + + def test_collect_context_with_no_teams(self): + """Test context collection for a member with no team membership""" + member = OrganizationMember.objects.get( + organization=self.organization, user_id=self.user.id + ) + # Remove user from all teams + with unguarded_write(using="default"): + member.teams.clear() + + context = collect_user_org_context(self.user, self.organization) + + assert context is not None + assert context.get("user_teams") == [] + assert context.get("user_projects") == [] + # All org projects should still be present + assert "all_org_projects" in context + all_project_slugs = {p["slug"] for p in context["all_org_projects"]} + assert all_project_slugs == {"project-1", "project-2", "other-project"} + + def test_collect_context_with_non_member_returns_default(self): + """Test context collection for a user who is not an organization member""" + other_user = self.create_user() + context = collect_user_org_context(other_user, self.organization) + + all_project_slugs = {p["slug"] for p in context["all_org_projects"]} + assert context == { + "org_slug": self.organization.slug, + "all_org_projects": context["all_org_projects"], + } + assert all_project_slugs == {"project-1", "project-2", "other-project"} + + def test_collect_context_with_timezone(self): + """Test context collection includes user's timezone setting""" + from sentry.users.services.user_option import user_option_service + + user_option_service.set_option( + user_id=self.user.id, key="timezone", value="America/Los_Angeles" + ) + + context = collect_user_org_context(self.user, self.organization) + + assert context is not None + assert context.get("user_timezone") == "America/Los_Angeles" + + def test_collect_context_with_request(self): + """Test context collection includes request metadata like IP address""" + request, _ = make_request() + context = collect_user_org_context(self.user, self.organization, request=request) + + assert context is not None + assert context.get("user_ip") == request.META.get("REMOTE_ADDR") From f18c73adb1ee4ca61896a321b6c0f1ca25aec54e Mon Sep 17 00:00:00 2001 From: joshuarli Date: Fri, 20 Mar 2026 15:17:38 -0700 Subject: [PATCH 5/7] fix: selective dispatch should target backend-selective.yml (#111256) --- .github/workflows/backend-selective.yml | 9 +++++++-- .github/workflows/getsentry-dispatch-selective.yml | 1 + .github/workflows/scripts/getsentry-dispatch.js | 8 +++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend-selective.yml b/.github/workflows/backend-selective.yml index 4228d25e5034ae..602f7a12a6edcc 100644 --- a/.github/workflows/backend-selective.yml +++ b/.github/workflows/backend-selective.yml @@ -15,6 +15,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: bash -euo pipefail {0} + # hack for https://github.com/actions/cache/issues/810#issuecomment-1222550359 env: SEGMENT_DOWNLOAD_TIMEOUT_MINS: 3 @@ -62,6 +66,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + CHANGED_FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --paginate --jq '.[].filename' | tr '\n' ' ') echo "Changed files: $CHANGED_FILES" echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" @@ -83,7 +88,7 @@ jobs: - name: Download coverage database id: download-coverage run: | - set -euo pipefail + mkdir -p .artifacts/coverage GCS_PATH="gs://getsentry-coverage-data/latest/.coverage.combined" @@ -106,7 +111,7 @@ jobs: COVERAGE_DB: ${{ steps.download-coverage.outputs.coverage-file }} CHANGED_FILES: ${{ steps.changed.outputs.files }} run: | - set -euo pipefail + python3 .github/workflows/scripts/compute-sentry-selected-tests.py \ --coverage-db "$COVERAGE_DB" \ diff --git a/.github/workflows/getsentry-dispatch-selective.yml b/.github/workflows/getsentry-dispatch-selective.yml index f7a1aeb022e51d..577c7879c57665 100644 --- a/.github/workflows/getsentry-dispatch-selective.yml +++ b/.github/workflows/getsentry-dispatch-selective.yml @@ -96,4 +96,5 @@ jobs: mergeCommitSha: '${{ steps.mergecommit.outputs.mergeCommitSha }}', fileChanges: ${{ toJson(steps.changes.outputs) }}, sentryChangedFiles: process.env.SENTRY_CHANGED_FILES, + targetWorkflow: 'backend-selective.yml', }); diff --git a/.github/workflows/scripts/getsentry-dispatch.js b/.github/workflows/scripts/getsentry-dispatch.js index 43ed386e6d3487..285be77daec73e 100644 --- a/.github/workflows/scripts/getsentry-dispatch.js +++ b/.github/workflows/scripts/getsentry-dispatch.js @@ -20,11 +20,17 @@ export async function dispatch({ fileChanges, mergeCommitSha, sentryChangedFiles, + targetWorkflow, }) { core.startGroup('Dispatching request to getsentry.'); + const dispatches = + targetWorkflow !== undefined + ? [{workflow: targetWorkflow, pathFilterName: 'backend_all'}] + : DISPATCHES; + await Promise.all( - DISPATCHES.map(({workflow, pathFilterName}) => { + dispatches.map(({workflow, pathFilterName}) => { const inputs = { pull_request_number: `${context.payload.pull_request.number}`, // needs to be string skip: `${fileChanges[pathFilterName] !== 'true'}`, // even though this is a boolean, it must be cast to a string From 1bb79dcc8e79e9380c1016f8e3d9e25bc8c9327d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 20 Mar 2026 15:56:05 -0700 Subject: [PATCH 6/7] ref(seer): Add title prop to ScmRepoTreeModal to help guide users when intent changes (#111217) This modal could be used for a few reasons: To prompt users to connect their providers, or to connect exposed repos from a previously connected provider. By making the title a prop we can remind the user what their job is in a context-specific way. --- .../repositories/scmRepoTreeModal.tsx | 10 +++++++--- .../components/repoTable/seerRepoTable.tsx | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/static/app/components/repositories/scmRepoTreeModal.tsx b/static/app/components/repositories/scmRepoTreeModal.tsx index 27074ef4a88aa6..057ebe3843cab1 100644 --- a/static/app/components/repositories/scmRepoTreeModal.tsx +++ b/static/app/components/repositories/scmRepoTreeModal.tsx @@ -8,16 +8,20 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {ScmIntegrationTree} from 'sentry/components/repositories/scmIntegrationTree/scmIntegrationTree'; import {ScmTreeFilters} from 'sentry/components/repositories/scmIntegrationTree/scmTreeFilters'; import type {RepoFilter} from 'sentry/components/repositories/scmIntegrationTree/types'; -import {t, tct} from 'sentry/locale'; +import {tct} from 'sentry/locale'; -export function ScmRepoTreeModal({Header, Body}: ModalRenderProps) { +interface Props extends ModalRenderProps { + title: string; +} + +export function ScmRepoTreeModal({Header, Body, title}: Props) { const [search, setSearch] = useState(''); const [repoFilter, setRepoFilter] = useState('all'); return (
- {t('Add Repository')} + {title}
diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx index 132c471ec8e4ef..95e1fcb4651487 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx @@ -158,14 +158,17 @@ export function SeerRepoTable() { priority="primary" icon={} onClick={() => { - openModal(deps => , { - modalCss: css` - width: 700px; - `, - onClose: () => { - queryClient.invalidateQueries({queryKey: queryOptions.queryKey}); - }, - }); + openModal( + deps => , + { + modalCss: css` + width: 700px; + `, + onClose: () => { + queryClient.invalidateQueries({queryKey: queryOptions.queryKey}); + }, + } + ); }} > {t('Add Repository')} From db929a770fea7b4dc67c43421b5b8f48beeaf8da Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:29:58 -0700 Subject: [PATCH 7/7] fix(seer): Remove noisy capture_exception for expected ObjectDoesNotExist (#111257) --- src/sentry/seer/endpoints/seer_rpc.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 87587541cb7831..2795c05760c5eb 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -252,8 +252,6 @@ def post(self, request: Request, method_name: str) -> Response: sentry_sdk.capture_exception() raise ParseError from e except ObjectDoesNotExist as e: - # Let this fall through, this is normal. - sentry_sdk.capture_exception() raise NotFound from e except SnubaRPCRateLimitExceeded as e: sentry_sdk.capture_exception()