cleanup/refactor (#1)

focus on fully automated as basic/default functionality. remove unnecessary commands except for local git debug. remove unnecessary interfaces and code. remove local git functionality from github client. probably some other stuff too
This commit is contained in:
Maximillian von Briesen 2023-05-04 20:01:46 -04:00 committed by GitHub
parent 3a312b2b9b
commit cbec4c1be9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 245 additions and 470 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

47
pullpal/debug.go Normal file
View File

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

View File

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

View File

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

View File

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