diff --git a/cmd/list-issues.go b/cmd/list-issues.go new file mode 100644 index 0000000..3878451 --- /dev/null +++ b/cmd/list-issues.go @@ -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) +} diff --git a/cmd/root.go b/cmd/root.go index 8783a52..a472981 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,11 +8,55 @@ import ( "github.com/mobyvb/pull-pal/llm" "github.com/mobyvb/pull-pal/pullpal" "github.com/mobyvb/pull-pal/vc" + "github.com/spf13/cobra" "github.com/spf13/viper" "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 var rootCmd = &cobra.Command{ Use: "pull-pal", @@ -26,16 +70,7 @@ It can be used to: // Uncomment the following line if your bare application // has an action associated with it: Run: func(cmd *cobra.Command, args []string) { - selfHandle := viper.GetString("handle") - 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") + cfg := getConfig() /* log, err := zap.NewProduction() @@ -47,16 +82,16 @@ It can be used to: log := zap.L() author := vc.Author{ - Email: selfEmail, - Handle: selfHandle, - Token: githubToken, + Email: cfg.selfEmail, + Handle: cfg.selfHandle, + Token: cfg.githubToken, } repo := vc.Repository{ - LocalPath: localRepoPath, - HostDomain: repoDomain, - Name: repoName, + LocalPath: cfg.localRepoPath, + HostDomain: cfg.repoDomain, + Name: cfg.repoName, Owner: vc.Author{ - Handle: repoHandle, + Handle: cfg.repoHandle, }, } 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 changeRequest llm.CodeChangeRequest - if promptToClipboard { - issue, changeRequest, err = p.PickIssueToClipboard(promptPath) + if cfg.promptToClipboard { + issue, changeRequest, err = p.PickIssueToClipboard(vc.ListIssueOptions{ + Handles: cfg.usersToListenTo, + Labels: cfg.requiredIssueLabels, + }) if err != nil { if !errors.Is(err, pullpal.IssueNotFound) { 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) } } 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 !errors.Is(err, pullpal.IssueNotFound) { 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") } 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.Scanln(&input) @@ -114,7 +155,7 @@ It can be used to: continue } - prURL, err := p.ProcessResponseFromFile(changeRequest, responsePath) + prURL, err := p.ProcessResponseFromFile(changeRequest, cfg.responsePath) if err != nil { fmt.Println("error parsing LLM response and/or making version control changes", err) return @@ -146,25 +187,35 @@ func init() { 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("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-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("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().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("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("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-handle", rootCmd.PersistentFlags().Lookup("repo-handle")) 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("prompt-to-clipboard", rootCmd.PersistentFlags().Lookup("prompt-to-clipboard")) viper.BindPFlag("prompt-path", rootCmd.PersistentFlags().Lookup("prompt-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() { diff --git a/pullpal/common.go b/pullpal/common.go index ecee81e..1c9e44b 100644 --- a/pullpal/common.go +++ b/pullpal/common.go @@ -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") // PickIssueToFile is the same as PickIssue, but the changeRequest is converted to a string and written to a file. -func (p *PullPal) PickIssueToFile(promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) { - issue, changeRequest, err = p.PickIssue() +func (p *PullPal) PickIssueToFile(listIssueOptions vc.ListIssueOptions, promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) { + issue, changeRequest, err = p.PickIssue(listIssueOptions) if err != nil { 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. -func (p *PullPal) PickIssueToClipboard(promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) { - issue, changeRequest, err = p.PickIssue() +func (p *PullPal) PickIssueToClipboard(listIssueOptions vc.ListIssueOptions) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) { + issue, changeRequest, err = p.PickIssue(listIssueOptions) if err != nil { 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. -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 - issues, err := p.vcClient.ListOpenIssues() + issues, err := p.vcClient.ListOpenIssues(listIssueOptions) if err != nil { return issue, changeRequest, err } @@ -154,3 +154,16 @@ func (p *PullPal) ProcessResponse(codeChangeRequest llm.CodeChangeRequest, llmRe _, url, err = p.vcClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse) return url, err } + +// ListIssues gets a list of all issues meeting the provided criteria. +func (p *PullPal) ListIssues(handles, labels []string) ([]vc.Issue, error) { + issues, err := p.vcClient.ListOpenIssues(vc.ListIssueOptions{ + Handles: handles, + Labels: labels, + }) + if err != nil { + return nil, err + } + + return issues, nil +} diff --git a/vc/common.go b/vc/common.go index fbcf14f..09ce011 100644 --- a/vc/common.go +++ b/vc/common.go @@ -17,6 +17,40 @@ type Issue struct { 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. type Author struct { 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. type VCClient interface { - // ListOpenIssues lists unresolved issues on the version control server. - ListOpenIssues() ([]Issue, error) + // ListOpenIssues lists unresolved issues meeting the provided criteria on the version control server. + 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(req llm.CodeChangeRequest, res llm.CodeChangeResponse) (id, url string, err error) // UpdateCodeChangeRequest updates an existing code change request on the version control server. diff --git a/vc/github.go b/vc/github.go index 13344c7..c4b666d 100644 --- a/vc/github.go +++ b/vc/github.go @@ -166,17 +166,27 @@ func randomBranchName() string { } // 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 - 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 { return nil, err } toReturn := []Issue{} for _, issue := range issues { - // TODO make this filtering configurable from outside - if issue.GetUser().GetLogin() != gc.repo.Owner.Handle { + issueUser := issue.GetUser().GetLogin() + allowedUser := false + for _, u := range options.Handles { + if issueUser == u { + allowedUser = true + break + } + } + if !allowedUser { continue } @@ -196,6 +206,11 @@ func (gc *GithubClient) ListOpenIssues() ([]Issue, error) { 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. func (gc *GithubClient) GetLocalFile(path string) (llm.File, error) { fullPath := filepath.Join(gc.repo.LocalPath, path)