diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93825ed..3d84cca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,12 +129,8 @@ jobs: runs-on: ubuntu-latest # The Enterprise Edition image exposes enterprise-only API surface without # a license (most endpoints work unlicensed; analysis and a few - # license-gated operations do not). This job never blocks the pipeline: - # if the image can't be pulled in this environment, or the suite finds - # itself pointed at a non-Enterprise instance, it skips cleanly instead - # of failing. Set the SONAR_ENTERPRISE_LICENSE repo secret to also - # exercise license-dependent behavior. - continue-on-error: true + # license-gated operations do not). Set the SONAR_ENTERPRISE_LICENSE repo + # secret to also exercise license-dependent behavior. services: sonarqube-enterprise: image: docker.io/library/sonarqube:2026.3.1-enterprise diff --git a/integration_testing/helpers/client.go b/integration_testing/helpers/client.go index abacd89..fb809d4 100644 --- a/integration_testing/helpers/client.go +++ b/integration_testing/helpers/client.go @@ -81,13 +81,21 @@ func NormalizeBaseURL(baseURL string) string { return baseURL } -// NewClient creates a new SonarQube client for e2e tests using the provided config. +// NewClient creates a new SonarQube client for e2e tests using the provided +// config. Every client is wired with RecordSchemaMismatches so that all API +// responses observed during the e2e run are checked against their modeled Go +// struct, catching fields the SDK's structures have drifted away from (see +// SchemaMismatches). func NewClient(cfg *Config) (*sonar.Client, error) { baseURL := NormalizeBaseURL(cfg.BaseURL) // Prefer token auth if available if cfg.Token != "" { - client, err := sonar.NewClient(nil, sonar.WithBaseURL(baseURL), sonar.WithToken(cfg.Token)) + client, err := sonar.NewClient(nil, + sonar.WithBaseURL(baseURL), + sonar.WithToken(cfg.Token), + sonar.WithSchemaObserver(RecordSchemaMismatches), + ) if err != nil { return nil, fmt.Errorf("failed to create client with token: %w", err) } @@ -96,7 +104,11 @@ func NewClient(cfg *Config) (*sonar.Client, error) { } // Fall back to basic auth - client, err := sonar.NewClient(nil, sonar.WithBaseURL(baseURL), sonar.WithBasicAuth(cfg.Username, cfg.Password)) + client, err := sonar.NewClient(nil, + sonar.WithBaseURL(baseURL), + sonar.WithBasicAuth(cfg.Username, cfg.Password), + sonar.WithSchemaObserver(RecordSchemaMismatches), + ) if err != nil { return nil, fmt.Errorf("failed to create client with basic auth: %w", err) } diff --git a/integration_testing/helpers/schema.go b/integration_testing/helpers/schema.go new file mode 100644 index 0000000..0d8586a --- /dev/null +++ b/integration_testing/helpers/schema.go @@ -0,0 +1,50 @@ +package helpers + +import ( + "slices" + "sync" + + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +//nolint:gochecknoglobals // process-wide recorder shared by every e2e client created via NewClient +var ( + schemaMismatchesMu sync.Mutex + schemaMismatchesSeen = make(map[string]struct{}) + schemaMismatches []sonar.SchemaMismatch +) + +// RecordSchemaMismatches is a sonar.SchemaObserver that accumulates every +// distinct SchemaMismatch observed across the whole e2e run. It is wired into +// every client returned by NewClient/NewDefaultClient so that any API call +// made by any e2e test contributes to strict schema validation, without each +// test needing to assert on it individually. Retrieve the accumulated results +// with SchemaMismatches. +func RecordSchemaMismatches(_ string, mismatches []sonar.SchemaMismatch) { + if len(mismatches) == 0 { + return + } + + schemaMismatchesMu.Lock() + defer schemaMismatchesMu.Unlock() + + for _, mismatch := range mismatches { + key := mismatch.GoType + "|" + mismatch.Path + if _, seen := schemaMismatchesSeen[key]; seen { + continue + } + + schemaMismatchesSeen[key] = struct{}{} + + schemaMismatches = append(schemaMismatches, mismatch) + } +} + +// SchemaMismatches returns every distinct SchemaMismatch recorded so far +// across the whole e2e run. +func SchemaMismatches() []sonar.SchemaMismatch { + schemaMismatchesMu.Lock() + defer schemaMismatchesMu.Unlock() + + return slices.Clone(schemaMismatches) +} diff --git a/integration_testing/suite_test.go b/integration_testing/suite_test.go index d932f61..0d67be3 100644 --- a/integration_testing/suite_test.go +++ b/integration_testing/suite_test.go @@ -1,6 +1,8 @@ package integration_testing_test import ( + "fmt" + "strings" "testing" "time" @@ -19,6 +21,29 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) }) +// AfterSuite performs strict schema validation across every API response +// observed during the run: every e2e client is wired to record any JSON field +// with no corresponding field on its destination Go struct (see +// helpers.RecordSchemaMismatches). Failing here, rather than in the +// individual spec that happened to trigger it, gives one consolidated report +// of every struct that has drifted from the real SonarQube API. +var _ = AfterSuite(func() { + mismatches := helpers.SchemaMismatches() + if len(mismatches) == 0 { + return + } + + lines := make([]string, 0, len(mismatches)) + for _, mismatch := range mismatches { + lines = append(lines, mismatch.String()) + } + + Fail(fmt.Sprintf( + "strict schema validation found %d field(s) in API responses with no match in their Go struct:\n%s", + len(mismatches), strings.Join(lines, "\n"), + )) +}) + func TestIntegrationTesting(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "SonarQube SDK E2E Test Suite") diff --git a/sonar/client.go b/sonar/client.go index 6d29524..ce5ed56 100644 --- a/sonar/client.go +++ b/sonar/client.go @@ -50,6 +50,7 @@ type Client struct { retryOptions *RetryOptions transportConfig *TransportConfig middlewares []Middleware + schemaObserver SchemaObserver userAgent string timeout time.Duration @@ -429,6 +430,24 @@ func WithTimeout(timeout time.Duration) ClientOptionFunc { } } +// WithSchemaObserver is a ClientOptionFunc that registers a callback invoked +// after every successfully decoded API response with any SchemaMismatch found +// between the raw response body and the destination struct. It is intended +// for test suites that want strict validation that the SDK's modeled response +// types still match what the SonarQube API actually returns; it has no effect +// on decoding itself and is nil (disabled) by default. +func WithSchemaObserver(observer SchemaObserver) ClientOptionFunc { + return func(c *Client) error { + if observer == nil { + return errors.New("WithSchemaObserver: observer must not be nil") + } + + c.schemaObserver = observer + + return nil + } +} + // ============================================= // SETTERS // ============================================= @@ -720,7 +739,20 @@ func (c *Client) Do(req *http.Request, dest any) (*http.Response, error) { httpClient := c.httpClient c.mu.RUnlock() - return Do(httpClient, req, dest) + if c.schemaObserver == nil { + return Do(httpClient, req, dest) + } + + endpoint := fmt.Sprintf("%s %s", req.Method, requestEndpoint(req)) + + return doWithBodyObserver(httpClient, req, dest, func(data []byte) { + mismatches, err := CheckSchema(endpoint, data, dest) + if err != nil { + return + } + + c.schemaObserver(endpoint, mismatches) + }) } // ============================================= diff --git a/sonar/client_schema_observer_test.go b/sonar/client_schema_observer_test.go new file mode 100644 index 0000000..63fe1b8 --- /dev/null +++ b/sonar/client_schema_observer_test.go @@ -0,0 +1,84 @@ +package sonar + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithSchemaObserver_ReportsUnknownField(t *testing.T) { + t.Parallel() + + ts := newTestServer(t, mockHandler(t, http.MethodGet, "/projects/search", http.StatusOK, + `{"components":[{"key":"k","ghostField":"boo"}],"paging":{"pageIndex":1,"pageSize":10,"total":1}}`)) + + var ( + gotEndpoint string + gotMismatches []SchemaMismatch + ) + + client, err := NewClient(nil, WithBaseURL(ts.url()), WithSchemaObserver(func(endpoint string, mismatches []SchemaMismatch) { + gotEndpoint = endpoint + gotMismatches = mismatches + })) + require.NoError(t, err) + + result, _, err := client.Projects.Search(context.Background(), &ProjectsSearchOptions{}) + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, gotMismatches, 1) + assert.Equal(t, "components[].ghostField", gotMismatches[0].Path) + assert.Contains(t, gotEndpoint, "/projects/search") +} + +func TestWithSchemaObserver_NoMismatchesWhenSchemaMatches(t *testing.T) { + t.Parallel() + + ts := newTestServer(t, mockHandler(t, http.MethodGet, "/projects/search", http.StatusOK, + `{"components":[{"key":"k"}],"paging":{"pageIndex":1,"pageSize":10,"total":1}}`)) + + called := false + + var gotMismatches []SchemaMismatch + + client, err := NewClient(nil, WithBaseURL(ts.url()), WithSchemaObserver(func(_ string, mismatches []SchemaMismatch) { + called = true + gotMismatches = mismatches + })) + require.NoError(t, err) + + _, _, err = client.Projects.Search(context.Background(), &ProjectsSearchOptions{}) + require.NoError(t, err) + + assert.True(t, called, "schema observer should have been invoked") + assert.Empty(t, gotMismatches) +} + +func TestWithSchemaObserver_NilObserverRejected(t *testing.T) { + t.Parallel() + + _, err := NewClient(nil, WithBaseURL("http://example.com/"), WithSchemaObserver(nil)) + require.Error(t, err) +} + +// TestDo_WithoutSchemaObserverBehavesLikeBefore verifies that a client with no +// schema observer configured decodes normally, ignoring unknown fields, since +// strict schema validation must stay fully opt-in. +func TestDo_WithoutSchemaObserverBehavesLikeBefore(t *testing.T) { + t.Parallel() + + ts := newTestServer(t, mockHandler(t, http.MethodGet, "/projects/search", http.StatusOK, + `{"components":[{"key":"k","ghostField":"boo"}]}`)) + + client, err := NewClient(nil, WithBaseURL(ts.url())) + require.NoError(t, err) + + result, _, err := client.Projects.Search(context.Background(), &ProjectsSearchOptions{}) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "k", result.Components[0].Key) +} diff --git a/sonar/client_util.go b/sonar/client_util.go index 8cef9eb..00cb81d 100644 --- a/sonar/client_util.go +++ b/sonar/client_util.go @@ -20,9 +20,19 @@ const maxErrorResponseBodyBytes = 1 << 20 // error if an API error has occurred. If v implements the io.Writer // interface, the raw response body will be written to v, without attempting to // first decode it. +func Do(httpClient *http.Client, req *http.Request, dest any) (*http.Response, error) { + return doWithBodyObserver(httpClient, req, dest, nil) +} + +// doWithBodyObserver implements Do, additionally invoking onBody, when +// non-nil, with the raw JSON response body right before it is decoded into +// dest. onBody is only called for the plain-JSON decode path (not for +// io.Writer or text destinations), and only once the response has been +// confirmed successful. Passing a nil onBody makes this behave identically to +// Do, decoding straight from the response stream rather than buffering it. // //nolint:wrapcheck // error context is clear from call site -func Do(httpClient *http.Client, req *http.Request, dest any) (*http.Response, error) { +func doWithBodyObserver(httpClient *http.Client, req *http.Request, dest any, onBody func([]byte)) (*http.Response, error) { isText := false if _, ok := dest.(*string); ok { @@ -56,24 +66,53 @@ func Do(httpClient *http.Client, req *http.Request, dest any) (*http.Response, e } if isText { - data, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return resp, readErr - } + return resp, decodeTextBody(resp, dest) + } + + return resp, decodeJSONBody(req, resp, dest, onBody) +} + +// decodeTextBody reads the response body and, if dest is a *string, stores it +// verbatim. +func decodeTextBody(resp *http.Response, dest any) error { + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } - if strPtr, ok := dest.(*string); ok { - *strPtr = string(data) + if strPtr, ok := dest.(*string); ok { + *strPtr = string(data) + } + + return nil +} + +// decodeJSONBody JSON-decodes the response body into dest. When onBody is +// nil, it decodes straight from the response stream; otherwise it buffers the +// full body first, hands it to onBody, then unmarshals from the buffer. +func decodeJSONBody(req *http.Request, resp *http.Response, dest any, onBody func([]byte)) error { + if onBody == nil { + err := json.NewDecoder(resp.Body).Decode(dest) + if err != nil { + return fmt.Errorf("failed to decode %s %s response body: %w", req.Method, requestEndpoint(req), err) } - return resp, nil + return nil } - err = json.NewDecoder(resp.Body).Decode(dest) + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + onBody(data) + + err = json.Unmarshal(data, dest) if err != nil { - return resp, fmt.Errorf("failed to decode %s %s response body: %w", req.Method, requestEndpoint(req), err) + return fmt.Errorf("failed to decode %s %s response body: %w", req.Method, requestEndpoint(req), err) } - return resp, nil + return nil } // requestEndpoint formats a request's endpoint as scheme://host/path, with the diff --git a/sonar/components_service.go b/sonar/components_service.go index 2a6dda5..3745853 100644 --- a/sonar/components_service.go +++ b/sonar/components_service.go @@ -242,9 +242,13 @@ type ComponentAncestor struct { } // ComponentDetails represents detailed component information in show results. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type ComponentDetails struct { // AnalysisDate is the last analysis date. AnalysisDate string `json:"analysisDate,omitempty"` + // IsAiCodeFixEnabled indicates if the AI CodeFix feature is enabled for this component. + IsAiCodeFixEnabled bool `json:"isAiCodeFixEnabled,omitempty"` // Key is the component key. Key string `json:"key,omitempty"` // Language is the component language. @@ -253,12 +257,18 @@ type ComponentDetails struct { LeakPeriodDate string `json:"leakPeriodDate,omitempty"` // Name is the component name. Name string `json:"name,omitempty"` + // NeedIssueSync indicates if issue synchronization is needed for this component. + NeedIssueSync bool `json:"needIssueSync,omitempty"` // Path is the path to the component. Path string `json:"path,omitempty"` // Qualifier is the component qualifier. Qualifier string `json:"qualifier,omitempty"` + // Tags is the list of tags associated with the component. + Tags []string `json:"tags,omitempty"` // Version is the component version. Version string `json:"version,omitempty"` + // Visibility is the visibility of the component (public or private). + Visibility string `json:"visibility,omitempty"` } // ComponentSuggestionItem represents an item in suggestions results. @@ -297,8 +307,12 @@ type ComponentSuggestionGroup struct { type ComponentTreeBase struct { // Description is the component description. Description string `json:"description,omitempty"` + // IsAiCodeFixEnabled indicates if the AI CodeFix feature is enabled for this component. + IsAiCodeFixEnabled bool `json:"isAiCodeFixEnabled,omitempty"` // Key is the component key. Key string `json:"key,omitempty"` + // Name is the component name. + Name string `json:"name,omitempty"` // Qualifier is the component qualifier. Qualifier string `json:"qualifier,omitempty"` // Tags is the list of tags. diff --git a/sonar/fix_suggestions_v2_service.go b/sonar/fix_suggestions_v2_service.go index c417633..8ece63f 100644 --- a/sonar/fix_suggestions_v2_service.go +++ b/sonar/fix_suggestions_v2_service.go @@ -72,26 +72,36 @@ type FixSuggestion struct { Changes []FixSuggestionChange `json:"changes,omitempty"` } -// FixSuggestionProvider represents an LLM provider configuration for fix suggestions. +// FixSuggestionProvider represents a configured LLM provider for fix suggestions. +// +//nolint:govet // fieldalignment: keeping logical field grouping for readability type FixSuggestionProvider struct { - // Key is the provider key. - Key string `json:"key,omitempty"` - // ModelKey is the model key used. - ModelKey string `json:"modelKey,omitempty"` - // Endpoint is the provider endpoint URL. - Endpoint string `json:"endpoint,omitempty"` + // Type is the provider type (e.g. AZURE_OPENAI, AWS_BEDROCK, CUSTOM, OPENAI). + Type string `json:"type,omitempty"` + // Name is the display name of the provider. + Name string `json:"name,omitempty"` + // SelfHosted indicates whether this is a self-hosted provider. + SelfHosted bool `json:"selfHosted,omitempty"` + // Selected indicates whether this provider is currently selected. + Selected bool `json:"selected,omitempty"` + // Model is the name of the selected model, if any. + Model string `json:"model,omitempty"` + // Recommended indicates whether this provider/model is recommended. + Recommended bool `json:"recommended,omitempty"` + // Config contains provider-specific configuration key/value pairs. + Config map[string]string `json:"config,omitempty"` + // Headers contains custom HTTP headers for self-hosted/custom providers. + Headers []any `json:"headers,omitempty"` } // FixSuggestionsFeatureEnablement represents the AI CodeFix feature enablement state. -// -//nolint:govet // fieldalignment: keeping logical field grouping for readability type FixSuggestionsFeatureEnablement struct { // Enablement is the enablement state. Enablement string `json:"enablement,omitempty"` // EnabledProjectKeys lists project keys where the feature is enabled. EnabledProjectKeys []string `json:"enabledProjectKeys,omitempty"` - // Provider contains the LLM provider configuration. - Provider FixSuggestionProvider `json:"provider,omitzero"` + // Providers is the list of configured LLM providers. + Providers []FixSuggestionProvider `json:"providers,omitempty"` } // FixSuggestionsAwarenessBanner represents the response from a banner interaction. diff --git a/sonar/github_provisioning_service.go b/sonar/github_provisioning_service.go index ca41811..5824f19 100644 --- a/sonar/github_provisioning_service.go +++ b/sonar/github_provisioning_service.go @@ -53,6 +53,8 @@ type GithubProvisioningStatus struct { // GithubProvisioningJitStatus represents the status of Just-In-Time provisioning. type GithubProvisioningJitStatus struct { + // ErrorMessage contains an error message if JIT provisioning failed. + ErrorMessage string `json:"errorMessage,omitempty"` // Status is the JIT provisioning status. Status string `json:"status,omitempty"` } diff --git a/sonar/issues_service.go b/sonar/issues_service.go index 53dbeac..13da4b5 100644 --- a/sonar/issues_service.go +++ b/sonar/issues_service.go @@ -409,6 +409,14 @@ type IssuesSearch struct { Rules []IssueRule `json:"rules,omitempty"` Users []IssueUser `json:"users,omitempty"` Paging Paging `json:"paging,omitzero"` + // Page is the current page number (legacy duplicate of Paging.PageIndex). + Page int64 `json:"p,omitempty"` + // PageSize is the page size (legacy duplicate of Paging.PageSize). + PageSize int64 `json:"ps,omitempty"` + // Total is the total number of issues (legacy duplicate of Paging.Total). + Total int64 `json:"total,omitempty"` + // EffortTotal is the total remediation effort, in minutes, of the returned issues. + EffortTotal int64 `json:"effortTotal,omitempty"` } // IssuesSetSeverity represents the response from setting severity. diff --git a/sonar/l10n_service.go b/sonar/l10n_service.go index d60b84b..b352cb5 100644 --- a/sonar/l10n_service.go +++ b/sonar/l10n_service.go @@ -23,6 +23,8 @@ type L10NService struct { type L10NIndex struct { // Locale is the locale used. Locale string `json:"locale,omitempty"` + // EffectiveLocale is the locale actually resolved and used to return messages. + EffectiveLocale string `json:"effectiveLocale,omitempty"` // Messages is a map of message keys to their localized values. Messages map[string]string `json:"messages,omitempty"` } diff --git a/sonar/metrics_service.go b/sonar/metrics_service.go index cc39b9d..994b00c 100644 --- a/sonar/metrics_service.go +++ b/sonar/metrics_service.go @@ -23,6 +23,12 @@ type MetricsSearch struct { Metrics []Metric `json:"metrics,omitempty"` // Paging contains pagination information. Paging Paging `json:"paging,omitzero"` + // Page is the current page number (legacy duplicate of Paging.PageIndex). + Page int64 `json:"p,omitempty"` + // PageSize is the page size (legacy duplicate of Paging.PageSize). + PageSize int64 `json:"ps,omitempty"` + // Total is the total number of metrics (legacy duplicate of Paging.Total). + Total int64 `json:"total,omitempty"` } // Metric represents a SonarQube metric. @@ -31,6 +37,8 @@ type MetricsSearch struct { type Metric struct { // Custom indicates whether this is a custom metric. Custom bool `json:"custom,omitempty"` + // DecimalScale is the number of decimal places to display for this metric. + DecimalScale int64 `json:"decimalScale,omitempty"` // Description is the metric description. Description string `json:"description,omitempty"` // Direction indicates the metric direction (-1: lower is better, 0: neutral, 1: higher is better). diff --git a/sonar/navigation_service.go b/sonar/navigation_service.go index ad0aa33..737fe91 100644 --- a/sonar/navigation_service.go +++ b/sonar/navigation_service.go @@ -25,6 +25,8 @@ type NavigationService struct { type NavigationComponent struct { // AnalysisDate is the date of the last analysis. AnalysisDate string `json:"analysisDate,omitempty"` + // Branch is the branch name of the component. + Branch string `json:"branch,omitempty"` // Breadcrumbs is the list of breadcrumb items for the component. Breadcrumbs []NavigationBreadcrumb `json:"breadcrumbs,omitempty"` // CanBrowseAllChildProjects indicates if user can browse all child projects. @@ -49,6 +51,8 @@ type NavigationComponent struct { QualityProfiles []NavigationQualityProfile `json:"qualityProfiles,omitempty"` // Version is the component version. Version string `json:"version,omitempty"` + // Visibility is the visibility of the component (public or private). + Visibility string `json:"visibility,omitempty"` } // NavigationBreadcrumb represents a breadcrumb item in navigation. @@ -65,8 +69,12 @@ type NavigationBreadcrumb struct { // //nolint:govet // fieldalignment - structure kept for readability type NavigationConfiguration struct { + // CanApplyPermissionTemplate indicates if the user can apply a permission template to the project. + CanApplyPermissionTemplate bool `json:"canApplyPermissionTemplate,omitempty"` // CanBrowseProject indicates if user can browse the project. CanBrowseProject bool `json:"canBrowseProject,omitempty"` + // CanUpdateProjectVisibilityToPrivate indicates if the user can change the project visibility to private. + CanUpdateProjectVisibilityToPrivate bool `json:"canUpdateProjectVisibilityToPrivate,omitempty"` // Extensions is the list of configuration extensions. Extensions []NavigationExtension `json:"extensions,omitempty"` // ShowBackgroundTasks indicates if background tasks should be shown. @@ -129,10 +137,14 @@ type NavigationGlobal struct { Edition string `json:"edition,omitempty"` // GlobalPages is the list of global pages. GlobalPages []NavigationExtension `json:"globalPages,omitempty"` + // InstanceUsesDefaultAdminCredentials indicates if the instance still uses the default admin credentials. + InstanceUsesDefaultAdminCredentials bool `json:"instanceUsesDefaultAdminCredentials,omitempty"` // LogoURL is the URL to the logo. LogoURL string `json:"logoUrl,omitempty"` // LogoWidth is the logo width. LogoWidth string `json:"logoWidth,omitempty"` + // NeedIssueSync indicates if issue synchronization is needed. + NeedIssueSync bool `json:"needIssueSync,omitempty"` // ProductionDatabase indicates if this is a production database. ProductionDatabase bool `json:"productionDatabase,omitempty"` // Qualifiers is the list of supported qualifiers. @@ -151,6 +163,8 @@ type NavigationGlobal struct { // //nolint:tagliatelle // API-defined JSON field names type NavigationGlobalSettings struct { + // DeveloperAggregatedInfoDisabled indicates if developer aggregated info is disabled. + DeveloperAggregatedInfoDisabled string `json:"sonar.developerAggregatedInfo.disabled,omitempty"` // EnableGravatar indicates if Gravatar is enabled. EnableGravatar string `json:"sonar.lf.enableGravatar,omitempty"` // GravatarServerURL is the Gravatar server URL. @@ -185,6 +199,8 @@ type NavigationSettings struct { // NavigationSettingsExtension represents a settings extension. type NavigationSettingsExtension struct { + // Key is the extension key. + Key string `json:"key,omitempty"` // Name is the extension name. Name string `json:"name,omitempty"` // URL is the extension URL. diff --git a/sonar/new_code_periods_service.go b/sonar/new_code_periods_service.go index 741c75c..d74326f 100644 --- a/sonar/new_code_periods_service.go +++ b/sonar/new_code_periods_service.go @@ -61,6 +61,8 @@ type NewCodePeriod struct { Type string `json:"type,omitempty"` // Value is the value of the new code period. Value string `json:"value,omitempty"` + // UpdatedAt is the timestamp (Unix epoch milliseconds) of the last update of this new code period. + UpdatedAt int64 `json:"updatedAt,omitempty"` } // ----------------------------------------------------------------------------- @@ -87,6 +89,8 @@ type NewCodePeriodsShow struct { Type string `json:"type,omitempty"` // Value is the value of the new code period. Value string `json:"value,omitempty"` + // UpdatedAt is the timestamp (Unix epoch milliseconds) of the last update of this new code period. + UpdatedAt int64 `json:"updatedAt,omitempty"` } // ----------------------------------------------------------------------------- diff --git a/sonar/permissions_service.go b/sonar/permissions_service.go index 73a5d5b..3160877 100644 --- a/sonar/permissions_service.go +++ b/sonar/permissions_service.go @@ -162,6 +162,10 @@ type PermissionsDefaultTemplate struct { type PermissionsTemplateGroup struct { // Description is the group description. Description string `json:"description,omitempty"` + // ID is the deprecated unique identifier of the group. + // + // Deprecated: Since SonarQube 8.4 - use Name instead. + ID string `json:"id,omitempty"` // Name is the group name. Name string `json:"name,omitempty"` // Permissions is the list of permissions granted to the group. @@ -193,6 +197,8 @@ type PermissionsCreateTemplate struct { } // PermissionsTemplateBasic represents basic permission template info returned on create. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type PermissionsTemplateBasic struct { // Description is the template description. Description string `json:"description,omitempty"` @@ -200,6 +206,8 @@ type PermissionsTemplateBasic struct { ID string `json:"id,omitempty"` // Name is the template name. Name string `json:"name,omitempty"` + // Permissions is the list of permissions in the template. + Permissions []PermissionsTemplatePermission `json:"permissions,omitempty"` // ProjectKeyPattern is the regex pattern for matching project keys. ProjectKeyPattern string `json:"projectKeyPattern,omitempty"` // CreatedAt is the template creation date. @@ -222,6 +230,18 @@ type PermissionsSearchTemplates struct { DefaultTemplates []PermissionsDefaultTemplate `json:"defaultTemplates,omitempty"` // PermissionTemplates is the list of permission templates. PermissionTemplates []PermissionTemplate `json:"permissionTemplates,omitempty"` + // Permissions is the list of permission definitions available to templates. + Permissions []PermissionsSearchTemplatePermission `json:"permissions,omitempty"` +} + +// PermissionsSearchTemplatePermission describes a permission definition available to templates. +type PermissionsSearchTemplatePermission struct { + // Key is the permission key. + Key string `json:"key,omitempty"` + // Name is the permission display name. + Name string `json:"name,omitempty"` + // Description is the permission description. + Description string `json:"description,omitempty"` } // PermissionsTemplateGroups represents the response from listing template groups. @@ -249,6 +269,8 @@ type PermissionsUpdateTemplate struct { } // PermissionsTemplateUpdated represents updated permission template info. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type PermissionsTemplateUpdated struct { // CreatedAt is the template creation date. CreatedAt string `json:"createdAt,omitempty"` @@ -258,6 +280,8 @@ type PermissionsTemplateUpdated struct { ID string `json:"id,omitempty"` // Name is the template name. Name string `json:"name,omitempty"` + // Permissions is the list of permissions in the template. + Permissions []PermissionsTemplatePermission `json:"permissions,omitempty"` // ProjectKeyPattern is the regex pattern for matching project keys. ProjectKeyPattern string `json:"projectKeyPattern,omitempty"` // UpdatedAt is the template last update date. diff --git a/sonar/plugins_service.go b/sonar/plugins_service.go index 38a017c..463c663 100644 --- a/sonar/plugins_service.go +++ b/sonar/plugins_service.go @@ -39,6 +39,10 @@ type PluginAvailable struct { Description string `json:"description,omitempty"` // EditionBundled indicates if the plugin is bundled with an edition. EditionBundled bool `json:"editionBundled,omitempty"` + // HomepageURL is the plugin homepage URL. + HomepageURL string `json:"homepageUrl,omitempty"` + // IssueTrackerURL is the issue tracker URL. + IssueTrackerURL string `json:"issueTrackerUrl,omitempty"` // Key is the plugin key. Key string `json:"key,omitempty"` // License is the plugin license. @@ -129,6 +133,8 @@ type PluginInstalled struct { OrganizationURL string `json:"organizationUrl,omitempty"` // RequiredForLanguages is the list of languages requiring this plugin. RequiredForLanguages []string `json:"requiredForLanguages,omitempty"` + // Type is the plugin type (bundled or external). + Type string `json:"type,omitempty"` // SonarLintSupported indicates if SonarLint is supported. SonarLintSupported bool `json:"sonarLintSupported,omitempty"` // UpdatedAt is the timestamp when the plugin was updated. @@ -204,9 +210,13 @@ type PluginPendingUpdate struct { } // PluginsUpdates represents the response from listing plugin updates. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type PluginsUpdates struct { // Plugins is the list of plugins with available updates. Plugins []PluginWithUpdates `json:"plugins,omitempty"` + // UpdateCenterRefresh is the timestamp of the last update center refresh. + UpdateCenterRefresh string `json:"updateCenterRefresh,omitempty"` } // PluginWithUpdates represents a plugin with available updates. diff --git a/sonar/qualitygates_service.go b/sonar/qualitygates_service.go index 2b83a26..a73be40 100644 --- a/sonar/qualitygates_service.go +++ b/sonar/qualitygates_service.go @@ -232,7 +232,7 @@ type QualityGateUser struct { // QualitygatesShow represents the response from showing a quality gate. // -//nolint:govet // Field alignment is less important than logical grouping +//nolint:govet,tagliatelle // Field alignment is less important than logical grouping; hasMQRConditions matches the API's JSON field name type QualitygatesShow struct { // Actions contains the actions available for this quality gate. Actions QualityGateActions `json:"actions,omitzero"` @@ -248,6 +248,10 @@ type QualitygatesShow struct { IsBuiltIn bool `json:"isBuiltIn,omitempty"` // IsDefault indicates if this is the default quality gate. IsDefault bool `json:"isDefault,omitempty"` + // HasMQRConditions indicates if the gate has conditions based on the Mode Quality Rating (MQR) metrics. + HasMQRConditions bool `json:"hasMQRConditions,omitempty"` + // HasStandardConditions indicates if the gate has conditions based on standard metrics. + HasStandardConditions bool `json:"hasStandardConditions,omitempty"` } // QualityGateCondition represents a condition in a quality gate. @@ -260,6 +264,8 @@ type QualityGateCondition struct { Metric string `json:"metric,omitempty"` // Op is the comparison operator (LT, GT). Op string `json:"op,omitempty"` + // IsCaycCondition indicates if this condition is part of the Clean As You Code standard conditions. + IsCaycCondition bool `json:"isCaycCondition,omitempty"` } // ----------------------------------------------------------------------------- diff --git a/sonar/qualityprofiles_service.go b/sonar/qualityprofiles_service.go index 5b524bf..6eb2b8b 100644 --- a/sonar/qualityprofiles_service.go +++ b/sonar/qualityprofiles_service.go @@ -29,6 +29,12 @@ type QualityprofilesChangelog struct { Paging Paging `json:"paging,omitzero"` // Events is the list of changelog events. Events []ChangelogEvent `json:"events,omitempty"` + // Page is the current page number (legacy duplicate of Paging.PageIndex). + Page int64 `json:"p,omitempty"` + // PageSize is the page size (legacy duplicate of Paging.PageSize). + PageSize int64 `json:"ps,omitempty"` + // Total is the total number of events (legacy duplicate of Paging.Total). + Total int64 `json:"total,omitempty"` } // ChangelogEvent represents a single event in the quality profile changelog. @@ -234,8 +240,6 @@ type QualityprofilesImporter struct { } // QualityprofilesInheritance represents the response from getting inheritance info. -// -//nolint:govet // Field alignment is less important than logical grouping type QualityprofilesInheritance struct { // Profile contains the current profile information. Profile QualityprofilesInheritanceProfile `json:"profile,omitzero"` @@ -246,6 +250,8 @@ type QualityprofilesInheritance struct { } // QualityprofilesInheritanceProfile represents a profile in an inheritance hierarchy. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type QualityprofilesInheritanceProfile struct { // Key is the unique key of the profile. Key string `json:"key,omitempty"` @@ -259,6 +265,8 @@ type QualityprofilesInheritanceProfile struct { OverridingRuleCount int64 `json:"overridingRuleCount,omitempty"` // IsBuiltIn indicates if this is a built-in profile. IsBuiltIn bool `json:"isBuiltIn,omitempty"` + // Parent is the key of the parent profile, present on the current profile entry. + Parent string `json:"parent,omitempty"` } // QualityprofilesProjects represents the response from listing associated projects. @@ -318,7 +326,7 @@ type QualityProfile struct { // LastUsed is the timestamp when the profile was last used. LastUsed string `json:"lastUsed,omitempty"` // RuleUpdatedAt is the timestamp when rules were last updated. - RuleUpdatedAt string `json:"ruleUpdatedAt,omitempty"` + RuleUpdatedAt string `json:"rulesUpdatedAt,omitempty"` // UserUpdatedAt is the timestamp when the profile was last updated by a user. UserUpdatedAt string `json:"userUpdatedAt,omitempty"` // ActiveDeprecatedRuleCount is the count of active deprecated rules. diff --git a/sonar/rules_service.go b/sonar/rules_service.go index 07805a4..a38aad6 100644 --- a/sonar/rules_service.go +++ b/sonar/rules_service.go @@ -106,6 +106,8 @@ type RulesCreate struct { } // RulesDefinition represents a SonarQube rule. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type RulesDefinition struct { // Key is the unique identifier of the rule. Key string `json:"key,omitempty"` @@ -155,6 +157,28 @@ type RulesDefinition struct { Tags []any `json:"tags,omitempty"` // Impacts is the list of impacts on software quality. Impacts []RulesImpact `json:"impacts,omitempty"` + // DescriptionSections is the list of structured description sections of the rule. + DescriptionSections []RulesDescriptionSection `json:"descriptionSections,omitempty"` + // EducationPrinciples is the list of education principle keys associated with the rule. + EducationPrinciples []string `json:"educationPrinciples,omitempty"` + // RemFnType is the remediation function type. + RemFnType string `json:"remFnType,omitempty"` + // RemFnBaseEffort is the base effort of the remediation function. + RemFnBaseEffort string `json:"remFnBaseEffort,omitempty"` + // DefaultRemFnType is the default remediation function type. + DefaultRemFnType string `json:"defaultRemFnType,omitempty"` + // DefaultRemFnBaseEffort is the default base effort of the remediation function. + DefaultRemFnBaseEffort string `json:"defaultRemFnBaseEffort,omitempty"` + // DebtRemFnType is the deprecated remediation function type (legacy debt terminology). + // + // Deprecated: use RemFnType instead. + DebtRemFnType string `json:"debtRemFnType,omitempty"` + // DefaultDebtRemFnType is the deprecated default remediation function type (legacy debt terminology). + // + // Deprecated: use DefaultRemFnType instead. + DefaultDebtRemFnType string `json:"defaultDebtRemFnType,omitempty"` + // RemFnOverloaded indicates if the remediation function overrides the template's default. + RemFnOverloaded bool `json:"remFnOverloaded,omitempty"` // IsTemplate indicates if this is a template rule that can be used to create custom rules. IsTemplate bool `json:"isTemplate,omitempty"` // IsExternal indicates if this is an external rule. @@ -195,6 +219,12 @@ type RulesSearch struct { Facets []RulesSearchFacet `json:"facets,omitempty"` Rules []RulesDetails `json:"rules,omitempty"` Paging Paging `json:"paging,omitzero"` + // Page is the current page number (legacy duplicate of Paging.PageIndex). + Page int64 `json:"p,omitempty"` + // PageSize is the page size (legacy duplicate of Paging.PageSize). + PageSize int64 `json:"ps,omitempty"` + // Total is the total number of rules (legacy duplicate of Paging.Total). + Total int64 `json:"total,omitempty"` } // RulesActivation represents how a rule is activated in a quality profile. @@ -229,6 +259,8 @@ type RulesParamKV struct { type RulesSearchFacet struct { // Name is the facet name (e.g., languages, repositories, tags). Name string `json:"name,omitempty"` + // Property is the property key the facet was computed on. + Property string `json:"property,omitempty"` // Values is the list of facet values with their counts. Values []RulesFacetItem `json:"values,omitempty"` } @@ -243,41 +275,54 @@ type RulesFacetItem struct { // RulesDetails contains comprehensive information about a rule. type RulesDetails struct { - Name string `json:"name,omitempty"` - Key string `json:"key,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` - RemFnType string `json:"remFnType,omitempty"` - HTMLDesc string `json:"htmlDesc,omitempty"` - HTMLNote string `json:"htmlNote,omitempty"` - MdNote string `json:"mdNote,omitempty"` - NoteLogin string `json:"noteLogin,omitempty"` - CleanCodeAttribute string `json:"cleanCodeAttribute,omitempty"` - InternalKey string `json:"internalKey,omitempty"` - RemFnGapMultiplier string `json:"remFnGapMultiplier,omitempty"` - RemFnBaseEffort string `json:"remFnBaseEffort,omitempty"` - DefaultRemFnBaseEffort string `json:"defaultRemFnBaseEffort,omitempty"` - Lang string `json:"lang,omitempty"` - LangName string `json:"langName,omitempty"` - CleanCodeAttributeCategory string `json:"cleanCodeAttributeCategory,omitempty"` - GapDescription string `json:"gapDescription,omitempty"` - Repo string `json:"repo,omitempty"` - Scope string `json:"scope,omitempty"` - Severity string `json:"severity,omitempty"` - Status string `json:"status,omitempty"` - DefaultRemFnType string `json:"defaultRemFnType,omitempty"` - DefaultRemFnGapMultiplier string `json:"defaultRemFnGapMultiplier,omitempty"` - TemplateKey string `json:"templateKey,omitempty"` - Type string `json:"type,omitempty"` - Impacts []RulesImpact `json:"impacts,omitempty"` - Tags []any `json:"tags,omitempty"` - SysTags []string `json:"sysTags,omitempty"` - Params []RulesParam `json:"params,omitempty"` - DescriptionSections []RulesDescriptionSection `json:"descriptionSections,omitempty"` - IsTemplate bool `json:"isTemplate,omitempty"` - IsExternal bool `json:"isExternal,omitempty"` - RemFnOverloaded bool `json:"remFnOverloaded,omitempty"` - Template bool `json:"template,omitempty"` + Name string `json:"name,omitempty"` + Key string `json:"key,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + RemFnType string `json:"remFnType,omitempty"` + HTMLDesc string `json:"htmlDesc,omitempty"` + MdDesc string `json:"mdDesc,omitempty"` + HTMLNote string `json:"htmlNote,omitempty"` + MdNote string `json:"mdNote,omitempty"` + NoteLogin string `json:"noteLogin,omitempty"` + CleanCodeAttribute string `json:"cleanCodeAttribute,omitempty"` + InternalKey string `json:"internalKey,omitempty"` + RemFnGapMultiplier string `json:"remFnGapMultiplier,omitempty"` + RemFnBaseEffort string `json:"remFnBaseEffort,omitempty"` + DefaultRemFnBaseEffort string `json:"defaultRemFnBaseEffort,omitempty"` + // DebtRemFnType is the deprecated remediation function type (legacy debt terminology). + // + // Deprecated: use RemFnType instead. + DebtRemFnType string `json:"debtRemFnType,omitempty"` + // DefaultDebtRemFnType is the deprecated default remediation function type (legacy debt terminology). + // + // Deprecated: use DefaultRemFnType instead. + DefaultDebtRemFnType string `json:"defaultDebtRemFnType,omitempty"` + Lang string `json:"lang,omitempty"` + LangName string `json:"langName,omitempty"` + CleanCodeAttributeCategory string `json:"cleanCodeAttributeCategory,omitempty"` + GapDescription string `json:"gapDescription,omitempty"` + Repo string `json:"repo,omitempty"` + Scope string `json:"scope,omitempty"` + Severity string `json:"severity,omitempty"` + Status string `json:"status,omitempty"` + DefaultRemFnType string `json:"defaultRemFnType,omitempty"` + DefaultRemFnGapMultiplier string `json:"defaultRemFnGapMultiplier,omitempty"` + TemplateKey string `json:"templateKey,omitempty"` + Type string `json:"type,omitempty"` + Impacts []RulesImpact `json:"impacts,omitempty"` + Tags []any `json:"tags,omitempty"` + SysTags []string `json:"sysTags,omitempty"` + // DeprecatedKeys contains the deprecated rule keys, for rules that were renamed or moved. + DeprecatedKeys RulesDeprecatedKeys `json:"deprecatedKeys,omitzero"` + // EducationPrinciples is the list of education principle keys associated with the rule. + EducationPrinciples []string `json:"educationPrinciples,omitempty"` + Params []RulesParam `json:"params,omitempty"` + DescriptionSections []RulesDescriptionSection `json:"descriptionSections,omitempty"` + IsTemplate bool `json:"isTemplate,omitempty"` + IsExternal bool `json:"isExternal,omitempty"` + RemFnOverloaded bool `json:"remFnOverloaded,omitempty"` + Template bool `json:"template,omitempty"` } // RulesDescriptionSection represents a section of a rule's description. @@ -298,6 +343,12 @@ type RulesDescriptionSectionContext struct { Key string `json:"key,omitempty"` } +// RulesDeprecatedKeys represents the deprecated keys of a rule that was renamed or moved. +type RulesDeprecatedKeys struct { + // DeprecatedKey is the list of deprecated rule keys. + DeprecatedKey []string `json:"deprecatedKey,omitempty"` +} + // RulesShow represents the response from showing a specific rule. type RulesShow struct { Actives []RulesActivationDetailed `json:"actives,omitempty"` @@ -305,7 +356,13 @@ type RulesShow struct { } // RulesActivationDetailed contains detailed information about a rule activation. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type RulesActivationDetailed struct { + // CreatedAt is the date and time the rule was activated. + CreatedAt string `json:"createdAt,omitempty"` + // Impacts is the list of software quality impacts of the activated rule. + Impacts []RulesImpact `json:"impacts,omitempty"` // Inherit indicates how the rule is inherited (NONE, INHERITED, OVERRIDES). Inherit string `json:"inherit,omitempty"` // QProfile is the key of the quality profile where the rule is activated. @@ -316,6 +373,8 @@ type RulesActivationDetailed struct { Params []RulesParamKV `json:"params,omitempty"` // PrioritizedRule indicates if the rule is prioritized in this profile. PrioritizedRule bool `json:"prioritizedRule,omitempty"` + // UpdatedAt is the date and time the rule activation was last updated. + UpdatedAt string `json:"updatedAt,omitempty"` } // RulesTags contains the list of available rule tags. diff --git a/sonar/schema.go b/sonar/schema.go new file mode 100644 index 0000000..0b3c9eb --- /dev/null +++ b/sonar/schema.go @@ -0,0 +1,221 @@ +package sonar + +import ( + "encoding/json" + "fmt" + "maps" + "reflect" + "strings" +) + +// SchemaMismatch describes a JSON object field observed in a decoded API +// response that has no corresponding field on the destination Go struct. It +// signals that a modeled response type has drifted from what the SonarQube +// API actually returns: either a field is missing from the struct, or the +// struct models it under the wrong name. +type SchemaMismatch struct { + // Endpoint identifies the request the mismatch was observed on, formatted + // as "METHOD scheme://host/path". + Endpoint string + // GoType is the name of the struct type being validated. + GoType string + // Path is the dotted/bracketed path to the unmapped field, e.g. + // "components[].measures[].bestValue". + Path string +} + +// String formats the mismatch as a single human-readable line. +func (m SchemaMismatch) String() string { + return fmt.Sprintf("%s: field %q has no match in %s", m.Endpoint, m.Path, m.GoType) +} + +// SchemaObserver is invoked by Client.Do, when configured via +// WithSchemaObserver, after every successfully decoded API response. It +// receives any SchemaMismatch found between the raw response body and the +// destination struct for that call. mismatches is empty when the response +// matched its destination type exactly. +type SchemaObserver func(endpoint string, mismatches []SchemaMismatch) + +// CheckSchema compares a raw JSON response body against the exported, +// JSON-tagged fields declared on the type of dest and returns one +// SchemaMismatch per JSON object key that has no corresponding struct field. +// Object keys are only checked where the JSON value lines up against a Go +// struct; JSON matched against a Go map is left unchecked since maps +// intentionally accept arbitrary keys. +// +// CheckSchema returns an error only if data is not valid JSON. A nil dest, or +// a dest whose underlying type is never a struct anywhere in its shape, +// simply yields no mismatches. +func CheckSchema(endpoint string, data []byte, dest any) ([]SchemaMismatch, error) { + if dest == nil { + return nil, nil + } + + var raw any + + err := json.Unmarshal(data, &raw) + if err != nil { + return nil, fmt.Errorf("failed to parse response body for schema check: %w", err) + } + + destType := indirectType(reflect.TypeOf(dest)) + if destType == nil { + return nil, nil + } + + var mismatches []SchemaMismatch + + walkSchema(raw, destType, "", endpoint, &mismatches) + + return mismatches, nil +} + +// indirectType dereferences pointer types until it reaches the underlying type. +func indirectType(fieldType reflect.Type) reflect.Type { + for fieldType != nil && fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + + return fieldType +} + +// walkSchema recursively compares a JSON-decoded value against a Go type, +// appending a SchemaMismatch for every object key with no matching struct field. +func walkSchema(raw any, fieldType reflect.Type, path, endpoint string, mismatches *[]SchemaMismatch) { + if raw == nil || fieldType == nil { + return + } + + fieldType = indirectType(fieldType) + + switch fieldType.Kind() { //nolint:exhaustive // only structs, slices/arrays and maps carry nested keys worth checking + case reflect.Struct: + walkSchemaStruct(raw, fieldType, path, endpoint, mismatches) + case reflect.Slice, reflect.Array: + walkSchemaSlice(raw, fieldType.Elem(), path, endpoint, mismatches) + case reflect.Map: + walkSchemaMap(raw, fieldType.Elem(), path, endpoint, mismatches) + default: + // scalar, interface or other leaf kinds: nothing further to validate + } +} + +// walkSchemaStruct compares a JSON object against the fields declared on a +// Go struct type, recursing into every known field's value. +func walkSchemaStruct(raw any, structType reflect.Type, path, endpoint string, mismatches *[]SchemaMismatch) { + object, ok := raw.(map[string]any) + if !ok { + return + } + + knownFields := schemaFields(structType) + + for key, value := range object { + knownFieldType, known := knownFields[key] + if !known { + *mismatches = append(*mismatches, SchemaMismatch{ + Endpoint: endpoint, + GoType: structType.String(), + Path: joinSchemaPath(path, key), + }) + + continue + } + + walkSchema(value, knownFieldType, joinSchemaPath(path, key), endpoint, mismatches) + } +} + +// walkSchemaSlice recurses into every element of a JSON array against a Go +// slice/array element type. +func walkSchemaSlice(raw any, elemType reflect.Type, path, endpoint string, mismatches *[]SchemaMismatch) { + array, ok := raw.([]any) + if !ok { + return + } + + for _, item := range array { + walkSchema(item, elemType, path+"[]", endpoint, mismatches) + } +} + +// walkSchemaMap recurses into every value of a JSON object against a Go map +// element type. Map keys themselves are never flagged as mismatches since +// maps intentionally accept arbitrary keys. +func walkSchemaMap(raw any, elemType reflect.Type, path, endpoint string, mismatches *[]SchemaMismatch) { + object, ok := raw.(map[string]any) + if !ok { + return + } + + for key, value := range object { + walkSchema(value, elemType, joinSchemaPath(path, key), endpoint, mismatches) + } +} + +// schemaFields returns the JSON key to Go field type mapping declared on +// struct type structType. Anonymous (embedded) struct fields are flattened +// into the parent's field set, matching how encoding/json treats them. +func schemaFields(structType reflect.Type) map[string]reflect.Type { + knownFields := make(map[string]reflect.Type, structType.NumField()) + + for field := range structType.Fields() { + name, embedded := schemaFieldName(field) + + // Unexported anonymous struct fields still promote their exported + // inner fields (encoding/json does the same), so the PkgPath check + // only applies to ordinary, non-embedded fields. + switch { + case embedded: + mergeEmbeddedSchemaFields(knownFields, field.Type) + case field.PkgPath != "": + continue + case name != "": + knownFields[name] = field.Type + } + } + + return knownFields +} + +// schemaFieldName returns the JSON key for field, and whether field is an +// embedded struct whose own fields should be flattened into the parent +// instead of being registered under a key of their own. +func schemaFieldName(field reflect.StructField) (name string, embedded bool) { + tag := field.Tag.Get("json") + if tag == "-" { + return "", false + } + + name, _, _ = strings.Cut(tag, ",") + + if name == "" { + if field.Anonymous { + return "", true + } + + name = field.Name + } + + return name, false +} + +// mergeEmbeddedSchemaFields flattens the fields of an embedded struct type +// into knownFields, mirroring encoding/json's handling of anonymous fields. +func mergeEmbeddedSchemaFields(knownFields map[string]reflect.Type, embeddedType reflect.Type) { + embeddedType = indirectType(embeddedType) + if embeddedType == nil || embeddedType.Kind() != reflect.Struct { + return + } + + maps.Copy(knownFields, schemaFields(embeddedType)) +} + +// joinSchemaPath appends key to path, separating existing segments with a dot. +func joinSchemaPath(path, key string) string { + if path == "" { + return key + } + + return path + "." + key +} diff --git a/sonar/schema_test.go b/sonar/schema_test.go new file mode 100644 index 0000000..7d577d3 --- /dev/null +++ b/sonar/schema_test.go @@ -0,0 +1,151 @@ +package sonar + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type schemaTestLeaf struct { + Name string `json:"name,omitempty"` +} + +type schemaTestEmbedded struct { + Extra string `json:"extra,omitempty"` +} + +type schemaTestParent struct { + schemaTestEmbedded + Key string `json:"key,omitempty"` + Ignored string `json:"-"` + Leaf schemaTestLeaf `json:"leaf,omitzero"` + Leaves []schemaTestLeaf `json:"leaves,omitempty"` + ByName map[string]schemaTestLeaf `json:"byName,omitempty"` + Freeform map[string]any `json:"freeform,omitempty"` + Nested *schemaTestLeaf `json:"nested,omitempty"` + Deep [][]schemaTestLeaf `json:"deep,omitempty"` + Various map[string][]schemaTestLeaf `json:"various,omitempty"` +} + +func TestCheckSchema_NoMismatches(t *testing.T) { + data := []byte(`{"key":"k","extra":"e","leaf":{"name":"n"},"leaves":[{"name":"a"},{"name":"b"}]}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + assert.Empty(t, mismatches) +} + +func TestCheckSchema_TopLevelUnknownField(t *testing.T) { + data := []byte(`{"key":"k","ghost":"boo"}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + require.Len(t, mismatches, 1) + assert.Equal(t, "ghost", mismatches[0].Path) + assert.Equal(t, "GET /x", mismatches[0].Endpoint) + assert.Contains(t, mismatches[0].GoType, "schemaTestParent") +} + +func TestCheckSchema_NestedStructUnknownField(t *testing.T) { + data := []byte(`{"leaf":{"name":"n","surname":"boo"}}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + require.Len(t, mismatches, 1) + assert.Equal(t, "leaf.surname", mismatches[0].Path) +} + +func TestCheckSchema_SliceOfStructsUnknownField(t *testing.T) { + data := []byte(`{"leaves":[{"name":"a"},{"name":"b","surname":"boo"}]}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + require.Len(t, mismatches, 1) + assert.Equal(t, "leaves[].surname", mismatches[0].Path) +} + +func TestCheckSchema_MapOfStructsChecksValues(t *testing.T) { + data := []byte(`{"byName":{"anyKeyAllowed":{"name":"n","surname":"boo"}}}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + require.Len(t, mismatches, 1) + assert.Equal(t, "byName.anyKeyAllowed.surname", mismatches[0].Path) +} + +func TestCheckSchema_FreeformMapAllowsArbitraryKeys(t *testing.T) { + data := []byte(`{"freeform":{"whatever":"goes","nested":{"a":1}}}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + assert.Empty(t, mismatches) +} + +func TestCheckSchema_PointerFieldChecked(t *testing.T) { + data := []byte(`{"nested":{"name":"n","surname":"boo"}}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + require.Len(t, mismatches, 1) + assert.Equal(t, "nested.surname", mismatches[0].Path) +} + +func TestCheckSchema_NestedSliceAndMapCombinations(t *testing.T) { + data := []byte(`{ + "deep": [[{"name":"a","surname":"boo"}]], + "various": {"k": [{"name":"a"}, {"name":"b","surname":"boo"}]} + }`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + + paths := make([]string, 0, len(mismatches)) + for _, m := range mismatches { + paths = append(paths, m.Path) + } + + assert.ElementsMatch(t, []string{"deep[][].surname", "various.k[].surname"}, paths) +} + +func TestCheckSchema_EmbeddedFieldFlattened(t *testing.T) { + data := []byte(`{"extra":"e"}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + assert.Empty(t, mismatches) +} + +func TestCheckSchema_IgnoredFieldNeverMatchesOrFlags(t *testing.T) { + data := []byte(`{"Ignored":"should not match the Go field, should be flagged instead"}`) + + mismatches, err := CheckSchema("GET /x", data, &schemaTestParent{}) + require.NoError(t, err) + require.Len(t, mismatches, 1) + assert.Equal(t, "Ignored", mismatches[0].Path) +} + +func TestCheckSchema_NilDestYieldsNoMismatches(t *testing.T) { + mismatches, err := CheckSchema("GET /x", []byte(`{"anything":"goes"}`), nil) + require.NoError(t, err) + assert.Nil(t, mismatches) +} + +func TestCheckSchema_NonStructDestYieldsNoMismatches(t *testing.T) { + var dest string + + mismatches, err := CheckSchema("GET /x", []byte(`{"anything":"goes"}`), &dest) + require.NoError(t, err) + assert.Empty(t, mismatches) +} + +func TestCheckSchema_InvalidJSONReturnsError(t *testing.T) { + mismatches, err := CheckSchema("GET /x", []byte(`{not valid json`), &schemaTestParent{}) + require.Error(t, err) + assert.Nil(t, mismatches) +} + +func TestSchemaMismatch_String(t *testing.T) { + mismatch := SchemaMismatch{Endpoint: "GET /x", GoType: "sonar.Foo", Path: "bar"} + assert.Equal(t, `GET /x: field "bar" has no match in sonar.Foo`, mismatch.String()) +} diff --git a/sonar/settings_service.go b/sonar/settings_service.go index 1647021..a98d361 100644 --- a/sonar/settings_service.go +++ b/sonar/settings_service.go @@ -56,6 +56,8 @@ type SettingDefinition struct { DefaultValue string `json:"defaultValue,omitempty"` // Description is the description of the setting. Description string `json:"description,omitempty"` + // DeprecatedKey is the previous key of the setting, kept for backward compatibility. + DeprecatedKey string `json:"deprecatedKey,omitempty"` // Key is the unique key of the setting. Key string `json:"key,omitempty"` // Name is the display name of the setting. @@ -101,6 +103,8 @@ type SettingsValues struct { } // SettingValue represents a setting value. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type SettingValue struct { // Key is the unique key of the setting. Key string `json:"key,omitempty"` @@ -112,6 +116,8 @@ type SettingValue struct { FieldValues []map[string]string `json:"fieldValues,omitempty"` // Inherited indicates if the value is inherited from a parent component. Inherited bool `json:"inherited,omitempty"` + // ParentValue is the value inherited from the parent component (for single-value settings). + ParentValue string `json:"parentValue,omitempty"` } // ----------------------------------------------------------------------------- diff --git a/sonar/system_service.go b/sonar/system_service.go index bc73b12..23aa1fc 100644 --- a/sonar/system_service.go +++ b/sonar/system_service.go @@ -141,6 +141,8 @@ type SystemInfo struct { Health string `json:"Health,omitempty"` // HealthCauses contains reasons for any health issues. HealthCauses []any `json:"Health Causes,omitempty"` + // MCP contains Model Context Protocol integration status. + MCP SystemInfoMCP `json:"MCP,omitzero"` // Plugins contains installed plugin information. Plugins map[string]string `json:"Plugins,omitempty"` // SearchIndexes contains search index statistics. @@ -175,10 +177,38 @@ type SystemInfoALMs struct { // SystemInfoBundled contains versions of bundled plugins. type SystemInfoBundled struct { + // Abap is the ABAP plugin version. + Abap string `json:"abap,omitempty"` + // Architecture is the architecture plugin version. + Architecture string `json:"architecture,omitempty"` + // Architecturejavafrontend is the architecture Java frontend plugin version. + Architecturejavafrontend string `json:"architecturejavafrontend,omitempty"` + // Architecturejavascriptfrontend is the architecture JavaScript frontend plugin version. + Architecturejavascriptfrontend string `json:"architecturejavascriptfrontend,omitempty"` + // Cayc is the Clean-as-You-Code plugin version. + Cayc string `json:"cayc,omitempty"` + // Cfamilydependencies is the C-family dependencies plugin version. + Cfamilydependencies string `json:"cfamilydependencies,omitempty"` + // Cobol is the COBOL plugin version. + Cobol string `json:"cobol,omitempty"` // Config is the config plugin version. Config string `json:"config,omitempty"` + // Cpp is the C++ plugin version. + Cpp string `json:"cpp,omitempty"` // Csharp is the C# plugin version. Csharp string `json:"csharp,omitempty"` + // Csharpenterprise is the C# enterprise plugin version. + Csharpenterprise string `json:"csharpenterprise,omitempty"` + // Dart is the Dart plugin version. + Dart string `json:"dart,omitempty"` + // Dbd is the database design plugin version. + Dbd string `json:"dbd,omitempty"` + // Dbdjavafrontend is the database design Java frontend plugin version. + Dbdjavafrontend string `json:"dbdjavafrontend,omitempty"` + // Dbdpythonfrontend is the database design Python frontend plugin version. + Dbdpythonfrontend string `json:"dbdpythonfrontend,omitempty"` + // Dre is the DRE plugin version. + Dre string `json:"dre,omitempty"` // Flex is the Flex plugin version. Flex string `json:"flex,omitempty"` // Go is the Go plugin version. @@ -187,30 +217,82 @@ type SystemInfoBundled struct { Iac string `json:"iac,omitempty"` // Jacoco is the JaCoCo plugin version. Jacoco string `json:"jacoco,omitempty"` + // Jasmin is the Jasmin plugin version. + Jasmin string `json:"jasmin,omitempty"` // Java is the Java plugin version. Java string `json:"java,omitempty"` // Javascript is the JavaScript plugin version. Javascript string `json:"javascript,omitempty"` + // Javasymbolicexecution is the Java symbolic execution plugin version. + Javasymbolicexecution string `json:"javasymbolicexecution,omitempty"` + // Jcl is the JCL plugin version. + Jcl string `json:"jcl,omitempty"` // Kotlin is the Kotlin plugin version. Kotlin string `json:"kotlin,omitempty"` // Php is the PHP plugin version. Php string `json:"php,omitempty"` + // Pli is the PL/I plugin version. + Pli string `json:"pli,omitempty"` + // Plsql is the PL/SQL plugin version. + Plsql string `json:"plsql,omitempty"` // Python is the Python plugin version. Python string `json:"python,omitempty"` + // Rpg is the RPG plugin version. + Rpg string `json:"rpg,omitempty"` // Ruby is the Ruby plugin version. Ruby string `json:"ruby,omitempty"` + // Rust is the Rust plugin version. + Rust string `json:"rust,omitempty"` + // Security is the security plugin version. + Security string `json:"security,omitempty"` + // Securitycsharpfrontend is the security C# frontend plugin version. + Securitycsharpfrontend string `json:"securitycsharpfrontend,omitempty"` + // Securitygofrontend is the security Go frontend plugin version. + Securitygofrontend string `json:"securitygofrontend,omitempty"` + // Securityjavafrontend is the security Java frontend plugin version. + Securityjavafrontend string `json:"securityjavafrontend,omitempty"` + // Securitykotlinfrontend is the security Kotlin frontend plugin version. + Securitykotlinfrontend string `json:"securitykotlinfrontend,omitempty"` + // Securityphpfrontend is the security PHP frontend plugin version. + Securityphpfrontend string `json:"securityphpfrontend,omitempty"` + // Securitypythonfrontend is the security Python frontend plugin version. + Securitypythonfrontend string `json:"securitypythonfrontend,omitempty"` + // Securityvbnetfrontend is the security VB.NET frontend plugin version. + Securityvbnetfrontend string `json:"securityvbnetfrontend,omitempty"` + // Sonarapex is the Apex plugin version. + Sonarapex string `json:"sonarapex,omitempty"` // Sonarscala is the Scala plugin version. Sonarscala string `json:"sonarscala,omitempty"` + // Swift is the Swift plugin version. + Swift string `json:"swift,omitempty"` // Text is the Text plugin version. Text string `json:"text,omitempty"` + // Textenterprise is the text enterprise plugin version. + Textenterprise string `json:"textenterprise,omitempty"` + // Tsql is the T-SQL plugin version. + Tsql string `json:"tsql,omitempty"` + // Vb is the VB plugin version. + Vb string `json:"vb,omitempty"` // Vbnet is the VB.NET plugin version. Vbnet string `json:"vbnet,omitempty"` + // Vbnetenterprise is the VB.NET enterprise plugin version. + Vbnetenterprise string `json:"vbnetenterprise,omitempty"` // Web is the Web plugin version. Web string `json:"web,omitempty"` // XML is the XML plugin version. XML string `json:"xml,omitempty"` } +// SystemInfoMCP contains Model Context Protocol integration status. +// +//nolint:tagliatelle // JSON tags match SonarQube API field names +type SystemInfoMCP struct { + // Enabled indicates whether MCP integration is enabled. + Enabled bool `json:"Enabled,omitempty"` + // Healthy indicates whether MCP integration is healthy. + Healthy bool `json:"Healthy,omitempty"` +} + // DatabaseConnectionPool contains database connection pool information. // //nolint:tagliatelle // JSON tags match SonarQube API field names @@ -419,6 +501,8 @@ type ServerPushConnections struct { type SystemInfoSystem struct { // AcceptedExternalIdentityProviders lists accepted external identity providers. AcceptedExternalIdentityProviders string `json:"Accepted external identity providers,omitempty"` + // Container indicates if running in a container. + Container bool `json:"Container,omitempty"` // DataDir is the data directory path. DataDir string `json:"Data Dir,omitempty"` // Docker indicates if running in Docker. @@ -433,6 +517,8 @@ type SystemInfoSystem struct { HighAvailability bool `json:"High Availability,omitempty"` // HomeDir is the home directory path. HomeDir string `json:"Home Dir,omitempty"` + // LinesOfCode is the total number of lines of code analyzed by this instance. + LinesOfCode int64 `json:"Lines of Code,omitempty"` // OfficialDistribution indicates if this is an official distribution. OfficialDistribution bool `json:"Official Distribution,omitempty"` // Processors is the number of available processors. diff --git a/sonar/user_groups_service.go b/sonar/user_groups_service.go index eb3793d..9d81e6e 100644 --- a/sonar/user_groups_service.go +++ b/sonar/user_groups_service.go @@ -99,6 +99,12 @@ type UserGroupsUsers struct { Users []UserGroupsUser `json:"users,omitempty"` // Paging contains pagination information. Paging Paging `json:"paging,omitzero"` + // Page is the current page number (legacy duplicate of Paging.PageIndex). + Page int64 `json:"p,omitempty"` + // PageSize is the page size (legacy duplicate of Paging.PageSize). + PageSize int64 `json:"ps,omitempty"` + // Total is the total number of users (legacy duplicate of Paging.Total). + Total int64 `json:"total,omitempty"` } // ----------------------------------------------------------------------------- diff --git a/sonar/user_tokens_service.go b/sonar/user_tokens_service.go index d52fda7..ef1ce65 100644 --- a/sonar/user_tokens_service.go +++ b/sonar/user_tokens_service.go @@ -76,6 +76,8 @@ type UserTokensGenerate struct { Login string `json:"login,omitempty"` // Name is the name of the token. Name string `json:"name,omitempty"` + // ProjectKey is the key of the project the token is scoped to. + ProjectKey string `json:"projectKey,omitempty"` // Token is the generated token value (only returned once). Token string `json:"token,omitempty"` // Type is the type of the token. diff --git a/sonar/users_service.go b/sonar/users_service.go index 88a26fc..34dd8d8 100644 --- a/sonar/users_service.go +++ b/sonar/users_service.go @@ -102,6 +102,12 @@ type User struct { Active bool `json:"active,omitempty"` // Email is the user's email address. Email string `json:"email,omitempty"` + // ExternalIdentity is the user's external identity. + ExternalIdentity string `json:"externalIdentity,omitempty"` + // ExternalProvider is the user's external identity provider. + ExternalProvider string `json:"externalProvider,omitempty"` + // Groups is the list of groups the user belongs to. + Groups []string `json:"groups,omitempty"` // Local indicates whether the user is authenticated locally. Local bool `json:"local,omitempty"` // Login is the user's login. @@ -152,6 +158,10 @@ type UsersSearchResult struct { type UsersDeactivateResult struct { // Active indicates whether the user is active (always false for deactivated users). Active bool `json:"active,omitempty"` + // ExternalIdentity is the user's external identity. + ExternalIdentity string `json:"externalIdentity,omitempty"` + // ExternalProvider is the user's external identity provider. + ExternalProvider string `json:"externalProvider,omitempty"` // Groups is the list of groups (empty for deactivated users). Groups []any `json:"groups,omitempty"` // Local indicates whether the user was authenticated locally. @@ -204,6 +214,24 @@ type UsersCurrentProfile struct { type UsersDismissedNotices struct { // EducationPrinciples indicates whether the education principles notice was dismissed. EducationPrinciples bool `json:"educationPrinciples,omitempty"` + // IssueCleanCodeGuide indicates whether the issue clean code guide notice was dismissed. + IssueCleanCodeGuide bool `json:"issueCleanCodeGuide,omitempty"` + // IssueNewIssueStatusAndTransitionGuide indicates whether the issue new status and transition guide notice was dismissed. + IssueNewIssueStatusAndTransitionGuide bool `json:"issueNewIssueStatusAndTransitionGuide,omitempty"` + // OverviewZeroNewIssuesSimplification indicates whether the overview zero new issues simplification notice was dismissed. + OverviewZeroNewIssuesSimplification bool `json:"overviewZeroNewIssuesSimplification,omitempty"` + // ShowDesignAndArchitectureBanner indicates whether the design and architecture banner notice was dismissed. + ShowDesignAndArchitectureBanner bool `json:"showDesignAndArchitectureBanner,omitempty"` + // ShowDesignAndArchitectureOptInBanner indicates whether the design and architecture opt-in banner notice was dismissed. + ShowDesignAndArchitectureOptInBanner bool `json:"showDesignAndArchitectureOptInBanner,omitempty"` + // ShowDesignAndArchitectureTour indicates whether the design and architecture tour notice was dismissed. + ShowDesignAndArchitectureTour bool `json:"showDesignAndArchitectureTour,omitempty"` + // ShowEnableSca indicates whether the enable SCA notice was dismissed. + ShowEnableSca bool `json:"showEnableSca,omitempty"` + // ShowNewModesBanner indicates whether the new modes banner notice was dismissed. + ShowNewModesBanner bool `json:"showNewModesBanner,omitempty"` + // ShowSandboxedIssuesIntro indicates whether the sandboxed issues intro notice was dismissed. + ShowSandboxedIssuesIntro bool `json:"showSandboxedIssuesIntro,omitempty"` // SonarlintAd indicates whether the SonarLint ad notice was dismissed. SonarlintAd bool `json:"sonarlintAd,omitempty"` } diff --git a/sonar/views_service.go b/sonar/views_service.go index 9b39406..6262c12 100644 --- a/sonar/views_service.go +++ b/sonar/views_service.go @@ -17,11 +17,15 @@ type ViewsService struct { // ----------------------------------------------------------------------------- // View represents a SonarQube portfolio/view. +// +//nolint:govet // Field alignment less important than maintaining consistent field order for readability type View struct { // Description is the portfolio description. // // Live-verified: the real API field is "desc", not "description". Description string `json:"desc,omitempty"` + // IsFavorite indicates whether the portfolio is marked as favorite by the current user. + IsFavorite bool `json:"isFavorite,omitempty"` // Key is the portfolio key. When this View represents a sub-portfolio // nested under a parent (e.g. within ViewDetails.SubViews), this is a // composite ":" key, not the sub-portfolio's own key -