pull-pal/pullpal/common.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

302 lines
8.1 KiB
Go

package pullpal
import (
"context"
"errors"
"fmt"
"math/rand"
"path/filepath"
"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")
type Config struct {
WaitDuration time.Duration
LocalRepoPath string
Repos []string
Self vc.Author
ListIssueOptions vc.ListIssueOptions
Model string
OpenAIToken string
DebugDir string
}
// 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
cfg Config
repos []pullPalRepo
openAIClient *llm.OpenAIClient
}
type pullPalRepo 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, cfg Config) (*PullPal, error) {
openAIClient := llm.NewOpenAIClient(log.Named("openaiClient"), cfg.Model, cfg.OpenAIToken, cfg.DebugDir)
ppRepos := []pullPalRepo{}
for _, r := range cfg.Repos {
parts := strings.Split(r, "/")
if len(parts) < 3 {
continue
}
host := parts[0]
owner := parts[1]
name := parts[2]
newRepo := vc.Repository{
LocalPath: filepath.Join(cfg.LocalRepoPath, owner, name),
HostDomain: host,
Name: name,
Owner: vc.Author{
Handle: owner,
},
}
ghClient, err := vc.NewGithubClient(ctx, log.Named("ghclient-"+r), cfg.Self, newRepo)
if err != nil {
return nil, err
}
localGitClient, err := vc.NewLocalGitClient(log.Named("gitclient-"+r), cfg.Self, newRepo, cfg.DebugDir)
if err != nil {
return nil, err
}
ppRepos = append(ppRepos, pullPalRepo{
ctx: ctx,
log: log,
ghClient: ghClient,
localGitClient: localGitClient,
openAIClient: openAIClient,
listIssueOptions: cfg.ListIssueOptions,
})
}
if len(ppRepos) == 0 {
return nil, errors.New("no repos set up")
}
return &PullPal{
ctx: ctx,
log: log,
repos: ppRepos,
openAIClient: openAIClient,
cfg: cfg,
}, 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 {
for _, r := range p.repos {
err := r.checkIssuesAndComments()
if err != nil {
p.log.Error("issue checking repo for issues and comments", zap.Error(err))
}
}
// TODO remove sleep
p.log.Info("sleeping", zap.Duration("wait duration", p.cfg.WaitDuration))
time.Sleep(p.cfg.WaitDuration)
}
}
// checkIssuesAndComments will attempt to find and solve one issue and one comment, and then return.
func (p pullPalRepo) checkIssuesAndComments() error {
p.log.Debug("checking github issues...")
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 {
p.log.Debug("no issues found")
} else {
p.log.Info("picked issue to process")
issue := issues[0]
err = p.handleIssue(issue)
if err != nil {
p.log.Error("error handling issue", zap.Error(err))
commentText := fmt.Sprintf("I ran into a problem working on this:\n```\n%s\n```", err.Error())
err = p.ghClient.CommentOnIssue(issue.Number, commentText)
if err != nil {
p.log.Error("error commenting on issue with error", zap.Error(err))
return err
}
}
}
p.log.Debug("checking pr comments...")
comments, err := p.ghClient.ListOpenComments(vc.ListCommentOptions{
Handles: p.listIssueOptions.Handles,
})
if err != nil {
p.log.Error("error listing comments", zap.Error(err))
return err
}
if len(comments) == 0 {
p.log.Debug("no comments found")
} else {
p.log.Info("picked comment to process")
comment := comments[0]
err = p.handleComment(comment)
if err != nil {
p.log.Error("error handling comment", zap.Error(err))
commentText := fmt.Sprintf("I ran into a problem working on this:\n```\n%s\n```", err.Error())
err = p.ghClient.RespondToComment(comment.PRNumber, comment.ID, commentText)
if err != nil {
p.log.Error("error commenting on thread with error", zap.Error(err))
return err
}
}
}
return nil
}
func (p *pullPalRepo) handleIssue(issue vc.Issue) error {
// remove labels from issue so that it is not picked up again until labels are reapplied
for _, label := range p.listIssueOptions.Labels {
err = p.ghClient.RemoveLabelFromIssue(issue.Number, label)
if err != nil {
p.log.Error("error removing labels from issue", zap.Error(err))
return err
}
}
changeRequest, err := p.localGitClient.ParseIssueAndStartCommit(issue)
if err != nil {
p.log.Error("error parsing issue and starting commit", zap.Error(err))
return err
}
changeResponse, err := p.openAIClient.EvaluateCCR(p.ctx, "", changeRequest)
if err != nil {
return err
}
randomNumber := rand.Intn(100) + 1
newBranchName := fmt.Sprintf("fix-%d-%d", issue.Number, randomNumber)
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)
if err != nil {
return err
}
}
commitMessage := fmt.Sprintf("%s\n\n%s\n\nResolves #%d", changeRequest.Subject, changeResponse.Notes, changeRequest.IssueNumber)
p.log.Info("about to create commit", zap.String("message", commitMessage))
err = p.localGitClient.FinishCommit(commitMessage)
if err != nil {
return err
}
p.log.Info("pushing to branch", zap.String("branchname", newBranchName))
err = p.localGitClient.PushBranch(newBranchName)
if err != nil {
p.log.Info("error pushing to branch", zap.Error(err))
return err
}
// open code change request
_, url, err := p.ghClient.OpenCodeChangeRequest(changeRequest, changeResponse, newBranchName)
if err != nil {
return err
}
p.log.Info("successfully created PR", zap.String("URL", url))
return nil
}
func (p *pullPalRepo) handleComment(comment vc.Comment) error {
if comment.Branch == "" {
return errors.New("no branch provided in comment")
}
file, err := p.localGitClient.GetLocalFile(comment.FilePath)
if err != nil {
return err
}
diffCommentRequest := llm.DiffCommentRequest{
File: file,
Contents: comment.Body,
Diff: comment.DiffHunk,
PRNumber: comment.PRNumber,
}
p.log.Info("diff comment request", zap.String("req", diffCommentRequest.String()))
diffCommentResponse, err := p.openAIClient.EvaluateDiffComment(p.ctx, "", diffCommentRequest)
if err != nil {
return err
}
if diffCommentResponse.Type == llm.ResponseCodeChange {
p.log.Info("about to start commit")
err = p.localGitClient.StartCommit()
if err != nil {
return err
}
p.log.Info("checking out branch", zap.String("name", comment.Branch))
err = p.localGitClient.CheckoutRemoteBranch(comment.Branch)
if err != nil {
return err
}
p.log.Info("replacing or adding file", zap.String("path", diffCommentResponse.File.Path), zap.String("contents", diffCommentResponse.File.Contents))
err = p.localGitClient.ReplaceOrAddLocalFile(diffCommentResponse.File)
if err != nil {
return err
}
commitMessage := "update based on comment"
p.log.Info("about to create commit", zap.String("message", commitMessage))
err = p.localGitClient.FinishCommit(commitMessage)
if err != nil {
return err
}
err = p.localGitClient.PushBranch(comment.Branch)
if err != nil {
return err
}
}
err = p.ghClient.RespondToComment(comment.PRNumber, comment.ID, diffCommentResponse.Response)
if err != nil {
p.log.Error("error commenting on issue", zap.Error(err))
return err
}
p.log.Info("responded addressed comment")
return nil
}