pull-pal/vc/git.go
Maximillian von Briesen d92efcb7e9
Prompt improvements (#9)
improve prompt templates to get response in yaml format and make parsing easier
also add debug file functionality so that exact input, prompts, and output can be easily seen for every request
2023-09-04 16:44:59 -04:00

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))
}