code.oscarkilo.com/okg

Hash:
6c0aa1c526f4d41219e486b1cf16ecd9407dbe8b
Author:
Igor Naverniouk <[email protected]>
Date:
Thu Mar 12 13:23:10 2026 -0400
Message:
Refactor okg CLI to use klee/ library package The okg binary now imports oscarkilo.com/okg/klee for all Klee API operations. Types (PR, Comment, CreateRepoRequest, etc.) and HTTP methods live in the library; the CLI files are thin wrappers that parse flags and format output. This makes the Klee client importable by other Go projects (e.g., lab/corp for the M11-B merge gateway).
diff --git a/auth.go b/auth.go
index 883902c..589977e 100644
--- a/auth.go
+++ b/auth.go
@@ -13,7 +13,8 @@ func runAuth(args []string) error {
case "login":
return runAuthLogin(args[1:])
default:
- return fmt.Errorf("unknown auth command: %s", args[0])
+ return fmt.Errorf(
+ "unknown auth command: %s", args[0])
}
}

@@ -25,24 +26,28 @@ func runAuthLogin(args []string) error {
case "--host":
i++
if i >= len(args) {
- return fmt.Errorf("--host requires a value")
+ return fmt.Errorf(
+ "--host requires a value")
}
host = args[i]
case "--user":
i++
if i >= len(args) {
- return fmt.Errorf("--user requires a value")
+ return fmt.Errorf(
+ "--user requires a value")
}
user = args[i]
default:
- return fmt.Errorf("unknown flag: %s", args[i])
+ return fmt.Errorf(
+ "unknown flag: %s", args[i])
}
}

reader := bufio.NewReader(os.Stdin)

if host == "" {
- fmt.Print("Host (default http://localhost:42069): ")
+ fmt.Print(
+ "Host (default http://localhost:42069): ")
line, _ := reader.ReadString('\n')
host = strings.TrimSpace(line)
if host == "" {
@@ -63,19 +68,19 @@ func runAuthLogin(args []string) error {
}
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 given, call profile edit to map
+ // the API key to this username in mock who.
if user != "" {
- cl := newClient(cfg)
+ cl := newKleeClient(cfg)
payload := map[string]string{
"username": user,
"name": user,
}
- var result map[string]string
- err := cl.postJSON(
- "/login/profile/edit", payload, &result)
+ err := cl.PostJSON(
+ "/login/profile/edit", payload, nil)
if err != nil {
- return fmt.Errorf("setting username: %v", err)
+ return fmt.Errorf(
+ "setting username: %v", err)
}
fmt.Printf("Authenticated as %s\n", user)
}
diff --git a/client.go b/client.go
index 14aaee3..d0dd78b 100644
--- a/client.go
+++ b/client.go
@@ -1,24 +1,27 @@
package main

-import "encoding/json"
import "fmt"
-import "io"
-import "net/http"
import "os"
import "os/exec"
import "regexp"
import "strings"

+import "oscarkilo.com/okg/klee"
+
var repoRegex = regexp.MustCompile(
- `code\.oscarkilo\.com/([a-z][-a-z0-9]*?)(?:\.git)?$`)
+ `code\.oscarkilo\.com/` +
+ `([a-z][-a-z0-9]*?)(?:\.git)?$`)

-// detectRepo parses the git remote URL for the klee repo name.
+// detectRepo parses the git remote URL for the
+// klee repo name.
func detectRepo() (string, error) {
- cmd := exec.Command("git", "remote", "get-url", "origin")
+ 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)
+ "not a git repo or no remote 'origin': %v",
+ err)
}
url := strings.TrimSpace(string(out))
m := repoRegex.FindStringSubmatch(url)
@@ -29,11 +32,11 @@ func detectRepo() (string, error) {
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
+// resolveRepo returns the repo name from --repo
+// flag, OKG_REPO env var, or git remote detection.
+func resolveRepo(flag_repo string) (string, error) {
+ if flag_repo != "" {
+ return flag_repo, nil
}
if v := os.Getenv("OKG_REPO"); v != "" {
return v, nil
@@ -41,109 +44,6 @@ func resolveRepo(flagRepo string) (string, error) {
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
+func newKleeClient(cfg *Config) *klee.Client {
+ return klee.NewClient(cfg.Host, cfg.ApiKey)
}
diff --git a/klee/klee.go b/klee/klee.go
index 3ac5577..106e64e 100644
--- a/klee/klee.go
+++ b/klee/klee.go
@@ -306,6 +306,17 @@ func (c *Client) patchJSON(
return nil
}

+// PostJSON performs a POST with a JSON body and
+// decodes the response. Useful for endpoints not
+// covered by typed methods.
+func (c *Client) PostJSON(
+ path string,
+ payload interface{},
+ dst interface{},
+) error {
+ return c.postJSON(path, payload, dst)
+}
+
// AgentName extracts the agent name from a Klee
// sub-user username. Given "operator.claude",
// returns "claude". If no dot, returns the input.
diff --git a/okg_test.go b/okg_test.go
index 62dffa9..8227575 100644
--- a/okg_test.go
+++ b/okg_test.go
@@ -8,29 +8,44 @@ import "strings"
import "testing"
import "time"

+import "oscarkilo.com/okg/klee"
+
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])
+ 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)
+ 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])
+ 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(
+ "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", "")
}

