From 256acc61c730d4fd16087c88bebf47b5618b3e95 Mon Sep 17 00:00:00 2001 From: Damjan Jelic <103260312+duke-b@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:38:22 +0100 Subject: [PATCH] Feature/#162 mttr backend (#163) * init commit' * Added tests * Test fix * Test fix --- devlake-go/api/main.go | 2 + devlake-go/api/main_test.go | 12 ++- devlake-go/api/service/metric_mttr_service.go | 25 +++++ .../api/service/metric_mttr_service_test.go | 96 +++++++++++++++++++ .../sql_client/sql_queries/monthly_mltc.sql | 14 +-- .../sql_client/sql_queries/monthly_mttr.sql | 42 ++++++++ .../sql_client/sql_queries/quarterly_mttr.sql | 57 +++++++++++ .../api/sql_client/sql_queries/queries.go | 9 ++ .../sql_client/sql_queries/weekly_mttr.sql | 55 +++++++++++ devlake-go/api/validation/parameters.go | 2 +- devlake-go/api/validation/parameters_test.go | 2 +- 11 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 devlake-go/api/service/metric_mttr_service.go create mode 100644 devlake-go/api/service/metric_mttr_service_test.go create mode 100644 devlake-go/api/sql_client/sql_queries/monthly_mttr.sql create mode 100644 devlake-go/api/sql_client/sql_queries/quarterly_mttr.sql create mode 100644 devlake-go/api/sql_client/sql_queries/weekly_mttr.sql diff --git a/devlake-go/api/main.go b/devlake-go/api/main.go index 2165325..585593b 100644 --- a/devlake-go/api/main.go +++ b/devlake-go/api/main.go @@ -46,11 +46,13 @@ func metricHandler(client sql_client.ClientInterface) func(w http.ResponseWriter dfService := service.MetricDfService{Client: client} mltcService := service.MetricMltcService{Client: client} cfrService := service.MetricCfrService{Client: client} + mttrService := service.MetricMttrService{Client: client} serviceMap := map[string]service.Service[models.MetricResponse]{ "df_count": dfService, "df_average": dfService, "mltc": mltcService, "cfr": cfrService, + "mttr": mttrService, } return handler(validation.ValidMetricServiceParameters, serviceMap) diff --git a/devlake-go/api/main_test.go b/devlake-go/api/main_test.go index 9943e0c..75c8c1b 100644 --- a/devlake-go/api/main_test.go +++ b/devlake-go/api/main_test.go @@ -22,18 +22,18 @@ func Test_metricHandler(t *testing.T) { name: "should throw 400 response when not specifying metric type", req: httptest.NewRequest(http.MethodGet, "/dora/api/metric?", nil), expectStatusCode: 400, - expectBody: "type should be provided as one of the following: df_count, df_average, mltc, cfr\n", + expectBody: "type should be provided as one of the following: df_count, df_average, mltc, cfr, mttr\n", }, { name: "should throw 400 response when specifying nonsense metric type", req: httptest.NewRequest(http.MethodGet, "/dora/api/metric?type=not_metric", nil), - expectBody: "type should be provided as one of the following: df_count, df_average, mltc, cfr\n", + expectBody: "type should be provided as one of the following: df_count, df_average, mltc, cfr, mttr\n", expectStatusCode: 400, }, { name: "should throw 400 response when specifying multiple metric types", req: httptest.NewRequest(http.MethodGet, "/dora/api/metric?type=df_count&type=df_average", nil), - expectBody: "type should be provided as one of the following: df_count, df_average, mltc, cfr\n", + expectBody: "type should be provided as one of the following: df_count, df_average, mltc, cfr, mttr\n", expectStatusCode: 400, }, { @@ -60,6 +60,12 @@ func Test_metricHandler(t *testing.T) { expectBody: `{"aggregation":"weekly","dataPoints":[]}` + "\n", expectStatusCode: 200, }, + { + name: "should return data response when specifying mttr", + req: httptest.NewRequest(http.MethodGet, "/dora/api/metric?type=mttr", nil), + expectBody: `{"aggregation":"weekly","dataPoints":[]}` + "\n", + expectStatusCode: 200, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/devlake-go/api/service/metric_mttr_service.go b/devlake-go/api/service/metric_mttr_service.go new file mode 100644 index 0000000..63a3ec1 --- /dev/null +++ b/devlake-go/api/service/metric_mttr_service.go @@ -0,0 +1,25 @@ +package service + +import ( + "github.com/devoteamnl/opendora/api/models" + "github.com/devoteamnl/opendora/api/sql_client" + "github.com/devoteamnl/opendora/api/sql_client/sql_queries" +) + +type MetricMttrService struct { + Client sql_client.ClientInterface +} + +func (service MetricMttrService) ServeRequest(params ServiceParameters) (models.MetricResponse, error) { + aggregationQueryMap := map[string]string{ + "weekly": sql_queries.WeeklyMttrSql, + "monthly": sql_queries.MonthlyMttrSql, + "quarterly": sql_queries.QuarterlyMttrSql, + } + + query := aggregationQueryMap[params.Aggregation] + + dataPoints, err := service.Client.QueryDeployments(query, sql_client.QueryParams{To: params.To, From: params.From, Project: params.Project}) + + return models.MetricResponse{Aggregation: params.Aggregation, DataPoints: dataPoints}, err +} diff --git a/devlake-go/api/service/metric_mttr_service_test.go b/devlake-go/api/service/metric_mttr_service_test.go new file mode 100644 index 0000000..3457d7f --- /dev/null +++ b/devlake-go/api/service/metric_mttr_service_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "fmt" + "reflect" + "testing" + + "github.com/devoteamnl/opendora/api/models" + "github.com/devoteamnl/opendora/api/sql_client" + "github.com/devoteamnl/opendora/api/sql_client/sql_queries" +) + +func TestMetricMttrService_ServeRequest(t *testing.T) { + + exampleWeeklyDataPoints := []models.DataPoint{{Key: "202338", Value: 0}, {Key: "202337", Value: 1}, {Key: "202336", Value: 2}} + exampleMonthlyDataPoints := []models.DataPoint{{Key: "23/04", Value: 6}, {Key: "23/03", Value: 5}, {Key: "23/02", Value: 4}} + exampleQuarterlyDataPoints := []models.DataPoint{{Key: "2024-01-01", Value: 6}, {Key: "2023-10-01", Value: 5}, {Key: "2023-07-01", Value: 4}} + + dataMockMap := map[string]sql_client.MockDeploymentsDataReturn{ + sql_queries.WeeklyMttrSql: {Data: exampleWeeklyDataPoints}, + sql_queries.MonthlyMttrSql: {Data: exampleMonthlyDataPoints}, + sql_queries.QuarterlyMttrSql: {Data: exampleQuarterlyDataPoints}} + + errorMockMap := map[string]sql_client.MockDeploymentsDataReturn{ + sql_queries.WeeklyMttrSql: {Err: fmt.Errorf("error from weekly query")}, + sql_queries.MonthlyMttrSql: {Err: fmt.Errorf("error from monthly query")}, + sql_queries.QuarterlyMttrSql: {Err: fmt.Errorf("error from quarterly query")}, + } + + tests := []struct { + name string + params ServiceParameters + mockClient sql_client.MockClient + expectResponse models.MetricResponse + expectError string + }{ + { + name: "should return an error with an unexpected error from the database", + params: ServiceParameters{TypeQuery: "mttr", Aggregation: "weekly", Project: "", To: 0, From: 0}, + mockClient: sql_client.MockClient{MockDeploymentsDataMap: errorMockMap}, + expectResponse: models.MetricResponse{Aggregation: "weekly", DataPoints: nil}, + expectError: "error from weekly query", + }, + { + name: "should return an error with an unexpected error from the database", + params: ServiceParameters{TypeQuery: "mttr", Aggregation: "monthly", Project: "", To: 0, From: 0}, + mockClient: sql_client.MockClient{MockDeploymentsDataMap: errorMockMap}, + expectResponse: models.MetricResponse{Aggregation: "monthly", DataPoints: nil}, + expectError: "error from monthly query", + }, + { + name: "should return an error with an unexpected error from the database", + params: ServiceParameters{TypeQuery: "mttr", Aggregation: "quarterly", Project: "", To: 0, From: 0}, + mockClient: sql_client.MockClient{MockDeploymentsDataMap: errorMockMap}, + expectResponse: models.MetricResponse{Aggregation: "quarterly", DataPoints: nil}, + expectError: "error from quarterly query", + }, + { + name: "should return weekly data points from the database", + params: ServiceParameters{TypeQuery: "mttr", Aggregation: "weekly", Project: "", To: 0, From: 0}, + mockClient: sql_client.MockClient{MockDeploymentsDataMap: dataMockMap}, + expectResponse: models.MetricResponse{Aggregation: "weekly", DataPoints: exampleWeeklyDataPoints}, + expectError: "", + }, + { + name: "should return monthly data points from the database", + params: ServiceParameters{TypeQuery: "mttr", Aggregation: "monthly", Project: "", To: 0, From: 0}, + mockClient: sql_client.MockClient{MockDeploymentsDataMap: dataMockMap}, + expectResponse: models.MetricResponse{Aggregation: "monthly", DataPoints: exampleMonthlyDataPoints}, + expectError: "", + }, + { + name: "should return quarterly data points from the database", + params: ServiceParameters{TypeQuery: "mttr", Aggregation: "quarterly", Project: "", To: 0, From: 0}, + mockClient: sql_client.MockClient{MockDeploymentsDataMap: dataMockMap}, + expectResponse: models.MetricResponse{Aggregation: "quarterly", DataPoints: exampleQuarterlyDataPoints}, + expectError: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mttrService := MetricMttrService{Client: tt.mockClient} + got, err := mttrService.ServeRequest(tt.params) + + if err == nil && tt.expectError != "" { + t.Errorf("expected '%v' got no error", tt.expectError) + } + if err != nil && err.Error() != tt.expectError { + t.Errorf("expected '%v' got '%v'", tt.expectError, err) + } + if !reflect.DeepEqual(got, tt.expectResponse) { + t.Errorf("MttrService.ServeRequest() = %v, want %v", got, tt.expectResponse) + } + }) + } +} diff --git a/devlake-go/api/sql_client/sql_queries/monthly_mltc.sql b/devlake-go/api/sql_client/sql_queries/monthly_mltc.sql index 990a725..85b2527 100644 --- a/devlake-go/api/sql_client/sql_queries/monthly_mltc.sql +++ b/devlake-go/api/sql_client/sql_queries/monthly_mltc.sql @@ -11,8 +11,8 @@ WITH _pr_stats AS ( JOIN repos ON cdc.repo_id = repos.id WHERE ( - :project = "" - OR LOWER(repos.name) LIKE CONCAT('%/', LOWER(:project)) + :project = "" + OR LOWER(repos.name) LIKE CONCAT('%/', LOWER(:project)) ) AND pr.merged_date IS NOT NULL AND ppm.pr_cycle_time IS NOT NULL @@ -33,11 +33,11 @@ _clt as( ) SELECT - cm.month as data_key, - case - when _clt.median_change_lead_time is null then 0 - else _clt.median_change_lead_time/60 - end as data_value + cm.month AS data_key, + CASE + WHEN _clt.median_change_lead_time IS NULL THEN 0 + ELSE _clt.median_change_lead_time/60 + END AS data_value FROM calendar_months cm LEFT JOIN _clt ON cm.month = _clt.month diff --git a/devlake-go/api/sql_client/sql_queries/monthly_mttr.sql b/devlake-go/api/sql_client/sql_queries/monthly_mttr.sql new file mode 100644 index 0000000..c15adc9 --- /dev/null +++ b/devlake-go/api/sql_client/sql_queries/monthly_mttr.sql @@ -0,0 +1,42 @@ +WITH _incidents AS ( + SELECT + DISTINCT i.id, + DATE_FORMAT(i.created_date,'%y/%m') AS month, + cast(lead_time_minutes AS signed) AS lead_time_minutes + FROM + issues i + JOIN board_issues bi ON i.id = bi.issue_id + JOIN boards b ON bi.board_id = b.id + JOIN project_mapping pm ON b.id = pm.row_id AND pm.`table` = 'boards' + WHERE + ( + :project = "" + OR LOWER(repos.name) LIKE CONCAT('%/', LOWER(:project)) + ) + AND i.type = 'INCIDENT' + AND i.lead_time_minutes IS NOT NULL +), + +_find_median_mttr_each_month_ranks as( + SELECT *, percent_rank() OVER(PARTITION BY month ORDER BY lead_time_minutes) AS ranks + FROM _incidents +), + +_mttr as( + SELECT month, max(lead_time_minutes) AS median_time_to_resolve + FROM _find_median_mttr_each_month_ranks + WHERE ranks <= 0.5 + GROUP BY month +) + +SELECT + cm.month AS data_key, + CASE + WHEN m.median_time_to_resolve IS NULL THEN 0 + ELSE m.median_time_to_resolve/60 + END AS data_value +FROM + calendar_months cm + LEFT JOIN _mttr m ON cm.month = m.month + WHERE cm.month_timestamp BETWEEN FROM_UNIXTIME(:from) + AND FROM_UNIXTIME(:to) \ No newline at end of file diff --git a/devlake-go/api/sql_client/sql_queries/quarterly_mttr.sql b/devlake-go/api/sql_client/sql_queries/quarterly_mttr.sql new file mode 100644 index 0000000..a4d8461 --- /dev/null +++ b/devlake-go/api/sql_client/sql_queries/quarterly_mttr.sql @@ -0,0 +1,57 @@ + +WITH RECURSIVE calendar_quarters AS ( + SELECT + DATE_ADD( + MAKEDATE(YEAR(FROM_UNIXTIME(:from)), 1), + INTERVAL QUARTER(FROM_UNIXTIME(:from)) -1 QUARTER + ) AS quarter_date + UNION + ALL + SELECT + DATE_ADD(quarter_date, INTERVAL 1 QUARTER) + FROM + calendar_quarters + WHERE + quarter_date < FROM_UNIXTIME(:to) +), _incidents AS ( + SELECT + DISTINCT i.id, + i.created_date AS quarter, + cast(lead_time_minutes AS signed) AS lead_time_minutes + FROM + issues i + JOIN board_issues bi ON i.id = bi.issue_id + JOIN boards b ON bi.board_id = b.id + JOIN project_mapping pm ON b.id = pm.row_id AND pm.`table` = 'boards' + WHERE + ( + :project = "" + OR LOWER(repos.name) LIKE CONCAT('%/', LOWER(:project)) + ) + AND i.type = 'INCIDENT' + AND i.lead_time_minutes IS NOT NULL +), + +_find_median_mttr_each_quarter_ranks as( + SELECT *, percent_rank() OVER(PARTITION BY quarter ORDER BY lead_time_minutes) AS ranks + FROM _incidents +), + +_mttr as( + SELECT quarter, max(lead_time_minutes) AS median_time_to_resolve + FROM _find_median_mttr_each_quarter_ranks + WHERE ranks <= 0.5 + GROUP BY quarter +) + +SELECT + cq.quarter_date AS data_key, + CASE + WHEN m.median_time_to_resolve IS NULL THEN 0 + ELSE m.median_time_to_resolve/60 + END AS data_value +FROM + calendar_quarters cq + LEFT JOIN _mttr m ON cq.quarter_date = m.quarter + ORDER BY + cq.quarter_date DESC \ No newline at end of file diff --git a/devlake-go/api/sql_client/sql_queries/queries.go b/devlake-go/api/sql_client/sql_queries/queries.go index 644a560..2bfaa1b 100644 --- a/devlake-go/api/sql_client/sql_queries/queries.go +++ b/devlake-go/api/sql_client/sql_queries/queries.go @@ -40,3 +40,12 @@ var MonthlyCfrSql string //go:embed quarterly_cfr.sql var QuarterlyCfrSql string + +//go:embed weekly_mttr.sql +var WeeklyMttrSql string + +//go:embed monthly_mttr.sql +var MonthlyMttrSql string + +//go:embed quarterly_mttr.sql +var QuarterlyMttrSql string diff --git a/devlake-go/api/sql_client/sql_queries/weekly_mttr.sql b/devlake-go/api/sql_client/sql_queries/weekly_mttr.sql new file mode 100644 index 0000000..b487b95 --- /dev/null +++ b/devlake-go/api/sql_client/sql_queries/weekly_mttr.sql @@ -0,0 +1,55 @@ +WITH RECURSIVE calendar_weeks AS ( + SELECT + STR_TO_DATE( + CONCAT(YEARWEEK(FROM_UNIXTIME(:from)), ' Sunday'), + '%X%V %W' + ) AS week_date + UNION + ALL + SELECT + DATE_ADD(week_date, INTERVAL 1 WEEK) + FROM + calendar_weeks + WHERE + week_date < FROM_UNIXTIME(:to) +),_incidents AS ( + SELECT + DISTINCT i.id, + YEARWEEK(i.created_date) AS week, + cast(lead_time_minutes AS signed) AS lead_time_minutes + FROM + issues i + JOIN board_issues bi ON i.id = bi.issue_id + JOIN boards b ON bi.board_id = b.id + JOIN project_mapping pm ON b.id = pm.row_id AND pm.`table` = 'boards' + WHERE + ( + :project = "" + OR LOWER(repos.name) LIKE CONCAT('%/', LOWER(:project)) + ) + AND i.type = 'INCIDENT' + AND i.lead_time_minutes IS NOT NULL +), + +_find_median_mttr_each_week_ranks as( + SELECT *, percent_rank() OVER(PARTITION BY week ORDER BY lead_time_minutes) AS ranks + FROM _incidents +), + +_mttr as( + SELECT week, max(lead_time_minutes) AS median_time_to_resolve + FROM _find_median_mttr_each_week_ranks + WHERE ranks <= 0.5 + GROUP BY week +) + +SELECT + YEARWEEK(cw.week_date) AS data_key, + CASE + WHEN m.median_time_to_resolve IS NULL THEN 0 + ELSE m.median_time_to_resolve/60 END AS data_value +FROM + calendar_weeks cw + LEFT JOIN _mttr m ON YEARWEEK(cw.week_date) = m.week + ORDER BY + cw.week_date DESC \ No newline at end of file diff --git a/devlake-go/api/validation/parameters.go b/devlake-go/api/validation/parameters.go index db17388..aea75a4 100644 --- a/devlake-go/api/validation/parameters.go +++ b/devlake-go/api/validation/parameters.go @@ -115,7 +115,7 @@ func ValidMetricServiceParameters(queries url.Values) (service.ServiceParameters return service.ServiceParameters{}, fmt.Errorf("aggregation should be provided as either weekly, monthly or quarterly") } - serviceParameters, err := validServiceParameters(queries, []string{"df_count", "df_average", "mltc", "cfr"}) + serviceParameters, err := validServiceParameters(queries, []string{"df_count", "df_average", "mltc", "cfr", "mttr"}) if err != nil { return serviceParameters, err } diff --git a/devlake-go/api/validation/parameters_test.go b/devlake-go/api/validation/parameters_test.go index 9b32c73..339eb16 100644 --- a/devlake-go/api/validation/parameters_test.go +++ b/devlake-go/api/validation/parameters_test.go @@ -437,7 +437,7 @@ func Test_ValidMetricServiceParameters(t *testing.T) { name: "should return an error for an invalid type parameter", values: url.Values{"type": {"df"}}, expectServiceParameters: service.ServiceParameters{}, - expectError: "type should be provided as one of the following: df_count, df_average, mltc, cfr", + expectError: "type should be provided as one of the following: df_count, df_average, mltc, cfr, mttr", }, { name: "should return an error for an invalid aggregation parameter",