// Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitlab import ( "crypto/md5" "encoding/hex" "fmt" "net/http" "strings" gitlab "gitlab.com/gitlab-org/api/client-go/v2" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( mergeRefs = "refs/merge-requests/%d/head" // merge request merged with base VisibilityLevelInternal = 10 stateOpened = "opened" actionOpen = "open" actionClose = "close" actionReopen = "reopen" actionMerge = "merge" actionUpdate = "update" metadataReasonAssigned = "assigned" metadataReasonUnassigned = "unassigned" metadataReasonMilestoned = "milestoned" metadataReasonDemilestoned = "demilestoned" metadataReasonTitleEdited = "title_edited" metadataReasonDescriptionEdited = "description_edited" metadataReasonLabelsAdded = "labels_added" metadataReasonLabelsCleared = "labels_cleared" metadataReasonLabelsUpdated = "labels_updated" metadataReasonReviewRequested = "review_requested" ) func (g *GitLab) convertGitLabRepo(_repo *gitlab.Project, projectMember *gitlab.ProjectMember) (*model.Repo, error) { parts := strings.Split(_repo.PathWithNamespace, "/") owner := strings.Join(parts[:len(parts)-1], "/") name := parts[len(parts)-1] repo := &model.Repo{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(_repo.ID)), Owner: owner, Name: name, FullName: _repo.PathWithNamespace, Avatar: _repo.AvatarURL, ForgeURL: _repo.WebURL, Clone: _repo.HTTPURLToRepo, CloneSSH: _repo.SSHURLToRepo, Branch: _repo.DefaultBranch, Visibility: model.RepoVisibility(_repo.Visibility), IsSCMPrivate: _repo.Visibility == gitlab.InternalVisibility || _repo.Visibility == gitlab.PrivateVisibility, Perm: &model.Perm{ Pull: isRead(_repo, projectMember), Push: isWrite(projectMember), Admin: isAdmin(projectMember), }, PREnabled: _repo.MergeRequestsAccessLevel != gitlab.DisabledAccessControl, } if len(repo.Avatar) != 0 && !strings.HasPrefix(repo.Avatar, "http") { repo.Avatar = fmt.Sprintf("%s/%s", g.url, repo.Avatar) } return repo, nil } func convertMergeRequestHook(hook *gitlab.MergeEvent, req *http.Request) (mergeID, milestoneID int64, repo *model.Repo, pipeline *model.Pipeline, err error) { repo = &model.Repo{} pipeline = &model.Pipeline{} target := hook.ObjectAttributes.Target source := hook.ObjectAttributes.Source obj := hook.ObjectAttributes switch obj.Action { case actionClose, actionMerge: // pull close event pipeline.Event = model.EventPullClosed case actionOpen, actionReopen: // pull open event -> pull event pipeline.Event = model.EventPull case actionUpdate: if obj.OldRev != "" && obj.State == stateOpened { // if some git action happened then OldRev != "" -> it's a normal pull_request trigger // https://github.com/woodpecker-ci/woodpecker/pull/3338 // https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#merge-request-events pipeline.Event = model.EventPull break } pipeline.Event = model.EventPullMetadata // All changes are just update actions ... so we have to look into the changes section var reason []string if len(hook.Changes.Assignees.Current) != 0 { reason = append(reason, metadataReasonAssigned) } if len(hook.Changes.Assignees.Previous) != 0 { reason = append(reason, metadataReasonUnassigned) } if hook.Changes.MilestoneID.Current != 0 { reason = append(reason, metadataReasonMilestoned) } if hook.Changes.MilestoneID.Previous != 0 { reason = append(reason, metadataReasonDemilestoned) } if len(hook.Changes.Title.Current) != 0 || len(hook.Changes.Title.Previous) != 0 { reason = append(reason, metadataReasonTitleEdited) } if len(hook.Changes.Description.Current) != 0 || len(hook.Changes.Description.Previous) != 0 { reason = append(reason, metadataReasonDescriptionEdited) } switch { case len(hook.Changes.Labels.Current) != 0 && len(hook.Changes.Labels.Previous) == 0: reason = append(reason, metadataReasonLabelsAdded) case len(hook.Changes.Labels.Current) == 0 && len(hook.Changes.Labels.Previous) != 0: reason = append(reason, metadataReasonLabelsCleared) case len(hook.Changes.Labels.Current) != 0 && len(hook.Changes.Labels.Previous) != 0: reason = append(reason, metadataReasonLabelsUpdated) } if len(hook.Changes.Reviewers.Current) > len(hook.Changes.Reviewers.Previous) { reason = append(reason, metadataReasonReviewRequested) } for i := range reason { reason[i] = common.NormalizeEventReason(reason[i]) } pipeline.EventReason = reason if len(pipeline.EventReason) == 0 { return 0, 0, nil, nil, &types.ErrIgnoreEvent{ Event: "Merge Request Hook", Reason: fmt.Sprintf("Action '%s' no supported changes detected", obj.Action), } } default: // non supported action return 0, 0, nil, nil, &types.ErrIgnoreEvent{ Event: "Merge Request Hook", Reason: fmt.Sprintf("Action '%s' not supported", obj.Action), } } switch { case target == nil && source == nil: return 0, 0, nil, nil, fmt.Errorf("target and source keys expected in merge request hook") case target == nil: return 0, 0, nil, nil, fmt.Errorf("target key expected in merge request hook") case source == nil: return 0, 0, nil, nil, fmt.Errorf("source key expected in merge request hook") } if target.PathWithNamespace != "" { var err error if repo.Owner, repo.Name, err = extractFromPath(target.PathWithNamespace); err != nil { return 0, 0, nil, nil, err } repo.FullName = target.PathWithNamespace } else { repo.Owner = req.FormValue("owner") repo.Name = req.FormValue("name") repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name) } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(obj.TargetProjectID)) repo.ForgeURL = target.WebURL if target.GitHTTPURL != "" { repo.Clone = target.GitHTTPURL } else { repo.Clone = target.HTTPURL } if target.GitSSHURL != "" { repo.CloneSSH = target.GitSSHURL } else { repo.CloneSSH = target.SSHURL } repo.Branch = target.DefaultBranch if target.AvatarURL != "" { repo.Avatar = target.AvatarURL } lastCommit := obj.LastCommit pipeline.Message = lastCommit.Message pipeline.Commit = lastCommit.ID pipeline.Ref = fmt.Sprintf(mergeRefs, obj.IID) pipeline.Branch = obj.SourceBranch pipeline.Refspec = fmt.Sprintf("%s:%s", obj.SourceBranch, obj.TargetBranch) author := lastCommit.Author pipeline.Author = author.Name pipeline.Email = author.Email if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } pipeline.Title = obj.Title pipeline.ForgeURL = obj.URL pipeline.PullRequestLabels = convertLabels(hook.Labels) pipeline.FromFork = target.PathWithNamespace != source.PathWithNamespace return obj.IID, hook.ObjectAttributes.MilestoneID, repo, pipeline, nil } func convertPushHook(hook *gitlab.PushEvent) (*model.Repo, *model.Pipeline, error) { repo := &model.Repo{} pipeline := &model.Pipeline{} var err error if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil { return nil, nil, err } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.ProjectID)) repo.Avatar = hook.Project.AvatarURL repo.ForgeURL = hook.Project.WebURL repo.Clone = hook.Project.GitHTTPURL repo.CloneSSH = hook.Project.GitSSHURL repo.FullName = hook.Project.PathWithNamespace repo.Branch = hook.Project.DefaultBranch switch hook.Project.Visibility { case gitlab.PrivateVisibility: repo.IsSCMPrivate = true case gitlab.InternalVisibility: repo.IsSCMPrivate = true case gitlab.PublicVisibility: repo.IsSCMPrivate = false } pipeline.Event = model.EventPush pipeline.Commit = hook.After pipeline.Branch = strings.TrimPrefix(hook.Ref, "refs/heads/") pipeline.Ref = hook.Ref // assume a capacity of 4 changed files per commit files := make([]string, 0, len(hook.Commits)*4) for _, cm := range hook.Commits { if hook.After == cm.ID { pipeline.Author = cm.Author.Name pipeline.Email = cm.Author.Email pipeline.Message = cm.Message pipeline.Timestamp = cm.Timestamp.Unix() if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } } files = append(files, cm.Added...) files = append(files, cm.Removed...) files = append(files, cm.Modified...) } pipeline.ChangedFiles = utils.DeduplicateStrings(files) return repo, pipeline, nil } func convertTagHook(hook *gitlab.TagEvent) (*model.Repo, *model.Pipeline, string, error) { repo := &model.Repo{} pipeline := &model.Pipeline{} var err error if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil { return nil, nil, "", err } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.ProjectID)) repo.Avatar = hook.Project.AvatarURL repo.ForgeURL = hook.Project.WebURL repo.Clone = hook.Project.GitHTTPURL repo.CloneSSH = hook.Project.GitSSHURL repo.FullName = hook.Project.PathWithNamespace repo.Branch = hook.Project.DefaultBranch switch hook.Project.Visibility { case gitlab.PrivateVisibility: repo.IsSCMPrivate = true case gitlab.InternalVisibility: repo.IsSCMPrivate = true case gitlab.PublicVisibility: repo.IsSCMPrivate = false } refTag := strings.TrimPrefix(hook.Ref, "refs/heads/") pipeline.Event = model.EventTag pipeline.Commit = hook.After pipeline.Branch = refTag pipeline.Ref = hook.Ref for _, cm := range hook.Commits { if hook.After == cm.ID { pipeline.Author = cm.Author.Name pipeline.Email = cm.Author.Email pipeline.Message = cm.Message pipeline.Timestamp = cm.Timestamp.Unix() if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } break } } return repo, pipeline, hook.After, nil } func convertReleaseHook(hook *gitlab.ReleaseEvent) (*model.Repo, *model.Pipeline, error) { repo := &model.Repo{} var err error if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil { return nil, nil, err } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.Project.ID)) repo.Avatar = "" if hook.Project.AvatarURL != nil { repo.Avatar = *hook.Project.AvatarURL } repo.ForgeURL = hook.Project.WebURL repo.Clone = hook.Project.GitHTTPURL repo.CloneSSH = hook.Project.GitSSHURL repo.FullName = hook.Project.PathWithNamespace repo.Branch = hook.Project.DefaultBranch repo.IsSCMPrivate = hook.Project.VisibilityLevel > VisibilityLevelInternal pipeline := &model.Pipeline{ Event: model.EventRelease, Commit: hook.Commit.ID, ForgeURL: hook.URL, Message: fmt.Sprintf("created release %s", hook.Name), Sender: hook.Commit.Author.Name, Author: hook.Commit.Author.Name, Email: hook.Commit.Author.Email, // Tag name here is the ref. We should add the refs/tags, so // it is known it's a tag (git-plugin looks for it) Ref: "refs/tags/" + hook.Tag, } if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } return repo, pipeline, nil } func getUserAvatar(email string) string { hasher := md5.New() hasher.Write([]byte(email)) return fmt.Sprintf( "%s/%v.jpg?s=%s", gravatarBase, hex.EncodeToString(hasher.Sum(nil)), "128", ) } // extractFromPath splits a repository path string into owner and name components. // It requires at least two path components, otherwise an error is returned. func extractFromPath(str string) (string, string, error) { const minPathComponents = 2 s := strings.Split(str, "/") if len(s) < minPathComponents { return "", "", fmt.Errorf("minimum match not found") } owner := strings.Join(s[:len(s)-1], "/") name := s[len(s)-1] return owner, name, nil } func convertLabels(from []*gitlab.EventLabel) []string { labels := make([]string, len(from)) for i, label := range from { labels[i] = label.Title } return labels }