mirror of
https://github.com/Pull-Pal/pull-pal.git
synced 2024-12-22 01:56:26 -05:00
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:
parent
3a312b2b9b
commit
cbec4c1be9
@ -6,9 +6,9 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var runCmd = &cobra.Command{
|
var debugGitCmd = &cobra.Command{
|
||||||
Use: "run",
|
Use: "debug-git",
|
||||||
Short: "Runs a fully automated pull pal service",
|
Short: "debug git functionality",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
cfg := getConfig()
|
cfg := getConfig()
|
||||||
|
|
||||||
@ -19,14 +19,14 @@ var runCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
fmt.Println("Successfully initialized pull pal")
|
fmt.Println("Successfully initialized pull pal")
|
||||||
|
|
||||||
err = p.Run()
|
err = p.DebugGit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("error running", err)
|
fmt.Println("err debugging git", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(runCmd)
|
rootCmd.AddCommand(debugGitCmd)
|
||||||
}
|
}
|
@ -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)
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
87
cmd/root.go
87
cmd/root.go
@ -2,11 +2,9 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mobyvb/pull-pal/llm"
|
|
||||||
"github.com/mobyvb/pull-pal/pullpal"
|
"github.com/mobyvb/pull-pal/pullpal"
|
||||||
"github.com/mobyvb/pull-pal/vc"
|
"github.com/mobyvb/pull-pal/vc"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@ -30,8 +28,6 @@ type config struct {
|
|||||||
|
|
||||||
// local paths
|
// local paths
|
||||||
localRepoPath string
|
localRepoPath string
|
||||||
promptPath string
|
|
||||||
responsePath string
|
|
||||||
|
|
||||||
// program settings
|
// program settings
|
||||||
promptToClipboard bool
|
promptToClipboard bool
|
||||||
@ -51,8 +47,6 @@ func getConfig() config {
|
|||||||
repoName: viper.GetString("repo-name"),
|
repoName: viper.GetString("repo-name"),
|
||||||
|
|
||||||
localRepoPath: viper.GetString("local-repo-path"),
|
localRepoPath: viper.GetString("local-repo-path"),
|
||||||
promptPath: viper.GetString("prompt-path"),
|
|
||||||
responsePath: viper.GetString("response-path"),
|
|
||||||
|
|
||||||
promptToClipboard: viper.GetBool("prompt-to-clipboard"),
|
promptToClipboard: viper.GetBool("prompt-to-clipboard"),
|
||||||
usersToListenTo: viper.GetStringSlice("users-to-listen-to"),
|
usersToListenTo: viper.GetStringSlice("users-to-listen-to"),
|
||||||
@ -61,6 +55,7 @@ func getConfig() config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getPullPal(ctx context.Context, cfg config) (*pullpal.PullPal, error) {
|
func getPullPal(ctx context.Context, cfg config) (*pullpal.PullPal, error) {
|
||||||
|
// TODO figure out debug logging
|
||||||
log, err := zap.NewProduction()
|
log, err := zap.NewProduction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
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
|
// rootCmd represents the base command when called without any subcommands
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "pull-pal",
|
Short: "run an automated digital assitant to monitor and make code changes to a github repository",
|
||||||
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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
cfg := getConfig()
|
cfg := getConfig()
|
||||||
|
|
||||||
@ -111,64 +97,11 @@ It can be used to:
|
|||||||
}
|
}
|
||||||
fmt.Println("Successfully initialized pull pal")
|
fmt.Println("Successfully initialized pull pal")
|
||||||
|
|
||||||
// TODO this loop breaks on the second iteration due to a weird git state or something
|
err = p.Run()
|
||||||
for {
|
if err != nil {
|
||||||
var input string
|
fmt.Println("error running", err)
|
||||||
fmt.Println("Press 'enter' when ready to select issue. Type 'exit' to exit.")
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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-handle", "o", "REPO-HANDLE", "handle of repository's owner on version control server")
|
||||||
rootCmd.PersistentFlags().StringP("repo-name", "n", "REPO-NAME", "name of repository on version control server")
|
rootCmd.PersistentFlags().StringP("repo-name", "n", "REPO-NAME", "name of repository on version control server")
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringP("local-repo-path", "l", "/tmp/pullpalrepo/", "path where pull pal will check out a local copy of the repository")
|
rootCmd.PersistentFlags().StringP("local-repo-path", "l", "/tmp/pullpallrepo", "local path to check out ephemeral repository in")
|
||||||
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("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")
|
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("repo-name", rootCmd.PersistentFlags().Lookup("repo-name"))
|
||||||
|
|
||||||
viper.BindPFlag("local-repo-path", rootCmd.PersistentFlags().Lookup("local-repo-path"))
|
viper.BindPFlag("local-repo-path", rootCmd.PersistentFlags().Lookup("local-repo-path"))
|
||||||
viper.BindPFlag("prompt-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("users-to-listen-to", rootCmd.PersistentFlags().Lookup("users-to-listen-to"))
|
||||||
viper.BindPFlag("required-issue-labels", rootCmd.PersistentFlags().Lookup("required-issue-labels"))
|
viper.BindPFlag("required-issue-labels", rootCmd.PersistentFlags().Lookup("required-issue-labels"))
|
||||||
}
|
}
|
||||||
|
1
go.mod
1
go.mod
@ -3,7 +3,6 @@ module github.com/mobyvb/pull-pal
|
|||||||
go 1.20
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4
|
|
||||||
github.com/go-git/go-git/v5 v5.6.1
|
github.com/go-git/go-git/v5 v5.6.1
|
||||||
github.com/google/go-github v17.0.0+incompatible
|
github.com/google/go-github v17.0.0+incompatible
|
||||||
github.com/sashabaranov/go-openai v1.9.0
|
github.com/sashabaranov/go-openai v1.9.0
|
||||||
|
2
go.sum
2
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/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 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
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/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
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=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
@ -37,7 +37,7 @@ func (req CodeChangeRequest) MustGetPrompt() string {
|
|||||||
|
|
||||||
// GetPrompt converts the information in the request to a prompt for an LLM.
|
// GetPrompt converts the information in the request to a prompt for an LLM.
|
||||||
func (req CodeChangeRequest) GetPrompt() (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -72,10 +72,16 @@ func (res CodeChangeResponse) String() string {
|
|||||||
|
|
||||||
// ParseCodeChangeResponse parses the LLM's response to CodeChangeRequest (string) into a CodeChangeResponse.
|
// ParseCodeChangeResponse parses the LLM's response to CodeChangeRequest (string) into a CodeChangeResponse.
|
||||||
func ParseCodeChangeResponse(llmResponse string) CodeChangeResponse {
|
func ParseCodeChangeResponse(llmResponse string) CodeChangeResponse {
|
||||||
sections := strings.Split(llmResponse, "Notes:")
|
sections := strings.Split(llmResponse, "ppnotes:")
|
||||||
|
|
||||||
filesSection := sections[0]
|
filesSection := ""
|
||||||
notes := strings.TrimSpace(sections[1])
|
if len(sections) > 0 {
|
||||||
|
filesSection = sections[0]
|
||||||
|
}
|
||||||
|
notes := ""
|
||||||
|
if len(sections) > 1 {
|
||||||
|
notes = strings.TrimSpace(sections[1])
|
||||||
|
}
|
||||||
|
|
||||||
files := parseFiles(filesSection)
|
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.
|
// parseFiles process the "files" subsection of the LLM's response. It is a helper for GetCodeChangeResponse.
|
||||||
func parseFiles(filesSection string) []File {
|
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:"
|
// first item in the list is just gonna be "Files:"
|
||||||
fileStringList = fileStringList[1:]
|
fileStringList = fileStringList[1:]
|
||||||
|
|
||||||
@ -98,7 +107,10 @@ func parseFiles(filesSection string) []File {
|
|||||||
)
|
)
|
||||||
fileList := make([]File, len(fileStringList))
|
fileList := make([]File, len(fileStringList))
|
||||||
for i, f := range 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 := replacer.Replace(fileParts[0])
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
|
|
||||||
|
@ -23,10 +23,11 @@ func (oc *OpenAIClient) EvaluateCCR(ctx context.Context, req CodeChangeRequest)
|
|||||||
resp, err := oc.client.CreateChatCompletion(
|
resp, err := oc.client.CreateChatCompletion(
|
||||||
ctx,
|
ctx,
|
||||||
openai.ChatCompletionRequest{
|
openai.ChatCompletionRequest{
|
||||||
Model: openai.GPT3Dot5Turbo,
|
// TODO make model configurable
|
||||||
|
Model: openai.GPT4,
|
||||||
|
//Model: openai.GPT3Dot5Turbo,
|
||||||
Messages: []openai.ChatCompletionMessage{
|
Messages: []openai.ChatCompletionMessage{
|
||||||
{
|
{
|
||||||
// TODO is this the correct role for my prompts?
|
|
||||||
Role: openai.ChatMessageRoleUser,
|
Role: openai.ChatMessageRoleUser,
|
||||||
Content: req.String(),
|
Content: req.String(),
|
||||||
},
|
},
|
||||||
@ -38,10 +39,10 @@ func (oc *OpenAIClient) EvaluateCCR(ctx context.Context, req CodeChangeRequest)
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO use different choices/different options in different branches/worktrees?
|
|
||||||
choice := resp.Choices[0].Message.Content
|
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
|
return ParseCodeChangeResponse(choice), nil
|
||||||
}
|
}
|
||||||
|
@ -15,10 +15,10 @@ Body:
|
|||||||
Respond in the exact format:
|
Respond in the exact format:
|
||||||
Files:
|
Files:
|
||||||
{{ range $index, $file := .Files }}
|
{{ range $index, $file := .Files }}
|
||||||
- name: {{ $file.Path }}
|
ppname: {{ $file.Path }}
|
||||||
contents:
|
ppcontents:
|
||||||
[new {{ $file.Path }} contents]
|
[new {{ $file.Path }} contents]
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
Notes:
|
ppnotes:
|
||||||
[additional context about your changes]
|
[additional context about your changes]
|
@ -24,9 +24,9 @@ Q
|
|||||||
Response Template B:
|
Response Template B:
|
||||||
R
|
R
|
||||||
Files:
|
Files:
|
||||||
- name: {{ .Path }}
|
ppname: {{ .Path }}
|
||||||
contents:
|
ppcontents:
|
||||||
[new {{ .Path }} contents]
|
[new {{ .Path }} contents]
|
||||||
|
|
||||||
Response:
|
ppresponse:
|
||||||
[additional context about your changes]
|
[additional context about your changes]
|
@ -4,14 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mobyvb/pull-pal/llm"
|
"github.com/mobyvb/pull-pal/llm"
|
||||||
"github.com/mobyvb/pull-pal/vc"
|
"github.com/mobyvb/pull-pal/vc"
|
||||||
|
|
||||||
"github.com/atotto/clipboard"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -19,16 +18,16 @@ import (
|
|||||||
var IssueNotFound = errors.New("no issue found")
|
var IssueNotFound = errors.New("no issue found")
|
||||||
|
|
||||||
// PullPal is the service responsible for:
|
// PullPal is the service responsible for:
|
||||||
// * Interacting with git server (e.g. reading issues and making PRs on Github)
|
// - Interacting with git server (e.g. reading issues and making PRs on Github)
|
||||||
// * Generating LLM prompts
|
// - Generating LLM prompts
|
||||||
// * Parsing LLM responses
|
// - Parsing LLM responses
|
||||||
// * Interacting with LLM (e.g. with GPT via OpenAI API)
|
// - Interacting with LLM (e.g. with GPT via OpenAI API)
|
||||||
type PullPal struct {
|
type PullPal struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
listIssueOptions vc.ListIssueOptions
|
listIssueOptions vc.ListIssueOptions
|
||||||
|
|
||||||
vcClient vc.VCClient
|
ghClient *vc.GithubClient
|
||||||
localGitClient *vc.LocalGitClient
|
localGitClient *vc.LocalGitClient
|
||||||
openAIClient *llm.OpenAIClient
|
openAIClient *llm.OpenAIClient
|
||||||
}
|
}
|
||||||
@ -39,7 +38,7 @@ func NewPullPal(ctx context.Context, log *zap.Logger, listIssueOptions vc.ListIs
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
localGitClient, err := vc.NewLocalGitClient(self, repo)
|
localGitClient, err := vc.NewLocalGitClient(log, self, repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -49,7 +48,7 @@ func NewPullPal(ctx context.Context, log *zap.Logger, listIssueOptions vc.ListIs
|
|||||||
log: log,
|
log: log,
|
||||||
listIssueOptions: listIssueOptions,
|
listIssueOptions: listIssueOptions,
|
||||||
|
|
||||||
vcClient: ghClient,
|
ghClient: ghClient,
|
||||||
localGitClient: localGitClient,
|
localGitClient: localGitClient,
|
||||||
openAIClient: llm.NewOpenAIClient(log.Named("openaiClient"), openAIToken),
|
openAIClient: llm.NewOpenAIClient(log.Named("openaiClient"), openAIToken),
|
||||||
}, nil
|
}, nil
|
||||||
@ -60,20 +59,38 @@ func (p *PullPal) Run() error {
|
|||||||
p.log.Info("Starting Pull Pal")
|
p.log.Info("Starting Pull Pal")
|
||||||
// TODO gracefully handle context cancelation
|
// TODO gracefully handle context cancelation
|
||||||
for {
|
for {
|
||||||
issues, err := p.vcClient.ListOpenIssues(p.listIssueOptions)
|
issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Error("error listing issues", zap.Error(err))
|
p.log.Error("error listing issues", zap.Error(err))
|
||||||
continue
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
// todo don't sleep
|
// todo don't sleep
|
||||||
p.log.Info("no issues found. sleeping for 5 mins")
|
p.log.Info("no issues found. sleeping for 30 seconds")
|
||||||
time.Sleep(5 * time.Minute)
|
time.Sleep(30 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
issue := issues[0]
|
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
|
// remove file list from issue body
|
||||||
// TODO do this better and probably somewhere else
|
// TODO do this better and probably somewhere else
|
||||||
@ -89,7 +106,7 @@ func (p *PullPal) Run() error {
|
|||||||
files := []llm.File{}
|
files := []llm.File{}
|
||||||
for _, path := range fileList {
|
for _, path := range fileList {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
nextFile, err := p.vcClient.GetLocalFile(path)
|
nextFile, err := p.localGitClient.GetLocalFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Error("error getting file from vcclient", zap.Error(err))
|
p.log.Error("error getting file from vcclient", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
@ -115,40 +132,63 @@ func (p *PullPal) Run() error {
|
|||||||
//codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse)
|
//codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse)
|
||||||
|
|
||||||
// create commit with file changes
|
// create commit with file changes
|
||||||
err = p.vcClient.StartCommit()
|
err = p.localGitClient.StartCommit()
|
||||||
|
//err = p.ghClient.StartCommit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Error("error starting commit", zap.Error(err))
|
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 {
|
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 {
|
if err != nil {
|
||||||
p.log.Error("error replacing or adding file", zap.Error(err))
|
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
|
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 {
|
if err != nil {
|
||||||
p.log.Error("error finshing commit", zap.Error(err))
|
p.log.Error("error finishing commit", zap.Error(err))
|
||||||
continue
|
// 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
|
// 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 {
|
if err != nil {
|
||||||
p.log.Error("error opening PR", zap.Error(err))
|
p.log.Error("error opening PR", zap.Error(err))
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
p.log.Info("successfully created PR", zap.String("URL", url))
|
p.log.Info("successfully created PR", zap.String("URL", url))
|
||||||
|
|
||||||
p.log.Info("going to sleep for five mins")
|
p.log.Info("going to sleep for thirty seconds")
|
||||||
time.Sleep(5 * time.Minute)
|
time.Sleep(30 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
// PickIssueToFile is the same as PickIssue, but the changeRequest is converted to a string and written to a file.
|
// PickIssueToFile is the same as PickIssue, but the changeRequest is converted to a string and written to a file.
|
||||||
func (p *PullPal) PickIssueToFile(promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
|
func (p *PullPal) PickIssueToFile(promptPath string) (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
|
||||||
issue, changeRequest, err = p.PickIssue()
|
issue, changeRequest, err = p.PickIssue()
|
||||||
@ -180,11 +220,12 @@ func (p *PullPal) PickIssueToClipboard() (issue vc.Issue, changeRequest llm.Code
|
|||||||
err = clipboard.WriteAll(prompt)
|
err = clipboard.WriteAll(prompt)
|
||||||
return issue, changeRequest, err
|
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.
|
// 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() (issue vc.Issue, changeRequest llm.CodeChangeRequest, err error) {
|
||||||
// TODO I should be able to pass in settings for listing issues from here
|
// TODO I should be able to pass in settings for listing issues from here
|
||||||
issues, err := p.vcClient.ListOpenIssues(p.listIssueOptions)
|
issues, err := p.ghClient.ListOpenIssues(p.listIssueOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return issue, changeRequest, err
|
return issue, changeRequest, err
|
||||||
}
|
}
|
||||||
@ -209,7 +250,7 @@ func (p *PullPal) PickIssue() (issue vc.Issue, changeRequest llm.CodeChangeReque
|
|||||||
files := []llm.File{}
|
files := []llm.File{}
|
||||||
for _, path := range fileList {
|
for _, path := range fileList {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
nextFile, err := p.vcClient.GetLocalFile(path)
|
nextFile, err := p.ghClient.GetLocalFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return issue, changeRequest, err
|
return issue, changeRequest, err
|
||||||
}
|
}
|
||||||
@ -223,7 +264,8 @@ func (p *PullPal) PickIssue() (issue vc.Issue, changeRequest llm.CodeChangeReque
|
|||||||
|
|
||||||
return issue, changeRequest, nil
|
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.
|
// 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) {
|
func (p *PullPal) ProcessResponseFromFile(codeChangeRequest llm.CodeChangeRequest, llmResponsePath string) (url string, err error) {
|
||||||
data, err := ioutil.ReadFile(llmResponsePath)
|
data, err := ioutil.ReadFile(llmResponsePath)
|
||||||
@ -239,31 +281,33 @@ func (p *PullPal) ProcessResponse(codeChangeRequest llm.CodeChangeRequest, llmRe
|
|||||||
codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse)
|
codeChangeResponse := llm.ParseCodeChangeResponse(llmResponse)
|
||||||
|
|
||||||
// 2. create commit with file changes
|
// 2. create commit with file changes
|
||||||
err = p.vcClient.StartCommit()
|
err = p.ghClient.StartCommit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, f := range codeChangeResponse.Files {
|
for _, f := range codeChangeResponse.Files {
|
||||||
err = p.vcClient.ReplaceOrAddLocalFile(f)
|
err = p.ghClient.ReplaceOrAddLocalFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
commitMessage := codeChangeRequest.Subject + "\n\n" + codeChangeResponse.Notes + "\n\nResolves: #" + codeChangeRequest.IssueID
|
commitMessage := codeChangeRequest.Subject + "\n\n" + codeChangeResponse.Notes + "\n\nResolves: #" + codeChangeRequest.IssueID
|
||||||
err = p.vcClient.FinishCommit(commitMessage)
|
err = p.ghClient.FinishCommit(commitMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. open code change request
|
// 3. open code change request
|
||||||
_, url, err = p.vcClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse)
|
_, url, err = p.ghClient.OpenCodeChangeRequest(codeChangeRequest, codeChangeResponse)
|
||||||
return url, err
|
return url, err
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
// ListIssues gets a list of all issues meeting the provided criteria.
|
// ListIssues gets a list of all issues meeting the provided criteria.
|
||||||
func (p *PullPal) ListIssues(handles, labels []string) ([]vc.Issue, error) {
|
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,
|
Handles: handles,
|
||||||
Labels: labels,
|
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.
|
// 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) {
|
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,
|
ChangeID: changeID,
|
||||||
Handles: handles,
|
Handles: handles,
|
||||||
})
|
})
|
||||||
@ -302,7 +346,7 @@ func (p *PullPal) MakeLocalChange(issue vc.Issue) error {
|
|||||||
files := []llm.File{}
|
files := []llm.File{}
|
||||||
for _, path := range fileList {
|
for _, path := range fileList {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
nextFile, err := p.vcClient.GetLocalFile(path)
|
nextFile, err := p.ghClient.GetLocalFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -326,3 +370,4 @@ func (p *PullPal) MakeLocalChange(issue vc.Issue) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
47
pullpal/debug.go
Normal file
47
pullpal/debug.go
Normal 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
|
||||||
|
}
|
23
vc/common.go
23
vc/common.go
@ -3,8 +3,6 @@ package vc
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mobyvb/pull-pal/llm"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -82,24 +80,3 @@ func (repo Repository) SSH() string {
|
|||||||
func (repo Repository) HTTPS() string {
|
func (repo Repository) HTTPS() string {
|
||||||
return fmt.Sprintf("https://%s/%s/%s.git", repo.HostDomain, repo.Owner.Handle, repo.Name)
|
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
|
|
||||||
}
|
|
||||||
|
51
vc/git.go
51
vc/git.go
@ -11,10 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mobyvb/pull-pal/llm"
|
"github.com/mobyvb/pull-pal/llm"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/config"
|
"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/object"
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
"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.
|
// 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
|
// clone provided repository to local path
|
||||||
if repo.LocalPath == "" {
|
if repo.LocalPath == "" {
|
||||||
return nil, errors.New("local path to clone repository not provided")
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
func (gc *LocalGitClient) SwitchBranch(branchName string) (err error) {
|
func (gc *LocalGitClient) SwitchBranch(branchName string) (err error) {
|
||||||
branchRefName := plumbing.NewBranchReferenceName(branchName)
|
if gc.worktree == nil {
|
||||||
remoteName := "origin"
|
return errors.New("worktree is nil - cannot check out a branch")
|
||||||
|
}
|
||||||
|
|
||||||
err = gc.repo.localRepo.CreateBranch(&config.Branch{
|
branchRefName := plumbing.NewBranchReferenceName(branchName)
|
||||||
Name: branchName,
|
// remoteName := "origin"
|
||||||
Remote: remoteName,
|
|
||||||
Merge: branchRefName,
|
err = gc.repo.localRepo.Fetch(&git.FetchOptions{
|
||||||
|
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
func (gc *LocalGitClient) PushBranch(branchName string) (err error) {
|
func (gc *LocalGitClient) PushBranch(branchName string) (err error) {
|
||||||
branchRefName := plumbing.NewBranchReferenceName(branchName)
|
//branchRefName := plumbing.NewBranchReferenceName(branchName)
|
||||||
remoteName := "origin"
|
remoteName := "origin"
|
||||||
|
|
||||||
// Push the new branch to the remote repository
|
// 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{
|
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{
|
Auth: &http.BasicAuth{
|
||||||
Username: gc.self.Handle,
|
Username: gc.self.Handle,
|
||||||
Password: gc.self.Token,
|
Password: gc.self.Token,
|
||||||
@ -146,9 +169,13 @@ func (gc *LocalGitClient) ReplaceOrAddLocalFile(newFile llm.File) error {
|
|||||||
if strings.HasSuffix(newFile.Path, ".go") {
|
if strings.HasSuffix(newFile.Path, ".go") {
|
||||||
newContents, err := format.Source([]byte(newFile.Contents))
|
newContents, err := format.Source([]byte(newFile.Contents))
|
||||||
if err != nil {
|
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)
|
fullPath := filepath.Join(gc.repo.LocalPath, newFile.Path)
|
||||||
|
219
vc/github.go
219
vc/github.go
@ -2,25 +2,12 @@ package vc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/format"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mobyvb/pull-pal/llm"
|
"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"
|
"github.com/google/go-github/github"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
@ -34,8 +21,6 @@ type GithubClient struct {
|
|||||||
client *github.Client
|
client *github.Client
|
||||||
self Author
|
self Author
|
||||||
repo Repository
|
repo Repository
|
||||||
|
|
||||||
worktree *git.Worktree
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGithubClient initializes a Github client and checks out a repository locally.
|
// 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...
|
// oauth client is used to list issues, open pull requests, etc...
|
||||||
tc := oauth2.NewClient(ctx, ts)
|
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.")
|
log.Info("Success. Github client set up.")
|
||||||
|
|
||||||
return &GithubClient{
|
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.
|
// 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
|
// TODO handle gc.ctx canceled
|
||||||
|
|
||||||
title := req.Subject
|
title := req.Subject
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "update files"
|
title = "update files"
|
||||||
}
|
}
|
||||||
branchName := randomBranchName()
|
|
||||||
branchRefName := plumbing.NewBranchReferenceName(branchName)
|
|
||||||
baseBranch := "main"
|
|
||||||
remoteName := "origin"
|
|
||||||
body := res.Notes
|
body := res.Notes
|
||||||
body += fmt.Sprintf("\n\nResolves #%s", req.IssueID)
|
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.
|
// 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{
|
pr, _, err := gc.client.PullRequests.Create(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, &github.NewPullRequest{
|
||||||
Title: &title,
|
Title: &title,
|
||||||
Head: &branchName,
|
Head: &fromBranch,
|
||||||
Base: &baseBranch,
|
Base: &toBranch,
|
||||||
Body: &body,
|
Body: &body,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -159,12 +75,6 @@ func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm
|
|||||||
return id, url, nil
|
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.
|
// ListOpenIssues lists unresolved issues in the Github repository.
|
||||||
func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error) {
|
func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error) {
|
||||||
// List and parse GitHub issues
|
// List and parse GitHub issues
|
||||||
@ -206,6 +116,39 @@ func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error
|
|||||||
return toReturn, nil
|
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.
|
// ListOpenComments lists unresolved comments in the Github repository.
|
||||||
func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, error) {
|
func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, error) {
|
||||||
toReturn := []Comment{}
|
toReturn := []Comment{}
|
||||||
@ -250,91 +193,3 @@ func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment,
|
|||||||
|
|
||||||
return toReturn, nil
|
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
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user