code.oscarkilo.com/okg

Hash:
9b4c953ff93cfffd0976d102fb173f4eef21ff92
Author:
Igor Naverniouk <[email protected]>
Date:
Thu Jun 4 20:57:06 2026 -0400
Message:
okg: add `okg group remove-member` POST /groups/leave via //okg/who's new LeaveGroup method. CLI shape: `okg group remove-member GROUP USER [USER ...]`. /groups/leave takes owids, not usernames — asymmetric with /groups/join. Rather than patch //who (which would cascade to fixing its current clients), the CLI does the translation: 1. GET /groups/list to resolve GROUP → group_owid. 2. POST /groups/members on group_owid to get username → owid map for the members. 3. POST /groups/leave for each (group_owid, member_owid). Two upfront round-trips plus N leave calls. The N members share the resolution, so a bulk remove is at most N+2 requests. //okg/who gets matching new methods + types: - GroupMembers(owid) → GroupMembersResponse {Up, Down, Usernames}. - LeaveGroup(LeaveGroupRequest{GroupOwid, MemberOwid}). Tests: TestGroupMembers pins the POST body shape; TestLeaveGroup pins the leave request body.
diff --git a/README.md b/README.md
index 59d442f..6a76993 100644
--- a/README.md
+++ b/README.md
@@ -56,9 +56,10 @@ okg pr reopen NUMBER

okg auth login [--key KEY] [--host HOST]

-okg group list [--json]
-okg group create NAME [--full-name TEXT] [--owner USER]
-okg group add-member GROUP USER [USER ...]
+okg group list [--json]
+okg group create NAME [--full-name TEXT] [--owner USER]
+okg group add-member GROUP USER [USER ...]
+okg group remove-member GROUP USER [USER ...]

okg embed [--model NAME] [--dims N] [--full-path]
(reads stdin → vectors on stdout)
diff --git a/group.go b/group.go
index beab408..7c03219 100644
--- a/group.go
+++ b/group.go
@@ -21,6 +21,8 @@ func runGroup(args []string) error {
return runGroupCreate(args[1:])
case "add-member":
return runGroupAddMember(args[1:])
+ case "remove-member":
+ return runGroupRemoveMember(args[1:])
default:
return fmt.Errorf(
"unknown group subcommand: %s", args[0])
@@ -137,6 +139,82 @@ func runGroupAddMember(args []string) error {
return nil
}

+func runGroupRemoveMember(args []string) error {
+ fs := flag.NewFlagSet(
+ "group remove-member", flag.ContinueOnError)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ positional := fs.Args()
+ if len(positional) < 2 {
+ return fmt.Errorf(
+ "usage: okg group remove-member " +
+ "GROUP USER [USER ...]")
+ }
+ groupName := positional[0]
+ members := positional[1:]
+
+ c, err := newWhoClient()
+ if err != nil {
+ return err
+ }
+
+ // /groups/leave wants owids, not usernames. Resolve the
+ // group's owid via ListGroups, then the members' owids via
+ // GroupMembers. Two round-trips on top of the actual leave
+ // calls; shared across all members in this invocation.
+ groupOwid, err := resolveGroupOwid(c, groupName)
+ if err != nil {
+ return err
+ }
+ ms, err := c.GroupMembers(groupOwid)
+ if err != nil {
+ return err
+ }
+ username2owid := make(map[string]string)
+ for owid, name := range ms.Usernames {
+ username2owid[name] = owid
+ }
+
+ for _, m := range members {
+ memberOwid, ok := username2owid[m]
+ if !ok {
+ return fmt.Errorf(
+ "user %q is not a member of %s", m, groupName)
+ }
+ if err := c.LeaveGroup(who.LeaveGroupRequest{
+ GroupOwid: groupOwid,
+ MemberOwid: memberOwid,
+ }); err != nil {
+ return fmt.Errorf(
+ "remove %s from %s: %v", m, groupName, err)
+ }
+ }
+ fmt.Printf(
+ "Removed %d member(s) from %s\n",
+ len(members), groupName)
+ return nil
+}
+
+// resolveGroupOwid translates a group username to its owid via
+// /groups/list. Returns an error if the group isn't visible to
+// the caller.
+func resolveGroupOwid(
+ c *who.HTTPClient, name string,
+) (string, error) {
+ groups, err := c.ListGroups()
+ if err != nil {
+ return "", err
+ }
+ for _, g := range groups {
+ if g.Username == name {
+ return g.Owid, nil
+ }
+ }
+ return "", fmt.Errorf(
+ "group %q not found (or not visible to caller)", name)
+}
+
// newWhoClient builds a //who client from saved config. Shared
// by every `okg group` subcommand.
func newWhoClient() (*who.HTTPClient, error) {
diff --git a/main.go b/main.go
index 23cd231..028166c 100644
--- a/main.go
+++ b/main.go
@@ -65,6 +65,8 @@ GROUPS

okg group add-member GROUP USER [USER ...]

+ okg group remove-member GROUP USER [USER ...]
+
PULL REQUESTS
okg pr list
--state STATE open or closed (default: open)
@@ -131,6 +133,7 @@ EXAMPLES
okg group list
okg group create chat-bots
okg group add-member chat-bots claude openclaw
+ okg group remove-member chat-bots openclaw

Pull requests
okg pr list --state open
diff --git a/who/who.go b/who/who.go
index aa2e82b..f15c81c 100644
--- a/who/who.go
+++ b/who/who.go
@@ -104,3 +104,42 @@ type JoinGroupsRequest struct {
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
return c.PostJSON("/groups/join", req, nil)
}
+
+// GroupMembersResponse is the response from POST
+// /groups/members. Up lists the groups that contain the queried
+// entity (each level as a slice of owids). Down lists its
+// members the same way. Usernames maps every owid mentioned to
+// its //who username.
+type GroupMembersResponse struct {
+ Up [][]string `json:"up"`
+ Down [][]string `json:"down"`
+ Usernames map[string]string `json:"usernames"`
+}
+
+// GroupMembers returns membership DAG navigation for the given
+// entity owid. For a group, Down enumerates its members.
+func (c *HTTPClient) GroupMembers(
+ ownerOwid string,
+) (*GroupMembersResponse, error) {
+ var res GroupMembersResponse
+ err := c.PostJSON("/groups/members",
+ map[string]string{"owid": ownerOwid}, &res)
+ if err != nil {
+ return nil, err
+ }
+ return &res, nil
+}
+
+// LeaveGroupRequest is the body for POST /groups/leave. Both
+// IDs are owids (obfuscated wids); to translate from usernames,
+// call ListGroups + GroupMembers first.
+type LeaveGroupRequest struct {
+ GroupOwid string `json:"group_owid"`
+ MemberOwid string `json:"member_owid"`
+}
+
+// LeaveGroup removes a member from a group. The caller must own
+// the group.
+func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
+ return c.PostJSON("/groups/leave", req, nil)
+}
diff --git a/who/who_test.go b/who/who_test.go
index d0d4da1..ad6c1c8 100644
--- a/who/who_test.go
+++ b/who/who_test.go
@@ -142,6 +142,76 @@ func TestCreateGroup(t *testing.T) {
}
}

