2023-04-22 14:39:54 -04:00
|
|
|
package pullpal
|
|
|
|
|
|
|
|
import (
|
2023-04-22 16:23:56 -04:00
|
|
|
"context"
|
2023-04-22 17:50:57 -04:00
|
|
|
"errors"
|
2023-04-25 20:32:08 -04:00
|
|
|
"fmt"
|
2023-05-15 19:23:36 -04:00
|
|
|
"math/rand"
|
2023-05-12 00:59:21 -04:00
|
|
|
"path/filepath"
|
2023-04-22 17:50:57 -04:00
|
|
|
"strings"
|
2023-05-02 20:07:48 -04:00
|
|
|
"time"
|
2023-04-22 16:23:56 -04:00
|
|
|
|
2023-04-22 17:50:57 -04:00
|
|
|
"github.com/mobyvb/pull-pal/llm"
|
2023-04-22 16:23:56 -04:00
|
|
|
"github.com/mobyvb/pull-pal/vc"
|
|
|
|
|
|
|
|
"go.uber.org/zap"
|
2023-04-22 14:39:54 -04:00
|
|
|
)
|
|
|
|
|
2023-05-02 20:07:48 -04:00
|
|
|
// IssueNotFound is returned when no issue can be found to generate a prompt for.
|
|
|
|
var IssueNotFound = errors.New("no issue found")
|
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
type Config struct {
|
|
|
|
WaitDuration time.Duration
|
|
|
|
LocalRepoPath string
|
|
|
|
Repos []string
|
|
|
|
Self vc.Author
|
|
|
|
ListIssueOptions vc.ListIssueOptions
|
|
|
|
Model string
|
|
|
|
OpenAIToken string
|
|
|
|
}
|
|
|
|
|
2023-04-22 14:39:54 -04:00
|
|
|
// PullPal is the service responsible for:
|
2023-05-04 20:01:46 -04:00
|
|
|
// - 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)
|
2023-04-22 14:39:54 -04:00
|
|
|
type PullPal struct {
|
2023-05-12 00:59:21 -04:00
|
|
|
ctx context.Context
|
|
|
|
log *zap.Logger
|
|
|
|
cfg Config
|
|
|
|
|
|
|
|
repos []pullPalRepo
|
|
|
|
openAIClient *llm.OpenAIClient
|
|
|
|
}
|
|
|
|
|
|
|
|
type pullPalRepo struct {
|
|
|
|
ctx context.Context
|
|
|
|
log *zap.Logger
|
2023-04-22 14:39:54 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
listIssueOptions vc.ListIssueOptions
|
|
|
|
ghClient *vc.GithubClient
|
|
|
|
localGitClient *vc.LocalGitClient
|
|
|
|
openAIClient *llm.OpenAIClient
|
2023-04-22 14:39:54 -04:00
|
|
|
}
|
|
|
|
|
2023-04-22 17:50:57 -04:00
|
|
|
// NewPullPal creates a new "pull pal service", including setting up local version control and LLM integrations.
|
2023-05-12 00:59:21 -04:00
|
|
|
func NewPullPal(ctx context.Context, log *zap.Logger, cfg Config) (*PullPal, error) {
|
|
|
|
openAIClient := llm.NewOpenAIClient(log.Named("openaiClient"), cfg.Model, cfg.OpenAIToken)
|
|
|
|
|
|
|
|
ppRepos := []pullPalRepo{}
|
|
|
|
for _, r := range cfg.Repos {
|
|
|
|
fmt.Println(r)
|
|
|
|
parts := strings.Split(r, "/")
|
|
|
|
if len(parts) < 3 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
host := parts[0]
|
|
|
|
owner := parts[1]
|
|
|
|
name := parts[2]
|
|
|
|
fmt.Println(host)
|
|
|
|
fmt.Println(owner)
|
|
|
|
fmt.Println(name)
|
|
|
|
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)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
ppRepos = append(ppRepos, pullPalRepo{
|
|
|
|
ctx: ctx,
|
|
|
|
log: log,
|
|
|
|
|
|
|
|
ghClient: ghClient,
|
|
|
|
localGitClient: localGitClient,
|
|
|
|
openAIClient: openAIClient,
|
|
|
|
|
|
|
|
listIssueOptions: cfg.ListIssueOptions,
|
|
|
|
})
|
2023-04-22 14:39:54 -04:00
|
|
|
}
|
2023-05-12 00:59:21 -04:00
|
|
|
if len(ppRepos) == 0 {
|
|
|
|
return nil, errors.New("no repos set up")
|
2023-04-25 20:32:08 -04:00
|
|
|
}
|
2023-04-22 14:39:54 -04:00
|
|
|
|
2023-04-22 16:23:56 -04:00
|
|
|
return &PullPal{
|
2023-05-12 00:59:21 -04:00
|
|
|
ctx: ctx,
|
|
|
|
log: log,
|
2023-04-22 14:39:54 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
repos: ppRepos,
|
|
|
|
openAIClient: openAIClient,
|
|
|
|
cfg: cfg,
|
2023-04-22 16:23:56 -04:00
|
|
|
}, nil
|
2023-04-22 14:39:54 -04:00
|
|
|
}
|
2023-04-22 17:50:57 -04:00
|
|
|
|
2023-05-02 20:07:48 -04:00
|
|
|
// 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 {
|
2023-05-12 00:59:21 -04:00
|
|
|
for _, r := range p.repos {
|
|
|
|
err := r.checkIssuesAndComments()
|
2023-05-04 20:01:46 -04:00
|
|
|
if err != nil {
|
2023-05-12 00:59:21 -04:00
|
|
|
p.log.Error("issue checking repo for issues and comments", zap.Error(err))
|
2023-05-04 20:01:46 -04:00
|
|
|
}
|
|
|
|
}
|
2023-05-02 20:07:48 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
// TODO remove sleep
|
|
|
|
p.log.Info("sleeping", zap.Duration("wait duration", p.cfg.WaitDuration))
|
|
|
|
time.Sleep(p.cfg.WaitDuration)
|
|
|
|
}
|
|
|
|
}
|
2023-05-02 20:07:48 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
// checkIssuesAndComments will attempt to find and solve one issue and one comment, and then return.
|
|
|
|
func (p pullPalRepo) checkIssuesAndComments() error {
|
2023-05-15 19:23:36 -04:00
|
|
|
p.log.Debug("checking github issues...")
|
2023-05-12 00:59:21 -04:00
|
|
|
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 {
|
2023-05-15 19:23:36 -04:00
|
|
|
p.log.Debug("no issues found")
|
2023-05-12 00:59:21 -04:00
|
|
|
} else {
|
|
|
|
p.log.Info("picked issue to process")
|
2023-05-02 20:07:48 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
issue := issues[0]
|
|
|
|
err = p.handleIssue(issue)
|
|
|
|
if err != nil {
|
|
|
|
// TODO leave comment if error (make configurable)
|
|
|
|
p.log.Error("error handling issue", zap.Error(err))
|
2023-05-12 01:22:10 -04:00
|
|
|
commentText := fmt.Sprintf("I ran into a problem working on this:\n```\n%s\n```", err.Error())
|
2023-05-12 00:59:21 -04:00
|
|
|
err = p.ghClient.CommentOnIssue(issue.Number, commentText)
|
2023-05-02 20:07:48 -04:00
|
|
|
if err != nil {
|
2023-05-12 00:59:21 -04:00
|
|
|
p.log.Error("error commenting on issue with error", zap.Error(err))
|
|
|
|
return err
|
2023-05-02 20:07:48 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-04-22 17:58:31 -04:00
|
|
|
|
2023-05-15 19:23:36 -04:00
|
|
|
p.log.Debug("checking pr comments...")
|
2023-05-12 00:59:21 -04:00
|
|
|
comments, err := p.ghClient.ListOpenComments(vc.ListCommentOptions{
|
|
|
|
Handles: p.listIssueOptions.Handles,
|
|
|
|
})
|
2023-04-22 21:56:52 -04:00
|
|
|
if err != nil {
|
2023-05-12 00:59:21 -04:00
|
|
|
p.log.Error("error listing comments", zap.Error(err))
|
2023-05-08 20:14:19 -04:00
|
|
|
return err
|
2023-04-22 21:56:52 -04:00
|
|
|
}
|
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
if len(comments) == 0 {
|
2023-05-15 19:23:36 -04:00
|
|
|
p.log.Debug("no comments found")
|
2023-05-12 00:59:21 -04:00
|
|
|
} else {
|
|
|
|
p.log.Info("picked comment to process")
|
|
|
|
|
|
|
|
comment := comments[0]
|
|
|
|
err = p.handleComment(comment)
|
|
|
|
if err != nil {
|
|
|
|
// TODO leave comment if error (make configurable)
|
|
|
|
p.log.Error("error handling comment", zap.Error(err))
|
2023-05-12 01:22:10 -04:00
|
|
|
commentText := fmt.Sprintf("I ran into a problem working on this:\n```\n%s\n```", err.Error())
|
2023-05-12 00:59:21 -04:00
|
|
|
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 {
|
|
|
|
err := p.ghClient.CommentOnIssue(issue.Number, "working on it")
|
2023-04-22 17:50:57 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Error("error commenting on issue", zap.Error(err))
|
|
|
|
return err
|
2023-04-22 17:50:57 -04:00
|
|
|
}
|
2023-05-08 20:14:19 -04:00
|
|
|
for _, label := range p.listIssueOptions.Labels {
|
2023-05-12 00:59:21 -04:00
|
|
|
err = p.ghClient.RemoveLabelFromIssue(issue.Number, label)
|
2023-05-08 20:14:19 -04:00
|
|
|
if err != nil {
|
|
|
|
p.log.Error("error removing labels from issue", zap.Error(err))
|
|
|
|
return err
|
|
|
|
}
|
2023-04-22 17:50:57 -04:00
|
|
|
}
|
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
changeRequest, err := p.localGitClient.ParseIssueAndStartCommit(issue)
|
2023-04-22 17:50:57 -04:00
|
|
|
if err != nil {
|
2023-05-12 01:22:10 -04:00
|
|
|
p.log.Error("error parsing issue and starting commit", zap.Error(err))
|
2023-05-08 20:14:19 -04:00
|
|
|
return err
|
|
|
|
}
|
2023-04-22 17:50:57 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
changeResponse, err := p.openAIClient.EvaluateCCR(p.ctx, "", changeRequest)
|
2023-04-22 17:50:57 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
return err
|
2023-04-22 17:50:57 -04:00
|
|
|
}
|
2023-05-08 20:14:19 -04:00
|
|
|
|
2023-05-15 19:23:36 -04:00
|
|
|
randomNumber := rand.Intn(100) + 1
|
|
|
|
newBranchName := fmt.Sprintf("fix-%d-%d", issue.Number, randomNumber)
|
2023-05-08 20:14:19 -04:00
|
|
|
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)
|
2023-04-22 17:50:57 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
return err
|
2023-04-22 17:50:57 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
commitMessage := fmt.Sprintf("%s\n\n%s\n\nResolves #%d", changeRequest.Subject, changeResponse.Notes, changeRequest.IssueNumber)
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Info("about to create commit", zap.String("message", commitMessage))
|
|
|
|
err = p.localGitClient.FinishCommit(commitMessage)
|
2023-04-22 17:50:57 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
return err
|
2023-04-22 17:50:57 -04:00
|
|
|
}
|
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Info("pushing to branch", zap.String("branchname", newBranchName))
|
|
|
|
err = p.localGitClient.PushBranch(newBranchName)
|
2023-04-24 19:49:10 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Info("error pushing to branch", zap.Error(err))
|
|
|
|
return err
|
2023-04-24 19:49:10 -04:00
|
|
|
}
|
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
// open code change request
|
|
|
|
// TODO don't hardcode main branch, make configurable
|
2023-05-12 00:59:21 -04:00
|
|
|
_, url, err := p.ghClient.OpenCodeChangeRequest(changeRequest, changeResponse, newBranchName)
|
2023-04-24 20:28:27 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
return err
|
2023-04-24 20:28:27 -04:00
|
|
|
}
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Info("successfully created PR", zap.String("URL", url))
|
2023-04-24 20:28:27 -04:00
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
return nil
|
2023-04-24 20:28:27 -04:00
|
|
|
}
|
2023-04-25 20:32:08 -04:00
|
|
|
|
2023-05-12 00:59:21 -04:00
|
|
|
func (p *pullPalRepo) handleComment(comment vc.Comment) error {
|
2023-05-08 20:14:19 -04:00
|
|
|
if comment.Branch == "" {
|
|
|
|
return errors.New("no branch provided in comment")
|
|
|
|
}
|
2023-04-25 20:32:08 -04:00
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
file, err := p.localGitClient.GetLocalFile(comment.FilePath)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
2023-04-25 20:32:08 -04:00
|
|
|
}
|
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
diffCommentRequest := llm.DiffCommentRequest{
|
|
|
|
File: file,
|
|
|
|
Contents: comment.Body,
|
|
|
|
Diff: comment.DiffHunk,
|
|
|
|
}
|
|
|
|
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)
|
2023-04-25 20:32:08 -04:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
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
|
|
|
|
}
|
2023-04-25 20:32:08 -04:00
|
|
|
}
|
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
err = p.ghClient.RespondToComment(comment.PRNumber, comment.ID, diffCommentResponse.Answer)
|
2023-04-25 20:32:08 -04:00
|
|
|
if err != nil {
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Error("error commenting on issue", zap.Error(err))
|
2023-04-25 20:32:08 -04:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-05-08 20:14:19 -04:00
|
|
|
p.log.Info("responded addressed comment")
|
2023-04-25 20:32:08 -04:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|