From 9e1a6bf209dc5d4105365ca712b9ce6cbcdb5ce0 Mon Sep 17 00:00:00 2001 From: Brad Williams Date: Fri, 5 Jun 2026 13:50:30 -0400 Subject: [PATCH] CRD support for Release Qualifiers rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- .../release.openshift.io_releasepayloads.yaml | 362 +++++ hack/update-codegen.sh | 6 - pkg/apis/release/v1alpha1/types.go | 93 ++ .../release/v1alpha1/zz_generated.deepcopy.go | 119 +- pkg/releasequalifiers/merge.go | 162 -- pkg/releasequalifiers/merge_test.go | 1379 ----------------- .../notifications/jira/jira.go | 6 - .../notifications/jira/types.go | 52 +- .../jira/zz_generated.deepcopy.go | 15 + .../notifications/slack/slack.go | 6 - .../notifications/slack/types.go | 47 - .../slack/zz_generated.deepcopy.go | 50 - pkg/releasequalifiers/notifications/types.go | 9 - .../notifications/zz_generated.deepcopy.go | 6 - pkg/releasequalifiers/prettyprint.go | 56 - pkg/releasequalifiers/prettyprint_test.go | 814 ---------- pkg/releasequalifiers/types.go | 8 +- .../zz_generated.deepcopy.go | 9 +- 18 files changed, 643 insertions(+), 2556 deletions(-) delete mode 100644 pkg/releasequalifiers/merge.go delete mode 100644 pkg/releasequalifiers/merge_test.go delete mode 100644 pkg/releasequalifiers/notifications/jira/jira.go delete mode 100644 pkg/releasequalifiers/notifications/slack/slack.go delete mode 100644 pkg/releasequalifiers/notifications/slack/types.go delete mode 100644 pkg/releasequalifiers/notifications/slack/zz_generated.deepcopy.go delete mode 100644 pkg/releasequalifiers/prettyprint.go delete mode 100644 pkg/releasequalifiers/prettyprint_test.go diff --git a/artifacts/release.openshift.io_releasepayloads.yaml b/artifacts/release.openshift.io_releasepayloads.yaml index 145e0568d..ea807a0ab 100644 --- a/artifacts/release.openshift.io_releasepayloads.yaml +++ b/artifacts/release.openshift.io_releasepayloads.yaml @@ -11,6 +11,8 @@ spec: kind: ReleasePayload listKind: ReleasePayloadList plural: releasepayloads + shortNames: + - rp singular: releasepayload scope: Namespaced versions: @@ -45,6 +47,9 @@ spec: namespace: description: Namespace must match that of the ReleasePayload type: string + streamName: + description: StreamName is the name of the release stream this payload belongs to (e.g. "4.19.0-0.nightly") + type: string payloadCreationConfig: description: PayloadCreationConfig the configuration used when creating the ReleasePayload type: object @@ -116,6 +121,101 @@ spec: maxRetries: description: MaxRetries Maximum retry attempts for the job. Defaults to 0 - do not retry on fail type: integer + qualifiers: + description: 'Qualifiers holds the releasequalifiers.ReleaseQualifiers definitions that enable, and override (if specified), any settings defined in: https://github.com/openshift/release/blob/master/core-services/release-controller/release-qualifiers.yaml' + type: object + additionalProperties: + description: ReleaseQualifier defines the configuration for a single release qualifier It contains metadata about the qualifier and its notification settings + type: object + properties: + approval: + description: Approval indicates whether this qualifier is earned via Team Approval Using a pointer to distinguish between "not set" and "set to false" + type: boolean + badgeName: + description: BadgeName short name displayed, as UI badges, in job level summaries + type: string + description: + description: Description contains detailed information about the qualifier for display in tooltips or detailed views + type: string + enabled: + description: Enabled indicates whether this qualifier is currently active Using a pointer to distinguish between "not set" and "set to false" + type: boolean + failureLabels: + description: FailureLabels labels to apply when qualifying jobs fail + type: array + items: + type: string + notifications: + description: Notifications contains configuration for notification channels + type: object + properties: + jira: + description: Jira contains Jira-specific notification configuration + type: object + properties: + assignee: + description: Assignee is the default assignee for tickets created by this qualifier + type: string + component: + description: Component is the Jira component this qualifier relates to + type: string + description: + description: Description is the default description text for Jira tickets + type: string + escalations: + description: Escalations defines the escalation rules for Jira notifications Each escalation specifies when and how to create tickets based on failure patterns + type: array + items: + description: 'Escalation defines a single escalation rule for Jira notifications It specifies the conditions and actions for creating Jira tickets Multiple criteria can be combined to create sophisticated escalation rules: - Simple: Failures=3 triggers after 3 consecutive failures - Windowed: OverLastRuns=10, Failures=2 triggers if >=2 of last 10 runs failed - Percentage: OverLastRuns=10, PassPercentage=60 triggers if <60% of last 10 runs passed - Time-bounded: OverPeriod="2d", OverLastRuns=20, PassPercentage=80 considers last 20 runs or runs from last 2 days (whichever provides more samples)' + type: object + properties: + failures: + description: Failures is the number of failures required to trigger this escalation When used alone, this counts consecutive failures When combined with OverLastRuns, this counts total failures in the window + type: integer + mentions: + description: Mentions is a list of users to mention in the Jira ticket + type: array + items: + type: string + name: + description: Name is the unique identifier for this escalation level + type: string + needsInfo: + description: NeedsInfo is a list of users to add as watchers or request information from + type: array + items: + type: string + overLastRuns: + description: OverLastRuns defines the window of recent runs to consider If omitted when Failures is set, defaults to Failures (consecutive mode) Can be combined with Failures, PassPercentage, or OverPeriod + type: integer + minimum: 1 + overPeriod: + description: 'OverPeriod defines a time window for considering runs (e.g., "2d" for 2 days) Used with OverLastRuns to expand the sample set: whichever provides more runs is used Format examples: "1h", "24h", "2d", "1w"' + type: string + pattern: ^[1-9]\d*(h|d|w)$ + passPercentage: + description: PassPercentage defines the minimum pass rate required (0-100) Escalates when the pass rate falls below this threshold Must be used with OverLastRuns to define the evaluation window + type: integer + maximum: 100 + minimum: 0 + priority: + description: Priority is the Jira priority level for tickets created at this escalation + type: string + project: + description: Project is the Jira project key where tickets will be created + type: string + summary: + description: Summary is the default summary text for Jira tickets + type: string + thread: + description: Thread identifier for separating notifications across jobs When multiple jobs contribute to the same qualifier, different thread values will result in separate Jira tickets being created + type: string + payloadBadgeStatus: + description: PayloadBadgeStatus indicates if/when the qualifier's BadgeName should be displayed at the ReleasePayload level + type: string + summary: + description: Summary provides a brief description of what this qualifier represents + type: string informingJobs: description: InformingJobs are release verification jobs used to execute tests against a ReleasePayload type: array @@ -135,6 +235,101 @@ spec: maxRetries: description: MaxRetries Maximum retry attempts for the job. Defaults to 0 - do not retry on fail type: integer + qualifiers: + description: 'Qualifiers holds the releasequalifiers.ReleaseQualifiers definitions that enable, and override (if specified), any settings defined in: https://github.com/openshift/release/blob/master/core-services/release-controller/release-qualifiers.yaml' + type: object + additionalProperties: + description: ReleaseQualifier defines the configuration for a single release qualifier It contains metadata about the qualifier and its notification settings + type: object + properties: + approval: + description: Approval indicates whether this qualifier is earned via Team Approval Using a pointer to distinguish between "not set" and "set to false" + type: boolean + badgeName: + description: BadgeName short name displayed, as UI badges, in job level summaries + type: string + description: + description: Description contains detailed information about the qualifier for display in tooltips or detailed views + type: string + enabled: + description: Enabled indicates whether this qualifier is currently active Using a pointer to distinguish between "not set" and "set to false" + type: boolean + failureLabels: + description: FailureLabels labels to apply when qualifying jobs fail + type: array + items: + type: string + notifications: + description: Notifications contains configuration for notification channels + type: object + properties: + jira: + description: Jira contains Jira-specific notification configuration + type: object + properties: + assignee: + description: Assignee is the default assignee for tickets created by this qualifier + type: string + component: + description: Component is the Jira component this qualifier relates to + type: string + description: + description: Description is the default description text for Jira tickets + type: string + escalations: + description: Escalations defines the escalation rules for Jira notifications Each escalation specifies when and how to create tickets based on failure patterns + type: array + items: + description: 'Escalation defines a single escalation rule for Jira notifications It specifies the conditions and actions for creating Jira tickets Multiple criteria can be combined to create sophisticated escalation rules: - Simple: Failures=3 triggers after 3 consecutive failures - Windowed: OverLastRuns=10, Failures=2 triggers if >=2 of last 10 runs failed - Percentage: OverLastRuns=10, PassPercentage=60 triggers if <60% of last 10 runs passed - Time-bounded: OverPeriod="2d", OverLastRuns=20, PassPercentage=80 considers last 20 runs or runs from last 2 days (whichever provides more samples)' + type: object + properties: + failures: + description: Failures is the number of failures required to trigger this escalation When used alone, this counts consecutive failures When combined with OverLastRuns, this counts total failures in the window + type: integer + mentions: + description: Mentions is a list of users to mention in the Jira ticket + type: array + items: + type: string + name: + description: Name is the unique identifier for this escalation level + type: string + needsInfo: + description: NeedsInfo is a list of users to add as watchers or request information from + type: array + items: + type: string + overLastRuns: + description: OverLastRuns defines the window of recent runs to consider If omitted when Failures is set, defaults to Failures (consecutive mode) Can be combined with Failures, PassPercentage, or OverPeriod + type: integer + minimum: 1 + overPeriod: + description: 'OverPeriod defines a time window for considering runs (e.g., "2d" for 2 days) Used with OverLastRuns to expand the sample set: whichever provides more runs is used Format examples: "1h", "24h", "2d", "1w"' + type: string + pattern: ^[1-9]\d*(h|d|w)$ + passPercentage: + description: PassPercentage defines the minimum pass rate required (0-100) Escalates when the pass rate falls below this threshold Must be used with OverLastRuns to define the evaluation window + type: integer + maximum: 100 + minimum: 0 + priority: + description: Priority is the Jira priority level for tickets created at this escalation + type: string + project: + description: Project is the Jira project key where tickets will be created + type: string + summary: + description: Summary is the default summary text for Jira tickets + type: string + thread: + description: Thread identifier for separating notifications across jobs When multiple jobs contribute to the same qualifier, different thread values will result in separate Jira tickets being created + type: string + payloadBadgeStatus: + description: PayloadBadgeStatus indicates if/when the qualifier's BadgeName should be displayed at the ReleasePayload level + type: string + summary: + description: Summary provides a brief description of what this qualifier represents + type: string payloadVerificationDataSource: description: PayloadVerificationDataSource where JobRunResult will be collected from. type: string @@ -164,6 +359,101 @@ spec: maxRetries: description: MaxRetries Maximum retry attempts for the job. Defaults to 0 - do not retry on fail type: integer + qualifiers: + description: 'Qualifiers holds the releasequalifiers.ReleaseQualifiers definitions that enable, and override (if specified), any settings defined in: https://github.com/openshift/release/blob/master/core-services/release-controller/release-qualifiers.yaml' + type: object + additionalProperties: + description: ReleaseQualifier defines the configuration for a single release qualifier It contains metadata about the qualifier and its notification settings + type: object + properties: + approval: + description: Approval indicates whether this qualifier is earned via Team Approval Using a pointer to distinguish between "not set" and "set to false" + type: boolean + badgeName: + description: BadgeName short name displayed, as UI badges, in job level summaries + type: string + description: + description: Description contains detailed information about the qualifier for display in tooltips or detailed views + type: string + enabled: + description: Enabled indicates whether this qualifier is currently active Using a pointer to distinguish between "not set" and "set to false" + type: boolean + failureLabels: + description: FailureLabels labels to apply when qualifying jobs fail + type: array + items: + type: string + notifications: + description: Notifications contains configuration for notification channels + type: object + properties: + jira: + description: Jira contains Jira-specific notification configuration + type: object + properties: + assignee: + description: Assignee is the default assignee for tickets created by this qualifier + type: string + component: + description: Component is the Jira component this qualifier relates to + type: string + description: + description: Description is the default description text for Jira tickets + type: string + escalations: + description: Escalations defines the escalation rules for Jira notifications Each escalation specifies when and how to create tickets based on failure patterns + type: array + items: + description: 'Escalation defines a single escalation rule for Jira notifications It specifies the conditions and actions for creating Jira tickets Multiple criteria can be combined to create sophisticated escalation rules: - Simple: Failures=3 triggers after 3 consecutive failures - Windowed: OverLastRuns=10, Failures=2 triggers if >=2 of last 10 runs failed - Percentage: OverLastRuns=10, PassPercentage=60 triggers if <60% of last 10 runs passed - Time-bounded: OverPeriod="2d", OverLastRuns=20, PassPercentage=80 considers last 20 runs or runs from last 2 days (whichever provides more samples)' + type: object + properties: + failures: + description: Failures is the number of failures required to trigger this escalation When used alone, this counts consecutive failures When combined with OverLastRuns, this counts total failures in the window + type: integer + mentions: + description: Mentions is a list of users to mention in the Jira ticket + type: array + items: + type: string + name: + description: Name is the unique identifier for this escalation level + type: string + needsInfo: + description: NeedsInfo is a list of users to add as watchers or request information from + type: array + items: + type: string + overLastRuns: + description: OverLastRuns defines the window of recent runs to consider If omitted when Failures is set, defaults to Failures (consecutive mode) Can be combined with Failures, PassPercentage, or OverPeriod + type: integer + minimum: 1 + overPeriod: + description: 'OverPeriod defines a time window for considering runs (e.g., "2d" for 2 days) Used with OverLastRuns to expand the sample set: whichever provides more runs is used Format examples: "1h", "24h", "2d", "1w"' + type: string + pattern: ^[1-9]\d*(h|d|w)$ + passPercentage: + description: PassPercentage defines the minimum pass rate required (0-100) Escalates when the pass rate falls below this threshold Must be used with OverLastRuns to define the evaluation window + type: integer + maximum: 100 + minimum: 0 + priority: + description: Priority is the Jira priority level for tickets created at this escalation + type: string + project: + description: Project is the Jira project key where tickets will be created + type: string + summary: + description: Summary is the default summary text for Jira tickets + type: string + thread: + description: Thread identifier for separating notifications across jobs When multiple jobs contribute to the same qualifier, different thread values will result in separate Jira tickets being created + type: string + payloadBadgeStatus: + description: PayloadBadgeStatus indicates if/when the qualifier's BadgeName should be displayed at the ReleasePayload level + type: string + summary: + description: Summary provides a brief description of what this qualifier represents + type: string x-kubernetes-validations: - rule: '!has(oldSelf.payloadVerificationDataSource) || has(self.payloadVerificationDataSource)' message: PayloadVerificationDataSource is required once set @@ -340,6 +630,78 @@ spec: state: description: AggregateState is the overall success/failure of all the executed jobs type: string + qualifiersSummary: + description: QualifiersSummary aggregates all qualifier-related information for this payload. Contains payload-level qualifier metadata (e.g., failure labels) and a map of per-qualifier summaries. + type: object + properties: + failureLabels: + description: FailureLabels are labels to apply to the payload when any qualifying jobs fail. Aggregated from the merged qualifier config for all qualifiers referenced by this payload. + type: array + items: + type: string + qualifiers: + description: Qualifiers maps each QualifierId to summary information about that qualifier, including the list of jobs that reference it, aggregate state, and badge status. This field is computed from the Spec and provides a convenient lookup for UI and reporting purposes. + type: object + additionalProperties: + description: ReleaseQualifierSummary contains summary information about a specific release qualifier including which jobs reference it, aggregate state, and badge status. + type: object + properties: + aggregateState: + description: AggregateState represents the overall state of all jobs for this qualifier Computed using the same logic as ComputeJobState + type: string + approval: + description: Approval indicates this qualifier is approval-based (label-driven) rather than job-based. + type: boolean + badgeEarned: + description: 'BadgeEarned indicates whether this qualifier badge has been earned Badges are earned when: qualifier.Enabled == true AND AggregateState == JobStateSuccess' + type: boolean + badgeName: + description: BadgeName is the display name for the badge from the global config Empty if no badge is configured for this qualifier + type: string + badgePropagated: + description: 'BadgePropagated indicates whether the earned badge should be displayed at payload level Computed from: BadgeEarned AND PayloadBadgeStatus rules Only true if badge is earned AND PayloadBadgeStatus allows propagation' + type: boolean + failureLabels: + description: FailureLabels are labels to apply when this qualifier's jobs fail. Computed from the merged global and per-job qualifier config. + type: array + items: + type: string + jiraNotifications: + description: 'JiraNotifications tracks per-thread Jira escalation notification state. The map key is the thread ID (format: ----).' + type: object + additionalProperties: + description: JiraNotificationState tracks the notification state for a specific Jira escalation thread. This state is used to prevent duplicate notifications and detect when conditions abate. + type: object + properties: + abated: + description: Abated indicates conditions have improved since the last escalation. When true, an abatement comment has been left on the Jira ticket. Cleared when conditions worsen again and a new escalation triggers. + type: boolean + activeEscalation: + description: ActiveEscalation is the name of the highest escalation level that has been notified + type: string + activePriority: + description: ActivePriority is the Jira priority of the active escalation + type: string + issueKey: + description: IssueKey is the Jira issue key (e.g., "OCPBUGS-123") created for this thread + type: string + lastTransitionTime: + description: LastTransitionTime is when the notification state last changed + type: string + format: date-time + jobs: + description: Jobs lists all jobs that reference this qualifier + type: array + items: + description: ReleaseQualifierJobReference represents a job that has been tagged with a specific qualifier + type: object + properties: + ciConfigurationJobName: + description: CIConfigurationJobName is the name of the prowjob definition as stored in the CI Job Configuration + type: string + ciConfigurationName: + description: CIConfigurationName the unique name given to a verification test + type: string releaseCreationJobResult: description: ReleaseCreationJobResult stores the coordinates and status of the release creation job that is created, by the release-controller, to create the release imagestream defined by the PayloadCoordinates in the ReleasePayloadSpec. If the release creation job fails to get created or completes unsuccessfully, the ReleasePayload will automatically be "Rejected". If the release creation job is successful, the release-controller will then begin the validation process. type: object diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 5f02b95b8..4dcce3f70 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -82,12 +82,6 @@ deepcopy-gen \ --output-file zz_generated.deepcopy.go \ github.com/openshift/release-controller/pkg/releasequalifiers/notifications/jira -echo "Generating deepcopy for pkg/releasequalifiers/notifications/slack..." -deepcopy-gen \ - --go-header-file "${BOILERPLATE}" \ - --output-file zz_generated.deepcopy.go \ - github.com/openshift/release-controller/pkg/releasequalifiers/notifications/slack - # Generate typed clients (clientset, listers, informers) echo "Generating clientset..." client-gen \ diff --git a/pkg/apis/release/v1alpha1/types.go b/pkg/apis/release/v1alpha1/types.go index ece5c6f37..e2d17986c 100644 --- a/pkg/apis/release/v1alpha1/types.go +++ b/pkg/apis/release/v1alpha1/types.go @@ -1,12 +1,14 @@ package v1alpha1 import ( + "github.com/openshift/release-controller/pkg/releasequalifiers" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=rp // ReleasePayload encapsulates the information for the creation of a ReleasePayload // and aggregates the results of its respective verification tests. @@ -153,6 +155,9 @@ type PayloadCoordinates struct { // Namespace must match that of the ReleasePayload Namespace string `json:"namespace,omitempty"` + // StreamName is the name of the release stream this payload belongs to (e.g. "4.19.0-0.nightly") + StreamName string `json:"streamName,omitempty"` + // ImagestreamName is the location of the configured "release" imagestream // - This is a configurable parameter ("to") passed into the release-controller via the ReleaseConfig's defined here: // https://github.com/openshift/release/blob/master/core-services/release-controller/_releases @@ -296,6 +301,10 @@ type CIConfiguration struct { MaxRetries int `json:"maxRetries,omitempty"` // AnalysisJobCount Number of asynchronous jobs to execute for release analysis. AnalysisJobCount int `json:"analysisJobCount,omitempty"` + // Qualifiers holds the releasequalifiers.ReleaseQualifiers definitions that enable, + // and override (if specified), any settings defined in: + // https://github.com/openshift/release/blob/master/core-services/release-controller/release-qualifiers.yaml + Qualifiers releasequalifiers.ReleaseQualifiers `json:"qualifiers,omitempty"` } // ReleasePayloadStatus the status of all the promotion test jobs @@ -327,6 +336,24 @@ type ReleasePayloadStatus struct { // UpgradeJobResults stores the results of generated upgrade jobs UpgradeJobResults []JobStatus `json:"upgradeJobResults,omitempty"` + + // QualifiersSummary aggregates all qualifier-related information for this payload. + // Contains payload-level qualifier metadata (e.g., failure labels) and a map of + // per-qualifier summaries. + QualifiersSummary *QualifiersSummary `json:"qualifiersSummary,omitempty"` +} + +// QualifiersSummary aggregates all qualifier-related information for a ReleasePayload. +type QualifiersSummary struct { + // FailureLabels are labels to apply to the payload when any qualifying jobs fail. + // Aggregated from the merged qualifier config for all qualifiers referenced by this payload. + FailureLabels []string `json:"failureLabels,omitempty"` + + // Qualifiers maps each QualifierId to summary information about that qualifier, + // including the list of jobs that reference it, aggregate state, and badge status. + // This field is computed from the Spec and provides a convenient lookup for + // UI and reporting purposes. + Qualifiers map[releasequalifiers.QualifierId]ReleaseQualifierSummary `json:"qualifiers,omitempty"` } // These are valid condition types for ReleasePayloadStatus. @@ -532,6 +559,72 @@ type JobRunResult struct { UpgradeType JobRunUpgradeType `json:"upgradeType,omitempty"` } +// ReleaseQualifierJobReference represents a job that has been tagged with a specific qualifier +type ReleaseQualifierJobReference struct { + // CIConfigurationName the unique name given to a verification test + CIConfigurationName string `json:"ciConfigurationName"` + + // CIConfigurationJobName is the name of the prowjob definition as stored in the CI Job Configuration + CIConfigurationJobName string `json:"ciConfigurationJobName"` +} + +// ReleaseQualifierSummary contains summary information about a specific release qualifier +// including which jobs reference it, aggregate state, and badge status. +type ReleaseQualifierSummary struct { + // Jobs lists all jobs that reference this qualifier + Jobs []ReleaseQualifierJobReference `json:"jobs"` + + // AggregateState represents the overall state of all jobs for this qualifier + // Computed using the same logic as ComputeJobState + AggregateState JobState `json:"aggregateState,omitempty"` + + // BadgeName is the display name for the badge from the global config + // Empty if no badge is configured for this qualifier + BadgeName string `json:"badgeName,omitempty"` + + // BadgeEarned indicates whether this qualifier badge has been earned + // Badges are earned when: qualifier.Enabled == true AND AggregateState == JobStateSuccess + BadgeEarned bool `json:"badgeEarned,omitempty"` + + // BadgePropagated indicates whether the earned badge should be displayed at payload level + // Computed from: BadgeEarned AND PayloadBadgeStatus rules + // Only true if badge is earned AND PayloadBadgeStatus allows propagation + BadgePropagated bool `json:"badgePropagated,omitempty"` + + // Approval indicates this qualifier is approval-based (label-driven) rather than job-based. + Approval bool `json:"approval,omitempty"` + + // FailureLabels are labels to apply when this qualifier's jobs fail. + // Computed from the merged global and per-job qualifier config. + FailureLabels []string `json:"failureLabels,omitempty"` + + // JiraNotifications tracks per-thread Jira escalation notification state. + // The map key is the thread ID (format: ----). + JiraNotifications map[string]JiraNotificationState `json:"jiraNotifications,omitempty"` +} + +// JiraNotificationState tracks the notification state for a specific Jira escalation thread. +// This state is used to prevent duplicate notifications and detect when conditions abate. +// +k8s:deepcopy-gen=true +type JiraNotificationState struct { + // IssueKey is the Jira issue key (e.g., "OCPBUGS-123") created for this thread + IssueKey string `json:"issueKey,omitempty"` + + // ActiveEscalation is the name of the highest escalation level that has been notified + ActiveEscalation string `json:"activeEscalation,omitempty"` + + // ActivePriority is the Jira priority of the active escalation + ActivePriority string `json:"activePriority,omitempty"` + + // Abated indicates conditions have improved since the last escalation. + // When true, an abatement comment has been left on the Jira ticket. + // Cleared when conditions worsen again and a new escalation triggers. + Abated bool `json:"abated,omitempty"` + + // LastTransitionTime is when the notification state last changed + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` +} + // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // ReleasePayloadList is a list of ReleasePayloads diff --git a/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go index 00169a2c6..b42ba48b5 100644 --- a/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go @@ -6,6 +6,7 @@ package v1alpha1 import ( + releasequalifiers "github.com/openshift/release-controller/pkg/releasequalifiers" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -13,6 +14,13 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CIConfiguration) DeepCopyInto(out *CIConfiguration) { *out = *in + if in.Qualifiers != nil { + in, out := &in.Qualifiers, &out.Qualifiers + *out = make(releasequalifiers.ReleaseQualifiers, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } return } @@ -26,6 +34,23 @@ func (in *CIConfiguration) DeepCopy() *CIConfiguration { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JiraNotificationState) DeepCopyInto(out *JiraNotificationState) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JiraNotificationState. +func (in *JiraNotificationState) DeepCopy() *JiraNotificationState { + if in == nil { + return nil + } + out := new(JiraNotificationState) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JobRunCoordinates) DeepCopyInto(out *JobRunCoordinates) { *out = *in @@ -128,17 +153,23 @@ func (in *PayloadVerificationConfig) DeepCopyInto(out *PayloadVerificationConfig if in.BlockingJobs != nil { in, out := &in.BlockingJobs, &out.BlockingJobs *out = make([]CIConfiguration, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.InformingJobs != nil { in, out := &in.InformingJobs, &out.InformingJobs *out = make([]CIConfiguration, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.UpgradeJobs != nil { in, out := &in.UpgradeJobs, &out.UpgradeJobs *out = make([]CIConfiguration, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } return } @@ -169,6 +200,34 @@ func (in *ProwCoordinates) DeepCopy() *ProwCoordinates { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QualifiersSummary) DeepCopyInto(out *QualifiersSummary) { + *out = *in + if in.FailureLabels != nil { + in, out := &in.FailureLabels, &out.FailureLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Qualifiers != nil { + in, out := &in.Qualifiers, &out.Qualifiers + *out = make(map[releasequalifiers.QualifierId]ReleaseQualifierSummary, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QualifiersSummary. +func (in *QualifiersSummary) DeepCopy() *QualifiersSummary { + if in == nil { + return nil + } + out := new(QualifiersSummary) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ReleaseCoordinates) DeepCopyInto(out *ReleaseCoordinates) { *out = *in @@ -418,6 +477,11 @@ func (in *ReleasePayloadStatus) DeepCopyInto(out *ReleasePayloadStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.QualifiersSummary != nil { + in, out := &in.QualifiersSummary, &out.QualifiersSummary + *out = new(QualifiersSummary) + (*in).DeepCopyInto(*out) + } return } @@ -430,3 +494,52 @@ func (in *ReleasePayloadStatus) DeepCopy() *ReleasePayloadStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseQualifierJobReference) DeepCopyInto(out *ReleaseQualifierJobReference) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseQualifierJobReference. +func (in *ReleaseQualifierJobReference) DeepCopy() *ReleaseQualifierJobReference { + if in == nil { + return nil + } + out := new(ReleaseQualifierJobReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseQualifierSummary) DeepCopyInto(out *ReleaseQualifierSummary) { + *out = *in + if in.Jobs != nil { + in, out := &in.Jobs, &out.Jobs + *out = make([]ReleaseQualifierJobReference, len(*in)) + copy(*out, *in) + } + if in.FailureLabels != nil { + in, out := &in.FailureLabels, &out.FailureLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.JiraNotifications != nil { + in, out := &in.JiraNotifications, &out.JiraNotifications + *out = make(map[string]JiraNotificationState, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseQualifierSummary. +func (in *ReleaseQualifierSummary) DeepCopy() *ReleaseQualifierSummary { + if in == nil { + return nil + } + out := new(ReleaseQualifierSummary) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/releasequalifiers/merge.go b/pkg/releasequalifiers/merge.go deleted file mode 100644 index 9e42e2f82..000000000 --- a/pkg/releasequalifiers/merge.go +++ /dev/null @@ -1,162 +0,0 @@ -package releasequalifiers - -import ( - "sort" - - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/jira" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/slack" -) - -// Merge takes a ReleaseQualifiers object and returns a new ReleaseQualifiers containing the union of both -// Override values take precedence when both are defined -// Deep merge is performed for nested structures -func (rqs ReleaseQualifiers) Merge(overrides ReleaseQualifiers) ReleaseQualifiers { - result := make(ReleaseQualifiers) - for qualifierId, qualifier := range rqs { - if override, exists := overrides[qualifierId]; exists { - result[qualifierId] = mergeQualifier(qualifier, override) - } - } - return result -} - -// Merge takes a ReleaseQualifier object and returns a new ReleaseQualifier that is the union of both -// Override values take precedence when both are defined -// Deep merge is performed for nested structures -func (rq ReleaseQualifier) Merge(override ReleaseQualifier) ReleaseQualifier { - return mergeQualifier(rq, override) -} - -// mergeQualifier merges two individual ReleaseQualifier structs -func mergeQualifier(base, override ReleaseQualifier) ReleaseQualifier { - result := base - - // Override simple fields if they are set in override - if override.BadgeName != "" { - result.BadgeName = override.BadgeName - } - if override.Summary != "" { - result.Summary = override.Summary - } - if override.Description != "" { - result.Description = override.Description - } - if override.PayloadBadgeStatus != "" { - result.PayloadBadgeStatus = override.PayloadBadgeStatus - } - - // Override Enabled field if it's explicitly set in override - if override.Enabled != nil { - result.Enabled = override.Enabled - } - - // Override Labels if present in override - if override.Labels != nil { - result.Labels = override.Labels - } - - // Merge notifications if present in override - if override.Notifications != nil { - if result.Notifications == nil { - result.Notifications = ¬ifications.Notifications{} - } - result.Notifications = mergeNotifications(*result.Notifications, *override.Notifications) - } - - return result -} - -// mergeNotifications merges two Notifications structs -func mergeNotifications(base, override notifications.Notifications) *notifications.Notifications { - result := base - - // Merge Slack notifications - if override.Slack != nil { - if result.Slack == nil { - result.Slack = &slack.Notification{} - } - result.Slack = mergeSlackNotifications(*result.Slack, *override.Slack) - } - - // Merge Jira notifications - if override.Jira != nil { - if result.Jira == nil { - result.Jira = &jira.Notification{} - } - result.Jira = mergeJiraNotifications(*result.Jira, *override.Jira) - } - - return &result -} - -// mergeSlackNotifications merges two SlackNotification structs -func mergeSlackNotifications(base, override slack.Notification) *slack.Notification { - result := base - - // Merge escalations by name - escalationMap := make(map[string]slack.Escalation) - - // Add base escalations - for _, escalation := range result.Escalations { - escalationMap[escalation.Name] = escalation - } - - // Merge override escalations - for _, escalation := range override.Escalations { - escalationMap[escalation.Name] = escalation - } - - // Convert back to slice - result.Escalations = make([]slack.Escalation, 0, len(escalationMap)) - for _, escalation := range escalationMap { - result.Escalations = append(result.Escalations, escalation) - } - - sort.Sort(slack.BySlackEscalationName(result.Escalations)) - return &result -} - -// mergeJiraNotifications merges two JiraNotification structs -func mergeJiraNotifications(base, override jira.Notification) *jira.Notification { - result := base - - // Override simple fields if they are set in override - if override.Project != "" { - result.Project = override.Project - } - if override.Component != "" { - result.Component = override.Component - } - if override.Assignee != "" { - result.Assignee = override.Assignee - } - if override.Summary != "" { - result.Summary = override.Summary - } - if override.Description != "" { - result.Description = override.Description - } - - // Merge escalations by name - escalationMap := make(map[string]jira.Escalation) - - // Add base escalations - for _, escalation := range result.Escalations { - escalationMap[escalation.Name] = escalation - } - - // Merge override escalations - for _, escalation := range override.Escalations { - escalationMap[escalation.Name] = escalation - } - - // Convert back to slice - result.Escalations = make([]jira.Escalation, 0, len(escalationMap)) - for _, escalation := range escalationMap { - result.Escalations = append(result.Escalations, escalation) - } - - sort.Sort(jira.ByJiraEscalationName(result.Escalations)) - return &result -} diff --git a/pkg/releasequalifiers/merge_test.go b/pkg/releasequalifiers/merge_test.go deleted file mode 100644 index 70578895b..000000000 --- a/pkg/releasequalifiers/merge_test.go +++ /dev/null @@ -1,1379 +0,0 @@ -package releasequalifiers - -import ( - "reflect" - "testing" - - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/jira" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/slack" -) - -var ( - TRUE = BoolPtr(true) - FALSE = BoolPtr(false) -) - -func TestReleaseQualifiers_Merge(t *testing.T) { - tests := []struct { - name string - base ReleaseQualifiers - override ReleaseQualifiers - expected ReleaseQualifiers - }{ - { - name: "empty base and override", - base: ReleaseQualifiers{}, - override: ReleaseQualifiers{}, - expected: ReleaseQualifiers{}, - }, - { - name: "empty base with override", - base: ReleaseQualifiers{}, - override: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - expected: ReleaseQualifiers{}, - }, - { - name: "base with empty override", - base: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - override: ReleaseQualifiers{}, - expected: ReleaseQualifiers{}, - }, - { - name: "simple field override", - base: ReleaseQualifiers{ - "test": { - Enabled: FALSE, - BadgeName: "OLD", - Summary: "Old Summary", - }, - }, - override: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "NEW", - Description: "New Description", - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "NEW", - Summary: "Old Summary", // Not overridden - Description: "New Description", - }, - }, - }, - { - name: "add new qualifier", - base: ReleaseQualifiers{ - "existing": { - Enabled: TRUE, - BadgeName: "EXIST", - }, - }, - override: ReleaseQualifiers{ - "new": { - Enabled: TRUE, - BadgeName: "NEW", - }, - }, - expected: ReleaseQualifiers{}, - }, - { - name: "merge notifications - add slack to existing jira", - base: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - }, - }, - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Channel: "#test"}, - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - }, - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Channel: "#test"}, - }, - }, - }, - }, - }, - }, - { - name: "merge escalations - replace existing by name", - base: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "old", Period: "24h", MinFailures: 2}, - {Name: "keep", Period: "12h", MinFailures: 1}, - }, - }, - }, - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "old", Period: "12h", MinFailures: 1}, // Replace - {Name: "new", Period: "6h", MinFailures: 1}, // Add new - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "keep", Period: "12h", MinFailures: 1}, // Kept - {Name: "new", Period: "6h", MinFailures: 1}, // Added - {Name: "old", Period: "12h", MinFailures: 1}, // Replaced - }, - }, - }, - }, - }, - }, - { - name: "merge jira escalations", - base: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "BASE", - Escalations: []jira.Escalation{ - {Name: "low", Failures: 1, Priority: "low"}, - {Name: "keep", Failures: 2, Priority: "normal"}, - }, - }, - }, - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "OVERRIDE", // Override project - Summary: "Overriding summary", - Description: "Overriding description", - Escalations: []jira.Escalation{ - {Name: "low", Failures: 2, Priority: "normal"}, // Replace - {Name: "high", Failures: 5, Priority: "high"}, // Add new - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "OVERRIDE", - Summary: "Overriding summary", - Description: "Overriding description", - Escalations: []jira.Escalation{ - {Name: "high", Failures: 5, Priority: "high"}, // Added - {Name: "keep", Failures: 2, Priority: "normal"}, // Kept - {Name: "low", Failures: 2, Priority: "normal"}, // Replaced - }, - }, - }, - }, - }, - }, - { - name: "nil notifications in base", - base: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - // Notifications are nil - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Channel: "#test"}, - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Channel: "#test"}, - }, - }, - }, - }, - }, - }, - { - name: "nil slack in base notifications", - base: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{Project: "TEST"}, - // Slack is nil - }, - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Channel: "#test"}, - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{Project: "TEST"}, - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Channel: "#test"}, - }, - }, - }, - }, - }, - }, - { - name: "empty escalations", - base: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{}, - }, - }, - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "new", Channel: "#new"}, - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "new", Channel: "#new"}, - }, - }, - }, - }, - }, - }, - { - name: "bool field override (enabled)", - base: ReleaseQualifiers{ - "test": { - Enabled: FALSE, - }, - }, - override: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - }, - }, - }, - { - name: "string field override with empty string", - base: ReleaseQualifiers{ - "test": { - BadgeName: "ORIGINAL", - }, - }, - override: ReleaseQualifiers{ - "test": { - BadgeName: "", // Empty string should not override - }, - }, - expected: ReleaseQualifiers{ - "test": { - BadgeName: "ORIGINAL", // Should keep original - }, - }, - }, - { - name: "string field override with non-empty string", - base: ReleaseQualifiers{ - "test": { - BadgeName: "ORIGINAL", - }, - }, - override: ReleaseQualifiers{ - "test": { - BadgeName: "NEW", - }, - }, - expected: ReleaseQualifiers{ - "test": { - BadgeName: "NEW", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestReleaseQualifiers_Merge_EdgeCases(t *testing.T) { - tests := []struct { - name string - base ReleaseQualifiers - override ReleaseQualifiers - expected ReleaseQualifiers - checkNil bool - }{ - { - name: "nil base and override", - base: nil, - override: nil, - expected: ReleaseQualifiers{}, - checkNil: false, - }, - { - name: "complex nested merge", - base: ReleaseQualifiers{ - "complex": { - Enabled: FALSE, - BadgeName: "COMPLEX", - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "level1", Period: "1h", MinFailures: 1, Channel: "#level1", Mentions: []string{"@user1"}}, - {Name: "level2", Period: "2h", MinFailures: 2, Channel: "#level2", Mentions: []string{"@user2"}}, - }, - }, - Jira: &jira.Notification{ - Project: "PROJ1", - Component: "COMP1", - Escalations: []jira.Escalation{ - {Name: "jira1", Failures: 1, Priority: "low", Mentions: []string{"@jira1"}}, - }, - }, - }, - }, - }, - override: ReleaseQualifiers{ - "complex": { - Enabled: TRUE, // Override enabled - // Name not set, should keep original - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "level1", Period: "30m", MinFailures: 1, Channel: "#level1-new", Mentions: []string{"@user1", "@user3"}}, // Replace - {Name: "level3", Period: "3h", MinFailures: 3, Channel: "#level3", Mentions: []string{"@user3"}}, // Add new - }, - }, - Jira: &jira.Notification{ - Project: "PROJ2", // Override project - // Component not set, should keep original - Escalations: []jira.Escalation{ - {Name: "jira1", Failures: 2, Priority: "normal", Mentions: []string{"@jira1", "@jira2"}}, // Replace - {Name: "jira2", Failures: 5, Priority: "high", Mentions: []string{"@jira2"}}, // Add new - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "complex": { - Enabled: TRUE, - BadgeName: "COMPLEX", - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "level1", Period: "30m", MinFailures: 1, Channel: "#level1-new", Mentions: []string{"@user1", "@user3"}}, // Replaced - {Name: "level2", Period: "2h", MinFailures: 2, Channel: "#level2", Mentions: []string{"@user2"}}, // Kept - {Name: "level3", Period: "3h", MinFailures: 3, Channel: "#level3", Mentions: []string{"@user3"}}, // Added - }, - }, - Jira: &jira.Notification{ - Project: "PROJ2", - Component: "COMP1", - Escalations: []jira.Escalation{ - {Name: "jira1", Failures: 2, Priority: "normal", Mentions: []string{"@jira1", "@jira2"}}, // Replaced - {Name: "jira2", Failures: 5, Priority: "high", Mentions: []string{"@jira2"}}, // Added - }, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if result == nil { - t.Error("Expected non-nil result for nil maps") - } - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestReleaseQualifiers_Merge_PreserveOrder(t *testing.T) { - tests := []struct { - name string - base ReleaseQualifiers - override ReleaseQualifiers - expected ReleaseQualifiers - }{ - { - name: "merge doesn't break the alphabetical order of escalations", - base: ReleaseQualifiers{ - "order": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "first", Period: "1h"}, - {Name: "second", Period: "2h"}, - {Name: "third", Period: "3h"}, - }, - }, - }, - }, - }, - override: ReleaseQualifiers{ - "order": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "second", Period: "2h-new"}, // Replace second - {Name: "fourth", Period: "4h"}, // Add fourth - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "order": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "first", Period: "1h"}, - {Name: "fourth", Period: "4h"}, - {Name: "second", Period: "2h-new"}, // Replaced - {Name: "third", Period: "3h"}, - }, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestReleaseQualifiers_Merge_EmptyStringHandling(t *testing.T) { - tests := []struct { - name string - base ReleaseQualifiers - override ReleaseQualifiers - expected ReleaseQualifiers - }{ - { - name: "empty strings should not override existing values", - base: ReleaseQualifiers{ - "test": { - BadgeName: "ORIGINAL", - Summary: "Original Summary", - Description: "Original Description", - }, - }, - override: ReleaseQualifiers{ - "test": { - BadgeName: "", // Empty string should not override - Summary: "New Summary", - Description: "", // Empty string should not override - }, - }, - expected: ReleaseQualifiers{ - "test": { - BadgeName: "ORIGINAL", // Empty string should not override - Summary: "New Summary", // Non-empty string should override - Description: "Original Description", // Empty string should not override - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestReleaseQualifiers_Merge_ComprehensiveEdgeCases(t *testing.T) { - tests := []struct { - name string - base ReleaseQualifiers - override ReleaseQualifiers - expected ReleaseQualifiers - }{ - { - name: "nil base with non-nil override", - base: nil, - override: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - expected: ReleaseQualifiers{}, - }, - { - name: "non-nil base with nil override", - base: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - override: nil, - expected: ReleaseQualifiers{}, - }, - { - name: "override enabled from nil to false", - base: ReleaseQualifiers{ - "test": { - BadgeName: "TEST", - }, - }, - override: ReleaseQualifiers{ - "test": { - Enabled: FALSE, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: FALSE, - BadgeName: "TEST", - }, - }, - }, - { - name: "override enabled from false to true", - base: ReleaseQualifiers{ - "test": { - Enabled: FALSE, - BadgeName: "TEST", - }, - }, - override: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - }, - { - name: "merge with labels override", - base: ReleaseQualifiers{ - "test": { - Labels: []string{"old-label"}, - }, - }, - override: ReleaseQualifiers{ - "test": { - Labels: []string{"new-label1", "new-label2"}, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Labels: []string{"new-label1", "new-label2"}, - }, - }, - }, - { - name: "override labels with empty slice", - base: ReleaseQualifiers{ - "test": { - Labels: []string{"old-label"}, - }, - }, - override: ReleaseQualifiers{ - "test": { - Labels: []string{}, // Override with empty slice - }, - }, - expected: ReleaseQualifiers{ - "test": { - Labels: []string{}, - }, - }, - }, - { - name: "all payload badge status combinations", - base: ReleaseQualifiers{ - "test1": { - PayloadBadgeStatus: BadgeStatusYes, - }, - "test2": { - PayloadBadgeStatus: BadgeStatusNo, - }, - }, - override: ReleaseQualifiers{ - "test1": { - PayloadBadgeStatus: BadgeStatusOnSuccess, - }, - "test2": { - PayloadBadgeStatus: BadgeStatusOnFailure, - }, - }, - expected: ReleaseQualifiers{ - "test1": { - PayloadBadgeStatus: BadgeStatusOnSuccess, - }, - "test2": { - PayloadBadgeStatus: BadgeStatusOnFailure, - }, - }, - }, - { - name: "empty escalation lists", - base: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{}, - }, - Jira: &jira.Notification{ - Escalations: []jira.Escalation{}, - }, - }, - }, - }, - override: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "new", Period: "1h", MinFailures: 1}, - }, - }, - Jira: &jira.Notification{ - Escalations: []jira.Escalation{ - {Name: "new", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - }, - expected: ReleaseQualifiers{ - "test": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "new", Period: "1h", MinFailures: 1}, - }, - }, - Jira: &jira.Notification{ - Escalations: []jira.Escalation{ - {Name: "new", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestReleaseQualifier_Merge(t *testing.T) { - tests := []struct { - name string - base ReleaseQualifier - override ReleaseQualifier - expected ReleaseQualifier - }{ - { - name: "empty base and override", - base: ReleaseQualifier{}, - override: ReleaseQualifier{}, - expected: ReleaseQualifier{}, - }, - { - name: "empty base with override", - base: ReleaseQualifier{}, - override: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "TEST", - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - { - name: "base with empty override", - base: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "TEST", - }, - override: ReleaseQualifier{}, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - { - name: "override all simple fields", - base: ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "OLD_BADGE", - Summary: "Old Summary", - Description: "Old Description", - PayloadBadgeStatus: BadgeStatusNo, - }, - override: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "NEW_BADGE", - Summary: "New Summary", - Description: "New Description", - PayloadBadgeStatus: BadgeStatusYes, - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "NEW_BADGE", - Summary: "New Summary", - Description: "New Description", - PayloadBadgeStatus: BadgeStatusYes, - }, - }, - { - name: "partial override - only some fields", - base: ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "BASE_BADGE", - Summary: "Base Summary", - Description: "Base Description", - PayloadBadgeStatus: BadgeStatusNo, - }, - override: ReleaseQualifier{ - BadgeName: "OVERRIDE_BADGE", - Summary: "Override Summary", - }, - expected: ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "OVERRIDE_BADGE", - Summary: "Override Summary", - Description: "Base Description", - PayloadBadgeStatus: BadgeStatusNo, - }, - }, - { - name: "empty strings don't override", - base: ReleaseQualifier{ - BadgeName: "ORIGINAL", - Summary: "Original Summary", - Description: "Original Description", - }, - override: ReleaseQualifier{ - BadgeName: "", - Summary: "New Summary", - Description: "", - }, - expected: ReleaseQualifier{ - BadgeName: "ORIGINAL", - Summary: "New Summary", - Description: "Original Description", - }, - }, - { - name: "override enabled from nil to false", - base: ReleaseQualifier{ - BadgeName: "TEST", - }, - override: ReleaseQualifier{ - Enabled: FALSE, - }, - expected: ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "TEST", - }, - }, - { - name: "override enabled from false to true", - base: ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "TEST", - }, - override: ReleaseQualifier{ - Enabled: TRUE, - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "TEST", - }, - }, - { - name: "override labels", - base: ReleaseQualifier{ - Labels: []string{"old-label1", "old-label2"}, - }, - override: ReleaseQualifier{ - Labels: []string{"new-label1", "new-label2", "new-label3"}, - }, - expected: ReleaseQualifier{ - Labels: []string{"new-label1", "new-label2", "new-label3"}, - }, - }, - { - name: "override labels with empty slice", - base: ReleaseQualifier{ - Labels: []string{"old-label"}, - }, - override: ReleaseQualifier{ - Labels: []string{}, - }, - expected: ReleaseQualifier{ - Labels: []string{}, - }, - }, - { - name: "override Badge variants", - base: ReleaseQualifier{ - PayloadBadgeStatus: BadgeStatusYes, - }, - override: ReleaseQualifier{ - PayloadBadgeStatus: BadgeStatusOnSuccess, - }, - expected: ReleaseQualifier{ - PayloadBadgeStatus: BadgeStatusOnSuccess, - }, - }, - { - name: "add Slack notifications to empty base", - base: ReleaseQualifier{ - Enabled: TRUE, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - { - Name: "test", - Period: "24h", - MinFailures: 1, - Channel: "#test", - }, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - { - Name: "test", - Period: "24h", - MinFailures: 1, - Channel: "#test", - }, - }, - }, - }, - }, - }, - { - name: "add Jira notifications to empty base", - base: ReleaseQualifier{ - Enabled: TRUE, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Component: "TestComp", - Assignee: "test@example.com", - Escalations: []jira.Escalation{}, - }, - }, - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Component: "TestComp", - Assignee: "test@example.com", - Escalations: []jira.Escalation{}, - }, - }, - }, - }, - { - name: "merge Slack escalations - add new", - base: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "low", Period: "24h", MinFailures: 1}, - }, - }, - }, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "high", Period: "72h", MinFailures: 5}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "high", Period: "72h", MinFailures: 5}, - {Name: "low", Period: "24h", MinFailures: 1}, - }, - }, - }, - }, - }, - { - name: "merge Slack escalations - replace existing", - base: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Period: "24h", MinFailures: 1, Channel: "#old"}, - }, - }, - }, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Period: "48h", MinFailures: 3, Channel: "#new"}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Period: "48h", MinFailures: 3, Channel: "#new"}, - }, - }, - }, - }, - }, - { - name: "merge Jira escalations - add new", - base: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - {Name: "low", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Escalations: []jira.Escalation{ - {Name: "high", Failures: 5, Priority: "high"}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - {Name: "high", Failures: 5, Priority: "high"}, - {Name: "low", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - }, - { - name: "merge Jira escalations - replace existing", - base: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - {Name: "test", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Escalations: []jira.Escalation{ - {Name: "test", Failures: 5, Priority: "high"}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - {Name: "test", Failures: 5, Priority: "high"}, - }, - }, - }, - }, - }, - { - name: "override Jira Project field", - base: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "BASE_PROJECT", - Component: "BaseComp", - Escalations: []jira.Escalation{}, - }, - }, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "OVERRIDE_PROJECT", - Escalations: []jira.Escalation{}, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "OVERRIDE_PROJECT", - Component: "BaseComp", - Escalations: []jira.Escalation{}, - }, - }, - }, - }, - { - name: "add both Slack and Jira notifications", - base: ReleaseQualifier{ - Enabled: TRUE, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "slack-test", Period: "24h", MinFailures: 1}, - }, - }, - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - {Name: "jira-test", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "slack-test", Period: "24h", MinFailures: 1}, - }, - }, - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - {Name: "jira-test", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - }, - { - name: "complex merge - all fields", - base: ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "BASE", - Summary: "Base Summary", - Description: "Base Description", - PayloadBadgeStatus: BadgeStatusNo, - Labels: []string{"base-label"}, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "base-slack", Period: "24h", MinFailures: 1}, - }, - }, - Jira: &jira.Notification{ - Project: "BASE", - Escalations: []jira.Escalation{ - {Name: "base-jira", Failures: 1, Priority: "low"}, - }, - }, - }, - }, - override: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "OVERRIDE", - Summary: "Override Summary", - PayloadBadgeStatus: BadgeStatusOnSuccess, - Labels: []string{"override-label1", "override-label2"}, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "override-slack", Period: "48h", MinFailures: 3}, - }, - }, - Jira: &jira.Notification{ - Project: "OVERRIDE", - Escalations: []jira.Escalation{ - {Name: "override-jira", Failures: 5, Priority: "high"}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "OVERRIDE", - Summary: "Override Summary", - Description: "Base Description", - PayloadBadgeStatus: BadgeStatusOnSuccess, - Labels: []string{"override-label1", "override-label2"}, - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "base-slack", Period: "24h", MinFailures: 1}, - {Name: "override-slack", Period: "48h", MinFailures: 3}, - }, - }, - Jira: &jira.Notification{ - Project: "OVERRIDE", - Escalations: []jira.Escalation{ - {Name: "base-jira", Failures: 1, Priority: "low"}, - {Name: "override-jira", Failures: 5, Priority: "high"}, - }, - }, - }, - }, - }, - { - name: "empty base with notifications", - base: ReleaseQualifier{}, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Period: "24h", MinFailures: 1}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Period: "24h", MinFailures: 1}, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestReleaseQualifier_Merge_PointerReceiver(t *testing.T) { - tests := []struct { - name string - base *ReleaseQualifier - override ReleaseQualifier - expected ReleaseQualifier - }{ - { - name: "pointer receiver with basic override", - base: &ReleaseQualifier{ - Enabled: FALSE, - BadgeName: "BASE", - }, - override: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "OVERRIDE", - }, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "OVERRIDE", - }, - }, - { - name: "pointer receiver preserves base when override is empty", - base: &ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "BASE", - Summary: "Base Summary", - Description: "Base Description", - }, - override: ReleaseQualifier{}, - expected: ReleaseQualifier{ - Enabled: TRUE, - BadgeName: "BASE", - Summary: "Base Summary", - Description: "Base Description", - }, - }, - { - name: "pointer receiver with notifications", - base: &ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "base", Period: "24h", MinFailures: 1}, - }, - }, - }, - }, - override: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "override", Period: "48h", MinFailures: 2}, - }, - }, - }, - }, - expected: ReleaseQualifier{ - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "base", Period: "24h", MinFailures: 1}, - {Name: "override", Period: "48h", MinFailures: 2}, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.base.Merge(tt.override) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("Merge() = %+v, want %+v", result, tt.expected) - } - }) - } -} diff --git a/pkg/releasequalifiers/notifications/jira/jira.go b/pkg/releasequalifiers/notifications/jira/jira.go deleted file mode 100644 index 8f4908743..000000000 --- a/pkg/releasequalifiers/notifications/jira/jira.go +++ /dev/null @@ -1,6 +0,0 @@ -package jira - -func (j Notification) Send() { - //TODO implement me - panic("implement me") -} diff --git a/pkg/releasequalifiers/notifications/jira/types.go b/pkg/releasequalifiers/notifications/jira/types.go index fce8a251d..b8a600e57 100644 --- a/pkg/releasequalifiers/notifications/jira/types.go +++ b/pkg/releasequalifiers/notifications/jira/types.go @@ -7,19 +7,24 @@ package jira // +k8s:deepcopy-gen=true type Notification struct { // Project is the Jira project key where tickets will be created - Project string `json:"project" yaml:"project"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` // Component is the Jira component this qualifier relates to - Component string `json:"component" yaml:"component"` + Component string `json:"component,omitempty" yaml:"component,omitempty"` // Assignee is the default assignee for tickets created by this qualifier - Assignee string `json:"assignee" yaml:"assignee"` + Assignee string `json:"assignee,omitempty" yaml:"assignee,omitempty"` // Summary is the default summary text for Jira tickets - Summary string `json:"summary" yaml:"summary"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` // Description is the default description text for Jira tickets - Description string `json:"description" yaml:"description"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // Thread identifier for separating notifications across jobs + // When multiple jobs contribute to the same qualifier, different thread values + // will result in separate Jira tickets being created + Thread string `json:"thread,omitempty" yaml:"thread,omitempty"` // Escalations defines the escalation rules for Jira notifications // Each escalation specifies when and how to create tickets based on failure patterns @@ -28,19 +33,50 @@ type Notification struct { // Escalation defines a single escalation rule for Jira notifications // It specifies the conditions and actions for creating Jira tickets +// Multiple criteria can be combined to create sophisticated escalation rules: +// - Simple: Failures=3 triggers after 3 consecutive failures +// - Windowed: OverLastRuns=10, Failures=2 triggers if >=2 of last 10 runs failed +// - Percentage: OverLastRuns=10, PassPercentage=60 triggers if <60% of last 10 runs passed +// - Time-bounded: OverPeriod="2d", OverLastRuns=20, PassPercentage=80 considers last 20 runs +// or runs from last 2 days (whichever provides more samples) +// // +k8s:deepcopy-gen=true type Escalation struct { // Name is the unique identifier for this escalation level - Name string `json:"name" yaml:"name"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` // Failures is the number of failures required to trigger this escalation - Failures int `json:"failures" yaml:"failures"` + // When used alone, this counts consecutive failures + // When combined with OverLastRuns, this counts total failures in the window + Failures int `json:"failures,omitempty" yaml:"failures,omitempty"` + + // OverLastRuns defines the window of recent runs to consider + // If omitted when Failures is set, defaults to Failures (consecutive mode) + // Can be combined with Failures, PassPercentage, or OverPeriod + // +kubebuilder:validation:Minimum=1 + OverLastRuns *int `json:"overLastRuns,omitempty" yaml:"overLastRuns,omitempty"` + + // PassPercentage defines the minimum pass rate required (0-100) + // Escalates when the pass rate falls below this threshold + // Must be used with OverLastRuns to define the evaluation window + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=100 + PassPercentage *int `json:"passPercentage,omitempty" yaml:"passPercentage,omitempty"` + + // OverPeriod defines a time window for considering runs (e.g., "2d" for 2 days) + // Used with OverLastRuns to expand the sample set: whichever provides more runs is used + // Format examples: "1h", "24h", "2d", "1w" + // +kubebuilder:validation:Pattern=`^[1-9]\d*(h|d|w)$` + OverPeriod string `json:"overPeriod,omitempty" yaml:"overPeriod,omitempty"` // Priority is the Jira priority level for tickets created at this escalation - Priority string `json:"priority" yaml:"priority"` + Priority string `json:"priority,omitempty" yaml:"priority,omitempty"` // Mentions is a list of users to mention in the Jira ticket Mentions []string `json:"mentions,omitempty" yaml:"mentions,omitempty"` + + // NeedsInfo is a list of users to add as watchers or request information from + NeedsInfo []string `json:"needsInfo,omitempty" yaml:"needsInfo,omitempty"` } // ByJiraEscalationName sorts a list of Escalations' by their Name diff --git a/pkg/releasequalifiers/notifications/jira/zz_generated.deepcopy.go b/pkg/releasequalifiers/notifications/jira/zz_generated.deepcopy.go index fd36c7ade..ddde5aa2f 100644 --- a/pkg/releasequalifiers/notifications/jira/zz_generated.deepcopy.go +++ b/pkg/releasequalifiers/notifications/jira/zz_generated.deepcopy.go @@ -8,11 +8,26 @@ package jira // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Escalation) DeepCopyInto(out *Escalation) { *out = *in + if in.OverLastRuns != nil { + in, out := &in.OverLastRuns, &out.OverLastRuns + *out = new(int) + **out = **in + } + if in.PassPercentage != nil { + in, out := &in.PassPercentage, &out.PassPercentage + *out = new(int) + **out = **in + } if in.Mentions != nil { in, out := &in.Mentions, &out.Mentions *out = make([]string, len(*in)) copy(*out, *in) } + if in.NeedsInfo != nil { + in, out := &in.NeedsInfo, &out.NeedsInfo + *out = make([]string, len(*in)) + copy(*out, *in) + } return } diff --git a/pkg/releasequalifiers/notifications/slack/slack.go b/pkg/releasequalifiers/notifications/slack/slack.go deleted file mode 100644 index f5268e501..000000000 --- a/pkg/releasequalifiers/notifications/slack/slack.go +++ /dev/null @@ -1,6 +0,0 @@ -package slack - -func (s Notification) Send() { - //TODO implement me - panic("implement me") -} diff --git a/pkg/releasequalifiers/notifications/slack/types.go b/pkg/releasequalifiers/notifications/slack/types.go deleted file mode 100644 index 61b63f240..000000000 --- a/pkg/releasequalifiers/notifications/slack/types.go +++ /dev/null @@ -1,47 +0,0 @@ -// +k8s:deepcopy-gen=package - -package slack - -// Notification defines how notifications are sent via Slack -// It includes escalation rules and channel configuration -// +k8s:deepcopy-gen=true -type Notification struct { - // Escalations defines the escalation rules for Slack notifications - // Each escalation specifies when and how to notify based on failure patterns - Escalations []Escalation `json:"escalations,omitempty" yaml:"escalations,omitempty"` -} - -// Escalation defines a single escalation rule for Slack notifications -// It specifies the conditions and actions for escalating failures -// +k8s:deepcopy-gen=true -type Escalation struct { - // Name is the unique identifier for this escalation level - Name string `json:"name" yaml:"name"` - - // Period defines the time window over which failures are counted - Period string `json:"period" yaml:"period"` - - // MinFailures is the minimum number of failures required to trigger this escalation - MinFailures int `json:"minFailures" yaml:"minFailures"` - - // Channel is the Slack channel where notifications will be sent - Channel string `json:"channel" yaml:"channel"` - - // Mentions is a list of users or groups to mention in the notification - Mentions []string `json:"mentions,omitempty" yaml:"mentions,omitempty"` -} - -// BySlackEscalationName sorts a list of Escalations' by their Name -type BySlackEscalationName []Escalation - -func (in BySlackEscalationName) Less(i, j int) bool { - return in[i].Name < in[j].Name -} - -func (in BySlackEscalationName) Len() int { - return len(in) -} - -func (in BySlackEscalationName) Swap(i, j int) { - in[i], in[j] = in[j], in[i] -} diff --git a/pkg/releasequalifiers/notifications/slack/zz_generated.deepcopy.go b/pkg/releasequalifiers/notifications/slack/zz_generated.deepcopy.go deleted file mode 100644 index 116e982fe..000000000 --- a/pkg/releasequalifiers/notifications/slack/zz_generated.deepcopy.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// Code generated by deepcopy-gen. DO NOT EDIT. - -package slack - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Escalation) DeepCopyInto(out *Escalation) { - *out = *in - if in.Mentions != nil { - in, out := &in.Mentions, &out.Mentions - *out = make([]string, len(*in)) - copy(*out, *in) - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Escalation. -func (in *Escalation) DeepCopy() *Escalation { - if in == nil { - return nil - } - out := new(Escalation) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Notification) DeepCopyInto(out *Notification) { - *out = *in - if in.Escalations != nil { - in, out := &in.Escalations, &out.Escalations - *out = make([]Escalation, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Notification. -func (in *Notification) DeepCopy() *Notification { - if in == nil { - return nil - } - out := new(Notification) - in.DeepCopyInto(out) - return out -} diff --git a/pkg/releasequalifiers/notifications/types.go b/pkg/releasequalifiers/notifications/types.go index 09385dc14..7332904b1 100644 --- a/pkg/releasequalifiers/notifications/types.go +++ b/pkg/releasequalifiers/notifications/types.go @@ -4,21 +4,12 @@ package notifications import ( "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/jira" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/slack" ) // Notifications defines the notification settings for a ReleaseQualifier // It supports multiple notification channels like Slack and Jira // +k8s:deepcopy-gen=true type Notifications struct { - // Slack contains Slack-specific notification configuration - Slack *slack.Notification `json:"slack,omitempty"` - // Jira contains Jira-specific notification configuration Jira *jira.Notification `json:"jira,omitempty"` } - -// Notification interface to define a common framework for all ReleaseQualifierNotifications to adhere to -type Notification interface { - Send() -} diff --git a/pkg/releasequalifiers/notifications/zz_generated.deepcopy.go b/pkg/releasequalifiers/notifications/zz_generated.deepcopy.go index 7b340e791..e8db70893 100644 --- a/pkg/releasequalifiers/notifications/zz_generated.deepcopy.go +++ b/pkg/releasequalifiers/notifications/zz_generated.deepcopy.go @@ -7,17 +7,11 @@ package notifications import ( jira "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/jira" - slack "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/slack" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Notifications) DeepCopyInto(out *Notifications) { *out = *in - if in.Slack != nil { - in, out := &in.Slack, &out.Slack - *out = new(slack.Notification) - (*in).DeepCopyInto(*out) - } if in.Jira != nil { in, out := &in.Jira, &out.Jira *out = new(jira.Notification) diff --git a/pkg/releasequalifiers/prettyprint.go b/pkg/releasequalifiers/prettyprint.go deleted file mode 100644 index 39ede36bd..000000000 --- a/pkg/releasequalifiers/prettyprint.go +++ /dev/null @@ -1,56 +0,0 @@ -package releasequalifiers - -import ( - "encoding/json" - "sort" -) - -// PrettyPrint returns a pretty-printed JSON representation of ReleaseQualifiers -// with alphabetically sorted keys for consistent output -func (rqs ReleaseQualifiers) PrettyPrint() (string, error) { - // use json marshaller to convert all structs to maps - json1, err := json.Marshal(rqs) - if err != nil { - return "", err - } - qualifiersMap := make(ReleaseQualifiers) - if err := json.Unmarshal(json1, &qualifiersMap); err != nil { - return "", err - } - genericMap := make(map[string]any) - for id, qualifier := range qualifiersMap { - // unset labels - qualifier.Labels = nil - // sort escalation slices - if qualifier.Notifications != nil { - if qualifier.Notifications.Jira != nil { - sort.Slice(qualifier.Notifications.Jira.Escalations, func(i, j int) bool { - return qualifier.Notifications.Jira.Escalations[i].Name < qualifier.Notifications.Jira.Escalations[j].Name - }) - } - if qualifier.Notifications.Slack != nil { - sort.Slice(qualifier.Notifications.Slack.Escalations, func(i, j int) bool { - return qualifier.Notifications.Slack.Escalations[i].Name < qualifier.Notifications.Slack.Escalations[j].Name - }) - } - } - genericMap[string(id)] = qualifier - } - // Marshal with pretty printing - jsonData, err := json.MarshalIndent(genericMap, "", " ") - if err != nil { - return "", err - } - return string(jsonData), nil -} - -// PrettyPrint returns a pretty-printed JSON representation of ReleaseQualifier -// with alphabetically sorted keys for consistent output -func (rq ReleaseQualifier) PrettyPrint() (string, error) { - // Marshal with pretty printing - jsonData, err := json.MarshalIndent(rq, "", " ") - if err != nil { - return "", err - } - return string(jsonData), nil -} diff --git a/pkg/releasequalifiers/prettyprint_test.go b/pkg/releasequalifiers/prettyprint_test.go deleted file mode 100644 index dd3b4fbf3..000000000 --- a/pkg/releasequalifiers/prettyprint_test.go +++ /dev/null @@ -1,814 +0,0 @@ -package releasequalifiers - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/jira" - "github.com/openshift/release-controller/pkg/releasequalifiers/notifications/slack" -) - -func TestReleaseQualifiers_PrettyPrint(t *testing.T) { - tests := []struct { - name string - qualifiers ReleaseQualifiers - expectedKeys []string - }{ - { - name: "pretty print with sorted keys and escalations", - qualifiers: ReleaseQualifiers{ - "zebra": { - Enabled: BoolPtr(true), - BadgeName: "ZEB", - Summary: "Zebra Component", - Description: "Detailed zebra description", - PayloadBadgeStatus: BadgeStatusOnSuccess, - }, - "alpha": { - Enabled: BoolPtr(false), - BadgeName: "ALP", - Summary: "Alpha Component", - Description: "Detailed alpha description", - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Component: "alpha-comp", - Summary: "Alpha jira summary", - Description: "Alpha jira description", - Assignee: "alpha@example.com", - Escalations: []jira.Escalation{ - {Name: "zulu", Failures: 1, Priority: "low"}, - {Name: "alpha", Failures: 2, Priority: "high"}, - }, - }, - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "zulu", Period: "1h", MinFailures: 1, Channel: "#zulu"}, - {Name: "alpha", Period: "2h", MinFailures: 2, Channel: "#alpha"}, - }, - }, - }, - }, - "beta": { - Enabled: BoolPtr(true), - BadgeName: "BET", - Summary: "Beta Component", - }, - }, - expectedKeys: []string{"alpha", "beta", "zebra"}, - }, - { - name: "empty qualifiers", - qualifiers: ReleaseQualifiers{}, - expectedKeys: []string{}, - }, - { - name: "single qualifier with all fields", - qualifiers: ReleaseQualifiers{ - "comprehensive": { - Enabled: BoolPtr(true), - BadgeName: "COMP", - Summary: "Comprehensive test", - Description: "Full description", - PayloadBadgeStatus: BadgeStatusNo, - Labels: []string{"label1", "label2"}, - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "PROJ", - Component: "comp", - Summary: "Jira summary", - Description: "Jira description", - Assignee: "user@test.com", - Escalations: []jira.Escalation{ - {Name: "esc1", Failures: 3, Priority: "High", Mentions: []string{"@team"}}, - {Name: "esc2", Failures: 5, Priority: "Critical"}, - }, - }, - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "slack1", Period: "1h", MinFailures: 2, Channel: "#ch1", Mentions: []string{"@oncall"}}, - {Name: "slack2", Period: "30m", MinFailures: 1, Channel: "#ch2"}, - }, - }, - }, - }, - }, - expectedKeys: []string{"comprehensive"}, - }, - { - name: "multiple qualifiers with varying complexity", - qualifiers: ReleaseQualifiers{ - "simple": { - Enabled: BoolPtr(false), - BadgeName: "SIM", - }, - "with-labels": { - Enabled: BoolPtr(true), - BadgeName: "LAB", - Labels: []string{"critical", "urgent"}, - }, - "with-payload": { - Enabled: BoolPtr(true), - BadgeName: "PAY", - PayloadBadgeStatus: BadgeStatusOnFailure, - }, - }, - expectedKeys: []string{"simple", "with-labels", "with-payload"}, - }, - { - name: "qualifiers with only notifications", - qualifiers: ReleaseQualifiers{ - "jira-only": { - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - }, - }, - }, - "slack-only": { - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "test", Period: "1h", MinFailures: 1, Channel: "#test"}, - }, - }, - }, - }, - }, - expectedKeys: []string{"jira-only", "slack-only"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prettyJSON, err := tt.qualifiers.PrettyPrint() - if err != nil { - t.Fatalf("Error pretty printing: %v", err) - } - - // Verify that the JSON is valid - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(prettyJSON), &parsed); err != nil { - t.Errorf("Generated JSON is not valid: %v", err) - } - - // Verify that all expected keys are present - for _, key := range tt.expectedKeys { - if _, exists := parsed[key]; !exists { - t.Errorf("Expected key '%s' not found in parsed JSON", key) - } - } - - // Verify JSON is properly indented if not empty - if len(tt.expectedKeys) > 0 { - if !strings.Contains(prettyJSON, "\n") { - t.Error("Expected pretty-printed JSON with newlines") - } - } - - // Skip detailed validation for empty qualifiers - if len(tt.expectedKeys) == 0 { - return - } - - // Perform specific validations based on test case - switch tt.name { - case "pretty print with sorted keys and escalations": - // Verify that keys are sorted alphabetically - if !strings.Contains(prettyJSON, `"alpha":`) { - t.Error("Expected 'alpha' key to be present") - } - if !strings.Contains(prettyJSON, `"beta":`) { - t.Error("Expected 'beta' key to be present") - } - if !strings.Contains(prettyJSON, `"zebra":`) { - t.Error("Expected 'zebra' key to be present") - } - - // Find the positions of the keys - alphaPos := strings.Index(prettyJSON, `"alpha":`) - betaPos := strings.Index(prettyJSON, `"beta":`) - zebraPos := strings.Index(prettyJSON, `"zebra":`) - - // Verify alphabetical order - if alphaPos >= betaPos || betaPos >= zebraPos { - t.Errorf("Keys are not in alphabetical order. Positions: alpha=%d, beta=%d, zebra=%d", alphaPos, betaPos, zebraPos) - } - - // Verify that the alpha qualifier has notifications with sorted escalations - alphaQualifier, ok := parsed["alpha"].(map[string]interface{}) - if !ok { - t.Error("Expected alpha qualifier to be a map") - return - } - - // Verify alpha has description and other fields - if desc, ok := alphaQualifier["description"].(string); !ok || desc == "" { - t.Error("Expected alpha to have description field") - } - - n, ok := alphaQualifier["notifications"].(map[string]interface{}) - if !ok { - t.Error("Expected notifications to be a map") - return - } - - // Check Jira escalations are sorted by name and have all fields - if j, ok := n["jira"].(map[string]interface{}); ok { - // Check Jira fields - if _, ok := j["component"].(string); !ok { - t.Error("Expected component field in Jira") - } - if _, ok := j["summary"].(string); !ok { - t.Error("Expected summary field in Jira") - } - if _, ok := j["description"].(string); !ok { - t.Error("Expected description field in Jira") - } - if _, ok := j["assignee"].(string); !ok { - t.Error("Expected assignee field in Jira") - } - - if escalations, ok := j["escalations"].([]interface{}); ok { - if len(escalations) >= 2 { - firstEsc := escalations[0].(map[string]interface{}) - secondEsc := escalations[1].(map[string]interface{}) - - if firstEsc["name"] != "alpha" || secondEsc["name"] != "zulu" { - t.Errorf("Expected escalations to be sorted by name. Got: %v, %v", firstEsc["name"], secondEsc["name"]) - } - } - } - } - - // Check Slack escalations are sorted by name - if s, ok := n["slack"].(map[string]interface{}); ok { - if escalations, ok := s["escalations"].([]interface{}); ok { - if len(escalations) >= 2 { - firstEsc := escalations[0].(map[string]interface{}) - secondEsc := escalations[1].(map[string]interface{}) - - if firstEsc["name"] != "alpha" || secondEsc["name"] != "zulu" { - t.Errorf("Expected escalations to be sorted by name. Got: %v, %v", firstEsc["name"], secondEsc["name"]) - } - } - } - } - - // Verify zebra has badge and description - zebraQualifier, ok := parsed["zebra"].(map[string]interface{}) - if !ok { - t.Error("Expected zebra qualifier to be a map") - return - } - if badge, ok := zebraQualifier["payloadBadgeStatus"].(string); !ok || badge != string(BadgeStatusOnSuccess) { - t.Error("Expected zebra to have payloadBadgeStatus field") - } - if desc, ok := zebraQualifier["description"].(string); !ok || desc == "" { - t.Error("Expected zebra to have description field") - } - - case "single qualifier with all fields": - comp, ok := parsed["comprehensive"].(map[string]interface{}) - if !ok { - t.Fatal("Expected comprehensive qualifier to be a map") - } - - // Verify all fields are present (except labels, which is not included by formatQualifierForJSON) - if _, ok := comp["enabled"]; !ok { - t.Error("Expected enabled field") - } - if _, ok := comp["badgeName"]; !ok { - t.Error("Expected badgeName field") - } - if _, ok := comp["summary"]; !ok { - t.Error("Expected summary field") - } - if _, ok := comp["description"]; !ok { - t.Error("Expected description field") - } - if badge, ok := comp["payloadBadgeStatus"].(string); !ok || badge != string(BadgeStatusNo) { - t.Error("Expected payloadBadgeStatus field with 'No' value") - } - // Note: labels field is not included in formatQualifierForJSON output - - // Verify notifications with both Jira and Slack escalations - n, ok := comp["notifications"].(map[string]interface{}) - if !ok { - t.Fatal("Expected notifications to be a map") - } - - if _, ok := n["jira"]; !ok { - t.Error("Expected jira notifications") - } - if _, ok := n["slack"]; !ok { - t.Error("Expected slack notifications") - } - - case "multiple qualifiers with varying complexity": - // Verify simple qualifier - if simple, ok := parsed["simple"].(map[string]interface{}); ok { - if enabled, ok := simple["enabled"].(bool); !ok || enabled { - t.Error("Expected simple qualifier to have enabled=false") - } - } - - // Verify with-labels qualifier exists (labels not included in formatQualifierForJSON) - if _, ok := parsed["with-labels"]; !ok { - t.Error("Expected with-labels qualifier to be present") - } - - // Verify with-payload qualifier has badge - if withPayload, ok := parsed["with-payload"].(map[string]interface{}); ok { - if badge, ok := withPayload["payloadBadgeStatus"].(string); !ok || badge != string(BadgeStatusOnFailure) { - t.Error("Expected with-payload qualifier to have payloadBadgeStatus=OnFailure") - } - } - - // Verify alphabetical ordering: simple, with-labels, with-payload - simplePos := strings.Index(prettyJSON, `"simple":`) - labelsPos := strings.Index(prettyJSON, `"with-labels":`) - payloadPos := strings.Index(prettyJSON, `"with-payload":`) - - if simplePos >= labelsPos || labelsPos >= payloadPos { - t.Errorf("Keys not in alphabetical order. Positions: simple=%d, with-labels=%d, with-payload=%d", simplePos, labelsPos, payloadPos) - } - - case "qualifiers with only notifications": - // Verify jira-only has jira notifications - if jiraOnly, ok := parsed["jira-only"].(map[string]interface{}); ok { - if n, ok := jiraOnly["notifications"].(map[string]interface{}); ok { - if _, ok := n["jira"]; !ok { - t.Error("Expected jira-only to have jira notifications") - } - if _, ok := n["slack"]; ok { - t.Error("Did not expect jira-only to have slack notifications") - } - } - } - - // Verify slack-only has Slack notifications - if slackOnly, ok := parsed["slack-only"].(map[string]interface{}); ok { - if n, ok := slackOnly["notifications"].(map[string]interface{}); ok { - if _, ok := n["slack"]; !ok { - t.Error("Expected slack-only to have slack notifications") - } - if _, ok := n["jira"]; ok { - t.Error("Did not expect slack-only to have jira notifications") - } - } - } - - // Verify alphabetical ordering - jiraPos := strings.Index(prettyJSON, `"jira-only":`) - slackPos := strings.Index(prettyJSON, `"slack-only":`) - if jiraPos >= slackPos { - t.Errorf("Keys not in alphabetical order. Positions: jira-only=%d, slack-only=%d", jiraPos, slackPos) - } - } - }) - } -} - -func TestReleaseQualifier_PrettyPrint(t *testing.T) { - tests := []struct { - name string - qualifier ReleaseQualifier - wantErr bool - validate func(t *testing.T, result string) - }{ - { - name: "minimal qualifier with only enabled", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - if enabled, ok := parsed["enabled"].(bool); !ok || !enabled { - t.Error("Expected enabled to be true") - } - }, - }, - { - name: "full qualifier with all fields", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "TEST", - Summary: "Test Summary", - Description: "Test Description", - PayloadBadgeStatus: "payload-test", - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "PROJ", - Component: "comp", - Summary: "Jira Summary", - Description: "Jira Description", - Assignee: "user@example.com", - Escalations: []jira.Escalation{ - { - Name: "critical", - Failures: 5, - Priority: "Critical", - Mentions: []string{"@team"}, - }, - }, - }, - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - { - Name: "alert", - Period: "1h", - MinFailures: 3, - Channel: "#alerts", - Mentions: []string{"@oncall"}, - }, - }, - }, - }, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - // Verify all top-level fields are present - if _, ok := parsed["enabled"]; !ok { - t.Error("Expected enabled field") - } - if _, ok := parsed["badgeName"]; !ok { - t.Error("Expected badgeName field") - } - if _, ok := parsed["summary"]; !ok { - t.Error("Expected summary field") - } - if _, ok := parsed["description"]; !ok { - t.Error("Expected description field") - } - if _, ok := parsed["payloadBadgeStatus"]; !ok { - t.Error("Expected payloadBadgeStatus field") - } - if _, ok := parsed["notifications"]; !ok { - t.Error("Expected notifications field") - } - - // Verify the JSON is properly formatted (has indentation) - if !strings.Contains(result, "\n") { - t.Error("Expected pretty-printed JSON with newlines") - } - }, - }, - { - name: "qualifier with jira notifications only", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(false), - BadgeName: "JIRA-ONLY", - Summary: "Jira Only Test", - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Component: "component-a", - Summary: "Test Issue", - Escalations: []jira.Escalation{ - {Name: "low", Failures: 1, Priority: "Low"}, - {Name: "high", Failures: 10, Priority: "High"}, - }, - }, - }, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - n, ok := parsed["notifications"].(map[string]interface{}) - if !ok { - t.Fatal("Expected notifications to be a map") - } - - if _, ok := n["jira"]; !ok { - t.Error("Expected jira notifications") - } - if _, ok := n["slack"]; ok { - t.Error("Did not expect slack notifications") - } - }, - }, - { - name: "qualifier with slack notifications only", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "SLACK-ONLY", - Summary: "Slack Only Test", - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - {Name: "warn", Period: "30m", MinFailures: 2, Channel: "#warnings"}, - {Name: "critical", Period: "5m", MinFailures: 5, Channel: "#critical"}, - }, - }, - }, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - n, ok := parsed["notifications"].(map[string]interface{}) - if !ok { - t.Fatal("Expected notifications to be a map") - } - - if _, ok := n["slack"]; !ok { - t.Error("Expected slack notifications") - } - if _, ok := n["jira"]; ok { - t.Error("Did not expect jira notifications") - } - }, - }, - { - name: "qualifier with empty notifications", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "EMPTY", - Notifications: ¬ifications.Notifications{}, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - // Empty notifications should still be in the output - if _, ok := parsed["notifications"]; !ok { - t.Error("Expected notifications field even if empty") - } - }, - }, - { - name: "qualifier with escalations with mentions", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "MENTIONS", - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "TEST", - Escalations: []jira.Escalation{ - { - Name: "with-mentions", - Failures: 3, - Priority: "Medium", - Mentions: []string{"@team-a", "@team-b"}, - }, - }, - }, - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - { - Name: "with-mentions", - Period: "1h", - MinFailures: 2, - Channel: "#test", - Mentions: []string{"@oncall", "@manager"}, - }, - }, - }, - }, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - n, ok := parsed["notifications"].(map[string]interface{}) - if !ok { - t.Fatal("Expected notifications to be a map") - } - - // Check Jira mentions - if jiraMap, ok := n["jira"].(map[string]interface{}); ok { - if escalations, ok := jiraMap["escalations"].([]interface{}); ok && len(escalations) > 0 { - esc := escalations[0].(map[string]interface{}) - if mentions, ok := esc["mentions"].([]interface{}); !ok || len(mentions) != 2 { - t.Error("Expected Jira escalation to have 2 mentions") - } - } - } - - // Check Slack mentions - if slackMap, ok := n["slack"].(map[string]interface{}); ok { - if escalations, ok := slackMap["escalations"].([]interface{}); ok && len(escalations) > 0 { - esc := escalations[0].(map[string]interface{}) - if mentions, ok := esc["mentions"].([]interface{}); !ok || len(mentions) != 2 { - t.Error("Expected Slack escalation to have 2 mentions") - } - } - } - }, - }, - { - name: "qualifier with description field", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "DESC-TEST", - Summary: "Summary text", - Description: "This is a detailed description for the qualifier", - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - if desc, ok := parsed["description"].(string); !ok || desc != "This is a detailed description for the qualifier" { - t.Errorf("Expected description field with correct value, got: %v", parsed["description"]) - } - }, - }, - { - name: "qualifier with payload badge", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "BADGE-TEST", - PayloadBadgeStatus: BadgeStatusYes, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - if badge, ok := parsed["payloadBadgeStatus"].(string); !ok || badge != string(BadgeStatusYes) { - t.Errorf("Expected payloadBadgeStatus field with value 'Yes', got: %v", parsed["payloadBadgeStatus"]) - } - }, - }, - { - name: "qualifier with all jira fields", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "FULL-JIRA", - Notifications: ¬ifications.Notifications{ - Jira: &jira.Notification{ - Project: "MYPROJ", - Component: "my-component", - Summary: "Issue summary", - Description: "Issue description", - Assignee: "user@example.com", - Escalations: []jira.Escalation{ - { - Name: "no-mentions", - Failures: 5, - Priority: "High", - }, - }, - }, - }, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - n, ok := parsed["notifications"].(map[string]interface{}) - if !ok { - t.Fatal("Expected notifications to be a map") - } - - jiraMap, ok := n["jira"].(map[string]interface{}) - if !ok { - t.Fatal("Expected jira to be a map") - } - - // Check all Jira fields - if project, ok := jiraMap["project"].(string); !ok || project != "MYPROJ" { - t.Errorf("Expected project field, got: %v", jiraMap["project"]) - } - if component, ok := jiraMap["component"].(string); !ok || component != "my-component" { - t.Errorf("Expected component field, got: %v", jiraMap["component"]) - } - if summary, ok := jiraMap["summary"].(string); !ok || summary != "Issue summary" { - t.Errorf("Expected summary field, got: %v", jiraMap["summary"]) - } - if description, ok := jiraMap["description"].(string); !ok || description != "Issue description" { - t.Errorf("Expected description field, got: %v", jiraMap["description"]) - } - if assignee, ok := jiraMap["assignee"].(string); !ok || assignee != "user@example.com" { - t.Errorf("Expected assignee field, got: %v", jiraMap["assignee"]) - } - - // Check escalations without mentions - if escalations, ok := jiraMap["escalations"].([]interface{}); ok && len(escalations) > 0 { - esc := escalations[0].(map[string]interface{}) - if _, ok := esc["mentions"]; ok { - t.Error("Did not expect mentions field in escalation without mentions") - } - if name, ok := esc["name"].(string); !ok || name != "no-mentions" { - t.Errorf("Expected name field, got: %v", esc["name"]) - } - } - }, - }, - { - name: "qualifier with labels", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "LABELS-TEST", - Labels: []string{"bug", "priority-high", "team-a"}, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - if labels, ok := parsed["labels"].([]interface{}); !ok || len(labels) != 3 { - t.Errorf("Expected labels field with 3 items, got: %v", parsed["labels"]) - } - }, - }, - { - name: "qualifier with slack escalations without mentions", - qualifier: ReleaseQualifier{ - Enabled: BoolPtr(true), - BadgeName: "SLACK-NO-MENTIONS", - Notifications: ¬ifications.Notifications{ - Slack: &slack.Notification{ - Escalations: []slack.Escalation{ - { - Name: "no-mentions-escalation", - Period: "2h", - MinFailures: 10, - Channel: "#test-channel", - }, - }, - }, - }, - }, - wantErr: false, - validate: func(t *testing.T, result string) { - var parsed map[string]interface{} - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Errorf("Failed to parse JSON: %v", err) - } - - n, ok := parsed["notifications"].(map[string]interface{}) - if !ok { - t.Fatal("Expected notifications to be a map") - } - - slackMap, ok := n["slack"].(map[string]interface{}) - if !ok { - t.Fatal("Expected slack to be a map") - } - - escalations, ok := slackMap["escalations"].([]interface{}) - if !ok || len(escalations) == 0 { - t.Fatal("Expected escalations array") - } - - esc := escalations[0].(map[string]interface{}) - if _, ok := esc["mentions"]; ok { - t.Error("Did not expect mentions field in escalation without mentions") - } - if name, ok := esc["name"].(string); !ok || name != "no-mentions-escalation" { - t.Errorf("Expected name field, got: %v", esc["name"]) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := tt.qualifier.PrettyPrint() - if (err != nil) != tt.wantErr { - t.Errorf("ReleaseQualifier.PrettyPrint() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !tt.wantErr && tt.validate != nil { - tt.validate(t, result) - } - }) - } -} diff --git a/pkg/releasequalifiers/types.go b/pkg/releasequalifiers/types.go index 3306a04a9..f7bdb5cbe 100644 --- a/pkg/releasequalifiers/types.go +++ b/pkg/releasequalifiers/types.go @@ -22,6 +22,10 @@ type ReleaseQualifier struct { // Using a pointer to distinguish between "not set" and "set to false" Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // Approval indicates whether this qualifier is earned via Team Approval + // Using a pointer to distinguish between "not set" and "set to false" + Approval *bool `json:"approval,omitempty" yaml:"approval,omitempty"` + // BadgeName short name displayed, as UI badges, in job level summaries BadgeName string `json:"badgeName,omitempty" yaml:"badgeName,omitempty"` @@ -34,8 +38,8 @@ type ReleaseQualifier struct { // PayloadBadgeStatus indicates if/when the qualifier's BadgeName should be displayed at the ReleasePayload level PayloadBadgeStatus BadgeStatus `json:"payloadBadgeStatus,omitempty" yaml:"payloadBadgeStatus,omitempty"` - // Labels the labels to apply when qualifying jobs fail - Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"` + // FailureLabels labels to apply when qualifying jobs fail + FailureLabels []string `json:"failureLabels,omitempty" yaml:"failureLabels,omitempty"` // Notifications contains configuration for notification channels Notifications *notifications.Notifications `json:"notifications,omitempty" yaml:"notifications,omitempty"` diff --git a/pkg/releasequalifiers/zz_generated.deepcopy.go b/pkg/releasequalifiers/zz_generated.deepcopy.go index b696988d0..4c6f9c18e 100644 --- a/pkg/releasequalifiers/zz_generated.deepcopy.go +++ b/pkg/releasequalifiers/zz_generated.deepcopy.go @@ -17,8 +17,13 @@ func (in *ReleaseQualifier) DeepCopyInto(out *ReleaseQualifier) { *out = new(bool) **out = **in } - if in.Labels != nil { - in, out := &in.Labels, &out.Labels + if in.Approval != nil { + in, out := &in.Approval, &out.Approval + *out = new(bool) + **out = **in + } + if in.FailureLabels != nil { + in, out := &in.FailureLabels, &out.FailureLabels *out = make([]string, len(*in)) copy(*out, *in) }