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 }