mirror of
https://github.com/Pull-Pal/pull-pal.git
synced 2024-11-03 01:38:33 -04:00
10c77854a9
OpenAIClient can be used to interact with OpenAI's API. Untested at the moment (except with an API key that does not yet have perms) Local git client splits the "git" functionality (e.g. make commits, checkout branches, etc...) away from the "github" client I also removed a lot of duplicate code for the different commands in cmd And created a basic "local issue" command to send a "code change request" to the llm, and receive a code change response. Eventually, I want this to make the corresponding local git changes, for the user to check. TODO: github client should only be responsible for github-specific actions, e.g. opening a PR or listing issues/comments
256 lines
8.5 KiB
Go
256 lines
8.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/mobyvb/pull-pal/llm"
|
|
"github.com/mobyvb/pull-pal/pullpal"
|
|
"github.com/mobyvb/pull-pal/vc"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// 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
|
|
openAIToken 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"),
|
|
openAIToken: viper.GetString("open-ai-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"),
|
|
}
|
|
}
|
|
|
|
func getPullPal(ctx context.Context, cfg config) (*pullpal.PullPal, error) {
|
|
/*
|
|
log, err := zap.NewProduction()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
*/
|
|
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(ctx, log.Named("pullpal"), author, repo, cfg.openAIToken)
|
|
|
|
return p, err
|
|
}
|
|
|
|
// rootCmd represents the base command when called without any subcommands
|
|
var rootCmd = &cobra.Command{
|
|
Use: "pull-pal",
|
|
Short: "A bot that uses large language models to act as a collaborator on a git project",
|
|
Long: `A bot that uses large language models to act as a collaborator on a git project.
|
|
|
|
It can be used to:
|
|
* Monitor a repository for open issues, and generate LLM prompts according to the issue details
|
|
* Read an LLM response and process it into a new git commit and code change request on the version control server
|
|
`,
|
|
// Uncomment the following line if your bare application
|
|
// has an action associated with it:
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
cfg := getConfig()
|
|
|
|
p, err := getPullPal(cmd.Context(), cfg)
|
|
if err != nil {
|
|
fmt.Println("error creating new pull pal", err)
|
|
return
|
|
}
|
|
fmt.Println("Successfully initialized pull pal")
|
|
|
|
// TODO this loop breaks on the second iteration due to a weird git state or something
|
|
for {
|
|
var input string
|
|
fmt.Println("Press 'enter' when ready to select issue. Type 'exit' to exit.")
|
|
fmt.Scanln(&input)
|
|
if input == "exit" {
|
|
break
|
|
}
|
|
|
|
var issue vc.Issue
|
|
var changeRequest llm.CodeChangeRequest
|
|
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)
|
|
return
|
|
} else {
|
|
fmt.Println("No issues found. Proceeding to parse prompt")
|
|
}
|
|
} else {
|
|
fmt.Printf("Picked issue and copied prompt to clipboard. Issue #%s\n", issue.ID)
|
|
}
|
|
} else {
|
|
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)
|
|
return
|
|
}
|
|
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, cfg.promptPath)
|
|
}
|
|
}
|
|
|
|
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)
|
|
if input == "exit" {
|
|
break
|
|
}
|
|
if input == "skip" {
|
|
fmt.Println()
|
|
continue
|
|
}
|
|
|
|
prURL, err := p.ProcessResponseFromFile(changeRequest, cfg.responsePath)
|
|
if err != nil {
|
|
fmt.Println("error parsing LLM response and/or making version control changes", err)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("Successfully opened a code change request. Link: %s\n", prURL)
|
|
}
|
|
|
|
fmt.Println("Done. Thank you!")
|
|
},
|
|
}
|
|
|
|
// Execute adds all child commands to the root command and sets flags appropriately.
|
|
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
|
func Execute() {
|
|
err := rootCmd.Execute()
|
|
if err != nil {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
var cfgFile string
|
|
|
|
func init() {
|
|
cobra.OnInitialize(initConfig)
|
|
|
|
// TODO make config values requried
|
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.pull-pal.yaml)")
|
|
|
|
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("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().StringP("local-repo-path", "l", "/tmp/pullpalrepo/", "path where pull pal will check out a local copy of the repository")
|
|
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("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("local-repo-path", rootCmd.PersistentFlags().Lookup("local-repo-path"))
|
|
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() {
|
|
if cfgFile != "" {
|
|
fmt.Println("cfg file exists")
|
|
// Use config file from the flag.
|
|
viper.SetConfigFile(cfgFile)
|
|
} else {
|
|
// Find home directory.
|
|
home, err := os.UserHomeDir()
|
|
cobra.CheckErr(err)
|
|
|
|
// Search config in home directory with name ".cobra" (without extension).
|
|
viper.AddConfigPath(home)
|
|
viper.SetConfigType("yaml")
|
|
viper.SetConfigName(".pull-pal")
|
|
}
|
|
|
|
// TODO figure out how to get env variables to work
|
|
viper.SetEnvPrefix("pullpal")
|
|
viper.AutomaticEnv()
|
|
|
|
if err := viper.ReadInConfig(); err == nil {
|
|
fmt.Println("Using config file:", viper.ConfigFileUsed())
|
|
}
|
|
|
|
}
|