1
// Package klee provides a Go client for the Klee
2
// git server API (code.oscarkilo.com).
3
package klee
4
5
import "encoding/json"
6
import "fmt"
7
import "io"
8
import "net/http"
9
import "strings"
10
import "time"
11
12
// Client talks to a Klee git server.
13
type Client struct {
14
Host string // e.g. "https://code.oscarkilo.com"
15
APIKey string // Bearer token
16
HTTP *http.Client
17
}
18
19
// NewClient creates a Klee client.
20
func NewClient(host, api_key string) *Client {
21
return &Client{
22
Host: host,
23
APIKey: api_key,
24
HTTP: &http.Client{},
25
}
26
}
27
28
// PR is a pull request on Klee.
29
type PR struct {
30
Number int `json:"number"`
31
Title string `json:"title"`
32
Body string `json:"body"`
33
State string `json:"state"`
34
Merged bool `json:"merged"`
35
Head string `json:"head"`
36
Base string `json:"base"`
37
Author string `json:"author"`
38
Files []string `json:"files"`
39
Created time.Time `json:"created"`
40
Updated time.Time `json:"updated"`
41
MergedBy string `json:"merged_by,omitempty"`
42
MergedAt time.Time `json:"merged_at,omitempty"`
43
}
44
45
// Comment is a PR comment on Klee.
46
type Comment struct {
47
ID int `json:"id"`
48
Author string `json:"author"`
49
Body string `json:"body"`
50
Verdict string `json:"verdict,omitempty"`
51
File string `json:"file,omitempty"`
52
Line int `json:"line,omitempty"`
53
Created time.Time `json:"created"`
54
}
55
56
// CreatePRRequest is the body for creating a PR.
57
type CreatePRRequest struct {
58
Head string `json:"head"`
59
Base string `json:"base"`
60
Title string `json:"title"`
61
Body string `json:"body"`
62
}
63
64
// AddCommentRequest is the body for adding a
65
// comment to a PR.
66
type AddCommentRequest struct {
67
Body string `json:"body"`
68
Verdict string `json:"verdict,omitempty"`
69
}
70
71
// CreateRepoRequest is the body for creating a
72
// repo on Klee.
73
type CreateRepoRequest struct {
74
RepoName string `json:"repo_name"`
75
ReaderUsername string `json:"reader_username"`
76
}
77
78
// RepoInfo describes a repository.
79
type RepoInfo struct {
80
Name string `json:"name"`
81
IsPublic bool `json:"is_public"`
82
Authz *RepoInfoAuthz `json:"authz"`
83
}
84
85
// RepoInfoAuthz describes repo access.
86
type RepoInfoAuthz struct {
87
IsOwner bool `json:"is_owner"`
88
IsReader bool `json:"is_reader"`
89
OwnerUsername string `json:"owner_username"`
90
ReaderUsername string `json:"reader_username"`
91
}
92
93
// LsResponse is the response from /.ls.
94
type LsResponse struct {
95
Repos []RepoInfo `json:"repos"`
96
CanCreateRepos bool `json:"can_create_repos"`
97
}
98
99
// --- PR operations ---
100
101
// ListPRs lists PRs for a repo by state.
102
func (c *Client) ListPRs(
103
repo, state string,
104
) ([]PR, error) {
105
path := fmt.Sprintf(
106
"/%s/prs?state=%s", repo, state)
107
var prs []PR
108
if err := c.getJSON(path, &prs); err != nil {
109
return nil, err
110
}
111
return prs, nil
112
}
113
114
// GetPR fetches a single PR.
115
func (c *Client) GetPR(
116
repo string, number int,
117
) (*PR, error) {
118
path := fmt.Sprintf(
119
"/%s/pr/%d", repo, number)
120
var pr PR
121
if err := c.getJSON(path, &pr); err != nil {
122
return nil, err
123
}
124
return &pr, nil
125
}
126
127
// CreatePR creates a new PR.
128
func (c *Client) CreatePR(
129
repo string, req CreatePRRequest,
130
) (*PR, error) {
131
path := fmt.Sprintf("/%s/prs", repo)
132
var pr PR
133
if err := c.postJSON(path, req, &pr); err != nil {
134
return nil, err
135
}
136
return &pr, nil
137
}
138
139
// GetComments fetches comments on a PR.
140
func (c *Client) GetComments(
141
repo string, number int,
142
) ([]Comment, error) {
143
path := fmt.Sprintf(
144
"/%s/pr/%d/comments", repo, number)
145
var comments []Comment
146
if err := c.getJSON(path, &comments); err != nil {
147
return nil, err
148
}
149
return comments, nil
150
}
151
152
// AddComment adds a comment to a PR.
153
func (c *Client) AddComment(
154
repo string, number int, req AddCommentRequest,
155
) (*Comment, error) {
156
path := fmt.Sprintf(
157
"/%s/pr/%d/comments", repo, number)
158
var comment Comment
159
if err := c.postJSON(
160
path, req, &comment); err != nil {
161
return nil, err
162
}
163
return &comment, nil
164
}
165
166
// MergePR merges a PR.
167
func (c *Client) MergePR(
168
repo string, number int,
169
) (*PR, error) {
170
path := fmt.Sprintf(
171
"/%s/pr/%d/merge", repo, number)
172
var pr PR
173
if err := c.postJSON(path, nil, &pr); err != nil {
174
return nil, err
175
}
176
return &pr, nil
177
}
178
179
// SetPRState changes a PR's state (open/closed).
180
func (c *Client) SetPRState(
181
repo string, number int, state string,
182
) (*PR, error) {
183
path := fmt.Sprintf(
184
"/%s/pr/%d", repo, number)
185
payload := map[string]string{"state": state}
186
var pr PR
187
if err := c.patchJSON(
188
path, payload, &pr); err != nil {
189
return nil, err
190
}
191
return &pr, nil
192
}
193
194
// --- Repo operations ---
195
196
// ListRepos lists repos accessible to the caller.
197
func (c *Client) ListRepos() (
198
*LsResponse, error,
199
) {
200
var res LsResponse
201
if err := c.getJSON("/.ls", &res); err != nil {
202
return nil, err
203
}
204
return &res, nil
205
}
206
207
// CreateRepo creates a new repo on Klee.
208
func (c *Client) CreateRepo(
209
req CreateRepoRequest,
210
) error {
211
return c.postJSON("/.add-repo", req, nil)
212
}
213
214
// --- HTTP helpers ---
215
216
func (c *Client) do(
217
method, path string, body io.Reader,
218
) (*http.Response, error) {
219
url := c.Host + path
220
req, err := http.NewRequest(method, url, body)
221
if err != nil {
222
return nil, err
223
}
224
req.Header.Set("Accept", "application/json")
225
if c.APIKey != "" {
226
req.Header.Set("Authorization",
227
"Bearer "+c.APIKey)
228
}
229
if body != nil {
230
req.Header.Set(
231
"Content-Type", "application/json")
232
}
233
return c.HTTP.Do(req)
234
}
235
236
func (c *Client) getJSON(
237
path string, dst interface{},
238
) error {
239
resp, err := c.do("GET", path, nil)
240
if err != nil {
241
return err
242
}
243
defer resp.Body.Close()
244
if resp.StatusCode >= 400 {
245
b, _ := io.ReadAll(resp.Body)
246
return fmt.Errorf(
247
"HTTP %d: %s", resp.StatusCode, b)
248
}
249
return json.NewDecoder(resp.Body).Decode(dst)
250
}
251
252
func (c *Client) postJSON(
253
path string,
254
payload interface{},
255
dst interface{},
256
) error {
257
var body io.Reader
258
if payload != nil {
259
data, err := json.Marshal(payload)
260
if err != nil {
261
return err
262
}
263
body = strings.NewReader(string(data))
264
}
265
resp, err := c.do("POST", path, body)
266
if err != nil {
267
return err
268
}
269
defer resp.Body.Close()
270
if resp.StatusCode >= 400 {
271
b, _ := io.ReadAll(resp.Body)
272
return fmt.Errorf(
273
"HTTP %d: %s", resp.StatusCode, b)
274
}
275
if dst != nil {
276
return json.NewDecoder(
277
resp.Body).Decode(dst)
278
}
279
return nil
280
}
281
282
func (c *Client) patchJSON(
283
path string,
284
payload interface{},
285
dst interface{},
286
) error {
287
data, err := json.Marshal(payload)
288
if err != nil {
289
return err
290
}
291
body := strings.NewReader(string(data))
292
resp, err := c.do("PATCH", path, body)
293
if err != nil {
294
return err
295
}
296
defer resp.Body.Close()
297
if resp.StatusCode >= 400 {
298
b, _ := io.ReadAll(resp.Body)
299
return fmt.Errorf(
300
"HTTP %d: %s", resp.StatusCode, b)
301
}
302
if dst != nil {
303
return json.NewDecoder(
304
resp.Body).Decode(dst)
305
}
306
return nil
307
}
308
309
// AgentName extracts the agent name from a Klee
310
// sub-user username. Given "operator.claude",
311
// returns "claude". If no dot, returns the input.
312
func AgentName(klee_user string) string {
313
i := strings.LastIndex(klee_user, ".")
314
if i < 0 {
315
return klee_user
316
}
317
return klee_user[i+1:]
318
}
1
package klee
2
3
import "encoding/json"
4
import "net/http"
5
import "net/http/httptest"
6
import "testing"
7
8
func TestGetPR(t *testing.T) {
9
srv := mockPR(t, &PR{
10
Number: 1,
11
Author: "op.claude",
12
State: "open",
13
Files: []string{"root/reports/claude/x.md"},
14
})
15
defer srv.Close()
16
17
c := NewClient(srv.URL, "key")
18
pr, err := c.GetPR("brain", 1)
19
check(t, err == nil, "err: %v", err)
20
check(t, pr.Number == 1, "number: %d", pr.Number)
21
check(t, pr.Author == "op.claude",
22
"author: %s", pr.Author)
23
check(t, len(pr.Files) == 1,
24
"files: %d", len(pr.Files))
25
}
26
27
func TestGetComments(t *testing.T) {
28
srv := mockComments(t, []Comment{
29
{Author: "op.helper", Verdict: "approve"},
30
{Author: "op.claude", Verdict: ""},
31
})
32
defer srv.Close()
33
34
c := NewClient(srv.URL, "key")
35
cs, err := c.GetComments("brain", 1)
36
check(t, err == nil, "err: %v", err)
37
check(t, len(cs) == 2, "len: %d", len(cs))
38
check(t, cs[0].Verdict == "approve",
39
"verdict: %s", cs[0].Verdict)
40
}
41
42
func TestMergePR(t *testing.T) {
43
merged := false
44
srv := httptest.NewServer(
45
http.HandlerFunc(
46
func(w http.ResponseWriter, r *http.Request) {
47
check(t, r.Method == "POST",
48
"method: %s", r.Method)
49
check(t, r.URL.Path == "/brain/pr/1/merge",
50
"path: %s", r.URL.Path)
51
merged = true
52
w.Header().Set(
53
"Content-Type", "application/json")
54
json.NewEncoder(w).Encode(
55
&PR{Number: 1, Merged: true})
56
}))
57
defer srv.Close()
58
59
c := NewClient(srv.URL, "key")
60
pr, err := c.MergePR("brain", 1)
61
check(t, err == nil, "err: %v", err)
62
check(t, merged, "merge not called")
63
check(t, pr.Merged, "not merged")
64
}
65
66
func TestGetPR_NotFound(t *testing.T) {
67
srv := httptest.NewServer(
68
http.HandlerFunc(
69
func(w http.ResponseWriter, r *http.Request) {
70
w.WriteHeader(404)
71
}))
72
defer srv.Close()
73
74
c := NewClient(srv.URL, "key")
75
_, err := c.GetPR("brain", 99)
76
check(t, err != nil, "want error")
77
}
78
79
func TestCreateRepo(t *testing.T) {
80
var got CreateRepoRequest
81
srv := httptest.NewServer(
82
http.HandlerFunc(
83
func(w http.ResponseWriter, r *http.Request) {
84
json.NewDecoder(r.Body).Decode(&got)
85
w.WriteHeader(204)
86
}))
87
defer srv.Close()
88
89
c := NewClient(srv.URL, "key")
90
err := c.CreateRepo(CreateRepoRequest{
91
RepoName: "test-repo",
92
ReaderUsername: "igor.agents",
93
})
94
check(t, err == nil, "err: %v", err)
95
check(t, got.RepoName == "test-repo",
96
"name: %s", got.RepoName)
97
check(t, got.ReaderUsername == "igor.agents",
98
"reader: %s", got.ReaderUsername)
99
}
100
101
func TestAgentName(t *testing.T) {
102
check(t, AgentName("op.claude") == "claude",
103
"got %s", AgentName("op.claude"))
104
check(t, AgentName("op.sub.helper") == "helper",
105
"got %s", AgentName("op.sub.helper"))
106
check(t, AgentName("root") == "root",
107
"got %s", AgentName("root"))
108
}
109
110
func TestAddComment(t *testing.T) {
111
srv := httptest.NewServer(
112
http.HandlerFunc(
113
func(w http.ResponseWriter, r *http.Request) {
114
var req AddCommentRequest
115
json.NewDecoder(r.Body).Decode(&req)
116
w.Header().Set(
117
"Content-Type", "application/json")
118
json.NewEncoder(w).Encode(&Comment{
119
ID: 1,
120
Author: "op.claude",
121
Body: req.Body,
122
Verdict: req.Verdict,
123
})
124
}))
125
defer srv.Close()
126
127
c := NewClient(srv.URL, "key")
128
comment, err := c.AddComment("brain", 1,
129
AddCommentRequest{
130
Body: "LGTM",
131
Verdict: "approve",
132
})
133
check(t, err == nil, "err: %v", err)
134
check(t, comment.Body == "LGTM",
135
"body: %s", comment.Body)
136
check(t, comment.Verdict == "approve",
137
"verdict: %s", comment.Verdict)
138
}
139
140
// --- helpers ---
141
142
func check(
143
t *testing.T, ok bool, format string,
144
args ...interface{},
145
) {
146
t.Helper()
147
if !ok {
148
t.Errorf(format, args...)
149
}
150
}
151
152
func mockPR(
153
t *testing.T, pr *PR,
154
) *httptest.Server {
155
t.Helper()
156
return httptest.NewServer(
157
http.HandlerFunc(
158
func(w http.ResponseWriter, r *http.Request) {
159
w.Header().Set(
160
"Content-Type", "application/json")
161
json.NewEncoder(w).Encode(pr)
162
}))
163
}
164
165
func mockComments(
166
t *testing.T, comments []Comment,
167
) *httptest.Server {
168
t.Helper()
169
return httptest.NewServer(
170
http.HandlerFunc(
171
func(w http.ResponseWriter, r *http.Request) {
172
w.Header().Set(
173
"Content-Type", "application/json")
174
json.NewEncoder(w).Encode(comments)
175
}))
176
}