mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-19 19:49:44 -06:00
Like e45c61d6f0, but for .project.
I originally thought `SET_NULL` would be a good way to "do stuff later", but
that's only so the degree that [1] updates are cheaper than deletes and [2]
2nd-order effects (further deletes in the dep-tree) are avoided.
Now that we have explicit Project-deletion (deps-first, delayed, properly batched)
the SET_NULL behavior is always a no-op (but with cost in queries).
As a result, in the test for project deletion (which has deletes for many
of the altered models), the following 12 queries are no longer done:
```
SELECT "projects_project"."id", [..many fields..] FROM "projects_project" WHERE "projects_project"."id" = 1
DELETE FROM "projects_projectmembership" WHERE "projects_projectmembership"."project_id" IN (1)
DELETE FROM "alerts_messagingserviceconfig" WHERE "alerts_messagingserviceconfig"."project_id" IN (1)
UPDATE "releases_release" SET "project_id" = NULL WHERE "releases_release"."project_id" IN (1)
UPDATE "issues_issue" SET "project_id" = NULL WHERE "issues_issue"."project_id" IN (1)
UPDATE "issues_grouping" SET "project_id" = NULL WHERE "issues_grouping"."project_id" IN (1)
UPDATE "events_event" SET "project_id" = NULL WHERE "events_event"."project_id" IN (1)
UPDATE "tags_tagkey" SET "project_id" = NULL WHERE "tags_tagkey"."project_id" IN (1)
UPDATE "tags_tagvalue" SET "project_id" = NULL WHERE "tags_tagvalue"."project_id" IN (1)
UPDATE "tags_eventtag" SET "project_id" = NULL WHERE "tags_eventtag"."project_id" IN (1)
UPDATE "tags_issuetag" SET "project_id" = NULL WHERE "tags_issuetag"."project_id" IN (1)
```
166 lines
7.3 KiB
Python
166 lines
7.3 KiB
Python
import json
|
|
import re
|
|
import uuid
|
|
|
|
from semver.version import Version
|
|
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.db.models.functions import Concat
|
|
from django.db.models import Value
|
|
|
|
from issues.models import Issue, TurningPoint, TurningPointKind
|
|
|
|
|
|
RE_PACKAGE_VERSION = re.compile('((?P<package>.*)[@])?(?P<version>.*)')
|
|
|
|
|
|
def is_valid_semver(full_version):
|
|
try:
|
|
version = RE_PACKAGE_VERSION.match(full_version).groupdict()["version"]
|
|
Version.parse(version)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def release_sort_key(release):
|
|
return (
|
|
release.sort_epoch,
|
|
Version.parse(release.semver) if release.is_semver else release.date_released
|
|
)
|
|
|
|
|
|
def ordered_releases(*filter_args, **filter_kwargs):
|
|
"""Sorting Release objects in code (as opposed to in-DB) to facilitate semver-based sorting when applicable"""
|
|
releases = Release.objects.filter(*filter_args, **filter_kwargs)
|
|
|
|
return sorted(releases, key=release_sort_key)
|
|
|
|
|
|
class Release(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
|
|
# sentry does releases per-org; we don't follow that example. our belief is basically: [1] in reality releases are
|
|
# per software package and a software package is basically a bugsink project and [2] any cross-project-per-org
|
|
# analysis you might do is more likely to be in the realm of "transactions", something we don't want to support.
|
|
project = models.ForeignKey("projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)
|
|
|
|
# full version as provided by either implicit (per-event) or explicit (some API) means, including package name
|
|
# max_length matches Even.release (which is deduced from Sentry)
|
|
version = models.CharField(max_length=250, null=False, blank=False)
|
|
|
|
date_released = models.DateTimeField(default=timezone.now)
|
|
|
|
semver = models.CharField(max_length=255, null=False, editable=False)
|
|
is_semver = models.BooleanField(editable=False)
|
|
|
|
# sort_epoch is a way to ensure that we can sort releases alternatingly by date and by semver. The idea is that
|
|
# whenever we switch from one to the other, we increment the epoch. This way, we can sort releases by epoch first
|
|
# and then by date or semver. i.e. when transitioning between version schemes, ordering will "Just Work".
|
|
# The typical scenario involves a straightforward shift from a state of "being too lazy to set up and merely using
|
|
# hashes" to adopting semver.
|
|
sort_epoch = models.IntegerField(editable=False)
|
|
|
|
def save(self, *args, **kwargs):
|
|
if self.is_semver is None:
|
|
self.is_semver = is_valid_semver(self.version)
|
|
if self.is_semver:
|
|
self.semver = RE_PACKAGE_VERSION.match(self.version)["version"]
|
|
|
|
# whether doing this epoch setting inline on-creation is a smart idea... will become clear soon enough.
|
|
any_release_from_last_epoch = Release.objects.filter(project=self.project).order_by("sort_epoch").last()
|
|
if any_release_from_last_epoch is None:
|
|
self.sort_epoch = 0
|
|
elif self.is_semver == any_release_from_last_epoch.is_semver:
|
|
self.sort_epoch = any_release_from_last_epoch.sort_epoch
|
|
else:
|
|
self.sort_epoch = any_release_from_last_epoch.sort_epoch + 1
|
|
|
|
super(Release, self).save(*args, **kwargs)
|
|
|
|
class Meta:
|
|
unique_together = ("project", "version")
|
|
|
|
indexes = [
|
|
models.Index(fields=["sort_epoch"]),
|
|
]
|
|
|
|
def get_short_version(self):
|
|
if self.version == "":
|
|
# the reason for this little hack is to have something show up in the UI for this case. I 'assume' (mother
|
|
# of all ...) that in most reasonable cases we actually don't show releases if there's an empty release
|
|
# (i.e. for the single empty release we really shouldn't, because then project.has_releases should be false)
|
|
# but I've seen at least one case where you still have to show something even for the empty release: when
|
|
# switching back to "no release". (that's why I say "most reasonable cases"). (observed in testing, because
|
|
# test-events generally wildly vary in the release info they carry).
|
|
return "«no version»"
|
|
if self.is_semver:
|
|
return self.version
|
|
return self.version[:12]
|
|
|
|
|
|
def create_release_if_needed(project, version, event, issue=None):
|
|
if version is None:
|
|
# because `create_release_if_needed` is called with Issue.release (non-nullable), the below "won't happen"
|
|
raise ValueError('The None-like version must be the empty string')
|
|
|
|
# NOTE: we even create a Release for the empty release here; we need the associated info (date_released) if a
|
|
# real release is ever created later.
|
|
|
|
version = sanitize_version(version)
|
|
|
|
release, release_created = Release.objects.get_or_create(project=project, version=version)
|
|
if release_created and version != "":
|
|
if not project.has_releases:
|
|
project.has_releases = True
|
|
project.save()
|
|
|
|
if release == project.get_latest_release():
|
|
resolved_by_next_qs = Issue.objects.filter(project=project, is_resolved_by_next_release=True)
|
|
|
|
# NOTE: once we introduce an explicit way of creating releases (not event-based) we can not rely on a
|
|
# triggering event anymore for our timestamp.
|
|
|
|
TurningPoint.objects.bulk_create([TurningPoint(
|
|
project=project,
|
|
issue=issue, kind=TurningPointKind.NEXT_MATERIALIZED, triggering_event=event,
|
|
metadata=json.dumps({"actual_release": release.version}), timestamp=event.ingested_at)
|
|
for issue in resolved_by_next_qs
|
|
])
|
|
event.never_evict = True # .save() will be called by the caller of this function
|
|
|
|
resolved_by_next_qs.update(
|
|
fixed_at=Concat("fixed_at", Value(release.version + "\n")),
|
|
is_resolved_by_next_release=False,
|
|
)
|
|
|
|
if issue is not None and issue.is_resolved_by_next_release:
|
|
# a bit of a hack: if we have an in-memory issue, we must update it as well.
|
|
issue.fixed_at = issue.fixed_at + release.version + "\n"
|
|
issue.is_resolved_by_next_release = False
|
|
|
|
return release
|
|
|
|
|
|
def sanitize_version(version):
|
|
"""
|
|
Implements the folllowing restrictions are from the Sentry documentation:
|
|
|
|
> There are a few restrictions -- the release name cannot:
|
|
>
|
|
> - contain newlines, tabulator characters, forward slashes(/), or back slashes(\\)
|
|
> - be (in their entirety) period (.), double period (..), or space ( )
|
|
> - exceed 200 characters
|
|
|
|
It does so as sanitize-dont-raise, i.e. it will return a sanitized version of the input string, but will not raise
|
|
an exception if the input string is invalid. Reason: we care about having valid data (and we rely e.g. on the lack
|
|
of newlines for our parsing), but we never want an invalid input to lead to discarded events. And there's no one
|
|
to 'read' a rejected event and fix it.
|
|
"""
|
|
|
|
step_1 = version.replace("\n", "").replace("\t", "").replace("/", "").replace("\\", "")
|
|
step_2 = "sanitized" if step_1 in (".", "..", " ") else step_1
|
|
step_3 = step_2[:200]
|
|
return step_3
|