From 1260023547c6b975ed9d068b13cb1f5659f49928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 21 Dec 2021 16:44:27 +0100 Subject: [PATCH] repo sync: Use the new merge-upstream API if available This helps avoid the errors when a new commit from upstream is not yet available in the fork, resulting in a HTTP 404 when trying to update the ref in the fork. If the merge-upstream API is unavailable (404) or errors out (409/422), the functionality falls back to the previous functionality of manually updating refs. This ensures that `--force` still has effect. --- pkg/cmd/repo/sync/http.go | 35 ++++++++++++++ pkg/cmd/repo/sync/sync.go | 77 ++++++++++++++++++------------- pkg/cmd/repo/sync/sync_test.go | 84 +++++++++++++++++++--------------- 3 files changed, 128 insertions(+), 68 deletions(-) diff --git a/pkg/cmd/repo/sync/http.go b/pkg/cmd/repo/sync/http.go index 2c1e967ced6..d5cc0e2821c 100644 --- a/pkg/cmd/repo/sync/http.go +++ b/pkg/cmd/repo/sync/http.go @@ -3,7 +3,9 @@ package sync import ( "bytes" "encoding/json" + "errors" "fmt" + "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" @@ -27,6 +29,39 @@ func latestCommit(client *api.Client, repo ghrepo.Interface, branch string) (com return response, err } +type upstreamMergeErr struct{ error } + +var upstreamMergeUnavailableErr = upstreamMergeErr{errors.New("upstream merge API is unavailable")} + +func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch string) (string, error) { + var payload bytes.Buffer + if err := json.NewEncoder(&payload).Encode(map[string]interface{}{ + "branch": branch, + }); err != nil { + return "", err + } + + var response struct { + Message string `json:"message"` + MergeType string `json:"merge_type"` + BaseBranch string `json:"base_branch"` + } + path := fmt.Sprintf("repos/%s/%s/merge-upstream", repo.RepoOwner(), repo.RepoName()) + var httpErr api.HTTPError + if err := client.REST(repo.RepoHost(), "POST", path, &payload, &response); err != nil { + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case http.StatusUnprocessableEntity, http.StatusConflict: + return "", upstreamMergeErr{errors.New(httpErr.Message)} + case http.StatusNotFound: + return "", upstreamMergeUnavailableErr + } + } + return "", err + } + return response.BaseBranch, nil +} + func syncFork(client *api.Client, repo ghrepo.Interface, branch, SHA string, force bool) error { path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) body := map[string]interface{}{ diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index 8a71161f5fa..50238df0ce5 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" @@ -177,38 +178,19 @@ func syncRemoteRepo(opts *SyncOptions) error { return err } - if opts.SrcArg == "" { - opts.IO.StartProgressIndicator() - srcRepo, err = api.RepoParent(apiClient, destRepo) - opts.IO.StopProgressIndicator() - if err != nil { - return err - } - if srcRepo == nil { - return fmt.Errorf("can't determine source repository for %s because repository is not fork", ghrepo.FullName(destRepo)) - } - } else { + if opts.SrcArg != "" { srcRepo, err = ghrepo.FromFullName(opts.SrcArg) if err != nil { return err } } - if destRepo.RepoHost() != srcRepo.RepoHost() { + if srcRepo != nil && destRepo.RepoHost() != srcRepo.RepoHost() { return fmt.Errorf("can't sync repositories from different hosts") } - if opts.Branch == "" { - opts.IO.StartProgressIndicator() - opts.Branch, err = api.RepoDefaultBranch(apiClient, srcRepo) - opts.IO.StopProgressIndicator() - if err != nil { - return err - } - } - opts.IO.StartProgressIndicator() - err = executeRemoteRepoSync(apiClient, destRepo, srcRepo, opts) + baseBranchLabel, err := executeRemoteRepoSync(apiClient, destRepo, srcRepo, opts) opts.IO.StopProgressIndicator() if err != nil { if errors.Is(err, divergingError) { @@ -219,11 +201,15 @@ func syncRemoteRepo(opts *SyncOptions) error { if opts.IO.IsStdoutTTY() { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "%s Synced the \"%s\" branch from %s to %s\n", + branchName := opts.Branch + if idx := strings.Index(baseBranchLabel, ":"); idx >= 0 { + branchName = baseBranchLabel[idx+1:] + } + fmt.Fprintf(opts.IO.Out, "%s Synced the \"%s:%s\" branch from \"%s\"\n", cs.SuccessIcon(), - opts.Branch, - ghrepo.FullName(srcRepo), - ghrepo.FullName(destRepo)) + destRepo.RepoOwner(), + branchName, + baseBranchLabel) } return nil @@ -294,20 +280,47 @@ func executeLocalRepoSync(srcRepo ghrepo.Interface, remote string, opts *SyncOpt return nil } -func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interface, opts *SyncOptions) error { - commit, err := latestCommit(client, srcRepo, opts.Branch) +func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interface, opts *SyncOptions) (string, error) { + branchName := opts.Branch + if branchName == "" { + var err error + branchName, err = api.RepoDefaultBranch(client, destRepo) + if err != nil { + return "", err + } + } + + var apiErr upstreamMergeErr + if baseBranch, err := triggerUpstreamMerge(client, destRepo, branchName); err == nil { + return baseBranch, nil + } else if !errors.As(err, &apiErr) { + return "", err + } + + if srcRepo == nil { + var err error + srcRepo, err = api.RepoParent(client, destRepo) + if err != nil { + return "", err + } + if srcRepo == nil { + return "", fmt.Errorf("can't determine source repository for %s because repository is not fork", ghrepo.FullName(destRepo)) + } + } + + commit, err := latestCommit(client, srcRepo, branchName) if err != nil { - return err + return "", err } // This is not a great way to detect the error returned by the API // Unfortunately API returns 422 for multiple reasons notFastForwardErrorMessage := regexp.MustCompile(`^Update is not a fast forward$`) - err = syncFork(client, destRepo, opts.Branch, commit.Object.SHA, opts.Force) + err = syncFork(client, destRepo, branchName, commit.Object.SHA, opts.Force) var httpErr api.HTTPError if err != nil && errors.As(err, &httpErr) && notFastForwardErrorMessage.MatchString(httpErr.Message) { - return divergingError + return "", divergingError } - return err + return fmt.Sprintf("%s:%s", srcRepo.RepoOwner(), branchName), nil } diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go index a3cc62fe6ab..3af87a44963 100644 --- a/pkg/cmd/repo/sync/sync_test.go +++ b/pkg/cmd/repo/sync/sync_test.go @@ -262,10 +262,26 @@ func Test_SyncRun(t *testing.T) { wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to local repository\n", }, { - name: "sync remote fork with parent - tty", + name: "sync remote fork with parent with new api - tty", tty: true, opts: &SyncOptions{ - DestArg: "OWNER/REPO-FORK", + DestArg: "FORKOWNER/REPO-FORK", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) + reg.Register( + httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(200, `{"base_branch": "OWNER:trunk"}`)) + }, + wantStdout: "✓ Synced the \"FORKOWNER:trunk\" branch from \"OWNER:trunk\"\n", + }, + { + name: "sync remote fork with parent using api fallback - tty", + tty: true, + opts: &SyncOptions{ + DestArg: "FORKOWNER/REPO-FORK", }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -274,34 +290,31 @@ func Test_SyncRun(t *testing.T) { reg.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) + reg.Register( + httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(404, `{}`)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), + httpmock.REST("PATCH", "repos/FORKOWNER/REPO-FORK/git/refs/heads/trunk"), httpmock.StringResponse(`{}`)) }, - wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to OWNER/REPO-FORK\n", + wantStdout: "✓ Synced the \"FORKOWNER:trunk\" branch from \"OWNER:trunk\"\n", }, { name: "sync remote fork with parent - notty", tty: false, opts: &SyncOptions{ - DestArg: "OWNER/REPO-FORK", + DestArg: "FORKOWNER/REPO-FORK", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query RepositoryFindParent\b`), - httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`)) reg.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), - httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) - reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), - httpmock.StringResponse(`{}`)) + httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(200, `{"base_branch": "OWNER:trunk"}`)) }, wantStdout: "", }, @@ -310,11 +323,15 @@ func Test_SyncRun(t *testing.T) { tty: true, opts: &SyncOptions{ DestArg: "OWNER/REPO", + Branch: "trunk", }, httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/merge-upstream"), + httpmock.StatusStringResponse(422, `{"message": "Validation Failed"}`)) reg.Register( httpmock.GraphQL(`query RepositoryFindParent\b`), - httpmock.StringResponse(`{"data":{"repository":{}}}`)) + httpmock.StringResponse(`{"data":{"repository":{"parent":null}}}`)) }, wantErr: true, errMsg: "can't determine source repository for OWNER/REPO because repository is not fork", @@ -325,19 +342,14 @@ func Test_SyncRun(t *testing.T) { opts: &SyncOptions{ DestArg: "OWNER/REPO", SrcArg: "OWNER2/REPO2", + Branch: "trunk", }, httpStubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) - reg.Register( - httpmock.REST("GET", "repos/OWNER2/REPO2/git/refs/heads/trunk"), - httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) - reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO/git/refs/heads/trunk"), - httpmock.StringResponse(`{}`)) + httpmock.REST("POST", "repos/OWNER/REPO/merge-upstream"), + httpmock.StatusStringResponse(200, `{"base_branch": "OWNER2:trunk"}`)) }, - wantStdout: "✓ Synced the \"trunk\" branch from OWNER2/REPO2 to OWNER/REPO\n", + wantStdout: "✓ Synced the \"OWNER:trunk\" branch from \"OWNER2:trunk\"\n", }, { name: "sync remote fork with parent and specified branch", @@ -348,16 +360,10 @@ func Test_SyncRun(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.GraphQL(`query RepositoryFindParent\b`), - httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`)) - reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/test"), - httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) - reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/test"), - httpmock.StringResponse(`{}`)) + httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(200, `{"base_branch": "OWNER:test"}`)) }, - wantStdout: "✓ Synced the \"test\" branch from OWNER/REPO to OWNER/REPO-FORK\n", + wantStdout: "✓ Synced the \"OWNER:test\" branch from \"OWNER:test\"\n", }, { name: "sync remote fork with parent and force specified", @@ -373,6 +379,9 @@ func Test_SyncRun(t *testing.T) { reg.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) @@ -380,7 +389,7 @@ func Test_SyncRun(t *testing.T) { httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), httpmock.StringResponse(`{}`)) }, - wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to OWNER/REPO-FORK\n", + wantStdout: "✓ Synced the \"OWNER:trunk\" branch from \"OWNER:trunk\"\n", }, { name: "sync remote fork with parent and not fast forward merge", @@ -395,6 +404,9 @@ func Test_SyncRun(t *testing.T) { reg.Register( httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) @@ -454,11 +466,11 @@ func Test_SyncRun(t *testing.T) { defer reg.Verify(t) err := syncRun(tt.opts) if tt.wantErr { - assert.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) + assert.EqualError(t, err, tt.errMsg) return + } else if err != nil { + t.Fatalf("syncRun() unexpected error: %v", err) } - assert.NoError(t, err) assert.Equal(t, tt.wantStdout, stdout.String()) }) }