test_project_group_index.py 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640
  1. from __future__ import annotations
  2. import time
  3. from collections.abc import Sequence
  4. from datetime import timedelta
  5. from functools import cached_property
  6. from unittest.mock import Mock, call, patch
  7. from urllib.parse import quote
  8. from uuid import uuid4
  9. from django.conf import settings
  10. from django.utils import timezone
  11. from sentry.integrations.models.external_issue import ExternalIssue
  12. from sentry.integrations.models.organization_integration import OrganizationIntegration
  13. from sentry.issues.grouptype import PerformanceSlowDBQueryGroupType
  14. from sentry.models.activity import Activity
  15. from sentry.models.apitoken import ApiToken
  16. from sentry.models.group import Group, GroupStatus
  17. from sentry.models.groupassignee import GroupAssignee
  18. from sentry.models.groupbookmark import GroupBookmark
  19. from sentry.models.grouphash import GroupHash
  20. from sentry.models.groupinbox import GroupInboxReason, add_group_to_inbox
  21. from sentry.models.grouplink import GroupLink
  22. from sentry.models.groupresolution import GroupResolution
  23. from sentry.models.groupseen import GroupSeen
  24. from sentry.models.groupshare import GroupShare
  25. from sentry.models.groupsnooze import GroupSnooze
  26. from sentry.models.groupsubscription import GroupSubscription
  27. from sentry.models.grouptombstone import GroupTombstone
  28. from sentry.models.project import Project
  29. from sentry.models.release import Release
  30. from sentry.silo.base import SiloMode
  31. from sentry.testutils.cases import APITestCase, SnubaTestCase
  32. from sentry.testutils.helpers import Feature, parse_link_header, with_feature
  33. from sentry.testutils.helpers.datetime import before_now
  34. from sentry.testutils.silo import assume_test_silo_mode
  35. from sentry.types.activity import ActivityType
  36. from sentry.users.models.user_option import UserOption
  37. from sentry.utils import json
  38. class GroupListTest(APITestCase, SnubaTestCase):
  39. def setUp(self):
  40. super().setUp()
  41. self.min_ago = before_now(minutes=1)
  42. def _parse_links(self, header):
  43. # links come in {url: {...attrs}}, but we need {rel: {...attrs}}
  44. links = {}
  45. for url, attrs in parse_link_header(header).items():
  46. links[attrs["rel"]] = attrs
  47. attrs["href"] = url
  48. return links
  49. @cached_property
  50. def path(self):
  51. return f"/api/0/projects/{self.project.organization.slug}/{self.project.slug}/issues/"
  52. def test_sort_by_date_with_tag(self):
  53. # XXX(dcramer): this tests a case where an ambiguous column name existed
  54. group1 = self.create_group(last_seen=before_now(seconds=1))
  55. self.login_as(user=self.user)
  56. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  57. assert response.status_code == 200
  58. assert len(response.data) == 1
  59. assert response.data[0]["id"] == str(group1.id)
  60. def test_invalid_query(self):
  61. self.create_group(last_seen=before_now(seconds=1))
  62. self.login_as(user=self.user)
  63. response = self.client.get(f"{self.path}?sort_by=date&query=timesSeen:>1t", format="json")
  64. assert response.status_code == 400
  65. assert "Error parsing search query" in response.data["detail"]
  66. def test_simple_pagination(self):
  67. event1 = self.store_event(
  68. data={
  69. "fingerprint": ["put-me-in-group-1"],
  70. "timestamp": (self.min_ago - timedelta(seconds=2)).isoformat(),
  71. },
  72. project_id=self.project.id,
  73. )
  74. event2 = self.store_event(
  75. data={
  76. "fingerprint": ["put-me-in-group-2"],
  77. "timestamp": (self.min_ago - timedelta(seconds=1)).isoformat(),
  78. },
  79. project_id=self.project.id,
  80. )
  81. self.login_as(user=self.user)
  82. response = self.client.get(f"{self.path}?sort_by=date&limit=1", format="json")
  83. assert response.status_code == 200
  84. assert len(response.data) == 1
  85. assert response.data[0]["id"] == str(event2.group.id)
  86. links = self._parse_links(response["Link"])
  87. assert links["previous"]["results"] == "false"
  88. assert links["next"]["results"] == "true"
  89. response = self.client.get(links["next"]["href"], format="json")
  90. assert response.status_code == 200
  91. assert len(response.data) == 1
  92. assert response.data[0]["id"] == str(event1.group.id)
  93. links = self._parse_links(response["Link"])
  94. assert links["previous"]["results"] == "true"
  95. assert links["next"]["results"] == "false"
  96. def test_stats_period(self):
  97. # TODO(dcramer): this test really only checks if validation happens
  98. # on statsPeriod
  99. self.create_group(last_seen=before_now(seconds=1))
  100. self.create_group(last_seen=timezone.now())
  101. self.login_as(user=self.user)
  102. response = self.client.get(f"{self.path}?statsPeriod=24h", format="json")
  103. assert response.status_code == 200
  104. response = self.client.get(f"{self.path}?statsPeriod=14d", format="json")
  105. assert response.status_code == 200
  106. response = self.client.get(f"{self.path}?statsPeriod=", format="json")
  107. assert response.status_code == 200
  108. time.sleep(1)
  109. response = self.client.get(f"{self.path}?statsPeriod=48h", format="json")
  110. assert response.status_code == 400
  111. def test_environment(self):
  112. self.store_event(
  113. data={
  114. "fingerprint": ["put-me-in-group1"],
  115. "timestamp": self.min_ago.isoformat(),
  116. "environment": "production",
  117. },
  118. project_id=self.project.id,
  119. )
  120. self.store_event(
  121. data={
  122. "fingerprint": ["put-me-in-group2"],
  123. "timestamp": self.min_ago.isoformat(),
  124. "environment": "staging",
  125. },
  126. project_id=self.project.id,
  127. )
  128. self.login_as(user=self.user)
  129. response = self.client.get(self.path + "?environment=production", format="json")
  130. assert response.status_code == 200
  131. assert len(response.data) == 1
  132. response = self.client.get(self.path + "?environment=garbage", format="json")
  133. assert response.status_code == 200
  134. assert len(response.data) == 0
  135. def test_auto_resolved(self):
  136. project = self.project
  137. project.update_option("sentry:resolve_age", 1)
  138. self.create_group(last_seen=before_now(days=1))
  139. group2 = self.create_group(last_seen=timezone.now())
  140. self.login_as(user=self.user)
  141. response = self.client.get(self.path + "?query=is:unresolved", format="json")
  142. assert response.status_code == 200
  143. assert len(response.data) == 1
  144. assert response.data[0]["id"] == str(group2.id)
  145. def test_lookup_by_event_id(self):
  146. project = self.project
  147. project.update_option("sentry:resolve_age", 1)
  148. event_id = "c" * 32
  149. event = self.store_event(
  150. data={"event_id": event_id, "timestamp": self.min_ago.isoformat()},
  151. project_id=self.project.id,
  152. )
  153. self.login_as(user=self.user)
  154. response = self.client.get("{}?query={}".format(self.path, "c" * 32), format="json")
  155. assert response.status_code == 200
  156. assert len(response.data) == 1
  157. assert response.data[0]["id"] == str(event.group.id)
  158. assert response.data[0]["matchingEventId"] == event.event_id
  159. def test_lookup_by_event_with_matching_environment(self):
  160. project = self.project
  161. project.update_option("sentry:resolve_age", 1)
  162. self.create_environment(name="test", project=project)
  163. event = self.store_event(
  164. data={"environment": "test", "timestamp": self.min_ago.isoformat()},
  165. project_id=self.project.id,
  166. )
  167. self.login_as(user=self.user)
  168. response = self.client.get(
  169. f"{self.path}?query={event.event_id}&environment=test", format="json"
  170. )
  171. assert response.status_code == 200
  172. assert len(response.data) == 1
  173. assert response.data[0]["id"] == str(event.group.id)
  174. assert response.data[0]["matchingEventId"] == event.event_id
  175. assert response.data[0]["matchingEventEnvironment"] == "test"
  176. def test_lookup_by_event_id_with_whitespace(self):
  177. project = self.project
  178. project.update_option("sentry:resolve_age", 1)
  179. event = self.store_event(
  180. data={"event_id": "c" * 32, "timestamp": self.min_ago.isoformat()},
  181. project_id=self.project.id,
  182. )
  183. self.login_as(user=self.user)
  184. response = self.client.get(
  185. "{}?query=%20%20{}%20%20".format(self.path, "c" * 32), format="json"
  186. )
  187. assert response.status_code == 200
  188. assert len(response.data) == 1
  189. assert response.data[0]["id"] == str(event.group.id)
  190. def test_lookup_by_unknown_event_id(self):
  191. project = self.project
  192. project.update_option("sentry:resolve_age", 1)
  193. self.create_group()
  194. self.create_group()
  195. self.login_as(user=self.user)
  196. response = self.client.get("{}?query={}".format(self.path, "c" * 32), format="json")
  197. assert response.status_code == 200
  198. assert len(response.data) == 0
  199. def test_lookup_by_short_id(self):
  200. group = self.group
  201. short_id = group.qualified_short_id
  202. self.login_as(user=self.user)
  203. response = self.client.get(f"{self.path}?query={short_id}&shortIdLookup=1", format="json")
  204. assert response.status_code == 200
  205. assert len(response.data) == 1
  206. def test_lookup_by_short_id_no_perms(self):
  207. organization = self.create_organization()
  208. project = self.create_project(organization=organization)
  209. project2 = self.create_project(organization=organization)
  210. team = self.create_team(organization=organization)
  211. project2.add_team(team)
  212. group = self.create_group(project=project)
  213. user = self.create_user()
  214. self.create_member(organization=organization, user=user, teams=[team])
  215. short_id = group.qualified_short_id
  216. self.login_as(user=user)
  217. path = f"/api/0/projects/{organization.slug}/{project2.slug}/issues/"
  218. response = self.client.get(f"{path}?query={short_id}&shortIdLookup=1", format="json")
  219. assert response.status_code == 200
  220. assert len(response.data) == 0
  221. def test_lookup_by_first_release(self):
  222. self.login_as(self.user)
  223. project = self.project
  224. project2 = self.create_project(name="baz", organization=project.organization)
  225. release = Release.objects.create(organization=project.organization, version="12345")
  226. release.add_project(project)
  227. release.add_project(project2)
  228. group = self.store_event(
  229. data={"release": release.version, "timestamp": before_now(seconds=1).isoformat()},
  230. project_id=project.id,
  231. ).group
  232. self.store_event(
  233. data={"release": release.version, "timestamp": before_now(seconds=1).isoformat()},
  234. project_id=project2.id,
  235. )
  236. url = "{}?query={}".format(self.path, 'first-release:"%s"' % release.version)
  237. response = self.client.get(url, format="json")
  238. issues = json.loads(response.content)
  239. assert response.status_code == 200
  240. assert len(issues) == 1
  241. assert int(issues[0]["id"]) == group.id
  242. def test_lookup_by_release(self):
  243. self.login_as(self.user)
  244. version = "12345"
  245. event = self.store_event(
  246. data={"tags": {"sentry:release": version}}, project_id=self.project.id
  247. )
  248. group = event.group
  249. url = "{}?query={}".format(self.path, quote('release:"%s"' % version))
  250. response = self.client.get(url, format="json")
  251. issues = json.loads(response.content)
  252. assert response.status_code == 200
  253. assert len(issues) == 1
  254. assert int(issues[0]["id"]) == group.id
  255. def test_lookup_by_release_wildcard(self):
  256. self.login_as(self.user)
  257. version = "12345"
  258. event = self.store_event(
  259. data={"tags": {"sentry:release": version}}, project_id=self.project.id
  260. )
  261. group = event.group
  262. release_wildcard = version[:3] + "*"
  263. url = "{}?query={}".format(self.path, quote('release:"%s"' % release_wildcard))
  264. response = self.client.get(url, format="json")
  265. issues = json.loads(response.content)
  266. assert response.status_code == 200
  267. assert len(issues) == 1
  268. assert int(issues[0]["id"]) == group.id
  269. def test_pending_delete_pending_merge_excluded(self):
  270. self.create_group(status=GroupStatus.PENDING_DELETION)
  271. group = self.create_group()
  272. self.create_group(status=GroupStatus.DELETION_IN_PROGRESS)
  273. self.create_group(status=GroupStatus.PENDING_MERGE)
  274. self.login_as(user=self.user)
  275. response = self.client.get(self.path + "?query=is:unresolved", format="json")
  276. assert len(response.data) == 1
  277. assert response.data[0]["id"] == str(group.id)
  278. def test_filters_based_on_retention(self):
  279. self.login_as(user=self.user)
  280. self.create_group(last_seen=timezone.now() - timedelta(days=2))
  281. with self.options({"system.event-retention-days": 1}):
  282. response = self.client.get(self.path)
  283. assert response.status_code == 200, response.content
  284. assert len(response.data) == 0
  285. def test_token_auth(self):
  286. with assume_test_silo_mode(SiloMode.CONTROL):
  287. token = ApiToken.objects.create(user=self.user, scopes=256)
  288. response = self.client.get(
  289. self.path, format="json", HTTP_AUTHORIZATION=f"Bearer {token.token}"
  290. )
  291. assert response.status_code == 200, response.content
  292. def test_filter_not_unresolved(self):
  293. event = self.store_event(
  294. data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]},
  295. project_id=self.project.id,
  296. )
  297. event.group.update(status=GroupStatus.RESOLVED, substatus=None)
  298. self.login_as(user=self.user)
  299. response = self.client.get(f"{self.path}?query=!is:unresolved", format="json")
  300. assert response.status_code == 200
  301. assert [int(r["id"]) for r in response.data] == [event.group.id]
  302. def test_single_group_by_hash(self):
  303. event = self.store_event(
  304. data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]},
  305. project_id=self.project.id,
  306. )
  307. self.login_as(user=self.user)
  308. response = self.client.get(f"{self.path}?hashes={event.get_primary_hash()}")
  309. assert response.status_code == 200
  310. assert len(response.data) == 1
  311. assert int(response.data[0]["id"]) == event.group.id
  312. def test_multiple_groups_by_hashes(self):
  313. event = self.store_event(
  314. data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]},
  315. project_id=self.project.id,
  316. )
  317. event2 = self.store_event(
  318. data={"timestamp": before_now(seconds=400).isoformat(), "fingerprint": ["group-2"]},
  319. project_id=self.project.id,
  320. )
  321. self.login_as(user=self.user)
  322. response = self.client.get(
  323. f"{self.path}?hashes={event.get_primary_hash()}&hashes={event2.get_primary_hash()}"
  324. )
  325. assert response.status_code == 200
  326. assert len(response.data) == 2
  327. response_group_ids = [int(group["id"]) for group in response.data]
  328. assert event.group.id in response_group_ids
  329. assert event2.group.id in response_group_ids
  330. class GroupUpdateTest(APITestCase, SnubaTestCase):
  331. def setUp(self):
  332. super().setUp()
  333. self.min_ago = timezone.now() - timedelta(minutes=1)
  334. @cached_property
  335. def path(self):
  336. return f"/api/0/projects/{self.project.organization.slug}/{self.project.slug}/issues/"
  337. def assertNoResolution(self, group):
  338. assert not GroupResolution.objects.filter(group=group).exists()
  339. def test_global_resolve(self):
  340. group1 = self.create_group(status=GroupStatus.RESOLVED)
  341. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  342. group3 = self.create_group(status=GroupStatus.IGNORED)
  343. group4 = self.create_group(
  344. project=self.create_project(slug="foo"),
  345. status=GroupStatus.UNRESOLVED,
  346. )
  347. self.login_as(user=self.user)
  348. response = self.client.put(
  349. f"{self.path}?status=unresolved&query=is:unresolved",
  350. data={"status": "resolved"},
  351. format="json",
  352. )
  353. assert response.status_code == 200, response.data
  354. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  355. # the previously resolved entry should not be included
  356. new_group1 = Group.objects.get(id=group1.id)
  357. assert new_group1.status == GroupStatus.RESOLVED
  358. assert new_group1.resolved_at is None
  359. # this wont exist because it wasn't affected
  360. assert not GroupSubscription.objects.filter(user_id=self.user.id, group=new_group1).exists()
  361. new_group2 = Group.objects.get(id=group2.id)
  362. assert new_group2.status == GroupStatus.RESOLVED
  363. assert new_group2.resolved_at is not None
  364. assert GroupSubscription.objects.filter(
  365. user_id=self.user.id, group=new_group2, is_active=True
  366. ).exists()
  367. # the ignored entry should not be included
  368. new_group3 = Group.objects.get(id=group3.id)
  369. assert new_group3.status == GroupStatus.IGNORED
  370. assert new_group3.resolved_at is None
  371. assert not GroupSubscription.objects.filter(user_id=self.user.id, group=new_group3)
  372. new_group4 = Group.objects.get(id=group4.id)
  373. assert new_group4.status == GroupStatus.UNRESOLVED
  374. assert new_group4.resolved_at is None
  375. assert not GroupSubscription.objects.filter(user_id=self.user.id, group=new_group4)
  376. def test_bulk_resolve(self):
  377. self.login_as(user=self.user)
  378. for i in range(200):
  379. self.create_group(status=GroupStatus.UNRESOLVED)
  380. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  381. assert len(response.data) == 100
  382. response = self.client.put(
  383. f"{self.path}?status=unresolved&query=is:unresolved",
  384. data={"status": "resolved"},
  385. format="json",
  386. )
  387. assert response.status_code == 200, response.data
  388. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  389. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  390. assert len(response.data) == 0
  391. @patch("sentry.integrations.example.integration.ExampleIntegration.sync_status_outbound")
  392. def test_resolve_with_integration(self, mock_sync_status_outbound):
  393. self.login_as(user=self.user)
  394. org = self.organization
  395. with assume_test_silo_mode(SiloMode.CONTROL):
  396. integration = self.create_provider_integration(provider="example", name="Example")
  397. integration.add_organization(org, self.user)
  398. group = self.create_group(status=GroupStatus.UNRESOLVED)
  399. with assume_test_silo_mode(SiloMode.CONTROL):
  400. OrganizationIntegration.objects.filter(
  401. integration_id=integration.id, organization_id=group.organization.id
  402. ).update(
  403. config={
  404. "sync_comments": True,
  405. "sync_status_outbound": True,
  406. "sync_status_inbound": True,
  407. "sync_assignee_outbound": True,
  408. "sync_assignee_inbound": True,
  409. }
  410. )
  411. external_issue = ExternalIssue.objects.get_or_create(
  412. organization_id=org.id, integration_id=integration.id, key="APP-%s" % group.id
  413. )[0]
  414. GroupLink.objects.get_or_create(
  415. group_id=group.id,
  416. project_id=group.project_id,
  417. linked_type=GroupLink.LinkedType.issue,
  418. linked_id=external_issue.id,
  419. relationship=GroupLink.Relationship.references,
  420. )[0]
  421. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  422. assert len(response.data) == 1
  423. with self.tasks():
  424. with self.feature({"organizations:integrations-issue-sync": True}):
  425. response = self.client.put(
  426. f"{self.path}?status=unresolved&query=is:unresolved",
  427. data={"status": "resolved"},
  428. format="json",
  429. )
  430. assert response.status_code == 200, response.data
  431. group = Group.objects.get(id=group.id)
  432. assert group.status == GroupStatus.RESOLVED
  433. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  434. mock_sync_status_outbound.assert_called_once_with(
  435. external_issue, True, group.project_id
  436. )
  437. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  438. assert len(response.data) == 0
  439. @patch("sentry.integrations.example.integration.ExampleIntegration.sync_status_outbound")
  440. def test_set_unresolved_with_integration(self, mock_sync_status_outbound):
  441. release = self.create_release(project=self.project, version="abc")
  442. group = self.create_group(status=GroupStatus.RESOLVED)
  443. org = self.organization
  444. with assume_test_silo_mode(SiloMode.CONTROL):
  445. integration = self.create_provider_integration(provider="example", name="Example")
  446. integration.add_organization(org, self.user)
  447. OrganizationIntegration.objects.filter(
  448. integration_id=integration.id, organization_id=group.organization.id
  449. ).update(
  450. config={
  451. "sync_comments": True,
  452. "sync_status_outbound": True,
  453. "sync_status_inbound": True,
  454. "sync_assignee_outbound": True,
  455. "sync_assignee_inbound": True,
  456. }
  457. )
  458. GroupResolution.objects.create(group=group, release=release)
  459. external_issue = ExternalIssue.objects.get_or_create(
  460. organization_id=org.id, integration_id=integration.id, key="APP-%s" % group.id
  461. )[0]
  462. GroupLink.objects.get_or_create(
  463. group_id=group.id,
  464. project_id=group.project_id,
  465. linked_type=GroupLink.LinkedType.issue,
  466. linked_id=external_issue.id,
  467. relationship=GroupLink.Relationship.references,
  468. )[0]
  469. self.login_as(user=self.user)
  470. url = f"{self.path}?id={group.id}"
  471. with self.tasks():
  472. with self.feature({"organizations:integrations-issue-sync": True}):
  473. response = self.client.put(url, data={"status": "unresolved"}, format="json")
  474. assert response.status_code == 200
  475. assert response.data == {"status": "unresolved", "statusDetails": {}}
  476. group = Group.objects.get(id=group.id)
  477. assert group.status == GroupStatus.UNRESOLVED
  478. self.assertNoResolution(group)
  479. assert GroupSubscription.objects.filter(
  480. user_id=self.user.id, group=group, is_active=True
  481. ).exists()
  482. mock_sync_status_outbound.assert_called_once_with(
  483. external_issue, False, group.project_id
  484. )
  485. def test_self_assign_issue(self):
  486. group = self.create_group(status=GroupStatus.UNRESOLVED)
  487. user = self.user
  488. with assume_test_silo_mode(SiloMode.CONTROL):
  489. uo1 = UserOption.objects.create(
  490. key="self_assign_issue", value="1", project_id=None, user=user
  491. )
  492. self.login_as(user=user)
  493. url = f"{self.path}?id={group.id}"
  494. response = self.client.put(url, data={"status": "resolved"}, format="json")
  495. assert response.status_code == 200, response.data
  496. assert response.data["assignedTo"]["id"] == str(user.id)
  497. assert response.data["assignedTo"]["type"] == "user"
  498. assert response.data["status"] == "resolved"
  499. assert GroupAssignee.objects.filter(group=group, user_id=user.id).exists()
  500. assert GroupSubscription.objects.filter(
  501. user_id=user.id, group=group, is_active=True
  502. ).exists()
  503. with assume_test_silo_mode(SiloMode.CONTROL):
  504. uo1.delete()
  505. def test_self_assign_issue_next_release(self):
  506. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  507. release.add_project(self.project)
  508. group = self.create_group(status=GroupStatus.UNRESOLVED)
  509. with assume_test_silo_mode(SiloMode.CONTROL):
  510. uo1 = UserOption.objects.create(
  511. key="self_assign_issue", value="1", project_id=None, user=self.user
  512. )
  513. self.login_as(user=self.user)
  514. url = f"{self.path}?id={group.id}"
  515. response = self.client.put(url, data={"status": "resolvedInNextRelease"}, format="json")
  516. assert response.status_code == 200
  517. assert response.data["status"] == "resolved"
  518. assert response.data["statusDetails"]["inNextRelease"]
  519. assert response.data["assignedTo"]["id"] == str(self.user.id)
  520. assert response.data["assignedTo"]["type"] == "user"
  521. group = Group.objects.get(id=group.id)
  522. assert group.status == GroupStatus.RESOLVED
  523. assert GroupResolution.objects.filter(group=group, release=release).exists()
  524. assert GroupSubscription.objects.filter(
  525. user_id=self.user.id, group=group, is_active=True
  526. ).exists()
  527. activity = Activity.objects.get(
  528. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  529. )
  530. assert activity.data["version"] == ""
  531. with assume_test_silo_mode(SiloMode.CONTROL):
  532. uo1.delete()
  533. def test_selective_status_update(self):
  534. group1 = self.create_group(status=GroupStatus.RESOLVED)
  535. group1.resolved_at = timezone.now()
  536. group1.save()
  537. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  538. group3 = self.create_group(status=GroupStatus.IGNORED)
  539. group4 = self.create_group(
  540. project=self.create_project(slug="foo"),
  541. status=GroupStatus.UNRESOLVED,
  542. )
  543. self.login_as(user=self.user)
  544. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  545. response = self.client.put(url, data={"status": "resolved"}, format="json")
  546. assert response.status_code == 200
  547. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  548. new_group1 = Group.objects.get(id=group1.id)
  549. assert new_group1.resolved_at is not None
  550. assert new_group1.status == GroupStatus.RESOLVED
  551. new_group2 = Group.objects.get(id=group2.id)
  552. assert new_group2.resolved_at is not None
  553. assert new_group2.status == GroupStatus.RESOLVED
  554. assert GroupSubscription.objects.filter(
  555. user_id=self.user.id, group=new_group2, is_active=True
  556. ).exists()
  557. new_group3 = Group.objects.get(id=group3.id)
  558. assert new_group3.resolved_at is None
  559. assert new_group3.status == GroupStatus.IGNORED
  560. new_group4 = Group.objects.get(id=group4.id)
  561. assert new_group4.resolved_at is None
  562. assert new_group4.status == GroupStatus.UNRESOLVED
  563. def test_set_resolved_in_current_release(self):
  564. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  565. release.add_project(self.project)
  566. group = self.create_group(status=GroupStatus.UNRESOLVED)
  567. self.login_as(user=self.user)
  568. url = f"{self.path}?id={group.id}"
  569. response = self.client.put(
  570. url,
  571. data={"status": "resolved", "statusDetails": {"inRelease": "latest"}},
  572. format="json",
  573. )
  574. assert response.status_code == 200
  575. assert response.data["status"] == "resolved"
  576. assert response.data["statusDetails"]["inRelease"] == release.version
  577. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  578. assert "activity" in response.data
  579. group = Group.objects.get(id=group.id)
  580. assert group.status == GroupStatus.RESOLVED
  581. resolution = GroupResolution.objects.get(group=group)
  582. assert resolution.release == release
  583. assert resolution.type == GroupResolution.Type.in_release
  584. assert resolution.status == GroupResolution.Status.resolved
  585. assert resolution.actor_id == self.user.id
  586. assert GroupSubscription.objects.filter(
  587. user_id=self.user.id, group=group, is_active=True
  588. ).exists()
  589. activity = Activity.objects.get(
  590. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  591. )
  592. assert activity.data["version"] == release.version
  593. def test_set_resolved_in_explicit_release(self):
  594. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  595. release.add_project(self.project)
  596. release2 = Release.objects.create(organization_id=self.project.organization_id, version="b")
  597. release2.add_project(self.project)
  598. group = self.create_group(status=GroupStatus.UNRESOLVED)
  599. self.login_as(user=self.user)
  600. url = f"{self.path}?id={group.id}"
  601. response = self.client.put(
  602. url,
  603. data={"status": "resolved", "statusDetails": {"inRelease": release.version}},
  604. format="json",
  605. )
  606. assert response.status_code == 200
  607. assert response.data["status"] == "resolved"
  608. assert response.data["statusDetails"]["inRelease"] == release.version
  609. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  610. assert "activity" in response.data
  611. group = Group.objects.get(id=group.id)
  612. assert group.status == GroupStatus.RESOLVED
  613. resolution = GroupResolution.objects.get(group=group)
  614. assert resolution.release == release
  615. assert resolution.type == GroupResolution.Type.in_release
  616. assert resolution.status == GroupResolution.Status.resolved
  617. assert resolution.actor_id == self.user.id
  618. assert GroupSubscription.objects.filter(
  619. user_id=self.user.id, group=group, is_active=True
  620. ).exists()
  621. activity = Activity.objects.get(
  622. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  623. )
  624. assert activity.data["version"] == release.version
  625. def test_set_resolved_in_next_release(self):
  626. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  627. release.add_project(self.project)
  628. group = self.create_group(status=GroupStatus.UNRESOLVED)
  629. self.login_as(user=self.user)
  630. url = f"{self.path}?id={group.id}"
  631. response = self.client.put(
  632. url,
  633. data={"status": "resolved", "statusDetails": {"inNextRelease": True}},
  634. format="json",
  635. )
  636. assert response.status_code == 200
  637. assert response.data["status"] == "resolved"
  638. assert response.data["statusDetails"]["inNextRelease"]
  639. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  640. assert "activity" in response.data
  641. group = Group.objects.get(id=group.id)
  642. assert group.status == GroupStatus.RESOLVED
  643. resolution = GroupResolution.objects.get(group=group)
  644. assert resolution.release == release
  645. assert resolution.type == GroupResolution.Type.in_next_release
  646. assert resolution.status == GroupResolution.Status.pending
  647. assert resolution.actor_id == self.user.id
  648. assert GroupSubscription.objects.filter(
  649. user_id=self.user.id, group=group, is_active=True
  650. ).exists()
  651. activity = Activity.objects.get(
  652. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  653. )
  654. assert activity.data["version"] == ""
  655. def test_set_resolved_in_next_release_legacy(self):
  656. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  657. release.add_project(self.project)
  658. group = self.create_group(status=GroupStatus.UNRESOLVED)
  659. self.login_as(user=self.user)
  660. url = f"{self.path}?id={group.id}"
  661. response = self.client.put(url, data={"status": "resolvedInNextRelease"}, format="json")
  662. assert response.status_code == 200
  663. assert response.data["status"] == "resolved"
  664. assert response.data["statusDetails"]["inNextRelease"]
  665. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  666. group = Group.objects.get(id=group.id)
  667. assert group.status == GroupStatus.RESOLVED
  668. resolution = GroupResolution.objects.get(group=group)
  669. assert resolution.release == release
  670. assert resolution.type == GroupResolution.Type.in_next_release
  671. assert resolution.status == GroupResolution.Status.pending
  672. assert resolution.actor_id == self.user.id
  673. assert GroupSubscription.objects.filter(
  674. user_id=self.user.id, group=group, is_active=True
  675. ).exists()
  676. activity = Activity.objects.get(
  677. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  678. )
  679. assert activity.data["version"] == ""
  680. @with_feature("organizations:resolve-in-upcoming-release")
  681. def test_set_resolved_in_upcoming_release(self):
  682. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  683. release.add_project(self.project)
  684. group = self.create_group(status=GroupStatus.UNRESOLVED)
  685. self.login_as(user=self.user)
  686. url = f"{self.path}?id={group.id}"
  687. response = self.client.put(
  688. url,
  689. data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}},
  690. format="json",
  691. )
  692. assert response.status_code == 200
  693. assert response.data["status"] == "resolved"
  694. assert response.data["statusDetails"]["inUpcomingRelease"]
  695. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  696. assert "activity" in response.data
  697. group = Group.objects.get(id=group.id)
  698. assert group.status == GroupStatus.RESOLVED
  699. resolution = GroupResolution.objects.get(group=group)
  700. assert resolution.release == release
  701. assert resolution.type == GroupResolution.Type.in_upcoming_release
  702. assert resolution.status == GroupResolution.Status.pending
  703. assert resolution.actor_id == self.user.id
  704. assert GroupSubscription.objects.filter(
  705. user_id=self.user.id, group=group, is_active=True
  706. ).exists()
  707. activity = Activity.objects.get(
  708. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  709. )
  710. assert activity.data["version"] == ""
  711. def test_upcoming_release_flag_validation(self):
  712. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  713. release.add_project(self.project)
  714. group = self.create_group(status=GroupStatus.UNRESOLVED)
  715. self.login_as(user=self.user)
  716. url = f"{self.path}?id={group.id}"
  717. response = self.client.put(
  718. url,
  719. data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}},
  720. format="json",
  721. )
  722. assert response.status_code == 400
  723. assert (
  724. response.data["statusDetails"]["inUpcomingRelease"][0]
  725. == "Your organization does not have access to this feature."
  726. )
  727. @with_feature("organizations:resolve-in-upcoming-release")
  728. def test_upcoming_release_release_validation(self):
  729. group = self.create_group(status=GroupStatus.UNRESOLVED)
  730. self.login_as(user=self.user)
  731. url = f"{self.path}?id={group.id}"
  732. response = self.client.put(
  733. url,
  734. data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}},
  735. format="json",
  736. )
  737. assert response.status_code == 400
  738. assert (
  739. response.data["statusDetails"]["inUpcomingRelease"][0]
  740. == "No release data present in the system."
  741. )
  742. def test_set_resolved_in_explicit_commit_unreleased(self):
  743. repo = self.create_repo(project=self.project, name=self.project.name)
  744. commit = self.create_commit(project=self.project, repo=repo)
  745. group = self.create_group(status=GroupStatus.UNRESOLVED)
  746. self.login_as(user=self.user)
  747. url = f"{self.path}?id={group.id}"
  748. response = self.client.put(
  749. url,
  750. data={
  751. "status": "resolved",
  752. "statusDetails": {"inCommit": {"commit": commit.key, "repository": repo.name}},
  753. },
  754. format="json",
  755. )
  756. assert response.status_code == 200
  757. assert response.data["status"] == "resolved"
  758. assert response.data["statusDetails"]["inCommit"]["id"] == commit.key
  759. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  760. group = Group.objects.get(id=group.id)
  761. assert group.status == GroupStatus.RESOLVED
  762. link = GroupLink.objects.get(group_id=group.id)
  763. assert link.linked_type == GroupLink.LinkedType.commit
  764. assert link.relationship == GroupLink.Relationship.resolves
  765. assert link.linked_id == commit.id
  766. assert GroupSubscription.objects.filter(
  767. user_id=self.user.id, group=group, is_active=True
  768. ).exists()
  769. activity = Activity.objects.get(group=group, type=ActivityType.SET_RESOLVED_IN_COMMIT.value)
  770. assert activity.data["commit"] == commit.id
  771. def test_set_resolved_in_explicit_commit_released(self):
  772. release = self.create_release(project=self.project)
  773. repo = self.create_repo(project=self.project, name=self.project.name)
  774. commit = self.create_commit(project=self.project, repo=repo, release=release)
  775. group = self.create_group(status=GroupStatus.UNRESOLVED)
  776. self.login_as(user=self.user)
  777. url = f"{self.path}?id={group.id}"
  778. response = self.client.put(
  779. url,
  780. data={
  781. "status": "resolved",
  782. "statusDetails": {"inCommit": {"commit": commit.key, "repository": repo.name}},
  783. },
  784. format="json",
  785. )
  786. assert response.status_code == 200
  787. assert response.data["status"] == "resolved"
  788. assert response.data["statusDetails"]["inCommit"]["id"] == commit.key
  789. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  790. group = Group.objects.get(id=group.id)
  791. assert group.status == GroupStatus.RESOLVED
  792. link = GroupLink.objects.get(group_id=group.id)
  793. assert link.project_id == self.project.id
  794. assert link.linked_type == GroupLink.LinkedType.commit
  795. assert link.relationship == GroupLink.Relationship.resolves
  796. assert link.linked_id == commit.id
  797. assert GroupSubscription.objects.filter(
  798. user_id=self.user.id, group=group, is_active=True
  799. ).exists()
  800. activity = Activity.objects.get(group=group, type=ActivityType.SET_RESOLVED_IN_COMMIT.value)
  801. assert activity.data["commit"] == commit.id
  802. resolution = GroupResolution.objects.get(group=group)
  803. assert resolution.type == GroupResolution.Type.in_release
  804. assert resolution.status == GroupResolution.Status.resolved
  805. def test_set_resolved_in_explicit_commit_missing(self):
  806. repo = self.create_repo(project=self.project, name=self.project.name)
  807. group = self.create_group(status=GroupStatus.UNRESOLVED)
  808. self.login_as(user=self.user)
  809. url = f"{self.path}?id={group.id}"
  810. response = self.client.put(
  811. url,
  812. data={
  813. "status": "resolved",
  814. "statusDetails": {"inCommit": {"commit": "a" * 40, "repository": repo.name}},
  815. },
  816. format="json",
  817. )
  818. assert response.status_code == 400
  819. assert (
  820. response.data["statusDetails"]["inCommit"]["commit"][0]
  821. == "Unable to find the given commit."
  822. )
  823. def test_set_unresolved(self):
  824. release = self.create_release(project=self.project, version="abc")
  825. group = self.create_group(status=GroupStatus.RESOLVED)
  826. GroupResolution.objects.create(group=group, release=release)
  827. self.login_as(user=self.user)
  828. url = f"{self.path}?id={group.id}"
  829. response = self.client.put(url, data={"status": "unresolved"}, format="json")
  830. assert response.status_code == 200
  831. assert response.data == {"status": "unresolved", "statusDetails": {}}
  832. group = Group.objects.get(id=group.id)
  833. assert group.status == GroupStatus.UNRESOLVED
  834. self.assertNoResolution(group)
  835. assert GroupSubscription.objects.filter(
  836. user_id=self.user.id, group=group, is_active=True
  837. ).exists()
  838. def test_set_unresolved_on_snooze(self):
  839. group = self.create_group(status=GroupStatus.IGNORED)
  840. GroupSnooze.objects.create(group=group, until=timezone.now() - timedelta(days=1))
  841. self.login_as(user=self.user)
  842. url = f"{self.path}?id={group.id}"
  843. response = self.client.put(url, data={"status": "unresolved"}, format="json")
  844. assert response.status_code == 200
  845. assert response.data == {"status": "unresolved", "statusDetails": {}}
  846. group = Group.objects.get(id=group.id)
  847. assert group.status == GroupStatus.UNRESOLVED
  848. def test_basic_ignore(self):
  849. group = self.create_group(status=GroupStatus.RESOLVED)
  850. snooze = GroupSnooze.objects.create(group=group, until=timezone.now())
  851. self.login_as(user=self.user)
  852. url = f"{self.path}?id={group.id}"
  853. response = self.client.put(url, data={"status": "ignored"}, format="json")
  854. assert response.status_code == 200
  855. # existing snooze objects should be cleaned up
  856. assert not GroupSnooze.objects.filter(id=snooze.id).exists()
  857. group = Group.objects.get(id=group.id)
  858. assert group.status == GroupStatus.IGNORED
  859. assert response.data == {"status": "ignored", "statusDetails": {}, "inbox": None}
  860. def test_snooze_duration(self):
  861. group = self.create_group(status=GroupStatus.RESOLVED)
  862. self.login_as(user=self.user)
  863. url = f"{self.path}?id={group.id}"
  864. response = self.client.put(
  865. url, data={"status": "ignored", "ignoreDuration": 30}, format="json"
  866. )
  867. assert response.status_code == 200
  868. snooze = GroupSnooze.objects.get(group=group)
  869. now = timezone.now()
  870. assert snooze.count is None
  871. assert snooze.until is not None
  872. assert snooze.until > now + timedelta(minutes=29)
  873. assert snooze.until < now + timedelta(minutes=31)
  874. assert snooze.user_count is None
  875. assert snooze.user_window is None
  876. assert snooze.window is None
  877. assert response.data["status"] == "ignored"
  878. assert response.data["statusDetails"]["ignoreCount"] == snooze.count
  879. assert response.data["statusDetails"]["ignoreWindow"] == snooze.window
  880. assert response.data["statusDetails"]["ignoreUserCount"] == snooze.user_count
  881. assert response.data["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  882. assert response.data["statusDetails"]["ignoreUntil"] == snooze.until
  883. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  884. def test_snooze_count(self):
  885. group = self.create_group(status=GroupStatus.RESOLVED, times_seen=1)
  886. self.login_as(user=self.user)
  887. url = f"{self.path}?id={group.id}"
  888. response = self.client.put(
  889. url, data={"status": "ignored", "ignoreCount": 100}, format="json"
  890. )
  891. assert response.status_code == 200
  892. snooze = GroupSnooze.objects.get(group=group)
  893. assert snooze.count == 100
  894. assert snooze.until is None
  895. assert snooze.user_count is None
  896. assert snooze.user_window is None
  897. assert snooze.window is None
  898. assert snooze.state is not None
  899. assert snooze.state["times_seen"] == 1
  900. assert response.data["status"] == "ignored"
  901. assert response.data["statusDetails"]["ignoreCount"] == snooze.count
  902. assert response.data["statusDetails"]["ignoreWindow"] == snooze.window
  903. assert response.data["statusDetails"]["ignoreUserCount"] == snooze.user_count
  904. assert response.data["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  905. assert response.data["statusDetails"]["ignoreUntil"] == snooze.until
  906. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  907. def test_snooze_user_count(self):
  908. for i in range(10):
  909. event = self.store_event(
  910. data={
  911. "fingerprint": ["put-me-in-group-1"],
  912. "user": {"id": str(i)},
  913. "timestamp": (self.min_ago + timedelta(seconds=i)).isoformat(),
  914. },
  915. project_id=self.project.id,
  916. )
  917. group = Group.objects.get(id=event.group.id)
  918. group.status = GroupStatus.RESOLVED
  919. group.substatus = None
  920. group.save()
  921. self.login_as(user=self.user)
  922. url = f"{self.path}?id={group.id}"
  923. response = self.client.put(
  924. url, data={"status": "ignored", "ignoreUserCount": 10}, format="json"
  925. )
  926. assert response.status_code == 200
  927. snooze = GroupSnooze.objects.get(group=group)
  928. assert snooze.count is None
  929. assert snooze.until is None
  930. assert snooze.user_count == 10
  931. assert snooze.user_window is None
  932. assert snooze.window is None
  933. assert snooze.state is not None
  934. assert snooze.state["users_seen"] == 10
  935. assert response.data["status"] == "ignored"
  936. assert response.data["statusDetails"]["ignoreCount"] == snooze.count
  937. assert response.data["statusDetails"]["ignoreWindow"] == snooze.window
  938. assert response.data["statusDetails"]["ignoreUserCount"] == snooze.user_count
  939. assert response.data["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  940. assert response.data["statusDetails"]["ignoreUntil"] == snooze.until
  941. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  942. def test_set_bookmarked(self):
  943. group1 = self.create_group(status=GroupStatus.RESOLVED)
  944. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  945. group3 = self.create_group(status=GroupStatus.IGNORED)
  946. group4 = self.create_group(
  947. project=self.create_project(slug="foo"),
  948. status=GroupStatus.UNRESOLVED,
  949. )
  950. self.login_as(user=self.user)
  951. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  952. response = self.client.put(url, data={"isBookmarked": "true"}, format="json")
  953. assert response.status_code == 200
  954. assert response.data == {"isBookmarked": True}
  955. bookmark1 = GroupBookmark.objects.filter(group=group1, user_id=self.user.id)
  956. assert bookmark1.exists()
  957. assert GroupSubscription.objects.filter(
  958. user_id=self.user.id, group=group1, is_active=True
  959. ).exists()
  960. bookmark2 = GroupBookmark.objects.filter(group=group2, user_id=self.user.id)
  961. assert bookmark2.exists()
  962. assert GroupSubscription.objects.filter(
  963. user_id=self.user.id, group=group2, is_active=True
  964. ).exists()
  965. bookmark3 = GroupBookmark.objects.filter(group=group3, user_id=self.user.id)
  966. assert not bookmark3.exists()
  967. bookmark4 = GroupBookmark.objects.filter(group=group4, user_id=self.user.id)
  968. assert not bookmark4.exists()
  969. def test_subscription(self):
  970. group1 = self.create_group()
  971. group2 = self.create_group()
  972. group3 = self.create_group()
  973. group4 = self.create_group(project=self.create_project(slug="foo"))
  974. self.login_as(user=self.user)
  975. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  976. response = self.client.put(url, data={"isSubscribed": "true"}, format="json")
  977. assert response.status_code == 200
  978. assert response.data == {"isSubscribed": True, "subscriptionDetails": {"reason": "unknown"}}
  979. assert GroupSubscription.objects.filter(
  980. group=group1, user_id=self.user.id, is_active=True
  981. ).exists()
  982. assert GroupSubscription.objects.filter(
  983. group=group2, user_id=self.user.id, is_active=True
  984. ).exists()
  985. assert not GroupSubscription.objects.filter(group=group3, user_id=self.user.id).exists()
  986. assert not GroupSubscription.objects.filter(group=group4, user_id=self.user.id).exists()
  987. def test_set_public(self):
  988. group1 = self.create_group()
  989. group2 = self.create_group()
  990. self.login_as(user=self.user)
  991. url = f"{self.path}?id={group1.id}&id={group2.id}"
  992. response = self.client.put(url, data={"isPublic": "true"}, format="json")
  993. assert response.status_code == 200
  994. assert response.data["isPublic"] is True
  995. assert "shareId" in response.data
  996. new_group1 = Group.objects.get(id=group1.id)
  997. assert bool(new_group1.get_share_id())
  998. new_group2 = Group.objects.get(id=group2.id)
  999. assert bool(new_group2.get_share_id())
  1000. def test_set_private(self):
  1001. group1 = self.create_group()
  1002. group2 = self.create_group()
  1003. # Manually mark them as shared
  1004. for g in group1, group2:
  1005. GroupShare.objects.create(project_id=g.project_id, group=g)
  1006. assert bool(g.get_share_id())
  1007. self.login_as(user=self.user)
  1008. url = f"{self.path}?id={group1.id}&id={group2.id}"
  1009. response = self.client.put(url, data={"isPublic": "false"}, format="json")
  1010. assert response.status_code == 200
  1011. assert response.data == {"isPublic": False, "shareId": None}
  1012. new_group1 = Group.objects.get(id=group1.id)
  1013. assert not bool(new_group1.get_share_id())
  1014. new_group2 = Group.objects.get(id=group2.id)
  1015. assert not bool(new_group2.get_share_id())
  1016. def test_set_has_seen(self):
  1017. group1 = self.create_group(status=GroupStatus.RESOLVED)
  1018. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  1019. group3 = self.create_group(status=GroupStatus.IGNORED)
  1020. group4 = self.create_group(
  1021. project=self.create_project(slug="foo"),
  1022. status=GroupStatus.UNRESOLVED,
  1023. )
  1024. self.login_as(user=self.user)
  1025. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  1026. response = self.client.put(url, data={"hasSeen": "true"}, format="json")
  1027. assert response.status_code == 200
  1028. assert response.data == {"hasSeen": True}
  1029. r1 = GroupSeen.objects.filter(group=group1, user_id=self.user.id)
  1030. assert r1.exists()
  1031. r2 = GroupSeen.objects.filter(group=group2, user_id=self.user.id)
  1032. assert r2.exists()
  1033. r3 = GroupSeen.objects.filter(group=group3, user_id=self.user.id)
  1034. assert not r3.exists()
  1035. r4 = GroupSeen.objects.filter(group=group4, user_id=self.user.id)
  1036. assert not r4.exists()
  1037. def test_inbox_fields(self):
  1038. group1 = self.create_group(status=GroupStatus.UNRESOLVED)
  1039. add_group_to_inbox(group1, GroupInboxReason.NEW)
  1040. self.login_as(user=self.user)
  1041. url = f"{self.path}?id={group1.id}"
  1042. response = self.client.put(url, data={"status": "resolved"}, format="json")
  1043. assert "inbox" in response.data
  1044. assert response.data["inbox"] is None
  1045. group2 = self.create_group(status=GroupStatus.RESOLVED)
  1046. add_group_to_inbox(group2, GroupInboxReason.NEW)
  1047. self.login_as(user=self.user)
  1048. url = f"{self.path}?id={group2.id}"
  1049. response = self.client.put(url, data={"status": "resolved"}, format="json")
  1050. assert "inbox" not in response.data
  1051. @patch("sentry.issues.merge.uuid4")
  1052. @patch("sentry.issues.merge.merge_groups")
  1053. @patch("sentry.eventstream.backend")
  1054. def test_merge(self, mock_eventstream, merge_groups, mock_uuid4):
  1055. eventstream_state = object()
  1056. mock_eventstream.start_merge = Mock(return_value=eventstream_state)
  1057. mock_uuid4.return_value = self.get_mock_uuid()
  1058. group1 = self.create_group(times_seen=1)
  1059. group2 = self.create_group(times_seen=50)
  1060. group3 = self.create_group(times_seen=2)
  1061. self.create_group()
  1062. self.login_as(user=self.user)
  1063. url = f"{self.path}?id={group1.id}&id={group2.id}&id={group3.id}"
  1064. response = self.client.put(url, data={"merge": "1"}, format="json")
  1065. assert response.status_code == 200
  1066. assert response.data["merge"]["parent"] == str(group2.id)
  1067. assert sorted(response.data["merge"]["children"]) == sorted(
  1068. [str(group1.id), str(group3.id)]
  1069. )
  1070. mock_eventstream.start_merge.assert_called_once_with(
  1071. group1.project_id, [group3.id, group1.id], group2.id
  1072. )
  1073. assert len(merge_groups.mock_calls) == 1
  1074. merge_groups.delay.assert_any_call(
  1075. from_object_ids=[group3.id, group1.id],
  1076. to_object_id=group2.id,
  1077. transaction_id="abc123",
  1078. eventstream_state=eventstream_state,
  1079. )
  1080. @patch("sentry.issues.merge.uuid4")
  1081. @patch("sentry.issues.merge.merge_groups")
  1082. @patch("sentry.eventstream.backend")
  1083. def test_merge_performance_issues(self, mock_eventstream, merge_groups, mock_uuid4):
  1084. eventstream_state = object()
  1085. mock_eventstream.start_merge = Mock(return_value=eventstream_state)
  1086. mock_uuid4.return_value = self.get_mock_uuid()
  1087. group1 = self.create_group(times_seen=1, type=PerformanceSlowDBQueryGroupType.type_id)
  1088. group2 = self.create_group(times_seen=50, type=PerformanceSlowDBQueryGroupType.type_id)
  1089. group3 = self.create_group(times_seen=2, type=PerformanceSlowDBQueryGroupType.type_id)
  1090. self.create_group()
  1091. self.login_as(user=self.user)
  1092. url = f"{self.path}?id={group1.id}&id={group2.id}&id={group3.id}"
  1093. response = self.client.put(url, data={"merge": "1"}, format="json")
  1094. assert response.status_code == 400, response.content
  1095. def test_assign(self):
  1096. group1 = self.create_group(is_public=True)
  1097. group2 = self.create_group(is_public=True)
  1098. user = self.user
  1099. self.login_as(user=user)
  1100. url = f"{self.path}?id={group1.id}"
  1101. response = self.client.put(url, data={"assignedTo": user.username})
  1102. assert response.status_code == 200
  1103. assert response.data["assignedTo"]["id"] == str(user.id)
  1104. assert response.data["assignedTo"]["type"] == "user"
  1105. assert GroupAssignee.objects.filter(group=group1, user_id=user.id).exists()
  1106. assert not GroupAssignee.objects.filter(group=group2, user_id=user.id).exists()
  1107. assert (
  1108. Activity.objects.filter(
  1109. group=group1, user_id=user.id, type=ActivityType.ASSIGNED.value
  1110. ).count()
  1111. == 1
  1112. )
  1113. assert GroupSubscription.objects.filter(
  1114. user_id=user.id, group=group1, is_active=True
  1115. ).exists()
  1116. response = self.client.put(url, data={"assignedTo": ""}, format="json")
  1117. assert response.status_code == 200, response.content
  1118. assert response.data["assignedTo"] is None
  1119. assert not GroupAssignee.objects.filter(group=group1, user_id=user.id).exists()
  1120. def test_assign_non_member(self):
  1121. group = self.create_group(is_public=True)
  1122. member = self.user
  1123. non_member = self.create_user("bar@example.com")
  1124. self.login_as(user=member)
  1125. url = f"{self.path}?id={group.id}"
  1126. response = self.client.put(url, data={"assignedTo": non_member.username}, format="json")
  1127. assert response.status_code == 400, response.content
  1128. def test_assign_team(self):
  1129. self.login_as(user=self.user)
  1130. group = self.create_group()
  1131. other_member = self.create_user("bar@example.com")
  1132. team = self.create_team(
  1133. organization=group.project.organization, members=[self.user, other_member]
  1134. )
  1135. group.project.add_team(team)
  1136. url = f"{self.path}?id={group.id}"
  1137. response = self.client.put(url, data={"assignedTo": f"team:{team.id}"})
  1138. assert response.status_code == 200
  1139. assert response.data["assignedTo"]["id"] == str(team.id)
  1140. assert response.data["assignedTo"]["type"] == "team"
  1141. assert GroupAssignee.objects.filter(group=group, team=team).exists()
  1142. assert Activity.objects.filter(group=group, type=ActivityType.ASSIGNED.value).count() == 1
  1143. assert GroupSubscription.objects.filter(group=group, is_active=True).count() == 2
  1144. response = self.client.put(url, data={"assignedTo": ""}, format="json")
  1145. assert response.status_code == 200, response.content
  1146. assert response.data["assignedTo"] is None
  1147. def test_discard(self):
  1148. group1 = self.create_group(is_public=True)
  1149. group2 = self.create_group(is_public=True)
  1150. group_hash = GroupHash.objects.create(hash="x" * 32, project=group1.project, group=group1)
  1151. user = self.user
  1152. self.login_as(user=user)
  1153. url = f"{self.path}?id={group1.id}"
  1154. with self.tasks():
  1155. with self.feature("projects:discard-groups"):
  1156. response = self.client.put(url, data={"discard": True})
  1157. assert response.status_code == 204
  1158. assert not Group.objects.filter(id=group1.id).exists()
  1159. assert Group.objects.filter(id=group2.id).exists()
  1160. assert GroupHash.objects.filter(id=group_hash.id).exists()
  1161. tombstone = GroupTombstone.objects.get(
  1162. id=GroupHash.objects.get(id=group_hash.id).group_tombstone_id
  1163. )
  1164. assert tombstone.message == group1.message
  1165. assert tombstone.culprit == group1.culprit
  1166. assert tombstone.project == group1.project
  1167. assert tombstone.data == group1.data
  1168. @patch(
  1169. "sentry.models.OrganizationMember.get_scopes",
  1170. return_value=frozenset(s for s in settings.SENTRY_SCOPES if s != "event:admin"),
  1171. )
  1172. def test_discard_requires_events_admin(self, mock_get_scopes):
  1173. group1 = self.create_group(is_public=True)
  1174. user = self.user
  1175. self.login_as(user=user)
  1176. url = f"{self.path}?id={group1.id}"
  1177. with self.tasks(), self.feature("projects:discard-groups"):
  1178. response = self.client.put(url, data={"discard": True})
  1179. assert response.status_code == 400
  1180. assert Group.objects.filter(id=group1.id).exists()
  1181. class GroupDeleteTest(APITestCase, SnubaTestCase):
  1182. @cached_property
  1183. def path(self):
  1184. return f"/api/0/projects/{self.project.organization.slug}/{self.project.slug}/issues/"
  1185. def create_groups(
  1186. self, groups_to_create: Sequence[tuple[int, Project, int | str | None]]
  1187. ) -> list[Group]:
  1188. groups = []
  1189. for status, project, type in groups_to_create:
  1190. if type is None:
  1191. groups.append(self.create_group(status=status, project=project))
  1192. else:
  1193. groups.append(self.create_group(status=status, project=project, type=type))
  1194. for g in groups:
  1195. hash = uuid4().hex
  1196. GroupHash.objects.create(project=g.project, hash=hash, group=g)
  1197. return groups
  1198. def assert_groups_being_deleted(self, groups: Sequence[Group]) -> None:
  1199. for g in groups:
  1200. assert Group.objects.get(id=g.id).status == GroupStatus.PENDING_DELETION
  1201. assert not GroupHash.objects.filter(group_id=g.id).exists()
  1202. # XXX: I do not understand why this update is necessary for the tests to function
  1203. Group.objects.filter(id__in=[g.id for g in groups]).update(status=GroupStatus.UNRESOLVED)
  1204. def assert_groups_are_gone(self, groups: Sequence[Group]) -> None:
  1205. for g in groups:
  1206. assert not Group.objects.filter(id=g.id).exists()
  1207. assert not GroupHash.objects.filter(group_id=g.id).exists()
  1208. def assert_groups_not_deleted(self, groups: Sequence[Group]) -> None:
  1209. for g in groups:
  1210. assert Group.objects.filter(id=g.id).exists()
  1211. assert Group.objects.get(id=g.id).status != GroupStatus.PENDING_DELETION
  1212. assert GroupHash.objects.filter(group_id=g.id).exists()
  1213. @patch("sentry.eventstream.backend")
  1214. def test_delete_by_id(self, mock_eventstream):
  1215. eventstream_state = {"event_stream_state": uuid4()}
  1216. mock_eventstream.start_delete_groups = Mock(return_value=eventstream_state)
  1217. groups = self.create_groups(
  1218. [
  1219. (GroupStatus.RESOLVED, self.project, None),
  1220. (GroupStatus.UNRESOLVED, self.project, None),
  1221. (GroupStatus.IGNORED, self.project, None),
  1222. (GroupStatus.UNRESOLVED, self.create_project(slug="foo"), None),
  1223. ],
  1224. )
  1225. group1, group2, group3, group4 = groups
  1226. self.login_as(user=self.user)
  1227. # Group 4 will not be deleted because it belongs to a different project
  1228. url = f"{self.path}?id={group1.id}&id={group2.id}&id={group4.id}"
  1229. response = self.client.delete(url, format="json")
  1230. mock_eventstream.start_delete_groups.assert_called_once_with(
  1231. group1.project_id, [group1.id, group2.id]
  1232. )
  1233. assert response.status_code == 204
  1234. self.assert_groups_being_deleted([group1, group2])
  1235. # Group 4 is not deleted because it belongs to a different project
  1236. self.assert_groups_not_deleted([group3, group4])
  1237. with self.tasks():
  1238. response = self.client.delete(url, format="json")
  1239. # XXX(markus): Something is sending duplicated replacements to snuba --
  1240. # once from within tasks.deletions.groups and another time from
  1241. # sentry.deletions.defaults.groups
  1242. assert mock_eventstream.end_delete_groups.call_args_list == [
  1243. call(eventstream_state),
  1244. call(eventstream_state),
  1245. ]
  1246. assert response.status_code == 204
  1247. self.assert_groups_are_gone([group1, group2])
  1248. self.assert_groups_not_deleted([group3, group4])
  1249. @patch("sentry.eventstream.backend")
  1250. def test_delete_performance_issue_by_id(self, mock_eventstream):
  1251. eventstream_state = {"event_stream_state": uuid4()}
  1252. mock_eventstream.start_delete_groups = Mock(return_value=eventstream_state)
  1253. group1, group2 = self.create_groups(
  1254. [
  1255. (GroupStatus.RESOLVED, self.project, PerformanceSlowDBQueryGroupType.type_id),
  1256. (GroupStatus.UNRESOLVED, self.project, PerformanceSlowDBQueryGroupType.type_id),
  1257. ],
  1258. )
  1259. self.login_as(user=self.user)
  1260. url = f"{self.path}?id={group1.id}&id={group2.id}"
  1261. response = self.client.delete(url, format="json")
  1262. # We do not support issue platform deletions
  1263. assert response.status_code == 400
  1264. self.assert_groups_not_deleted([group1, group2])
  1265. # We are allowed to delete the groups with the feature flag enabled
  1266. with Feature({"organizations:issue-platform-deletion": True}), self.tasks():
  1267. response = self.client.delete(url, format="json")
  1268. assert response.status_code == 204
  1269. self.assert_groups_are_gone([group1, group2])
  1270. def test_bulk_delete(self):
  1271. groups_to_create = []
  1272. for _ in range(10, 41):
  1273. groups_to_create.append((GroupStatus.RESOLVED, self.project, None))
  1274. groups = self.create_groups(groups_to_create)
  1275. self.login_as(user=self.user)
  1276. # if query is '' it defaults to is:unresolved
  1277. url = self.path + "?query="
  1278. response = self.client.delete(url, format="json")
  1279. assert response.status_code == 204
  1280. self.assert_groups_being_deleted(groups)
  1281. with self.tasks():
  1282. response = self.client.delete(url, format="json")
  1283. assert response.status_code == 204
  1284. self.assert_groups_are_gone(groups)
  1285. def test_bulk_delete_performance_issues(self):
  1286. groups_to_create = []
  1287. for _ in range(10, 41):
  1288. groups_to_create.append(
  1289. (GroupStatus.RESOLVED, self.project, PerformanceSlowDBQueryGroupType.type_id)
  1290. )
  1291. groups = self.create_groups(groups_to_create)
  1292. self.login_as(user=self.user)
  1293. # if query is '' it defaults to is:unresolved
  1294. url = self.path + "?query="
  1295. response = self.client.delete(url, format="json")
  1296. # We do not support issue platform deletions
  1297. assert response.status_code == 400
  1298. self.assert_groups_not_deleted(groups)
  1299. # We are allowed to delete the groups with the feature flag enabled
  1300. with Feature({"organizations:issue-platform-deletion": True}), self.tasks():
  1301. response = self.client.delete(url, format="json")
  1302. assert response.status_code == 204
  1303. self.assert_groups_are_gone(groups)