From 59a5c40c6fcf99a6b240465de4428bd3b415309d Mon Sep 17 00:00:00 2001 From: esanuandra Date: Thu, 6 Aug 2026 18:14:16 +0300 Subject: [PATCH 1/4] Add get_latest_detected_push method --- .../test_backfill_record.py | 60 +++++++++++++++++++ treeherder/perf/models.py | 20 +++++++ .../webapp/api/performance_serializers.py | 12 ++++ 3 files changed, 92 insertions(+) create mode 100644 tests/perf/auto_perf_sheriffing/test_backfill_record.py diff --git a/tests/perf/auto_perf_sheriffing/test_backfill_record.py b/tests/perf/auto_perf_sheriffing/test_backfill_record.py new file mode 100644 index 00000000000..54cf3238d2d --- /dev/null +++ b/tests/perf/auto_perf_sheriffing/test_backfill_record.py @@ -0,0 +1,60 @@ +import json + +import pytest + +from treeherder.perf.models import BackfillRecord, BackfillReport +from treeherder.webapp.api.performance_serializers import BackfillRecordSerializer + + +@pytest.fixture +def backfill_record_with_logs(test_perf_alert): + report = BackfillReport.objects.create(summary=test_perf_alert.summary) + record = BackfillRecord.objects.create(alert=test_perf_alert, report=report) + record.backfill_logs = json.dumps( + [ + {"iteration": 0, "status": "initial", + "detected_push_id": 111, "detected_push_revision": "aaaa1111bbbb"}, + {"iteration": 0, "status": "backfill_requested"}, # no push → must be skipped + {"iteration": 1, "status": "right", + "detected_push_id": 222, "detected_push_revision": "cccc2222dddd"}, + ] + ) + record.save() + return record + + +@pytest.mark.django_db +def test_get_latest_detected_push_returns_most_recent(backfill_record_with_logs): + assert backfill_record_with_logs.get_latest_detected_push() == { + "detected_push_id": 222, + "detected_push_revision": "cccc2222dddd", + } + + +@pytest.mark.django_db +def test_serializer_exposes_detected_push(backfill_record_with_logs): + data = BackfillRecordSerializer(backfill_record_with_logs).data + assert data["detected_push_id"] == 222 + assert data["detected_push_revision"] == "cccc2222dddd" + + +@pytest.mark.django_db +def test_detected_push_null_when_no_logs(test_perf_alert): + report = BackfillReport.objects.create(summary=test_perf_alert.summary) + record = BackfillRecord.objects.create(alert=test_perf_alert, report=report) # backfill_logs='[]' + data = BackfillRecordSerializer(record).data + assert data["detected_push_id"] is None + assert data["detected_push_revision"] is None + + +@pytest.mark.django_db +def test_detected_push_falls_back_to_scalar(test_perf_alert): + report = BackfillReport.objects.create(summary=test_perf_alert.summary) + record = BackfillRecord.objects.create( + alert=test_perf_alert, report=report, last_detected_push_id=999 + ) + assert record.get_latest_detected_push() == { + "detected_push_id": 999, + "detected_push_revision": None, + } + diff --git a/treeherder/perf/models.py b/treeherder/perf/models.py index f12a03bb62a..e71eff9670a 100644 --- a/treeherder/perf/models.py +++ b/treeherder/perf/models.py @@ -1196,6 +1196,26 @@ def get_backfill_log(self, iteration: int) -> dict: return entry return None + def get_latest_detected_push(self) -> dict | None: + """ + Parse backfill_logs and return the most recent iteration's detected culprit push + as {"detected_push_id": int|None, "detected_push_revision": str|None}, or None + if nothing was ever detected. + """ + for entry in reversed(self.get_backfill_logs()): + if entry.get("detected_push_id") is not None: + return { + "detected_push_id": entry.get("detected_push_id"), + "detected_push_revision": entry.get("detected_push_revision"), + } + # Fallback for legacy records or logs that only hold the scalar. + if self.last_detected_push_id is not None: + return { + "detected_push_id": self.last_detected_push_id, + "detected_push_revision": None, + } + return None + def save(self, *args, **kwargs): # refresh parent's latest update time super().save(*args, **kwargs) diff --git a/treeherder/webapp/api/performance_serializers.py b/treeherder/webapp/api/performance_serializers.py index 974200dd635..34ffa5069ef 100644 --- a/treeherder/webapp/api/performance_serializers.py +++ b/treeherder/webapp/api/performance_serializers.py @@ -90,6 +90,16 @@ class BackfillRecordSerializer(serializers.Serializer): total_backfills_failed = serializers.IntegerField() total_backfills_successful = serializers.IntegerField() total_backfills_in_progress = serializers.IntegerField() + detected_push_id = serializers.SerializerMethodField() + detected_push_revision = serializers.SerializerMethodField() + + def get_detected_push_id(self, obj): + detected = obj.get_latest_detected_push() + return detected["detected_push_id"] if detected else None + + def get_detected_push_revision(self, obj): + detected = obj.get_latest_detected_push() + return detected["detected_push_revision"] if detected else None class Meta: model = BackfillRecord @@ -101,6 +111,8 @@ class Meta: "total_backfills_failed", "total_backfills_successful", "total_backfills_in_progress", + "detected_push_id", + "detected_push_revision", ) From b348258f49e4c980d1918337378165c51c2cb322 Mon Sep 17 00:00:00 2001 From: esanuandra Date: Wed, 26 Aug 2026 15:10:50 +0300 Subject: [PATCH 2/4] display detected push revision in alert row fix lint error --- .../alerts-view/alerts_table_row_test.jsx | 41 +++++++++++++++++ .../ui/perfherder/alerts-view/alerts_test.jsx | 4 +- ui/perfherder/alerts/AlertTableRow.jsx | 45 ++++++++++++------- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx b/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx index 9b8c95563a2..60be90986a6 100644 --- a/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx +++ b/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx @@ -3,6 +3,7 @@ import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; import AlertTableRow from '../../../../ui/perfherder/alerts/AlertTableRow'; import testAlertSummaries from '../../mock/alert_summaries'; import { thPlatformMap } from '../../../../ui/helpers/constants'; +import { alertBackfillResultStatusMap } from '../../../../ui/perfherder/perf-helpers/constants'; const testUser = { username: 'mozilla-ldap/test_user@mozilla.com', @@ -445,3 +446,43 @@ describe('graph link highlight', () => { expect(setLastClickedGraphAlertId).toHaveBeenCalledWith(testAlert.id); }); }); + +describe('detected push revision', () => { + const alertWithBackfill = (backfillOverrides = {}) => ({ + ...testAlert, + backfill_record: { + status: alertBackfillResultStatusMap.successful, + total_backfills_successful: 2, + ...backfillOverrides, + }, + }); + + test('renders the detected push revision, styled italic/muted/small', async () => { + const revision = 'a1b2c3d4e5f6'; + const { getByText } = alertTableRowTest({ + alert: alertWithBackfill({ detected_push_revision: revision }), + tags: false, + }); + + const revisionEl = await waitFor(() => getByText(revision)); + expect(revisionEl).toBeInTheDocument(); + expect(revisionEl).toHaveClass('fst-italic'); + expect(revisionEl).toHaveClass('text-muted'); + expect(revisionEl).toHaveClass('small'); + }); + + test('does not render the revision span when detected_push_revision is absent', async () => { + const alert = alertWithBackfill(); // backfill record present, but no detected push + const { container, getByTestId } = alertTableRowTest({ + alert, + tags: false, + }); + + // the Sherlock icon still renders (backfill_record is present)... + await waitFor(() => getByTestId(`alert ${alert.id} sherlock icon`)); + // ...but the styled detected-push revision span does not + expect( + container.querySelector('.fst-italic.text-muted.small'), + ).toBeNull(); + }); +}); diff --git a/tests/ui/perfherder/alerts-view/alerts_test.jsx b/tests/ui/perfherder/alerts-view/alerts_test.jsx index 15095a78495..c81dec3d2fa 100644 --- a/tests/ui/perfherder/alerts-view/alerts_test.jsx +++ b/tests/ui/perfherder/alerts-view/alerts_test.jsx @@ -291,7 +291,7 @@ test('selecting all alerts and marking them as acknowledged updates all alerts', expect(alertCheckbox1).toHaveProperty('checked', true); expect(alertCheckbox2).toHaveProperty('checked', true); }); - let acknowledgeButton = await waitFor(() => getByText('Acknowledge')); + const acknowledgeButton = await waitFor(() => getByText('Acknowledge')); fireEvent.click(acknowledgeButton); @@ -391,7 +391,7 @@ test('selecting the alert summary checkbox then deselecting one alert only updat expect(alertCheckbox4).toHaveProperty('checked', true); }); - let acknowledgeButton = await waitFor(() => getByText('Acknowledge')); + const acknowledgeButton = await waitFor(() => getByText('Acknowledge')); fireEvent.click(acknowledgeButton); // only the selected alert has been updated diff --git a/ui/perfherder/alerts/AlertTableRow.jsx b/ui/perfherder/alerts/AlertTableRow.jsx index c6acfcec8db..582b0e898b6 100644 --- a/ui/perfherder/alerts/AlertTableRow.jsx +++ b/ui/perfherder/alerts/AlertTableRow.jsx @@ -362,7 +362,13 @@ export default class AlertTableRow extends React.Component { } render() { - const { user = null, alert, alertSummary, lastClickedGraphAlertId, setLastClickedGraphAlertId } = this.props; + const { + user = null, + alert, + alertSummary, + lastClickedGraphAlertId, + setLastClickedGraphAlertId, + } = this.props; const { starred, checkboxSelected, icons } = this.state; const { repository, framework, revision } = alertSummary; @@ -384,7 +390,8 @@ export default class AlertTableRow extends React.Component { ? `Classified by ${alert.classifier_email}` : 'Classified automatically'; const bookmarkClass = starred ? 'visible' : ''; - const graphActive = lastClickedGraphAlertId !== null && lastClickedGraphAlertId === alert.id; + const graphActive = + lastClickedGraphAlertId !== null && lastClickedGraphAlertId === alert.id; const noiseProfile = alert.noise_profile || 'N\\A'; const noiseProfileTooltip = alert.noise_profile ? noiseProfiles[alert.noise_profile.replace('/', '')] @@ -398,6 +405,7 @@ export default class AlertTableRow extends React.Component { alert.side_by_side_available; const backfillStatusInfo = this.getBackfillStatusInfo(alert); + const detectedPushRevision = alert.backfill_record?.detected_push_revision; let sherlockTooltip = backfillStatusInfo?.message; if (backfillStatusInfo?.displayTasksCount) { sherlockTooltip = ( @@ -484,19 +492,26 @@ export default class AlertTableRow extends React.Component { this.getTitleText(alert, alertStatus) )} {backfillStatusInfo && ( - - - } - tooltipText={sherlockTooltip} - /> - + <> + + + } + tooltipText={sherlockTooltip} + /> + + {detectedPushRevision && ( + + {detectedPushRevision.slice(0, 12)} + + )} + )} From a5b0be3c12e152dd5302141ac1535abd458c796c Mon Sep 17 00:00:00 2001 From: esanuandra Date: Wed, 26 Aug 2026 16:44:52 +0300 Subject: [PATCH 3/4] style: ruff-format test_backfill_record.py --- .../test_backfill_record.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/perf/auto_perf_sheriffing/test_backfill_record.py b/tests/perf/auto_perf_sheriffing/test_backfill_record.py index 54cf3238d2d..c4349802330 100644 --- a/tests/perf/auto_perf_sheriffing/test_backfill_record.py +++ b/tests/perf/auto_perf_sheriffing/test_backfill_record.py @@ -12,11 +12,19 @@ def backfill_record_with_logs(test_perf_alert): record = BackfillRecord.objects.create(alert=test_perf_alert, report=report) record.backfill_logs = json.dumps( [ - {"iteration": 0, "status": "initial", - "detected_push_id": 111, "detected_push_revision": "aaaa1111bbbb"}, - {"iteration": 0, "status": "backfill_requested"}, # no push → must be skipped - {"iteration": 1, "status": "right", - "detected_push_id": 222, "detected_push_revision": "cccc2222dddd"}, + { + "iteration": 0, + "status": "initial", + "detected_push_id": 111, + "detected_push_revision": "aaaa1111bbbb", + }, + {"iteration": 0, "status": "backfill_requested"}, # no push → must be skipped + { + "iteration": 1, + "status": "right", + "detected_push_id": 222, + "detected_push_revision": "cccc2222dddd", + }, ] ) record.save() @@ -41,7 +49,9 @@ def test_serializer_exposes_detected_push(backfill_record_with_logs): @pytest.mark.django_db def test_detected_push_null_when_no_logs(test_perf_alert): report = BackfillReport.objects.create(summary=test_perf_alert.summary) - record = BackfillRecord.objects.create(alert=test_perf_alert, report=report) # backfill_logs='[]' + record = BackfillRecord.objects.create( + alert=test_perf_alert, report=report + ) # backfill_logs='[]' data = BackfillRecordSerializer(record).data assert data["detected_push_id"] is None assert data["detected_push_revision"] is None @@ -57,4 +67,3 @@ def test_detected_push_falls_back_to_scalar(test_perf_alert): "detected_push_id": 999, "detected_push_revision": None, } - From 14aa5d4c17e531367bc8c765594bcac60e03cb47 Mon Sep 17 00:00:00 2001 From: esanuandra Date: Wed, 26 Aug 2026 16:55:46 +0300 Subject: [PATCH 4/4] add suggested culprit text next to revision --- .../alerts-view/alerts_table_row_test.jsx | 21 +++++++++++++++++-- ui/perfherder/alerts/AlertTableRow.jsx | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx b/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx index 60be90986a6..8dc3d4a65d5 100644 --- a/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx +++ b/tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx @@ -457,20 +457,37 @@ describe('detected push revision', () => { }, }); - test('renders the detected push revision, styled italic/muted/small', async () => { + test('renders the suggested culprit revision, styled italic/muted/small', async () => { const revision = 'a1b2c3d4e5f6'; const { getByText } = alertTableRowTest({ alert: alertWithBackfill({ detected_push_revision: revision }), tags: false, }); - const revisionEl = await waitFor(() => getByText(revision)); + const revisionEl = await waitFor(() => + getByText(`Suggested culprit: ${revision}`), + ); expect(revisionEl).toBeInTheDocument(); expect(revisionEl).toHaveClass('fst-italic'); expect(revisionEl).toHaveClass('text-muted'); expect(revisionEl).toHaveClass('small'); }); + test('truncates the suggested culprit revision to 12 characters', async () => { + const revision = 'abcdef0123456789abcdef0123456789abcdef01'; // 40-char sha + const { getByText, queryByText } = alertTableRowTest({ + alert: alertWithBackfill({ detected_push_revision: revision }), + tags: false, + }); + + const revisionEl = await waitFor(() => + getByText(`Suggested culprit: ${revision.slice(0, 12)}`), + ); + expect(revisionEl).toBeInTheDocument(); + // the full, untruncated revision is not shown + expect(queryByText(`Suggested culprit: ${revision}`)).toBeNull(); + }); + test('does not render the revision span when detected_push_revision is absent', async () => { const alert = alertWithBackfill(); // backfill record present, but no detected push const { container, getByTestId } = alertTableRowTest({ diff --git a/ui/perfherder/alerts/AlertTableRow.jsx b/ui/perfherder/alerts/AlertTableRow.jsx index 582b0e898b6..7ecebf33319 100644 --- a/ui/perfherder/alerts/AlertTableRow.jsx +++ b/ui/perfherder/alerts/AlertTableRow.jsx @@ -508,7 +508,7 @@ export default class AlertTableRow extends React.Component { {detectedPushRevision && ( - {detectedPushRevision.slice(0, 12)} + Suggested culprit: {detectedPushRevision.slice(0, 12)} )}