Files
atlantis/server/events/vcs/gitea/client.go
2025-03-10 22:08:22 +00:00

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
}