From 01a9b80bd30a37bf85d4b72d6f68031df8cf9c36 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Fri, 9 May 2025 12:32:56 -0500 Subject: [PATCH] pr: implement creating prs --- cmd/knit/main.go | 2 + git/git.go | 94 ++++++++++++++++++++++++++++++++++++ pr/create.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++ pr/pr.go | 20 ++++++++ 4 files changed, 239 insertions(+) create mode 100644 git/git.go create mode 100644 pr/create.go create mode 100644 pr/pr.go diff --git a/cmd/knit/main.go b/cmd/knit/main.go index c09ad69..54fcd2a 100644 --- a/cmd/knit/main.go +++ b/cmd/knit/main.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "tangled.sh/rockorager.dev/knit/auth" "tangled.sh/rockorager.dev/knit/config" + "tangled.sh/rockorager.dev/knit/pr" "tangled.sh/rockorager.dev/knit/repo" ) @@ -18,6 +19,7 @@ func main() { } root.AddCommand(auth.Command()) + root.AddCommand(pr.Command()) root.AddCommand(repo.Command()) if err := config.LoadOrCreate(); err != nil { diff --git a/git/git.go b/git/git.go new file mode 100644 index 0000000..3817bd7 --- /dev/null +++ b/git/git.go @@ -0,0 +1,94 @@ +package git + +import ( + "bufio" + "fmt" + "net/url" + "os/exec" + "strings" +) + +type Remote = struct { + Host string + Path string +} + +// Remotes gets the configured git remotes for the current working directory +func Remotes() ([]*Remote, error) { + cmd := exec.Command("git", "remote", "--verbose") + + output, err := cmd.Output() + if err != nil { + return nil, err + } + + remotes := []*Remote{} + + scanner := bufio.NewScanner(strings.NewReader(string(output))) +outer: + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) != 3 { + continue + } + u, err := normalizeGitURL(fields[1]) + if err != nil { + continue + } + + for _, remote := range remotes { + if remote.Host == u.Host && remote.Path == u.Path { + continue outer + } + } + + remotes = append(remotes, u) + + } + + return remotes, nil +} + +func normalizeGitURL(raw string) (*Remote, error) { + // Handle SSH-style: git@github.com:user/repo.git + if at := strings.Index(raw, "@"); at != -1 { + colon := strings.Index(raw, ":") + if colon == -1 || colon < at { + return nil, fmt.Errorf("invalid SSH Git URL: %s", raw) + } + + host := raw[at+1 : colon] + path := raw[colon+1:] + return &Remote{ + Host: host, + Path: path, + }, nil + } + + // Handle HTTPS-style + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + return &Remote{ + Host: u.Host, + Path: strings.TrimPrefix(u.Path, "/"), + }, nil + } + + return nil, fmt.Errorf("unsupported Git URL format: %s", raw) +} + +func FormatPatch(revRange string) (string, error) { + cmd := exec.Command("git", "format-patch", "--stdout", revRange) + + output, err := cmd.Output() + if err != nil { + return "", err + } + + return string(output), nil +} diff --git a/pr/create.go b/pr/create.go new file mode 100644 index 0000000..9d9ec3c --- /dev/null +++ b/pr/create.go @@ -0,0 +1,123 @@ +package pr + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "github.com/zalando/go-keyring" + "tangled.sh/rockorager.dev/knit" + "tangled.sh/rockorager.dev/knit/auth" + "tangled.sh/rockorager.dev/knit/config" + "tangled.sh/rockorager.dev/knit/git" +) + +func Create(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("invalid args: %s", args) + } + + handle := config.DefaultHandleForHost(knit.DefaultHost) + if handle == "" { + return knit.ErrRequiresAuth + } + + client, err := auth.NewClient(knit.DefaultHost, handle) + if errors.Is(err, keyring.ErrNotFound) { + return knit.ErrRequiresAuth + } + _ = client + + // Get available remotes + remotes, err := git.Remotes() + if err != nil { + return err + } + + if len(remotes) == 0 { + return fmt.Errorf("no configured remotes") + } + + remote := remotes[0] + if len(remotes) > 1 { + options := make([]huh.Option[*git.Remote], 0, len(remotes)) + for _, remote := range remotes { + p := path.Join(remote.Host, remote.Path) + options = append(options, huh.NewOption(p, remote)) + } + huh.NewSelect[*git.Remote](). + Title("Select a remote to create a pull request"). + Value(&remote). + Options(options...). + Run() + } + + patch, err := git.FormatPatch(args[0]) + if err != nil { + return err + } + + targetBranch := "main" + huh.NewInput(). + Title("Target branch"). + Placeholder("main"). + Value(&targetBranch).Run() + + var title string + huh.NewInput(). + Title("Pull Request Title"). + Placeholder("(optional)"). + Value(&title).Run() + + var description string + huh.NewInput(). + Title("Pull Request Description"). + Placeholder("(optional)"). + Value(&description).Run() + + form := url.Values{} + form.Add("title", title) + form.Add("body", description) + form.Add("targetBranch", targetBranch) + form.Add("patch", patch) + + p := remote.Path + if !strings.HasPrefix(p, "@") { + p = "@" + p + } + + u := url.URL{ + Scheme: "https", + Host: remote.Host, + Path: path.Join(p, "/pulls/new"), + } + + resp, err := client.Post( + u.String(), + "application/x-www-form-urlencoded", + strings.NewReader(form.Encode()), + ) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + fmt.Println(string(b)) + + return nil +} diff --git a/pr/pr.go b/pr/pr.go new file mode 100644 index 0000000..170e646 --- /dev/null +++ b/pr/pr.go @@ -0,0 +1,20 @@ +package pr + +import "github.com/spf13/cobra" + +func Command() *cobra.Command { + pr := &cobra.Command{ + Use: "pr", + Short: "Interact with PRs", + Run: func(cmd *cobra.Command, args []string) { + cmd.Usage() + }, + } + + pr.AddCommand(&cobra.Command{ + Use: "create", + RunE: Create, + }) + + return pr +} -- 2.51.2