@@ -64,7 +79,8 @@ func TestParsePRFlags(t *testing.T) {
t.Fatal(err)
}
if f.repo != "widget" {
- t.Errorf("repo: want widget, got %q", f.repo)
+ t.Errorf(
+ "repo: want widget, got %q", f.repo)
}
if !f.asJSON {
t.Error("asJSON: want true")
@@ -80,7 +96,8 @@ func TestParsePRFlagsEmpty(t *testing.T) {
t.Fatal(err)
}
if f.repo != "" {
- t.Errorf("repo: want empty, got %q", f.repo)
+ t.Errorf(
+ "repo: want empty, got %q", f.repo)
}
if f.asJSON {
t.Error("asJSON: want false")
@@ -102,7 +119,9 @@ func TestAge(t *testing.T) {
t.Helper()
got := age(time.Now().Add(-d))
if got != want {
- t.Errorf("age(-%v): want %q, got %q", d, want, got)
+ t.Errorf(
+ "age(-%v): want %q, got %q",
+ d, want, got)
}
}
check(30*time.Second, "just now")
@@ -122,10 +141,14 @@ func TestConfigEnvOverrides(t *testing.T) {
t.Fatal(err)
}
if cfg.Host != "http://test:1234" {
- t.Errorf("Host: want http://test:1234, got %q", cfg.Host)
+ 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)
+ t.Errorf(
+ "ApiKey: want env-key, got %q",
+ cfg.ApiKey)
}
}

@@ -144,8 +167,7 @@ func TestConfigDefaultHost(t *testing.T) {
}

