From 7668d85a86dc6f995f861da1d31db08f238a6dfa Mon Sep 17 00:00:00 2001 From: Sergiy Kulanov Date: Fri, 24 Apr 2026 13:16:06 +0300 Subject: [PATCH] EPMDEDP-16730: refactor: Isolate Tekton Results types behind local projection Upstream portal OpenAPI spec no longer exposes TektonResult and TektonResultSummary as named components, so the regenerated client leaks anonymous structs and nullable.Nullable wrappers across the pipelinerun flow. Introduce a local tektonResult projection, convert once at the API boundary, and keep mapPipelineRunInfo, computeDuration, fetch helpers, and tests working against clean internal types instead of propagating the generated surface throughout the service. Signed-off-by: Sergiy Kulanov --- go.mod | 10 +- go.sum | 2 - internal/portal/openapi/spec.json | 169 +++++++++++++-------------- internal/portal/pipelinerun.go | 75 +++++++++--- internal/portal/pipelinerun_test.go | 20 ++-- internal/portal/pipelinerun_types.go | 27 ++++- internal/portal/restapi/api_gen.go | 90 +++++++------- 7 files changed, 224 insertions(+), 169 deletions(-) diff --git a/go.mod b/go.mod index 62e8a99..d7eba06 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,17 @@ go 1.26.1 require ( charm.land/lipgloss/v2 v2.0.2 github.com/coreos/go-oidc/v3 v3.17.0 + github.com/google/uuid v1.5.0 + github.com/oapi-codegen/nullable v1.1.0 + github.com/oapi-codegen/runtime v1.1.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/zalando/go-keyring v0.2.6 golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.20.0 ) require ( @@ -30,13 +35,10 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/google/uuid v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/oapi-codegen/nullable v1.1.0 // indirect - github.com/oapi-codegen/runtime v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -45,11 +47,9 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.31.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index 237634b..6e311a2 100644 --- a/go.sum +++ b/go.sum @@ -115,8 +115,6 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/portal/openapi/spec.json b/internal/portal/openapi/spec.json index 47f5d1d..5e4d709 100644 --- a/internal/portal/openapi/spec.json +++ b/internal/portal/openapi/spec.json @@ -911,7 +911,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PipelineRunResultsResponse" + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "create_time": { + "type": "string" + }, + "update_time": { + "type": "string" + }, + "summary": { + "type": "object", + "nullable": true, + "properties": { + "record": { + "type": "string" + }, + "type": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "UNKNOWN", + "SUCCESS", + "FAILURE", + "TIMEOUT", + "CANCELLED" + ], + "default": "UNKNOWN" + }, + "start_time": { + "type": "string", + "nullable": true + }, + "end_time": { + "type": "string", + "nullable": true + }, + "annotations": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "record", + "type", + "status" + ] + }, + "annotations": { + "type": "object", + "additionalProperties": {} + }, + "etag": { + "type": "string" + } + }, + "required": [ + "uid", + "name", + "create_time", + "update_time" + ] + } + }, + "nextPageToken": { + "type": "string" + } + }, + "required": [ + "results" + ] } } } @@ -2462,92 +2543,6 @@ "exitCode" ] }, - "PipelineRunResultsResponse": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TektonResult" - } - }, - "nextPageToken": { - "type": "string" - } - }, - "required": [ - "results" - ] - }, - "TektonResult": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "name": { - "type": "string" - }, - "create_time": { - "type": "string" - }, - "update_time": { - "type": "string" - }, - "summary": { - "$ref": "#/components/schemas/TektonResultSummary" - }, - "annotations": { - "type": "object", - "additionalProperties": {} - }, - "etag": { - "type": "string" - } - }, - "required": [ - "uid", - "name", - "create_time", - "update_time" - ] - }, - "TektonResultSummary": { - "type": "object", - "properties": { - "record": { - "type": "string" - }, - "type": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "UNKNOWN", - "SUCCESS", - "FAILURE", - "TIMEOUT", - "CANCELLED" - ] - }, - "start_time": { - "type": "string" - }, - "end_time": { - "type": "string" - }, - "annotations": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "record", - "type", - "status" - ] - }, "SCAMetrics": { "type": "object", "description": "Vulnerability-count metrics for a project or component.", diff --git a/internal/portal/pipelinerun.go b/internal/portal/pipelinerun.go index 9613aca..a323a13 100644 --- a/internal/portal/pipelinerun.go +++ b/internal/portal/pipelinerun.go @@ -14,12 +14,58 @@ import ( "time" "github.com/google/uuid" + "github.com/oapi-codegen/nullable" "golang.org/x/sync/errgroup" "github.com/KubeRocketCI/cli/internal/portal/restapi" "github.com/KubeRocketCI/cli/internal/ptr" ) +// toTektonResult converts a generated Tekton Results record into the local +// projection. Centralizing the generated anonymous-struct and nullable.Nullable +// wrappers here keeps service code and tests on clean internal types; Go +// structural identity makes the parameter match resp.JSON200.Results[i]. +func toTektonResult(gen *struct { + Annotations *map[string]any `json:"annotations,omitempty"` + CreateTime string `json:"create_time"` + Etag *string `json:"etag,omitempty"` + Name string `json:"name"` + Summary nullable.Nullable[struct { + Annotations *map[string]any `json:"annotations,omitempty"` + EndTime nullable.Nullable[string] `json:"end_time,omitempty"` + Record string `json:"record"` + StartTime nullable.Nullable[string] `json:"start_time,omitempty"` + Status restapi.TektonResultsGetPipelineRunResults200ResultsSummaryStatus `json:"status"` + Type string `json:"type"` + }] `json:"summary,omitempty"` + Uid string `json:"uid"` + UpdateTime string `json:"update_time"` +}) *tektonResult { + r := &tektonResult{ + UID: gen.Uid, + Name: gen.Name, + CreateTime: gen.CreateTime, + UpdateTime: gen.UpdateTime, + Annotations: ptr.Deref(gen.Annotations, nil), + } + + sum, err := gen.Summary.Get() + if err != nil { + return r + } + + start, _ := sum.StartTime.Get() + end, _ := sum.EndTime.Get() + r.Summary = &tektonResultSummary{ + Record: sum.Record, + Status: string(sum.Status), + StartTime: start, + EndTime: end, + } + + return r +} + var pipelineRunResourceConfig = restapi.K8sListJSONBody{ ResourceConfig: struct { ApiVersion string `json:"apiVersion"` @@ -114,7 +160,8 @@ func (s *PipelineRunService) getFromResults( return nil, fmt.Errorf("pipeline run %q: %w", name, ErrNotFound) } - r := &resp.JSON200.Results[0] + r := toTektonResult(&resp.JSON200.Results[0]) + info := mapPipelineRunInfo(r) info.PortalURL = s.pipelineRunPortalURL(info.Name) @@ -224,7 +271,7 @@ func (s *PipelineRunService) listFromResults( runs := make([]PipelineRunInfo, 0, len(resp.JSON200.Results)) for i := range resp.JSON200.Results { - info := mapPipelineRunInfo(&resp.JSON200.Results[i]) + info := mapPipelineRunInfo(toTektonResult(&resp.JSON200.Results[i])) info.PortalURL = s.pipelineRunPortalURL(info.Name) runs = append(runs, info) } @@ -541,7 +588,7 @@ func parseResultAnnotations(raw string) map[string]string { // fetchExpansion fetches optional task tree or logs for the given pipeline run result. func (s *PipelineRunService) fetchExpansion( - ctx context.Context, r *restapi.TektonResult, out *PipelineRunListResult, includeLogs, includeReason bool, + ctx context.Context, r *tektonResult, out *PipelineRunListResult, includeLogs, includeReason bool, ) error { if includeReason { return s.fetchReason(ctx, r, out) @@ -559,7 +606,7 @@ func (s *PipelineRunService) fetchExpansion( return nil } -func (s *PipelineRunService) fetchLogs(ctx context.Context, r *restapi.TektonResult) (string, error) { +func (s *PipelineRunService) fetchLogs(ctx context.Context, r *tektonResult) (string, error) { if r.Summary == nil { return "", nil } @@ -601,7 +648,7 @@ func (s *PipelineRunService) fetchLogs(ctx context.Context, r *restapi.TektonRes } func (s *PipelineRunService) fetchReason( - ctx context.Context, r *restapi.TektonResult, out *PipelineRunListResult, + ctx context.Context, r *tektonResult, out *PipelineRunListResult, ) error { if r.Summary == nil { return nil @@ -765,15 +812,15 @@ func computeTaskDuration(t *restapi.TaskRun) string { } // mapPipelineRunInfo converts a Tekton Result to the display model. -func mapPipelineRunInfo(r *restapi.TektonResult) PipelineRunInfo { +func mapPipelineRunInfo(r *tektonResult) PipelineRunInfo { name := resultAnnotation(r, annotationObjectName) if name == "" { - name = r.Uid + name = r.UID } status := "" if r.Summary != nil { - status = displayStatus(string(r.Summary.Status)) + status = displayStatus(r.Summary.Status) } // Prefer summary.start_time (actual pipeline start) over create_time (when the @@ -782,10 +829,8 @@ func mapPipelineRunInfo(r *restapi.TektonResult) PipelineRunInfo { // use status.startTime and would sort alongside Results entries tagged with // completion timestamps. startTime := r.CreateTime - if r.Summary != nil { - if s := ptr.Deref(r.Summary.StartTime, ""); s != "" { - startTime = s - } + if r.Summary != nil && r.Summary.StartTime != "" { + startTime = r.Summary.StartTime } return PipelineRunInfo{ @@ -805,12 +850,12 @@ func mapPipelineRunInfo(r *restapi.TektonResult) PipelineRunInfo { } } -func computeDuration(r *restapi.TektonResult, startStr string) string { - if r.Summary == nil || string(r.Summary.Status) == resultStatusUnknown { +func computeDuration(r *tektonResult, startStr string) string { + if r.Summary == nil || r.Summary.Status == resultStatusUnknown { return "" } - endStr := ptr.Deref(r.Summary.EndTime, "") + endStr := r.Summary.EndTime if endStr == "" { endStr = r.UpdateTime } diff --git a/internal/portal/pipelinerun_test.go b/internal/portal/pipelinerun_test.go index 9a9fe76..f979de1 100644 --- a/internal/portal/pipelinerun_test.go +++ b/internal/portal/pipelinerun_test.go @@ -416,16 +416,16 @@ func TestMapPipelineRunInfo_PrefersSummaryStartTime(t *testing.T) { summaryStart := "2024-01-01T10:00:00Z" recordCreate := "2024-01-01T10:05:00Z" // record stored 5 min after start - r := &restapi.TektonResult{ - Uid: "uuid-1", + r := &tektonResult{ + UID: "uuid-1", Name: "results/ns/records/r1", CreateTime: recordCreate, UpdateTime: "2024-01-01T10:05:00Z", - Summary: &restapi.TektonResultSummary{ + Summary: &tektonResultSummary{ Record: "results/ns/records/r1", Status: "SUCCESS", - StartTime: &summaryStart, - EndTime: func() *string { s := "2024-01-01T10:04:30Z"; return &s }(), + StartTime: summaryStart, + EndTime: "2024-01-01T10:04:30Z", }, } @@ -440,8 +440,8 @@ func TestMapPipelineRunInfo_FallsBackToCreateTime(t *testing.T) { t.Run("no summary", func(t *testing.T) { t.Parallel() - r := &restapi.TektonResult{ - Uid: "uuid-1", + r := &tektonResult{ + UID: "uuid-1", Name: "results/ns/records/r1", CreateTime: "2024-01-01T10:05:00Z", UpdateTime: "2024-01-01T10:05:00Z", @@ -452,12 +452,12 @@ func TestMapPipelineRunInfo_FallsBackToCreateTime(t *testing.T) { t.Run("summary without start_time", func(t *testing.T) { t.Parallel() - r := &restapi.TektonResult{ - Uid: "uuid-1", + r := &tektonResult{ + UID: "uuid-1", Name: "results/ns/records/r1", CreateTime: "2024-01-01T10:05:00Z", UpdateTime: "2024-01-01T10:05:00Z", - Summary: &restapi.TektonResultSummary{ + Summary: &tektonResultSummary{ Record: "results/ns/records/r1", Status: "SUCCESS", }, diff --git a/internal/portal/pipelinerun_types.go b/internal/portal/pipelinerun_types.go index c6cd887..47df35d 100644 --- a/internal/portal/pipelinerun_types.go +++ b/internal/portal/pipelinerun_types.go @@ -79,13 +79,32 @@ func displayStatus(resultStatus string) string { const conditionSucceeded = "Succeeded" -func resultAnnotation(r *restapi.TektonResult, key string) string { - annotations := ptr.Deref(r.Annotations, nil) - if annotations == nil { +// tektonResult is a local projection of a Tekton Results record. The portal +// inlines these fields into the response schema rather than exposing a named +// component, so we copy them across the API boundary and keep the service code +// and tests free of the generated anonymous struct. +type tektonResult struct { + UID string + Name string + CreateTime string + UpdateTime string + Annotations map[string]any + Summary *tektonResultSummary +} + +type tektonResultSummary struct { + Record string + Status string + StartTime string + EndTime string +} + +func resultAnnotation(r *tektonResult, key string) string { + if r.Annotations == nil { return "" } - v, ok := annotations[key] + v, ok := r.Annotations[key] if !ok { return "" } diff --git a/internal/portal/restapi/api_gen.go b/internal/portal/restapi/api_gen.go index 51b761c..d4ed0a6 100644 --- a/internal/portal/restapi/api_gen.go +++ b/internal/portal/restapi/api_gen.go @@ -107,15 +107,6 @@ const ( WARN SonarProjectDetailQualityGateStatus = "WARN" ) -// Defines values for TektonResultSummaryStatus. -const ( - CANCELLED TektonResultSummaryStatus = "CANCELLED" - FAILURE TektonResultSummaryStatus = "FAILURE" - SUCCESS TektonResultSummaryStatus = "SUCCESS" - TIMEOUT TektonResultSummaryStatus = "TIMEOUT" - UNKNOWN TektonResultSummaryStatus = "UNKNOWN" -) - // Defines values for ScaComponentsParamsOnlyOutdated. const ( ScaComponentsParamsOnlyOutdatedFalse ScaComponentsParamsOnlyOutdated = "false" @@ -158,12 +149,6 @@ const ( True SonarIssuesParamsAsc = "true" ) -// PipelineRunResultsResponse defines model for PipelineRunResultsResponse. -type PipelineRunResultsResponse struct { - NextPageToken *string `json:"nextPageToken,omitempty"` - Results []TektonResult `json:"results"` -} - // SCAComponent defines model for SCAComponent. type SCAComponent struct { Group *string `json:"group,omitempty"` @@ -515,30 +500,6 @@ type TaskRunStep struct { } `json:"waiting,omitempty"` } -// TektonResult defines model for TektonResult. -type TektonResult struct { - Annotations *map[string]interface{} `json:"annotations,omitempty"` - CreateTime string `json:"create_time"` - Etag *string `json:"etag,omitempty"` - Name string `json:"name"` - Summary *TektonResultSummary `json:"summary,omitempty"` - Uid string `json:"uid"` - UpdateTime string `json:"update_time"` -} - -// TektonResultSummary defines model for TektonResultSummary. -type TektonResultSummary struct { - Annotations *map[string]interface{} `json:"annotations,omitempty"` - EndTime *string `json:"end_time,omitempty"` - Record string `json:"record"` - StartTime *string `json:"start_time,omitempty"` - Status TektonResultSummaryStatus `json:"status"` - Type string `json:"type"` -} - -// TektonResultSummaryStatus defines model for TektonResultSummary.Status. -type TektonResultSummaryStatus string - // ErrorBADREQUEST The error information type ErrorBADREQUEST struct { // Code The error code @@ -2516,13 +2477,32 @@ func (r ConfigOidcResponse) StatusCode() int { type TektonResultsGetPipelineRunResultsResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *PipelineRunResultsResponse - JSON400 *ErrorBADREQUEST - JSON401 *ErrorUNAUTHORIZED - JSON403 *ErrorFORBIDDEN - JSON404 *ErrorNOTFOUND - JSON500 *ErrorINTERNALSERVERERROR + JSON200 *struct { + NextPageToken *string `json:"nextPageToken,omitempty"` + Results []struct { + Annotations *map[string]interface{} `json:"annotations,omitempty"` + CreateTime string `json:"create_time"` + Etag *string `json:"etag,omitempty"` + Name string `json:"name"` + Summary nullable.Nullable[struct { + Annotations *map[string]interface{} `json:"annotations,omitempty"` + EndTime nullable.Nullable[string] `json:"end_time,omitempty"` + Record string `json:"record"` + StartTime nullable.Nullable[string] `json:"start_time,omitempty"` + Status TektonResultsGetPipelineRunResults200ResultsSummaryStatus `json:"status"` + Type string `json:"type"` + }] `json:"summary,omitempty"` + Uid string `json:"uid"` + UpdateTime string `json:"update_time"` + } `json:"results"` + } + JSON400 *ErrorBADREQUEST + JSON401 *ErrorUNAUTHORIZED + JSON403 *ErrorFORBIDDEN + JSON404 *ErrorNOTFOUND + JSON500 *ErrorINTERNALSERVERERROR } +type TektonResultsGetPipelineRunResults200ResultsSummaryStatus string // Status returns HTTPResponse.Status func (r TektonResultsGetPipelineRunResultsResponse) Status() string { @@ -3201,7 +3181,25 @@ func ParseTektonResultsGetPipelineRunResultsResponse(rsp *http.Response) (*Tekto switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PipelineRunResultsResponse + var dest struct { + NextPageToken *string `json:"nextPageToken,omitempty"` + Results []struct { + Annotations *map[string]interface{} `json:"annotations,omitempty"` + CreateTime string `json:"create_time"` + Etag *string `json:"etag,omitempty"` + Name string `json:"name"` + Summary nullable.Nullable[struct { + Annotations *map[string]interface{} `json:"annotations,omitempty"` + EndTime nullable.Nullable[string] `json:"end_time,omitempty"` + Record string `json:"record"` + StartTime nullable.Nullable[string] `json:"start_time,omitempty"` + Status TektonResultsGetPipelineRunResults200ResultsSummaryStatus `json:"status"` + Type string `json:"type"` + }] `json:"summary,omitempty"` + Uid string `json:"uid"` + UpdateTime string `json:"update_time"` + } `json:"results"` + } if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err }