1
# okg — Oscar Kilo Git CLI
2
3
A `gh`-style command-line tool for interacting with
4
[klee](https://code.oscarkilo.com), the Oscar Kilo git server.
5
6
Designed for both humans and AI agents (Claude, OpenClaw) to
7
use directly from the command line.
8
9
## Install
10
11
```bash
12
go install oscarkilo.com/okg@latest
13
```
14
15
Or build from source:
16
17
```bash
18
git clone https://code.oscarkilo.com/okg
19
cd okg && go build .
20
```
21
22
## Setup
23
24
```bash
25
# Interactive login (saves to ~/.config/okg/config.json)
26
okg auth login
27
28
# With flags
29
okg auth login --host https://code.oscarkilo.com --user igor
30
31
# Or use environment variables
32
export OKG_HOST=https://code.oscarkilo.com
33
export KLEX_API_KEY=your-api-key
34
```
35
36
## Commands
37
38
```
39
okg repo list
40
41
okg pr list [--state open|closed]
42
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
43
okg pr view NUMBER
44
okg pr diff NUMBER
45
okg pr comment NUMBER --body BODY [--approve | --request-changes]
46
okg pr merge NUMBER
47
okg pr close NUMBER
48
okg pr reopen NUMBER
49
50
okg auth login [--host HOST] [--user USERNAME]
51
```
52
53
### Flags
54
55
- `--repo REPO` overrides auto-detected repo name
56
(normally parsed from `git remote get-url origin`)
57
- `--json` outputs raw JSON for any command
58
- `OKG_REPO` env var also overrides repo detection
59
60
## Repo Detection
61
62
Like `gh`, okg detects the repo from the current directory's
63
git remote:
64
65
```
66
git remote get-url origin
67
→ https://code.oscarkilo.com/widget.git
68
→ repo = "widget"
69
```
70
71
## Dependencies
72
73
None beyond the Go standard library.
1
package main
2
3
import "bufio"
4
import "fmt"
5
import "os"
6
import "strings"
7
8
func runAuth(args []string) error {
9
if len(args) == 0 {
10
return fmt.Errorf("usage: okg auth login")
11
}
12
switch args[0] {
13
case "login":
14
return runAuthLogin(args[1:])
15
default:
16
return fmt.Errorf("unknown auth command: %s", args[0])
17
}
18
}
19
20
func runAuthLogin(args []string) error {
21
host := ""
22
user := ""
23
for i := 0; i < len(args); i++ {
24
switch args[i] {
25
case "--host":
26
i++
27
if i >= len(args) {
28
return fmt.Errorf("--host requires a value")
29
}
30
host = args[i]
31
case "--user":
32
i++
33
if i >= len(args) {
34
return fmt.Errorf("--user requires a value")
35
}
36
user = args[i]
37
default:
38
return fmt.Errorf("unknown flag: %s", args[i])
39
}
40
}
41
42
reader := bufio.NewReader(os.Stdin)
43
44
if host == "" {
45
fmt.Print("Host (default http://localhost:42069): ")
46
line, _ := reader.ReadString('\n')
47
host = strings.TrimSpace(line)
48
if host == "" {
49
host = "http://localhost:42069"
50
}
51
}
52
53
fmt.Print("API key: ")
54
api_key, _ := reader.ReadString('\n')
55
api_key = strings.TrimSpace(api_key)
56
if api_key == "" {
57
return fmt.Errorf("API key is required")
58
}
59
60
cfg := &Config{Host: host, ApiKey: api_key}
61
if err := saveConfig(cfg); err != nil {
62
return fmt.Errorf("saving config: %v", err)
63
}
64
fmt.Printf("Saved config to %s\n", configPath())
65
66
// If --user given, call profile edit to map the
67
// API key to this username in mock who.
68
if user != "" {
69
cl := newClient(cfg)
70
payload := map[string]string{
71
"username": user,
72
"name": user,
73
}
74
var result map[string]string
75
err := cl.postJSON(
76
"/login/profile/edit", payload, &result)
77
if err != nil {
78
return fmt.Errorf("setting username: %v", err)
79
}
80
fmt.Printf("Authenticated as %s\n", user)
81
}
82
83
return nil
84
}
1
package main
2
3
import "encoding/json"
4
import "fmt"
5
import "io"
6
import "net/http"
7
import "os"
8
import "os/exec"
9
import "regexp"
10
import "strings"
11
12
var repoRegex = regexp.MustCompile(
13
`code\.oscarkilo\.com/([a-z][-a-z0-9]*)\.git`)
14
15
// detectRepo parses the git remote URL for the klee repo name.
16
func detectRepo() (string, error) {
17
cmd := exec.Command("git", "remote", "get-url", "origin")
18
out, err := cmd.Output()
19
if err != nil {
20
return "", fmt.Errorf(
21
"not a git repo or no remote 'origin': %v", err)
22
}
23
url := strings.TrimSpace(string(out))
24
m := repoRegex.FindStringSubmatch(url)
25
if m == nil {
26
return "", fmt.Errorf(
27
"remote URL %q is not a klee repo", url)
28
}
29
return m[1], nil
30
}
31
32
// resolveRepo returns the repo name from --repo flag,
33
// OKG_REPO env var, or git remote detection.
34
func resolveRepo(flagRepo string) (string, error) {
35
if flagRepo != "" {
36
return flagRepo, nil
37
}
38
if v := os.Getenv("OKG_REPO"); v != "" {
39
return v, nil
40
}
41
return detectRepo()
42
}
43
44
type Client struct {
45
Host string
46
ApiKey string
47
http *http.Client
48
}
49
50
func newClient(cfg *Config) *Client {
51
return &Client{
52
Host: cfg.Host,
53
ApiKey: cfg.ApiKey,
54
http: &http.Client{},
55
}
56
}
57
58
// do performs an HTTP request and returns the response.
59
func (c *Client) do(
60
method, path string, body io.Reader,
61
) (*http.Response, error) {
62
url := c.Host + path
63
req, err := http.NewRequest(method, url, body)
64
if err != nil {
65
return nil, err
66
}
67
req.Header.Set("Accept", "application/json")
68
if c.ApiKey != "" {
69
req.Header.Set(
70
"Authorization", "Bearer "+c.ApiKey)
71
}
72
if body != nil {
73
req.Header.Set("Content-Type", "application/json")
74
}
75
return c.http.Do(req)
76
}
77
78
// getJSON performs a GET and decodes JSON into dst.
79
func (c *Client) getJSON(path string, dst interface{}) error {
80
resp, err := c.do("GET", path, nil)
81
if err != nil {
82
return err
83
}
84
defer resp.Body.Close()
85
if resp.StatusCode >= 400 {
86
body, _ := io.ReadAll(resp.Body)
87
return fmt.Errorf(
88
"HTTP %d: %s", resp.StatusCode, string(body))
89
}
90
return json.NewDecoder(resp.Body).Decode(dst)
91
}
92
93
// postJSON performs a POST with a JSON body,
94
// decodes the response into dst.
95
func (c *Client) postJSON(
96
path string, payload interface{}, dst interface{},
97
) error {
98
body, err := jsonBody(payload)
99
if err != nil {
100
return err
101
}
102
resp, err := c.do("POST", path, body)
103
if err != nil {
104
return err
105
}
106
defer resp.Body.Close()
107
if resp.StatusCode >= 400 {
108
b, _ := io.ReadAll(resp.Body)
109
return fmt.Errorf(
110
"HTTP %d: %s", resp.StatusCode, string(b))
111
}
112
if dst != nil {
113
return json.NewDecoder(resp.Body).Decode(dst)
114
}
115
return nil
116
}
117
118
// patchJSON performs a PATCH with a JSON body,
119
// decodes the response into dst.
120
func (c *Client) patchJSON(
121
path string, payload interface{}, dst interface{},
122
) error {
123
body, err := jsonBody(payload)
124
if err != nil {
125
return err
126
}
127
resp, err := c.do("PATCH", path, body)
128
if err != nil {
129
return err
130
}
131
defer resp.Body.Close()
132
if resp.StatusCode >= 400 {
133
b, _ := io.ReadAll(resp.Body)
134
return fmt.Errorf(
135
"HTTP %d: %s", resp.StatusCode, string(b))
136
}
137
if dst != nil {
138
return json.NewDecoder(resp.Body).Decode(dst)
139
}
140
return nil
141
}
142
143
func jsonBody(v interface{}) (io.Reader, error) {
144
data, err := json.Marshal(v)
145
if err != nil {
146
return nil, err
147
}
148
return strings.NewReader(string(data)), nil
149
}
1
package main
2
3
import "encoding/json"
4
import "fmt"
5
import "os"
6
import "path/filepath"
7
8
type Config struct {
9
Host string `json:"host"`
10
ApiKey string `json:"api_key"`
11
}
12
13
func configPath() string {
14
home, err := os.UserHomeDir()
15
if err != nil {
16
return ""
17
}
18
return filepath.Join(home, ".config", "okg", "config.json")
19
}
20
21
func loadConfig() (*Config, error) {
22
c := &Config{}
23
24
// Load from file.
25
path := configPath()
26
if path != "" {
27
data, err := os.ReadFile(path)
28
if err == nil {
29
json.Unmarshal(data, c)
30
}
31
}
32
33
// Env overrides.
34
if v := os.Getenv("OKG_HOST"); v != "" {
35
c.Host = v
36
}
37
if v := os.Getenv("KLEX_API_KEY"); v != "" {
38
c.ApiKey = v
39
}
40
41
// Defaults.
42
if c.Host == "" {
43
c.Host = "http://localhost:42069"
44
}
45
46
return c, nil
47
}
48
49
func saveConfig(c *Config) error {
50
path := configPath()
51
if path == "" {
52
return fmt.Errorf("cannot determine home directory")
53
}
54
dir := filepath.Dir(path)
55
if err := os.MkdirAll(dir, 0700); err != nil {
56
return fmt.Errorf("mkdir %s: %v", dir, err)
57
}
58
data, err := json.MarshalIndent(c, "", " ")
59
if err != nil {
60
return err
61
}
62
return os.WriteFile(path, data, 0600)
63
}
1
module oscarkilo.com/okg
2
3
go 1.23
1
package main
2
3
import "fmt"
4
import "os"
5
6
func main() {
7
args := os.Args[1:]
8
if len(args) == 0 {
9
printUsage()
10
os.Exit(1)
11
}
12
13
var err error
14
switch args[0] {
15
case "pr":
16
err = runPR(args[1:])
17
case "repo":
18
err = runRepo(args[1:])
19
case "auth":
20
err = runAuth(args[1:])
21
case "help", "--help", "-h":
22
printUsage()
23
return
24
default:
25
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
26
printUsage()
27
os.Exit(1)
28
}
29
30
if err != nil {
31
fmt.Fprintf(os.Stderr, "error: %v\n", err)
32
os.Exit(1)
33
}
34
}
35
36
func printUsage() {
37
fmt.Fprintf(os.Stderr, `okg — Oscar Kilo Git CLI
38
39
Usage:
40
okg pr list [--state open|closed] [--json]
41
okg pr create --head BRANCH [--base master] \
42
--title TITLE [--body BODY] [--json]
43
okg pr view NUMBER [--json]
44
okg pr diff NUMBER
45
okg pr comment NUMBER --body BODY \
46
[--approve | --request-changes]
47
okg pr merge NUMBER [--json]
48
okg pr close NUMBER [--json]
49
okg pr reopen NUMBER [--json]
50
okg repo list [--json]
51
okg auth login [--host HOST] [--user USERNAME]
52
53
Flags:
54
--repo REPO Override auto-detected repo name
55
--json Output raw JSON
56
`)
57
}
1
package main
2
3
import "os"
4
import "testing"
5
import "time"
6
7
func TestRepoRegex(t *testing.T) {
8
check := func(url, want string) {
9
t.Helper()
10
m := repoRegex.FindStringSubmatch(url)
11
if want == "" {
12
if m != nil {
13
t.Errorf("%q: want no match, got %q", url, m[1])
14
}
15
return
16
}
17
if m == nil {
18
t.Errorf("%q: want %q, got no match", url, want)
19
return
20
}
21
if m[1] != want {
22
t.Errorf("%q: want %q, got %q", url, want, m[1])
23
}
24
}
25
check("https://code.oscarkilo.com/widget.git", "widget")
26
check("https://code.oscarkilo.com/klee.git", "klee")
27
check("https://code.oscarkilo.com/my-repo.git", "my-repo")
28
check("https://code.oscarkilo.com/a123.git", "a123")
29
check("https://github.com/foo/bar.git", "")
30
check("not-a-url", "")
31
}
32
33
func TestResolveRepo(t *testing.T) {
34
// Flag takes priority.
35
repo, err := resolveRepo("from-flag")
36
if err != nil {
37
t.Fatal(err)
38
}
39
if repo != "from-flag" {
40
t.Errorf("want from-flag, got %q", repo)
41
}
42
43
// Env var takes priority over detection.
44
os.Setenv("OKG_REPO", "from-env")
45
defer os.Unsetenv("OKG_REPO")
46
repo, err = resolveRepo("")
47
if err != nil {
48
t.Fatal(err)
49
}
50
if repo != "from-env" {
51
t.Errorf("want from-env, got %q", repo)
52
}
53
}
54
55
func TestParsePRFlags(t *testing.T) {
56
f, rest, err := parsePRFlags([]string{
57
"--repo", "widget", "--json", "42",
58
})
59
if err != nil {
60
t.Fatal(err)
61
}
62
if f.repo != "widget" {
63
t.Errorf("repo: want widget, got %q", f.repo)
64
}
65
if !f.asJSON {
66
t.Error("asJSON: want true")
67
}
68
if len(rest) != 1 || rest[0] != "42" {
69
t.Errorf("rest: want [42], got %v", rest)
70
}
71
}
72
73
func TestParsePRFlagsEmpty(t *testing.T) {
74
f, rest, err := parsePRFlags(nil)
75
if err != nil {
76
t.Fatal(err)
77
}
78
if f.repo != "" {
79
t.Errorf("repo: want empty, got %q", f.repo)
80
}
81
if f.asJSON {
82
t.Error("asJSON: want false")
83
}
84
if len(rest) != 0 {
85
t.Errorf("rest: want empty, got %v", rest)
86
}
87
}
88
89
func TestParsePRFlagsMissingValue(t *testing.T) {
90
_, _, err := parsePRFlags([]string{"--repo"})
91
if err == nil {
92
t.Error("want error for --repo without value")
93
}
94
}
95
96
func TestAge(t *testing.T) {
97
check := func(d time.Duration, want string) {
98
t.Helper()
99
got := age(time.Now().Add(-d))
100
if got != want {
101
t.Errorf("age(-%v): want %q, got %q", d, want, got)
102
}
103
}
104
check(30*time.Second, "just now")
105
check(5*time.Minute, "5m")
106
check(3*time.Hour, "3h")
107
check(48*time.Hour, "2d")
108
}
109
110
func TestConfigEnvOverrides(t *testing.T) {
111
os.Setenv("OKG_HOST", "http://test:1234")
112
os.Setenv("KLEX_API_KEY", "env-key")
113
defer os.Unsetenv("OKG_HOST")
114
defer os.Unsetenv("KLEX_API_KEY")
115
116
cfg, err := loadConfig()
117
if err != nil {
118
t.Fatal(err)
119
}
120
if cfg.Host != "http://test:1234" {
121
t.Errorf("Host: want http://test:1234, got %q", cfg.Host)
122
}
123
if cfg.ApiKey != "env-key" {
124
t.Errorf("ApiKey: want env-key, got %q", cfg.ApiKey)
125
}
126
}
127
128
func TestConfigDefaultHost(t *testing.T) {
129
os.Unsetenv("OKG_HOST")
130
os.Unsetenv("KLEX_API_KEY")
131
cfg, err := loadConfig()
132
if err != nil {
133
t.Fatal(err)
134
}
135
if cfg.Host != "http://localhost:42069" {
136
t.Errorf(
137
"Host: want http://localhost:42069, got %q",
138
cfg.Host)
139
}
140
}
1
package main
2
3
import "encoding/json"
4
import "fmt"
5
import "os"
6
import "os/exec"
7
import "strconv"
8
import "text/tabwriter"
9
import "time"
10
11
type PR struct {
12
Number int `json:"number"`
13
Title string `json:"title"`
14
Body string `json:"body"`
15
State string `json:"state"`
16
Merged bool `json:"merged"`
17
Head string `json:"head"`
18
Base string `json:"base"`
19
Author string `json:"author"`
20
Created time.Time `json:"created"`
21
Updated time.Time `json:"updated"`
22
MergedBy string `json:"merged_by,omitempty"`
23
MergedAt time.Time `json:"merged_at,omitempty"`
24
}
25
26
type Comment struct {
27
ID int `json:"id"`
28
Author string `json:"author"`
29
Body string `json:"body"`
30
Verdict string `json:"verdict,omitempty"`
31
File string `json:"file,omitempty"`
32
Line int `json:"line,omitempty"`
33
Created time.Time `json:"created"`
34
}
35
36
func runPR(args []string) error {
37
if len(args) == 0 {
38
return fmt.Errorf(
39
"usage: okg pr <list|create|view|diff" +
40
"|comment|merge|close|reopen>")
41
}
42
switch args[0] {
43
case "list":
44
return runPRList(args[1:])
45
case "create":
46
return runPRCreate(args[1:])
47
case "view":
48
return runPRView(args[1:])
49
case "diff":
50
return runPRDiff(args[1:])
51
case "comment":
52
return runPRComment(args[1:])
53
case "merge":
54
return runPRMerge(args[1:])
55
case "close":
56
return runPRClose(args[1:])
57
case "reopen":
58
return runPRReopen(args[1:])
59
default:
60
return fmt.Errorf("unknown pr command: %s", args[0])
61
}
62
}
63
64
// parseFlags extracts --repo and --json from args,
65
// returns remaining positional args.
66
type prFlags struct {
67
repo string
68
asJSON bool
69
}
70
71
func parsePRFlags(args []string) (
72
*prFlags, []string, error,
73
) {
74
f := &prFlags{}
75
var rest []string
76
for i := 0; i < len(args); i++ {
77
switch args[i] {
78
case "--repo":
79
i++
80
if i >= len(args) {
81
return nil, nil, fmt.Errorf(
82
"--repo requires a value")
83
}
84
f.repo = args[i]
85
case "--json":
86
f.asJSON = true
87
default:
88
rest = append(rest, args[i])
89
}
90
}
91
return f, rest, nil
92
}
93
94
func setupClient(
95
flagRepo string,
96
) (*Client, string, error) {
97
cfg, err := loadConfig()
98
if err != nil {
99
return nil, "", err
100
}
101
repo, err := resolveRepo(flagRepo)
102
if err != nil {
103
return nil, "", err
104
}
105
return newClient(cfg), repo, nil
106
}
107
108
func outputJSON(v interface{}) error {
109
enc := json.NewEncoder(os.Stdout)
110
enc.SetIndent("", " ")
111
return enc.Encode(v)
112
}
113
114
func age(t time.Time) string {
115
d := time.Since(t)
116
switch {
117
case d < time.Minute:
118
return "just now"
119
case d < time.Hour:
120
return fmt.Sprintf("%dm", int(d.Minutes()))
121
case d < 24*time.Hour:
122
return fmt.Sprintf("%dh", int(d.Hours()))
123
default:
124
return fmt.Sprintf("%dd", int(d.Hours()/24))
125
}
126
}
127
128
// --- pr list ---
129
130
func runPRList(args []string) error {
131
f, rest, err := parsePRFlags(args)
132
if err != nil {
133
return err
134
}
135
136
state := "open"
137
for i := 0; i < len(rest); i++ {
138
if rest[i] == "--state" {
139
i++
140
if i >= len(rest) {
141
return fmt.Errorf("--state requires a value")
142
}
143
state = rest[i]
144
}
145
}
146
147
cl, repo, err := setupClient(f.repo)
148
if err != nil {
149
return err
150
}
151
152
path := fmt.Sprintf("/%s/prs?state=%s", repo, state)
153
var prs []PR
154
if err := cl.getJSON(path, &prs); err != nil {
155
return err
156
}
157
158
if f.asJSON {
159
return outputJSON(prs)
160
}
161
162
tw := tabwriter.NewWriter(
163
os.Stdout, 0, 4, 2, ' ', 0)
164
fmt.Fprintln(tw, "#\tTITLE\tAUTHOR\tHEAD\tBASE\tAGE")
165
for _, p := range prs {
166
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n",
167
p.Number, p.Title, p.Author,
168
p.Head, p.Base, age(p.Created))
169
}
170
return tw.Flush()
171
}
172
173
// --- pr view ---
174
175
func runPRView(args []string) error {
176
f, rest, err := parsePRFlags(args)
177
if err != nil {
178
return err
179
}
180
if len(rest) < 1 {
181
return fmt.Errorf("usage: okg pr view NUMBER")
182
}
183
num, err := strconv.Atoi(rest[0])
184
if err != nil {
185
return fmt.Errorf("invalid PR number: %s", rest[0])
186
}
187
188
cl, repo, err := setupClient(f.repo)
189
if err != nil {
190
return err
191
}
192
193
var p PR
194
path := fmt.Sprintf("/%s/pr/%d", repo, num)
195
if err := cl.getJSON(path, &p); err != nil {
196
return err
197
}
198
199
var comments []Comment
200
cpath := fmt.Sprintf("/%s/pr/%d/comments", repo, num)
201
if err := cl.getJSON(cpath, &comments); err != nil {
202
return err
203
}
204
205
if f.asJSON {
206
return outputJSON(map[string]interface{}{
207
"pr": p,
208
"comments": comments,
209
})
210
}
211
212
// Header.
213
state_str := p.State
214
if p.Merged {
215
state_str = "merged"
216
}
217
fmt.Printf("#%d %s (%s)\n", p.Number, p.Title, state_str)
218
fmt.Printf(" %s wants to merge %s into %s\n",
219
p.Author, p.Head, p.Base)
220
fmt.Printf(" Created %s\n", p.Created.Format(time.RFC3339))
221
if p.Body != "" {
222
fmt.Printf("\n%s\n", p.Body)
223
}
224
225
// Comments.
226
if len(comments) > 0 {
227
fmt.Printf("\n--- Comments ---\n")
228
for _, c := range comments {
229
verdict := ""
230
if c.Verdict != "" {
231
verdict = fmt.Sprintf(" [%s]", c.Verdict)
232
}
233
fmt.Printf("\n@%s%s (%s):\n%s\n",
234
c.Author, verdict,
235
c.Created.Format(time.RFC3339), c.Body)
236
}
237
}
238
return nil
239
}
240
241
// --- pr diff ---
242
243
func runPRDiff(args []string) error {
244
f, rest, err := parsePRFlags(args)
245
if err != nil {
246
return err
247
}
248
if len(rest) < 1 {
249
return fmt.Errorf("usage: okg pr diff NUMBER")
250
}
251
num, err := strconv.Atoi(rest[0])
252
if err != nil {
253
return fmt.Errorf("invalid PR number: %s", rest[0])
254
}
255
256
cl, repo, err := setupClient(f.repo)
257
if err != nil {
258
return err
259
}
260
261
var p PR
262
path := fmt.Sprintf("/%s/pr/%d", repo, num)
263
if err := cl.getJSON(path, &p); err != nil {
264
return err
265
}
266
267
// Run git diff locally.
268
cmd := exec.Command(
269
"git", "diff", p.Base+"..."+p.Head)
270
cmd.Stdout = os.Stdout
271
cmd.Stderr = os.Stderr
272
return cmd.Run()
273
}
274
275
// --- pr create ---
276
277
func runPRCreate(args []string) error {
278
f, rest, err := parsePRFlags(args)
279
if err != nil {
280
return err
281
}
282
283
head := ""
284
base := "master"
285
title := ""
286
body := ""
287
for i := 0; i < len(rest); i++ {
288
switch rest[i] {
289
case "--head":
290
i++
291
if i >= len(rest) {
292
return fmt.Errorf("--head requires a value")
293
}
294
head = rest[i]
295
case "--base":
296
i++
297
if i >= len(rest) {
298
return fmt.Errorf("--base requires a value")
299
}
300
base = rest[i]
301
case "--title":
302
i++
303
if i >= len(rest) {
304
return fmt.Errorf("--title requires a value")
305
}
306
title = rest[i]
307
case "--body":
308
i++
309
if i >= len(rest) {
310
return fmt.Errorf("--body requires a value")
311
}
312
body = rest[i]
313
default:
314
return fmt.Errorf("unknown flag: %s", rest[i])
315
}
316
}
317
318
if head == "" {
319
return fmt.Errorf("--head is required")
320
}
321
if title == "" {
322
return fmt.Errorf("--title is required")
323
}
324
325
cl, repo, err := setupClient(f.repo)
326
if err != nil {
327
return err
328
}
329
330
payload := map[string]string{
331
"head": head,
332
"base": base,
333
"title": title,
334
"body": body,
335
}
336
var p PR
337
path := fmt.Sprintf("/%s/prs", repo)
338
if err := cl.postJSON(path, payload, &p); err != nil {
339
return err
340
}
341
342
if f.asJSON {
343
return outputJSON(p)
344
}
345
346
fmt.Printf("Created PR #%d: %s\n", p.Number, p.Title)
347
fmt.Printf(" %s -> %s\n", p.Head, p.Base)
348
return nil
349
}
350
351
// --- pr comment ---
352
353
func runPRComment(args []string) error {
354
f, rest, err := parsePRFlags(args)
355
if err != nil {
356
return err
357
}
358
if len(rest) < 1 {
359
return fmt.Errorf(
360
"usage: okg pr comment NUMBER --body BODY")
361
}
362
num, err := strconv.Atoi(rest[0])
363
if err != nil {
364
return fmt.Errorf("invalid PR number: %s", rest[0])
365
}
366
rest = rest[1:]
367
368
body := ""
369
verdict := ""
370
for i := 0; i < len(rest); i++ {
371
switch rest[i] {
372
case "--body":
373
i++
374
if i >= len(rest) {
375
return fmt.Errorf("--body requires a value")
376
}
377
body = rest[i]
378
case "--approve":
379
verdict = "approve"
380
case "--request-changes":
381
verdict = "request_changes"
382
default:
383
return fmt.Errorf("unknown flag: %s", rest[i])
384
}
385
}
386
if body == "" {
387
return fmt.Errorf("--body is required")
388
}
389
390
cl, repo, err := setupClient(f.repo)
391
if err != nil {
392
return err
393
}
394
395
payload := map[string]string{
396
"body": body,
397
"verdict": verdict,
398
}
399
var c Comment
400
path := fmt.Sprintf("/%s/pr/%d/comments", repo, num)
401
if err := cl.postJSON(path, payload, &c); err != nil {
402
return err
403
}
404
405
if f.asJSON {
406
return outputJSON(c)
407
}
408
409
fmt.Printf("Comment #%d by @%s", c.ID, c.Author)
410
if c.Verdict != "" {
411
fmt.Printf(" [%s]", c.Verdict)
412
}
413
fmt.Printf(":\n%s\n", c.Body)
414
return nil
415
}
416
417
// --- pr merge ---
418
419
func runPRMerge(args []string) error {
420
f, rest, err := parsePRFlags(args)
421
if err != nil {
422
return err
423
}
424
if len(rest) < 1 {
425
return fmt.Errorf("usage: okg pr merge NUMBER")
426
}
427
num, err := strconv.Atoi(rest[0])
428
if err != nil {
429
return fmt.Errorf("invalid PR number: %s", rest[0])
430
}
431
432
cl, repo, err := setupClient(f.repo)
433
if err != nil {
434
return err
435
}
436
437
var p PR
438
path := fmt.Sprintf("/%s/pr/%d/merge", repo, num)
439
if err := cl.postJSON(path, nil, &p); err != nil {
440
return err
441
}
442
443
if f.asJSON {
444
return outputJSON(p)
445
}
446
447
fmt.Printf("PR #%d merged by @%s\n", p.Number, p.MergedBy)
448
return nil
449
}
450
451
// --- pr close ---
452
453
func runPRClose(args []string) error {
454
return runPRStateChange("closed", args)
455
}
456
457
// --- pr reopen ---
458
459
func runPRReopen(args []string) error {
460
return runPRStateChange("open", args)
461
}
462
463
func runPRStateChange(
464
new_state string, args []string,
465
) error {
466
f, rest, err := parsePRFlags(args)
467
if err != nil {
468
return err
469
}
470
if len(rest) < 1 {
471
return fmt.Errorf(
472
"usage: okg pr close|reopen NUMBER")
473
}
474
num, err := strconv.Atoi(rest[0])
475
if err != nil {
476
return fmt.Errorf("invalid PR number: %s", rest[0])
477
}
478
479
cl, repo, err := setupClient(f.repo)
480
if err != nil {
481
return err
482
}
483
484
payload := map[string]string{"state": new_state}
485
var p PR
486
path := fmt.Sprintf("/%s/pr/%d", repo, num)
487
if err := cl.patchJSON(path, payload, &p); err != nil {
488
return err
489
}
490
491
if f.asJSON {
492
return outputJSON(p)
493
}
494
495
fmt.Printf("PR #%d is now %s\n", p.Number, p.State)
496
return nil
497
}
1
package main
2
3
import "encoding/json"
4
import "fmt"
5
import "os"
6
import "text/tabwriter"
7
8
type repoInfo struct {
9
Name string `json:"name"`
10
IsPublic bool `json:"is_public"`
11
Authz *repoInfoAuthz `json:"authz"`
12
}
13
14
type repoInfoAuthz struct {
15
IsOwner bool `json:"is_owner"`
16
IsReader bool `json:"is_reader"`
17
OwnerUsername string `json:"owner_username"`
18
ReaderUsername string `json:"reader_username"`
19
}
20
21
type lsResponse struct {
22
Repos []repoInfo `json:"repos"`
23
CanCreateRepos bool `json:"can_create_repos"`
24
}
25
26
func runRepo(args []string) error {
27
if len(args) == 0 {
28
return fmt.Errorf("usage: okg repo list")
29
}
30
switch args[0] {
31
case "list":
32
return runRepoList(args[1:])
33
default:
34
return fmt.Errorf("unknown repo command: %s", args[0])
35
}
36
}
37
38
func runRepoList(args []string) error {
39
as_json := false
40
for _, a := range args {
41
if a == "--json" {
42
as_json = true
43
}
44
}
45
46
cfg, err := loadConfig()
47
if err != nil {
48
return err
49
}
50
cl := newClient(cfg)
51
52
var res lsResponse
53
if err := cl.getJSON("/.ls", &res); err != nil {
54
return err
55
}
56
57
if as_json {
58
enc := json.NewEncoder(os.Stdout)
59
enc.SetIndent("", " ")
60
return enc.Encode(res)
61
}
62
63
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
64
fmt.Fprintln(tw, "NAME\tOWNER\tREADER")
65
for _, r := range res.Repos {
66
owner := ""
67
reader := ""
68
if r.Authz != nil {
69
owner = r.Authz.OwnerUsername
70
reader = r.Authz.ReaderUsername
71
}
72
fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Name, owner, reader)
73
}
74
return tw.Flush()
75
}