├── .github ├── joshuarli-ydiff.png └── ymattw-ydiff.png ├── .gitignore ├── Makefile ├── README.md ├── generate-tests.sh ├── test.sh ├── tests ├── dotfiles │ └── 1 │ │ ├── in.diff │ │ └── out └── sentry │ ├── 1 │ ├── in.diff │ └── out │ ├── 2 │ ├── in.diff │ └── out │ ├── 3 │ ├── in.diff │ └── out │ ├── 4 │ ├── in.diff │ └── out │ ├── 5 │ ├── in.diff │ └── out │ ├── 6 │ ├── in.diff │ └── out │ ├── 7 │ ├── in.diff │ └── out │ ├── 8 │ ├── in.diff │ └── out │ ├── 9 │ ├── in.diff │ └── out │ └── 10 │ ├── in.diff │ └── out ├── ydiff └── ydiff.c /.github/joshuarli-ydiff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuarli/ydiff/d87bd31ac24a78255e71271d159631f967ab0a11/.github/joshuarli-ydiff.png -------------------------------------------------------------------------------- /.github/ymattw-ydiff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuarli/ydiff/d87bd31ac24a78255e71271d159631f967ab0a11/.github/ymattw-ydiff.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ydiff-bin* 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: clean 3 | make ydiff-bin 4 | 5 | .PHONY: clean 6 | clean: 7 | rm -f ydiff.c ydiff-bin ydiff-bin-static 8 | 9 | ydiff.c: ydiff 10 | cython --embed -3 -D --fast-fail ydiff 11 | 12 | .PHONY: fast 13 | fast: clean 14 | make ydiff.c 15 | 16 | ydiff-bin: ydiff.c 17 | # Some other incantations that might work (or similar): 18 | # gcc -O3 -flto $^ $$(python3-config --cflags --ldflags) -o $@ 19 | # gcc -O3 -flto $^ -I/usr/include/python3.8 -lpython3.8 -o $@ 20 | gcc -O3 -flto $^ $$(pkg-config --cflags --libs python3) -o $@ 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # joshuarli's ydiff fork 2 | 3 | **UNMAINTAINED:** I use [delta](https://github.com/dandavison/delta) now, [here's my configuration](https://github.com/joshuarli/dotfiles/commit/f6db90ea70642edecca6193065302f67ce36fb1d). 4 | 5 | This is [ydiff](https://github.com/ymattw/ydiff), distilled down to the very core. 6 | 7 | It's been heavily modified and stripped down to a very specific feature subset: 8 | 9 | - py3.6+ 10 | - assumes utf-8 11 | - only read git unified diffs from stdin 12 | - only side-by-side rendering 13 | 14 | And, modified to be visually less noisy. 15 | 16 | * [Screenshots](#Screenshots) 17 | * [Performance](#Performance) 18 | * [Installation](#Installation) 19 | 20 | 21 | ## Screenshots 22 | 23 | Here's what it looks like: 24 | 25 | 26 | 27 | Compared to before (the automatic width detection failed lol): 28 | 29 | 30 | 31 | 32 | ## Performance 33 | 34 | I'm definitely not smart enough to port it to more performant languages, 35 | but I've made it quite fast: 36 | 37 | ``` 38 | $ git log --patch | pv > /tmp/sentry-git-log 39 | ... 17.4MiB/s ... 40 | -rw-r--r-- 1 josh josh 573M Jun 27 00:00 /tmp/sentry-git-log 41 | 42 | $ ./ymattw-ydiff.py --wrap --side-by-side --color=always --pager=cat \ 43 | < /tmp/sentry-git-log | pv >/dev/null 44 | 45 | ... 761KiB/s ... 46 | 47 | $ ./ydiff < /tmp/sentry-git-log | pv >/dev/null 48 | 49 | ... 1.37MiB/s ... 50 | ``` 51 | 52 | I've also optimized it to build as a relatively clean Cython binary, 53 | which runs a bit faster but more importantly is faster to start up: 54 | 55 | ``` 56 | $ ./ydiff-bin < /tmp/sentry-git-log | pv >/dev/null 57 | ... 1.60MiB/s ... 58 | 59 | $ hyperfine --warmup 3 "./ydiff < tests/sentry/1/in.diff >/dev/null" 60 | Benchmark #1: ./ydiff < tests/sentry/1/in.diff >/dev/null 61 | Time (mean ± σ): 115.3 ms ± 2.3 ms [User: 53.1 ms, System: 43.4 ms] 62 | Range (min … max): 112.5 ms … 123.7 ms 24 runs 63 | 64 | $ hyperfine --warmup 3 "./ydiff-bin < tests/sentry/1/in.diff >/dev/null" 65 | Benchmark #1: ./ydiff-bin < tests/sentry/1/in.diff >/dev/null 66 | Time (mean ± σ): 35.8 ms ± 1.9 ms [User: 26.3 ms, System: 7.8 ms] 67 | Range (min … max): 32.8 ms … 40.2 ms 74 runs 68 | ``` 69 | 70 | ## Installation 71 | 72 | Just download [this](https://raw.githubusercontent.com/joshuarli/ydiff/master/ydiff) to anywhere on your PATH, then set the following git config: 73 | 74 | git config --global pager.diff "ydiff | less" 75 | git config --global pager.show "ydiff | less" 76 | git config --global pager.log less 77 | git config --global color.diff never 78 | 79 | I also recommend setting `LESS=FSXR`. `less` will use those flags by default. You could alternatively put those in your git config. 80 | 81 | Optionally, if you have a C compiler, you can compile the Cython binary with `make ydiff-bin`. 82 | -------------------------------------------------------------------------------- /generate-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd -P)" 6 | 7 | mkdir -p "${HERE}/tests" 8 | 9 | cd "$1" 10 | [[ ! -d ".git" ]] && { 11 | echo "${1} doesn't seem to be a git repository." 12 | echo "usage: ${0} path/to/git/repo [# commits from SHA] [SHA]" 13 | exit 1 14 | } 15 | 16 | d="${HERE}/tests/$(basename $PWD)" 17 | mkdir -p "$d" 18 | 19 | if [[ -z "$2" ]]; then 20 | read -n1 -p "You're about to generate a massive test from repo ${1}. Are you sure? (ENTER) " 21 | mkdir -p "${d}/1" 22 | git log --patch > "${d}/1/in.diff" 23 | YDIFF_WIDTH=130 "${HERE}/ydiff" -s < "${d}/1/in.diff" > "${d}/1/out" 24 | exit 25 | fi 26 | 27 | # sentry 10 3265a18241ed4f3e62642c532f0278be792e8c90 28 | start="${3:-HEAD}" 29 | git checkout "$start" 30 | for i in $(seq 1 "$2"); do 31 | mkdir -pv "${d}/${i}" 32 | git show --no-ext-diff "HEAD~${i}" > "${d}/${i}/in.diff" 33 | YDIFF_WIDTH=130 "${HERE}/ydiff" -s < "${d}/${i}/in.diff" > "${d}/${i}/out" 34 | done 35 | git checkout - 36 | 37 | # TODO: git log --patch on smallish sample repos to generate HUGE diffs, 38 | # could be useful for perf testing 39 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # SIGINT will only stop 1 loop iter, we want to abort entire test script 4 | trap 'exit' INT 5 | 6 | make ydiff-bin || exit 7 | 8 | suite="$1" 9 | 10 | for t in tests/"$suite"/*; do 11 | echo "test ${t}: ydiff" 12 | YDIFF_WIDTH=130 time ./ydiff < "${t}/in.diff" > /tmp/out 13 | cmp "${t}/out" /tmp/out || diff "${t}/out" /tmp/out 14 | 15 | echo "test ${t}: ydiff-bin" 16 | YDIFF_WIDTH=130 time ./ydiff-bin < "${t}/in.diff" > /tmp/out 17 | cmp "${t}/out" /tmp/out || diff "${t}/out" /tmp/out 18 | done 19 | -------------------------------------------------------------------------------- /tests/sentry/10/in.diff: -------------------------------------------------------------------------------- 1 | commit 9342cbe0c4c8adb0834b08d3b6f86429a26ef931 2 | Author: William Mak 3 | Date: Mon Jun 22 13:49:36 2020 -0400 4 | 5 | fix(perf-views): Key transactions couldnt query on aggregates (#19424) 6 | 7 | - `user_aggregate_conditions` was missing on the key transactions query 8 | 9 | diff --git a/src/sentry/snuba/discover.py b/src/sentry/snuba/discover.py 10 | index 8438573331..a6e360aded 100644 11 | --- a/src/sentry/snuba/discover.py 12 | +++ b/src/sentry/snuba/discover.py 13 | @@ -626,6 +626,7 @@ def key_transaction_query(selected_columns, user_query, params, orderby, referre 14 | orderby=orderby, 15 | referrer=referrer, 16 | conditions=key_transaction_conditions(queryset), 17 | + use_aggregate_conditions=True, 18 | ) 19 | 20 | 21 | diff --git a/tests/snuba/api/endpoints/test_discover_key_transactions.py b/tests/snuba/api/endpoints/test_discover_key_transactions.py 22 | index bd30c252a1..46b6676cb8 100644 23 | --- a/tests/snuba/api/endpoints/test_discover_key_transactions.py 24 | +++ b/tests/snuba/api/endpoints/test_discover_key_transactions.py 25 | @@ -376,6 +376,52 @@ class KeyTransactionTest(APITestCase, SnubaTestCase): 26 | assert len(data) == 1 27 | assert data[0]["transaction"] == event_data["transaction"] 28 | 29 | + @patch("django.utils.timezone.now") 30 | + def test_get_transaction_with_aggregate_query(self, mock_now): 31 | + mock_now.return_value = before_now().replace(tzinfo=pytz.utc) 32 | + event_data = load_data("transaction", timestamp=before_now(minutes=1)) 33 | + 34 | + transactions = [ 35 | + ("127.0.0.1", "/foo_transaction/", 2), 36 | + ("192.168.0.1", "/blah_transaction/", 3), 37 | + ] 38 | + 39 | + for ip_address, transaction, count in transactions: 40 | + event_data["transaction"] = transaction 41 | + event_data["user"]["ip_address"] = ip_address 42 | + for _ in range(count): 43 | + self.store_event(data=event_data, project_id=self.project.id) 44 | + KeyTransaction.objects.create( 45 | + owner=self.user, 46 | + organization=self.org, 47 | + transaction=event_data["transaction"], 48 | + project=self.project, 49 | + ) 50 | + 51 | + with self.feature("organizations:performance-view"): 52 | + url = reverse("sentry-api-0-organization-key-transactions", args=[self.org.slug]) 53 | + response = self.client.get( 54 | + url, 55 | + { 56 | + "project": [self.project.id], 57 | + "orderby": "transaction", 58 | + "query": "count():>2", 59 | + "field": [ 60 | + "transaction", 61 | + "transaction_status", 62 | + "project", 63 | + "count()", 64 | + "failure_rate()", 65 | + "percentile(transaction.duration, 0.95)", 66 | + ], 67 | + }, 68 | + ) 69 | + 70 | + assert response.status_code == 200 71 | + data = response.data["data"] 72 | + assert len(data) == 1 73 | + assert data[0]["transaction"] == event_data["transaction"] 74 | + 75 | @patch("django.utils.timezone.now") 76 | def test_get_transaction_with_backslash_and_quotes(self, mock_now): 77 | mock_now.return_value = before_now().replace(tzinfo=pytz.utc) 78 | -------------------------------------------------------------------------------- /tests/sentry/10/out: -------------------------------------------------------------------------------- 1 | commit 9342cbe0c4c8adb0834b08d3b6f86429a26ef931 2 | Author: William Mak 3 | Date: Mon Jun 22 13:49:36 2020 -0400 4 |  5 |  fix(perf-views): Key transactions couldnt query on aggregates (#19424) 6 |  7 |  - `user_aggregate_conditions` was missing on the key transactions query 8 |  9 | diff --git a/src/sentry/snuba/discover.py b/src/sentry/snuba/discover.py 10 | index 8438573331..a6e360aded 100644 11 | --- a/src/sentry/snuba/discover.py 12 | +++ b/src/sentry/snuba/discover.py 13 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈626 orderby=orderby, 626  orderby=orderby, 14 | 627 referrer=referrer, 627  referrer=referrer, 15 | 628 conditions=key_transaction_conditions(queryset), 628  conditions=key_transaction_conditions(queryset), 16 |   629  use_aggregate_conditions=True, 17 | 629 ) 630  ) 18 | 630 631  19 | 631 632  20 | diff --git a/tests/snuba/api/endpoints/test_discover_key_transactions.py b/tests/snuba/api/endpoints/test_discover_key_transactions.py 21 | index bd30c252a1..46b6676cb8 100644 22 | --- a/tests/snuba/api/endpoints/test_discover_key_transactions.py 23 | +++ b/tests/snuba/api/endpoints/test_discover_key_transactions.py 24 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈  376  assert len(data) == 1 25 |   377  assert data[0]["transaction"] == event_data["transac 26 |     tion"] 27 |   378  28 |   379  @patch("django.utils.timezone.now") 29 |   380  def test_get_transaction_with_aggregate_query(self, mock 30 |     _now): 31 |   381  mock_now.return_value = before_now().replace(tzinfo= 32 |     pytz.utc) 33 |   382  event_data = load_data("transaction", timestamp=befo 34 |     re_now(minutes=1)) 35 |   383  36 |   384  transactions = [ 37 |   385  ("127.0.0.1", "/foo_transaction/", 2), 38 |   386  ("192.168.0.1", "/blah_transaction/", 3), 39 |   387  ] 40 |   388  41 |   389  for ip_address, transaction, count in transactions: 42 |   390  event_data["transaction"] = transaction 43 |   391  event_data["user"]["ip_address"] = ip_address 44 |   392  for _ in range(count): 45 |   393  self.store_event(data=event_data, project_id 46 |     =self.project.id) 47 |   394  KeyTransaction.objects.create( 48 |   395  owner=self.user, 49 |   396  organization=self.org, 50 |   397  transaction=event_data["transaction"], 51 |   398  project=self.project, 52 |   399  ) 53 |   400  54 |   401  with self.feature("organizations:performance-view"): 55 |   402  url = reverse("sentry-api-0-organization-key-tra 56 |     nsactions", args=[self.org.slug]) 57 |   403  response = self.client.get( 58 |   404  url, 59 |   405  { 60 |   406  "project": [self.project.id], 61 |   407  "orderby": "transaction", 62 |   408  "query": "count():>2", 63 |   409  "field": [ 64 |   410  "transaction", 65 |   411  "transaction_status", 66 |   412  "project", 67 |   413  "count()", 68 |   414  "failure_rate()", 69 |   415  "percentile(transaction.duration, 0. 70 |     95)", 71 |   416  ], 72 |   417  }, 73 |   418  ) 74 |   419  75 |   420  assert response.status_code == 200 76 |   421  data = response.data["data"] 77 | 376 assert len(data) == 1 422  assert len(data) == 1 78 | 377 assert data[0]["transaction"] == event_data["transac 423  assert data[0]["transaction"] == event_data["transac 79 |   tion"]   tion"] 80 | 378 424  81 | 379 @patch("django.utils.timezone.now") 425  @patch("django.utils.timezone.now") 82 | 380 def test_get_transaction_with_backslash_and_quotes(self, 426  def test_get_transaction_with_backslash_and_quotes(self, 83 |   mock_now):   mock_now): 84 | 381 mock_now.return_value = before_now().replace(tzinfo= 427  mock_now.return_value = before_now().replace(tzinfo= 85 |   pytz.utc)   pytz.utc) 86 | -------------------------------------------------------------------------------- /tests/sentry/2/in.diff: -------------------------------------------------------------------------------- 1 | commit 32d42cb465e28d19c8ca3d5da17b31236c8753b4 2 | Author: Matej Minar 3 | Date: Mon Jun 22 22:51:22 2020 +0200 4 | 5 | fix(ui): Do not send query param to artifacts endpoint (#19495) 6 | 7 | diff --git a/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx b/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx 8 | index ce7761ed79..ffda3848d9 100644 9 | --- a/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx 10 | +++ b/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx 11 | @@ -65,7 +65,7 @@ class ReleaseArtifacts extends React.Component { 12 | this.props.api.request(this.getFilesEndpoint(), { 13 | method: 'GET', 14 | // We need to omit global selection header url params because they are not supported 15 | - data: omit(this.props.location.query, Object.values(URL_PARAM)), 16 | + data: omit(this.props.location.query, [...Object.values(URL_PARAM), 'query']), 17 | success: (data, _, jqXHR) => { 18 | this.setState({ 19 | error: false, 20 | -------------------------------------------------------------------------------- /tests/sentry/2/out: -------------------------------------------------------------------------------- 1 | commit 32d42cb465e28d19c8ca3d5da17b31236c8753b4 2 | Author: Matej Minar 3 | Date: Mon Jun 22 22:51:22 2020 +0200 4 |  5 |  fix(ui): Do not send query param to artifacts endpoint (#19495) 6 |  7 | diff --git a/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx b/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx 8 | index ce7761ed79..ffda3848d9 100644 9 | --- a/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx 10 | +++ b/src/sentry/static/sentry/app/views/releases/detail/releaseArtifacts.jsx 11 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈65 this.props.api.request(this.getFilesEndpoint(), { 65  this.props.api.request(this.getFilesEndpoint(), { 12 | 66 method: 'GET', 66  method: 'GET', 13 | 67 // We need to omit global selection header url params b 67  // We need to omit global selection header url params b 14 |   ecause they are not supported   ecause they are not supported 15 | 68  data: omit(this.props.location.query, Object.values(URL 68  data: omit(this.props.location.query, [...Object.values 16 |   _PARAM)),   (URL_PARAM), 'query']), 17 | 69 success: (data, _, jqXHR) => { 69  success: (data, _, jqXHR) => { 18 | 70 this.setState({ 70  this.setState({ 19 | 71 error: false, 71  error: false, 20 | -------------------------------------------------------------------------------- /tests/sentry/3/in.diff: -------------------------------------------------------------------------------- 1 | commit a4deb0adb2b7699bd3ac228b3e58a782f9af2a9c 2 | Author: Dan Fuller 3 | Date: Mon Jun 22 13:40:54 2020 -0700 4 | 5 | feat(metric_alerts): Start writing activity to alerts to note when the alert actually started (#19358) 6 | 7 | * feat(metric_alerts): Start writing activity to alerts to note when the alert actually started 8 | 9 | We currently write an activit to alerts to mark when it was created. This isn't the same as when the 10 | alert started, which will always be some amount of time before we created the alert. This adds an 11 | extra activity to note the actual start time, which makes it clear to the user what happened, and 12 | gives us a date that matches up with the start marker on the graph. 13 | 14 | * Adding incident activity support 15 | 16 | * Adding incident activity support 17 | 18 | Co-authored-by: Chris Fuller 19 | 20 | diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py 21 | index f80c7b452a..ffab9f9023 100644 22 | --- a/src/sentry/incidents/logic.py 23 | +++ b/src/sentry/incidents/logic.py 24 | @@ -97,6 +97,9 @@ def create_incident( 25 | sender=type(incident_project), instance=incident_project, created=True 26 | ) 27 | 28 | + create_incident_activity( 29 | + incident, IncidentActivityType.STARTED, user=user, date_added=date_started 30 | + ) 31 | create_incident_activity(incident, IncidentActivityType.DETECTED, user=user) 32 | analytics.record( 33 | "incident.created", 34 | @@ -200,11 +203,15 @@ def create_incident_activity( 35 | previous_value=None, 36 | comment=None, 37 | mentioned_user_ids=None, 38 | + date_added=None, 39 | ): 40 | if activity_type == IncidentActivityType.COMMENT and user: 41 | subscribe_to_incident(incident, user) 42 | value = six.text_type(value) if value is not None else value 43 | previous_value = six.text_type(previous_value) if previous_value is not None else previous_value 44 | + kwargs = {} 45 | + if date_added: 46 | + kwargs["date_added"] = date_added 47 | activity = IncidentActivity.objects.create( 48 | incident=incident, 49 | type=activity_type.value, 50 | @@ -212,6 +219,7 @@ def create_incident_activity( 51 | value=value, 52 | previous_value=previous_value, 53 | comment=comment, 54 | + **kwargs 55 | ) 56 | 57 | if mentioned_user_ids: 58 | diff --git a/src/sentry/incidents/models.py b/src/sentry/incidents/models.py 59 | index 0686df61eb..ff6f92728a 100644 60 | --- a/src/sentry/incidents/models.py 61 | +++ b/src/sentry/incidents/models.py 62 | @@ -249,6 +249,7 @@ class IncidentActivityType(Enum): 63 | DETECTED = 1 64 | STATUS_CHANGE = 2 65 | COMMENT = 3 66 | + STARTED = 4 67 | 68 | 69 | class IncidentActivity(Model): 70 | diff --git a/src/sentry/incidents/subscription_processor.py b/src/sentry/incidents/subscription_processor.py 71 | index 487b8ed1b9..3e2d5f07f3 100644 72 | --- a/src/sentry/incidents/subscription_processor.py 73 | +++ b/src/sentry/incidents/subscription_processor.py 74 | @@ -204,6 +204,9 @@ class SubscriptionProcessor(object): 75 | self.alert_rule.name, 76 | alert_rule=self.alert_rule, 77 | date_started=detected_at, 78 | + # TODO: This should probably be either the current time or the 79 | + # message time. Current time likely makes most sense, since this is 80 | + # when we actually noticed the problem. 81 | date_detected=detected_at, 82 | projects=[self.subscription.project], 83 | ) 84 | diff --git a/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx b/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx 85 | index 3e49261085..2e50d1dc88 100644 86 | --- a/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx 87 | +++ b/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx 88 | @@ -37,6 +37,7 @@ class StatusItem extends React.Component { 89 | const {activity, authorName, incident, showTime} = this.props; 90 | 91 | const isDetected = activity.type === IncidentActivityType.DETECTED; 92 | + const isStarted = activity.type === IncidentActivityType.STARTED; 93 | const isClosed = 94 | activity.type === IncidentActivityType.STATUS_CHANGE && 95 | activity.value === `${IncidentStatus.CLOSED}`; 96 | @@ -44,7 +45,7 @@ class StatusItem extends React.Component { 97 | activity.type === IncidentActivityType.STATUS_CHANGE && !isClosed; 98 | 99 | // Unknown activity, don't render anything 100 | - if (!isDetected && !isClosed && !isTriggerChange) { 101 | + if (!isStarted && !isDetected && !isClosed && !isTriggerChange) { 102 | return null; 103 | } 104 | 105 | @@ -83,12 +84,11 @@ class StatusItem extends React.Component { 106 | })} 107 | {isDetected && 108 | (incident?.alertRule 109 | - ? tct('[user] was triggered', { 110 | - user: {incident.alertRule.name}, 111 | - }) 112 | + ? t('Alert was created') 113 | : tct('[user] created an alert', { 114 | user: {authorName}, 115 | }))} 116 | + {isStarted && t('Trigger conditions were met')} 117 | 118 | } 119 | date={getDynamicText({value: activity.dateCreated, fixed: new Date(0)})} 120 | diff --git a/src/sentry/static/sentry/app/views/alerts/types.tsx b/src/sentry/static/sentry/app/views/alerts/types.tsx 121 | index 5f4288c19f..02a55a6128 100644 122 | --- a/src/sentry/static/sentry/app/views/alerts/types.tsx 123 | +++ b/src/sentry/static/sentry/app/views/alerts/types.tsx 124 | @@ -66,10 +66,11 @@ export enum IncidentType { 125 | } 126 | 127 | export enum IncidentActivityType { 128 | - CREATED, 129 | - DETECTED, 130 | - STATUS_CHANGE, 131 | - COMMENT, 132 | + CREATED = 0, 133 | + DETECTED = 1, 134 | + STATUS_CHANGE = 2, 135 | + COMMENT = 3, 136 | + STARTED = 4, 137 | } 138 | 139 | export enum IncidentStatus { 140 | diff --git a/tests/sentry/incidents/test_logic.py b/tests/sentry/incidents/test_logic.py 141 | index 1346641edd..503a02b58c 100644 142 | --- a/tests/sentry/incidents/test_logic.py 143 | +++ b/tests/sentry/incidents/test_logic.py 144 | @@ -83,7 +83,7 @@ class CreateIncidentTest(TestCase): 145 | def test_simple(self): 146 | incident_type = IncidentType.ALERT_TRIGGERED 147 | title = "hello" 148 | - date_started = timezone.now() 149 | + date_started = timezone.now() - timedelta(minutes=5) 150 | alert_rule = create_alert_rule( 151 | self.organization, [self.project], "hello", "level:error", "count()", 10, 1 152 | ) 153 | @@ -107,6 +107,12 @@ class CreateIncidentTest(TestCase): 154 | assert IncidentProject.objects.filter( 155 | incident=incident, project__in=[self.project] 156 | ).exists() 157 | + assert ( 158 | + IncidentActivity.objects.filter( 159 | + incident=incident, type=IncidentActivityType.STARTED.value, date_added=date_started 160 | + ).count() 161 | + == 1 162 | + ) 163 | assert ( 164 | IncidentActivity.objects.filter( 165 | incident=incident, type=IncidentActivityType.DETECTED.value 166 | -------------------------------------------------------------------------------- /tests/sentry/3/out: -------------------------------------------------------------------------------- 1 | commit a4deb0adb2b7699bd3ac228b3e58a782f9af2a9c 2 | Author: Dan Fuller 3 | Date: Mon Jun 22 13:40:54 2020 -0700 4 |  5 |  feat(metric_alerts): Start writing activity to alerts to note when the alert actually started (#19358) 6 |  7 |  * feat(metric_alerts): Start writing activity to alerts to note when the alert actually started 8 |  9 |  We currently write an activit to alerts to mark when it was created. This isn't the same as when the 10 |  alert started, which will always be some amount of time before we created the alert. This adds an 11 |  extra activity to note the actual start time, which makes it clear to the user what happened, and 12 |  gives us a date that matches up with the start marker on the graph. 13 |  14 |  * Adding incident activity support 15 |  16 |  * Adding incident activity support 17 |  18 |  Co-authored-by: Chris Fuller 19 |  20 | diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py 21 | index f80c7b452a..ffab9f9023 100644 22 | --- a/src/sentry/incidents/logic.py 23 | +++ b/src/sentry/incidents/logic.py 24 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 97 sender=type(incident_project), instance=  97  sender=type(incident_project), instance= 25 |   incident_project, created=True   incident_project, created=True 26 |  98 )  98  ) 27 |  99  99  28 |   100  create_incident_activity( 29 |   101  incident, IncidentActivityType.STARTED, user=use 30 |     r, date_added=date_started 31 |   102  ) 32 | 100 create_incident_activity(incident, IncidentActivityT 103  create_incident_activity(incident, IncidentActivityT 33 |   ype.DETECTED, user=user)   ype.DETECTED, user=user) 34 | 101 analytics.record( 104  analytics.record( 35 | 102 "incident.created", 105  "incident.created", 36 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈200 previous_value=None, 203  previous_value=None, 37 | 201 comment=None, 204  comment=None, 38 | 202 mentioned_user_ids=None, 205  mentioned_user_ids=None, 39 |   206  date_added=None, 40 | 203 ): 207 ): 41 | 204 if activity_type == IncidentActivityType.COMMENT and use 208  if activity_type == IncidentActivityType.COMMENT and use 42 |   r:   r: 43 | 205 subscribe_to_incident(incident, user) 209  subscribe_to_incident(incident, user) 44 | 206 value = six.text_type(value) if value is not None else v 210  value = six.text_type(value) if value is not None else v 45 |   alue   alue 46 | 207 previous_value = six.text_type(previous_value) if previo 211  previous_value = six.text_type(previous_value) if previo 47 |   us_value is not None else previous_value   us_value is not None else previous_value 48 |   212  kwargs = {} 49 |   213  if date_added: 50 |   214  kwargs["date_added"] = date_added 51 | 208 activity = IncidentActivity.objects.create( 215  activity = IncidentActivity.objects.create( 52 | 209 incident=incident, 216  incident=incident, 53 | 210 type=activity_type.value, 217  type=activity_type.value, 54 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈212 value=value, 219  value=value, 55 | 213 previous_value=previous_value, 220  previous_value=previous_value, 56 | 214 comment=comment, 221  comment=comment, 57 |   222  **kwargs 58 | 215 ) 223  ) 59 | 216 224  60 | 217 if mentioned_user_ids: 225  if mentioned_user_ids: 61 | diff --git a/src/sentry/incidents/models.py b/src/sentry/incidents/models.py 62 | index 0686df61eb..ff6f92728a 100644 63 | --- a/src/sentry/incidents/models.py 64 | +++ b/src/sentry/incidents/models.py 65 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈249 DETECTED = 1 249  DETECTED = 1 66 | 250 STATUS_CHANGE = 2 250  STATUS_CHANGE = 2 67 | 251 COMMENT = 3 251  COMMENT = 3 68 |   252  STARTED = 4 69 | 252 253  70 | 253 254  71 | 254 class IncidentActivity(Model): 255 class IncidentActivity(Model): 72 | diff --git a/src/sentry/incidents/subscription_processor.py b/src/sentry/incidents/subscription_processor.py 73 | index 487b8ed1b9..3e2d5f07f3 100644 74 | --- a/src/sentry/incidents/subscription_processor.py 75 | +++ b/src/sentry/incidents/subscription_processor.py 76 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈204 self.alert_rule.name, 204  self.alert_rule.name, 77 | 205 alert_rule=self.alert_rule, 205  alert_rule=self.alert_rule, 78 | 206 date_started=detected_at, 206  date_started=detected_at, 79 |   207  # TODO: This should probably be either t 80 |     he current time or the 81 |   208  # message time. Current time likely make 82 |     s most sense, since this is 83 |   209  # when we actually noticed the problem. 84 | 207 date_detected=detected_at, 210  date_detected=detected_at, 85 | 208 projects=[self.subscription.project], 211  projects=[self.subscription.project], 86 | 209 ) 212  ) 87 | diff --git a/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx b/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx 88 | index 3e49261085..2e50d1dc88 100644 89 | --- a/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx 90 | +++ b/src/sentry/static/sentry/app/views/alerts/details/activity/statusItem.tsx 91 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈37 const {activity, authorName, incident, showTime} = this.p 37  const {activity, authorName, incident, showTime} = this.p 92 |   rops;   rops; 93 | 38 38  94 | 39 const isDetected = activity.type === IncidentActivityType 39  const isDetected = activity.type === IncidentActivityType 95 |   .DETECTED;   .DETECTED; 96 |   40  const isStarted = activity.type === IncidentActivityType. 97 |     STARTED; 98 | 40 const isClosed = 41  const isClosed = 99 | 41 activity.type === IncidentActivityType.STATUS_CHANGE && 42  activity.type === IncidentActivityType.STATUS_CHANGE && 100 | 42 activity.value === `${IncidentStatus.CLOSED}`; 43  activity.value === `${IncidentStatus.CLOSED}`; 101 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈44 activity.type === IncidentActivityType.STATUS_CHANGE && 45  activity.type === IncidentActivityType.STATUS_CHANGE && 102 |   !isClosed;   !isClosed; 103 | 45 46  104 | 46 // Unknown activity, don't render anything 47  // Unknown activity, don't render anything 105 | 47  if (!isDetected && !isClosed && !isTriggerChange) { 48  if (!isStarted && !isDetected && !isClosed && !isTriggerC 106 |     hange) { 107 | 48 return null; 49  return null; 108 | 49 } 50  } 109 | 50 51  110 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈83 })} 84  })} 111 | 84 {isDetected && 85  {isDetected && 112 | 85 (incident?.alertRule 86  (incident?.alertRule 113 | 86  ? tct('[user] was triggered', { 87  ? t('Alert was created') 114 | 87  user: {incident.alertRule.na   115 |   me},   116 | 88  })   117 | 89 : tct('[user] created an alert', { 88  : tct('[user] created an alert', { 118 | 90 user: {authorName}{authorName},   lue>, 120 | 91 }))} 90  }))} 121 |   91  {isStarted && t('Trigger conditions were met')} 122 | 92 92  123 | 93 } 93  } 124 | 94 date={getDynamicText({value: activity.dateCreated, fi 94  date={getDynamicText({value: activity.dateCreated, fi 125 |   xed: new Date(0)})}   xed: new Date(0)})} 126 | diff --git a/src/sentry/static/sentry/app/views/alerts/types.tsx b/src/sentry/static/sentry/app/views/alerts/types.tsx 127 | index 5f4288c19f..02a55a6128 100644 128 | --- a/src/sentry/static/sentry/app/views/alerts/types.tsx 129 | +++ b/src/sentry/static/sentry/app/views/alerts/types.tsx 130 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈66 } 66 } 131 | 67 67  132 | 68 export enum IncidentActivityType { 68 export enum IncidentActivityType { 133 | 69  CREATED, 69  CREATED = 0, 134 | 70  DETECTED, 70  DETECTED = 1, 135 | 71  STATUS_CHANGE, 71  STATUS_CHANGE = 2, 136 | 72  COMMENT, 72  COMMENT = 3, 137 |   73  STARTED = 4, 138 | 73 } 74 } 139 | 74 75  140 | 75 export enum IncidentStatus { 76 export enum IncidentStatus { 141 | diff --git a/tests/sentry/incidents/test_logic.py b/tests/sentry/incidents/test_logic.py 142 | index 1346641edd..503a02b58c 100644 143 | --- a/tests/sentry/incidents/test_logic.py 144 | +++ b/tests/sentry/incidents/test_logic.py 145 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 83 def test_simple(self):  83  def test_simple(self): 146 |  84 incident_type = IncidentType.ALERT_TRIGGERED  84  incident_type = IncidentType.ALERT_TRIGGERED 147 |  85 title = "hello"  85  title = "hello" 148 |  86  date_started = timezone.now()  86  date_started = timezone.now() - timedelta(minutes=5) 149 |  87 alert_rule = create_alert_rule(  87  alert_rule = create_alert_rule( 150 |  88 self.organization, [self.project], "hello", "lev  88  self.organization, [self.project], "hello", "lev 151 |   el:error", "count()", 10, 1   el:error", "count()", 10, 1 152 |  89 )  89  ) 153 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈107 assert IncidentProject.objects.filter( 107  assert IncidentProject.objects.filter( 154 | 108 incident=incident, project__in=[self.project] 108  incident=incident, project__in=[self.project] 155 | 109 ).exists() 109  ).exists() 156 | 110 assert ( 110  assert ( 157 | 111 IncidentActivity.objects.filter( 111  IncidentActivity.objects.filter( 158 |   112  incident=incident, type=IncidentActivityType 159 |     .STARTED.value, date_added=date_started 160 |   113  ).count() 161 |   114  == 1 162 |   115  ) 163 |   116  assert ( 164 |   117  IncidentActivity.objects.filter( 165 | 112 incident=incident, type=IncidentActivityType 118  incident=incident, type=IncidentActivityType 166 |   .DETECTED.value   .DETECTED.value 167 | -------------------------------------------------------------------------------- /tests/sentry/4/in.diff: -------------------------------------------------------------------------------- 1 | commit cc5f484cb765464f1030645ee529a7115498bc00 2 | Author: Dan Fuller 3 | Date: Mon Jun 22 13:25:38 2020 -0700 4 | 5 | fix(metric_alerts): Fix snapshots that contain float values. (#19472) 6 | 7 | When we started supporting floats in metric alerts we forgot to make sure snapshots handled them 8 | correctly. Since the model was set to an array of IntegerField it was silently converting the floats 9 | to ints. This fixes that issue. We have some invalid historical snapshots, but they look like 10 | they're all in Sentry so I'll leave them as is. 11 | 12 | Surprisingly we don't need a migration for this. The tool generates no changes. 13 | 14 | diff --git a/src/sentry/incidents/models.py b/src/sentry/incidents/models.py 15 | index 320222c8c5..0686df61eb 100644 16 | --- a/src/sentry/incidents/models.py 17 | +++ b/src/sentry/incidents/models.py 18 | @@ -219,7 +219,7 @@ class TimeSeriesSnapshot(Model): 19 | 20 | start = models.DateTimeField() 21 | end = models.DateTimeField() 22 | - values = ArrayField(of=ArrayField(models.IntegerField())) 23 | + values = ArrayField(of=ArrayField(models.FloatField())) 24 | period = models.IntegerField() 25 | date_added = models.DateTimeField(default=timezone.now) 26 | 27 | @@ -234,7 +234,15 @@ class TimeSeriesSnapshot(Model): 28 | and 'count' keys. 29 | :return: 30 | """ 31 | - return {"data": [{"time": time, "count": count} for time, count in self.values]} 32 | + # We store the values here as floats so that we can support percentage stats. 33 | + # We don't want to return the time as a float, and to keep things consistent 34 | + # with what Snuba returns we cast floats to ints when they're whole numbers. 35 | + return { 36 | + "data": [ 37 | + {"time": int(time), "count": count if not count.is_integer() else int(count)} 38 | + for time, count in self.values 39 | + ] 40 | + } 41 | 42 | 43 | class IncidentActivityType(Enum): 44 | diff --git a/tests/sentry/incidents/test_logic.py b/tests/sentry/incidents/test_logic.py 45 | index 23c42bac84..1346641edd 100644 46 | --- a/tests/sentry/incidents/test_logic.py 47 | +++ b/tests/sentry/incidents/test_logic.py 48 | @@ -63,10 +63,12 @@ from sentry.incidents.models import ( 49 | IncidentActivityType, 50 | IncidentProject, 51 | PendingIncidentSnapshot, 52 | + IncidentSnapshot, 53 | IncidentStatus, 54 | IncidentStatusMethod, 55 | IncidentSubscription, 56 | IncidentType, 57 | + TimeSeriesSnapshot, 58 | ) 59 | from sentry.snuba.models import QueryDatasets 60 | from sentry.models.integration import Integration 61 | @@ -618,6 +620,38 @@ class GetIncidentStatsTest(TestCase, BaseIncidentsTest): 62 | ) 63 | self.run_test(open_incident) 64 | 65 | + def test_floats(self): 66 | + alert_rule = self.create_alert_rule( 67 | + self.organization, dataset=QueryDatasets.TRANSACTIONS, aggregate="p75()" 68 | + ) 69 | + incident = self.create_incident( 70 | + self.organization, 71 | + title="Hi", 72 | + date_started=timezone.now() - timedelta(days=30), 73 | + alert_rule=alert_rule, 74 | + ) 75 | + update_incident_status( 76 | + incident, IncidentStatus.CLOSED, status_method=IncidentStatusMethod.RULE_TRIGGERED 77 | + ) 78 | + time_series_values = [[0, 1], [1, 5], [2, 5.5]] 79 | + time_series_snapshot = TimeSeriesSnapshot.objects.create( 80 | + start=timezone.now() - timedelta(hours=1), 81 | + end=timezone.now(), 82 | + values=time_series_values, 83 | + period=3000, 84 | + ) 85 | + IncidentSnapshot.objects.create( 86 | + incident=incident, 87 | + event_stats_snapshot=time_series_snapshot, 88 | + unique_users=1234, 89 | + total_events=4567, 90 | + ) 91 | + 92 | + incident_stats = get_incident_stats(incident, windowed_stats=True) 93 | + assert incident_stats["event_stats"].data["data"] == [ 94 | + {"time": time, "count": count} for time, count in time_series_values 95 | + ] 96 | + 97 | 98 | class CreateAlertRuleTest(TestCase, BaseIncidentsTest): 99 | def test(self): 100 | -------------------------------------------------------------------------------- /tests/sentry/4/out: -------------------------------------------------------------------------------- 1 | commit cc5f484cb765464f1030645ee529a7115498bc00 2 | Author: Dan Fuller 3 | Date: Mon Jun 22 13:25:38 2020 -0700 4 |  5 |  fix(metric_alerts): Fix snapshots that contain float values. (#19472) 6 |  7 |  When we started supporting floats in metric alerts we forgot to make sure snapshots handled them 8 |  correctly. Since the model was set to an array of IntegerField it was silently converting the floats 9 |  to ints. This fixes that issue. We have some invalid historical snapshots, but they look like 10 |  they're all in Sentry so I'll leave them as is. 11 |  12 |  Surprisingly we don't need a migration for this. The tool generates no changes. 13 |  14 | diff --git a/src/sentry/incidents/models.py b/src/sentry/incidents/models.py 15 | index 320222c8c5..0686df61eb 100644 16 | --- a/src/sentry/incidents/models.py 17 | +++ b/src/sentry/incidents/models.py 18 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈219 219  19 | 220 start = models.DateTimeField() 220  start = models.DateTimeField() 20 | 221 end = models.DateTimeField() 221  end = models.DateTimeField() 21 | 222  values = ArrayField(of=ArrayField(models.IntegerField()) 222  values = ArrayField(of=ArrayField(models.FloatField())) 22 |   )   23 | 223 period = models.IntegerField() 223  period = models.IntegerField() 24 | 224 date_added = models.DateTimeField(default=timezone.now) 224  date_added = models.DateTimeField(default=timezone.now) 25 | 225 225  26 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈234 and 'count' keys. 234  and 'count' keys. 27 | 235 :return: 235  :return: 28 | 236 """ 236  """ 29 | 237  return {"data": [{"time": time, "count": count} for  237  # We store the values here as floats so that we can  30 |   time, count in self.values]}   support percentage stats. 31 |   238  # We don't want to return the time as a float, and t 32 |     o keep things consistent 33 |   239  # with what Snuba returns we cast floats to ints whe 34 |     n they're whole numbers. 35 |   240  return { 36 |   241  "data": [ 37 |   242  {"time": int(time), "count": count if not co 38 |     unt.is_integer() else int(count)} 39 |   243  for time, count in self.values 40 |   244  ] 41 |   245  } 42 | 238 246  43 | 239 247  44 | 240 class IncidentActivityType(Enum): 248 class IncidentActivityType(Enum): 45 | diff --git a/tests/sentry/incidents/test_logic.py b/tests/sentry/incidents/test_logic.py 46 | index 23c42bac84..1346641edd 100644 47 | --- a/tests/sentry/incidents/test_logic.py 48 | +++ b/tests/sentry/incidents/test_logic.py 49 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 63 IncidentActivityType,  63  IncidentActivityType, 50 |  64 IncidentProject,  64  IncidentProject, 51 |  65 PendingIncidentSnapshot,  65  PendingIncidentSnapshot, 52 |    66  IncidentSnapshot, 53 |  66 IncidentStatus,  67  IncidentStatus, 54 |  67 IncidentStatusMethod,  68  IncidentStatusMethod, 55 |  68 IncidentSubscription,  69  IncidentSubscription, 56 |  69 IncidentType,  70  IncidentType, 57 |    71  TimeSeriesSnapshot, 58 |  70 )  72 ) 59 |  71 from sentry.snuba.models import QueryDatasets  73 from sentry.snuba.models import QueryDatasets 60 |  72 from sentry.models.integration import Integration  74 from sentry.models.integration import Integration 61 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈618 ) 620  ) 62 | 619 self.run_test(open_incident) 621  self.run_test(open_incident) 63 |   622  64 |   623  def test_floats(self): 65 |   624  alert_rule = self.create_alert_rule( 66 |   625  self.organization, dataset=QueryDatasets.TRANSAC 67 |     TIONS, aggregate="p75()" 68 |   626  ) 69 |   627  incident = self.create_incident( 70 |   628  self.organization, 71 |   629  title="Hi", 72 |   630  date_started=timezone.now() - timedelta(days=30) 73 |     , 74 |   631  alert_rule=alert_rule, 75 |   632  ) 76 |   633  update_incident_status( 77 |   634  incident, IncidentStatus.CLOSED, status_method=I 78 |     ncidentStatusMethod.RULE_TRIGGERED 79 |   635  ) 80 |   636  time_series_values = [[0, 1], [1, 5], [2, 5.5]] 81 |   637  time_series_snapshot = TimeSeriesSnapshot.objects.cr 82 |     eate( 83 |   638  start=timezone.now() - timedelta(hours=1), 84 |   639  end=timezone.now(), 85 |   640  values=time_series_values, 86 |   641  period=3000, 87 |   642  ) 88 |   643  IncidentSnapshot.objects.create( 89 |   644  incident=incident, 90 |   645  event_stats_snapshot=time_series_snapshot, 91 |   646  unique_users=1234, 92 |   647  total_events=4567, 93 |   648  ) 94 |   649  95 |   650  incident_stats = get_incident_stats(incident, window 96 |     ed_stats=True) 97 |   651  assert incident_stats["event_stats"].data["data"] == 98 |      [ 99 |   652  {"time": time, "count": count} for time, count i 100 |     n time_series_values 101 |   653  ] 102 | 620 654  103 | 621 655  104 | 622 class CreateAlertRuleTest(TestCase, BaseIncidentsTest): 656 class CreateAlertRuleTest(TestCase, BaseIncidentsTest): 105 | 623 def test(self): 657  def test(self): 106 | -------------------------------------------------------------------------------- /tests/sentry/6/in.diff: -------------------------------------------------------------------------------- 1 | commit b6714f7f268d6c0df1263c0b654be5a27661e337 2 | Author: Billy Vong 3 | Date: Mon Jun 22 13:00:26 2020 -0700 4 | 5 | feat(ui): Update `` to accept React node for `icon` (#19470) 6 | 7 | Previously only accepted a string for icon (and used `InlineSvg`) -- update to accept a string or an Icon component 8 | 9 | Currently, will only be used in getsentry 10 | 11 | diff --git a/src/sentry/static/sentry/app/views/settings/components/tag.tsx b/src/sentry/static/sentry/app/views/settings/components/tag.tsx 12 | index 7ed0d85c19..311685e0b0 100644 13 | --- a/src/sentry/static/sentry/app/views/settings/components/tag.tsx 14 | +++ b/src/sentry/static/sentry/app/views/settings/components/tag.tsx 15 | @@ -3,11 +3,12 @@ import styled from '@emotion/styled'; 16 | 17 | import InlineSvg from 'app/components/inlineSvg'; 18 | import {Theme} from 'app/utils/theme'; 19 | +import space from 'app/styles/space'; 20 | 21 | type Props = React.HTMLAttributes & { 22 | priority?: keyof Theme['badge'] | keyof Theme['alert']; 23 | size?: string; 24 | - icon?: string; 25 | + icon?: string | React.ReactNode; 26 | border?: boolean; 27 | inline?: boolean; 28 | }; 29 | @@ -42,7 +43,15 @@ const Tag = styled( 30 | ...props 31 | }: Props) => ( 32 |
33 | - {icon && } 34 | + {icon && ( 35 | + 36 | + {React.isValidElement(icon) ? ( 37 | + React.cloneElement(icon, {size: 'xs'}) 38 | + ) : typeof icon === 'string' ? ( 39 | + 40 | + ) : null} 41 | + 42 | + )} 43 | {children} 44 |
45 | ) 46 | @@ -56,6 +65,7 @@ const Tag = styled( 47 | text-align: center; 48 | white-space: nowrap; 49 | vertical-align: middle; 50 | + align-items: center; 51 | border-radius: ${p => (p.size === 'small' ? '0.25em' : '2em')}; 52 | text-transform: lowercase; 53 | font-weight: ${p => (p.size === 'small' ? 'bold' : 'normal')}; 54 | @@ -64,8 +74,8 @@ const Tag = styled( 55 | ${p => getMarginLeft(p)}; 56 | `; 57 | 58 | -const StyledInlineSvg = styled(InlineSvg)` 59 | - margin-right: 4px; 60 | +const IconWrapper = styled('span')` 61 | + margin-right: ${space(0.5)}; 62 | `; 63 | 64 | export default Tag; 65 | diff --git a/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap b/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap 66 | index db6b51c74c..7106150464 100644 67 | --- a/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap 68 | +++ b/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap 69 | @@ -8,12 +8,12 @@ exports[`Tag renders 1`] = ` 70 | > 71 | 78 |
82 | Text to Copy 83 |
84 | diff --git a/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap b/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap 85 | index 861f83e405..3f09c05a49 100644 86 | --- a/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap 87 | +++ b/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap 88 | @@ -1679,11 +1679,11 @@ exports[`Filter default render 1`] = ` 89 | color="blue400" 90 | > 91 | 96 |
101 | info 102 | @@ -1765,11 +1765,11 @@ exports[`Filter default render 1`] = ` 103 | color="red400" 104 | > 105 | 110 |
115 | error 116 | -------------------------------------------------------------------------------- /tests/sentry/6/out: -------------------------------------------------------------------------------- 1 | commit b6714f7f268d6c0df1263c0b654be5a27661e337 2 | Author: Billy Vong 3 | Date: Mon Jun 22 13:00:26 2020 -0700 4 |  5 |  feat(ui): Update `` to accept React node for `icon` (#19470) 6 |  7 |  Previously only accepted a string for icon (and used `InlineSvg`) -- update to accept a string or an Icon component 8 |  9 |  Currently, will only be used in getsentry 10 |  11 | diff --git a/src/sentry/static/sentry/app/views/settings/components/tag.tsx b/src/sentry/static/sentry/app/views/settings/components/tag.tsx 12 | index 7ed0d85c19..311685e0b0 100644 13 | --- a/src/sentry/static/sentry/app/views/settings/components/tag.tsx 14 | +++ b/src/sentry/static/sentry/app/views/settings/components/tag.tsx 15 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 3  3  16 |  4 import InlineSvg from 'app/components/inlineSvg';  4 import InlineSvg from 'app/components/inlineSvg'; 17 |  5 import {Theme} from 'app/utils/theme';  5 import {Theme} from 'app/utils/theme'; 18 |    6 import space from 'app/styles/space'; 19 |  6  7  20 |  7 type Props = React.HTMLAttributes & {  8 type Props = React.HTMLAttributes & { 21 |  8 priority?: keyof Theme['badge'] | keyof Theme['alert'];  9  priority?: keyof Theme['badge'] | keyof Theme['alert']; 22 |  9 size?: string; 10  size?: string; 23 | 10  icon?: string; 11  icon?: string | React.ReactNode; 24 | 11 border?: boolean; 12  border?: boolean; 25 | 12 inline?: boolean; 13  inline?: boolean; 26 | 13 }; 14 }; 27 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈42 ...props 43  ...props 28 | 43 }: Props) => ( 44  }: Props) => ( 29 | 44
45 
30 |   46  {icon && ( 31 |   47   32 |   48  {React.isValidElement(icon) ? ( 33 |   49  React.cloneElement(icon, {size: 'xs'}) 34 |   50  ) : typeof icon === 'string' ? ( 35 | 45  {icon && <StyledInlineSvg src={icon} size="12px" />} 51     36 |   52  ) : null} 37 |   53   38 |   54  )} 39 | 46 {children} 55  {children} 40 | 47
56 
41 | 48 ) 57  ) 42 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈56 text-align: center; 65  text-align: center; 43 | 57 white-space: nowrap; 66  white-space: nowrap; 44 | 58 vertical-align: middle; 67  vertical-align: middle; 45 |   68  align-items: center; 46 | 59 border-radius: ${p => (p.size === 'small' ? '0.25em' : '2em 69  border-radius: ${p => (p.size === 'small' ? '0.25em' : '2em 47 |   ')};   ')}; 48 | 60 text-transform: lowercase; 70  text-transform: lowercase; 49 | 61 font-weight: ${p => (p.size === 'small' ? 'bold' : 'normal' 71  font-weight: ${p => (p.size === 'small' ? 'bold' : 'normal' 50 |   )};   )}; 51 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈64 ${p => getMarginLeft(p)}; 74  ${p => getMarginLeft(p)}; 52 | 65 `; 75 `; 53 | 66 76  54 | 67 const StyledInlineSvg = styled(InlineSvg)` 77 const IconWrapper = styled('span')` 55 | 68  margin-right: 4px; 78  margin-right: ${space(0.5)}; 56 | 69 `; 79 `; 57 | 70 80  58 | 71 export default Tag; 81 export default Tag; 59 | diff --git a/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap b/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap 60 | index db6b51c74c..7106150464 100644 61 | --- a/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap 62 | +++ b/tests/js/spec/components/__snapshots__/tag.spec.jsx.snap 63 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 8 >  8 > 64 |  9 14  > 70 | 15
17  > 73 | 18 Text to Copy 18  Text to Copy 74 | 19
19 
75 | diff --git a/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap b/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap 76 | index 861f83e405..3f09c05a49 100644 77 | --- a/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap 78 | +++ b/tests/js/spec/components/events/interfaces/breadcrumbs/__snapshots__/filter.spec.tsx.snap 79 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1679 color="blue400" 1679  color="blue400" 80 | 1680 > 1680  > 81 | 1681 1684  > 86 | 1685
1688  > 91 | 1689 info 1689  info 92 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1765 color="red400" 1765  color="red400" 93 | 1766 > 1766  > 94 | 1767 1770  > 99 | 1771
1774  > 104 | 1775 error 1775  error 105 | -------------------------------------------------------------------------------- /tests/sentry/7/in.diff: -------------------------------------------------------------------------------- 1 | commit 48d81f61f905d0c92f840d3806b05f19965daddf 2 | Author: k-fish <6111995+k-fish@users.noreply.github.com> 3 | Date: Mon Jun 22 12:28:37 2020 -0700 4 | 5 | fix(discover): Fixed incorrect tags from showing in autocomplete (#19459) 6 | 7 | Fixed incorrect tags from showing in autocomplete 8 | 9 | `count()` for example was showing up in the autocomplete if you had visited discover before visiting the performance landing page. The search bar would then incorrectly show it as a recommendation 10 | 11 | This was caused by assigning into a reused object, I turned it into a proper field and switched field keys to an `Enum` so that it could be used with record to make sure the field tags and values correctly mapped to real field keys. 12 | 13 | * Remove export from enum and switch to generating FIELD_TAGS again 14 | * Replace 'as const' and assert with direct typing 15 | 16 | diff --git a/src/sentry/static/sentry/app/utils/discover/fields.tsx b/src/sentry/static/sentry/app/utils/discover/fields.tsx 17 | index 7c0ea52762..69ae69024d 100644 18 | --- a/src/sentry/static/sentry/app/utils/discover/fields.tsx 19 | +++ b/src/sentry/static/sentry/app/utils/discover/fields.tsx 20 | @@ -261,83 +261,153 @@ export type Aggregation = { 21 | multiPlotType: PlotType; 22 | }; 23 | 24 | +enum FieldKey { 25 | + CULPRIT = 'culprit', 26 | + DEVICE_ARCH = 'device.arch', 27 | + DEVICE_BATTERY_LEVEL = 'device.battery_level', 28 | + DEVICE_BRAND = 'device.brand', 29 | + DEVICE_CHARGING = 'device.charging', 30 | + DEVICE_LOCALE = 'device.locale', 31 | + DEVICE_NAME = 'device.name', 32 | + DEVICE_ONLINE = 'device.online', 33 | + DEVICE_ORIENTATION = 'device.orientation', 34 | + DEVICE_SIMULATOR = 'device.simulator', 35 | + DEVICE_UUID = 'device.uuid', 36 | + DIST = 'dist', 37 | + ENVIRONMENT = 'environment', 38 | + ERROR_HANDLED = 'error.handled', 39 | + ERROR_MECHANISM = 'error.mechanism', 40 | + ERROR_TYPE = 'error.type', 41 | + ERROR_VALUE = 'error.value', 42 | + EVENT_TYPE = 'event.type', 43 | + GEO_CITY = 'geo.city', 44 | + GEO_COUNTRY_CODE = 'geo.country_code', 45 | + GEO_REGION = 'geo.region', 46 | + HTTP_METHOD = 'http.method', 47 | + HTTP_URL = 'http.url', 48 | + ID = 'id', 49 | + ISSUE = 'issue', 50 | + LOCATION = 'location', 51 | + MESSAGE = 'message', 52 | + OS_BUILD = 'os.build', 53 | + OS_KERNEL_VERSION = 'os.kernel_version', 54 | + PLATFORM_NAME = 'platform.name', 55 | + PROJECT = 'project', 56 | + RELEASE = 'release', 57 | + SDK_NAME = 'sdk.name', 58 | + SDK_VERSION = 'sdk.version', 59 | + STACK_ABS_PATH = 'stack.abs_path', 60 | + STACK_COLNO = 'stack.colno', 61 | + STACK_FILENAME = 'stack.filename', 62 | + STACK_FUNCTION = 'stack.function', 63 | + STACK_IN_APP = 'stack.in_app', 64 | + STACK_LINENO = 'stack.lineno', 65 | + STACK_MODULE = 'stack.module', 66 | + STACK_PACKAGE = 'stack.package', 67 | + STACK_STACK_LEVEL = 'stack.stack_level', 68 | + TIME = 'time', 69 | + TIMESTAMP = 'timestamp', 70 | + TITLE = 'title', 71 | + TRACE = 'trace', 72 | + TRACE_PARENT_SPAN = 'trace.parent_span', 73 | + TRACE_SPAN = 'trace.span', 74 | + TRANSACTION = 'transaction', 75 | + TRANSACTION_DURATION = 'transaction.duration', 76 | + TRANSACTION_OP = 'transaction.op', 77 | + TRANSACTION_STATUS = 'transaction.status', 78 | + USER = 'user', 79 | + USER_EMAIL = 'user.email', 80 | + USER_ID = 'user.id', 81 | + USER_IP = 'user.ip', 82 | + USER_USERNAME = 'user.username', 83 | +} 84 | + 85 | /** 86 | * Refer to src/sentry/snuba/events.py, search for Columns 87 | */ 88 | -export const FIELDS = { 89 | - id: 'string', 90 | +export const FIELDS: Readonly> = { 91 | + [FieldKey.ID]: 'string', 92 | // issue.id and project.id are omitted on purpose. 93 | // Customers should use `issue` and `project` instead. 94 | - timestamp: 'date', 95 | - time: 'date', 96 | - 97 | - culprit: 'string', 98 | - location: 'string', 99 | - message: 'string', 100 | - 'platform.name': 'string', 101 | - environment: 'string', 102 | - release: 'string', 103 | - dist: 'string', 104 | - title: 'string', 105 | - 'event.type': 'string', 106 | + [FieldKey.TIMESTAMP]: 'date', 107 | + [FieldKey.TIME]: 'date', 108 | + 109 | + [FieldKey.CULPRIT]: 'string', 110 | + [FieldKey.LOCATION]: 'string', 111 | + [FieldKey.MESSAGE]: 'string', 112 | + [FieldKey.PLATFORM_NAME]: 'string', 113 | + [FieldKey.ENVIRONMENT]: 'string', 114 | + [FieldKey.RELEASE]: 'string', 115 | + [FieldKey.DIST]: 'string', 116 | + [FieldKey.TITLE]: 'string', 117 | + [FieldKey.EVENT_TYPE]: 'string', 118 | // tags.key and tags.value are omitted on purpose as well. 119 | 120 | - transaction: 'string', 121 | - user: 'string', 122 | - 'user.id': 'string', 123 | - 'user.email': 'string', 124 | - 'user.username': 'string', 125 | - 'user.ip': 'string', 126 | - 'sdk.name': 'string', 127 | - 'sdk.version': 'string', 128 | - 'http.method': 'string', 129 | - 'http.url': 'string', 130 | - 'os.build': 'string', 131 | - 'os.kernel_version': 'string', 132 | - 'device.name': 'string', 133 | - 'device.brand': 'string', 134 | - 'device.locale': 'string', 135 | - 'device.uuid': 'string', 136 | - 'device.arch': 'string', 137 | - 'device.battery_level': 'number', 138 | - 'device.orientation': 'string', 139 | - 'device.simulator': 'boolean', 140 | - 'device.online': 'boolean', 141 | - 'device.charging': 'boolean', 142 | - 'geo.country_code': 'string', 143 | - 'geo.region': 'string', 144 | - 'geo.city': 'string', 145 | - 'error.type': 'string', 146 | - 'error.value': 'string', 147 | - 'error.mechanism': 'string', 148 | - 'error.handled': 'boolean', 149 | - 'stack.abs_path': 'string', 150 | - 'stack.filename': 'string', 151 | - 'stack.package': 'string', 152 | - 'stack.module': 'string', 153 | - 'stack.function': 'string', 154 | - 'stack.in_app': 'boolean', 155 | - 'stack.colno': 'number', 156 | - 'stack.lineno': 'number', 157 | - 'stack.stack_level': 'number', 158 | + [FieldKey.TRANSACTION]: 'string', 159 | + [FieldKey.USER]: 'string', 160 | + [FieldKey.USER_ID]: 'string', 161 | + [FieldKey.USER_EMAIL]: 'string', 162 | + [FieldKey.USER_USERNAME]: 'string', 163 | + [FieldKey.USER_IP]: 'string', 164 | + [FieldKey.SDK_NAME]: 'string', 165 | + [FieldKey.SDK_VERSION]: 'string', 166 | + [FieldKey.HTTP_METHOD]: 'string', 167 | + [FieldKey.HTTP_URL]: 'string', 168 | + [FieldKey.OS_BUILD]: 'string', 169 | + [FieldKey.OS_KERNEL_VERSION]: 'string', 170 | + [FieldKey.DEVICE_NAME]: 'string', 171 | + [FieldKey.DEVICE_BRAND]: 'string', 172 | + [FieldKey.DEVICE_LOCALE]: 'string', 173 | + [FieldKey.DEVICE_UUID]: 'string', 174 | + [FieldKey.DEVICE_ARCH]: 'string', 175 | + [FieldKey.DEVICE_BATTERY_LEVEL]: 'number', 176 | + [FieldKey.DEVICE_ORIENTATION]: 'string', 177 | + [FieldKey.DEVICE_SIMULATOR]: 'boolean', 178 | + [FieldKey.DEVICE_ONLINE]: 'boolean', 179 | + [FieldKey.DEVICE_CHARGING]: 'boolean', 180 | + [FieldKey.GEO_COUNTRY_CODE]: 'string', 181 | + [FieldKey.GEO_REGION]: 'string', 182 | + [FieldKey.GEO_CITY]: 'string', 183 | + [FieldKey.ERROR_TYPE]: 'string', 184 | + [FieldKey.ERROR_VALUE]: 'string', 185 | + [FieldKey.ERROR_MECHANISM]: 'string', 186 | + [FieldKey.ERROR_HANDLED]: 'boolean', 187 | + [FieldKey.STACK_ABS_PATH]: 'string', 188 | + [FieldKey.STACK_FILENAME]: 'string', 189 | + [FieldKey.STACK_PACKAGE]: 'string', 190 | + [FieldKey.STACK_MODULE]: 'string', 191 | + [FieldKey.STACK_FUNCTION]: 'string', 192 | + [FieldKey.STACK_IN_APP]: 'boolean', 193 | + [FieldKey.STACK_COLNO]: 'number', 194 | + [FieldKey.STACK_LINENO]: 'number', 195 | + [FieldKey.STACK_STACK_LEVEL]: 'number', 196 | // contexts.key and contexts.value omitted on purpose. 197 | 198 | // Transaction event fields. 199 | - 'transaction.duration': 'duration', 200 | - 'transaction.op': 'string', 201 | - 'transaction.status': 'string', 202 | + [FieldKey.TRANSACTION_DURATION]: 'duration', 203 | + [FieldKey.TRANSACTION_OP]: 'string', 204 | + [FieldKey.TRANSACTION_STATUS]: 'string', 205 | 206 | - trace: 'string', 207 | - 'trace.span': 'string', 208 | - 'trace.parent_span': 'string', 209 | + [FieldKey.TRACE]: 'string', 210 | + [FieldKey.TRACE_SPAN]: 'string', 211 | + [FieldKey.TRACE_PARENT_SPAN]: 'string', 212 | 213 | // Field alises defined in src/sentry/api/event_search.py 214 | - project: 'string', 215 | - issue: 'string', 216 | -} as const; 217 | -assert(FIELDS as Readonly<{[key in keyof typeof FIELDS]: ColumnType}>); 218 | + [FieldKey.PROJECT]: 'string', 219 | + [FieldKey.ISSUE]: 'string', 220 | +}; 221 | + 222 | +export type FieldTag = { 223 | + key: FieldKey; 224 | + name: FieldKey; 225 | +}; 226 | + 227 | +export const FIELD_TAGS = Object.freeze( 228 | + Object.fromEntries(Object.keys(FIELDS).map(item => [item, {key: item, name: item}])) 229 | +); 230 | 231 | -export type FieldKey = keyof typeof FIELDS | string | ''; 232 | +// Allows for a less strict field key definition in cases we are returning custom strings as fields 233 | +export type LooseFieldKey = FieldKey | string | ''; 234 | 235 | // This list should be removed with the tranaction-events feature flag. 236 | export const TRACING_FIELDS = [ 237 | diff --git a/src/sentry/static/sentry/app/views/events/searchBar.tsx b/src/sentry/static/sentry/app/views/events/searchBar.tsx 238 | index 2bee7646e3..10c503474e 100644 239 | --- a/src/sentry/static/sentry/app/views/events/searchBar.tsx 240 | +++ b/src/sentry/static/sentry/app/views/events/searchBar.tsx 241 | @@ -12,7 +12,7 @@ import {defined} from 'app/utils'; 242 | import {fetchTagValues} from 'app/actionCreators/tags'; 243 | import SentryTypes from 'app/sentryTypes'; 244 | import SmartSearchBar, {SearchType} from 'app/components/smartSearchBar'; 245 | -import {Field, FIELDS, TRACING_FIELDS} from 'app/utils/discover/fields'; 246 | +import {Field, FIELD_TAGS, TRACING_FIELDS} from 'app/utils/discover/fields'; 247 | import withApi from 'app/utils/withApi'; 248 | import withTags from 'app/utils/withTags'; 249 | import {Client} from 'app/api'; 250 | @@ -23,10 +23,6 @@ const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp( 251 | 'g' 252 | ); 253 | 254 | -const FIELD_TAGS = Object.fromEntries( 255 | - Object.keys(FIELDS).map(item => [item, {key: item, name: item}]) 256 | -); 257 | - 258 | type SearchBarProps = Omit, 'tags'> & { 259 | api: Client; 260 | organization: Organization; 261 | @@ -101,7 +97,7 @@ class SearchBar extends React.PureComponent { 262 | : {}; 263 | 264 | const fieldTags = organization.features.includes('performance-view') 265 | - ? assign(FIELD_TAGS, functionTags) 266 | + ? Object.assign({}, FIELD_TAGS, functionTags) 267 | : omit(FIELD_TAGS, TRACING_FIELDS); 268 | 269 | const combined = assign({}, tags, fieldTags); 270 | diff --git a/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx b/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx 271 | index 3acb977f8a..9ff83b3f82 100644 272 | --- a/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx 273 | +++ b/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx 274 | @@ -16,7 +16,7 @@ import { 275 | explodeFieldString, 276 | generateFieldAsString, 277 | AggregationKey, 278 | - FieldKey, 279 | + LooseFieldKey, 280 | AGGREGATIONS, 281 | FIELDS, 282 | } from 'app/utils/discover/fields'; 283 | @@ -30,7 +30,7 @@ type Props = Omit & { 284 | 285 | type OptionConfig = { 286 | aggregations: AggregationKey[]; 287 | - fields: FieldKey[]; 288 | + fields: LooseFieldKey[]; 289 | }; 290 | 291 | const errorFieldConfig: OptionConfig = { 292 | -------------------------------------------------------------------------------- /tests/sentry/7/out: -------------------------------------------------------------------------------- 1 | commit 48d81f61f905d0c92f840d3806b05f19965daddf 2 | Author: k-fish <6111995+k-fish@users.noreply.github.com> 3 | Date: Mon Jun 22 12:28:37 2020 -0700 4 |  5 |  fix(discover): Fixed incorrect tags from showing in autocomplete (#19459) 6 |  7 |  Fixed incorrect tags from showing in autocomplete 8 |  9 |  `count()` for example was showing up in the autocomplete if you had visited discover before visiting the performance landing page. The search bar would then incorrectly show it as a recommendation 10 |  11 |  This was caused by assigning into a reused object, I turned it into a proper field and switched field keys to an `Enum` so that it could be used with record to make sure the field tags and values correctly mapped to real field keys. 12 |  13 |  * Remove export from enum and switch to generating FIELD_TAGS again 14 |  * Replace 'as const' and assert with direct typing 15 |  16 | diff --git a/src/sentry/static/sentry/app/utils/discover/fields.tsx b/src/sentry/static/sentry/app/utils/discover/fields.tsx 17 | index 7c0ea52762..69ae69024d 100644 18 | --- a/src/sentry/static/sentry/app/utils/discover/fields.tsx 19 | +++ b/src/sentry/static/sentry/app/utils/discover/fields.tsx 20 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈261 multiPlotType: PlotType; 261  multiPlotType: PlotType; 21 | 262 }; 262 }; 22 |   263  23 |   264 enum FieldKey { 24 |   265  CULPRIT = 'culprit', 25 |   266  DEVICE_ARCH = 'device.arch', 26 |   267  DEVICE_BATTERY_LEVEL = 'device.battery_level', 27 |   268  DEVICE_BRAND = 'device.brand', 28 |   269  DEVICE_CHARGING = 'device.charging', 29 |   270  DEVICE_LOCALE = 'device.locale', 30 |   271  DEVICE_NAME = 'device.name', 31 |   272  DEVICE_ONLINE = 'device.online', 32 |   273  DEVICE_ORIENTATION = 'device.orientation', 33 |   274  DEVICE_SIMULATOR = 'device.simulator', 34 |   275  DEVICE_UUID = 'device.uuid', 35 |   276  DIST = 'dist', 36 |   277  ENVIRONMENT = 'environment', 37 |   278  ERROR_HANDLED = 'error.handled', 38 |   279  ERROR_MECHANISM = 'error.mechanism', 39 |   280  ERROR_TYPE = 'error.type', 40 |   281  ERROR_VALUE = 'error.value', 41 |   282  EVENT_TYPE = 'event.type', 42 |   283  GEO_CITY = 'geo.city', 43 |   284  GEO_COUNTRY_CODE = 'geo.country_code', 44 |   285  GEO_REGION = 'geo.region', 45 |   286  HTTP_METHOD = 'http.method', 46 |   287  HTTP_URL = 'http.url', 47 |   288  ID = 'id', 48 |   289  ISSUE = 'issue', 49 |   290  LOCATION = 'location', 50 |   291  MESSAGE = 'message', 51 |   292  OS_BUILD = 'os.build', 52 |   293  OS_KERNEL_VERSION = 'os.kernel_version', 53 |   294  PLATFORM_NAME = 'platform.name', 54 |   295  PROJECT = 'project', 55 |   296  RELEASE = 'release', 56 |   297  SDK_NAME = 'sdk.name', 57 |   298  SDK_VERSION = 'sdk.version', 58 |   299  STACK_ABS_PATH = 'stack.abs_path', 59 |   300  STACK_COLNO = 'stack.colno', 60 |   301  STACK_FILENAME = 'stack.filename', 61 |   302  STACK_FUNCTION = 'stack.function', 62 |   303  STACK_IN_APP = 'stack.in_app', 63 |   304  STACK_LINENO = 'stack.lineno', 64 |   305  STACK_MODULE = 'stack.module', 65 |   306  STACK_PACKAGE = 'stack.package', 66 |   307  STACK_STACK_LEVEL = 'stack.stack_level', 67 |   308  TIME = 'time', 68 |   309  TIMESTAMP = 'timestamp', 69 |   310  TITLE = 'title', 70 |   311  TRACE = 'trace', 71 |   312  TRACE_PARENT_SPAN = 'trace.parent_span', 72 |   313  TRACE_SPAN = 'trace.span', 73 |   314  TRANSACTION = 'transaction', 74 |   315  TRANSACTION_DURATION = 'transaction.duration', 75 |   316  TRANSACTION_OP = 'transaction.op', 76 |   317  TRANSACTION_STATUS = 'transaction.status', 77 |   318  USER = 'user', 78 |   319  USER_EMAIL = 'user.email', 79 |   320  USER_ID = 'user.id', 80 |   321  USER_IP = 'user.ip', 81 |   322  USER_USERNAME = 'user.username', 82 |   323 } 83 | 263 324  84 | 264 /** 325 /** 85 | 265 * Refer to src/sentry/snuba/events.py, search for Columns 326  * Refer to src/sentry/snuba/events.py, search for Columns 86 | 266 */ 327  */ 87 | 267 export const FIELDS = { 328 export const FIELDS: Readonly>  88 |     = { 89 | 268  id: 'string', 329  [FieldKey.ID]: 'string', 90 | 269 // issue.id and project.id are omitted on purpose. 330  // issue.id and project.id are omitted on purpose. 91 | 270 // Customers should use `issue` and `project` instead. 331  // Customers should use `issue` and `project` instead. 92 | 271  timestamp: 'date', 332  [FieldKey.TIMESTAMP]: 'date', 93 | 272  time: 'date', 333  [FieldKey.TIME]: 'date', 94 | 273 334  95 | 274  culprit: 'string', 335  [FieldKey.CULPRIT]: 'string', 96 | 275  location: 'string', 336  [FieldKey.LOCATION]: 'string', 97 | 276  message: 'string', 337  [FieldKey.MESSAGE]: 'string', 98 | 277  'platform.name': 'string', 338  [FieldKey.PLATFORM_NAME]: 'string', 99 | 278  environment: 'string', 339  [FieldKey.ENVIRONMENT]: 'string', 100 | 279  release: 'string', 340  [FieldKey.RELEASE]: 'string', 101 | 280  dist: 'string', 341  [FieldKey.DIST]: 'string', 102 | 281  title: 'string', 342  [FieldKey.TITLE]: 'string', 103 | 282  'event.type': 'string', 343  [FieldKey.EVENT_TYPE]: 'string', 104 | 283 // tags.key and tags.value are omitted on purpose as well. 344  // tags.key and tags.value are omitted on purpose as well. 105 | 284 345  106 | 285  transaction: 'string', 346  [FieldKey.TRANSACTION]: 'string', 107 | 286  user: 'string', 347  [FieldKey.USER]: 'string', 108 | 287  'user.id': 'string', 348  [FieldKey.USER_ID]: 'string', 109 | 288  'user.email': 'string', 349  [FieldKey.USER_EMAIL]: 'string', 110 | 289  'user.username': 'string', 350  [FieldKey.USER_USERNAME]: 'string', 111 | 290  'user.ip': 'string', 351  [FieldKey.USER_IP]: 'string', 112 | 291  'sdk.name': 'string', 352  [FieldKey.SDK_NAME]: 'string', 113 | 292  'sdk.version': 'string', 353  [FieldKey.SDK_VERSION]: 'string', 114 | 293  'http.method': 'string', 354  [FieldKey.HTTP_METHOD]: 'string', 115 | 294  'http.url': 'string', 355  [FieldKey.HTTP_URL]: 'string', 116 | 295  'os.build': 'string', 356  [FieldKey.OS_BUILD]: 'string', 117 | 296  'os.kernel_version': 'string', 357  [FieldKey.OS_KERNEL_VERSION]: 'string', 118 | 297  'device.name': 'string', 358  [FieldKey.DEVICE_NAME]: 'string', 119 | 298  'device.brand': 'string', 359  [FieldKey.DEVICE_BRAND]: 'string', 120 | 299  'device.locale': 'string', 360  [FieldKey.DEVICE_LOCALE]: 'string', 121 | 300  'device.uuid': 'string', 361  [FieldKey.DEVICE_UUID]: 'string', 122 | 301  'device.arch': 'string', 362  [FieldKey.DEVICE_ARCH]: 'string', 123 | 302  'device.battery_level': 'number', 363  [FieldKey.DEVICE_BATTERY_LEVEL]: 'number', 124 | 303  'device.orientation': 'string', 364  [FieldKey.DEVICE_ORIENTATION]: 'string', 125 | 304  'device.simulator': 'boolean', 365  [FieldKey.DEVICE_SIMULATOR]: 'boolean', 126 | 305  'device.online': 'boolean', 366  [FieldKey.DEVICE_ONLINE]: 'boolean', 127 | 306  'device.charging': 'boolean', 367  [FieldKey.DEVICE_CHARGING]: 'boolean', 128 | 307  'geo.country_code': 'string', 368  [FieldKey.GEO_COUNTRY_CODE]: 'string', 129 | 308  'geo.region': 'string', 369  [FieldKey.GEO_REGION]: 'string', 130 | 309  'geo.city': 'string', 370  [FieldKey.GEO_CITY]: 'string', 131 | 310  'error.type': 'string', 371  [FieldKey.ERROR_TYPE]: 'string', 132 | 311  'error.value': 'string', 372  [FieldKey.ERROR_VALUE]: 'string', 133 | 312  'error.mechanism': 'string', 373  [FieldKey.ERROR_MECHANISM]: 'string', 134 | 313  'error.handled': 'boolean', 374  [FieldKey.ERROR_HANDLED]: 'boolean', 135 | 314  'stack.abs_path': 'string', 375  [FieldKey.STACK_ABS_PATH]: 'string', 136 | 315  'stack.filename': 'string', 376  [FieldKey.STACK_FILENAME]: 'string', 137 | 316  'stack.package': 'string', 377  [FieldKey.STACK_PACKAGE]: 'string', 138 | 317  'stack.module': 'string', 378  [FieldKey.STACK_MODULE]: 'string', 139 | 318  'stack.function': 'string', 379  [FieldKey.STACK_FUNCTION]: 'string', 140 | 319  'stack.in_app': 'boolean', 380  [FieldKey.STACK_IN_APP]: 'boolean', 141 | 320  'stack.colno': 'number', 381  [FieldKey.STACK_COLNO]: 'number', 142 | 321  'stack.lineno': 'number', 382  [FieldKey.STACK_LINENO]: 'number', 143 | 322  'stack.stack_level': 'number', 383  [FieldKey.STACK_STACK_LEVEL]: 'number', 144 | 323 // contexts.key and contexts.value omitted on purpose. 384  // contexts.key and contexts.value omitted on purpose. 145 | 324 385  146 | 325 // Transaction event fields. 386  // Transaction event fields. 147 | 326  'transaction.duration': 'duration', 387  [FieldKey.TRANSACTION_DURATION]: 'duration', 148 | 327  'transaction.op': 'string', 388  [FieldKey.TRANSACTION_OP]: 'string', 149 | 328  'transaction.status': 'string', 389  [FieldKey.TRANSACTION_STATUS]: 'string', 150 | 329 390  151 | 330  trace: 'string', 391  [FieldKey.TRACE]: 'string', 152 | 331  'trace.span': 'string', 392  [FieldKey.TRACE_SPAN]: 'string', 153 | 332  'trace.parent_span': 'string', 393  [FieldKey.TRACE_PARENT_SPAN]: 'string', 154 | 333 394  155 | 334 // Field alises defined in src/sentry/api/event_search.py 395  // Field alises defined in src/sentry/api/event_search.py 156 | 335  project: 'string', 396  [FieldKey.PROJECT]: 'string', 157 | 336  issue: 'string', 397  [FieldKey.ISSUE]: 'string', 158 | 337 } as const; 398 }; 159 | 338 assert(FIELDS as Readonly<{[key in keyof typeof FIELDS]: Col   160 |   umnType}>);   161 | 339 399  162 | 340 export type FieldKey = keyof typeof FIELDS | string | ''; 400 export type FieldTag = { 163 |   401  key: FieldKey; 164 |   402  name: FieldKey; 165 |   403 }; 166 |   404  167 |   405 export const FIELD_TAGS = Object.freeze( 168 |   406  Object.fromEntries(Object.keys(FIELDS).map(item => [item,  169 |     {key: item, name: item}])) 170 |   407 ); 171 |   408  172 |   409 // Allows for a less strict field key definition in cases we 173 |      are returning custom strings as fields 174 |   410 export type LooseFieldKey = FieldKey | string | ''; 175 | 341 411  176 | 342 // This list should be removed with the tranaction-events fe 412 // This list should be removed with the tranaction-events fe 177 |   ature flag.   ature flag. 178 | 343 export const TRACING_FIELDS = [ 413 export const TRACING_FIELDS = [ 179 | diff --git a/src/sentry/static/sentry/app/views/events/searchBar.tsx b/src/sentry/static/sentry/app/views/events/searchBar.tsx 180 | index 2bee7646e3..10c503474e 100644 181 | --- a/src/sentry/static/sentry/app/views/events/searchBar.tsx 182 | +++ b/src/sentry/static/sentry/app/views/events/searchBar.tsx 183 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 12 import {fetchTagValues} from 'app/actionCreators/tags';  12 import {fetchTagValues} from 'app/actionCreators/tags'; 184 |  13 import SentryTypes from 'app/sentryTypes';  13 import SentryTypes from 'app/sentryTypes'; 185 |  14 import SmartSearchBar, {SearchType} from 'app/components/sma  14 import SmartSearchBar, {SearchType} from 'app/components/sma 186 |   rtSearchBar';   rtSearchBar'; 187 |  15 import {Field, FIELDS, TRACING_FIELDS} from 'app/utils/disco  15 import {Field, FIELD_TAGS, TRACING_FIELDS} from 'app/utils/d 188 |   ver/fields';   iscover/fields'; 189 |  16 import withApi from 'app/utils/withApi';  16 import withApi from 'app/utils/withApi'; 190 |  17 import withTags from 'app/utils/withTags';  17 import withTags from 'app/utils/withTags'; 191 |  18 import {Client} from 'app/api';  18 import {Client} from 'app/api'; 192 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 23 'g'  23  'g' 193 |  24 );   194 |  25    195 |  26 const FIELD_TAGS = Object.fromEntries(   196 |  27  Object.keys(FIELDS).map(item => [item, {key: item, name: i   197 |   tem}])   198 |  28 );  24 ); 199 |  29  25  200 |  30 type SearchBarProps = Omit, 'tags'> & {   SearchBar>, 'tags'> & { 202 |  31 api: Client;  27  api: Client; 203 |  32 organization: Organization;  28  organization: Organization; 204 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈101 : {};  97  : {}; 205 | 102  98  206 | 103 const fieldTags = organization.features.includes('perfor  99  const fieldTags = organization.features.includes('perfor 207 |   mance-view')   mance-view') 208 | 104  ? assign(FIELD_TAGS, functionTags) 100  ? Object.assign({}, FIELD_TAGS, functionTags) 209 | 105 : omit(FIELD_TAGS, TRACING_FIELDS); 101  : omit(FIELD_TAGS, TRACING_FIELDS); 210 | 106 102  211 | 107 const combined = assign({}, tags, fieldTags); 103  const combined = assign({}, tags, fieldTags); 212 | diff --git a/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx b/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx 213 | index 3acb977f8a..9ff83b3f82 100644 214 | --- a/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx 215 | +++ b/src/sentry/static/sentry/app/views/settings/incidentRules/metricField.tsx 216 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈16 explodeFieldString, 16  explodeFieldString, 217 | 17 generateFieldAsString, 17  generateFieldAsString, 218 | 18 AggregationKey, 18  AggregationKey, 219 | 19  FieldKey, 19  LooseFieldKey, 220 | 20 AGGREGATIONS, 20  AGGREGATIONS, 221 | 21 FIELDS, 21  FIELDS, 222 | 22 } from 'app/utils/discover/fields'; 22 } from 'app/utils/discover/fields'; 223 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈30 30  224 | 31 type OptionConfig = { 31 type OptionConfig = { 225 | 32 aggregations: AggregationKey[]; 32  aggregations: AggregationKey[]; 226 | 33  fields: FieldKey[]; 33  fields: LooseFieldKey[]; 227 | 34 }; 34 }; 228 | 35 35  229 | 36 const errorFieldConfig: OptionConfig = { 36 const errorFieldConfig: OptionConfig = { 230 | -------------------------------------------------------------------------------- /tests/sentry/8/in.diff: -------------------------------------------------------------------------------- 1 | commit b3e365e5db58046244d52cad596d0dd390d9c59c 2 | Author: k-fish <6111995+k-fish@users.noreply.github.com> 3 | Date: Mon Jun 22 11:53:59 2020 -0700 4 | 5 | ref(ts): Convert `scoreBar` to typescript (#19434) 6 | 7 | This converts the `scoreBar` component to typescript 8 | 9 | * ref(ts): Convert `scoreBar` to typescript 10 | * Switch ScoreBar to be a functional component 11 | 12 | diff --git a/src/sentry/static/sentry/app/components/scoreBar.jsx b/src/sentry/static/sentry/app/components/scoreBar.jsx 13 | deleted file mode 100644 14 | index 049556bf76..0000000000 15 | --- a/src/sentry/static/sentry/app/components/scoreBar.jsx 16 | +++ /dev/null 17 | @@ -1,77 +0,0 @@ 18 | -import PropTypes from 'prop-types'; 19 | -import React from 'react'; 20 | -import styled from '@emotion/styled'; 21 | - 22 | -import theme from 'app/utils/theme'; 23 | - 24 | -class ScoreBar extends React.Component { 25 | - static propTypes = { 26 | - vertical: PropTypes.bool, 27 | - score: PropTypes.number.isRequired, 28 | - /** Array of strings */ 29 | - palette: PropTypes.arrayOf(PropTypes.string), 30 | - /** Array of classNames whose index maps to score */ 31 | - paletteClassNames: PropTypes.arrayOf(PropTypes.string), 32 | - size: PropTypes.number, 33 | - thickness: PropTypes.number, 34 | - radius: PropTypes.number, 35 | - }; 36 | - 37 | - static defaultProps = { 38 | - size: 40, 39 | - thickness: 4, 40 | - radius: 3, 41 | - palette: theme.similarity.colors, 42 | - }; 43 | - 44 | - render() { 45 | - const {className, vertical, palette, score, size, thickness, radius} = this.props; 46 | - const maxScore = palette.length; 47 | - 48 | - // Make sure score is between 0 and maxScore 49 | - const scoreInBounds = score >= maxScore ? maxScore : score <= 0 ? 0 : score; 50 | - // Make sure paletteIndex is 0 based 51 | - const paletteIndex = scoreInBounds - 1; 52 | - 53 | - // Size of bar, depends on orientation, although we could just apply a transformation via css 54 | - const barProps = { 55 | - vertical, 56 | - thickness, 57 | - size, 58 | - radius, 59 | - }; 60 | - 61 | - return ( 62 | -
63 | - {[...Array(scoreInBounds)].map((_j, i) => ( 64 | - 65 | - ))} 66 | - {[...Array(maxScore - scoreInBounds)].map((_j, i) => ( 67 | - 68 | - ))} 69 | -
70 | - ); 71 | - } 72 | -} 73 | - 74 | -const StyledScoreBar = styled(ScoreBar)` 75 | - display: flex; 76 | - 77 | - ${p => 78 | - p.vertical 79 | - ? `flex-direction: column-reverse; 80 | - justify-content: flex-end;` 81 | - : 'min-width: 80px;'}; 82 | -`; 83 | - 84 | -const Bar = styled('div')` 85 | - border-radius: ${p => p.radius}px; 86 | - margin: 2px; 87 | - ${p => p.empty && `background-color: ${p.theme.similarity.empty};`}; 88 | - ${p => p.color && `background-color: ${p.color};`}; 89 | - 90 | - width: ${p => (!p.vertical ? p.thickness : p.size)}px; 91 | - height: ${p => (!p.vertical ? p.size : p.thickness)}px; 92 | -`; 93 | - 94 | -export default StyledScoreBar; 95 | diff --git a/src/sentry/static/sentry/app/components/scoreBar.tsx b/src/sentry/static/sentry/app/components/scoreBar.tsx 96 | new file mode 100644 97 | index 0000000000..968ac64120 98 | --- /dev/null 99 | +++ b/src/sentry/static/sentry/app/components/scoreBar.tsx 100 | @@ -0,0 +1,95 @@ 101 | +import PropTypes from 'prop-types'; 102 | +import React from 'react'; 103 | +import styled from '@emotion/styled'; 104 | + 105 | +import theme from 'app/utils/theme'; 106 | + 107 | +type Props = { 108 | + score: number; 109 | + size?: number; 110 | + thickness?: number; 111 | + radius?: number; 112 | + palette?: Readonly; 113 | + className?: string; 114 | + paletteClassNames?: string[]; 115 | + vertical?: boolean; 116 | +}; 117 | + 118 | +const ScoreBar = ({ 119 | + score, 120 | + className, 121 | + vertical, 122 | + size = 40, 123 | + thickness = 4, 124 | + radius = 3, 125 | + palette = theme.similarity.colors, 126 | +}: Props) => { 127 | + const maxScore = palette.length; 128 | + 129 | + // Make sure score is between 0 and maxScore 130 | + const scoreInBounds = score >= maxScore ? maxScore : score <= 0 ? 0 : score; 131 | + // Make sure paletteIndex is 0 based 132 | + const paletteIndex = scoreInBounds - 1; 133 | + 134 | + // Size of bar, depends on orientation, although we could just apply a transformation via css 135 | + const barProps = { 136 | + vertical, 137 | + thickness, 138 | + size, 139 | + radius, 140 | + }; 141 | + 142 | + return ( 143 | +
144 | + {[...Array(scoreInBounds)].map((_j, i) => ( 145 | + 146 | + ))} 147 | + {[...Array(maxScore - scoreInBounds)].map((_j, i) => ( 148 | + 149 | + ))} 150 | +
151 | + ); 152 | +}; 153 | + 154 | +ScoreBar.propTypes = { 155 | + vertical: PropTypes.bool, 156 | + score: PropTypes.number.isRequired, 157 | + /** Array of strings */ 158 | + palette: PropTypes.arrayOf(PropTypes.string), 159 | + /** Array of classNames whose index maps to score */ 160 | + paletteClassNames: PropTypes.arrayOf(PropTypes.string), 161 | + size: PropTypes.number, 162 | + thickness: PropTypes.number, 163 | + radius: PropTypes.number, 164 | +}; 165 | + 166 | +const StyledScoreBar = styled(ScoreBar)` 167 | + display: flex; 168 | + 169 | + ${p => 170 | + p.vertical 171 | + ? `flex-direction: column-reverse; 172 | + justify-content: flex-end;` 173 | + : 'min-width: 80px;'}; 174 | +`; 175 | + 176 | +type BarProps = { 177 | + radius: number; 178 | + size: number; 179 | + thickness: number; 180 | + color?: string; 181 | + empty?: boolean; 182 | + vertical?: boolean; 183 | +}; 184 | + 185 | +const Bar = styled('div')` 186 | + border-radius: ${p => p.radius}px; 187 | + margin: 2px; 188 | + ${p => p.empty && `background-color: ${p.theme.similarity.empty};`}; 189 | + ${p => p.color && `background-color: ${p.color};`}; 190 | + 191 | + width: ${p => (!p.vertical ? p.thickness : p.size)}px; 192 | + height: ${p => (!p.vertical ? p.size : p.thickness)}px; 193 | +`; 194 | + 195 | +export default StyledScoreBar; 196 | diff --git a/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap b/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap 197 | index be35bc42ae..7b7e51ca00 100644 198 | --- a/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap 199 | +++ b/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap 200 | @@ -1669,40 +1669,16 @@ exports[`Issues Similar View renders with mocked data 1`] = ` 201 | onMouseLeave={[Function]} 202 | > 203 | 219 | 237 |
241 | 246 |
255 |
264 |
273 |
282 |
288 | @@ -1807,40 +1783,16 @@ exports[`Issues Similar View renders with mocked data 1`] = ` 289 | onMouseLeave={[Function]} 290 | > 291 | 307 | 325 |
329 | 334 |
340 | @@ -1865,7 +1817,7 @@ exports[`Issues Similar View renders with mocked data 1`] = ` 341 | vertical={true} 342 | > 343 |
349 | @@ -1879,7 +1831,7 @@ exports[`Issues Similar View renders with mocked data 1`] = ` 350 | vertical={true} 351 | > 352 |
358 | @@ -1893,7 +1845,7 @@ exports[`Issues Similar View renders with mocked data 1`] = ` 359 | vertical={true} 360 | > 361 |
367 | @@ -1907,7 +1859,7 @@ exports[`Issues Similar View renders with mocked data 1`] = ` 368 | vertical={true} 369 | > 370 |
376 | -------------------------------------------------------------------------------- /tests/sentry/8/out: -------------------------------------------------------------------------------- 1 | commit b3e365e5db58046244d52cad596d0dd390d9c59c 2 | Author: k-fish <6111995+k-fish@users.noreply.github.com> 3 | Date: Mon Jun 22 11:53:59 2020 -0700 4 |  5 |  ref(ts): Convert `scoreBar` to typescript (#19434) 6 |  7 |  This converts the `scoreBar` component to typescript 8 |  9 |  * ref(ts): Convert `scoreBar` to typescript 10 |  * Switch ScoreBar to be a functional component 11 |  12 | diff --git a/src/sentry/static/sentry/app/components/scoreBar.jsx b/src/sentry/static/sentry/app/components/scoreBar.jsx 13 | deleted file mode 100644 14 | index 049556bf76..0000000000 15 | --- a/src/sentry/static/sentry/app/components/scoreBar.jsx 16 | +++ /dev/null 17 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 1 import PropTypes from 'prop-types';   18 |  2 import React from 'react';   19 |  3 import styled from '@emotion/styled';   20 |  4    21 |  5 import theme from 'app/utils/theme';   22 |  6    23 |  7 class ScoreBar extends React.Component {   24 |  8  static propTypes = {   25 |  9  vertical: PropTypes.bool,   26 | 10  score: PropTypes.number.isRequired,   27 | 11  /** Array of strings */   28 | 12  palette: PropTypes.arrayOf(PropTypes.string),   29 | 13  /** Array of classNames whose index maps to score */   30 | 14  paletteClassNames: PropTypes.arrayOf(PropTypes.string),   31 | 15  size: PropTypes.number,   32 | 16  thickness: PropTypes.number,   33 | 17  radius: PropTypes.number,   34 | 18  };   35 | 19    36 | 20  static defaultProps = {   37 | 21  size: 40,   38 | 22  thickness: 4,   39 | 23  radius: 3,   40 | 24  palette: theme.similarity.colors,   41 | 25  };   42 | 26    43 | 27  render() {   44 | 28  const {className, vertical, palette, score, size, thickne   45 |   ss, radius} = this.props;   46 | 29  const maxScore = palette.length;   47 | 30    48 | 31  // Make sure score is between 0 and maxScore   49 | 32  const scoreInBounds = score >= maxScore ? maxScore : scor   50 |   e <= 0 ? 0 : score;   51 | 33  // Make sure paletteIndex is 0 based   52 | 34  const paletteIndex = scoreInBounds - 1;   53 | 35    54 | 36  // Size of bar, depends on orientation, although we could   55 |    just apply a transformation via css   56 | 37  const barProps = {   57 | 38  vertical,   58 | 39  thickness,   59 | 40  size,   60 | 41  radius,   61 | 42  };   62 | 43    63 | 44  return (   64 | 45 
   65 | 46  {[...Array(scoreInBounds)].map((_j, i) => (   66 | 47     68 | 48  ))}   69 | 49  {[...Array(maxScore - scoreInBounds)].map((_j, i) =>    70 |   (   71 | 50     72 | 51  ))}   73 | 52 
   74 | 53  );   75 | 54  }   76 | 55 }   77 | 56    78 | 57 const StyledScoreBar = styled(ScoreBar)`   79 | 58  display: flex;   80 | 59    81 | 60  ${p =>   82 | 61  p.vertical   83 | 62  ? `flex-direction: column-reverse;   84 | 63  justify-content: flex-end;`   85 | 64  : 'min-width: 80px;'};   86 | 65 `;   87 | 66    88 | 67 const Bar = styled('div')`   89 | 68  border-radius: ${p => p.radius}px;   90 | 69  margin: 2px;   91 | 70  ${p => p.empty && `background-color: ${p.theme.similarity.e   92 |   mpty};`};   93 | 71  ${p => p.color && `background-color: ${p.color};`};   94 | 72    95 | 73  width: ${p => (!p.vertical ? p.thickness : p.size)}px;   96 | 74  height: ${p => (!p.vertical ? p.size : p.thickness)}px;   97 | 75 `;   98 | 76    99 | 77 export default StyledScoreBar;   100 | diff --git a/src/sentry/static/sentry/app/components/scoreBar.tsx b/src/sentry/static/sentry/app/components/scoreBar.tsx 101 | new file mode 100644 102 | index 0000000000..968ac64120 103 | --- /dev/null 104 | +++ b/src/sentry/static/sentry/app/components/scoreBar.tsx 105 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈   1 import PropTypes from 'prop-types'; 106 |    2 import React from 'react'; 107 |    3 import styled from '@emotion/styled'; 108 |    4  109 |    5 import theme from 'app/utils/theme'; 110 |    6  111 |    7 type Props = { 112 |    8  score: number; 113 |    9  size?: number; 114 |   10  thickness?: number; 115 |   11  radius?: number; 116 |   12  palette?: Readonly; 117 |   13  className?: string; 118 |   14  paletteClassNames?: string[]; 119 |   15  vertical?: boolean; 120 |   16 }; 121 |   17  122 |   18 const ScoreBar = ({ 123 |   19  score, 124 |   20  className, 125 |   21  vertical, 126 |   22  size = 40, 127 |   23  thickness = 4, 128 |   24  radius = 3, 129 |   25  palette = theme.similarity.colors, 130 |   26 }: Props) => { 131 |   27  const maxScore = palette.length; 132 |   28  133 |   29  // Make sure score is between 0 and maxScore 134 |   30  const scoreInBounds = score >= maxScore ? maxScore : score  135 |     <= 0 ? 0 : score; 136 |   31  // Make sure paletteIndex is 0 based 137 |   32  const paletteIndex = scoreInBounds - 1; 138 |   33  139 |   34  // Size of bar, depends on orientation, although we could j 140 |     ust apply a transformation via css 141 |   35  const barProps = { 142 |   36  vertical, 143 |   37  thickness, 144 |   38  size, 145 |   39  radius, 146 |   40  }; 147 |   41  148 |   42  return ( 149 |   43 
 150 |   44  {[...Array(scoreInBounds)].map((_j, i) => ( 151 |   45   153 |   46  ))} 154 |   47  {[...Array(maxScore - scoreInBounds)].map((_j, i) => ( 155 |   48   156 |   49  ))} 157 |   50 
 158 |   51  ); 159 |   52 }; 160 |   53  161 |   54 ScoreBar.propTypes = { 162 |   55  vertical: PropTypes.bool, 163 |   56  score: PropTypes.number.isRequired, 164 |   57  /** Array of strings */ 165 |   58  palette: PropTypes.arrayOf(PropTypes.string), 166 |   59  /** Array of classNames whose index maps to score */ 167 |   60  paletteClassNames: PropTypes.arrayOf(PropTypes.string), 168 |   61  size: PropTypes.number, 169 |   62  thickness: PropTypes.number, 170 |   63  radius: PropTypes.number, 171 |   64 }; 172 |   65  173 |   66 const StyledScoreBar = styled(ScoreBar)` 174 |   67  display: flex; 175 |   68  176 |   69  ${p => 177 |   70  p.vertical 178 |   71  ? `flex-direction: column-reverse; 179 |   72  justify-content: flex-end;` 180 |   73  : 'min-width: 80px;'}; 181 |   74 `; 182 |   75  183 |   76 type BarProps = { 184 |   77  radius: number; 185 |   78  size: number; 186 |   79  thickness: number; 187 |   80  color?: string; 188 |   81  empty?: boolean; 189 |   82  vertical?: boolean; 190 |   83 }; 191 |   84  192 |   85 const Bar = styled('div')` 193 |   86  border-radius: ${p => p.radius}px; 194 |   87  margin: 2px; 195 |   88  ${p => p.empty && `background-color: ${p.theme.similarity.e 196 |     mpty};`}; 197 |   89  ${p => p.color && `background-color: ${p.color};`}; 198 |   90  199 |   91  width: ${p => (!p.vertical ? p.thickness : p.size)}px; 200 |   92  height: ${p => (!p.vertical ? p.size : p.thickness)}px; 201 |   93 `; 202 |   94  203 |   95 export default StyledScoreBar; 204 | diff --git a/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap b/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap 205 | index be35bc42ae..7b7e51ca00 100644 206 | --- a/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap 207 | +++ b/tests/js/spec/views/organizationGroupDetails/__snapshots__/groupSimilar.spec.jsx.snap 208 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1669 onMouseLeave={[Function]} 1669  onMouseLeave={[Function]} 209 | 1670 > 1670  > 210 | 1671 1674  > 226 | 1687 1679  > 244 | 1704
1682  > 248 | 1707 1690  > 252 | 1715
1705  > 260 | 1730
1720  > 268 | 1745
1735  > 276 | 1760
1750  > 284 | 1775
1755  /> 290 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1807 onMouseLeave={[Function]} 1783  onMouseLeave={[Function]} 291 | 1808 > 1784  > 292 | 1809 1788  > 308 | 1825 1793  > 326 | 1842
1796  > 330 | 1845 1804  > 334 | 1853
1809  /> 340 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1865 vertical={true} 1817  vertical={true} 341 | 1866 > 1818  > 342 | 1867
1823  /> 348 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1879 vertical={true} 1831  vertical={true} 349 | 1880 > 1832  > 350 | 1881
1837  /> 356 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1893 vertical={true} 1845  vertical={true} 357 | 1894 > 1846  > 358 | 1895
1851  /> 364 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈1907 vertical={true} 1859  vertical={true} 365 | 1908 > 1860  > 366 | 1909
1865  /> 372 | -------------------------------------------------------------------------------- /tests/sentry/9/in.diff: -------------------------------------------------------------------------------- 1 | commit 24ff1aa9e589590243f886e1ff41c9b53b4a58e1 2 | Author: Dan Fuller 3 | Date: Mon Jun 22 11:33:32 2020 -0700 4 | 5 | chore(subscriptions): Add logging about partitions/offsets when assigning/revoking partitions, and (#19474) 6 | 7 | when committing offsets 8 | 9 | I want to be able to see what the offsets are set to on the consumer so that we can come up with a 10 | more permanent solution for the issues where our consumer lag monitor misfires due to low 11 | transaction alert usage. 12 | 13 | diff --git a/src/sentry/snuba/query_subscription_consumer.py b/src/sentry/snuba/query_subscription_consumer.py 14 | index 1b824f8765..a7f57ae359 100644 15 | --- a/src/sentry/snuba/query_subscription_consumer.py 16 | +++ b/src/sentry/snuba/query_subscription_consumer.py 17 | @@ -87,12 +87,14 @@ class QuerySubscriptionConsumer(object): 18 | else: 19 | updated_offset = partition.offset 20 | self.offsets[partition.partition] = updated_offset 21 | + logger.info("query-subscription-consumer.on_assign", extra={"offsets": self.offsets}) 22 | 23 | def on_revoke(consumer, partitions): 24 | partition_numbers = [partition.partition for partition in partitions] 25 | self.commit_offsets(partition_numbers) 26 | for partition_number in partition_numbers: 27 | self.offsets.pop(partition_number, None) 28 | + logger.info("query-subscription-consumer.on_revoke", extra={"offsets": self.offsets}) 29 | 30 | self.consumer = Consumer(conf) 31 | self.consumer.subscribe([self.topic], on_assign=on_assign, on_revoke=on_revoke) 32 | @@ -131,6 +133,11 @@ class QuerySubscriptionConsumer(object): 33 | self.shutdown() 34 | 35 | def commit_offsets(self, partitions=None): 36 | + logger.info( 37 | + "query-subscription-consumer.commit_offsets", 38 | + extra={"offsets": self.offsets, "partitions": partitions}, 39 | + ) 40 | + 41 | if self.offsets and self.consumer: 42 | if partitions is None: 43 | partitions = self.offsets.keys() 44 | -------------------------------------------------------------------------------- /tests/sentry/9/out: -------------------------------------------------------------------------------- 1 | commit 24ff1aa9e589590243f886e1ff41c9b53b4a58e1 2 | Author: Dan Fuller 3 | Date: Mon Jun 22 11:33:32 2020 -0700 4 |  5 |  chore(subscriptions): Add logging about partitions/offsets when assigning/revoking partitions, and (#19474) 6 |  7 |  when committing offsets 8 |  9 |  I want to be able to see what the offsets are set to on the consumer so that we can come up with a 10 |  more permanent solution for the issues where our consumer lag monitor misfires due to low 11 |  transaction alert usage. 12 |  13 | diff --git a/src/sentry/snuba/query_subscription_consumer.py b/src/sentry/snuba/query_subscription_consumer.py 14 | index 1b824f8765..a7f57ae359 100644 15 | --- a/src/sentry/snuba/query_subscription_consumer.py 16 | +++ b/src/sentry/snuba/query_subscription_consumer.py 17 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 87 else:  87  else: 18 |  88 updated_offset = partition.offset  88  updated_offset = partition.offset 19 |  89 self.offsets[partition.partition] = updated_  89  self.offsets[partition.partition] = updated_ 20 |   offset   offset 21 |    90  logger.info("query-subscription-consumer.on_assi 22 |     gn", extra={"offsets": self.offsets}) 23 |  90  91  24 |  91 def on_revoke(consumer, partitions):  92  def on_revoke(consumer, partitions): 25 |  92 partition_numbers = [partition.partition for par  93  partition_numbers = [partition.partition for par 26 |   tition in partitions]   tition in partitions] 27 |  93 self.commit_offsets(partition_numbers)  94  self.commit_offsets(partition_numbers) 28 |  94 for partition_number in partition_numbers:  95  for partition_number in partition_numbers: 29 |  95 self.offsets.pop(partition_number, None)  96  self.offsets.pop(partition_number, None) 30 |    97  logger.info("query-subscription-consumer.on_revo 31 |     ke", extra={"offsets": self.offsets}) 32 |  96  98  33 |  97 self.consumer = Consumer(conf)  99  self.consumer = Consumer(conf) 34 |  98 self.consumer.subscribe([self.topic], on_assign=on_a 100  self.consumer.subscribe([self.topic], on_assign=on_a 35 |   ssign, on_revoke=on_revoke)   ssign, on_revoke=on_revoke) 36 | ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈131 self.shutdown() 133  self.shutdown() 37 | 132 134  38 | 133 def commit_offsets(self, partitions=None): 135  def commit_offsets(self, partitions=None): 39 |   136  logger.info( 40 |   137  "query-subscription-consumer.commit_offsets", 41 |   138  extra={"offsets": self.offsets, "partitions": pa 42 |     rtitions}, 43 |   139  ) 44 |   140  45 | 134 if self.offsets and self.consumer: 141  if self.offsets and self.consumer: 46 | 135 if partitions is None: 142  if partitions is None: 47 | 136 partitions = self.offsets.keys() 143  partitions = self.offsets.keys() 48 | -------------------------------------------------------------------------------- /ydiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import signal 4 | signal.signal(signal.SIGPIPE, signal.SIG_DFL) 5 | signal.signal(signal.SIGINT, signal.SIG_DFL) 6 | 7 | import sys 8 | import difflib 9 | from os import get_terminal_size, environ 10 | 11 | # XXX: Since I'm using ydiff like `git diff | ydiff | less`, 12 | # both stdin and stdout are redirected, so we'll get inappropriate ioctl for those devices. 13 | # But we can use stderr (fd 2)! 14 | terminal_width = int(environ.get("YDIFF_WIDTH") or get_terminal_size(2)[0]) 15 | 16 | COLOR_RESET = "\x1b[0m" 17 | COLOR_REVERSE = "\x1b[7m" 18 | COLOR_PLAIN = "\x1b[22m" 19 | COLOR_RED = "\x1b[31m" 20 | COLOR_GREEN = "\x1b[32m" 21 | COLOR_YELLOW = "\x1b[33m" 22 | COLOR_CYAN = "\x1b[36m" 23 | COLOR_GRAY = "\x1b[37m" 24 | 25 | hunk_meta_display = f"{COLOR_GRAY}{'┈' * terminal_width}{COLOR_RESET}" 26 | 27 | 28 | def strsplit(text, width): 29 | """strsplit() splits a given string into two substrings, \x1b-aware. 30 | 31 | It returns 3-tuple: (first string, second string, number of visible chars 32 | in the first string). 33 | 34 | If some color was active at the splitting point, then the first string is 35 | appended with the resetting sequence, and the second string is prefixed 36 | with all active colors. 37 | """ 38 | first = "" 39 | found_colors = "" 40 | chars_cnt = 0 41 | append_len = 0 42 | 43 | while len(text): 44 | # First of all, check if current string begins with any escape sequence. 45 | if text[0] == "\x1b": 46 | color_end = text.find("m") 47 | if color_end != -1: 48 | color = text[:color_end+1] 49 | if color == COLOR_RESET: 50 | found_colors = "" 51 | else: 52 | found_colors += color 53 | append_len = len(color) 54 | 55 | if not append_len: 56 | # Current string does not start with any escape sequence, so, 57 | # either add one more visible char to the "first" string, or 58 | # break if that string is already large enough. 59 | if chars_cnt >= width: 60 | break 61 | chars_cnt += 1 62 | # would popfront be more efficient here? 63 | first += text[0] 64 | text = text[1:] 65 | continue 66 | 67 | first += text[:append_len] 68 | text = text[append_len:] 69 | append_len = 0 70 | 71 | second = text 72 | 73 | # If the first string has some active colors at the splitting point, 74 | # reset it and append the same colors to the second string. 75 | if found_colors: 76 | return first + COLOR_RESET, found_colors + second, chars_cnt 77 | 78 | return first, second, chars_cnt 79 | 80 | 81 | class Hunk(object): 82 | def __init__(self, hunk_headers, old_addr, new_addr): 83 | self._hunk_headers = hunk_headers 84 | self._old_addr = old_addr # tuple (start, offset) 85 | self._new_addr = new_addr # tuple (start, offset) 86 | self._hunk_list = [] # list of tuple (attr, line) 87 | 88 | def append(self, hunk_line): 89 | """hunk_line is a 2-element tuple: (attr, text), where attr is: 90 | '-': old, '+': new, ' ': common 91 | """ 92 | self._hunk_list.append(hunk_line) 93 | 94 | def mdiff(self): 95 | """The difflib._mdiff() function returns an interator which returns a 96 | tuple: (from line tuple, to line tuple, boolean flag) 97 | 98 | from/to line tuple -- (line num, line text) 99 | line num -- integer or None (to indicate a context separation) 100 | line text -- original line text with following markers inserted: 101 | '\0+' -- marks start of added text 102 | '\0-' -- marks start of deleted text 103 | '\0^' -- marks start of changed text 104 | '\1' -- marks end of added/deleted/changed text 105 | 106 | boolean flag -- None indicates context separation, True indicates 107 | either "from" or "to" line contains a change, otherwise False. 108 | """ 109 | return difflib._mdiff(self._get_old_text(), self._get_new_text()) 110 | 111 | def _get_old_text(self): 112 | return [line for attr, line in self._hunk_list if attr != "+"] 113 | 114 | def _get_new_text(self): 115 | return [line for attr, line in self._hunk_list if attr != "-"] 116 | 117 | def is_completed(self): 118 | old_completed = self._old_addr[1] == len(self._get_old_text()) 119 | if not old_completed: 120 | return False 121 | # new_completed 122 | return self._new_addr[1] == len(self._get_new_text()) 123 | 124 | 125 | class UnifiedDiff(object): 126 | def __init__(self, headers=None, old_path=None, new_path=None, hunks=None): 127 | self._headers = headers or [] 128 | self._old_path = old_path or None 129 | self._new_path = new_path or None 130 | self._hunks = hunks or [] 131 | 132 | def is_old_path(self, line): 133 | return line.startswith("--- ") 134 | 135 | def is_new_path(self, line): 136 | return line.startswith("+++ ") 137 | 138 | def is_hunk_meta(self, line): 139 | return ( 140 | line.startswith("@@ -") 141 | and line.find(" @@") >= 8 142 | ) 143 | 144 | def parse_hunk_meta(self, hunk_meta): 145 | # @@ -3,7 +3,6 @@ 146 | a = hunk_meta.split()[1].split(",") # -3 7 147 | if len(a) > 1: 148 | old_addr = (int(a[0][1:]), int(a[1])) 149 | else: 150 | # @@ -1 +1,2 @@ 151 | old_addr = (int(a[0][1:]), 1) 152 | 153 | b = hunk_meta.split()[2].split(",") # +3 6 154 | if len(b) > 1: 155 | new_addr = (int(b[0][1:]), int(b[1])) 156 | else: 157 | # @@ -0,0 +1 @@ 158 | new_addr = (int(b[0][1:]), 1) 159 | 160 | return old_addr, new_addr 161 | 162 | def parse_hunk_line(self, line): 163 | return line[0], line[1:] 164 | 165 | def is_old(self, line): 166 | return ( 167 | line.startswith("-") 168 | and not self.is_old_path(line) 169 | ) 170 | 171 | def is_new(self, line): 172 | return line.startswith("+") and not self.is_new_path(line) 173 | 174 | def is_common(self, line): 175 | return line.startswith(" ") 176 | 177 | def is_eof(self, line): 178 | # \ No newline at end of file 179 | # \ No newline at end of property 180 | return line.startswith(r"\ No newline at end of") 181 | 182 | def is_only_in_dir(self, line): 183 | return line.startswith("Only in ") 184 | 185 | def is_binary_differ(self, line): 186 | return line.startswith("Binary files") and line.endswith("differ") 187 | 188 | 189 | class DiffParser(object): 190 | def __init__(self, stream): 191 | self._stream = stream 192 | 193 | def get_diff_generator(self): 194 | """parse all diff lines, construct a list of UnifiedDiff objects""" 195 | diff = UnifiedDiff() 196 | headers = [] 197 | 198 | for line in self._stream: 199 | if diff.is_old_path(line): 200 | # This is a new diff when current hunk is not yet genreated or 201 | # is completed. We yield previous diff if exists and construct 202 | # a new one for this case. Otherwise it's acutally an 'old' 203 | # line starts with '--- '. 204 | if not diff._hunks or diff._hunks[-1].is_completed(): 205 | if diff._old_path and diff._new_path and diff._hunks: 206 | yield diff 207 | diff = UnifiedDiff(headers, line, None, None) 208 | headers = [] 209 | else: 210 | diff._hunks[-1].append(diff.parse_hunk_line(line)) 211 | 212 | elif diff.is_new_path(line) and diff._old_path: 213 | if not diff._new_path: 214 | diff._new_path = line 215 | else: 216 | diff._hunks[-1].append(diff.parse_hunk_line(line)) 217 | 218 | elif diff.is_hunk_meta(line): 219 | hunk_meta = line 220 | old_addr, new_addr = diff.parse_hunk_meta(hunk_meta) 221 | hunk = Hunk(headers, old_addr, new_addr) 222 | headers = [] 223 | diff._hunks.append(hunk) 224 | 225 | elif ( 226 | diff._hunks 227 | and not headers 228 | and (diff.is_old(line) or diff.is_new(line) or diff.is_common(line)) 229 | ): 230 | diff._hunks[-1].append(diff.parse_hunk_line(line)) 231 | 232 | elif diff.is_eof(line): 233 | pass 234 | 235 | elif diff.is_only_in_dir(line) or diff.is_binary_differ(line): 236 | # 'Only in foo:' and 'Binary files ... differ' are considered 237 | # as separate diffs, so yield current diff, then this line 238 | # 239 | if diff._old_path and diff._new_path and diff._hunks: 240 | # Current diff is comppletely constructed 241 | yield diff 242 | headers.append(line) 243 | yield UnifiedDiff(headers, None, None, None) 244 | headers = [] 245 | diff = UnifiedDiff() 246 | 247 | else: 248 | # All other non-recognized lines are considered as headers or 249 | # hunk headers respectively 250 | headers.append(line) 251 | 252 | # Validate and yield the last patch set if it is not yielded yet 253 | if diff._old_path: 254 | assert diff._new_path is not None 255 | if diff._hunks: 256 | assert len(diff._hunks[-1]._hunk_list) > 0 257 | yield diff 258 | 259 | if headers: 260 | # Tolerate dangling headers, just yield a UnifiedDiff object with 261 | # only header lines 262 | yield UnifiedDiff(headers, None, None, None) 263 | 264 | 265 | class DiffMarker(object): 266 | def markup_side_by_side(self, diff): 267 | def _fit_with_marker_mix(text): 268 | """Wrap input text which contains mdiff tags, markup at the 269 | meantime 270 | """ 271 | out = COLOR_PLAIN 272 | while text: 273 | if text.startswith("\x00-"): 274 | out += f'{COLOR_REVERSE}{COLOR_RED}' 275 | text = text[2:] 276 | elif text.startswith("\x00+"): 277 | out += f'{COLOR_REVERSE}{COLOR_GREEN}' 278 | text = text[2:] 279 | elif text.startswith("\x00^"): 280 | out += f'{COLOR_REVERSE}{COLOR_YELLOW}' 281 | text = text[2:] 282 | elif text.startswith("\x01"): 283 | if len(text) > 1: 284 | out += f'{COLOR_RESET}{COLOR_PLAIN}' 285 | text = text[1:] 286 | else: 287 | # FIXME: utf-8 wchar might break the rule here, e.g. 288 | # u'\u554a' takes double width of a single letter, also 289 | # this depends on your terminal font. I guess audience of 290 | # this tool never put that kind of symbol in their code :-) 291 | out += text[0] 292 | text = text[1:] 293 | 294 | return out + COLOR_RESET 295 | 296 | # Set up number width, note last hunk might be empty 297 | try: 298 | start, offset = diff._hunks[-1]._old_addr 299 | max1 = start + offset - 1 300 | start, offset = diff._hunks[-1]._new_addr 301 | max2 = start + offset - 1 302 | except IndexError: 303 | max1 = max2 = 0 304 | 305 | num_width = max(len(str(max1)), len(str(max2))) 306 | 307 | # Each line is like 'nnn TEXT nnn TEXT\n', so width is half of 308 | # [terminal size minus the line number columns and 3 separating spaces. 309 | width = (terminal_width - num_width * 2 - 3) // 2 310 | 311 | for line in diff._headers: 312 | yield f"{COLOR_CYAN}{line}{COLOR_RESET}" 313 | 314 | if diff._old_path is not None and diff._new_path is not None: 315 | yield f"{COLOR_YELLOW}{diff._old_path}{COLOR_RESET}" 316 | yield f"{COLOR_YELLOW}{diff._new_path}{COLOR_RESET}" 317 | 318 | for hunk in diff._hunks: 319 | for hunk_header in hunk._hunk_headers: 320 | yield f"{COLOR_CYAN}{hunk_header}{COLOR_RESET}" 321 | 322 | yield hunk_meta_display 323 | 324 | for old, new, changed in hunk.mdiff(): 325 | if old[0]: 326 | left_num = str(hunk._old_addr[0] + int(old[0]) - 1) 327 | else: 328 | left_num = " " 329 | 330 | if new[0]: 331 | right_num = str(hunk._new_addr[0] + int(new[0]) - 1) 332 | else: 333 | right_num = " " 334 | 335 | left = old[1].replace("\t", " " * 8).replace("\n", "").replace("\r", "") 336 | right = new[1].replace("\t", " " * 8).replace("\n", "").replace("\r", "") 337 | 338 | if changed: 339 | if not old[0]: 340 | left = "" 341 | right = right.rstrip("\x01") 342 | if right.startswith("\x00+"): 343 | right = right[2:] 344 | right = f"{COLOR_GREEN}{right}{COLOR_RESET}" 345 | elif not new[0]: 346 | left = left.rstrip("\x01") 347 | if left.startswith("\x00-"): 348 | left = left[2:] 349 | left = f"{COLOR_RED}{left}{COLOR_RESET}" 350 | right = "" 351 | else: 352 | left = _fit_with_marker_mix(left) 353 | right = _fit_with_marker_mix(right) 354 | else: 355 | right = f"{COLOR_RESET}{right}" 356 | 357 | # Need to wrap long lines, so here we'll iterate, 358 | # shaving off `width` chars from both left and right 359 | # strings, until both are empty. Also, line number needs to 360 | # be printed only for the first part. 361 | lncur = left_num 362 | rncur = right_num 363 | while left or right: 364 | # Split both left and right lines, preserving escaping 365 | # sequences correctly. 366 | lcur, left, llen = strsplit(left, width) 367 | rcur, right, rlen = strsplit(right, width) 368 | 369 | # Pad left line with spaces if needed 370 | if llen < width: 371 | lcur += " " * (width - llen) 372 | # XXX: this doesn't work lol 373 | # lcur = f"{lcur: <{width}}" 374 | 375 | yield f"{COLOR_GRAY}{lncur:>{num_width}}{COLOR_RESET} {lcur} {COLOR_GRAY}{rncur:>{num_width}}{COLOR_RESET} {rcur}\n" 376 | 377 | # Clean line numbers for further iterations 378 | lncur = "" 379 | rncur = "" 380 | 381 | 382 | for diff in DiffParser(sys.stdin).get_diff_generator(): 383 | for line in DiffMarker().markup_side_by_side(diff): 384 | sys.stdout.buffer.write(line.encode()) 385 | --------------------------------------------------------------------------------