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

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
    }
}