mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package agit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
git_model "github.com/hanzoai/git/models/git"
|
|
issues_model "github.com/hanzoai/git/models/issues"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/git"
|
|
"github.com/hanzoai/git/modules/git/gitcmd"
|
|
"github.com/hanzoai/git/modules/gitrepo"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/private"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/util"
|
|
notify_service "github.com/hanzoai/git/services/notify"
|
|
pull_service "github.com/hanzoai/git/services/pull"
|
|
)
|
|
|
|
func parseAgitPushOptionValue(s string) string {
|
|
if base64Value, ok := strings.CutPrefix(s, "{base64}"); ok {
|
|
decoded, err := base64.StdEncoding.DecodeString(base64Value)
|
|
return util.Iif(err == nil, string(decoded), s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string) (string, string, error) {
|
|
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if baseBranchExist {
|
|
return baseBranchName, "", nil
|
|
}
|
|
|
|
// try match <target-branch>/<topic-branch>
|
|
// refs/for have been trimmed to get baseBranchName
|
|
for p, v := range baseBranchName {
|
|
if v != '/' {
|
|
continue
|
|
}
|
|
|
|
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName[:p])
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if baseBranchExist {
|
|
return baseBranchName[:p], baseBranchName[p+1:], nil
|
|
}
|
|
}
|
|
|
|
return "", "", util.NewNotExistErrorf("base branch does not exist")
|
|
}
|
|
|
|
// ProcReceive handle proc receive work
|
|
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
|
|
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
|
|
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
|
|
topicBranch := opts.GitPushOptions["topic"]
|
|
|
|
// some options are base64-encoded with "{base64}" prefix if they contain new lines
|
|
// other agit push options like "issue", "reviewer" and "cc" are not supported
|
|
title := parseAgitPushOptionValue(opts.GitPushOptions["title"])
|
|
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
|
|
|
|
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
|
userName := strings.ToLower(opts.UserName)
|
|
|
|
pusher, err := user_model.GetUserByID(ctx, opts.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user. Error: %w", err)
|
|
}
|
|
|
|
for i := range opts.OldCommitIDs {
|
|
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
OriginalRef: opts.RefFullNames[i],
|
|
OldOID: opts.OldCommitIDs[i],
|
|
NewOID: opts.NewCommitIDs[i],
|
|
Err: "Can't delete not exist branch",
|
|
})
|
|
continue
|
|
}
|
|
|
|
if !opts.RefFullNames[i].IsFor() {
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
IsNotMatched: true,
|
|
OriginalRef: opts.RefFullNames[i],
|
|
})
|
|
continue
|
|
}
|
|
|
|
baseBranchName, currentTopicBranch, err := GetAgitBranchInfo(ctx, repo.ID, opts.RefFullNames[i].ForBranchName())
|
|
if err != nil {
|
|
if !errors.Is(err, util.ErrNotExist) {
|
|
return nil, fmt.Errorf("failed to get branch information. Error: %w", err)
|
|
}
|
|
// If branch does not exist, we can continue
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
OriginalRef: opts.RefFullNames[i],
|
|
OldOID: opts.OldCommitIDs[i],
|
|
NewOID: opts.NewCommitIDs[i],
|
|
Err: "base-branch does not exist",
|
|
})
|
|
continue
|
|
}
|
|
|
|
if len(topicBranch) == 0 && len(currentTopicBranch) == 0 {
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
OriginalRef: opts.RefFullNames[i],
|
|
OldOID: opts.OldCommitIDs[i],
|
|
NewOID: opts.NewCommitIDs[i],
|
|
Err: "topic-branch is not set",
|
|
})
|
|
continue
|
|
}
|
|
|
|
if len(currentTopicBranch) == 0 {
|
|
currentTopicBranch = topicBranch
|
|
}
|
|
|
|
// because different user maybe want to use same topic,
|
|
// So it's better to make sure the topic branch name
|
|
// has username prefix
|
|
var headBranch string
|
|
if !strings.HasPrefix(currentTopicBranch, userName+"/") {
|
|
headBranch = userName + "/" + currentTopicBranch
|
|
} else {
|
|
headBranch = currentTopicBranch
|
|
}
|
|
|
|
pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, repo.ID, headBranch, baseBranchName, issues_model.PullRequestFlowAGit)
|
|
if err != nil {
|
|
if !issues_model.IsErrPullRequestNotExist(err) {
|
|
return nil, fmt.Errorf("failed to get unmerged agit flow pull request in repository: %s Error: %w", repo.FullName(), err)
|
|
}
|
|
|
|
var commit *git.Commit
|
|
if title == "" || description == "" {
|
|
commit, err = gitRepo.GetCommit(opts.NewCommitIDs[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get commit %s in repository: %s Error: %w", opts.NewCommitIDs[i], repo.FullName(), err)
|
|
}
|
|
// create a new pull request
|
|
if title == "" {
|
|
title = commit.MessageTitle()
|
|
}
|
|
if description == "" {
|
|
description = commit.MessageBody()
|
|
}
|
|
}
|
|
|
|
prIssue := &issues_model.Issue{
|
|
RepoID: repo.ID,
|
|
Title: title,
|
|
PosterID: pusher.ID,
|
|
Poster: pusher,
|
|
IsPull: true,
|
|
Content: description,
|
|
}
|
|
|
|
pr := &issues_model.PullRequest{
|
|
HeadRepoID: repo.ID,
|
|
BaseRepoID: repo.ID,
|
|
HeadBranch: headBranch,
|
|
HeadCommitID: opts.NewCommitIDs[i],
|
|
BaseBranch: baseBranchName,
|
|
HeadRepo: repo,
|
|
BaseRepo: repo,
|
|
MergeBase: "",
|
|
Type: issues_model.PullRequestNative,
|
|
Flow: issues_model.PullRequestFlowAGit,
|
|
}
|
|
prOpts := &pull_service.NewPullRequestOptions{
|
|
Repo: repo,
|
|
Issue: prIssue,
|
|
PullRequest: pr,
|
|
}
|
|
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID)
|
|
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
Ref: pr.GetGitHeadRefName(),
|
|
OriginalRef: opts.RefFullNames[i],
|
|
OldOID: objectFormat.EmptyObjectID().String(),
|
|
NewOID: opts.NewCommitIDs[i],
|
|
IsCreatePR: false, // AGit always creates a pull request so there is no point in prompting user to create one
|
|
URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index),
|
|
ShouldShowMessage: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx),
|
|
HeadBranch: headBranch,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// update exist pull request
|
|
if err := pr.LoadBaseRepo(ctx); err != nil {
|
|
return nil, fmt.Errorf("unable to load base repository for PR[%d] Error: %w", pr.ID, err)
|
|
}
|
|
|
|
oldCommitID, err := gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to get ref commit id in base repository for PR[%d] Error: %w", pr.ID, err)
|
|
}
|
|
|
|
if oldCommitID == opts.NewCommitIDs[i] {
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
OriginalRef: opts.RefFullNames[i],
|
|
OldOID: opts.OldCommitIDs[i],
|
|
NewOID: opts.NewCommitIDs[i],
|
|
Err: "new commit is same with old commit",
|
|
})
|
|
continue
|
|
}
|
|
|
|
if !forcePush.Value() {
|
|
output, _, err := gitrepo.RunCmdString(ctx, repo,
|
|
gitcmd.NewCommand("rev-list", "--max-count=1").
|
|
AddDynamicArguments(oldCommitID, "^"+opts.NewCommitIDs[i]),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to detect force push: %w", err)
|
|
} else if len(output) > 0 {
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
OriginalRef: opts.RefFullNames[i],
|
|
OldOID: opts.OldCommitIDs[i],
|
|
NewOID: opts.NewCommitIDs[i],
|
|
Err: "request `force-push` push option",
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Store old commit ID for review staleness checking
|
|
oldHeadCommitID := pr.HeadCommitID
|
|
|
|
pr.HeadCommitID = opts.NewCommitIDs[i]
|
|
if err = pull_service.UpdateRef(ctx, pr); err != nil {
|
|
return nil, fmt.Errorf("failed to update pull ref. Error: %w", err)
|
|
}
|
|
|
|
// Mark existing reviews as stale when PR content changes (same as regular GitHub flow)
|
|
if oldHeadCommitID != opts.NewCommitIDs[i] {
|
|
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 := pull_service.DismissApprovalReviews(ctx, pusher, pr); err != nil {
|
|
log.Error("DismissApprovalReviews: %v", err)
|
|
}
|
|
}
|
|
|
|
// Mark reviews for the new commit as not stale
|
|
if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, opts.NewCommitIDs[i]); err != nil {
|
|
log.Error("MarkReviewsAsNotStale: %v", err)
|
|
}
|
|
}
|
|
|
|
pull_service.StartPullRequestCheckImmediately(ctx, pr)
|
|
err = pr.LoadIssue(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load pull issue. Error: %w", err)
|
|
}
|
|
|
|
isForcePush := forcePush.Value()
|
|
comment, commentCreated, err := pull_service.CreatePushPullComment(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i], isForcePush)
|
|
if err != nil {
|
|
log.Error("CreatePushPullComment: %v", err)
|
|
} else if commentCreated {
|
|
notify_service.PullRequestPushCommits(ctx, pusher, pr, comment)
|
|
}
|
|
notify_service.PullRequestSynchronized(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i])
|
|
|
|
results = append(results, private.HookProcReceiveRefResult{
|
|
OldOID: oldCommitID,
|
|
NewOID: opts.NewCommitIDs[i],
|
|
Ref: pr.GetGitHeadRefName(),
|
|
OriginalRef: opts.RefFullNames[i],
|
|
IsForcePush: isForcePush,
|
|
IsCreatePR: false,
|
|
URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index),
|
|
ShouldShowMessage: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx),
|
|
})
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// UserNameChanged handle user name change for agit flow pull
|
|
func UserNameChanged(ctx context.Context, user *user_model.User, newName string) error {
|
|
pulls, err := issues_model.GetAllUnmergedAgitPullRequestByPoster(ctx, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
newName = strings.ToLower(newName)
|
|
|
|
for _, pull := range pulls {
|
|
pull.HeadBranch = strings.TrimPrefix(pull.HeadBranch, user.LowerName+"/")
|
|
pull.HeadBranch = newName + "/" + pull.HeadBranch
|
|
if err = pull.UpdateCols(ctx, "head_branch"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|