diff --git a/api/v1alpha1/commitstatus_types.go b/api/v1alpha1/commitstatus_types.go index df99da121..2f9ba5d96 100644 --- a/api/v1alpha1/commitstatus_types.go +++ b/api/v1alpha1/commitstatus_types.go @@ -49,7 +49,7 @@ type CommitStatusSpec struct { Phase CommitStatusPhase `json:"phase"` // pending, success, failure // (Github: error, failure, pending, success) // (Gitlab: pending, running, success, failed, canceled) - // (Bitbucket: INPROGRESS, STOPPED, SUCCESSFUL, FAILED) + // (Bitbucket Cloud: INPROGRESS, STOPPED, SUCCESSFUL, FAILED) // Url is a URL that the user can follow to see more details about the status // +kubebuilder:validation:Optional diff --git a/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go index 671a9c37c..b03900270 100644 --- a/api/v1alpha1/common_types.go +++ b/api/v1alpha1/common_types.go @@ -28,6 +28,9 @@ type GitLab struct { Domain string `json:"domain,omitempty"` } +// BitbucketCloud is a Bitbucket Cloud SCM provider configuration. It is used to configure the Bitbucket Cloud settings. +type BitbucketCloud struct{} + // Forgejo is a Forgejo SCM provider configuration. It is used to configure the Forgejo settings. type Forgejo struct { // Domain is the Forgejo domain, such as "codeberg.org" or "forgejo.mycompany.com". @@ -91,6 +94,20 @@ type ForgejoRepo struct { Name string `json:"name"` } +// BitbucketCloudRepo is a repository in Bitbucket Cloud, identified by its owner and name. +type BitbucketCloudRepo struct { + // Owner is the owner of the repository (can be a user or workspace). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_-]+$" + Owner string `json:"owner"` + // Name is the name of the repository. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=62 + // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_.-]+$" + Name string `json:"name"` +} + // FakeRepo is a placeholder for a repository in the fake SCM provider, used for testing purposes. type FakeRepo struct { // Owner is the owner of the repository. diff --git a/api/v1alpha1/gitrepository_types.go b/api/v1alpha1/gitrepository_types.go index 3c65b7d99..0fcff3811 100644 --- a/api/v1alpha1/gitrepository_types.go +++ b/api/v1alpha1/gitrepository_types.go @@ -24,12 +24,13 @@ import ( // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. // GitRepositorySpec defines the desired state of GitRepository -// +kubebuilder:validation:ExactlyOneOf=github;gitlab;forgejo;fake +// +kubebuilder:validation:ExactlyOneOf=github;gitlab;forgejo;bitbucketCloud;fake type GitRepositorySpec struct { - GitHub *GitHubRepo `json:"github,omitempty"` - GitLab *GitLabRepo `json:"gitlab,omitempty"` - Forgejo *ForgejoRepo `json:"forgejo,omitempty"` - Fake *FakeRepo `json:"fake,omitempty"` + GitHub *GitHubRepo `json:"github,omitempty"` + GitLab *GitLabRepo `json:"gitlab,omitempty"` + Forgejo *ForgejoRepo `json:"forgejo,omitempty"` + BitbucketCloud *BitbucketCloudRepo `json:"bitbucketCloud,omitempty"` + Fake *FakeRepo `json:"fake,omitempty"` // +kubebuilder:validation:Required ScmProviderRef ScmProviderObjectReference `json:"scmProviderRef"` } diff --git a/api/v1alpha1/scmprovider_types.go b/api/v1alpha1/scmprovider_types.go index 7316477a0..eb552f142 100644 --- a/api/v1alpha1/scmprovider_types.go +++ b/api/v1alpha1/scmprovider_types.go @@ -31,7 +31,7 @@ import ( var ScmProviderKind = reflect.TypeOf(ScmProvider{}).Name() // ScmProviderSpec defines the desired state of ScmProvider -// +kubebuilder:validation:ExactlyOneOf=github;gitlab;forgejo;fake +// +kubebuilder:validation:ExactlyOneOf=github;gitlab;forgejo;bitbucketCloud;fake type ScmProviderSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file @@ -48,6 +48,9 @@ type ScmProviderSpec struct { // Forgejo required configuration for Forgejo as the SCM provider Forgejo *Forgejo `json:"forgejo,omitempty"` + // BitbucketCloud required configuration for Bitbucket Cloud as the SCM provider + BitbucketCloud *BitbucketCloud `json:"bitbucketCloud,omitempty"` + // Fake required configuration for Fake as the SCM provider Fake *Fake `json:"fake,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index e8a4d4ef1..c3f0f7e86 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -171,6 +171,36 @@ func (in *ArgoCDCommitStatusStatus) DeepCopy() *ArgoCDCommitStatusStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitbucketCloud) DeepCopyInto(out *BitbucketCloud) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitbucketCloud. +func (in *BitbucketCloud) DeepCopy() *BitbucketCloud { + if in == nil { + return nil + } + out := new(BitbucketCloud) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitbucketCloudRepo) DeepCopyInto(out *BitbucketCloudRepo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitbucketCloudRepo. +func (in *BitbucketCloudRepo) DeepCopy() *BitbucketCloudRepo { + if in == nil { + return nil + } + out := new(BitbucketCloudRepo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Bucket) DeepCopyInto(out *Bucket) { *out = *in @@ -1027,6 +1057,11 @@ func (in *GitRepositorySpec) DeepCopyInto(out *GitRepositorySpec) { *out = new(ForgejoRepo) **out = **in } + if in.BitbucketCloud != nil { + in, out := &in.BitbucketCloud, &out.BitbucketCloud + *out = new(BitbucketCloudRepo) + **out = **in + } if in.Fake != nil { in, out := &in.Fake, &out.Fake *out = new(FakeRepo) @@ -1687,6 +1722,11 @@ func (in *ScmProviderSpec) DeepCopyInto(out *ScmProviderSpec) { *out = new(Forgejo) **out = **in } + if in.BitbucketCloud != nil { + in, out := &in.BitbucketCloud, &out.BitbucketCloud + *out = new(BitbucketCloud) + **out = **in + } if in.Fake != nil { in, out := &in.Fake, &out.Fake *out = new(Fake) diff --git a/config/crd/bases/promoter.argoproj.io_clusterscmproviders.yaml b/config/crd/bases/promoter.argoproj.io_clusterscmproviders.yaml index 295237b57..ea7b98d7a 100644 --- a/config/crd/bases/promoter.argoproj.io_clusterscmproviders.yaml +++ b/config/crd/bases/promoter.argoproj.io_clusterscmproviders.yaml @@ -44,6 +44,10 @@ spec: spec: description: ScmProviderSpec defines the desired state of ScmProvider properties: + bitbucketCloud: + description: BitbucketCloud required configuration for Bitbucket Cloud + as the SCM provider + type: object fake: description: Fake required configuration for Fake as the SCM provider properties: @@ -117,9 +121,9 @@ spec: x-kubernetes-map-type: atomic type: object x-kubernetes-validations: - - message: exactly one of the fields in [github gitlab forgejo fake] must - be set - rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.fake)].filter(x,x==true).size() + - message: exactly one of the fields in [github gitlab forgejo bitbucketCloud + fake] must be set + rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.bitbucketCloud),has(self.fake)].filter(x,x==true).size() == 1' status: description: ScmProviderStatus defines the observed state of ScmProvider diff --git a/config/crd/bases/promoter.argoproj.io_gitrepositories.yaml b/config/crd/bases/promoter.argoproj.io_gitrepositories.yaml index 97735fec5..dbf5edd9a 100644 --- a/config/crd/bases/promoter.argoproj.io_gitrepositories.yaml +++ b/config/crd/bases/promoter.argoproj.io_gitrepositories.yaml @@ -46,6 +46,25 @@ spec: spec: description: GitRepositorySpec defines the desired state of GitRepository properties: + bitbucketCloud: + description: BitbucketCloudRepo is a repository in Bitbucket Cloud, + identified by its owner and name. + properties: + name: + description: Name is the name of the repository. + maxLength: 62 + pattern: ^[a-zA-Z0-9_.-]+$ + type: string + owner: + description: Owner is the owner of the repository (can be a user + or workspace). + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + required: + - name + - owner + type: object fake: description: FakeRepo is a placeholder for a repository in the fake SCM provider, used for testing purposes. @@ -136,9 +155,9 @@ spec: - scmProviderRef type: object x-kubernetes-validations: - - message: exactly one of the fields in [github gitlab forgejo fake] must - be set - rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.fake)].filter(x,x==true).size() + - message: exactly one of the fields in [github gitlab forgejo bitbucketCloud + fake] must be set + rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.bitbucketCloud),has(self.fake)].filter(x,x==true).size() == 1' status: description: GitRepositoryStatus defines the observed state of GitRepository diff --git a/config/crd/bases/promoter.argoproj.io_scmproviders.yaml b/config/crd/bases/promoter.argoproj.io_scmproviders.yaml index f84b7224f..dfcd44ccb 100644 --- a/config/crd/bases/promoter.argoproj.io_scmproviders.yaml +++ b/config/crd/bases/promoter.argoproj.io_scmproviders.yaml @@ -43,6 +43,10 @@ spec: spec: description: ScmProviderSpec defines the desired state of ScmProvider properties: + bitbucketCloud: + description: BitbucketCloud required configuration for Bitbucket Cloud + as the SCM provider + type: object fake: description: Fake required configuration for Fake as the SCM provider properties: @@ -116,9 +120,9 @@ spec: x-kubernetes-map-type: atomic type: object x-kubernetes-validations: - - message: exactly one of the fields in [github gitlab forgejo fake] must - be set - rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.fake)].filter(x,x==true).size() + - message: exactly one of the fields in [github gitlab forgejo bitbucketCloud + fake] must be set + rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.bitbucketCloud),has(self.fake)].filter(x,x==true).size() == 1' status: description: ScmProviderStatus defines the observed state of ScmProvider diff --git a/dist/install.yaml b/dist/install.yaml index b1b4ffc45..b583222ab 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -1483,6 +1483,10 @@ spec: spec: description: ScmProviderSpec defines the desired state of ScmProvider properties: + bitbucketCloud: + description: BitbucketCloud required configuration for Bitbucket Cloud + as the SCM provider + type: object fake: description: Fake required configuration for Fake as the SCM provider properties: @@ -1556,9 +1560,9 @@ spec: x-kubernetes-map-type: atomic type: object x-kubernetes-validations: - - message: exactly one of the fields in [github gitlab forgejo fake] must - be set - rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.fake)].filter(x,x==true).size() + - message: exactly one of the fields in [github gitlab forgejo bitbucketCloud + fake] must be set + rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.bitbucketCloud),has(self.fake)].filter(x,x==true).size() == 1' status: description: ScmProviderStatus defines the observed state of ScmProvider @@ -3178,6 +3182,25 @@ spec: spec: description: GitRepositorySpec defines the desired state of GitRepository properties: + bitbucketCloud: + description: BitbucketCloudRepo is a repository in Bitbucket Cloud, + identified by its owner and name. + properties: + name: + description: Name is the name of the repository. + maxLength: 62 + pattern: ^[a-zA-Z0-9_.-]+$ + type: string + owner: + description: Owner is the owner of the repository (can be a user + or workspace). + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + required: + - name + - owner + type: object fake: description: FakeRepo is a placeholder for a repository in the fake SCM provider, used for testing purposes. @@ -3268,9 +3291,9 @@ spec: - scmProviderRef type: object x-kubernetes-validations: - - message: exactly one of the fields in [github gitlab forgejo fake] must - be set - rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.fake)].filter(x,x==true).size() + - message: exactly one of the fields in [github gitlab forgejo bitbucketCloud + fake] must be set + rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.bitbucketCloud),has(self.fake)].filter(x,x==true).size() == 1' status: description: GitRepositoryStatus defines the observed state of GitRepository @@ -4977,6 +5000,10 @@ spec: spec: description: ScmProviderSpec defines the desired state of ScmProvider properties: + bitbucketCloud: + description: BitbucketCloud required configuration for Bitbucket Cloud + as the SCM provider + type: object fake: description: Fake required configuration for Fake as the SCM provider properties: @@ -5050,9 +5077,9 @@ spec: x-kubernetes-map-type: atomic type: object x-kubernetes-validations: - - message: exactly one of the fields in [github gitlab forgejo fake] must - be set - rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.fake)].filter(x,x==true).size() + - message: exactly one of the fields in [github gitlab forgejo bitbucketCloud + fake] must be set + rule: '[has(self.github),has(self.gitlab),has(self.forgejo),has(self.bitbucketCloud),has(self.fake)].filter(x,x==true).size() == 1' status: description: ScmProviderStatus defines the observed state of ScmProvider diff --git a/docs/getting-started.md b/docs/getting-started.md index 1f04d671e..2d8c64626 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ # Getting Started -This guide will help you get started installing and setting up the GitOps Promoter. We currently only support -GitHub, GitHub Enterprise, GitLab and Forgejo (including Codeberg) as the SCM providers. We would welcome any contributions to add support for other providers. +This guide will help you get started installing and setting up the GitOps Promoter. We currently support +GitHub, GitHub Enterprise, GitLab, Bitbucket Cloud, and Forgejo (including Codeberg) as the SCM providers. We would welcome any contributions to add support for other providers. ## Requirements @@ -23,7 +23,6 @@ kubectl apply -f https://github.com/argoproj-labs/gitops-promoter/releases/downl You will need to [create a GitHub App](https://docs.github.com/en/developers/apps/creating-a-github-app) and configure it to allow the GitOps Promoter to interact with your GitHub repository. - During the creation the GitHub App, you will need to configure the following settings: ### Permissions @@ -38,8 +37,8 @@ During the creation the GitHub App, you will need to configure the following set > [!NOTE] > We do support configuration of a GitHub App webhook that triggers PR creation upon Push. However, we do not configure -> the ingress to allow GitHub to reach the GitOps Promoter. You will need to configure the ingress to allow GitHub to reach -> the GitOps Promoter via the service promoter-webhook-receiver which listens on port `3333`. If you do not use webhooks +> the ingress to allow GitHub to reach the GitOps Promoter. You will need to configure the ingress to allow GitHub to reach +> the GitOps Promoter via the service promoter-webhook-receiver which listens on port `3333`. If you do not use webhooks > you might want to adjust the auto reconciliation interval to a lower value using these `promotionStrategyRequeueDuration` and > `changeTransferPolicyRequeueDuration` fields of the `ControllerConfiguration` resource. @@ -89,7 +88,7 @@ stringData: > [!NOTE] > This Secret will need to be installed to the same namespace that you plan on creating PromotionStrategy resources in. -We also need a GitRepository and ScmProvider, which are custom resources that represent a git repository and a provider. +We also need a GitRepository and ScmProvider, which are custom resources that represent a git repository and a provider. Here is an example of both resources: ```yaml @@ -119,11 +118,11 @@ spec: > [!IMPORTANT] > Make sure your staging branches (`environment/development-next`, `environment/staging-next`, etc.) are not auto-deleted > when PRs are merged. You can do this either by disabling auto-deletion of branches in the repository settings (in -> Settings > Automatically delete head branches) or by adding a branch protection rule for a matching pattern such as +> Settings > Automatically delete head branches) or by adding a branch protection rule for a matching pattern such as > `environment/*-next` (`/` characters are separators in GitHub's glob implementation, so `*-next` will not work). > [!NOTE] -> The GitRepository and ScmProvider also need to be installed to the same namespace that you plan on creating PromotionStrategy +> The GitRepository and ScmProvider also need to be installed to the same namespace that you plan on creating PromotionStrategy > resources in, and it also needs to be in the same namespace of the secret it references. ## GitLab Configuration @@ -140,7 +139,7 @@ stringData: token: ``` -We also need a GitRepository and ScmProvider, which is are custom resources that represents a git repository and a provider. +We also need a GitRepository and ScmProvider, which is are custom resources that represents a git repository and a provider. Here is an example of both resources: ```yaml @@ -220,9 +219,108 @@ spec: name: # The secret that contains the GitLab Access Token ``` +## Bitbucket Cloud Configuration + +To configure the GitOps Promoter with Bitbucket Cloud, you will need to create a repository access token with the appropriate permissions and configure the necessary resources to allow the promoter to interact with your repository. + +### Creating a Bitbucket Cloud Repository Access Token + +1. Navigate to your repository URL +2. Click on "Repository settings" in the sidebar +3. Navigate to "Access tokens" +4. Click "Create access token" +5. Give it a name (e.g., "GitOps Promoter") +6. Select the following permissions: + * **Repositories**: Read and Write + * **Pull requests**: Read and Write + +### Webhooks (Optional - but highly recommended) + > [!NOTE] -> The GitRepository and ScmProvider also need to be installed to the same namespace that you plan on creating PromotionStrategy resources in, and it also needs to be in the same namespace of the secret it references. +> We do support configuration of a Bitbucket Cloud webhook that triggers PR creation upon Push. However, we do not configure +> the ingress to allow Bitbucket Cloud to reach the GitOps Promoter. You will need to configure the ingress to allow Bitbucket Cloud to reach +> the GitOps Promoter via the service promoter-webhook-receiver which listens on port `3333`. If you do not use webhooks +> you might want to adjust the auto reconciliation interval to a lower value using these `promotionStrategyRequeueDuration` and +> `changeTransferPolicyRequeueDuration` fields of the `ControllerConfiguration` resource. + +To enable webhook support for automatic PR creation on push: + +1. Navigate to your repository URL +2. Click on "Repository settings" in the sidebar +3. Navigate to "Webhooks" +4. Click "Add webhook" +5. Configure the webhook: + * **Title**: GitOps Promoter + * **URL**: `https://argo-github-app-webhook.com/` # Replace with your domain + * **Triggers**: Select "Repository: Push" + +Here is an example Ingress configuration for the webhook receiver: +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: promoter-webhook-receiver + namespace: promoter-system + annotations: + # Add any necessary annotations for your ingress controller + # For example, if using nginx-ingress: + # nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + rules: + - host: argo-github-app-webhook.com # Replace with your domain + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: promoter-webhook-receiver + port: + number: 3333 +``` + +### Configuration + +This access token should be used in a secret as follows: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: +type: Opaque +stringData: + token: +``` + +We also need a GitRepository and ScmProvider, which are custom resources that represent a git repository and a provider. +Here is an example of both resources: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: ScmProvider +metadata: + name: +spec: + secretRef: + name: + bitbucketCloud: {} +--- +apiVersion: promoter.argoproj.io/v1alpha1 +kind: GitRepository +metadata: + name: +spec: + bitbucketCloud: + owner: + name: + scmProviderRef: + name: +``` + +> [!NOTE] +> The GitRepository and ScmProvider also need to be installed to the same namespace that you plan on creating PromotionStrategy resources in, and it also needs to be in the same namespace of the secret it references. ## Promotion Strategy diff --git a/go.mod b/go.mod index 9d6f1429a..3c51c382d 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-task/slim-sprig/v3 v3.0.0 github.com/google/go-github/v71 v71.0.0 + github.com/ktrysmt/go-bitbucket v0.9.87 github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 github.com/prometheus/client_golang v1.23.2 @@ -83,6 +84,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index 8d06f116d..47e481252 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ktrysmt/go-bitbucket v0.9.87 h1:eR7E4ndyKpO2+HdBwUlsC5K/40nEDpjMLEWsPLY97oQ= +github.com/ktrysmt/go-bitbucket v0.9.87/go.mod h1:slSdGm9Vh3L2ZOU1r7Fu2B9rPJvsflYgneRCoPA83eY= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -143,6 +145,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/controller/argocdcommitstatus_controller.go b/internal/controller/argocdcommitstatus_controller.go index 3ebbdb46d..ef008abec 100644 --- a/internal/controller/argocdcommitstatus_controller.go +++ b/internal/controller/argocdcommitstatus_controller.go @@ -45,6 +45,7 @@ import ( promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" "github.com/argoproj-labs/gitops-promoter/internal/git" "github.com/argoproj-labs/gitops-promoter/internal/scms" + bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" "github.com/argoproj-labs/gitops-promoter/internal/scms/github" @@ -737,6 +738,13 @@ func (r *ArgoCDCommitStatusReconciler) getGitAuthProvider(ctx context.Context, a return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create GitLab client: %w", err) } return gitlabClient, ps.Spec.RepositoryReference, nil + case scmProvider.GetSpec().BitbucketCloud != nil: + logger.V(4).Info("Creating Bitbucket Cloud git authentication provider") + bitbucketClient, err := bitbucket_cloud.NewBitbucketCloudGitAuthenticationProvider(scmProvider, secret) + if err != nil { + return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create Bitbucket Cloud client: %w", err) + } + return bitbucketClient, ps.Spec.RepositoryReference, nil case scmProvider.GetSpec().Forgejo != nil: logger.V(4).Info("Creating Forgejo git authentication provider") return forgejo.NewForgejoGitAuthenticationProvider(scmProvider, secret), ps.Spec.RepositoryReference, nil diff --git a/internal/controller/changetransferpolicy_controller.go b/internal/controller/changetransferpolicy_controller.go index 5bd8dae94..da68310d1 100644 --- a/internal/controller/changetransferpolicy_controller.go +++ b/internal/controller/changetransferpolicy_controller.go @@ -30,6 +30,7 @@ import ( "github.com/argoproj-labs/gitops-promoter/internal/git" "github.com/argoproj-labs/gitops-promoter/internal/scms" + bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" "github.com/argoproj-labs/gitops-promoter/internal/scms/github" @@ -530,6 +531,13 @@ func (r *ChangeTransferPolicyReconciler) getGitAuthProvider(ctx context.Context, case scmProvider.GetSpec().Forgejo != nil: logger.V(4).Info("Creating Forgejo git authentication provider") return forgejo.NewForgejoGitAuthenticationProvider(scmProvider, secret), nil + case scmProvider.GetSpec().BitbucketCloud != nil: + logger.V(4).Info("Creating Bitbucket Cloud git authentication provider") + provider, err := bitbucket_cloud.NewBitbucketCloudGitAuthenticationProvider(scmProvider, secret) + if err != nil { + return nil, fmt.Errorf("failed to create Bitbucket Cloud Auth Provider: %w", err) + } + return provider, nil default: return nil, errors.New("no supported git authentication provider found") } @@ -811,6 +819,8 @@ func (r *ChangeTransferPolicyReconciler) creatOrUpdatePullRequest(ctx context.Co prName = utils.GetPullRequestName(gitRepo.Spec.Forgejo.Owner, gitRepo.Spec.Forgejo.Name, ctp.Spec.ProposedBranch, ctp.Spec.ActiveBranch) case gitRepo.Spec.Fake != nil: prName = utils.GetPullRequestName(gitRepo.Spec.Fake.Owner, gitRepo.Spec.Fake.Name, ctp.Spec.ProposedBranch, ctp.Spec.ActiveBranch) + case gitRepo.Spec.BitbucketCloud != nil: + prName = utils.GetPullRequestName(gitRepo.Spec.BitbucketCloud.Owner, gitRepo.Spec.BitbucketCloud.Name, ctp.Spec.ProposedBranch, ctp.Spec.ActiveBranch) default: return nil, errors.New("unsupported git repository type") } diff --git a/internal/controller/commitstatus_controller.go b/internal/controller/commitstatus_controller.go index c032ac1bc..ce4f0a3c4 100644 --- a/internal/controller/commitstatus_controller.go +++ b/internal/controller/commitstatus_controller.go @@ -24,6 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/predicate" + bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" "github.com/argoproj-labs/gitops-promoter/internal/settings" @@ -163,6 +164,13 @@ func (r *CommitStatusReconciler) getCommitStatusProvider(ctx context.Context, co return nil, fmt.Errorf("failed to get GitLab provider for domain %q with secret %q: %w", scmProvider.GetSpec().GitLab.Domain, secret.Name, err) } return p, nil + case scmProvider.GetSpec().BitbucketCloud != nil: + var p *bitbucket_cloud.CommitStatus + p, err = bitbucket_cloud.NewBitbucketCloudCommitStatusProvider(r.Client, *secret, "") + if err != nil { + return nil, fmt.Errorf("failed to get Bitbucket Cloud provider with secret %q: %w", secret.Name, err) + } + return p, nil case scmProvider.GetSpec().Forgejo != nil: var p *forgejo.CommitStatus p, err = forgejo.NewForgejoCommitStatusProvider(r.Client, scmProvider, *secret) diff --git a/internal/controller/pullrequest_controller.go b/internal/controller/pullrequest_controller.go index 684c6b58a..27af75396 100644 --- a/internal/controller/pullrequest_controller.go +++ b/internal/controller/pullrequest_controller.go @@ -29,6 +29,7 @@ import ( promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" "github.com/argoproj-labs/gitops-promoter/internal/git" "github.com/argoproj-labs/gitops-promoter/internal/scms" + bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" "github.com/argoproj-labs/gitops-promoter/internal/scms/github" @@ -286,6 +287,8 @@ func (r *PullRequestReconciler) getPullRequestProvider(ctx context.Context, pr p return github.NewGithubPullRequestProvider(ctx, r.Client, scmProvider, *secret, gitRepository.Spec.GitHub.Owner) //nolint:wrapcheck case scmProvider.GetSpec().GitLab != nil: return gitlab.NewGitlabPullRequestProvider(r.Client, *secret, scmProvider.GetSpec().GitLab.Domain) //nolint:wrapcheck + case scmProvider.GetSpec().BitbucketCloud != nil: + return bitbucket_cloud.NewBitbucketCloudPullRequestProvider(r.Client, *secret) //nolint:wrapcheck case scmProvider.GetSpec().Forgejo != nil: return forgejo.NewForgejoPullRequestProvider(r.Client, *secret, scmProvider.GetSpec().Forgejo.Domain) //nolint:wrapcheck case scmProvider.GetSpec().Fake != nil: diff --git a/internal/controller/testdata/ClusterScmProvider.yaml b/internal/controller/testdata/ClusterScmProvider.yaml index dd3713001..4f422527b 100644 --- a/internal/controller/testdata/ClusterScmProvider.yaml +++ b/internal/controller/testdata/ClusterScmProvider.yaml @@ -7,7 +7,7 @@ spec: # Secret must be in the same namespace where the promoter is running name: example-cluster-scm-provider-secret - # You must specify either github, gitlab, or forgejo. Both are provided here as examples. + # You must specify either github, gitlab, forgejo, or bitbucketCloud. Multiple are provided here as examples. # If you do not need to specify any sub-fields, just set the field to {}. github: @@ -20,3 +20,5 @@ spec: forgejo: domain: forgejo.example.com + + bitbucketCloud: {} diff --git a/internal/controller/testdata/GitRepository.yaml b/internal/controller/testdata/GitRepository.yaml index 0f9088787..c804e804a 100644 --- a/internal/controller/testdata/GitRepository.yaml +++ b/internal/controller/testdata/GitRepository.yaml @@ -17,6 +17,10 @@ spec: name: owner: + bitbucketCloud: + owner: + name: + scmProviderRef: kind: ScmProvider name: example-scm-provider diff --git a/internal/controller/testdata/ScmProvider.yaml b/internal/controller/testdata/ScmProvider.yaml index 68c480c34..0db874979 100644 --- a/internal/controller/testdata/ScmProvider.yaml +++ b/internal/controller/testdata/ScmProvider.yaml @@ -6,7 +6,7 @@ spec: secretRef: name: example-scm-provider-secret - # You must specify either github, gitlab, or forgejo. Both are provided here as examples. + # You must specify either github, gitlab, forgejo, or bitbucketCloud. Multiple are provided here as examples. # If you do not need to specify any sub-fields, just set the field to {}. github: @@ -19,3 +19,5 @@ spec: forgejo: domain: + + bitbucketCloud: {} diff --git a/internal/scms/bitbucket_cloud/commit_status.go b/internal/scms/bitbucket_cloud/commit_status.go new file mode 100644 index 000000000..4d8cc617b --- /dev/null +++ b/internal/scms/bitbucket_cloud/commit_status.go @@ -0,0 +1,106 @@ +package bitbucket_cloud + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/ktrysmt/go-bitbucket" + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" + "github.com/argoproj-labs/gitops-promoter/internal/metrics" + "github.com/argoproj-labs/gitops-promoter/internal/scms" + "github.com/argoproj-labs/gitops-promoter/internal/utils" +) + +// Bitbucket Cloud Key field for commit status request max length is 40 +const maxKeyFieldLength = 40 + +// CommitStatus implements the scms.CommitStatusProvider interface for Bitbucket Cloud. +type CommitStatus struct { + client *bitbucket.Client + k8sClient client.Client +} + +var _ scms.CommitStatusProvider = &CommitStatus{} + +// NewBitbucketCloudCommitStatusProvider creates a new instance of CommitStatus for Bitbucket Cloud. +func NewBitbucketCloudCommitStatusProvider(k8sClient client.Client, secret v1.Secret, domain string) (*CommitStatus, error) { + client, err := GetClient(secret) + if err != nil { + return nil, err + } + + return &CommitStatus{client: client, k8sClient: k8sClient}, nil +} + +// Set sets the commit status for a given commit SHA in the specified repository. +func (cs *CommitStatus) Set(ctx context.Context, commitStatus *v1alpha1.CommitStatus) (*v1alpha1.CommitStatus, error) { + logger := log.FromContext(ctx) + logger.Info("Setting Commit Phase") + + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, cs.k8sClient, client.ObjectKey{ + Namespace: commitStatus.Namespace, + Name: commitStatus.Spec.RepositoryReference.Name, + }) + if err != nil { + return nil, fmt.Errorf("failed to get repo: %w", err) + } + + commitOptions := &bitbucket.CommitsOptions{ + Owner: repo.Spec.BitbucketCloud.Owner, + RepoSlug: repo.Spec.BitbucketCloud.Name, + Revision: commitStatus.Spec.Sha, + } + + commitUrl := commitStatus.Spec.Url + if commitUrl == "" { + commitUrl = createCommitURL(repo, commitStatus.Spec.Sha) + } + commitStatusOptions := &bitbucket.CommitStatusOptions{ + State: phaseToBuildState(commitStatus.Spec.Phase), + Key: utils.TruncateString(commitStatus.Spec.Name, maxKeyFieldLength), + Url: commitUrl, + Description: commitStatus.Spec.Description, + } + + start := time.Now() + result, err := cs.client.Repositories.Commits.CreateCommitStatus( + commitOptions, + commitStatusOptions, + ) + statusCode := parseErrorStatusCode(err, http.StatusCreated) + metrics.RecordSCMCall(repo, metrics.SCMAPICommitStatus, metrics.SCMOperationCreate, statusCode, time.Since(start), nil) + + if err != nil { + var unexpectedErr *bitbucket.UnexpectedResponseStatusError + if errors.As(err, &unexpectedErr) { + return nil, fmt.Errorf("failed to create status: %w", unexpectedErr.ErrorWithBody()) + } + return nil, fmt.Errorf("failed to create status: %w", err) + } + + logger.V(4).Info("bitbucket response status", "status", statusCode) + + // Parse the response + resultMap, ok := result.(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected response type from Bitbucket API: %T", result) + } + + // Extract state + state, ok := resultMap["state"].(string) + if !ok { + return nil, errors.New("state field missing or invalid type in Bitbucket API response") + } + + commitStatus.Status.Phase = buildStateToPhase(state) + commitStatus.Status.Sha = commitStatus.Spec.Sha + + return commitStatus, nil +} diff --git a/internal/scms/bitbucket_cloud/git_operations.go b/internal/scms/bitbucket_cloud/git_operations.go new file mode 100644 index 000000000..557f3d8fb --- /dev/null +++ b/internal/scms/bitbucket_cloud/git_operations.go @@ -0,0 +1,67 @@ +package bitbucket_cloud + +import ( + "context" + "fmt" + "net/url" + + "github.com/ktrysmt/go-bitbucket" + v1 "k8s.io/api/core/v1" + + "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" + "github.com/argoproj-labs/gitops-promoter/internal/scms" +) + +// GitAuthenticationProvider implements the scms.GitOperationsProvider interface for Bitbucket Cloud. +type GitAuthenticationProvider struct { + scmProvider v1alpha1.GenericScmProvider + secret *v1.Secret + client *bitbucket.Client +} + +var _ scms.GitOperationsProvider = &GitAuthenticationProvider{} + +// NewBitbucketCloudGitAuthenticationProvider creates a new instance of GitAuthenticationProvider for Bitbucket Cloud. +func NewBitbucketCloudGitAuthenticationProvider(scmProvider v1alpha1.GenericScmProvider, secret *v1.Secret) (*GitAuthenticationProvider, error) { + client, err := GetClient(*secret) + if err != nil { + return nil, fmt.Errorf("failed to create Bitbucket Cloud Client: %w", err) + } + + return &GitAuthenticationProvider{ + scmProvider: scmProvider, + secret: secret, + client: client, + }, nil +} + +// GetGitHttpsRepoUrl constructs the HTTPS URL for a Bitbucket Cloud repository based on the provided GitRepository object. +func (GitAuthenticationProvider) GetGitHttpsRepoUrl(repo v1alpha1.GitRepository) string { + repoUrl := fmt.Sprintf("%s/%s/%s.git", BitbucketBaseURL, repo.Spec.BitbucketCloud.Owner, repo.Spec.BitbucketCloud.Name) + if _, err := url.Parse(repoUrl); err != nil { + return "" + } + return repoUrl +} + +// GetToken retrieves the Bitbucket Cloud access token from the secret. +func (bb GitAuthenticationProvider) GetToken(ctx context.Context) (string, error) { + return string(bb.secret.Data["token"]), nil +} + +// GetUser returns a placeholder user for Bitbucket Cloud authentication. +func (GitAuthenticationProvider) GetUser(ctx context.Context) (string, error) { + return "x-token-auth", nil +} + +// GetClient creates a new Bitbucket Cloud client using the provided secret. +func GetClient(secret v1.Secret) (*bitbucket.Client, error) { + token := string(secret.Data["token"]) + if token == "" { + return nil, fmt.Errorf("secret %q is missing required data key 'token'", secret.Name) + } + + client := bitbucket.NewOAuthbearerToken(token) + + return client, nil +} diff --git a/internal/scms/bitbucket_cloud/pullrequest.go b/internal/scms/bitbucket_cloud/pullrequest.go new file mode 100644 index 000000000..dd5557a4d --- /dev/null +++ b/internal/scms/bitbucket_cloud/pullrequest.go @@ -0,0 +1,318 @@ +package bitbucket_cloud + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/ktrysmt/go-bitbucket" + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" + "github.com/argoproj-labs/gitops-promoter/internal/metrics" + "github.com/argoproj-labs/gitops-promoter/internal/scms" + "github.com/argoproj-labs/gitops-promoter/internal/utils" +) + +// PullRequest implements the scms.PullRequestProvider interface for Bitbucket Cloud. +type PullRequest struct { + client *bitbucket.Client + k8sClient client.Client +} + +var _ scms.PullRequestProvider = &PullRequest{} + +// NewBitbucketCloudPullRequestProvider creates a new instance of PullRequest for Bitbucket Cloud. +func NewBitbucketCloudPullRequestProvider(k8sClient client.Client, secret v1.Secret) (*PullRequest, error) { + client, err := GetClient(secret) + if err != nil { + return nil, err + } + + return &PullRequest{ + client: client, + k8sClient: k8sClient, + }, nil +} + +// Create creates a new pull request with the specified title, head, base, and description. +func (pr *PullRequest) Create(ctx context.Context, title, head, base, desc string, prObj v1alpha1.PullRequest) (string, error) { + logger := log.FromContext(ctx) + + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, pr.k8sClient, client.ObjectKey{ + Namespace: prObj.Namespace, + Name: prObj.Spec.RepositoryReference.Name, + }) + if err != nil { + return "", fmt.Errorf("failed to get GitRepository: %w", err) + } + + options := &bitbucket.PullRequestsOptions{ + Owner: repo.Spec.BitbucketCloud.Owner, + RepoSlug: repo.Spec.BitbucketCloud.Name, + SourceBranch: head, + DestinationBranch: base, + Title: title, + Description: desc, + CloseSourceBranch: false, + } + + start := time.Now() + resp, err := pr.client.Repositories.PullRequests.Create(options) + statusCode := parseErrorStatusCode(err, http.StatusCreated) + metrics.RecordSCMCall(repo, metrics.SCMAPIPullRequest, metrics.SCMOperationCreate, statusCode, time.Since(start), nil) + + if err != nil { + var unexpectedErr *bitbucket.UnexpectedResponseStatusError + if errors.As(err, &unexpectedErr) { + return "", fmt.Errorf("failed to create pull request: %w", unexpectedErr.ErrorWithBody()) + } + return "", fmt.Errorf("failed to create pull request: %w", err) + } + + // Extract pull request ID from response + respMap, ok := resp.(map[string]any) + if !ok { + return "", fmt.Errorf("unexpected response type from Bitbucket API: %T", resp) + } + + idValue, exists := respMap["id"] + if !exists { + return "", errors.New("pull request ID not found in Bitbucket API response") + } + + idFloat, ok := idValue.(float64) + if !ok { + return "", fmt.Errorf("pull request ID has unexpected type: %T (expected float64)", idValue) + } + + logger.V(4).Info("bitbucket response status", "status", statusCode) + logger.V(4).Info("created pull request", "id", int(idFloat)) + + return strconv.Itoa(int(idFloat)), nil +} + +// Update updates an existing pull request with the specified title and description. +func (pr *PullRequest) Update(ctx context.Context, title, description string, prObj v1alpha1.PullRequest) error { + logger := log.FromContext(ctx) + + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, pr.k8sClient, client.ObjectKey{ + Namespace: prObj.Namespace, + Name: prObj.Spec.RepositoryReference.Name, + }) + if err != nil { + return fmt.Errorf("failed to get repo: %w", err) + } + + options := &bitbucket.PullRequestsOptions{ + Owner: repo.Spec.BitbucketCloud.Owner, + RepoSlug: repo.Spec.BitbucketCloud.Name, + ID: prObj.Status.ID, + Title: title, + Description: description, + } + + start := time.Now() + _, err = pr.client.Repositories.PullRequests.Update(options) + statusCode := parseErrorStatusCode(err, http.StatusOK) + metrics.RecordSCMCall(repo, metrics.SCMAPIPullRequest, metrics.SCMOperationUpdate, statusCode, time.Since(start), nil) + + if err != nil { + var unexpectedErr *bitbucket.UnexpectedResponseStatusError + if errors.As(err, &unexpectedErr) { + return fmt.Errorf("failed to update pull request: %w", unexpectedErr.ErrorWithBody()) + } + return fmt.Errorf("failed to update pull request: %w", err) + } + + logger.V(4).Info("bitbucket response status", "status", statusCode) + logger.V(4).Info("updated pull request", "id", prObj.Status.ID) + + return nil +} + +// Close closes an existing pull request. +func (pr *PullRequest) Close(ctx context.Context, prObj v1alpha1.PullRequest) error { + logger := log.FromContext(ctx) + + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, pr.k8sClient, client.ObjectKey{ + Namespace: prObj.Namespace, + Name: prObj.Spec.RepositoryReference.Name, + }) + if err != nil { + return fmt.Errorf("failed to get repo: %w", err) + } + + options := &bitbucket.PullRequestsOptions{ + Owner: repo.Spec.BitbucketCloud.Owner, + RepoSlug: repo.Spec.BitbucketCloud.Name, + ID: prObj.Status.ID, + } + + start := time.Now() + _, err = pr.client.Repositories.PullRequests.Decline(options) + statusCode := parseErrorStatusCode(err, http.StatusOK) + metrics.RecordSCMCall(repo, metrics.SCMAPIPullRequest, metrics.SCMOperationClose, statusCode, time.Since(start), nil) + + if err != nil { + var unexpectedErr *bitbucket.UnexpectedResponseStatusError + if errors.As(err, &unexpectedErr) { + return fmt.Errorf("failed to close pull request: %w", unexpectedErr.ErrorWithBody()) + } + return fmt.Errorf("failed to close pull request: %w", err) + } + + logger.V(4).Info("bitbucket response status", "status", statusCode) + logger.V(4).Info("closed pull request", "id", prObj.Status.ID) + + return nil +} + +// Merge merges an existing pull request with the specified commit message. +func (pr *PullRequest) Merge(ctx context.Context, prObj v1alpha1.PullRequest) error { + logger := log.FromContext(ctx) + + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, pr.k8sClient, client.ObjectKey{ + Namespace: prObj.Namespace, + Name: prObj.Spec.RepositoryReference.Name, + }) + if err != nil { + return fmt.Errorf("failed to get repo: %w", err) + } + + options := &bitbucket.PullRequestsOptions{ + Owner: repo.Spec.BitbucketCloud.Owner, + RepoSlug: repo.Spec.BitbucketCloud.Name, + ID: prObj.Status.ID, + CloseSourceBranch: false, + } + + start := time.Now() + _, err = pr.client.Repositories.PullRequests.Merge(options) + statusCode := parseErrorStatusCode(err, http.StatusOK) + metrics.RecordSCMCall(repo, metrics.SCMAPIPullRequest, metrics.SCMOperationMerge, statusCode, time.Since(start), nil) + + if err != nil { + var unexpectedErr *bitbucket.UnexpectedResponseStatusError + if errors.As(err, &unexpectedErr) { + return fmt.Errorf("failed to merge request: %w", unexpectedErr.ErrorWithBody()) + } + return fmt.Errorf("failed to merge request: %w", err) + } + + logger.V(4).Info("bitbucket response status", "status", statusCode) + logger.V(4).Info("merged pull request", "id", prObj.Status.ID) + + return nil +} + +// FindOpen checks if a pull request is open and returns its status. +func (pr *PullRequest) FindOpen(ctx context.Context, pullRequest v1alpha1.PullRequest) (bool, string, time.Time, error) { + logger := log.FromContext(ctx) + logger.V(4).Info("Finding Open Pull Request") + + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, pr.k8sClient, client.ObjectKey{ + Namespace: pullRequest.Namespace, + Name: pullRequest.Spec.RepositoryReference.Name, + }) + if err != nil { + return false, "", time.Time{}, fmt.Errorf("failed to get repo: %w", err) + } + + // Build query to find PRs matching source and target branches and state + // Bitbucket query syntax: https://developer.atlassian.com/cloud/bitbucket/rest/intro/#querying + query := fmt.Sprintf(`source.branch.name=%s AND destination.branch.name=%s AND state="OPEN"`, + strconv.Quote(pullRequest.Spec.SourceBranch), + strconv.Quote(pullRequest.Spec.TargetBranch), + ) + + options := &bitbucket.PullRequestsOptions{ + Owner: repo.Spec.BitbucketCloud.Owner, + RepoSlug: repo.Spec.BitbucketCloud.Name, + Query: query, + } + + start := time.Now() + result, err := pr.client.Repositories.PullRequests.Gets(options) + statusCode := parseErrorStatusCode(err, http.StatusOK) + metrics.RecordSCMCall(repo, metrics.SCMAPIPullRequest, metrics.SCMOperationList, statusCode, time.Since(start), nil) + + if err != nil { + var unexpectedErr *bitbucket.UnexpectedResponseStatusError + if errors.As(err, &unexpectedErr) { + return false, "", time.Time{}, fmt.Errorf("failed to list pull requests: %w", unexpectedErr.ErrorWithBody()) + } + return false, "", time.Time{}, fmt.Errorf("failed to list pull requests: %w", err) + } + + logger.V(4).Info("bitbucket response status", "status", statusCode) + + // Parse the paginated response + resultMap, ok := result.(map[string]any) + if !ok { + return false, "", time.Time{}, fmt.Errorf("unexpected response type from Bitbucket API: %T", result) + } + + values, exists := resultMap["values"] + if !exists { + return false, "", time.Time{}, nil + } + prs, ok := values.([]any) + if !ok || len(prs) == 0 { + return false, "", time.Time{}, nil + } + + // Get the first matching PR + firstPR, ok := prs[0].(map[string]any) + if !ok { + return false, "", time.Time{}, errors.New("unexpected PR format in response") + } + + // Extract and validate PR ID + idValue, exists := firstPR["id"] + if !exists { + return false, "", time.Time{}, errors.New("PR ID not found in response") + } + idFloat, ok := idValue.(float64) + if !ok { + return false, "", time.Time{}, fmt.Errorf("PR ID has unexpected type: %T", idValue) + } + + // Extract and validate created_on timestamp + createdOn, exists := firstPR["created_on"] + if !exists { + return false, "", time.Time{}, errors.New("created_on not found in response") + } + createdStr, ok := createdOn.(string) + if !ok { + return false, "", time.Time{}, fmt.Errorf("created_on has unexpected type: %T", createdOn) + } + + createdAt, err := time.Parse(time.RFC3339, createdStr) + if err != nil { + return false, "", time.Time{}, fmt.Errorf("failed to parse created_on timestamp: %w", err) + } + + return true, strconv.Itoa(int(idFloat)), createdAt, nil +} + +// GetUrl retrieves the URL of the pull request. +func (pr *PullRequest) GetUrl(ctx context.Context, prObj v1alpha1.PullRequest) (string, error) { + repo, err := utils.GetGitRepositoryFromObjectKey(ctx, pr.k8sClient, client.ObjectKey{ + Namespace: prObj.Namespace, + Name: prObj.Spec.RepositoryReference.Name, + }) + if err != nil { + return "", fmt.Errorf("failed to get repo: %w", err) + } + + return fmt.Sprintf("https://bitbucket.org/%s/%s/pull-requests/%s", + repo.Spec.BitbucketCloud.Owner, + repo.Spec.BitbucketCloud.Name, + prObj.Status.ID), nil +} diff --git a/internal/scms/bitbucket_cloud/utils.go b/internal/scms/bitbucket_cloud/utils.go new file mode 100644 index 000000000..4063bf874 --- /dev/null +++ b/internal/scms/bitbucket_cloud/utils.go @@ -0,0 +1,83 @@ +package bitbucket_cloud + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/ktrysmt/go-bitbucket" + + v1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" +) + +// BitbucketBaseURL is the base URL for Bitbucket Cloud +const BitbucketBaseURL = "https://bitbucket.org" + +// parseErrorStatusCode extracts the HTTP status code from a Bitbucket API error. +// The Bitbucket client doesn't return HTTP response metadata, so we parse +// the error message to determine status codes (e.g., "400 Bad Request"). +// Returns the provided defaultStatusCode if the error is not a Bitbucket error. +func parseErrorStatusCode(err error, defaultStatusCode int) int { + if err == nil { + return defaultStatusCode + } + + var bbErr *bitbucket.UnexpectedResponseStatusError + if !errors.As(err, &bbErr) { + return http.StatusInternalServerError + } + + errMsg := bbErr.Error() + switch { + case strings.HasPrefix(errMsg, "400"): + return http.StatusBadRequest + case strings.HasPrefix(errMsg, "401"): + return http.StatusUnauthorized + case strings.HasPrefix(errMsg, "404"): + return http.StatusNotFound + case strings.HasPrefix(errMsg, "409"): + return http.StatusConflict + case strings.HasPrefix(errMsg, "555"): + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +} + +// phaseToBuildState converts a CommitStatusPhase to a Bitbucket Cloud build state. +// Bitbucket Cloud states: SUCCESSFUL, FAILED, INPROGRESS, STOPPED +// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commit-statuses/#api-repositories-workspace-repo-slug-commit-commit-statuses-build-post-request-body +func phaseToBuildState(phase v1alpha1.CommitStatusPhase) string { + switch phase { + case v1alpha1.CommitPhaseSuccess: + return "SUCCESSFUL" + case v1alpha1.CommitPhasePending: + return "INPROGRESS" + default: + return "FAILED" + } +} + +// buildStateToPhase converts a Bitbucket Cloud build state to a CommitStatusPhase. +// Bitbucket Cloud states: SUCCESSFUL, FAILED, INPROGRESS, STOPPED +// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commit-statuses/#api-repositories-workspace-repo-slug-commit-commit-statuses-build-post-request-body +func buildStateToPhase(buildState string) v1alpha1.CommitStatusPhase { + switch buildState { + case "SUCCESSFUL": + return v1alpha1.CommitPhaseSuccess + case "INPROGRESS": + return v1alpha1.CommitPhasePending + default: + return v1alpha1.CommitPhaseFailure + } +} + +func createCommitURL(repo *v1alpha1.GitRepository, sha string) string { + return fmt.Sprintf("%s/%s/%s/commits/%s", + BitbucketBaseURL, + repo.Spec.BitbucketCloud.Owner, + repo.Spec.BitbucketCloud.Name, + sha, + ) +} diff --git a/internal/webhookreceiver/server.go b/internal/webhookreceiver/server.go index 956311c8a..958e0a6e9 100644 --- a/internal/webhookreceiver/server.go +++ b/internal/webhookreceiver/server.go @@ -23,10 +23,11 @@ var logger = ctrl.Log.WithName("webhookReceiver") // Provider type constants const ( - ProviderGitHub = "github" - ProviderGitLab = "gitlab" - ProviderForgejo = "forgejo" - ProviderUnknown = "" + ProviderGitHub = "github" + ProviderGitLab = "gitlab" + ProviderForgejo = "forgejo" + ProviderBitbucketCloud = "bitbucketCloud" + ProviderUnknown = "" ) // EnqueueFunc is a function type that can be used to enqueue CTP reconcile requests @@ -81,7 +82,7 @@ func (wr *WebhookReceiver) Start(ctx context.Context, addr string) error { } // DetectProvider determines the SCM provider based on webhook headers. -// Returns ProviderGitHub, ProviderGitLab, ProviderForgejo, or ProviderUnknown. +// Returns ProviderGitHub, ProviderGitLab, ProviderForgejo, ProviderBitbucketCloud, or ProviderUnknown. func (wr *WebhookReceiver) DetectProvider(r *http.Request) string { // Check for GitHub webhook headers if r.Header.Get("X-Github-Event") != "" || r.Header.Get("X-Github-Delivery") != "" { @@ -98,6 +99,11 @@ func (wr *WebhookReceiver) DetectProvider(r *http.Request) string { return ProviderForgejo } + // Check for Bitbucket Cloud webhook headers + if r.Header.Get("X-Hook-Uuid") != "" { + return ProviderBitbucketCloud + } + return ProviderUnknown } @@ -189,6 +195,20 @@ func (wr *WebhookReceiver) findChangeTransferPolicy(ctx context.Context, provide beforeSha = gjson.GetBytes(jsonBytes, "before").String() ref = gjson.GetBytes(jsonBytes, "ref").String() } + case ProviderBitbucketCloud: + // Bitbucket Cloud webhook format + if gjson.GetBytes(jsonBytes, "push.changes").Exists() && gjson.GetBytes(jsonBytes, "actor").Exists() { + changes := gjson.GetBytes(jsonBytes, "push.changes") + if changes.IsArray() && len(changes.Array()) > 0 { + firstChange := changes.Array()[0] + beforeSha = firstChange.Get("old.target.hash").String() + if newName := firstChange.Get("new.name"); newName.Exists() { + ref = "refs/heads/" + newName.String() + } else if oldName := firstChange.Get("old.name"); oldName.Exists() { + ref = "refs/heads/" + oldName.String() + } + } + } default: logger.V(4).Info("unsupported provider", "provider", provider) return nil, nil @@ -251,5 +271,15 @@ func (wr *WebhookReceiver) extractDeliveryID(r *http.Request) string { if id := r.Header.Get("X-Gitea-Delivery"); id != "" { return id } + // Bitbucket Cloud + // X-Request-UUID: Unique identifier for the webhook request + // X-Hook-UUID: Unique identifier for the webhook itself (also used for provider detection) + // Note: Go's http.Header.Get is case-insensitive, so this will match X-Request-UUID correctly + if id := r.Header.Get("X-Request-Uuid"); id != "" { + return id + } + if id := r.Header.Get("X-Hook-Uuid"); id != "" { + return id + } return "" } diff --git a/internal/webhookreceiver/server_test.go b/internal/webhookreceiver/server_test.go index 9ab239e39..272e5b59b 100644 --- a/internal/webhookreceiver/server_test.go +++ b/internal/webhookreceiver/server_test.go @@ -55,6 +55,12 @@ var _ = Describe("DetectProvider", func() { }, expectedResult: webhookreceiver.ProviderForgejo, }, + "Bitbucket Cloud webhook with X-Hook-UUID": { + headers: map[string]string{ + "X-Hook-UUID": "12345-abcde", + }, + expectedResult: webhookreceiver.ProviderBitbucketCloud, + }, "Unknown provider - no headers": { headers: map[string]string{}, expectedResult: webhookreceiver.ProviderUnknown,