woodpecker/server/forge/gitlab/gitlab.go
qwerty287 0970f35df5
Do not store inactive repos (#1658)
Do not sync repos with forge if the repo is not necessary in DB.

In the DB, only repos that were active once or repos that are currently
active are stored. When trying to enable new repos, the repos list is
fetched from the forge instead and displayed directly. In addition to
this, the forge func `Perm` was removed and is now merged with `Repo`.

Solves a TODO on RepoBatch.

---------

Co-authored-by: Anbraten <anton@ju60.de>
2023-03-21 23:01:59 +01:00

727 lines
19 KiB
Go

// Copyright 2022 Woodpecker Authors
// Copyright 2018 Drone.IO Inc.
//
// 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 (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/xanzy/go-gitlab"
"golang.org/x/oauth2"
"github.com/woodpecker-ci/woodpecker/server"
"github.com/woodpecker-ci/woodpecker/server/forge"
"github.com/woodpecker-ci/woodpecker/server/forge/common"
forge_types "github.com/woodpecker-ci/woodpecker/server/forge/types"
"github.com/woodpecker-ci/woodpecker/server/model"
"github.com/woodpecker-ci/woodpecker/server/store"
"github.com/woodpecker-ci/woodpecker/shared/utils"
)
const (
defaultScope = "api"
perPage = 100
)
// Opts defines configuration options.
type Opts struct {
URL string // Gitlab server url.
ClientID string // Oauth2 client id.
ClientSecret string // Oauth2 client secret.
SkipVerify bool // Skip ssl verification.
}
// Gitlab implements "Forge" interface
type GitLab struct {
URL string
ClientID string
ClientSecret string
SkipVerify bool
HideArchives bool
Search bool
}
// New returns a Forge implementation that integrates with Gitlab, an open
// source Git service. See https://gitlab.com
func New(opts Opts) (forge.Forge, error) {
return &GitLab{
URL: opts.URL,
ClientID: opts.ClientID,
ClientSecret: opts.ClientSecret,
SkipVerify: opts.SkipVerify,
}, nil
}
// Name returns the string name of this driver
func (g *GitLab) Name() string {
return "gitlab"
}
func (g *GitLab) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) {
return &oauth2.Config{
ClientID: g.ClientID,
ClientSecret: g.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/oauth/authorize", g.URL),
TokenURL: fmt.Sprintf("%s/oauth/token", g.URL),
},
Scopes: []string{defaultScope},
RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost),
},
context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: g.SkipVerify},
Proxy: http.ProxyFromEnvironment,
}})
}
// Login authenticates the session and returns the
// forge user details.
func (g *GitLab) Login(ctx context.Context, res http.ResponseWriter, req *http.Request) (*model.User, error) {
config, oauth2Ctx := g.oauth2Config(ctx)
// get the OAuth errors
if err := req.FormValue("error"); err != "" {
return nil, &forge_types.AuthError{
Err: err,
Description: req.FormValue("error_description"),
URI: req.FormValue("error_uri"),
}
}
// get the OAuth code
code := req.FormValue("code")
if len(code) == 0 {
http.Redirect(res, req, config.AuthCodeURL("woodpecker"), http.StatusSeeOther)
return nil, nil
}
token, err := config.Exchange(oauth2Ctx, code)
if err != nil {
return nil, fmt.Errorf("Error exchanging token. %w", err)
}
client, err := newClient(g.URL, token.AccessToken, g.SkipVerify)
if err != nil {
return nil, err
}
login, _, err := client.Users.CurrentUser(gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
user := &model.User{
Login: login.Username,
Email: login.Email,
Avatar: login.AvatarURL,
Token: token.AccessToken,
Secret: token.RefreshToken,
}
if !strings.HasPrefix(user.Avatar, "http") {
user.Avatar = g.URL + "/" + login.AvatarURL
}
return user, nil
}
// Refresh refreshes the Gitlab oauth2 access token. If the token is
// refreshed the user is updated and a true value is returned.
func (g *GitLab) Refresh(ctx context.Context, user *model.User) (bool, error) {
config, oauth2Ctx := g.oauth2Config(ctx)
config.RedirectURL = ""
source := config.TokenSource(oauth2Ctx, &oauth2.Token{
AccessToken: user.Token,
RefreshToken: user.Secret,
Expiry: time.Unix(user.Expiry, 0),
})
token, err := source.Token()
if err != nil || len(token.AccessToken) == 0 {
return false, err
}
user.Token = token.AccessToken
user.Secret = token.RefreshToken
user.Expiry = token.Expiry.UTC().Unix()
return true, nil
}
// Auth authenticates the session and returns the forge user login for the given token
func (g *GitLab) Auth(ctx context.Context, token, _ string) (string, error) {
client, err := newClient(g.URL, token, g.SkipVerify)
if err != nil {
return "", err
}
login, _, err := client.Users.CurrentUser(gitlab.WithContext(ctx))
if err != nil {
return "", err
}
return login.Username, nil
}
// Teams fetches a list of team memberships from the forge.
func (g *GitLab) Teams(ctx context.Context, user *model.User) ([]*model.Team, error) {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return nil, err
}
teams := make([]*model.Team, 0, perPage)
for i := 1; true; i++ {
batch, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{
ListOptions: gitlab.ListOptions{Page: i, PerPage: perPage},
AllAvailable: gitlab.Bool(false),
MinAccessLevel: gitlab.AccessLevel(gitlab.DeveloperPermissions), // TODO: check what's best here
}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
for i := range batch {
teams = append(teams, &model.Team{
Login: batch[i].Name,
Avatar: batch[i].AvatarURL,
},
)
}
if len(batch) < perPage {
break
}
}
return teams, nil
}
// getProject fetches the named repository from the forge.
func (g *GitLab) getProject(ctx context.Context, client *gitlab.Client, owner, name string) (*gitlab.Project, error) {
repo, _, err := client.Projects.GetProject(fmt.Sprintf("%s/%s", owner, name), nil, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
return repo, nil
}
// Repo fetches the repository from the forge.
func (g *GitLab) Repo(ctx context.Context, user *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return nil, err
}
if remoteID.IsValid() {
intID, err := strconv.ParseInt(string(remoteID), 10, 64)
if err != nil {
return nil, err
}
_repo, _, err := client.Projects.GetProject(int(intID), nil, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
return g.convertGitLabRepo(_repo)
}
_repo, err := g.getProject(ctx, client, owner, name)
if err != nil {
return nil, err
}
return g.convertGitLabRepo(_repo)
}
// Repos fetches a list of repos from the forge.
func (g *GitLab) Repos(ctx context.Context, user *model.User) ([]*model.Repo, error) {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return nil, err
}
repos := make([]*model.Repo, 0, perPage)
opts := &gitlab.ListProjectsOptions{
ListOptions: gitlab.ListOptions{PerPage: perPage},
MinAccessLevel: gitlab.AccessLevel(gitlab.DeveloperPermissions), // TODO: check what's best here
}
if g.HideArchives {
opts.Archived = gitlab.Bool(false)
}
for i := 1; true; i++ {
opts.Page = i
batch, _, err := client.Projects.ListProjects(opts, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
for i := range batch {
repo, err := g.convertGitLabRepo(batch[i])
if err != nil {
return nil, err
}
// TODO(648) remove when woodpecker understands nested repos
if strings.Count(repo.FullName, "/") > 1 {
log.Debug().Msgf("Skipping nested repository %s for user %s, because they are not supported, yet (see #648).", repo.FullName, user.Login)
continue
}
repos = append(repos, repo)
}
if len(batch) < perPage {
break
}
}
return repos, err
}
func (g *GitLab) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.PaginationData) ([]*model.PullRequest, error) {
token := ""
if u != nil {
token = u.Token
}
client, err := newClient(g.URL, token, g.SkipVerify)
if err != nil {
return nil, err
}
_repo, err := g.getProject(ctx, client, r.Owner, r.Name)
if err != nil {
return nil, err
}
state := "open"
pullRequests, _, err := client.MergeRequests.ListProjectMergeRequests(_repo.ID, &gitlab.ListProjectMergeRequestsOptions{
ListOptions: gitlab.ListOptions{Page: int(p.Page), PerPage: int(p.PerPage)},
State: &state,
})
if err != nil {
return nil, err
}
result := make([]*model.PullRequest, len(pullRequests))
for i := range pullRequests {
result[i] = &model.PullRequest{
Index: int64(pullRequests[i].ID),
Title: pullRequests[i].Title,
}
}
return result, err
}
// File fetches a file from the forge repository and returns in string format.
func (g *GitLab) File(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, fileName string) ([]byte, error) {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return nil, err
}
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return nil, err
}
file, _, err := client.RepositoryFiles.GetRawFile(_repo.ID, fileName, &gitlab.GetRawFileOptions{Ref: &pipeline.Commit}, gitlab.WithContext(ctx))
return file, err
}
// Dir fetches a folder from the forge repository
func (g *GitLab) Dir(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, path string) ([]*forge_types.FileMeta, error) {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return nil, err
}
files := make([]*forge_types.FileMeta, 0, perPage)
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return nil, err
}
opts := &gitlab.ListTreeOptions{
ListOptions: gitlab.ListOptions{PerPage: perPage},
Path: &path,
Ref: &pipeline.Commit,
Recursive: gitlab.Bool(false),
}
for i := 1; true; i++ {
opts.Page = 1
batch, _, err := client.Repositories.ListTree(_repo.ID, opts, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
for i := range batch {
if batch[i].Type != "blob" { // no file
continue
}
data, err := g.File(ctx, user, repo, pipeline, batch[i].Path)
if err != nil {
return nil, err
}
files = append(files, &forge_types.FileMeta{
Name: batch[i].Path,
Data: data,
})
}
if len(batch) < perPage {
break
}
}
return files, nil
}
// Status sends the commit status back to gitlab.
func (g *GitLab) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, step *model.Step) error {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return err
}
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return err
}
_, _, err = client.Commits.SetCommitStatus(_repo.ID, pipeline.Commit, &gitlab.SetCommitStatusOptions{
State: getStatus(step.State),
Description: gitlab.String(common.GetPipelineStatusDescription(step.State)),
TargetURL: gitlab.String(common.GetPipelineStatusLink(repo, pipeline, step)),
Context: gitlab.String(common.GetPipelineStatusContext(repo, pipeline, step)),
}, gitlab.WithContext(ctx))
return err
}
// Netrc returns a netrc file capable of authenticating Gitlab requests and
// cloning Gitlab repositories. The netrc will use the global machine account
// when configured.
func (g *GitLab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
login := ""
token := ""
if u != nil {
login = "oauth2"
token = u.Token
}
host, err := common.ExtractHostFromCloneURL(r.Clone)
if err != nil {
return nil, err
}
return &model.Netrc{
Login: login,
Password: token,
Machine: host,
}, nil
}
func (g *GitLab) getTokenAndWebURL(link string) (token, webURL string, err error) {
uri, err := url.Parse(link)
if err != nil {
return "", "", err
}
token = uri.Query().Get("access_token")
webURL = fmt.Sprintf("%s://%s/api/hook", uri.Scheme, uri.Host)
return token, webURL, nil
}
// Activate activates a repository by adding a Post-commit hook and
// a Public Deploy key, if applicable.
func (g *GitLab) Activate(ctx context.Context, user *model.User, repo *model.Repo, link string) error {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return err
}
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return err
}
token, webURL, err := g.getTokenAndWebURL(link)
if err != nil {
return err
}
if len(token) == 0 {
return fmt.Errorf("no token found")
}
_, _, err = client.Projects.AddProjectHook(_repo.ID, &gitlab.AddProjectHookOptions{
URL: gitlab.String(webURL),
Token: gitlab.String(token),
PushEvents: gitlab.Bool(true),
TagPushEvents: gitlab.Bool(true),
MergeRequestsEvents: gitlab.Bool(true),
DeploymentEvents: gitlab.Bool(true),
EnableSSLVerification: gitlab.Bool(!g.SkipVerify),
}, gitlab.WithContext(ctx))
return err
}
// Deactivate removes a repository by removing all the post-commit hooks
// which are equal to link and removing the SSH deploy key.
func (g *GitLab) Deactivate(ctx context.Context, user *model.User, repo *model.Repo, link string) error {
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return err
}
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return err
}
_, webURL, err := g.getTokenAndWebURL(link)
if err != nil {
return err
}
hookID := -1
listProjectHooksOptions := &gitlab.ListProjectHooksOptions{
PerPage: 10,
Page: 1,
}
for {
hooks, resp, err := client.Projects.ListProjectHooks(_repo.ID, listProjectHooksOptions, gitlab.WithContext(ctx))
if err != nil {
return err
}
for _, hook := range hooks {
if hook.URL == webURL {
hookID = hook.ID
break
}
}
// Exit the loop when we've seen all pages
if resp.CurrentPage >= resp.TotalPages {
break
}
// Update the page number to get the next page
listProjectHooksOptions.Page = resp.NextPage
}
if hookID == -1 {
return fmt.Errorf("could not find hook to delete")
}
_, err = client.Projects.DeleteProjectHook(_repo.ID, hookID, gitlab.WithContext(ctx))
return err
}
// Branches returns the names of all branches for the named repository.
func (g *GitLab) Branches(ctx context.Context, user *model.User, repo *model.Repo) ([]string, error) {
token := ""
if user != nil {
token = user.Token
}
client, err := newClient(g.URL, token, g.SkipVerify)
if err != nil {
return nil, err
}
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return nil, err
}
gitlabBranches, _, err := client.Branches.ListBranches(_repo.ID, &gitlab.ListBranchesOptions{}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
branches := make([]string, 0)
for _, branch := range gitlabBranches {
branches = append(branches, branch.Name)
}
return branches, nil
}
// BranchHead returns the sha of the head (latest commit) of the specified branch
func (g *GitLab) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (string, error) {
token := ""
if u != nil {
token = u.Token
}
client, err := newClient(g.URL, token, g.SkipVerify)
if err != nil {
return "", err
}
_repo, err := g.getProject(ctx, client, r.Owner, r.Name)
if err != nil {
return "", err
}
b, _, err := client.Branches.GetBranch(_repo.ID, branch, gitlab.WithContext(ctx))
if err != nil {
return "", err
}
return b.Commit.ID, nil
}
// Hook parses the post-commit hook from the Request body
// and returns the required data in a standard format.
func (g *GitLab) Hook(ctx context.Context, req *http.Request) (*model.Repo, *model.Pipeline, error) {
defer req.Body.Close()
payload, err := io.ReadAll(req.Body)
if err != nil {
return nil, nil, err
}
parsed, err := gitlab.ParseWebhook(gitlab.WebhookEventType(req), payload)
if err != nil {
return nil, nil, err
}
switch event := parsed.(type) {
case *gitlab.MergeEvent:
mergeIID, repo, pipeline, err := convertMergeRequestHook(event, req)
if err != nil {
return nil, nil, err
}
if pipeline, err = g.loadChangedFilesFromMergeRequest(ctx, repo, pipeline, mergeIID); err != nil {
return nil, nil, err
}
return repo, pipeline, nil
case *gitlab.PushEvent:
return convertPushHook(event)
case *gitlab.TagEvent:
return convertTagHook(event)
default:
return nil, nil, nil
}
}
// OrgMembership returns if user is member of organization and if user
// is admin/owner in this organization.
func (g *GitLab) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {
client, err := newClient(g.URL, u.Token, g.SkipVerify)
if err != nil {
return nil, err
}
groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{
ListOptions: gitlab.ListOptions{
Page: 1,
PerPage: 100,
},
Search: gitlab.String(owner),
}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
var gid int
for _, group := range groups {
if group.Name == owner {
gid = group.ID
break
}
}
if gid == 0 {
return &model.OrgPerm{}, nil
}
opts := &gitlab.ListGroupMembersOptions{
ListOptions: gitlab.ListOptions{
Page: 1,
PerPage: 100,
},
}
for i := 1; true; i++ {
opts.Page = i
members, _, err := client.Groups.ListAllGroupMembers(gid, opts, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
for _, member := range members {
if member.Username == u.Login {
return &model.OrgPerm{Member: true, Admin: member.AccessLevel >= gitlab.OwnerPermissions}, nil
}
}
if len(members) < opts.PerPage {
break
}
}
return &model.OrgPerm{}, nil
}
func (g *GitLab) loadChangedFilesFromMergeRequest(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, mergeIID int) (*model.Pipeline, error) {
_store, ok := store.TryFromContext(ctx)
if !ok {
log.Error().Msg("could not get store from context")
return pipeline, nil
}
repo, err := _store.GetRepoNameFallback(tmpRepo.ForgeRemoteID, tmpRepo.FullName)
if err != nil {
return nil, err
}
user, err := _store.GetUser(repo.UserID)
if err != nil {
return nil, err
}
client, err := newClient(g.URL, user.Token, g.SkipVerify)
if err != nil {
return nil, err
}
_repo, err := g.getProject(ctx, client, repo.Owner, repo.Name)
if err != nil {
return nil, err
}
changes, _, err := client.MergeRequests.GetMergeRequestChanges(_repo.ID, mergeIID, &gitlab.GetMergeRequestChangesOptions{}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
files := make([]string, 0, len(changes.Changes)*2)
for _, file := range changes.Changes {
files = append(files, file.NewPath, file.OldPath)
}
pipeline.ChangedFiles = utils.DedupStrings(files)
return pipeline, nil
}