diff --git a/vc/common.go b/vc/common.go index b96e71f..3f1d280 100644 --- a/vc/common.go +++ b/vc/common.go @@ -52,4 +52,14 @@ type VCClient interface { // UpdateCodeChangeRequest updates an existing code change request on the version control server. // UpdateCodeChangeRequest(id string, res llm.CodeChangeResponse) // TODO: add/read comments to/from issues and code change requests + // GetLocalFile gets the current representation of the file at the provided path from the local git repo. + GetLocalFile(path string) (llm.File, error) + // StartCommit initiates a commit process, after which files can be modified and added to the commit. + StartCommit() error + // ReplaceOrAddLocalFile updates or adds a file in the locally cloned repo, and applies these changes to the current git worktree. + ReplaceOrAddLocalFile(newFile llm.File) error + // FinishCommit completes a commit, after which a code change request can be opened or updated. + FinishCommit(message string) error + // Close tears down the VCClient. + Close() error } diff --git a/vc/github.go b/vc/github.go index 48524e7..3fb5662 100644 --- a/vc/github.go +++ b/vc/github.go @@ -6,12 +6,21 @@ import ( "encoding/hex" "errors" "fmt" + "go/format" + "io/ioutil" + "os" + "path/filepath" "strconv" + "strings" + + "github.com/mobyvb/pull-pal/llm" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/google/go-github/github" - "github.com/mobyvb/pull-pal/llm" "go.uber.org/zap" "golang.org/x/oauth2" ) @@ -24,6 +33,8 @@ type GithubClient struct { client *github.Client self Author repo Repository + + worktree *git.Worktree } // NewGithubClient initializes a Github client and checks out a repository locally. @@ -69,14 +80,16 @@ func NewGithubClient(ctx context.Context, log *zap.Logger, self Author, repo Rep }, nil } -// OpenCodeChangeRequest opens a new PR on Github based on the provided LLM request and response. +// OpenCodeChangeRequest pushes to a new remote branch and opens a PR on Github. func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse) (id, url string, err error) { // TODO handle gc.ctx canceled gc.log.Debug("Creating a new pull request...") title := req.Subject branchName := randomBranchName() + branchRefName := plumbing.NewBranchReferenceName(branchName) baseBranch := "main" + remoteName := "origin" body := res.Notes body += fmt.Sprintf("\n\nResolves #%s", req.IssueID) issue, err := strconv.Atoi(req.IssueID) @@ -85,6 +98,44 @@ func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm return "", "", err } + // Create new local branch + headRef, err := gc.repo.localRepo.Head() + if err != nil { + return "", "", err + } + err = gc.repo.localRepo.CreateBranch(&config.Branch{ + Name: branchName, + Remote: remoteName, + Merge: branchRefName, + }) + if err != nil { + return "", "", err + } + + // Update the branch to point to the new commit + err = gc.repo.localRepo.Storer.SetReference(plumbing.NewHashReference(branchRefName, headRef.Hash())) + if err != nil { + return "", "", err + } + + // Push the new branch to the remote repository + remote, err := gc.repo.localRepo.Remote(remoteName) + if err != nil { + return "", "", err + } + + err = remote.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branchRefName, branchName))}, + Auth: &http.BasicAuth{ + Username: gc.self.Handle, + Password: gc.self.Token, + }, + }) + if err != nil { + return "", "", err + } + + // Finally, open a pull request from the new branch. pr, _, err := gc.client.PullRequests.Create(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, &github.NewPullRequest{ Title: &title, Head: &branchName, @@ -136,3 +187,87 @@ func (gc *GithubClient) ListOpenIssues() ([]Issue, error) { return toReturn, nil } + +// GetLocalFile gets the current representation of the file at the provided path from the local git repo. +func (gc *GithubClient) GetLocalFile(path string) (llm.File, error) { + fullPath := filepath.Join(gc.repo.LocalPath, path) + + data, err := ioutil.ReadFile(fullPath) + if err != nil { + return llm.File{}, err + } + + return llm.File{ + Path: path, + Contents: string(data), + }, nil +} + +// StartCommit creates a new worktree associated with this Github client. +func (gc *GithubClient) StartCommit() error { + if gc.worktree != nil { + return errors.New("worktree is not nil - cannot start a new commit") + } + + worktree, err := gc.repo.localRepo.Worktree() + if err != nil { + return err + } + + gc.worktree = worktree + + return nil +} + +// ReplaceOrAddLocalFile updates or adds a file in the locally cloned repo, and applies these changes to the current git worktree. +func (gc *GithubClient) ReplaceOrAddLocalFile(newFile llm.File) error { + if gc.worktree == nil { + return errors.New("worktree is nil - StartCommit must be called") + } + + // TODO format non-go files as well + if strings.HasSuffix(newFile.Path, ".go") { + newContents, err := format.Source([]byte(newFile.Contents)) + if err != nil { + return err + } + newFile.Contents = string(newContents) + } + + fullPath := filepath.Join(gc.repo.LocalPath, newFile.Path) + + err := ioutil.WriteFile(fullPath, []byte(newFile.Contents), 0644) + if err != nil { + return err + } + + _, err = gc.worktree.Add(newFile.Path) + + return err +} + +// FinishCommit completes a commit, after which a code change request can be opened or updated. +func (gc *GithubClient) FinishCommit(message string) error { + if gc.worktree == nil { + return errors.New("worktree is nil - StartCommit must be called") + } + _, err := gc.worktree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: gc.self.Handle, + Email: gc.self.Email, + }, + }) + + return err +} + +func (gc *GithubClient) Close() error { + // Remove local repository + if gc.repo.LocalPath != "" { + err := os.RemoveAll(gc.repo.LocalPath) + + return err + } + + return nil +}