+2
cmd/knit/main.go
+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/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
+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
+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
+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
+
}