pull-pal/pullpal/common.go

374 lines
10 KiB
Go

package pullpal
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/mobyvb/pull-pal/llm"
"github.com/mobyvb/pull-pal/vc"
"go.uber.org/zap"
)
// IssueNotFound is returned when no issue can be found to generate a prompt for.
var IssueNotFound = errors.New("no issue found")
// PullPal is the service responsible for:
// - Interacting with git server (e.g. reading issues and making PRs on Github)
// - Generating LLM prompts
// - Parsing LLM responses
// - Interacting with LLM (e.g. with GPT via OpenAI API)
type PullPal struct {
ctx context.Context
log *zap.Logger
listIssueOptions vc.ListIssueOptions
ghClient *vc.GithubClient
localGitClient *vc.LocalGitClient
openAIClient *llm.OpenAIClient
}
// NewPullPal creates a new "pull pal service", including setting up local version control and LLM integrations.
func NewPullPal(ctx context.Context, log *zap.Logger, listIssueOptions vc.ListIssueOptions, self vc.Author, repo vc.Repository, openAIToken string) (*PullPal, error) {
ghClient, err := vc.NewGithubClient(ctx, log, self, repo)
if err != nil {
return nil, err
}
localGitClient, err := vc.NewLocalGitClient(log, self, repo)
if err != nil {
return nil, err
}
return &PullPal{
ctx: ctx,
log: log,
listIssueOptions: listIssueOptions,
ghClient: ghClient,
localGitClient: localGitClient,
openAIClient: llm.NewOpenAIClient(log.Named("openaiClient"), openAIToken),
}, nil
}
// Run starts pull pal as a fully automated service that periodically requests changes and creates pull requests based on them.
func (p *PullPal) Run() error {
p.log.Info("Starting Pull Pal")
// TODO gracefully handle context cancelation
for {
issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions)
if err != nil {
p.log.Error("error listing issues", zap.Error(err))
return err
}
if len(issues) == 0 {
// todo don't sleep
p.log.Info("no issues found. sleeping for 30 seconds")
time.Sleep(30 * time.Second)
continue
}
issue := issues[0]
issueNumber, err := strconv.Atoi(issue.ID)
if err != nil {
p.log.Error("error converting issue ID to int", zap.Error(err))
return err
}
err = p.ghClient.CommentOnIssue(issueNumber, "on it")
if err != nil {
p.log.Error("error commenting on issue", zap.Error(err))
return err
}
for _, label := range p.listIssueOptions.Labels {
err = p.ghClient.RemoveLabelFromIssue(issueNumber, label)
if err != nil {
p.log.Error("error removing labels from issue", zap.Error(err))
return err
}
}
// remove file list from issue body
// TODO do this better and probably somewhere else
parts := strings.Split(issue.Body, "Files:")
issue.Body = parts[0]
fileList := []string{}
if len(parts) > 1 {
fileList = strings.Split(parts[1], ",")
}
// get file contents from local git repository
files := []llm.File{}
for _, path := range fileList {
path = strings.TrimSpace(path)
nextFile, err := p.localGitClient.GetLocalFile(path)
if err != nil {
p.log.Error("error getting file from vcclient", zap.Error(err))
continue
}
files = append(files, nextFile)
}
changeRequest := llm.CodeChangeRequest{
Subject: issue.Subject,
Body: issue.Body,
IssueID: issue.ID,
Files: files,
}
changeResponse, err := p.openAIClient.EvaluateCCR(p.ctx, changeRequest)
if err != nil {
p.log.Error("error getting response from openai", zap.Error(err))
continue
}
// parse llm response
//codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse)
// create commit with file changes
err = p.localGitClient.StartCommit()
//err = p.ghClient.StartCommit()
if err != nil {
p.log.Error("error starting commit", zap.Error(err))
return err
}
newBranchName := fmt.Sprintf("fix-%s", changeRequest.IssueID)
/*
err = p.localGitClient.SwitchBranch(newBranchName)
if err != nil {
p.log.Error("error switching branch", zap.Error(err))
return err
}
*/
for _, f := range changeResponse.Files {
p.log.Info("replacing or adding file", zap.String("path", f.Path), zap.String("contents", f.Contents))
err = p.localGitClient.ReplaceOrAddLocalFile(f)
// err = p.ghClient.ReplaceOrAddLocalFile(f)
if err != nil {
p.log.Error("error replacing or adding file", zap.Error(err))
return err
}
}
commitMessage := changeRequest.Subject + "\n\n" + changeResponse.Notes + "\n\nResolves: #" + changeRequest.IssueID
p.log.Info("about to create commit", zap.String("message", commitMessage))
err = p.localGitClient.FinishCommit(commitMessage)
if err != nil {
p.log.Error("error finishing commit", zap.Error(err))
// TODO figure out why sometimes finish commit returns "already up-to-date error"
// return err
}
err = p.localGitClient.PushBranch(newBranchName)
if err != nil {
p.log.Error("error pushing branch", zap.Error(err))
return err
}
// open code change request
// TODO don't hardcode main branch, make configurable
_, url, err := p.ghClient.OpenCodeChangeRequest(changeRequest, changeResponse, newBranchName, "main")
if err != nil {
p.log.Error("error opening PR", zap.Error(err))
return err
}
p.log.Info("successfully created PR", zap.String("URL", url))
p.log.Info("going to sleep for thirty seconds")
time.Sleep(30 * time.Second)
}
return nil
}
/*
// PickIssueToFile is the same as PickIssue, but the changeRequest is converted to a string and written to a file.
func (p *PullPal) PickIssueToFile(promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
issue, changeRequest, err = p.PickIssue()
if err != nil {
return issue, changeRequest, err
}
prompt, err := changeRequest.GetPrompt()
if err != nil {
return issue, changeRequest, err
}
err = ioutil.WriteFile(promptPath, []byte(prompt), 0644)
return issue, changeRequest, err
}
// PickIssueToClipboard is the same as PickIssue, but the changeRequest is converted to a string and copied to the clipboard.
func (p *PullPal) PickIssueToClipboard() (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
issue, changeRequest, err = p.PickIssue()
if err != nil {
return issue, changeRequest, err
}
prompt, err := changeRequest.GetPrompt()
if err != nil {
return issue, changeRequest, err
}
err = clipboard.WriteAll(prompt)
return issue, changeRequest, err
}
*/
/*
// PickIssue selects an issue from the version control server and returns the selected issue, as well as the LLM prompt needed to fulfill the request.
func (p *PullPal) PickIssue() (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
// TODO I should be able to pass in settings for listing issues from here
issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions)
if err != nil {
return issue, changeRequest, err
}
if len(issues) == 0 {
return issue, changeRequest, IssueNotFound
}
issue = issues[0]
// remove file list from issue body
// TODO do this better
parts := strings.Split(issue.Body, "Files:")
issue.Body = parts[0]
fileList := []string{}
if len(parts) > 1 {
fileList = strings.Split(parts[1], ",")
}
// get file contents from local git repository
files := []llm.File{}
for _, path := range fileList {
path = strings.TrimSpace(path)
nextFile, err := p.ghClient.GetLocalFile(path)
if err != nil {
return issue, changeRequest, err
}
files = append(files, nextFile)
}
changeRequest.Subject = issue.Subject
changeRequest.Body = issue.Body
changeRequest.IssueID = issue.ID
changeRequest.Files = files
return issue, changeRequest, nil
}
*/
/*
// ProcessResponseFromFile is the same as ProcessResponse, but the response is inputted into a file rather than passed directly as an argument.
func (p *PullPal) ProcessResponseFromFile(codeChangeRequest llm.CodeChangeRequest, llmResponsePath string) (url string, err error) {
data, err := ioutil.ReadFile(llmResponsePath)
if err != nil {
return "", err
}
return p.ProcessResponse(codeChangeRequest, string(data))
}
// ProcessResponse parses the llm response, updates files in the local git repo accordingly, and opens a new code change request (e.g. Github PR).
func (p *PullPal) ProcessResponse(codeChangeRequest llm.CodeChangeRequest, llmResponse string) (url string, err error) {
// 1. parse llm response
codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse)
// 2. create commit with file changes
err = p.ghClient.StartCommit()
if err != nil {
return "", err
}
for _, f := range codeChangeResponse.Files {
err = p.ghClient.ReplaceOrAddLocalFile(f)
if err != nil {
return "", err
}
}
commitMessage := codeChangeRequest.Subject + "\n\n" + codeChangeResponse.Notes + "\n\nResolves: #" + codeChangeRequest.IssueID
err = p.ghClient.FinishCommit(commitMessage)
if err != nil {
return "", err
}
// 3. open code change request
_, url, err = p.ghClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse)
return url, err
}
*/
/*
// ListIssues gets a list of all issues meeting the provided criteria.
func (p *PullPal) ListIssues(handles, labels []string) ([]vc.Issue, error) {
issues, err := p.ghClient.ListOpenIssues(vc.ListIssueOptions{
Handles: handles,
Labels: labels,
})
if err != nil {
return nil, err
}
return issues, nil
}
// ListComments gets a list of all comments meeting the provided criteria on a PR.
func (p *PullPal) ListComments(changeID string, handles []string) ([]vc.Comment, error) {
comments, err := p.ghClient.ListOpenComments(vc.ListCommentOptions{
ChangeID: changeID,
Handles: handles,
})
if err != nil {
return nil, err
}
return comments, nil
}
func (p *PullPal) MakeLocalChange(issue vc.Issue) error {
// remove file list from issue body
// TODO do this better
parts := strings.Split(issue.Body, "Files:")
issue.Body = parts[0]
fileList := []string{}
if len(parts) > 1 {
fileList = strings.Split(parts[1], ",")
}
// get file contents from local git repository
files := []llm.File{}
for _, path := range fileList {
path = strings.TrimSpace(path)
nextFile, err := p.ghClient.GetLocalFile(path)
if err != nil {
return err
}
files = append(files, nextFile)
}
changeRequest := llm.CodeChangeRequest{
Subject: issue.Subject,
Body: issue.Body,
IssueID: issue.ID,
Files: files,
}
res, err := p.openAIClient.EvaluateCCR(p.ctx, changeRequest)
if err != nil {
return err
}
fmt.Println("response from openai")
fmt.Println(res)
return nil
}
*/