A CLI for tangled.sh
1package pr
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "os"
10 "os/exec"
11 "path"
12 "strings"
13 "sync"
14
15 "github.com/charmbracelet/huh"
16 "github.com/charmbracelet/huh/spinner"
17 "github.com/spf13/cobra"
18 "github.com/zalando/go-keyring"
19 "tangled.sh/rockorager.dev/knit"
20 "tangled.sh/rockorager.dev/knit/auth"
21 "tangled.sh/rockorager.dev/knit/config"
22 "tangled.sh/rockorager.dev/knit/git"
23)
24
25func Create(cmd *cobra.Command, args []string) error {
26 if len(args) != 1 {
27 return fmt.Errorf("invalid args: %s", args)
28 }
29
30 handle := config.DefaultHandleForHost(knit.DefaultHost)
31 if handle == "" {
32 return knit.ErrRequiresAuth
33 }
34
35 client, err := auth.NewClient(knit.DefaultHost, handle)
36 if errors.Is(err, keyring.ErrNotFound) {
37 return knit.ErrRequiresAuth
38 }
39 _ = client
40
41 paths, err := git.FormatPatchToTmp(args[0])
42 if err != nil {
43 return err
44 }
45
46 editor := exec.Command(knit.Editor(), paths...)
47 editor.Stdout = os.Stdout
48 editor.Stderr = os.Stderr
49 editor.Stdin = os.Stdin
50 if err := editor.Run(); err != nil {
51 return err
52 }
53
54 // Combine the result into a single slice
55 var patch []byte
56 for _, p := range paths {
57 b, err := os.ReadFile(p)
58 if err != nil {
59 return err
60 }
61 patch = append(patch, b...)
62 }
63
64 // Get available remotes
65 remotes, err := git.Remotes()
66 if err != nil {
67 return err
68 }
69
70 if len(remotes) == 0 {
71 return fmt.Errorf("no configured remotes")
72 }
73
74 remote := remotes[0]
75 if len(remotes) > 1 {
76 options := make([]huh.Option[*git.Remote], 0, len(remotes))
77 for _, remote := range remotes {
78 p := path.Join(remote.Host, remote.Path)
79 options = append(options, huh.NewOption(p, remote))
80 }
81 huh.NewSelect[*git.Remote]().
82 Title("Select a remote to create a pull request").
83 Value(&remote).
84 Options(options...).
85 Run()
86 }
87
88 branches, err := git.RemoteBranches(remote.Name)
89 if err != nil {
90 return err
91 }
92
93 options := make([]huh.Option[string], 0, len(branches))
94 for _, branch := range branches {
95 options = append(options, huh.NewOption(branch, branch))
96 }
97
98 var (
99 targetBranch string
100 title string
101 description string
102 )
103
104 if err := huh.NewSelect[string]().
105 Title("Target branch").
106 Options(options...).
107 Value(&targetBranch).Run(); err != nil {
108 return err
109 }
110
111 if err := huh.NewInput().
112 Title("Title").
113 Placeholder("(optional)").
114 Value(&title).Run(); err != nil {
115 return err
116 }
117
118 if err := huh.NewText().
119 Title("Description").
120 Placeholder("(optional)").
121 Value(&description).Run(); err != nil {
122 return err
123 }
124
125 var submit bool
126 if err := huh.NewConfirm().
127 Title(fmt.Sprintf("Submit PR to %s/%s?", remote.Host, remote.Path)).
128 Value(&submit).
129 Run(); err != nil {
130 return err
131 }
132
133 if !submit {
134 return fmt.Errorf("PR submission canceled")
135 }
136
137 form := url.Values{}
138 form.Add("title", title)
139 form.Add("body", description)
140 form.Add("targetBranch", targetBranch)
141 form.Add("patch", string(patch))
142
143 p := remote.Path
144 if !strings.HasPrefix(p, "@") {
145 p = "@" + p
146 }
147
148 u := url.URL{
149 Scheme: "https",
150 Host: remote.Host,
151 Path: path.Join(p, "/pulls/new"),
152 }
153
154 ctx, stop := context.WithCancel(cmd.Context())
155 defer stop()
156 wg := &sync.WaitGroup{}
157 wg.Add(1)
158 go func(wg *sync.WaitGroup) {
159 spinner.New().
160 Context(ctx).
161 Title("Creating PR...").
162 Run()
163 wg.Done()
164 }(wg)
165
166 resp, err := client.Post(
167 u.String(),
168 "application/x-www-form-urlencoded",
169 strings.NewReader(form.Encode()),
170 )
171 if err != nil {
172 return err
173 }
174 defer resp.Body.Close()
175 auth.SaveCookies(resp.Cookies(), knit.DefaultHost, handle)
176
177 if resp.StatusCode != http.StatusOK {
178 return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
179 }
180
181 stop()
182 wg.Wait()
183 fmt.Printf("\x1b[32m✔\x1b[m PR created!\r\n")
184
185 return nil
186}