Add plain "list issues" command and reorganize

* Add configurable users to list issues from
* Add configurable labels to require on issues
* Add a CLI subcommand that lists issues according to the criteria and
  does nothing else
* Update existing CLI REPL to list issues according to the new criteria
* Clean up and reorganize config code a little
* Add some basic types and function signature for listing comments on
  code change requests
This commit is contained in:
Moby von Briesen 2023-04-24 19:49:10 -04:00
parent b29f544c23
commit 9cc0f4a1b8
5 changed files with 207 additions and 39 deletions

53
cmd/list-issues.go Normal file
View File

@ -0,0 +1,53 @@
package cmd
import (
"fmt"
"github.com/mobyvb/pull-pal/pullpal"
"github.com/mobyvb/pull-pal/vc"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
var listIssuesCmd = &cobra.Command{
Use: "list-issues",
Short: "Lists github issues meeting the configured criteria",
Long: "Lists github issues meeting the configured criteria",
Run: func(cmd *cobra.Command, args []string) {
cfg := getConfig()
fmt.Println("list issues called")
log := zap.L()
author := vc.Author{
Email: cfg.selfEmail,
Handle: cfg.selfHandle,
Token: cfg.githubToken,
}
repo := vc.Repository{
LocalPath: cfg.localRepoPath,
HostDomain: cfg.repoDomain,
Name: cfg.repoName,
Owner: vc.Author{
Handle: cfg.repoHandle,
},
}
p, err := pullpal.NewPullPal(cmd.Context(), log.Named("pullpal"), author, repo)
if err != nil {
fmt.Println("error creating new pull pal", err)
return
}
fmt.Println("Successfully initialized pull pal")
issueList, err := p.ListIssues(cfg.usersToListenTo, cfg.requiredIssueLabels)
if err != nil {
fmt.Println("error listing issues", err)
return
}
fmt.Println(issueList)
},
}
func init() {
rootCmd.AddCommand(listIssuesCmd)
}

View File

@ -8,11 +8,55 @@ import (
"github.com/mobyvb/pull-pal/llm" "github.com/mobyvb/pull-pal/llm"
"github.com/mobyvb/pull-pal/pullpal" "github.com/mobyvb/pull-pal/pullpal"
"github.com/mobyvb/pull-pal/vc" "github.com/mobyvb/pull-pal/vc"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
) )
// todo: some of this config definition/usage can be moved to other packages
type config struct {
// bot credentials + github info
selfHandle string
selfEmail string
githubToken string
// remote repo info
repoDomain string
repoHandle string
repoName string
// local paths
localRepoPath string
promptPath string
responsePath string
// program settings
promptToClipboard bool
usersToListenTo []string
requiredIssueLabels []string
}
func getConfig() config {
return config{
selfHandle: viper.GetString("handle"),
selfEmail: viper.GetString("email"),
githubToken: viper.GetString("github-token"),
repoDomain: viper.GetString("repo-domain"),
repoHandle: viper.GetString("repo-handle"),
repoName: viper.GetString("repo-name"),
localRepoPath: viper.GetString("local-repo-path"),
promptPath: viper.GetString("prompt-path"),
responsePath: viper.GetString("response-path"),
promptToClipboard: viper.GetBool("prompt-to-clipboard"),
usersToListenTo: viper.GetStringSlice("users-to-listen-to"),
requiredIssueLabels: viper.GetStringSlice("required-issue-labels"),
}
}
// rootCmd represents the base command when called without any subcommands // rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "pull-pal", Use: "pull-pal",
@ -26,16 +70,7 @@ It can be used to:
// Uncomment the following line if your bare application // Uncomment the following line if your bare application
// has an action associated with it: // has an action associated with it:
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
selfHandle := viper.GetString("handle") cfg := getConfig()
selfEmail := viper.GetString("email")
repoDomain := viper.GetString("repo-domain")
repoHandle := viper.GetString("repo-handle")
repoName := viper.GetString("repo-name")
githubToken := viper.GetString("github-token")
localRepoPath := viper.GetString("local-repo-path")
promptPath := viper.GetString("prompt-path")
promptToClipboard := viper.GetBool("prompt-to-clipboard")
responsePath := viper.GetString("response-path")
/* /*
log, err := zap.NewProduction() log, err := zap.NewProduction()
@ -47,16 +82,16 @@ It can be used to:
log := zap.L() log := zap.L()
author := vc.Author{ author := vc.Author{
Email: selfEmail, Email: cfg.selfEmail,
Handle: selfHandle, Handle: cfg.selfHandle,
Token: githubToken, Token: cfg.githubToken,
} }
repo := vc.Repository{ repo := vc.Repository{
LocalPath: localRepoPath, LocalPath: cfg.localRepoPath,
HostDomain: repoDomain, HostDomain: cfg.repoDomain,
Name: repoName, Name: cfg.repoName,
Owner: vc.Author{ Owner: vc.Author{
Handle: repoHandle, Handle: cfg.repoHandle,
}, },
} }
p, err := pullpal.NewPullPal(cmd.Context(), log.Named("pullpal"), author, repo) p, err := pullpal.NewPullPal(cmd.Context(), log.Named("pullpal"), author, repo)
@ -77,8 +112,11 @@ It can be used to:
var issue vc.Issue var issue vc.Issue
var changeRequest llm.CodeChangeRequest var changeRequest llm.CodeChangeRequest
if promptToClipboard { if cfg.promptToClipboard {
issue, changeRequest, err = p.PickIssueToClipboard(promptPath) issue, changeRequest, err = p.PickIssueToClipboard(vc.ListIssueOptions{
Handles: cfg.usersToListenTo,
Labels: cfg.requiredIssueLabels,
})
if err != nil { if err != nil {
if !errors.Is(err, pullpal.IssueNotFound) { if !errors.Is(err, pullpal.IssueNotFound) {
fmt.Println("error selecting issue and/or generating prompt", err) fmt.Println("error selecting issue and/or generating prompt", err)
@ -90,7 +128,10 @@ It can be used to:
fmt.Printf("Picked issue and copied prompt to clipboard. Issue #%s\n", issue.ID) fmt.Printf("Picked issue and copied prompt to clipboard. Issue #%s\n", issue.ID)
} }
} else { } else {
issue, changeRequest, err = p.PickIssueToFile(promptPath) issue, changeRequest, err = p.PickIssueToFile(vc.ListIssueOptions{
Handles: cfg.usersToListenTo,
Labels: cfg.requiredIssueLabels,
}, cfg.promptPath)
if err != nil { if err != nil {
if !errors.Is(err, pullpal.IssueNotFound) { if !errors.Is(err, pullpal.IssueNotFound) {
fmt.Println("error selecting issue and/or generating prompt", err) fmt.Println("error selecting issue and/or generating prompt", err)
@ -98,11 +139,11 @@ It can be used to:
} }
fmt.Println("No issues found. Proceeding to parse prompt") fmt.Println("No issues found. Proceeding to parse prompt")
} else { } else {
fmt.Printf("Picked issue and copied prompt to clipboard. Issue #%s. Prompt location %s\n", issue.ID, promptPath) fmt.Printf("Picked issue and copied prompt to clipboard. Issue #%s. Prompt location %s\n", issue.ID, cfg.promptPath)
} }
} }
fmt.Printf("\nInsert LLM response into response file: %s", responsePath) fmt.Printf("\nInsert LLM response into response file: %s", cfg.responsePath)
fmt.Println("Press 'enter' when ready to parse response. Enter 'skip' to skip response parsing. Enter 'exit' to exit.") fmt.Println("Press 'enter' when ready to parse response. Enter 'skip' to skip response parsing. Enter 'exit' to exit.")
fmt.Scanln(&input) fmt.Scanln(&input)
@ -114,7 +155,7 @@ It can be used to:
continue continue
} }
prURL, err := p.ProcessResponseFromFile(changeRequest, responsePath) prURL, err := p.ProcessResponseFromFile(changeRequest, cfg.responsePath)
if err != nil { if err != nil {
fmt.Println("error parsing LLM response and/or making version control changes", err) fmt.Println("error parsing LLM response and/or making version control changes", err)
return return
@ -146,25 +187,35 @@ func init() {
rootCmd.PersistentFlags().StringP("handle", "u", "HANDLE", "handle to use for version control actions") rootCmd.PersistentFlags().StringP("handle", "u", "HANDLE", "handle to use for version control actions")
rootCmd.PersistentFlags().StringP("email", "e", "EMAIL", "email to use for version control actions") rootCmd.PersistentFlags().StringP("email", "e", "EMAIL", "email to use for version control actions")
rootCmd.PersistentFlags().StringP("github-token", "t", "GITHUB TOKEN", "token for authenticating Github actions")
rootCmd.PersistentFlags().StringP("repo-domain", "d", "github.com", "domain for version control server") 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-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().StringP("repo-name", "n", "REPO-NAME", "name of repository on version control server")
rootCmd.PersistentFlags().StringP("github-token", "t", "GITHUB TOKEN", "token for authenticating Github actions")
rootCmd.PersistentFlags().StringP("local-repo-path", "l", "/tmp/pullpalrepo/", "path where pull pal will check out a local copy of the repository") rootCmd.PersistentFlags().StringP("local-repo-path", "l", "/tmp/pullpalrepo/", "path where pull pal will check out a local copy of the repository")
rootCmd.PersistentFlags().BoolP("prompt-to-clipboard", "c", false, "whether to copy LLM prompt to clipboard rather than using a file")
rootCmd.PersistentFlags().StringP("prompt-path", "p", "./path/to/prompt.txt", "path where pull pal will write the llm prompt") rootCmd.PersistentFlags().StringP("prompt-path", "p", "./path/to/prompt.txt", "path where pull pal will write the llm prompt")
rootCmd.PersistentFlags().StringP("response-path", "r", "./path/to/response.txt", "path where pull pal will read the llm response from") rootCmd.PersistentFlags().StringP("response-path", "r", "./path/to/response.txt", "path where pull pal will read the llm response from")
rootCmd.PersistentFlags().BoolP("prompt-to-clipboard", "c", false, "whether to copy LLM prompt to clipboard rather than using a file")
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")
viper.BindPFlag("handle", rootCmd.PersistentFlags().Lookup("handle")) viper.BindPFlag("handle", rootCmd.PersistentFlags().Lookup("handle"))
viper.BindPFlag("email", rootCmd.PersistentFlags().Lookup("email")) viper.BindPFlag("email", rootCmd.PersistentFlags().Lookup("email"))
viper.BindPFlag("github-token", rootCmd.PersistentFlags().Lookup("github-token"))
viper.BindPFlag("repo-domain", rootCmd.PersistentFlags().Lookup("repo-domain")) viper.BindPFlag("repo-domain", rootCmd.PersistentFlags().Lookup("repo-domain"))
viper.BindPFlag("repo-handle", rootCmd.PersistentFlags().Lookup("repo-handle")) viper.BindPFlag("repo-handle", rootCmd.PersistentFlags().Lookup("repo-handle"))
viper.BindPFlag("repo-name", rootCmd.PersistentFlags().Lookup("repo-name")) viper.BindPFlag("repo-name", rootCmd.PersistentFlags().Lookup("repo-name"))
viper.BindPFlag("github-token", rootCmd.PersistentFlags().Lookup("github-token"))
viper.BindPFlag("local-repo-path", rootCmd.PersistentFlags().Lookup("local-repo-path")) viper.BindPFlag("local-repo-path", rootCmd.PersistentFlags().Lookup("local-repo-path"))
viper.BindPFlag("prompt-to-clipboard", rootCmd.PersistentFlags().Lookup("prompt-to-clipboard"))
viper.BindPFlag("prompt-path", rootCmd.PersistentFlags().Lookup("prompt-path")) viper.BindPFlag("prompt-path", rootCmd.PersistentFlags().Lookup("prompt-path"))
viper.BindPFlag("response-path", rootCmd.PersistentFlags().Lookup("response-path")) viper.BindPFlag("response-path", rootCmd.PersistentFlags().Lookup("response-path"))
viper.BindPFlag("prompt-to-clipboard", rootCmd.PersistentFlags().Lookup("prompt-to-clipboard"))
viper.BindPFlag("users-to-listen-to", rootCmd.PersistentFlags().Lookup("users-to-listen-to"))
viper.BindPFlag("required-issue-labels", rootCmd.PersistentFlags().Lookup("required-issue-labels"))
} }
func initConfig() { func initConfig() {

View File

@ -44,8 +44,8 @@ func NewPullPal(ctx context.Context, log *zap.Logger, self vc.Author, repo vc.Re
var IssueNotFound = errors.New("no issue found") var IssueNotFound = errors.New("no issue found")
// PickIssueToFile is the same as PickIssue, but the changeRequest is converted to a string and written to a file. // 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) { func (p *PullPal) PickIssueToFile(listIssueOptions vc.ListIssueOptions, promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
issue, changeRequest, err = p.PickIssue() issue, changeRequest, err = p.PickIssue(listIssueOptions)
if err != nil { if err != nil {
return issue, changeRequest, err return issue, changeRequest, err
} }
@ -60,8 +60,8 @@ func (p *PullPal) PickIssueToFile(promptPath string) (issue vc.Issue, changeRequ
} }
// PickIssueToClipboard is the same as PickIssue, but the changeRequest is converted to a string and copied to the clipboard. // PickIssueToClipboard is the same as PickIssue, but the changeRequest is converted to a string and copied to the clipboard.
func (p *PullPal) PickIssueToClipboard(promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) { func (p *PullPal) PickIssueToClipboard(listIssueOptions vc.ListIssueOptions) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
issue, changeRequest, err = p.PickIssue() issue, changeRequest, err = p.PickIssue(listIssueOptions)
if err != nil { if err != nil {
return issue, changeRequest, err return issue, changeRequest, err
} }
@ -76,9 +76,9 @@ func (p *PullPal) PickIssueToClipboard(promptPath string) (issue vc.Issue, chang
} }
// 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. // 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) { func (p *PullPal) PickIssue(listIssueOptions vc.ListIssueOptions) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
// TODO I should be able to pass in settings for listing issues from here // TODO I should be able to pass in settings for listing issues from here
issues, err := p.vcClient.ListOpenIssues() issues, err := p.vcClient.ListOpenIssues(listIssueOptions)
if err != nil { if err != nil {
return issue, changeRequest, err return issue, changeRequest, err
} }
@ -154,3 +154,16 @@ func (p *PullPal) ProcessResponse(codeChangeRequest llm.CodeChangeRequest, llmRe
_, url, err = p.vcClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse) _, url, err = p.vcClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse)
return url, err 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.vcClient.ListOpenIssues(vc.ListIssueOptions{
Handles: handles,
Labels: labels,
})
if err != nil {
return nil, err
}
return issues, nil
}

View File

@ -17,6 +17,40 @@ type Issue struct {
Author Author Author Author
} }
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)
}
// ListIssueOptions defines options for listing issues.
type ListIssueOptions struct {
// Labels defines the list of labels an issue must have in order to be listed
// The issue must have *every* label provided.
Labels []string
// Handles defines the list of usernames to list issues from
// The issue can be created by *any* user provided.
Handles []string
}
// Comment represents a comment on a code change request.
// TODO comments on issue?
type Comment struct {
// ChangeID is the local identifier for the code change request this comment was left on (e.g. Github PR number)
ChangeID string
// Line is the contents of the code on the line where this comment was left
Line string
Body string
Author Author
}
// ListCommentOptions defines options for listing comments.
type ListCommentOptions struct {
// ChangeID is the local identifier for the code change request to list comments from (e.g. Github PR number)
ChangeID string
// Handles defines the list of usernames to list comments from
// The comment can be created by *any* user provided.
Handles []string
}
// Author represents a commit, issue, or code change request author on a version control server. // Author represents a commit, issue, or code change request author on a version control server.
type Author struct { type Author struct {
Email string Email string
@ -45,8 +79,10 @@ func (repo Repository) HTTPS() string {
// VCClient is an interface for version control server's client, e.g. a Github or Gerrit client. // VCClient is an interface for version control server's client, e.g. a Github or Gerrit client.
type VCClient interface { type VCClient interface {
// ListOpenIssues lists unresolved issues on the version control server. // ListOpenIssues lists unresolved issues meeting the provided criteria on the version control server.
ListOpenIssues() ([]Issue, error) ListOpenIssues(opts ListIssueOptions) ([]Issue, error)
// ListOpenComments lists unresolved comments meeting the provided criteria on the version control server.
ListOpenComments(opts ListCommentOptions) ([]Comment, error)
// OpenCodeChangeRequest opens a new "code change request" on the version control server (e.g. "pull request" in Github). // OpenCodeChangeRequest opens a new "code change request" on the version control server (e.g. "pull request" in Github).
OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse) (id, url string, err error) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse) (id, url string, err error)
// UpdateCodeChangeRequest updates an existing code change request on the version control server. // UpdateCodeChangeRequest updates an existing code change request on the version control server.

View File

@ -166,17 +166,27 @@ func randomBranchName() string {
} }
// ListOpenIssues lists unresolved issues in the Github repository. // ListOpenIssues lists unresolved issues in the Github repository.
func (gc *GithubClient) ListOpenIssues() ([]Issue, error) { func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error) {
// List and parse GitHub issues // List and parse GitHub issues
issues, _, err := gc.client.Issues.ListByRepo(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, nil) opt := &github.IssueListByRepoOptions{
Labels: options.Labels,
}
issues, _, err := gc.client.Issues.ListByRepo(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, opt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
toReturn := []Issue{} toReturn := []Issue{}
for _, issue := range issues { for _, issue := range issues {
// TODO make this filtering configurable from outside issueUser := issue.GetUser().GetLogin()
if issue.GetUser().GetLogin() != gc.repo.Owner.Handle { allowedUser := false
for _, u := range options.Handles {
if issueUser == u {
allowedUser = true
break
}
}
if !allowedUser {
continue continue
} }
@ -196,6 +206,11 @@ func (gc *GithubClient) ListOpenIssues() ([]Issue, error) {
return toReturn, nil return toReturn, nil
} }
// ListOpenComments lists unresolved comments in the Github repository.
func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, error) {
return nil, nil
}
// GetLocalFile gets the current representation of the file at the provided path from the local git repo. // GetLocalFile gets the current representation of the file at the provided path from the local git repo.
func (gc *GithubClient) GetLocalFile(path string) (llm.File, error) { func (gc *GithubClient) GetLocalFile(path string) (llm.File, error) {
fullPath := filepath.Join(gc.repo.LocalPath, path) fullPath := filepath.Join(gc.repo.LocalPath, path)