feat(trail): wire types for threads, attachments, watchers · Entire
feat(trail): wire types for threads, attachments, watchers
9791784→main· computermode·1w ago·5 files·+235 added/-7 removed
Add the thread, attachment, and watcher request/response structs (matching the backend wire shapes: login vs actor-id identity fields, single-level replies, nullable attachment dimensions, presence-based watcher user IDs). Generalize resolveTrailForApproval into resolveNumberedTrail, shared by the new number-keyed subresource commands.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Sessions
01KX6NNFT4Z3XKR08WN3AKA39QView transcript
Changes
5
cmd/entire/cli
api
Atrail_attachment_types.go+29
Atrail_thread_types.go+99
Atrail_thread_types_test.go+91
Atrail_watcher_types.go+8
Mtrail_approval_cmd.go+8/-7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package api
// TrailAttachment is a trail attachment (v1: images only). The server strips
// the internal r2_key; there is no URL field — a serve URL is built from the
// id and a variant. Width/Height/CreatedByUserID/DeletedAt are nullable.
type TrailAttachment struct {
ID string `json:"id"`
TrailID string `json:"trail_id"`
RepoID string `json:"repo_id"`
Kind string `json:"kind"`
ContentType string `json:"content_type"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
Width *int `json:"width"`
Height *int `json:"height"`
CreatedByUserID *string `json:"created_by_user_id"`
CreatedAt string `json:"created_at"`
DeletedAt *string `json:"deleted_at"`
}
// TrailAttachmentsResponse is the response from GET .../:number/attachments.
type TrailAttachmentsResponse struct {
Attachments []TrailAttachment `json:"attachments"`
}
// TrailAttachmentUploadResponse is the response from POST .../:number/attachments.
type TrailAttachmentUploadResponse struct {
Attachment TrailAttachment `json:"attachment"`
}
Acmd/entire/cli/api/trail_attachment_types.go+29
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package api
import "time"
// Trail discussion-thread wire types. A thread has messages; each message may
// carry a single level of replies. Identity fields differ by source: Author,
// LastMessageAuthor, and Participants[].Login are GitHub logins, while
// CreatedBy and ResolvedBy are actor UUIDs (the server maps them differently).
// TrailThreadReply is a reply on a thread message. Replies do not nest further.
type TrailThreadReply struct {
ID string `json:"id"`
Author string `json:"author"` // GitHub login
CreatedAt time.Time `json:"created_at"`
Body string `json:"body"`
}
// TrailThreadMessage is a top-level message in a thread.
type TrailThreadMessage struct {
ID string `json:"id"`
Author string `json:"author"` // GitHub login
CreatedAt time.Time `json:"created_at"`
Body string `json:"body"`
Replies []TrailThreadReply `json:"replies"`
}
// TrailThreadParticipant identifies a thread participant by login.
type TrailThreadParticipant struct {
Login string `json:"login"`
}
// TrailThreadSummary is a thread's metadata. The server's review_comment blob
// (present only for kind=="code_review") is intentionally not decoded here:
// code-review threads are surfaced through `trail finding`.
type TrailThreadSummary struct {
ID string `json:"id"`
TrailID string `json:"trail_id"`
Kind string `json:"kind"` // "discussion" | "code_review"
Title string `json:"title"`
ReviewCommentID *string `json:"review_comment_id"`
Resolved bool `json:"resolved"`
ResolvedBy *string `json:"resolved_by"` // actor UUID
ResolvedAt *time.Time `json:"resolved_at"`
CreatedBy *string `json:"created_by"` // actor UUID
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastMessageAt *time.Time `json:"last_message_at"`
LastMessageAuthor *string `json:"last_message_author"` // GitHub login
MessageCount int `json:"message_count"`
Participants []TrailThreadParticipant `json:"participants"`
}
// TrailThreadsResponse is the response from GET .../:number/threads.
type TrailThreadsResponse struct {
Items []TrailThreadSummary `json:"items"`
EventCursor string `json:"event_cursor"`
}
// TrailThreadDetailResponse is the response from GET .../:number/threads/:id.
type TrailThreadDetailResponse struct {
Thread TrailThreadSummary `json:"thread"`
Messages []TrailThreadMessage `json:"messages"`
EventCursor string `json:"event_cursor"`
}
// TrailThreadCreateRequest is the body for POST .../:number/threads.
// Body is required; Title is optional (server defaults it to "Conversation").
type TrailThreadCreateRequest struct {
Title string `json:"title,omitempty"`
Body string `json:"body"`
}
// TrailThreadCreateResponse is the response from POST .../:number/threads.
type TrailThreadCreateResponse struct {
Thread TrailThreadSummary `json:"thread"`
Message *TrailThreadMessage `json:"message"`
}
// TrailThreadUpdateRequest is the body for PATCH .../:number/threads/:id.
// Pointer fields distinguish "not provided" from an explicit value.
type TrailThreadUpdateRequest struct {
Title *string `json:"title,omitempty"`
Resolved *bool `json:"resolved,omitempty"`
}
// TrailThreadUpdateResponse is the response from PATCH .../:number/threads/:id.
type TrailThreadUpdateResponse struct {
Thread TrailThreadSummary `json:"thread"`
}
// TrailThreadMessageRequest is the body for POST/PATCH message endpoints.
type TrailThreadMessageRequest struct {
Body string `json:"body"`
}
// TrailThreadMessageResponse is the response from the message endpoints.
type TrailThreadMessageResponse struct {
Message TrailThreadMessage `json:"message"`
}
Acmd/entire/cli/api/trail_thread_types.go+99
1
2
3
4
5
6
7
8
package api
import (
"encoding/json"
"testing"
)
const threadTestLogin = "alice"
func TestTrailThreadDetailDecodes(t *testing.T) {
t.Parallel()
payload := []byte(`{
"thread": {
"id": "th1", "trail_id": "tr1", "kind": "discussion", "title": "Design",
"review_comment_id": null, "resolved": false,
"resolved_by": null, "resolved_at": null,
"created_by": "actor-uuid", "created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:01:00Z",
"last_message_at": "2026-07-10T00:01:00Z", "last_message_author": "alice",
"message_count": 2, "participants": [{"login":"alice"},{"login":"bob"}]
},
"messages": [\
{"id":"m1","author":"alice","created_at":"2026-07-10T00:00:00Z","body":"hi",\
"replies":[{"id":"r1","author":"bob","created_at":"2026-07-10T00:00:30Z","body":"yo"}]}\
],
"event_cursor": "42"
}`)
var out TrailThreadDetailResponse
if err := json.Unmarshal(payload, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.EventCursor != "42" {
t.Errorf("EventCursor = %q, want 42", out.EventCursor)
}
if out.Thread.CreatedBy == nil || *out.Thread.CreatedBy != "actor-uuid" {
t.Errorf("CreatedBy = %v, want actor-uuid", out.Thread.CreatedBy)
}
if out.Thread.ResolvedBy != nil {
t.Errorf("ResolvedBy = %v, want nil", out.Thread.ResolvedBy)
}
if out.Thread.LastMessageAuthor == nil || *out.Thread.LastMessageAuthor != threadTestLogin {
t.Errorf("LastMessageAuthor = %v, want alice", out.Thread.LastMessageAuthor)
}
if len(out.Thread.Participants) != 2 || out.Thread.Participants[0].Login != threadTestLogin {
t.Errorf("Participants = %#v", out.Thread.Participants)
}
if len(out.Messages) != 1 || out.Messages[0].Author != threadTestLogin {
t.Fatalf("Messages = %#v", out.Messages)
}
if len(out.Messages[0].Replies) != 1 || out.Messages[0].Replies[0].Author != "bob" {
t.Errorf("Replies = %#v", out.Messages[0].Replies)
}
}
func TestTrailThreadUpdateRequestMarshalsResolvedFalse(t *testing.T) {
t.Parallel()
f := false
b, err := json.Marshal(TrailThreadUpdateRequest{Resolved: &f})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(b) != `{"resolved":false}` {
t.Errorf("got %s, want {\"resolved\":false}", b)
}
// Omitting resolved (nil) must drop the field.
b2, err := json.Marshal(TrailThreadUpdateRequest{})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(b2) != `{}` {
t.Errorf("got %s, want {}`, b2)
}
}
func TestTrailAttachmentDecodesNullableFields(t *testing.T) {
t.Parallel()
payload := []byte(`{"attachment":{"id":"a1","trail_id":"t1","repo_id":"r1","kind":"image",
"content_type":"image/png","filename":"x.png","size_bytes":1234,
"width":null,"height":null,"created_by_user_id":null,
"created_at":"2026-07-10T00:00:00Z","deleted_at":null}}`)
var out TrailAttachmentUploadResponse
if err := json.Unmarshal(payload, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Attachment.SizeBytes != 1234 || out.Attachment.ContentType != "image/png" {
t.Errorf("attachment = %#v", out.Attachment)
}
if out.Attachment.Width != nil || out.Attachment.DeletedAt != nil {
t.Errorf("nullable fields should be nil: %#v", out.Attachment)
}
}
}
Acmd/entire/cli/api/trail_thread_types_test.go+91
1
2
3
4
5
6
7
8
package api
// TrailWatchersResponse is the response from GET /api/v1/trails/:trail_id/watchers.
// Watchers are the user IDs of clients currently connected to the trail's live
// event stream (presence-based, not a persisted subscription list).
type TrailWatchersResponse struct {
Watchers []string `json:"watchers"`
}
Acmd/entire/cli/api/trail_watcher_types.go+8
27 unmodified lines
28
29
30
31
32
33
34
31
32
33
34
35
36
37
38
3 unmodified lines
42
43
44
44
45
46
47
48
15 unmodified lines
64
65
66
66
67
68
69
70
76 unmodified lines
147
148
149
149
150
151
152
153
27 unmodified lines
return api.TrailApprovalRequest{Event: event, Body: msg}, nil
}
// resolveTrailForApproval resolves a numbered trail by optional selector,
// falling back to the current branch (or --branch). Approvals target the
// trail number, so a trail without a number is rejected.
func resolveTrailForApproval(ctx context.Context, client *api.Client, repoOverride, selector, branch string) (*api.TrailResource, string, string, string, error) {
// resolveNumberedTrail resolves a trail by optional selector, falling back to
// the current branch (or --branch), and requires it to have a number (the
// number-keyed subresource endpoints — approvals, threads — reject a trail
// without one).
func resolveNumberedTrail(ctx context.Context, client *api.Client, repoOverride, selector, branch string) (*api.TrailResource, string, string, string, error) {
forge, owner, repoName, err := resolveTrailRepoOrRemote(ctx, repoOverride)
if err != nil {
return nil, "", "", "", err
}
if found.Number <= 0 {
return nil, "", "", "", errors.New("trail has no number yet; cannot submit an approval")
}
return found, forge, owner, repoName, nil
}
15 unmodified lines
return err
}
return runAuthenticatedTrailAPI(ctx, w, insecureHTTP, repoOverride, func(ctx context.Context, client *api.Client) error {
found, forge, owner, repoName, err := resolveTrailForApproval(ctx, client, repoOverride, selector, branch)
found, forge, owner, repoName, err := resolveNumberedTrail(ctx, client, repoOverride, selector, branch)
if err != nil {
return err
}
}