Skip to content

Commit 5fb2b8d

Browse files
Merge branch 'master' into brendan/metrics-tooltip
2 parents 8a5a539 + b41be62 commit 5fb2b8d

File tree

12 files changed

+186
-33
lines changed

12 files changed

+186
-33
lines changed

.github/workflows/scripts/compute-sentry-selected-tests.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
re.compile(r"^tests/(acceptance|apidocs|js|tools)/"),
8787
]
8888

89+
# Tests that should always be run even if not explicitly selected.
90+
ALWAYS_RUN_TESTS: set[str] = {
91+
"tests/sentry/taskworker/test_config.py",
92+
}
93+
8994

9095
def _is_test(path: str) -> bool:
9196
return any(path.startswith(d) for d in TEST_DIRS)
@@ -225,6 +230,9 @@ def main() -> int:
225230
print(f"Including {len(existing_changed)} directly changed test files")
226231
affected_test_files.update(existing_changed)
227232

233+
# Always run these tests
234+
affected_test_files.update(ALWAYS_RUN_TESTS)
235+
228236
# Filter to sentry tests only (drop any getsentry tests from coverage)
229237
affected_test_files = {f for f in affected_test_files if _is_test(f)}
230238

.github/workflows/scripts/test_compute_sentry_selected_tests.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
sys.modules["compute_sentry_selected_tests"] = _mod
2020
_spec.loader.exec_module(_mod)
2121

22-
from compute_sentry_selected_tests import _query_coverage, main
22+
from compute_sentry_selected_tests import ALWAYS_RUN_TESTS, _query_coverage, main
2323

2424

2525
def _create_coverage_db(path: str, file_to_contexts: dict[str, list[str]]) -> None:
@@ -178,8 +178,9 @@ def test_selective_returns_matched_tests(self, tmp_path):
178178

179179
gh = gh_output.read_text()
180180
assert "has-selected-tests=true" in gh
181-
assert "test-count=1" in gh
182-
assert output.read_text().strip() == "tests/sentry/test_org.py"
181+
assert f"test-count={1 + len(ALWAYS_RUN_TESTS)}" in gh
182+
expected_output = sorted({"tests/sentry/test_org.py"} | ALWAYS_RUN_TESTS)
183+
assert output.read_text().splitlines() == expected_output
183184

184185
def test_getsentry_tests_filtered_out(self, tmp_path):
185186
"""Coverage may return getsentry tests — they should be filtered."""
@@ -211,8 +212,9 @@ def test_getsentry_tests_filtered_out(self, tmp_path):
211212
{"GITHUB_OUTPUT": str(gh_output)},
212213
)
213214

214-
assert "test-count=1" in gh_output.read_text()
215-
assert output.read_text().strip() == "tests/sentry/test_org.py"
215+
assert f"test-count={1 + len(ALWAYS_RUN_TESTS)}" in gh_output.read_text()
216+
expected_output = sorted({"tests/sentry/test_org.py"} | ALWAYS_RUN_TESTS)
217+
assert output.read_text().splitlines() == expected_output
216218

217219
def test_changed_test_file_included(self, tmp_path):
218220
db_path = tmp_path / "coverage.db"
@@ -236,7 +238,7 @@ def test_changed_test_file_included(self, tmp_path):
236238

237239
gh = gh_output.read_text()
238240
assert "has-selected-tests=true" in gh
239-
assert "test-count=1" in gh
241+
assert f"test-count={1 + len(ALWAYS_RUN_TESTS)}" in gh
240242

241243
def test_excluded_test_dirs_skipped(self, tmp_path):
242244
"""Tests in acceptance/apidocs/js/tools should not be selected."""
@@ -257,10 +259,10 @@ def test_excluded_test_dirs_skipped(self, tmp_path):
257259
{"GITHUB_OUTPUT": str(gh_output)},
258260
)
259261

260-
assert "test-count=0" in gh_output.read_text()
262+
assert f"test-count={len(ALWAYS_RUN_TESTS)}" in gh_output.read_text()
261263

262-
def test_zero_tests_signals_selective_applied(self, tmp_path):
263-
"""0 tests after filtering should signal 'run nothing', not full suite."""
264+
def test_zero_coverage_matches_still_runs_always_run_tests(self, tmp_path):
265+
"""No coverage matches should still run ALWAYS_RUN_TESTS, not the full suite."""
264266
db_path = tmp_path / "coverage.db"
265267
_create_coverage_db(str(db_path), {})
266268
output = tmp_path / "output.txt"
@@ -282,8 +284,8 @@ def test_zero_tests_signals_selective_applied(self, tmp_path):
282284

