diff --git a/cmd/run.go b/cmd/debug.go similarity index 66% rename from cmd/run.go rename to cmd/debug.go index e04fade..bd38088 100644 --- a/cmd/run.go +++ b/cmd/debug.go @@ -6,9 +6,9 @@ import ( "github.com/spf13/cobra" ) -var runCmd = &cobra.Command{ - Use: "run", - Short: "Runs a fully automated pull pal service", +var debugGitCmd = &cobra.Command{ + Use: "debug-git", + Short: "debug git functionality", Run: func(cmd *cobra.Command, args []string) { cfg := getConfig() @@ -19,14 +19,14 @@ var runCmd = &cobra.Command{ } fmt.Println("Successfully initialized pull pal") - err = p.Run() + err = p.DebugGit() if err != nil { - fmt.Println("error running", err) + fmt.Println("err debugging git", err) return } }, } func init() { - rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(debugGitCmd) } diff --git a/cmd/list-comments.go b/cmd/list-comments.go deleted file mode 100644 index 8e5abde..0000000 --- a/cmd/list-comments.go +++ /dev/null @@ -1,36 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var listCommentsCmd = &cobra.Command{ - Use: "list-comments", - Short: "Lists comments on a Github PR meeting the configured criteria", - Long: "Lists comments on a Github PR meeting the configured criteria", - Args: cobra.ExactArgs(1), - 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") - - prID := args[0] - issueList, err := p.ListComments(prID, cfg.usersToListenTo) - if err != nil { - fmt.Println("error listing issues", err) - return - } - fmt.Println(issueList) - }, -} - -func init() { - rootCmd.AddCommand(listCommentsCmd) -} diff --git a/cmd/list-issues.go b/cmd/list-issues.go deleted file mode 100644 index 4e7c330..0000000 --- a/cmd/list-issues.go +++ /dev/null @@ -1,34 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -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() - - p, err := getPullPal(cmd.Context(), cfg) - 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/local-issue.go b/cmd/local-issue.go deleted file mode 100644 index e8fe91f..0000000 --- a/cmd/local-issue.go +++ /dev/null @@ -1,43 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/mobyvb/pull-pal/vc" - - "github.com/spf13/cobra" -) - -var localIssueCmd = &cobra.Command{ - Use: "local-issue", - Short: "Processes a locally-defined/provided issue rather than remotely reading one from the Github repo", - // TODO csv filepath as arg? - // Args: cobra.ExactArgs(1), - 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") - - newIssue := vc.Issue{ - Subject: "a few updates", - Body: "Add a quote from Frodo to the README.md and index.html files.\nSwitch main.go to port 7777.\nFiles:index.html,README.md,main.go", - Author: vc.Author{ - Handle: "mobyvb", - }, - } - err = p.MakeLocalChange(newIssue) - if err != nil { - fmt.Println("err making local change", err) - return - } - }, -} - -func init() { - rootCmd.AddCommand(localIssueCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 9bda540..1d1b244 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,11 +2,9 @@ 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" @@ -30,8 +28,6 @@ type config struct { // local paths localRepoPath string - promptPath string - responsePath string // program settings promptToClipboard bool @@ -51,8 +47,6 @@ func getConfig() config { 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"), @@ -61,6 +55,7 @@ func getConfig() config { } func getPullPal(ctx context.Context, cfg config) (*pullpal.PullPal, error) { + // TODO figure out debug logging log, err := zap.NewProduction() if err != nil { panic(err) @@ -91,16 +86,7 @@ func getPullPal(ctx context.Context, cfg config) (*pullpal.PullPal, error) { // 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: + Short: "run an automated digital assitant to monitor and make code changes to a github repository", Run: func(cmd *cobra.Command, args []string) { cfg := getConfig() @@ -111,64 +97,11 @@ It can be used to: } 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() - 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(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) + err = p.Run() + if err != nil { + fmt.Println("error running", err) + return } - - fmt.Println("Done. Thank you!") }, } @@ -198,11 +131,8 @@ func init() { 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().StringP("local-repo-path", "l", "/tmp/pullpallrepo", "local path to check out ephemeral repository in") - 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") @@ -216,10 +146,7 @@ func init() { 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")) } diff --git a/go.mod b/go.mod index 764e2fc..b1e92b5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/mobyvb/pull-pal go 1.20 require ( - github.com/atotto/clipboard v0.1.4 github.com/go-git/go-git/v5 v5.6.1 github.com/google/go-github v17.0.0+incompatible github.com/sashabaranov/go-openai v1.9.0 diff --git a/go.sum b/go.sum index 3ad2548..f949981 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= diff --git a/llm/common.go b/llm/common.go index e3b7c5e..c89f6c0 100644 --- a/llm/common.go +++ b/llm/common.go @@ -37,7 +37,7 @@ func (req CodeChangeRequest) MustGetPrompt() string { // GetPrompt converts the information in the request to a prompt for an LLM. func (req CodeChangeRequest) GetPrompt() (string, error) { - tmpl, err := template.ParseFiles("./llm/code-change-request.tmpl") + tmpl, err := template.ParseFiles("./llm/prompts/code-change-request.tmpl") if err != nil { return "", err } @@ -72,10 +72,16 @@ func (res CodeChangeResponse) String() string { // ParseCodeChangeResponse parses the LLM's response to CodeChangeRequest (string) into a CodeChangeResponse. func ParseCodeChangeResponse(llmResponse string) CodeChangeResponse { - sections := strings.Split(llmResponse, "Notes:") + sections := strings.Split(llmResponse, "ppnotes:") - filesSection := sections[0] - notes := strings.TrimSpace(sections[1]) + filesSection := "" + if len(sections) > 0 { + filesSection = sections[0] + } + notes := "" + if len(sections) > 1 { + notes = strings.TrimSpace(sections[1]) + } files := parseFiles(filesSection) @@ -87,7 +93,10 @@ func ParseCodeChangeResponse(llmResponse string) CodeChangeResponse { // parseFiles process the "files" subsection of the LLM's response. It is a helper for GetCodeChangeResponse. func parseFiles(filesSection string) []File { - fileStringList := strings.Split(filesSection, "name:") + fileStringList := strings.Split(filesSection, "ppname:") + if len(fileStringList) < 2 { + return []File{} + } // first item in the list is just gonna be "Files:" fileStringList = fileStringList[1:] @@ -98,7 +107,10 @@ func parseFiles(filesSection string) []File { ) fileList := make([]File, len(fileStringList)) for i, f := range fileStringList { - fileParts := strings.Split(f, "contents:") + fileParts := strings.Split(f, "ppcontents:") + if len(fileParts) < 2 { + continue + } path := replacer.Replace(fileParts[0]) path = strings.TrimSpace(path) diff --git a/llm/openai.go b/llm/openai.go index 4233933..688df95 100644 --- a/llm/openai.go +++ b/llm/openai.go @@ -23,10 +23,11 @@ func (oc *OpenAIClient) EvaluateCCR(ctx context.Context, req CodeChangeRequest) resp, err := oc.client.CreateChatCompletion( ctx, openai.ChatCompletionRequest{ - Model: openai.GPT3Dot5Turbo, + // TODO make model configurable + Model: openai.GPT4, + //Model: openai.GPT3Dot5Turbo, Messages: []openai.ChatCompletionMessage{ { - // TODO is this the correct role for my prompts? Role: openai.ChatMessageRoleUser, Content: req.String(), }, @@ -38,10 +39,10 @@ func (oc *OpenAIClient) EvaluateCCR(ctx context.Context, req CodeChangeRequest) return res, err } - // TODO use different choices/different options in different branches/worktrees? choice := resp.Choices[0].Message.Content - oc.log.Debug("got response from llm", zap.String("output", choice)) + // TODO make debug log when I figure out how to config that + oc.log.Info("got response from llm", zap.String("output", choice)) return ParseCodeChangeResponse(choice), nil } diff --git a/llm/code-change-request.tmpl b/llm/prompts/code-change-request.tmpl similarity index 88% rename from llm/code-change-request.tmpl rename to llm/prompts/code-change-request.tmpl index 843ed9a..44d2194 100644 --- a/llm/code-change-request.tmpl +++ b/llm/prompts/code-change-request.tmpl @@ -15,10 +15,10 @@ Body: Respond in the exact format: Files: {{ range $index, $file := .Files }} - - name: {{ $file.Path }} - contents: + ppname: {{ $file.Path }} + ppcontents: [new {{ $file.Path }} contents] {{ end }} -Notes: +ppnotes: [additional context about your changes] diff --git a/llm/comment-diff-request.tmpl b/llm/prompts/comment-diff-request.tmpl similarity index 93% rename from llm/comment-diff-request.tmpl rename to llm/prompts/comment-diff-request.tmpl index 8b74f53..de3c5b5 100644 --- a/llm/comment-diff-request.tmpl +++ b/llm/prompts/comment-diff-request.tmpl @@ -24,9 +24,9 @@ Q Response Template B: R Files: - - name: {{ .Path }} - contents: + ppname: {{ .Path }} + ppcontents: [new {{ .Path }} contents] -Response: +ppresponse: [additional context about your changes] diff --git a/pullpal/common.go b/pullpal/common.go index d7cb267..9457402 100644 --- a/pullpal/common.go +++ b/pullpal/common.go @@ -4,14 +4,13 @@ import ( "context" "errors" "fmt" - "io/ioutil" + "strconv" "strings" "time" "github.com/mobyvb/pull-pal/llm" "github.com/mobyvb/pull-pal/vc" - "github.com/atotto/clipboard" "go.uber.org/zap" ) @@ -19,16 +18,16 @@ import ( var IssueNotFound = errors.New("no issue found") // 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) +// - 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 - vcClient vc.VCClient + ghClient *vc.GithubClient localGitClient *vc.LocalGitClient openAIClient *llm.OpenAIClient } @@ -39,7 +38,7 @@ func NewPullPal(ctx context.Context, log *zap.Logger, listIssueOptions vc.ListIs if err != nil { return nil, err } - localGitClient, err := vc.NewLocalGitClient(self, repo) + localGitClient, err := vc.NewLocalGitClient(log, self, repo) if err != nil { return nil, err } @@ -49,7 +48,7 @@ func NewPullPal(ctx context.Context, log *zap.Logger, listIssueOptions vc.ListIs log: log, listIssueOptions: listIssueOptions, - vcClient: ghClient, + ghClient: ghClient, localGitClient: localGitClient, openAIClient: llm.NewOpenAIClient(log.Named("openaiClient"), openAIToken), }, nil @@ -60,20 +59,38 @@ func (p *PullPal) Run() error { p.log.Info("Starting Pull Pal") // TODO gracefully handle context cancelation for { - issues, err := p.vcClient.ListOpenIssues(p.listIssueOptions) + issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions) if err != nil { p.log.Error("error listing issues", zap.Error(err)) - continue + return err } if len(issues) == 0 { // todo don't sleep - p.log.Info("no issues found. sleeping for 5 mins") - time.Sleep(5 * time.Minute) + p.log.Info("no issues found. sleeping for 30 seconds") + time.Sleep(30 * time.Second) continue } issue := issues[0] + issueNumber, err := strconv.Atoi(issue.ID) + if err != nil { + p.log.Error("error converting issue ID to int", zap.Error(err)) + return err + } + + err = p.ghClient.CommentOnIssue(issueNumber, "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) + 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 @@ -89,7 +106,7 @@ func (p *PullPal) Run() error { files := []llm.File{} for _, path := range fileList { path = strings.TrimSpace(path) - nextFile, err := p.vcClient.GetLocalFile(path) + nextFile, err := p.localGitClient.GetLocalFile(path) if err != nil { p.log.Error("error getting file from vcclient", zap.Error(err)) continue @@ -115,40 +132,63 @@ func (p *PullPal) Run() error { //codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse) // create commit with file changes - err = p.vcClient.StartCommit() + err = p.localGitClient.StartCommit() + //err = p.ghClient.StartCommit() if err != nil { p.log.Error("error starting commit", zap.Error(err)) - continue + return err } + newBranchName := fmt.Sprintf("fix-%s", changeRequest.IssueID) + /* + err = p.localGitClient.SwitchBranch(newBranchName) + if err != nil { + p.log.Error("error switching branch", zap.Error(err)) + return err + } + */ for _, f := range changeResponse.Files { - err = p.vcClient.ReplaceOrAddLocalFile(f) + p.log.Info("replacing or adding file", zap.String("path", f.Path), zap.String("contents", f.Contents)) + err = p.localGitClient.ReplaceOrAddLocalFile(f) + // err = p.ghClient.ReplaceOrAddLocalFile(f) if err != nil { p.log.Error("error replacing or adding file", zap.Error(err)) - continue + return err } } commitMessage := changeRequest.Subject + "\n\n" + changeResponse.Notes + "\n\nResolves: #" + changeRequest.IssueID - err = p.vcClient.FinishCommit(commitMessage) + p.log.Info("about to create commit", zap.String("message", commitMessage)) + err = p.localGitClient.FinishCommit(commitMessage) if err != nil { - p.log.Error("error finshing commit", zap.Error(err)) - continue + p.log.Error("error finishing commit", zap.Error(err)) + // TODO figure out why sometimes finish commit returns "already up-to-date error" + // return err + } + + err = p.localGitClient.PushBranch(newBranchName) + if err != nil { + p.log.Error("error pushing branch", zap.Error(err)) + return err } // open code change request - _, url, err := p.vcClient.OpenCodeChangeRequest(changeRequest, changeResponse) + // TODO don't hardcode main branch, make configurable + _, url, err := p.ghClient.OpenCodeChangeRequest(changeRequest, changeResponse, newBranchName, "main") if err != nil { p.log.Error("error opening PR", zap.Error(err)) + return err } p.log.Info("successfully created PR", zap.String("URL", url)) - p.log.Info("going to sleep for five mins") - time.Sleep(5 * time.Minute) + p.log.Info("going to sleep for thirty seconds") + time.Sleep(30 * time.Second) } return nil } +/* + // 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() @@ -180,11 +220,12 @@ func (p *PullPal) PickIssueToClipboard() (issue vc.Issue, changeRequest llm.Code err = clipboard.WriteAll(prompt) return issue, changeRequest, err } - +*/ +/* // 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) { // TODO I should be able to pass in settings for listing issues from here - issues, err := p.vcClient.ListOpenIssues(p.listIssueOptions) + issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions) if err != nil { return issue, changeRequest, err } @@ -209,7 +250,7 @@ func (p *PullPal) PickIssue() (issue vc.Issue, changeRequest llm.CodeChangeReque files := []llm.File{} for _, path := range fileList { path = strings.TrimSpace(path) - nextFile, err := p.vcClient.GetLocalFile(path) + nextFile, err := p.ghClient.GetLocalFile(path) if err != nil { return issue, changeRequest, err } @@ -223,7 +264,8 @@ func (p *PullPal) PickIssue() (issue vc.Issue, changeRequest llm.CodeChangeReque return issue, changeRequest, nil } - +*/ +/* // ProcessResponseFromFile is the same as ProcessResponse, but the response is inputted into a file rather than passed directly as an argument. func (p *PullPal) ProcessResponseFromFile(codeChangeRequest llm.CodeChangeRequest, llmResponsePath string) (url string, err error) { data, err := ioutil.ReadFile(llmResponsePath) @@ -239,31 +281,33 @@ func (p *PullPal) ProcessResponse(codeChangeRequest llm.CodeChangeRequest, llmRe codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse) // 2. create commit with file changes - err = p.vcClient.StartCommit() + err = p.ghClient.StartCommit() if err != nil { return "", err } for _, f := range codeChangeResponse.Files { - err = p.vcClient.ReplaceOrAddLocalFile(f) + err = p.ghClient.ReplaceOrAddLocalFile(f) if err != nil { return "", err } } commitMessage := codeChangeRequest.Subject + "\n\n" + codeChangeResponse.Notes + "\n\nResolves: #" + codeChangeRequest.IssueID - err = p.vcClient.FinishCommit(commitMessage) + err = p.ghClient.FinishCommit(commitMessage) if err != nil { return "", err } // 3. open code change request - _, url, err = p.vcClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse) + _, url, err = p.ghClient.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{ + issues, err := p.ghClient.ListOpenIssues(vc.ListIssueOptions{ Handles: handles, Labels: labels, }) @@ -276,7 +320,7 @@ func (p *PullPal) ListIssues(handles, labels []string) ([]vc.Issue, error) { // ListComments gets a list of all comments meeting the provided criteria on a PR. func (p *PullPal) ListComments(changeID string, handles []string) ([]vc.Comment, error) { - comments, err := p.vcClient.ListOpenComments(vc.ListCommentOptions{ + comments, err := p.ghClient.ListOpenComments(vc.ListCommentOptions{ ChangeID: changeID, Handles: handles, }) @@ -302,7 +346,7 @@ func (p *PullPal) MakeLocalChange(issue vc.Issue) error { files := []llm.File{} for _, path := range fileList { path = strings.TrimSpace(path) - nextFile, err := p.vcClient.GetLocalFile(path) + nextFile, err := p.ghClient.GetLocalFile(path) if err != nil { return err } @@ -326,3 +370,4 @@ func (p *PullPal) MakeLocalChange(issue vc.Issue) error { return nil } +*/ diff --git a/pullpal/debug.go b/pullpal/debug.go new file mode 100644 index 0000000..2388e0c --- /dev/null +++ b/pullpal/debug.go @@ -0,0 +1,47 @@ +package pullpal + +import ( + "fmt" + + "github.com/mobyvb/pull-pal/llm" + "go.uber.org/zap" +) + +func (p *PullPal) DebugGit() error { + p.log.Info("Starting Pull Pal git debug") + + // create commit with file changes + err := p.localGitClient.StartCommit() + //err = p.ghClient.StartCommit() + if err != nil { + p.log.Error("error starting commit", zap.Error(err)) + return err + } + newBranchName := fmt.Sprintf("debug-branch") + + for _, f := range []string{"a", "b"} { + err = p.localGitClient.ReplaceOrAddLocalFile(llm.File{ + Path: f, + Contents: "hello", + }) + if err != nil { + p.log.Error("error replacing or adding file", zap.Error(err)) + return err + } + } + + commitMessage := "debug commit message" + err = p.localGitClient.FinishCommit(commitMessage) + if err != nil { + p.log.Error("error finishing commit", zap.Error(err)) + return err + } + + err = p.localGitClient.PushBranch(newBranchName) + if err != nil { + p.log.Error("error pushing branch", zap.Error(err)) + return err + } + + return nil +} diff --git a/vc/common.go b/vc/common.go index 9570601..1623142 100644 --- a/vc/common.go +++ b/vc/common.go @@ -3,8 +3,6 @@ package vc import ( "fmt" - "github.com/mobyvb/pull-pal/llm" - "github.com/go-git/go-git/v5" ) @@ -82,24 +80,3 @@ 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) } - -// VCClient is an interface for version control server's client, e.g. a Github or Gerrit client. -type VCClient interface { - // 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. - // UpdateCodeChangeRequest(id string, res llm.CodeChangeResponse) - // TODO: add/read comments to/from issues and code change requests - // GetLocalFile gets the current representation of the file at the provided path from the local git repo. - GetLocalFile(path string) (llm.File, error) - // StartCommit initiates a commit process, after which files can be modified and added to the commit. - StartCommit() error - // ReplaceOrAddLocalFile updates or adds a file in the locally cloned repo, and applies these changes to the current git worktree. - ReplaceOrAddLocalFile(newFile llm.File) error - // FinishCommit completes a commit, after which a code change request can be opened or updated. - FinishCommit(message string) error -} diff --git a/vc/git.go b/vc/git.go index 2e8a521..110a2b2 100644 --- a/vc/git.go +++ b/vc/git.go @@ -11,10 +11,10 @@ import ( "time" "github.com/mobyvb/pull-pal/llm" + "go.uber.org/zap" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/http" ) @@ -28,7 +28,8 @@ type LocalGitClient struct { } // NewLocalGitClient initializes a local git client by checking out a repository locally. -func NewLocalGitClient( /*ctx context.Context, log *zap.Logger, */ self Author, repo Repository) (*LocalGitClient, error) { +func NewLocalGitClient( /*ctx context.Context, */ log *zap.Logger, self Author, repo Repository) (*LocalGitClient, error) { + log.Info("checking out local github repo", zap.String("repo name", repo.Name), zap.String("local path", repo.LocalPath)) // clone provided repository to local path if repo.LocalPath == "" { return nil, errors.New("local path to clone repository not provided") @@ -59,24 +60,44 @@ func NewLocalGitClient( /*ctx context.Context, log *zap.Logger, */ self Author, }, nil } +/* func (gc *LocalGitClient) SwitchBranch(branchName string) (err error) { - branchRefName := plumbing.NewBranchReferenceName(branchName) - remoteName := "origin" + if gc.worktree == nil { + return errors.New("worktree is nil - cannot check out a branch") + } - err = gc.repo.localRepo.CreateBranch(&config.Branch{ - Name: branchName, - Remote: remoteName, - Merge: branchRefName, + branchRefName := plumbing.NewBranchReferenceName(branchName) + // remoteName := "origin" + + err = gc.repo.localRepo.Fetch(&git.FetchOptions{ + RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}, }) if err != nil { return err } + err = gc.worktree.Checkout(&git.CheckoutOptions{ + Branch: branchRefName, + Force: true, + }) + if err != nil { + return err + } + err = gc.repo.localRepo.CreateBranch(&config.Branch{ + Name: branchName, + Remote: remoteName, + Merge: branchRefName, + }) + if err != nil { + return err + } + return nil } +*/ func (gc *LocalGitClient) PushBranch(branchName string) (err error) { - branchRefName := plumbing.NewBranchReferenceName(branchName) + //branchRefName := plumbing.NewBranchReferenceName(branchName) remoteName := "origin" // Push the new branch to the remote repository @@ -86,7 +107,9 @@ func (gc *LocalGitClient) PushBranch(branchName string) (err error) { } err = remote.Push(&git.PushOptions{ - RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branchRefName, branchName))}, + RemoteName: remoteName, + // TODO remove hardcoded "main" + RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", "main", branchName))}, Auth: &http.BasicAuth{ Username: gc.self.Handle, Password: gc.self.Token, @@ -146,9 +169,13 @@ func (gc *LocalGitClient) ReplaceOrAddLocalFile(newFile llm.File) error { if strings.HasSuffix(newFile.Path, ".go") { newContents, err := format.Source([]byte(newFile.Contents)) if err != nil { - return err + // TODO also make logger accessible + fmt.Println("go format error") + // TODO handle this error + // return err + } else { + newFile.Contents = string(newContents) } - newFile.Contents = string(newContents) } fullPath := filepath.Join(gc.repo.LocalPath, newFile.Path) diff --git a/vc/github.go b/vc/github.go index 2aa4f7c..3cadafd 100644 --- a/vc/github.go +++ b/vc/github.go @@ -2,25 +2,12 @@ package vc import ( "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" - "go/format" - "io/ioutil" - "os" - "path/filepath" "strconv" - "strings" - "time" "github.com/mobyvb/pull-pal/llm" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/google/go-github/github" "go.uber.org/zap" "golang.org/x/oauth2" @@ -34,8 +21,6 @@ type GithubClient struct { client *github.Client self Author repo Repository - - worktree *git.Worktree } // NewGithubClient initializes a Github client and checks out a repository locally. @@ -50,35 +35,6 @@ func NewGithubClient(ctx context.Context, log *zap.Logger, self Author, repo Rep // oauth client is used to list issues, open pull requests, etc... tc := oauth2.NewClient(ctx, ts) - // clone provided repository to local path - if repo.LocalPath == "" { - return nil, errors.New("local path to clone repository not provided") - } - - if repo.LocalPath != "" { - // remove local repo if it exists already - err := os.RemoveAll(repo.LocalPath) - if err != nil { - return nil, err - } - } - - log.Info("Cloning repository locally...", zap.String("local repo path", repo.LocalPath), zap.String("url", repo.SSH())) - // TODO this can be done in-memory - see https://pkg.go.dev/github.com/go-git/go-git/v5#readme-in-memory-example - localRepo, err := git.PlainClone(repo.LocalPath, false, &git.CloneOptions{ - URL: repo.SSH(), - // URL: repo.HTTPS(), - Auth: &http.BasicAuth{ - Username: self.Handle, - Password: self.Token, - }, - }) - if err != nil { - log.Info("failed") - return nil, err - } - repo.localRepo = localRepo - log.Info("Success. Github client set up.") return &GithubClient{ @@ -91,62 +47,22 @@ 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) (id, url string, err error) { +func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse, fromBranch, toBranch string) (id, url string, err error) { // TODO handle gc.ctx canceled title := req.Subject if title == "" { title = "update files" } - branchName := randomBranchName() - branchRefName := plumbing.NewBranchReferenceName(branchName) - baseBranch := "main" - remoteName := "origin" + body := res.Notes body += fmt.Sprintf("\n\nResolves #%s", req.IssueID) - // Create new local branch - headRef, err := gc.repo.localRepo.Head() - if err != nil { - return "", "", err - } - err = gc.repo.localRepo.CreateBranch(&config.Branch{ - Name: branchName, - Remote: remoteName, - Merge: branchRefName, - }) - if err != nil { - return "", "", err - } - - // Update the branch to point to the new commit - err = gc.repo.localRepo.Storer.SetReference(plumbing.NewHashReference(branchRefName, headRef.Hash())) - if err != nil { - return "", "", err - } - - // Push the new branch to the remote repository - remote, err := gc.repo.localRepo.Remote(remoteName) - if err != nil { - return "", "", err - } - - err = remote.Push(&git.PushOptions{ - RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branchRefName, branchName))}, - Auth: &http.BasicAuth{ - Username: gc.self.Handle, - Password: gc.self.Token, - }, - }) - if err != nil { - return "", "", err - } - // 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: &branchName, - Base: &baseBranch, + Head: &fromBranch, + Base: &toBranch, Body: &body, }) if err != nil { @@ -159,12 +75,6 @@ func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm return id, url, nil } -func randomBranchName() string { - bytes := make([]byte, 4) - rand.Read(bytes) - return hex.EncodeToString(bytes) -} - // ListOpenIssues lists unresolved issues in the Github repository. func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error) { // List and parse GitHub issues @@ -206,6 +116,39 @@ func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error return toReturn, nil } +// CommentOnIssue adds a comment to the issue provided. +func (gc *GithubClient) CommentOnIssue(issueNumber int, comment string) error { + ghComment := &github.IssueComment{ + Body: github.String(comment), + } + + _, _, err := gc.client.Issues.CreateComment(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, issueNumber, ghComment) + + return err +} + +// RemoveLabelFromIssue removes the provided label from an issue if that label is applied. +func (gc *GithubClient) RemoveLabelFromIssue(issueNumber int, label string) error { + hasLabel := false + labels, _, err := gc.client.Issues.ListLabelsByIssue(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, issueNumber, nil) + if err != nil { + return err + } + for _, l := range labels { + if l.GetName() == label { + hasLabel = true + break + } + } + + if hasLabel { + _, err = gc.client.Issues.RemoveLabelForIssue(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, issueNumber, label) + return err + } + + return nil +} + // ListOpenComments lists unresolved comments in the Github repository. func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, error) { toReturn := []Comment{} @@ -250,91 +193,3 @@ func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, return toReturn, 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) - - data, err := ioutil.ReadFile(fullPath) - if err != nil { - // if file doesn't exist, just return an empty file - // this means we want to prompt the llm to populate it for the first time - if errors.Is(err, os.ErrNotExist) { - return llm.File{ - Path: path, - Contents: "", - }, nil - } - return llm.File{}, err - } - - return llm.File{ - Path: path, - Contents: string(data), - }, nil -} - -// StartCommit creates a new worktree associated with this Github client. -func (gc *GithubClient) StartCommit() error { - if gc.worktree != nil { - return errors.New("worktree is not nil - cannot start a new commit") - } - - worktree, err := gc.repo.localRepo.Worktree() - if err != nil { - return err - } - - gc.worktree = worktree - - return nil -} - -// ReplaceOrAddLocalFile updates or adds a file in the locally cloned repo, and applies these changes to the current git worktree. -func (gc *GithubClient) ReplaceOrAddLocalFile(newFile llm.File) error { - if gc.worktree == nil { - return errors.New("worktree is nil - StartCommit must be called") - } - - // TODO format non-go files as well - if strings.HasSuffix(newFile.Path, ".go") { - newContents, err := format.Source([]byte(newFile.Contents)) - if err != nil { - return err - } - newFile.Contents = string(newContents) - } - - fullPath := filepath.Join(gc.repo.LocalPath, newFile.Path) - - err := ioutil.WriteFile(fullPath, []byte(newFile.Contents), 0644) - if err != nil { - return err - } - - _, err = gc.worktree.Add(newFile.Path) - - return err -} - -// FinishCommit completes a commit, after which a code change request can be opened or updated. -func (gc *GithubClient) FinishCommit(message string) error { - if gc.worktree == nil { - return errors.New("worktree is nil - StartCommit must be called") - } - _, err := gc.worktree.Commit(message, &git.CommitOptions{ - Author: &object.Signature{ - Name: gc.self.Handle, - Email: gc.self.Email, - When: time.Now(), - }, - }) - if err != nil { - return err - } - - // set worktree to nil so a new commit can be started - gc.worktree = nil - - return nil -}