diff --git a/README.md b/README.md index c4ed0336..13b5b3e4 100755 --- a/README.md +++ b/README.md @@ -258,6 +258,9 @@ token: topic: - example +# Attempt to commit and push through the Github API, rather than pushing through the git client. +use-gh-api: false + # The name of a user. All repositories owned by that user will be used. user: - example diff --git a/cmd/cmd-run.go b/cmd/cmd-run.go index 4bbaecc9..d6e5ded9 100755 --- a/cmd/cmd-run.go +++ b/cmd/cmd-run.go @@ -61,6 +61,7 @@ Available values: _ = cmd.RegisterFlagCompletionFunc("conflict-strategy", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"skip", "replace"}, cobra.ShellCompDirectiveNoFileComp }) + cmd.Flags().BoolP("use-gh-api", "", false, "Attempt to commit and push through the Github API, rather than pushing through the git client.") cmd.Flags().StringSliceP("labels", "", nil, "Labels to be added to any created pull request.") cmd.Flags().StringP("author-name", "", "", "Name of the committer. If not set, the global git config setting will be used.") cmd.Flags().StringP("author-email", "", "", "Email of the committer. If not set, the global git config setting will be used.") @@ -92,6 +93,7 @@ func run(cmd *cobra.Command, _ []string) error { concurrent, _ := flag.GetInt("concurrent") skipPullRequest, _ := flag.GetBool("skip-pr") pushOnly, _ := flag.GetBool("push-only") + useGHAPI, _ := flag.GetBool("use-gh-api") skipRepository, _ := flag.GetStringSlice("skip-repo") interactive, _ := flag.GetBool("interactive") dryRun, _ := flag.GetBool("dry-run") @@ -241,6 +243,7 @@ func run(cmd *cobra.Command, _ []string) error { ForkOwner: forkOwner, SkipPullRequest: skipPullRequest, PushOnly: pushOnly, + UseGHAPI: useGHAPI, SkipRepository: skipRepository, CommitAuthor: commitAuthor, BaseBranch: baseBranchName, diff --git a/internal/git/cmdgit/git.go b/internal/git/cmdgit/git.go index 2a13234b..bdac15d8 100644 --- a/internal/git/cmdgit/git.go +++ b/internal/git/cmdgit/git.go @@ -149,3 +149,15 @@ func (g *Git) AddRemote(name, url string) error { _, err := g.run(cmd) return err } + +func (g *Git) Additions() map[string]string { + return make(map[string]string) +} + +func (g *Git) Deletions() []string { + return make([]string, 0) +} + +func (g *Git) OldHash() string { + return "" +} diff --git a/internal/git/gogit/git.go b/internal/git/gogit/git.go index 39aecbf4..6f530528 100755 --- a/internal/git/gogit/git.go +++ b/internal/git/gogit/git.go @@ -3,6 +3,8 @@ package gogit import ( "bytes" "context" + "encoding/base64" + "os" "time" "github.com/go-git/go-git/v5/config" @@ -22,6 +24,10 @@ type Git struct { FetchDepth int // Limit fetching to the specified number of commits repo *git.Repository // The repository after the clone has been made + + additions map[string]string // Files being added (used for GHAPI) + deletions []string // Files being remove (used for GHAPI) + oldHash plumbing.Hash } // Clone a repository @@ -75,6 +81,37 @@ func (g *Git) Changes() (bool, error) { return !status.IsClean(), nil } +func (g *Git) GetFileChangesAsBase64(w git.Worktree) error { + treeStatus, err := w.Status() + + if err != nil { + return err + } + + g.additions = make(map[string]string) + + for path, status := range treeStatus { + s := status.Worktree + + if s == git.Deleted || s == git.Renamed || s == git.Copied { + g.deletions = append(g.deletions, path) + } else if s == git.Added || s == git.Modified || s == git.Untracked { + data, err := os.ReadFile(g.Directory + "/" + path) + + if err != nil { + return err + } + + output := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(output, data) + + g.additions[path] = string(output) + } + } + + return nil +} + // Commit and push all changes func (g *Git) Commit(commitAuthor *internalgit.CommitAuthor, commitMessage string) error { w, err := g.repo.Worktree() @@ -89,6 +126,11 @@ func (g *Git) Commit(commitAuthor *internalgit.CommitAuthor, commitMessage strin } w.Excludes = patterns + err = g.GetFileChangesAsBase64(*w) + if err != nil { + return err + } + err = w.AddWithOptions(&git.AddOptions{ All: true, }) @@ -117,7 +159,7 @@ func (g *Git) Commit(commitAuthor *internalgit.CommitAuthor, commitMessage strin if err != nil { return err } - oldHash := oldHead.Hash() + g.oldHash = oldHead.Hash() var author *object.Signature if commitAuthor != nil { @@ -140,7 +182,7 @@ func (g *Git) Commit(commitAuthor *internalgit.CommitAuthor, commitMessage strin return err } - _ = g.logDiff(oldHash, commit.Hash) + _ = g.logDiff(g.oldHash, commit.Hash) return nil } @@ -210,6 +252,18 @@ func (g *Git) Push(ctx context.Context, remoteName string, force bool) error { }) } +func (g *Git) Additions() map[string]string { + return g.additions +} + +func (g *Git) Deletions() []string { + return g.deletions +} + +func (g *Git) OldHash() string { + return g.oldHash.String() +} + // AddRemote adds a new remote func (g *Git) AddRemote(name, url string) error { _, err := g.repo.CreateRemote(&config.RemoteConfig{ diff --git a/internal/multigitter/run.go b/internal/multigitter/run.go index 4128188d..92957f6c 100755 --- a/internal/multigitter/run.go +++ b/internal/multigitter/run.go @@ -68,6 +68,7 @@ type Runner struct { ForkOwner string // The owner of the new fork. If empty, the fork should happen on the logged in user ConflictStrategy ConflictStrategy // Defines what will happen if a branch already exists + UseGHAPI bool Draft bool // If set, creates Pull Requests as draft @@ -338,7 +339,38 @@ func (r *Runner) runSingleRepo(ctx context.Context, repo scm.Repository) (scm.Pu log.Info("Pushing changes to remote") forcePush := featureBranchExist && r.ConflictStrategy == ConflictStrategyReplace - err = sourceController.Push(ctx, remoteName, forcePush) + if r.UseGHAPI { + if ghapi, ok := r.VersionController.(interface { + CommitAndPushThoughGraphQL(ctx context.Context, + headline string, + featureBranch string, + cloneURL string, + oldHash string, + additions map[string]string, + deletions []string, + forcePush bool, + branchExist bool) error + }); ok { + err = ghapi.CommitAndPushThoughGraphQL(ctx, + r.CommitMessage, + r.FeatureBranch, + repo.CloneURL(), + sourceController.OldHash(), + sourceController.Additions(), + sourceController.Deletions(), + forcePush, + featureBranchExist) + if err != nil { + return nil, err + } + } else { + log.Info("Could not find CommitThroughAPI, falling back on default push") + err = sourceController.Push(ctx, remoteName, forcePush) + } + } else { + err = sourceController.Push(ctx, remoteName, forcePush) + } + if err != nil { return nil, errors.Wrap(err, "could not push changes") } diff --git a/internal/multigitter/shared.go b/internal/multigitter/shared.go index fde49cf3..10d903e6 100644 --- a/internal/multigitter/shared.go +++ b/internal/multigitter/shared.go @@ -34,6 +34,9 @@ type Git interface { BranchExist(remoteName, branchName string) (bool, error) Push(ctx context.Context, remoteName string, force bool) error AddRemote(name, url string) error + Additions() map[string]string + Deletions() []string + OldHash() string } type stackTracer interface { diff --git a/internal/scm/github/graphql_commit.go b/internal/scm/github/graphql_commit.go new file mode 100644 index 00000000..3a0458a5 --- /dev/null +++ b/internal/scm/github/graphql_commit.go @@ -0,0 +1,271 @@ +package github + +import ( + "context" + "fmt" + "strings" +) + +func (g *Github) CommitAndPushThoughGraphQL(ctx context.Context, + headline string, + featureBranch string, + cloneURL string, + oldHash string, + additions map[string]string, + deletions []string, + forcePush bool, + branchExist bool) error { + array := strings.Split(cloneURL, "/") + + repositoryName := strings.Trim(array[len(array)-1], ".git") + owner := array[len(array)-2] + + if forcePush { + fmt.Printf("about to get branchid\n") + + branchID, err := g.getBranchID(ctx, owner, repositoryName, featureBranch) + if err != nil { + return err + } + + fmt.Printf("about to deleteref\n") + + err = g.deleteRef(ctx, branchID) + if err != nil { + return err + } + + branchExist = false + } + + if !branchExist { + fmt.Printf("about to create branch\n") + + err := g.CreateBranch(ctx, owner, + repositoryName, + featureBranch, + oldHash) + if err != nil { + return err + } + } + + fmt.Printf("About to commit though API\n") + + err := g.CommitThroughAPI(ctx, owner, repositoryName, featureBranch, oldHash, headline, additions, deletions) + + if err != nil { + return err + } + + return nil +} + +func (g *Github) getRepositoryID(ctx context.Context, owner string, name string) (string, error) { + query := `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + } + }` + + var result getRepositoryOutput + + err := g.makeGraphQLRequest(ctx, query, &RepositoryInput{Name: name, Owner: owner}, &result) + + if err != nil { + return "", err + } + + return result.Repository.ID, nil +} + +func (g *Github) getBranchID(ctx context.Context, owner string, repoName string, branchName string) (string, error) { + query := `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + refs(first: 100, refPrefix: "refs/heads/") { + edges { + node { + id + name + } + } + } + } + }` + + var result getRefsOutput + + err := g.makeGraphQLRequest(ctx, query, &RepositoryInput{Name: repoName, Owner: owner}, &result) + + if err != nil { + return "", err + } + + for _, edge := range result.Repository.Refs.Edges { + if edge.Node.Name == branchName { + return edge.Node.ID, nil + } + } + + return "", fmt.Errorf("unable to find branch named %s to delete", branchName) +} + +func (g *Github) CreateBranch(ctx context.Context, owner string, repoName string, branchName string, oid string) error { + query := `mutation($input: CreateRefInput!){ + createRef(input: $input) { + ref { + name + } + } + }` + + var cri CreateRefInput + + repoID, err := g.getRepositoryID(ctx, owner, repoName) + + if err != nil { + return err + } + + if !strings.HasPrefix(repoName, "refs/heads/") { + cri.Input.Name = "refs/heads/" + branchName + } else { + cri.Input.Name = branchName + } + + cri.Input.Oid = oid + cri.Input.RepositoryID = repoID + + var result interface{} + + err = g.makeGraphQLRequest(ctx, query, cri, &result) + + if err != nil { + return err + } + + return nil +} + +func (g *Github) CommitThroughAPI(ctx context.Context, owner string, repoName string, branch string, oid string, headline string, additions map[string]string, deletions []string) error { + query := ` + mutation ($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { + url + } + } + }` + + var v createCommitOnBranchInput + + v.Input.Branch.RepositoryNameWithOwner = owner + "/" + repoName + + v.Input.Branch.BranchName = branch + v.Input.ExpectedHeadOid = oid + v.Input.Message.Headline = headline + + for path, contents := range additions { + v.Input.FileChanges.Additions = append(v.Input.FileChanges.Additions, struct { + Path string "json:\"path,omitempty\"" + Contents string "json:\"contents,omitempty\"" + }{Path: path, Contents: contents}) + } + + for _, path := range deletions { + v.Input.FileChanges.Deletions = append(v.Input.FileChanges.Deletions, struct { + Path string "json:\"path,omitempty\"" + }{Path: path}) + } + + var result map[string]interface{} + + err := g.makeGraphQLRequest(ctx, query, v, &result) + + if err != nil { + return err + } + + return nil +} + +func (g *Github) deleteRef(ctx context.Context, branchRef string) error { + query := `mutation($input: DeleteRefInput!){ + deleteRef(input: $input){ + clientMutationId + } + }` + + var deleteRefInput DeleteRefInput + deleteRefInput.Input.RefID = branchRef + + type ignoreReturn map[string]interface{} + + err := g.makeGraphQLRequest(ctx, query, deleteRefInput, &ignoreReturn{}) + + if err != nil { + return err + } + + return nil +} + +type createCommitOnBranchInput struct { + Input struct { + ExpectedHeadOid string `json:"expectedHeadOid"` + Branch struct { + RepositoryNameWithOwner string `json:"repositoryNameWithOwner"` + BranchName string `json:"branchName"` + } `json:"branch"` + Message struct { + Headline string `json:"headline"` + } `json:"message"` + FileChanges struct { + Additions []struct { + Path string `json:"path,omitempty"` + Contents string `json:"contents,omitempty"` + } `json:"additions"` + Deletions []struct { + Path string `json:"path,omitempty"` + } `json:"deletions"` + } `json:"fileChanges"` + } `json:"input"` +} + +type CreateRefInput struct { + Input struct { + Name string `json:"name"` + Oid string `json:"oid"` + RepositoryID string `json:"repositoryId"` + } `json:"input"` +} + +type RepositoryInput struct { + Name string `json:"name"` + Owner string `json:"owner"` +} + +type DeleteRefInput struct { + Input struct { + RefID string `json:"refId"` + } `json:"input"` +} + +type getRepositoryOutput struct { + Repository struct { + ID string `json:"id"` + } `json:"repository"` +} + +type getRefsOutput struct { + Repository struct { + Refs struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"refs"` + } `json:"repository"` +} diff --git a/multi-gitter b/multi-gitter new file mode 100755 index 00000000..335809e2 Binary files /dev/null and b/multi-gitter differ