@@ -10,6 +10,9 @@ import (
1010 "regexp"
1111 "strings"
1212 "sync"
13+ "time"
14+
15+ bb "github.com/ktrysmt/go-bitbucket"
1316
1417 "github.com/go-playground/webhooks/v6/azuredevops"
1518 "github.com/go-playground/webhooks/v6/bitbucket"
@@ -29,6 +32,7 @@ import (
2932 "github.com/argoproj/argo-cd/v3/util/app/path"
3033 "github.com/argoproj/argo-cd/v3/util/argo"
3134 "github.com/argoproj/argo-cd/v3/util/db"
35+ "github.com/argoproj/argo-cd/v3/util/git"
3236 "github.com/argoproj/argo-cd/v3/util/glob"
3337 "github.com/argoproj/argo-cd/v3/util/settings"
3438)
@@ -61,6 +65,7 @@ type ArgoCDWebhookHandler struct {
6165 bitbucketserver * bitbucketserver.Webhook
6266 azuredevops * azuredevops.Webhook
6367 gogs * gogs.Webhook
68+ settings * settings.ArgoCDSettings
6469 settingsSrc settingsSource
6570 queue chan any
6671 maxWebhookPayloadSizeB int64
@@ -105,6 +110,7 @@ func NewHandler(namespace string, applicationNamespaces []string, webhookParalle
105110 settingsSrc : settingsSrc ,
106111 repoCache : repoCache ,
107112 serverCache : serverCache ,
113+ settings : set ,
108114 db : argoDB ,
109115 queue : make (chan any , payloadQueueSize ),
110116 maxWebhookPayloadSizeB : maxWebhookPayloadSizeB ,
@@ -137,8 +143,8 @@ func ParseRevision(ref string) string {
137143}
138144
139145// affectedRevisionInfo examines a payload from a webhook event, and extracts the repo web URL,
140- // the revision, and whether or not this affected origin/HEAD (the default branch of the repository)
141- func affectedRevisionInfo (payloadIf any ) (webURLs []string , revision string , change changeInfo , touchedHead bool , changedFiles []string ) {
146+ // the revision, and whether, or not this affected origin/HEAD (the default branch of the repository)
147+ func ( a * ArgoCDWebhookHandler ) affectedRevisionInfo (payloadIf any ) (webURLs []string , revision string , change changeInfo , touchedHead bool , changedFiles []string ) {
142148 switch payload := payloadIf .(type ) {
143149 case azuredevops.GitPushEvent :
144150 // See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push
@@ -189,16 +195,55 @@ func affectedRevisionInfo(payloadIf any) (webURLs []string, revision string, cha
189195 // See: https://confluence.atlassian.com/bitbucket/event-payloads-740262817.html#EventPayloads-Push
190196 // NOTE: this is untested
191197 webURLs = append (webURLs , payload .Repository .Links .HTML .Href )
192- // TODO: bitbucket includes multiple changes as part of a single event.
193- // We only pick the first but need to consider how to handle multiple
194- for _ , change := range payload . Push . Changes {
195- revision = change .New .Name
198+ for _ , changes := range payload . Push . Changes {
199+ revision = changes . New . Name
200+ change . shaBefore = changes . Old . Target . Hash
201+ change . shaAfter = changes .New .Target . Hash
196202 break
197203 }
198204 // Not actually sure how to check if the incoming change affected HEAD just by examining the
199205 // payload alone. To be safe, we just return true and let the controller check for himself.
200206 touchedHead = true
201207
208+ // Get DiffSet only for authenticated webhooks.
209+ // when WebhookBitbucketUUID is set in argocd-secret, then the payload must be signed and
210+ // signature is validated before payload is parsed.
211+ if len (a .settings .WebhookBitbucketUUID ) > 0 {
212+ ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
213+ defer cancel ()
214+ argoRepo , err := a .lookupRepository (ctx , webURLs [0 ])
215+ if err != nil {
216+ log .Warnf ("error trying to find a matching repo for URL %s: %v" , payload .Repository .Links .HTML .Href , err )
217+ break
218+ }
219+ if argoRepo == nil {
220+ // it could be a public repository with no repo creds stored.
221+ // initialize with empty bearer token to use the no auth bitbucket client.
222+ log .Debugf ("no bitbucket repository configured for URL %s, initializing with empty bearer token" , webURLs [0 ])
223+ argoRepo = & v1alpha1.Repository {BearerToken : "" , Repo : webURLs [0 ]}
224+ }
225+ apiBaseURL := strings .ReplaceAll (payload .Repository .Links .Self .Href , "/repositories/" + payload .Repository .FullName , "" )
226+ bbClient , err := newBitbucketClient (ctx , argoRepo , apiBaseURL )
227+ if err != nil {
228+ log .Warnf ("error creating Bitbucket client for repo %s: %v" , payload .Repository .Name , err )
229+ break
230+ }
231+ log .Debugf ("created bitbucket client with base URL '%s'" , apiBaseURL )
232+ owner := strings .ReplaceAll (payload .Repository .FullName , "/" + payload .Repository .Name , "" )
233+ spec := change .shaBefore + ".." + change .shaAfter
234+ diffStatChangedFiles , err := fetchDiffStatFromBitbucket (ctx , bbClient , owner , payload .Repository .Name , spec )
235+ if err != nil {
236+ log .Warnf ("error fetching changed files using bitbucket diffstat api: %v" , err )
237+ }
238+ changedFiles = append (changedFiles , diffStatChangedFiles ... )
239+ touchedHead , err = isHeadTouched (ctx , bbClient , owner , payload .Repository .Name , revision )
240+ if err != nil {
241+ log .Warnf ("error fetching bitbucket repo details: %v" , err )
242+ // To be safe, we just return true and let the controller check for himself.
243+ touchedHead = true
244+ }
245+ }
246+
202247 // Bitbucket does not include a list of changed files anywhere in it's payload
203248 // so we cannot update changedFiles for this type of payload
204249 case bitbucketserver.RepositoryReferenceChangedPayload :
@@ -251,7 +296,7 @@ type changeInfo struct {
251296
252297// HandleEvent handles webhook events for repo push events
253298func (a * ArgoCDWebhookHandler ) HandleEvent (payload any ) {
254- webURLs , revision , change , touchedHead , changedFiles := affectedRevisionInfo (payload )
299+ webURLs , revision , change , touchedHead , changedFiles := a . affectedRevisionInfo (payload )
255300 // NOTE: the webURL does not include the .git extension
256301 if len (webURLs ) == 0 {
257302 log .Info ("Ignoring webhook event" )
@@ -405,6 +450,23 @@ func (a *ArgoCDWebhookHandler) storePreviouslyCachedManifests(app *v1alpha1.Appl
405450 return nil
406451}
407452
453+ // lookupRepository returns a repository with its credentials for a given URL. If there are no matching repository secret found,
454+ // then nil repository is returned.
455+ func (a * ArgoCDWebhookHandler ) lookupRepository (ctx context.Context , repoURL string ) (* v1alpha1.Repository , error ) {
456+ repositories , err := a .db .ListRepositories (ctx )
457+ if err != nil {
458+ return nil , fmt .Errorf ("error listing repositories: %w" , err )
459+ }
460+ var repository * v1alpha1.Repository
461+ for _ , repo := range repositories {
462+ if git .SameURL (repo .Repo , repoURL ) {
463+ log .Debugf ("found a matching repository for URL %s" , repoURL )
464+ return repo , nil
465+ }
466+ }
467+ return repository , nil
468+ }
469+
408470func sourceRevisionHasChanged (source v1alpha1.ApplicationSource , revision string , touchedHead bool ) bool {
409471 targetRev := ParseRevision (source .TargetRevision )
410472 if targetRev == "HEAD" || targetRev == "" { // revision is head
@@ -430,6 +492,76 @@ func sourceUsesURL(source v1alpha1.ApplicationSource, webURL string, repoRegexp
430492 return true
431493}
432494
495+ // newBitbucketClient creates a new bitbucket client for the given repository and uses the provided apiURL to connect
496+ // to the bitbucket server. If the repository uses basic auth, then a basic auth client is created or if bearer token
497+ // is provided, then oauth based client is created.
498+ func newBitbucketClient (_ context.Context , repository * v1alpha1.Repository , apiBaseURL string ) (* bb.Client , error ) {
499+ var bbClient * bb.Client
500+ if repository .Username != "" && repository .Password != "" {
501+ log .Debugf ("fetched user/password for repository URL '%s', initializing basic auth client" , repository .Repo )
502+ if repository .Username == "x-token-auth" {
503+ bbClient = bb .NewOAuthbearerToken (repository .Password )
504+ } else {
505+ bbClient = bb .NewBasicAuth (repository .Username , repository .Password )
506+ }
507+ } else {
508+ if repository .BearerToken != "" {
509+ log .Debugf ("fetched bearer token for repository URL '%s', initializing bearer token auth based client" , repository .Repo )
510+ } else {
511+ log .Debugf ("no credentials available for repository URL '%s', initializing no auth client" , repository .Repo )
512+ }
513+ bbClient = bb .NewOAuthbearerToken (repository .BearerToken )
514+ }
515+ // parse and set the target URL of the Bitbucket server in the client
516+ repoBaseURL , err := url .Parse (apiBaseURL )
517+ if err != nil {
518+ return nil , fmt .Errorf ("failed to parse bitbucket api base URL '%s'" , apiBaseURL )
519+ }
520+ bbClient .SetApiBaseURL (* repoBaseURL )
521+ return bbClient , nil
522+ }
523+
524+ // fetchDiffStatFromBitbucket gets the list of files changed between two commits, by making a diffstat api callback to the
525+ // bitbucket server from where the webhook orignated.
526+ func fetchDiffStatFromBitbucket (_ context.Context , bbClient * bb.Client , owner , repoSlug , spec string ) ([]string , error ) {
527+ // Getting the files changed from diff API:
528+ // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-diffstat-spec-get
529+
530+ // invoke the diffstat api call to get the list of changed files between two commit shas
531+ log .Debugf ("invoking diffstat call with parameters: [Owner:%s, RepoSlug:%s, Spec:%s]" , owner , repoSlug , spec )
532+ diffStatResp , err := bbClient .Repositories .Diff .GetDiffStat (& bb.DiffStatOptions {
533+ Owner : owner ,
534+ RepoSlug : repoSlug ,
535+ Spec : spec ,
536+ Renames : true ,
537+ })
538+ if err != nil {
539+ return nil , fmt .Errorf ("error getting the diffstat: %w" , err )
540+ }
541+ changedFiles := make ([]string , len (diffStatResp .DiffStats ))
542+ for i , value := range diffStatResp .DiffStats {
543+ changedFilePath := value .New ["path" ]
544+ if changedFilePath != nil {
545+ changedFiles [i ] = changedFilePath .(string )
546+ }
547+ }
548+ log .Debugf ("changed files for spec %s: %v" , spec , changedFiles )
549+ return changedFiles , nil
550+ }
551+
552+ // isHeadTouched returns true if the repository's main branch is modified, false otherwise
553+ func isHeadTouched (ctx context.Context , bbClient * bb.Client , owner , repoSlug , revision string ) (bool , error ) {
554+ bbRepoOptions := & bb.RepositoryOptions {
555+ Owner : owner ,
556+ RepoSlug : repoSlug ,
557+ }
558+ bbRepo , err := bbClient .Repositories .Repository .Get (bbRepoOptions .WithContext (ctx ))
559+ if err != nil {
560+ return false , err
561+ }
562+ return bbRepo .Mainbranch .Name == revision , nil
563+ }
564+
433565func (a * ArgoCDWebhookHandler ) Handler (w http.ResponseWriter , r * http.Request ) {
434566 var payload any
435567 var err error
0 commit comments