Spaces:
Paused
Paused
| // 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 | |
| } | |