pull-pal/vc/git.go

306 lines
7.7 KiB
Go

package vc
import (
"errors"
"fmt"
"go/format"
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/mobyvb/pull-pal/llm"
"go.uber.org/zap"
"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"
)
// LocalGitClient represents a service that interacts with a local git repository.
type LocalGitClient struct {
log *zap.Logger
self Author
repo Repository
worktree *git.Worktree
debugDir string
}
// NewLocalGitClient initializes a local git client by checking out a repository locally.
func NewLocalGitClient(log *zap.Logger, self Author, repo Repository, debugDir string) (*LocalGitClient, error) {
log.Info("checking out local github repo", zap.String("repo name", repo.Name), zap.String("local path", repo.LocalPath))
// clone provided repository to local path
if repo.LocalPath == "" {
return nil, errors.New("local path to clone repository not provided")
}
// remove local repo if it exists already
err := os.RemoveAll(repo.LocalPath)
if err != nil {
return nil, err
}
localRepo, err := git.PlainClone(repo.LocalPath, false, &git.CloneOptions{
URL: repo.SSH(),
// URL: repo.HTTPS(),
Auth: &http.BasicAuth{
Username: self.Handle,
Password: self.Token,
},
})
if err != nil {
return nil, err
}
repo.localRepo = localRepo
return &LocalGitClient{
log: log,
self: self,
repo: repo,
debugDir: debugDir,
}, nil
}
func (gc *LocalGitClient) CheckoutRemoteBranch(branchName string) (err error) {
if gc.worktree == nil {
return errors.New("worktree is nil - cannot check out a branch")
}
// TODO configurable remote
branchRefName := plumbing.NewRemoteReferenceName("origin", branchName)
branchCoOpts := git.CheckoutOptions{
Branch: plumbing.ReferenceName(branchRefName),
Force: true,
}
err = gc.worktree.Checkout(&branchCoOpts)
if err != nil {
return err
}
// Pull the latest changes from the remote branch
/*
err = gc.worktree.Pull(&git.PullOptions{
RemoteName: "origin",
Auth: &http.BasicAuth{
Username: gc.self.Handle,
Password: gc.self.Token,
},
Force: true,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
return err
}
*/
return nil
}
func (gc *LocalGitClient) PushBranch(branchName string) (err error) {
//branchRefName := plumbing.NewBranchReferenceName(branchName)
remoteName := "origin"
headRef, err := gc.repo.localRepo.Head()
if err != nil {
return err
}
// Create new branch at current HEAD
branchRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), headRef.Hash())
err = gc.repo.localRepo.Storer.SetReference(branchRef)
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{
RemoteName: remoteName,
// TODO remove hardcoded "main"
RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branchName, branchName))},
Auth: &http.BasicAuth{
Username: gc.self.Handle,
Password: gc.self.Token,
},
})
if err != nil {
return err
}
return nil
}
func (gc *LocalGitClient) GetLocalFile(path string) (llm.File, error) {
fullPath := filepath.Join(gc.repo.LocalPath, path)
data, err := ioutil.ReadFile(fullPath)
if err != nil {
// if file doesn't exist, just return an empty file
// this means we want to prompt the llm to populate it for the first time
if errors.Is(err, os.ErrNotExist) {
return llm.File{
Path: path,
Contents: "",
}, nil
}
return llm.File{}, err
}
return llm.File{
Path: path,
Contents: string(data),
}, nil
}
func (gc *LocalGitClient) 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 *LocalGitClient) 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 {
// TODO also make logger accessible
fmt.Println("go format error")
// TODO handle this error
// return err
} else {
newFile.Contents = string(newContents)
}
}
fullPath := filepath.Join(gc.repo.LocalPath, newFile.Path)
dirPath := filepath.Dir(fullPath)
err := os.MkdirAll(dirPath, 0755)
if err != nil {
return err
}
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 *LocalGitClient) 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,
When: time.Now(),
},
})
if err != nil {
return err
}
// set worktree to nil so a new commit can be started
gc.worktree = nil
return nil
}
// ParseIssueAndStartCommit parses the information provided in the issue to check out the appropriate branch,
// get the contents of the files mentioned in the issue, and initialize the worktree.
func (gc *LocalGitClient) ParseIssueAndStartCommit(issue Issue) (llm.CodeChangeRequest, error) {
var changeRequest llm.CodeChangeRequest
if gc.worktree != nil {
return changeRequest, errors.New("worktree is active - some other work is incomplete")
}
issueBody := ParseIssueBody(issue.Body)
gc.log.Info("issue body info", zap.Any("files", issueBody.FilePaths))
// start a worktree
err := gc.StartCommit()
if err != nil {
gc.log.Error("error starting commit", zap.Error(err))
return changeRequest, err
}
err = gc.CheckoutRemoteBranch(issueBody.BaseBranch)
if err != nil {
gc.log.Error("error checking out remote branch", zap.Error(err))
return changeRequest, err
}
// get file contents from local git repository
files := []llm.File{}
for _, path := range issueBody.FilePaths {
nextFile, err := gc.GetLocalFile(path)
if err != nil {
gc.log.Error("error getting local file", zap.Error(err))
return changeRequest, err
}
files = append(files, nextFile)
}
req := llm.CodeChangeRequest{
Subject: issue.Subject,
Body: issueBody.PromptBody,
IssueNumber: issue.Number,
Files: files,
BaseBranch: issueBody.BaseBranch,
}
debugFileNamePrefix := fmt.Sprintf("issue-%d-%d", issue.Number, time.Now().Unix())
gc.writeDebug("issues", debugFileNamePrefix+"-originalbody.txt", issue.Body)
gc.writeDebug("issues", debugFileNamePrefix+"-parsed-req.txt", req.String())
return req, nil
}
func (gc *LocalGitClient) writeDebug(subdir, filename, contents string) {
if gc.debugDir == "" {
return
}
fullFolderPath := path.Join(gc.debugDir, subdir)
err := os.MkdirAll(fullFolderPath, os.ModePerm)
if err != nil {
gc.log.Error("failed to ensure debug directory existed", zap.String("folderpath", fullFolderPath), zap.Error(err))
return
}
fullPath := path.Join(fullFolderPath, filename)
err = ioutil.WriteFile(fullPath, []byte(contents), 0644)
if err != nil {
gc.log.Error("failed to write response to debug file", zap.String("filepath", fullPath), zap.Error(err))
return
}
gc.log.Info("response written to debug file", zap.String("filepath", fullPath))
}