mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-08-05 20:08:37 +00:00
518 lines
16 KiB
Go
518 lines
16 KiB
Go
// Copyright 2024 Martijn van der Kleijn & Florian Beisel
|
|
//
|
|
// 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 gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.gitea.io/sdk/gitea"
|
|
"github.com/pkg/errors"
|
|
"github.com/runatlantis/atlantis/server/events/models"
|
|
"github.com/runatlantis/atlantis/server/logging"
|
|
)
|
|
|
|
// Emergency break for Gitea pagination (just in case)
|
|
// Set to 500 to prevent runaway situations
|
|
// Value chosen purposely high, though randomly.
|
|
const giteaPaginationEBreak = 500
|
|
|
|
type GiteaClient struct {
|
|
giteaClient *gitea.Client
|
|
username string
|
|
token string
|
|
pageSize int
|
|
ctx context.Context
|
|
}
|
|
|
|
type GiteaPRReviewSummary struct {
|
|
Reviews []GiteaReview
|
|
}
|
|
|
|
type GiteaReview struct {
|
|
ID int64
|
|
Body string
|
|
Reviewer string
|
|
State gitea.ReviewStateType // e.g., "APPROVED", "PENDING", "REQUEST_CHANGES"
|
|
SubmittedAt time.Time
|
|
}
|
|
|
|
type GiteaPullGetter interface {
|
|
GetPullRequest(repo models.Repo, pullNum int) (*gitea.PullRequest, error)
|
|
}
|
|
|
|
// NewClient builds a client that makes API calls to Gitea. httpClient is the
|
|
// client to use to make the requests, username and password are used as basic
|
|
// auth in the requests, baseURL is the API's baseURL, ex. https://corp.com:7990.
|
|
// Don't include the API version, ex. '/1.0'.
|
|
func NewClient(baseURL string, username string, token string, pagesize int, logger logging.SimpleLogging) (*GiteaClient, error) {
|
|
logger.Debug("Creating new Gitea client for: %s", baseURL)
|
|
|
|
giteaClient, err := gitea.NewClient(baseURL,
|
|
gitea.SetToken(token),
|
|
gitea.SetUserAgent("atlantis"),
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "creating gitea client")
|
|
}
|
|
|
|
return &GiteaClient{
|
|
giteaClient: giteaClient,
|
|
username: username,
|
|
token: token,
|
|
pageSize: pagesize,
|
|
ctx: context.Background(),
|
|
}, nil
|
|
}
|
|
|
|
func (c *GiteaClient) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*gitea.PullRequest, error) {
|
|
logger.Debug("Getting Gitea pull request %d", pullNum)
|
|
|
|
pr, resp, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pullNum))
|
|
|
|
if err != nil {
|
|
logger.Debug("GET /repos/%v/%v/pulls/%d returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode)
|
|
return nil, err
|
|
}
|
|
|
|
return pr, nil
|
|
}
|
|
|
|
// GetModifiedFiles returns the names of files that were modified in the merge request
|
|
// relative to the repo root, e.g. parent/child/file.txt.
|
|
func (c *GiteaClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
|
|
logger.Debug("Getting modified files for Gitea pull request %d", pull.Num)
|
|
|
|
changedFiles := make([]string, 0)
|
|
page := 0
|
|
nextPage := 1
|
|
listOptions := gitea.ListPullRequestFilesOptions{
|
|
ListOptions: gitea.ListOptions{
|
|
Page: 1,
|
|
PageSize: c.pageSize,
|
|
},
|
|
}
|
|
|
|
for page < nextPage {
|
|
page = +1
|
|
listOptions.ListOptions.Page = page
|
|
files, resp, err := c.giteaClient.ListPullRequestFiles(repo.Owner, repo.Name, int64(pull.Num), listOptions)
|
|
if err != nil {
|
|
logger.Debug("[page %d] GET /repos/%v/%v/pulls/%d/files returned: %v", page, repo.Owner, repo.Name, pull.Num, resp.StatusCode)
|
|
return nil, err
|
|
}
|
|
|
|
for _, file := range files {
|
|
changedFiles = append(changedFiles, file.Filename)
|
|
}
|
|
|
|
nextPage = resp.NextPage
|
|
|
|
// Emergency break after giteaPaginationEBreak pages
|
|
if page >= giteaPaginationEBreak {
|
|
break
|
|
}
|
|
}
|
|
|
|
return changedFiles, nil
|
|
}
|
|
|
|
// CreateComment creates a comment on the merge request. As far as we're aware, Gitea has no built in max comment length right now.
|
|
func (c *GiteaClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error {
|
|
logger.Debug("Creating comment on Gitea pull request %d", pullNum)
|
|
|
|
opt := gitea.CreateIssueCommentOption{
|
|
Body: comment,
|
|
}
|
|
|
|
_, resp, err := c.giteaClient.CreateIssueComment(repo.Owner, repo.Name, int64(pullNum), opt)
|
|
|
|
if err != nil {
|
|
logger.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode)
|
|
return err
|
|
}
|
|
|
|
logger.Debug("Added comment to Gitea pull request %d: %s", pullNum, comment)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReactToComment adds a reaction to a comment.
|
|
func (c *GiteaClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error {
|
|
logger.Debug("Adding reaction to Gitea pull request comment %d", commentID)
|
|
|
|
_, resp, err := c.giteaClient.PostIssueCommentReaction(repo.Owner, repo.Name, commentID, reaction)
|
|
|
|
if err != nil {
|
|
logger.Debug("POST /repos/%v/%v/issues/comments/%d/reactions returned: %v", repo.Owner, repo.Name, commentID, resp.StatusCode)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HidePrevCommandComments hides the previous command comments from the pull
|
|
// request.
|
|
func (c *GiteaClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error {
|
|
logger.Debug("Hiding previous command comments on Gitea pull request %d", pullNum)
|
|
|
|
var allComments []*gitea.Comment
|
|
|
|
nextPage := int(1)
|
|
for {
|
|
// Initialize ListIssueCommentOptions with the current page
|
|
opts := gitea.ListIssueCommentOptions{
|
|
ListOptions: gitea.ListOptions{
|
|
Page: nextPage,
|
|
PageSize: c.pageSize,
|
|
},
|
|
}
|
|
|
|
comments, resp, err := c.giteaClient.ListIssueComments(repo.Owner, repo.Name, int64(pullNum), opts)
|
|
if err != nil {
|
|
logger.Debug("GET /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode)
|
|
return err
|
|
}
|
|
|
|
allComments = append(allComments, comments...)
|
|
|
|
// Break the loop if there are no more pages to fetch
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
nextPage = resp.NextPage
|
|
}
|
|
|
|
currentUser, resp, err := c.giteaClient.GetMyUserInfo()
|
|
if err != nil {
|
|
logger.Debug("GET /user returned: %v", resp.StatusCode)
|
|
return err
|
|
}
|
|
|
|
summaryHeader := fmt.Sprintf("<!--- +-Superseded Command-+ ---><details><summary>Superseded Atlantis %s</summary>", command)
|
|
summaryFooter := "</details>"
|
|
lineFeed := "\n"
|
|
|
|
for _, comment := range allComments {
|
|
if comment.Poster == nil || comment.Poster.UserName != currentUser.UserName {
|
|
continue
|
|
}
|
|
|
|
body := strings.Split(comment.Body, "\n")
|
|
if len(body) == 0 || (!strings.Contains(strings.ToLower(body[0]), strings.ToLower(command)) && dir != "" && !strings.Contains(strings.ToLower(body[0]), strings.ToLower(dir))) {
|
|
continue
|
|
}
|
|
|
|
supersededComment := summaryHeader + lineFeed + comment.Body + lineFeed + summaryFooter + lineFeed
|
|
|
|
logger.Debug("Hiding comment %s", comment.ID)
|
|
_, _, err := c.giteaClient.EditIssueComment(repo.Owner, repo.Name, comment.ID, gitea.EditIssueCommentOption{
|
|
Body: supersededComment,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PullIsApproved returns ApprovalStatus with IsApproved set to true if the pull request has a review that approved the PR.
|
|
func (c *GiteaClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) {
|
|
logger.Debug("Checking if Gitea pull request %d is approved", pull.Num)
|
|
|
|
page := 0
|
|
nextPage := 1
|
|
|
|
approvalStatus := models.ApprovalStatus{
|
|
IsApproved: false,
|
|
}
|
|
|
|
listOptions := gitea.ListPullReviewsOptions{
|
|
ListOptions: gitea.ListOptions{
|
|
Page: 1,
|
|
PageSize: c.pageSize,
|
|
},
|
|
}
|
|
|
|
for page < nextPage {
|
|
page = +1
|
|
listOptions.ListOptions.Page = page
|
|
pullReviews, resp, err := c.giteaClient.ListPullReviews(repo.Owner, repo.Name, int64(pull.Num), listOptions)
|
|
|
|
if err != nil {
|
|
logger.Debug("GET /repos/%v/%v/pulls/%d/reviews returned: %v", repo.Owner, repo.Name, pull.Num, resp.StatusCode)
|
|
return approvalStatus, err
|
|
}
|
|
|
|
for _, review := range pullReviews {
|
|
if review.State == gitea.ReviewStateApproved {
|
|
approvalStatus.IsApproved = true
|
|
approvalStatus.ApprovedBy = review.Reviewer.UserName
|
|
approvalStatus.Date = review.Submitted
|
|
|
|
return approvalStatus, nil
|
|
}
|
|
}
|
|
|
|
nextPage = resp.NextPage
|
|
|
|
// Emergency break after giteaPaginationEBreak pages
|
|
if page >= giteaPaginationEBreak {
|
|
break
|
|
}
|
|
}
|
|
|
|
return approvalStatus, nil
|
|
}
|
|
|
|
// PullIsMergeable returns true if the pull request is mergeable
|
|
func (c *GiteaClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (bool, error) {
|
|
logger.Debug("Checking if Gitea pull request %d is mergeable", pull.Num)
|
|
|
|
pullRequest, _, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pull.Num))
|
|
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
logger.Debug("Gitea pull request is mergeable: %v (%v)", pullRequest.Mergeable, pull.Num)
|
|
|
|
return pullRequest.Mergeable, nil
|
|
}
|
|
|
|
// UpdateStatus updates the commit status to state for pull. src is the
|
|
// source of this status. This should be relatively static across runs,
|
|
// ex. atlantis/plan or atlantis/apply.
|
|
// description is a description of this particular status update and can
|
|
// change across runs.
|
|
// url is an optional link that users should click on for more information
|
|
// about this status.
|
|
func (c *GiteaClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error {
|
|
giteaState := gitea.StatusFailure
|
|
|
|
switch state {
|
|
case models.PendingCommitStatus:
|
|
giteaState = gitea.StatusPending
|
|
case models.SuccessCommitStatus:
|
|
giteaState = gitea.StatusSuccess
|
|
case models.FailedCommitStatus:
|
|
giteaState = gitea.StatusFailure
|
|
}
|
|
|
|
logger.Info("Updating Gitea check status for '%s' to '%s'", src, state)
|
|
|
|
newStatusOption := gitea.CreateStatusOption{
|
|
State: giteaState,
|
|
TargetURL: url,
|
|
Description: description,
|
|
}
|
|
|
|
_, resp, err := c.giteaClient.CreateStatus(repo.Owner, repo.Name, pull.HeadCommit, newStatusOption)
|
|
|
|
if err != nil {
|
|
logger.Debug("POST /repos/%v/%v/statuses/%s returned: %v", repo.Owner, repo.Name, pull.HeadCommit, resp.StatusCode)
|
|
return err
|
|
}
|
|
|
|
logger.Debug("Gitea status for pull request updated: %v (%v)", state, pull.Num)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DiscardReviews discards / dismisses all pull request reviews
|
|
func (c *GiteaClient) DiscardReviews(_ logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error {
|
|
page := 0
|
|
nextPage := 1
|
|
|
|
dismissOptions := gitea.DismissPullReviewOptions{
|
|
Message: "Dismissed by Atlantis",
|
|
}
|
|
|
|
listOptions := gitea.ListPullReviewsOptions{
|
|
ListOptions: gitea.ListOptions{
|
|
Page: 1,
|
|
PageSize: c.pageSize,
|
|
},
|
|
}
|
|
|
|
for page < nextPage {
|
|
page = +1
|
|
listOptions.ListOptions.Page = page
|
|
pullReviews, resp, err := c.giteaClient.ListPullReviews(repo.Owner, repo.Name, int64(pull.Num), listOptions)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, review := range pullReviews {
|
|
_, err := c.giteaClient.DismissPullReview(repo.Owner, repo.Name, int64(pull.Num), review.ID, dismissOptions)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
nextPage = resp.NextPage
|
|
|
|
// Emergency break after giteaPaginationEBreak pages
|
|
if page >= giteaPaginationEBreak {
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *GiteaClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error {
|
|
logger.Debug("Merging Gitea pull request %d", pull.Num)
|
|
|
|
mergeOptions := gitea.MergePullRequestOption{
|
|
Style: gitea.MergeStyleMerge,
|
|
Title: "Atlantis merge",
|
|
Message: "Automatic merge by Atlantis",
|
|
DeleteBranchAfterMerge: pullOptions.DeleteSourceBranchOnMerge,
|
|
ForceMerge: false,
|
|
HeadCommitId: pull.HeadCommit,
|
|
MergeWhenChecksSucceed: false,
|
|
}
|
|
|
|
succeeded, resp, err := c.giteaClient.MergePullRequest(pull.BaseRepo.Owner, pull.BaseRepo.Name, int64(pull.Num), mergeOptions)
|
|
|
|
if err != nil {
|
|
logger.Debug("POST /repos/%v/%v/pulls/%d/merge returned: %v", pull.BaseRepo.Owner, pull.BaseRepo.Name, pull.Num, resp.StatusCode)
|
|
return err
|
|
}
|
|
|
|
if !succeeded {
|
|
return fmt.Errorf("merge failed: %s", resp.Status)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkdownPullLink specifies the string used in a pull request comment to reference another pull request.
|
|
func (c *GiteaClient) MarkdownPullLink(pull models.PullRequest) (string, error) {
|
|
return fmt.Sprintf("#%d", pull.Num), nil
|
|
}
|
|
|
|
// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to).
|
|
func (c *GiteaClient) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) {
|
|
// TODO: implement
|
|
return nil, errors.New("GetTeamNamesForUser not (yet) implemented for Gitea client")
|
|
}
|
|
|
|
// GetFileContent a repository file content from VCS (which support fetch a single file from repository)
|
|
// The first return value indicates whether the repo contains a file or not
|
|
// if BaseRepo had a file, its content will placed on the second return value
|
|
func (c *GiteaClient) GetFileContent(logger logging.SimpleLogging, pull models.PullRequest, fileName string) (bool, []byte, error) {
|
|
logger.Debug("Getting file content for %s in Gitea pull request %d", fileName, pull.Num)
|
|
|
|
content, resp, err := c.giteaClient.GetContents(pull.BaseRepo.Owner, pull.BaseRepo.Name, pull.HeadCommit, fileName)
|
|
|
|
if err != nil {
|
|
logger.Debug("GET /repos/%v/%v/contents/%s?ref=%v returned: %v", pull.BaseRepo.Owner, pull.BaseRepo.Name, fileName, pull.HeadCommit, resp.StatusCode)
|
|
return false, nil, err
|
|
}
|
|
|
|
if content.Type == "file" {
|
|
decodedData, err := base64.StdEncoding.DecodeString(*content.Content)
|
|
if err != nil {
|
|
return true, []byte{}, err
|
|
}
|
|
return true, decodedData, nil
|
|
}
|
|
|
|
return false, nil, nil
|
|
}
|
|
|
|
// SupportsSingleFileDownload returns true if the VCS supports downloading a single file
|
|
func (c *GiteaClient) SupportsSingleFileDownload(repo models.Repo) bool {
|
|
return true
|
|
}
|
|
|
|
// GetCloneURL returns the clone URL of the repo
|
|
func (c *GiteaClient) GetCloneURL(logger logging.SimpleLogging, _ models.VCSHostType, repo string) (string, error) {
|
|
logger.Debug("Getting clone URL for %s", repo)
|
|
|
|
parts := strings.Split(repo, "/")
|
|
if len(parts) < 2 {
|
|
return "", errors.New("invalid repo format, expected 'owner/repo'")
|
|
}
|
|
repository, _, err := c.giteaClient.GetRepo(parts[0], parts[1])
|
|
if err != nil {
|
|
logger.Debug("GET /repos/%v/%v returned an error: %v", parts[0], parts[1], err)
|
|
return "", err
|
|
}
|
|
return repository.CloneURL, nil
|
|
}
|
|
|
|
// GetPullLabels returns the labels of a pull request
|
|
func (c *GiteaClient) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
|
|
logger.Debug("Getting labels for Gitea pull request %d", pull.Num)
|
|
|
|
page := 0
|
|
nextPage := 1
|
|
results := make([]string, 0)
|
|
|
|
opts := gitea.ListLabelsOptions{
|
|
ListOptions: gitea.ListOptions{
|
|
Page: 0,
|
|
PageSize: c.pageSize,
|
|
},
|
|
}
|
|
|
|
for page < nextPage {
|
|
page = +1
|
|
opts.ListOptions.Page = page
|
|
|
|
labels, resp, err := c.giteaClient.GetIssueLabels(repo.Owner, repo.Name, int64(pull.Num), opts)
|
|
|
|
if err != nil {
|
|
logger.Debug("GET /repos/%v/%v/issues/%d/labels?%v returned: %v", repo.Owner, repo.Name, pull.Num, "unknown", resp.StatusCode)
|
|
return nil, err
|
|
}
|
|
|
|
for _, label := range labels {
|
|
results = append(results, label.Name)
|
|
}
|
|
|
|
nextPage = resp.NextPage
|
|
|
|
// Emergency break after giteaPaginationEBreak pages
|
|
if page >= giteaPaginationEBreak {
|
|
break
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func ValidateSignature(payload []byte, signature string, secretKey []byte) error {
|
|
isValid, err := gitea.VerifyWebhookSignature(string(secretKey), signature, payload)
|
|
if err != nil {
|
|
return errors.New("signature verification internal error")
|
|
}
|
|
if !isValid {
|
|
return errors.New("invalid signature")
|
|
}
|
|
|
|
return nil
|
|
}
|