A CLI for tangled.sh

pr: implement creating prs

authored by rockorager.dev and committed by Tangled 01a9b80b ac193da6

Changed files
+239
cmd
knit
git
pr
+2
cmd/knit/main.go
··· 8 "github.com/spf13/cobra" 9 "tangled.sh/rockorager.dev/knit/auth" 10 "tangled.sh/rockorager.dev/knit/config" 11 "tangled.sh/rockorager.dev/knit/repo" 12 ) 13 ··· 18 } 19 20 root.AddCommand(auth.Command()) 21 root.AddCommand(repo.Command()) 22 23 if err := config.LoadOrCreate(); err != nil {
··· 8 "github.com/spf13/cobra" 9 "tangled.sh/rockorager.dev/knit/auth" 10 "tangled.sh/rockorager.dev/knit/config" 11 + "tangled.sh/rockorager.dev/knit/pr" 12 "tangled.sh/rockorager.dev/knit/repo" 13 ) 14 ··· 19 } 20 21 root.AddCommand(auth.Command()) 22 + root.AddCommand(pr.Command()) 23 root.AddCommand(repo.Command()) 24 25 if err := config.LoadOrCreate(); err != nil {
+94
git/git.go
···
··· 1 + package git 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "net/url" 7 + "os/exec" 8 + "strings" 9 + ) 10 + 11 + type Remote = struct { 12 + Host string 13 + Path string 14 + } 15 + 16 + // Remotes gets the configured git remotes for the current working directory 17 + func Remotes() ([]*Remote, error) { 18 + cmd := exec.Command("git", "remote", "--verbose") 19 + 20 + output, err := cmd.Output() 21 + if err != nil { 22 + return nil, err 23 + } 24 + 25 + remotes := []*Remote{} 26 + 27 + scanner := bufio.NewScanner(strings.NewReader(string(output))) 28 + outer: 29 + for scanner.Scan() { 30 + line := scanner.Text() 31 + fields := strings.Fields(line) 32 + if len(fields) != 3 { 33 + continue 34 + } 35 + u, err := normalizeGitURL(fields[1]) 36 + if err != nil { 37 + continue 38 + } 39 + 40 + for _, remote := range remotes { 41 + if remote.Host == u.Host && remote.Path == u.Path { 42 + continue outer 43 + } 44 + } 45 + 46 + remotes = append(remotes, u) 47 + 48 + } 49 + 50 + return remotes, nil 51 + } 52 + 53 + func normalizeGitURL(raw string) (*Remote, error) { 54 + // Handle SSH-style: git@github.com:user/repo.git 55 + if at := strings.Index(raw, "@"); at != -1 { 56 + colon := strings.Index(raw, ":") 57 + if colon == -1 || colon < at { 58 + return nil, fmt.Errorf("invalid SSH Git URL: %s", raw) 59 + } 60 + 61 + host := raw[at+1 : colon] 62 + path := raw[colon+1:] 63 + return &Remote{ 64 + Host: host, 65 + Path: path, 66 + }, nil 67 + } 68 + 69 + // Handle HTTPS-style 70 + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { 71 + u, err := url.Parse(raw) 72 + if err != nil { 73 + return nil, fmt.Errorf("invalid URL: %w", err) 74 + } 75 + 76 + return &Remote{ 77 + Host: u.Host, 78 + Path: strings.TrimPrefix(u.Path, "/"), 79 + }, nil 80 + } 81 + 82 + return nil, fmt.Errorf("unsupported Git URL format: %s", raw) 83 + } 84 + 85 + func FormatPatch(revRange string) (string, error) { 86 + cmd := exec.Command("git", "format-patch", "--stdout", revRange) 87 + 88 + output, err := cmd.Output() 89 + if err != nil { 90 + return "", err 91 + } 92 + 93 + return string(output), nil 94 + }
+123
pr/create.go
···
··· 1 + package pr 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "path" 10 + "strings" 11 + 12 + "github.com/charmbracelet/huh" 13 + "github.com/spf13/cobra" 14 + "github.com/zalando/go-keyring" 15 + "tangled.sh/rockorager.dev/knit" 16 + "tangled.sh/rockorager.dev/knit/auth" 17 + "tangled.sh/rockorager.dev/knit/config" 18 + "tangled.sh/rockorager.dev/knit/git" 19 + ) 20 + 21 + func Create(cmd *cobra.Command, args []string) error { 22 + if len(args) != 1 { 23 + return fmt.Errorf("invalid args: %s", args) 24 + } 25 + 26 + handle := config.DefaultHandleForHost(knit.DefaultHost) 27 + if handle == "" { 28 + return knit.ErrRequiresAuth 29 + } 30 + 31 + client, err := auth.NewClient(knit.DefaultHost, handle) 32 + if errors.Is(err, keyring.ErrNotFound) { 33 + return knit.ErrRequiresAuth 34 + } 35 + _ = client 36 + 37 + // Get available remotes 38 + remotes, err := git.Remotes() 39 + if err != nil { 40 + return err 41 + } 42 + 43 + if len(remotes) == 0 { 44 + return fmt.Errorf("no configured remotes") 45 + } 46 + 47 + remote := remotes[0] 48 + if len(remotes) > 1 { 49 + options := make([]huh.Option[*git.Remote], 0, len(remotes)) 50 + for _, remote := range remotes { 51 + p := path.Join(remote.Host, remote.Path) 52 + options = append(options, huh.NewOption(p, remote)) 53 + } 54 + huh.NewSelect[*git.Remote](). 55 + Title("Select a remote to create a pull request"). 56 + Value(&remote). 57 + Options(options...). 58 + Run() 59 + } 60 + 61 + patch, err := git.FormatPatch(args[0]) 62 + if err != nil { 63 + return err 64 + } 65 + 66 + targetBranch := "main" 67 + huh.NewInput(). 68 + Title("Target branch"). 69 + Placeholder("main"). 70 + Value(&targetBranch).Run() 71 + 72 + var title string 73 + huh.NewInput(). 74 + Title("Pull Request Title"). 75 + Placeholder("(optional)"). 76 + Value(&title).Run() 77 + 78 + var description string 79 + huh.NewInput(). 80 + Title("Pull Request Description"). 81 + Placeholder("(optional)"). 82 + Value(&description).Run() 83 + 84 + form := url.Values{} 85 + form.Add("title", title) 86 + form.Add("body", description) 87 + form.Add("targetBranch", targetBranch) 88 + form.Add("patch", patch) 89 + 90 + p := remote.Path 91 + if !strings.HasPrefix(p, "@") { 92 + p = "@" + p 93 + } 94 + 95 + u := url.URL{ 96 + Scheme: "https", 97 + Host: remote.Host, 98 + Path: path.Join(p, "/pulls/new"), 99 + } 100 + 101 + resp, err := client.Post( 102 + u.String(), 103 + "application/x-www-form-urlencoded", 104 + strings.NewReader(form.Encode()), 105 + ) 106 + if err != nil { 107 + return err 108 + } 109 + defer resp.Body.Close() 110 + 111 + if resp.StatusCode != http.StatusOK { 112 + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 113 + } 114 + 115 + b, err := io.ReadAll(resp.Body) 116 + if err != nil { 117 + return err 118 + } 119 + 120 + fmt.Println(string(b)) 121 + 122 + return nil 123 + }
+20
pr/pr.go
···
··· 1 + package pr 2 + 3 + import "github.com/spf13/cobra" 4 + 5 + func Command() *cobra.Command { 6 + pr := &cobra.Command{ 7 + Use: "pr", 8 + Short: "Interact with PRs", 9 + Run: func(cmd *cobra.Command, args []string) { 10 + cmd.Usage() 11 + }, 12 + } 13 + 14 + pr.AddCommand(&cobra.Command{ 15 + Use: "create", 16 + RunE: Create, 17 + }) 18 + 19 + return pr 20 + }