+func TestGroupMembers(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/groups/members" {
+ t.Errorf(
+ "path: got %q, want /groups/members",
+ r.URL.Path)
+ }
+ var body map[string]string
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["owid"] != "team-owid" {
+ t.Errorf(
+ "owid: got %q, want team-owid", body["owid"])
+ }
+ json.NewEncoder(w).Encode(GroupMembersResponse{
+ Down: [][]string{
+ {"alice-owid", "bob-owid"},
+ },
+ Usernames: map[string]string{
+ "alice-owid": "alice",
+ "bob-owid": "bob",
+ },
+ })
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "test-key")
+ res, err := c.GroupMembers("team-owid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Usernames["alice-owid"] != "alice" {
+ t.Errorf(
+ "Usernames[alice-owid]: got %q",
+ res.Usernames["alice-owid"])
+ }
+}
+
+func TestLeaveGroup(t *testing.T) {
+ var seen LeaveGroupRequest
+ srv := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/groups/leave" {
+ t.Errorf(
+ "path: got %q, want /groups/leave",
+ r.URL.Path)
+ }
+ json.NewDecoder(r.Body).Decode(&seen)
+ w.WriteHeader(204)
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "test-key")
+ err := c.LeaveGroup(LeaveGroupRequest{
+ GroupOwid: "team-owid",
+ MemberOwid: "alice-owid",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen.GroupOwid != "team-owid" {
+ t.Errorf(
+ "GroupOwid: got %q", seen.GroupOwid)
+ }
+ if seen.MemberOwid != "alice-owid" {
+ t.Errorf(
+ "MemberOwid: got %q", seen.MemberOwid)
+ }
+}
+
func TestJoinGroups(t *testing.T) {
var seen JoinGroupsRequest
srv := httptest.NewServer(http.HandlerFunc(
a/README.md
b/README.md
1
# okg — Oscar Kilo Goodness
1
# okg — Oscar Kilo Goodness
2
2
3
A command-line tool that bundles Oscar Kilo's externally usable
3
A command-line tool that bundles Oscar Kilo's externally usable
4
services into one binary. Today: git ops against
4
services into one binary. Today: git ops against
5
[klee](https://code.oscarkilo.com), and LLM embeddings against
5
[klee](https://code.oscarkilo.com), and LLM embeddings against
6
[klex](https://oscarkilo.com/klex). More LLM utilities (`one`,
6
[klex](https://oscarkilo.com/klex). More LLM utilities (`one`,
7
`exemplary`) coming as the legacy `klex-git` binaries fold in.
7
`exemplary`) coming as the legacy `klex-git` binaries fold in.
8
8
9
Designed for both humans and AI agents (Claude, OpenClaw) to
9
Designed for both humans and AI agents (Claude, OpenClaw) to
10
use directly from the command line.
10
use directly from the command line.
11
11
12
## Install
12
## Install
13
13
14
```bash
14
```bash
15
go install oscarkilo.com/okg@latest
15
go install oscarkilo.com/okg@latest
16
```
16
```
17
17
18
Or build from source:
18
Or build from source:
19
19
20
```bash
20
```bash
21
git clone https://code.oscarkilo.com/okg
21
git clone https://code.oscarkilo.com/okg
22
cd okg && go build .
22
cd okg && go build .
23
```
23
```
24
24
25
## Setup
25
## Setup
26
26
27
By default okg talks to the production klee host
27
By default okg talks to the production klee host
28
(`https://code.oscarkilo.com`); the only setup you need is your
28
(`https://code.oscarkilo.com`); the only setup you need is your
29
API key. `okg auth login` saves it to
29
API key. `okg auth login` saves it to
30
`~/.config/okg/config.json`.
30
`~/.config/okg/config.json`.
31
31
32
```bash
32
```bash
33
# Non-interactive (for agents and scripts).
33
# Non-interactive (for agents and scripts).
34
okg auth login --key sk-...
34
okg auth login --key sk-...
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
36
36
37
# Interactive (prompts for host then key).
37
# Interactive (prompts for host then key).
38
okg auth login
38
okg auth login
39
```
39
```
40
40
41
Override the host for dev/local work with `--host` on `auth login`.
41
Override the host for dev/local work with `--host` on `auth login`.
42
42
43
## Commands
43
## Commands
44
44
45
```
45
```
46
okg repo list
46
okg repo list
47
47
48
okg pr list [--state open|closed]
48
okg pr list [--state open|closed]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
50
okg pr view NUMBER
50
okg pr view NUMBER
51
okg pr diff NUMBER
51
okg pr diff NUMBER
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
53
okg pr merge NUMBER
53
okg pr merge NUMBER
54
okg pr close NUMBER
54
okg pr close NUMBER
55
okg pr reopen NUMBER
55
okg pr reopen NUMBER
56
56
57
okg auth login [--key KEY] [--host HOST]
57
okg auth login [--key KEY] [--host HOST]
58
58
59
okg group list [--json]
59
okg group list [--json]
60
okg group create NAME [--full-name TEXT] [--owner USER]
60
okg group create NAME [--full-name TEXT] [--owner USER]
61
okg group add-member GROUP USER [USER ...]
61
okg group add-member GROUP USER [USER ...]
62
okg group remove-member GROUP USER [USER ...]
62
63
63
okg embed [--model NAME] [--dims N] [--full-path]
64
okg embed [--model NAME] [--dims N] [--full-path]
64
(reads stdin → vectors on stdout)
65
(reads stdin → vectors on stdout)
65
okg one [--model NAME] [--system-file FILE] \
66
okg one [--model NAME] [--system-file FILE] \
66
[--prompt-file FILE] [--attach FILE] \
67
[--prompt-file FILE] [--attach FILE] \
67
[--format text|json|jsonindent] [--fast-fail]
68
[--format text|json|jsonindent] [--fast-fail]
68
(reads stdin as a JSON MessagesRequest)
69
(reads stdin as a JSON MessagesRequest)
69
```
70
```
70
71
71
### Coming next
72
### Coming next
72
73
73
- `okg exemplary` — few-shot batch runner (from
74
- `okg exemplary` — few-shot batch runner (from
74
`klex-git/exemplary`).
75
`klex-git/exemplary`).
75
76
76
Once that lands, the standalone `klex-git` binaries are
77
Once that lands, the standalone `klex-git` binaries are
77
deprecated.
78
deprecated.
78
79
79
### Flags
80
### Flags
80
81
81
- `--repo REPO` overrides auto-detected repo name
82
- `--repo REPO` overrides auto-detected repo name
82
(normally parsed from `git remote get-url origin`)
83
(normally parsed from `git remote get-url origin`)
83
- `--json` outputs raw JSON for any command
84
- `--json` outputs raw JSON for any command
84
- `OKG_REPO` env var also overrides repo detection
85
- `OKG_REPO` env var also overrides repo detection
85
86
86
## Repo Detection
87
## Repo Detection
87
88
88
Like `gh`, okg detects the repo from the current directory's
89
Like `gh`, okg detects the repo from the current directory's
89
git remote:
90
git remote:
90
91
91
```
92
```
92
git remote get-url origin
93
git remote get-url origin
93
→ https://code.oscarkilo.com/widget.git
94
→ https://code.oscarkilo.com/widget.git
94
→ repo = "widget"
95
→ repo = "widget"
95
```
96
```
96
97
97
## Dependencies
98
## Dependencies
98
99
99
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
100
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
100
to be folded in once all `klex-git` binaries have moved here).
101
to be folded in once all `klex-git` binaries have moved here).
101
- Otherwise Go standard library only.
102
- Otherwise Go standard library only.
a/group.go
b/group.go
1
package main
1
package main
2
2
3
import "encoding/json"
3
import "encoding/json"
4
import "flag"
4
import "flag"
5
import "fmt"
5
import "fmt"
6
import "os"
6
import "os"
7
import "text/tabwriter"
7
import "text/tabwriter"
8
8
9
import "oscarkilo.com/okg/who"
9
import "oscarkilo.com/okg/who"
10
10
11
func runGroup(args []string) error {
11
func runGroup(args []string) error {
12
if len(args) == 0 {
12
if len(args) == 0 {
13
return fmt.Errorf(
13
return fmt.Errorf(
14
"usage: okg group SUBCOMMAND ... " +
14
"usage: okg group SUBCOMMAND ... " +
15
"(try `okg --help`)")
15
"(try `okg --help`)")
16
}
16
}
17
switch args[0] {
17
switch args[0] {
18
case "list":
18
case "list":
19
return runGroupList(args[1:])
19
return runGroupList(args[1:])
20
case "create":
20
case "create":
21
return runGroupCreate(args[1:])
21
return runGroupCreate(args[1:])
22
case "add-member":
22
case "add-member":
23
return runGroupAddMember(args[1:])
23
return runGroupAddMember(args[1:])
24
case "remove-member":
25
return runGroupRemoveMember(args[1:])
24
default:
26
default:
25
return fmt.Errorf(
27
return fmt.Errorf(
26
"unknown group subcommand: %s", args[0])
28
"unknown group subcommand: %s", args[0])
27
}
29
}
28
}
30
}
29
31
30
func runGroupList(args []string) error {
32
func runGroupList(args []string) error {
31
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
33
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
32
asJSON := fs.Bool("json", false, "output raw JSON")
34
asJSON := fs.Bool("json", false, "output raw JSON")
33
if err := fs.Parse(args); err != nil {
35
if err := fs.Parse(args); err != nil {
34
return err
36
return err
35
}
37
}
36
38
37
c, err := newWhoClient()
39
c, err := newWhoClient()
38
if err != nil {
40
if err != nil {
39
return err
41
return err
40
}
42
}
41
43
42
groups, err := c.ListGroups()
44
groups, err := c.ListGroups()
43
if err != nil {
45
if err != nil {
44
return err
46
return err
45
}
47
}
46
48
47
if *asJSON {
49
if *asJSON {
48
buf, err := json.MarshalIndent(groups, "", " ")
50
buf, err := json.MarshalIndent(groups, "", " ")
49
if err != nil {
51
if err != nil {
50
return err
52
return err
51
}
53
}
52
fmt.Println(string(buf))
54
fmt.Println(string(buf))
53
return nil
55
return nil
54
}
56
}
55
57
56
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
58
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
57
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
59
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
58
for _, g := range groups {
60
for _, g := range groups {
59
fmt.Fprintf(tw, "%s\t%s\t%s\n",
61
fmt.Fprintf(tw, "%s\t%s\t%s\n",
60
g.Username, g.Name, g.OwnerUsername)
62
g.Username, g.Name, g.OwnerUsername)
61
}
63
}
62
return tw.Flush()
64
return tw.Flush()
63
}
65
}
64
66
65
func runGroupCreate(args []string) error {
67
func runGroupCreate(args []string) error {
66
fs := flag.NewFlagSet(
68
fs := flag.NewFlagSet(
67
"group create", flag.ContinueOnError)
69
"group create", flag.ContinueOnError)
68
fullName := fs.String("full-name", "",
70
fullName := fs.String("full-name", "",
69
"display name (default: NAME)")
71
"display name (default: NAME)")
70
owner := fs.String("owner", "",
72
owner := fs.String("owner", "",
71
"owner username (default: caller)")
73
"owner username (default: caller)")
72
if err := fs.Parse(args); err != nil {
74
if err := fs.Parse(args); err != nil {
73
return err
75
return err
74
}
76
}
75
positional := fs.Args()
77
positional := fs.Args()
76
if len(positional) != 1 {
78
if len(positional) != 1 {
77
return fmt.Errorf("usage: okg group create NAME")
79
return fmt.Errorf("usage: okg group create NAME")
78
}
80
}
79
name := positional[0]
81
name := positional[0]
80
82
81
c, err := newWhoClient()
83
c, err := newWhoClient()
82
if err != nil {
84
if err != nil {
83
return err
85
return err
84
}
86
}
85
87
86
if *owner == "" {
88
if *owner == "" {
87
me, err := c.GetProfile()
89
me, err := c.GetProfile()
88
if err != nil {
90
if err != nil {
89
return fmt.Errorf("resolve caller: %v", err)
91
return fmt.Errorf("resolve caller: %v", err)
90
}
92
}
91
*owner = me.Username
93
*owner = me.Username
92
}
94
}
93
95
94
displayName := *fullName
96
displayName := *fullName
95
if displayName == "" {
97
if displayName == "" {
96
displayName = name
98
displayName = name
97
}
99
}
98
100
99
if err := c.CreateGroup(who.CreateGroupRequest{
101
if err := c.CreateGroup(who.CreateGroupRequest{
100
Username: name,
102
Username: name,
101
Name: displayName,
103
Name: displayName,
102
OwnerUsername: *owner,
104
OwnerUsername: *owner,
103
}); err != nil {
105
}); err != nil {
104
return err
106
return err
105
}
107
}
106
fmt.Printf(
108
fmt.Printf(
107
"Created group %s (owner: %s)\n", name, *owner)
109
"Created group %s (owner: %s)\n", name, *owner)
108
return nil
110
return nil
109
}
111
}
110
112
111
func runGroupAddMember(args []string) error {
113
func runGroupAddMember(args []string) error {
112
fs := flag.NewFlagSet(
114
fs := flag.NewFlagSet(
113
"group add-member", flag.ContinueOnError)
115
"group add-member", flag.ContinueOnError)
114
if err := fs.Parse(args); err != nil {
116
if err := fs.Parse(args); err != nil {
115
return err
117
return err
116
}
118
}
117
positional := fs.Args()
119
positional := fs.Args()
118
if len(positional) < 2 {
120
if len(positional) < 2 {
119
return fmt.Errorf(
121
return fmt.Errorf(
120
"usage: okg group add-member GROUP USER [USER ...]")
122
"usage: okg group add-member GROUP USER [USER ...]")
121
}
123
}
122
group := positional[0]
124
group := positional[0]
123
members := positional[1:]
125
members := positional[1:]
124
126
125
c, err := newWhoClient()
127
c, err := newWhoClient()
126
if err != nil {
128
if err != nil {
127
return err
129
return err
128
}
130
}
129
if err := c.JoinGroups(who.JoinGroupsRequest{
131
if err := c.JoinGroups(who.JoinGroupsRequest{
130
GroupUsernames: []string{group},
132
GroupUsernames: []string{group},
131
MemberUsernames: members,
133
MemberUsernames: members,
132
}); err != nil {
134
}); err != nil {
133
return err
135
return err
134
}
136
}
135
fmt.Printf(
137
fmt.Printf(
136
"Added %d member(s) to %s\n", len(members), group)
138
"Added %d member(s) to %s\n", len(members), group)
137
return nil
139
return nil
138
}
140
}
139
141
142
func runGroupRemoveMember(args []string) error {
143
fs := flag.NewFlagSet(
144
"group remove-member", flag.ContinueOnError)
145
if err := fs.Parse(args); err != nil {
146
return err
147
}
148
positional := fs.Args()
149
if len(positional) < 2 {
150
return fmt.Errorf(
151
"usage: okg group remove-member " +
152
"GROUP USER [USER ...]")
153
}
154
groupName := positional[0]
155
members := positional[1:]
156
157
c, err := newWhoClient()
158
if err != nil {
159
return err
160
}
161
162
// /groups/leave wants owids, not usernames. Resolve the
163
// group's owid via ListGroups, then the members' owids via
164
// GroupMembers. Two round-trips on top of the actual leave
165
// calls; shared across all members in this invocation.
166
groupOwid, err := resolveGroupOwid(c, groupName)
167
if err != nil {
168
return err
169
}
170
ms, err := c.GroupMembers(groupOwid)
171
if err != nil {
172
return err
173
}
174
username2owid := make(map[string]string)
175
for owid, name := range ms.Usernames {
176
username2owid[name] = owid
177
}
178
179
for _, m := range members {
180
memberOwid, ok := username2owid[m]
181
if !ok {
182
return fmt.Errorf(
183
"user %q is not a member of %s", m, groupName)
184
}
185
if err := c.LeaveGroup(who.LeaveGroupRequest{
186
GroupOwid: groupOwid,
187
MemberOwid: memberOwid,
188
}); err != nil {
189
return fmt.Errorf(
190
"remove %s from %s: %v", m, groupName, err)
191
}
192
}
193
fmt.Printf(
194
"Removed %d member(s) from %s\n",
195
len(members), groupName)
196
return nil
197
}
198
199
// resolveGroupOwid translates a group username to its owid via
200
// /groups/list. Returns an error if the group isn't visible to
201
// the caller.
202
func resolveGroupOwid(
203
c *who.HTTPClient, name string,
204
) (string, error) {
205
groups, err := c.ListGroups()
206
if err != nil {
207
return "", err
208
}
209
for _, g := range groups {
210
if g.Username == name {
211
return g.Owid, nil
212
}
213
}
214
return "", fmt.Errorf(
215
"group %q not found (or not visible to caller)", name)
216
}
217
140
// newWhoClient builds a //who client from saved config. Shared
218
// newWhoClient builds a //who client from saved config. Shared
141
// by every `okg group` subcommand.
219
// by every `okg group` subcommand.
142
func newWhoClient() (*who.HTTPClient, error) {
220
func newWhoClient() (*who.HTTPClient, error) {
143
cfg, err := loadConfig()
221
cfg, err := loadConfig()
144
if err != nil {
222
if err != nil {
145
return nil, err
223
return nil, err
146
}
224
}
147
if cfg.ApiKey == "" {
225
if cfg.ApiKey == "" {
148
return nil, fmt.Errorf(
226
return nil, fmt.Errorf(
149
"no API key — run `okg auth login --key sk-...`")
227
"no API key — run `okg auth login --key sk-...`")
150
}
228
}
151
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
229
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
152
}
230
}
a/main.go
b/main.go
1
package main
1
package main
2
2
3
import "fmt"
3
import "fmt"
4
import "os"
4
import "os"
5
5
6
func main() {
6
func main() {
7
args := os.Args[1:]
7
args := os.Args[1:]
8
if len(args) == 0 {
8
if len(args) == 0 {
9
printUsage()
9
printUsage()
10
os.Exit(1)
10
os.Exit(1)
11
}
11
}
12
12
13
var err error
13
var err error
14
switch args[0] {
14
switch args[0] {
15
case "pr":
15
case "pr":
16
err = runPR(args[1:])
16
err = runPR(args[1:])
17
case "repo":
17
case "repo":
18
err = runRepo(args[1:])
18
err = runRepo(args[1:])
19
case "auth":
19
case "auth":
20
err = runAuth(args[1:])
20
err = runAuth(args[1:])
21
case "embed":
21
case "embed":
22
err = runEmbed(args[1:])
22
err = runEmbed(args[1:])
23
case "one":
23
case "one":
24
err = runOne(args[1:])
24
err = runOne(args[1:])
25
case "group":
25
case "group":
26
err = runGroup(args[1:])
26
err = runGroup(args[1:])
27
case "help", "--help", "-h":
27
case "help", "--help", "-h":
28
printUsage()
28
printUsage()
29
return
29
return
30
default:
30
default:
31
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
31
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
32
printUsage()
32
printUsage()
33
os.Exit(1)
33
os.Exit(1)
34
}
34
}
35
35
36
if err != nil {
36
if err != nil {
37
fmt.Fprintf(os.Stderr, "error: %v\n", err)
37
fmt.Fprintf(os.Stderr, "error: %v\n", err)
38
os.Exit(1)
38
os.Exit(1)
39
}
39
}
40
}
40
}
41
41
42
func printUsage() {
42
func printUsage() {
43
fmt.Fprintf(os.Stderr, `NAME
43
fmt.Fprintf(os.Stderr, `NAME
44
okg — Oscar Kilo Goodness
44
okg — Oscar Kilo Goodness
45
45
46
SETUP
46
SETUP
47
okg auth login
47
okg auth login
48
--key KEY API key (also accepted via stdin)
48
--key KEY API key (also accepted via stdin)
49
--host HOST klee host (default: production)
49
--host HOST klee host (default: production)
50
50
51
GIT REPOS
51
GIT REPOS
52
okg repo list
52
okg repo list
53
--json output raw JSON
53
--json output raw JSON
54
54
55
okg repo create NAME
55
okg repo create NAME
56
--reader USER grant read to USER (default: anyone)
56
--reader USER grant read to USER (default: anyone)
57
57
58
GROUPS
58
GROUPS
59
okg group list
59
okg group list
60
--json output raw JSON
60
--json output raw JSON
61
61
62
okg group create NAME
62
okg group create NAME
63
--full-name TEXT display name (default: NAME)
63
--full-name TEXT display name (default: NAME)
64
--owner USER owner username (default: caller)
64
--owner USER owner username (default: caller)
65
65
66
okg group add-member GROUP USER [USER ...]
66
okg group add-member GROUP USER [USER ...]
67
67
68
okg group remove-member GROUP USER [USER ...]
69
68
PULL REQUESTS
70
PULL REQUESTS
69
okg pr list
71
okg pr list
70
--state STATE open or closed (default: open)
72
--state STATE open or closed (default: open)
71
--json output raw JSON
73
--json output raw JSON
72
74
73
okg pr create
75
okg pr create
74
--head BRANCH source branch
76
--head BRANCH source branch
75
--base BRANCH target branch (default: master)
77
--base BRANCH target branch (default: master)
76
--title TITLE PR title
78
--title TITLE PR title
77
--body BODY PR body (optional)
79
--body BODY PR body (optional)
78
--json output raw JSON
80
--json output raw JSON
79
81
80
okg pr view NUMBER
82
okg pr view NUMBER
81
--json output raw JSON
83
--json output raw JSON
82
84
83
okg pr diff NUMBER
85
okg pr diff NUMBER
84
86
85
okg pr comment NUMBER
87
okg pr comment NUMBER
86
--body BODY comment body
88
--body BODY comment body
87
--approve also approve the PR
89
--approve also approve the PR
88
--request-changes also request changes
90
--request-changes also request changes
89
91
90
okg pr merge NUMBER
92
okg pr merge NUMBER
91
--json output raw JSON
93
--json output raw JSON
92
94
93
okg pr close NUMBER
95
okg pr close NUMBER
94
--json output raw JSON
96
--json output raw JSON
95
97
96
okg pr reopen NUMBER
98
okg pr reopen NUMBER
97
--json output raw JSON
99
--json output raw JSON
98
100
99
ARTIFICIAL INTELLIGENCE
101
ARTIFICIAL INTELLIGENCE
100
okg embed
102
okg embed
101
--model NAME embedding model
103
--model NAME embedding model
102
(default: openai:text-embedding-3-small)
104
(default: openai:text-embedding-3-small)
103
--dims N number of dimensions (default: 1536)
105
--dims N number of dimensions (default: 1536)
104
--full-path one vector per prefix of input
106
--full-path one vector per prefix of input
105
(reads stdin; writes vectors to stdout, one per line)
107
(reads stdin; writes vectors to stdout, one per line)
106
108
107
okg one
109
okg one
108
--model NAME override .Model in the request
110
--model NAME override .Model in the request
109
--system-file FILE override .System with contents of FILE
111
--system-file FILE override .System with contents of FILE
110
--prompt-file FILE append FILE as a user prompt
112
--prompt-file FILE append FILE as a user prompt
111
--attach FILE attach an image or PDF to the prompt
113
--attach FILE attach an image or PDF to the prompt
112
--format FORMAT text | json | jsonindent (default: text)
114
--format FORMAT text | json | jsonindent (default: text)
113
--fast-fail preflight attachment MIME (default: on)
115
--fast-fail preflight attachment MIME (default: on)
114
(reads stdin as a JSON MessagesRequest; flags override
116
(reads stdin as a JSON MessagesRequest; flags override
115
its fields)
117
its fields)
116
118
117
GLOBAL FLAGS
119
GLOBAL FLAGS
118
--repo REPO override auto-detected repo name
120
--repo REPO override auto-detected repo name
119
--json output raw JSON (where applicable)
121
--json output raw JSON (where applicable)
120
122
121
EXAMPLES
123
EXAMPLES
122
Setup
124
Setup
123
okg auth login --key sk-...
125
okg auth login --key sk-...
124
cat ~/.klex.key | okg auth login
126
cat ~/.klex.key | okg auth login
125
127
126
Git repos
128
Git repos
127
okg repo list
129
okg repo list
128
okg repo create my-new-repo
130
okg repo create my-new-repo
129
131
130
Groups
132
Groups
131
okg group list
133
okg group list
132
okg group create chat-bots
134
okg group create chat-bots
133
okg group add-member chat-bots claude openclaw
135
okg group add-member chat-bots claude openclaw
136
okg group remove-member chat-bots openclaw
134
137
135
Pull requests
138
Pull requests
136
okg pr list --state open
139
okg pr list --state open
137
okg pr view 42
140
okg pr view 42
138
okg pr comment 42 --body 'LGTM' --approve
141
okg pr comment 42 --body 'LGTM' --approve
139
142
140
Artificial intelligence
143
Artificial intelligence
141
echo 'hello world' | okg embed --dims 384
144
echo 'hello world' | okg embed --dims 384
142
echo Hello? > /tmp/q.txt && \
145
echo Hello? > /tmp/q.txt && \
143
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
146
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
144
`)
147
`)
145
}
148
}
a/who/who.go
b/who/who.go
1
// Package who is the public HTTP client for OscarKilo's //who
1
// Package who is the public HTTP client for OscarKilo's //who
2
// service. It is the world-public side of //who, used by
2
// service. It is the world-public side of //who, used by
3
// `okg` and any other tool that talks to oscarkilo.com over
3
// `okg` and any other tool that talks to oscarkilo.com over
4
// the network.
4
// the network.
5
//
5
//
6
// //clients/who (intra-cluster, has the in-process MockClient,
6
// //clients/who (intra-cluster, has the in-process MockClient,
7
// the /internal/* calls, the dev/prod factory) will eventually
7
// the /internal/* calls, the dev/prod factory) will eventually
8
// import this package via `import . "oscarkilo.com/okg/who"`
8
// import this package via `import . "oscarkilo.com/okg/who"`
9
// so the two share types and request paths. To make that dot
9
// so the two share types and request paths. To make that dot
10
// import work, the struct here is named HTTPClient — leaving
10
// import work, the struct here is named HTTPClient — leaving
11
// the unqualified `Client` name free for //clients/who's
11
// the unqualified `Client` name free for //clients/who's
12
// existing interface.
12
// existing interface.
13
package who
13
package who
14
14
15
import "oscarkilo.com/okg/internal/rest"
15
import "oscarkilo.com/okg/internal/rest"
16
16
17
// HTTPClient talks to a //who server over HTTPS. Embeds the
17
// HTTPClient talks to a //who server over HTTPS. Embeds the
18
// shared rest helpers; who-specific methods live below.
18
// shared rest helpers; who-specific methods live below.
19
type HTTPClient struct {
19
type HTTPClient struct {
20
*rest.Client
20
*rest.Client
21
}
21
}
22
22
23
func NewHTTPClient(host, apiKey string) *HTTPClient {
23
func NewHTTPClient(host, apiKey string) *HTTPClient {
24
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
24
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
25
}
25
}
26
26
27
// ---- Caller identity ----
27
// ---- Caller identity ----
28
28
29
// Profile is the caller's //who profile. Mirrors the
29
// Profile is the caller's //who profile. Mirrors the
30
// JSON-serialized subset of //who.Profile that GET
30
// JSON-serialized subset of //who.Profile that GET
31
// /login/profile/get returns. Extra fields the server may
31
// /login/profile/get returns. Extra fields the server may
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
33
// permissively if added later; we just don't expose them here
33
// permissively if added later; we just don't expose them here
34
// until okg has a use for them.
34
// until okg has a use for them.
35
type Profile struct {
35
type Profile struct {
36
Owid string `json:"owid"`
36
Owid string `json:"owid"`
37
Username string `json:"username"`
37
Username string `json:"username"`
38
Name string `json:"name"`
38
Name string `json:"name"`
39
Email string `json:"email"`
39
Email string `json:"email"`
40
PortraitUrl string `json:"portrait_url"`
40
PortraitUrl string `json:"portrait_url"`
41
OwnerOwid string `json:"owner_owid"`
41
OwnerOwid string `json:"owner_owid"`
42
Groups []string `json:"groups"`
42
Groups []string `json:"groups"`
43
}
43
}
44
44
45
// GetProfile returns the caller's own profile, identified by
45
// GetProfile returns the caller's own profile, identified by
46
// the Bearer key.
46
// the Bearer key.
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
48
var p Profile
48
var p Profile
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
50
return nil, err
50
return nil, err
51
}
51
}
52
return &p, nil
52
return &p, nil
53
}
53
}
54
54
55
// ---- Groups ----
55
// ---- Groups ----
56
56
57
// Group describes a //who group (a named entity with members).
57
// Group describes a //who group (a named entity with members).
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
59
type Group struct {
59
type Group struct {
60
Owid string `json:"owid"`
60
Owid string `json:"owid"`
61
Username string `json:"username"`
61
Username string `json:"username"`
62
Name string `json:"name"`
62
Name string `json:"name"`
63
OwnerOwid string `json:"owner_owid"`
63
OwnerOwid string `json:"owner_owid"`
64
OwnerUsername string `json:"owner_username"`
64
OwnerUsername string `json:"owner_username"`
65
}
65
}
66
66
67
type listGroupsResponse struct {
67
type listGroupsResponse struct {
68
Groups []Group `json:"groups"`
68
Groups []Group `json:"groups"`
69
}
69
}
70
70
71
// ListGroups returns the groups visible to the caller.
71
// ListGroups returns the groups visible to the caller.
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
73
var res listGroupsResponse
73
var res listGroupsResponse
74
if err := c.GetJSON("/groups/list", &res); err != nil {
74
if err := c.GetJSON("/groups/list", &res); err != nil {
75
return nil, err
75
return nil, err
76
}
76
}
77
return res.Groups, nil
77
return res.Groups, nil
78
}
78
}
79
79
80
// CreateGroupRequest is the body for POST /groups/add. Username
80
// CreateGroupRequest is the body for POST /groups/add. Username
81
// is the group's //who username (e.g. "team"); Name is the
81
// is the group's //who username (e.g. "team"); Name is the
82
// human-facing display name; OwnerUsername is the user who'll
82
// human-facing display name; OwnerUsername is the user who'll
83
// own the new group.
83
// own the new group.
84
type CreateGroupRequest struct {
84
type CreateGroupRequest struct {
85
Username string `json:"username"`
85
Username string `json:"username"`
86
Name string `json:"name"`
86
Name string `json:"name"`
87
OwnerUsername string `json:"owner_username"`
87
OwnerUsername string `json:"owner_username"`
88
}
88
}
89
89
90
// CreateGroup creates a new //who group.
90
// CreateGroup creates a new //who group.
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
92
return c.PostJSON("/groups/add", req, nil)
92
return c.PostJSON("/groups/add", req, nil)
93
}
93
}
94
94
95
// JoinGroupsRequest is the body for POST /groups/join. The
95
// JoinGroupsRequest is the body for POST /groups/join. The
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
97
// pair, requiring the caller to be an owner of each group.
97
// pair, requiring the caller to be an owner of each group.
98
type JoinGroupsRequest struct {
98
type JoinGroupsRequest struct {
99
GroupUsernames []string `json:"group_usernames"`
99
GroupUsernames []string `json:"group_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
101
}
101
}
102
102
103
// JoinGroups adds members to groups (all pairwise combinations).
103
// JoinGroups adds members to groups (all pairwise combinations).
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
105
return c.PostJSON("/groups/join", req, nil)
105
return c.PostJSON("/groups/join", req, nil)
106
}
106
}
107
108
// GroupMembersResponse is the response from POST
109
// /groups/members. Up lists the groups that contain the queried
110
// entity (each level as a slice of owids). Down lists its
111
// members the same way. Usernames maps every owid mentioned to
112
// its //who username.
113
type GroupMembersResponse struct {
114
Up [][]string `json:"up"`
115
Down [][]string `json:"down"`
116
Usernames map[string]string `json:"usernames"`
117
}
118
119
// GroupMembers returns membership DAG navigation for the given
120
// entity owid. For a group, Down enumerates its members.
121
func (c *HTTPClient) GroupMembers(
122
ownerOwid string,
123
) (*GroupMembersResponse, error) {
124
var res GroupMembersResponse
125
err := c.PostJSON("/groups/members",
126
map[string]string{"owid": ownerOwid}, &res)
127
if err != nil {
128
return nil, err
129
}
130
return &res, nil
131
}
132
133
// LeaveGroupRequest is the body for POST /groups/leave. Both
134
// IDs are owids (obfuscated wids); to translate from usernames,
135
// call ListGroups + GroupMembers first.
136
type LeaveGroupRequest struct {
137
GroupOwid string `json:"group_owid"`
138
MemberOwid string `json:"member_owid"`
139
}
140
141
// LeaveGroup removes a member from a group. The caller must own
142
// the group.
143
func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
144
return c.PostJSON("/groups/leave", req, nil)
145
}
a/who/who_test.go
b/who/who_test.go
1
package who
1
package who
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 "testing"
6
import "testing"
7
7
8
func TestListGroups(t *testing.T) {
8
func TestListGroups(t *testing.T) {
9
srv := httptest.NewServer(http.HandlerFunc(
9
srv := httptest.NewServer(http.HandlerFunc(
10
func(w http.ResponseWriter, r *http.Request) {
10
func(w http.ResponseWriter, r *http.Request) {
11
if r.URL.Path != "/groups/list" {
11
if r.URL.Path != "/groups/list" {
12
t.Errorf("path: got %q, want /groups/list",
12
t.Errorf("path: got %q, want /groups/list",
13
r.URL.Path)
13
r.URL.Path)
14
}
14
}
15
if r.Header.Get("Authorization") !=
15
if r.Header.Get("Authorization") !=
16
"Bearer test-key" {
16
"Bearer test-key" {
17
t.Errorf("Authorization: got %q",
17
t.Errorf("Authorization: got %q",
18
r.Header.Get("Authorization"))
18
r.Header.Get("Authorization"))
19
}
19
}
20
w.Header().Set(
20
w.Header().Set(
21
"Content-Type", "application/json")
21
"Content-Type", "application/json")
22
json.NewEncoder(w).Encode(listGroupsResponse{
22
json.NewEncoder(w).Encode(listGroupsResponse{
23
Groups: []Group{
23
Groups: []Group{
24
{
24
{
25
Username: "team",
25
Username: "team",
26
Name: "Team",
26
Name: "Team",
27
OwnerUsername: "alice",
27
OwnerUsername: "alice",
28
},
28
},
29
{
29
{
30
Username: "admins",
30
Username: "admins",
31
Name: "Admins",
31
Name: "Admins",
32
OwnerUsername: "alice",
32
OwnerUsername: "alice",
33
},
33
},
34
},
34
},
35
})
35
})
36
}))
36
}))
37
defer srv.Close()
37
defer srv.Close()
38
38
39
c := NewHTTPClient(srv.URL, "test-key")
39
c := NewHTTPClient(srv.URL, "test-key")
40
groups, err := c.ListGroups()
40
groups, err := c.ListGroups()
41
if err != nil {
41
if err != nil {
42
t.Fatal(err)
42
t.Fatal(err)
43
}
43
}
44
if len(groups) != 2 {
44
if len(groups) != 2 {
45
t.Fatalf("len: got %d, want 2", len(groups))
45
t.Fatalf("len: got %d, want 2", len(groups))
46
}
46
}
47
if groups[0].Username != "team" {
47
if groups[0].Username != "team" {
48
t.Errorf(
48
t.Errorf(
49
"groups[0].Username: got %q, want team",
49
"groups[0].Username: got %q, want team",
50
groups[0].Username)
50
groups[0].Username)
51
}
51
}
52
if groups[1].OwnerUsername != "alice" {
52
if groups[1].OwnerUsername != "alice" {
53
t.Errorf(
53
t.Errorf(
54
"groups[1].OwnerUsername: got %q, want alice",
54
"groups[1].OwnerUsername: got %q, want alice",
55
groups[1].OwnerUsername)
55
groups[1].OwnerUsername)
56
}
56
}
57
}
57
}
58
58
59
func TestListGroupsHTTPError(t *testing.T) {
59
func TestListGroupsHTTPError(t *testing.T) {
60
srv := httptest.NewServer(http.HandlerFunc(
60
srv := httptest.NewServer(http.HandlerFunc(
61
func(w http.ResponseWriter, r *http.Request) {
61
func(w http.ResponseWriter, r *http.Request) {
62
w.WriteHeader(401)
62
w.WriteHeader(401)
63
w.Write([]byte("not authenticated"))
63
w.Write([]byte("not authenticated"))
64
}))
64
}))
65
defer srv.Close()
65
defer srv.Close()
66
66
67
c := NewHTTPClient(srv.URL, "bad-key")
67
c := NewHTTPClient(srv.URL, "bad-key")
68
_, err := c.ListGroups()
68
_, err := c.ListGroups()
69
if err == nil {
69
if err == nil {
70
t.Fatal("want error on 401, got nil")
70
t.Fatal("want error on 401, got nil")
71
}
71
}
72
}
72
}
73
73
74
func TestGetProfile(t *testing.T) {
74
func TestGetProfile(t *testing.T) {
75
srv := httptest.NewServer(http.HandlerFunc(
75
srv := httptest.NewServer(http.HandlerFunc(
76
func(w http.ResponseWriter, r *http.Request) {
76
func(w http.ResponseWriter, r *http.Request) {
77
if r.URL.Path != "/login/profile/get" {
77
if r.URL.Path != "/login/profile/get" {
78
t.Errorf(
78
t.Errorf(
79
"path: got %q, want /login/profile/get",
79
"path: got %q, want /login/profile/get",
80
r.URL.Path)
80
r.URL.Path)
81
}
81
}
82
w.Header().Set(
82
w.Header().Set(
83
"Content-Type", "application/json")
83
"Content-Type", "application/json")
84
json.NewEncoder(w).Encode(Profile{
84
json.NewEncoder(w).Encode(Profile{
85
Username: "alice",
85
Username: "alice",
86
Name: "Alice Liddell",
86
Name: "Alice Liddell",
87
87
88
Groups: []string{"team", "admins"},
88
Groups: []string{"team", "admins"},
89
})
89
})
90
}))
90
}))
91
defer srv.Close()
91
defer srv.Close()
92
92
93
c := NewHTTPClient(srv.URL, "test-key")
93
c := NewHTTPClient(srv.URL, "test-key")
94
p, err := c.GetProfile()
94
p, err := c.GetProfile()
95
if err != nil {
95
if err != nil {
96
t.Fatal(err)
96
t.Fatal(err)
97
}
97
}
98
if p.Username != "alice" {
98
if p.Username != "alice" {
99
t.Errorf(
99
t.Errorf(
100
"Username: got %q, want alice", p.Username)
100
"Username: got %q, want alice", p.Username)
101
}
101
}
102
if len(p.Groups) != 2 {
102
if len(p.Groups) != 2 {
103
t.Errorf(
103
t.Errorf(
104
"Groups: got %d, want 2", len(p.Groups))
104
"Groups: got %d, want 2", len(p.Groups))
105
}
105
}
106
}
106
}
107
107
108
func TestCreateGroup(t *testing.T) {
108
func TestCreateGroup(t *testing.T) {
109
var seen CreateGroupRequest
109
var seen CreateGroupRequest
110
srv := httptest.NewServer(http.HandlerFunc(
110
srv := httptest.NewServer(http.HandlerFunc(
111
func(w http.ResponseWriter, r *http.Request) {
111
func(w http.ResponseWriter, r *http.Request) {
112
if r.URL.Path != "/groups/add" {
112
if r.URL.Path != "/groups/add" {
113
t.Errorf(
113
t.Errorf(
114
"path: got %q, want /groups/add",
114
"path: got %q, want /groups/add",
115
r.URL.Path)
115
r.URL.Path)
116
}
116
}
117
if r.Method != "POST" {
117
if r.Method != "POST" {
118
t.Errorf("method: got %q, want POST", r.Method)
118
t.Errorf("method: got %q, want POST", r.Method)
119
}
119
}
120
json.NewDecoder(r.Body).Decode(&seen)
120
json.NewDecoder(r.Body).Decode(&seen)
121
w.WriteHeader(200)
121
w.WriteHeader(200)
122
}))
122
}))
123
defer srv.Close()
123
defer srv.Close()
124
124
125
c := NewHTTPClient(srv.URL, "test-key")
125
c := NewHTTPClient(srv.URL, "test-key")
126
err := c.CreateGroup(CreateGroupRequest{
126
err := c.CreateGroup(CreateGroupRequest{
127
Username: "team",
127
Username: "team",
128
Name: "Team",
128
Name: "Team",
129
OwnerUsername: "alice",
129
OwnerUsername: "alice",
130
})
130
})
131
if err != nil {
131
if err != nil {
132
t.Fatal(err)
132
t.Fatal(err)
133
}
133
}
134
if seen.Username != "team" {
134
if seen.Username != "team" {
135
t.Errorf(
135
t.Errorf(
136
"Username: got %q, want team", seen.Username)
136
"Username: got %q, want team", seen.Username)
137
}
137
}
138
if seen.OwnerUsername != "alice" {
138
if seen.OwnerUsername != "alice" {
139
t.Errorf(
139
t.Errorf(
140
"OwnerUsername: got %q, want alice",
140
"OwnerUsername: got %q, want alice",
141
seen.OwnerUsername)
141
seen.OwnerUsername)
142
}
142
}
143
}
143
}
144
144
145
func TestGroupMembers(t *testing.T) {
146
srv := httptest.NewServer(http.HandlerFunc(
147
func(w http.ResponseWriter, r *http.Request) {
148
if r.URL.Path != "/groups/members" {
149
t.Errorf(
150
"path: got %q, want /groups/members",
151
r.URL.Path)
152
}
153
var body map[string]string
154
json.NewDecoder(r.Body).Decode(&body)
155
if body["owid"] != "team-owid" {
156
t.Errorf(
157
"owid: got %q, want team-owid", body["owid"])
158
}
159
json.NewEncoder(w).Encode(GroupMembersResponse{
160
Down: [][]string{
161
{"alice-owid", "bob-owid"},
162
},
163
Usernames: map[string]string{
164
"alice-owid": "alice",
165
"bob-owid": "bob",
166
},
167
})
168
}))
169
defer srv.Close()
170
171
c := NewHTTPClient(srv.URL, "test-key")
172
res, err := c.GroupMembers("team-owid")
173
if err != nil {
174
t.Fatal(err)
175
}
176
if res.Usernames["alice-owid"] != "alice" {
177
t.Errorf(
178
"Usernames[alice-owid]: got %q",
179
res.Usernames["alice-owid"])
180
}
181
}
182
183
func TestLeaveGroup(t *testing.T) {
184
var seen LeaveGroupRequest
185
srv := httptest.NewServer(http.HandlerFunc(
186
func(w http.ResponseWriter, r *http.Request) {
187
if r.URL.Path != "/groups/leave" {
188
t.Errorf(
189
"path: got %q, want /groups/leave",
190
r.URL.Path)
191
}
192
json.NewDecoder(r.Body).Decode(&seen)
193
w.WriteHeader(204)
194
}))
195
defer srv.Close()
196
197
c := NewHTTPClient(srv.URL, "test-key")
198
err := c.LeaveGroup(LeaveGroupRequest{
199
GroupOwid: "team-owid",
200
MemberOwid: "alice-owid",
201
})
202
if err != nil {
203
t.Fatal(err)
204
}
205
if seen.GroupOwid != "team-owid" {
206
t.Errorf(
207
"GroupOwid: got %q", seen.GroupOwid)
208
}
209
if seen.MemberOwid != "alice-owid" {
210
t.Errorf(
211
"MemberOwid: got %q", seen.MemberOwid)
212
}
213
}
214
145
func TestJoinGroups(t *testing.T) {
215
func TestJoinGroups(t *testing.T) {
146
var seen JoinGroupsRequest
216
var seen JoinGroupsRequest
147
srv := httptest.NewServer(http.HandlerFunc(
217
srv := httptest.NewServer(http.HandlerFunc(
148
func(w http.ResponseWriter, r *http.Request) {
218
func(w http.ResponseWriter, r *http.Request) {
149
if r.URL.Path != "/groups/join" {
219
if r.URL.Path != "/groups/join" {
150
t.Errorf(
220
t.Errorf(
151
"path: got %q, want /groups/join",
221
"path: got %q, want /groups/join",
152
r.URL.Path)
222
r.URL.Path)
153
}
223
}
154
if r.Method != "POST" {
224
if r.Method != "POST" {
155
t.Errorf("method: got %q, want POST", r.Method)
225
t.Errorf("method: got %q, want POST", r.Method)
156
}
226
}
157
json.NewDecoder(r.Body).Decode(&seen)
227
json.NewDecoder(r.Body).Decode(&seen)
158
w.WriteHeader(200)
228
w.WriteHeader(200)
159
}))
229
}))
160
defer srv.Close()
230
defer srv.Close()
161
231
162
c := NewHTTPClient(srv.URL, "test-key")
232
c := NewHTTPClient(srv.URL, "test-key")
163
err := c.JoinGroups(JoinGroupsRequest{
233
err := c.JoinGroups(JoinGroupsRequest{
164
GroupUsernames: []string{"team"},
234
GroupUsernames: []string{"team"},
165
MemberUsernames: []string{"alice", "bob"},
235
MemberUsernames: []string{"alice", "bob"},
166
})
236
})
167
if err != nil {
237
if err != nil {
168
t.Fatal(err)
238
t.Fatal(err)
169
}
239
}
170
if len(seen.GroupUsernames) != 1 ||
240
if len(seen.GroupUsernames) != 1 ||
171
seen.GroupUsernames[0] != "team" {
241
seen.GroupUsernames[0] != "team" {
172
t.Errorf(
242
t.Errorf(
173
"GroupUsernames: got %v, want [team]",
243
"GroupUsernames: got %v, want [team]",
174
seen.GroupUsernames)
244
seen.GroupUsernames)
175
}
245
}
176
if len(seen.MemberUsernames) != 2 {
246
if len(seen.MemberUsernames) != 2 {
177
t.Errorf(
247
t.Errorf(
178
"MemberUsernames: got %v, want 2 members",
248
"MemberUsernames: got %v, want 2 members",
179
seen.MemberUsernames)
249
seen.MemberUsernames)
180
}
250
}
181
}
251
}