code.oscarkilo.com/okg

Hash:
946f8dbee61c4fc6c88276af2127e76fd00b34a4
Author:
Igor Naverniouk <[email protected]>
Date:
Fri Mar 6 20:23:39 2026 -0500
Message:
okg: gh-style CLI for klee git server Command-line tool for interacting with klee's PR system, designed for both humans and AI agents. Supports all PR operations (list, create, view, diff, comment, merge, close, reopen), repo listing, and auth login with Bearer token mapping. Auto-detects repo from git remote URL. Config stored in ~/.config/okg/config.json with env var overrides (OKG_HOST, KLEX_API_KEY). No external dependencies — stdlib only.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..543a12a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+okg
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..09bef45
--- /dev/null
+++ b/README.md
@@ -0,0 +1,73 @@
+# okg — Oscar Kilo Git CLI
+
+A `gh`-style command-line tool for interacting with
+[klee](https://code.oscarkilo.com), the Oscar Kilo git server.
+
+Designed for both humans and AI agents (Claude, OpenClaw) to
+use directly from the command line.
+
+## Install
+
+```bash
+go install oscarkilo.com/okg@latest
+```
+
+Or build from source:
+
+```bash
+git clone https://code.oscarkilo.com/okg
+cd okg && go build .
+```
+
+## Setup
+
+```bash
+# Interactive login (saves to ~/.config/okg/config.json)
+okg auth login
+
+# With flags
+okg auth login --host https://code.oscarkilo.com --user igor
+
+# Or use environment variables
+export OKG_HOST=https://code.oscarkilo.com
+export KLEX_API_KEY=your-api-key
+```
+
+## Commands
+
+```
+okg repo list
+
+okg pr list [--state open|closed]
+okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
+okg pr view NUMBER
+okg pr diff NUMBER
+okg pr comment NUMBER --body BODY [--approve | --request-changes]
+okg pr merge NUMBER
+okg pr close NUMBER
+okg pr reopen NUMBER
+
+okg auth login [--host HOST] [--user USERNAME]
+```
+
+### Flags
+
+- `--repo REPO` overrides auto-detected repo name
+ (normally parsed from `git remote get-url origin`)
+- `--json` outputs raw JSON for any command
+- `OKG_REPO` env var also overrides repo detection
+
+## Repo Detection
+
+Like `gh`, okg detects the repo from the current directory's
+git remote:
+
+```
+git remote get-url origin
+→ https://code.oscarkilo.com/widget.git
+→ repo = "widget"
+```
+
+## Dependencies
+
+None beyond the Go standard library.
diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..883902c
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,84 @@
+package main
+
+import "bufio"
+import "fmt"
+import "os"
+import "strings"
+
+func runAuth(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("usage: okg auth login")
+ }
+ switch args[0] {
+ case "login":
+ return runAuthLogin(args[1:])
+ default:
+ return fmt.Errorf("unknown auth command: %s", args[0])
+ }
+}
+
+func runAuthLogin(args []string) error {
+ host := ""
+ user := ""
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--host":
+ i++
+ if i >= len(args) {
+ return fmt.Errorf("--host requires a value")
+ }
+ host = args[i]
+ case "--user":
+ i++
+ if i >= len(args) {
+ return fmt.Errorf("--user requires a value")
+ }
+ user = args[i]
+ default:
+ return fmt.Errorf("unknown flag: %s", args[i])
+ }
+ }
+
+ reader := bufio.NewReader(os.Stdin)
+
+ if host == "" {
+ fmt.Print("Host (default http://localhost:42069): ")
+ line, _ := reader.ReadString('\n')
+ host = strings.TrimSpace(line)
+ if host == "" {
+ host = "http://localhost:42069"
+ }
+ }
+
+ fmt.Print("API key: ")
+ api_key, _ := reader.ReadString('\n')
+ api_key = strings.TrimSpace(api_key)
+ if api_key == "" {
+ return fmt.Errorf("API key is required")
+ }
+
+ cfg := &Config{Host: host, ApiKey: api_key}
+ if err := saveConfig(cfg); err != nil {
+ return fmt.Errorf("saving config: %v", err)
+ }
+ fmt.Printf("Saved config to %s\n", configPath())
+
+ // If --user given, call profile edit to map the
+ // API key to this username in mock who.
+ if user != "" {
+ cl := newClient(cfg)
+ payload := map[string]string{
+ "username": user,
+ "name": user,
+ }
+ var result map[string]string
+ err := cl.postJSON(
+ "/login/profile/edit", payload, &result)
+ if err != nil {
+ return fmt.Errorf("setting username: %v", err)
+ }
+ fmt.Printf("Authenticated as %s\n", user)
+ }
+
+ return nil
+}
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..3998828
--- /dev/null
+++ b/client.go
@@ -0,0 +1,149 @@
+package main
+
+import "encoding/json"
+import "fmt"
+import "io"
+import "net/http"
+import "os"
+import "os/exec"
+import "regexp"
+import "strings"
+
+var repoRegex = regexp.MustCompile(
+ `code\.oscarkilo\.com/([a-z][-a-z0-9]*)\.git`)
+
+// detectRepo parses the git remote URL for the klee repo name.
+func detectRepo() (string, error) {
+ cmd := exec.Command("git", "remote", "get-url", "origin")
+ out, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf(
+ "not a git repo or no remote 'origin': %v", err)
+ }
+ url := strings.TrimSpace(string(out))
+ m := repoRegex.FindStringSubmatch(url)
+ if m == nil {
+ return "", fmt.Errorf(
+ "remote URL %q is not a klee repo", url)
+ }
+ return m[1], nil
+}
+
+// resolveRepo returns the repo name from --repo flag,
+// OKG_REPO env var, or git remote detection.
+func resolveRepo(flagRepo string) (string, error) {
+ if flagRepo != "" {
+ return flagRepo, nil
+ }
+ if v := os.Getenv("OKG_REPO"); v != "" {
+ return v, nil
+ }
+ return detectRepo()
+}
+
+type Client struct {
+ Host string
+ ApiKey string
+ http *http.Client
+}
+
+func newClient(cfg *Config) *Client {
+ return &Client{
+ Host: cfg.Host,
+ ApiKey: cfg.ApiKey,
+ http: &http.Client{},
+ }
+}
+
+// do performs an HTTP request and returns the response.
+func (c *Client) do(
+ method, path string, body io.Reader,
+) (*http.Response, error) {
+ url := c.Host + path
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ if c.ApiKey != "" {
+ req.Header.Set(
+ "Authorization", "Bearer "+c.ApiKey)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return c.http.Do(req)
+}
+
+// getJSON performs a GET and decodes JSON into dst.
+func (c *Client) getJSON(path string, dst interface{}) error {
+ resp, err := c.do("GET", path, nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf(
+ "HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ return json.NewDecoder(resp.Body).Decode(dst)
+}
+
+// postJSON performs a POST with a JSON body,
+// decodes the response into dst.
+func (c *Client) postJSON(
+ path string, payload interface{}, dst interface{},
+) error {
+ body, err := jsonBody(payload)
+ if err != nil {
+ return err
+ }
+ resp, err := c.do("POST", path, body)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ b, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf(
+ "HTTP %d: %s", resp.StatusCode, string(b))
+ }
+ if dst != nil {
+ return json.NewDecoder(resp.Body).Decode(dst)
+ }
+ return nil
+}
+
+// patchJSON performs a PATCH with a JSON body,
+// decodes the response into dst.
+func (c *Client) patchJSON(
+ path string, payload interface{}, dst interface{},
+) error {
+ body, err := jsonBody(payload)
+ if err != nil {
+ return err
+ }
+ resp, err := c.do("PATCH", path, body)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ b, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf(
+ "HTTP %d: %s", resp.StatusCode, string(b))
+ }
+ if dst != nil {
+ return json.NewDecoder(resp.Body).Decode(dst)
+ }
+ return nil
+}
+
+func jsonBody(v interface{}) (io.Reader, error) {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+ return strings.NewReader(string(data)), nil
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..2ba12f5
--- /dev/null
+++ b/config.go
@@ -0,0 +1,63 @@
+package main
+
+import "encoding/json"
+import "fmt"
+import "os"
+import "path/filepath"
+
+type Config struct {
+ Host string `json:"host"`
+ ApiKey string `json:"api_key"`
+}
+
+func configPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ return filepath.Join(home, ".config", "okg", "config.json")
+}
+
+func loadConfig() (*Config, error) {
+ c := &Config{}
+
+ // Load from file.
+ path := configPath()
+ if path != "" {
+ data, err := os.ReadFile(path)
+ if err == nil {
+ json.Unmarshal(data, c)
+ }
+ }
+
+ // Env overrides.
+ if v := os.Getenv("OKG_HOST"); v != "" {
+ c.Host = v
+ }
+ if v := os.Getenv("KLEX_API_KEY"); v != "" {
+ c.ApiKey = v
+ }
+
+ // Defaults.
+ if c.Host == "" {
+ c.Host = "http://localhost:42069"
+ }
+
+ return c, nil
+}
+
+func saveConfig(c *Config) error {
+ path := configPath()
+ if path == "" {
+ return fmt.Errorf("cannot determine home directory")
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return fmt.Errorf("mkdir %s: %v", dir, err)
+ }
+ data, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0600)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6d06c4c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module oscarkilo.com/okg
+
+go 1.23
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..2c62b20
--- /dev/null
+++ b/main.go
@@ -0,0 +1,57 @@
+package main
+
+import "fmt"
+import "os"
+
+func main() {
+ args := os.Args[1:]
+ if len(args) == 0 {
+ printUsage()
+ os.Exit(1)
+ }
+
+ var err error
+ switch args[0] {
+ case "pr":
+ err = runPR(args[1:])
+ case "repo":
+ err = runRepo(args[1:])
+ case "auth":
+ err = runAuth(args[1:])
+ case "help", "--help", "-h":
+ printUsage()
+ return
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
+ printUsage()
+ os.Exit(1)
+ }
+
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func printUsage() {
+ fmt.Fprintf(os.Stderr, `okg — Oscar Kilo Git CLI
+
+Usage:
+ okg pr list [--state open|closed] [--json]
+ okg pr create --head BRANCH [--base master] \
+ --title TITLE [--body BODY] [--json]
+ okg pr view NUMBER [--json]
+ okg pr diff NUMBER
+ okg pr comment NUMBER --body BODY \
+ [--approve | --request-changes]
+ okg pr merge NUMBER [--json]
+ okg pr close NUMBER [--json]
+ okg pr reopen NUMBER [--json]
+ okg repo list [--json]
+ okg auth login [--host HOST] [--user USERNAME]
+
+Flags:
+ --repo REPO Override auto-detected repo name
+ --json Output raw JSON
+`)
+}
diff --git a/okg_test.go b/okg_test.go
new file mode 100644
index 0000000..6f1c920
--- /dev/null
+++ b/okg_test.go
@@ -0,0 +1,140 @@
+package main
+
+import "os"
+import "testing"
+import "time"
+
+func TestRepoRegex(t *testing.T) {
+ check := func(url, want string) {
+ t.Helper()
+ m := repoRegex.FindStringSubmatch(url)
+ if want == "" {
+ if m != nil {
+ t.Errorf("%q: want no match, got %q", url, m[1])
+ }
+ return
+ }
+ if m == nil {
+ t.Errorf("%q: want %q, got no match", url, want)
+ return
+ }
+ if m[1] != want {
+ t.Errorf("%q: want %q, got %q", url, want, m[1])
+ }
+ }
+ check("https://code.oscarkilo.com/widget.git", "widget")
+ check("https://code.oscarkilo.com/klee.git", "klee")
+ check("https://code.oscarkilo.com/my-repo.git", "my-repo")
+ check("https://code.oscarkilo.com/a123.git", "a123")
+ check("https://github.com/foo/bar.git", "")
+ check("not-a-url", "")
+}
+
+func TestResolveRepo(t *testing.T) {
+ // Flag takes priority.
+ repo, err := resolveRepo("from-flag")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repo != "from-flag" {
+ t.Errorf("want from-flag, got %q", repo)
+ }
+
+ // Env var takes priority over detection.
+ os.Setenv("OKG_REPO", "from-env")
+ defer os.Unsetenv("OKG_REPO")
+ repo, err = resolveRepo("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repo != "from-env" {
+ t.Errorf("want from-env, got %q", repo)
+ }
+}
+
+func TestParsePRFlags(t *testing.T) {
+ f, rest, err := parsePRFlags([]string{
+ "--repo", "widget", "--json", "42",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.repo != "widget" {
+ t.Errorf("repo: want widget, got %q", f.repo)
+ }
+ if !f.asJSON {
+ t.Error("asJSON: want true")
+ }
+ if len(rest) != 1 || rest[0] != "42" {
+ t.Errorf("rest: want [42], got %v", rest)
+ }
+}
+
+func TestParsePRFlagsEmpty(t *testing.T) {
+ f, rest, err := parsePRFlags(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.repo != "" {
+ t.Errorf("repo: want empty, got %q", f.repo)
+ }
+ if f.asJSON {
+ t.Error("asJSON: want false")
+ }
+ if len(rest) != 0 {
+ t.Errorf("rest: want empty, got %v", rest)
+ }
+}
+
+func TestParsePRFlagsMissingValue(t *testing.T) {
+ _, _, err := parsePRFlags([]string{"--repo"})
+ if err == nil {
+ t.Error("want error for --repo without value")
+ }
+}
+
+func TestAge(t *testing.T) {
+ check := func(d time.Duration, want string) {
+ t.Helper()
+ got := age(time.Now().Add(-d))
+ if got != want {
+ t.Errorf("age(-%v): want %q, got %q", d, want, got)
+ }
+ }
+ check(30*time.Second, "just now")
+ check(5*time.Minute, "5m")
+ check(3*time.Hour, "3h")
+ check(48*time.Hour, "2d")
+}
+
+func TestConfigEnvOverrides(t *testing.T) {
+ os.Setenv("OKG_HOST", "http://test:1234")
+ os.Setenv("KLEX_API_KEY", "env-key")
+ defer os.Unsetenv("OKG_HOST")
+ defer os.Unsetenv("KLEX_API_KEY")
+
+ cfg, err := loadConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Host != "http://test:1234" {
+ t.Errorf("Host: want http://test:1234, got %q", cfg.Host)
+ }
+ if cfg.ApiKey != "env-key" {
+ t.Errorf("ApiKey: want env-key, got %q", cfg.ApiKey)
+ }
+}
+
+func TestConfigDefaultHost(t *testing.T) {
+ os.Unsetenv("OKG_HOST")
+ os.Unsetenv("KLEX_API_KEY")
+ cfg, err := loadConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Host != "http://localhost:42069" {
+ t.Errorf(
+ "Host: want http://localhost:42069, got %q",
+ cfg.Host)
+ }
+}
diff --git a/pr.go b/pr.go
new file mode 100644
index 0000000..94e1e09
--- /dev/null
+++ b/pr.go
@@ -0,0 +1,497 @@
+package main
+
+import "encoding/json"
+import "fmt"
+import "os"
+import "os/exec"
+import "strconv"
+import "text/tabwriter"
+import "time"
+
+type PR struct {
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Merged bool `json:"merged"`
+ Head string `json:"head"`
+ Base string `json:"base"`
+ Author string `json:"author"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ MergedBy string `json:"merged_by,omitempty"`
+ MergedAt time.Time `json:"merged_at,omitempty"`
+}
+
+type Comment struct {
+ ID int `json:"id"`
+ Author string `json:"author"`
+ Body string `json:"body"`
+ Verdict string `json:"verdict,omitempty"`
+ File string `json:"file,omitempty"`
+ Line int `json:"line,omitempty"`
+ Created time.Time `json:"created"`
+}
+
+func runPR(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf(
+ "usage: okg pr <list|create|view|diff" +
+ "|comment|merge|close|reopen>")
+ }
+ switch args[0] {
+ case "list":
+ return runPRList(args[1:])
+ case "create":
+ return runPRCreate(args[1:])
+ case "view":
+ return runPRView(args[1:])
+ case "diff":
+ return runPRDiff(args[1:])
+ case "comment":
+ return runPRComment(args[1:])
+ case "merge":
+ return runPRMerge(args[1:])
+ case "close":
+ return runPRClose(args[1:])
+ case "reopen":
+ return runPRReopen(args[1:])
+ default:
+ return fmt.Errorf("unknown pr command: %s", args[0])
+ }
+}
+
+// parseFlags extracts --repo and --json from args,
+// returns remaining positional args.
+type prFlags struct {
+ repo string
+ asJSON bool
+}
+
+func parsePRFlags(args []string) (
+ *prFlags, []string, error,
+) {
+ f := &prFlags{}
+ var rest []string
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--repo":
+ i++
+ if i >= len(args) {
+ return nil, nil, fmt.Errorf(
+ "--repo requires a value")
+ }
+ f.repo = args[i]
+ case "--json":
+ f.asJSON = true
+ default:
+ rest = append(rest, args[i])
+ }
+ }
+ return f, rest, nil
+}
+
+func setupClient(
+ flagRepo string,
+) (*Client, string, error) {
+ cfg, err := loadConfig()
+ if err != nil {
+ return nil, "", err
+ }
+ repo, err := resolveRepo(flagRepo)
+ if err != nil {
+ return nil, "", err
+ }
+ return newClient(cfg), repo, nil
+}
+
+func outputJSON(v interface{}) error {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(v)
+}
+
+func age(t time.Time) string {
+ d := time.Since(t)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%dh", int(d.Hours()))
+ default:
+ return fmt.Sprintf("%dd", int(d.Hours()/24))
+ }
+}
+
+// --- pr list ---
+
+func runPRList(args []string) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+
+ state := "open"
+ for i := 0; i < len(rest); i++ {
+ if rest[i] == "--state" {
+ i++
+ if i >= len(rest) {
+ return fmt.Errorf("--state requires a value")
+ }
+ state = rest[i]
+ }
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ path := fmt.Sprintf("/%s/prs?state=%s", repo, state)
+ var prs []PR
+ if err := cl.getJSON(path, &prs); err != nil {
+ return err
+ }
+
+ if f.asJSON {
+ return outputJSON(prs)
+ }
+
+ tw := tabwriter.NewWriter(
+ os.Stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(tw, "#\tTITLE\tAUTHOR\tHEAD\tBASE\tAGE")
+ for _, p := range prs {
+ fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n",
+ p.Number, p.Title, p.Author,
+ p.Head, p.Base, age(p.Created))
+ }
+ return tw.Flush()
+}
+
+// --- pr view ---
+
+func runPRView(args []string) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+ if len(rest) < 1 {
+ return fmt.Errorf("usage: okg pr view NUMBER")
+ }
+ num, err := strconv.Atoi(rest[0])
+ if err != nil {
+ return fmt.Errorf("invalid PR number: %s", rest[0])
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ var p PR
+ path := fmt.Sprintf("/%s/pr/%d", repo, num)
+ if err := cl.getJSON(path, &p); err != nil {
+ return err
+ }
+
+ var comments []Comment
+ cpath := fmt.Sprintf("/%s/pr/%d/comments", repo, num)
+ if err := cl.getJSON(cpath, &comments); err != nil {
+ return err
+ }
+
+ if f.asJSON {
+ return outputJSON(map[string]interface{}{
+ "pr": p,
+ "comments": comments,
+ })
+ }
+
+ // Header.
+ state_str := p.State
+ if p.Merged {
+ state_str = "merged"
+ }
+ fmt.Printf("#%d %s (%s)\n", p.Number, p.Title, state_str)
+ fmt.Printf(" %s wants to merge %s into %s\n",
+ p.Author, p.Head, p.Base)
+ fmt.Printf(" Created %s\n", p.Created.Format(time.RFC3339))
+ if p.Body != "" {
+ fmt.Printf("\n%s\n", p.Body)
+ }
+
+ // Comments.
+ if len(comments) > 0 {
+ fmt.Printf("\n--- Comments ---\n")
+ for _, c := range comments {
+ verdict := ""
+ if c.Verdict != "" {
+ verdict = fmt.Sprintf(" [%s]", c.Verdict)
+ }
+ fmt.Printf("\n@%s%s (%s):\n%s\n",
+ c.Author, verdict,
+ c.Created.Format(time.RFC3339), c.Body)
+ }
+ }
+ return nil
+}
+
+// --- pr diff ---
+
+func runPRDiff(args []string) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+ if len(rest) < 1 {
+ return fmt.Errorf("usage: okg pr diff NUMBER")
+ }
+ num, err := strconv.Atoi(rest[0])
+ if err != nil {
+ return fmt.Errorf("invalid PR number: %s", rest[0])
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ var p PR
+ path := fmt.Sprintf("/%s/pr/%d", repo, num)
+ if err := cl.getJSON(path, &p); err != nil {
+ return err
+ }
+
+ // Run git diff locally.
+ cmd := exec.Command(
+ "git", "diff", p.Base+"..."+p.Head)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+// --- pr create ---
+
+func runPRCreate(args []string) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+
+ head := ""
+ base := "master"
+ title := ""
+ body := ""
+ for i := 0; i < len(rest); i++ {
+ switch rest[i] {
+ case "--head":
+ i++
+ if i >= len(rest) {
+ return fmt.Errorf("--head requires a value")
+ }
+ head = rest[i]
+ case "--base":
+ i++
+ if i >= len(rest) {
+ return fmt.Errorf("--base requires a value")
+ }
+ base = rest[i]
+ case "--title":
+ i++
+ if i >= len(rest) {
+ return fmt.Errorf("--title requires a value")
+ }
+ title = rest[i]
+ case "--body":
+ i++
+ if i >= len(rest) {
+ return fmt.Errorf("--body requires a value")
+ }
+ body = rest[i]
+ default:
+ return fmt.Errorf("unknown flag: %s", rest[i])
+ }
+ }
+
+ if head == "" {
+ return fmt.Errorf("--head is required")
+ }
+ if title == "" {
+ return fmt.Errorf("--title is required")
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ payload := map[string]string{
+ "head": head,
+ "base": base,
+ "title": title,
+ "body": body,
+ }
+ var p PR
+ path := fmt.Sprintf("/%s/prs", repo)
+ if err := cl.postJSON(path, payload, &p); err != nil {
+ return err
+ }
+
+ if f.asJSON {
+ return outputJSON(p)
+ }
+
+ fmt.Printf("Created PR #%d: %s\n", p.Number, p.Title)
+ fmt.Printf(" %s -> %s\n", p.Head, p.Base)
+ return nil
+}
+
+// --- pr comment ---
+
+func runPRComment(args []string) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+ if len(rest) < 1 {
+ return fmt.Errorf(
+ "usage: okg pr comment NUMBER --body BODY")
+ }
+ num, err := strconv.Atoi(rest[0])
+ if err != nil {
+ return fmt.Errorf("invalid PR number: %s", rest[0])
+ }
+ rest = rest[1:]
+
+ body := ""
+ verdict := ""
+ for i := 0; i < len(rest); i++ {
+ switch rest[i] {
+ case "--body":
+ i++
+ if i >= len(rest) {
+ return fmt.Errorf("--body requires a value")
+ }
+ body = rest[i]
+ case "--approve":
+ verdict = "approve"
+ case "--request-changes":
+ verdict = "request_changes"
+ default:
+ return fmt.Errorf("unknown flag: %s", rest[i])
+ }
+ }
+ if body == "" {
+ return fmt.Errorf("--body is required")
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ payload := map[string]string{
+ "body": body,
+ "verdict": verdict,
+ }
+ var c Comment
+ path := fmt.Sprintf("/%s/pr/%d/comments", repo, num)
+ if err := cl.postJSON(path, payload, &c); err != nil {
+ return err
+ }
+
+ if f.asJSON {
+ return outputJSON(c)
+ }
+
+ fmt.Printf("Comment #%d by @%s", c.ID, c.Author)
+ if c.Verdict != "" {
+ fmt.Printf(" [%s]", c.Verdict)
+ }
+ fmt.Printf(":\n%s\n", c.Body)
+ return nil
+}
+
+// --- pr merge ---
+
+func runPRMerge(args []string) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+ if len(rest) < 1 {
+ return fmt.Errorf("usage: okg pr merge NUMBER")
+ }
+ num, err := strconv.Atoi(rest[0])
+ if err != nil {
+ return fmt.Errorf("invalid PR number: %s", rest[0])
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ var p PR
+ path := fmt.Sprintf("/%s/pr/%d/merge", repo, num)
+ if err := cl.postJSON(path, nil, &p); err != nil {
+ return err
+ }
+
+ if f.asJSON {
+ return outputJSON(p)
+ }
+
+ fmt.Printf("PR #%d merged by @%s\n", p.Number, p.MergedBy)
+ return nil
+}
+
+// --- pr close ---
+
+func runPRClose(args []string) error {
+ return runPRStateChange("closed", args)
+}
+
+// --- pr reopen ---
+
+func runPRReopen(args []string) error {
+ return runPRStateChange("open", args)
+}
+
+func runPRStateChange(
+ new_state string, args []string,
+) error {
+ f, rest, err := parsePRFlags(args)
+ if err != nil {
+ return err
+ }
+ if len(rest) < 1 {
+ return fmt.Errorf(
+ "usage: okg pr close|reopen NUMBER")
+ }
+ num, err := strconv.Atoi(rest[0])
+ if err != nil {
+ return fmt.Errorf("invalid PR number: %s", rest[0])
+ }
+
+ cl, repo, err := setupClient(f.repo)
+ if err != nil {
+ return err
+ }
+
+ payload := map[string]string{"state": new_state}
+ var p PR
+ path := fmt.Sprintf("/%s/pr/%d", repo, num)
+ if err := cl.patchJSON(path, payload, &p); err != nil {
+ return err
+ }
+
+ if f.asJSON {
+ return outputJSON(p)
+ }
+
+ fmt.Printf("PR #%d is now %s\n", p.Number, p.State)
+ return nil
+}
diff --git a/repo.go b/repo.go
new file mode 100644
index 0000000..fe1e54f
--- /dev/null
+++ b/repo.go
@@ -0,0 +1,75 @@
+package main
+
+import "encoding/json"
+import "fmt"
+import "os"
+import "text/tabwriter"
+
+type repoInfo struct {
+ Name string `json:"name"`
+ IsPublic bool `json:"is_public"`
+ Authz *repoInfoAuthz `json:"authz"`
+}
+
+type repoInfoAuthz struct {
+ IsOwner bool `json:"is_owner"`
+ IsReader bool `json:"is_reader"`
+ OwnerUsername string `json:"owner_username"`
+ ReaderUsername string `json:"reader_username"`
+}
+
+type lsResponse struct {
+ Repos []repoInfo `json:"repos"`
+ CanCreateRepos bool `json:"can_create_repos"`
+}
+
+func runRepo(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("usage: okg repo list")
+ }
+ switch args[0] {
+ case "list":
+ return runRepoList(args[1:])
+ default:
+ return fmt.Errorf("unknown repo command: %s", args[0])
+ }
+}
+
+func runRepoList(args []string) error {
+ as_json := false
+ for _, a := range args {
+ if a == "--json" {
+ as_json = true
+ }
+ }
+
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ cl := newClient(cfg)
+
+ var res lsResponse
+ if err := cl.getJSON("/.ls", &res); err != nil {
+ return err
+ }
+
+ if as_json {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(res)
+ }
+
+ tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(tw, "NAME\tOWNER\tREADER")
+ for _, r := range res.Repos {
+ owner := ""
+ reader := ""
+ if r.Authz != nil {
+ owner = r.Authz.OwnerUsername
+ reader = r.Authz.ReaderUsername
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Name, owner, reader)
+ }
+ return tw.Flush()
+}
/dev/null
b/.gitignore
1
okg
/dev/null
b/README.md
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.
/dev/null
b/auth.go
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
}
/dev/null
b/client.go
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
}
/dev/null
b/config.go
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
}
/dev/null
b/go.mod
1
module oscarkilo.com/okg
2
3
go 1.23
/dev/null
b/main.go
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
}
/dev/null
b/okg_test.go
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
}
/dev/null
b/pr.go
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
}
/dev/null
b/repo.go
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
}