283285
gh = gh_output.read_text()
284286
assert "has-selected-tests=true" in gh
285-
assert "test-count=0" in gh
286-
assert output.read_text() == ""
287+
assert f"test-count={len(ALWAYS_RUN_TESTS)}" in gh
288+
assert set(output.read_text().splitlines()) == ALWAYS_RUN_TESTS
287289

288290
def test_renamed_file_queries_old_path(self, tmp_path):
289291
"""When a file is renamed, the old path should be queried against the coverage DB."""
@@ -319,8 +321,9 @@ def test_renamed_file_queries_old_path(self, tmp_path):
319321

320322
gh = gh_output.read_text()
321323
assert "has-selected-tests=true" in gh
322-
assert "test-count=1" in gh
323-
assert output.read_text().strip() == "tests/sentry/test_old_name.py"
324+
assert f"test-count={1 + len(ALWAYS_RUN_TESTS)}" in gh
325+
expected_output = sorted({"tests/sentry/test_old_name.py"} | ALWAYS_RUN_TESTS)
326+
assert output.read_text().splitlines() == expected_output
324327

325328
def test_renamed_file_without_previous_misses_coverage(self, tmp_path):
326329
"""Without --previous-filenames, a renamed file gets no coverage hits."""
@@ -349,7 +352,7 @@ def test_renamed_file_without_previous_misses_coverage(self, tmp_path):
349352

350353
gh = gh_output.read_text()
351354
assert "has-selected-tests=true" in gh
352-
assert "test-count=0" in gh
355+
assert f"test-count={len(ALWAYS_RUN_TESTS)}" in gh
353356

354357
def test_missing_db_returns_error(self):
355358
ret = _run(["--coverage-db", "/nonexistent/coverage.db", "--changed-files", "foo.py"])

