mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-27 04:36:25 +01:00
This PR introduces project workflows, allowing users to configure event-based automations directly from the UI. Users can define filters and actions for each workflow event, and duplicate existing events when needed. ### Concepts Every workflow is composed of `event`, `filters`, and `actions`. - **Event** - An Event defines when a workflow should be triggered. It represents specific activities such as an issue being added to the project, a pull request being merged, or a project column being changed. - **Filter** - A Filter refines when an event should take effect. It allows users to specify conditions like issue type, labels, or project columns to ensure actions only run in targeted scenarios. - **Action** - An Action defines what happens when the event and filters match. It performs operations such as adding labels, moving issues between columns, or changing the issue or pull request state. ### Available Events Currently, the following eight events are supported: - On issues/pull requests closed - On issues/pull requests reopened - On issues/pull requests was added to the project, including when created in it - On issues/pull requests was removed from the project - On issues/pull requests' column was changed - On code changes requested - On code review approved - On pull request merged Each event type supports its own set of filters and actions. ### Available Filters There are four types of filters that can be configured for an event: - **Apply to** - Specify the target type: `issues only`, `pull requests only`, or `issues and pull requests`. - **Labels** - Trigger the event only if the issue or pull request contains one or more specified labels. - **Move from column** - Filter events based on the original project column. - **Move to column** - Filter events based on the destination project column. ### Available Actions There are four types of actions that can be executed when an event is triggered: - **Add labels** - Automatically add one or more labels to the issue or pull request. - **Remove labels** - Remove one or more specified labels. - **Move to column** - Move the issue or pull request to a designated project column. - **Issue state** - Close or reopen the issue. Pull requests can only be closed. ### Enable/Disable an event workflow Each event workflow can be individually enabled or disabled. When disabled, the workflow will be ignored even if the event occurs. This feature is useful when you want to temporarily stop certain automated actions without deleting the workflow configuration. Workflows of closed projects don't run either. ### Clone an event workflow When you need multiple workflows for the same event, you can clone an existing event to create a new one with different filters and actions. This allows you to customize how the same event behaves under different conditions, making workflows more flexible and powerful. ### Permissions Permissions are checked when workflows are managed, not when they run: - Viewing a project's workflows needs read access to the project - Managing them needs write access to the project plus write access to both issues and pull requests of a repository project's repository, being an owner of an organization project's organization or a site administrator, or being the user of a user project. Tokens limited to public repositories can't manage owner-level projects' workflows - A workflow runs when its event happens to an item of an open project that can hold the item's repository, unless that repository is archived, no matter who caused the event, and applies its actions without further permission checks. Pull requests are never reopened - Changes show as made by "Project workflow" on behalf of the user who triggered the event and don't trigger other workflows - Only users who can read an item can put it on a board or move its card The management rule is also documented on `CanManageWorkflows` in `services/projects/workflow.go`. Fixes: #14359 Fixes: #26704 Fixes: #27990 Replaces: #28745 Documentation PR: https://gitea.com/gitea/docs/pulls/308 Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Giteabot <teabot@gitea.io>
1072 lines
36 KiB
Go
1072 lines
36 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package pull
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"gitea.dev/models/db"
|
|
git_model "gitea.dev/models/git"
|
|
issues_model "gitea.dev/models/issues"
|
|
"gitea.dev/models/organization"
|
|
access_model "gitea.dev/models/perm/access"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unit"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/base"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/git"
|
|
"gitea.dev/modules/git/gitcmd"
|
|
"gitea.dev/modules/globallock"
|
|
"gitea.dev/modules/graceful"
|
|
"gitea.dev/modules/log"
|
|
repo_module "gitea.dev/modules/repository"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/util"
|
|
git_service "gitea.dev/services/git"
|
|
issue_service "gitea.dev/services/issue"
|
|
notify_service "gitea.dev/services/notify"
|
|
)
|
|
|
|
func getPullWorkingLockKey(prID int64) string {
|
|
return fmt.Sprintf("pull_working_%d", prID)
|
|
}
|
|
|
|
type NewPullRequestOptions struct {
|
|
Repo *repo_model.Repository
|
|
Issue *issues_model.Issue
|
|
LabelIDs []int64
|
|
AttachmentUUIDs []string
|
|
PullRequest *issues_model.PullRequest
|
|
AssigneeIDs []int64
|
|
Reviewers []*user_model.User
|
|
TeamReviewers []*organization.Team
|
|
ProjectIDs []int64
|
|
}
|
|
|
|
// NewPullRequest creates new pull request with labels for repository.
|
|
func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
|
repo, issue, labelIDs, uuids, pr, assigneeIDs := opts.Repo, opts.Issue, opts.LabelIDs, opts.AttachmentUUIDs, opts.PullRequest, opts.AssigneeIDs
|
|
if err := issue.LoadPoster(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) {
|
|
return user_model.ErrBlockedUser
|
|
}
|
|
|
|
// user should be a collaborator or a member of the organization for base repo
|
|
canCreate := issue.Poster.IsAdmin || pr.Flow == issues_model.PullRequestFlowAGit
|
|
canAssignProject := canCreate
|
|
if !canCreate {
|
|
canCreate, err := repo_model.HasAccessToRepoCodeUnit(ctx, repo, issue.Poster.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
canAssignProject = canCreate
|
|
|
|
if !canCreate {
|
|
// or user should have write permission in the head repo
|
|
if err := pr.LoadHeadRepo(ctx); err != nil {
|
|
return err
|
|
}
|
|
perm, err := access_model.GetDoerRepoPermission(ctx, pr.HeadRepo, issue.Poster)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !perm.CanWrite(unit.TypeCode) {
|
|
return issues_model.ErrMustCollaborator
|
|
}
|
|
canAssignProject = perm.CanWrite(unit.TypeProjects)
|
|
}
|
|
}
|
|
|
|
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
|
|
assigneeCommentMap := make(map[int64]*issues_model.Comment)
|
|
assignees := make(map[int64]*user_model.User)
|
|
var reviewNotifiers []*issue_service.ReviewRequestNotifier
|
|
var addedProjectIDs []int64
|
|
err := db.WithTx(ctx, func(ctx context.Context) error {
|
|
err := issues_model.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, assigneeID := range assigneeIDs {
|
|
assignee, err := user_model.GetUserByID(ctx, assigneeID)
|
|
if err != nil {
|
|
log.Error("GetUserByID: %v", err)
|
|
continue
|
|
}
|
|
comment, err := issue_service.AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assignee)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assignees[assigneeID] = assignee
|
|
assigneeCommentMap[assigneeID] = comment
|
|
}
|
|
|
|
if len(opts.ProjectIDs) > 0 && canAssignProject {
|
|
if addedProjectIDs, _, err = issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectIDs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
pr.Issue = issue
|
|
issue.PullRequest = pr
|
|
|
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
|
if err = PushToBaseRepo(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
if err = syncCommitDivergence(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
} else { // agit
|
|
if err = UpdateRefForAgit(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// add first push codes comment
|
|
if _, _, err := CreatePushPullComment(ctx, issue.Poster, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitHeadRefName(), false); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !pr.IsWorkInProgress(ctx) {
|
|
reviewNotifiers, err = issue_service.PullRequestCodeOwnersReview(ctx, pr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// cleanup: this will only remove the reference, the real commit will be clean up when next GC
|
|
if err1 := git.RemoveRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName()); err1 != nil {
|
|
log.Error("RemoveRef: %v", err1)
|
|
}
|
|
return err
|
|
}
|
|
|
|
issue_service.ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifiers)
|
|
|
|
// Request reviews, these should be requested before other notifications because they will add request reviews record
|
|
// on database
|
|
permDoer, err := access_model.GetDoerRepoPermission(ctx, repo, issue.Poster)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, reviewer := range opts.Reviewers {
|
|
if _, err = issue_service.ReviewRequest(ctx, pr.Issue, issue.Poster, &permDoer, reviewer, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, teamReviewer := range opts.TeamReviewers {
|
|
if _, err = issue_service.TeamReviewRequest(ctx, pr.Issue, issue.Poster, teamReviewer, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, issue.Poster, issue.Content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
notify_service.NewPullRequest(ctx, pr, mentions)
|
|
if len(issue.Labels) > 0 {
|
|
notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil)
|
|
}
|
|
if issue.Milestone != nil {
|
|
notify_service.IssueChangeMilestone(ctx, issue.Poster, issue, 0)
|
|
}
|
|
for _, assignee := range assignees {
|
|
notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assignee.ID])
|
|
}
|
|
notify_service.IssueChangeProjects(ctx, issue.Poster, issue, addedProjectIDs, nil)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ErrPullRequestHasMerged represents a "PullRequestHasMerged"-error
|
|
type ErrPullRequestHasMerged struct {
|
|
ID int64
|
|
IssueID int64
|
|
HeadRepoID int64
|
|
BaseRepoID int64
|
|
HeadBranch string
|
|
BaseBranch string
|
|
}
|
|
|
|
// IsErrPullRequestHasMerged checks if an error is a ErrPullRequestHasMerged.
|
|
func IsErrPullRequestHasMerged(err error) bool {
|
|
_, ok := err.(ErrPullRequestHasMerged)
|
|
return ok
|
|
}
|
|
|
|
// Error does pretty-printing :D
|
|
func (err ErrPullRequestHasMerged) Error() string {
|
|
return fmt.Sprintf("pull request has merged [id: %d, issue_id: %d, head_repo_id: %d, base_repo_id: %d, head_branch: %s, base_branch: %s]",
|
|
err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBranch, err.BaseBranch)
|
|
}
|
|
|
|
// ChangeTargetBranch changes the target branch of this pull request, as the given user.
|
|
func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, targetBranch string) (err error) {
|
|
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID))
|
|
if err != nil {
|
|
log.Error("lock.Lock(): %v", err)
|
|
return fmt.Errorf("lock.Lock: %w", err)
|
|
}
|
|
defer releaser()
|
|
|
|
// Current target branch is already the same
|
|
if pr.BaseBranch == targetBranch {
|
|
return nil
|
|
}
|
|
|
|
if pr.Issue.IsClosed {
|
|
return issues_model.ErrIssueIsClosed{
|
|
ID: pr.Issue.ID,
|
|
RepoID: pr.Issue.RepoID,
|
|
Index: pr.Issue.Index,
|
|
IsPull: true,
|
|
}
|
|
}
|
|
|
|
if pr.HasMerged {
|
|
return ErrPullRequestHasMerged{
|
|
ID: pr.ID,
|
|
IssueID: pr.Index,
|
|
HeadRepoID: pr.HeadRepoID,
|
|
BaseRepoID: pr.BaseRepoID,
|
|
HeadBranch: pr.HeadBranch,
|
|
BaseBranch: pr.BaseBranch,
|
|
}
|
|
}
|
|
|
|
exist, err := git_model.IsBranchExist(ctx, pr.BaseRepoID, targetBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exist {
|
|
return git_model.ErrBranchNotExist{
|
|
RepoID: pr.BaseRepoID,
|
|
BranchName: targetBranch,
|
|
}
|
|
}
|
|
|
|
// Check if branches are equal
|
|
branchesEqual, err := IsHeadEqualWithBranch(ctx, pr, targetBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if branchesEqual {
|
|
return git_model.ErrBranchesEqual{
|
|
HeadBranchName: pr.HeadBranch,
|
|
BaseBranchName: targetBranch,
|
|
}
|
|
}
|
|
|
|
// Check if pull request for the new target branch already exists
|
|
existingPr, err := issues_model.GetUnmergedPullRequest(ctx, pr.HeadRepoID, pr.BaseRepoID, pr.HeadBranch, targetBranch, issues_model.PullRequestFlowGithub)
|
|
if existingPr != nil {
|
|
return issues_model.ErrPullRequestAlreadyExists{
|
|
ID: existingPr.ID,
|
|
IssueID: existingPr.Index,
|
|
HeadRepoID: existingPr.HeadRepoID,
|
|
BaseRepoID: existingPr.BaseRepoID,
|
|
HeadBranch: existingPr.HeadBranch,
|
|
BaseBranch: existingPr.BaseBranch,
|
|
}
|
|
}
|
|
if err != nil && !issues_model.IsErrPullRequestNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
// Set new target branch
|
|
oldBranch := pr.BaseBranch
|
|
pr.BaseBranch = targetBranch
|
|
|
|
// Refresh patch
|
|
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update target branch, PR diff and status
|
|
// This is the same as markPullRequestAsMergeable in check service, but also updates base_branch
|
|
if pr.Status == issues_model.PullRequestStatusChecking {
|
|
pr.Status = issues_model.PullRequestStatusMergeable
|
|
}
|
|
|
|
// add first push codes comment
|
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
|
// The UPDATE acquires the transaction lock, if the UPDATE succeeds, it should have updated one row (the "base_branch" is changed)
|
|
// If no row is updated, it means the PR has been merged or closed in the meantime
|
|
updated, err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files", "base_branch")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if updated == 0 {
|
|
return util.ErrorWrap(util.ErrInvalidArgument, "pull request status has changed")
|
|
}
|
|
|
|
if err := syncCommitDivergence(ctx, pr); err != nil {
|
|
return fmt.Errorf("syncCommitDivergence: %w", err)
|
|
}
|
|
|
|
// The "official" flag of existing reviews was computed against the previous
|
|
// target branch's protection rules, so re-evaluate it against the new branch.
|
|
// Otherwise a stale official approval could bypass the new branch's protection.
|
|
pr.Issue.PullRequest = pr
|
|
if err := issues_model.RecalculateReviewsOfficial(ctx, pr.Issue); err != nil {
|
|
return fmt.Errorf("RecalculateReviewsOfficial: %w", err)
|
|
}
|
|
|
|
// Create comment
|
|
options := &issues_model.CreateCommentOptions{
|
|
Type: issues_model.CommentTypeChangeTargetBranch,
|
|
Doer: doer,
|
|
Repo: pr.Issue.Repo,
|
|
Issue: pr.Issue,
|
|
OldRef: oldBranch,
|
|
NewRef: targetBranch,
|
|
}
|
|
if _, err = issues_model.CreateComment(ctx, options); err != nil {
|
|
return fmt.Errorf("CreateChangeTargetBranchComment: %w", err)
|
|
}
|
|
|
|
// Delete all old push comments and insert new push comments
|
|
if _, err := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
|
|
And("type = ?", issues_model.CommentTypePullRequestPush).
|
|
NoAutoCondition().
|
|
Delete(new(issues_model.Comment)); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _, err = CreatePushPullComment(ctx, doer, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitHeadRefName(), false)
|
|
return err
|
|
})
|
|
}
|
|
|
|
type TestPullRequestOptions struct {
|
|
RepoID int64
|
|
Doer *user_model.User
|
|
Branch string
|
|
IsSync bool // True means it's a pull request synchronization, false means it's triggered for pull request merging or updating
|
|
IsForcePush bool
|
|
OldCommitID string
|
|
NewCommitID string
|
|
}
|
|
|
|
// AddTestPullRequestTask adds new test tasks by given head/base repository and head/base branch,
|
|
// and generate new patch for testing as needed.
|
|
func AddTestPullRequestTask(opts TestPullRequestOptions) {
|
|
log.Trace("AddTestPullRequestTask [head_repo_id: %d, head_branch: %s]: finding pull requests", opts.RepoID, opts.Branch)
|
|
graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
|
|
// this function does a lot of operations to various models, if the process gets killed in the middle,
|
|
// there is no way to recover at the moment. The best workaround is to let end user push again.
|
|
repo, err := repo_model.GetRepositoryByID(ctx, opts.RepoID)
|
|
if err != nil {
|
|
log.Error("GetRepositoryByID: %v", err)
|
|
return
|
|
}
|
|
// GetUnmergedPullRequestsByHeadInfo() only return open and unmerged PR.
|
|
headBranchPRs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, opts.RepoID, opts.Branch)
|
|
if err != nil {
|
|
log.Error("Find pull requests [head_repo_id: %d, head_branch: %s]: %v", opts.RepoID, opts.Branch, err)
|
|
return
|
|
}
|
|
|
|
for _, pr := range headBranchPRs {
|
|
log.Trace("Updating PR[%d]: composing new test task", pr.ID)
|
|
pr.HeadRepo = repo // avoid loading again
|
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
|
if err := PushToBaseRepo(ctx, pr); err != nil {
|
|
log.Error("PushToBaseRepo: %v", err)
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
// create push comment before check pull request status,
|
|
// then when the status is mergeable, the comment is already in database, to make testing easy and stable
|
|
comment, commentCreated, err := CreatePushPullComment(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID, opts.IsForcePush)
|
|
if err != nil {
|
|
log.Error("CreatePushPullComment: %v", err)
|
|
} else if commentCreated {
|
|
notify_service.PullRequestPushCommits(ctx, opts.Doer, pr, comment)
|
|
}
|
|
// The caller can be in a goroutine or a "push queue", "conflict check" can be time-consuming,
|
|
// and the concurrency should be limited, so the conflict check will be done in another queue
|
|
StartPullRequestCheckImmediately(ctx, pr)
|
|
}
|
|
|
|
if opts.IsSync {
|
|
if err = headBranchPRs.LoadAttributes(ctx); err != nil {
|
|
log.Error("PullRequestList.LoadAttributes: %v", err)
|
|
}
|
|
if invalidationErr := InvalidateCodeComments(ctx, headBranchPRs, repo, opts.Branch); invalidationErr != nil {
|
|
log.Error("InvalidateCodeComments: %v", invalidationErr)
|
|
}
|
|
if err == nil {
|
|
for _, pr := range headBranchPRs {
|
|
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
|
|
if opts.NewCommitID != "" && opts.NewCommitID != objectFormat.EmptyObjectID().String() {
|
|
changed, newMergeBase, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID)
|
|
if err != nil {
|
|
log.Error("checkIfPRContentChanged: %v", err)
|
|
}
|
|
if newMergeBase != "" && pr.MergeBase != newMergeBase {
|
|
pr.MergeBase = newMergeBase
|
|
if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base"); err != nil {
|
|
log.Error("Update merge base for %-v: %v", pr, err)
|
|
}
|
|
}
|
|
if changed {
|
|
// Mark old reviews as stale if diff to mergebase has changed
|
|
if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil {
|
|
log.Error("MarkReviewsAsStale: %v", err)
|
|
}
|
|
|
|
// dismiss all approval reviews if protected branch rule item enabled.
|
|
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
|
if err != nil {
|
|
log.Error("GetFirstMatchProtectedBranchRule: %v", err)
|
|
}
|
|
if pb != nil && pb.DismissStaleApprovals {
|
|
if err := DismissApprovalReviews(ctx, opts.Doer, pr); err != nil {
|
|
log.Error("DismissApprovalReviews: %v", err)
|
|
}
|
|
}
|
|
}
|
|
if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, opts.NewCommitID); err != nil {
|
|
log.Error("MarkReviewsAsNotStale: %v", err)
|
|
}
|
|
if err = syncCommitDivergence(ctx, pr); err != nil {
|
|
log.Error("syncCommitDivergence: %v", err)
|
|
}
|
|
}
|
|
|
|
if !pr.IsWorkInProgress(ctx) {
|
|
reviewNotifiers, err := issue_service.PullRequestCodeOwnersReview(ctx, pr)
|
|
if err != nil {
|
|
log.Error("PullRequestCodeOwnersReview: %v", err)
|
|
}
|
|
if len(reviewNotifiers) > 0 {
|
|
issue_service.ReviewRequestNotify(ctx, pr.Issue, opts.Doer, reviewNotifiers)
|
|
}
|
|
}
|
|
|
|
notify_service.PullRequestSynchronized(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID)
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Trace("AddTestPullRequestTask [base_repo_id: %d, base_branch: %s]: finding pull requests", opts.RepoID, opts.Branch)
|
|
// The base repositories of baseBranchPRs are the same one (opts.RepoID)
|
|
baseBranchPRs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, opts.RepoID, opts.Branch)
|
|
if err != nil {
|
|
log.Error("Find pull requests [base_repo_id: %d, base_branch: %s]: %v", opts.RepoID, opts.Branch, err)
|
|
return
|
|
}
|
|
for _, pr := range baseBranchPRs {
|
|
pr.BaseRepo = repo // avoid loading again
|
|
err = syncCommitDivergence(ctx, pr)
|
|
if err != nil {
|
|
if errors.Is(err, util.ErrNotExist) {
|
|
log.Warn("Cannot test PR %s/%d with base=%s head=%s: no longer exists", pr.BaseRepo.FullName(), pr.IssueID, pr.BaseBranch, pr.HeadBranch)
|
|
} else {
|
|
log.Error("syncCommitDivergence: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
StartPullRequestCheckDelayable(ctx, pr)
|
|
}
|
|
})
|
|
}
|
|
|
|
// checkIfPRContentChanged checks if diff to target branch has changed by push
|
|
// A commit can be considered to leave the PR untouched if the patch/diff with its merge base is unchanged
|
|
func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string) (hasChanged bool, mergeBase string, err error) {
|
|
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) // FIXME: why it still needs to create a temp repo, since the alongside calls like GetDiverging doesn't do so anymore
|
|
if err != nil {
|
|
log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err)
|
|
return false, "", err
|
|
}
|
|
defer cancel()
|
|
|
|
mergeBase, err = git.MergeBase(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
|
|
if err != nil {
|
|
return false, "", fmt.Errorf("GetMergeBase: %w", err)
|
|
}
|
|
|
|
cmd := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(newCommitID, oldCommitID, mergeBase)
|
|
|
|
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
|
defer stdoutReaderClose()
|
|
if err := cmd.WithRepo(prCtx.tmpRepo).
|
|
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
|
return util.IsEmptyReader(stdoutReader)
|
|
}).
|
|
RunWithStderr(ctx); err != nil {
|
|
if errors.Is(err, util.ErrNotEmpty) {
|
|
return true, mergeBase, nil
|
|
}
|
|
|
|
log.Error("Unable to run diff on %s %s %s in tempRepo for PR[%d]%s/%s...%s/%s: Error: %v",
|
|
newCommitID, oldCommitID, mergeBase,
|
|
pr.ID, pr.BaseRepo.FullName(), pr.BaseBranch, pr.HeadRepo.FullName(), pr.HeadBranch,
|
|
err)
|
|
|
|
return false, mergeBase, fmt.Errorf("Unable to run git diff --name-only -z %s %s %s: %w", newCommitID, oldCommitID, mergeBase, err)
|
|
}
|
|
|
|
return false, mergeBase, nil
|
|
}
|
|
|
|
// PushToBaseRepo fetches the head branch commit into the base repository and points the PR head ref at it.
|
|
// FIXME: Only update refs that actually changed?
|
|
func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) error {
|
|
log.Trace("PushToBaseRepo[%d]: updating base repo ref '%s'", pr.BaseRepoID, pr.GetGitHeadRefName())
|
|
|
|
if err := pr.LoadHeadRepo(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := pr.LoadBaseRepo(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := pr.LoadIssue(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
headCommitID, err := git.GetFullCommitID(ctx, pr.HeadRepo, git.BranchPrefix+pr.HeadBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// fetch, not push: pushing objects FetchRemoteTempCommit already fetched races background repacks
|
|
if err := git.FetchRemoteTempCommit(ctx, pr.BaseRepo, pr.HeadRepo, headCommitID); err != nil {
|
|
return fmt.Errorf("unable to fetch head branch %s:%s into base repo %s, err: %w",
|
|
pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseRepo.FullName(), err)
|
|
}
|
|
return git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), headCommitID)
|
|
}
|
|
|
|
// UpdatePullsRefs update all the PRs head file pointers like /refs/pull/1/head so that it will be dependent by other operations
|
|
func UpdatePullsRefs(ctx context.Context, repo *repo_model.Repository, update *repo_module.PushUpdateOptions) {
|
|
branch := update.RefFullName.BranchName()
|
|
// GetUnmergedPullRequestsByHeadInfo() only return open and unmerged PR.
|
|
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch)
|
|
if err != nil {
|
|
log.Error("Find pull requests [head_repo_id: %d, head_branch: %s]: %v", repo.ID, branch, err)
|
|
} else {
|
|
for _, pr := range prs {
|
|
log.Trace("Updating PR[%d]: composing new test task", pr.ID)
|
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
|
if err := PushToBaseRepo(ctx, pr); err != nil {
|
|
log.Error("PushToBaseRepo: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateRefForAgit update refs/pull/id/head directly for agit flow pull request
|
|
func UpdateRefForAgit(ctx context.Context, pr *issues_model.PullRequest) (err error) {
|
|
if err := pr.LoadBaseRepo(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), pr.HeadCommitID); err != nil {
|
|
return fmt.Errorf("unable to update ref %s for base repo %s, err: %w", pr.GetGitHeadRefName(), pr.BaseRepo.FullName(), err)
|
|
}
|
|
return syncCommitDivergence(ctx, pr)
|
|
}
|
|
|
|
// retargetBranchPulls change target branch for all pull requests whose base branch is the branch
|
|
// Both branch and targetBranch must be in the same repo (for security reasons)
|
|
func retargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch, targetBranch string) error {
|
|
prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := prs.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
for _, pr := range prs {
|
|
if err = pr.Issue.LoadRepo(ctx); err != nil {
|
|
errs = append(errs, err)
|
|
} else if err = ChangeTargetBranch(ctx, pr, doer, targetBranch); err != nil &&
|
|
!issues_model.IsErrIssueIsClosed(err) && !IsErrPullRequestHasMerged(err) &&
|
|
!issues_model.IsErrPullRequestAlreadyExists(err) {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// AdjustPullsCausedByBranchDeleted close all the pull requests who's head branch is the branch
|
|
// Or Close all the plls who's base branch is the branch if setting.Repository.PullRequest.RetargetChildrenOnMerge is false.
|
|
// If it's true, Retarget all these pulls to the default branch.
|
|
func AdjustPullsCausedByBranchDeleted(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) error {
|
|
// branch as head branch
|
|
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := prs.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
prs.SetHeadRepo(repo)
|
|
if err := prs.LoadRepositories(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
for _, pr := range prs {
|
|
if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrIssueIsClosed(err) && !issues_model.IsErrDependenciesLeft(err) {
|
|
errs = append(errs, err)
|
|
}
|
|
if err == nil {
|
|
if err := issues_model.AddDeletePRBranchComment(ctx, doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil {
|
|
log.Error("AddDeletePRBranchComment: %v", err)
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if setting.Repository.PullRequest.RetargetChildrenOnMerge {
|
|
if err := retargetBranchPulls(ctx, doer, repo.ID, branch, repo.DefaultBranch); err != nil {
|
|
log.Error("retargetBranchPulls failed: %v", err)
|
|
errs = append(errs, err)
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// branch as base branch
|
|
prs, err = issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repo.ID, branch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := prs.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
prs.SetBaseRepo(repo)
|
|
if err := prs.LoadRepositories(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
errs = nil
|
|
for _, pr := range prs {
|
|
if err = issues_model.AddDeletePRBranchComment(ctx, doer, pr.BaseRepo, pr.Issue.ID, pr.BaseBranch); err != nil {
|
|
log.Error("AddDeletePRBranchComment: %v", err)
|
|
errs = append(errs, err)
|
|
}
|
|
if err == nil {
|
|
if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrIssueIsClosed(err) && !issues_model.IsErrDependenciesLeft(err) {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// CloseRepoBranchesPulls close all pull requests which head branches are in the given repository, but only whose base repo is not in the given repository
|
|
func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) error {
|
|
branches, _, err := git.GetBranchesByPath(ctx, repo, 0, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
for _, branch := range branches {
|
|
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = prs.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, pr := range prs {
|
|
// If the base repository for this pr is this repository there is no need to close it
|
|
// as it is going to be deleted anyway
|
|
if pr.BaseRepoID == repo.ID {
|
|
continue
|
|
}
|
|
if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrIssueIsClosed(err) {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// GetSquashMergeCommitMessages returns the commit messages between head and merge base (if there is one)
|
|
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) (_ string, err error) {
|
|
if err := pr.LoadIssue(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := pr.Issue.LoadPoster(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if pr.HeadRepo == nil {
|
|
pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer closer.Close()
|
|
|
|
var headCommitRef git.RefName
|
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
|
headCommitRef = git.RefNameFromBranch(pr.HeadBranch)
|
|
} else {
|
|
pr.HeadCommitID, err = gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
headCommitRef = git.RefNameFromCommit(pr.HeadCommitID)
|
|
}
|
|
|
|
mergeBaseRef := git.RefNameFromCommit(pr.MergeBase)
|
|
|
|
limit := setting.Repository.PullRequest.DefaultMergeMessageCommitsLimit
|
|
|
|
limitedCommits, err := gitRepo.CommitsBetween(ctx, headCommitRef, mergeBaseRef, limit)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := pr.LoadBaseRepo(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
var mergeMessage string
|
|
switch pr.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig().DefaultSquashMergeMessage {
|
|
case repo_model.SquashMergeMessageCommitMessages:
|
|
mergeMessage = formatSquashMergeCommitMessages(limitedCommits)
|
|
case repo_model.SquashMergeMessagePRBody:
|
|
mergeMessage = strings.TrimSpace(pr.Issue.Content)
|
|
}
|
|
coAuthors := collectSquashMergeCommitCoAuthors(ctx, gitRepo, pr, headCommitRef, mergeBaseRef, limit, limitedCommits)
|
|
return buildSquashMergeCommitMessages(mergeMessage, coAuthors), nil
|
|
}
|
|
|
|
func buildSquashMergeCommitMessages(mergeMessage string, coAuthors []string) string {
|
|
if len(coAuthors) == 0 {
|
|
return mergeMessage
|
|
}
|
|
|
|
msgContent, msgSep, msgTrailer := git.CommitMessageSplitTrailer(mergeMessage)
|
|
if (msgSep == "" || msgSep == "\n\n") && msgTrailer == "" {
|
|
msgContent = strings.TrimRightFunc(msgContent, unicode.IsSpace)
|
|
msgSep = util.Iif(msgContent == "", "", "\n\n---------\n\n")
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString(msgContent)
|
|
sb.WriteString(msgSep)
|
|
if msgTrailer = strings.TrimSpace(msgTrailer); msgTrailer != "" {
|
|
sb.WriteString(msgTrailer)
|
|
sb.WriteRune('\n')
|
|
}
|
|
for _, author := range coAuthors {
|
|
sb.WriteString(git.CoAuthoredByTrailer + ": ")
|
|
sb.WriteString(author)
|
|
sb.WriteRune('\n')
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func collectSquashMergeCommitCoAuthors(ctx context.Context, gitRepo *git.Repository, pr *issues_model.PullRequest, headCommitRef, mergeBaseRef git.RefName, limitFirst int, limitedCommits []*git.Commit) []string {
|
|
posterSig := pr.Issue.Poster.NewGitSig().String()
|
|
uniqueAuthors := make(container.Set[string])
|
|
authors := make([]string, 0, len(limitedCommits))
|
|
|
|
for _, commit := range limitedCommits {
|
|
authorString := commit.Author.String()
|
|
if uniqueAuthors.Add(authorString) && authorString != posterSig {
|
|
// Compare use account as well to avoid adding the same author multiple times
|
|
// when email addresses are private or multiple emails are used.
|
|
commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
|
|
if commitUser == nil || commitUser.ID != pr.Issue.Poster.ID {
|
|
authors = append(authors, authorString)
|
|
}
|
|
}
|
|
}
|
|
|
|
// collect the remaining authors
|
|
if limitFirst >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors {
|
|
skip := limitFirst
|
|
batchLimit := 30
|
|
for {
|
|
commits, err := gitRepo.CommitsBetween(ctx, headCommitRef, mergeBaseRef, batchLimit, skip)
|
|
if err != nil {
|
|
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
|
|
return authors
|
|
}
|
|
if len(commits) == 0 {
|
|
break
|
|
}
|
|
for _, commit := range commits {
|
|
authorString := commit.Author.String()
|
|
if uniqueAuthors.Add(authorString) && authorString != posterSig {
|
|
commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
|
|
if commitUser == nil || commitUser.ID != pr.Issue.Poster.ID {
|
|
authors = append(authors, authorString)
|
|
}
|
|
}
|
|
}
|
|
skip += batchLimit
|
|
}
|
|
}
|
|
return authors
|
|
}
|
|
|
|
func formatSquashMergeCommitMessages(commits []*git.Commit) string {
|
|
maxMsgSize := setting.Repository.PullRequest.DefaultMergeMessageSize
|
|
sb := &bytes.Buffer{}
|
|
// commits list is in reverse chronological order
|
|
for _, commit := range slices.Backward(commits) {
|
|
msg := strings.TrimSpace(commit.MessageUTF8())
|
|
if msg == "" {
|
|
continue
|
|
}
|
|
|
|
// This format follows GitHub's squash commit message style,
|
|
// even if there are other "* " in the commit message body, they are written as-is.
|
|
// Maybe, ideally, we should indent those lines too.
|
|
_, _ = fmt.Fprintf(sb, "* %s\n\n", msg)
|
|
if maxMsgSize > 0 && sb.Len() >= maxMsgSize {
|
|
break
|
|
}
|
|
}
|
|
|
|
buf := bytes.TrimSpace(sb.Bytes())
|
|
if maxMsgSize > 0 && len(buf) > maxMsgSize {
|
|
buf = buf[:maxMsgSize]
|
|
for {
|
|
r, sz := utf8.DecodeLastRune(buf)
|
|
if r == utf8.RuneError && sz == 1 {
|
|
buf = buf[:len(buf)-1]
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
buf = append(buf, '.', '.', '.')
|
|
}
|
|
buf = append(buf, '\n', '\n')
|
|
return util.UnsafeBytesToString(buf)
|
|
}
|
|
|
|
// GetIssuesAllCommitStatus returns a map of issue ID to a list of all statuses for the most recent commit as well as a map of issue ID to only the commit's latest status.
|
|
// The returned statuses are redacted for doer.
|
|
func GetIssuesAllCommitStatus(ctx context.Context, doer *user_model.User, issues issues_model.IssueList) (map[int64][]*git_model.CommitStatus, map[int64]*git_model.CommitStatus, error) {
|
|
if err := issues.LoadPullRequests(ctx); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if _, err := issues.LoadRepositories(ctx); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var (
|
|
gitRepos = make(map[int64]*git.Repository)
|
|
res = make(map[int64][]*git_model.CommitStatus)
|
|
lastRes = make(map[int64]*git_model.CommitStatus)
|
|
err error
|
|
)
|
|
defer func() {
|
|
for _, gitRepo := range gitRepos {
|
|
gitRepo.Close()
|
|
}
|
|
}()
|
|
|
|
for _, issue := range issues {
|
|
if !issue.IsPull {
|
|
continue
|
|
}
|
|
gitRepo, ok := gitRepos[issue.RepoID]
|
|
if !ok {
|
|
gitRepo, err = git.OpenRepository(ctx, issue.Repo)
|
|
if err != nil {
|
|
log.Error("Cannot open git repository %-v for issue #%d[%d]. Error: %v", issue.Repo, issue.Index, issue.ID, err)
|
|
continue
|
|
}
|
|
gitRepos[issue.RepoID] = gitRepo
|
|
}
|
|
|
|
statuses, lastStatus, err := getAllCommitStatus(ctx, doer, gitRepo, issue.PullRequest)
|
|
if err != nil {
|
|
log.Error("getAllCommitStatus: cant get commit statuses of pull [%d]: %v", issue.PullRequest.ID, err)
|
|
continue
|
|
}
|
|
res[issue.PullRequest.ID] = statuses
|
|
lastRes[issue.PullRequest.ID] = lastStatus
|
|
}
|
|
return res, lastRes, nil
|
|
}
|
|
|
|
// getAllCommitStatus get pr's commit statuses.
|
|
func getAllCommitStatus(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, pr *issues_model.PullRequest) (statuses []*git_model.CommitStatus, lastStatus *git_model.CommitStatus, err error) {
|
|
sha, shaErr := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
|
|
if shaErr != nil {
|
|
return nil, nil, shaErr
|
|
}
|
|
|
|
statuses, err = git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll)
|
|
for _, status := range statuses {
|
|
status.Repo = pr.BaseRepo // the repo is already loaded, spare the permission lookup a query
|
|
}
|
|
// CalcCommitStatus copies a TargetURL out of the statuses, so hide before combining
|
|
git_model.CommitStatusesApplyDoerPermission(ctx, doer, statuses)
|
|
lastStatus = git_model.CalcCommitStatus(statuses)
|
|
return statuses, lastStatus, err
|
|
}
|
|
|
|
// IsHeadEqualWithBranch returns if the commits of branchName are available in pull request head
|
|
func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, branchName string) (bool, error) {
|
|
var err error
|
|
if err = pr.LoadBaseRepo(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
baseGitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer closer.Close()
|
|
|
|
baseCommit, err := baseGitRepo.GetBranchCommit(ctx, branchName)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if err = pr.LoadHeadRepo(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
var headGitRepo *git.Repository
|
|
if pr.HeadRepoID == pr.BaseRepoID {
|
|
headGitRepo = baseGitRepo
|
|
} else {
|
|
var closer io.Closer
|
|
|
|
headGitRepo, closer, err = git.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer closer.Close()
|
|
}
|
|
|
|
var headCommit *git.Commit
|
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
|
headCommit, err = headGitRepo.GetBranchCommit(ctx, pr.HeadBranch)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
} else {
|
|
pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if headCommit, err = baseGitRepo.GetCommit(ctx, pr.HeadCommitID); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
return baseCommit.HasPreviousCommit(ctx, baseGitRepo, headCommit.ID)
|
|
}
|
|
|
|
type CommitInfo struct {
|
|
Summary string `json:"summary"`
|
|
CommitterOrAuthorName string `json:"committer_or_author_name"`
|
|
ID string `json:"id"`
|
|
ShortSha string `json:"short_sha"`
|
|
Time string `json:"time"`
|
|
}
|
|
|
|
// GetPullCommits returns all commits on given pull request and the last review commit sha
|
|
// Attention: The last review commit sha must be from the latest review whose commit id is not empty.
|
|
// So the type of the latest review cannot be "ReviewTypeRequest".
|
|
func GetPullCommits(ctx context.Context, baseGitRepo *git.Repository, doer *user_model.User, issue *issues_model.Issue) ([]CommitInfo, string, error) {
|
|
pull := issue.PullRequest
|
|
|
|
if err := pull.LoadBaseRepo(ctx); err != nil {
|
|
return nil, "", err
|
|
}
|
|
baseBranch := pull.BaseBranch
|
|
if pull.HasMerged {
|
|
baseBranch = pull.MergeBase
|
|
}
|
|
compareInfo, err := git_service.GetCompareInfo(ctx, pull.BaseRepo, pull.BaseRepo, baseGitRepo, git.RefNameFromBranch(baseBranch), git.RefName(pull.GetGitHeadRefName()), false, false)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
commits := make([]CommitInfo, 0, len(compareInfo.Commits))
|
|
|
|
for _, commit := range compareInfo.Commits {
|
|
var committerOrAuthorName string
|
|
var commitTime time.Time
|
|
if commit.Author != nil {
|
|
committerOrAuthorName = commit.Author.Name
|
|
commitTime = commit.Author.When
|
|
} else {
|
|
committerOrAuthorName = commit.Committer.Name
|
|
commitTime = commit.Committer.When
|
|
}
|
|
|
|
commits = append(commits, CommitInfo{
|
|
Summary: commit.MessageTitle(),
|
|
CommitterOrAuthorName: committerOrAuthorName,
|
|
ID: commit.ID.String(),
|
|
ShortSha: base.ShortSha(commit.ID.String()),
|
|
Time: commitTime.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
var lastReviewCommitID string
|
|
if doer != nil {
|
|
// get last review of current user and store information in context (if available)
|
|
lastreview, err := issues_model.FindLatestReviews(ctx, issues_model.FindReviewOptions{
|
|
IssueID: issue.ID,
|
|
ReviewerID: doer.ID,
|
|
Types: []issues_model.ReviewType{
|
|
issues_model.ReviewTypeApprove,
|
|
issues_model.ReviewTypeComment,
|
|
issues_model.ReviewTypeReject,
|
|
},
|
|
})
|
|
|
|
if err != nil && !issues_model.IsErrReviewNotExist(err) {
|
|
return nil, "", err
|
|
}
|
|
if len(lastreview) > 0 {
|
|
lastReviewCommitID = lastreview[0].CommitID
|
|
}
|
|
}
|
|
|
|
return commits, lastReviewCommitID, nil
|
|
}
|