mirror of
https://github.com/Pull-Pal/pull-pal.git
synced 2024-11-12 15:27:42 -05:00
cbec4c1be9
focus on fully automated as basic/default functionality. remove unnecessary commands except for local git debug. remove unnecessary interfaces and code. remove local git functionality from github client. probably some other stuff too
374 lines
10 KiB
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
|
|
}
|
|
*/
|