func TestRunRepoCreateArgs(t *testing.T) {
- // Mock server that accepts /.add-repo.
- mock := newMockKlee(t, 204, "")
+ mock := newMockKleeRepo(t, 204, "")
defer mock.Close()

os.Setenv("OKG_HOST", mock.URL)
@@ -159,19 +181,20 @@ func TestRunRepoCreateArgs(t *testing.T) {
if err != nil {
t.Fatal(err)
}
-
if mock.req.RepoName != "my-repo" {
- t.Errorf("repo_name: want my-repo, got %q",
+ t.Errorf(
+ "repo_name: want my-repo, got %q",
mock.req.RepoName)
}
if mock.req.ReaderUsername != "igor.agents" {
- t.Errorf("reader: want igor.agents, got %q",
+ t.Errorf(
+ "reader: want igor.agents, got %q",
mock.req.ReaderUsername)
}
}

func TestRunRepoCreateNoReader(t *testing.T) {
- mock := newMockKlee(t, 204, "")
+ mock := newMockKleeRepo(t, 204, "")
defer mock.Close()

os.Setenv("OKG_HOST", mock.URL)
@@ -183,9 +206,9 @@ func TestRunRepoCreateNoReader(t *testing.T) {
if err != nil {
t.Fatal(err)
}
-
if mock.req.ReaderUsername != "" {
- t.Errorf("reader: want empty, got %q",
+ t.Errorf(
+ "reader: want empty, got %q",
mock.req.ReaderUsername)
}
}
@@ -197,12 +220,15 @@ func TestRunRepoCreateMissingName(t *testing.T) {
}
}

-func TestRunRepoCreateReaderMissingValue(t *testing.T) {
+func TestRunRepoCreateReaderMissingValue(
+ t *testing.T,
+) {
err := runRepoCreate([]string{
"my-repo", "--reader",
})
if err == nil {
- t.Fatal("want error for --reader without value")
+ t.Fatal(
+ "want error for --reader without value")
}
}

@@ -215,17 +241,20 @@ func TestRunRepoCreateUnknownFlag(t *testing.T) {
}
}

-func TestRunRepoCreateDuplicateName(t *testing.T) {
+func TestRunRepoCreateDuplicateName(
+ t *testing.T,
+) {
err := runRepoCreate([]string{
"my-repo", "extra",
})
if err == nil {
- t.Fatal("want error for extra positional arg")
+ t.Fatal(
+ "want error for extra positional arg")
}
}

func TestRunRepoCreateServerError(t *testing.T) {
- mock := newMockKlee(t, 403,
+ mock := newMockKleeRepo(t, 403,
"you are not an owner of "+
"klee://code.oscarkilo.com/.new-repo")
defer mock.Close()
@@ -244,17 +273,17 @@ func TestRunRepoCreateServerError(t *testing.T) {
}
}

-// mockKlee captures the /.add-repo request body.
-type mockKlee struct {
+// mockKleeRepo captures the /.add-repo request.
+type mockKleeRepo struct {
*httptest.Server
- req createRepoRequest
+ req klee.CreateRepoRequest
}

-func newMockKlee(
+func newMockKleeRepo(
t *testing.T, status int, body string,
-) *mockKlee {
+) *mockKleeRepo {
t.Helper()
- mk := &mockKlee{}
+ mk := &mockKleeRepo{}
mk.Server = httptest.NewServer(
http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
diff --git a/pr.go b/pr.go
index 94e1e09..188b645 100644
--- a/pr.go
+++ b/pr.go
@@ -8,30 +8,7 @@ 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"`
-}
+import "oscarkilo.com/okg/klee"

func runPR(args []string) error {
if len(args) == 0 {
@@ -57,7 +34,8 @@ func runPR(args []string) error {
case "reopen":
return runPRReopen(args[1:])
default:
- return fmt.Errorf("unknown pr command: %s", args[0])
+ return fmt.Errorf(
+ "unknown pr command: %s", args[0])
}
}

@@ -92,17 +70,17 @@ func parsePRFlags(args []string) (
}

func setupClient(
- flagRepo string,
-) (*Client, string, error) {
+ flag_repo string,
+) (*klee.Client, string, error) {
cfg, err := loadConfig()
if err != nil {
return nil, "", err
}
- repo, err := resolveRepo(flagRepo)
+ repo, err := resolveRepo(flag_repo)
if err != nil {
return nil, "", err
}
- return newClient(cfg), repo, nil
+ return newKleeClient(cfg), repo, nil
}

func outputJSON(v interface{}) error {
@@ -121,7 +99,8 @@ func age(t time.Time) string {
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
- return fmt.Sprintf("%dd", int(d.Hours()/24))
+ return fmt.Sprintf(
+ "%dd", int(d.Hours()/24))
}
}

@@ -138,7 +117,8 @@ func runPRList(args []string) error {
if rest[i] == "--state" {
i++
if i >= len(rest) {
- return fmt.Errorf("--state requires a value")
+ return fmt.Errorf(
+ "--state requires a value")
}
state = rest[i]
}
@@ -149,9 +129,8 @@ func runPRList(args []string) error {
return err
}

- path := fmt.Sprintf("/%s/prs?state=%s", repo, state)
- var prs []PR
- if err := cl.getJSON(path, &prs); err != nil {
+ prs, err := cl.ListPRs(repo, state)
+ if err != nil {
return err
}

@@ -161,7 +140,8 @@ func runPRList(args []string) error {

tw := tabwriter.NewWriter(
os.Stdout, 0, 4, 2, ' ', 0)
- fmt.Fprintln(tw, "#\tTITLE\tAUTHOR\tHEAD\tBASE\tAGE")
+ 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,
@@ -178,11 +158,13 @@ func runPRView(args []string) error {
return err
}
if len(rest) < 1 {
- return fmt.Errorf("usage: okg pr view NUMBER")
+ 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])
+ return fmt.Errorf(
+ "invalid PR number: %s", rest[0])
}

cl, repo, err := setupClient(f.repo)
@@ -190,15 +172,13 @@ func runPRView(args []string) error {
return err
}

- var p PR
- path := fmt.Sprintf("/%s/pr/%d", repo, num)
- if err := cl.getJSON(path, &p); err != nil {
+ p, err := cl.GetPR(repo, num)
+ if err != nil {
return err
}

- var comments []Comment
- cpath := fmt.Sprintf("/%s/pr/%d/comments", repo, num)
- if err := cl.getJSON(cpath, &comments); err != nil {
+ comments, err := cl.GetComments(repo, num)
+ if err != nil {
return err
}

@@ -214,10 +194,12 @@ func runPRView(args []string) error {
if p.Merged {
state_str = "merged"
}
- fmt.Printf("#%d %s (%s)\n", p.Number, p.Title, state_str)
+ 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))
+ fmt.Printf(" Created %s\n",
+ p.Created.Format(time.RFC3339))
if p.Body != "" {
fmt.Printf("\n%s\n", p.Body)
}
@@ -228,7 +210,8 @@ func runPRView(args []string) error {
for _, c := range comments {
verdict := ""
if c.Verdict != "" {
- verdict = fmt.Sprintf(" [%s]", c.Verdict)
+ verdict = fmt.Sprintf(
+ " [%s]", c.Verdict)
}
fmt.Printf("\n@%s%s (%s):\n%s\n",
c.Author, verdict,
@@ -246,11 +229,13 @@ func runPRDiff(args []string) error {
return err
}
if len(rest) < 1 {
- return fmt.Errorf("usage: okg pr diff NUMBER")
+ 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])
+ return fmt.Errorf(
+ "invalid PR number: %s", rest[0])
}

cl, repo, err := setupClient(f.repo)
@@ -258,9 +243,8 @@ func runPRDiff(args []string) error {
return err
}

- var p PR
- path := fmt.Sprintf("/%s/pr/%d", repo, num)
- if err := cl.getJSON(path, &p); err != nil {
+ p, err := cl.GetPR(repo, num)
+ if err != nil {
return err
}

@@ -289,29 +273,34 @@ func runPRCreate(args []string) error {
case "--head":
i++
if i >= len(rest) {
- return fmt.Errorf("--head requires a value")
+ return fmt.Errorf(
+ "--head requires a value")
}
head = rest[i]
case "--base":
i++
if i >= len(rest) {
- return fmt.Errorf("--base requires a value")
+ return fmt.Errorf(
+ "--base requires a value")
}
base = rest[i]
case "--title":
i++
if i >= len(rest) {
- return fmt.Errorf("--title requires a value")
+ return fmt.Errorf(
+ "--title requires a value")
}
title = rest[i]
case "--body":
i++
if i >= len(rest) {
- return fmt.Errorf("--body requires a value")
+ return fmt.Errorf(
+ "--body requires a value")
}
body = rest[i]
default:
- return fmt.Errorf("unknown flag: %s", rest[i])
+ return fmt.Errorf(
+ "unknown flag: %s", rest[i])
}
}

@@ -327,15 +316,14 @@ func runPRCreate(args []string) error {
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 {
+ p, err := cl.CreatePR(repo,
+ klee.CreatePRRequest{
+ Head: head,
+ Base: base,
+ Title: title,
+ Body: body,
+ })
+ if err != nil {
return err
}

@@ -343,7 +331,8 @@ func runPRCreate(args []string) error {
return outputJSON(p)
}

- fmt.Printf("Created PR #%d: %s\n", p.Number, p.Title)
+ fmt.Printf(
+ "Created PR #%d: %s\n", p.Number, p.Title)
fmt.Printf(" %s -> %s\n", p.Head, p.Base)
return nil
}
@@ -361,7 +350,8 @@ func runPRComment(args []string) error {
}
num, err := strconv.Atoi(rest[0])
if err != nil {
- return fmt.Errorf("invalid PR number: %s", rest[0])
+ return fmt.Errorf(
+ "invalid PR number: %s", rest[0])
}
rest = rest[1:]

@@ -372,7 +362,8 @@ func runPRComment(args []string) error {
case "--body":
i++
if i >= len(rest) {
- return fmt.Errorf("--body requires a value")
+ return fmt.Errorf(
+ "--body requires a value")
}
body = rest[i]
case "--approve":
@@ -380,7 +371,8 @@ func runPRComment(args []string) error {
case "--request-changes":
verdict = "request_changes"
default:
- return fmt.Errorf("unknown flag: %s", rest[i])
+ return fmt.Errorf(
+ "unknown flag: %s", rest[i])
}
}
if body == "" {
@@ -392,13 +384,12 @@ func runPRComment(args []string) error {
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 {
+ c, err := cl.AddComment(repo, num,
+ klee.AddCommentRequest{
+ Body: body,
+ Verdict: verdict,
+ })
+ if err != nil {
return err
}

@@ -422,11 +413,13 @@ func runPRMerge(args []string) error {
return err
}
if len(rest) < 1 {
- return fmt.Errorf("usage: okg pr merge NUMBER")
+ 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])
+ return fmt.Errorf(
+ "invalid PR number: %s", rest[0])
}

cl, repo, err := setupClient(f.repo)
@@ -434,9 +427,8 @@ func runPRMerge(args []string) error {
return err
}

- var p PR
- path := fmt.Sprintf("/%s/pr/%d/merge", repo, num)
- if err := cl.postJSON(path, nil, &p); err != nil {
+ p, err := cl.MergePR(repo, num)
+ if err != nil {
return err
}

@@ -444,7 +436,8 @@ func runPRMerge(args []string) error {
return outputJSON(p)
}

- fmt.Printf("PR #%d merged by @%s\n", p.Number, p.MergedBy)
+ fmt.Printf(
+ "PR #%d merged by @%s\n", p.Number, p.MergedBy)
return nil
}

@@ -473,7 +466,8 @@ func runPRStateChange(
}
num, err := strconv.Atoi(rest[0])
if err != nil {
- return fmt.Errorf("invalid PR number: %s", rest[0])
+ return fmt.Errorf(
+ "invalid PR number: %s", rest[0])
}

cl, repo, err := setupClient(f.repo)
@@ -481,10 +475,8 @@ func runPRStateChange(
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 {
+ p, err := cl.SetPRState(repo, num, new_state)
+ if err != nil {
return err
}

@@ -492,6 +484,7 @@ func runPRStateChange(
return outputJSON(p)
}

- fmt.Printf("PR #%d is now %s\n", p.Number, p.State)
+ fmt.Printf(
+ "PR #%d is now %s\n", p.Number, p.State)
return nil
}
diff --git a/repo.go b/repo.go
index e34c3cd..7ccd22f 100644
--- a/repo.go
+++ b/repo.go
@@ -1,28 +1,11 @@
package main

-import "encoding/json"
import "fmt"
import "os"
import "strings"
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"`
-}
+import "oscarkilo.com/okg/klee"

func runRepo(args []string) error {
if len(args) == 0 {
@@ -40,11 +23,6 @@ func runRepo(args []string) error {
}
}

-type createRepoRequest struct {
- RepoName string `json:"repo_name"`
- ReaderUsername string `json:"reader_username"`
-}
-
func runRepoCreate(args []string) error {
reader := ""
name := ""
@@ -53,7 +31,8 @@ func runRepoCreate(args []string) error {
case "--reader":
i++
if i >= len(args) {
- return fmt.Errorf("--reader requires a value")
+ return fmt.Errorf(
+ "--reader requires a value")
}
reader = args[i]
default:
@@ -70,20 +49,20 @@ func runRepoCreate(args []string) error {
}
if name == "" {
return fmt.Errorf(
- "usage: okg repo create NAME [--reader USER]")
+ "usage: okg repo create NAME " +
+ "[--reader USER]")
}

cfg, err := loadConfig()
if err != nil {
return err
}
- cl := newClient(cfg)
+ cl := newKleeClient(cfg)

- req := createRepoRequest{
+ err = cl.CreateRepo(klee.CreateRepoRequest{
RepoName: name,
ReaderUsername: reader,
- }
- err = cl.postJSON("/.add-repo", req, nil)
+ })
if err != nil {
return err
}
@@ -107,20 +86,19 @@ func runRepoList(args []string) error {
if err != nil {
return err
}
- cl := newClient(cfg)
+ cl := newKleeClient(cfg)

- var res lsResponse
- if err := cl.getJSON("/.ls", &res); err != nil {
+ res, err := cl.ListRepos()
+ if err != nil {
return err
}

if as_json {
- enc := json.NewEncoder(os.Stdout)
- enc.SetIndent("", " ")
- return enc.Encode(res)
+ return outputJSON(res)
}

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