From 9678a1c961079852349ab31101d704c1d57f93d5 Mon Sep 17 00:00:00 2001 From: Maximillian von Briesen Date: Fri, 12 May 2023 00:59:21 -0400 Subject: [PATCH] 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 --- README.md | 11 +- cmd/root.go | 45 ++++----- go.mod | 3 + llm/common.go | 9 +- pullpal/common.go | 252 ++++++++++++++++++++++++++-------------------- pullpal/debug.go | 38 +++---- vc/common.go | 48 ++++++++- vc/common_test.go | 99 ++++++++++++++++++ vc/git.go | 41 ++++++++ vc/github.go | 8 +- 10 files changed, 390 insertions(+), 164 deletions(-) create mode 100644 vc/common_test.go diff --git a/README.md b/README.md index 50081ea..337d33a 100644 --- a/README.md +++ b/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 ``` diff --git a/cmd/root.go b/cmd/root.go index 04d76e6..a7432ef 100644 --- a/cmd/root.go +++ b/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() { diff --git a/go.mod b/go.mod index b1e92b5..49e65a0 100644 --- a/go.mod +++ b/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 diff --git a/llm/common.go b/llm/common.go index 00a1c33..039d0df 100644 --- a/llm/common.go +++ b/llm/common.go @@ -19,10 +19,11 @@ const ( // CodeChangeRequest contains all necessary information for generating a prompt for a LLM. type CodeChangeRequest struct { - Files []File - Subject string - Body string - IssueID string + Files []File + Subject string + Body string + IssueNumber int + BaseBranch string } // CodeChangeResponse contains data derived from an LLM response to a prompt generated via a CodeChangeRequest. diff --git a/pullpal/common.go b/pullpal/common.go index 5d28431..256f528 100644 --- a/pullpal/common.go +++ b/pullpal/common.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "strconv" + "path/filepath" "strings" "time" @@ -17,40 +17,96 @@ 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 // - 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 + ctx context.Context + log *zap.Logger + cfg Config - ghClient *vc.GithubClient - localGitClient *vc.LocalGitClient - openAIClient *llm.OpenAIClient + 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) - if err != nil { - return nil, err +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.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, + }) } - localGitClient, err := vc.NewLocalGitClient(log, self, repo) - if err != nil { - return nil, err + if len(ppRepos) == 0 { + return nil, errors.New("no repos set up") } return &PullPal{ - ctx: ctx, - log: log, - listIssueOptions: listIssueOptions, + ctx: ctx, + log: log, - ghClient: ghClient, - localGitClient: localGitClient, - openAIClient: llm.NewOpenAIClient(log.Named("openaiClient"), model, openAIToken), + repos: ppRepos, + openAIClient: openAIClient, + cfg: cfg, }, nil } @@ -59,120 +115,102 @@ func (p *PullPal) Run() error { p.log.Info("Starting Pull Pal") // TODO gracefully handle context cancelation for { - p.log.Info("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.Info("no issues found") - } else { - p.log.Info("picked issue to process") - - issue := issues[0] - err = p.handleIssue(issue) + for _, r := range p.repos { + err := r.checkIssuesAndComments() if err != nil { - p.log.Error("error handling issue", zap.Error(err)) - } - } - - p.log.Info("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.Info("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)) + p.log.Error("issue checking repo for issues and comments", zap.Error(err)) } } // TODO remove sleep - p.log.Info("sleeping 30s") - time.Sleep(30 * time.Second) + p.log.Info("sleeping", zap.Duration("wait duration", p.cfg.WaitDuration)) + time.Sleep(p.cfg.WaitDuration) } } -func (p *PullPal) handleIssue(issue vc.Issue) error { - issueNumber, err := strconv.Atoi(issue.ID) +// 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 { - p.log.Error("error converting issue ID to int", zap.Error(err)) + p.log.Error("error listing issues", zap.Error(err)) return err } - err = p.ghClient.CommentOnIssue(issueNumber, "on it") + if len(issues) == 0 { + p.log.Info("no issues found") + } else { + p.log.Info("picked issue to process") + + 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 + } + } + } + + p.log.Info("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.Info("no comments found") + } 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)) + 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 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") 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) - if err != nil { - return err - } - files = append(files, nextFile) - } - - changeRequest := llm.CodeChangeRequest{ - Subject: issue.Subject, - Body: issue.Body, - IssueID: issue.ID, - Files: files, + changeRequest, err := p.localGitClient.ParseIssueAndStartCommit(issue) + if err != nil { + return err } 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") } diff --git a/pullpal/debug.go b/pullpal/debug.go index 75600dd..0c3d106 100644 --- a/pullpal/debug.go +++ b/pullpal/debug.go @@ -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 @@ -82,10 +84,10 @@ func (p *PullPal) DebugLLM() error { } codeChangeRequest := llm.CodeChangeRequest{ - 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", + 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", + IssueNumber: 1234, } p.log.Info("CODE CHANGE REQUEST", zap.String("request", codeChangeRequest.String())) diff --git a/vc/common.go b/vc/common.go index a6f345f..3defc64 100644 --- a/vc/common.go +++ b/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 +} diff --git a/vc/common_test.go b/vc/common_test.go new file mode 100644 index 0000000..b031fed --- /dev/null +++ b/vc/common_test.go @@ -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]) + } + } +} diff --git a/vc/git.go b/vc/git.go index 9dbde0d..cb76730 100644 --- a/vc/git.go +++ b/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 +} diff --git a/vc/github.go b/vc/github.go index dca025c..e475f5b 100644 --- a/vc/github.go +++ b/vc/github.go @@ -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(),