mirror of
https://github.com/Pull-Pal/pull-pal.git
synced 2025-01-02 15:36:51 -05:00
Multirepo support and lots of other stuff (#3)
* Parsing issue into llm request moved to `vc` package * Converted "issue ID" string to "issue number" int * Added config struct to `pullpal/common.go` * Added multi-repo config support * Added support for custom base branches for PRs * Added configurable wait time * Bot should comment on pull requests and threads when running into an error
This commit is contained in:
parent
ab7521477a
commit
9678a1c961
11
README.md
11
README.md
@ -22,17 +22,16 @@ The minimal configuration you need looks like this:
|
||||
```
|
||||
handle: [username of your bot's Github account]
|
||||
email: [email of your bot's Github account]
|
||||
repo-handle: [username of repository owner's Github account]
|
||||
repo-name: [name of repository on Github]
|
||||
users-to-listen-to: [comma-separated list of Github users who your bot will interact with on Github issues and PRs]
|
||||
required-issue-labels: [comma-separated list of issue labels that an issue must have in order to be considered by the bot (can be empty)]
|
||||
repos: [list of repositories, e.g. "github.com/owner/name" that bot will monitor]
|
||||
users-to-listen-to: [list of Github users who your bot will interact with on Github issues and PRs]
|
||||
required-issue-labels: [list of issue labels that an issue must have in order to be considered by the bot (can be empty)]
|
||||
github-token: ghp_xxx
|
||||
open-ai-token: sk-xxx
|
||||
```
|
||||
|
||||
You can acquire the Github token under "developer settings" in Github, in the "personal access tokens" section. The necessary requirements are:
|
||||
* this token must be created for the same account associated with `handle` and `email` in your config. Important to remember if you are using a separate Github account for your bot
|
||||
* the token must have permissions to interact with the repository at github.com/[repo-handle]/[repo-name]
|
||||
* the token must have permissions to interact with the repositories configured in `repos`
|
||||
* the token must have read and write permission to commit statuses, repository contents, discussions, issues, and pull requests
|
||||
|
||||
You can generate an API key for OpenAI by logging in to platform.openai.com, then going to https://platform.openai.com/account/api-keys
|
||||
@ -72,6 +71,8 @@ Add an index.html file. It should have a content section populated with a headin
|
||||
|
||||
Add a main.go file that serves index.html on port 8080.
|
||||
|
||||
---
|
||||
|
||||
Files: main.go, index.html
|
||||
```
|
||||
|
||||
|
45
cmd/root.go
45
cmd/root.go
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mobyvb/pull-pal/pullpal"
|
||||
"github.com/mobyvb/pull-pal/vc"
|
||||
@ -23,17 +24,15 @@ type config struct {
|
||||
openAIToken string
|
||||
|
||||
// remote repo info
|
||||
repoDomain string
|
||||
repoHandle string
|
||||
repoName string
|
||||
repos []string
|
||||
|
||||
// local paths
|
||||
localRepoPath string
|
||||
|
||||
// program settings
|
||||
promptToClipboard bool
|
||||
usersToListenTo []string
|
||||
requiredIssueLabels []string
|
||||
waitDuration time.Duration
|
||||
}
|
||||
|
||||
func getConfig() config {
|
||||
@ -43,15 +42,13 @@ func getConfig() config {
|
||||
githubToken: viper.GetString("github-token"),
|
||||
openAIToken: viper.GetString("open-ai-token"),
|
||||
|
||||
repoDomain: viper.GetString("repo-domain"),
|
||||
repoHandle: viper.GetString("repo-handle"),
|
||||
repoName: viper.GetString("repo-name"),
|
||||
repos: viper.GetStringSlice("repos"),
|
||||
|
||||
localRepoPath: viper.GetString("local-repo-path"),
|
||||
|
||||
promptToClipboard: viper.GetBool("prompt-to-clipboard"),
|
||||
usersToListenTo: viper.GetStringSlice("users-to-listen-to"),
|
||||
requiredIssueLabels: viper.GetStringSlice("required-issue-labels"),
|
||||
waitDuration: viper.GetDuration("wait-duration"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,20 +65,22 @@ func getPullPal(ctx context.Context, cfg config) (*pullpal.PullPal, error) {
|
||||
Handle: cfg.selfHandle,
|
||||
Token: cfg.githubToken,
|
||||
}
|
||||
repo := vc.Repository{
|
||||
LocalPath: cfg.localRepoPath,
|
||||
HostDomain: cfg.repoDomain,
|
||||
Name: cfg.repoName,
|
||||
Owner: vc.Author{
|
||||
Handle: cfg.repoHandle,
|
||||
},
|
||||
}
|
||||
listIssueOptions := vc.ListIssueOptions{
|
||||
Handles: cfg.usersToListenTo,
|
||||
Labels: cfg.requiredIssueLabels,
|
||||
}
|
||||
// TODO make model configurable
|
||||
p, err := pullpal.NewPullPal(ctx, log.Named("pullpal"), listIssueOptions, author, repo, openai.GPT4, cfg.openAIToken)
|
||||
ppCfg := pullpal.Config{
|
||||
WaitDuration: cfg.waitDuration,
|
||||
LocalRepoPath: cfg.localRepoPath,
|
||||
Repos: cfg.repos,
|
||||
Self: author,
|
||||
ListIssueOptions: listIssueOptions,
|
||||
// TODO configurable model
|
||||
Model: openai.GPT4,
|
||||
OpenAIToken: cfg.openAIToken,
|
||||
}
|
||||
p, err := pullpal.NewPullPal(ctx, log.Named("pullpal"), ppCfg)
|
||||
|
||||
return p, err
|
||||
}
|
||||
@ -129,28 +128,26 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringP("github-token", "t", "GITHUB TOKEN", "token for authenticating Github actions")
|
||||
rootCmd.PersistentFlags().StringP("open-ai-token", "k", "OPENAI TOKEN", "token for authenticating OpenAI")
|
||||
|
||||
rootCmd.PersistentFlags().StringP("repo-domain", "d", "github.com", "domain for version control server")
|
||||
rootCmd.PersistentFlags().StringP("repo-handle", "o", "REPO-HANDLE", "handle of repository's owner on version control server")
|
||||
rootCmd.PersistentFlags().StringP("repo-name", "n", "REPO-NAME", "name of repository on version control server")
|
||||
rootCmd.PersistentFlags().StringSliceP("repos", "r", []string{}, "a list of git repositories that Pull Pal will monitor")
|
||||
|
||||
rootCmd.PersistentFlags().StringP("local-repo-path", "l", "/tmp/pullpallrepo", "local path to check out ephemeral repository in")
|
||||
rootCmd.PersistentFlags().StringP("local-repo-path", "l", "/tmp/pullpalrepo", "local path to check out ephemeral repository in")
|
||||
|
||||
rootCmd.PersistentFlags().StringSliceP("users-to-listen-to", "a", []string{}, "a list of Github users that Pull Pal will respond to")
|
||||
rootCmd.PersistentFlags().StringSliceP("required-issue-labels", "i", []string{}, "a list of labels that are required for Pull Pal to select an issue")
|
||||
rootCmd.PersistentFlags().Duration("wait-time", 30*time.Second, "the amount of time Pull Pal should wait when no issues or comments are found to address")
|
||||
|
||||
viper.BindPFlag("handle", rootCmd.PersistentFlags().Lookup("handle"))
|
||||
viper.BindPFlag("email", rootCmd.PersistentFlags().Lookup("email"))
|
||||
viper.BindPFlag("github-token", rootCmd.PersistentFlags().Lookup("github-token"))
|
||||
viper.BindPFlag("open-ai-token", rootCmd.PersistentFlags().Lookup("open-ai-token"))
|
||||
|
||||
viper.BindPFlag("repo-domain", rootCmd.PersistentFlags().Lookup("repo-domain"))
|
||||
viper.BindPFlag("repo-handle", rootCmd.PersistentFlags().Lookup("repo-handle"))
|
||||
viper.BindPFlag("repo-name", rootCmd.PersistentFlags().Lookup("repo-name"))
|
||||
viper.BindPFlag("repos", rootCmd.PersistentFlags().Lookup("repos"))
|
||||
|
||||
viper.BindPFlag("local-repo-path", rootCmd.PersistentFlags().Lookup("local-repo-path"))
|
||||
|
||||
viper.BindPFlag("users-to-listen-to", rootCmd.PersistentFlags().Lookup("users-to-listen-to"))
|
||||
viper.BindPFlag("required-issue-labels", rootCmd.PersistentFlags().Lookup("required-issue-labels"))
|
||||
viper.BindPFlag("wait-time", rootCmd.PersistentFlags().Lookup("wait-time"))
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
|
3
go.mod
3
go.mod
@ -8,6 +8,7 @@ require (
|
||||
github.com/sashabaranov/go-openai v1.9.0
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/spf13/viper v1.15.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/oauth2 v0.7.0
|
||||
)
|
||||
@ -17,6 +18,7 @@ require (
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
|
||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||
github.com/cloudflare/circl v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.0 // indirect
|
||||
@ -32,6 +34,7 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/skeema/knownhosts v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.9.3 // indirect
|
||||
|
@ -22,7 +22,8 @@ type CodeChangeRequest struct {
|
||||
Files []File
|
||||
Subject string
|
||||
Body string
|
||||
IssueID string
|
||||
IssueNumber int
|
||||
BaseBranch string
|
||||
}
|
||||
|
||||
// CodeChangeResponse contains data derived from an LLM response to a prompt generated via a CodeChangeRequest.
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -17,6 +17,16 @@ import (
|
||||
// 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
|
||||
}
|
||||
|
||||
// PullPal is the service responsible for:
|
||||
// - Interacting with git server (e.g. reading issues and making PRs on Github)
|
||||
// - Generating LLM prompts
|
||||
@ -25,32 +35,78 @@ var IssueNotFound = errors.New("no issue found")
|
||||
type PullPal struct {
|
||||
ctx context.Context
|
||||
log *zap.Logger
|
||||
listIssueOptions vc.ListIssueOptions
|
||||
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, listIssueOptions vc.ListIssueOptions, self vc.Author, repo vc.Repository, model string, openAIToken string) (*PullPal, error) {
|
||||
ghClient, err := vc.NewGithubClient(ctx, log, self, repo)
|
||||
func NewPullPal(ctx context.Context, log *zap.Logger, cfg Config) (*PullPal, error) {
|
||||
openAIClient := llm.NewOpenAIClient(log.Named("openaiClient"), cfg.Model, cfg.OpenAIToken)
|
||||
|
||||
ppRepos := []pullPalRepo{}
|
||||
fmt.Println("asdfasfdasfasdfasdf")
|
||||
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, self, repo)
|
||||
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,
|
||||
})
|
||||
}
|
||||
if len(ppRepos) == 0 {
|
||||
return nil, errors.New("no repos set up")
|
||||
}
|
||||
|
||||
return &PullPal{
|
||||
ctx: ctx,
|
||||
log: log,
|
||||
listIssueOptions: listIssueOptions,
|
||||
|
||||
ghClient: ghClient,
|
||||
localGitClient: localGitClient,
|
||||
openAIClient: llm.NewOpenAIClient(log.Named("openaiClient"), model, openAIToken),
|
||||
repos: ppRepos,
|
||||
openAIClient: openAIClient,
|
||||
cfg: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -59,6 +115,21 @@ 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.Info("checking github issues...")
|
||||
issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions)
|
||||
if err != nil {
|
||||
@ -74,7 +145,14 @@ func (p *PullPal) Run() error {
|
||||
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))
|
||||
commentText := fmt.Sprintf("I ran into a problem working on this:\n```\n%w\n```", err)
|
||||
err = p.ghClient.CommentOnIssue(issue.Number, commentText)
|
||||
if err != nil {
|
||||
p.log.Error("error commenting on issue with error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,84 +173,44 @@ func (p *PullPal) Run() error {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO remove sleep
|
||||
p.log.Info("sleeping 30s")
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PullPal) handleIssue(issue vc.Issue) error {
|
||||
issueNumber, err := strconv.Atoi(issue.ID)
|
||||
commentText := fmt.Sprintf("I ran into a problem working on this:\n```\n%w\n```", err)
|
||||
err = p.ghClient.RespondToComment(comment.PRNumber, comment.ID, commentText)
|
||||
if err != nil {
|
||||
p.log.Error("error converting issue ID to int", zap.Error(err))
|
||||
p.log.Error("error commenting on thread with error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = p.ghClient.CommentOnIssue(issueNumber, "on it")
|
||||
func (p *pullPalRepo) handleIssue(issue vc.Issue) error {
|
||||
err := p.ghClient.CommentOnIssue(issue.Number, "working 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)
|
||||
err = p.ghClient.RemoveLabelFromIssue(issue.Number, 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)
|
||||
changeRequest, err := p.localGitClient.ParseIssueAndStartCommit(issue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
// create commit with file changes
|
||||
err = p.localGitClient.StartCommit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// todo remove hardcoded main
|
||||
p.log.Info("checking out main branch")
|
||||
err = p.localGitClient.CheckoutRemoteBranch("main")
|
||||
if err != nil {
|
||||
p.log.Info("error checking out main branch", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
newBranchName := fmt.Sprintf("fix-%s", changeRequest.IssueID)
|
||||
newBranchName := fmt.Sprintf("fix-%d", issue.Number)
|
||||
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)
|
||||
@ -181,7 +219,7 @@ func (p *PullPal) handleIssue(issue vc.Issue) error {
|
||||
}
|
||||
}
|
||||
|
||||
commitMessage := changeRequest.Subject + "\n\n" + changeResponse.Notes + "\n\nResolves: #" + changeRequest.IssueID
|
||||
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 {
|
||||
@ -197,7 +235,7 @@ func (p *PullPal) handleIssue(issue vc.Issue) error {
|
||||
|
||||
// open code change request
|
||||
// TODO don't hardcode main branch, make configurable
|
||||
_, url, err := p.ghClient.OpenCodeChangeRequest(changeRequest, changeResponse, newBranchName, "main")
|
||||
_, url, err := p.ghClient.OpenCodeChangeRequest(changeRequest, changeResponse, newBranchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -206,7 +244,7 @@ func (p *PullPal) handleIssue(issue vc.Issue) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PullPal) handleComment(comment vc.Comment) error {
|
||||
func (p *pullPalRepo) handleComment(comment vc.Comment) error {
|
||||
if comment.Branch == "" {
|
||||
return errors.New("no branch provided in comment")
|
||||
}
|
||||
|
@ -9,37 +9,38 @@ import (
|
||||
|
||||
func (p *PullPal) DebugGit() error {
|
||||
p.log.Info("Starting Pull Pal git debug")
|
||||
r := p.repos[0]
|
||||
|
||||
// create commit with file changes
|
||||
err := p.localGitClient.StartCommit()
|
||||
err := r.localGitClient.StartCommit()
|
||||
//err = p.ghClient.StartCommit()
|
||||
if err != nil {
|
||||
p.log.Error("error starting commit", zap.Error(err))
|
||||
r.log.Error("error starting commit", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
newBranchName := "debug-branch"
|
||||
|
||||
for _, f := range []string{"a", "b"} {
|
||||
err = p.localGitClient.ReplaceOrAddLocalFile(llm.File{
|
||||
err = r.localGitClient.ReplaceOrAddLocalFile(llm.File{
|
||||
Path: f,
|
||||
Contents: "hello",
|
||||
})
|
||||
if err != nil {
|
||||
p.log.Error("error replacing or adding file", zap.Error(err))
|
||||
r.log.Error("error replacing or adding file", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
commitMessage := "debug commit message"
|
||||
err = p.localGitClient.FinishCommit(commitMessage)
|
||||
err = r.localGitClient.FinishCommit(commitMessage)
|
||||
if err != nil {
|
||||
p.log.Error("error finishing commit", zap.Error(err))
|
||||
r.log.Error("error finishing commit", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = p.localGitClient.PushBranch(newBranchName)
|
||||
err = r.localGitClient.PushBranch(newBranchName)
|
||||
if err != nil {
|
||||
p.log.Error("error pushing branch", zap.Error(err))
|
||||
r.log.Error("error pushing branch", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
@ -49,25 +50,26 @@ func (p *PullPal) DebugGit() error {
|
||||
// todo dont require args for listing comments
|
||||
func (p *PullPal) DebugGithub(handles []string) error {
|
||||
p.log.Info("Starting Pull Pal Github debug")
|
||||
r := p.repos[0]
|
||||
|
||||
issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions)
|
||||
issues, err := r.ghClient.ListOpenIssues(r.listIssueOptions)
|
||||
if err != nil {
|
||||
p.log.Error("error listing issues", zap.Error(err))
|
||||
r.log.Error("error listing issues", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
for _, i := range issues {
|
||||
p.log.Info("got issue", zap.String("issue", i.String()))
|
||||
r.log.Info("got issue", zap.String("issue", i.String()))
|
||||
}
|
||||
|
||||
comments, err := p.ghClient.ListOpenComments(vc.ListCommentOptions{
|
||||
comments, err := r.ghClient.ListOpenComments(vc.ListCommentOptions{
|
||||
Handles: handles,
|
||||
})
|
||||
if err != nil {
|
||||
p.log.Error("error listing comments", zap.Error(err))
|
||||
r.log.Error("error listing comments", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
for _, c := range comments {
|
||||
p.log.Info("got comment", zap.String("comment", c.String()))
|
||||
r.log.Info("got comment", zap.String("comment", c.String()))
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -85,7 +87,7 @@ func (p *PullPal) DebugLLM() error {
|
||||
Files: []llm.File{file},
|
||||
Subject: "update port and add endpoint",
|
||||
Body: "use port 8080 for the server in main.go. Also add an endpoint at GET /api/numbers that returns a random integer between 2 and 10",
|
||||
IssueID: "1234",
|
||||
IssueNumber: 1234,
|
||||
}
|
||||
|
||||
p.log.Info("CODE CHANGE REQUEST", zap.String("request", codeChangeRequest.String()))
|
||||
|
48
vc/common.go
48
vc/common.go
@ -2,13 +2,14 @@ package vc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
// Issue represents an issue on a version control server.
|
||||
type Issue struct {
|
||||
ID string
|
||||
Number int
|
||||
Subject string
|
||||
Body string
|
||||
URL string
|
||||
@ -16,7 +17,7 @@ type Issue struct {
|
||||
}
|
||||
|
||||
func (i Issue) String() string {
|
||||
return fmt.Sprintf("Issue ID: %s\nAuthor: %s\nSubject: %s\nBody:\n%s\nURL: %s\n", i.ID, i.Author.Handle, i.Subject, i.Body, i.URL)
|
||||
return fmt.Sprintf("Issue #: %d\nAuthor: %s\nSubject: %s\nBody:\n%s\nURL: %s\n", i.Number, i.Author.Handle, i.Subject, i.Body, i.URL)
|
||||
}
|
||||
|
||||
// ListIssueOptions defines options for listing issues.
|
||||
@ -81,3 +82,46 @@ func (repo Repository) SSH() string {
|
||||
func (repo Repository) HTTPS() string {
|
||||
return fmt.Sprintf("https://%s/%s/%s.git", repo.HostDomain, repo.Owner.Handle, repo.Name)
|
||||
}
|
||||
|
||||
type IssueBody struct {
|
||||
PromptBody string
|
||||
FilePaths []string
|
||||
BaseBranch string
|
||||
}
|
||||
|
||||
func ParseIssueBody(body string) IssueBody {
|
||||
issueBody := IssueBody{
|
||||
BaseBranch: "main",
|
||||
}
|
||||
divider := "---"
|
||||
|
||||
parts := strings.Split(body, divider)
|
||||
issueBody.PromptBody = strings.TrimSpace(parts[0])
|
||||
// if there was nothing to split, no additional configuration was provided
|
||||
if len(parts) <= 1 {
|
||||
return issueBody
|
||||
}
|
||||
|
||||
configStr := parts[1]
|
||||
configLines := strings.Split(configStr, "\n")
|
||||
for _, line := range configLines {
|
||||
lineParts := strings.Split(line, ":")
|
||||
if len(lineParts) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(lineParts[0]))
|
||||
if key == "base" {
|
||||
issueBody.BaseBranch = strings.TrimSpace(lineParts[1])
|
||||
continue
|
||||
}
|
||||
if key == "files" {
|
||||
filePaths := strings.Split(lineParts[1], ",")
|
||||
for _, p := range filePaths {
|
||||
issueBody.FilePaths = append(issueBody.FilePaths, strings.TrimSpace(p))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return issueBody
|
||||
}
|
||||
|
99
vc/common_test.go
Normal file
99
vc/common_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package vc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mobyvb/pull-pal/vc"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseIssueBody(t *testing.T) {
|
||||
var testCases = []struct {
|
||||
testcase string
|
||||
body string
|
||||
parsed vc.IssueBody
|
||||
}{
|
||||
{
|
||||
"simple issue",
|
||||
`
|
||||
add an html file
|
||||
`,
|
||||
vc.IssueBody{
|
||||
PromptBody: "add an html file",
|
||||
BaseBranch: "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
"issue with explicit file list",
|
||||
`
|
||||
add an html file
|
||||
and also a go file
|
||||
read a readme file too
|
||||
|
||||
---
|
||||
|
||||
FiLeS: index.html, README.md ,main.go
|
||||
`,
|
||||
vc.IssueBody{
|
||||
PromptBody: "add an html file\nand also a go file\nread a readme file too",
|
||||
BaseBranch: "main",
|
||||
FilePaths: []string{"index.html", "README.md", "main.go"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"issue with a custom base branch",
|
||||
`
|
||||
add an html file
|
||||
---
|
||||
base: some-base-branch
|
||||
`,
|
||||
vc.IssueBody{
|
||||
PromptBody: "add an html file",
|
||||
BaseBranch: "some-base-branch",
|
||||
},
|
||||
},
|
||||
{
|
||||
"issue with an explicit base branch and file list",
|
||||
`
|
||||
add an html file
|
||||
---
|
||||
base: some-base-branch
|
||||
files: index.html, main.go
|
||||
`,
|
||||
vc.IssueBody{
|
||||
PromptBody: "add an html file",
|
||||
BaseBranch: "some-base-branch",
|
||||
FilePaths: []string{"index.html", "main.go"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"issue with garbage in config section",
|
||||
`
|
||||
add an html file
|
||||
---
|
||||
asdf:
|
||||
files: index.html, main.go
|
||||
: asdfsadf
|
||||
base: some-base-branch
|
||||
asdfjljldsfj
|
||||
nonexistentoption: asdf
|
||||
`,
|
||||
vc.IssueBody{
|
||||
PromptBody: "add an html file",
|
||||
BaseBranch: "some-base-branch",
|
||||
FilePaths: []string{"index.html", "main.go"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Log("testing case:", tt.testcase)
|
||||
parsed := vc.ParseIssueBody(tt.body)
|
||||
require.Equal(t, tt.parsed.PromptBody, parsed.PromptBody)
|
||||
require.Equal(t, tt.parsed.BaseBranch, parsed.BaseBranch)
|
||||
require.Equal(t, len(tt.parsed.FilePaths), len(parsed.FilePaths))
|
||||
for i, p := range tt.parsed.FilePaths {
|
||||
require.Equal(t, p, parsed.FilePaths[i])
|
||||
}
|
||||
}
|
||||
}
|
41
vc/git.go
41
vc/git.go
@ -219,3 +219,44 @@ func (gc *LocalGitClient) FinishCommit(message string) error {
|
||||
|
||||
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)
|
||||
|
||||
// start a worktree
|
||||
err := gc.StartCommit()
|
||||
if err != nil {
|
||||
return changeRequest, err
|
||||
}
|
||||
|
||||
err = gc.CheckoutRemoteBranch(issueBody.BaseBranch)
|
||||
if err != nil {
|
||||
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 {
|
||||
return changeRequest, err
|
||||
}
|
||||
files = append(files, nextFile)
|
||||
}
|
||||
|
||||
return llm.CodeChangeRequest{
|
||||
Subject: issue.Subject,
|
||||
Body: issueBody.PromptBody,
|
||||
IssueNumber: issue.Number,
|
||||
Files: files,
|
||||
BaseBranch: issueBody.BaseBranch,
|
||||
}, nil
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ func NewGithubClient(ctx context.Context, log *zap.Logger, self Author, repo Rep
|
||||
}
|
||||
|
||||
// OpenCodeChangeRequest pushes to a new remote branch and opens a PR on Github.
|
||||
func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse, fromBranch, toBranch string) (id, url string, err error) {
|
||||
func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse, fromBranch string) (id, url string, err error) {
|
||||
// TODO handle gc.ctx canceled
|
||||
|
||||
title := req.Subject
|
||||
@ -57,13 +57,13 @@ func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm
|
||||
}
|
||||
|
||||
body := res.Notes
|
||||
body += fmt.Sprintf("\n\nResolves #%s", req.IssueID)
|
||||
body += fmt.Sprintf("\n\nResolves #%d", req.IssueNumber)
|
||||
|
||||
// Finally, open a pull request from the new branch.
|
||||
pr, _, err := gc.client.PullRequests.Create(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, &github.NewPullRequest{
|
||||
Title: &title,
|
||||
Head: &fromBranch,
|
||||
Base: &toBranch,
|
||||
Base: &req.BaseBranch,
|
||||
Body: &body,
|
||||
})
|
||||
if err != nil {
|
||||
@ -102,7 +102,7 @@ func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error
|
||||
}
|
||||
|
||||
nextIssue := Issue{
|
||||
ID: strconv.Itoa(issue.GetNumber()),
|
||||
Number: issue.GetNumber(),
|
||||
Subject: issue.GetTitle(),
|
||||
Body: issue.GetBody(),
|
||||
URL: issue.GetHTMLURL(),
|
||||
|
Loading…
Reference in New Issue
Block a user