diff options
author | a1012112796 <1012112796@qq.com> | 2021-07-28 17:42:56 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-07-28 17:42:56 +0800 |
commit | 370516883717de0e6e2087c12d368eb1465ee3b0 (patch) | |
tree | 1dd7b0946ac2bbeb3b9f13518cf2df2f4ebca348 | |
parent | 5b2e2d29ca50ba4c11a44d4f1de18ffcf215ba1b (diff) | |
download | gitea-370516883717de0e6e2087c12d368eb1465ee3b0.tar.gz gitea-370516883717de0e6e2087c12d368eb1465ee3b0.zip |
Add agit flow support in gitea (#14295)
* feature: add agit flow support
ref: https://git-repo.info/en/2020/03/agit-flow-and-git-repo/
example:
```Bash
git checkout -b test
echo "test" >> README.md
git commit -m "test"
git push origin HEAD:refs/for/master -o topic=test
```
Signed-off-by: a1012112796 <1012112796@qq.com>
* fix lint
* simplify code add fix some nits
* update merge help message
* Apply suggestions from code review. Thanks @jiangxin
* add forced-update message
* fix lint
* splite writePktLine
* add refs/for/<target-branch>/<topic-branch> support also
* Add test code add fix api
* fix lint
* fix test
* skip test if git version < 2.29
* try test with git 2.30.1
* fix permission check bug
* fix some nit
* logic implify and test code update
* fix bug
* apply suggestions from code review
* prepare for merge
Signed-off-by: Andrew Thornton <art27@cantab.net>
* fix permission check bug
- test code update
- apply suggestions from code review @zeripath
Signed-off-by: a1012112796 <1012112796@qq.com>
* fix bug when target branch isn't exist
* prevent some special push and fix some nits
* fix lint
* try splite
* Apply suggestions from code review
- fix permission check
- handle user rename
* fix version negotiation
* remane
* fix template
* handle empty repo
* ui: fix branch link under the title
* fix nits
Co-authored-by: Andrew Thornton <art27@cantab.net>
Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
30 files changed, 1334 insertions, 32 deletions
diff --git a/cmd/hook.go b/cmd/hook.go index 87f1f37562..fb43add8d4 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -38,6 +38,7 @@ var ( subcmdHookPreReceive, subcmdHookUpdate, subcmdHookPostReceive, + subcmdHookProcReceive, }, } @@ -74,6 +75,18 @@ var ( }, }, } + // Note: new hook since git 2.29 + subcmdHookProcReceive = cli.Command{ + Name: "proc-receive", + Usage: "Delegate proc-receive Git hook", + Description: "This command should only be called by Git", + Action: runHookProcReceive, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "debug", + }, + }, + } ) type delayWriter struct { @@ -205,6 +218,11 @@ Gitea or set your environment appropriately.`, "") } } + supportProcRecive := false + if git.CheckGitVersionAtLeast("2.29") == nil { + supportProcRecive = true + } + for scanner.Scan() { // TODO: support news feeds for wiki if isWiki { @@ -223,7 +241,9 @@ Gitea or set your environment appropriately.`, "") lastline++ // If the ref is a branch or tag, check if it's protected - if strings.HasPrefix(refFullName, git.BranchPrefix) || strings.HasPrefix(refFullName, git.TagPrefix) { + // if supportProcRecive all ref should be checked because + // permission check was delayed + if supportProcRecive || strings.HasPrefix(refFullName, git.BranchPrefix) || strings.HasPrefix(refFullName, git.TagPrefix) { oldCommitIDs[count] = oldCommitID newCommitIDs[count] = newCommitID refFullNames[count] = refFullName @@ -463,3 +483,327 @@ func pushOptions() map[string]string { } return opts } + +func runHookProcReceive(c *cli.Context) error { + setup("hooks/proc-receive.log", c.Bool("debug")) + + if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { + if setting.OnlyAllowPushIfGiteaEnvironmentSet { + return fail(`Rejecting changes as Gitea environment not set. +If you are pushing over SSH you must push with a key managed by +Gitea or set your environment appropriately.`, "") + } + return nil + } + + ctx, cancel := installSignals() + defer cancel() + + if git.CheckGitVersionAtLeast("2.29") != nil { + return fail("Internal Server Error", "git not support proc-receive.") + } + + reader := bufio.NewReader(os.Stdin) + repoUser := os.Getenv(models.EnvRepoUsername) + repoName := os.Getenv(models.EnvRepoName) + pusherID, _ := strconv.ParseInt(os.Getenv(models.EnvPusherID), 10, 64) + pusherName := os.Getenv(models.EnvPusherName) + + // 1. Version and features negotiation. + // S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n) + // S: flush-pkt + // H: PKT-LINE(version=1\0push-options...) + // H: flush-pkt + + rs, err := readPktLine(reader, pktLineTypeData) + if err != nil { + return err + } + + const VersionHead string = "version=1" + + var ( + hasPushOptions bool + response = []byte(VersionHead) + requestOptions []string + ) + + index := bytes.IndexByte(rs.Data, byte(0)) + if index >= len(rs.Data) { + return fail("Internal Server Error", "pkt-line: format error "+fmt.Sprint(rs.Data)) + } + + if index < 0 { + if len(rs.Data) == 10 && rs.Data[9] == '\n' { + index = 9 + } else { + return fail("Internal Server Error", "pkt-line: format error "+fmt.Sprint(rs.Data)) + } + } + + if string(rs.Data[0:index]) != VersionHead { + return fail("Internal Server Error", "Received unsupported version: %s", string(rs.Data[0:index])) + } + requestOptions = strings.Split(string(rs.Data[index+1:]), " ") + + for _, option := range requestOptions { + if strings.HasPrefix(option, "push-options") { + response = append(response, byte(0)) + response = append(response, []byte("push-options")...) + hasPushOptions = true + } + } + response = append(response, '\n') + + _, err = readPktLine(reader, pktLineTypeFlush) + if err != nil { + return err + } + + err = writeDataPktLine(os.Stdout, response) + if err != nil { + return err + } + + err = writeFlushPktLine(os.Stdout) + if err != nil { + return err + } + + // 2. receive commands from server. + // S: PKT-LINE(<old-oid> <new-oid> <ref>) + // S: ... ... + // S: flush-pkt + // # [receive push-options] + // S: PKT-LINE(push-option) + // S: ... ... + // S: flush-pkt + hookOptions := private.HookOptions{ + UserName: pusherName, + UserID: pusherID, + } + hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize) + hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize) + hookOptions.RefFullNames = make([]string, 0, hookBatchSize) + + for { + // note: pktLineTypeUnknow means pktLineTypeFlush and pktLineTypeData all allowed + rs, err = readPktLine(reader, pktLineTypeUnknow) + if err != nil { + return err + } + + if rs.Type == pktLineTypeFlush { + break + } + t := strings.SplitN(string(rs.Data), " ", 3) + if len(t) != 3 { + continue + } + hookOptions.OldCommitIDs = append(hookOptions.OldCommitIDs, t[0]) + hookOptions.NewCommitIDs = append(hookOptions.NewCommitIDs, t[1]) + hookOptions.RefFullNames = append(hookOptions.RefFullNames, t[2]) + } + + hookOptions.GitPushOptions = make(map[string]string) + + if hasPushOptions { + for { + rs, err = readPktLine(reader, pktLineTypeUnknow) + if err != nil { + return err + } + + if rs.Type == pktLineTypeFlush { + break + } + + kv := strings.SplitN(string(rs.Data), "=", 2) + if len(kv) == 2 { + hookOptions.GitPushOptions[kv[0]] = kv[1] + } + } + } + + // 3. run hook + resp, err := private.HookProcReceive(ctx, repoUser, repoName, hookOptions) + if err != nil { + return fail("Internal Server Error", "run proc-receive hook failed :%v", err) + } + + // 4. response result to service + // # a. OK, but has an alternate reference. The alternate reference name + // # and other status can be given in option directives. + // H: PKT-LINE(ok <ref>) + // H: PKT-LINE(option refname <refname>) + // H: PKT-LINE(option old-oid <old-oid>) + // H: PKT-LINE(option new-oid <new-oid>) + // H: PKT-LINE(option forced-update) + // H: ... ... + // H: flush-pkt + // # b. NO, I reject it. + // H: PKT-LINE(ng <ref> <reason>) + // # c. Fall through, let 'receive-pack' to execute it. + // H: PKT-LINE(ok <ref>) + // H: PKT-LINE(option fall-through) + + for _, rs := range resp.Results { + if len(rs.Err) > 0 { + err = writeDataPktLine(os.Stdout, []byte("ng "+rs.OriginalRef+" "+rs.Err)) + if err != nil { + return err + } + continue + } + + if rs.IsNotMatched { + err = writeDataPktLine(os.Stdout, []byte("ok "+rs.OriginalRef)) + if err != nil { + return err + } + err = writeDataPktLine(os.Stdout, []byte("option fall-through")) + if err != nil { + return err + } + continue + } + + err = writeDataPktLine(os.Stdout, []byte("ok "+rs.OriginalRef)) + if err != nil { + return err + } + err = writeDataPktLine(os.Stdout, []byte("option refname "+rs.Ref)) + if err != nil { + return err + } + if rs.OldOID != git.EmptySHA { + err = writeDataPktLine(os.Stdout, []byte("option old-oid "+rs.OldOID)) + if err != nil { + return err + } + } + err = writeDataPktLine(os.Stdout, []byte("option new-oid "+rs.NewOID)) + if err != nil { + return err + } + if rs.IsForcePush { + err = writeDataPktLine(os.Stdout, []byte("option forced-update")) + if err != nil { + return err + } + } + } + err = writeFlushPktLine(os.Stdout) + + return err +} + +// git PKT-Line api +// pktLineType message type of pkt-line +type pktLineType int64 + +const ( + // UnKnow type + pktLineTypeUnknow pktLineType = 0 + // flush-pkt "0000" + pktLineTypeFlush pktLineType = iota + // data line + pktLineTypeData +) + +// gitPktLine pkt-line api +type gitPktLine struct { + Type pktLineType + Length uint64 + Data []byte +} + +func readPktLine(in *bufio.Reader, requestType pktLineType) (*gitPktLine, error) { + var ( + err error + r *gitPktLine + ) + + // read prefix + lengthBytes := make([]byte, 4) + for i := 0; i < 4; i++ { + lengthBytes[i], err = in.ReadByte() + if err != nil { + return nil, fail("Internal Server Error", "Pkt-Line: read stdin failed : %v", err) + } + } + + r = new(gitPktLine) + r.Length, err = strconv.ParseUint(string(lengthBytes), 16, 32) + if err != nil { + return nil, fail("Internal Server Error", "Pkt-Line format is wrong :%v", err) + } + + if r.Length == 0 { + if requestType == pktLineTypeData { + return nil, fail("Internal Server Error", "Pkt-Line format is wrong") + } + r.Type = pktLineTypeFlush + return r, nil + } + + if r.Length <= 4 || r.Length > 65520 || requestType == pktLineTypeFlush { + return nil, fail("Internal Server Error", "Pkt-Line format is wrong") + } + + r.Data = make([]byte, r.Length-4) + for i := range r.Data { + r.Data[i], err = in.ReadByte() + if err != nil { + return nil, fail("Internal Server Error", "Pkt-Line: read stdin failed : %v", err) + } + } + + r.Type = pktLineTypeData + + return r, nil +} + +func writeFlushPktLine(out io.Writer) error { + l, err := out.Write([]byte("0000")) + if err != nil { + return fail("Internal Server Error", "Pkt-Line response failed: %v", err) + } + if l != 4 { + return fail("Internal Server Error", "Pkt-Line response failed: %v", err) + } + + return nil +} + +func writeDataPktLine(out io.Writer, data []byte) error { + hexchar := []byte("0123456789abcdef") + hex := func(n uint64) byte { + return hexchar[(n)&15] + } + + length := uint64(len(data) + 4) + tmp := make([]byte, 4) + tmp[0] = hex(length >> 12) + tmp[1] = hex(length >> 8) + tmp[2] = hex(length >> 4) + tmp[3] = hex(length) + + lr, err := out.Write(tmp) + if err != nil { + return fail("Internal Server Error", "Pkt-Line response failed: %v", err) + } + if 4 != lr { + return fail("Internal Server Error", "Pkt-Line response failed: %v", err) + } + + lr, err = out.Write(data) + if err != nil { + return fail("Internal Server Error", "Pkt-Line response failed: %v", err) + } + if int(length-4) != lr { + return fail("Internal Server Error", "Pkt-Line response failed: %v", err) + } + + return nil +} diff --git a/cmd/hook_test.go b/cmd/hook_test.go new file mode 100644 index 0000000000..92c7e82a9a --- /dev/null +++ b/cmd/hook_test.go @@ -0,0 +1,41 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "bufio" + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPktLine(t *testing.T) { + // test read + s := strings.NewReader("0000") + r := bufio.NewReader(s) + result, err := readPktLine(r, pktLineTypeFlush) + assert.NoError(t, err) + assert.Equal(t, pktLineTypeFlush, result.Type) + + s = strings.NewReader("0006a\n") + r = bufio.NewReader(s) + result, err = readPktLine(r, pktLineTypeData) + assert.NoError(t, err) + assert.Equal(t, pktLineTypeData, result.Type) + assert.Equal(t, []byte("a\n"), result.Data) + + // test write + w := bytes.NewBuffer([]byte{}) + err = writeFlushPktLine(w) + assert.NoError(t, err) + assert.Equal(t, []byte("0000"), w.Bytes()) + + w.Reset() + err = writeDataPktLine(w, []byte("a\nb")) + assert.NoError(t, err) + assert.Equal(t, []byte("0007a\nb"), w.Bytes()) +} diff --git a/cmd/serv.go b/cmd/serv.go index 4479902ea1..2173a3a38b 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -17,6 +17,7 @@ import ( "time" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/pprof" @@ -146,6 +147,13 @@ func runServ(c *cli.Context) error { } if len(words) < 2 { + if git.CheckGitVersionAtLeast("2.29") == nil { + // for AGit Flow + if cmd == "ssh_info" { + fmt.Print(`{"type":"gitea","version":1}`) + return nil + } + } return fail("Too few arguments", "Too few arguments in cmd: %s", cmd) } diff --git a/integrations/git_test.go b/integrations/git_test.go index a9848eaa4c..38d7b29b2b 100644 --- a/integrations/git_test.go +++ b/integrations/git_test.go @@ -70,6 +70,7 @@ func testGit(t *testing.T, u *url.URL) { rawTest(t, &httpContext, little, big, littleLFS, bigLFS) mediaTest(t, &httpContext, little, big, littleLFS, bigLFS) + t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "master", "test/head")) t.Run("BranchProtectMerge", doBranchProtectPRMerge(&httpContext, dstPath)) t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge")) t.Run("MergeFork", func(t *testing.T) { @@ -111,6 +112,7 @@ func testGit(t *testing.T, u *url.URL) { rawTest(t, &sshContext, little, big, littleLFS, bigLFS) mediaTest(t, &sshContext, little, big, littleLFS, bigLFS) + t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "master", "test/head2")) t.Run("BranchProtectMerge", doBranchProtectPRMerge(&sshContext, dstPath)) t.Run("MergeFork", func(t *testing.T) { defer PrintCurrentTest(t)() @@ -593,3 +595,162 @@ func doBranchDelete(ctx APITestContext, owner, repo, branch string) func(*testin ctx.Session.MakeRequest(t, req, http.StatusOK) } } + +func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, baseBranch, headBranch string) func(t *testing.T) { + return func(t *testing.T) { + defer PrintCurrentTest(t)() + + // skip this test if git version is low + if git.CheckGitVersionAtLeast("2.29") != nil { + return + } + + gitRepo, err := git.OpenRepository(dstPath) + if !assert.NoError(t, err) { + return + } + defer gitRepo.Close() + + var ( + pr1, pr2 *models.PullRequest + commit string + ) + repo, err := models.GetRepositoryByOwnerAndName(ctx.Username, ctx.Reponame) + if !assert.NoError(t, err) { + return + } + + pullNum := models.GetCount(t, &models.PullRequest{}) + + t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch)) + + t.Run("AddCommit", func(t *testing.T) { + err := ioutil.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0666) + if !assert.NoError(t, err) { + return + } + + err = git.AddChanges(dstPath, true) + assert.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Testing commit 1", + }) + assert.NoError(t, err) + commit, err = gitRepo.GetRefCommitID("HEAD") + assert.NoError(t, err) + }) + + t.Run("Push", func(t *testing.T) { + _, err := git.NewCommand("push", "origin", "HEAD:refs/for/master", "-o", "topic="+headBranch).RunInDir(dstPath) + if !assert.NoError(t, err) { + return + } + models.AssertCount(t, &models.PullRequest{}, pullNum+1) + pr1 = models.AssertExistsAndLoadBean(t, &models.PullRequest{ + HeadRepoID: repo.ID, + Flow: models.PullRequestFlowAGit, + }).(*models.PullRequest) + if !assert.NotEmpty(t, pr1) { + return + } + prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, "user2/"+headBranch, pr1.HeadBranch) + assert.Equal(t, false, prMsg.HasMerged) + assert.Contains(t, "Testing commit 1", prMsg.Body) + assert.Equal(t, commit, prMsg.Head.Sha) + + _, err = git.NewCommand("push", "origin", "HEAD:refs/for/master/test/"+headBranch).RunInDir(dstPath) + if !assert.NoError(t, err) { + return + } + models.AssertCount(t, &models.PullRequest{}, pullNum+2) + pr2 = models.AssertExistsAndLoadBean(t, &models.PullRequest{ + HeadRepoID: repo.ID, + Index: pr1.Index + 1, + Flow: models.PullRequestFlowAGit, + }).(*models.PullRequest) + if !assert.NotEmpty(t, pr2) { + return + } + prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, "user2/test/"+headBranch, pr2.HeadBranch) + assert.Equal(t, false, prMsg.HasMerged) + }) + + if pr1 == nil || pr2 == nil { + return + } + + t.Run("AddCommit2", func(t *testing.T) { + err := ioutil.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content \n ## test content 2"), 0666) + if !assert.NoError(t, err) { + return + } + + err = git.AddChanges(dstPath, true) + assert.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Testing commit 2", + }) + assert.NoError(t, err) + commit, err = gitRepo.GetRefCommitID("HEAD") + assert.NoError(t, err) + }) + + t.Run("Push2", func(t *testing.T) { + _, err := git.NewCommand("push", "origin", "HEAD:refs/for/master", "-o", "topic="+headBranch).RunInDir(dstPath) + if !assert.NoError(t, err) { + return + } + models.AssertCount(t, &models.PullRequest{}, pullNum+2) + prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, false, prMsg.HasMerged) + assert.Equal(t, commit, prMsg.Head.Sha) + + _, err = git.NewCommand("push", "origin", "HEAD:refs/for/master/test/"+headBranch).RunInDir(dstPath) + if !assert.NoError(t, err) { + return + } + models.AssertCount(t, &models.PullRequest{}, pullNum+2) + prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, false, prMsg.HasMerged) + assert.Equal(t, commit, prMsg.Head.Sha) + }) + t.Run("Merge", doAPIMergePullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)) + t.Run("CheckoutMasterAgain", doGitCheckoutBranch(dstPath, "master")) + } +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index fed7b909c1..9b04a364ca 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -329,6 +329,8 @@ var migrations = []Migration{ NewMigration("Add key is verified to gpg key", addKeyIsVerified), // v189 -> v190 NewMigration("Unwrap ldap.Sources", unwrapLDAPSourceCfg), + // v190 -> v191 + NewMigration("Add agit flow pull request support", addAgitFlowPullRequest), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v190.go b/models/migrations/v190.go new file mode 100644 index 0000000000..8d1fba8373 --- /dev/null +++ b/models/migrations/v190.go @@ -0,0 +1,24 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +func addAgitFlowPullRequest(x *xorm.Engine) error { + type PullRequestFlow int + + type PullRequest struct { + Flow PullRequestFlow `xorm:"NOT NULL DEFAULT 0"` + } + + if err := x.Sync2(new(PullRequest)); err != nil { + return fmt.Errorf("sync2: %v", err) + } + return nil +} diff --git a/models/pull.go b/models/pull.go index 3717878f42..e8e815385d 100644 --- a/models/pull.go +++ b/models/pull.go @@ -38,6 +38,16 @@ const ( PullRequestStatusEmpty ) +// PullRequestFlow the flow of pull request +type PullRequestFlow int + +const ( + // PullRequestFlowGithub github flow from head branch to base branch + PullRequestFlowGithub PullRequestFlow = iota + // PullRequestFlowAGit Agit flow pull request, head branch is not exist + PullRequestFlowAGit +) + // PullRequest represents relation between pull request and repositories. type PullRequest struct { ID int64 `xorm:"pk autoincr"` @@ -58,6 +68,7 @@ type PullRequest struct { BaseRepoID int64 `xorm:"INDEX"` BaseRepo *Repository `xorm:"-"` HeadBranch string + HeadCommitID string `xorm:"-"` BaseBranch string ProtectedBranch *ProtectedBranch `xorm:"-"` MergeBase string `xorm:"VARCHAR(40)"` @@ -69,6 +80,8 @@ type PullRequest struct { MergedUnix timeutil.TimeStamp `xorm:"updated INDEX"` isHeadRepoLoaded bool `xorm:"-"` + + Flow PullRequestFlow `xorm:"NOT NULL DEFAULT 0"` } // MustHeadUserName returns the HeadRepo's username if failed return blank @@ -470,11 +483,11 @@ func NewPullRequest(repo *Repository, issue *Issue, labelIDs []int64, uuids []st // GetUnmergedPullRequest returns a pull request that is open and has not been merged // by given head/base and repo/branch. -func GetUnmergedPullRequest(headRepoID, baseRepoID int64, headBranch, baseBranch string) (*PullRequest, error) { +func GetUnmergedPullRequest(headRepoID, baseRepoID int64, headBranch, baseBranch string, flow PullRequestFlow) (*PullRequest, error) { pr := new(PullRequest) has, err := x. - Where("head_repo_id=? AND head_branch=? AND base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?", - headRepoID, headBranch, baseRepoID, baseBranch, false, false). + Where("head_repo_id=? AND head_branch=? AND base_repo_id=? AND base_branch=? AND has_merged=? AND flow = ? AND issue.is_closed=?", + headRepoID, headBranch, baseRepoID, baseBranch, false, flow, false). Join("INNER", "issue", "issue.id=pull_request.issue_id"). Get(pr) if err != nil { @@ -491,7 +504,7 @@ func GetUnmergedPullRequest(headRepoID, baseRepoID int64, headBranch, baseBranch func GetLatestPullRequestByHeadInfo(repoID int64, branch string) (*PullRequest, error) { pr := new(PullRequest) has, err := x. - Where("head_repo_id = ? AND head_branch = ?", repoID, branch). + Where("head_repo_id = ? AND head_branch = ? AND flow = ?", repoID, branch, PullRequestFlowGithub). OrderBy("id DESC"). Get(pr) if !has { @@ -566,6 +579,20 @@ func getPullRequestByIssueID(e Engine, issueID int64) (*PullRequest, error) { return pr, pr.loadAttributes(e) } +// GetAllUnmergedAgitPullRequestByPoster get all unmerged agit flow pull request +// By poster id. +func GetAllUnmergedAgitPullRequestByPoster(uid int64) ([]*PullRequest, error) { + pulls := make([]*PullRequest, 0, 10) + + err := x. + Where("has_merged=? AND flow = ? AND issue.is_closed=? AND issue.poster_id=?", + false, PullRequestFlowAGit, false, uid). + Join("INNER", "issue", "issue.id=pull_request.issue_id"). + Find(&pulls) + + return pulls, err +} + // GetPullRequestByIssueID returns pull request by given issue ID. func GetPullRequestByIssueID(issueID int64) (*PullRequest, error) { return getPullRequestByIssueID(x, issueID) @@ -663,6 +690,10 @@ func (pr *PullRequest) GetBaseBranchHTMLURL() string { // GetHeadBranchHTMLURL returns the HTML URL of the head branch func (pr *PullRequest) GetHeadBranchHTMLURL() string { + if pr.Flow == PullRequestFlowAGit { + return "" + } + if err := pr.LoadHeadRepo(); err != nil { log.Error("LoadHeadRepo: %v", err) return "" diff --git a/models/pull_list.go b/models/pull_list.go index 989de46891..2f685e19f5 100644 --- a/models/pull_list.go +++ b/models/pull_list.go @@ -51,8 +51,8 @@ func listPullRequestStatement(baseRepoID int64, opts *PullRequestsOptions) (*xor func GetUnmergedPullRequestsByHeadInfo(repoID int64, branch string) ([]*PullRequest, error) { prs := make([]*PullRequest, 0, 2) return prs, x. - Where("head_repo_id = ? AND head_branch = ? AND has_merged = ? AND issue.is_closed = ?", - repoID, branch, false, false). + Where("head_repo_id = ? AND head_branch = ? AND has_merged = ? AND issue.is_closed = ? AND flow = ?", + repoID, branch, false, false, PullRequestFlowGithub). Join("INNER", "issue", "issue.id = pull_request.issue_id"). Find(&prs) } diff --git a/models/pull_test.go b/models/pull_test.go index 5eaeb60e67..07216da324 100644 --- a/models/pull_test.go +++ b/models/pull_test.go @@ -92,11 +92,11 @@ func TestPullRequestsOldest(t *testing.T) { func TestGetUnmergedPullRequest(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) - pr, err := GetUnmergedPullRequest(1, 1, "branch2", "master") + pr, err := GetUnmergedPullRequest(1, 1, "branch2", "master", PullRequestFlowGithub) assert.NoError(t, err) assert.Equal(t, int64(2), pr.ID) - _, err = GetUnmergedPullRequest(1, 9223372036854775807, "branch1", "master") + _, err = GetUnmergedPullRequest(1, 9223372036854775807, "branch1", "master", PullRequestFlowGithub) assert.Error(t, err) assert.True(t, IsErrPullRequestNotExist(err)) } diff --git a/modules/convert/pull.go b/modules/convert/pull.go index 8bdf17a049..6c5d15c82e 100644 --- a/modules/convert/pull.go +++ b/modules/convert/pull.go @@ -95,7 +95,25 @@ func ToAPIPullRequest(pr *models.PullRequest) *api.PullRequest { } } - if pr.HeadRepo != nil { + if pr.Flow == models.PullRequestFlowAGit { + gitRepo, err := git.OpenRepository(pr.BaseRepo.RepoPath()) + if err != nil { + log.Error("OpenRepository[%s]: %v", pr.GetGitRefName(), err) + return nil + } + defer gitRepo.Close() + + apiPullRequest.Head.Sha, err = gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("GetRefCommitID[%s]: %v", pr.GetGitRefName(), err) + return nil + } + apiPullRequest.Head.RepoID = pr.BaseRepoID + apiPullRequest.Head.Repository = apiPullRequest.Base.Repository + apiPullRequest.Head.Name = "" + } + + if pr.HeadRepo != nil && pr.Flow == models.PullRequestFlowGithub { apiPullRequest.Head.RepoID = pr.HeadRepo.ID apiPullRequest.Head.Repository = ToRepo(pr.HeadRepo, models.AccessModeNone) diff --git a/modules/git/git.go b/modules/git/git.go index ef6ec0c2bf..7ab11736e8 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -37,6 +37,9 @@ var ( // will be checked on Init goVersionLessThan115 = true + + // SupportProcReceive version >= 2.29.0 + SupportProcReceive bool ) // LocalVersion returns current Git version from shell. @@ -183,6 +186,19 @@ func Init(ctx context.Context) error { } } + if CheckGitVersionAtLeast("2.29") == nil { + // set support for AGit flow + if err := checkAndAddConfig("receive.procReceiveRefs", "refs/for"); err != nil { + return err + } + SupportProcReceive = true + } else { + if err := checkAndRemoveConfig("receive.procReceiveRefs", "refs/for"); err != nil { + return err + } + SupportProcReceive = false + } + if runtime.GOOS == "windows" { if err := checkAndSetConfig("core.longpaths", "true", true); err != nil { return err @@ -232,6 +248,51 @@ func checkAndSetConfig(key, defaultValue string, forceToDefault bool) error { return nil } +func checkAndAddConfig(key, value string) error { + _, stderr, err := process.GetManager().Exec("git.Init(get setting)", GitExecutable, "config", "--get", key, value) + if err != nil { + perr, ok := err.(*process.Error) + if !ok { + return fmt.Errorf("Failed to get git %s(%v) errType %T: %s", key, err, err, stderr) + } + eerr, ok := perr.Err.(*exec.ExitError) + if !ok || eerr.ExitCode() != 1 { + return fmt.Errorf("Failed to get git %s(%v) errType %T: %s", key, err, err, stderr) + } + if eerr.ExitCode() == 1 { + if _, stderr, err = process.GetManager().Exec(fmt.Sprintf("git.Init(set %s)", key), "git", "config", "--global", "--add", key, value); err != nil { + return fmt.Errorf("Failed to set git %s(%s): %s", key, err, stderr) + } + return nil + } + } + + return nil +} + +func checkAndRemoveConfig(key, value string) error { + _, stderr, err := process.GetManager().Exec("git.Init(get setting)", GitExecutable, "config", "--get", key, value) + if err != nil { + perr, ok := err.(*process.Error) + if !ok { + return fmt.Errorf("Failed to get git %s(%v) errType %T: %s", key, err, err, stderr) + } + eerr, ok := perr.Err.(*exec.ExitError) + if !ok || eerr.ExitCode() != 1 { + return fmt.Errorf("Failed to get git %s(%v) errType %T: %s", key, err, err, stderr) + } + if eerr.ExitCode() == 1 { + return nil + } + } + + if _, stderr, err = process.GetManager().Exec(fmt.Sprintf("git.Init(set %s)", key), "git", "config", "--global", "--unset-all", key, value); err != nil { + return fmt.Errorf("Failed to set git %s(%s): %s", key, err, stderr) + } + + return nil +} + // Fsck verifies the connectivity and validity of the objects in the database func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args ...string) error { // Make sure timeout makes sense. diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go index 58781eb1c7..7c30b1fb20 100644 --- a/modules/git/repo_branch.go +++ b/modules/git/repo_branch.go @@ -13,6 +13,14 @@ import ( // BranchPrefix base dir of the branch information file store on git const BranchPrefix = "refs/heads/" +// AGit Flow + +// PullRequestPrefix sepcial ref to create a pull request: refs/for/<targe-branch>/<topic-branch> +// or refs/for/<targe-branch> -o topic='<topic-branch>' +const PullRequestPrefix = "refs/for/" + +// TODO: /refs/for-review for suggest change interface + // IsReferenceExist returns true if given reference exists in the repository. func IsReferenceExist(repoPath, name string) bool { _, err := NewCommand("show-ref", "--verify", "--", name).RunInDir(repoPath) diff --git a/modules/private/hook.go b/modules/private/hook.go index 9596f5f4da..4d0b5d22bf 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -6,6 +6,7 @@ package private import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -56,6 +57,7 @@ type HookOptions struct { GitPushOptions GitPushOptions PullRequestID int64 IsDeployKey bool + IsWiki bool } // SSHLogOption ssh log options @@ -79,6 +81,23 @@ type HookPostReceiveBranchResult struct { URL string } +// HockProcReceiveResult represents an individual result from ProcReceive +type HockProcReceiveResult struct { + Results []HockProcReceiveRefResult + Err string +} + +// HockProcReceiveRefResult represents an individual result from ProcReceive +type HockProcReceiveRefResult struct { + OldOID string + NewOID string + Ref string + OriginalRef string + IsForcePush bool + IsNotMatched bool + Err string +} + // HookPreReceive check whether the provided commits are allowed func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (int, string) { reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/pre-receive/%s/%s", @@ -130,6 +149,33 @@ func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookO return res, "" } +// HookProcReceive proc-receive hook +func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HockProcReceiveResult, error) { + reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/proc-receive/%s/%s", + url.PathEscape(ownerName), + url.PathEscape(repoName), + ) + + req := newInternalRequest(ctx, reqURL, "POST") + req = req.Header("Content-Type", "application/json") + req.SetTimeout(60*time.Second, time.Duration(60+len(opts.OldCommitIDs))*time.Second) + jsonBytes, _ := json.Marshal(opts) + req.Body(jsonBytes) + resp, err := req.Response() + if err != nil { + return nil, fmt.Errorf("Unable to contact gitea: %v", err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(decodeJSONError(resp).Err) + } + res := &HockProcReceiveResult{} + _ = json.NewDecoder(resp.Body).Decode(res) + + return res, nil +} + // SetDefaultBranch will set the default branch to the provided branch for the provided repository func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) error { reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s/%s", diff --git a/modules/repository/hooks.go b/modules/repository/hooks.go index ed6036851e..8b4e7d6302 100644 --- a/modules/repository/hooks.go +++ b/modules/repository/hooks.go @@ -12,6 +12,7 @@ import ( "path/filepath" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -75,6 +76,14 @@ done fmt.Sprintf("#!/usr/bin/env %s\n%s hook --config=%s update $1 $2 $3\n", setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)), fmt.Sprintf("#!/usr/bin/env %s\n%s hook --config=%s post-receive\n", setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)), } + + if git.SupportProcReceive { + hookNames = append(hookNames, "proc-receive") + hookTpls = append(hookTpls, + fmt.Sprintf("#!/usr/bin/env %s\n%s hook --config=%s proc-receive\n", setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf))) + giteaHookTpls = append(giteaHookTpls, "") + } + return } diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 66bcabfd38..de166d7ecb 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -310,7 +310,7 @@ func CreatePullRequest(ctx *context.APIContext) { defer headGitRepo.Close() // Check if another PR exists with the same targets - existingPr, err := models.GetUnmergedPullRequest(headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch) + existingPr, err := models.GetUnmergedPullRequest(headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch, models.PullRequestFlowGithub) if err != nil { if !models.IsErrPullRequestNotExist(err) { ctx.Error(http.StatusInternalServerError, "GetUnmergedPullRequest", err) diff --git a/routers/private/hook.go b/routers/private/hook.go index 9f5579b6ae..4bed86f38a 100644 --- a/routers/private/hook.go +++ b/routers/private/hook.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/agit" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" ) @@ -155,6 +156,56 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { private.GitQuarantinePath+"="+opts.GitQuarantinePath) } + if git.SupportProcReceive { + pusher, err := models.GetUserByID(opts.UserID) + if err != nil { + log.Error("models.GetUserByID:%v", err) + ctx.Error(http.StatusInternalServerError, "") + return + } + + perm, err := models.GetUserRepoPermission(repo, pusher) + if err != nil { + log.Error("models.GetUserRepoPermission:%v", err) + ctx.Error(http.StatusInternalServerError, "") + return + } + + canCreatePullRequest := perm.CanRead(models.UnitTypePullRequests) + + for _, refFullName := range opts.RefFullNames { + // if user want update other refs (branch or tag), + // should check code write permission because + // this check was delayed. + if !strings.HasPrefix(refFullName, git.PullRequestPrefix) { + if !perm.CanWrite(models.UnitTypeCode) { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "User permission denied.", + }) + return + } + + break + } else if repo.IsEmpty { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "Can't create pull request for an empty repository.", + }) + return + } else if !canCreatePullRequest { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "User permission denied.", + }) + return + } else if opts.IsWiki { + // TODO: maybe can do it ... + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "not support send pull request to wiki.", + }) + return + } + } + } + protectedTags, err := repo.GetProtectedTags() if err != nil { log.Error("Unable to get protected tags for %-v Error: %v", repo, err) @@ -392,11 +443,35 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { }) return } + } else if git.SupportProcReceive && strings.HasPrefix(refFullName, git.PullRequestPrefix) { + baseBranchName := opts.RefFullNames[i][len(git.PullRequestPrefix):] + + baseBranchExist := false + if gitRepo.IsBranchExist(baseBranchName) { + baseBranchExist = true + } + + if !baseBranchExist { + for p, v := range baseBranchName { + if v == '/' && gitRepo.IsBranchExist(baseBranchName[:p]) && p != len(baseBranchName)-1 { + baseBranchExist = true + break + } + } + } + + if !baseBranchExist { + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("Unexpected ref: %s", refFullName), + }) + return + } } else { log.Error("Unexpected ref: %s", refFullName) ctx.JSON(http.StatusInternalServerError, private.Response{ Err: fmt.Sprintf("Unexpected ref: %s", refFullName), }) + return } } @@ -537,7 +612,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { continue } - pr, err := models.GetUnmergedPullRequest(repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch) + pr, err := models.GetUnmergedPullRequest(repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, models.PullRequestFlowGithub) if err != nil && !models.IsErrPullRequestNotExist(err) { log.Error("Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err) ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ @@ -574,6 +649,30 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { }) } +// HookProcReceive proc-receive hook +func HookProcReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + if !git.SupportProcReceive { + ctx.Status(http.StatusNotFound) + return + } + + cancel := loadRepositoryAndGitRepoByParams(ctx) + if ctx.Written() { + return + } + defer cancel() + + results := agit.ProcRecive(ctx, opts) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusOK, private.HockProcReceiveResult{ + Results: results, + }) +} + // SetDefaultBranch updates the default branch func SetDefaultBranch(ctx *gitea_context.PrivateContext) { ownerName := ctx.Params(":owner") @@ -618,3 +717,44 @@ func SetDefaultBranch(ctx *gitea_context.PrivateContext) { } ctx.PlainText(http.StatusOK, []byte("success")) } + +func loadRepositoryAndGitRepoByParams(ctx *gitea_context.PrivateContext) context.CancelFunc { + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + + repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) + if err != nil { + log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + if repo.OwnerName == "" { + repo.OwnerName = ownerName + } + + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + + ctx.Repo = &gitea_context.Repository{ + Repository: repo, + GitRepo: gitRepo, + } + + // We opened it, we should close it + cancel := func() { + // If it's been set to nil then assume someone else has closed it. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } + } + + return cancel +} diff --git a/routers/private/internal.go b/routers/private/internal.go index 9202e67218..155e8c036b 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -58,6 +58,7 @@ func Routes() *web.Route { r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog) r.Post("/hook/pre-receive/{owner}/{repo}", bind(private.HookOptions{}), HookPreReceive) r.Post("/hook/post-receive/{owner}/{repo}", bind(private.HookOptions{}), HookPostReceive) + r.Post("/hook/proc-receive/{owner}/{repo}", bind(private.HookOptions{}), HookProcReceive) r.Post("/hook/set-default-branch/{owner}/{repo}/{branch}", SetDefaultBranch) r.Get("/serv/none/{keyid}", ServNoCommand) r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand) diff --git a/routers/private/serv.go b/routers/private/serv.go index 6e39790eb5..f80d16a7f8 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/setting" @@ -288,6 +289,11 @@ func ServCommand(ctx *context.PrivateContext) { return } } else { + // Because of special ref "refs/for" .. , need delay write permission check + if git.SupportProcReceive && unitType == models.UnitTypeCode { + mode = models.AccessModeRead + } + perm, err := models.GetUserRepoPermission(repo, user) if err != nil { log.Error("Unable to get permissions for %-v with key %d in %-v Error: %v", user, key.ID, repo, err) diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index e66aa614cb..f405362cf5 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -653,7 +653,7 @@ func CompareDiff(ctx *context.Context) { ctx.Data["HeadTags"] = headTags if ctx.Data["PageIsComparePull"] == true { - pr, err := models.GetUnmergedPullRequest(headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch) + pr, err := models.GetUnmergedPullRequest(headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch, models.PullRequestFlowGithub) if err != nil { if !models.IsErrPullRequestNotExist(err) { ctx.ServerError("GetUnmergedPullRequest", err) diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go index 3390f026a0..7947776f16 100644 --- a/routers/web/repo/http.go +++ b/routers/web/repo/http.go @@ -198,6 +198,11 @@ func httpBase(ctx *context.Context) (h *serviceHandler) { return } + // Because of special ref "refs/for" .. , need delay write permission check + if git.SupportProcReceive { + accessMode = models.AccessModeRead + } + if !perm.CanAccess(accessMode, unitType) { ctx.HandleText(http.StatusForbidden, "User permission denied") return diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 9639ea8201..8518917828 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2047,7 +2047,7 @@ func NewComment(ctx *context.Context) { if form.Status == "reopen" && issue.IsPull { pull := issue.PullRequest var err error - pr, err = models.GetUnmergedPullRequest(pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch) + pr, err = models.GetUnmergedPullRequest(pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) if err != nil { if !models.IsErrPullRequestNotExist(err) { ctx.ServerError("GetUnmergedPullRequest", err) @@ -2057,6 +2057,7 @@ func NewComment(ctx *context.Context) { // Regenerate patch and test conflict. if pr == nil { + issue.PullRequest.HeadCommitID = "" pull_service.AddToTaskQueue(issue.PullRequest) } } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 703bbd837a..565c645801 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -427,10 +427,18 @@ func PrepareViewPullInfo(ctx *context.Context, issue *models.Issue) *git.Compare } defer headGitRepo.Close() - headBranchExist = headGitRepo.IsBranchExist(pull.HeadBranch) + if pull.Flow == models.PullRequestFlowGithub { + headBranchExist = headGitRepo.IsBranchExist(pull.HeadBranch) + } else { + headBranchExist = git.IsReferenceExist(baseGitRepo.Path, pull.GetGitRefName()) + } if headBranchExist { - headBranchSha, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) + if pull.Flow != models.PullRequestFlowGithub { + headBranchSha, err = baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + } else { + headBranchSha, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) + } if err != nil { ctx.ServerError("GetBranchCommitID", err) return nil diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 682f920578..bec523509c 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/agit" "code.gitea.io/gitea/services/forms" "github.com/unknwon/i18n" @@ -76,6 +77,14 @@ func HandleUsernameChange(ctx *context.Context, user *models.User, newName strin return err } } + + // update all agit flow pull request header + err := agit.UserNameChanged(user, newName) + if err != nil { + ctx.ServerError("agit.UserNameChanged", err) + return err + } + log.Trace("User name changed: %s -> %s", user.Name, newName) return nil } diff --git a/routers/web/web.go b/routers/web/web.go index 26e6c31a47..a47fd518ac 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/metrics" @@ -146,6 +147,21 @@ func Routes() *web.Route { routes.Get("/metrics", append(common, Metrics)...) } + routes.Get("/ssh_info", func(rw http.ResponseWriter, req *http.Request) { + if !git.SupportProcReceive { + rw.WriteHeader(404) + return + } + rw.Header().Set("content-type", "text/json;charset=UTF-8") + _, err := rw.Write([]byte(`{"type":"gitea","version":1}`)) + if err != nil { + log.Error("fail to write result: err: %v", err) + rw.WriteHeader(500) + return + } + rw.WriteHeader(200) + }) + // Removed: toolbox.Toolboxer middleware will provide debug information which seems unnecessary common = append(common, context.Contexter()) diff --git a/services/agit/agit.go b/services/agit/agit.go new file mode 100644 index 0000000000..a89c255fe7 --- /dev/null +++ b/services/agit/agit.go @@ -0,0 +1,288 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package agit + +import ( + "fmt" + "net/http" + "os" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/notification" + "code.gitea.io/gitea/modules/private" + pull_service "code.gitea.io/gitea/services/pull" +) + +// ProcRecive handle proc receive work +func ProcRecive(ctx *context.PrivateContext, opts *private.HookOptions) []private.HockProcReceiveRefResult { + // TODO: Add more options? + var ( + topicBranch string + title string + description string + forcePush bool + ) + + results := make([]private.HockProcReceiveRefResult, 0, len(opts.OldCommitIDs)) + repo := ctx.Repo.Repository + gitRepo := ctx.Repo.GitRepo + ownerName := ctx.Repo.Repository.OwnerName + repoName := ctx.Repo.Repository.Name + + topicBranch = opts.GitPushOptions["topic"] + _, forcePush = opts.GitPushOptions["force-push"] + + for i := range opts.OldCommitIDs { + if opts.NewCommitIDs[i] == git.EmptySHA { + results = append(results, private.HockProcReceiveRefResult{ + OriginalRef: opts.RefFullNames[i], + OldOID: opts.OldCommitIDs[i], + NewOID: opts.NewCommitIDs[i], + Err: "Can't delete not exist branch", + }) + continue + } + + if !strings.HasPrefix(opts.RefFullNames[i], git.PullRequestPrefix) { + results = append(results, private.HockProcReceiveRefResult{ + IsNotMatched: true, + OriginalRef: opts.RefFullNames[i], + }) + continue + } + + baseBranchName := opts.RefFullNames[i][len(git.PullRequestPrefix):] + curentTopicBranch := "" + if !gitRepo.IsBranchExist(baseBranchName) { + // try match refs/for/<target-branch>/<topic-branch> + for p, v := range baseBranchName { + if v == '/' && gitRepo.IsBranchExist(baseBranchName[:p]) && p != len(baseBranchName)-1 { + curentTopicBranch = baseBranchName[p+1:] + baseBranchName = baseBranchName[:p] + break + } + } + } + + if len(topicBranch) == 0 && len(curentTopicBranch) == 0 { + results = append(results, private.HockProcReceiveRefResult{ + OriginalRef: opts.RefFullNames[i], + OldOID: opts.OldCommitIDs[i], + NewOID: opts.NewCommitIDs[i], + Err: "topic-branch is not set", + }) + continue + } + + headBranch := "" + userName := strings.ToLower(opts.UserName) + + if len(curentTopicBranch) == 0 { + curentTopicBranch = topicBranch + } + + // because different user maybe want to use same topic, + // So it's better to make sure the topic branch name + // has user name prefix + if !strings.HasPrefix(curentTopicBranch, userName+"/") { + headBranch = userName + "/" + curentTopicBranch + } else { + headBranch = curentTopicBranch + } + + pr, err := models.GetUnmergedPullRequest(repo.ID, repo.ID, headBranch, baseBranchName, models.PullRequestFlowAGit) + if err != nil { + if !models.IsErrPullRequestNotExist(err) { + log.Error("Failed to get unmerged agit flow pull request in repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to get unmerged agit flow pull request in repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + + // create a new pull request + if len(title) == 0 { + has := false + title, has = opts.GitPushOptions["title"] + if !has || len(title) == 0 { + commit, err := gitRepo.GetCommit(opts.NewCommitIDs[i]) + if err != nil { + log.Error("Failed to get commit %s in repository: %s/%s Error: %v", opts.NewCommitIDs[i], ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to get commit %s in repository: %s/%s Error: %v", opts.NewCommitIDs[i], ownerName, repoName, err), + }) + return nil + } + title = strings.Split(commit.CommitMessage, "\n")[0] + } + description = opts.GitPushOptions["description"] + } + + pusher, err := models.GetUserByID(opts.UserID) + if err != nil { + log.Error("Failed to get user. Error: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to get user. Error: %v", err), + }) + return nil + } + + prIssue := &models.Issue{ + RepoID: repo.ID, + Title: title, + PosterID: pusher.ID, + Poster: pusher, + IsPull: true, + Content: description, + } + + pr := &models.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: headBranch, + HeadCommitID: opts.NewCommitIDs[i], + BaseBranch: baseBranchName, + HeadRepo: repo, + BaseRepo: repo, + MergeBase: "", + Type: models.PullRequestGitea, + Flow: models.PullRequestFlowAGit, + } + + if err := pull_service.NewPullRequest(repo, prIssue, []int64{}, []string{}, pr, []int64{}); err != nil { + if models.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + return nil + } + ctx.Error(http.StatusInternalServerError, "NewPullRequest", err.Error()) + return nil + } + + log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID) + + results = append(results, private.HockProcReceiveRefResult{ + Ref: pr.GetGitRefName(), + OriginalRef: opts.RefFullNames[i], + OldOID: git.EmptySHA, + NewOID: opts.NewCommitIDs[i], + }) + continue + } + + // update exist pull request + if err := pr.LoadBaseRepo(); err != nil { + log.Error("Unable to load base repository for PR[%d] Error: %v", pr.ID, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Unable to load base repository for PR[%d] Error: %v", pr.ID, err), + }) + return nil + } + + oldCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("Unable to get ref commit id in base repository for PR[%d] Error: %v", pr.ID, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Unable to get ref commit id in base repository for PR[%d] Error: %v", pr.ID, err), + }) + return nil + } + + if oldCommitID == opts.NewCommitIDs[i] { + results = append(results, private.HockProcReceiveRefResult{ + OriginalRef: opts.RefFullNames[i], + OldOID: opts.OldCommitIDs[i], + NewOID: opts.NewCommitIDs[i], + Err: "new commit is same with old commit", + }) + continue + } + + if !forcePush { + output, err := git.NewCommand("rev-list", "--max-count=1", oldCommitID, "^"+opts.NewCommitIDs[i]).RunInDirWithEnv(repo.RepoPath(), os.Environ()) + if err != nil { + log.Error("Unable to detect force push between: %s and %s in %-v Error: %v", oldCommitID, opts.NewCommitIDs[i], repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Fail to detect force push: %v", err), + }) + return nil + } else if len(output) > 0 { + results = append(results, private.HockProcReceiveRefResult{ + OriginalRef: oldCommitID, + OldOID: opts.OldCommitIDs[i], + NewOID: opts.NewCommitIDs[i], + Err: "request `force-push` push option", + }) + continue + } + } + + pr.HeadCommitID = opts.NewCommitIDs[i] + if err = pull_service.UpdateRef(pr); err != nil { + log.Error("Failed to update pull ref. Error: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to update pull ref. Error: %v", err), + }) + return nil + } + + pull_service.AddToTaskQueue(pr) + pusher, err := models.GetUserByID(opts.UserID) + if err != nil { + log.Error("Failed to get user. Error: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to get user. Error: %v", err), + }) + return nil + } + err = pr.LoadIssue() + if err != nil { + log.Error("Failed to load pull issue. Error: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to load pull issue. Error: %v", err), + }) + return nil + } + comment, err := models.CreatePushPullComment(pusher, pr, oldCommitID, opts.NewCommitIDs[i]) + if err == nil && comment != nil { + notification.NotifyPullRequestPushCommits(pusher, pr, comment) + } + notification.NotifyPullRequestSynchronized(pusher, pr) + isForcePush := comment != nil && comment.IsForcePush + + results = append(results, private.HockProcReceiveRefResult{ + OldOID: oldCommitID, + NewOID: opts.NewCommitIDs[i], + Ref: pr.GetGitRefName(), + OriginalRef: opts.RefFullNames[i], + IsForcePush: isForcePush, + }) + } + + return results +} + +// UserNameChanged hanle user name change for agit flow pull +func UserNameChanged(user *models.User, newName string) error { + pulls, err := models.GetAllUnmergedAgitPullRequestByPoster(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("head_branch"); err != nil { + return err + } + } + + return nil +} diff --git a/services/pull/commit_status.go b/services/pull/commit_status.go index b8fb109440..c5c930ee0d 100644 --- a/services/pull/commit_status.go +++ b/services/pull/commit_status.go @@ -108,13 +108,21 @@ func GetPullRequestCommitStatusState(pr *models.PullRequest) (structs.CommitStat } defer headGitRepo.Close() - if !headGitRepo.IsBranchExist(pr.HeadBranch) { + if pr.Flow == models.PullRequestFlowGithub && !headGitRepo.IsBranchExist(pr.HeadBranch) { + return "", errors.New("Head branch does not exist, can not merge") + } + if pr.Flow == models.PullRequestFlowAGit && !git.IsReferenceExist(headGitRepo.Path, pr.GetGitRefName()) { return "", errors.New("Head branch does not exist, can not merge") } - sha, err := headGitRepo.GetBranchCommitID(pr.HeadBranch) + var sha string + if pr.Flow == models.PullRequestFlowGithub { + sha, err = headGitRepo.GetBranchCommitID(pr.HeadBranch) + } else { + sha, err = headGitRepo.GetRefCommitID(pr.GetGitRefName()) + } if err != nil { - return "", errors.Wrap(err, "GetBranchCommitID") + return "", err } if err := pr.LoadBaseRepo(); err != nil { diff --git a/services/pull/pull.go b/services/pull/pull.go index 6c108c224f..b33f641130 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -49,7 +49,12 @@ func NewPullRequest(repo *models.Repository, pull *models.Issue, labelIDs []int6 pr.Issue = pull pull.PullRequest = pr - if err := PushToBaseRepo(pr); err != nil { + if pr.Flow == models.PullRequestFlowGithub { + err = PushToBaseRepo(pr) + } else { + err = UpdateRef(pr) + } + if err != nil { return err } @@ -145,7 +150,7 @@ func ChangeTargetBranch(pr *models.PullRequest, doer *models.User, targetBranch } // Check if pull request for the new target branch already exists - existingPr, err := models.GetUnmergedPullRequest(pr.HeadRepoID, pr.BaseRepoID, pr.HeadBranch, targetBranch) + existingPr, err := models.GetUnmergedPullRequest(pr.HeadRepoID, pr.BaseRepoID, pr.HeadBranch, targetBranch, models.PullRequestFlowGithub) if existingPr != nil { return models.ErrPullRequestAlreadyExists{ ID: existingPr.ID, @@ -281,8 +286,12 @@ func AddTestPullRequestTask(doer *models.User, repoID int64, branch string, isSy for _, pr := range prs { log.Trace("Updating PR[%d]: composing new test task", pr.ID) - if err := PushToBaseRepo(pr); err != nil { - log.Error("PushToBaseRepo: %v", err) + if pr.Flow == models.PullRequestFlowGithub { + if err := PushToBaseRepo(pr); err != nil { + log.Error("PushToBaseRepo: %v", err) + continue + } + } else { continue } @@ -451,6 +460,22 @@ func pushToBaseRepoHelper(pr *models.PullRequest, prefixHeadBranch string) (err return nil } +// UpdateRef update refs/pull/id/head directly for agit flow pull request +func UpdateRef(pr *models.PullRequest) (err error) { + log.Trace("UpdateRef[%d]: upgate pull request ref in base repo '%s'", pr.ID, pr.GetGitRefName()) + if err := pr.LoadBaseRepo(); err != nil { + log.Error("Unable to load base repository for PR[%d] Error: %v", pr.ID, err) + return err + } + + _, err = git.NewCommand("update-ref", pr.GetGitRefName(), pr.HeadCommitID).RunInDir(pr.BaseRepo.RepoPath()) + if err != nil { + log.Error("Unable to update ref in base repository for PR[%d] Error: %v", pr.ID, err) + } + + return err +} + type errlist []error func (errs errlist) Error() string { @@ -562,7 +587,17 @@ func GetSquashMergeCommitMessages(pr *models.PullRequest) string { } defer gitRepo.Close() - headCommit, err := gitRepo.GetBranchCommit(pr.HeadBranch) + var headCommit *git.Commit + if pr.Flow == models.PullRequestFlowGithub { + headCommit, err = gitRepo.GetBranchCommit(pr.HeadBranch) + } else { + pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("Unable to get head commit: %s Error: %v", pr.GetGitRefName(), err) + return "" + } + headCommit, err = gitRepo.GetCommit(pr.HeadCommitID) + } if err != nil { log.Error("Unable to get head commit: %s Error: %v", pr.HeadBranch, err) return "" @@ -781,9 +816,20 @@ func IsHeadEqualWithBranch(pr *models.PullRequest, branchName string) (bool, err } defer headGitRepo.Close() - headCommit, err := headGitRepo.GetBranchCommit(pr.HeadBranch) - if err != nil { - return false, err + var headCommit *git.Commit + if pr.Flow == models.PullRequestFlowGithub { + headCommit, err = headGitRepo.GetBranchCommit(pr.HeadBranch) + if err != nil { + return false, err + } + } else { + pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + return false, err + } + if headCommit, err = baseGitRepo.GetCommit(pr.HeadCommitID); err != nil { + return false, err + } } return baseCommit.HasPreviousCommit(headCommit.ID) } diff --git a/services/pull/temp_repo.go b/services/pull/temp_repo.go index 19b488790a..54d09c8158 100644 --- a/services/pull/temp_repo.go +++ b/services/pull/temp_repo.go @@ -140,7 +140,15 @@ func createTemporaryRepo(pr *models.PullRequest) (string, error) { trackingBranch := "tracking" // Fetch head branch - if err := git.NewCommand("fetch", "--no-tags", remoteRepoName, git.BranchPrefix+pr.HeadBranch+":"+trackingBranch).RunInDirPipeline(tmpBasePath, &outbuf, &errbuf); err != nil { + var headBranch string + if pr.Flow == models.PullRequestFlowGithub { + headBranch = git.BranchPrefix + pr.HeadBranch + } else if len(pr.HeadCommitID) == 40 { // for not created pull request + headBranch = pr.HeadCommitID + } else { + headBranch = pr.GetGitRefName() + } + if err := git.NewCommand("fetch", "--no-tags", remoteRepoName, headBranch+":"+trackingBranch).RunInDirPipeline(tmpBasePath, &outbuf, &errbuf); err != nil { if err := models.RemoveTemporaryPath(tmpBasePath); err != nil { log.Error("CreateTempRepo: RemoveTemporaryPath: %s", err) } @@ -150,7 +158,7 @@ func createTemporaryRepo(pr *models.PullRequest) (string, error) { } } log.Error("Unable to fetch head_repo head branch [%s:%s -> tracking in %s]: %v:\n%s\n%s", pr.HeadRepo.FullName(), pr.HeadBranch, tmpBasePath, err, outbuf.String(), errbuf.String()) - return "", fmt.Errorf("Unable to fetch head_repo head branch [%s:%s -> tracking in tmpBasePath]: %v\n%s\n%s", pr.HeadRepo.FullName(), pr.HeadBranch, err, outbuf.String(), errbuf.String()) + return "", fmt.Errorf("Unable to fetch head_repo head branch [%s:%s -> tracking in tmpBasePath]: %v\n%s\n%s", pr.HeadRepo.FullName(), headBranch, err, outbuf.String(), errbuf.String()) } outbuf.Reset() errbuf.Reset() diff --git a/services/pull/update.go b/services/pull/update.go index f35e47cbf8..c2c13845e3 100644 --- a/services/pull/update.go +++ b/services/pull/update.go @@ -22,6 +22,11 @@ func Update(pull *models.PullRequest, doer *models.User, message string) error { BaseBranch: pull.HeadBranch, } + if pull.Flow == models.PullRequestFlowAGit { + // TODO: Not support update agit flow pull request's head branch + return fmt.Errorf("Not support update agit flow pull request's head branch") + } + if err := pr.LoadHeadRepo(); err != nil { log.Error("LoadHeadRepo: %v", err) return fmt.Errorf("LoadHeadRepo: %v", err) @@ -48,6 +53,10 @@ func Update(pull *models.PullRequest, doer *models.User, message string) error { // IsUserAllowedToUpdate check if user is allowed to update PR with given permissions and branch protections func IsUserAllowedToUpdate(pull *models.PullRequest, user *models.User) (bool, error) { + if pull.Flow == models.PullRequestFlowAGit { + return false, nil + } + if user == nil { return false, nil } diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl index fcb3597ae8..15ce63b4d3 100644 --- a/templates/repo/issue/view_content/pull.tmpl +++ b/templates/repo/issue/view_content/pull.tmpl @@ -461,13 +461,17 @@ {{end}} </div> </div> - <div class="instruct-toggle ml-3">{{$.i18n.Tr "repo.pulls.merge_instruction_hint" | Safe}}</div> + <div class="instruct-toggle ml-3"> {{$.i18n.Tr "repo.pulls.merge_instruction_hint" | Safe}} </div> <div class="instruct-content" style="display:none"> <div class="ui divider"></div> <div><h3 class="di">{{$.i18n.Tr "step1"}} </h3>{{$.i18n.Tr "repo.pulls.merge_instruction_step1_desc"}}</div> <div class="ui secondary segment"> - <div>git checkout -b {{if ne .Issue.PullRequest.HeadRepo.ID .Issue.PullRequest.BaseRepo.ID}}{{.Issue.PullRequest.HeadRepo.OwnerName}}-{{end}}{{.Issue.PullRequest.HeadBranch}} {{.Issue.PullRequest.BaseBranch}}</div> - <div>git pull {{if ne .Issue.PullRequest.HeadRepo.ID .Issue.PullRequest.BaseRepo.ID}}{{.Issue.PullRequest.HeadRepo.HTMLURL}}{{else}}origin{{end}} {{.Issue.PullRequest.HeadBranch}}</div> + {{if eq .Issue.PullRequest.Flow 0}} + <div>git checkout -b {{if ne .Issue.PullRequest.HeadRepo.ID .Issue.PullRequest.BaseRepo.ID}}{{.Issue.PullRequest.HeadRepo.OwnerName}}-{{end}}{{.Issue.PullRequest.HeadBranch}} {{.Issue.PullRequest.BaseBranch}}</div> + <div>git pull {{if ne .Issue.PullRequest.HeadRepo.ID .Issue.PullRequest.BaseRepo.ID}}{{.Issue.PullRequest.HeadRepo.HTMLURL}}{{else}}origin{{end}} {{.Issue.PullRequest.HeadBranch}}</div> + {{else}} + <div>git fetch origin {{.Issue.PullRequest.GetGitRefName}}:{{.Issue.PullRequest.HeadBranch}}</div> + {{end}} </div> <div><h3 class="di">{{$.i18n.Tr "step2"}} </h3>{{$.i18n.Tr "repo.pulls.merge_instruction_step2_desc"}}</div> <div class="ui secondary segment"> |