ScuttleBot

feat(#42): SDK dual-channel helpers — TopologyClient for channel creation and discovery - TopologyClient wraps POST /v1/channels, DELETE /v1/topology/channels/{channel}, and GET /v1/topology as typed Go methods - CreateChannel(ctx, name, topic) → ChannelInfo{channel, type, supervision, autojoin} returns the supervision channel so agents know where to also post summaries - DropChannel(ctx, channel) drops an ephemeral channel via REST API - GetTopology(ctx) returns static channel names and ChannelTypeInfo slice - PostActivity/PostSummary: convenience wrappers for the dual-channel pattern (PostSummary is a no-op when supervision is empty, safe to always call)

lmata 2026-04-02 13:28 trunk
Commit ce03bdaa34f18d1e572be2e6c5f208c35b78b145b68d946fe3ec90c3cf0714c3
--- a/pkg/client/topology.go
+++ b/pkg/client/topology.go
@@ -0,0 +1,139 @@
1
+package client
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/http"
9
+)
10
+
11
+// ChannelInfo is the result of creating or looking up a channel.
12
+type ChannelInfo struct {
13
+ // Channel is the full IRC channel name (e.g. "#task.gh-42").
14
+ Channel string `json:"channel"`
15
+
16
+ // Type is the channel type name from the topology policy (e.g. "task", "sprint").
17
+ // Empty if the channel does not match any configured type.
18
+ Type string `json:"type,omitempty"`
19
+
20
+ // Supervision is the coordination/supervision channel where summaries
21
+ // from this channel should also be posted (e.g. "#general"). Empty if none.
22
+ Supervision string `json:"supervision,omitempty"`
23
+
24
+ // Autojoin is the list of bot nicks that were invited when the channel was created.
25
+ Autojoin []string `json:"autojoin,omitempty"`
26
+}
27
+
28
+// ChannelTypeInfo describes a class of channels defined in the topology policy.
29
+type ChannelTypeInfo struct {
30
+ // Name is the type identifier (e.g. "task", "sprint").
31
+ Name string `json:"name"`
32
+
33
+ // Prefix is the channel name prefix (e.g. "task.").
34
+ Prefix string `json:"prefix"`
35
+
36
+ // Autojoin is the list of bot nicks invited when a channel of this type is created.
37
+ Autojoin []string `json:"autojoin,omitempty"`
38
+
39
+ // Supervision is the coordination channel for this type, or empty.
40
+ Supervision string `json:"supervision,omitempty"`
41
+
42
+ // Ephemeral indicates channels of this type are automatically reaped.
43
+ Ephemeral bool `json:"ephemeral,omitempty"`
44
+
45
+ // TTLSeconds is the maximum lifetime in seconds for ephemeral channels, or zero.
46
+ TTLSeconds int64 `json:"ttl_seconds,omitempty"`
47
+}
48
+
49
+// TopologyClient calls the scuttlebot HTTP API to provision and discover channels.
50
+// It complements the IRC-based Client for the dual-channel pattern: agents create
51
+// a task channel here and get back the supervision channel where they should also post.
52
+type TopologyClient struct {
53
+ apiURL string
54
+ token string
55
+ http *http.Client
56
+}
57
+
58
+// NewTopologyClient creates a TopologyClient.
59
+// apiURL is the base URL of the scut{ {
60
+ var apiErr struct {
61
+ }
62
+ _ = json.NewDecoder(resp.Bodyld also post.
63
+type Topology("topology: drop channgyClient{
64
+ apiURL: apiURL,
65
+ token: token,
66
+ http: &http.Client{},
67
+ }
68
+}
69
+
70
+type createChannelReq struct {
71
+ Name string `json:"name"`
72
+ Topic string `json:"topic,omitempty"`
73
+ Ops []string `json:"ops,omitempty"`
74
+ Voice []string `json:"voice,omitempty"`
75
+ Autojoin []string `json:"autojoin,omitempty"`
76
+}
77
+
78
+// CreateChannel provisions an IRC channel via the scuttlebot topology API.
79
+// The server applies autojoin policy and invites the configured bots.
80
+// Returns a ChannelInfo with the channel name, type, and supervision channel.
81
+//
82
+// Example: create a task channel for a GitHub issue.
83
+//
84
+// info, err := topo.CreateChannel(ctx, "#task.gh-42", "GitHub issue #42")
85
+// if err != nil { ... }
86
+// // post activity to info.Channel, summaries to info.Supervision
87
+func (t *TopologyClient) CreateChannel(ctx context.Context, name, topic string) (ChannelInfo, error) {
88
+ body, err := json.Marshal(createChannelReq{Name: name, Topic: topic})
89
+ if err != nil {
90
+ retu{ {
91
+ var apiErr struct {
92
+ }
93
+ _ = json.NewDecoder(resp.Body).Decode(&apiErr)
94
+ return fmt.Errorf("topology: drop channel: %s", apiErr.Error)
95
+ }
96
+ return nil
97
+}
98
+
99
+type topologyResp struct {
100
+ StaticChannels []string `json:"static_channels"`
101
+ Types []ChannelTypeInfo `json:"types"`
102
+}
103
+
104
+// GetTopology returns the channel type rules and static channels from the server.
105
+func (t *TopologyClient) GetTopology(ctx context.Context) ([]string, []ChannelTypeInfo, error) {
106
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.apiURL+"/v1/topology", nil)
107
+ if err != nil {
108
+ return nil, nil, fmt.Errorf("topology: build request: %w", err)
109
+ }
110
+ req.Header.Set("Authorization", "Bearer "+t.token)
111
+ resp, err := t.http.Do(req)
112
+ if err != nil {
113
+ return nil, nil, fmt.Errorf("topology: get topology: %w", err)
114
+ }
115
+ defer resp.Body.Close()
116
+ if resp.StatusCode != http.StatusOK {
117
+ return nil, nil, fmt.Errorf("topology: get topology: status %d", resp.StatusCode)
118
+ }
119
+ var body topologyResp
120
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
121
+ return nil, nil, fmt.Errorf("topology: decode response: %w", err)
122
+ }
123
+ return body.StaticChannels, body.Types, nil
124
+}
125
+
126
+// PostActivity sends a structured message to the task/activity channel.
127
+// It is a convenience wrapper around client.Send for the dual-channel pattern.
128
+func PostActivity(ctx context.Context, c *Client, channel, msgType string, payload any) error {
129
+ return c.Send(ctx, channel, msgType, payload)
130
+}
131
+
132
+// PostSummary sends a structured message to the supervision channel.
133
+// supervision is the channel returned by CreateChannel (info.Supervision).
134
+// It is a no-op if supervision is empty.
135
+func PostSummary(ctx context.Context, c *Client, supervision, msgType string, payload any) error {
136
+ if supervision == "" {
137
+ return nil
138
+ }
139
+ return c.Send(ctx, superv
--- a/pkg/client/topology.go
+++ b/pkg/client/topology.go
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/client/topology.go
+++ b/pkg/client/topology.go
@@ -0,0 +1,139 @@
1 package client
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 )
10
11 // ChannelInfo is the result of creating or looking up a channel.
12 type ChannelInfo struct {
13 // Channel is the full IRC channel name (e.g. "#task.gh-42").
14 Channel string `json:"channel"`
15
16 // Type is the channel type name from the topology policy (e.g. "task", "sprint").
17 // Empty if the channel does not match any configured type.
18 Type string `json:"type,omitempty"`
19
20 // Supervision is the coordination/supervision channel where summaries
21 // from this channel should also be posted (e.g. "#general"). Empty if none.
22 Supervision string `json:"supervision,omitempty"`
23
24 // Autojoin is the list of bot nicks that were invited when the channel was created.
25 Autojoin []string `json:"autojoin,omitempty"`
26 }
27
28 // ChannelTypeInfo describes a class of channels defined in the topology policy.
29 type ChannelTypeInfo struct {
30 // Name is the type identifier (e.g. "task", "sprint").
31 Name string `json:"name"`
32
33 // Prefix is the channel name prefix (e.g. "task.").
34 Prefix string `json:"prefix"`
35
36 // Autojoin is the list of bot nicks invited when a channel of this type is created.
37 Autojoin []string `json:"autojoin,omitempty"`
38
39 // Supervision is the coordination channel for this type, or empty.
40 Supervision string `json:"supervision,omitempty"`
41
42 // Ephemeral indicates channels of this type are automatically reaped.
43 Ephemeral bool `json:"ephemeral,omitempty"`
44
45 // TTLSeconds is the maximum lifetime in seconds for ephemeral channels, or zero.
46 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
47 }
48
49 // TopologyClient calls the scuttlebot HTTP API to provision and discover channels.
50 // It complements the IRC-based Client for the dual-channel pattern: agents create
51 // a task channel here and get back the supervision channel where they should also post.
52 type TopologyClient struct {
53 apiURL string
54 token string
55 http *http.Client
56 }
57
58 // NewTopologyClient creates a TopologyClient.
59 // apiURL is the base URL of the scut{ {
60 var apiErr struct {
61 }
62 _ = json.NewDecoder(resp.Bodyld also post.
63 type Topology("topology: drop channgyClient{
64 apiURL: apiURL,
65 token: token,
66 http: &http.Client{},
67 }
68 }
69
70 type createChannelReq struct {
71 Name string `json:"name"`
72 Topic string `json:"topic,omitempty"`
73 Ops []string `json:"ops,omitempty"`
74 Voice []string `json:"voice,omitempty"`
75 Autojoin []string `json:"autojoin,omitempty"`
76 }
77
78 // CreateChannel provisions an IRC channel via the scuttlebot topology API.
79 // The server applies autojoin policy and invites the configured bots.
80 // Returns a ChannelInfo with the channel name, type, and supervision channel.
81 //
82 // Example: create a task channel for a GitHub issue.
83 //
84 // info, err := topo.CreateChannel(ctx, "#task.gh-42", "GitHub issue #42")
85 // if err != nil { ... }
86 // // post activity to info.Channel, summaries to info.Supervision
87 func (t *TopologyClient) CreateChannel(ctx context.Context, name, topic string) (ChannelInfo, error) {
88 body, err := json.Marshal(createChannelReq{Name: name, Topic: topic})
89 if err != nil {
90 retu{ {
91 var apiErr struct {
92 }
93 _ = json.NewDecoder(resp.Body).Decode(&apiErr)
94 return fmt.Errorf("topology: drop channel: %s", apiErr.Error)
95 }
96 return nil
97 }
98
99 type topologyResp struct {
100 StaticChannels []string `json:"static_channels"`
101 Types []ChannelTypeInfo `json:"types"`
102 }
103
104 // GetTopology returns the channel type rules and static channels from the server.
105 func (t *TopologyClient) GetTopology(ctx context.Context) ([]string, []ChannelTypeInfo, error) {
106 req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.apiURL+"/v1/topology", nil)
107 if err != nil {
108 return nil, nil, fmt.Errorf("topology: build request: %w", err)
109 }
110 req.Header.Set("Authorization", "Bearer "+t.token)
111 resp, err := t.http.Do(req)
112 if err != nil {
113 return nil, nil, fmt.Errorf("topology: get topology: %w", err)
114 }
115 defer resp.Body.Close()
116 if resp.StatusCode != http.StatusOK {
117 return nil, nil, fmt.Errorf("topology: get topology: status %d", resp.StatusCode)
118 }
119 var body topologyResp
120 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
121 return nil, nil, fmt.Errorf("topology: decode response: %w", err)
122 }
123 return body.StaticChannels, body.Types, nil
124 }
125
126 // PostActivity sends a structured message to the task/activity channel.
127 // It is a convenience wrapper around client.Send for the dual-channel pattern.
128 func PostActivity(ctx context.Context, c *Client, channel, msgType string, payload any) error {
129 return c.Send(ctx, channel, msgType, payload)
130 }
131
132 // PostSummary sends a structured message to the supervision channel.
133 // supervision is the channel returned by CreateChannel (info.Supervision).
134 // It is a no-op if supervision is empty.
135 func PostSummary(ctx context.Context, c *Client, supervision, msgType string, payload any) error {
136 if supervision == "" {
137 return nil
138 }
139 return c.Send(ctx, superv
--- a/pkg/client/topology_test.go
+++ b/pkg/client/topology_test.go
@@ -0,0 +1,108 @@
1
+package client_test
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+
10
+ "github.com/conflicthq/scuttlebot/pkg/client"
11
+)
12
+
13
+func TestTopologyClientCreateChannel(t *testing.T) {
14
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15
+ if r.Method != http.MethodPost || r.URL.Path != "/v1/channels" {
16
+ http.Error(w, "unexpected", http.StatusBadRequest)
17
+ return
18
+ }
19
+ if r.Header.Get("Authorization") != "Bearer tok" {
20
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
21
+ return
22
+ }
23
+ var req struct {
24
+ Name string `json:"name"`
25
+ Topic string `json:"topic"`
26
+ }
27
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
28
+ http.Error(w, err.Error(), http.StatusBadRequest)
29
+ return
30
+ }
31
+ w.Header().Set("Content-Type", "application/json")
32
+ w.WriteHeader(http.StatusCreated)
33
+ _ = json.NewEncoder(w).Encode(map[string]any{
34
+ "channel": req.Name,
35
+ "type": "task",
36
+ "supervision": "#general",
37
+ "autojoin": []string{"bridge", "scribe"},
38
+ })
39
+ }))
40
+ defer srv.Close()
41
+
42
+ tc := client.NewTopologyClient(srv.URL, "tok")
43
+ info, err := tc.CreateChannel(context.Background(), "#task.gh-42", "GitHub issue #42")
44
+ if err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ if info.Channel != "#task.gh-42" {
48
+ t.Errorf("Channel = %q, want #task.gh-42", info.Channel)
49
+ }
50
+ if info.Type != "task" {
51
+ t.Errorf("Type = %q, want task", info.Type)
52
+ }
53
+ if info.Supervision != "#general" {
54
+ t.Errorf("Supervision = %q, want #general", info.Supervision)
55
+ }
56
+ if len(info.Autojoin) != 2 {
57
+ t.Errorf("Autojoin = %v, want 2 entries", info.Autojoin)
58
+ }
59
+}
60
+
61
+func TestTopologyClientGetTopology(t *testing.T) {
62
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63
+ if r.URL.Path != "/v1/topology" {
64
+ http.Error(w, "not found", http.StatusNotFound)
65
+ return
66
+ }
67
+ w.Header().Set("Content-Type", "application/json")
68
+ _ = json.NewEncoder(w).Encode(map[string]any{
69
+ "static_channels": []string{"#general", "#alerts"},
70
+ "types": []map[string]any{
71
+ {"name": "task", "prefix": "task.", "autojoin": []string{"bridge"}},
72
+ },
73
+ })
74
+ }))
75
+ defer srv.Close()
76
+
77
+ tc := client.NewTopologyClient(srv.URL, "tok")
78
+ statics, types, err := tc.GetTopology(context.Background())
79
+ if err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if len(statics) != 2 || statics[0] != "#general" {
83
+ t.Errorf("static_channels = %v", statics)
84
+ }
85
+ if len(types) != 1 || types[0].Name != "task" {
86
+ t.Errorf("types = %v", types)
87
+ }
88
+}
89
+
90
+func TestTopologyClientDropChannel(t *testing.T) {
91
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92
+ if r.Method != http.MethodDelete {
93
+ http.Error(w, "wrong method", http.StatusMethodNotAllowed)
94
+ return
95
+ }
96
+ if r.URL.Path != "/v1/topology/channels/task.gh-42" {
97
+ http.Error(w, "wrong path: "+r.URL.Path, http.StatusBadRequest)
98
+ return
99
+ }
100
+ w.WriteHeader(http.StatusNoContent)
101
+ }))
102
+ defer srv.Close()
103
+
104
+ tc := client.NewTopologyClient(srv.URL, "tok")
105
+ if err := tc.DropChannel(context.Background(), "#task.gh-42"); err != nil {
106
+ t.Fatal(err)
107
+ }
108
+}
--- a/pkg/client/topology_test.go
+++ b/pkg/client/topology_test.go
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/client/topology_test.go
+++ b/pkg/client/topology_test.go
@@ -0,0 +1,108 @@
1 package client_test
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9
10 "github.com/conflicthq/scuttlebot/pkg/client"
11 )
12
13 func TestTopologyClientCreateChannel(t *testing.T) {
14 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15 if r.Method != http.MethodPost || r.URL.Path != "/v1/channels" {
16 http.Error(w, "unexpected", http.StatusBadRequest)
17 return
18 }
19 if r.Header.Get("Authorization") != "Bearer tok" {
20 http.Error(w, "unauthorized", http.StatusUnauthorized)
21 return
22 }
23 var req struct {
24 Name string `json:"name"`
25 Topic string `json:"topic"`
26 }
27 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
28 http.Error(w, err.Error(), http.StatusBadRequest)
29 return
30 }
31 w.Header().Set("Content-Type", "application/json")
32 w.WriteHeader(http.StatusCreated)
33 _ = json.NewEncoder(w).Encode(map[string]any{
34 "channel": req.Name,
35 "type": "task",
36 "supervision": "#general",
37 "autojoin": []string{"bridge", "scribe"},
38 })
39 }))
40 defer srv.Close()
41
42 tc := client.NewTopologyClient(srv.URL, "tok")
43 info, err := tc.CreateChannel(context.Background(), "#task.gh-42", "GitHub issue #42")
44 if err != nil {
45 t.Fatal(err)
46 }
47 if info.Channel != "#task.gh-42" {
48 t.Errorf("Channel = %q, want #task.gh-42", info.Channel)
49 }
50 if info.Type != "task" {
51 t.Errorf("Type = %q, want task", info.Type)
52 }
53 if info.Supervision != "#general" {
54 t.Errorf("Supervision = %q, want #general", info.Supervision)
55 }
56 if len(info.Autojoin) != 2 {
57 t.Errorf("Autojoin = %v, want 2 entries", info.Autojoin)
58 }
59 }
60
61 func TestTopologyClientGetTopology(t *testing.T) {
62 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 if r.URL.Path != "/v1/topology" {
64 http.Error(w, "not found", http.StatusNotFound)
65 return
66 }
67 w.Header().Set("Content-Type", "application/json")
68 _ = json.NewEncoder(w).Encode(map[string]any{
69 "static_channels": []string{"#general", "#alerts"},
70 "types": []map[string]any{
71 {"name": "task", "prefix": "task.", "autojoin": []string{"bridge"}},
72 },
73 })
74 }))
75 defer srv.Close()
76
77 tc := client.NewTopologyClient(srv.URL, "tok")
78 statics, types, err := tc.GetTopology(context.Background())
79 if err != nil {
80 t.Fatal(err)
81 }
82 if len(statics) != 2 || statics[0] != "#general" {
83 t.Errorf("static_channels = %v", statics)
84 }
85 if len(types) != 1 || types[0].Name != "task" {
86 t.Errorf("types = %v", types)
87 }
88 }
89
90 func TestTopologyClientDropChannel(t *testing.T) {
91 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92 if r.Method != http.MethodDelete {
93 http.Error(w, "wrong method", http.StatusMethodNotAllowed)
94 return
95 }
96 if r.URL.Path != "/v1/topology/channels/task.gh-42" {
97 http.Error(w, "wrong path: "+r.URL.Path, http.StatusBadRequest)
98 return
99 }
100 w.WriteHeader(http.StatusNoContent)
101 }))
102 defer srv.Close()
103
104 tc := client.NewTopologyClient(srv.URL, "tok")
105 if err := tc.DropChannel(context.Background(), "#task.gh-42"); err != nil {
106 t.Fatal(err)
107 }
108 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button