diff --git a/CHANGES b/CHANGES index 3b101297dcb7e5..94c6903d9410eb 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,737 @@ +26.5.1 +------ + +### New Features ✨ + +#### Apigw + +- Expose proxy latency metrics by target by @gi0baro in [#116086](https://github.com/getsentry/sentry/pull/116086) +- Add non-orgid/slug endpoints to proxied cell requests by @gi0baro in [#115930](https://github.com/getsentry/sentry/pull/115930) + +#### Autofix + +- Allow non seat based seer to skip setup in [#116208](https://github.com/getsentry/sentry/pull/116208) +- Switch inspection to single llm call using gemini flas… by @Zylphrex in [#116071](https://github.com/getsentry/sentry/pull/116071) +- Autofix introspection analytics by @Zylphrex in [#115891](https://github.com/getsentry/sentry/pull/115891) +- Add UI labels for missing AutofixReferrer values by @chromy in [#115655](https://github.com/getsentry/sentry/pull/115655) +- Render line numbers in autofix evidence by @Zylphrex in [#115649](https://github.com/getsentry/sentry/pull/115649) + +#### Cells + +- Remove cross-org feature gating from notification settings by @lynnagara in [#115829](https://github.com/getsentry/sentry/pull/115829) +- Add cell-routing mode to devservices by @lynnagara in [#115737](https://github.com/getsentry/sentry/pull/115737) + +#### Cmdk + +- Add Open in Production and Open in Development actions in [#116242](https://github.com/getsentry/sentry/pull/116242) +- Freeze visible action list during keyboard navigation in [#115851](https://github.com/getsentry/sentry/pull/115851) +- Add project search action to command palette by @JonasBa in [#115591](https://github.com/getsentry/sentry/pull/115591) + +#### Conversations + +- Add copy conversation as markdown button in [#116171](https://github.com/getsentry/sentry/pull/116171) +- Swap badge from alpha to beta by @obostjancic in [#115712](https://github.com/getsentry/sentry/pull/115712) +- Add Amplitude analytics to conversation pages by @obostjancic in [#115622](https://github.com/getsentry/sentry/pull/115622) + +#### Dashboards + +- Add span-first support for web vital dashboard in [#115882](https://github.com/getsentry/sentry/pull/115882) +- Validate display type against dataset config by @DominikB2014 in [#115951](https://github.com/getsentry/sentry/pull/115951) +- Require metric_unit in AI tracemetrics aggregates by @DominikB2014 in [#116101](https://github.com/getsentry/sentry/pull/116101) +- Teach AI dashboard generator the tracemetrics aggregate format by @DominikB2014 in [#115480](https://github.com/getsentry/sentry/pull/115480) + +#### Explore + +- Heatmap tooltip trace links by @nikkikapadia in [#115925](https://github.com/getsentry/sentry/pull/115925) +- Link to aggregates from dropdown by @nsdeschenes in [#115789](https://github.com/getsentry/sentry/pull/115789) +- Add Heat Map widget to Explore metrics by @gggritso in [#115608](https://github.com/getsentry/sentry/pull/115608) + +#### Github Enterprise + +- Add frontend pipeline steps for GHE integration setup in [#114367](https://github.com/getsentry/sentry/pull/114367) +- Add API-driven pipeline backend for GHE integration setup in [#114366](https://github.com/getsentry/sentry/pull/114366) +- Allow github.com as a source for the GitHub Enterprise integration by @tnt-sentry in [#115599](https://github.com/getsentry/sentry/pull/115599) + +#### Issues + +- Bring back `SEER_PR_CREATED` activity creation and hide from timeline in [#116233](https://github.com/getsentry/sentry/pull/116233) +- Two-column activity icons, colors by @scttcper in [#115958](https://github.com/getsentry/sentry/pull/115958) +- Unify issue activity streams by @scttcper in [#115848](https://github.com/getsentry/sentry/pull/115848) +- Add activity feed v2 flag by @scttcper in [#115966](https://github.com/getsentry/sentry/pull/115966) +- Consolidate activity comment input by @scttcper in [#115824](https://github.com/getsentry/sentry/pull/115824) +- Replace DebugMeta store with context by @scttcper in [#115842](https://github.com/getsentry/sentry/pull/115842) + +#### Low Value Spans + +- Add configuration issue UI in [#116271](https://github.com/getsentry/sentry/pull/116271) +- Add Snuba referrer for detector by @vgrozdanic in [#115980](https://github.com/getsentry/sentry/pull/115980) +- Add low-value span issue UI by @ArthurKnaus in [#115870](https://github.com/getsentry/sentry/pull/115870) +- Add low-value span issue type by @ArthurKnaus in [#115868](https://github.com/getsentry/sentry/pull/115868) + +#### Onboarding + +- Link selected repository to project after creation by @wedamija in [#115761](https://github.com/getsentry/sentry/pull/115761) +- Update Hono onboarding with `@sentry/hono` by @s1gr1d in [#115476](https://github.com/getsentry/sentry/pull/115476) + +#### Ourlogs + +- Reduce modal export rows limit to 10k by @JoshuaKGoldberg in [#116180](https://github.com/getsentry/sentry/pull/116180) +- Show estimated total dataset size in needle-in-haystack searches by @JoshuaKGoldberg in [#115731](https://github.com/getsentry/sentry/pull/115731) +- Implement pinned logs with sticky header (part 1) by @JoshuaKGoldberg in [#115102](https://github.com/getsentry/sentry/pull/115102) +- Add 'Group by attribute' to log property context menu by @JoshuaKGoldberg in [#115420](https://github.com/getsentry/sentry/pull/115420) + +#### Preprod + +- Display snapshot image tags in card headers in [#115723](https://github.com/getsentry/sentry/pull/115723) +- Display images_skipped in snapshot table by @NicoHinderling in [#116074](https://github.com/getsentry/sentry/pull/116074) +- Add images_skipped to builds API response by @NicoHinderling in [#116073](https://github.com/getsentry/sentry/pull/116073) +- Display skipped images in snapshots UI by @NicoHinderling in [#116041](https://github.com/getsentry/sentry/pull/116041) +- Expose is_selective flag in snapshot details API response by @NicoHinderling in [#115832](https://github.com/getsentry/sentry/pull/115832) +- Add Snapshot status check rules API by @cameroncooke in [#115621](https://github.com/getsentry/sentry/pull/115621) + +#### Search + +- Add recommended sort option to issue stream dropdown in [#116197](https://github.com/getsentry/sentry/pull/116197) +- Surface recommended sort in UI when active via query param in [#116186](https://github.com/getsentry/sentry/pull/116186) +- Register feature flag for recommended issue sort by @roggenkemper in [#116191](https://github.com/getsentry/sentry/pull/116191) + +#### Seer + +- Add structured LLM context for replay list and detail pages in [#116045](https://github.com/getsentry/sentry/pull/116045) +- Always show action buttons in explorer chat blocks by @ChrisandraVaz in [#116049](https://github.com/getsentry/sentry/pull/116049) +- Add bulk Seer project connected repos endpoint by @srest2021 in [#115942](https://github.com/getsentry/sentry/pull/115942) +- Add Seer project connected repo endpoint by @srest2021 in [#115199](https://github.com/getsentry/sentry/pull/115199) +- Add structured LLM context for explore logs trace route by @Mihir-Mavalankar in [#116036](https://github.com/getsentry/sentry/pull/116036) +- Add CRUD helpers for Seer project repos by @srest2021 in [#115904](https://github.com/getsentry/sentry/pull/115904) +- Add structured LLM context for issue detail sub-tabs by @Mihir-Mavalankar in [#115936](https://github.com/getsentry/sentry/pull/115936) +- Add bulk-project Seer settings endpoint by @srest2021 in [#115234](https://github.com/getsentry/sentry/pull/115234) +- Add helper for bulk updating Seer project settings by @srest2021 in [#115756](https://github.com/getsentry/sentry/pull/115756) +- Scope /conversations slash command lookup with start/end/project by @chromy in [#115785](https://github.com/getsentry/sentry/pull/115785) +- Add single-project Seer settings endpoint by @srest2021 in [#115230](https://github.com/getsentry/sentry/pull/115230) +- Add SeerRun FK to SeerNightShiftRun by @trevor-e in [#115694](https://github.com/getsentry/sentry/pull/115694) +- Add SeerWorkflowConfig model and link to night shift runs by @trevor-e in [#115615](https://github.com/getsentry/sentry/pull/115615) +- Mirror last_triggered_at to SeerRun on autofix triggers by @trevor-e in [#115611](https://github.com/getsentry/sentry/pull/115611) + +#### Tracemetrics + +- Include equations in Add to Dashboard by @narsaynorath in [#116141](https://github.com/getsentry/sentry/pull/116141) +- Convert equation alias to full equation for queries by @narsaynorath in [#116047](https://github.com/getsentry/sentry/pull/116047) +- Open in Explore for metrics dashboard widgets by @narsaynorath in [#115805](https://github.com/getsentry/sentry/pull/115805) +- Lazy load trace details per metric by @nsdeschenes in [#115066](https://github.com/getsentry/sentry/pull/115066) + +#### Webhooks + +- Add dry run check to sentry app webhook path in [#116265](https://github.com/getsentry/sentry/pull/116265) +- Add payload validation during dual-write migration in [#116040](https://github.com/getsentry/sentry/pull/116040) +- Add metrics for legacy webhook migration validation by @Christinarlong in [#116039](https://github.com/getsentry/sentry/pull/116039) +- Wire new service with feature-flagged routing by @Christinarlong in [#115747](https://github.com/getsentry/sentry/pull/115747) +- Add standalone legacy webhook service module by @Christinarlong in [#115688](https://github.com/getsentry/sentry/pull/115688) +- Register legacy webhook migration feature flags by @Christinarlong in [#115669](https://github.com/getsentry/sentry/pull/115669) + +#### Other + +- (aci) Add sort param to workflow group history endpoint in [#116031](https://github.com/getsentry/sentry/pull/116031) +- (alerts) Add cleanup task to NotificationMessage in [#116027](https://github.com/getsentry/sentry/pull/116027) +- (amplitude) Track whether users are viewing sentry-built dashboards by @bcoe in [#116138](https://github.com/getsentry/sentry/pull/116138) +- (api-docs) Publish project event details endpoint in [#116059](https://github.com/getsentry/sentry/pull/116059) +- (apigateway) Add separated async `apigw` package by @gi0baro in [#115624](https://github.com/getsentry/sentry/pull/115624) +- (button) Add `size` prop to `ButtonBar` via `SizeContext` by @natemoo-re in [#115728](https://github.com/getsentry/sentry/pull/115728) +- (ci) Add merge_base_strategy tag to Jest CI runs by @ryan953 in [#115967](https://github.com/getsentry/sentry/pull/115967) +- (data-forwarding) Enable retries for data forwarders via task dispatch by @leeandher in [#115511](https://github.com/getsentry/sentry/pull/115511) +- (dev) Add SENTRY_CELL_ROUTING env var that runs cell-routing mode locally by @lynnagara in [#115852](https://github.com/getsentry/sentry/pull/115852) +- (dynamic-sampling) Add per-project volume query in [#114286](https://github.com/getsentry/sentry/pull/114286) +- (examples) Add task that produces by @bmckerry in [#115820](https://github.com/getsentry/sentry/pull/115820) +- (explorer) Add query parameter to explorer-runs API by @JonasBa in [#115760](https://github.com/getsentry/sentry/pull/115760) +- (integrations) Disable auth token creation button without perms by @cvxluo in [#115769](https://github.com/getsentry/sentry/pull/115769) +- (markdown) Expose default components via `Default` prop by @natemoo-re in [#115745](https://github.com/getsentry/sentry/pull/115745) +- (options) Add timing metric to options.get() by @kenzoengineer in [#115762](https://github.com/getsentry/sentry/pull/115762) +- (profiling) Add task for taskbroker passthrough mode by @untitaker in [#115065](https://github.com/getsentry/sentry/pull/115065) +- (repositories) Add project repo-link endpoint by @wedamija in [#115754](https://github.com/getsentry/sentry/pull/115754) +- (routes) Add redirect from /snapshots/ to explore releases by @NicoHinderling in [#116053](https://github.com/getsentry/sentry/pull/116053) +- (scm) Add streaming integration-proxy which accepts any 'Accepts' header value by @cmanallen in [#115917](https://github.com/getsentry/sentry/pull/115917) +- (self-healing) Add support for seer activities in workflow engine by @saponifi3d in [#115933](https://github.com/getsentry/sentry/pull/115933) +- (settings) Add 'Recent Error Events' column to project environments by @JoshuaKGoldberg in [#115902](https://github.com/getsentry/sentry/pull/115902) +- (source-map-config-issues) Updating processing errors metric by @Abdkhan14 in [#115822](https://github.com/getsentry/sentry/pull/115822) +- (spans) Add separate Redis cluster setting for span deduplication by @untitaker in [#116010](https://github.com/getsentry/sentry/pull/116010) +- (trace-waterfall) Small tweaks to trace-waterfall tab by @nsdeschenes in [#115584](https://github.com/getsentry/sentry/pull/115584) +- (ui) Add debug FeatureBadge variant by @chromy in [#116000](https://github.com/getsentry/sentry/pull/116000) +- Flags and rpc for frontend code search tool by @shruthilayaj in [#116098](https://github.com/getsentry/sentry/pull/116098) +- Add SENTRY_ALLOWED_IPS to allow IP, overwrite SENTRY_DISALLOWED… by @fe80 in [#115773](https://github.com/getsentry/sentry/pull/115773) +- Add Relay measurements conversion feature by @loewenheim in [#115979](https://github.com/getsentry/sentry/pull/115979) +- Track read options via seen logline by @joshuarli in [#115610](https://github.com/getsentry/sentry/pull/115610) +- Add toggle to migrate to billing platform by @noahsmartin in [#115895](https://github.com/getsentry/sentry/pull/115895) + +### Bug Fixes 🐛 + +#### Alerts + +- Handle gte/lte condition types in metric alert serializers by @kcons in [#115972](https://github.com/getsentry/sentry/pull/115972) +- Update migration to not remove FK to group by @ceorourke in [#115932](https://github.com/getsentry/sentry/pull/115932) +- Surface API error messages in create/update toasts by @malwilley in [#115894](https://github.com/getsentry/sentry/pull/115894) +- Batch NotificationMessage delete metric alert rows by @ceorourke in [#115726](https://github.com/getsentry/sentry/pull/115726) + +#### Api + +- Correctly parse `full` parameter in project events endpoint in [#116216](https://github.com/getsentry/sentry/pull/116216) +- Validate IDs in OrganizationGroupIndexEndpoint.delete by @kcons in [#115770](https://github.com/getsentry/sentry/pull/115770) + +#### Conversations + +- Restore side-by-side layout for platform option dropdown in [#116272](https://github.com/getsentry/sentry/pull/116272) +- Improve tool badge rendering and overflow behavior by @obostjancic in [#115880](https://github.com/getsentry/sentry/pull/115880) +- Improve truncation of non-UUID conversation IDs by @sentry-junior in [#115978](https://github.com/getsentry/sentry/pull/115978) + +#### Dashboards + +- Raise widget description limit to 350 by @DominikB2014 in [#116185](https://github.com/getsentry/sentry/pull/116185) +- Propagate global filters in Open in Issues link by @DominikB2014 in [#116105](https://github.com/getsentry/sentry/pull/116105) +- Stop widget header action clicks from bubbling by @skaasten in [#116096](https://github.com/getsentry/sentry/pull/116096) +- Anchor Editors dropdown to the right edge of the trigger by @skaasten in [#116104](https://github.com/getsentry/sentry/pull/116104) +- Reset table fields when switching from details widget by @DominikB2014 in [#115788](https://github.com/getsentry/sentry/pull/115788) +- Prevent sticky navbar misalignment on scroll by @priscilawebdev in [#115716](https://github.com/getsentry/sentry/pull/115716) + +#### Discover + +- Add missing check for DiscoverSavedQueryVisitEndpoint in [#116187](https://github.com/getsentry/sentry/pull/116187) +- Add org id to project filter by @nsdeschenes in [#116174](https://github.com/getsentry/sentry/pull/116174) + +#### Dynamic Sampling + +- Use the correct field name for dynamic sampling project id in [#116279](https://github.com/getsentry/sentry/pull/116279) +- Update run_eap_spans_table_query_in_chunks to yield individual rows and adjust tests accordingly by @constantinius in [#115995](https://github.com/getsentry/sentry/pull/115995) + +#### Events + +- Debug param wasn't being passed down correctly in [#116152](https://github.com/getsentry/sentry/pull/116152) +- Correctly parse full parameter in group hashes endpoint in [#116219](https://github.com/getsentry/sentry/pull/116219) + +#### Explore + +- Use unique ids for visuals in [#116204](https://github.com/getsentry/sentry/pull/116204) +- Cross events date selector allow 7d anytime within 30 days by @nikkikapadia in [#116099](https://github.com/getsentry/sentry/pull/116099) +- Increase strictness on URLs by @nsdeschenes in [#115881](https://github.com/getsentry/sentry/pull/115881) +- Pymark fail on test for arrays in detail endpoint by @manessaraj in [#115828](https://github.com/getsentry/sentry/pull/115828) + +#### Integrations + +- Validate user-provided IDs in webhooks by @kcons in [#115910](https://github.com/getsentry/sentry/pull/115910) +- Replace useIntegrationTabs with nuqs useQueryState by @ryan953 in [#115738](https://github.com/getsentry/sentry/pull/115738) + +#### Issues + +- Align collapsed activity row in [#116266](https://github.com/getsentry/sentry/pull/116266) +- Fix undefined variable in `StreamGroupSerializerSnuba` feature flag check in [#116259](https://github.com/getsentry/sentry/pull/116259) +- Move user serialization out of loop in ignored issues handler in [#116246](https://github.com/getsentry/sentry/pull/116246) +- Fix sidebar comment box horizontal overflow in [#116209](https://github.com/getsentry/sentry/pull/116209) +- Match short id when combined with filters in [#116153](https://github.com/getsentry/sentry/pull/116153) +- Make GroupSearchViewPermission fail closed for unknown object types by @roggenkemper in [#116183](https://github.com/getsentry/sentry/pull/116183) +- Provide correct value for `search.sort` SDK tag by @shashjar in [#116065](https://github.com/getsentry/sentry/pull/116065) +- Use full URL for open link button in breadcrumb messages by @scttcper in [#115911](https://github.com/getsentry/sentry/pull/115911) +- Enforce project access on event ID lookup endpoint by @oioki in [#115784](https://github.com/getsentry/sentry/pull/115784) +- Stop double-emitting issue activities for Seer PR created by @shashjar in [#115749](https://github.com/getsentry/sentry/pull/115749) +- Add int ID validation to a few endpoints by @kcons in [#115690](https://github.com/getsentry/sentry/pull/115690) +- Search org members for note mentions by @scttcper in [#115614](https://github.com/getsentry/sentry/pull/115614) + +#### Metrics + +- Resolve flaky metrics tab tests in [#116280](https://github.com/getsentry/sentry/pull/116280) +- Default to largest interval when using heatmaps visualization by @nikkikapadia in [#116129](https://github.com/getsentry/sentry/pull/116129) + +#### Monitors + +- Surface schedule config errors on cron form fields by @malwilley in [#116016](https://github.com/getsentry/sentry/pull/116016) +- Add tooltip for disabled project in edits by @JoshuaKGoldberg in [#115931](https://github.com/getsentry/sentry/pull/115931) + +#### Onboarding + +- Remove broken aria-label from RadioGroup radio inputs by @scttcper in [#116032](https://github.com/getsentry/sentry/pull/116032) +- Include shared feedback for Hono onbarding by @s1gr1d in [#115721](https://github.com/getsentry/sentry/pull/115721) + +#### Perforce + +- Update onboarding frontend for Unicode support by @mujacica in [#116005](https://github.com/getsentry/sentry/pull/116005) +- Support Unicode Perforce server connections by @mujacica in [#115775](https://github.com/getsentry/sentry/pull/115775) + +#### Preprod + +- Reduce snapshot download concurrency to prevent stream failures in [#116267](https://github.com/getsentry/sentry/pull/116267) +- Reapply "Include image key and field path in snapshot validation errors" by @runningcode in [#115987](https://github.com/getsentry/sentry/pull/115987) +- Remove native lazy loading from LazyImage component by @NicoHinderling in [#115922](https://github.com/getsentry/sentry/pull/115922) +- Eliminate race condition in snapshot status check posting by @NicoHinderling in [#115650](https://github.com/getsentry/sentry/pull/115650) +- Skip strict jsonschema for snapshot image metadata by @runningcode in [#115720](https://github.com/getsentry/sentry/pull/115720) +- Restore extra field passthrough in snapshot image responses by @NicoHinderling in [#115658](https://github.com/getsentry/sentry/pull/115658) +- Change snapshot image tags from list to dict by @NicoHinderling in [#115643](https://github.com/getsentry/sentry/pull/115643) + +#### Replays + +- Shrink timeline hover timestamp in [#116268](https://github.com/getsentry/sentry/pull/116268) +- Remove timeline icon z-index workaround in [#116255](https://github.com/getsentry/sentry/pull/116255) +- Remove extra padding from BodyGrid in replayLayout by @sentry-junior in [#116156](https://github.com/getsentry/sentry/pull/116156) +- Disable breadcrumbs autoscroll on user scroll by @JoshuaKGoldberg in [#115914](https://github.com/getsentry/sentry/pull/115914) +- Correct query invalidation on refresh by @JoshuaKGoldberg in [#115629](https://github.com/getsentry/sentry/pull/115629) +- Allow org admins to bulk delete replays by @jameskeane in [#115886](https://github.com/getsentry/sentry/pull/115886) +- Make link copy button accessible and non-variable width by @JoshuaKGoldberg in [#115598](https://github.com/getsentry/sentry/pull/115598) + +#### Search + +- Prevent Ask AI from doubling pasted query text in [#116050](https://github.com/getsentry/sentry/pull/116050) +- Hide size limit prompt while filtering by @nsdeschenes in [#115816](https://github.com/getsentry/sentry/pull/115816) + +#### Seer + +- Sort autofix project table by slug instead of name by @mrduncan in [#115642](https://github.com/getsentry/sentry/pull/115642) +- Keep repo loading indicator active by @scttcper in [#115854](https://github.com/getsentry/sentry/pull/115854) +- Pass issue short ID to coding agents by @JoshFerge in [#115838](https://github.com/getsentry/sentry/pull/115838) +- Make ToolResult.content optional to prevent Pydantic validation error by @sentry in [#115630](https://github.com/getsentry/sentry/pull/115630) + +#### Settings + +- Fix CI permission checkbox not reflecting state by @scttcper in [#116055](https://github.com/getsentry/sentry/pull/116055) +- Restore title on accept-invite and accept-transfer pages by @natemoo-re in [#116013](https://github.com/getsentry/sentry/pull/116013) +- Fix Seer drawer stopping point not changing on mutate from "No Automation" by @srest2021 in [#115847](https://github.com/getsentry/sentry/pull/115847) + +#### Snapshots + +- Add instrumentation logging to snapshot download stream in [#116079](https://github.com/getsentry/sentry/pull/116079) +- Add timeout override for snapshot download in emmett gateway by @NicoHinderling in [#116078](https://github.com/getsentry/sentry/pull/116078) + +#### Tests + +- Don't include trace context in symbolicator snapshots in [#116275](https://github.com/getsentry/sentry/pull/116275) +- Use findByRole for async options in opJsonPath.spec.tsx by @sentry in [#115645](https://github.com/getsentry/sentry/pull/115645) +- Correct monitor form crontab test with fireEvent by @sentry in [#115644](https://github.com/getsentry/sentry/pull/115644) +- Update staleTime and add default mocks for external issue tests by @sentry in [#115646](https://github.com/getsentry/sentry/pull/115646) + +#### Tracemetrics + +- Use equation alias format for widget builder in [#116213](https://github.com/getsentry/sentry/pull/116213) +- Expand selector dropdown menu width to 100% by @narsaynorath in [#116026](https://github.com/getsentry/sentry/pull/116026) +- Drop placeholder unit and always use none by @narsaynorath in [#116007](https://github.com/getsentry/sentry/pull/116007) +- Pass project and env in request filters for filter by @narsaynorath in [#115920](https://github.com/getsentry/sentry/pull/115920) + +#### Ui + +- Add inset focus ring to SimpleTable header cells in [#116276](https://github.com/getsentry/sentry/pull/116276) +- Increase dropdown z-index to appear above sidebar by @jameskeane in [#116139](https://github.com/getsentry/sentry/pull/116139) +- Add self signed package to support https by @scttcper in [#115941](https://github.com/getsentry/sentry/pull/115941) + +#### Workflow Engine + +- Sanitize corrupted dynamic_form_fields choice labels by @malwilley in [#115855](https://github.com/getsentry/sentry/pull/115855) +- Normalize error.handled values to 0/1 by @kcons in [#115740](https://github.com/getsentry/sentry/pull/115740) + +#### Other + +- (a11y) Add missing alt attributes to context icons and feedback images by @sentry-junior in [#115772](https://github.com/getsentry/sentry/pull/115772) +- (agents) Use minVersion in SDK update alert for consistency by @obostjancic in [#115714](https://github.com/getsentry/sentry/pull/115714) +- (api-docs) Correct event/replay/processing-error ID schemas in [#116201](https://github.com/getsentry/sentry/pull/116201) +- (apigw) Disable asyncpg statement cache (issues with pgbouncer) by @gi0baro in [#115992](https://github.com/getsentry/sentry/pull/115992) +- (attachments) Infer MIME type from filename when stored as octet-stream by @sentry-junior in [#115977](https://github.com/getsentry/sentry/pull/115977) +- (auth) Verify primary email on password reset by @michelletran-sentry in [#115651](https://github.com/getsentry/sentry/pull/115651) +- (autofix) Prevent loading spinner clip in artifact loading card by @priscilawebdev in [#115988](https://github.com/getsentry/sentry/pull/115988) +- (billing) Added fix to convert snuba sentry enum to the proto enum for usage stats by @krithikravi in [#115856](https://github.com/getsentry/sentry/pull/115856) +- (code-mapping) Update codeowners GET endpoint and tests in [#116309](https://github.com/getsentry/sentry/pull/116309) +- (codeblock) Improve nested scroll by @natemoo-re in [#115839](https://github.com/getsentry/sentry/pull/115839) +- (crons) De-flake "prefills with an existing monitor" test by @priscilawebdev in [#115782](https://github.com/getsentry/sentry/pull/115782) +- (cross-events) Correct styling based off date selection by @nsdeschenes in [#116124](https://github.com/getsentry/sentry/pull/116124) +- (cursored-scheduler) Recalculate batch size on tick interval change by @roggenkemper in [#115888](https://github.com/getsentry/sentry/pull/115888) +- (data_export) Cap export row limit at 10k for all callers by @manessaraj in [#116048](https://github.com/getsentry/sentry/pull/116048) +- (escalating) Register issue_velocity referrer in Referrer enum by @cvxluo in [#115812](https://github.com/getsentry/sentry/pull/115812) +- (feedback) Downgrade log level for insufficient feedback count in [#116247](https://github.com/getsentry/sentry/pull/116247) +- (forms) Preserve choice value types when submitting sentry app forms by @priscilawebdev in [#115869](https://github.com/getsentry/sentry/pull/115869) +- (grouping) Parameterize error message fingerprint variables by @lobsterkatie in [#115496](https://github.com/getsentry/sentry/pull/115496) +- (issue search) Fix invalid search query error message for device classes in [#116243](https://github.com/getsentry/sentry/pull/116243) +- (issue-detection) Add plural KBLayouts_iPhone.dat to FileIO ignore list by @roggenkemper in [#116182](https://github.com/getsentry/sentry/pull/116182) +- (jira) Bind JWT iss to body clientKey on install webhook by @michelletran-sentry in [#114225](https://github.com/getsentry/sentry/pull/114225) +- (kafkapublisher) Leaks memory: rdkafka stats grow without poll() in [#116123](https://github.com/getsentry/sentry/pull/116123) +- (members) Scope invite-request role updates to caller's allowed roles by @oioki in [#115807](https://github.com/getsentry/sentry/pull/115807) +- (migrations) Get rid of progress bar by @ceorourke in [#115691](https://github.com/getsentry/sentry/pull/115691) +- (mypy) Fix import location by @kcons in [#115654](https://github.com/getsentry/sentry/pull/115654) +- (ourlogs) Reset column sort to default on third click by @JoshuaKGoldberg in [#115751](https://github.com/getsentry/sentry/pull/115751) +- (pageFilters) Clear shift-click anchor on empty selection by @priscilawebdev in [#115472](https://github.com/getsentry/sentry/pull/115472) +- (profiles) Indicate invalid page URL state as error by @JoshuaKGoldberg in [#115897](https://github.com/getsentry/sentry/pull/115897) +- (profiling) Render single-sample continuous profile chunks in [#116234](https://github.com/getsentry/sentry/pull/116234) +- (rate-limit) Tighten rate limits on test notification endpoints by @nora-shap in [#115613](https://github.com/getsentry/sentry/pull/115613) +- (ratelimits) Handle AnonymousUser missing is_sentry_app attribute in [#116251](https://github.com/getsentry/sentry/pull/116251) +- (relay) Make trustedRelays optional on Organization type by @TkDodo in [#116014](https://github.com/getsentry/sentry/pull/116014) +- (releases) Pass Environment objects to get_latest_release by @mrduncan in [#115637](https://github.com/getsentry/sentry/pull/115637) +- (repositories) Fix deletion ordering for ProjectRepository children by @wedamija in [#115739](https://github.com/getsentry/sentry/pull/115739) +- (security) Add project-level access check to GroupEventJsonView by @roggenkemper in [#116184](https://github.com/getsentry/sentry/pull/116184) +- (self-hosted) Avoid install wizard mail TLS/SSL immutable errors by @aldy505 in [#114011](https://github.com/getsentry/sentry/pull/114011) +- (static) Add missing nonce attribute on app.js preload link by @oioki in [#115984](https://github.com/getsentry/sentry/pull/115984) +- (supergroups) Move to post process task in [#116195](https://github.com/getsentry/sentry/pull/116195) +- (tabs) Stop tooltips in overflowMenuItems from crashing the page by @TkDodo in [#115993](https://github.com/getsentry/sentry/pull/115993) +- (traces) Handle deleted groups in trace endpoint in [#116248](https://github.com/getsentry/sentry/pull/116248) +- (web) Redirect /scraps to stories by @priscilawebdev in [#115776](https://github.com/getsentry/sentry/pull/115776) +- (webauthn) Handle missing WebAuthn challenge data in [#116167](https://github.com/getsentry/sentry/pull/116167) +- (webhooks) Route sentry app actions through send_alert_webhook_v2 in new path in [#115975](https://github.com/getsentry/sentry/pull/115975) +- (workflow) Use Group cache in get_group_to_groupevent by @kcons in [#115960](https://github.com/getsentry/sentry/pull/115960) +- (workflows) Filter out workflows from other organizations in [#116075](https://github.com/getsentry/sentry/pull/116075) +- Add catch-all path to explore route and redirect to index by @adrianviquez in [#116066](https://github.com/getsentry/sentry/pull/116066) +- Revert "fix(ourlogs): stabilized column widths during scrolling (#115389)" by @getsentry-bot in [84d0139e](https://github.com/getsentry/sentry/commit/84d0139e1cc325da0c0e75380bc7dc3099c5f400) + +### Documentation 📚 + +- (replays) Fix OpenAPI schema/example for replay details response by @JoshFerge in [#115752](https://github.com/getsentry/sentry/pull/115752) +- (scraps) Render to HTML pattern by @natemoo-re in [#115943](https://github.com/getsentry/sentry/pull/115943) +- (snapshots) Add public OpenAPI documentation for snapshot endpoints in [#116231](https://github.com/getsentry/sentry/pull/116231) + +### Internal Changes 🔧 + +#### Admin + +- Migrate forkCustomer off browserHistory by @evanpurkhiser in [#115915](https://github.com/getsentry/sentry/pull/115915) +- Drop browserHistory and HOCs from ResultGrid by @evanpurkhiser in [#115908](https://github.com/getsentry/sentry/pull/115908) + +#### Alerts + +- Clean up usage of AlertRuleSerializerResponse in [#116218](https://github.com/getsentry/sentry/pull/116218) +- Remove AlertRuleSerializer in [#116052](https://github.com/getsentry/sentry/pull/116052) +- Remove PUT and POST legacy paths for metric alerts by @ceorourke in [#116017](https://github.com/getsentry/sentry/pull/116017) +- Fully remove metric alert columns on NotificationMessage by @ceorourke in [#116025](https://github.com/getsentry/sentry/pull/116025) +- Remove legacy issue alert delete endpoint code by @ceorourke in [#115954](https://github.com/getsentry/sentry/pull/115954) +- Add index on date_added, soft remove metric alert colu… by @ceorourke in [#115823](https://github.com/getsentry/sentry/pull/115823) +- Remove legacy issue alert GET endpoint code by @ceorourke in [#115948](https://github.com/getsentry/sentry/pull/115948) +- Migrate issue rule editor off browserHistory by @evanpurkhiser in [#115924](https://github.com/getsentry/sentry/pull/115924) +- Remove legacy metric alerts code by @ceorourke in [#115865](https://github.com/getsentry/sentry/pull/115865) +- Remove incident serializer usages by @ceorourke in [#115845](https://github.com/getsentry/sentry/pull/115845) +- Remove legacy metric alert handlers by @ceorourke in [#115850](https://github.com/getsentry/sentry/pull/115850) +- Remove metric alert columns on NotificationMessage by @ceorourke in [#115578](https://github.com/getsentry/sentry/pull/115578) +- Replace AlertStore with GlobalAlertProvider + useGlobalAlerts by @evanpurkhiser in [#115315](https://github.com/getsentry/sentry/pull/115315) +- Clean up old metric alert rows in NotificationMessage by @ceorourke in [#115647](https://github.com/getsentry/sentry/pull/115647) +- Remove unused team alerts endpoints by @ceorourke in [#115339](https://github.com/getsentry/sentry/pull/115339) +- Remove team alerts triggered modal by @ceorourke in [#115336](https://github.com/getsentry/sentry/pull/115336) + +#### Api + +- Type nullable fields in the base group serializer by @cvxluo in [#116068](https://github.com/getsentry/sentry/pull/116068) +- Move `GroupEventDetailsResponse` to event serializer module by @cvxluo in [#116058](https://github.com/getsentry/sentry/pull/116058) +- Resolve suggested_api from Django route names by @strongs in [#115907](https://github.com/getsentry/sentry/pull/115907) +- Migrate auth-error navigation off browserHistory by @evanpurkhiser in [#115935](https://github.com/getsentry/sentry/pull/115935) +- Move to_valid_int_id to a more central location by @kcons in [#115581](https://github.com/getsentry/sentry/pull/115581) + +#### Apigw + +- Add `abort_with_json` as an util, allow config httpx client limits by @gi0baro in [#116037](https://github.com/getsentry/sentry/pull/116037) +- Enhance proxy implementation by @gi0baro in [#115892](https://github.com/getsentry/sentry/pull/115892) + +#### Autofix + +- Remove SCM requirement from autofix in [#116206](https://github.com/getsentry/sentry/pull/116206) +- Remove legacy autofix path from GroupAutofixEndpoint by @chromy in [#116164](https://github.com/getsentry/sentry/pull/116164) +- Always use explorer mode in GroupAutofixEndpoint by @chromy in [#116162](https://github.com/getsentry/sentry/pull/116162) +- Remove old useAutofixData hook by @Zylphrex in [#116103](https://github.com/getsentry/sentry/pull/116103) +- Remove intelligence level from group ai autofix endpoint by @Zylphrex in [#116145](https://github.com/getsentry/sentry/pull/116145) +- Add log for autofix introspection reason by @Zylphrex in [#116132](https://github.com/getsentry/sentry/pull/116132) +- Remove unused autofix v1 UI by @Zylphrex in [#116100](https://github.com/getsentry/sentry/pull/116100) +- Use new Markdown primitive in v3 cards by @priscilawebdev in [#115879](https://github.com/getsentry/sentry/pull/115879) +- Check repo connected before starting autofix by @Zylphrex in [#115648](https://github.com/getsentry/sentry/pull/115648) + +#### Conversations + +- Adopt scraps primitives for 4 wrappers by @priscilawebdev in [#116082](https://github.com/getsentry/sentry/pull/116082) +- Default to 24h period in sidebar link by @obostjancic in [#115873](https://github.com/getsentry/sentry/pull/115873) + +#### Dashboards + +- Remove text widget flag defintion in [#116212](https://github.com/getsentry/sentry/pull/116212) +- Remove text widget flag references frontend in [#116210](https://github.com/getsentry/sentry/pull/116210) +- Remove text widget flag references backend in [#116207](https://github.com/getsentry/sentry/pull/116207) +- Migrate utils.tsx off browserHistory by @evanpurkhiser in [#115923](https://github.com/getsentry/sentry/pull/115923) +- Migrate detail.tsx off browserHistory to useNavigate by @evanpurkhiser in [#115903](https://github.com/getsentry/sentry/pull/115903) + +#### Discover + +- Migrate fieldRenderers off browserHistory by @evanpurkhiser in [#115938](https://github.com/getsentry/sentry/pull/115938) +- Migrate transactionsList off browserHistory by @evanpurkhiser in [#115926](https://github.com/getsentry/sentry/pull/115926) +- Migrate queryList off browserHistory by @evanpurkhiser in [#115913](https://github.com/getsentry/sentry/pull/115913) +- Migrate savedQuery off browserHistory by @evanpurkhiser in [#115912](https://github.com/getsentry/sentry/pull/115912) +- Migrate results.tsx off browserHistory by @evanpurkhiser in [#115909](https://github.com/getsentry/sentry/pull/115909) + +#### Dynamic Sampling + +- In per org pipeline, retrieve the project ids in config retrieval, just once by @shellmayr in [#115983](https://github.com/getsentry/sentry/pull/115983) +- Use already queried data when computing boosted release platform by @cmanallen in [#115792](https://github.com/getsentry/sentry/pull/115792) +- Rename dynamic sampling status enum by @shellmayr in [#115360](https://github.com/getsentry/sentry/pull/115360) +- Cleanup transaction based health check rule by @shellmayr in [#115471](https://github.com/getsentry/sentry/pull/115471) +- Add status for snuba timeouts by @shellmayr in [#115359](https://github.com/getsentry/sentry/pull/115359) + +#### Eslint + +- Turn on no-unsafe-member-access for scraps in [#116004](https://github.com/getsentry/sentry/pull/116004) +- Add curly rule to prettier config section by @sentry-junior in [#116158](https://github.com/getsentry/sentry/pull/116158) +- Enable no-unsafe-call for scraps by @TkDodo in [#115981](https://github.com/getsentry/sentry/pull/115981) +- Enable no-unsafe-arguments in scraps by @TkDodo in [#115877](https://github.com/getsentry/sentry/pull/115877) +- Enable no-unsafe-return for scraps by @TkDodo in [#115722](https://github.com/getsentry/sentry/pull/115722) + +#### Flags + +- Remove organizations:dashboards-drilldown-flow in [#115670](https://github.com/getsentry/sentry/pull/115670) +- Remove organizations:scoped-partner-oauth by @wedamija in [#115675](https://github.com/getsentry/sentry/pull/115675) +- Remove organizations:dashboards-import by @wedamija in [#115671](https://github.com/getsentry/sentry/pull/115671) +- Remove organizations:revoke-org-auth-on-slug-rename by @wedamija in [#114807](https://github.com/getsentry/sentry/pull/114807) +- Remove organizations:tracemetrics-alerts gates (backend) by @wedamija in [#115019](https://github.com/getsentry/sentry/pull/115019) +- Remove organizations:workflow-engine-metric-alert-group-by-creation by @wedamija in [#114805](https://github.com/getsentry/sentry/pull/114805) +- Remove organizations:ourlogs-stats, replace with `organizations:explore-dev-features` and move it to a permanent flag by @wedamija in [#115673](https://github.com/getsentry/sentry/pull/115673) +- Remove organizations:tracemetrics-alerts gates (frontend) by @wedamija in [#115018](https://github.com/getsentry/sentry/pull/115018) +- Remove organizations:performance-mep-reintroduce-histograms by @wedamija in [#115674](https://github.com/getsentry/sentry/pull/115674) +- Remove organizations:ingest-through-trusted-relays-only by @wedamija in [#115682](https://github.com/getsentry/sentry/pull/115682) +- Remove organizations:pr-page by @wedamija in [#115686](https://github.com/getsentry/sentry/pull/115686) +- Remove organizations:performance-remove-metrics-compatibility-fallback by @wedamija in [#115684](https://github.com/getsentry/sentry/pull/115684) +- Remove organizations:performance-transaction-name-only-search by @wedamija in [#115685](https://github.com/getsentry/sentry/pull/115685) +- Remove organizations:starfish-mobile-ui-module by @wedamija in [#115687](https://github.com/getsentry/sentry/pull/115687) +- Move organizations:init-sentry-toolbar to permanent by @wedamija in [#115862](https://github.com/getsentry/sentry/pull/115862) +- Remove organizations:on-demand-metrics-extraction-experimental by @wedamija in [#115683](https://github.com/getsentry/sentry/pull/115683) +- Remove organizations:view-hierarchies-options-dev by @wedamija in [#115678](https://github.com/getsentry/sentry/pull/115678) +- Remove organizations:issues-suspect-tags by @wedamija in [#115680](https://github.com/getsentry/sentry/pull/115680) +- Remove organizations:performance-spans-fields-stats by @wedamija in [#115679](https://github.com/getsentry/sentry/pull/115679) +- Remove organizations:update-action-status by @wedamija in [#115676](https://github.com/getsentry/sentry/pull/115676) +- Remove organizations:sentry-app-webhook-requests by @wedamija in [#114813](https://github.com/getsentry/sentry/pull/114813) + +#### Forms + +- Migrate projectFiltersSettings to scraps form system by @TkDodo in [#115783](https://github.com/getsentry/sentry/pull/115783) +- Migrate highlights settings by @priscilawebdev in [#115778](https://github.com/getsentry/sentry/pull/115778) +- Migrate early features settings by @priscilawebdev in [#115777](https://github.com/getsentry/sentry/pull/115777) +- Migrate keyRateLimitsForm off legacy Form by @priscilawebdev in [#115265](https://github.com/getsentry/sentry/pull/115265) +- Migrate addCodeOwnerModal off legacy Form by @priscilawebdev in [#115256](https://github.com/getsentry/sentry/pull/115256) + +#### Instrumentation Issues + +- Remove issue type config and types by @ArthurKnaus in [#115718](https://github.com/getsentry/sentry/pull/115718) +- Remove fix section UI by @ArthurKnaus in [#115717](https://github.com/getsentry/sentry/pull/115717) +- Remove nav entries and route by @ArthurKnaus in [#115715](https://github.com/getsentry/sentry/pull/115715) + +#### Issues + +- Use standard logging pattern in group details endpoint in [#116262](https://github.com/getsentry/sentry/pull/116262) +- Remove redundant check on `event_id` in [#116261](https://github.com/getsentry/sentry/pull/116261) +- Indicate duration when "Since First Seen" is selected in [#115533](https://github.com/getsentry/sentry/pull/115533) +- Remove grouping store by @scttcper in [#115970](https://github.com/getsentry/sentry/pull/115970) +- Remove the option gating custom tag resolver logic by @shashjar in [#116024](https://github.com/getsentry/sentry/pull/116024) +- Add multiple property to select field schema by @amy-chen23 in [#115814](https://github.com/getsentry/sentry/pull/115814) +- Prevent assigning issues to deactivated users by @amy-chen23 in [#115668](https://github.com/getsentry/sentry/pull/115668) +- Update frontend types after removing unnecessary issue activity metadata for Seer actions by @shashjar in [#115734](https://github.com/getsentry/sentry/pull/115734) +- Remove unnecessary structured metadata under issue activities for Seer actions by @shashjar in [#115732](https://github.com/getsentry/sentry/pull/115732) +- Remove stray `use_flagpole_for_all_features` usage by @lobsterkatie in [#115537](https://github.com/getsentry/sentry/pull/115537) + +#### Jest + +- Mark flaky jest tests - 2026-05-25 by @cursor in [#116121](https://github.com/getsentry/sentry/pull/116121) +- Mark flaky jest tests - 2026-05-18 by @cursor in [#115729](https://github.com/getsentry/sentry/pull/115729) + +#### Onboarding + +- Convert CreateSampleEventButton to functional component by @ryan953 in [#115830](https://github.com/getsentry/sentry/pull/115830) +- Adopt useModal in onboarding flows by @evanpurkhiser in [#115127](https://github.com/getsentry/sentry/pull/115127) + +#### Ourlogs + +- Remove `expanded` and window virtualizer from LogsInfiniteTable by @JoshuaKGoldberg in [#115884](https://github.com/getsentry/sentry/pull/115884) +- Remove ourlogs-table-expando flag backend code by @JoshuaKGoldberg in [#115794](https://github.com/getsentry/sentry/pull/115794) +- Remove ourlogs-table-expando flag frontend code by @JoshuaKGoldberg in [#115793](https://github.com/getsentry/sentry/pull/115793) + +#### Preprod + +- Simplify project filtering in latest base snapshot endpoint in [#116237](https://github.com/getsentry/sentry/pull/116237) +- Optimize snapshot download with connection reuse and progressive streaming by @NicoHinderling in [#116051](https://github.com/getsentry/sentry/pull/116051) +- Use TimeToIdle instead of TimeToLive for upload expiration by @NicoHinderling in [#116033](https://github.com/getsentry/sentry/pull/116033) +- Virtualize snapshot sidebar for 40k image builds by @NicoHinderling in [#115836](https://github.com/getsentry/sentry/pull/115836) +- Replace snapshot status badges with plain text by @mtopo27 in [#115659](https://github.com/getsentry/sentry/pull/115659) +- Remove deprecated snapshot detail TS types and update debug modal by @mtopo27 in [#115653](https://github.com/getsentry/sentry/pull/115653) +- Remove deprecated comparison_run_info and approval_info from snapshot detail API by @mtopo27 in [#115652](https://github.com/getsentry/sentry/pull/115652) + +#### Replays + +- Remove unused data export notifications endpoint in [#116232](https://github.com/getsentry/sentry/pull/116232) +- Replace useFetchSequentialPages with useInfiniteQuery by @ryan953 in [#116115](https://github.com/getsentry/sentry/pull/116115) +- Use shared platform icon resolver by @priscilawebdev in [#115705](https://github.com/getsentry/sentry/pull/115705) + +#### Repositories + +- Simplify ProjectRepoLink serializer and make url better by @wedamija in [#115826](https://github.com/getsentry/sentry/pull/115826) +- Drop old project/repository columns by @wedamija in [#115741](https://github.com/getsentry/sentry/pull/115741) +- Remove `project` and `repo` columns from `SeerProjectRepository` and `RepositoryProjectPathConfig` by @wedamija in [#115663](https://github.com/getsentry/sentry/pull/115663) +- Add unique index on `repository_project` columns by @wedamija in [#115662](https://github.com/getsentry/sentry/pull/115662) +- Remove feature flag branching for RepositoryProjectPathConfig reads by @wedamija in [#115607](https://github.com/getsentry/sentry/pull/115607) +- Remove feature flag branching for SeerProjectRepository reads by @wedamija in [#115606](https://github.com/getsentry/sentry/pull/115606) + +#### Scm + +- Merge integration-proxy endpoints by @cmanallen in [#116028](https://github.com/getsentry/sentry/pull/116028) +- Add quota policy for GitHub API requests by @cmanallen in [#115657](https://github.com/getsentry/sentry/pull/115657) + +#### Seer + +- Move agent access check from entrypoint to operator in [#116143](https://github.com/getsentry/sentry/pull/116143) +- Use `elif` instead of `if` in actionability filter logic for clarity in [#116203](https://github.com/getsentry/sentry/pull/116203) +- Remove seer-slack-workflows and seer-slack-explorer flags in [#116140](https://github.com/getsentry/sentry/pull/116140) +- Simplify block component states by @natemoo-re in [#115589](https://github.com/getsentry/sentry/pull/115589) +- Persist Seer Explorer input draft per run by @aliu39 in [#115919](https://github.com/getsentry/sentry/pull/115919) +- Replace chat history dropdown with searchable CompactSelect by @JonasBa in [#115843](https://github.com/getsentry/sentry/pull/115843) +- Rm severity group-seer option by @kddubey in [#115768](https://github.com/getsentry/sentry/pull/115768) +- Rm severity conditional routing by @kddubey in [#115765](https://github.com/getsentry/sentry/pull/115765) +- Option to route severity to group-seer by @kddubey in [#115702](https://github.com/getsentry/sentry/pull/115702) + +#### Settings + +- Update `action` prop and remove `hasPageFrame` by @natemoo-re in [#115815](https://github.com/getsentry/sentry/pull/115815) +- Update breadcrumbTitle spec for routes prop removal by @ryan953 in [#115866](https://github.com/getsentry/sentry/pull/115866) +- Move routes from prop to useRoutes() in BreadcrumbTitle by @ryan953 in [#115766](https://github.com/getsentry/sentry/pull/115766) +- Convert OrganizationAccessRequests to function component with fetchMutation by @ryan953 in [#115813](https://github.com/getsentry/sentry/pull/115813) +- Replace billing navigation config with a react-hook by @evanpurkhiser in [#115808](https://github.com/getsentry/sentry/pull/115808) + +#### Slack + +- Remove widget unfurl feature flags by @DominikB2014 in [#116128](https://github.com/getsentry/sentry/pull/116128) +- Move ephemeral message sending to workspace module by @leeandher in [#115586](https://github.com/getsentry/sentry/pull/115586) + +#### Snuba + +- Port query subscriptions consumer to taskbroker raw mode in [#116288](https://github.com/getsentry/sentry/pull/116288) +- Update tests for removal of boolean double-writing in [#111421](https://github.com/getsentry/sentry/pull/111421) +- Stop dropping deprecated spans dataset in reset_snuba by @phacops in [#115973](https://github.com/getsentry/sentry/pull/115973) +- Add exception type for snuba timeouts by @shellmayr in [#115362](https://github.com/getsentry/sentry/pull/115362) + +#### Spans + +- Remove tests for deprecated standalone spans storage in [#116147](https://github.com/getsentry/sentry/pull/116147) +- Extract flush_segment pipeline helpers by @lvthanh03 in [#116149](https://github.com/getsentry/sentry/pull/116149) +- Split load_segment_data into helper steps by @lvthanh03 in [#116136](https://github.com/getsentry/sentry/pull/116136) +- Split process_spans into typed pipeline steps by @lvthanh03 in [#115858](https://github.com/getsentry/sentry/pull/115858) +- Add back cumulative flusher log and flushed segments log by @lvthanh03 in [#116015](https://github.com/getsentry/sentry/pull/116015) +- Extract span buffer observability models by @lvthanh03 in [#115849](https://github.com/getsentry/sentry/pull/115849) +- Remove unused dropped_segments logic and zrem cleanup option by @lvthanh03 in [#115806](https://github.com/getsentry/sentry/pull/115806) +- Add isolated load segment data coverage by @lvthanh03 in [#115804](https://github.com/getsentry/sentry/pull/115804) +- Add add-buffer Lua script tests by @lvthanh03 in [#115801](https://github.com/getsentry/sentry/pull/115801) + +#### Ts + +- Remove RouteComponent by @evanpurkhiser in [#115999](https://github.com/getsentry/sentry/pull/115999) +- Remove unused RouteContextInterface type by @evanpurkhiser in [#115996](https://github.com/getsentry/sentry/pull/115996) + +#### Typing + +- Remove `tests.sentry.api.helpers.test_group_index` from mypy ignore list in [#116199](https://github.com/getsentry/sentry/pull/116199) +- Remove `tests.sentry.issues.test_utils` from mypy ignore list in [#116070](https://github.com/getsentry/sentry/pull/116070) + +#### Utils + +- Make ParityChecker print out mismatches in a PII safe way in [#116038](https://github.com/getsentry/sentry/pull/116038) +- Various clarifications in `SafeRolloutComparator` code in [#115946](https://github.com/getsentry/sentry/pull/115946) + +#### Workflow Engine + +- Remove unused const in [#116230](https://github.com/getsentry/sentry/pull/116230) +- Edit flag with the correct prefix in [#116198](https://github.com/getsentry/sentry/pull/116198) + +#### Other + +- (✂️) Remove form leftovers by @TkDodo in [#115724](https://github.com/getsentry/sentry/pull/115724) +- (aci) Minor cleanup to delayed workflow processing by @saponifi3d in [#115758](https://github.com/getsentry/sentry/pull/115758) +- (activity) Remove duplicate call to calculate initial priority from group metadata by @shashjar in [#116067](https://github.com/getsentry/sentry/pull/116067) +- (api-docs) Add GroupDetailsResponse type, params, and example in [#116113](https://github.com/getsentry/sentry/pull/116113) +- (autopilot) Delete autopilot module and all references by @vgrozdanic in [#115466](https://github.com/getsentry/sentry/pull/115466) +- (billing) Bump sentry-protos to 0.13.0 in [#116133](https://github.com/getsentry/sentry/pull/116133) +- (billing-platform) Log requests in service methods by @brendanhsentry in [#115971](https://github.com/getsentry/sentry/pull/115971) +- (bootstrap) Parallelize locale and moment chunk fetches by @JonasBa in [#115727](https://github.com/getsentry/sentry/pull/115727) +- (cells) Remove the includeFeatureFlags query param from the org listing request by @lynnagara in [#115833](https://github.com/getsentry/sentry/pull/115833) +- (ci) Split MDX typechecking into its own gated job by @natemoo-re in [#115744](https://github.com/getsentry/sentry/pull/115744) +- (compactSelect) Remove unused onSectionToggle callback by @TkDodo in [#115809](https://github.com/getsentry/sentry/pull/115809) +- (deps) Update sentry conventions package by @nsdeschenes in [#115989](https://github.com/getsentry/sentry/pull/115989) +- (detectors) Split connected and project alerts into separate sections by @malwilley in [#115947](https://github.com/getsentry/sentry/pull/115947) +- (dynamic-ampling) Add a metric counter to see if we sometimes have implicit-factor < 1 by @constantinius in [#115834](https://github.com/getsentry/sentry/pull/115834) +- (eap) Query typed-colon attribute as boolean instead of number in [#116299](https://github.com/getsentry/sentry/pull/116299) +- (events) Migrate ContextIcon to platformicons by @priscilawebdev in [#115701](https://github.com/getsentry/sentry/pull/115701) +- (explore) Port toolTags to scraps layout primitives by @priscilawebdev in [#116160](https://github.com/getsentry/sentry/pull/116160) +- (flagpole-wildcard-ops) Adding support for not_matches op (python) by @Abdkhan14 in [#115901](https://github.com/getsentry/sentry/pull/115901) +- (github-enterprise) Use monospace font for private key field in [#116303](https://github.com/getsentry/sentry/pull/116303) +- (hooks) Replace HookStore with a plain hook registry by @evanpurkhiser in [#115811](https://github.com/getsentry/sentry/pull/115811) +- (hookStore) Change HookStore to single-value semantics by @evanpurkhiser in [#115796](https://github.com/getsentry/sentry/pull/115796) +- (integrations) Add backfill_github_external_actor.gh_api_fetch_interval_s by @hobzcalvin in [#115763](https://github.com/getsentry/sentry/pull/115763) +- (issueDetails) Collapse ParticipantList wrapper div to a Flex by @evanpurkhiser in [#116175](https://github.com/getsentry/sentry/pull/116175) +- (issueDiff) Refactor event data fetching to use useQueries in [#116042](https://github.com/getsentry/sentry/pull/116042) +- (jira) Add Forge app manifest for Connect-to-Forge migration by @BYK in [#115603](https://github.com/getsentry/sentry/pull/115603) +- (lint) Ban React.Fragment in favor of named Fragment import by @natemoo-re in [#115939](https://github.com/getsentry/sentry/pull/115939) +- (metrics) Split metric attribute tree actions by @nsdeschenes in [#115641](https://github.com/getsentry/sentry/pull/115641) +- (mypy) Rename sort_stronger_modules to sort_weaklist in [#116106](https://github.com/getsentry/sentry/pull/116106) +- (np) Refactors notification context into a new class by @GabeVillalobos in [#113495](https://github.com/getsentry/sentry/pull/113495) +- (organization-create) Drop dead browserHistory comment by @evanpurkhiser in [#115928](https://github.com/getsentry/sentry/pull/115928) +- (overrides) Finish hook → override terminology rename by @evanpurkhiser in [#115825](https://github.com/getsentry/sentry/pull/115825) +- (oxfmt) Ignore pyproject.toml by @sentry-junior in [#116181](https://github.com/getsentry/sentry/pull/116181) +- (pipeline) Use Button busy prop for advancing state by @evanpurkhiser in [#116179](https://github.com/getsentry/sentry/pull/116179) +- (plugins) Inline PluginComponentBase into its two subclasses by @ryan953 in [#116112](https://github.com/getsentry/sentry/pull/116112) +- (profiling) Rename explore/profiling URL to explore/profiles in [#115627](https://github.com/getsentry/sentry/pull/115627) +- (project-detail) Migrate projectCharts off browserHistory by @evanpurkhiser in [#115916](https://github.com/getsentry/sentry/pull/115916) +- (releases) Convert ReleaseIssues to functional component by @ryan953 in [#115698](https://github.com/getsentry/sentry/pull/115698) +- (replay) Rename Breadcrumbs tab to Activity by @DominikB2014 in [#115278](https://github.com/getsentry/sentry/pull/115278) +- (routeAnalytics) Replace HookStore persistCallback with a plain module cell by @evanpurkhiser in [#115810](https://github.com/getsentry/sentry/pull/115810) +- (saved-queries) Align list endpoint access checks with detail by @oioki in [#115379](https://github.com/getsentry/sentry/pull/115379) +- (scraps) Adopt useModal in remaining call sites by @evanpurkhiser in [#115132](https://github.com/getsentry/sentry/pull/115132) +- (search) Add EAP API attribute visibility checks in [#116091](https://github.com/getsentry/sentry/pull/116091) +- (seer-explorer) Replace useSeerExplorerRunId with chat state context by @JonasBa in [#115631](https://github.com/getsentry/sentry/pull/115631) +- (segments) Add local cache for release creation and modification by @cmanallen in [#116173](https://github.com/getsentry/sentry/pull/116173) +- (snapshots) Batch image fetches and add timeouts for snapshot download by @NicoHinderling in [#116076](https://github.com/getsentry/sentry/pull/116076) +- (source-map-processing-errors) Emitting metric irrespective of … by @Abdkhan14 in [#115661](https://github.com/getsentry/sentry/pull/115661) +- (span-buffer) Remove flusher and buffer logger options by @untitaker in [#115487](https://github.com/getsentry/sentry/pull/115487) +- (static) Add preload hint for app.js entrypoint by @JonasBa in [#115800](https://github.com/getsentry/sentry/pull/115800) +- (tasks) Remove base64 encoding for bytes parameters in tasks in [#116293](https://github.com/getsentry/sentry/pull/116293) +- (taskworker) Move devenv for profiles consumer to taskbroker in [#116194](https://github.com/getsentry/sentry/pull/116194) +- (teams) Avoid organization N+1 in team projects by @scttcper in [#115735](https://github.com/getsentry/sentry/pull/115735) +- (test) Remove router return from initializeOrg by @evanpurkhiser in [#116002](https://github.com/getsentry/sentry/pull/116002) +- (tests) Replace `as jest.Mock` casts with `jest.mocked()` by @evanpurkhiser in [#115790](https://github.com/getsentry/sentry/pull/115790) +- (trace) Migrate virtualizedViewManager off browserHistory by @evanpurkhiser in [#115927](https://github.com/getsentry/sentry/pull/115927) +- (traceDrawer) Replace local SectionDivider/VerticalLine with Scraps Separator in [#116168](https://github.com/getsentry/sentry/pull/116168) +- (types) Add mypy types for sentry.search.snuba.executors by @saponifi3d in [#114994](https://github.com/getsentry/sentry/pull/114994) +- (ui) Upgrade lodash, figma connect by @scttcper in [#115950](https://github.com/getsentry/sentry/pull/115950) +- (vercel) Add logs on failure to add project in [#116235](https://github.com/getsentry/sentry/pull/116235) +- (workflows) Avoid a query on Organization in delayed_workflow by @kcons in [#115965](https://github.com/getsentry/sentry/pull/115965) +- Instruct agents to prefer type inference over call-side generics in [#116290](https://github.com/getsentry/sentry/pull/116290) +- Add right padding to seer header copy button in [#116286](https://github.com/getsentry/sentry/pull/116286) +- Remove code coverage stacktrace insights in [#115417](https://github.com/getsentry/sentry/pull/115417) +- Remove autopilot CODEOWNERS entries by @vgrozdanic in [#116085](https://github.com/getsentry/sentry/pull/116085) +- Replace withOrganization with useOrganization in function components by @evanpurkhiser in [#115343](https://github.com/getsentry/sentry/pull/115343) +- Remove withSentryRouter HOC by @evanpurkhiser in [#115949](https://github.com/getsentry/sentry/pull/115949) +- Migrate useRouter callsites to native RR6 hooks by @evanpurkhiser in [#115945](https://github.com/getsentry/sentry/pull/115945) +- Drop unused 'unmigratable' status literal from repo query types by @evanpurkhiser in [#115906](https://github.com/getsentry/sentry/pull/115906) +- Remove unmigratable repositories code path by @evanpurkhiser in [#115905](https://github.com/getsentry/sentry/pull/115905) +- Remove OrganizationConfigRepositoriesEndpoint by @evanpurkhiser in [#115898](https://github.com/getsentry/sentry/pull/115898) +- Remove unused PUT handler from repository details endpoint by @evanpurkhiser in [#115896](https://github.com/getsentry/sentry/pull/115896) +- Bump taskbroker-client to 0.1.15 by @bmckerry in [#115799](https://github.com/getsentry/sentry/pull/115799) +- Mark legacy react-router shim hooks as deprecated by @ryan953 in [#115767](https://github.com/getsentry/sentry/pull/115767) +- Merged Jest changedSince testing into main PR Jest job by @JoshuaKGoldberg in [#115549](https://github.com/getsentry/sentry/pull/115549) +- Replace browserHistory with useNavigate in useCleanQueryParamsOnRouteLeave by @ryan953 in [#115695](https://github.com/getsentry/sentry/pull/115695) +- Remove browserHistory by inlining navigate in upgradeNowModal callers by @ryan953 in [#115755](https://github.com/getsentry/sentry/pull/115755) +- Bump platformicons to 9.5.0 by @priscilawebdev in [#115707](https://github.com/getsentry/sentry/pull/115707) +- Bump new development version by @sentry-release-bot[bot] in [7ea81f9f](https://github.com/getsentry/sentry/commit/7ea81f9fbf91748936a96fa3105058751548bb07) + +### Other + +- fix(relocation) Remove invalid token scopes during export in [#116214](https://github.com/getsentry/sentry/pull/116214) +- chore(relocation) Exclude Email model from relocations v2 in [#116256](https://github.com/getsentry/sentry/pull/116256) +- chore(cells) Mainline org create via control in [#116046](https://github.com/getsentry/sentry/pull/116046) +- deps: Upgrade sentry-scm to 0.16.0 in [#116215](https://github.com/getsentry/sentry/pull/116215) +- chore(relocation) Remove unused outbox handler by @markstory in [#116030](https://github.com/getsentry/sentry/pull/116030) +- fix(relocation) Fix type errors when spawning a task by @markstory in [#116130](https://github.com/getsentry/sentry/pull/116130) +- Fix category missing by @noahsmartin in [#116056](https://github.com/getsentry/sentry/pull/116056) +- chore(relocations) Add bucket_path to RelocationFile by @markstory in [#116035](https://github.com/getsentry/sentry/pull/116035) +- chore(cells) Remove rollout option for connection pooling by @markstory in [#116011](https://github.com/getsentry/sentry/pull/116011) +- fix(ci) Don't capture log messages in RPC schema generation by @markstory in [#116003](https://github.com/getsentry/sentry/pull/116003) +- fix(typing) Remove sentry.middleware.auth from the ignore list by @markstory in [#115798](https://github.com/getsentry/sentry/pull/115798) +- feat(cells) Make organization avatar URL cell compatible by @markstory in [#115689](https://github.com/getsentry/sentry/pull/115689) +- deps(ui): Upgrade Rspack to v2, 124 fewer dependencies by @scttcper in [#113795](https://github.com/getsentry/sentry/pull/113795) +- o11y(seer): Track block content copy in Seer Explorer by @aliu39 in [#115900](https://github.com/getsentry/sentry/pull/115900) +- org-scoped URL for page export by @strongs in [#115844](https://github.com/getsentry/sentry/pull/115844) +- feat(cells) Use connection pools for cell RPC operations by @markstory in [#115827](https://github.com/getsentry/sentry/pull/115827) +- lint: enable jest/prefer-jest-mocked by @evanpurkhiser in [#115791](https://github.com/getsentry/sentry/pull/115791) +- feat(cells); Add org scoping to `GroupTagExportView` by @strongs in [#115841](https://github.com/getsentry/sentry/pull/115841) +- Remove legacy code paths for the combined rule endpoint by @ceorourke in [#115750](https://github.com/getsentry/sentry/pull/115750) +- Auto-create PRs for manual Seer handoff by @JoshFerge in [#115831](https://github.com/getsentry/sentry/pull/115831) +- feat(cells) Provision new orgs through control with feature flag by @markstory in [#115600](https://github.com/getsentry/sentry/pull/115600) +- Chore org index silo metrics by @markstory in [#115664](https://github.com/getsentry/sentry/pull/115664) +- o11y(assisted-query): Track error outcomes and reasons for AI query analytics by @aliu39 in [#115699](https://github.com/getsentry/sentry/pull/115699) +- deps(ui): Upgrade jest to 30.4 by @scttcper in [#115725](https://github.com/getsentry/sentry/pull/115725) + 26.5.0 ------ diff --git a/pyproject.toml b/pyproject.toml index 07e1c0cc6fce7d..af50cabb9b9540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ dependencies = [ "sentry-forked-email-reply-parser>=0.5.12.post1", "sentry-kafka-schemas>=2.1.27", "sentry-ophio>=1.1.3", + # sentry-options is only used in getsentry for now + "sentry-options>=1.0.13", "sentry-protos>=0.13.0", "sentry-redis-tools>=0.5.0", "sentry-relay>=0.9.27", @@ -1788,7 +1790,6 @@ module = [ "tests.sentry.api.endpoints.test_organization_auth_providers", "tests.sentry.api.endpoints.test_organization_auth_token_details", "tests.sentry.api.endpoints.test_organization_auth_tokens", - "tests.sentry.api.endpoints.test_organization_code_mapping_codeowners", "tests.sentry.api.endpoints.test_organization_config_integrations", "tests.sentry.api.endpoints.test_organization_events_trends_v2", "tests.sentry.api.endpoints.test_organization_fork", diff --git a/src/sentry/api/endpoints/oauth_userinfo.py b/src/sentry/api/endpoints/oauth_userinfo.py index 7078881e368197..9210eda691412f 100644 --- a/src/sentry/api/endpoints/oauth_userinfo.py +++ b/src/sentry/api/endpoints/oauth_userinfo.py @@ -1,3 +1,5 @@ +import hashlib + from rest_framework import status from rest_framework.authentication import get_authorization_header from rest_framework.exceptions import APIException @@ -80,14 +82,34 @@ def get(self, request: Request) -> Response: raise BearerTokenMissing() access_token = auth_header[1].decode("utf-8") + hashed_token = hashlib.sha256(access_token.encode()).hexdigest() try: - token_details = ApiToken.objects.get(token=access_token) + token_details = ApiToken.objects.select_related("user", "application").get( + hashed_token=hashed_token + ) except ApiToken.DoesNotExist: - raise BearerTokenInvalid() + try: + token_details = ApiToken.objects.select_related("user", "application").get( + token=access_token + ) + except ApiToken.DoesNotExist: + raise BearerTokenInvalid() + else: + token_details.hashed_token = hashed_token + token_details.save(update_fields=["hashed_token"]) if token_details.is_expired(): raise BearerTokenInvalid() + if not token_details.user.is_active: + raise BearerTokenInvalid() + + if getattr(token_details.user, "is_suspended", False): + raise BearerTokenInvalid() + + if token_details.application is not None and not token_details.application.is_active: + raise BearerTokenInvalid() + scopes = token_details.get_scopes() if "openid" not in scopes: raise BearerTokenInsufficientScope() diff --git a/src/sentry/api/endpoints/project_trace_item_details.py b/src/sentry/api/endpoints/project_trace_item_details.py index dc62ea7520b76b..6efbba3419db47 100644 --- a/src/sentry/api/endpoints/project_trace_item_details.py +++ b/src/sentry/api/endpoints/project_trace_item_details.py @@ -1,5 +1,6 @@ import time import uuid +from datetime import timedelta from typing import Any, Literal import sentry_sdk @@ -17,8 +18,10 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint from sentry.api.exceptions import BadRequest +from sentry.api.utils import get_date_range_from_params from sentry.auth.staff import is_active_staff from sentry.auth.superuser import is_active_superuser +from sentry.exceptions import InvalidParams from sentry.models.project import Project from sentry.search.eap import constants from sentry.search.eap.types import ( @@ -36,8 +39,10 @@ translate_search_type_for_internal_column, translate_to_sentry_conventions, ) +from sentry.search.utils import InvalidQuery, parse_datetime_string from sentry.snuba.referrer import Referrer -from sentry.utils import json, snuba_rpc +from sentry.utils import json +from sentry.utils.snuba_rpc import trace_item_details_rpc _NUMERIC_COERCIONS: dict[str, type] = {"valFloat": float, "valDouble": float} _VAL_TYPE_TO_COLUMN_TYPE: dict[str, ColumnType] = { @@ -362,9 +367,31 @@ def get(request: Request, project: Project, item_id: str) -> Response: if not serializer.is_valid(): return Response(serializer.errors, status=400) + try: + start, end = get_date_range_from_params(request.GET, optional=True) + except InvalidParams: + return Response("date range parameters invalid", status=400) + if "timestamp" in request.GET: + try: + example_timestamp = parse_datetime_string(request.GET["timestamp"]) + except InvalidQuery: + return Response("timestamp parameter invalid", status=400) + time_buffer = 1.5 + example_start = example_timestamp - timedelta(days=time_buffer) + example_end = example_timestamp + timedelta(days=time_buffer) + if start is not None: + start = max(start, example_start) + else: + start = example_start + if end is not None: + end = min(end, example_end) + else: + end = example_end + serialized = serializer.validated_data trace_id = serialized.get("trace_id") item_type = serialized.get("item_type") + sentry_sdk.set_tag("trace_item_details.item_type", item_type) referrer = serialized.get("referrer", Referrer.API_ORGANIZATION_TRACE_ITEM_DETAILS.value) trace_item_type = None @@ -377,12 +404,14 @@ def get(request: Request, project: Project, item_id: str) -> Response: raise BadRequest(detail=f"Unknown trace item type: {item_type}") start_timestamp_proto = ProtoTimestamp() - start_timestamp_proto.FromSeconds(0) - end_timestamp_proto = ProtoTimestamp() - - # due to clock drift, the end time can be in the future - add a week to be safe - end_timestamp_proto.FromSeconds(int(time.time()) + 60 * 60 * 24 * 7) + if start is not None and end is not None: + start_timestamp_proto.FromDatetime(start) + end_timestamp_proto.FromDatetime(end) + else: + start_timestamp_proto.FromSeconds(0) + # due to clock drift, the end time can be in the future - add a week to be safe + end_timestamp_proto.FromSeconds(int(time.time()) + 60 * 60 * 24 * 7) trace_id = request.GET.get("trace_id") if not trace_id: @@ -403,7 +432,7 @@ def get(request: Request, project: Project, item_id: str) -> Response: trace_id=trace_id, ) - resp = MessageToDict(snuba_rpc.trace_item_details_rpc(req)) + resp = MessageToDict(trace_item_details_rpc(req)) use_sentry_conventions = features.has( "organizations:performance-sentry-conventions-fields", diff --git a/src/sentry/api/handlers.py b/src/sentry/api/handlers.py index 7e037b185eb44c..01c88092bb1f70 100644 --- a/src/sentry/api/handlers.py +++ b/src/sentry/api/handlers.py @@ -21,6 +21,7 @@ def custom_exception_handler(exc, context): storage_key=exc.storage_key, quota_used=exc.quota_used, rejection_threshold=exc.rejection_threshold, + throttle_threshold=exc.throttle_threshold, ) # capture the rate limited exception so we can see it in Sentry diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 8d7b5f2b21f759..277c34037c6247 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2232,7 +2232,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_SELF_HOSTED_ERRORS_ONLY = False # only referenced in getsentry to provide the stable beacon version # updated with scripts/bump-version.sh -SELF_HOSTED_STABLE_VERSION = "26.5.0" +SELF_HOSTED_STABLE_VERSION = "26.5.1" # Whether we should look at X-Forwarded-For header or not # when checking REMOTE_ADDR ip addresses @@ -2910,8 +2910,6 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SEER_GROUPING_URL = SEER_DEFAULT_URL # for local development, these share a URL -SEER_GROUPING_BACKFILL_URL = SEER_DEFAULT_URL - SEER_SCORING_URL = SEER_DEFAULT_URL # for local development, these share a URL SEER_ANOMALY_DETECTION_MODEL_VERSION = "v1" diff --git a/src/sentry/data_export/tasks.py b/src/sentry/data_export/tasks.py index a785b0adee31ce..04e4217c05831a 100644 --- a/src/sentry/data_export/tasks.py +++ b/src/sentry/data_export/tasks.py @@ -100,11 +100,11 @@ def _sentry_metric_attrs( return attrs -def _page_token_b64_from_processor( +def _page_token_from_processor( processor: IssuesByTagProcessor | DiscoverProcessor | ExploreProcessor, -) -> str | None: +) -> bytes | None: if isinstance(processor, TraceItemFullExportProcessor) and processor.page_token is not None: - return base64.b64encode(processor.page_token).decode("ascii") + return processor.page_token return None @@ -162,7 +162,7 @@ def export_chunk_to_stored_blobs( export_limit: int, environment_id: int | None, first_page: bool = True, - page_token: str | None = None, + page_token: bytes | str | None = None, offset: int = 0, bytes_written: int = 0, batch_size: int = SNUBA_MAX_RESULTS, @@ -174,7 +174,7 @@ def export_chunk_to_stored_blobs( data_export, environment_id, output_mode, - page_token_b64=page_token, + page_token=page_token, ) with tempfile.TemporaryFile(mode="w+b") as tf: @@ -240,7 +240,7 @@ def _schedule_retry( base_bytes_written: int, environment_id: int | None, export_retries: int, - page_token: str | None, + page_token: bytes | str | None, delay_retry: bool = False, ) -> None: assemble_download.apply_async( @@ -280,7 +280,7 @@ def _schedule_next_task( "bytes_written": bytes_written, "environment_id": environment_id, "export_retries": export_retries, - "page_token": _page_token_b64_from_processor(processor), + "page_token": _page_token_from_processor(processor), } should_continue = next_offset < export_limit and ( (isinstance(processor, TraceItemFullExportProcessor) and processor.page_token is not None) @@ -325,7 +325,7 @@ def assemble_download( environment_id: int | None = None, export_retries: int = DEFAULT_EXPORT_RETRIES, *, - page_token: str | None = None, + page_token: bytes | str | None = None, **kwargs: Any, ) -> None: # The API response to export the data contains the ID which you can use @@ -573,7 +573,7 @@ def get_processor( environment_id: int | None, output_mode: OutputMode, *, - page_token_b64: str | None = None, + page_token: bytes | str | None = None, ) -> IssuesByTagProcessor | DiscoverProcessor | ExploreProcessor | TraceItemFullExportProcessor: try: if data_export.query_type == ExportQueryType.ISSUES_BY_TAG: @@ -597,17 +597,21 @@ def get_processor( output_mode=output_mode, ) elif data_export.query_type == ExportQueryType.TRACE_ITEM_FULL_EXPORT: - page_token: bytes | None = None - if page_token_b64: - try: - page_token = base64.b64decode(page_token_b64) - except (ValueError, TypeError) as e: - raise ExportError("Invalid export trace item pagination state.") from e + page_token_bytes: bytes | None = None + if page_token is not None: + # Handle both bytes (new) and base64 string (legacy) page tokens + if isinstance(page_token, str): + try: + page_token_bytes = base64.b64decode(page_token) + except (ValueError, TypeError) as e: + raise ExportError("Invalid export trace item pagination state.") from e + else: + page_token_bytes = page_token return TraceItemFullExportProcessor( explore_query=data_export.query_info, organization=data_export.organization, output_mode=output_mode, - page_token=page_token, + page_token=page_token_bytes, ) else: diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 3c1652652315c8..25c6aa355cbe8e 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -138,6 +138,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Temporary: log full Jira Cloud `issue.updated` webhook payloads so we can design project-change link rewriting. manager.add("organizations:jira-issue-updated-payload-logging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Use the paginated project endpoint in Jira org config to avoid timeouts on large instances. + manager.add("organizations:jira-paginated-project-config", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable inviting billing members to organizations at the member limit. manager.add("organizations:invite-billing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False) # Enable inviting members to organizations. @@ -177,6 +179,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:onboarding-scm-experiment", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Experiment: SCM onboarding project details A/B test manager.add("organizations:onboarding-scm-project-details-experiment", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Experiment: SCM-first project creation wizard A/B test (project creation flow, not new-org onboarding) + manager.add("organizations:onboarding-scm-project-creation-experiment", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable large ownership rule file size limit manager.add("organizations:ownership-size-limit-large", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable xlarge ownership rule file size limit @@ -341,8 +345,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-query-date-range-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True) # Make Insights overview pages use EAP instead of transactions (because eap is not on AM1) manager.add("organizations:insights-modules-use-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable access to insights metrics alerts - manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development diff --git a/src/sentry/incidents/charts.py b/src/sentry/incidents/charts.py index 232cbf1004b650..cf7df6e442e237 100644 --- a/src/sentry/incidents/charts.py +++ b/src/sentry/incidents/charts.py @@ -5,7 +5,6 @@ from django.utils import timezone -from sentry import features from sentry.api import client from sentry.api.base import logger from sentry.api.utils import get_datetime_from_stats_period @@ -210,15 +209,9 @@ def build_metric_alert_chart( ), } - allow_mri = features.has( - "organizations:insights-alerts", - organization, - actor=user, - ) aggregate = translate_aggregate_field( snuba_query.aggregate, reverse=True, - allow_mri=allow_mri, allow_eap=dataset == Dataset.EventsAnalyticsPlatform, ) # If we allow alerts to be across multiple orgs this will break diff --git a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py index b239590e885c29..75faac1498c526 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py @@ -59,6 +59,10 @@ def convert_args(self, request: Request, organization_id_or_slug, config_id, *ar return (args, kwargs) def get(self, request: Request, config_id, organization, config) -> Response: + project = config.project_repository.project + if not request.access.has_project_access(project): + return self.respond(status=status.HTTP_403_FORBIDDEN) + try: codeowner_contents = get_codeowner_contents(config) except ApiError as e: diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 57c9c3c6b28f71..807c0be881c073 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict from urllib.parse import parse_qs, quote, urlencode, urlparse from cryptography.hazmat.backends import default_backend @@ -14,7 +14,10 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt +from rest_framework import serializers +from rest_framework.fields import BooleanField, CharField, URLField +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -40,7 +43,8 @@ ) from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.users.models.identity import Identity from sentry.web.helpers import render_to_response @@ -257,6 +261,153 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR ) +class InstallationConfigData(TypedDict): + url: str + consumer_key: str + private_key: str + verify_ssl: bool + + +class InstallationConfigSerializer(CamelSnakeSerializer[InstallationConfigData]): + url = URLField(required=True) + consumer_key = CharField(required=True, max_length=200) + private_key = CharField(required=True) + verify_ssl = BooleanField(required=False, default=True) + + def validate_private_key(self, value: str) -> str: + try: + load_pem_private_key(value.encode("utf-8"), None, default_backend()) + except Exception: + raise serializers.ValidationError( + "Private key must be a valid SSH private key encoded in a PEM format." + ) + return value + + +class InstallationConfigApiStep: + """ + Collect Bitbucket Server consumer credentials and verify them by fetching an + OAuth 1.0a request token. The token is stored on pipeline state so the next + step can build an authorize URL and exchange it for an access token. + """ + + step_name = "installation_config" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return {} + + def get_serializer_cls(self) -> type: + return InstallationConfigSerializer + + def handle_post( + self, + validated_data: InstallationConfigData, + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + validated_data["url"] = validated_data["url"].rstrip("/") + + client = BitbucketServerSetupClient( + validated_data["url"], + validated_data["consumer_key"], + validated_data["private_key"], + validated_data["verify_ssl"], + ) + + with IntegrationPipelineViewEvent( + IntegrationPipelineViewType.OAUTH_LOGIN, + IntegrationDomain.SOURCE_CODE_MANAGEMENT, + BitbucketServerIntegrationProvider.key, + ).capture() as lifecycle: + try: + request_token = client.get_request_token() + except ApiError as error: + lifecycle.record_failure(str(error), extra={"url": validated_data["url"]}) + return PipelineStepResult.error( + f"Could not fetch a request token from Bitbucket. {error}" + ) + + if not request_token.get("oauth_token") or not request_token.get("oauth_token_secret"): + lifecycle.record_failure( + "missing oauth_token", extra={"url": validated_data["url"]} + ) + return PipelineStepResult.error("Missing oauth_token") + + pipeline.bind_state("installation_data", validated_data) + pipeline.bind_state("request_token", request_token) + return PipelineStepResult.advance() + + +class OAuthCallbackData(TypedDict): + oauth_token: str + + +class OAuthCallbackSerializer(CamelSnakeSerializer[OAuthCallbackData]): + oauth_token = CharField(required=True) + + +class OAuthStepData(TypedDict): + oauthUrl: str + + +class OAuthApiStep: + """ + Build the Bitbucket Server authorize URL from the previously-fetched request + token, then exchange the callback's oauth_token (which Bitbucket Server uses + as the verifier) for an access token. + """ + + step_name = "oauth_callback" + + def _client(self, pipeline: IntegrationPipeline) -> BitbucketServerSetupClient: + installation = pipeline.fetch_state("installation_data") + if installation is None: + raise AssertionError("pipeline called out of order") + return BitbucketServerSetupClient( + installation["url"], + installation["consumer_key"], + installation["private_key"], + installation["verify_ssl"], + ) + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> OAuthStepData: + request_token = pipeline.fetch_state("request_token") + if request_token is None: + raise AssertionError("pipeline called out of order") + return {"oauthUrl": self._client(pipeline).get_authorize_url(request_token)} + + def get_serializer_cls(self) -> type: + return OAuthCallbackSerializer + + def handle_post( + self, + validated_data: OAuthCallbackData, + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + request_token = pipeline.fetch_state("request_token") + if request_token is None: + raise AssertionError("pipeline called out of order") + + with IntegrationPipelineViewEvent( + IntegrationPipelineViewType.OAUTH_CALLBACK, + IntegrationDomain.SOURCE_CODE_MANAGEMENT, + BitbucketServerIntegrationProvider.key, + ).capture() as lifecycle: + try: + access_token = self._client(pipeline).get_access_token( + request_token, validated_data["oauth_token"] + ) + except ApiError as error: + lifecycle.record_failure(str(error)) + return PipelineStepResult.error( + f"Could not fetch an access token from Bitbucket. {error}" + ) + + pipeline.bind_state("access_token", access_token) + return PipelineStepResult.advance() + + class BitbucketServerIntegration(RepositoryIntegration[BitbucketServerClient]): """ IntegrationInstallation implementation for Bitbucket Server @@ -395,6 +546,9 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]: return [InstallationConfigView(), OAuthLoginView(), OAuthCallbackView()] + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [InstallationConfigApiStep(), OAuthApiStep()] + def post_install( self, integration: Integration, diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 7ff28cf8592f6b..b4c73d81c57c98 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -164,13 +164,30 @@ def use_email_scope(cls): def get_organization_config(self) -> list[dict[str, Any]]: configuration: list[dict[str, Any]] = self._get_organization_config_default_values() + context = organization_service.get_organization_by_id( + id=self.organization_id, include_projects=False, include_teams=False + ) + assert context, "organizationcontext must exist to get org" + organization = context.organization + client = self.get_client() try: - projects: list[JiraProjectMapping] = [ - JiraProjectMapping(value=p["id"], label=p["name"]) - for p in client.get_projects_list() - ] + if features.has("organizations:jira-paginated-project-config", organization): + # Use the paginated endpoint to avoid fetching all projects at once, + # which can time out for large Jira instances. The settings page + # dropdown search (typeahead) handles finding projects beyond this + # initial page via JiraSearchEndpoint. + projects_response = client.get_projects_paginated(params={"maxResults": 50}) + projects: list[JiraProjectMapping] = [ + JiraProjectMapping(value=p["id"], label=p["name"]) + for p in projects_response.get("values", []) + ] + else: + projects = [ + JiraProjectMapping(value=p["id"], label=p["name"]) + for p in client.get_projects_list() + ] self._set_status_choices_in_organization_config(configuration, projects) configuration[0]["addDropdown"]["items"] = projects except ApiError: @@ -179,12 +196,6 @@ def get_organization_config(self) -> list[dict[str, Any]]: "Unable to communicate with the Jira instance. You may need to reinstall the addon." ) - context = organization_service.get_organization_by_id( - id=self.organization_id, include_projects=False, include_teams=False - ) - assert context, "organizationcontext must exist to get org" - organization = context.organization - has_issue_sync = features.has("organizations:integrations-issue-sync", organization) if not has_issue_sync: for field in configuration: diff --git a/src/sentry/middleware/access_log.py b/src/sentry/middleware/access_log.py index 696ecebaf688bf..983916d73e1d4d 100644 --- a/src/sentry/middleware/access_log.py +++ b/src/sentry/middleware/access_log.py @@ -75,6 +75,7 @@ def _get_rate_limit_stats_dict(request: Request) -> dict[str, str | int | None]: "snuba_rejection_threshold": getattr( snuba_rate_limit_metadata, "rejection_threshold", None ), + "snuba_throttle_threshold": getattr(snuba_rate_limit_metadata, "throttle_threshold", None), "snuba_storage_key": getattr(snuba_rate_limit_metadata, "storage_key", None), } diff --git a/src/sentry/models/project.py b/src/sentry/models/project.py index 20082b4ff89ca5..4138f49aa516e8 100644 --- a/src/sentry/models/project.py +++ b/src/sentry/models/project.py @@ -681,9 +681,22 @@ def transfer_to(self, organization: Organization) -> None: .values_list("id", flat=True) ) - Workflow.objects.filter(id__in=exclusive_workflow_ids).update( - organization_id=organization.id + # Update org and environment references for transferred workflows + workflows_with_env = dict( + Workflow.objects.filter( + id__in=exclusive_workflow_ids, environment_id__isnull=False + ).values_list("id", "environment_id") ) + for workflow_id, env_id in workflows_with_env.items(): + Workflow.objects.filter(id=workflow_id).update( + organization_id=organization.id, + environment_id=Environment.get_or_create( + self, name=environment_names.get(env_id) + ).id, + ) + Workflow.objects.filter(id__in=exclusive_workflow_ids).exclude( + id__in=workflows_with_env.keys() + ).update(organization_id=organization.id) # Update DataConditionGroups connected to the transferred workflows # These are linked via WorkflowDataConditionGroup with a unique constraint on condition_group diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index 69d165ebdd0e60..8ca58acbc051b4 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -287,8 +287,10 @@ def is_set_on_disk(self, key: str) -> bool: def _record_seen(self, key: str) -> None: """Emit one log line per key per process lifetime so reads can be audited in GCP. Logs before adding to _seen so a logging failure - doesn't permanently suppress the event.""" - logger.info("option.seen", extra={"option_key": key}) + doesn't permanently suppress the event. In debug mode, mark keys as + seen without logging to keep local tooling output clean.""" + if not settings.DEBUG: + logger.info("option.seen", extra={"option_key": key}) self._seen.add(key) def get(self, key: str, silent=False): diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py index 980f6ee27112de..3e629dee082411 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py @@ -23,6 +23,7 @@ from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff +from sentry.constants import ObjectStatus from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot import ( @@ -148,6 +149,7 @@ def get( qs = ( PreprodArtifact.objects.filter( project__organization_id=organization.id, + project__status=ObjectStatus.ACTIVE, app_id=app_id, preprodsnapshotmetrics__isnull=False, ) @@ -159,6 +161,9 @@ def get( .select_related("commit_comparison", "project", "preprodsnapshotmetrics") ) + if not is_active_staff(request) and not request.access.has_global_access: + qs = qs.filter(project_id__in=request.access.accessible_project_ids) + if project_id is not None: qs = qs.filter(project_id=project_id) if branch: @@ -169,9 +174,6 @@ def get( if artifact is None: return Response({"detail": "No snapshot found"}, status=404) - if not is_active_staff(request) and not request.access.has_project_access(artifact.project): - return Response({"detail": "No snapshot found"}, status=404) - snapshot_metrics = artifact.preprodsnapshotmetrics manifest_key = (snapshot_metrics.extras or {}).get("manifest_key") if not manifest_key: diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 2a6447e62f4905..0c9e2ed7366cb5 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -3,7 +3,6 @@ # in modules such as this one where hybrid cloud data models or service classes are # defined, because we want to reflect on type annotations and avoid forward references. -import base64 import logging from datetime import UTC, datetime from io import BytesIO @@ -64,7 +63,7 @@ def request_new_export( requesting_region_name, replying_region_name, org_slug, - base64.b64encode(encrypt_with_public_key).decode("utf8"), + encrypt_with_public_key, int(round(datetime.now(tz=UTC).timestamp())), ] ) diff --git a/src/sentry/relocation/tasks/process.py b/src/sentry/relocation/tasks/process.py index f229c1a482e191..9b7341f075cea6 100644 --- a/src/sentry/relocation/tasks/process.py +++ b/src/sentry/relocation/tasks/process.py @@ -319,7 +319,7 @@ def fulfill_cross_region_export_request( requesting_cell_name: str, replying_cell_name: str, org_slug: str, - encrypt_with_public_key: str, + encrypt_with_public_key: bytes | str, # Unix timestamp, in seconds. scheduled_at: int, ) -> None: @@ -334,7 +334,11 @@ def fulfill_cross_region_export_request( """ from sentry.relocation.tasks.transfer import process_relocation_transfer_region - encrypt_with_public_key_bytes = base64.b64decode(encrypt_with_public_key.encode("utf8")) + # Handle both bytes (new) and base64 string (legacy) + if isinstance(encrypt_with_public_key, str): + encrypt_with_public_key_bytes = base64.b64decode(encrypt_with_public_key.encode("utf8")) + else: + encrypt_with_public_key_bytes = encrypt_with_public_key logger_data = { "uuid": uuid_str, diff --git a/src/sentry/scm/private/helpers.py b/src/sentry/scm/private/helpers.py index fc9eef14ce9572..71ac7ed5617d3b 100644 --- a/src/sentry/scm/private/helpers.py +++ b/src/sentry/scm/private/helpers.py @@ -77,6 +77,19 @@ def fetch_repository(organization_id: int, repository_id: RepositoryId) -> Repos if not isinstance(repo.provider, str): return None + provider_name = repo.provider.removeprefix("integrations:") + web_base_url: str | None = None + if provider_name == "github_enterprise": + integration = integration_service.get_integration( + integration_id=repo.integration_id, + organization_id=organization_id, + ) + if integration: + domain_name = integration.metadata.get("domain_name") + if domain_name: + base_host = domain_name.split("/", 1)[0] + web_base_url = f"https://{base_host}" + return cast( Repository, { @@ -86,8 +99,8 @@ def fetch_repository(organization_id: int, repository_id: RepositoryId) -> Repos "is_active": repo.status == ObjectStatus.ACTIVE, "name": repo.name, "organization_id": repo.organization_id, - "provider_name": repo.provider.removeprefix("integrations:"), - "web_base_url": None, + "provider_name": provider_name, + "web_base_url": web_base_url, }, ) diff --git a/src/sentry/search/eap/occurrences/rollout_utils.py b/src/sentry/search/eap/occurrences/rollout_utils.py index 4810e1fd885a4c..d90bafb0d3a59b 100644 --- a/src/sentry/search/eap/occurrences/rollout_utils.py +++ b/src/sentry/search/eap/occurrences/rollout_utils.py @@ -1,8 +1,13 @@ from sentry.utils.rollout import SafeRolloutComparator +# TODO: When this experiment is over and we're deleting this class, go remove the check for +# `use_legacy_comparison_metric_name` in `SafeRolloutComparator.compare`. + class EAPOccurrencesComparator(SafeRolloutComparator): ROLLOUT_NAME = "occurrences_on_eap" + # NOTE: Shim to not break existing dashboards. Don't use in new comparators! + use_legacy_comparison_metric_name = True EAP_OCCURRENCES_SHOULD_RUN_EXPERIMENT_OPTION = ( diff --git a/src/sentry/search/events/builder/metrics.py b/src/sentry/search/events/builder/metrics.py index cf755ca300b193..3fd9f147ecd7fb 100644 --- a/src/sentry/search/events/builder/metrics.py +++ b/src/sentry/search/events/builder/metrics.py @@ -697,7 +697,10 @@ def resolve_tag_key(self, value: str) -> int | str | None: value = self.column_remapping.get(value, value) if self.use_default_tags: - if value in self.default_metric_tags: + if ( + value in self.default_metric_tags + or self.builder_config.skip_field_validation_for_entity_subscription_deletion + ): return self.resolve_metric_index(value) else: raise IncompatibleMetricsQuery(f"{value} is not a tag in the metrics dataset") diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 973c8a1939ff8a..9b0f73ef53ec9d 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -423,9 +423,7 @@ def run_automation( if is_seer_autotriggered_autofix_rate_limited_and_increment(group.project, group.organization): return - stopping_point = None - if is_seer_seat_based_tier_enabled(group.organization): - stopping_point = get_automation_stopping_point(group) + stopping_point = get_automation_stopping_point(group) _trigger_autofix_task.delay( group_id=group.id, @@ -464,16 +462,22 @@ def is_group_triggering_automation(group: Group) -> bool: return True -def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint: +def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint | None: """ Get the automation stopping point for a group. """ - fixability_score = get_and_update_group_fixability_score(group) - fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) - user_preference = read_preference_from_sentry_db(group.project).automated_run_stopping_point - return _apply_user_preference_upper_bound(fixability_stopping_point, user_preference) + if is_seer_seat_based_tier_enabled(group.organization): + fixability_score = get_and_update_group_fixability_score(group) + fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) + + return _apply_user_preference_upper_bound(fixability_stopping_point, user_preference) + + if user_preference: + return AutofixStoppingPoint(user_preference) + + return None def _generate_summary( diff --git a/src/sentry/snuba/snuba_query_validator.py b/src/sentry/snuba/snuba_query_validator.py index 17ee38964fc9d7..72f78320797c19 100644 --- a/src/sentry/snuba/snuba_query_validator.py +++ b/src/sentry/snuba/snuba_query_validator.py @@ -265,10 +265,6 @@ def _validate_aggregate(self, data: dict[str, Any]) -> None: "organizations:custom-metrics", self.context["organization"], actor=self.context.get("user", None), - ) or features.has( - "organizations:insights-alerts", - self.context["organization"], - actor=self.context.get("user", None), ) allow_eap = dataset == Dataset.EventsAnalyticsPlatform @@ -304,10 +300,6 @@ def _validate_query(self, data: dict[str, Any]) -> None: "organizations:custom-metrics", self.context["organization"], actor=self.context.get("user", None), - ) or features.has( - "organizations:insights-alerts", - self.context["organization"], - actor=self.context.get("user", None), ): try: column_is_mri = is_mri( diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index 16b16b6227b79a..c55f2a6d7ada43 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -140,8 +140,8 @@ def _qualify_short_id(project: str, short_id: int | None) -> str | None: else: try: issue = Group.objects.get(id=issue_id, project__id=occurrence.project_id) - except Group.DoesNotExist as e: - logger.error(e) + except Group.DoesNotExist: + logger.info("Group %s not found in _serialize_rpc_issue", issue_id) return None group_cache[issue_id] = issue return SerializedIssue( @@ -171,8 +171,8 @@ def _qualify_short_id(project: str, short_id: int | None) -> str | None: else: try: issue = Group.objects.get(id=issue_id, project__id=event["project.id"]) - except Group.DoesNotExist as e: - logger.error(e) + except Group.DoesNotExist: + logger.info("Group %s not found in _serialize_rpc_issue", issue_id) return None group_cache[issue_id] = issue diff --git a/src/sentry/types/ratelimit.py b/src/sentry/types/ratelimit.py index 360bd4228ba265..8b44915c0b64ee 100644 --- a/src/sentry/types/ratelimit.py +++ b/src/sentry/types/ratelimit.py @@ -76,4 +76,5 @@ class SnubaRateLimitMeta: quota_unit: str | None quota_used: int | None rejection_threshold: int | None + throttle_threshold: int | None storage_key: str | None diff --git a/src/sentry/utils/cursored_scheduler.py b/src/sentry/utils/cursored_scheduler.py index 90428df867f47e..af81dc1d82753c 100644 --- a/src/sentry/utils/cursored_scheduler.py +++ b/src/sentry/utils/cursored_scheduler.py @@ -58,6 +58,7 @@ def is_eligible(pk: int) -> bool: import logging import math +import random import time from collections.abc import Callable from datetime import timedelta @@ -136,6 +137,7 @@ def __init__( cycle_duration: timedelta, lock_duration: int = DEFAULT_LOCK_DURATION_SECONDS, validate_item: Callable[[int], bool] | None = None, + shuffle: bool = False, ): self.name = name self.schedule_key = schedule_key @@ -151,6 +153,7 @@ def __init__( self.cache_ttl = int(cycle_duration.total_seconds() * 2) self.lock_duration = lock_duration self.validate_item = validate_item + self.shuffle = shuffle self._metric_tags = {"scheduler": name} @property @@ -233,6 +236,9 @@ def _initialize_cycle(self) -> int: all_pks = list(self.queryset.order_by("pk").values_list("pk", flat=True)) + if self.shuffle: + random.shuffle(all_pks) + client = self._get_redis_client() existing_len = client.llen(self.pk_list_cache_key) diff --git a/src/sentry/utils/rollout.py b/src/sentry/utils/rollout.py index b3c2f24f79b5a9..81641f67447141 100644 --- a/src/sentry/utils/rollout.py +++ b/src/sentry/utils/rollout.py @@ -1,7 +1,7 @@ import logging import random from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any, Literal, TypeVar from sentry import options from sentry.options import register @@ -19,6 +19,8 @@ TData = TypeVar("TData") +SourceOfTruth = Literal["control", "experimental", "neither", "both"] + class SafeRolloutComparator: """ @@ -177,7 +179,7 @@ def _maybe_log_mismatch( cls, *, callsite: str, - use_experimental_data: bool, + source_of_truth: SourceOfTruth, is_exact_match: bool, is_reasonable_match: bool | None, is_experimental_data_nullish: bool | None, @@ -196,7 +198,7 @@ def _maybe_log_mismatch( extra={ "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, - "source_of_truth": ("experimental" if use_experimental_data else "control"), + "source_of_truth": source_of_truth, "exact_match": is_exact_match, "reasonable_match": is_reasonable_match, "is_null_result": is_experimental_data_nullish, @@ -251,26 +253,31 @@ def should_use_experimental_data(cls, callsite: str) -> bool: return use_experimental_data @classmethod - def check_and_choose( + def compare( cls, control_data: TData, experimental_data: TData, callsite: str, + source_of_truth: SourceOfTruth = "neither", is_experimental_data_nullish: bool | None = None, reasonable_match_comparator: Callable[[TData, TData], bool] | None = None, debug_context: dict[str, Any] | None = None, data_serializer: Callable[[TData], Any] | None = None, - ) -> TData: + ) -> None: """ - This function does two things: - - First, it compares control & experimental data and logs info to DataDog. - - Second, it determines which of the inputs should be returned & used downstream. + Compare control & experimental data, emit metrics, and log mismatches. Use this directly + (rather than `check_and_choose`) if you don't need help determining which data to use + downstream - e.g. if you won't be using either branch's data, or if you'll be using both. Inputs: * control_data: Some data from the control branch (e.g. dict[str, str]) * experimental_data: Some data from the experimental branch (of same type as control) * callsite: A unique string identifying place that uses this class. Should be the same as what's passed to `should_check_experiment`. + * source_of_truth: Which branch's data the caller will actually use downstream. Defaults to + "neither" (the typical direct-call case). `check_and_choose` passes "control" or + "experimental" based on the use-experimental-data allowlist; callers using both branches + should pass "both". * is_experimental_data_nullish: Whether the result is a "null result" (e.g. empty array). This helps to determine whether a "match" is significant. * reasonable_match_comparator: Optional predicate for semantic correctness, returning True @@ -281,16 +288,14 @@ def check_and_choose( * data_serializer: Optional serializer for control/experimental payloads in logs. Defaults to `_default_serialize_for_log`. """ - use_experimental_data = cls.should_use_experimental_data(callsite) is_exact_match = control_data == experimental_data is_reasonable_match: bool | None = None - # Part 1: Compare results, log debug info, and emit metrics tags: dict[str, str] = { "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, "exact_match": str(is_exact_match), - "source_of_truth": ("experimental" if use_experimental_data else "control"), + "source_of_truth": source_of_truth, } if is_experimental_data_nullish is not None: @@ -317,7 +322,7 @@ def check_and_choose( try: cls._maybe_log_mismatch( callsite=callsite, - use_experimental_data=use_experimental_data, + source_of_truth=source_of_truth, is_exact_match=is_exact_match, is_reasonable_match=is_reasonable_match, is_experimental_data_nullish=is_experimental_data_nullish, @@ -332,12 +337,41 @@ def check_and_choose( extra={"rollout_name": cls.ROLLOUT_NAME, "callsite": callsite}, ) - metrics.incr( - "SafeRolloutComparator.check_and_choose", - tags=tags, - ) + # TODO: This shim is only used in `EAPOccurrencesComparator`. Once that's deleted, this + # check can go away and we can standardize on emitting just the `compare` metric. + if getattr(cls, "use_legacy_comparison_metric_name", None): + metrics.incr("SafeRolloutComparator.check_and_choose", tags=tags) + else: + metrics.incr("SafeRolloutComparator.compare", tags=tags) + + @classmethod + def check_and_choose( + cls, + control_data: TData, + experimental_data: TData, + callsite: str, + is_experimental_data_nullish: bool | None = None, + reasonable_match_comparator: Callable[[TData, TData], bool] | None = None, + debug_context: dict[str, Any] | None = None, + data_serializer: Callable[[TData], Any] | None = None, + ) -> TData: + """ + Compare control & experimental data (via `compare`), then return whichever branch should be + used downstream based on the use-experimental-data allowlist. - # Part 2: determine which data to return + See `compare` for parameter documentation. + """ + use_experimental_data = cls.should_use_experimental_data(callsite) + cls.compare( + control_data=control_data, + experimental_data=experimental_data, + callsite=callsite, + source_of_truth="experimental" if use_experimental_data else "control", + is_experimental_data_nullish=is_experimental_data_nullish, + reasonable_match_comparator=reasonable_match_comparator, + debug_context=debug_context, + data_serializer=data_serializer, + ) return experimental_data if use_experimental_data else control_data @classmethod diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index 04ebdae2d34738..9df4ab0bdc0ea5 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -387,6 +387,7 @@ def __init__( storage_key: str | None = None, quota_used: int | None = None, rejection_threshold: int | None = None, + throttle_threshold: int | None = None, ) -> None: super().__init__(message) self.policy = policy @@ -394,6 +395,7 @@ def __init__( self.storage_key = storage_key self.quota_used = quota_used self.rejection_threshold = rejection_threshold + self.throttle_threshold = throttle_threshold class SchemaValidationError(QueryExecutionError): @@ -1351,7 +1353,8 @@ def _bulk_snuba_query(snuba_requests: Sequence[SnubaRequest]) -> ResultSet: quota_unit=policy_info["quota_unit"], storage_key=policy_info["storage_key"], quota_used=policy_info["quota_used"], - rejection_threshold=policy_info["rejection_threshold"], + rejection_threshold=policy_info.get("rejection_threshold"), + throttle_threshold=policy_info.get("throttle_threshold"), ) except KeyError: logger.warning( diff --git a/src/sentry/workflow_engine/apps.py b/src/sentry/workflow_engine/apps.py index 17b893f6efef57..6145830a03714b 100644 --- a/src/sentry/workflow_engine/apps.py +++ b/src/sentry/workflow_engine/apps.py @@ -8,4 +8,5 @@ def ready(self) -> None: # Import items that use registries or respond to events import sentry.workflow_engine.handlers # NOQA import sentry.workflow_engine.receivers # NOQA + import sentry.workflow_engine.handlers.workflow.workflow_activity_handlers # NOQA from sentry.workflow_engine.endpoints import serializers # NOQA diff --git a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py new file mode 100644 index 00000000000000..3df4b76102be15 --- /dev/null +++ b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py @@ -0,0 +1,9 @@ +from sentry.models.activity import Activity +from sentry.models.group import Group +from sentry.workflow_engine.registry import workflow_activity_registry + + +@workflow_activity_registry.register("seer_activity") +def seer_activity_handler(group: Group, activity: Activity) -> None: + # TODO(Leander): Implement this handler + pass diff --git a/src/sentry/workflow_engine/registry.py b/src/sentry/workflow_engine/registry.py index fda4a5744cdde0..6ec921a6ab6371 100644 --- a/src/sentry/workflow_engine/registry.py +++ b/src/sentry/workflow_engine/registry.py @@ -1,8 +1,21 @@ from typing import Any +from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.utils.registry import Registry -from sentry.workflow_engine.types import ActionHandler, DataConditionHandler, DataSourceTypeHandler +from sentry.workflow_engine.types import ( + ActionHandler, + DataConditionHandler, + DataSourceTypeHandler, + WorkflowActivityHandler, +) data_source_type_registry = Registry[type[DataSourceTypeHandler[Any]]]() condition_handler_registry = Registry[type[DataConditionHandler[Any]]](enable_reverse_lookup=False) action_handler_registry = Registry[type[ActionHandler]](enable_reverse_lookup=False) +workflow_activity_registry = Registry[WorkflowActivityHandler](enable_reverse_lookup=False) + + +def invoke_workflow_activity_handlers(group: Group, activity: Activity) -> None: + for handler in workflow_activity_registry.registrations.values(): + handler(group, activity) diff --git a/src/sentry/workflow_engine/types.py b/src/sentry/workflow_engine/types.py index d783e26be1b1fc..febb8a838482b5 100644 --- a/src/sentry/workflow_engine/types.py +++ b/src/sentry/workflow_engine/types.py @@ -2,6 +2,7 @@ import random from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass, field from enum import IntEnum, StrEnum from logging import Logger @@ -423,3 +424,6 @@ class DetectorSettings: validator: type[BaseDetectorTypeValidator] | None = None config_schema: dict[str, Any] = field(default_factory=dict) filter: Q | None = None + + +WorkflowActivityHandler: TypeAlias = Callable[["Group", "Activity"], None] diff --git a/static/app/components/activity/author.tsx b/static/app/components/activity/author.tsx deleted file mode 100644 index 3bee67baaa0517..00000000000000 --- a/static/app/components/activity/author.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import styled from '@emotion/styled'; - -const ActivityAuthor = styled('span')` - font-weight: ${p => p.theme.font.weight.sans.medium}; - font-size: ${p => p.theme.font.size.md}; -`; - -export {ActivityAuthor}; diff --git a/static/app/components/activity/item/bubble.tsx b/static/app/components/activity/item/bubble.tsx deleted file mode 100644 index e6e39a768b19b1..00000000000000 --- a/static/app/components/activity/item/bubble.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import styled from '@emotion/styled'; - -export interface ActivityBubbleProps extends React.HTMLAttributes { - backgroundColor?: string; - borderColor?: string; -} - -/** - * This creates a bordered box that has a left pointing arrow - * on the left-side at the top. - */ -const ActivityBubble = styled('div')` - display: flex; - justify-content: center; - flex-direction: column; - align-items: stretch; - flex: 1; - background-color: ${p => p.backgroundColor || p.theme.tokens.background.primary}; - border: 1px solid ${p => p.borderColor || p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - position: relative; - width: 100%; /* this is used in Incidents Details - a chart can cause overflow and won't resize properly */ - - &:before { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid ${p => p.borderColor || p.theme.tokens.border.primary}; - position: absolute; - left: -7px; - top: 12px; - } - - &:after { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ - border-right: 6px solid ${p => p.backgroundColor || p.theme.tokens.background.primary}; - position: absolute; - left: -6px; - top: 13px; - } -`; - -export {ActivityBubble}; diff --git a/static/app/components/activity/item/index.tsx b/static/app/components/activity/item/index.tsx deleted file mode 100644 index fc2705df6d1245..00000000000000 --- a/static/app/components/activity/item/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {Flex} from '@sentry/scraps/layout'; - -import {DateTime} from 'sentry/components/dateTime'; -import {TimeSince} from 'sentry/components/timeSince'; -import {textStyles} from 'sentry/styles/text'; -import type {AvatarUser} from 'sentry/types/user'; - -import {ActivityAvatar} from './avatar'; -import type {ActivityBubbleProps} from './bubble'; -import {ActivityBubble} from './bubble'; - -type ActivityAuthorType = 'user' | 'system'; - -interface ActivityItemProps { - /** - * Used to render an avatar for the author. Currently can be a user, otherwise - * defaults as a "system" avatar (i.e. sentry) - * - * `user` is required if `type` is "user" - */ - author?: { - type: ActivityAuthorType; - user?: AvatarUser; - }; - avatarSize?: number; - bubbleProps?: ActivityBubbleProps; - children?: React.ReactNode; - - className?: string; - /** - * If supplied, will show the time that the activity started - */ - date?: string | Date; - /** - * Can be a react node or a render function. render function will not include default wrapper - */ - header?: React.ReactNode; - /** - * Do not show the date in the header - */ - hideDate?: boolean; - /** - * This is used to uniquely identify the activity item for use as an anchor - */ - id?: string; - /** - * If supplied, will show the interval that the activity occurred in - */ - interval?: number; - /** - * Removes padding on the activtiy body - */ - noPadding?: boolean; - /** - * Show exact time instead of relative date/time. - */ - showTime?: boolean; -} - -function ActivityItem({ - author, - avatarSize, - bubbleProps, - className, - children, - date, - interval, - noPadding, - id, - header, - hideDate = false, - showTime = false, -}: ActivityItemProps) { - const showDate = !hideDate && date && !interval; - const showRange = !hideDate && date && interval; - const dateEnded = showRange - ? moment(date).add(interval, 'minutes').utc().format() - : undefined; - const timeOnly = Boolean( - date && dateEnded && moment(date).date() === moment(dateEnded).date() - ); - - return ( - - {id && } - - {author && ( - - )} - - - {header && ( - - {header} - {date && showDate && !showTime && } - {date && showDate && showTime && } - - {showRange && ( - - - {' — '} - - - )} - - )} - - {children && (noPadding ? children : {children})} - - - ); -} - -const ActivityHeader = styled('div')` - display: flex; - align-items: center; - padding: 6px ${p => p.theme.space.xl}; - border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; - font-size: ${p => p.theme.font.size.md}; - - &:last-child { - border-bottom: none; - } -`; - -const ActivityHeaderContent = styled('div')` - flex: 1; -`; - -const ActivityBody = styled('div')` - padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl}; - ${textStyles} -`; - -const StyledActivityAvatar = styled(ActivityAvatar)` - margin-right: ${p => p.theme.space.md}; -`; - -const StyledTimeSince = styled(TimeSince)` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const StyledDateTime = styled(DateTime)` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const StyledDateTimeWindow = styled('div')` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const StyledActivityBubble = styled(ActivityBubble)` - width: 75%; - overflow-wrap: break-word; -`; - -export {ActivityItem}; diff --git a/static/app/components/activity/note/body.tsx b/static/app/components/activity/note/body.tsx index 1f947691827094..0db775c65d94e5 100644 --- a/static/app/components/activity/note/body.tsx +++ b/static/app/components/activity/note/body.tsx @@ -1,50 +1,15 @@ -import styled from '@emotion/styled'; - -import {MarkedText} from 'sentry/utils/marked/markedText'; +import {Markdown} from '@sentry/scraps/markdown'; type Props = { text: string; }; function NoteBody({text}: Props) { - return ; + return ( +
+ +
+ ); } -const StyledNoteBody = styled(MarkedText)` - ul { - list-style: disc; - } - - h1, - h2, - h3, - h4, - p, - ul:not(.nav), - ol, - pre, - hr, - blockquote { - margin-bottom: ${p => p.theme.space.xl}; - } - - ul, - ol { - padding-left: 20px; - } - - p { - a { - word-wrap: break-word; - } - } - - blockquote { - font-size: 15px; - border-left: 5px solid ${p => p.theme.tokens.border.secondary}; - padding-left: ${p => p.theme.space.md}; - margin-left: 0; - } -`; - export {NoteBody}; diff --git a/static/app/components/activity/note/compact.tsx b/static/app/components/activity/note/compact.tsx index 54078f116e173f..d7e122a1c0ce16 100644 --- a/static/app/components/activity/note/compact.tsx +++ b/static/app/components/activity/note/compact.tsx @@ -1,8 +1,7 @@ import {useCallback, useId, useState} from 'react'; import type {MentionsInputProps} from 'react-mentions'; import {Mention, MentionsInput} from 'react-mentions'; -import type {Theme} from '@emotion/react'; -import {css, useTheme} from '@emotion/react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -152,7 +151,6 @@ export function CompactNoteInput({ borderRadius: theme.radius.md, }, }), - width: '100%', }} placeholder={placeholder} onChange={handleChange} @@ -200,46 +198,7 @@ export function CompactNoteInput({ ); } -const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => { - if (!p.error) { - return ''; - } - - return css` - color: ${p.theme.tokens.content.danger}; - margin: -1px; - border: 1px solid ${p.theme.tokens.border.danger}; - border-radius: ${p.theme.radius.md}; - - &:before { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid ${p.theme.colors.red400}; - position: absolute; - left: -7px; - top: 12px; - } - - &:after { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-right: 6px solid #fff; - position: absolute; - left: -5px; - top: 12px; - } - `; -}; - -const NoteInputForm = styled('form')<{error?: string}>` +const NoteInputForm = styled('form')` display: flex; flex-direction: column; gap: ${p => p.theme.space.sm}; @@ -247,6 +206,4 @@ const NoteInputForm = styled('form')<{error?: string}>` width: 100%; min-width: 0; transition: padding 0.2s ease-in-out; - - ${getNoteInputErrorStyles}; `; diff --git a/static/app/components/activity/note/input.spec.tsx b/static/app/components/activity/note/input.spec.tsx index 9d75873f853f3a..8245aceecdfbb0 100644 --- a/static/app/components/activity/note/input.spec.tsx +++ b/static/app/components/activity/note/input.spec.tsx @@ -104,7 +104,7 @@ describe('NoteInput', () => { describe('Existing Item', () => { const props = { noteId: 'item-id', - text: 'an existing item', + text: 'an **existing** [item](https://docs.sentry.io/)', }; it('edits existing message', async () => { @@ -114,18 +114,24 @@ describe('NoteInput', () => { // Switch to preview await userEvent.click(screen.getByRole('radio', {name: 'Preview'})); - expect(screen.getByText('an existing item')).toBeInTheDocument(); + expect(screen.getByText('existing').closest('strong')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'item'})).toHaveAttribute( + 'href', + 'https://docs.sentry.io/' + ); // Switch to edit await userEvent.click(screen.getByRole('radio', {name: 'Edit'})); - expect(screen.getByRole('textbox')).toHaveTextContent('an existing item'); + expect(screen.getByRole('textbox')).toHaveTextContent( + 'an **existing** [item](https://docs.sentry.io/)' + ); // Can edit text await userEvent.type(screen.getByRole('textbox'), ' new content{Control>}{Enter}'); expect(onUpdate).toHaveBeenCalledWith({ - text: 'an existing item new content', + text: 'an **existing** [item](https://docs.sentry.io/) new content', mentions: [], }); }); diff --git a/static/app/components/activity/note/input.tsx b/static/app/components/activity/note/input.tsx index c2fca1e9f9a7bf..2baed82c40baf9 100644 --- a/static/app/components/activity/note/input.tsx +++ b/static/app/components/activity/note/input.tsx @@ -1,7 +1,6 @@ import {useCallback, useId, useState} from 'react'; import {Mention, MentionsInput} from 'react-mentions'; -import type {Theme} from '@emotion/react'; -import {css, useTheme} from '@emotion/react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion, useReducedMotion} from 'framer-motion'; import {z} from 'zod'; @@ -9,13 +8,13 @@ import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; import {Flex} from '@sentry/scraps/layout'; +import {Markdown} from '@sentry/scraps/markdown'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Text} from '@sentry/scraps/text'; import {IconMarkdown} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {textStyles} from 'sentry/styles/text'; import type {NoteType} from 'sentry/types/alerts'; -import {MarkedText} from 'sentry/utils/marked/markedText'; import {useMemberMentionData} from 'sentry/utils/members/useMemberMentionData'; import {useTeams} from 'sentry/utils/useTeams'; @@ -156,65 +155,62 @@ export function NoteInput({ {field => ( - +
{editorMode === 'write' ? ( > {({ref, ...fieldProps}) => ( - - { - setAreControlsVisible(true); - field.handleChange(e.target.value); - onChange?.(e, {updating: existingItem}); - }} - onFocus={() => setAreControlsVisible(true)} - onKeyDown={e => { - if ( - e.key === 'Enter' && - (e.metaKey || e.ctrlKey) && - field.state.value.trim() !== '' - ) { - e.preventDefault(); - form.handleSubmit(); - } - }} - value={field.state.value} - required - autoFocus={existingItem} - > - `@${display}`} - markup="**[sentry.strip:member]__display__**" - appendSpaceOnAdd - /> - display} - appendSpaceOnAdd - /> - - + { + setAreControlsVisible(true); + field.handleChange(e.target.value); + onChange?.(e, {updating: existingItem}); + }} + onFocus={() => setAreControlsVisible(true)} + onKeyDown={e => { + if ( + e.key === 'Enter' && + (e.metaKey || e.ctrlKey) && + field.state.value.trim() !== '' + ) { + e.preventDefault(); + form.handleSubmit(); + } + }} + value={field.state.value} + required + autoFocus={existingItem} + > + `@${display}`} + markup="**[sentry.strip:member]__display__**" + appendSpaceOnAdd + /> + display} + appendSpaceOnAdd + /> + )} ) : ( - + + + )} - +
)}
@@ -236,16 +232,20 @@ export function NoteInput({ {t('Preview')} - - - {t('Markdown supported')} - + + + + {t('Markdown supported')} + + {errorMessage && ( -
- {errorMessage} -
+ + + {errorMessage} + + )} {existingItem && ( @@ -270,62 +270,20 @@ export function NoteInput({ ); } -type NotePreviewProps = { - minHeight: Props['minHeight']; - theme: Theme; -}; - -const getNotePreviewCss = (p: NotePreviewProps) => css` - max-height: 1000px; - max-width: 100%; - ${p.minHeight - ? css` - min-height: ${p.minHeight}px; - ` - : ''}; - padding: ${p.theme.space.lg} ${p.theme.space.lg}; - overflow: auto; - border: 0; -`; - const EditorSurface = styled('div')` background: ${p => p.theme.tokens.background.primary}; border: 1px solid ${p => p.theme.tokens.border.primary}; border-radius: ${p => p.theme.radius.md}; `; -const NoteInputPanel = styled('div')` - ${textStyles} -`; - -const MentionsEditor = styled('div')` - flex: 1; - min-width: 0; -`; - const MotionControls = styled(motion.div)` overflow: hidden; isolation: isolate; `; -const ErrorMessage = styled('span')` - display: flex; - align-items: center; - height: 100%; - color: ${p => p.theme.tokens.content.danger}; - font-size: 0.9em; -`; - -const MarkdownIndicator = styled('span')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; - color: ${p => p.theme.tokens.content.secondary}; - font-size: ${p => p.theme.font.size.sm}; -`; - -const NotePreview = styled(MarkedText, { - shouldForwardProp: prop => prop !== 'minHeight', -})<{minHeight: Props['minHeight']}>` - ${p => getNotePreviewCss(p)}; +const NotePreview = styled('div')` + max-height: 1000px; + max-width: 100%; + padding: ${p => p.theme.space.lg}; + overflow: auto; `; diff --git a/static/app/components/activity/note/mentionStyle.tsx b/static/app/components/activity/note/mentionStyle.tsx index 303ebf6b8d0285..d5fd82d61714cc 100644 --- a/static/app/components/activity/note/mentionStyle.tsx +++ b/static/app/components/activity/note/mentionStyle.tsx @@ -23,6 +23,8 @@ export function mentionStyle({theme, minHeight, inputStyle}: Options) { }; return { + width: '100%', + control: { backgroundColor: 'transparent', fontSize: theme.font.size.md, diff --git a/static/app/components/contextPickerModal.tsx b/static/app/components/contextPickerModal.tsx index dd5008c55e0972..ea107161cdfa44 100644 --- a/static/app/components/contextPickerModal.tsx +++ b/static/app/components/contextPickerModal.tsx @@ -27,7 +27,7 @@ import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Integration} from 'sentry/types/integrations'; -import type {Organization, Team} from 'sentry/types/organization'; +import type {OrganizationSummary, Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; @@ -67,7 +67,7 @@ type SharedProps = ModalRenderProps & { }; type ContentProps = SharedProps & { - organizations: Organization[]; + organizations: OrganizationSummary[]; selectedOrgSlug: string | undefined; setSelectedOrgSlug: (slug: string) => void; projectSlugs?: string[]; @@ -464,7 +464,7 @@ function TeamSelector({ function ConfigUrlContainer( props: SharedProps & { configQueryKey: ApiQueryKey; - organizations: Organization[]; + organizations: OrganizationSummary[]; selectedOrgSlug: string | undefined; setSelectedOrgSlug: Dispatch>; } @@ -514,7 +514,7 @@ function ConfigPickerContent({ Body, }: SharedProps & { integrationConfigs: Integration[]; - organizations: Organization[]; + organizations: OrganizationSummary[]; selectedOrgSlug: string | undefined; setSelectedOrgSlug: Dispatch>; }) { diff --git a/static/app/components/core/alert/alert.snapshots.tsx b/static/app/components/core/alert/alert.snapshots.tsx index 8492a95cb5fc8e..9e13a0889489e3 100644 --- a/static/app/components/core/alert/alert.snapshots.tsx +++ b/static/app/components/core/alert/alert.snapshots.tsx @@ -24,7 +24,7 @@ describe('Alert', () => { ), - variant => ({theme: themeName, variant: String(variant)}) + variant => ({tags: {variant: String(variant), area: 'core'}}) ); it.snapshot.each([ @@ -44,7 +44,7 @@ describe('Alert', () => { ), - variant => ({theme: themeName, variant: String(variant), showIcon: 'false'}) + variant => ({tags: {variant: String(variant), showIcon: 'false', area: 'core'}}) ); it.snapshot.each([ @@ -64,7 +64,7 @@ describe('Alert', () => { ), - variant => ({theme: themeName, variant: String(variant), system: 'true'}) + variant => ({tags: {variant: String(variant), system: 'true', area: 'core'}}) ); }); }); diff --git a/static/app/components/core/badge/badge.snapshots.tsx b/static/app/components/core/badge/badge.snapshots.tsx index 8541a9bfc64871..7a3dea24025bc0 100644 --- a/static/app/components/core/badge/badge.snapshots.tsx +++ b/static/app/components/core/badge/badge.snapshots.tsx @@ -31,7 +31,7 @@ describe('Badge', () => { ), - variant => ({theme: themeName, variant: String(variant)}) + variant => ({tags: {variant: String(variant), area: 'core'}}) ); }); }); diff --git a/static/app/components/core/button/button.snapshots.tsx b/static/app/components/core/button/button.snapshots.tsx index 3166a36df39a50..545d826f761fdf 100644 --- a/static/app/components/core/button/button.snapshots.tsx +++ b/static/app/components/core/button/button.snapshots.tsx @@ -46,6 +46,7 @@ describe('Button', () => { { group: `${themeName} – without icon`, display_name: `${themeName} / ${variant} / ${size} / without icon`, + tags: {variant: String(variant), size: String(size), area: 'core'}, } ); @@ -61,6 +62,7 @@ describe('Button', () => { { group: `${themeName} – with icon`, display_name: `${themeName} / ${variant} / ${size} / with icon`, + tags: {variant: String(variant), size: String(size), area: 'core'}, } ); @@ -79,6 +81,7 @@ describe('Button', () => { { group: `${themeName} – icon-only`, display_name: `${themeName} / ${variant} / ${size} / icon-only`, + tags: {variant: String(variant), size: String(size), area: 'core'}, } ); }); diff --git a/static/app/components/core/checkbox/checkbox.snapshots.tsx b/static/app/components/core/checkbox/checkbox.snapshots.tsx index 10889450c17252..3b3e494819f844 100644 --- a/static/app/components/core/checkbox/checkbox.snapshots.tsx +++ b/static/app/components/core/checkbox/checkbox.snapshots.tsx @@ -17,31 +17,44 @@ describe('Checkbox', () => { {}} /> - ) + ), + checked => ({tags: {checked: String(checked), area: 'core'}}) ); - it.snapshot.each(['xs', 'sm', 'md'])('size-%s', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each(['xs', 'sm', 'md'])( + 'size-%s', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size: String(size), area: 'core'}}) + ); - it.snapshot('disabled-unchecked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-unchecked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('disabled-checked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-checked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', checked: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/input/inputGroup.snapshots.tsx b/static/app/components/core/input/inputGroup.snapshots.tsx index 4115e463bdc891..721e608db4fb66 100644 --- a/static/app/components/core/input/inputGroup.snapshots.tsx +++ b/static/app/components/core/input/inputGroup.snapshots.tsx @@ -22,43 +22,55 @@ describe('InputGroup', () => { ), - size => ({theme: themeName, size: String(size)}) + size => ({tags: {size: String(size), area: 'core'}}) ); - it.snapshot('disabled', () => ( - -
- - - -
-
- )); + it.snapshot( + 'disabled', + () => ( + +
+ + + +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('with-leading-items', () => ( - -
- - - - - - -
-
- )); + it.snapshot( + 'with-leading-items', + () => ( + +
+ + + + + + +
+
+ ), + {tags: {area: 'core'}} + ); - it.snapshot('with-leading-items-disabled', () => ( - -
- - - - - - -
-
- )); + it.snapshot( + 'with-leading-items-disabled', + () => ( + +
+ + + + + + +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/radio/radio.snapshots.tsx b/static/app/components/core/radio/radio.snapshots.tsx index 47394929fb5167..6f5bce5c0071d9 100644 --- a/static/app/components/core/radio/radio.snapshots.tsx +++ b/static/app/components/core/radio/radio.snapshots.tsx @@ -9,36 +9,52 @@ const themes = {light: lightTheme, dark: darkTheme}; describe('Radio', () => { describe.each(['light', 'dark'] as const)('theme-%s', themeName => { - it.snapshot.each<'sm' | 'md'>(['sm', 'md'])('size-%s-unchecked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each<'sm' | 'md'>(['sm', 'md'])( + 'size-%s-unchecked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size, area: 'core'}}) + ); - it.snapshot.each<'sm' | 'md'>(['sm', 'md'])('size-%s-checked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each<'sm' | 'md'>(['sm', 'md'])( + 'size-%s-checked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size, checked: 'true', area: 'core'}}) + ); - it.snapshot('disabled-unchecked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-unchecked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('disabled-checked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-checked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', checked: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/switch/switch.snapshots.tsx b/static/app/components/core/switch/switch.snapshots.tsx index d4bec807b47f56..e8465d9326c309 100644 --- a/static/app/components/core/switch/switch.snapshots.tsx +++ b/static/app/components/core/switch/switch.snapshots.tsx @@ -9,36 +9,52 @@ const themes = {light: lightTheme, dark: darkTheme}; describe('Switch', () => { describe.each(['light', 'dark'] as const)('theme-%s', themeName => { - it.snapshot.each(['sm', 'lg'])('size-%s-unchecked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each(['sm', 'lg'])( + 'size-%s-unchecked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size: String(size), area: 'core'}}) + ); - it.snapshot.each(['sm', 'lg'])('size-%s-checked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each(['sm', 'lg'])( + 'size-%s-checked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size: String(size), checked: 'true', area: 'core'}}) + ); - it.snapshot('disabled-unchecked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-unchecked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('disabled-checked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-checked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', checked: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/text/text.snapshots.tsx b/static/app/components/core/text/text.snapshots.tsx index 2512cd819d00d8..b0e44d9377b1a7 100644 --- a/static/app/components/core/text/text.snapshots.tsx +++ b/static/app/components/core/text/text.snapshots.tsx @@ -19,7 +19,7 @@ describe('Text', () => { ), - size => ({theme: themeName, size}) + size => ({tags: {size, area: 'core'}}) ); it.snapshot.each([ @@ -39,7 +39,7 @@ describe('Text', () => { ), - variant => ({theme: themeName, variant}) + variant => ({tags: {variant, area: 'core'}}) ); it.snapshot( @@ -51,7 +51,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -63,7 +63,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -75,7 +75,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -87,7 +87,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -99,7 +99,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -111,7 +111,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -123,7 +123,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -135,7 +135,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -147,7 +147,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot.each(['left', 'center', 'right', 'justify'] as const)( @@ -161,7 +161,7 @@ describe('Text', () => { ), - align => ({theme: themeName, align}) + align => ({tags: {align, area: 'core'}}) ); it.snapshot.each(['compressed', 'comfortable'] as const)( @@ -176,7 +176,7 @@ describe('Text', () => { ), - density => ({theme: themeName, density}) + density => ({tags: {density, area: 'core'}}) ); it.snapshot( @@ -190,7 +190,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -204,7 +204,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot.each(['balance', 'pretty', 'nowrap', 'stable'] as const)( @@ -218,7 +218,7 @@ describe('Text', () => { ), - textWrap => ({theme: themeName, textWrap}) + textWrap => ({tags: {textWrap, area: 'core'}}) ); it.snapshot.each(['nowrap', 'pre', 'pre-line', 'pre-wrap'] as const)( @@ -230,7 +230,7 @@ describe('Text', () => { ), - wrap => ({theme: themeName, wrap}) + wrap => ({tags: {wrap, area: 'core'}}) ); // === Combined props === @@ -245,7 +245,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -259,7 +259,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -273,7 +273,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -287,7 +287,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -302,7 +302,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); }); }); diff --git a/static/app/components/events/userFeedback.spec.tsx b/static/app/components/events/userFeedback.spec.tsx new file mode 100644 index 00000000000000..67680cc7055a08 --- /dev/null +++ b/static/app/components/events/userFeedback.spec.tsx @@ -0,0 +1,89 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import type {UserReport} from 'sentry/types/group'; +import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; + +import {EventUserFeedback} from './userFeedback'; + +jest.mock('sentry/utils/useCopyToClipboard'); +const mockCopy = jest.fn(); +jest.mocked(useCopyToClipboard).mockReturnValue({copy: mockCopy}); + +function makeReport(overrides: Partial = {}): UserReport { + return { + comments: 'Line one\n', + dateCreated: '2024-01-01T00:00:00.000Z', + email: 'jane@example.com', + event: {eventID: 'abc123', id: '1'}, + eventID: 'abc123', + id: '1', + issue: {} as UserReport['issue'], + name: 'Jane Reporter', + user: { + avatarUrl: null, + email: 'jane@example.com', + id: '1', + ipAddress: null, + name: 'Jane Reporter', + username: 'jane', + }, + ...overrides, + }; +} + +describe('EventUserFeedback', () => { + beforeEach(() => { + mockCopy.mockClear(); + }); + + it('renders feedback details and copies the reporter email', async () => { + render( + + ); + + expect(screen.getByText('Jane Reporter')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'View event'})).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/123/events/abc123/?referrer=user-feedback' + ); + + const emailButton = screen.getByRole('button', {name: 'jane@example.com'}); + await userEvent.click(emailButton); + + expect(mockCopy).toHaveBeenCalledWith('jane@example.com', { + successMessage: 'Copied email to clipboard', + }); + }); + + it('does not repeat the email when it matches the reporter name', async () => { + render( + + ); + + expect(screen.getAllByText('Jane@Example.com')).toHaveLength(1); + + await userEvent.click(screen.getByRole('button', {name: 'Copy email address'})); + + expect(mockCopy).toHaveBeenCalledWith('jane@example.com', { + successMessage: 'Copied email to clipboard', + }); + }); + + it('preserves comment text without rendering html and hides the event link', () => { + render(); + + expect(screen.queryByRole('link', {name: 'View event'})).not.toBeInTheDocument(); + expect(screen.getByTestId('letter_avatar-avatar')).toHaveTextContent('JR'); + expect(document.querySelector('script')).not.toBeInTheDocument(); + const comment = document.querySelector('p'); + expect(comment?.textContent).toBe('Line one\n'); + }); +}); diff --git a/static/app/components/events/userFeedback.stories.tsx b/static/app/components/events/userFeedback.stories.tsx new file mode 100644 index 00000000000000..b99db848e4df92 --- /dev/null +++ b/static/app/components/events/userFeedback.stories.tsx @@ -0,0 +1,33 @@ +import * as Storybook from 'sentry/stories'; +import type {UserReport} from 'sentry/types/group'; + +import {EventUserFeedback} from './userFeedback'; + +const report: UserReport = { + comments: 'The checkout button did nothing after I submitted payment.\nI tried twice.', + dateCreated: '2024-01-01T00:00:00.000Z', + email: 'jane@example.com', + event: {eventID: 'abc123', id: '1'}, + eventID: 'abc123', + id: '1', + name: 'Jane Reporter', + user: { + avatarUrl: null, + email: 'jane@example.com', + id: '1', + ipAddress: null, + name: 'Jane Reporter', + username: 'jane', + }, +}; + +export default Storybook.story('EventUserFeedback', story => { + story('Default', () => ( +
+ +
+ )); +}); diff --git a/static/app/components/events/userFeedback.tsx b/static/app/components/events/userFeedback.tsx index ddc9478a106369..c3f2cd05da3557 100644 --- a/static/app/components/events/userFeedback.tsx +++ b/static/app/components/events/userFeedback.tsx @@ -1,95 +1,140 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; -import {ActivityAuthor} from 'sentry/components/activity/author'; -import {ActivityItem} from 'sentry/components/activity/item'; +import {ActivityAvatar} from 'sentry/components/activity/item/avatar'; +import {TimeSince} from 'sentry/components/timeSince'; import {IconCopy} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {UserReport} from 'sentry/types/group'; -import {escape, nl2br} from 'sentry/utils'; +import type {AvatarUser} from 'sentry/types/user'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; type Props = { - issueId: string; - orgSlug: string; report: UserReport; - className?: string; - showEventLink?: boolean; + eventLink?: string; }; -export function EventUserFeedback({ - className, - report, - orgSlug, - issueId, - showEventLink = true, -}: Props) { - const user = report.user || { - name: report.name, - email: report.email, - id: '', - username: '', - ip_address: '', - }; - +export function EventUserFeedback({eventLink, report}: Props) { const {copy} = useCopyToClipboard(); + const showEmailLabel = !isSameIdentity(report.name, report.email); + const copyEmail = () => + copy(report.email, {successMessage: t('Copied email to clipboard')}); return ( -
- - {report.name} - + + + + + + + {report.name} + + + + {eventLink && ( + + {t('View event')} + )} - } - > -

- -

+ + + + +
+ + + + {report.comments} + + + +
); } -const StyledActivityItem = styled(ActivityItem)` - margin-bottom: 0; -`; +function isSameIdentity(name: string, email: string) { + return name.trim().toLowerCase() === email.trim().toLowerCase(); +} -const CopyButton = styled(Button)` - color: ${p => p.theme.tokens.content.secondary}; - font-size: ${p => p.theme.font.size.sm}; - font-weight: ${p => p.theme.font.weight.sans.regular}; -`; +function getAvatarUser(report: UserReport): AvatarUser | undefined { + const user = report.user; + + if (!user) { + return { + id: '', + email: report.email, + name: report.name, + username: '', + ip_address: '', + }; + } + + return { + id: user.id, + email: user.email ?? '', + name: user.name ?? report.name, + username: user.username ?? '', + ip_address: user.ipAddress ?? '', + avatarUrl: user.avatarUrl ?? undefined, + }; +} + +const FeedbackBubble = styled('div')` + display: flex; + justify-content: center; + flex-direction: column; + align-items: stretch; + flex: 1; + width: 75%; + overflow-wrap: break-word; + background-color: ${p => p.theme.tokens.background.primary}; + border: 1px solid ${p => p.theme.tokens.border.primary}; + border-radius: ${p => p.theme.radius.md}; + position: relative; -const StyledIconCopy = styled(IconCopy)``; + &:before { + display: block; + content: ''; + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid ${p => p.theme.tokens.border.primary}; + position: absolute; + left: -7px; + top: 12px; + } -const ViewEventLink = styled(Link)` - font-weight: ${p => p.theme.font.weight.sans.regular}; - font-size: 0.9em; + &:after { + display: block; + content: ''; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + border-right: 6px solid ${p => p.theme.tokens.background.primary}; + position: absolute; + left: -6px; + top: 13px; + } `; diff --git a/static/app/components/idBadge/baseBadge.tsx b/static/app/components/idBadge/baseBadge.tsx index 827a613732e288..7cb6f5252fbc59 100644 --- a/static/app/components/idBadge/baseBadge.tsx +++ b/static/app/components/idBadge/baseBadge.tsx @@ -11,7 +11,7 @@ import { import {Flex} from '@sentry/scraps/layout'; import type {Actor} from 'sentry/types/core'; -import type {Organization, Team} from 'sentry/types/organization'; +import type {OrganizationSummary, Team} from 'sentry/types/organization'; import type {AvatarProject} from 'sentry/types/project'; import type {AvatarUser} from 'sentry/types/user'; import type {SpaceSize} from 'sentry/utils/theme'; @@ -30,7 +30,7 @@ export interface BaseBadgeProps { interface AllBaseBadgeProps extends BaseBadgeProps { displayName: React.ReactNode; actor?: Actor; - organization?: Organization; + organization?: OrganizationSummary; project?: AvatarProject; team?: Team; user?: AvatarUser; diff --git a/static/app/components/idBadge/organizationBadge.tsx b/static/app/components/idBadge/organizationBadge.tsx index 8ec36c5b51b6a6..8c1af86ba5d7a1 100644 --- a/static/app/components/idBadge/organizationBadge.tsx +++ b/static/app/components/idBadge/organizationBadge.tsx @@ -1,10 +1,10 @@ -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import {BadgeDisplayName} from './badgeDisplayName'; import {BaseBadge, type BaseBadgeProps} from './baseBadge'; export interface OrganizationBadgeProps extends BaseBadgeProps { - organization: Organization; + organization: OrganizationSummary; /** * When true will default max-width, or specify one as a string */ diff --git a/static/app/components/issueDiff/index.tsx b/static/app/components/issueDiff/index.tsx index 9cbe582a49d2a8..7f93e19f6acc90 100644 --- a/static/app/components/issueDiff/index.tsx +++ b/static/app/components/issueDiff/index.tsx @@ -1,5 +1,5 @@ -import {lazy, useEffect, useMemo, useRef} from 'react'; -import {useQuery} from '@tanstack/react-query'; +import {lazy, useEffect, useRef} from 'react'; +import {skipToken, useQueries} from '@tanstack/react-query'; import {Flex} from '@sentry/scraps/layout'; @@ -71,99 +71,112 @@ export function IssueDiff({ const newestFirst = isStacktraceNewestFirst(); - const baseLatestQuery = useQuery({ - ...apiOptions.as<{eventID: string}>()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: baseIssueId, - eventId: 'latest', - }, - staleTime: 60_000, - } - ), - enabled: baseEventId === 'latest', - }); - - const targetLatestQuery = useQuery({ - ...apiOptions.as<{eventID: string}>()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: targetIssueId, - eventId: 'latest', - }, - staleTime: 60_000, - } - ), - enabled: targetEventId === 'latest', + // Resolve "latest" to concrete event IDs (skipped if concrete IDs were passed) + const [baseLatestQuery, targetLatestQuery] = useQueries({ + queries: [ + apiOptions.as<{eventID: string}>()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: + baseEventId === 'latest' + ? { + organizationIdOrSlug: organization.slug, + issueId: baseIssueId, + eventId: 'latest', + } + : skipToken, + staleTime: 60_000, + } + ), + apiOptions.as<{eventID: string}>()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: + targetEventId === 'latest' + ? { + organizationIdOrSlug: organization.slug, + issueId: targetIssueId, + eventId: 'latest', + } + : skipToken, + staleTime: 60_000, + } + ), + ], }); + // Derive resolved IDs reactively from the query results const resolvedBaseEventId = baseEventId === 'latest' ? baseLatestQuery.data?.eventID : baseEventId; const resolvedTargetEventId = targetEventId === 'latest' ? targetLatestQuery.data?.eventID : targetEventId; - const baseEventQuery = useQuery({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: baseIssueId, - eventId: resolvedBaseEventId ?? '', - }, - staleTime: 60_000, - } - ), - enabled: Boolean(resolvedBaseEventId), - }); - - const targetEventQuery = useQuery({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: targetIssueId, - eventId: resolvedTargetEventId ?? '', - }, - staleTime: 60_000, - } - ), - enabled: Boolean(resolvedTargetEventId), - }); - - const {combinedBase, combinedTarget} = useMemo( - () => ({ + // Fetch actual event data once IDs are resolved + const { + combinedBase, + combinedTarget, + isLoading, + hasError, + baseEventData, + targetEventData, + } = useQueries({ + queries: [ + apiOptions.as()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: resolvedBaseEventId + ? { + organizationIdOrSlug: organization.slug, + issueId: baseIssueId, + eventId: resolvedBaseEventId, + } + : skipToken, + staleTime: 60_000, + } + ), + apiOptions.as()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: resolvedTargetEventId + ? { + organizationIdOrSlug: organization.slug, + issueId: targetIssueId, + eventId: resolvedTargetEventId, + } + : skipToken, + staleTime: 60_000, + } + ), + ], + combine: ([baseEvent, targetEvent]) => ({ + isLoading: baseEvent.isPending || targetEvent.isPending, + hasError: + baseLatestQuery.isError || + targetLatestQuery.isError || + baseEvent.isError || + targetEvent.isError, + baseEventData: baseEvent.data, + targetEventData: targetEvent.data, combinedBase: getCombinedStacktrace({ - event: baseEventQuery.data, + event: baseEvent.data, hasSimilarityEmbeddingsFeature, newestFirst, }), combinedTarget: getCombinedStacktrace({ - event: targetEventQuery.data, + event: targetEvent.data, hasSimilarityEmbeddingsFeature, newestFirst, }), }), - [ - baseEventQuery.data, - targetEventQuery.data, - hasSimilarityEmbeddingsFeature, - newestFirst, - ] - ); + }); useEffect(() => { if ( hasTrackedAnalytics.current || !organization || !hasSimilarityEmbeddingsFeature || - !baseEventQuery.data || - !targetEventQuery.data + !baseEventData || + !targetEventData ) { return; } @@ -171,27 +184,19 @@ export function IssueDiff({ hasTrackedAnalytics.current = true; trackAnalytics('issue_details.similar_issues.diff_clicked', { organization, - project_id: baseEventQuery.data?.projectID, - group_id: baseEventQuery.data?.groupID, - parent_group_id: targetEventQuery.data?.groupID, + project_id: baseEventData?.projectID, + group_id: baseEventData?.groupID, + parent_group_id: targetEventData?.groupID, shouldBeGrouped, }); }, [ - baseEventQuery.data, + baseEventData, hasSimilarityEmbeddingsFeature, organization, shouldBeGrouped, - targetEventQuery.data, + targetEventData, ]); - const hasError = - baseLatestQuery.isError || - targetLatestQuery.isError || - baseEventQuery.isError || - targetEventQuery.isError; - - const isLoading = baseEventQuery.isPending || targetEventQuery.isPending; - if (hasError) { return ( diff --git a/static/app/components/onboarding/onboardingContext.tsx b/static/app/components/onboarding/onboardingContext.tsx index 42a3279729584a..3e51ceeeaa0a7d 100644 --- a/static/app/components/onboarding/onboardingContext.tsx +++ b/static/app/components/onboarding/onboardingContext.tsx @@ -12,7 +12,7 @@ import type {AlertRuleOptions} from 'sentry/views/projectInstall/issueAlertOptio * Cleared by the platform features step when the platform changes, so * stale inputs don't carry across platform selections. */ -interface ProjectDetailsFormState { +export interface ProjectDetailsFormState { alertRuleConfig?: AlertRuleOptions; projectName?: string; teamSlug?: string; @@ -34,7 +34,7 @@ type OnboardingContextProps = { selectedRepository?: Repository; }; -export type OnboardingSessionState = { +type OnboardingSessionState = { createdProjectSlug?: string; projectDetailsForm?: ProjectDetailsFormState; selectedFeatures?: ProductSolution[]; diff --git a/static/app/components/pageFilters/project/projectPageFilter.spec.tsx b/static/app/components/pageFilters/project/projectPageFilter.spec.tsx index 7024791c2a91e4..70a2daaa688361 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.spec.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.spec.tsx @@ -731,6 +731,47 @@ describe('ProjectPageFilter', () => { expect(within(projectRows[3]!).getByText('regular-project-b')).toBeInTheDocument(); }); + it('sorts bookmarked non-member projects above unbookmarked member projects', async () => { + const projectsWithMixedMembership = [ + ProjectFixture({id: '1', slug: 'selected-project', isMember: true}), + ProjectFixture({id: '2', slug: 'member-project', isMember: true}), + ProjectFixture({ + id: '3', + slug: 'bookmarked-non-member', + isMember: false, + isBookmarked: true, + }), + ProjectFixture({id: '4', slug: 'non-member-project', isMember: false}), + ]; + + ProjectsStore.loadInitialData(projectsWithMixedMembership); + + PageFiltersStore.onInitializeUrlState({ + projects: [1], + environments: [], + datetime: {start: null, end: null, period: '14d', utc: null}, + }); + + render(, { + organization, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {project: '1'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'selected-project'})); + + const projectRows = screen.getAllByRole('row'); + + // Skip the 2 special items (All Projects, My Projects) + expect(within(projectRows[2]!).getByText('selected-project')).toBeInTheDocument(); + expect( + within(projectRows[3]!).getByText('bookmarked-non-member') + ).toBeInTheDocument(); + expect(within(projectRows[4]!).getByText('member-project')).toBeInTheDocument(); + expect(within(projectRows[5]!).getByText('non-member-project')).toBeInTheDocument(); + }); + it('maintains stable sort when bookmarking, then applies new sort on menu reopen', async () => { const mockApi = MockApiClient.addMockResponse({ method: 'PUT', diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 0c560b153ae657..46573bbef4a82f 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -352,8 +352,8 @@ export function ProjectPageFilter({ bookmarkedSnapshotRef.current ? [ !lastSelected.includes(parseInt(project.id, 10)), - !project.isMember, !bookmarkedSnapshotRef.current.has(project.id), + !project.isMember, project.slug, ] : [ diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx new file mode 100644 index 00000000000000..2d42a87eedcb9b --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx @@ -0,0 +1,168 @@ +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {bitbucketServerIntegrationPipeline} from './pipelineIntegrationBitbucketServer'; +import {createMakeStepProps, dispatchPipelineMessage, setupMockPopup} from './testUtils'; + +const InstallationConfigStep = bitbucketServerIntegrationPipeline.steps[0].component; +const OAuthCallbackStep = bitbucketServerIntegrationPipeline.steps[1].component; + +const makeStepProps = createMakeStepProps({totalSteps: 2}); + +let mockPopup: Window; + +beforeEach(() => { + mockPopup = setupMockPopup(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +async function fillRequiredConfigFields() { + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket URL'}), + 'https://bitbucket.example.com' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Key'}), + 'sentry-bot' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Private Key'}), + '-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----' + ); +} + +describe('Bitbucket Server InstallationConfigStep', () => { + it('renders the config form fields', () => { + render(); + + expect(screen.getByRole('textbox', {name: 'Bitbucket URL'})).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Key'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Private Key'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Continue'})).toBeInTheDocument(); + }); + + it('calls advance with form data on submit', async () => { + const advance = jest.fn(); + render(); + + await fillRequiredConfigFields(); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith({ + url: 'https://bitbucket.example.com', + consumerKey: 'sentry-bot', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----', + verifySsl: true, + }); + }); + }); + + it('strips trailing slashes from the URL', async () => { + const advance = jest.fn(); + render(); + + await fillRequiredConfigFields(); + await userEvent.clear(screen.getByRole('textbox', {name: 'Bitbucket URL'})); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket URL'}), + 'https://bitbucket.example.com///' + ); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith( + expect.objectContaining({url: 'https://bitbucket.example.com'}) + ); + }); + }); + + it('shows busy state when isAdvancing', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); + }); +}); + +describe('Bitbucket Server OAuthCallbackStep', () => { + const oauthUrl = + 'https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=req-token'; + + it('renders the authorize button', () => { + render(); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toBeInTheDocument(); + }); + + it('opens the popup and advances with oauthToken on callback', async () => { + const advance = jest.fn(); + render(); + + await userEvent.click( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ); + + expect(window.open).toHaveBeenCalledWith( + oauthUrl, + 'pipeline_popup', + expect.any(String) + ); + + dispatchPipelineMessage({ + source: mockPopup, + data: { + _pipeline_source: 'sentry-pipeline', + oauth_token: 'callback-token', + }, + }); + + expect(advance).toHaveBeenCalledWith({oauthToken: 'callback-token'}); + }); + + it('disables the authorize button when oauthUrl is missing', () => { + render(); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toBeDisabled(); + }); + + it('shows popup-blocked notice when window.open returns null', async () => { + jest.spyOn(window, 'open').mockReturnValue(null); + + render(); + + await userEvent.click( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ); + + expect( + screen.getByText( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + ) + ).toBeInTheDocument(); + }); + + it('shows busy state when isAdvancing', () => { + render( + + ); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toHaveAttribute('aria-busy', 'true'); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx new file mode 100644 index 00000000000000..d06435206457a5 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx @@ -0,0 +1,235 @@ +import {useCallback, useEffect} from 'react'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, setFieldErrors, useScrapsForm} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import {t, tct} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +import {useRedirectPopupStep} from './shared/useRedirectPopupStep'; +import type {PipelineDefinition, PipelineStepProps} from './types'; +import {pipelineComplete} from './types'; + +const installationConfigSchema = z.object({ + url: z + .string() + .min(1, t('Bitbucket URL is required')) + .url(t('Enter a valid URL')) + .transform(v => v.replace(/\/+$/, '')), + consumerKey: z + .string() + .min(1, t('Consumer Key is required')) + .max(200, t('Consumer Key is limited to 200 characters')), + privateKey: z.string().min(1, t('Private Key is required')), + verifySsl: z.boolean(), +}); + +interface InstallationConfigAdvanceData { + consumerKey: string; + privateKey: string; + url: string; + verifySsl: boolean; +} + +function InstallationConfigStep({ + advance, + advanceError, + isAdvancing, + isInitializing, +}: PipelineStepProps, InstallationConfigAdvanceData>) { + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + url: '', + consumerKey: '', + privateKey: '', + verifySsl: true, + }, + validators: {onDynamic: installationConfigSchema}, + onSubmit: ({value}) => { + advance(installationConfigSchema.parse(value)); + }, + }); + + useEffect(() => { + if (advanceError) { + setFieldErrors(form, advanceError); + } + }, [advanceError, form]); + + return ( + + + + {tct( + 'Create an Application Link on your Bitbucket Server instance for Sentry, then enter the consumer credentials below. Refer to the [link:documentation] for setup instructions.', + { + link: ( + + ), + } + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + + {t('Continue')} + + + + + ); +} + +interface OAuthStepData { + oauthUrl?: string; +} + +function OAuthCallbackStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const handleCallback = useCallback( + (data: Record) => { + if (data.oauth_token) { + advance({oauthToken: data.oauth_token}); + } + }, + [advance] + ); + + const {openPopup, isWaitingForCallback, popupStatus} = useRedirectPopupStep({ + redirectUrl: stepData?.oauthUrl, + onCallback: handleCallback, + }); + + return ( + + + + {t( + 'Authorize Sentry on your Bitbucket Server instance to complete the integration setup.' + )} + + {isWaitingForCallback && ( + + {t('A popup should have opened to authorize with Bitbucket Server.')} + + )} + {popupStatus === 'failed-to-open' && ( + + {t( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + )} + + )} + + {isWaitingForCallback && !isAdvancing ? ( + + ) : ( + + )} + + ); +} + +export const bitbucketServerIntegrationPipeline = { + type: 'integration', + provider: 'bitbucket_server', + actionTitle: t('Installing Bitbucket Server Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'installation_config', + shortDescription: t('Configuring Bitbucket Server connection'), + component: InstallationConfigStep, + }, + { + stepId: 'oauth_callback', + shortDescription: t('Authorizing via OAuth'), + component: OAuthCallbackStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index d4e49141317ec2..71f9281ddb6254 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,6 +1,7 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; import {awsLambdaIntegrationPipeline} from './pipelineIntegrationAwsLambda'; import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; +import {bitbucketServerIntegrationPipeline} from './pipelineIntegrationBitbucketServer'; import {claudeCodeIntegrationPipeline} from './pipelineIntegrationClaudeCode'; import {cursorIntegrationPipeline} from './pipelineIntegrationCursor'; import {discordIntegrationPipeline} from './pipelineIntegrationDiscord'; @@ -23,6 +24,7 @@ import {vstsIntegrationPipeline} from './pipelineIntegrationVsts'; export const PIPELINE_REGISTRY = [ awsLambdaIntegrationPipeline, bitbucketIntegrationPipeline, + bitbucketServerIntegrationPipeline, claudeCodeIntegrationPipeline, cursorIntegrationPipeline, discordIntegrationPipeline, diff --git a/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx b/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx index ca955081a0d1b8..1769a9569292fd 100644 --- a/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx +++ b/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx @@ -100,8 +100,7 @@ describe('PreprodBuildsSnapshotTable', () => { } it.snapshot('status-approved', () => renderTable(makeBuild()), { - theme: themeName, - state: 'status-approved', + tags: {area: 'snapshots'}, }); it.snapshot( @@ -122,7 +121,7 @@ describe('PreprodBuildsSnapshotTable', () => { }, }) ), - {theme: themeName, state: 'status-needs-approval'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -143,7 +142,7 @@ describe('PreprodBuildsSnapshotTable', () => { }, }) ), - {theme: themeName, state: 'status-no-base-build'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -154,7 +153,7 @@ describe('PreprodBuildsSnapshotTable', () => { snapshot_comparison_info: undefined, }) ), - {theme: themeName, state: 'status-no-comparison'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -175,7 +174,7 @@ describe('PreprodBuildsSnapshotTable', () => { }, }) ), - {theme: themeName, state: 'changes-no-changes'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/stores/organizationsStore.tsx b/static/app/stores/organizationsStore.tsx index 23b7c4d07b353a..376142669bc0ce 100644 --- a/static/app/stores/organizationsStore.tsx +++ b/static/app/stores/organizationsStore.tsx @@ -1,22 +1,22 @@ import {createStore} from 'reflux'; -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import type {StrictStoreDefinition} from './types'; interface State { loaded: boolean; - organizations: Organization[]; + organizations: OrganizationSummary[]; } interface OrganizationsStoreDefinition extends StrictStoreDefinition { - addOrReplace(item: Organization): void; - get(slug: string): Organization | undefined; - getAll(): Organization[]; - load(items: Organization[]): void; - onChangeSlug(prev: Organization, next: Partial): void; + addOrReplace(item: OrganizationSummary): void; + get(slug: string): OrganizationSummary | undefined; + getAll(): OrganizationSummary[]; + load(items: OrganizationSummary[]): void; + onChangeSlug(prev: OrganizationSummary, next: Partial): void; onRemoveSuccess(slug: string): void; - onUpdate(org: Partial): void; + onUpdate(org: Partial): void; remove(slug: string): void; } @@ -62,7 +62,7 @@ const storeConfig: OrganizationsStoreDefinition = { }, get(slug) { - return this.state.organizations.find((item: Organization) => item.slug === slug); + return this.state.organizations.find(item => item.slug === slug); }, getAll() { @@ -97,7 +97,7 @@ const storeConfig: OrganizationsStoreDefinition = { this.trigger(newOrgs); }, - load(items: Organization[]) { + load(items: OrganizationSummary[]) { const newOrgs = [...items]; this.state = {organizations: newOrgs, loaded: true}; this.trigger(newOrgs); diff --git a/static/app/types/event.tsx b/static/app/types/event.tsx index e159a6de9b1b2e..18acdce37cd2ce 100644 --- a/static/app/types/event.tsx +++ b/static/app/types/event.tsx @@ -12,7 +12,7 @@ import type {SymbolicatorStatus} from 'sentry/components/events/interfaces/types import type {RawCrumb} from './breadcrumbs'; import type {Image} from './debugImage'; -import type {IssueAttachment, IssueCategory, IssueType} from './group'; +import type {IssueAttachment, IssueCategory, IssueType, UserReport} from './group'; import type {PlatformKey} from './project'; import type {Release} from './release'; import type {StackTraceMechanism, StacktraceType} from './stacktrace'; @@ -789,7 +789,7 @@ interface EventBase { version: string | null; } | null; sdkUpdates?: SDKUpdatesSuggestion[]; - userReport?: any; + userReport?: UserReport | null; } interface TraceEventContexts extends EventContexts { diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx index d2c2c27444e27d..9cf74b4a918dff 100644 --- a/static/app/types/group.tsx +++ b/static/app/types/group.tsx @@ -1266,9 +1266,16 @@ export type UserReport = { event: {eventID: string; id: string}; eventID: string; id: string; - issue: Group; name: string; - user: User; + user: { + avatarUrl: string | null; + email: string | null; + id: string; + ipAddress: string | null; + name: string | null; + username: string | null; + } | null; + issue?: Group | null; }; export type KeyValueListDataItem = { diff --git a/static/app/utils.tsx b/static/app/utils.tsx index cb24e64fdda091..7ef5fb24d8b3df 100644 --- a/static/app/utils.tsx +++ b/static/app/utils.tsx @@ -18,10 +18,6 @@ export function defined(item: T): item is Exclude { return item !== undefined && item !== null; } -export function nl2br(str: string): string { - return str.replace(/\r\n|\r|\n/g, '
'); -} - export function escape(str: string): string { return str .replace(/&/g, '&') diff --git a/static/app/utils/api/knownGetsentryApiUrls.ts b/static/app/utils/api/knownGetsentryApiUrls.ts index cedea81749de18..a00abfdb27334b 100644 --- a/static/app/utils/api/knownGetsentryApiUrls.ts +++ b/static/app/utils/api/knownGetsentryApiUrls.ts @@ -36,7 +36,6 @@ export type KnownGetsentryApiUrls = | '/customers/$organizationIdOrSlug/members/' | '/customers/$organizationIdOrSlug/migrate-google-domain/' | '/customers/$organizationIdOrSlug/ondemand-budgets/' - | '/customers/$organizationIdOrSlug/plan-migrations/' | '/customers/$organizationIdOrSlug/policies/' | '/customers/$organizationIdOrSlug/product-trial/' | '/customers/$organizationIdOrSlug/projects/$projectIdOrSlug/stats/' diff --git a/static/app/utils/integrations/useAddIntegration.tsx b/static/app/utils/integrations/useAddIntegration.tsx index c6a3912976cc9f..e45844b9cfafdc 100644 --- a/static/app/utils/integrations/useAddIntegration.tsx +++ b/static/app/utils/integrations/useAddIntegration.tsx @@ -50,6 +50,7 @@ const UNCONDITIONAL_API_PIPELINE_PROVIDERS = [ 'cursor', 'discord', 'github', + 'github_enterprise', 'gitlab', 'opsgenie', 'pagerduty', diff --git a/static/app/utils/number/NUMBER_FORMATTING.md b/static/app/utils/number/NUMBER_FORMATTING.md index 616260ee7e10cd..13bf6f13d87c8c 100644 --- a/static/app/utils/number/NUMBER_FORMATTING.md +++ b/static/app/utils/number/NUMBER_FORMATTING.md @@ -266,6 +266,10 @@ Each entry shows the function signature, the rounding/precision logic, and concr [formatYAxisValue.tsx](static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue.tsx) · Integers → `formatAbbreviatedNumber`. Non-integers → `toLocaleString({maximumFractionDigits: 20})` (full precision, trusts ECharts to provide round values). +### `formatYAxisValue(value, 'number'/'integer', ...)` + +[formatYAxisValue.tsx](static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx) · NOTE: This function is ONLY for HEAT MAPS! Integers → `formatAbbreviatedNumber`. Non-integers → `formatNumberWithDynamicDecimalPoints(value)` (ECharts treats heat map y-axis as categories so it will not do a great job at formatting and providing round values. Hence we are rounding them off ourselves). + ### `formatTooltipValue(value, 'number'/'integer', ...)` [formatTooltipValue.tsx](static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue.tsx) · `toLocaleString({maximumFractionDigits: 4})`. If `0 < value < 0.0001`: switches to `{maximumSignificantDigits: 4}` to avoid `"0.0000"`. diff --git a/static/app/views/automations/components/automationListTable/index.tsx b/static/app/views/automations/components/automationListTable/index.tsx index c9255f40c24697..25de05b6e5bee3 100644 --- a/static/app/views/automations/components/automationListTable/index.tsx +++ b/static/app/views/automations/components/automationListTable/index.tsx @@ -8,7 +8,6 @@ import {LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import {LoadingError} from 'sentry/components/loadingError'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {SelectAllHeaderCheckbox} from 'sentry/components/workflowEngine/ui/selectAllHeaderCheckbox'; @@ -25,6 +24,7 @@ import { AutomationListRowSkeleton, } from 'sentry/views/automations/components/automationListTable/row'; import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants'; +import {useCanEditAutomation} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; type AutomationListTableProps = { @@ -91,7 +91,7 @@ export function AutomationListTable({ allResultsVisible, }: AutomationListTableProps) { const organization = useOrganization(); - const canEditAutomations = hasEveryAccess(['alerts:write'], {organization}); + const canEditAutomations = useCanEditAutomation(); const [query] = useQueryState('query', parseAsString); const [selected, setSelected] = useState(new Set()); diff --git a/static/app/views/automations/components/automationListTable/row.tsx b/static/app/views/automations/components/automationListTable/row.tsx index 331a4a546aceb6..1f161033d5e103 100644 --- a/static/app/views/automations/components/automationListTable/row.tsx +++ b/static/app/views/automations/components/automationListTable/row.tsx @@ -3,16 +3,15 @@ import styled from '@emotion/styled'; import {Checkbox} from '@sentry/scraps/checkbox'; import {Flex} from '@sentry/scraps/layout'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import {Placeholder} from 'sentry/components/placeholder'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {ActionCell} from 'sentry/components/workflowEngine/gridCell/actionCell'; import {AutomationTitleCell} from 'sentry/components/workflowEngine/gridCell/automationTitleCell'; import {TimeAgoCell} from 'sentry/components/workflowEngine/gridCell/timeAgoCell'; import type {Automation} from 'sentry/types/workflowEngine/automations'; -import {useOrganization} from 'sentry/utils/useOrganization'; import {AutomationListConnectedDetectors} from 'sentry/views/automations/components/automationListTable/connectedDetectors'; import {ProjectsCell} from 'sentry/views/automations/components/automationListTable/projectsCell'; +import {useCanEditAutomation} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {getAutomationActions} from 'sentry/views/automations/hooks/utils'; type AutomationListRowProps = { @@ -26,8 +25,7 @@ export function AutomationListRow({ selected, onSelect, }: AutomationListRowProps) { - const organization = useOrganization(); - const canEditAutomations = hasEveryAccess(['alerts:write'], {organization}); + const canEditAutomations = useCanEditAutomation(); const actions = getAutomationActions(automation); const {enabled, lastTriggered, detectorIds} = automation; diff --git a/static/app/views/automations/components/disabledAlert.spec.tsx b/static/app/views/automations/components/disabledAlert.spec.tsx index f7bedc2c5d3af3..9e9dacc9626811 100644 --- a/static/app/views/automations/components/disabledAlert.spec.tsx +++ b/static/app/views/automations/components/disabledAlert.spec.tsx @@ -89,7 +89,7 @@ describe('DisabledAlert', () => { expect( await screen.findByText( textWithMarkupMatcher( - 'You do not have permission to edit this alert. Ask your organization owner or manager to enable alert access for you.' + 'You do not have permission to create or edit alerts. Ask your organization owner or manager to enable alert access for you.' ) ) ).toBeInTheDocument(); diff --git a/static/app/views/automations/components/disabledAlert.tsx b/static/app/views/automations/components/disabledAlert.tsx index ea553502c75736..f75380e576a026 100644 --- a/static/app/views/automations/components/disabledAlert.tsx +++ b/static/app/views/automations/components/disabledAlert.tsx @@ -1,14 +1,15 @@ import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; -import {Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import {IconPlay} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; +import {t} from 'sentry/locale'; import type {Automation} from 'sentry/types/workflowEngine/automations'; -import {useOrganization} from 'sentry/utils/useOrganization'; import {useUpdateAutomation} from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; type DisabledAlertProps = { automation: Automation; @@ -20,10 +21,9 @@ type DisabledAlertProps = { * enable it. The alert automatically hides when the automation is enabled. */ export function DisabledAlert({automation}: DisabledAlertProps) { - const organization = useOrganization(); const {mutate: updateAutomation, isPending: isEnabling} = useUpdateAutomation(); - const canEdit = hasEveryAccess(['alerts:write'], {organization}); + const canEdit = useCanEditAutomation(); if (automation.enabled) { return null; @@ -37,19 +37,7 @@ export function DisabledAlert({automation}: DisabledAlertProps) { }); }; - const permissionTooltipText = tct( - 'You do not have permission to edit this alert. Ask your organization owner or manager to [settingsLink:enable alert access] for you.', - { - settingsLink: ( - - ), - } - ); + const permissionTooltipText = getNoAlertWritePermissionTooltip(); return ( diff --git a/static/app/views/automations/components/editAutomationActions.tsx b/static/app/views/automations/components/editAutomationActions.tsx index b9aa817afe277a..13cc9fb3ba0698 100644 --- a/static/app/views/automations/components/editAutomationActions.tsx +++ b/static/app/views/automations/components/editAutomationActions.tsx @@ -14,6 +14,10 @@ import { useDeleteAutomationMutation, useUpdateAutomation, } from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {makeAutomationBasePathname} from 'sentry/views/automations/pathnames'; interface EditAutomationActionsProps { @@ -24,6 +28,8 @@ interface EditAutomationActionsProps { export function EditAutomationActions({automation, form}: EditAutomationActionsProps) { const organization = useOrganization(); const navigate = useNavigate(); + const canEdit = useCanEditAutomation(); + const permissionTooltipText = canEdit ? undefined : getNoAlertWritePermissionTooltip(); const {mutateAsync: deleteAutomation, isPending: isDeleting} = useDeleteAutomationMutation(); const {mutate: updateAutomation, isPending: isUpdating} = useUpdateAutomation(); @@ -63,16 +69,30 @@ export function EditAutomationActions({automation, form}: EditAutomationActionsP variant="secondary" size="sm" onClick={toggleDisabled} - disabled={isUpdating} + disabled={!canEdit || isUpdating} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {automation.enabled ? t('Disable') : t('Enable')} - {() => ( - )} diff --git a/static/app/views/automations/detail.spec.tsx b/static/app/views/automations/detail.spec.tsx index d463da4ce91feb..8a769867aad2b1 100644 --- a/static/app/views/automations/detail.spec.tsx +++ b/static/app/views/automations/detail.spec.tsx @@ -267,6 +267,29 @@ describe('AutomationDetail', () => { ).not.toBeInTheDocument(); }); + it('disables action buttons without alerts:write permission', async () => { + const noWriteOrg = OrganizationFixture({ + features: ['workflow-engine-ui'], + access: ['org:read', 'alerts:read'], + }); + + render(, { + organization: noWriteOrg, + initialRouterConfig: { + route: '/alerts/:automationId/', + location: {pathname: '/alerts/123/'}, + }, + }); + + await screen.findByRole('heading', {name: 'Test Automation'}); + + expect(screen.getByRole('button', {name: 'Disable'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Edit'})).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + it('displays connected projects and monitors', async () => { const project = ProjectFixture({id: '10', slug: 'my-project', name: 'My Project'}); ProjectsStore.loadInitialData([project]); diff --git a/static/app/views/automations/detail.tsx b/static/app/views/automations/detail.tsx index 63d1b7d68f159b..2bde170607bd69 100644 --- a/static/app/views/automations/detail.tsx +++ b/static/app/views/automations/detail.tsx @@ -37,6 +37,10 @@ import {ConnectedMonitorsList} from 'sentry/views/automations/components/connect import {ConnectedProjectsList} from 'sentry/views/automations/components/connectedProjectsList'; import {DisabledAlert} from 'sentry/views/automations/components/disabledAlert'; import {useAutomationQuery, useUpdateAutomation} from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {getAutomationActionsWarning} from 'sentry/views/automations/hooks/utils'; import { makeAutomationBasePathname, @@ -270,6 +274,8 @@ export default function AutomationDetail() { function Actions({automation, size}: {automation: Automation; size?: 'sm'}) { const organization = useOrganization(); const {mutate: updateAutomation, isPending: isUpdating} = useUpdateAutomation(); + const canEdit = useCanEditAutomation(); + const permissionTooltipText = canEdit ? undefined : getNoAlertWritePermissionTooltip(); const toggleDisabled = () => { const newEnabled = !automation.enabled; @@ -289,11 +295,20 @@ function Actions({automation, size}: {automation: Automation; size?: 'sm'}) { return ( - } size={size} diff --git a/static/app/views/automations/hooks/useCanEditAutomation.tsx b/static/app/views/automations/hooks/useCanEditAutomation.tsx new file mode 100644 index 00000000000000..94d5f2853ec504 --- /dev/null +++ b/static/app/views/automations/hooks/useCanEditAutomation.tsx @@ -0,0 +1,34 @@ +import type {ReactNode} from 'react'; + +import {Link} from '@sentry/scraps/link'; + +import {hasEveryAccess} from 'sentry/components/acl/access'; +import {tct} from 'sentry/locale'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function useCanEditAutomation(): boolean { + const organization = useOrganization(); + return hasEveryAccess(['alerts:write'], {organization}); +} + +function AlertsMemberWriteSettingsLink({children}: {children?: ReactNode}) { + const organization = useOrganization(); + + return ( + + {children} + + ); +} + +export function getNoAlertWritePermissionTooltip() { + return tct( + 'You do not have permission to create or edit alerts. Ask your organization owner or manager to [settingsLink:enable alert access] for you.', + {settingsLink: } + ); +} diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index f4c6a925be895b..8fb761f3473465 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -591,4 +591,17 @@ describe('AutomationsList', () => { ).toBeInTheDocument(); }); }); + + it('disables the create alert button without alerts:write permission', async () => { + const noWriteOrg = OrganizationFixture({ + features: ['workflow-engine-ui'], + access: ['org:read', 'alerts:read'], + }); + + render(, {organization: noWriteOrg}); + await screen.findByText('Automation 1'); + + const createButton = screen.getByRole('button', {name: 'Create Alert'}); + expect(createButton).toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index c8b07687377ce7..d41939d77dd0a7 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -25,6 +25,10 @@ import {AutomationListTable} from 'sentry/views/automations/components/automatio import {AutomationSearch} from 'sentry/views/automations/components/automationListTable/search'; import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants'; import {automationsApiOptions} from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {makeAutomationCreatePathname} from 'sentry/views/automations/pathnames'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; @@ -131,6 +135,7 @@ function TableHeader() { const location = useLocation(); const navigate = useNavigate(); const hasPageFrameFeature = useHasPageFrameFeature(); + const canCreateAlert = useCanEditAutomation(); const initialQuery = typeof location.query.query === 'string' ? location.query.query : ''; @@ -159,6 +164,11 @@ function TableHeader() { {hasPageFrameFeature ? ( } size="sm" @@ -174,6 +184,7 @@ function TableHeader() { function Actions() { const organization = useOrganization(); const hasPageFrameFeature = useHasPageFrameFeature(); + const canCreateAlert = useCanEditAutomation(); return ( @@ -181,6 +192,11 @@ function Actions() { {hasPageFrameFeature ? null : ( } size="sm" diff --git a/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx new file mode 100644 index 00000000000000..70d0261973257e --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx @@ -0,0 +1,97 @@ +import {formatYAxisValue} from './formatYAxisValue'; + +describe('formatYAxisValue', () => { + describe('integer', () => { + it.each([ + [0, '0'], + [17, '17'], + [171, '171'], + [17111, '17K'], + [17_000_110, '17M'], + [1_000_110_000, '1B'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'integer')).toEqual(formattedValue); + }); + }); + + describe('number', () => { + it.each([ + [0.000033452, '0.00003345'], + [0.00003, '0.00003'], + [17.1238, '17.12'], + [170, '170'], + [17111, '17K'], + [17_000_110, '17M'], + [1772313.1, '1,772,313.1'], + [1772313.11123, '1,772,313.11'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'number')).toEqual(formattedValue); + }); + }); + + describe('percentage', () => { + it.each([ + [0, '0'], + [0.00005, '0.005%'], + [0.712, '71.2%'], + [17.123, '1,712.3%'], + [1, '100%'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'percentage')).toEqual(formattedValue); + }); + }); + + describe('duration', () => { + it.each([ + [0, 'millisecond', '0'], + [0.712, 'second', '712ms'], + [1230, 'second', '20.5min'], + ])('Formats %s as %s', (value, unit, formattedValue) => { + expect(formatYAxisValue(value, 'duration', unit)).toEqual(formattedValue); + }); + }); + + describe('size', () => { + it.each([ + [0, 'byte', '0'], + [0.712, 'megabyte', '712 KB'], + [1231, 'kibibyte', '1.2 MiB'], + ])('Formats %s as %s', (value, unit, formattedValue) => { + expect(formatYAxisValue(value, 'size', unit)).toEqual(formattedValue); + }); + }); + + describe('rate', () => { + it.each([ + [0, '1/second', '0'], + [-3, '1/second', '-3/s'], + [0.712, '1/second', '0.712/s'], + [12700, '1/second', '12.7K/s'], + [0.0003, '1/second', '0.0003/s'], + [0.00000153, '1/second', '0.00000153/s'], + [0.35, '1/second', '0.35/s'], + [10, '1/second', '10/s'], + // eslint-disable-next-line unicorn/no-zero-fractions + [10.0, '1/second', '10/s'], + [1231, '1/minute', '1.231K/min'], + [110000, '1/second', '110K/s'], + [110001, '1/second', '110.001K/s'], + [123456789, '1/second', '123.457M/s'], + ])('Formats %s as %s', (value, unit, formattedValue) => { + expect(formatYAxisValue(value, 'rate', unit)).toEqual(formattedValue); + }); + }); + + describe('currency', () => { + it.each([ + [0, '0'], + [17, '$17'], + [171, '$171'], + [17111, '$17.11K'], + [17_000_110, '$17M'], + [1_000_110_000, '$1B'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'currency')).toEqual(formattedValue); + }); + }); +}); diff --git a/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx new file mode 100644 index 00000000000000..8b90133eb5c674 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx @@ -0,0 +1,94 @@ +import {formatBytesBase2} from 'sentry/utils/bytes/formatBytesBase2'; +import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; +import { + ABYTE_UNITS, + DurationUnit, + RATE_UNIT_LABELS, + RateUnit, + SizeUnit, +} from 'sentry/utils/discover/fields'; +import {formatAbbreviatedNumber, formatDollars} from 'sentry/utils/formatters'; +import {formatNumberWithDynamicDecimalPoints} from 'sentry/utils/number/formatNumberWithDynamicDecimalPoints'; +import {formatPercentage} from 'sentry/utils/number/formatPercentage'; +import {convertDuration} from 'sentry/utils/unitConversion/convertDuration'; +import {convertSize} from 'sentry/utils/unitConversion/convertSize'; +import { + NUMBER_MIN_VALUE, + NUMBER_MAX_FRACTION_DIGITS, +} from 'sentry/views/dashboards/widgets/common/settings'; +import { + isADurationUnit, + isARateUnit, + isASizeUnit, +} from 'sentry/views/dashboards/widgets/common/typePredicates'; +import {formatYAxisDuration} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisDuration'; + +/** + * Format a value for the Y axis on an ECharts heat map graph. + * + * The values on the Y axis are chosen by ECharts. ECharts will automatically + * select, when possible, nice round values. With heat maps this is not the case. + * Since the Y axis in heat maps are considered categories to ECharts, + * We need to format the values ourselves to the precision we'd like to see, + * especially with floating point numbers. + * + * The rest of the logic is the same as the time series widget Y axis formatter + * (static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue.tsx). + */ +export function formatYAxisValue(value: number, type: string, unit?: string): string { + if (value === 0) { + return '0'; + } + + switch (type) { + case 'integer': + return formatAbbreviatedNumber(value); + case 'number': + if (Number.isInteger(value)) { + return formatAbbreviatedNumber(value); + } + if (value > 0 && value < NUMBER_MIN_VALUE) { + return value.toLocaleString(undefined, { + maximumSignificantDigits: NUMBER_MAX_FRACTION_DIGITS, + }); + } + return formatNumberWithDynamicDecimalPoints(value); + case 'percentage': + return formatPercentage(value, 3); + case 'duration': { + const durationUnit = isADurationUnit(unit) ? unit : DurationUnit.MILLISECOND; + const durationInMilliseconds = convertDuration( + value, + durationUnit, + DurationUnit.MILLISECOND + ); + return formatYAxisDuration(durationInMilliseconds); + } + case 'size': { + const sizeUnit = isASizeUnit(unit) ? unit : SizeUnit.BYTE; + const sizeInBytes = convertSize(value, sizeUnit, SizeUnit.BYTE); + + const formatter = ABYTE_UNITS.includes(unit ?? 'byte') + ? formatBytesBase10 + : formatBytesBase2; + + return formatter(sizeInBytes); + } + case 'rate': { + // Always show rate in the original dataset's unit. If the unit is not + // appropriate, always convert the unit in the original dataset first. + // This way, named rate functions like `epm()` will be shows in per minute + // units + const rateUnit = isARateUnit(unit) ? unit : RateUnit.PER_SECOND; + return `${value.toLocaleString(undefined, { + notation: 'compact', + maximumSignificantDigits: 6, + })}${RATE_UNIT_LABELS[rateUnit]}`; + } + case 'currency': { + return formatDollars(value); + } + default: + return value.toString(); + } +} diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx index 37ddc7aa4b24ca..a5a00be295d42d 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx @@ -21,10 +21,10 @@ import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {ECHARTS_MISSING_DATA_VALUE} from 'sentry/utils/timeSeries/timeSeriesItemToEChartsDataPoint'; import {useOrganization} from 'sentry/utils/useOrganization'; import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings'; +import {formatYAxisValue} from 'sentry/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue'; import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized'; import {formatTooltipValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue'; import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp'; -import {formatYAxisValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue'; import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings'; import {getExploreUrl, type GetExploreUrlArgs} from 'sentry/views/explore/utils'; diff --git a/static/app/views/detectors/components/details/common/automations.tsx b/static/app/views/detectors/components/details/common/automations.tsx index 9e33ea6d399d80..8585176c913039 100644 --- a/static/app/views/detectors/components/details/common/automations.tsx +++ b/static/app/views/detectors/components/details/common/automations.tsx @@ -7,7 +7,6 @@ import {ProjectAvatar} from '@sentry/scraps/avatar'; import {Button} from '@sentry/scraps/button'; import {useDrawer} from '@sentry/scraps/drawer'; import {Flex, Stack} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; import {getPaginationCaption, Pagination} from '@sentry/scraps/pagination'; import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -27,6 +26,7 @@ import {useProjectFromId} from 'sentry/utils/useProjectFromId'; import {AutomationBuilderDrawerForm} from 'sentry/views/automations/components/automationBuilderDrawerForm'; import {AutomationSearch} from 'sentry/views/automations/components/automationListTable/search'; import {automationsApiOptions} from 'sentry/views/automations/hooks'; +import {getNoAlertWritePermissionTooltip} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {getAutomationActions} from 'sentry/views/automations/hooks/utils'; import {ConnectAutomationsDrawer} from 'sentry/views/detectors/components/connectAutomationsDrawer'; import {useUpdateDetector} from 'sentry/views/detectors/hooks'; @@ -217,19 +217,7 @@ export function DetectorDetailsAutomations({detector}: Props) { const permissionTooltipText = canEditWorkflowConnections ? undefined - : t( - 'Ask your organization owner or manager to [settingsLink:enable alerts access] for you.', - { - settingsLink: ( - - ), - } - ); + : getNoAlertWritePermissionTooltip(); return ( @@ -242,7 +230,7 @@ export function DetectorDetailsAutomations({detector}: Props) { icon={} onClick={openCreateDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('New Alert')} @@ -250,7 +238,7 @@ export function DetectorDetailsAutomations({detector}: Props) { size="xs" onClick={toggleDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} icon={} > {t('Edit Alerts')} @@ -268,7 +256,7 @@ export function DetectorDetailsAutomations({detector}: Props) { size="sm" onClick={toggleDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('Connect Existing Alerts')} @@ -277,7 +265,7 @@ export function DetectorDetailsAutomations({detector}: Props) { icon={} onClick={openCreateDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('Create a New Alert')} diff --git a/static/app/views/detectors/components/forms/automateSection.tsx b/static/app/views/detectors/components/forms/automateSection.tsx index 8df0067d696869..0775cc97365054 100644 --- a/static/app/views/detectors/components/forms/automateSection.tsx +++ b/static/app/views/detectors/components/forms/automateSection.tsx @@ -16,6 +16,10 @@ import {IconAdd, IconEdit} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; import {AutomationBuilderDrawerForm} from 'sentry/views/automations/components/automationBuilderDrawerForm'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {ConnectAutomationsDrawer} from 'sentry/views/detectors/components/connectAutomationsDrawer'; import {ConnectedAutomationsList} from 'sentry/views/detectors/components/connectedAutomationList'; import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; @@ -82,6 +86,10 @@ function AutomateSectionInner({ }: AutomateSectionInnerProps) { const ref = useRef(null); const {openDrawer, closeDrawer, isDrawerOpen} = useDrawer(); + const canEditAutomation = useCanEditAutomation(); + const permissionTooltipText = canEditAutomation + ? undefined + : getNoAlertWritePermissionTooltip(); const toggleDrawer = () => { if (isDrawerOpen) { @@ -140,10 +148,22 @@ function AutomateSectionInner({ /> - - @@ -170,10 +190,17 @@ function AutomateSectionInner({ size="sm" style={{width: 'min-content'}} onClick={toggleDrawer} + disabled={!canEditAutomation} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('Connect Existing Alerts')} - diff --git a/static/app/views/detectors/list/common/detectorListActions.tsx b/static/app/views/detectors/list/common/detectorListActions.tsx index 41c39d87359362..3e9beedccf3c45 100644 --- a/static/app/views/detectors/list/common/detectorListActions.tsx +++ b/static/app/views/detectors/list/common/detectorListActions.tsx @@ -46,6 +46,7 @@ export function DetectorListActions({children, detectorType}: DetectorListAction title: canCreateDetector ? undefined : getNoPermissionToCreateMonitorsTooltip(), + isHoverable: true, }} > {t('Create Monitor')} diff --git a/static/app/views/detectors/list/common/detectorListHeader.tsx b/static/app/views/detectors/list/common/detectorListHeader.tsx index 9d2f32c8f175b3..286f36c0e993f2 100644 --- a/static/app/views/detectors/list/common/detectorListHeader.tsx +++ b/static/app/views/detectors/list/common/detectorListHeader.tsx @@ -80,6 +80,7 @@ export function DetectorListHeader({ title: canCreateDetector ? undefined : getNoPermissionToCreateMonitorsTooltip(), + isHoverable: true, }} > {t('Create Monitor')} diff --git a/static/app/views/detectors/utils/monitorAccessMessages.tsx b/static/app/views/detectors/utils/monitorAccessMessages.tsx index fbdcca0c79d221..8d60285e9635a0 100644 --- a/static/app/views/detectors/utils/monitorAccessMessages.tsx +++ b/static/app/views/detectors/utils/monitorAccessMessages.tsx @@ -1,9 +1,11 @@ +import type {ReactNode} from 'react'; + import {Link} from '@sentry/scraps/link'; import {t, tct} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; -function AlertsMemberWriteSettingsLink() { +function AlertsMemberWriteSettingsLink({children}: {children?: ReactNode}) { const organization = useOrganization(); return ( @@ -12,7 +14,9 @@ function AlertsMemberWriteSettingsLink() { hash: 'alertsMemberWrite', pathname: `/settings/${organization.slug}/`, }} - /> + > + {children} + ); } diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 88111caf156c63..2fabb552aea528 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -174,6 +174,32 @@ describe('ActivitySection', () => { expect(screen.queryByText('Test Note')).not.toBeInTheDocument(); }); + it('renders note markdown', async () => { + const activityGroup = GroupFixture({ + id: '1338', + activity: [ + { + type: GroupActivityType.NOTE, + id: 'note-1', + data: {text: '**Bold Note** and [docs](https://docs.sentry.io/)'}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + ], + project, + }); + + render(); + + expect(await screen.findByTestId('activity-note-body')).toContainElement( + screen.getByText('Bold Note').closest('strong') + ); + expect(screen.getByRole('link', {name: 'docs'})).toHaveAttribute( + 'href', + 'https://docs.sentry.io/' + ); + }); + it('renders activity actor markers', async () => { const activityGroup = GroupFixture({ id: '1338', diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 630947ce936186..a2a4bb301f1afd 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -17,7 +17,6 @@ import {TimeSince} from 'sentry/components/timeSince'; import {IconEllipsis} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {GroupStore} from 'sentry/stores/groupStore'; -import {textStyles} from 'sentry/styles/text'; import type {NoteType} from 'sentry/types/alerts'; import type {Group, GroupActivity, GroupActivityNote} from 'sentry/types/group'; import {GroupActivityType, SEER_ACTIVITY_TYPES} from 'sentry/types/group'; @@ -205,9 +204,7 @@ function TimelineItem({ onCancel={() => setEditing(false)} /> ) : typeof message === 'string' ? ( - - - + ) : ( {message} @@ -521,11 +518,6 @@ const MoreActivityIcon = styled('div')` background: ${p => p.theme.tokens.background.primary}; `; -const NoteWrapper = styled('div')<{size: 'sm' | 'md'}>` - ${textStyles} - font-size: ${p => (p.size === 'md' ? p.theme.font.size.md : p.theme.font.size.sm)}; -`; - const ActivityInputFrame = styled('div')` color: ${p => p.theme.tokens.content.primary}; min-width: 0; diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx index 1299bd6b392b11..d62f01539bb22e 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx @@ -150,12 +150,7 @@ export function EventDetailsContent({ )} {event.userReport && ( - + )} {(event.contexts?.metric_alert?.alert_rule_id || diff --git a/static/app/views/issueDetails/groupUserFeedback.tsx b/static/app/views/issueDetails/groupUserFeedback.tsx index d245aea0a57254..a84d02fae4deda 100644 --- a/static/app/views/issueDetails/groupUserFeedback.tsx +++ b/static/app/views/issueDetails/groupUserFeedback.tsx @@ -1,7 +1,7 @@ -import {Fragment} from 'react'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; +import {Stack} from '@sentry/scraps/layout'; import {Pagination} from '@sentry/scraps/pagination'; import {EventUserFeedback} from 'sentry/components/events/userFeedback'; @@ -78,27 +78,22 @@ function GroupUserFeedback() { {reportList.length === 0 ? ( ) : ( - - {reportList.map((item, idx) => ( - + {reportList.map(item => ( + ))} - + )} ); } -const StyledEventUserFeedback = styled(EventUserFeedback)` - margin-bottom: ${p => p.theme.space.xl}; -`; - const StyledLayoutBody = styled(Layout.Body)` border: 1px solid ${p => p.theme.tokens.border.primary}; border-radius: ${p => p.theme.radius.md}; diff --git a/static/app/views/issueDetails/streamline/header/header.tsx b/static/app/views/issueDetails/streamline/header/header.tsx index 8da71c4984e99c..88cc27674ac862 100644 --- a/static/app/views/issueDetails/streamline/header/header.tsx +++ b/static/app/views/issueDetails/streamline/header/header.tsx @@ -124,7 +124,7 @@ export function StreamlinedGroupHeader({event, group, project}: GroupHeaderProps > {primaryTitle} - {isAIDetectedIssue && } + {isAIDetectedIssue && } {issueTypeConfig.eventAndUserCounts.enabled && ( diff --git a/static/app/views/navigation/primary/organizationDropdown.tsx b/static/app/views/navigation/primary/organizationDropdown.tsx index 8b3b54757435bb..0ea87d4363ae8a 100644 --- a/static/app/views/navigation/primary/organizationDropdown.tsx +++ b/static/app/views/navigation/primary/organizationDropdown.tsx @@ -18,7 +18,7 @@ import {t, tn} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import {isDemoModeActive} from 'sentry/utils/demoMode'; import {localizeDomain, resolveRoute} from 'sentry/utils/resolveRoute'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -184,7 +184,7 @@ export function OrganizationDropdown(props: OrganizationDropdownProps) { ); } -function makeOrganizationMenuItem(org: Organization): MenuItemProps { +function makeOrganizationMenuItem(org: OrganizationSummary): MenuItemProps { return { key: org.id, label: , @@ -193,7 +193,7 @@ function makeOrganizationMenuItem(org: Organization): MenuItemProps { }; } -function makeInactiveOrganizationMenuItem(org: Organization): MenuItemProps { +function makeInactiveOrganizationMenuItem(org: OrganizationSummary): MenuItemProps { return { ...makeOrganizationMenuItem(org), trailingItems: , diff --git a/static/app/views/onboarding/components/scmProviderPills.spec.tsx b/static/app/views/onboarding/components/scmProviderPills.spec.tsx index e823ea1d2fd354..d75d949ce5322d 100644 --- a/static/app/views/onboarding/components/scmProviderPills.spec.tsx +++ b/static/app/views/onboarding/components/scmProviderPills.spec.tsx @@ -3,6 +3,8 @@ import {GitLabIntegrationProviderFixture} from 'sentry-fixture/gitlabIntegration import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import * as pipelineModal from 'sentry/components/pipeline/modal'; + import {ScmProviderPills} from './scmProviderPills'; const bitbucketProvider = GitHubIntegrationProviderFixture({ @@ -92,10 +94,9 @@ describe('ScmProviderPills', () => { }); it('triggers install flow when clicking a dropdown item', async () => { - const open = jest.spyOn(window, 'open').mockReturnValue({ - focus: jest.fn(), - close: jest.fn(), - } as any); + const openPipelineModalSpy = jest + .spyOn(pipelineModal, 'openPipelineModal') + .mockImplementation(() => {}); const providers = [GitHubIntegrationProviderFixture(), gitHubEnterpriseProvider]; @@ -104,7 +105,7 @@ describe('ScmProviderPills', () => { await userEvent.click(screen.getByRole('button', {name: 'More'})); await userEvent.click(screen.getByRole('menuitemradio', {name: 'GitHub Enterprise'})); - expect(open).toHaveBeenCalledTimes(1); + expect(openPipelineModalSpy).toHaveBeenCalledTimes(1); }); it('does not render "More" dropdown when all providers are primary', () => { diff --git a/static/app/views/onboarding/components/scmRepoSelector.spec.tsx b/static/app/views/onboarding/components/scmRepoSelector.spec.tsx index df195cc2dccb53..c17b18cd88fccd 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.spec.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.spec.tsx @@ -4,10 +4,7 @@ import {RepositoryFixture} from 'sentry-fixture/repository'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {ScmRepoSelector} from './scmRepoSelector'; @@ -26,13 +23,24 @@ jest.mock('@tanstack/react-virtual', () => ({ })), })); -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); +interface DefaultPropsOverrides { + integration: Integration; + onClearDerivedState?: jest.Mock; + onRepositoryChange?: jest.Mock; + selectedRepository?: Repository; +} + +function defaultProps({ + integration, + onClearDerivedState = jest.fn(), + onRepositoryChange = jest.fn(), + selectedRepository, +}: DefaultPropsOverrides) { + return { + integration, + selectedRepository, + onRepositoryChange, + onClearDerivedState, }; } @@ -56,7 +64,6 @@ describe('ScmRepoSelector', () => { afterEach(() => { MockApiClient.clearMockResponses(); - sessionStorage.clear(); }); it('renders search placeholder', () => { @@ -65,9 +72,8 @@ describe('ScmRepoSelector', () => { body: {repos: []}, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); expect(screen.getByText('Search repositories')).toBeInTheDocument(); @@ -79,9 +85,8 @@ describe('ScmRepoSelector', () => { body: {repos: []}, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); await userEvent.click(screen.getByRole('textbox')); @@ -100,9 +105,8 @@ describe('ScmRepoSelector', () => { body: {detail: 'Internal Error'}, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); await userEvent.click(screen.getByRole('textbox')); @@ -123,9 +127,8 @@ describe('ScmRepoSelector', () => { }, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); await userEvent.click(screen.getByRole('textbox')); @@ -136,7 +139,7 @@ describe('ScmRepoSelector', () => { expect(screen.getByRole('menuitemradio', {name: 'relay'})).toBeInTheDocument(); }); - it('shows selected repo value when one is in context', () => { + it('shows selected repo value when one is provided', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, body: {repos: []}, @@ -147,10 +150,15 @@ describe('ScmRepoSelector', () => { externalSlug: 'getsentry/old-repo', }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + render( + , + {organization} + ); expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument(); }); @@ -180,15 +188,19 @@ describe('ScmRepoSelector', () => { ], }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + const onRepositoryChange = jest.fn(); + render( + , + {organization} + ); await userEvent.click(screen.getByRole('textbox')); await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'})); await waitFor(() => expect(reposLookup).toHaveBeenCalled()); + expect(onRepositoryChange).toHaveBeenCalled(); }); it('clears the selected repo', async () => { @@ -202,18 +214,23 @@ describe('ScmRepoSelector', () => { externalSlug: 'getsentry/old-repo', }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + const onRepositoryChange = jest.fn(); + render( + , + {organization} + ); expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument(); await userEvent.click(await screen.findByTestId('icon-close')); - await waitFor(() => { - expect(screen.queryByText('getsentry/old-repo')).not.toBeInTheDocument(); - }); + await waitFor(() => expect(onRepositoryChange).toHaveBeenCalledWith(undefined)); }); it('does not duplicate selected repo when it appears in results', async () => { @@ -232,10 +249,15 @@ describe('ScmRepoSelector', () => { }, }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + render( + , + {organization} + ); await userEvent.click(screen.getByRole('textbox')); @@ -249,4 +271,49 @@ describe('ScmRepoSelector', () => { screen.queryByRole('menuitemradio', {name: 'getsentry/sentry'}) ).not.toBeInTheDocument(); }); + + it('fires onClearDerivedState exactly once per user-driven repo change', async () => { + // The underlying selection hook calls onRepositoryChange multiple times for + // a single user click (optimistic + resolved/created paths). The derived- + // state callback must only fire once per click so it isn't redundantly + // wiping flow state on every internal update. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, + body: { + repos: [ + { + externalId: '1', + identifier: 'getsentry/sentry', + name: 'sentry', + isInstalled: false, + }, + ], + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: [ + RepositoryFixture({name: 'getsentry/sentry', externalSlug: 'getsentry/sentry'}), + ], + }); + + const onClearDerivedState = jest.fn(); + const onRepositoryChange = jest.fn(); + render( + , + {organization} + ); + + await userEvent.click(screen.getByRole('textbox')); + await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'})); + + await waitFor(() => expect(onRepositoryChange).toHaveBeenCalled()); + expect(onClearDerivedState).toHaveBeenCalledTimes(1); + }); }); diff --git a/static/app/views/onboarding/components/scmRepoSelector.tsx b/static/app/views/onboarding/components/scmRepoSelector.tsx index 9d35f370dd5426..fbeb980fbc7fc5 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.tsx @@ -2,9 +2,8 @@ import {useMemo} from 'react'; import {Select} from '@sentry/scraps/select'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {t} from 'sentry/locale'; -import type {Integration} from 'sentry/types/integrations'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -15,12 +14,23 @@ import {useScmRepoSelection} from './useScmRepoSelection'; interface ScmRepoSelectorProps { integration: Integration; + // Fired once per user-driven change (select or clear) so callers can + // invalidate state derived from the repo (platform, features, created + // project). Distinct from onRepositoryChange because the underlying repo + // selection hook can fire that callback multiple times for one user action + // (optimistic + resolved + error paths). + onClearDerivedState: () => void; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedRepository: Repository | undefined; } -export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { +export function ScmRepoSelector({ + integration, + onClearDerivedState, + onRepositoryChange, + selectedRepository, +}: ScmRepoSelectorProps) { const organization = useOrganization(); - const {selectedRepository, setSelectedRepository, clearDerivedState} = - useOnboardingContext(); const {reposByIdentifier, dropdownItems, isFetching, isError} = useScmRepos( integration.id, selectedRepository @@ -28,7 +38,7 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { const {busy, handleSelect, handleRemove} = useScmRepoSelection({ integration, - onSelect: setSelectedRepository, + onSelect: onRepositoryChange, reposByIdentifier, }); @@ -50,9 +60,7 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { }, [dropdownItems, selectedRepository]); function handleChange(option: {value: string} | null) { - // Changing or clearing the repo invalidates downstream state (platform, - // features, created project) which are all derived from the selected repo. - clearDerivedState(); + onClearDerivedState(); if (option === null) { handleRemove(); diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index e1b505706e6f34..c61e4a646a6bf3 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -69,6 +69,87 @@ const legacyOnboardingSteps: StepDescriptor[] = [ }, ]; +// Adapters bridge the SCM step components — which accept all flow state via +// props — to the onboarding flow's OnboardingContext. They let the same step +// components be reused by other flows (e.g. project creation) that source +// state from somewhere other than session storage. + +function ScmConnectAdapter({onComplete, genBackButton}: StepProps) { + const { + selectedIntegration, + setSelectedIntegration, + selectedRepository, + setSelectedRepository, + clearDerivedState, + } = useOnboardingContext(); + + return ( + + ); +} + +function ScmPlatformFeaturesAdapter({onComplete, genBackButton}: StepProps) { + const { + selectedRepository, + selectedPlatform, + setSelectedPlatform, + selectedFeatures, + setSelectedFeatures, + setProjectDetailsForm, + createdProjectSlug, + setCreatedProjectSlug, + } = useOnboardingContext(); + + return ( + setProjectDetailsForm(undefined)} + onProjectCreated={setCreatedProjectSlug} + onComplete={onComplete} + genBackButton={genBackButton} + /> + ); +} + +function ScmProjectDetailsAdapter({onComplete, genBackButton}: StepProps) { + const { + selectedPlatform, + selectedFeatures, + selectedRepository, + createdProjectSlug, + setCreatedProjectSlug, + projectDetailsForm, + setProjectDetailsForm, + } = useOnboardingContext(); + + return ( + + ); +} + const scmOnboardingSteps: StepDescriptor[] = [ { id: OnboardingStepId.WELCOME, @@ -79,19 +160,19 @@ const scmOnboardingSteps: StepDescriptor[] = [ { id: OnboardingStepId.SCM_CONNECT, title: t('Connect repository'), - Component: ScmConnect, + Component: ScmConnectAdapter, cornerVariant: 'top-left', }, { id: OnboardingStepId.SCM_PLATFORM_FEATURES, title: t('Create your first project'), - Component: ScmPlatformFeatures, + Component: ScmPlatformFeaturesAdapter, cornerVariant: 'top-left', }, { id: OnboardingStepId.SCM_PROJECT_DETAILS, title: t('Project details'), - Component: ScmProjectDetails, + Component: ScmProjectDetailsAdapter, hasFooter: true, cornerVariant: 'top-left', }, diff --git a/static/app/views/onboarding/scmConnect.tsx b/static/app/views/onboarding/scmConnect.tsx index 96c4299a623346..74f98ac840a809 100644 --- a/static/app/views/onboarding/scmConnect.tsx +++ b/static/app/views/onboarding/scmConnect.tsx @@ -7,10 +7,9 @@ import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {IconCheckmark, IconClose, IconLock} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {Integration} from 'sentry/types/integrations'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -22,6 +21,19 @@ import {useScmProviders} from './components/useScmProviders'; import {SCM_STEP_CONTENT_WIDTH} from './consts'; import type {StepProps} from './types'; +interface ScmConnectProps { + // Fired once per user-driven repo change so callers can invalidate state + // derived from the repo (platform, features, created project). See + // ScmRepoSelector for why this is separate from onRepositoryChange. + onClearDerivedState: () => void; + onComplete: StepProps['onComplete']; + onIntegrationChange: (integration: Integration | undefined) => void; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedIntegration: Integration | undefined; + selectedRepository: Repository | undefined; + genBackButton?: StepProps['genBackButton']; +} + const SCM_INFO_SECTIONS = [ { title: t('How we use access'), @@ -47,14 +59,16 @@ const SCM_INFO_SECTIONS = [ }, ]; -export function ScmConnect({onComplete, genBackButton}: StepProps) { +export function ScmConnect({ + onClearDerivedState, + onComplete, + onIntegrationChange, + onRepositoryChange, + selectedIntegration, + selectedRepository, + genBackButton, +}: ScmConnectProps) { const organization = useOrganization(); - const { - selectedIntegration, - setSelectedIntegration, - selectedRepository, - setSelectedRepository, - } = useOnboardingContext(); const { scmProviders, isPending, @@ -76,11 +90,11 @@ export function ScmConnect({onComplete, genBackButton}: StepProps) { const handleInstall = useCallback( (data: Integration) => { - setSelectedIntegration(data); - setSelectedRepository(undefined); + onIntegrationChange(data); + onRepositoryChange(undefined); refetchIntegrations(); }, - [setSelectedIntegration, setSelectedRepository, refetchIntegrations] + [onIntegrationChange, onRepositoryChange, refetchIntegrations] ); return ( @@ -117,7 +131,12 @@ export function ScmConnect({onComplete, genBackButton}: StepProps) { effectiveIntegration.name )} - + ) : ( @@ -212,7 +231,7 @@ export function ScmConnect({onComplete, genBackButton}: StepProps) { }} onClick={() => { if (effectiveIntegration && !selectedIntegration) { - setSelectedIntegration(effectiveIntegration); + onIntegrationChange(effectiveIntegration); } onComplete(); }} diff --git a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx index 8da847c4195d32..4be31e599feec7 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx @@ -14,14 +14,11 @@ import { } from 'sentry-test/reactTestingLibrary'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; +import type {Repository} from 'sentry/types/integrations'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import * as analytics from 'sentry/utils/analytics'; -import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {ScmPlatformFeatures} from './scmPlatformFeatures'; @@ -57,13 +54,24 @@ jest.mock('sentry/data/platforms', () => { }; }); -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); +interface StateOverrides { + createdProjectSlug?: string; + selectedFeatures?: ProductSolution[]; + selectedPlatform?: OnboardingSelectedSDK; + selectedRepository?: Repository; +} + +function defaultProps(state: StateOverrides = {}) { + return { + selectedRepository: state.selectedRepository, + selectedPlatform: state.selectedPlatform, + selectedFeatures: state.selectedFeatures, + createdProjectSlug: state.createdProjectSlug, + onPlatformChange: jest.fn(), + onFeaturesChange: jest.fn(), + onClearProjectDetailsForm: jest.fn(), + onProjectCreated: jest.fn(), + onComplete: jest.fn(), }; } @@ -79,7 +87,6 @@ describe('ScmPlatformFeatures', () => { beforeEach(() => { jest.clearAllMocks(); - sessionStorageWrapper.clear(); ProjectsStore.loadInitialData([]); TeamStore.loadInitialData([]); }); @@ -104,17 +111,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); const radioGroup = await screen.findByRole('radiogroup'); @@ -138,17 +136,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect( @@ -164,17 +153,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect( @@ -198,17 +178,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect( @@ -228,13 +199,7 @@ describe('ScmPlatformFeatures', () => { render( null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ + {...defaultProps({ selectedRepository: mockRepository, selectedPlatform: { key: 'nintendo-switch', @@ -244,8 +209,9 @@ describe('ScmPlatformFeatures', () => { link: null, category: 'all', }, - }), - } + })} + />, + {organization} ); await screen.findByRole('button', {name: 'Continue'}); @@ -276,17 +242,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); const changeButton = await screen.findByRole('button', { @@ -305,17 +262,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect(await screen.findByText('Select a platform')).toBeInTheDocument(); @@ -325,17 +273,7 @@ describe('ScmPlatformFeatures', () => { }); it('renders manual picker when no repository in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); expect(await screen.findByText('Select a platform')).toBeInTheDocument(); expect( @@ -344,17 +282,7 @@ describe('ScmPlatformFeatures', () => { }); it('continue button is disabled when no platform selected', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); // Wait for the component to fully settle (CompactSelect triggers async popper updates) await screen.findByText('Select a platform'); @@ -371,17 +299,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); // Wait for auto-select of first detected platform @@ -401,47 +320,29 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [pythonPlatform]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedRepository: mockRepository, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, {organization}); // Wait for feature cards to appear await screen.findByText('What do you want to instrument?'); - // Neither profiling nor tracing should be checked initially - expect(screen.getByRole('checkbox', {name: /Profiling/})).not.toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Tracing/})).not.toBeChecked(); - - // Enable profiling — tracing should auto-enable + // Enable profiling — onFeaturesChange should be called with tracing also enabled await userEvent.click(screen.getByRole('checkbox', {name: /Profiling/})); - expect(screen.getByRole('checkbox', {name: /Profiling/})).toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Tracing/})).toBeChecked(); + expect(props.onFeaturesChange).toHaveBeenCalledWith( + expect.arrayContaining([ + ProductSolution.ERROR_MONITORING, + ProductSolution.PROFILING, + ProductSolution.PERFORMANCE_MONITORING, + ]) + ); }); it('shows framework suggestion modal when selecting a base language', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); renderGlobalModal(); await screen.findByText('Select a platform'); @@ -454,20 +355,12 @@ describe('ScmPlatformFeatures', () => { }); it('opens console modal when selecting a disabled gaming platform', async () => { - render( - null} - />, - { - // No enabledConsolePlatforms — all console platforms are blocked - organization: OrganizationFixture({ - features: ['performance-view', 'session-replay', 'profiling-view'], - }), - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, { + // No enabledConsolePlatforms — all console platforms are blocked + organization: OrganizationFixture({ + features: ['performance-view', 'session-replay', 'profiling-view'], + }), + }); renderGlobalModal(); await screen.findByText('Select a platform'); @@ -490,45 +383,36 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [pythonPlatform]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedPlatform: { - key: 'python', - name: 'Python', - language: 'python', - type: 'language', - link: 'https://docs.sentry.io/platforms/python/', - category: 'popular', - }, - selectedFeatures: [ - ProductSolution.ERROR_MONITORING, - ProductSolution.PERFORMANCE_MONITORING, - ProductSolution.PROFILING, - ], - }), - } - ); + const props = defaultProps({ + selectedRepository: mockRepository, + selectedPlatform: { + key: 'python', + name: 'Python', + language: 'python', + type: 'language', + link: 'https://docs.sentry.io/platforms/python/', + category: 'popular', + }, + selectedFeatures: [ + ProductSolution.ERROR_MONITORING, + ProductSolution.PERFORMANCE_MONITORING, + ProductSolution.PROFILING, + ], + }); + render(, {organization}); // Wait for feature cards to appear await screen.findByText('What do you want to instrument?'); - // Both should be checked initially - expect(screen.getByRole('checkbox', {name: /Tracing/})).toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Profiling/})).toBeChecked(); - - // Disable tracing — profiling should auto-disable + // Disable tracing — onFeaturesChange should drop both tracing and profiling await userEvent.click(screen.getByRole('checkbox', {name: /Tracing/})); - expect(screen.getByRole('checkbox', {name: /Tracing/})).not.toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Profiling/})).not.toBeChecked(); + expect(props.onFeaturesChange).toHaveBeenCalledWith( + expect.not.arrayContaining([ + ProductSolution.PERFORMANCE_MONITORING, + ProductSolution.PROFILING, + ]) + ); }); it('clears persisted project details form when detected platform changes', async () => { @@ -546,31 +430,15 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - projectDetailsForm: { - projectName: 'stale-name', - teamSlug: 'stale-team', - }, - }), - } - ); + // The component is stateless w.r.t. the form, so we just verify it calls + // the clear callback when the user changes the detected platform. + const props = defaultProps({selectedRepository: mockRepository}); + render(, {organization}); const djangoCard = await screen.findByRole('radio', {name: /Django/}); await userEvent.click(djangoCard); - await waitFor(() => { - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.projectDetailsForm).toBeUndefined(); - }); + expect(props.onClearProjectDetailsForm).toHaveBeenCalled(); }); describe('analytics', () => { @@ -581,17 +449,7 @@ describe('ScmPlatformFeatures', () => { }); it('fires step viewed event on mount', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); await screen.findByText('Select a platform'); @@ -617,17 +475,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); // Wait for detected platforms, then click the second one @@ -659,17 +508,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); await screen.findByRole('heading', {level: 3, name: 'Available with Next.js'}); @@ -693,17 +533,12 @@ describe('ScmPlatformFeatures', () => { render( null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ + {...defaultProps({ selectedRepository: mockRepository, selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } + })} + />, + {organization} ); await screen.findByText('What do you want to instrument?'); @@ -727,17 +562,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); const changeButton = await screen.findByRole('button', { @@ -780,7 +606,6 @@ describe('ScmPlatformFeatures', () => { }); it('auto-creates the project on Continue and forwards selected features', async () => { - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -791,20 +616,11 @@ describe('ScmPlatformFeatures', () => { body: createdProject, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -824,13 +640,13 @@ describe('ScmPlatformFeatures', () => { }) ); }); - expect(onComplete).toHaveBeenCalledWith(nextJsPlatform, { + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { product: [ProductSolution.ERROR_MONITORING], }); + expect(props.onProjectCreated).toHaveBeenCalledWith(createdProject.slug); }); it('links selected repository to project after creation', async () => { - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -857,21 +673,12 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedRepository: mockRepository, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedRepository: mockRepository, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -879,7 +686,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); expect(repoLinkRequest).toHaveBeenCalledWith( @@ -892,7 +699,6 @@ describe('ScmPlatformFeatures', () => { }); it('reuses the existing project when the platform is unchanged', async () => { - const onComplete = jest.fn(); const existingProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -904,21 +710,12 @@ describe('ScmPlatformFeatures', () => { body: existingProject, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - createdProjectSlug: existingProject.slug, - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + createdProjectSlug: existingProject.slug, + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -926,7 +723,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith(nextJsPlatform, { + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { product: [ProductSolution.ERROR_MONITORING], }); }); @@ -934,7 +731,6 @@ describe('ScmPlatformFeatures', () => { }); it('creates a new project when the platform changed from the existing one', async () => { - const onComplete = jest.fn(); const stalePythonProject = ProjectFixture({ slug: 'python', platform: 'python', @@ -950,21 +746,12 @@ describe('ScmPlatformFeatures', () => { body: newProject, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - createdProjectSlug: stalePythonProject.slug, - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + createdProjectSlug: stalePythonProject.slug, + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -974,7 +761,7 @@ describe('ScmPlatformFeatures', () => { await waitFor(() => { expect(createRequest).toHaveBeenCalled(); }); - expect(onComplete).toHaveBeenCalledWith(nextJsPlatform, { + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { product: [ProductSolution.ERROR_MONITORING], }); }); @@ -985,7 +772,6 @@ describe('ScmPlatformFeatures', () => { // currentPlatformKey falls back to the detected key. Passing undefined // to onComplete here would trip goNextStep's SETUP_DOCS guard because // the captured closure still sees selectedPlatform as undefined. - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -1012,19 +798,8 @@ describe('ScmPlatformFeatures', () => { body: {}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + const props = defaultProps({selectedRepository: mockRepository}); + render(, {organization}); await screen.findByRole('radio', {name: /Next.js/}); await waitFor(() => { @@ -1033,7 +808,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith( + expect(props.onComplete).toHaveBeenCalledWith( expect.objectContaining({key: 'javascript-nextjs'}), {product: [ProductSolution.ERROR_MONITORING]} ); @@ -1060,27 +835,19 @@ describe('ScmPlatformFeatures', () => { }; it('advances without creating a project on Continue', async () => { - const onComplete = jest.fn(); const createRequest = MockApiClient.addMockResponse({ url: `/teams/${experimentOrganization.slug}/team-slug/projects/`, method: 'POST', body: ProjectFixture(), }); - render( - null} - />, - { - organization: experimentOrganization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, { + organization: experimentOrganization, + }); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -1088,7 +855,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith(); + expect(props.onComplete).toHaveBeenCalledWith(); }); expect(createRequest).not.toHaveBeenCalled(); }); diff --git a/static/app/views/onboarding/scmPlatformFeatures.tsx b/static/app/views/onboarding/scmPlatformFeatures.tsx index e88d935652ccf1..98f877bba80e0d 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.tsx @@ -14,7 +14,6 @@ import {closeModal, openConsoleModal} from 'sentry/actionCreators/modal'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import { getDisabledProducts, platformProductAvailability, @@ -24,6 +23,7 @@ import {PLATFORM_PRODUCT_INFO} from 'sentry/data/platformProductInfo.generated'; import {platforms} from 'sentry/data/platforms'; import {IconBroadcast, IconBusiness, IconGeneric} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import type {Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Team} from 'sentry/types/organization'; import type {PlatformIntegration, PlatformKey} from 'sentry/types/project'; @@ -100,20 +100,34 @@ function getPlatformName(platformKey: PlatformKey | undefined) { return getPlatformInfo(platformKey)?.name; } -export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { +interface ScmPlatformFeaturesProps { + createdProjectSlug: string | undefined; + onClearProjectDetailsForm: () => void; + onComplete: StepProps['onComplete']; + onFeaturesChange: (features: ProductSolution[] | undefined) => void; + onPlatformChange: (platform: OnboardingSelectedSDK | undefined) => void; + onProjectCreated: (slug: string | undefined) => void; + selectedFeatures: ProductSolution[] | undefined; + selectedPlatform: OnboardingSelectedSDK | undefined; + selectedRepository: Repository | undefined; + genBackButton?: StepProps['genBackButton']; +} + +export function ScmPlatformFeatures({ + createdProjectSlug, + onClearProjectDetailsForm, + onComplete, + onFeaturesChange, + onPlatformChange, + onProjectCreated, + selectedFeatures, + selectedPlatform, + selectedRepository, + genBackButton, +}: ScmPlatformFeaturesProps) { const {openModal} = useModal(); const organization = useOrganization(); - const { - selectedRepository, - selectedPlatform, - setSelectedPlatform, - selectedFeatures, - setSelectedFeatures, - setProjectDetailsForm, - createdProjectSlug, - setCreatedProjectSlug, - } = useOnboardingContext(); const {teams, fetching: isLoadingTeams} = useTeams(); const {projects, initiallyLoaded: projectsLoaded} = useProjects(); @@ -138,10 +152,10 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { (platformKey: PlatformKey) => { const info = getPlatformInfo(platformKey); if (info) { - setSelectedPlatform(toSelectedSdk(info)); + onPlatformChange(toSelectedSdk(info)); } }, - [setSelectedPlatform] + [onPlatformChange] ); const hasScmConnected = !!selectedRepository; @@ -260,7 +274,7 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { } } - setSelectedFeatures(Array.from(newFeatures)); + onFeaturesChange(Array.from(newFeatures)); trackAnalytics('onboarding.scm_platform_feature_toggled', { organization, @@ -271,7 +285,7 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { }, [ currentFeatures, - setSelectedFeatures, + onFeaturesChange, disabledProducts, availableFeatures, organization, @@ -280,9 +294,9 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { ); const applyPlatformSelection = (sdk: OnboardingSelectedSDK) => { - setSelectedPlatform(sdk); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onPlatformChange(sdk); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); }; const handleManualPlatformSelect = async (option: {value: string}) => { @@ -340,8 +354,8 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { } setPlatform(platformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -355,8 +369,8 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { return; } setPlatform(platformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -378,8 +392,8 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { setShowManualPicker(false); if (detectedPlatformKey) { setPlatform(detectedPlatformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); } } @@ -393,12 +407,12 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { !hasProjectDetailsStep && (isLoadingTeams || !projectsLoaded); async function handleContinue() { - // Persist derived defaults to context if user accepted them + // Persist derived defaults if the user accepted them without an explicit click if (currentPlatformKey && !selectedPlatform?.key) { setPlatform(currentPlatformKey); } if (!selectedFeatures) { - setSelectedFeatures(currentFeatures); + onFeaturesChange(currentFeatures); } if (!hasProjectDetailsStep) { @@ -435,7 +449,7 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { default_rules: true, firstTeamSlug: firstAdminTeam?.slug, }); - setCreatedProjectSlug(project.slug); + onProjectCreated(project.slug); if (selectedRepository?.id) { try { diff --git a/static/app/views/onboarding/scmProjectDetails.spec.tsx b/static/app/views/onboarding/scmProjectDetails.spec.tsx index ec53836fe4fcdd..dc06d6aade791b 100644 --- a/static/app/views/onboarding/scmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/scmProjectDetails.spec.tsx @@ -5,26 +5,35 @@ import {TeamFixture} from 'sentry-fixture/team'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; +import type {Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import * as analytics from 'sentry/utils/analytics'; -import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {MetricValues, RuleAction} from 'sentry/views/projectInstall/issueAlertOptions'; import {ScmProjectDetails} from './scmProjectDetails'; -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); +interface StateOverrides { + createdProjectSlug?: string; + projectDetailsForm?: ProjectDetailsFormState; + selectedFeatures?: ProductSolution[]; + selectedPlatform?: OnboardingSelectedSDK; + selectedRepository?: Repository; +} + +function defaultProps(state: StateOverrides = {}) { + return { + selectedPlatform: state.selectedPlatform, + selectedFeatures: state.selectedFeatures, + selectedRepository: state.selectedRepository, + createdProjectSlug: state.createdProjectSlug, + projectDetailsForm: state.projectDetailsForm, + onProjectCreated: jest.fn(), + onProjectDetailsFormChange: jest.fn(), + onComplete: jest.fn(), }; } @@ -44,7 +53,6 @@ describe('ScmProjectDetails', () => { const teamWithAccess = TeamFixture({slug: 'my-team', access: ['team:admin']}); beforeEach(() => { - sessionStorageWrapper.clear(); TeamStore.loadInitialData([teamWithAccess]); ProjectsStore.loadInitialData([]); @@ -67,37 +75,17 @@ describe('ScmProjectDetails', () => { }); it('renders step header with heading', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); expect(await screen.findByText('Project details')).toBeInTheDocument(); }); it('renders section headers with icons', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); expect(await screen.findByText('Give your project a name')).toBeInTheDocument(); expect(screen.getByText('Assign a team')).toBeInTheDocument(); @@ -106,83 +94,31 @@ describe('ScmProjectDetails', () => { }); it('renders project name defaulted from platform key', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); - - const input = await screen.findByPlaceholderText('project-name'); - expect(input).toHaveValue('javascript-nextjs'); - }); - - it('uses platform key as default name even when repository is in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - selectedRepository: mockRepository, - }), - } - ); + render(, { + organization, + }); const input = await screen.findByPlaceholderText('project-name'); expect(input).toHaveValue('javascript-nextjs'); }); it('renders card-style alert frequency options', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); expect(await screen.findByText('High priority issues')).toBeInTheDocument(); expect(screen.getByText('Custom')).toBeInTheDocument(); expect(screen.getByText("I'll create my own alerts later")).toBeInTheDocument(); }); - it('create project button is disabled without platform in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + it('create project button is disabled without platform', async () => { + render(, {organization}); expect(await screen.findByRole('button', {name: 'Create project'})).toBeDisabled(); }); it('create project button calls API and completes on success', async () => { - const onComplete = jest.fn(); - const projectCreationRequest = MockApiClient.addMockResponse({ url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, method: 'POST', @@ -203,19 +139,8 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); const createButton = await screen.findByRole('button', {name: 'Create project'}); await userEvent.click(createButton); @@ -225,12 +150,11 @@ describe('ScmProjectDetails', () => { }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); it('links selected repository to project after creation', async () => { - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', name: 'javascript-nextjs', @@ -266,25 +190,16 @@ describe('ScmProjectDetails', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - selectedRepository: mockRepository, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + selectedRepository: mockRepository, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); expect(repoLinkRequest).toHaveBeenCalledWith( @@ -297,25 +212,15 @@ describe('ScmProjectDetails', () => { }); it('defaults team selector to first admin team', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); // TeamSelector renders the team slug as the selected value expect(await screen.findByText(`#${teamWithAccess.slug}`)).toBeInTheDocument(); }); - it('updates context with project slug after creation', async () => { + it('stores project slug via onProjectCreated after creation', async () => { const createdProject = ProjectFixture({ slug: 'my-custom-project', name: 'my-custom-project', @@ -339,60 +244,37 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); - // Verify the project slug was stored separately in context (not overwriting - // selectedPlatform.key) so onboarding.tsx can find the project via - // useRecentCreatedProject while preserving the original platform selection. - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.createdProjectSlug).toBe('my-custom-project'); - expect(stored.selectedPlatform?.key).toBe('javascript-nextjs'); + expect(props.onProjectCreated).toHaveBeenCalledWith('my-custom-project'); }); - it('restores form inputs from persisted projectDetailsForm', async () => { + it('restores form inputs from projectDetailsForm prop', async () => { render( null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ + {...defaultProps({ selectedPlatform: mockPlatform, projectDetailsForm: { projectName: 'my-saved-name', teamSlug: teamWithAccess.slug, }, - }), - } + })} + />, + {organization} ); const input = await screen.findByPlaceholderText('project-name'); expect(input).toHaveValue('my-saved-name'); }); - it('persists form state to context on successful creation', async () => { + it('persists form state on successful creation', async () => { MockApiClient.addMockResponse({ url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, method: 'POST', @@ -411,36 +293,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.projectDetailsForm).toEqual( + expect(props.onProjectDetailsFormChange).toHaveBeenCalledWith( expect.objectContaining({ projectName: 'javascript-nextjs', teamSlug: teamWithAccess.slug, }) ); - expect(stored.projectDetailsForm.alertRuleConfig).toBeDefined(); }); it('reuses existing project when nothing changed on back-nav', async () => { @@ -458,37 +325,26 @@ describe('ScmProjectDetails', () => { body: existingProject, }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: existingProject.slug, - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: existingProject.slug, + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); expect(createRequest).not.toHaveBeenCalled(); expect(trackAnalyticsSpy).toHaveBeenCalledWith( @@ -522,32 +378,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: existingProject.slug, - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: existingProject.slug, + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); const input = await screen.findByPlaceholderText('project-name'); await userEvent.clear(input); @@ -559,7 +404,7 @@ describe('ScmProjectDetails', () => { expect(createRequest).toHaveBeenCalled(); }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); @@ -593,32 +438,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: stalePythonProject.slug, - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: stalePythonProject.slug, + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); @@ -626,7 +460,7 @@ describe('ScmProjectDetails', () => { expect(createRequest).toHaveBeenCalled(); }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); @@ -651,32 +485,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: 'javascript-nextjs', - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: 'javascript-nextjs', + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); @@ -684,13 +507,11 @@ describe('ScmProjectDetails', () => { expect(createRequest).toHaveBeenCalled(); }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); it('shows error message on project creation failure', async () => { - const onComplete = jest.fn(); - MockApiClient.addMockResponse({ url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, method: 'POST', @@ -698,44 +519,23 @@ describe('ScmProjectDetails', () => { body: {detail: 'Internal Error'}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); const createButton = await screen.findByRole('button', {name: 'Create project'}); await userEvent.click(createButton); await waitFor(() => { - expect(onComplete).not.toHaveBeenCalled(); + expect(props.onComplete).not.toHaveBeenCalled(); }); }); it('fires step viewed analytics on mount', async () => { const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); await screen.findByText('Project details'); @@ -766,26 +566,13 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); const eventKeys = trackAnalyticsSpy.mock.calls.map(call => call[0]); diff --git a/static/app/views/onboarding/scmProjectDetails.tsx b/static/app/views/onboarding/scmProjectDetails.tsx index f5b72758285a29..f500378c5eb665 100644 --- a/static/app/views/onboarding/scmProjectDetails.tsx +++ b/static/app/views/onboarding/scmProjectDetails.tsx @@ -7,11 +7,14 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; +import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext'; import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules'; import {TeamSelector} from 'sentry/components/teamSelector'; import {IconGroup, IconProject, IconSiren} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {Repository} from 'sentry/types/integrations'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Team} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import {fetchMutation} from 'sentry/utils/queryClient'; @@ -32,17 +35,30 @@ import {ScmAlertFrequency} from './components/scmAlertFrequency'; import {ScmStepHeader} from './components/scmStepHeader'; import type {StepProps} from './types'; -export function ScmProjectDetails({onComplete, genBackButton}: StepProps) { +interface ScmProjectDetailsProps { + createdProjectSlug: string | undefined; + onComplete: StepProps['onComplete']; + onProjectCreated: (slug: string | undefined) => void; + onProjectDetailsFormChange: (form: ProjectDetailsFormState | undefined) => void; + projectDetailsForm: ProjectDetailsFormState | undefined; + selectedFeatures: ProductSolution[] | undefined; + selectedPlatform: OnboardingSelectedSDK | undefined; + selectedRepository: Repository | undefined; + genBackButton?: StepProps['genBackButton']; +} + +export function ScmProjectDetails({ + createdProjectSlug, + onComplete, + onProjectCreated, + onProjectDetailsFormChange, + projectDetailsForm, + selectedFeatures, + selectedPlatform, + selectedRepository, + genBackButton, +}: ScmProjectDetailsProps) { const organization = useOrganization(); - const { - selectedPlatform, - selectedFeatures, - selectedRepository, - createdProjectSlug, - setCreatedProjectSlug, - projectDetailsForm, - setProjectDetailsForm, - } = useOnboardingContext(); const {teams, fetching: isLoadingTeams} = useTeams(); const {projects, initiallyLoaded: projectsLoaded} = useProjects(); const createProjectAndRules = useCreateProjectAndRules(); @@ -160,7 +176,7 @@ export function ScmProjectDetails({onComplete, genBackButton}: StepProps) { // Store the project slug separately so onboarding.tsx can find // the project via useRecentCreatedProject without corrupting // selectedPlatform.key (which the platform features step needs). - setCreatedProjectSlug(project.slug); + onProjectCreated(project.slug); if (selectedRepository?.id) { try { @@ -174,7 +190,7 @@ export function ScmProjectDetails({onComplete, genBackButton}: StepProps) { } } - setProjectDetailsForm({ + onProjectDetailsFormChange({ projectName: projectNameResolved, teamSlug: teamSlugResolved, alertRuleConfig, diff --git a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx index e3bb3e7519f0aa..77e4511821d9e4 100644 --- a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx @@ -120,7 +120,7 @@ describe('DiffImageDisplay', () => { /> ), - diffMode => ({theme: themeName, state: diffMode}) + () => ({tags: {area: 'snapshots'}}) ); it.snapshot( @@ -136,7 +136,7 @@ describe('DiffImageDisplay', () => { /> ), - {theme: themeName, state: 'split-missing-diff-image-key'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx b/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx index d48b63ef7f7457..c11e20ae930b62 100644 --- a/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx @@ -73,7 +73,7 @@ describe('SingleImageDisplay', () => { /> ), - {theme: themeName, state: 'basic-image-display'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx b/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx index 4d074f99aae81a..0a59185219bbc6 100644 --- a/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx @@ -193,7 +193,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'card-header-display-name-and-filename'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -203,7 +203,7 @@ describe('SnapshotCards', () => { ), - {theme: themeName, state: 'card-header-filename-only'} + {tags: {area: 'snapshots'}} ); function snapshotCardHeaderStatus({ @@ -227,7 +227,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: `card-header-${state}`} + {tags: {area: 'snapshots'}} ); } @@ -259,7 +259,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'card-header-static'} + {tags: {area: 'snapshots'}} ); function snapshotPairCard({ @@ -292,7 +292,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: `pair-card-${state}`} + {tags: {area: 'snapshots'}} ); } @@ -337,7 +337,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-added-selected-with-display-name'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -356,7 +356,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-removed-unselected'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -376,7 +376,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-renamed-with-pair-metadata'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -395,7 +395,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-solo-filename-only-no-status'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx index 60d62c6fb7c097..c85240971e4093 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx @@ -73,7 +73,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'default'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -92,7 +92,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'active-group'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -110,7 +110,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'filtered'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -128,7 +128,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'no-results'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/settings/account/notifications/fields.tsx b/static/app/views/settings/account/notifications/fields.tsx index 1d5056430cebfc..a83836e840d032 100644 --- a/static/app/views/settings/account/notifications/fields.tsx +++ b/static/app/views/settings/account/notifications/fields.tsx @@ -318,7 +318,11 @@ export const QUOTA_FIELDS = [ label: ( {t('Spend Allocations')}{' '} - + ), help: t('Receive notifications about your spend allocations.'), @@ -328,23 +332,3 @@ export const QUOTA_FIELDS = [ ] as const, }, ]; - -export const SPEND_FIELDS = [ - { - name: 'quota', - label: t('Spend Notifications'), - help: tct( - 'Receive notifications when your spend crosses predefined or custom thresholds. [learnMore:Learn more]', - { - learnMore: ( - - ), - } - ), - choices: [ - ['always', t('On')], - ['never', t('Off')], - ] as const, - }, - ...QUOTA_FIELDS.slice(1), -]; diff --git a/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx b/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx index 56b22a331664d3..c2e20127ef2bfe 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx @@ -17,7 +17,7 @@ import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {IconAdd, IconDelete} from 'sentry/icons'; import {t} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useLocation} from 'sentry/utils/useLocation'; @@ -38,7 +38,7 @@ interface NotificationSettingsByEntityProps { handleRemoveNotificationOption: (id: string) => void; notificationOptions: NotificationOptionsObject[]; notificationType: string; - organizations: Organization[]; + organizations: OrganizationSummary[]; } export function NotificationSettingsByEntity({ @@ -92,7 +92,7 @@ export function NotificationSettingsByEntity({ // always loading all projects even though we only need it sometimes const entities = entityType === 'project' ? projects || [] : organizations; // create maps by the project id for constant time lookups - const entityById = keyBy(entities, 'id'); + const entityById = keyBy(entities, 'id'); const handleOrgChange = (organizationId: string) => { navigate( @@ -136,7 +136,7 @@ export function NotificationSettingsByEntity({ const idBadgeProps = entityType === 'project' ? {project: entity as Project} - : {organization: entity as Organization}; + : {organization: entity as OrganizationSummary}; return ( @@ -190,7 +190,7 @@ export function NotificationSettingsByEntity({ const idBadgeProps = entityType === 'project' ? {project: entity as Project} - : {organization: entity as Organization}; + : {organization: entity as OrganizationSummary}; return { label: entityType === 'project' ? obj.slug : obj.name, diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx index a7cc61b90e6a8c..40f5758dccd07d 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx @@ -157,15 +157,13 @@ describe('NotificationSettingsByType', () => { 'Receive notifications when your organization exceeds the following limits.' ) ).toBeInTheDocument(); - expect( - await screen.findByText('Receive notifications about your error quotas.') - ).toBeInTheDocument(); expect(screen.getByText('Errors')).toBeInTheDocument(); expect(screen.getByText('Transactions')).toBeInTheDocument(); + expect(screen.getByText('Spans')).toBeInTheDocument(); expect(screen.getByText('Session Replays')).toBeInTheDocument(); expect(screen.getByText('Attachments')).toBeInTheDocument(); + expect(screen.getByText('Seer Budget')).toBeInTheDocument(); expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect(screen.queryByText('Spans')).not.toBeInTheDocument(); }); it('adds a project override and removes it', async () => { renderComponent({}); @@ -263,272 +261,10 @@ describe('NotificationSettingsByType', () => { expect(changeProvidersMock).toHaveBeenCalledTimes(1); }); - it('renders spend notifications page instead of quota notifications with flag', async () => { - const organizationWithFlag = OrganizationFixture(); - organizationWithFlag.features.push('spend-visibility-notifications'); - const organizationNoFlag = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organizationWithFlag, organizationNoFlag], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - expect(screen.queryByText('Quota Notifications')).not.toBeInTheDocument(); - expect( - screen.getByText('Control the notifications you receive for organization spend.') - ).toBeInTheDocument(); - }); - - it('toggle user spend notifications', async () => { - const organizationWithFlag = OrganizationFixture(); - organizationWithFlag.features.push('spend-visibility-notifications'); - const organizationNoFlag = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organizationWithFlag, organizationNoFlag], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - const editSettingMock = MockApiClient.addMockResponse({ - url: '/users/me/notification-options/', - method: 'PUT', - body: { - id: '7', - scopeIdentifier: '1', - scopeType: 'user', - type: 'quota', - value: 'never', - }, - }); - - // toggle spend notifications off - await selectEvent.select(screen.getAllByText('On')[0]!, 'Off'); - - expect(editSettingMock).toHaveBeenCalledTimes(1); - expect(editSettingMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: { - scopeIdentifier: '1', - scopeType: 'user', - type: 'quota', - value: 'never', - }, - }) - ); - }); + it('hides quota notifications on self-hosted', () => { + ConfigStore.set('isSelfHosted', true); + const {container} = renderComponent({notificationType: 'quota'}); - it('spend notifications on org with am3 with spend visibility notifications', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am3-tier', - 'continuous-profiling-billing', - 'seer-billing', - 'logs-billing', - 'expose-category-trace-metric-byte', - 'seer-user-billing-launch', - ], - }); - renderComponent({ - notificationType: 'quota', - organizations: [organization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect( - screen.getByText('Continuous Profile Hours', {exact: true}) - ).toBeInTheDocument(); - expect(screen.getByText('UI Profile Hours', {exact: true})).toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - expect(screen.getByText('Logs')).toBeInTheDocument(); - expect(screen.getByText('Active Contributors')).toBeInTheDocument(); - expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); - - const editSettingMock = MockApiClient.addMockResponse({ - url: '/users/me/notification-options/', - method: 'PUT', - body: { - id: '7', - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }); - - // toggle spans quota notifications off - await selectEvent.select(screen.getAllByText('On')[4]!, 'Off'); - - expect(editSettingMock).toHaveBeenCalledTimes(1); - expect(editSettingMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: { - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }) - ); - }); - - it('spend notifications on org with am3 and org without am3', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am3-tier', - 'continuous-profiling-billing', - 'seer-billing', - ], - }); - const otherOrganization = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organization, otherOrganization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect(screen.getByText('Transactions')).toBeInTheDocument(); - expect( - screen.getByText('Continuous Profile Hours', {exact: true}) - ).toBeInTheDocument(); - expect(screen.getByText('UI Profile Hours', {exact: true})).toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - }); - - it('spend notifications on org with am1 org only', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am1-tier', - 'continuous-profiling-billing', - 'seer-billing', - ], - }); - const otherOrganization = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organization, otherOrganization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect(screen.getByText('Transactions')).toBeInTheDocument(); - expect( - screen.queryByText('Continuous Profile Hours', {exact: true}) - ).not.toBeInTheDocument(); - expect(screen.queryByText('UI Profile Hours', {exact: true})).not.toBeInTheDocument(); - expect(screen.queryByText('Spans')).not.toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - expect(screen.getByText('Size Analysis Builds')).toBeInTheDocument(); - }); - - it('spend notifications on org with am3 without spend visibility notifications', async () => { - const organization = OrganizationFixture({ - features: ['am3-tier', 'continuous-profiling-billing', 'seer-billing'], - }); - renderComponent({ - notificationType: 'quota', - organizations: [organization], - }); - - expect(await screen.findByText('Errors')).toBeInTheDocument(); - expect(screen.queryByText('Spend Notifications')).not.toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect( - screen.getByText('Continuous Profile Hours', {exact: true}) - ).toBeInTheDocument(); - expect(screen.getByText('UI Profile Hours', {exact: true})).toBeInTheDocument(); - expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - - const editSettingMock = MockApiClient.addMockResponse({ - url: '/users/me/notification-options/', - method: 'PUT', - body: { - id: '7', - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }); - - // toggle spans quota notifications off - await selectEvent.select(screen.getAllByText('On')[3]!, 'Off'); - - expect(editSettingMock).toHaveBeenCalledTimes(1); - expect(editSettingMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: { - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }) - ); - }); - - it('should not show categories without related features', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am3-tier', - // No continuous-profiling-billing feature - // No seer-billing feature - // No logs-billing feature - // No expose-category-trace-metric-byte feature - ], - }); - renderComponent({ - notificationType: 'quota', - organizations: [organization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - // These should be present - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - - // These should NOT be present - expect( - screen.queryByText('Continuous Profile Hours', {exact: true}) - ).not.toBeInTheDocument(); - expect(screen.queryByText('UI Profile Hours', {exact: true})).not.toBeInTheDocument(); - expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); - expect(screen.queryByText('Seer Budget')).not.toBeInTheDocument(); - expect(screen.queryByText('Logs')).not.toBeInTheDocument(); - expect(screen.queryByText('Application Metrics')).not.toBeInTheDocument(); - expect(screen.queryByText('Active Contributors')).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); }); }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index 825939526a5362..ce0c541a127717 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -31,7 +31,6 @@ import { ACCOUNT_NOTIFICATION_FIELDS, NOTIFICATION_SETTING_FIELDS, QUOTA_FIELDS, - SPEND_FIELDS, } from './fields'; import {NotificationSettingsByEntity} from './notificationSettingsByEntity'; import type {Identity} from './types'; @@ -214,95 +213,6 @@ export function NotificationSettingsByType({notificationType}: Props) { }); }; - const filterCategoryFields = ( - fields: Array<{ - choices: ReadonlyArray; - name: string; - help?: React.ReactNode; - label?: React.ReactNode; - }> - ) => { - // at least one org exists with am3 tiered plan - const hasOrgWithAm3 = organizations.some(organization => - organization.features?.includes('am3-tier') - ); - - // at least one org exists without am3 tier plan - const hasOrgWithoutAm3 = organizations.some( - organization => !organization.features?.includes('am3-tier') - ); - - // at least one org exists with am2 tier plan - const hasOrgWithAm2 = organizations.some(organization => - organization.features?.includes('am2-tier') - ); - - // at least one org exists with am1 tier plan - const hasOrgWithAm1 = organizations.some(organization => - organization.features?.includes('am1-tier') - ); - - // Check if any organization has the continuous-profiling-billing feature flag - const hasOrgWithContinuousProfilingBilling = organizations.some(organization => - organization.features?.includes('continuous-profiling-billing') - ); - - const hasSeerBilling = organizations.some(organization => - organization.features?.includes('seer-billing') - ); - - const hasLogsBilling = organizations.some(organization => - organization.features?.includes('logs-billing') - ); - - const hasTraceMetricsBilling = organizations.some(organization => - organization.features?.includes('expose-category-trace-metric-byte') - ); - - const hasSeerUserBilling = organizations.some(organization => - organization.features?.includes('seer-user-billing-launch') - ); - - const excludeTransactions = hasOrgWithAm3 && !hasOrgWithoutAm3; - const includeSpans = hasOrgWithAm3; - const includeProfileDuration = - (hasOrgWithAm2 || hasOrgWithAm3) && hasOrgWithContinuousProfilingBilling; - const includeSeer = hasSeerBilling; - const includeLogs = hasLogsBilling; - const includeSizeAnalysis = hasOrgWithAm3 || hasOrgWithAm2 || hasOrgWithAm1; - - return fields.filter(field => { - if (field.name === 'quotaSpans' && !includeSpans) { - return false; - } - if (field.name === 'quotaTransactions' && excludeTransactions) { - return false; - } - if ( - ['quotaProfileDuration', 'quotaProfileDurationUI'].includes(field.name) && - !includeProfileDuration - ) { - return false; - } - if (field.name.startsWith('quotaSeerBudget') && !includeSeer) { - return false; - } - if (field.name.startsWith('quotaLogBytes') && !includeLogs) { - return false; - } - if (field.name.startsWith('quotaTraceMetricBytes') && !hasTraceMetricsBilling) { - return false; - } - if (field.name.startsWith('quotaSeerUsers') && !hasSeerUserBilling) { - return false; - } - if (field.name.startsWith('quotaSize') && !includeSizeAnalysis) { - return false; - } - return true; - }); - }; - const removeNotificationMutation = useMutation({ mutationFn: (id: string) => fetchMutation({method: 'DELETE', url: `/users/me/notification-options/${id}/`}), @@ -369,21 +279,14 @@ export function NotificationSettingsByType({notificationType}: Props) { const unlinkedSlackOrgs = getUnlinkedOrgs('slack'); const unlinkedSlackStagingOrgs = getUnlinkedOrgs('slack_staging'); - let notificationDetails = ACCOUNT_NOTIFICATION_FIELDS[notificationType]!; - if ( - notificationType === 'quota' && - organizations.some(org => org.features?.includes('spend-visibility-notifications')) - ) { - notificationDetails = { - ...notificationDetails, - title: t('Spend Notifications'), - description: t('Control the notifications you receive for organization spend.'), - }; - } - const {title, description} = notificationDetails; + const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType]!; const entityType = isGroupedByProject(notificationType) ? 'project' : 'organization'; + if (notificationType === 'quota' && ConfigStore.get('isSelfHosted')) { + return null; + } + if ( notificationOptionStatus === 'pending' || notificationProviderStatus === 'pending' || @@ -460,13 +363,7 @@ export function NotificationSettingsByType({notificationType}: Props) { }); const renderQuotaFields = () => { - const hasSpendVisibility = organizations.some(organization => - organization.features?.includes('spend-visibility-notifications') - ); - const sourceFields = hasSpendVisibility ? SPEND_FIELDS : QUOTA_FIELDS; - const filteredFields = filterCategoryFields(sourceFields); - - return filteredFields.map(field => { + return QUOTA_FIELDS.map(field => { const schema = z.object({[field.name]: z.string()}); return ( void; organizationId: string | undefined; - organizations: Organization[]; + organizations: OrganizationSummary[]; }; export function OrganizationSelectHeader({ diff --git a/static/app/views/sharedGroupDetails/sharedEventContent.tsx b/static/app/views/sharedGroupDetails/sharedEventContent.tsx index f324c49c105265..ff09f89ed2f8e4 100644 --- a/static/app/views/sharedGroupDetails/sharedEventContent.tsx +++ b/static/app/views/sharedGroupDetails/sharedEventContent.tsx @@ -37,16 +37,16 @@ export function SharedEventContent({organization, project, event, group}: Props) } const projectSlug = project.slug; + const userReport = event.userReport; return (
- {event.userReport && ( + {userReport && ( diff --git a/static/gsAdmin/components/customers/pendingChanges.spec.tsx b/static/gsAdmin/components/customers/pendingChanges.spec.tsx index 066cc69d780ff9..592c55b66b4669 100644 --- a/static/gsAdmin/components/customers/pendingChanges.spec.tsx +++ b/static/gsAdmin/components/customers/pendingChanges.spec.tsx @@ -2,7 +2,6 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory'; import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {PlanMigrationFixture} from 'getsentry-test/fixtures/planMigration'; import {SeerReservedBudgetFixture} from 'getsentry-test/fixtures/reservedBudget'; import { Am3DsEnterpriseSubscriptionFixture, @@ -15,9 +14,8 @@ import {DataCategory} from 'sentry/types/core'; import {PendingChanges} from 'admin/components/customers/pendingChanges'; import {PendingChangesFixture} from 'getsentry/__fixtures__/pendingChanges'; import {PlanFixture} from 'getsentry/__fixtures__/plan'; -import {ANNUAL, RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; -import * as usePlanMigrations from 'getsentry/hooks/usePlanMigrations'; -import {CohortId, OnDemandBudgetMode} from 'getsentry/types'; +import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; +import {OnDemandBudgetMode} from 'getsentry/types'; describe('PendingChanges', () => { it('renders null pendingChanges)', () => { @@ -244,76 +242,6 @@ describe('PendingChanges', () => { ); }); - it('renders pending changes for plan migration', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business_auf'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business_auf', - contractInterval: ANNUAL, - organization, - onDemandPeriodEnd: '2018-10-24', - contractPeriodEnd: '2019-09-24', - pendingChanges: PendingChangesFixture({ - planDetails: PlanFixture({ - id: 'am3_business_auf', - name: 'Business', - contractInterval: 'annual', - billingInterval: 'annual', - onDemandCategories: [ - DataCategory.ERRORS, - DataCategory.ATTACHMENTS, - DataCategory.SPANS, - DataCategory.REPLAYS, - DataCategory.MONITOR_SEATS, - ], - }), - reserved: { - errors: 50_000, - spans: 10_000_000, - replays: 50, - monitorSeats: 1, - attachments: 1, - }, - effectiveDate: '2019-09-25', - onDemandEffectiveDate: '2018-10-25', - }), - }); - const migrationDate = '2018-10-25'; - const migration = PlanMigrationFixture({ - cohortId: CohortId.TENTH, - effectiveAt: migrationDate, - }); - jest - .spyOn(usePlanMigrations, 'usePlanMigrations') - .mockReturnValue({planMigrations: [migration], isLoading: false}); - - const {container} = render(); - - // expected copy for plan changes - expect(container).toHaveTextContent( - 'This account has pending changes to the subscription' - ); - expect(container).toHaveTextContent( - 'The following changes will take effect on Oct 25, 2018' - ); - expect(container).toHaveTextContent('Plan changes — Business → Business'); - expect(container).toHaveTextContent( - 'Reserved performance units — 100,000 → 0 transactions' - ); - expect(container).toHaveTextContent('Reserved replays — 500 → 50 session replays'); - expect(container).toHaveTextContent('Reserved spans — 0 → 10,000,000 spans'); - - // no actual changes - expect(container).not.toHaveTextContent('Reserved errors — 50,000 → 50,000 errors'); - expect(container).not.toHaveTextContent( - 'Reserved attachments — 1 GB → 1 GB attachments' - ); - expect(container).not.toHaveTextContent( - 'Reserved cron monitors — 1 → 1 cron monitor' - ); - }); - it('renders reserved budgets with existing budgets', () => { const subscription = Am3DsEnterpriseSubscriptionFixture({ organization: OrganizationFixture(), diff --git a/static/gsAdmin/components/customers/pendingChanges.tsx b/static/gsAdmin/components/customers/pendingChanges.tsx index a415d3dbaff958..59cdb5cf9989ac 100644 --- a/static/gsAdmin/components/customers/pendingChanges.tsx +++ b/static/gsAdmin/components/customers/pendingChanges.tsx @@ -9,8 +9,7 @@ import {IconArrow} from 'sentry/icons'; import {DataCategory} from 'sentry/types/core'; import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; -import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations'; -import type {Plan, PlanMigration, Subscription} from 'getsentry/types'; +import type {Plan, Subscription} from 'getsentry/types'; import {displayBudgetName, formatReservedWithUnits} from 'getsentry/utils/billing'; import { getPlanCategoryName, @@ -332,7 +331,7 @@ type Change = { items: React.ReactNode[]; }; -function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) { +function getChanges(subscription: Subscription) { const {pendingChanges} = subscription; const changeSet: Change[] = []; @@ -340,13 +339,7 @@ function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) return changeSet; } - const activeMigration = planMigrations.find( - ({dateApplied, cohort}) => dateApplied === null && cohort?.nextPlan - ); - - const {onDemandEffectiveDate} = pendingChanges; - - const effectiveDate = activeMigration?.effectiveAt ?? pendingChanges.effectiveDate; + const {onDemandEffectiveDate, effectiveDate} = pendingChanges; const regularChanges = getRegularChanges(subscription); const onDemandChanges = getOnDemandChanges(subscription); @@ -371,16 +364,12 @@ function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) export function PendingChanges({subscription}: any) { const {pendingChanges} = subscription; - const {planMigrations, isLoading} = usePlanMigrations(); - if (isLoading) { - return null; - } if (typeof pendingChanges !== 'object' || pendingChanges === null) { return null; } - const changes = getChanges(subscription, planMigrations); + const changes = getChanges(subscription); if (!changes.length) { return null; } diff --git a/static/gsApp/hooks/usePlanMigrations.tsx b/static/gsApp/hooks/usePlanMigrations.tsx deleted file mode 100644 index 2f79d539dfcbc0..00000000000000 --- a/static/gsApp/hooks/usePlanMigrations.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type {Organization} from 'sentry/types/organization'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useUser} from 'sentry/utils/useUser'; - -import type {PlanMigration} from 'getsentry/types'; - -const hasBillingAccess = ({access}: Organization) => access?.includes('org:billing'); - -interface PlanMigrationsHook { - isLoading: boolean; - planMigrations: PlanMigration[]; -} - -export function usePlanMigrations(): PlanMigrationsHook { - const organization = useOrganization(); - const user = useUser(); - const enabled = hasBillingAccess(organization) || user.isStaff; - const {data: planMigrations, isPending} = useApiQuery( - [ - getApiUrl('/customers/$organizationIdOrSlug/plan-migrations/', { - path: {organizationIdOrSlug: organization.slug}, - }), - {query: {scheduled: 1, applied: 0}}, - ], - { - staleTime: Infinity, - enabled, - retry: false, - notifyOnChangeProps: ['isLoading', 'data'], - } - ); - - return { - planMigrations: planMigrations ?? [], - isLoading: enabled ? isPending : false, - }; -} diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 44942b3e0f9567..c40857e094190f 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -950,54 +950,6 @@ export type RecurringCredit = | RecurringPercentDiscount | RecurringEventCredit; -export enum CohortId { - SECOND = 2, - THIRD = 3, - FOURTH = 4, - FIFTH = 5, - SIXTH = 6, - SEVENTH = 7, - EIGHTH = 8, - NINTH = 9, - TENTH = 10, - TEST_ONE = 111, -} - -export type Cohort = { - cohortId: CohortId; - nextPlan: NextPlanInfo | null; - secondDiscount: number; -}; - -export type NextPlanInfo = { - contractPeriod: string; - discountAmount: number; - discountMonths: number; - id: string; - name: string; - reserved: Partial>; - totalPrice: number; - categoryCredits?: Partial< - Record< - DataCategory, - { - credits: number; - months: number; - } - > - >; -}; - -export type PlanMigration = { - cohort: Cohort | null; - dateApplied: string | null; - effectiveAt: string | null; - id: number | string; - planTier: string; - recurringCredits: RecurringCredit[]; - scheduled: boolean; -}; - export enum PlanTier { /** * Performance plans with continuous profiling diff --git a/static/gsApp/views/amCheckout/components/cart.spec.tsx b/static/gsApp/views/amCheckout/components/cart.spec.tsx index 0957c25842bb14..763e28515ea959 100644 --- a/static/gsApp/views/amCheckout/components/cart.spec.tsx +++ b/static/gsApp/views/amCheckout/components/cart.spec.tsx @@ -62,11 +62,6 @@ describe('Cart', () => { setMockDate(MOCK_TODAY); MockApiClient.clearMockResponses(); SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/index.spec.tsx b/static/gsApp/views/amCheckout/index.spec.tsx index 5011aa363d60ec..e2a66f2019d2e0 100644 --- a/static/gsApp/views/amCheckout/index.spec.tsx +++ b/static/gsApp/views/amCheckout/index.spec.tsx @@ -66,11 +66,6 @@ describe('Legacy Tier Checkout', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', @@ -195,11 +190,6 @@ describe('Default Tier Checkout', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx b/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx index 71a6a89564856b..d13d4aa58f3eeb 100644 --- a/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx @@ -41,11 +41,6 @@ describe('AddBillingInformation', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx b/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx index 5c336c6f2f5105..ab15b46ac13b8e 100644 --- a/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx @@ -40,11 +40,6 @@ describe('BuildYourPlan', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx b/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx index 3aed1b4b659827..3a568eea8869a8 100644 --- a/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx @@ -47,11 +47,6 @@ describe('ChooseYourBillingCycle', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx index fb31cf53977208..06f05b9e1ece2a 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx @@ -52,11 +52,6 @@ describe('ProductSelect', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx b/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx index 879c64c33337f7..9fb6f3f37b1bef 100644 --- a/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx @@ -99,11 +99,6 @@ describe('ReserveAdditionalVolume', () => { beforeEach(() => { SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'GET', @@ -234,11 +229,6 @@ describe('ReserveAdditionalVolume', () => { subscription = SubscriptionFixture({organization}); stepProps.subscription = subscription; SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'GET', diff --git a/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx b/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx index b2385a211baa80..523e5296430286 100644 --- a/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx +++ b/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx @@ -52,11 +52,6 @@ describe('Subscription > BillingInformation', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/prompts-activity/`, body: {}, diff --git a/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx b/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx index 8ab976a935c667..a0f60b38a49392 100644 --- a/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx +++ b/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx @@ -1,10 +1,8 @@ import type {Organization} from 'sentry/types/organization'; -import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations'; import type {Subscription} from 'getsentry/types'; import {PendingChanges} from './pendingChanges'; -import {PlanMigrationActive} from './planMigrationActive'; type Props = { organization: Organization; @@ -12,18 +10,5 @@ type Props = { }; export function DecidePendingChanges({subscription, organization}: Props) { - const {planMigrations, isLoading} = usePlanMigrations(); - if (isLoading) { - return null; - } - - const activeMigration = planMigrations.find( - ({dateApplied, cohort}) => dateApplied === null && cohort?.nextPlan - ); - - return activeMigration ? ( - - ) : ( - - ); + return ; } diff --git a/static/gsApp/views/subscriptionPage/notifications.spec.tsx b/static/gsApp/views/subscriptionPage/notifications.spec.tsx index 534d13b2f52b83..0a1dffa939ecd6 100644 --- a/static/gsApp/views/subscriptionPage/notifications.spec.tsx +++ b/static/gsApp/views/subscriptionPage/notifications.spec.tsx @@ -20,11 +20,6 @@ describe('Subscription > Notifications', () => { method: 'GET', body: {reservedPercent: [90], perProductOndemandPercent: [80, 50]}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', diff --git a/static/gsApp/views/subscriptionPage/overview.spec.tsx b/static/gsApp/views/subscriptionPage/overview.spec.tsx index b03f95c9f79cff..f617a6533e0acb 100644 --- a/static/gsApp/views/subscriptionPage/overview.spec.tsx +++ b/static/gsApp/views/subscriptionPage/overview.spec.tsx @@ -2,17 +2,15 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {PlanMigrationFixture} from 'getsentry-test/fixtures/planMigration'; import {RecurringCreditFixture} from 'getsentry-test/fixtures/recurringCredit'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen} from 'sentry-test/reactTestingLibrary'; -import {textWithMarkupMatcher} from 'sentry-test/utils'; import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/secondaryNavigationContext'; import {PendingChangesFixture} from 'getsentry/__fixtures__/pendingChanges'; import {SubscriptionStore} from 'getsentry/stores/subscriptionStore'; -import {CohortId, PlanTier} from 'getsentry/types'; +import {PlanTier} from 'getsentry/types'; import Overview from 'getsentry/views/subscriptionPage/overview'; describe('Subscription > Overview', () => { @@ -26,12 +24,6 @@ describe('Subscription > Overview', () => { method: 'GET', body: CustomerUsageFixture(), }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/recurring-credits/`, method: 'GET', @@ -67,7 +59,7 @@ describe('Subscription > Overview', () => { SubscriptionStore.set(organization.slug, {}); }); - describe('Plan Migrations', () => { + describe('Pending Changes', () => { const subscription = SubscriptionFixture({ organization, plan: 'mm2_b_100k', @@ -90,62 +82,6 @@ describe('Subscription > Overview', () => { expect( await screen.findByText(/The following changes will take effect on/) ).toBeInTheDocument(); - - expect(screen.queryByText("We're updating our")).not.toBeInTheDocument(); - }); - - it('renders plan migration', async () => { - SubscriptionStore.set(organization.slug, subscription); - const planMigrations = [PlanMigrationFixture({cohortId: CohortId.SECOND})]; - const mockApi = MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: planMigrations, - }); - - render(, { - organization, - additionalWrapper: SecondaryNavigationContextProvider, - }); - - expect( - await screen.findByText(textWithMarkupMatcher("We're updating our Team Plan")) - ).toBeInTheDocument(); - expect( - screen.queryByText('The following changes will take effect on') - ).not.toBeInTheDocument(); - - expect(mockApi).toHaveBeenCalledTimes(1); - }); - - it('does not render already applied plan migration', async () => { - SubscriptionStore.set(organization.slug, subscription); - const planMigrations = [ - PlanMigrationFixture({ - cohortId: CohortId.SECOND, - dateApplied: '2021-08-01', - }), - ]; - const mockApi = MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: planMigrations, - }); - - render(, { - organization, - additionalWrapper: SecondaryNavigationContextProvider, - }); - - expect( - await screen.findByText(/The following changes will take effect on/) - ).toBeInTheDocument(); - - expect(screen.queryByText("We're updating our")).not.toBeInTheDocument(); - - expect(mockApi).toHaveBeenCalledTimes(1); }); }); diff --git a/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx b/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx index a3cbcda390b0e3..26251a7ce54765 100644 --- a/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx @@ -21,12 +21,6 @@ describe('Subscription > PaymentHistory', () => { url: '/organizations/dogz-rule/promotions/trigger-check/', method: 'POST', }); - MockApiClient.addMockResponse({ - url: '/customers/dogz-rule/plan-migrations/', - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: '/customers/dogz-rule/recurring-credits/', method: 'GET', diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx deleted file mode 100644 index e3ae1adac0c284..00000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx +++ /dev/null @@ -1,908 +0,0 @@ -import moment from 'moment-timezone'; -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {PlanMigrationFixture} from 'getsentry-test/fixtures/planMigration'; -import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {ANNUAL} from 'getsentry/constants'; -import {CohortId} from 'getsentry/types'; -import {PlanMigrationActive} from 'getsentry/views/subscriptionPage/planMigrationActive/index'; - -describe('PlanMigrationActive cohort 2', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'mm2_b_100k', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.SECOND, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Team Plan")).toBeInTheDocument(); - expect( - screen.getByText('These plan changes will take place on Oct 25, 2018.') - ).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders null', () => { - render(); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders null with missing next plan', () => { - render( - - ); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(6); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Team/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - expect(screen.queryByTestId('new-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$29/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$44/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$29\*/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders discount note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$29 for 5 months, then changes to \$44 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 3', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'mm2_b_100k_ac', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.THIRD, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Team Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders null', () => { - render(); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders null with missing next plan', () => { - render( - - ); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Team/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('renders contract row', () => { - renderSimple(); - expect(screen.getByTestId('current-contract')).toHaveTextContent(/annual/); - expect(screen.getByTestId('new-contract')).toHaveTextContent(/monthly/); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$26/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$44/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$26/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$26 for 5 months, then changes to \$44 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 4', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'mm2_b_100k_auf', - billingInterval: 'annual', - organization, - }); - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.FOURTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Team Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getAllByText(migrationDate, {exact: false})).toHaveLength(2); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Team/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$312/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$480/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$312/); - }); - - it('renders renewal price row', () => { - renderSimple(); - expect(screen.getByTestId('current-renewal')).toHaveTextContent(/\$312/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$480/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$452/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders annual dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('annual-dollar-credits')).toHaveTextContent( - /\*Discount of \$168 for plan changes on Oct 18, 2017. An additional one-time \$28 discount applies at contract renewal on Oct 25, 2018\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 5', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'm1', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.FIFTH, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Medium Plan")).toBeInTheDocument(); - expect( - screen.getByText('These plan changes will take place on Oct 25, 2018.') - ).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(6); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Medium/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - expect(screen.queryByTestId('new-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$199/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$484/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$199\*/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/1M/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/1M/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders discount note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$199 for 5 months, then changes to \$484 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 6', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 's1_ac', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.SIXTH, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Small Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Small/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('renders contract row', () => { - renderSimple(); - expect(screen.getByTestId('current-contract')).toHaveTextContent(/annual/); - expect(screen.getByTestId('new-contract')).toHaveTextContent(/monthly/); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$26/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$44/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$26/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$26 for 5 months, then changes to \$44 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 7', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'm1_auf', - billingInterval: 'annual', - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.SEVENTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Medium Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getAllByText(migrationDate, {exact: false})).toHaveLength(2); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Medium/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$2,148/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$5,232/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$2,148/); - }); - - it('renders renewal price row', () => { - renderSimple(); - expect(screen.getByTestId('current-renewal')).toHaveTextContent(/\$2,148/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$5,232/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$4,718/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/1M/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/1M/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders annual dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('annual-dollar-credits')).toHaveTextContent( - /\*Discount of \$3,084 for plan changes on Oct 18, 2017. An additional one-time \$514 discount applies at contract renewal on Oct 25, 2018\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 8', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business', - organization, - }); - subscription.categories.errors!.reserved = 100_000; // test that it renders the correct next reserved values even if it's not the base volume - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.EIGHTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Business Plan")).toBeInTheDocument(); - expect( - screen.getByText('10M spans for easier debugging and performance monitoring') - ).toBeInTheDocument(); - expect( - screen.getByText('Simplified, cheaper pay-as-you-go pricing') - ).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(migrationDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Business/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$89/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$89/); - }); - - it('does not renders renewal price row', () => { - renderSimple(); - expect(screen.queryByTestId('current-renewal')).not.toBeInTheDocument(); - }); - - // TODO(isabella): condense category-specific assertions into a single test - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K errors/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K errors/); - }); - - it('renders spans row', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent( - /100K performance units/ - ); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByText(/Tracing and Performance Monitoring/)).toBeInTheDocument(); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/1 GB/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders replays row', () => { - renderSimple(); - expect(screen.getByTestId('current-replays')).toHaveTextContent(/500 replays/); - expect(screen.getByTestId('new-replays')).toHaveTextContent(/50 replays/); - }); - - it('renders monitor seats row', () => { - renderSimple(); - expect(screen.getByTestId('current-monitorSeats')).toHaveTextContent( - /1 cron monitor/ - ); - expect(screen.getByTestId('new-monitorSeats')).toHaveTextContent(/1 cron monitor/); - }); - - it('does not render profile duration row', () => { - renderSimple(); - expect(screen.queryByTestId('current-profileDuration')).not.toBeInTheDocument(); - expect(screen.queryByTestId('new-profileDuration')).not.toBeInTheDocument(); - }); - - it('renders replays credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*We'll provide an additional 450 replays for the next 2 monthly usage cycles after your plan is upgraded, at no charge./ - ); - }); -}); - -describe('PlanMigrationActive cohort 9', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business_auf'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business_auf', - contractInterval: ANNUAL, - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.NINTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Business Plan")).toBeInTheDocument(); - expect( - screen.getByText('10M spans for easier debugging and performance monitoring') - ).toBeInTheDocument(); - expect( - screen.getByText('Simplified, cheaper pay-as-you-go pricing') - ).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(migrationDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Business/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$960/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$960/); - }); - - it('does not renders renewal price row', () => { - renderSimple(); - expect(screen.queryByTestId('current-renewal')).not.toBeInTheDocument(); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/50K errors/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/50K errors/); - }); - - it('renders spans row', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent( - /100K performance units/ - ); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByText(/Tracing and Performance Monitoring/)).toBeInTheDocument(); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/1 GB/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders replays row', () => { - renderSimple(); - expect(screen.getByTestId('current-replays')).toHaveTextContent(/500 replays/); - expect(screen.getByTestId('new-replays')).toHaveTextContent(/50 replays/); - }); - - it('renders monitor seats row', () => { - renderSimple(); - expect(screen.getByTestId('current-monitorSeats')).toHaveTextContent( - /1 cron monitor/ - ); - expect(screen.getByTestId('new-monitorSeats')).toHaveTextContent(/1 cron monitor/); - }); - - it('renders replays credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*We'll provide an additional 450 replays for the next 2 months following the end of your current annual contract, at no charge./ - ); - }); -}); - -describe('PlanMigrationActive cohort 10', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business_auf'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business_auf', - contractInterval: ANNUAL, - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.TENTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Business Plan")).toBeInTheDocument(); - expect( - screen.getByText('10M spans for easier debugging and performance monitoring') - ).toBeInTheDocument(); - expect( - screen.getByText('Simplified, cheaper pay-as-you-go pricing') - ).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(migrationDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Business/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$960/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$960/); - }); - - it('does not renders renewal price row', () => { - renderSimple(); - expect(screen.queryByTestId('current-renewal')).not.toBeInTheDocument(); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/50K errors/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/50K errors/); - }); - - it('renders spans row', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent( - /100K performance units/ - ); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByText(/Tracing and Performance Monitoring/)).toBeInTheDocument(); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/1 GB/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders replays row', () => { - renderSimple(); - expect(screen.getByTestId('current-replays')).toHaveTextContent(/500 replays/); - expect(screen.getByTestId('new-replays')).toHaveTextContent(/50 replays/); - }); - - it('renders monitor seats row', () => { - renderSimple(); - expect(screen.getByTestId('current-monitorSeats')).toHaveTextContent( - /1 cron monitor/ - ); - expect(screen.getByTestId('new-monitorSeats')).toHaveTextContent(/1 cron monitor/); - }); - - it('renders replays credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*You'll retain the same monthly replay quota throughout the remainder of your annual subscription./ - ); - }); -}); - -describe('PlanMigrationActive cohort 111 -- TEST ONLY', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'am3_business_auf', - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.TEST_ONE, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders combined credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*We'll provide an additional 100000 errors for the next 1 months, 100000 replays for the next 1 months, and 100000 spans for the next 1 months following the end of your current annual contract, at no charge./ - ); - }); - - it('renders spans row with existing spans', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect( - screen.queryByText(/Tracing and Performance Monitoring/) - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx deleted file mode 100644 index 3c154c49e9227c..00000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import {useEffect} from 'react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {Button} from '@sentry/scraps/button'; -import {ExternalLink} from '@sentry/scraps/link'; - -import {Panel} from 'sentry/components/panels/panel'; -import {IconBusiness} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {ConfigStore} from 'sentry/stores/configStore'; -import {showIntercom} from 'sentry/utils/intercom'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -import {ZendeskLink} from 'getsentry/components/zendeskLink'; -import {ANNUAL} from 'getsentry/constants'; -import {CohortId, type PlanMigration, type Subscription} from 'getsentry/types'; -import {trackGetsentryAnalytics} from 'getsentry/utils/trackGetsentryAnalytics'; -import {PanelBodyWithTable} from 'getsentry/views/subscriptionPage/styles'; - -import {PlanMigrationTable} from './planMigrationTable'; - -type Props = { - migration: undefined | PlanMigration; - subscription: Subscription; -}; - -function NewFeature({title}: {title: string}) { - return ( - - - {title} - - ); -} - -function getMigrationDate(migration: PlanMigration, subscription: Subscription) { - if (migration.effectiveAt) { - return moment(migration.effectiveAt).format('ll'); - } - if (subscription.billingInterval === ANNUAL) { - return moment(subscription.onDemandPeriodEnd).add(1, 'days').format('ll'); - } - return moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); -} - -export function PlanMigrationActive({subscription, migration}: Props) { - const organization = useOrganization(); - const hasIntercom = organization.features.includes('intercom-support'); - const shouldRender = Boolean(migration?.cohort?.nextPlan); - - useEffect(() => { - if (shouldRender && hasIntercom) { - trackGetsentryAnalytics('intercom_link.viewed', { - organization, - source: 'billing', - }); - } - }, [shouldRender, hasIntercom, organization]); - - if (!migration?.cohort?.nextPlan) { - return null; - } - - async function handleIntercomClick() { - trackGetsentryAnalytics('intercom_link.clicked', { - organization, - source: 'billing', - }); - try { - await showIntercom(organization.slug); - } catch { - const supportEmail = ConfigStore.get('supportEmail'); - if (supportEmail) { - window.location.href = `mailto:${supportEmail}?subject=${window.encodeURIComponent('Legacy Plan Migration Question')}`; - } - } - } - - const supportLink = hasIntercom ? ( - - ) : ( - - ); - - const isAM3Migration = migration.cohort.cohortId >= CohortId.EIGHTH; - - return ( - - - - -

- {tct("We're updating our [planName] Plan", { - planName: subscription.planDetails.name, - })} -

-

- {tct('These plan changes will take place on [date].', { - date: getMigrationDate(migration, subscription), - })} -

-
-
{t('New Features:')}
- {isAM3Migration ? ( -

- - - -

- ) : ( -

- - - - -

- )} -
-
- - - {tct( - 'For more details please see our [faqLink:FAQ] or contact [supportLink:Support].', - { - faqLink: ( - - ), - supportLink, - } - )} - -
- -
-
- ); -} - -const StyledPanelBody = styled(PanelBodyWithTable)` - h6 { - font-weight: 400; - font-size: ${p => p.theme.font.size.lg}; - margin-bottom: ${p => p.theme.space.sm}; - } - - table { - margin-bottom: ${p => p.theme.space.md}; - } - - p, - h4 { - margin: 0; - } -`; - -const MigrationDetailsWithFooter = styled('div')` - display: grid; - grid-auto-flow: row; - align-content: space-between; -`; - -const MigrationDetails = styled('div')` - display: grid; - gap: ${p => p.theme.space['2xl']}; -`; - -const Feature = styled('span')` - display: grid; - grid-template-columns: max-content auto; - gap: ${p => p.theme.space.md}; - align-items: center; - align-content: center; -`; - -const MoreInfo = styled('p')` - font-size: ${p => p.theme.font.size.sm}; -`; diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx deleted file mode 100644 index ad498ad7c25063..00000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {DataCategoryExact} from 'sentry/types/core'; - -import {PlanMigrationRow} from 'getsentry/views/subscriptionPage/planMigrationActive/planMigrationRow'; - -function renderRow(props: React.ComponentProps) { - return render( - - - - -
- ); -} - -describe('PlanMigrationRow', () => { - it.each([ - ['attachment', DataCategoryExact.ATTACHMENT, 'attachments'], - ['log_byte', DataCategoryExact.LOG_BYTE, 'logBytes'], - ['trace_metric_byte', DataCategoryExact.TRACE_METRIC_BYTE, 'traceMetricBytes'], - ])( - 'renders byte category %s with GB suffix and no appended display name', - (_label, category, testIdSuffix) => { - renderRow({type: category, currentValue: 10, nextValue: 20}); - - const currentCell = screen.getByTestId(`current-${testIdSuffix}`); - const newCell = screen.getByTestId(`new-${testIdSuffix}`); - - expect(currentCell).toHaveTextContent(/GB$/); - expect(newCell).toHaveTextContent(/GB$/); - } - ); - - it('renders PROFILE_DURATION with hours suffix', () => { - renderRow({ - type: DataCategoryExact.PROFILE_DURATION, - currentValue: 1, - nextValue: 5, - }); - - const currentCell = screen.getByTestId('current-profileDuration'); - const newCell = screen.getByTestId('new-profileDuration'); - - expect(currentCell).toHaveTextContent(/hour$/); - expect(newCell).toHaveTextContent(/hours$/); - }); - - it('renders count category with display name', () => { - renderRow({ - type: DataCategoryExact.ERROR, - currentValue: 50000, - nextValue: 100000, - }); - - const currentCell = screen.getByTestId('current-errors'); - const newCell = screen.getByTestId('new-errors'); - - expect(currentCell).not.toHaveTextContent(/GB/); - expect(newCell).not.toHaveTextContent(/GB/); - expect(currentCell).toHaveTextContent(/errors?$/i); - expect(newCell).toHaveTextContent(/errors?$/i); - }); -}); diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx deleted file mode 100644 index 3972d596d1557d..00000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import styled from '@emotion/styled'; - -import {DATA_CATEGORY_INFO} from 'sentry/constants'; -import {IconArrow} from 'sentry/icons'; -import {tct} from 'sentry/locale'; -import {DataCategoryExact} from 'sentry/types/core'; - -import {formatReservedWithUnits} from 'getsentry/utils/billing'; -import {displayPrice} from 'getsentry/views/amCheckout/utils'; - -type Props = DataRow | PriceRow | RenewalPriceRow | PlanRow | ContractRow; - -type DataRow = { - currentValue: number | null; - nextValue: number | null; - type: DataCategoryExact; - hasCredits?: boolean; - previousType?: DataCategoryExact; - titleOverride?: string; -}; - -type PriceRow = { - currentValue: number; - discountPrice: number; - nextValue: number; - type: 'price'; - hasCredits?: boolean; -}; - -// RenewalPriceRow shown for AUF plans only to differentiate between the first discount at the migration and second discounts at annual contract renewal -type RenewalPriceRow = { - currentValue: number; - discountPrice: number; - nextValue: number; - type: 'renewal'; - hasCredits?: boolean; -}; - -type PlanRow = { - currentValue: string; - nextValue: string; - type: 'plan'; -}; - -type ContractRow = { - currentValue: string; - nextValue: string; - type: 'contract'; -}; - -function formatCategoryRowString( - category: DataCategoryExact, - quantity: number | null, - options: {isAbbreviated: boolean} -): string { - const reservedWithUnits = formatReservedWithUnits( - quantity, - DATA_CATEGORY_INFO[category].plural, - options - ); - if (DATA_CATEGORY_INFO[category].formatting.unitType === 'bytes') { - return reservedWithUnits; - } - - if (category === DataCategoryExact.PROFILE_DURATION) { - const postfix = reservedWithUnits === '1' ? 'hour' : 'hours'; - return `${reservedWithUnits} ${postfix}`; - } - - if (category === DataCategoryExact.TRANSACTION) { - return `${reservedWithUnits} performance units`; - } - - const displayName = DATA_CATEGORY_INFO[category].displayName; - const plural = `${displayName}s`; - return `${reservedWithUnits} ${quantity === 1 ? displayName : plural}`; -} - -export function PlanMigrationRow(props: Props) { - let currentValue: React.ReactNode; - let nextValue: React.ReactNode; - let discountPrice: string | undefined; - let currentTitle: React.ReactNode = - DATA_CATEGORY_INFO[props.type as DataCategoryExact]?.productName ?? props.type; - const dataTestIdSuffix: string = - DATA_CATEGORY_INFO[props.type as DataCategoryExact]?.plural ?? props.type; - - const options = {isAbbreviated: true}; - - // TODO(data categories): BIL-955 - switch (props.type) { - case 'plan': - currentValue = tct('Legacy [currentValue]', {currentValue: props.currentValue}); - nextValue = props.nextValue; - break; - case 'contract': - currentValue = props.currentValue; - nextValue = props.nextValue; - break; - case 'price': - currentValue = displayPrice({cents: props.currentValue}); - discountPrice = displayPrice({cents: props.discountPrice}); - nextValue = displayPrice({cents: props.nextValue}); - break; - case 'renewal': - currentValue = displayPrice({cents: props.currentValue}); - discountPrice = displayPrice({cents: props.discountPrice}); - nextValue = displayPrice({cents: props.nextValue}); - currentTitle = 'renewal price'; - break; - default: { - // assume DataCategoryExact - currentValue = formatCategoryRowString( - props.previousType ?? props.type, - props.currentValue, - options - ); - const formattedNextValue = formatCategoryRowString( - props.type, - props.nextValue, - options - ); - nextValue = props.hasCredits ? `${formattedNextValue}*` : formattedNextValue; - if (props.titleOverride) { - currentTitle = props.titleOverride; - } - break; - } - } - - const hasDiscount = - (props.type === 'price' || props.type === 'renewal') && props.hasCredits; - - return ( - - {currentTitle} - {currentValue} - - - - {hasDiscount ? ( - - {nextValue} - {`${discountPrice}*`} - - ) : ( - {nextValue} - )} - - ); -} - -const Title = styled('td')` - text-transform: capitalize; -`; - -const DiscountCell = styled('td')` - display: flex; - gap: ${p => p.theme.space.md}; - justify-content: flex-end; -`; - -const DiscountedPrice = styled('span')` - text-decoration: line-through; - font-weight: 400; -`; diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx deleted file mode 100644 index d368e9971d94ba..00000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {DATA_CATEGORY_INFO} from 'sentry/constants'; -import {t, tct} from 'sentry/locale'; -import {DataCategory, DataCategoryExact} from 'sentry/types/core'; -import {oxfordizeArray} from 'sentry/utils/oxfordizeArray'; - -import {ANNUAL, MONTHLY} from 'getsentry/constants'; -import { - CohortId, - type NextPlanInfo, - type PlanMigration, - type Subscription, -} from 'getsentry/types'; -import {getCategoryInfoFromPlural} from 'getsentry/utils/dataCategory'; -import {displayPrice} from 'getsentry/views/amCheckout/utils'; -import {AlertStripedTable} from 'getsentry/views/subscriptionPage/styles'; - -import {PlanMigrationRow} from './planMigrationRow'; - -type Props = { - migration: PlanMigration; - subscription: Subscription; -}; - -export function PlanMigrationTable({subscription, migration}: Props) { - if (!migration?.cohort?.nextPlan) { - return null; - } - - // migrations from AM1/AM2 to AM3 - const isAM3Migration = - migration.cohort.cohortId >= CohortId.EIGHTH && - migration.cohort.cohortId <= CohortId.TENTH; - - const planName = subscription.planDetails.name; - const planPrice = subscription.planDetails.price; - - const planTerm = subscription.planDetails.contractInterval; - const cohort = migration.cohort; - const nextPlan = cohort.nextPlan!; - const secondDiscount = cohort.secondDiscount; - // Setting default to monthly to handle nextPlan if the endpoint update is not updated yet - // Prior plan migrations are all monthly contracts - const nextPlanTerm = nextPlan.contractPeriod ?? MONTHLY; - // The nextPlan.discountAmount is handled differently for monthly & annual billing intervals. Using these checks to display correct info - const hasMonthlyDiscount = !!( - nextPlan.discountAmount && - nextPlan.discountMonths && - subscription.billingInterval === MONTHLY - ); - const hasAnnualDiscount = !!( - nextPlan.discountAmount && - nextPlan.discountMonths && - subscription.billingInterval === ANNUAL - ); - const hasSecondDiscount = !!(secondDiscount && hasAnnualDiscount); - const annualMigrationDate = migration.effectiveAt - ? moment(migration.effectiveAt).format('ll') - : moment(subscription.onDemandPeriodEnd).add(1, 'days').format('ll'); - - const getRowParamsForCategory = (category: DataCategory) => { - // for AM1/AM2 to AM3 migrations, we move from transactions-based billing to spans-based billing - // so we render the row as a transition from reserved transactions volume to reserved spans volume - const isSpans = category === DataCategory.SPANS; - const shouldShowCurrentSpans = - isSpans && !!subscription.categories[category]?.reserved; - const isTransactionsToSpansMigration = isSpans && !shouldShowCurrentSpans; - - const currentValue = isTransactionsToSpansMigration - ? (subscription.categories.transactions?.reserved ?? null) - : (subscription.categories[category]?.reserved ?? null); - const titleOverride = isTransactionsToSpansMigration - ? t('Tracing and Performance Monitoring') - : undefined; - const previousType = isTransactionsToSpansMigration - ? DataCategoryExact.TRANSACTION - : undefined; - - const categoryInfo = getCategoryInfoFromPlural(category); - if (!categoryInfo) { - return null; - } - - const type = categoryInfo.name; - - const nextValue = getNextDataCategoryValue( - nextPlan, - isAM3Migration, // update this if shouldUseExistingVolume should be true for future migrations - type, - subscription - ); - - return { - type, - previousType, - currentValue, - nextValue, - hasCredits: !!nextPlan.categoryCredits?.[category]?.credits, - titleOverride, - }; - }; - - const sortRowParamMappings = ( - rowParamsMapping: Array> - ) => { - return rowParamsMapping - .filter(rowParams => !!rowParams) - .sort((a, b) => { - // sort based on order of the categories in the subscription's current plan - // if previousType exists, we need to use that since it means we're migrating - // from a category on the subscription's current plan that won't be available - // in the new plan - const aCategoryExact = a?.previousType ?? a?.type; - const bCategoryExact = b?.previousType ?? b?.type; - const aCategory = aCategoryExact - ? DATA_CATEGORY_INFO[aCategoryExact]?.plural - : null; - const bCategory = bCategoryExact - ? DATA_CATEGORY_INFO[bCategoryExact]?.plural - : null; - const aOrder = aCategory - ? (subscription.categories[aCategory]?.order ?? Infinity) - : Infinity; - const bOrder = bCategory - ? (subscription.categories[bCategory]?.order ?? Infinity) - : Infinity; - return aOrder - bOrder; - }); - }; - - const getCategoryRows = () => { - const rowParamsMapping = Object.entries(nextPlan.reserved) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([category, _]) => getRowParamsForCategory(category as DataCategory)); - - return sortRowParamMappings(rowParamsMapping).map(rowParams => ( - - )); - }; - - return ( - - - - - - {t('Current')} - - {t('New')} - - - - - {planTerm !== nextPlanTerm && ( - - )} - - {hasAnnualDiscount && ( - - )} - {getCategoryRows()} - - - {hasMonthlyDiscount && ( - - * - {tct( - '[currentPrice] for [discountMonths] months, then changes to [nextPrice] per month on [discountEndDate].', - { - currentPrice: displayPrice({cents: subscription.planDetails.price}), - discountMonths: nextPlan.discountMonths, - nextPrice: displayPrice({cents: nextPlan.totalPrice}), - discountEndDate: moment(subscription.contractPeriodEnd) - .add(nextPlan.discountMonths, 'months') - .add(1, 'days') - .format('ll'), - } - )} - - )} - {hasAnnualDiscount && ( - - * - {tct('Discount of [firstDiscount] for plan changes on [migrationDate].', { - migrationDate: annualMigrationDate, - firstDiscount: displayPrice({ - cents: nextPlan.totalPrice - subscription.planDetails.price, - }), - })} - {hasSecondDiscount && - tct( - ' An additional one-time [secondDiscount] discount applies at contract renewal on [contractRenewalDate].', - { - secondDiscount: displayPrice({cents: secondDiscount}), - contractRenewalDate: moment(subscription.contractPeriodEnd) - .add(1, 'days') - .format('ll'), - } - )} - - )} - {getCategoryCredits(migration.cohort.cohortId, nextPlan)} - - ); -} - -function getNextDataCategoryValue( - nextPlan: NextPlanInfo, - shouldUseExistingVolume: boolean, - category: DataCategoryExact, - subscription: Subscription -) { - const key = DATA_CATEGORY_INFO[category].plural as DataCategory; - if ( - shouldUseExistingVolume && - subscription.planDetails.categories.includes(key) && - subscription.categories[key]?.reserved !== - subscription.planDetails.planCategories[key]![0]!.events - ) { - return subscription.categories[key]!.reserved; - } - return nextPlan.reserved[key] ?? null; -} - -function getCategoryCredits(cohortId: CohortId, nextPlan: NextPlanInfo) { - if (!nextPlan.categoryCredits) { - return null; - } - - let message: string; - if (cohortId === CohortId.TENTH) { - message = - "You'll retain the same monthly replay quota throughout the remainder of your annual subscription."; - } else { - const categoryCredits = nextPlan.categoryCredits; - - message = "We'll provide an additional "; - const isAnnualNextPlan = nextPlan.contractPeriod === 'annual'; - - const creditsToDisplay: string[] = []; - - Object.entries(categoryCredits) - .filter(([_, creditInfo]) => creditInfo.credits !== null) - .forEach(([category, creditInfo]) => { - const {credits, months} = creditInfo; - if (credits !== 0 && months !== 0) { - creditsToDisplay.push( - `${credits} ${category} for the next ${months} ${isAnnualNextPlan ? 'months' : 'monthly usage cycles'}` - ); - } - }); - - message += oxfordizeArray(creditsToDisplay); - - if (nextPlan.contractPeriod === 'annual') { - message += ' following the end of your current annual contract'; - } else { - message += ' after your plan is upgraded'; - } - message += ', at no charge.'; - } - - return ( - *{tct('[message]', {message})} - ); -} - -const TableContainer = styled('div')` - display: grid; - grid-auto-flow: row; - align-content: space-between; -`; - -const Credits = styled('p')` - font-size: ${p => p.theme.font.size.sm}; - color: ${p => p.theme.tokens.content.secondary}; -`; diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx index 5e81c3e1ed60b2..e439dc16aede2a 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx @@ -35,12 +35,6 @@ describe('SubscriptionHeader', () => { url: '/organizations/org-slug/promotions/trigger-check/', method: 'POST', }); - MockApiClient.addMockResponse({ - url: '/customers/org-slug/plan-migrations/', - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/prompts-activity/', body: {}, diff --git a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx index 45e83f37754ac9..0892c3aeb2153d 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx @@ -15,21 +15,10 @@ describe('SubscriptionUpsellBanner', () => { url: '/organizations/org-slug/prompts-activity/', body: promptResponse, }); - MockApiClient.addMockResponse({ - url: '/customers/org-slug/plan-migrations/?applied=0', - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: '/customers/org-slug/', body: {}, }); - MockApiClient.addMockResponse({ - url: '/customers/org-slug/plan-migrations/', - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); }); it('should render banner for users on free plan with billing access', async () => { diff --git a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx index 8c40ac8b3fc22b..0997f509582fb0 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx @@ -11,7 +11,6 @@ import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; import UpgradeOrTrialButton from 'getsentry/components/upgradeOrTrialButton'; -import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations'; import type {Subscription} from 'getsentry/types'; import { hasPartnerMigrationFeature, @@ -78,16 +77,9 @@ function useIsSubscriptionUpsellHidden( subscription: Subscription, organization: Organization ): boolean { - const {planMigrations, isLoading} = usePlanMigrations(); - // Hide while loading - if (isLoading) { - return true; - } - - // hide upsell for mmx plans and forced plan migrations + // hide upsell for mmx plans const isLegacyUpsell = - (!hasPerformance(subscription.planDetails) || planMigrations.length > 0) && - !subscription.canTrial; + !hasPerformance(subscription.planDetails) && !subscription.canTrial; // hide upsell for customers on partner plans with flag const hasEndingPartnerPlan = hasPartnerMigrationFeature(organization); diff --git a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx index 0875883f841718..d6c1a7f9334730 100644 --- a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx @@ -49,12 +49,6 @@ describe('Subscription > UsageHistory', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/prompts-activity/`, body: {}, diff --git a/static/gsApp/views/subscriptionPage/usageLog.spec.tsx b/static/gsApp/views/subscriptionPage/usageLog.spec.tsx index 302bc2e173f5f1..add29c11be71e5 100644 --- a/static/gsApp/views/subscriptionPage/usageLog.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageLog.spec.tsx @@ -39,12 +39,6 @@ describe('Subscription Usage Log', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/recurring-credits/`, method: 'GET', diff --git a/tests/js/getsentry-test/fixtures/planMigration.ts b/tests/js/getsentry-test/fixtures/planMigration.ts deleted file mode 100644 index de037a5cc5cb2d..00000000000000 --- a/tests/js/getsentry-test/fixtures/planMigration.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type {Cohort, PlanMigration as PlanMigrationType} from 'getsentry/types'; -import {CohortId} from 'getsentry/types'; - -const SecondCohort: Cohort = { - cohortId: CohortId.SECOND, - nextPlan: { - id: 'am1_team', - name: 'Team', - totalPrice: 4400, - - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 1500, - discountMonths: 5, - contractPeriod: 'monthly', - categoryCredits: { - errors: { - credits: 0, - months: 0, - }, - }, - }, - secondDiscount: 0, -}; - -const ThirdCohort: Cohort = { - cohortId: CohortId.THIRD, - nextPlan: { - id: 'am2_team', - name: 'Team', - totalPrice: 4400, - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 1800, - discountMonths: 5, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const FourthCohort: Cohort = { - cohortId: CohortId.FOURTH, - nextPlan: { - id: 'am2_team', - name: 'Team', - totalPrice: 48000, - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 16800, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 2800, -}; - -const FifthCohort: Cohort = { - cohortId: CohortId.FIFTH, - nextPlan: { - id: 'am2_business', - name: 'Business', - totalPrice: 48400, - reserved: {errors: 1_000_000, transactions: 100000, attachments: 1}, - discountAmount: 28500, - discountMonths: 5, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const SixthCohort: Cohort = { - cohortId: CohortId.SIXTH, - nextPlan: { - id: 'am2_team', - name: 'Team', - totalPrice: 4400, - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 1800, - discountMonths: 5, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const SeventhCohort: Cohort = { - cohortId: CohortId.SEVENTH, - nextPlan: { - id: 'am2_business', - name: 'Business', - totalPrice: 523200, - reserved: {errors: 1_000_000, transactions: 100000, attachments: 1}, - discountAmount: 308400, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 51400, -}; - -const EighthCohort: Cohort = { - cohortId: CohortId.EIGHTH, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 89_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - replays: { - credits: 450, - months: 2, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const NinthCohort: Cohort = { - cohortId: CohortId.NINTH, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 960_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - replays: { - credits: 450, - months: 2, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 0, -}; - -const TenthCohort: Cohort = { - cohortId: CohortId.TENTH, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 960_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - replays: { - credits: 450, - months: 2, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 0, -}; - -const testOneCohort: Cohort = { - cohortId: CohortId.TEST_ONE, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 960_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - errors: { - credits: 100_000, - months: 1, - }, - replays: { - credits: 100_000, - months: 1, - }, - spans: { - credits: 100_000, - months: 1, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 0, -}; - -const CohortLookup: Record = { - [CohortId.SECOND]: SecondCohort, - [CohortId.THIRD]: ThirdCohort, - [CohortId.FOURTH]: FourthCohort, - [CohortId.FIFTH]: FifthCohort, - [CohortId.SIXTH]: SixthCohort, - [CohortId.SEVENTH]: SeventhCohort, - [CohortId.EIGHTH]: EighthCohort, - [CohortId.NINTH]: NinthCohort, - [CohortId.TENTH]: TenthCohort, - [CohortId.TEST_ONE]: testOneCohort, -}; - -export function PlanMigrationFixture({ - cohortId, - ...params -}: {cohortId: CohortId} & Partial): PlanMigrationType { - return { - id: 1, - cohort: CohortLookup[cohortId] ?? null, - dateApplied: null, - planTier: 'am2', - scheduled: false, - effectiveAt: '', - recurringCredits: [], - ...params, - }; -} diff --git a/tests/js/sentry-test/snapshots/snapshot-framework.ts b/tests/js/sentry-test/snapshots/snapshot-framework.ts index 0c7f275eb8e4eb..6284fa4321ff62 100644 --- a/tests/js/sentry-test/snapshots/snapshot-framework.ts +++ b/tests/js/sentry-test/snapshots/snapshot-framework.ts @@ -1,11 +1,13 @@ import type {ReactElement} from 'react'; import {closeBrowser, takeSnapshot} from './snapshot'; +import type {SnapshotTestMetadata} from './snapshot-image-metadata'; interface SnapshotDetails { displayName: string; fileSlug: string; group: string | null; + theme: string | undefined; } /** @@ -26,20 +28,23 @@ function parseSnapshotDetails(testName: string, fallbackName: string): SnapshotD displayName: fallbackName, fileSlug: fallbackName.toLowerCase(), group: null, + theme: undefined, }; } - const group = parts[0]!.trim().replace(/\s+/g, '/'); + const ancestry = parts[0]!.trim(); + const group = ancestry.replace(/\s+/g, '/'); const displayName = parts[1]!.trim(); const fileSlug = `${group}/${displayName}`.replace(/\s+/g, '').toLowerCase(); + const themeMatch = ancestry.match(/\b(light|dark)\b/); - return {displayName, fileSlug, group}; + return {displayName, fileSlug, group, theme: themeMatch?.[1]}; } function snapshotTest( name: string, renderFn: () => ReactElement, - metadata: Record = {} + metadata: SnapshotTestMetadata = {} ): void { test(`snapshot: ${name}`, async () => { const {testPath, currentTestName} = expect.getState(); @@ -47,17 +52,15 @@ function snapshotTest( throw new Error('Could not determine test file path'); } - const {displayName, fileSlug, group} = parseSnapshotDetails( - currentTestName ?? '', - name - ); + const details = parseSnapshotDetails(currentTestName ?? '', name); await takeSnapshot({ - fileSlug, - displayName, + fileSlug: details.fileSlug, + displayName: details.displayName, renderFn, testFilePath: testPath, - group, + group: details.group, + theme: details.theme, metadata, }); }); @@ -67,7 +70,7 @@ snapshotTest.each = function snapshotEach(table: T[]) { return ( name: string, renderFn: (value: T) => ReactElement, - metadataFn?: (value: T) => Record + metadataFn?: (value: T) => SnapshotTestMetadata ) => { for (const value of table) { const testName = name.replace('%s', String(value)); @@ -91,7 +94,7 @@ declare global { ) => ( name: string, renderFn: (value: T) => ReactElement, - metadataFn?: (value: T) => Record + metadataFn?: (value: T) => SnapshotTestMetadata ) => void; }; } diff --git a/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts b/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts index c4533ab34e45c9..2689f37b1ba31a 100644 --- a/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts +++ b/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts @@ -5,5 +5,16 @@ export interface SnapshotImageMetadata { test_file_path: string; }; group?: string | null; + tags?: Record; // Skip height, width and image_file_name as they're handled by the CLI } + +type SnapshotArea = 'core' | 'snapshots'; + +type SnapshotTags = {area: SnapshotArea} & Record; + +export interface SnapshotTestMetadata { + display_name?: string; + group?: string; + tags?: SnapshotTags; +} diff --git a/tests/js/sentry-test/snapshots/snapshot.ts b/tests/js/sentry-test/snapshots/snapshot.ts index bda0db759307c5..a47108633ac4b3 100644 --- a/tests/js/sentry-test/snapshots/snapshot.ts +++ b/tests/js/sentry-test/snapshots/snapshot.ts @@ -10,7 +10,10 @@ import {CacheProvider} from '@emotion/react'; import createEmotionServer from '@emotion/server/create-instance'; import {chromium, type Browser} from 'playwright'; -import type {SnapshotImageMetadata} from 'sentry-test/snapshots/snapshot-image-metadata'; +import type { + SnapshotImageMetadata, + SnapshotTestMetadata, +} from 'sentry-test/snapshots/snapshot-image-metadata'; const PROJECT_ROOT = path.resolve(__dirname, '../../../..'); const FONTS_DIR = path.resolve(PROJECT_ROOT, 'static/fonts'); @@ -101,9 +104,10 @@ interface TakeSnapshotOptions { displayName: string; fileSlug: string; group: string | null; - metadata: Record; + metadata: SnapshotTestMetadata; renderFn: () => ReactElement; testFilePath: string; + theme: string | undefined; } export async function takeSnapshot({ @@ -112,6 +116,7 @@ export async function takeSnapshot({ renderFn, testFilePath, group, + theme, metadata, }: TakeSnapshotOptions): Promise { const element = renderFn(); @@ -142,10 +147,16 @@ export async function takeSnapshot({ mkdirSync(outputDir, {recursive: true}); } + const autoTags: Record = {}; + if (theme) { + autoTags.theme = theme; + } + const tags = {...autoTags, ...metadata.tags}; + const meta: SnapshotImageMetadata = { - display_name: displayName, - group, - ...metadata, + display_name: metadata.display_name ?? displayName, + group: metadata.group ?? group, + tags: Object.keys(tags).length > 0 ? tags : undefined, context: {test_file_path: relativePath}, }; diff --git a/tests/sentry/api/endpoints/test_oauth_userinfo.py b/tests/sentry/api/endpoints/test_oauth_userinfo.py index 253eefeac240ba..66ebe46a076876 100644 --- a/tests/sentry/api/endpoints/test_oauth_userinfo.py +++ b/tests/sentry/api/endpoints/test_oauth_userinfo.py @@ -1,9 +1,12 @@ import datetime +import hashlib from django.urls import reverse from django.utils import timezone from rest_framework.test import APIClient +from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus +from sentry.models.apitoken import ApiToken from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test @@ -147,3 +150,52 @@ def test_gets_multiple_scopes(self) -> None: # openid information assert response.data["sub"] == str(self.user.id) + + def test_resolves_token_by_hash_when_plaintext_cleared(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["openid"]) + plaintext = token.token + expected_hash = hashlib.sha256(plaintext.encode()).hexdigest() + assert token.hashed_token == expected_hash + + # Scramble the plaintext column so only the hashed path can match + ApiToken.objects.filter(id=token.id).update(token=f"scrambled-{plaintext[:50]}") + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {plaintext}") + response = self.client.get(self.path) + + assert response.status_code == 200 + assert response.data["sub"] == str(self.user.id) + + def test_rejects_token_for_inactive_user(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["openid"]) + self.user.update(is_active=False) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + response = self.client.get(self.path) + + assert response.status_code == 401 + assert response.data["error"] == "invalid_token" + + def test_rejects_token_for_suspended_user(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["openid"]) + self.user.update(is_suspended=True) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + response = self.client.get(self.path) + + assert response.status_code == 401 + assert response.data["error"] == "invalid_token" + + def test_rejects_token_for_inactive_application(self) -> None: + app = ApiApplication.objects.create( + name="test-app", + redirect_uris="https://example.com/callback", + ) + token = self.create_user_auth_token(user=self.user, scope_list=["openid"], application=app) + app.update(status=ApiApplicationStatus.inactive) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + response = self.client.get(self.path) + + assert response.status_code == 401 + assert response.data["error"] == "invalid_token" diff --git a/tests/sentry/api/endpoints/test_organization_code_mapping_codeowners.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_codeowners.py similarity index 59% rename from tests/sentry/api/endpoints/test_organization_code_mapping_codeowners.py rename to tests/sentry/integrations/api/endpoints/test_organization_code_mapping_codeowners.py index 072f43a459b28d..4a70980b02e402 100644 --- a/tests/sentry/api/endpoints/test_organization_code_mapping_codeowners.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_codeowners.py @@ -18,6 +18,10 @@ def setUp(self) -> None: self.login_as(user=self.user) + # Restrict project membership so that team assignment controls access. + self.organization.flags.allow_joinleave = False + self.organization.save() + self.team = self.create_team(organization=self.organization, name="Mariachi Band") self.project = self.create_project( organization=self.organization, teams=[self.team], name="Bengal" @@ -61,3 +65,40 @@ def test_codeowner_contents(self, mock_get_codeowner_file: MagicMock) -> None: resp = self.client.get(self.url) assert resp.status_code == 200 assert resp.data == GITHUB_CODEOWNER + + @patch( + "sentry.integrations.github.integration.GitHubIntegration.get_codeowner_file", + return_value=GITHUB_CODEOWNER, + ) + def test_user_without_project_access_cannot_read_codeowners( + self, mock_get_codeowner_file: MagicMock + ) -> None: + outsider = self.create_user() + self.create_member( + organization=self.organization, + user=outsider, + has_global_access=False, + teams=[], + ) + self.login_as(user=outsider) + resp = self.client.get(self.url) + assert resp.status_code == 403 + + @patch( + "sentry.integrations.github.integration.GitHubIntegration.get_codeowner_file", + return_value=GITHUB_CODEOWNER, + ) + def test_user_with_project_access_can_read_codeowners( + self, mock_get_codeowner_file: MagicMock + ) -> None: + insider = self.create_user() + self.create_member( + organization=self.organization, + user=insider, + has_global_access=False, + teams=[self.team], + ) + self.login_as(user=insider) + resp = self.client.get(self.url) + assert resp.status_code == 200 + assert resp.data == GITHUB_CODEOWNER diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 726bdd044bfff4..8f998c61f7e626 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,17 +1,20 @@ from functools import cached_property +from typing import Any from unittest.mock import MagicMock, patch import responses +from django.urls import reverse from requests.exceptions import ReadTimeout from fixtures.bitbucket_server import EXAMPLE_PRIVATE_KEY from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.pipeline import IntegrationPipeline from sentry.models.repository import Repository from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric -from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.identity import Identity, IdentityProvider @@ -476,3 +479,257 @@ def test_extract_source_path_from_source_url(self) -> None: ] for source_url, expected in test_cases: assert installation.extract_source_path_from_source_url(repo, source_url) == expected + + +REQUEST_TOKEN_BODY = "oauth_token=req-token&oauth_token_secret=req-token-secret" +ACCESS_TOKEN_BODY = "oauth_token=valid-token&oauth_token_secret=valid-secret" + + +@control_silo_test +class BitbucketServerApiPipelineTest(APITestCase): + endpoint = "sentry-api-0-organization-pipeline" + method = "post" + + bbs_url = "https://bitbucket.example.com" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def tearDown(self) -> None: + responses.reset() + super().tearDown() + + def _pipeline_url(self) -> str: + return reverse( + self.endpoint, + args=[self.organization.slug, IntegrationPipeline.pipeline_name], + ) + + def _initialize(self) -> Any: + return self.client.post( + self._pipeline_url(), + data={"action": "initialize", "provider": "bitbucket_server"}, + format="json", + ) + + def _advance(self, data: dict[str, Any]) -> Any: + return self.client.post(self._pipeline_url(), data=data, format="json") + + def _submit_config(self, **overrides: Any) -> Any: + data = { + "url": self.bbs_url, + "consumerKey": "sentry-bot", + "privateKey": EXAMPLE_PRIVATE_KEY, + "verifySsl": False, + } + data.update(overrides) + return self._advance(data) + + def _stub_request_token(self, **kwargs: Any) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/request-token", + status=kwargs.pop("status", 200), + content_type="text/plain", + body=kwargs.pop("body", REQUEST_TOKEN_BODY), + **kwargs, + ) + + def _stub_access_token(self, **kwargs: Any) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/access-token", + status=kwargs.pop("status", 200), + content_type="text/plain", + body=kwargs.pop("body", ACCESS_TOKEN_BODY), + **kwargs, + ) + + @responses.activate + def test_initialize_pipeline(self) -> None: + resp = self._initialize() + assert resp.status_code == 200 + assert resp.data["provider"] == "bitbucket_server" + assert resp.data["step"] == "installation_config" + assert resp.data["stepIndex"] == 0 + assert resp.data["totalSteps"] == 2 + assert resp.data["data"] == {} + + @responses.activate + def test_config_step_validation_missing_required_fields(self) -> None: + self._initialize() + resp = self._advance({"url": self.bbs_url}) + assert resp.status_code == 400 + for field in ("consumerKey", "privateKey"): + assert resp.data[field] == ["This field is required."] + + @responses.activate + def test_config_step_validation_invalid_url(self) -> None: + self._initialize() + resp = self._submit_config(url="bitbucket.example.com") + assert resp.status_code == 400 + assert resp.data["url"] == ["Enter a valid URL."] + + @responses.activate + def test_config_step_validation_invalid_private_key(self) -> None: + self._initialize() + resp = self._submit_config(privateKey="hot-garbage") + assert resp.status_code == 400 + assert "PEM format" in resp.data["privateKey"][0] + + @responses.activate + def test_config_step_validation_consumer_key_too_long(self) -> None: + self._initialize() + resp = self._submit_config(consumerKey="x" * 201) + assert resp.status_code == 400 + assert "200 characters" in resp.data["consumerKey"][0] + + @responses.activate + def test_config_step_advance(self) -> None: + self._stub_request_token() + self._initialize() + resp = self._submit_config() + assert resp.status_code == 200 + assert resp.data["status"] == "advance" + assert resp.data["step"] == "oauth_callback" + assert resp.data["stepIndex"] == 1 + assert resp.data["data"]["oauthUrl"] == ( + f"{self.bbs_url}/plugins/servlet/oauth/authorize?oauth_token=req-token" + ) + + @responses.activate + def test_config_step_strips_trailing_slash(self) -> None: + self._stub_request_token() + self._initialize() + resp = self._submit_config(url=f"{self.bbs_url}//") + assert resp.status_code == 200 + assert resp.data["status"] == "advance" + assert resp.data["data"]["oauthUrl"] == ( + f"{self.bbs_url}/plugins/servlet/oauth/authorize?oauth_token=req-token" + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_config_step_request_token_timeout(self, mock_record: MagicMock) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/request-token", + body=ReadTimeout("Read timed out. (read timeout=30)"), + ) + self._initialize() + resp = self._submit_config() + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "request token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric( + mock_record, "Timed out attempting to reach host: bitbucket.example.com" + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_config_step_request_token_fails(self, mock_record: MagicMock) -> None: + self._stub_request_token(status=503, body="") + self._initialize() + resp = self._submit_config() + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "request token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric(mock_record, "") + + @responses.activate + def test_oauth_step_validation_missing_token(self) -> None: + self._stub_request_token() + self._initialize() + self._submit_config() + resp = self._advance({}) + assert resp.status_code == 400 + assert resp.data["oauthToken"] == ["This field is required."] + + @responses.activate + def test_oauth_step_passes_callback_token_as_verifier(self) -> None: + # Bitbucket Server uses the callback's oauth_token as the OAuth 1.0a + # verifier when exchanging for an access token. Confirm the access-token + # request signature contains the value from the callback. + self._stub_request_token() + self._stub_access_token() + self._initialize() + self._submit_config() + self._advance({"oauthToken": "callback-token"}) + + access_token_calls = [ + call + for call in responses.calls + if call.request.url == f"{self.bbs_url}/plugins/servlet/oauth/access-token" + ] + assert len(access_token_calls) == 1 + assert ( + 'oauth_verifier="callback-token"' + in access_token_calls[0].request.headers["Authorization"] + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_oauth_step_access_token_failure(self, mock_record: MagicMock) -> None: + self._stub_request_token() + error_msg = "it broke" + self._stub_access_token(status=500, body=error_msg) + self._initialize() + self._submit_config() + + resp = self._advance({"oauthToken": "callback-token"}) + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "access token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric(mock_record, error_msg) + + @responses.activate + def test_full_pipeline_flow(self) -> None: + self._stub_request_token() + self._stub_access_token() + + resp = self._initialize() + assert resp.data["step"] == "installation_config" + + resp = self._submit_config() + assert resp.data["step"] == "oauth_callback" + + resp = self._advance({"oauthToken": "callback-token"}) + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + integration = Integration.objects.get(provider="bitbucket_server") + assert integration.name == "sentry-bot" + assert integration.external_id == "bitbucket.example.com:sentry-bot" + assert integration.metadata["base_url"] == self.bbs_url + assert integration.metadata["domain_name"] == "bitbucket.example.com" + assert integration.metadata["verify_ssl"] is False + + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + + idp = IdentityProvider.objects.get(type="bitbucket_server") + identity = Identity.objects.get( + idp=idp, user=self.user, external_id="bitbucket.example.com:sentry-bot" + ) + assert identity.data["consumer_key"] == "sentry-bot" + assert identity.data["access_token"] == "valid-token" + assert identity.data["access_token_secret"] == "valid-secret" + assert identity.data["private_key"] == EXAMPLE_PRIVATE_KEY + + @responses.activate + def test_full_pipeline_truncates_external_id(self) -> None: + self._stub_request_token() + self._stub_access_token() + + self._initialize() + long_key = "a-very-long-consumer-key-that-when-combined-with-host-would-overflow" + self._submit_config(consumerKey=long_key) + self._advance({"oauthToken": "callback-token"}) + + integration = Integration.objects.get(provider="bitbucket_server") + assert ( + integration.external_id + == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" + ) diff --git a/tests/sentry/integrations/jira/test_integration.py b/tests/sentry/integrations/jira/test_integration.py index 6ed2a9e2dc9c4f..19829ea177102b 100644 --- a/tests/sentry/integrations/jira/test_integration.py +++ b/tests/sentry/integrations/jira/test_integration.py @@ -1297,6 +1297,112 @@ def test_get_config_data_issue_keys(self) -> None: == "hello world, goodnight, moon" ) + @responses.activate + def test_get_organization_config_uses_projects_list_without_flag(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project", + json=[{"id": "10000", "name": "Project A"}], + ) + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/statuses/search", + json={"values": []}, + ) + + installation = integration.get_installation(self.organization.id) + config = installation.get_organization_config() + + assert config[0]["addDropdown"]["items"] == [ + {"value": "10000", "label": "Project A"}, + ] + assert any( + "rest/api/2/project" in call.request.url and "search" not in call.request.url + for call in responses.calls + ) + + @responses.activate + def test_get_organization_config_uses_paginated_endpoint_with_flag(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={ + "values": [ + {"id": "10000", "name": "Project A"}, + {"id": "10001", "name": "Project B"}, + ], + "maxResults": 50, + "total": 2, + }, + ) + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/statuses/search", + json={"values": []}, + ) + + installation = integration.get_installation(self.organization.id) + with self.feature("organizations:jira-paginated-project-config"): + config = installation.get_organization_config() + + assert config[0]["addDropdown"]["items"] == [ + {"value": "10000", "label": "Project A"}, + {"value": "10001", "label": "Project B"}, + ] + assert any("rest/api/2/project/search" in call.request.url for call in responses.calls) + + @responses.activate + def test_get_organization_config_paginated_api_error_disables_config(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={"errorMessages": ["Something went wrong"]}, + status=500, + ) + + installation = integration.get_installation(self.organization.id) + with self.feature("organizations:jira-paginated-project-config"): + config = installation.get_organization_config() + + assert config[0]["disabled"] is True + assert "Unable to communicate" in config[0]["disabledReason"] + def test_error_fields_from_json_issue_not_found(self) -> None: integration = self.create_provider_integration(provider="jira", name="Example Jira") integration.add_organization(self.organization, self.user) diff --git a/tests/sentry/middleware/test_access_log_middleware.py b/tests/sentry/middleware/test_access_log_middleware.py index 23acd6efdbf538..01048de169a118 100644 --- a/tests/sentry/middleware/test_access_log_middleware.py +++ b/tests/sentry/middleware/test_access_log_middleware.py @@ -88,6 +88,7 @@ def get(self, request): policy="ConcurrentRateLimitAllocationPolicy", quota_used=41, rejection_threshold=40, + throttle_threshold=30, quota_unit="no_units", storage_key="test_storage_key", ) @@ -202,6 +203,7 @@ def get(self, request, organization_context, organization): "snuba_storage_key", "snuba_quota_used", "snuba_rejection_threshold", + "snuba_throttle_threshold", "token_last_characters", "gateway_proxy", ) @@ -265,6 +267,7 @@ def test_access_log_snuba_rate_limited(self) -> None: assert self.captured_logs[0].snuba_storage_key == "test_storage_key" assert self.captured_logs[0].snuba_quota_used == "41" assert self.captured_logs[0].snuba_rejection_threshold == "40" + assert self.captured_logs[0].snuba_throttle_threshold == "30" @all_silo_test diff --git a/tests/sentry/models/test_project.py b/tests/sentry/models/test_project.py index a8d8daf2e56991..20806a95c927c4 100644 --- a/tests/sentry/models/test_project.py +++ b/tests/sentry/models/test_project.py @@ -638,6 +638,28 @@ def test_transfer_to_organization_with_workflow_when_condition_groups(self) -> N assert workflow.organization_id == to_org.id assert when_condition_group.organization_id == to_org.id + def test_transfer_to_organization_updates_workflow_environment(self) -> None: + from_org = self.create_organization() + to_org = self.create_organization() + team = self.create_team(organization=from_org) + project = self.create_project(teams=[team]) + + env = self.create_environment(project=project, name="production") + detector = self.create_detector(project=project) + workflow = self.create_workflow(organization=from_org, environment=env) + self.create_detector_workflow(detector=detector, workflow=workflow) + + project.transfer_to(organization=to_org) + + workflow.refresh_from_db() + + assert workflow.organization_id == to_org.id + assert workflow.environment_id is not None + assert workflow.environment_id != env.id + new_env = Environment.objects.get(id=workflow.environment_id) + assert new_env.organization_id == to_org.id + assert new_env.name == "production" + def test_transfer_to_organization_nulls_detector_owner(self) -> None: from_user = self.create_user() from_org = self.create_organization(owner=from_user) diff --git a/tests/sentry/scm/integration/test_helpers_integration.py b/tests/sentry/scm/integration/test_helpers_integration.py index 3056f8dfb40b63..fc4997ce001c4d 100644 --- a/tests/sentry/scm/integration/test_helpers_integration.py +++ b/tests/sentry/scm/integration/test_helpers_integration.py @@ -73,6 +73,74 @@ def test_fetch_by_provider_and_name_returns_repository(self) -> None: def test_fetch_by_provider_and_external_id_returns_none_for_nonexistent(self) -> None: assert fetch_repository(self.organization.id, ("github", "nonexistent")) is None + def test_fetch_ghe_repo_populates_web_base_url(self) -> None: + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Acme", + external_id="ghe-acme-1", + metadata={ + "domain_name": "github.acme.com/installations/1", + "installation_id": "1", + "installation": {"id": "1", "private_key": "x", "verify_ssl": True}, + }, + ) + RepositoryModel.objects.create( + organization_id=self.organization.id, + name="acme/widget", + provider="integrations:github_enterprise", + external_id="9001", + status=ObjectStatus.ACTIVE, + integration_id=integration.id, + ) + + result = fetch_repository(self.organization.id, ("github_enterprise", "9001")) + + assert result is not None + assert result["provider_name"] == "github_enterprise" + assert result["web_base_url"] == "https://github.acme.com" + + def test_fetch_ghe_cloud_repo_populates_web_base_url(self) -> None: + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Cloud Acme", + external_id="ghe-cloud-acme-1", + metadata={ + "domain_name": "acme-corp.ghe.com", + "installation_id": "2", + "installation": {"id": "2", "private_key": "x", "verify_ssl": True}, + }, + ) + RepositoryModel.objects.create( + organization_id=self.organization.id, + name="acme/widget", + provider="integrations:github_enterprise", + external_id="9002", + status=ObjectStatus.ACTIVE, + integration_id=integration.id, + ) + + result = fetch_repository(self.organization.id, ("github_enterprise", "9002")) + + assert result is not None + assert result["web_base_url"] == "https://acme-corp.ghe.com" + + def test_fetch_non_ghe_repo_web_base_url_is_none(self) -> None: + repo = RepositoryModel.objects.create( + organization_id=self.organization.id, + name="test-org/test-repo", + provider="integrations:github", + external_id="11111", + status=ObjectStatus.ACTIVE, + integration_id=1, + ) + + result = fetch_repository(self.organization.id, repo.id) + + assert result is not None + assert result["web_base_url"] is None + class TestFetchServiceProvider(TestCase): def test_returns_provider_from_map_to_provider(self) -> None: @@ -113,3 +181,83 @@ def test_returns_none_for_nonexistent_integration(self) -> None: } result = fetch_service_provider(self.organization.id, repository) assert result is None + + def test_github_enterprise_returns_github_provider(self) -> None: + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Acme", + external_id="ghe-dispatch-1", + metadata={ + "domain_name": "github.acme.com", + "installation_id": "1", + "installation": {"id": "1", "private_key": "x", "verify_ssl": True}, + }, + ) + + repository: Repository = { + "id": 1, + "integration_id": integration.id, + "name": "acme/widget", + "organization_id": self.organization.id, + "is_active": True, + "external_id": "9001", + "provider_name": "github_enterprise", + "web_base_url": "https://github.acme.com", + } + provider = fetch_service_provider(self.organization.id, repository) + + assert isinstance(provider, GitHubProvider) + + def test_github_enterprise_without_integration_returns_none(self) -> None: + repository: Repository = { + "id": 1, + "integration_id": 99999, + "name": "acme/widget", + "organization_id": self.organization.id, + "is_active": True, + "external_id": "9001", + "provider_name": "github_enterprise", + "web_base_url": "https://github.acme.com", + } + assert fetch_service_provider(self.organization.id, repository) is None + + def test_github_enterprise_client_error_returns_none(self) -> None: + from unittest.mock import patch + + from sentry.shared_integrations.exceptions import IntegrationError + + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Acme", + external_id="ghe-clienterror-1", + metadata={ + "domain_name": "github.acme.com", + "installation_id": "1", + "installation": {"id": "1", "private_key": "x", "verify_ssl": True}, + }, + ) + repository: Repository = { + "id": 1, + "integration_id": integration.id, + "name": "acme/widget", + "organization_id": self.organization.id, + "is_active": True, + "external_id": "9001", + "provider_name": "github_enterprise", + "web_base_url": "https://github.acme.com", + } + + with ( + patch( + "sentry.scm.private.helpers.integration_service.get_integration", + return_value=integration, + ), + patch.object( + type(integration.get_installation(organization_id=self.organization.id)), + "get_client", + side_effect=IntegrationError("boom"), + ), + ): + assert fetch_service_provider(self.organization.id, repository) is None diff --git a/tests/sentry/search/events/builder/test_metrics.py b/tests/sentry/search/events/builder/test_metrics.py index 94e4837e220762..1236d0655da40d 100644 --- a/tests/sentry/search/events/builder/test_metrics.py +++ b/tests/sentry/search/events/builder/test_metrics.py @@ -442,6 +442,31 @@ def test_incorrect_parameter_for_metrics(self) -> None: selected_columns=["transaction", "count_unique(test)"], ) + def test_non_default_tag_in_query_raises(self) -> None: + with pytest.raises(IncompatibleMetricsQuery): + MetricsQueryBuilder( + self.params, + query="http.url:https://example.com", + dataset=Dataset.PerformanceMetrics, + selected_columns=["count()"], + ) + + def test_non_default_tag_in_query_skipped_for_subscription_deletion(self) -> None: + indexer.record( + use_case_id=UseCaseID.TRANSACTIONS, + org_id=self.organization.id, + string="http.url", + ) + MetricsQueryBuilder( + self.params, + query="http.url:https://example.com", + dataset=Dataset.PerformanceMetrics, + selected_columns=["count()"], + config=QueryBuilderConfig( + skip_field_validation_for_entity_subscription_deletion=True, + ), + ) + def test_project_filter(self) -> None: query = MetricsQueryBuilder( self.params, diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 6c3763a9854ea6..5abbaaafc7fb43 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -924,7 +924,7 @@ def test_without_seat_based_tier( run_automation(self.group, self.user, self.event, SeerAutomationSource.POST_PROCESS) mock_trigger.assert_called_once() - assert mock_trigger.call_args[1]["stopping_point"] is None + assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.CODE_CHANGES @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") @patch("sentry.seer.autofix.issue_summary.is_group_triggering_automation", return_value=True) @@ -1277,6 +1277,7 @@ def test_returns_false_when_rate_limited(self, mock_fixability, mock_quota, mock assert is_group_triggering_automation(self.group) is False +@patch("sentry.seer.autofix.issue_summary.is_seer_seat_based_tier_enabled", return_value=True) @with_feature({"organizations:gen-ai-features": True}) class TestGetAutomationStoppingPoint(TestCase): def setUp(self) -> None: @@ -1284,21 +1285,21 @@ def setUp(self) -> None: self.group = self.create_group() @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_default_preference_limits_stopping_point(self, mock_fixability): + def test_default_preference_limits_stopping_point(self, mock_fixability, mock_seat_based_tier): """Unset preference falls back to the well-known default (code_changes).""" mock_fixability.return_value = 0.80 assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.CODE_CHANGES @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_user_preference_limits_stopping_point(self, mock_fixability): + def test_user_preference_limits_stopping_point(self, mock_fixability, mock_seat_based_tier): mock_fixability.return_value = 0.80 self.group.project.update_option("sentry:seer_automated_run_stopping_point", "solution") assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.SOLUTION @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_low_fixability_returns_root_cause(self, mock_fixability): + def test_low_fixability_returns_root_cause(self, mock_fixability, mock_seat_based_tier): mock_fixability.return_value = 0.50 self.group.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") @@ -1306,7 +1307,9 @@ def test_low_fixability_returns_root_cause(self, mock_fixability): @patch("sentry.seer.autofix.issue_summary.read_preference_from_sentry_db") @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_null_stopping_point_uses_fixability_only(self, mock_fixability, mock_read_pref): + def test_null_stopping_point_uses_fixability_only( + self, mock_fixability, mock_read_pref, mock_seat_based_tier + ): """When preference.automated_run_stopping_point is None, fixability score alone drives the result.""" from sentry.seer.models.seer_api_models import SeerProjectPreference diff --git a/tests/sentry/utils/test_cursored_scheduler.py b/tests/sentry/utils/test_cursored_scheduler.py index bc7c3bc4affcf7..df8550bb993cb9 100644 --- a/tests/sentry/utils/test_cursored_scheduler.py +++ b/tests/sentry/utils/test_cursored_scheduler.py @@ -447,6 +447,48 @@ def test_no_recalculation_when_interval_unchanged(self): scheduler.tick() assert self.mock_task.delay.call_count == 10 + def test_shuffle_randomizes_pk_order(self): + """When shuffle=True, PKs are not in ascending order.""" + ois = self._create_org_integrations(30) + sorted_pks = [oi.pk for oi in ois] + + scheduler = CursoredScheduler( + name="test_scheduler", + schedule_key="test-scheduler-beat", + queryset=OrganizationIntegration.objects.filter( + integration__provider="github", + status=ObjectStatus.ACTIVE, + ), + task=self.mock_task, + cycle_duration=timedelta(minutes=3), + shuffle=True, + ) + + # Complete entire cycle to collect all dispatched PKs + all_dispatched: list[int] = [] + while scheduler.tick(): + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + self.mock_task.reset_mock() + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + + assert set(all_dispatched) == set(sorted_pks) + assert all_dispatched != sorted_pks + + def test_shuffle_false_preserves_pk_order(self): + """When shuffle=False (default), PKs are in ascending PK order.""" + ois = self._create_org_integrations(30) + sorted_pks = [oi.pk for oi in ois] + + scheduler = self._make_scheduler() + + all_dispatched: list[int] = [] + while scheduler.tick(): + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + self.mock_task.reset_mock() + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + + assert all_dispatched == sorted_pks + def test_interval_decrease_halves_batch_size(self): """When tick interval is halved, batch size is halved for remaining items.""" self._create_org_integrations(30) diff --git a/tests/sentry/utils/test_snuba.py b/tests/sentry/utils/test_snuba.py index a275d59d35e5ba..4259177faf5726 100644 --- a/tests/sentry/utils/test_snuba.py +++ b/tests/sentry/utils/test_snuba.py @@ -628,3 +628,43 @@ def test_rate_limit_error_handling_with_stats_but_no_quota_details( assert ( str(exc_info.value) == "Query on could not be run due to allocation policies, info: ..." ) + + @mock.patch("sentry.utils.snuba._snuba_query") + def test_rate_limit_error_handling_throttle_only(self, mock_snuba_query) -> None: + """ + Test that policy metadata propagates when the 429 came from a throttle: + rejected_by is empty and throttled_by carries throttle_threshold, with no rejection_threshold + """ + mock_response = mock.Mock(spec=HTTPResponse) + mock_response.status = 429 + mock_response.data = json.dumps( + { + "error": { + "message": "Query scanned more than the allocated amount of bytes", + }, + "quota_allowance": { + "summary": { + "rejected_by": {}, + "throttled_by": { + "policy": "BytesScannedRejectingPolicy", + "quota_used": 1500000000000, + "throttle_threshold": 1000000000000, + "quota_unit": "bytes", + "storage_key": "errors_ro", + }, + } + }, + } + ).encode() + + mock_snuba_query.return_value = ("test_referrer", mock_response, lambda x: x, lambda x: x) + + with pytest.raises(RateLimitExceeded) as exc_info: + _bulk_snuba_query([self.snuba_request]) + + assert exc_info.value.policy == "BytesScannedRejectingPolicy" + assert exc_info.value.storage_key == "errors_ro" + assert exc_info.value.quota_used == 1500000000000 + assert exc_info.value.quota_unit == "bytes" + assert exc_info.value.throttle_threshold == 1000000000000 + assert exc_info.value.rejection_threshold is None diff --git a/tests/sentry/workflow_engine/handlers/workflow/__init__.py b/tests/sentry/workflow_engine/handlers/workflow/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py new file mode 100644 index 00000000000000..3a0b094b48e4dc --- /dev/null +++ b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py @@ -0,0 +1,38 @@ +from unittest import mock + +from sentry.testutils.cases import TestCase +from sentry.types.activity import ActivityType +from sentry.workflow_engine.registry import ( + invoke_workflow_activity_handlers, + workflow_activity_registry, +) + + +class WorkflowActivityRegistryTest(TestCase): + def setUp(self) -> None: + self.group = self.create_group() + self.activity = self.create_group_activity( + group=self.group, type=ActivityType.SEER_PR_CREATED.value + ) + + def test_registrants(self) -> None: + assert "seer_activity" in workflow_activity_registry.registrations + assert len(workflow_activity_registry.registrations) == 1 + + def test_invoke_handlers(self) -> None: + handler_a = mock.Mock() + handler_b = mock.Mock() + + with mock.patch.dict( + workflow_activity_registry.registrations, + {"handler_a": handler_a, "handler_b": handler_b}, + clear=True, + ): + invoke_workflow_activity_handlers(self.group, self.activity) + + handler_a.assert_called_once_with(self.group, self.activity) + handler_b.assert_called_once_with(self.group, self.activity) + + def test_invoke_handlers_no_registrants(self) -> None: + with mock.patch.dict(workflow_activity_registry.registrations, {}, clear=True): + invoke_workflow_activity_handlers(self.group, self.activity) diff --git a/tests/snuba/api/endpoints/test_project_trace_item_details.py b/tests/snuba/api/endpoints/test_project_trace_item_details.py index 7694cabfbad340..dc29c5c6f079c3 100644 --- a/tests/snuba/api/endpoints/test_project_trace_item_details.py +++ b/tests/snuba/api/endpoints/test_project_trace_item_details.py @@ -1,4 +1,5 @@ import uuid +from datetime import timedelta from unittest import mock import pytest @@ -32,7 +33,7 @@ def setUp(self) -> None: self.one_min_ago = before_now(minutes=1) self.trace_uuid = str(uuid.uuid4()).replace("-", "") - def do_request(self, event_type: str, item_id: str, features=None): + def do_request(self, event_type: str, item_id: str, extra_data=None, features=None): item_details_url = reverse( "sentry-api-0-project-trace-item-details", kwargs={ @@ -43,13 +44,16 @@ def do_request(self, event_type: str, item_id: str, features=None): ) if features is None: features = self.features + data = { + "item_type": event_type, + "trace_id": self.trace_uuid, + } + if extra_data is not None: + data.update(extra_data) with self.feature(features): return self.client.get( item_details_url, - { - "item_type": event_type, - "trace_id": self.trace_uuid, - }, + data, ) def test_simple(self) -> None: @@ -639,3 +643,124 @@ def test_attachment(self) -> None: "meta": {}, "timestamp": mock.ANY, } + + def test_with_timestamp(self) -> None: + log = self.create_ourlog( + { + "body": "foo", + "trace_id": self.trace_uuid, + }, + attributes={ + "str_attr": { + "string_value": "1", + }, + "int_attr": {"int_value": 2}, + "float_attr": { + "double_value": 3.0, + }, + "bool_attr": { + "bool_value": True, + }, + }, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + for extra_data in [ + {"timestamp": self.one_min_ago.isoformat()}, + {"statsPeriod": "24h"}, + ]: + trace_details_response = self.do_request("logs", item_id, extra_data=extra_data) + + assert trace_details_response.status_code == 200, trace_details_response.content + + timestamp_nanos = int(self.one_min_ago.timestamp() * 1_000_000_000) + assert trace_details_response.data["attributes"] == [ + {"name": "tags[bool_attr,boolean]", "type": "bool", "value": True}, + {"name": "tags[float_attr,number]", "type": "float", "value": 3.0}, + { + "name": "observed_timestamp", + "type": "int", + "value": str(timestamp_nanos), + }, + {"name": "project_id", "type": "int", "value": str(self.project.id)}, + {"name": "severity_number", "type": "int", "value": "0"}, + {"name": "tags[int_attr,number]", "type": "int", "value": "2"}, + { + "name": "timestamp_precise", + "type": "int", + "value": str(timestamp_nanos), + }, + {"name": "message", "type": "str", "value": "foo"}, + {"name": "severity", "type": "str", "value": "INFO"}, + {"name": "str_attr", "type": "str", "value": "1"}, + {"name": "trace", "type": "str", "value": self.trace_uuid}, + ] + assert trace_details_response.data["itemId"] == item_id + assert ( + trace_details_response.data["timestamp"] + == self.one_min_ago.replace(microsecond=0, tzinfo=None).isoformat() + "Z" + ) + + def test_with_incorrect_timestamp(self) -> None: + log = self.create_ourlog( + { + "body": "foo", + "trace_id": self.trace_uuid, + }, + attributes={ + "str_attr": { + "string_value": "1", + }, + "int_attr": {"int_value": 2}, + "float_attr": { + "double_value": 3.0, + }, + "bool_attr": { + "bool_value": True, + }, + }, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + for extra_data in [ + {"timestamp": (self.one_min_ago - timedelta(days=30)).isoformat()}, + {"statsPeriodEnd": "24h", "statsPeriodStart": "48h"}, + ]: + trace_details_response = self.do_request("logs", item_id, extra_data=extra_data) + + assert trace_details_response.status_code == 404, trace_details_response.content + + def test_with_invalid_timestamp(self) -> None: + log = self.create_ourlog( + { + "body": "foo", + "trace_id": self.trace_uuid, + }, + attributes={ + "str_attr": { + "string_value": "1", + }, + "int_attr": {"int_value": 2}, + "float_attr": { + "double_value": 3.0, + }, + "bool_attr": { + "bool_value": True, + }, + }, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + for extra_data in [ + {"timestamp": "beepboop"}, + {"statsPeriod": "hello"}, + ]: + trace_details_response = self.do_request("logs", item_id, extra_data=extra_data) + + assert trace_details_response.status_code == 400, trace_details_response.content diff --git a/uv.lock b/uv.lock index c980bb32905649..810372d3af4894 100644 --- a/uv.lock +++ b/uv.lock @@ -2262,6 +2262,7 @@ dependencies = [ { name = "sentry-forked-email-reply-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-kafka-schemas", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-ophio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sentry-options", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-redis-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-relay", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2435,6 +2436,7 @@ requires-dist = [ { name = "sentry-forked-email-reply-parser", specifier = ">=0.5.12.post1" }, { name = "sentry-kafka-schemas", specifier = ">=2.1.27" }, { name = "sentry-ophio", specifier = ">=1.1.3" }, + { name = "sentry-options", specifier = ">=1.0.13" }, { name = "sentry-protos", specifier = ">=0.13.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.0" }, { name = "sentry-relay", specifier = ">=0.9.27" }, @@ -2607,6 +2609,16 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_ophio-1.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c2feae9d4842e941ade5989c48982e60a743f262bb3c28222f1844fffa12ea" }, ] +[[package]] +name = "sentry-options" +version = "1.0.13" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.0.13-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:54008be0ada4a761776bf8e819fcd78ff3c03a9880be6983238c49db0cc8f333" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.0.13-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88360d1125c72c15c0a683e62deddabc75fa8efea18380e5b67bfea9eb78d532" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.0.13-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c3dbe0dde282aea1d6416c0820a125a9e3ab88c05998452813255ce7ea07372" }, +] + [[package]] name = "sentry-protos" version = "0.13.0"