diff --git a/api/v1alpha1/applicationset_types.go b/api/v1alpha1/applicationset_types.go index b6dc6bc1..05804258 100644 --- a/api/v1alpha1/applicationset_types.go +++ b/api/v1alpha1/applicationset_types.go @@ -292,8 +292,9 @@ type GitFileGeneratorItem struct { // SCMProviderGenerator defines a generator that scrapes a SCMaaS API to find candidate repos. type SCMProviderGenerator struct { // Which provider to use and config for it. - Github *SCMProviderGeneratorGithub `json:"github,omitempty"` - Gitlab *SCMProviderGeneratorGitlab `json:"gitlab,omitempty"` + Github *SCMProviderGeneratorGithub `json:"github,omitempty"` + Gitlab *SCMProviderGeneratorGitlab `json:"gitlab,omitempty"` + BitbucketServer *SCMProviderGeneratorBitbucketServer `json:"bitbucketServer,omitempty"` // Filters for which repos should be considered. Filters []SCMProviderGeneratorFilter `json:"filters,omitempty"` // Which protocol to use for the SCM URL. Default is provider-specific but ssh if possible. Not all providers @@ -304,7 +305,7 @@ type SCMProviderGenerator struct { Template ApplicationSetTemplate `json:"template,omitempty"` } -// SCMProviderGeneratorGithub defines a connection info specific to GitHub. +// SCMProviderGeneratorGithub defines connection info specific to GitHub. type SCMProviderGeneratorGithub struct { // GitHub org to scan. Required. Organization string `json:"organization"` @@ -316,7 +317,7 @@ type SCMProviderGeneratorGithub struct { AllBranches bool `json:"allBranches,omitempty"` } -// SCMProviderGeneratorGitlab defines a connection info specific to Gitlab. +// SCMProviderGeneratorGitlab defines connection info specific to Gitlab. type SCMProviderGeneratorGitlab struct { // Gitlab group to scan. Required. You can use either the project id (recommended) or the full namespaced path. Group string `json:"group"` @@ -330,6 +331,18 @@ type SCMProviderGeneratorGitlab struct { AllBranches bool `json:"allBranches,omitempty"` } +// SCMProviderGeneratorBitbucketServer defines connection info specific to Bitbucket Server. +type SCMProviderGeneratorBitbucketServer struct { + // Project to scan. Required. + Project string `json:"project"` + // The Bitbucket Server REST API URL to talk to. Required. + API string `json:"api"` + // Credentials for Basic auth + BasicAuth *BasicAuthBitbucketServer `json:"basicAuth,omitempty"` + // Scan all branches instead of just the default branch. + AllBranches bool `json:"allBranches,omitempty"` +} + // SCMProviderGeneratorFilter is a single repository filter. // If multiple filter types are set on a single struct, they will be AND'd together. All filters must // pass for a repo to be included. @@ -347,13 +360,16 @@ type SCMProviderGeneratorFilter struct { // PullRequestGenerator defines a generator that scrapes a PullRequest API to find candidate pull requests. type PullRequestGenerator struct { // Which provider to use and config for it. - Github *PullRequestGeneratorGithub `json:"github,omitempty"` + Github *PullRequestGeneratorGithub `json:"github,omitempty"` + BitbucketServer *PullRequestGeneratorBitbucketServer `json:"bitbucketServer,omitempty"` + // Filters for which pull requests should be considered. + Filters []PullRequestGeneratorFilter `json:"filters,omitempty"` // Standard parameters. RequeueAfterSeconds *int64 `json:"requeueAfterSeconds,omitempty"` Template ApplicationSetTemplate `json:"template,omitempty"` } -// PullRequestGenerator defines a connection info specific to GitHub. +// PullRequestGenerator defines connection info specific to GitHub. type PullRequestGeneratorGithub struct { // GitHub org or user to scan. Required. Owner string `json:"owner"` @@ -367,6 +383,33 @@ type PullRequestGeneratorGithub struct { Labels []string `json:"labels,omitempty"` } +// PullRequestGenerator defines connection info specific to BitbucketServer. +type PullRequestGeneratorBitbucketServer struct { + // Project to scan. Required. + Project string `json:"project"` + // Repo name to scan. Required. + Repo string `json:"repo"` + // The Bitbucket REST API URL to talk to e.g. https://bitbucket.org/rest Required. + API string `json:"api"` + // Credentials for Basic auth + BasicAuth *BasicAuthBitbucketServer `json:"basicAuth,omitempty"` +} + +// BasicAuthBitbucketServer defines the username/(password or personal access token) for Basic auth. +type BasicAuthBitbucketServer struct { + // Username for Basic auth + Username string `json:"username"` + // Password (or personal access token) reference. + PasswordRef *SecretRef `json:"passwordRef"` +} + +// PullRequestGeneratorFilter is a single pull request filter. +// If multiple filter types are set on a single struct, they will be AND'd together. All filters must +// pass for a pull request to be included. +type PullRequestGeneratorFilter struct { + BranchMatch *string `json:"branchMatch,omitempty"` +} + // ApplicationSetStatus defines the observed state of ApplicationSet type ApplicationSetStatus struct { // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4248a934..ff281ef6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -415,6 +415,26 @@ func (in ApplicationSetTerminalGenerators) DeepCopy() ApplicationSetTerminalGene return *out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuthBitbucketServer) DeepCopyInto(out *BasicAuthBitbucketServer) { + *out = *in + if in.PasswordRef != nil { + in, out := &in.PasswordRef, &out.PasswordRef + *out = new(SecretRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuthBitbucketServer. +func (in *BasicAuthBitbucketServer) DeepCopy() *BasicAuthBitbucketServer { + if in == nil { + return nil + } + out := new(BasicAuthBitbucketServer) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterGenerator) DeepCopyInto(out *ClusterGenerator) { *out = *in @@ -660,6 +680,18 @@ func (in *PullRequestGenerator) DeepCopyInto(out *PullRequestGenerator) { *out = new(PullRequestGeneratorGithub) (*in).DeepCopyInto(*out) } + if in.BitbucketServer != nil { + in, out := &in.BitbucketServer, &out.BitbucketServer + *out = new(PullRequestGeneratorBitbucketServer) + (*in).DeepCopyInto(*out) + } + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]PullRequestGeneratorFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.RequeueAfterSeconds != nil { in, out := &in.RequeueAfterSeconds, &out.RequeueAfterSeconds *out = new(int64) @@ -678,6 +710,46 @@ func (in *PullRequestGenerator) DeepCopy() *PullRequestGenerator { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PullRequestGeneratorBitbucketServer) DeepCopyInto(out *PullRequestGeneratorBitbucketServer) { + *out = *in + if in.BasicAuth != nil { + in, out := &in.BasicAuth, &out.BasicAuth + *out = new(BasicAuthBitbucketServer) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PullRequestGeneratorBitbucketServer. +func (in *PullRequestGeneratorBitbucketServer) DeepCopy() *PullRequestGeneratorBitbucketServer { + if in == nil { + return nil + } + out := new(PullRequestGeneratorBitbucketServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PullRequestGeneratorFilter) DeepCopyInto(out *PullRequestGeneratorFilter) { + *out = *in + if in.BranchMatch != nil { + in, out := &in.BranchMatch, &out.BranchMatch + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PullRequestGeneratorFilter. +func (in *PullRequestGeneratorFilter) DeepCopy() *PullRequestGeneratorFilter { + if in == nil { + return nil + } + out := new(PullRequestGeneratorFilter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PullRequestGeneratorGithub) DeepCopyInto(out *PullRequestGeneratorGithub) { *out = *in @@ -716,6 +788,11 @@ func (in *SCMProviderGenerator) DeepCopyInto(out *SCMProviderGenerator) { *out = new(SCMProviderGeneratorGitlab) (*in).DeepCopyInto(*out) } + if in.BitbucketServer != nil { + in, out := &in.BitbucketServer, &out.BitbucketServer + *out = new(SCMProviderGeneratorBitbucketServer) + (*in).DeepCopyInto(*out) + } if in.Filters != nil { in, out := &in.Filters, &out.Filters *out = make([]SCMProviderGeneratorFilter, len(*in)) @@ -741,6 +818,26 @@ func (in *SCMProviderGenerator) DeepCopy() *SCMProviderGenerator { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SCMProviderGeneratorBitbucketServer) DeepCopyInto(out *SCMProviderGeneratorBitbucketServer) { + *out = *in + if in.BasicAuth != nil { + in, out := &in.BasicAuth, &out.BasicAuth + *out = new(BasicAuthBitbucketServer) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SCMProviderGeneratorBitbucketServer. +func (in *SCMProviderGeneratorBitbucketServer) DeepCopy() *SCMProviderGeneratorBitbucketServer { + if in == nil { + return nil + } + out := new(SCMProviderGeneratorBitbucketServer) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SCMProviderGeneratorFilter) DeepCopyInto(out *SCMProviderGeneratorFilter) { *out = *in diff --git a/docs/Generators-Pull-Request.md b/docs/Generators-Pull-Request.md index 6cad00ff..cad482bc 100644 --- a/docs/Generators-Pull-Request.md +++ b/docs/Generators-Pull-Request.md @@ -53,6 +53,71 @@ spec: * `tokenRef`: A `Secret` name and key containing the GitHub access token to use for requests. If not specified, will make anonymous requests which have a lower rate limit and can only see public repositories. (Optional) * `labels`: Labels is used to filter the PRs that you want to target. (Optional) +## Bitbucket Server + +Fetch pull requests from a repo hosted on a Bitbucket Server (not the same as Bitbucket Cloud). + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: myapps +spec: + generators: + - pullRequest: + bitbucketServer: + project: myproject + repo: myrepository + # URL of the Bitbucket Server. Required. + api: https://mycompany.bitbucket.org + # Credentials for Basic authentication. Required for private repositories. + basicAuth: + # The username to authenticate with + username: myuser + # Reference to a Secret containing the password or personal access token. + passwordRef: + secretName: mypassword + key: password + # Labels are not supported by Bitbucket Server, so filtering by label is not possible. + # Filter PRs using the source branch name. (optional) + filters: + - branchMatch: ".*-argocd" + template: + # ... +``` + +* `project`: Required name of the Bitbucket project +* `repo`: Required name of the Bitbucket repository. +* `api`: Required URL to access the Bitbucket REST API. For the example above, an API request would be made to `https://mycompany.bitbucket.org/rest/api/1.0/projects/myproject/repos/myrepository/pull-requests` +* `branchMatch`: Optional regexp filter which should match the source branch name. This is an alternative to labels which are not supported by Bitbucket Server. + +If you want to access a private repository, you must also provide the credentials for Basic auth (this is the only auth supported currently): +* `username`: The username to authenticate with. It only needs read access to the relevant repo. +* `passwordRef`: A `Secret` name and key containing the password or personal access token to use for requests. + +## Filters + +Filters allow selecting which pull requests to generate for. Each filter can declare one or more conditions, all of which must pass. If multiple filters are present, any can match for a repository to be included. If no filters are specified, all pull requests will be processed. +Currently, only a subset of filters is available when comparing with SCM provider filters. + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: myapps +spec: + generators: + - scmProvider: + # ... + # Include any pull request ending with "argocd". (optional) + filters: + - branchMatch: ".*-argocd" + template: + # ... +``` + +* `branchMatch`: A regexp matched against source branch names. + ## Template As with all generators, several keys are available for replacement in the generated application. diff --git a/docs/Generators-SCM-Provider.md b/docs/Generators-SCM-Provider.md index 4107e4ac..9aaae459 100644 --- a/docs/Generators-SCM-Provider.md +++ b/docs/Generators-SCM-Provider.md @@ -94,6 +94,47 @@ For label filtering, the repository tags are used. Available clone protocols are `ssh` and `https`. +## Bitbucket Server + +Use the Bitbucket Server API (1.0) to scan repos in a project. Note that Bitbucket Server is not to same as Bitbucket Cloud (API 2.0) + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: myapps +spec: + generators: + - scmProvider: + bitbucketServer: + project: myproject + # URL of the Bitbucket Server. Required. + api: https://mycompany.bitbucket.org + # If true, scan every branch of every repository. If false, scan only the default branch. Defaults to false. + allBranches: true + # Credentials for Basic authentication. Required for private repositories. + basicAuth: + # The username to authenticate with + username: myuser + # Reference to a Secret containing the password or personal access token. + passwordRef: + secretName: mypassword + key: password + # Support for filtering by labels is TODO. Bitbucket server labels are not supported for PRs, but they are for repos + template: + # ... +``` + +* `project`: Required name of the Bitbucket project +* `api`: Required URL to access the Bitbucket REST api. +* `allBranches`: By default (false) the template will only be evaluated for the default branch of each repo. If this is true, every branch of every repository will be passed to the filters. If using this flag, you likely want to use a `branchMatch` filter. + +If you want to access a private repository, you must also provide the credentials for Basic auth (this is the only auth supported currently): +* `username`: The username to authenticate with. It only needs read access to the relevant repo. +* `passwordRef`: A `Secret` name and key containing the password or personal access token to use for requests. + +Available clone protocols are `ssh` and `https`. + ## Filters Filters allow selecting which repositories to generate for. Each filter can declare one or more conditions, all of which must pass. If multiple filters are present, any can match for a repository to be included. If no filters are specified, all repositories will be processed. diff --git a/go.mod b/go.mod index 81f7868a..b8de6287 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/argoproj/argo-cd/v2 v2.3.0-rc5.0.20220206192056-4b04a3918029 github.com/argoproj/gitops-engine v0.5.1-0.20220126184517-b0c5e00ccfa5 github.com/argoproj/pkg v0.11.1-0.20211203175135-36c59d8fafe0 + github.com/gfleury/go-bitbucket-v1 v0.0.0-20220125132502-90a950f9bcba github.com/go-logr/logr v1.2.2 github.com/google/go-github/v35 v35.0.0 github.com/imdario/mergo v0.3.12 @@ -89,6 +90,7 @@ require ( github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/moby/spdystream v0.2.0 // indirect github.com/moby/term v0.0.0-20210610120745-9d4ed1856297 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -97,6 +99,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.11.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect diff --git a/go.sum b/go.sum index 9474b218..796fe452 100644 --- a/go.sum +++ b/go.sum @@ -281,6 +281,8 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/gfleury/go-bitbucket-v1 v0.0.0-20220125132502-90a950f9bcba h1:JstvmY1XmxDNHsCqxzjNLbB+mUJDT/wQIinmCmnU0yM= +github.com/gfleury/go-bitbucket-v1 v0.0.0-20220125132502-90a950f9bcba/go.mod h1:LB3osS9X2JMYmTzcCArHHLrndBAfcVLQAvUddfs+ONs= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= @@ -672,6 +674,7 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/ipvs v1.0.1/go.mod h1:2pngiyseZbIKXNv7hsKj3O9UEz30c53MT9005gt2hxQ= diff --git a/manifests/crds/argoproj.io_applicationsets.yaml b/manifests/crds/argoproj.io_applicationsets.yaml index 7eda3253..6b8d463c 100644 --- a/manifests/crds/argoproj.io_applicationsets.yaml +++ b/manifests/crds/argoproj.io_applicationsets.yaml @@ -2459,6 +2459,44 @@ spec: x-kubernetes-preserve-unknown-fields: true pullRequest: properties: + bitbucketServer: + properties: + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + repo: + type: string + required: + - api + - project + - repo + type: object + filters: + items: + properties: + branchMatch: + type: string + type: object + type: array github: properties: api: @@ -2765,6 +2803,36 @@ spec: type: object scmProvider: properties: + bitbucketServer: + properties: + allBranches: + type: boolean + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + required: + - api + - project + type: object cloneProtocol: type: string filters: @@ -4601,6 +4669,44 @@ spec: x-kubernetes-preserve-unknown-fields: true pullRequest: properties: + bitbucketServer: + properties: + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + repo: + type: string + required: + - api + - project + - repo + type: object + filters: + items: + properties: + branchMatch: + type: string + type: object + type: array github: properties: api: @@ -4907,6 +5013,36 @@ spec: type: object scmProvider: properties: + bitbucketServer: + properties: + allBranches: + type: boolean + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + required: + - api + - project + type: object cloneProtocol: type: string filters: @@ -5532,6 +5668,44 @@ spec: type: object pullRequest: properties: + bitbucketServer: + properties: + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + repo: + type: string + required: + - api + - project + - repo + type: object + filters: + items: + properties: + branchMatch: + type: string + type: object + type: array github: properties: api: @@ -5838,6 +6012,36 @@ spec: type: object scmProvider: properties: + bitbucketServer: + properties: + allBranches: + type: boolean + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + required: + - api + - project + type: object cloneProtocol: type: string filters: diff --git a/manifests/install.yaml b/manifests/install.yaml index fec1e96d..bf8de52e 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -2458,6 +2458,44 @@ spec: x-kubernetes-preserve-unknown-fields: true pullRequest: properties: + bitbucketServer: + properties: + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + repo: + type: string + required: + - api + - project + - repo + type: object + filters: + items: + properties: + branchMatch: + type: string + type: object + type: array github: properties: api: @@ -2764,6 +2802,36 @@ spec: type: object scmProvider: properties: + bitbucketServer: + properties: + allBranches: + type: boolean + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + required: + - api + - project + type: object cloneProtocol: type: string filters: @@ -4600,6 +4668,44 @@ spec: x-kubernetes-preserve-unknown-fields: true pullRequest: properties: + bitbucketServer: + properties: + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + repo: + type: string + required: + - api + - project + - repo + type: object + filters: + items: + properties: + branchMatch: + type: string + type: object + type: array github: properties: api: @@ -4906,6 +5012,36 @@ spec: type: object scmProvider: properties: + bitbucketServer: + properties: + allBranches: + type: boolean + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + required: + - api + - project + type: object cloneProtocol: type: string filters: @@ -5531,6 +5667,44 @@ spec: type: object pullRequest: properties: + bitbucketServer: + properties: + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + repo: + type: string + required: + - api + - project + - repo + type: object + filters: + items: + properties: + branchMatch: + type: string + type: object + type: array github: properties: api: @@ -5837,6 +6011,36 @@ spec: type: object scmProvider: properties: + bitbucketServer: + properties: + allBranches: + type: boolean + api: + type: string + basicAuth: + properties: + passwordRef: + properties: + key: + type: string + secretName: + type: string + required: + - key + - secretName + type: object + username: + type: string + required: + - passwordRef + - username + type: object + project: + type: string + required: + - api + - project + type: object cloneProtocol: type: string filters: diff --git a/pkg/generators/pull_request.go b/pkg/generators/pull_request.go index 2461cccb..22d71186 100644 --- a/pkg/generators/pull_request.go +++ b/pkg/generators/pull_request.go @@ -10,6 +10,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" argoprojiov1alpha1 "github.com/argoproj/applicationset/api/v1alpha1" + "github.com/argoproj/applicationset/pkg/services/pull_request" pullrequest "github.com/argoproj/applicationset/pkg/services/pull_request" ) @@ -61,7 +62,7 @@ func (g *PullRequestGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha return nil, fmt.Errorf("failed to select pull request service provider: %v", err) } - pulls, err := svc.List(ctx) + pulls, err := pull_request.ListPullRequests(ctx, svc, appSetGenerator.PullRequest.Filters) if err != nil { return nil, fmt.Errorf("error listing repos: %v", err) } @@ -86,6 +87,18 @@ func (g *PullRequestGenerator) selectServiceProvider(ctx context.Context, genera } return pullrequest.NewGithubService(ctx, token, providerConfig.API, providerConfig.Owner, providerConfig.Repo, providerConfig.Labels) } + if generatorConfig.BitbucketServer != nil { + providerConfig := generatorConfig.BitbucketServer + if providerConfig.BasicAuth != nil { + password, err := g.getSecretRef(ctx, providerConfig.BasicAuth.PasswordRef, applicationSetInfo.Namespace) + if err != nil { + return nil, fmt.Errorf("error fetching Secret token: %v", err) + } + return pullrequest.NewBitbucketServiceBasicAuth(ctx, providerConfig.BasicAuth.Username, password, providerConfig.API, providerConfig.Project, providerConfig.Repo) + } else { + return pullrequest.NewBitbucketServiceNoAuth(ctx, providerConfig.API, providerConfig.Project, providerConfig.Repo) + } + } return nil, fmt.Errorf("no Pull Request provider implementation configured") } diff --git a/pkg/generators/scm_provider.go b/pkg/generators/scm_provider.go index b4c145b9..c69445c6 100644 --- a/pkg/generators/scm_provider.go +++ b/pkg/generators/scm_provider.go @@ -77,6 +77,21 @@ func (g *SCMProviderGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha if err != nil { return nil, fmt.Errorf("error initializing Gitlab service: %v", err) } + } else if providerConfig.BitbucketServer != nil { + providerConfig := providerConfig.BitbucketServer + var scmError error + if providerConfig.BasicAuth != nil { + password, err := g.getSecretRef(ctx, providerConfig.BasicAuth.PasswordRef, applicationSetInfo.Namespace) + if err != nil { + return nil, fmt.Errorf("error fetching Secret token: %v", err) + } + provider, scmError = scm_provider.NewBitbucketServerProviderBasicAuth(ctx, providerConfig.BasicAuth.Username, password, providerConfig.API, providerConfig.Project, providerConfig.AllBranches) + } else { + provider, scmError = scm_provider.NewBitbucketServerProviderNoAuth(ctx, providerConfig.API, providerConfig.Project, providerConfig.AllBranches) + } + if scmError != nil { + return nil, fmt.Errorf("error initializing Bitbucket Server service: %v", scmError) + } } else { return nil, fmt.Errorf("no SCM provider implementation configured") } diff --git a/pkg/services/pull_request/bitbucket_server.go b/pkg/services/pull_request/bitbucket_server.go new file mode 100644 index 00000000..2523cd70 --- /dev/null +++ b/pkg/services/pull_request/bitbucket_server.go @@ -0,0 +1,82 @@ +package pull_request + +import ( + "context" + "fmt" + + "github.com/argoproj/applicationset/pkg/utils" + bitbucketv1 "github.com/gfleury/go-bitbucket-v1" + log "github.com/sirupsen/logrus" +) + +type BitbucketService struct { + client *bitbucketv1.APIClient + projectKey string + repositorySlug string + // Not supported for PRs by Bitbucket Server + // labels []string +} + +var _ PullRequestService = (*BitbucketService)(nil) + +func NewBitbucketServiceBasicAuth(ctx context.Context, username, password, url, projectKey, repositorySlug string) (PullRequestService, error) { + bitbucketConfig := bitbucketv1.NewConfiguration(url) + // Avoid the XSRF check + bitbucketConfig.AddDefaultHeader("x-atlassian-token", "no-check") + bitbucketConfig.AddDefaultHeader("x-requested-with", "XMLHttpRequest") + + ctx = context.WithValue(ctx, bitbucketv1.ContextBasicAuth, bitbucketv1.BasicAuth{ + UserName: username, + Password: password, + }) + return newBitbucketService(ctx, bitbucketConfig, projectKey, repositorySlug) +} + +func NewBitbucketServiceNoAuth(ctx context.Context, url, projectKey, repositorySlug string) (PullRequestService, error) { + return newBitbucketService(ctx, bitbucketv1.NewConfiguration(url), projectKey, repositorySlug) +} + +func newBitbucketService(ctx context.Context, bitbucketConfig *bitbucketv1.Configuration, projectKey, repositorySlug string) (PullRequestService, error) { + bitbucketConfig.BasePath = utils.NormalizeBitbucketBasePath(bitbucketConfig.BasePath) + bitbucketClient := bitbucketv1.NewAPIClient(ctx, bitbucketConfig) + + return &BitbucketService{ + client: bitbucketClient, + projectKey: projectKey, + repositorySlug: repositorySlug, + }, nil +} + +func (b *BitbucketService) List(_ context.Context) ([]*PullRequest, error) { + paged := map[string]interface{}{ + "limit": 100, + } + + pullRequests := []*PullRequest{} + for { + response, err := b.client.DefaultApi.GetPullRequestsPage(b.projectKey, b.repositorySlug, paged) + if err != nil { + return nil, fmt.Errorf("error listing pull requests for %s/%s: %v", b.projectKey, b.repositorySlug, err) + } + pulls, err := bitbucketv1.GetPullRequestsResponse(response) + if err != nil { + log.Errorf("error parsing pull request response '%v'", response.Values) + return nil, fmt.Errorf("error parsing pull request response for %s/%s: %v", b.projectKey, b.repositorySlug, err) + } + + for _, pull := range pulls { + pullRequests = append(pullRequests, &PullRequest{ + Number: pull.ID, + Branch: pull.FromRef.DisplayID, // ID: refs/heads/main DisplayID: main + HeadSHA: pull.FromRef.LatestCommit, // This is not defined in the official docs, but works in practice + }) + } + + hasNextPage, nextPageStart := bitbucketv1.HasNextPage(response) + if !hasNextPage { + break + } + paged["start"] = nextPageStart + } + return pullRequests, nil +} diff --git a/pkg/services/pull_request/bitbucket_server_test.go b/pkg/services/pull_request/bitbucket_server_test.go new file mode 100644 index 00000000..09f5cf52 --- /dev/null +++ b/pkg/services/pull_request/bitbucket_server_test.go @@ -0,0 +1,319 @@ +package pull_request + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/argoproj/applicationset/api/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func defaultHandler(t *testing.T) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var err error + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": true, + "values": [ + { + "id": 101, + "fromRef": { + "id": "refs/heads/feature-ABC-123", + "displayId": "feature-ABC-123", + "latestCommit": "cb3cf2e4d1517c83e720d2585b9402dbef71f992" + } + } + ], + "start": 0 + }`) + default: + t.Fail() + } + if err != nil { + t.Fail() + } + } +} + +func TestListPullRequestNoAuth(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + defaultHandler(t)(w, r) + })) + defer ts.Close() + svc, err := NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + pullRequests, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{}) + assert.NoError(t, err) + assert.Equal(t, 1, len(pullRequests)) + assert.Equal(t, 101, pullRequests[0].Number) + assert.Equal(t, "feature-ABC-123", pullRequests[0].Branch) + assert.Equal(t, "cb3cf2e4d1517c83e720d2585b9402dbef71f992", pullRequests[0].HeadSHA) +} + +func TestListPullRequestPagination(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var err error + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100": + _, err = io.WriteString(w, `{ + "size": 2, + "limit": 2, + "isLastPage": false, + "values": [ + { + "id": 101, + "fromRef": { + "id": "refs/heads/feature-101", + "displayId": "feature-101", + "latestCommit": "ab3cf2e4d1517c83e720d2585b9402dbef71f992" + } + }, + { + "id": 102, + "fromRef": { + "id": "refs/heads/feature-102", + "displayId": "feature-102", + "latestCommit": "bb3cf2e4d1517c83e720d2585b9402dbef71f992" + } + } + ], + "nextPageStart": 200 + }`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100&start=200": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 2, + "isLastPage": true, + "values": [ + { + "id": 200, + "fromRef": { + "id": "refs/heads/feature-200", + "displayId": "feature-200", + "latestCommit": "cb3cf2e4d1517c83e720d2585b9402dbef71f992" + } + } + ], + "start": 200 + }`) + default: + t.Fail() + } + if err != nil { + t.Fail() + } + })) + defer ts.Close() + svc, err := NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + pullRequests, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{}) + assert.NoError(t, err) + assert.Equal(t, 3, len(pullRequests)) + assert.Equal(t, PullRequest{ + Number: 101, + Branch: "feature-101", + HeadSHA: "ab3cf2e4d1517c83e720d2585b9402dbef71f992", + }, *pullRequests[0]) + assert.Equal(t, PullRequest{ + Number: 102, + Branch: "feature-102", + HeadSHA: "bb3cf2e4d1517c83e720d2585b9402dbef71f992", + }, *pullRequests[1]) + assert.Equal(t, PullRequest{ + Number: 200, + Branch: "feature-200", + HeadSHA: "cb3cf2e4d1517c83e720d2585b9402dbef71f992", + }, *pullRequests[2]) +} + +func TestListPullRequestBasicAuth(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // base64(user:password) + assert.Equal(t, "Basic dXNlcjpwYXNzd29yZA==", r.Header.Get("Authorization")) + assert.Equal(t, "no-check", r.Header.Get("X-Atlassian-Token")) + defaultHandler(t)(w, r) + })) + defer ts.Close() + svc, err := NewBitbucketServiceBasicAuth(context.Background(), "user", "password", ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + pullRequests, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{}) + assert.NoError(t, err) + assert.Equal(t, 1, len(pullRequests)) + assert.Equal(t, 101, pullRequests[0].Number) + assert.Equal(t, "feature-ABC-123", pullRequests[0].Branch) + assert.Equal(t, "cb3cf2e4d1517c83e720d2585b9402dbef71f992", pullRequests[0].HeadSHA) +} + +func TestListResponseError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer ts.Close() + svc, _ := NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + _, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{}) + assert.Error(t, err) +} + +func TestListResponseMalformed(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100": + _, err := io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": true, + "values": { "id": 101 }, + "start": 0 + }`) + if err != nil { + t.Fail() + } + default: + t.Fail() + } + })) + defer ts.Close() + svc, _ := NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + _, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{}) + assert.Error(t, err) +} + +func TestListResponseEmpty(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100": + _, err := io.WriteString(w, `{ + "size": 0, + "limit": 100, + "isLastPage": true, + "values": [], + "start": 0 + }`) + if err != nil { + t.Fail() + } + default: + t.Fail() + } + })) + defer ts.Close() + svc, err := NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + pullRequests, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{}) + assert.NoError(t, err) + assert.Empty(t, pullRequests) +} + +func TestListPullRequestBranchMatch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var err error + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100": + _, err = io.WriteString(w, `{ + "size": 2, + "limit": 2, + "isLastPage": false, + "values": [ + { + "id": 101, + "fromRef": { + "id": "refs/heads/feature-101", + "displayId": "feature-101", + "latestCommit": "ab3cf2e4d1517c83e720d2585b9402dbef71f992" + } + }, + { + "id": 102, + "fromRef": { + "id": "refs/heads/feature-102", + "displayId": "feature-102", + "latestCommit": "bb3cf2e4d1517c83e720d2585b9402dbef71f992" + } + } + ], + "nextPageStart": 200 + }`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/pull-requests?limit=100&start=200": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 2, + "isLastPage": true, + "values": [ + { + "id": 200, + "fromRef": { + "id": "refs/heads/feature-200", + "displayId": "feature-200", + "latestCommit": "cb3cf2e4d1517c83e720d2585b9402dbef71f992" + } + } + ], + "start": 200 + }`) + default: + t.Fail() + } + if err != nil { + t.Fail() + } + })) + defer ts.Close() + regexp := `feature-1[\d]{2}` + svc, err := NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + pullRequests, err := ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{ + { + BranchMatch: ®exp, + }, + }) + assert.NoError(t, err) + assert.Equal(t, 2, len(pullRequests)) + assert.Equal(t, PullRequest{ + Number: 101, + Branch: "feature-101", + HeadSHA: "ab3cf2e4d1517c83e720d2585b9402dbef71f992", + }, *pullRequests[0]) + assert.Equal(t, PullRequest{ + Number: 102, + Branch: "feature-102", + HeadSHA: "bb3cf2e4d1517c83e720d2585b9402dbef71f992", + }, *pullRequests[1]) + + regexp = `.*2$` + svc, err = NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + pullRequests, err = ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{ + { + BranchMatch: ®exp, + }, + }) + assert.NoError(t, err) + assert.Equal(t, 1, len(pullRequests)) + assert.Equal(t, PullRequest{ + Number: 102, + Branch: "feature-102", + HeadSHA: "bb3cf2e4d1517c83e720d2585b9402dbef71f992", + }, *pullRequests[0]) + + regexp = `[\d{2}` + svc, err = NewBitbucketServiceNoAuth(context.Background(), ts.URL, "PROJECT", "REPO") + assert.NoError(t, err) + _, err = ListPullRequests(context.Background(), svc, []v1alpha1.PullRequestGeneratorFilter{ + { + BranchMatch: ®exp, + }, + }) + assert.Error(t, err) +} diff --git a/pkg/services/pull_request/interface.go b/pkg/services/pull_request/interface.go index bc67681c..c55fa5ef 100644 --- a/pkg/services/pull_request/interface.go +++ b/pkg/services/pull_request/interface.go @@ -1,6 +1,9 @@ package pull_request -import "context" +import ( + "context" + "regexp" +) type PullRequest struct { // Number is a number that will be the ID of the pull request. @@ -15,3 +18,7 @@ type PullRequestService interface { // List gets a list of pull requests. List(ctx context.Context) ([]*PullRequest, error) } + +type Filter struct { + BranchMatch *regexp.Regexp +} diff --git a/pkg/services/pull_request/utils.go b/pkg/services/pull_request/utils.go new file mode 100644 index 00000000..59fd18e2 --- /dev/null +++ b/pkg/services/pull_request/utils.go @@ -0,0 +1,62 @@ +package pull_request + +import ( + "context" + "fmt" + "regexp" + + argoprojiov1alpha1 "github.com/argoproj/applicationset/api/v1alpha1" +) + +func compileFilters(filters []argoprojiov1alpha1.PullRequestGeneratorFilter) ([]*Filter, error) { + outFilters := make([]*Filter, 0, len(filters)) + for _, filter := range filters { + outFilter := &Filter{} + var err error + if filter.BranchMatch != nil { + outFilter.BranchMatch, err = regexp.Compile(*filter.BranchMatch) + if err != nil { + return nil, fmt.Errorf("error compiling BranchMatch regexp %q: %v", *filter.BranchMatch, err) + } + } + outFilters = append(outFilters, outFilter) + } + return outFilters, nil +} + +func matchFilter(pullRequest *PullRequest, filter *Filter) bool { + if filter.BranchMatch != nil && !filter.BranchMatch.MatchString(pullRequest.Branch) { + return false + } + + return true +} + +func ListPullRequests(ctx context.Context, provider PullRequestService, filters []argoprojiov1alpha1.PullRequestGeneratorFilter) ([]*PullRequest, error) { + compiledFilters, err := compileFilters(filters) + if err != nil { + return nil, err + } + + pullRequests, err := provider.List(ctx) + if err != nil { + return nil, err + } + + if len(compiledFilters) == 0 { + return pullRequests, nil + } + + filteredPullRequests := make([]*PullRequest, 0, len(pullRequests)) + for _, pullRequest := range pullRequests { + for _, filter := range compiledFilters { + matches := matchFilter(pullRequest, filter) + if matches { + filteredPullRequests = append(filteredPullRequests, pullRequest) + break + } + } + } + + return filteredPullRequests, nil +} diff --git a/pkg/services/pull_request/utils_test.go b/pkg/services/pull_request/utils_test.go new file mode 100644 index 00000000..2f65b9cc --- /dev/null +++ b/pkg/services/pull_request/utils_test.go @@ -0,0 +1,139 @@ +package pull_request + +import ( + "context" + "testing" + + argoprojiov1alpha1 "github.com/argoproj/applicationset/api/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func strp(s string) *string { + return &s +} +func TestFilterBranchMatchBadRegexp(t *testing.T) { + provider, _ := NewFakeService( + context.Background(), + []*PullRequest{ + { + Number: 1, + Branch: "branch1", + HeadSHA: "089d92cbf9ff857a39e6feccd32798ca700fb958", + }, + }, + nil, + ) + filters := []argoprojiov1alpha1.PullRequestGeneratorFilter{ + { + BranchMatch: strp("("), + }, + } + _, err := ListPullRequests(context.Background(), provider, filters) + assert.Error(t, err) +} + +func TestFilterBranchMatch(t *testing.T) { + provider, _ := NewFakeService( + context.Background(), + []*PullRequest{ + { + Number: 1, + Branch: "one", + HeadSHA: "189d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 2, + Branch: "two", + HeadSHA: "289d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 3, + Branch: "three", + HeadSHA: "389d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 4, + Branch: "four", + HeadSHA: "489d92cbf9ff857a39e6feccd32798ca700fb958", + }, + }, + nil, + ) + filters := []argoprojiov1alpha1.PullRequestGeneratorFilter{ + { + BranchMatch: strp("w"), + }, + } + pullRequests, err := ListPullRequests(context.Background(), provider, filters) + assert.NoError(t, err) + assert.Len(t, pullRequests, 1) + assert.Equal(t, "two", pullRequests[0].Branch) +} + +func TestMultiFilterOr(t *testing.T) { + provider, _ := NewFakeService( + context.Background(), + []*PullRequest{ + { + Number: 1, + Branch: "one", + HeadSHA: "189d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 2, + Branch: "two", + HeadSHA: "289d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 3, + Branch: "three", + HeadSHA: "389d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 4, + Branch: "four", + HeadSHA: "489d92cbf9ff857a39e6feccd32798ca700fb958", + }, + }, + nil, + ) + filters := []argoprojiov1alpha1.PullRequestGeneratorFilter{ + { + BranchMatch: strp("w"), + }, + { + BranchMatch: strp("r"), + }, + } + pullRequests, err := ListPullRequests(context.Background(), provider, filters) + assert.NoError(t, err) + assert.Len(t, pullRequests, 3) + assert.Equal(t, "two", pullRequests[0].Branch) + assert.Equal(t, "three", pullRequests[1].Branch) + assert.Equal(t, "four", pullRequests[2].Branch) +} + +func TestNoFilters(t *testing.T) { + provider, _ := NewFakeService( + context.Background(), + []*PullRequest{ + { + Number: 1, + Branch: "one", + HeadSHA: "189d92cbf9ff857a39e6feccd32798ca700fb958", + }, + { + Number: 2, + Branch: "two", + HeadSHA: "289d92cbf9ff857a39e6feccd32798ca700fb958", + }, + }, + nil, + ) + filters := []argoprojiov1alpha1.PullRequestGeneratorFilter{} + repos, err := ListPullRequests(context.Background(), provider, filters) + assert.NoError(t, err) + assert.Len(t, repos, 2) + assert.Equal(t, "one", repos[0].Branch) + assert.Equal(t, "two", repos[1].Branch) +} diff --git a/pkg/services/scm_provider/bitbucket_server.go b/pkg/services/scm_provider/bitbucket_server.go new file mode 100644 index 00000000..6ab72a2a --- /dev/null +++ b/pkg/services/scm_provider/bitbucket_server.go @@ -0,0 +1,207 @@ +package scm_provider + +import ( + "context" + "fmt" + + "github.com/argoproj/applicationset/pkg/utils" + bitbucketv1 "github.com/gfleury/go-bitbucket-v1" + log "github.com/sirupsen/logrus" +) + +type BitbucketServerProvider struct { + client *bitbucketv1.APIClient + projectKey string + allBranches bool +} + +var _ SCMProviderService = &BitbucketServerProvider{} + +func NewBitbucketServerProviderBasicAuth(ctx context.Context, username, password, url, projectKey string, allBranches bool) (*BitbucketServerProvider, error) { + bitbucketConfig := bitbucketv1.NewConfiguration(url) + // Avoid the XSRF check + bitbucketConfig.AddDefaultHeader("x-atlassian-token", "no-check") + bitbucketConfig.AddDefaultHeader("x-requested-with", "XMLHttpRequest") + + ctx = context.WithValue(ctx, bitbucketv1.ContextBasicAuth, bitbucketv1.BasicAuth{ + UserName: username, + Password: password, + }) + return newBitbucketServerProvider(ctx, bitbucketConfig, projectKey, allBranches) +} + +func NewBitbucketServerProviderNoAuth(ctx context.Context, url, projectKey string, allBranches bool) (*BitbucketServerProvider, error) { + return newBitbucketServerProvider(ctx, bitbucketv1.NewConfiguration(url), projectKey, allBranches) +} + +func newBitbucketServerProvider(ctx context.Context, bitbucketConfig *bitbucketv1.Configuration, projectKey string, allBranches bool) (*BitbucketServerProvider, error) { + bitbucketConfig.BasePath = utils.NormalizeBitbucketBasePath(bitbucketConfig.BasePath) + bitbucketClient := bitbucketv1.NewAPIClient(ctx, bitbucketConfig) + + return &BitbucketServerProvider{ + client: bitbucketClient, + projectKey: projectKey, + allBranches: allBranches, + }, nil +} + +func (b *BitbucketServerProvider) ListRepos(_ context.Context, cloneProtocol string) ([]*Repository, error) { + paged := map[string]interface{}{ + "limit": 100, + } + repos := []*Repository{} + for { + response, err := b.client.DefaultApi.GetRepositoriesWithOptions(b.projectKey, paged) + if err != nil { + return nil, fmt.Errorf("error listing repositories for %s: %v", b.projectKey, err) + } + repositories, err := bitbucketv1.GetRepositoriesResponse(response) + if err != nil { + log.Errorf("error parsing repositories response '%v'", response.Values) + return nil, fmt.Errorf("error parsing repositories response %s: %v", b.projectKey, err) + } + for _, bitbucketRepo := range repositories { + var url string + switch cloneProtocol { + // Default to SSH if unspecified (i.e. if ""). + case "", "ssh": + url = getCloneURLFromLinks(bitbucketRepo.Links.Clone, "ssh") + case "https": + url = getCloneURLFromLinks(bitbucketRepo.Links.Clone, "http") + default: + return nil, fmt.Errorf("unknown clone protocol for Bitbucket Server %v", cloneProtocol) + } + + org := bitbucketRepo.Project.Key + repo := bitbucketRepo.Name + // Bitbucket doesn't return the default branch in the repo query, fetch it here + branch, err := b.getDefaultBranch(org, repo) + if err != nil { + return nil, err + } + if branch == nil { + log.Debugf("%s/%s does not have a default branch, skipping", org, repo) + continue + } + + repos = append(repos, &Repository{ + Organization: org, + Repository: repo, + URL: url, + Branch: branch.DisplayID, + SHA: branch.LatestCommit, + Labels: []string{}, // Not supported by library + RepositoryId: bitbucketRepo.ID, + }) + } + hasNextPage, nextPageStart := bitbucketv1.HasNextPage(response) + if !hasNextPage { + break + } + paged["start"] = nextPageStart + } + return repos, nil +} + +func (b *BitbucketServerProvider) RepoHasPath(_ context.Context, repo *Repository, path string) (bool, error) { + opts := map[string]interface{}{ + "limit": 100, + "at": repo.Branch, + "type_": true, + } + // No need to query for all pages here + response, err := b.client.DefaultApi.GetContent_0(repo.Organization, repo.Repository, path, opts) + if response != nil && response.StatusCode == 404 { + // File/directory not found + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (b *BitbucketServerProvider) GetBranches(_ context.Context, repo *Repository) ([]*Repository, error) { + repos := []*Repository{} + branches, err := b.listBranches(repo) + if err != nil { + return nil, fmt.Errorf("error listing branches for %s/%s: %v", repo.Organization, repo.Repository, err) + } + + for _, branch := range branches { + repos = append(repos, &Repository{ + Organization: repo.Organization, + Repository: repo.Repository, + URL: repo.URL, + Branch: branch.DisplayID, + SHA: branch.LatestCommit, + Labels: repo.Labels, + RepositoryId: repo.RepositoryId, + }) + } + return repos, nil +} + +func (b *BitbucketServerProvider) listBranches(repo *Repository) ([]bitbucketv1.Branch, error) { + // If we don't specifically want to query for all branches, just use the default branch and call it a day. + if !b.allBranches { + branch, err := b.getDefaultBranch(repo.Organization, repo.Repository) + if err != nil { + return nil, err + } + if branch == nil { + return []bitbucketv1.Branch{}, nil + } + return []bitbucketv1.Branch{*branch}, nil + } + // Otherwise, scrape the GetBranches API. + branches := []bitbucketv1.Branch{} + paged := map[string]interface{}{ + "limit": 100, + } + for { + response, err := b.client.DefaultApi.GetBranches(repo.Organization, repo.Repository, paged) + if err != nil { + return nil, fmt.Errorf("error listing branches for %s/%s: %v", repo.Organization, repo.Repository, err) + } + bitbucketBranches, err := bitbucketv1.GetBranchesResponse(response) + if err != nil { + log.Errorf("error parsing branches response '%v'", response.Values) + return nil, fmt.Errorf("error parsing branches response for %s/%s: %v", repo.Organization, repo.Repository, err) + } + + branches = append(branches, bitbucketBranches...) + + hasNextPage, nextPageStart := bitbucketv1.HasNextPage(response) + if !hasNextPage { + break + } + paged["start"] = nextPageStart + } + return branches, nil +} + +func (b *BitbucketServerProvider) getDefaultBranch(org string, repo string) (*bitbucketv1.Branch, error) { + response, err := b.client.DefaultApi.GetDefaultBranch(org, repo) + if response != nil && response.StatusCode == 404 { + // There's no default branch i.e. empty repo, not an error + return nil, nil + } + if err != nil { + return nil, err + } + branch, err := bitbucketv1.GetBranchResponse(response) + if err != nil { + return nil, err + } + return &branch, nil +} + +func getCloneURLFromLinks(links []bitbucketv1.CloneLink, name string) string { + for _, link := range links { + if link.Name == name { + return link.Href + } + } + return "" +} diff --git a/pkg/services/scm_provider/bitbucket_server_test.go b/pkg/services/scm_provider/bitbucket_server_test.go new file mode 100644 index 00000000..986e03a8 --- /dev/null +++ b/pkg/services/scm_provider/bitbucket_server_test.go @@ -0,0 +1,572 @@ +package scm_provider + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func defaultHandler(t *testing.T) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var err error + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos?limit=100": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": true, + "values": [ + { + "id": 1, + "name": "REPO", + "project": { + "key": "PROJECT" + }, + "links": { + "clone": [ + { + "href": "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + "name": "ssh" + }, + { + "href": "https://mycompany.bitbucket.org/scm/PROJECT/REPO.git", + "name": "http" + } + ] + } + } + ], + "start": 0 + }`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches?limit=100": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": true, + "values": [ + { + "id": "refs/heads/main", + "displayId": "main", + "type": "BRANCH", + "latestCommit": "8d51122def5632836d1cb1026e879069e10a1e13", + "latestChangeset": "8d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + } + ], + "start": 0 + }`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + _, err = io.WriteString(w, `{ + "id": "refs/heads/main", + "displayId": "main", + "type": "BRANCH", + "latestCommit": "8d51122def5632836d1cb1026e879069e10a1e13", + "latestChangeset": "8d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + }`) + default: + t.Fail() + } + if err != nil { + t.Fail() + } + } +} + +func verifyDefaultRepo(t *testing.T, err error, repos []*Repository) { + assert.NoError(t, err) + assert.Equal(t, 1, len(repos)) + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Branch: "main", + SHA: "8d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 1, + }, *repos[0]) +} + +func TestListReposNoAuth(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", true) + assert.NoError(t, err) + repos, err := provider.ListRepos(context.Background(), "ssh") + verifyDefaultRepo(t, err, repos) +} + +func TestListReposPagination(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + var err error + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos?limit=100": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": false, + "values": [ + { + "id": 100, + "name": "REPO", + "project": { + "key": "PROJECT" + }, + "links": { + "clone": [ + { + "href": "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + "name": "ssh" + }, + { + "href": "https://mycompany.bitbucket.org/scm/PROJECT/REPO.git", + "name": "http" + } + ] + } + } + ], + "start": 0, + "nextPageStart": 200 + }`) + case "/rest/api/1.0/projects/PROJECT/repos?limit=100&start=200": + _, err = io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": true, + "values": [ + { + "id": 200, + "name": "REPO2", + "project": { + "key": "PROJECT" + }, + "links": { + "clone": [ + { + "href": "ssh://git@mycompany.bitbucket.org/PROJECT/REPO2.git", + "name": "ssh" + }, + { + "href": "https://mycompany.bitbucket.org/scm/PROJECT/REPO2.git", + "name": "http" + } + ] + } + } + ], + "start": 200 + }`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + _, err = io.WriteString(w, `{ + "id": "refs/heads/main", + "displayId": "main", + "type": "BRANCH", + "latestCommit": "8d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + }`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO2/branches/default": + _, err = io.WriteString(w, `{ + "id": "refs/heads/development", + "displayId": "development", + "type": "BRANCH", + "latestCommit": "2d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + }`) + default: + t.Fail() + } + if err != nil { + t.Fail() + } + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", true) + assert.NoError(t, err) + repos, err := provider.ListRepos(context.Background(), "ssh") + assert.NoError(t, err) + assert.Equal(t, 2, len(repos)) + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Branch: "main", + SHA: "8d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 100, + }, *repos[0]) + + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO2", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO2.git", + Branch: "development", + SHA: "2d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 200, + }, *repos[1]) +} + +func TestGetBranchesBranchPagination(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches?limit=100": + _, err := io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": false, + "values": [ + { + "id": "refs/heads/main", + "displayId": "main", + "type": "BRANCH", + "latestCommit": "8d51122def5632836d1cb1026e879069e10a1e13", + "latestChangeset": "8d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + } + ], + "start": 0, + "nextPageStart": 200 + }`) + if err != nil { + t.Fail() + } + return + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches?limit=100&start=200": + _, err := io.WriteString(w, `{ + "size": 1, + "limit": 100, + "isLastPage": true, + "values": [ + { + "id": "refs/heads/feature", + "displayId": "feature", + "type": "BRANCH", + "latestCommit": "9d51122def5632836d1cb1026e879069e10a1e13", + "latestChangeset": "9d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + } + ], + "start": 200 + }`) + if err != nil { + t.Fail() + } + return + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", true) + assert.NoError(t, err) + repos, err := provider.GetBranches(context.Background(), &Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Labels: []string{}, + RepositoryId: 1, + }) + assert.NoError(t, err) + assert.Equal(t, 2, len(repos)) + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Branch: "main", + SHA: "8d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 1, + }, *repos[0]) + + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Branch: "feature", + SHA: "9d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 1, + }, *repos[1]) +} + +func TestGetBranchesDefaultOnly(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + _, err := io.WriteString(w, `{ + "id": "refs/heads/default", + "displayId": "default", + "type": "BRANCH", + "latestCommit": "ab51122def5632836d1cb1026e879069e10a1e13", + "latestChangeset": "ab51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + }`) + if err != nil { + t.Fail() + } + return + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", false) + assert.NoError(t, err) + repos, err := provider.GetBranches(context.Background(), &Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Labels: []string{}, + RepositoryId: 1, + }) + assert.NoError(t, err) + assert.Equal(t, 1, len(repos)) + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Branch: "default", + SHA: "ab51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 1, + }, *repos[0]) +} + +func TestGetBranchesMissingDefault(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + http.Error(w, "Not found", 404) + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", false) + assert.NoError(t, err) + repos, err := provider.GetBranches(context.Background(), &Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Labels: []string{}, + RepositoryId: 1, + }) + assert.NoError(t, err) + assert.Empty(t, repos) +} + +func TestGetBranchesErrorDefaultBranch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + http.Error(w, "Internal server error", 500) + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", false) + assert.NoError(t, err) + _, err = provider.GetBranches(context.Background(), &Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Labels: []string{}, + RepositoryId: 1, + }) + assert.Error(t, err) +} + +func TestListReposBasicAuth(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Basic dXNlcjpwYXNzd29yZA==", r.Header.Get("Authorization")) + assert.Equal(t, "no-check", r.Header.Get("X-Atlassian-Token")) + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderBasicAuth(context.Background(), "user", "password", ts.URL, "PROJECT", true) + assert.NoError(t, err) + repos, err := provider.ListRepos(context.Background(), "ssh") + verifyDefaultRepo(t, err, repos) +} + +func TestListReposDefaultBranch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + _, err := io.WriteString(w, `{ + "id": "refs/heads/default", + "displayId": "default", + "type": "BRANCH", + "latestCommit": "1d51122def5632836d1cb1026e879069e10a1e13", + "latestChangeset": "1d51122def5632836d1cb1026e879069e10a1e13", + "isDefault": true + }`) + if err != nil { + t.Fail() + } + return + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", false) + assert.NoError(t, err) + repos, err := provider.ListRepos(context.Background(), "ssh") + assert.NoError(t, err) + assert.Equal(t, 1, len(repos)) + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "ssh://git@mycompany.bitbucket.org/PROJECT/REPO.git", + Branch: "default", + SHA: "1d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 1, + }, *repos[0]) +} + +func TestListReposMissingDefaultBranch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + http.Error(w, "Not found", 404) + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", false) + assert.NoError(t, err) + repos, err := provider.ListRepos(context.Background(), "ssh") + assert.NoError(t, err) + assert.Empty(t, repos) +} + +func TestListReposErrorDefaultBranch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/branches/default": + http.Error(w, "Internal server error", 500) + } + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", false) + assert.NoError(t, err) + _, err = provider.ListRepos(context.Background(), "ssh") + assert.Error(t, err) +} + +func TestListReposCloneProtocol(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", true) + assert.NoError(t, err) + repos, err := provider.ListRepos(context.Background(), "https") + assert.NoError(t, err) + assert.Equal(t, 1, len(repos)) + assert.Equal(t, Repository{ + Organization: "PROJECT", + Repository: "REPO", + URL: "https://mycompany.bitbucket.org/scm/PROJECT/REPO.git", + Branch: "main", + SHA: "8d51122def5632836d1cb1026e879069e10a1e13", + Labels: []string{}, + RepositoryId: 1, + }, *repos[0]) +} + +func TestListReposUnknownProtocol(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + defaultHandler(t)(w, r) + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", true) + assert.NoError(t, err) + _, errProtocol := provider.ListRepos(context.Background(), "http") + assert.NotNil(t, errProtocol) +} + +func TestBitbucketServerHasPath(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + switch r.RequestURI { + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/pkg?at=main&limit=100&type=true": + _, err = io.WriteString(w, `{"type":"DIRECTORY"}`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/pkg/?at=main&limit=100&type=true": + _, err = io.WriteString(w, `{"type":"DIRECTORY"}`) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/anotherpkg/file.txt?at=main&limit=100&type=true": + _, err = io.WriteString(w, `{"type":"FILE"}`) + + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/anotherpkg/missing.txt?at=main&limit=100&type=true": + http.Error(w, "The path \"anotherpkg/missing.txt\" does not exist at revision \"main\"", 404) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/notathing?at=main&limit=100&type=true": + http.Error(w, "The path \"notathing\" does not exist at revision \"main\"", 404) + + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/return-redirect?at=main&limit=100&type=true": + http.Redirect(w, r, "http://"+r.Host+"/rest/api/1.0/projects/PROJECT/repos/REPO/browse/redirected?at=main&limit=100&type=true", 301) + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/redirected?at=main&limit=100&type=true": + _, err = io.WriteString(w, `{"type":"DIRECTORY"}`) + + case "/rest/api/1.0/projects/PROJECT/repos/REPO/browse/unauthorized-response?at=main&limit=100&type=true": + http.Error(w, "Authentication failed", 401) + + default: + t.Fail() + } + if err != nil { + t.Fail() + } + })) + defer ts.Close() + provider, err := NewBitbucketServerProviderNoAuth(context.Background(), ts.URL, "PROJECT", true) + assert.NoError(t, err) + repo := &Repository{ + Organization: "PROJECT", + Repository: "REPO", + Branch: "main", + } + ok, err := provider.RepoHasPath(context.Background(), repo, "pkg") + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = provider.RepoHasPath(context.Background(), repo, "pkg/") + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = provider.RepoHasPath(context.Background(), repo, "anotherpkg/file.txt") + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = provider.RepoHasPath(context.Background(), repo, "anotherpkg/missing.txt") + assert.NoError(t, err) + assert.False(t, ok) + + ok, err = provider.RepoHasPath(context.Background(), repo, "notathing") + assert.NoError(t, err) + assert.False(t, ok) + + ok, err = provider.RepoHasPath(context.Background(), repo, "return-redirect") + assert.NoError(t, err) + assert.True(t, ok) + + _, err = provider.RepoHasPath(context.Background(), repo, "unauthorized-response") + assert.Error(t, err) +} diff --git a/pkg/services/scm_provider/utils.go b/pkg/services/scm_provider/utils.go index 07f29a59..67eccb94 100644 --- a/pkg/services/scm_provider/utils.go +++ b/pkg/services/scm_provider/utils.go @@ -34,7 +34,7 @@ func compileFilters(filters []argoprojiov1alpha1.SCMProviderGeneratorFilter) ([] if filter.BranchMatch != nil { outFilter.BranchMatch, err = regexp.Compile(*filter.BranchMatch) if err != nil { - return nil, fmt.Errorf("error compiling BranchMatch regexp %q: %v", *filter.LabelMatch, err) + return nil, fmt.Errorf("error compiling BranchMatch regexp %q: %v", *filter.BranchMatch, err) } outFilter.FilterType = FilterTypeBranch } diff --git a/pkg/utils/util.go b/pkg/utils/util.go index 1436cb23..a0b6dc25 100644 --- a/pkg/utils/util.go +++ b/pkg/utils/util.go @@ -176,3 +176,13 @@ func addInvalidGeneratorNames(names map[string]bool, applicationSetInfo *argopro break } } + +func NormalizeBitbucketBasePath(basePath string) string { + if strings.HasSuffix(basePath, "/rest/") { + return strings.TrimSuffix(basePath, "/") + } + if !strings.HasSuffix(basePath, "/rest") { + return basePath + "/rest" + } + return basePath +} diff --git a/pkg/utils/util_test.go b/pkg/utils/util_test.go index d56b25c7..ca5fd1fb 100644 --- a/pkg/utils/util_test.go +++ b/pkg/utils/util_test.go @@ -649,3 +649,30 @@ func TestInvalidGenerators(t *testing.T) { assert.Equal(t, c.expectedNames, names, c.testName) } } + +func TestNormalizeBitbucketBasePath(t *testing.T) { + for _, c := range []struct { + testName string + basePath string + expectedBasePath string + }{ + { + testName: "default api url", + basePath: "https://company.bitbucket.com", + expectedBasePath: "https://company.bitbucket.com/rest", + }, + { + testName: "with /rest suffix", + basePath: "https://company.bitbucket.com/rest", + expectedBasePath: "https://company.bitbucket.com/rest", + }, + { + testName: "with /rest/ suffix", + basePath: "https://company.bitbucket.com/rest/", + expectedBasePath: "https://company.bitbucket.com/rest", + }, + } { + result := NormalizeBitbucketBasePath(c.basePath) + assert.Equal(t, c.expectedBasePath, result, c.testName) + } +}