src/sentry/notifications/notification_action/types.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
)
3636
from sentry.types.activity import ActivityType
3737
from sentry.types.rules import RuleFuture
38-
from sentry.workflow_engine.models import Action, AlertRuleWorkflow, Detector
38+
from sentry.workflow_engine.models import Action, AlertRuleWorkflow, Detector, Workflow
3939
from sentry.workflow_engine.types import ActionInvocation, DetectorPriorityLevel, WorkflowEventData
4040
from sentry.workflow_engine.typings.notification_action import (
4141
ACTION_FIELD_MAPPINGS,
@@ -201,7 +201,19 @@ def create_rule_instance_from_action(
201201
workflow_id = getattr(action, "workflow_id", None)
202202
rule_id = None
203203

204-
label = detector.name
204+
label = None
205+
# Attempt to query the workflow name for non-test notifications.
206+
if workflow_id is not None and workflow_id != TEST_NOTIFICATION_ID:
207+
try:
208+
workflow = Workflow.objects.get(id=workflow_id)
209+
label = workflow.name
210+
except Workflow.DoesNotExist:
211+
# If the workflow no longer exists, bail and use detector name
212+
# as a fallback.
213+
pass
214+
215+
if label is None:
216+
label = detector.name
205217
# Build link to the rule if it exists, otherwise build link to the workflow.
206218
# FE will handle redirection if necessary from rule -> workflow
207219

static/app/components/noProjectMessage.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import * as Layout from 'sentry/components/layouts/thirds';
99
import {t} from 'sentry/locale';
1010
import type {Organization} from 'sentry/types/organization';
1111
import {useCanCreateProject} from 'sentry/utils/useCanCreateProject';
12+
import {useHasProjectAccess} from 'sentry/utils/useHasProjectAccess';
1213
import {useProjects} from 'sentry/utils/useProjects';
13-
import {useUser} from 'sentry/utils/useUser';
1414
import {makeProjectsPathname} from 'sentry/views/projects/pathname';
1515

1616
type Props = {
@@ -24,17 +24,15 @@ export function NoProjectMessage({
2424
organization,
2525
superuserNeedsToBeProjectMember,
2626
}: Props) {
27-
const user = useUser();
28-
const {projects, initiallyLoaded: projectsLoaded} = useProjects();
27+
const {projects} = useProjects();
28+
const {hasProjectAccess, projectsLoaded} = useHasProjectAccess({
29+
superuserNeedsToBeProjectMember,
30+
});
2931

3032
const canUserCreateProject = useCanCreateProject();
3133
const canJoinTeam = organization.access.includes('team:read');
3234

3335
const orgHasProjects = !!projects?.length;
34-
const hasProjectAccess =
35-
user.isSuperuser && !superuserNeedsToBeProjectMember
36-
? !!projects?.some(p => p.hasAccess)
37-
: !!projects?.some(p => p.isMember && p.hasAccess);
3836

3937
if (hasProjectAccess || !projectsLoaded) {
4038
return <Fragment>{children}</Fragment>;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {ProjectFixture} from 'sentry-fixture/project';
2+
3+
import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
4+
5+
import {ConfigStore} from 'sentry/stores/configStore';
6+
import {ProjectsStore} from 'sentry/stores/projectsStore';
7+
8+
import {useHasProjectAccess} from './useHasProjectAccess';
9+
10+
describe('useHasProjectAccess', () => {
11+
beforeEach(() => {
12+
ProjectsStore.reset();
13+
ConfigStore.set('user', {...ConfigStore.get('user'), isSuperuser: false});
14+
});
15+
16+
it('returns false when there are no projects', () => {
17+
ProjectsStore.loadInitialData([]);
18+
19+
const {result} = renderHookWithProviders(() => useHasProjectAccess());
20+
21+
expect(result.current.hasProjectAccess).toBe(false);
22+
expect(result.current.projectsLoaded).toBe(true);
23+
});
24+
25+
it('returns true when user is a member of a project with access', () => {
26+
ProjectsStore.loadInitialData([ProjectFixture({hasAccess: true, isMember: true})]);
27+
28+
const {result} = renderHookWithProviders(() => useHasProjectAccess());
29+
30+
expect(result.current.hasProjectAccess).toBe(true);
31+
});
32+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {useProjects} from 'sentry/utils/useProjects';
2+
import {useUser} from 'sentry/utils/useUser';
3+
4+
type Options = {
5+
/**
6+
* When true, superusers must also be a project member to count as having access.
7+
*/
8+
superuserNeedsToBeProjectMember?: boolean;
9+
};
10+
11+
/**
12+
* Returns whether the current user has access to at least one project,
13+
* and whether the project list has finished loading.
14+
*/
15+
export function useHasProjectAccess(options?: Options) {
16+
const user = useUser();
17+
const {projects, initiallyLoaded: projectsLoaded} = useProjects();
18+
19+
const hasProjectAccess =
20+
user.isSuperuser && !options?.superuserNeedsToBeProjectMember
21+
? !!projects?.some(p => p.hasAccess)
22+
: !!projects?.some(p => p.isMember && p.hasAccess);
23+
24+
return {hasProjectAccess, projectsLoaded};
25+
}

static/app/views/dashboards/hooks/useGetStarredDashboards.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {Organization} from 'sentry/types/organization';
22
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
33
import type {ApiQueryKey} from 'sentry/utils/queryClient';
44
import {useApiQuery} from 'sentry/utils/queryClient';
5+
import {useHasProjectAccess} from 'sentry/utils/useHasProjectAccess';
56
import {useOrganization} from 'sentry/utils/useOrganization';
67
import type {DashboardListItem} from 'sentry/views/dashboards/types';
78

@@ -29,7 +30,10 @@ export function getStarredDashboardsQueryKey(organization: Organization): ApiQue
2930

3031
export function useGetStarredDashboards() {
3132
const organization = useOrganization();
33+
const {hasProjectAccess, projectsLoaded} = useHasProjectAccess();
34+
3235
return useApiQuery<DashboardListItem[]>(getStarredDashboardsQueryKey(organization), {
3336
staleTime: Infinity,
37+
enabled: hasProjectAccess || !projectsLoaded,
3438
});
3539
}

static/app/views/dashboards/manage/index.spec.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ describe('Dashboards > Detail', () => {
112112
).toBeInTheDocument();
113113
});
114114

115+
it('does not fetch dashboards when there are no projects', async () => {
116+
act(() => ProjectsStore.loadInitialData([]));
117+
118+
const dashboardsRequest = MockApiClient.addMockResponse({
119+
url: '/organizations/org-slug/dashboards/',
120+
body: [],
121+
});
122+
123+
render(<ManageDashboards />, {
124+
organization: mockAuthorizedOrg,
125+
});
126+
127+
expect(
128+
await screen.findByText('You need at least one project to use this view')
129+
).toBeInTheDocument();
130+
131+
expect(dashboardsRequest).not.toHaveBeenCalled();
132+
});
133+
115134
it('creates new dashboard', async () => {
116135
const org = OrganizationFixture({features: FEATURES});
117136
const mockNavigate = jest.fn();

static/app/views/dashboards/manage/index.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
3939
import {scheduleMicroTask} from 'sentry/utils/scheduleMicroTask';
4040
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
4141
import {useApi} from 'sentry/utils/useApi';
42+
import {useHasProjectAccess} from 'sentry/utils/useHasProjectAccess';
4243
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
4344
import {useLocation} from 'sentry/utils/useLocation';
4445
import {useNavigate} from 'sentry/utils/useNavigate';
@@ -189,6 +190,8 @@ function ManageDashboards() {
189190
columnCount: DASHBOARD_GRID_DEFAULT_NUM_COLUMNS,
190191
});
191192

193+
const {hasProjectAccess, projectsLoaded} = useHasProjectAccess();
194+
192195
const sortOptions = getSortOptions({
193196
organization,
194197
dashboardsLayout,
@@ -222,10 +225,12 @@ function ManageDashboards() {
222225
],
223226
{
224227
staleTime: 0,
225-
enabled: !(
226-
organization.features.includes('dashboards-starred-reordering') &&
227-
dashboardsLayout === TABLE
228-
),
228+
enabled:
229+
(hasProjectAccess || !projectsLoaded) &&
230+
!(
231+
organization.features.includes('dashboards-starred-reordering') &&
232+
dashboardsLayout === TABLE
233+
),
229234
}
230235
);
231236

@@ -257,6 +262,7 @@ function ManageDashboards() {
257262
cursor: decodeScalar(location.query[OWNED_CURSOR_KEY], ''),
258263
sort: getActiveSort()!.value,
259264
enabled:
265+
(hasProjectAccess || !projectsLoaded) &&
260266
organization.features.includes('dashboards-starred-reordering') &&
261267
dashboardsLayout === TABLE,
262268
});

tests/sentry/notifications/notification_action/test_issue_alert_registry_handlers.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
BaseIssueAlertHandler,
2626
TicketingIssueAlertHandler,
2727
)
28-
from sentry.notifications.types import ActionTargetType, FallthroughChoiceType
28+
from sentry.notifications.types import TEST_NOTIFICATION_ID, ActionTargetType, FallthroughChoiceType
2929
from sentry.testutils.helpers.data_blobs import (
3030
AZURE_DEVOPS_ACTION_DATA_BLOBS,
3131
EMAIL_ACTION_DATA_BLOBS,
@@ -168,7 +168,7 @@ def test_create_rule_instance_from_action_with_workflow_only(self) -> None:
168168
assert rule.environment_id is not None
169169
assert self.workflow.environment is not None
170170
assert rule.environment_id == self.workflow.environment.id
171-
assert rule.label == self.detector.name
171+
assert rule.label == self.workflow.name
172172
assert rule.data == {
173173
"actions": [
174174
{
@@ -183,6 +183,51 @@ def test_create_rule_instance_from_action_with_workflow_only(self) -> None:
183183
assert rule.status == ObjectStatus.ACTIVE
184184
assert rule.source == RuleSource.ISSUE
185185

186+
def test_create_rule_instance_from_action_deleted_workflow_falls_back_to_detector_name(
187+
self,
188+
) -> None:
189+
"""Test that label falls back to detector.name when the workflow no longer exists"""
190+
workflow_id = self.workflow.id
191+
self.workflow.delete()
192+
rule = self.handler.create_rule_instance_from_action(
193+
self.action, self.detector, self.event_data
194+
)
195+
196+
assert isinstance(rule, Rule)
197+
assert rule.label == self.detector.name
198+
assert rule.data == {
199+
"actions": [
200+
{
201+
"id": "sentry.integrations.discord.notify_action.DiscordNotifyServiceAction",
202+
"server": "1234567890",
203+
"channel_id": "channel456",
204+
"tags": "environment,user,my_tag",
205+
"workflow_id": workflow_id,
206+
}
207+
]
208+
}
209+
210+
def test_create_rule_instance_from_action_with_test_notification_id(self) -> None:
211+
"""Test that Workflow lookup is skipped for test notifications, falling back to detector name"""
212+
self.action.workflow_id = TEST_NOTIFICATION_ID
213+
rule = self.handler.create_rule_instance_from_action(
214+
self.action, self.detector, self.event_data
215+
)
216+
217+
assert isinstance(rule, Rule)
218+
assert rule.label == self.detector.name
219+
assert rule.data == {
220+
"actions": [
221+
{
222+
"id": "sentry.integrations.discord.notify_action.DiscordNotifyServiceAction",
223+
"server": "1234567890",
224+
"channel_id": "channel456",
225+
"tags": "environment,user,my_tag",
226+
"legacy_rule_id": TEST_NOTIFICATION_ID,
227+
}
228+
],
229+
}
230+
186231
def test_create_rule_instance_from_action_no_environment(self) -> None:
187232
"""Test that create_rule_instance_from_action creates a Rule with correct attributes"""
188233
self.create_workflow()

0 commit comments

Comments
 (0)