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:
Maximillian von Briesen 2023-05-12 00:59:21 -04:00 committed by GitHub
parent ab7521477a
commit 9678a1c961
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 390 additions and 164 deletions

View File

@ -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
```

View File

@ -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
View File

@ -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

View File

@ -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.

View File

@ -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")
}

View File

@ -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()))

View File

@ -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
View 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])
}
}
}

View File

@ -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
}

View File

@ -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(),