Merge pull request #1705 from entireio/feat/trail-collaboration-parity · Entire

Home

Log in

Merge pull request #1705 from entireio/feat/trail-collaboration-parity

e73403f→main·

gtrrz-victor·18h ago·13 files·+1,444 added/-116 removed

feat(trail): CLI parity for trail collaboration (metadata, approvals, discussion threads)

Changes

13

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
92
93
94
95
96
97
98
99

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

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

Acmd/entire/cli/api/trail_thread_types_test.go+73

20 unmodified lines

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
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
20 unmodified lines

73
74
75
76
77
78
79
80
81
33 unmodified lines

115
116
117
111
112
113
114
118
119
120
121
122
123
124
125
126
127
128
7 unmodified lines

136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166

20 unmodified lines

// TrailResource represents a single trail from the API.
type TrailResource struct {
    ID              string           `json:"id,omitempty"`
    Number          int              `json:"number,omitempty"`
    URL             string           `json:"url,omitempty"`
    Branch          string           `json:"branch"`
    Base            string           `json:"base"`
    Title           string           `json:"title"`
    Body            string           `json:"body"`
    Status          string           `json:"status"`
    Phase           string           `json:"phase,omitempty"`
    Author          *trail.Author    `json:"author"`
    Assignees       []string         `json:"assignees"`
    Labels          []string         `json:"labels"`
    Priority        string           `json:"priority,omitempty"`
    Type            string           `json:"type,omitempty"`
    Reviewers       []trail.Reviewer `json:"reviewers,omitempty"`
    CreatedAt       time.Time        `json:"created_at"`
    UpdatedAt       time.Time        `json:"updated_at"`
    MergedAt        *time.Time       `json:"merged_at,omitempty"`
    CommentCount    int              `json:"comment_count,omitempty"`
    UnresolvedCount int              `json:"unresolved_count,omitempty"`
    CheckpointCount int              `json:"checkpoint_count,omitempty"`
    CommitsAhead    int              `json:"commits_ahead,omitempty"`
    ID        string           `json:"id,omitempty"`
    Number    int              `json:"number,omitempty"`
    URL       string           `json:"url,omitempty"`
    Branch    string           `json:"branch"`
    Base      string           `json:"base"`
    Title     string           `json:"title"`
    Body      string           `json:"body"`
    Status    string           `json:"status"`
    Phase     string           `json:"phase,omitempty"`
    Author    *trail.Author    `json:"author"`
    Assignees []string         `json:"assignees"`
    Labels    []string         `json:"labels"`
    Priority  string           `json:"priority,omitempty"`
    Type      string           `json:"type,omitempty"`
    Reviewers []trail.Reviewer `json:"reviewers,omitempty"`
    // RequestedReviewers holds the logins requested for review (distinct from
    // Reviewers, which carries per-login review status). The replace-set
    // --add-reviewer/--remove-reviewer merge computes the new set from this.
    RequestedReviewers []string   `json:"requested_reviewers,omitempty"`
    CreatedAt          time.Time  `json:"created_at"`
    UpdatedAt          time.Time  `json:"updated_at"`
    MergedAt           *time.Time `json:"merged_at,omitempty"`
    CommentCount       int        `json:"comment_count,omitempty"`
    UnresolvedCount    int        `json:"unresolved_count,omitempty"`
    CheckpointCount    int        `json:"checkpoint_count,omitempty"`
    CommitsAhead       int        `json:"commits_ahead,omitempty"`
    // BodyDocument carries the trail's description (collaborative editor doc).
    // The list endpoint omits it; the detail endpoint populates it.
    BodyDocument *TrailBodyDocument `json:"body_document,omitempty"`
20 unmodified lines

Author:    r.Author,
        Assignees: r.Assignees,
        Labels:    r.Labels,
        Type:      trail.Type(r.Type),
        Priority:  trail.Priority(r.Priority),
        Reviewers: r.Reviewers,
        CreatedAt: r.CreatedAt,
        UpdatedAt: r.UpdatedAt,
        MergedAt:  r.MergedAt,
33 unmodified lines

// Pointer fields distinguish "not provided" (nil) from "set to value".
// For slices, *[]string is used so nil means "no change" while &[]string{} means "clear".
type TrailUpdateRequest struct {
    Status *string   `json:"status,omitempty"`
    Title  *string   `json:"title,omitempty"`
    Body   *string   `json:"body,omitempty"`
    Labels *[]string `json:"labels,omitempty"`
    Status             *string   `json:"status,omitempty"`
    Title              *string   `json:"title,omitempty"`
    Body               *string   `json:"body,omitempty"`
    Labels             *[]string `json:"labels,omitempty"`
    Assignees          *[]string `json:"assignees,omitempty"`
    RequestedReviewers *[]string `json:"requested_reviewers,omitempty"`
    Type               *string   `json:"type,omitempty"`
    Priority           *string   `json:"priority,omitempty"`
}

// TrailUpdateResponse is the response from PATCH /api/v1/trails/:org/:repo/:trailId.
7 unmodified lines

type TrailDeleteResponse struct {
    OK bool `json:"ok"`
}

// TrailApproval is a single approval decision on a trail.
type TrailApproval struct {
    ID        string        `json:"id"`
    Author    *trail.Author `json:"author"`
    Event     string        `json:"event"` // "approved" | "changes_requested"
    Body      string        `json:"body,omitempty"`
    CommitSHA string        `json:"commit_sha,omitempty"`
    CreatedAt time.Time     `json:"created_at"`
}

// TrailApprovalRequest is the body for POST .../:number/approvals.
// Event is "APPROVE" or "REQUEST_CHANGES"; Body is required for REQUEST_CHANGES.
type TrailApprovalRequest struct {
    Event string `json:"event"`
    Body  string `json:"body,omitempty"`
}

// TrailApprovalResponse is the response from POST .../:number/approvals.
type TrailApprovalResponse struct {
    OK       bool          `json:"ok"`
    Approval TrailApproval `json:"approval"`
}

// TrailApprovalsResponse is the response from GET .../:number/approvals.
type TrailApprovalsResponse struct {
    Approvals []TrailApproval `json:"approvals"`
}

Mcmd/entire/cli/api/trail_types.go+65/-26

2 unmodified lines

3
4
5
6
7
8
9
10
50 unmodified lines

61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84

2 unmodified lines

import (
    "encoding/json"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/trail"
)

// TestTrailResourceDecodesServerURL covers the wire-compatibility matrix for the
50 unmodified lines

t.Fatalf("metadata URL = %q, want propagated server url", metadata.URL)
    }
}

func TestToMetadataMapsTypePriorityReviewers(t *testing.T) {
    t.Parallel()
    login := "octocat"
    r := &TrailResource{
        Type:      "bug",
        Priority:  "high",
        Reviewers: []trail.Reviewer{{Login: "rev1", Status: trail.ReviewerApproved}},
        Author:    &trail.Author{ID: "1", Login: &login},
    }
    m := r.ToMetadata()
    if m.Type != trail.TypeBug {
        t.Errorf("Type = %q, want bug", m.Type)
    }
    if m.Priority != trail.PriorityHigh {
        t.Errorf("Priority = %q, want high", m.Priority)
    }
    if len(m.Reviewers) != 1 || m.Reviewers[0].Login != "rev1" {
        t.Errorf("Reviewers = %#v, want one rev1", m.Reviewers)
    }
}

Mcmd/entire/cli/api/trail_types_test.go+23

72 unmodified lines

73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
17 unmodified lines

144
145
146
147
148
149
150
151
152

72 unmodified lines

Status ReviewerStatus `json:"status"`
}

// Type represents the category of a trail. Mirrors VALID_TRAIL_TYPES server-side.
type Type string

const (
    TypeBug     Type = "bug"
    TypeFeature Type = "feature"
    TypeTask    Type = "task"
)

// ValidTypes returns all valid trail types.
func ValidTypes() []Type { return []Type{TypeBug, TypeFeature, TypeTask} }

// IsValid reports whether t is a recognized trail type.
func (t Type) IsValid() bool {
    for _, vt := range ValidTypes() {
        if t == vt {
            return true
        }
    }
    return false
}

// Priority represents a trail's priority. Mirrors VALID_PRIORITIES server-side.
type Priority string

const (
    PriorityUrgent Priority = "urgent"
    PriorityHigh   Priority = "high"
    PriorityMedium Priority = "medium"
    PriorityLow    Priority = "low"
    PriorityNone   Priority = "none"
)

// ValidPriorities returns all valid priorities in descending urgency order.
func ValidPriorities() []Priority {
    return []Priority{PriorityUrgent, PriorityHigh, PriorityMedium, PriorityLow, PriorityNone}
}

// IsValid reports whether p is a recognized priority.
func (p Priority) IsValid() bool {
    for _, vp := range ValidPriorities() {
        if p == vp {
            return true
        }
    }
    return false
}

// Author identifies the user who created a trail.
// On the wire the whole object may be null when the original author can no
// longer be resolved (e.g. the GitHub user no longer exists), and login may
17 unmodified lines

Author    *Author    `json:"author"`
    Assignees []string   `json:"assignees"`
    Labels    []string   `json:"labels"`
    Type      Type       `json:"type,omitempty"`
    Priority  Priority   `json:"priority,omitempty"`
    Reviewers []Reviewer `json:"reviewers,omitempty"`
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt time.Time  `json:"updated_at"`
    MergedAt  *time.Time `json:"merged_at"`

Mcmd/entire/cli/trail/trail.go+51

89 unmodified lines

90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137

89 unmodified lines

})
    }
}

func TestType_IsValid(t *testing.T) {
    t.Parallel()
    tests := []struct {
        typ   Type
        valid bool
    }{
        {TypeBug, true}, {TypeFeature, true}, {TypeTask, true},
        {"", false}, {"epic", false},
    }
    for _, tt := range tests {
        t.Run(string(tt.typ), func(t *testing.T) {
            t.Parallel()
            if got := tt.typ.IsValid(); got != tt.valid {
                t.Errorf("Type(%q).IsValid() = %v, want %v", tt.typ, got, tt.valid)
            }
        })
    }
    if len(ValidTypes()) != 3 {
        t.Errorf("ValidTypes() len = %d, want 3", len(ValidTypes()))
    }
}

func TestPriority_IsValid(t *testing.T) {
    t.Parallel()
    tests := []struct {
        p     Priority
        valid bool
    }{
        {PriorityUrgent, true}, {PriorityHigh, true}, {PriorityMedium, true},
        {PriorityLow, true}, {PriorityNone, true},
        {"", false}, {"critical", false},
    }
    for _, tt := range tests {
        t.Run(string(tt.p), func(t *testing.T) {
            t.Parallel()
            if got := tt.p.IsValid(); got != tt.valid {
                t.Errorf("Priority(%q).IsValid() = %v, want %v", tt.p, got, tt.valid)
            }
        })
    }
    if len(ValidPriorities()) != 5 {
        t.Errorf("ValidPriorities() len = %d, want 5", len(ValidPriorities()))
    }
}

Mcmd/entire/cli/trail/trail_test.go+45

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202

package cli

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "strings"

"github.com/entireio/cli/cmd/entire/cli/api"
    "github.com/spf13/cobra"
)

// trailApprovalsPath builds the approvals collection path for a trail number.
func trailApprovalsPath(forge, owner, repo string, number int) string {
    return trailNumberPath(forge, owner, repo, number) + "/approvals"
}

// buildApprovalRequest validates and constructs an approval request. A
// REQUEST_CHANGES decision requires a non-empty message; the server enforces
// this too, but a client-side check gives a clearer error before the round trip.
func buildApprovalRequest(event, message string) (api.TrailApprovalRequest, error) {
    msg := strings.TrimSpace(message)
    if event == "REQUEST_CHANGES" && msg == "" {
        return api.TrailApprovalRequest{}, errors.New("--message is required when requesting changes")
    }
    return api.TrailApprovalRequest{Event: event, Body: msg}, nil
}

// 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
    }
    found, err := resolveTrailBySelector(ctx, client, forge, owner, repoName, selector, branch)
    if err != nil {
        return nil, "", "", "", err
    }
    if found.Number <= 0 {
        return nil, "", "", "", errors.New("trail has no number yet")
    }
    return found, forge, owner, repoName, nil
}

// selectorFromArgs returns the first positional arg, mirroring `trail show`.
func selectorFromArgs(args []string) string {
    if len(args) == 1 {
        return args[0]
    }
    return ""
}

func submitTrailApproval(ctx context.Context, w, errW io.Writer, insecureHTTP bool, repoOverride, selector, branch, event, message, successVerb string) error {
    if selector != "" && strings.TrimSpace(branch) != "" {
        return errors.New("pass a trail selector or --branch, not both")
    }
    req, err := buildApprovalRequest(event, message)
    if err != nil {
        return err
    }
    // Auth/not-logged-in messages go to stderr; w carries command output only.
    return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, repoOverride, func(ctx context.Context, client *api.Client) error {
        found, forge, owner, repoName, err := resolveNumberedTrail(ctx, client, repoOverride, selector, branch)
        if err != nil {
            return err
        }
        resp, err := client.Post(ctx, trailApprovalsPath(forge, owner, repoName, found.Number), req)
        if err != nil {
            return fmt.Errorf("failed to submit approval: %w", err)
        }
        defer resp.Body.Close()
        if err := checkTrailResponse(resp); err != nil {
            return err
        }
        var out api.TrailApprovalResponse
        if err := api.DecodeJSON(resp, &out); err != nil {
            return fmt.Errorf("failed to decode approval response: %w", err)
        }
        fmt.Fprintf(w, "%s trail #%d\n", successVerb, found.Number)
        return nil
    })
}

func newTrailApproveCmd() *cobra.Command {
    var message, branch string
    cmd := &cobra.Command{
        Use:   "approve [<trail>]",
        Short: "Approve a trail",
        Long: `Approve a trail.

If <trail> is omitted, approves the trail for the current branch (or --branch).
The trail must be open and have a linked branch.`,
        Args: cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            if err := ensureTrailRepoHasTarget(cmd, selectorFromArgs(args) != "" || strings.TrimSpace(branch) != "", "pass a trail selector or --branch"); err != nil {
                return err
            }
            return submitTrailApproval(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd),
                trailRepoFlag(cmd), selectorFromArgs(args), branch, "APPROVE", message, "Approved")
        },
    }
    cmd.Flags().StringVarP(&message, "message", "m", "", "Optional approval comment")
    cmd.Flags().StringVar(&branch, "branch", "", "Branch of the trail (defaults to current); cannot be combined with a trail selector")
    return cmd
}

func newTrailRequestChangesCmd() *cobra.Command {
    var message, branch string
    cmd := &cobra.Command{
        Use:   "request-changes [<trail>]",
        Short: "Request changes on a trail",
        Long: `Request changes on a trail.

If <trail> is omitted, targets the trail for the current branch (or --branch).
A reason (--message) is required. The trail must be open and have a linked branch.`,
        Args: cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            if err := ensureTrailRepoHasTarget(cmd, selectorFromArgs(args) != "" || strings.TrimSpace(branch) != "", "pass a trail selector or --branch"); err != nil {
                return err
            }
            return submitTrailApproval(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd),
                trailRepoFlag(cmd), selectorFromArgs(args), branch, "REQUEST_CHANGES", message, "Requested changes on")
        },
    }
    cmd.Flags().StringVarP(&message, "message", "m", "", "Reason for requesting changes (required)")
    cmd.Flags().StringVar(&branch, "branch", "", "Branch of the trail (defaults to current); cannot be combined with a trail selector")
    return cmd
}

func newTrailApprovalsCmd() *cobra.Command {
    var branch string
    var jsonOut bool
    cmd := &cobra.Command{
        Use:   "approvals [<trail>]",
        Short: "List approval decisions on a trail",
        Args:  cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            if err := ensureTrailRepoHasTarget(cmd, selectorFromArgs(args) != "" || strings.TrimSpace(branch) != "", "pass a trail selector or --branch"); err != nil {
                return err
            }
            return runTrailApprovals(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd),
                trailRepoFlag(cmd), selectorFromArgs(args), branch, jsonOut)
        },
    }
    cmd.Flags().StringVar(&branch, "branch", "", "Branch of the trail (defaults to current); cannot be combined with a trail selector")
    cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
    return cmd
}

func runTrailApprovals(ctx context.Context, w, errW io.Writer, insecureHTTP bool, repoOverride, selector, branch string, jsonOut bool) error {
    if selector != "" && strings.TrimSpace(branch) != "" {
        return errors.New("pass a trail selector or --branch, not both")
    }
    // Auth/not-logged-in messages go to stderr; w carries command output only.
    return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, repoOverride, func(ctx context.Context, client *api.Client) error {
        found, forge, owner, repoName, err := resolveNumberedTrail(ctx, client, repoOverride, selector, branch)
        if err != nil {
            return err
        }
        resp, err := client.Get(ctx, trailApprovalsPath(forge, owner, repoName, found.Number))
        if err != nil {
            return fmt.Errorf("failed to list approvals: %w", err)
        }
        defer resp.Body.Close()
        if err := checkTrailResponse(resp); err != nil {
            return err
        }
        var out api.TrailApprovalsResponse
        if err := api.DecodeJSON(resp, &out); err != nil {
            return fmt.Errorf("failed to decode approvals response: %w", err)
        }
        if jsonOut {
            enc := json.NewEncoder(w)
            enc.SetIndent("", "  ")
            return enc.Encode(out)
        }
        if len(out.Approvals) == 0 {
            fmt.Fprintf(w, "No approvals on trail #%d\n", found.Number)
            return nil
        }
        for _, a := range out.Approvals {
            login := ""
            if a.Author != nil && a.Author.Login != nil {
                login = *a.Author.Login
            }
            sha := a.CommitSHA
            if len(sha) > 7 {
                sha = sha[:7]
            }
            fmt.Fprintf(w, "%s  %s  %s  %s\n", a.Event, login, sha, a.CreatedAt.Format("2006-01-02T15:04:05Z07:00"))
            if strings.TrimSpace(a.Body) != "" {
                fmt.Fprintf(w, "    %s\n", a.Body)
            }
        }
        return nil
    })
}

Acmd/entire/cli/trail_approval_cmd.go+202

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

package cli

import (
    "strings"
    "testing"
)

func TestBuildApprovalRequestRequiresMessageForRequestChanges(t *testing.T) {
    t.Parallel()
    if _, err := buildApprovalRequest("REQUEST_CHANGES", "  "); err == nil {
        t.Error("REQUEST_CHANGES without message should be rejected")
    }
    req, err := buildApprovalRequest("REQUEST_CHANGES", "please fix")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if req.Event != "REQUEST_CHANGES" || req.Body != "please fix" {
        t.Fatalf("req = %#v", req)
    }
}

func TestBuildApprovalRequestApproveAllowsEmptyMessage(t *testing.T) {
    t.Parallel()
    req, err := buildApprovalRequest("APPROVE", "")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if req.Event != "APPROVE" || req.Body != "" {
        t.Fatalf("req = %#v", req)
    }
}

func TestTrailApprovalsPath(t *testing.T) {
    t.Parallel()
    got := trailApprovalsPath("gh", "acme", "widgets", 7)
    if !strings.HasSuffix(got, "/7/approvals") {
        t.Fatalf("path = %q, want .../7/approvals suffix", got)
    }
}

func TestTrailApprovalCmdsHaveExpectedFlags(t *testing.T) {
    t.Parallel()
    if newTrailApproveCmd().Flags().Lookup("message") == nil {
        t.Error("approve missing --message")
    }
    if newTrailRequestChangesCmd().Flags().Lookup("message") == nil {
        t.Error("request-changes missing --message")
    }
    if newTrailApprovalsCmd().Flags().Lookup("json") == nil {
        t.Error("approvals missing --json")
    }
}

Acmd/entire/cli/trail_approval_cmd_test.go+52

85 unmodified lines

86
87
88
89
90
91
92
93
94
95
28 unmodified lines

124
125
126
123
124
125
126
127
127
128
129
130
131
132
162 unmodified lines

295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
506 unmodified lines

820
821
822
808
823
824
825
826
827
4 unmodified lines

832
833
834
819
835
836
837
838
2 unmodified lines

841
842
843
844
845
846
847
848
849
1 unmodified line

851
852
853
835
854
855
856
857
1 unmodified line

859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
25 unmodified lines

900
901
902
874
903
904
905
906
126 unmodified lines

1033
1034
1035
1007
1008
1036
1037
1038
1039
1040
41 unmodified lines

1082
1083
1084
1056
1085
1086
1058
1059
1060
1061
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
3 unmodified lines

1100
1101
1102
1071
1072
1103
1104
1105
1106
1107
4 unmodified lines

1112
1113
1114
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
4 unmodified lines

1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
25 unmodified lines

1200
1201
1202
1149
1203
1204
1205
1206
1207
1208
1209
1210
37 unmodified lines

1248
1249
1250
1193
1194
1195
1196
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1203
1204
1205
1206
1207
1208
1209
1210
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1 unmodified line

1285
1286
1287
1218
1219
1220
1221
1222
1223
1224
1225
1288
1289
1227
1228
1229
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
11 unmodified lines

1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
7 unmodified lines

1396
1397
1398
1263
1264
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463

85 unmodified lines

cmd.AddCommand(newTrailDeleteCmd())
    cmd.AddCommand(newTrailFindingCmd())
    cmd.AddCommand(newTrailWatchCmd())
    cmd.AddCommand(newTrailApproveCmd())
    cmd.AddCommand(newTrailRequestChangesCmd())
    cmd.AddCommand(newTrailApprovalsCmd())
    cmd.AddCommand(newTrailCommentCmd())

return cmd
}
28 unmodified lines

return nil
}

// ensureTrailRepoHasTarget requires an explicit branch or trail selector when
// --repo targets a repository other than the local clone. Without one, the
// branch-defaulting commands fall back to the local checkout's current branch,
// which would silently resolve the wrong trail (a shared branch name) in the
// overridden repo. hint names the acceptable targets for the command.
// ensureTrailRepoHasTarget requires an explicit branch or selector when --repo
// targets another repo; otherwise the command would resolve the local branch
// against the wrong repo. hint names the acceptable targets.
func ensureTrailRepoHasTarget(cmd *cobra.Command, hasTarget bool, hint string) error {
    if trailRepoFlag(cmd) != "" && !hasTarget {
        return fmt.Errorf("--repo requires an explicit target: %s", hint)
162 unmodified lines

if len(m.Assignees) > 0 {
        fmt.Fprintf(w, "  %s%s\n", label("Assignees: "), strings.Join(m.Assignees, ", "))
    }
    if strings.TrimSpace(string(m.Type)) != "" {
        fmt.Fprintf(w, "  %s%s\n", label("Type:      "), m.Type)
    }
    if p := strings.TrimSpace(string(m.Priority)); p != "" && p != string(trail.PriorityNone) {
        fmt.Fprintf(w, "  %s%s\n", label("Priority:  "), m.Priority)
    }
    if len(m.Reviewers) > 0 {
        parts := make([]string, 0, len(m.Reviewers))
        for _, r := range m.Reviewers {
            parts = append(parts, fmt.Sprintf("%s (%s)", r.Login, r.Status))
        }
        fmt.Fprintf(w, "  %s%s\n", label("Reviewers: "), strings.Join(parts, ", "))
    }
    fmt.Fprintf(w, "  %s%s\n", label("Created: "), m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"))
    fmt.Fprintf(w, "  %s%s\n", label("Updated: "), m.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"))
    if strings.TrimSpace(bodyText) != "" {
506 unmodified lines

}

func newTrailCreateCmd() *cobra.Command {
    var title, body, base, branch, status string
    var title, body, base, branch, status, typeStr, priorityStr string
    var assignees []string
    var checkout, noBranch bool

cmd := &cobra.Command{
4 unmodified lines

if err := ensureNoTrailRepoOverride(cmd, "trail create"); err != nil {
                return err
            }
            return runTrailCreate(cmd, title, body, base, branch, status, checkout, noBranch)
            return runTrailCreate(cmd, title, body, base, branch, status, typeStr, priorityStr, assignees, checkout, noBranch)
        },
    }

2 unmodified lines

cmd.Flags().StringVar(&base, "base", "", "Base branch (defaults to detected default branch)")
    cmd.Flags().StringVar(&branch, "branch", "", "Branch for the trail (defaults to current branch)")
    cmd.Flags().StringVar(&status, "status", "", "Initial status (defaults to open)")
    cmd.Flags().StringVar(&typeStr, "type", "", fmt.Sprintf("Type (%s)", formatValidTypes()))
    cmd.Flags().StringVar(&priorityStr, "priority", "", fmt.Sprintf("Priority (%s)", formatValidPriorities()))
    cmd.Flags().StringSliceVar(&assignees, "add-assignee", nil, "Assign user(s) by login")
    cmd.Flags().BoolVar(&checkout, "checkout", false, "Check out the branch after creating it")
    cmd.Flags().BoolVar(&noBranch, "no-branch", false, "Create a branchless trail")

1 unmodified line

}

//nolint:cyclop // sequential steps for creating a trail — splitting would obscure the flow
func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr string, checkout, noBranch bool) error {
func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr, typeStr, priorityStr string, assignees []string, checkout, noBranch bool) error {
    ctx := cmd.Context()
    w := cmd.OutOrStdout()
    errW := cmd.ErrOrStderr()
1 unmodified line

if err := validateTrailCreateFlagCombos(cmd, checkout, noBranch); err != nil {
        return err
    }
    if cmd.Flags().Changed("type") {
        if !trail.Type(strings.TrimSpace(typeStr)).IsValid() {
            return fmt.Errorf("invalid type %q: valid values are %s", typeStr, formatValidTypes())
        }
    }
    if cmd.Flags().Changed("priority") {
        if !trail.Priority(strings.TrimSpace(priorityStr)).IsValid() {
            return fmt.Errorf("invalid priority %q: valid values are %s", priorityStr, formatValidPriorities())
        }
    }

repo, err := strategy.OpenRepository(ctx)
    if err != nil {
25 unmodified lines

return err
    }

createResp, err := postTrailCreate(ctx, client, forge, owner, repoName, title, body, branch, base, statusStr)
    createResp, err := postTrailCreate(ctx, client, forge, owner, repoName, title, body, branch, base, statusStr, strings.TrimSpace(typeStr), strings.TrimSpace(priorityStr), assignees)
    if err != nil {
        cleanupCreatedTrailBranch(repo, branch, branchState.LocalCreated, branchState.RemotePushed, errW)
        return err
126 unmodified lines

return nil
}

func postTrailCreate(ctx context.Context, client *api.Client, forge, owner, repoName, title, body, branch, base, statusStr string) (api.TrailCreateResponse, error) {
    createReq := newTrailCreateRequest(title, body, branch, base, statusStr)
func postTrailCreate(ctx context.Context, client *api.Client, forge, owner, repoName, title, body, branch, base, statusStr, typeStr, priorityStr string, assignees []string) (api.TrailCreateResponse, error) {
    createReq := newTrailCreateRequest(title, body, branch, base, statusStr, typeStr, priorityStr, assignees)
    resp, err := client.Post(ctx, trailsBasePath(forge, owner, repoName), createReq)
    if err != nil {
        noteTrailCommandEnablement(ctx, client, err)
41 unmodified lines

return nil
}

func newTrailCreateRequest(title, body, branch, base, statusStr string) api.TrailCreateRequest {
func newTrailCreateRequest(title, body, branch, base, statusStr, typeStr, priorityStr string, assignees []string) api.TrailCreateRequest {
    req := api.TrailCreateRequest{
        Title:  title,
        Body:   body,
        Base:   base,
        Status: statusStr,
        Title:     title,
        Body:      body,
        Base:      base,
        Status:    statusStr,
        Type:      typeStr,
        Priority:  priorityStr,
        Assignees: assignees,
    }
    if branch != "" {
        req.BranchName = branch
3 unmodified lines

}

func newTrailUpdateCmd() *cobra.Command {
    var statusStr, title, body, branch string
    var labelAdd, labelRemove []string
    var statusStr, title, body, branch, typeStr, priorityStr string
    var labelAdd, labelRemove, assigneeAdd, assigneeRemove, reviewerAdd, reviewerRemove []string

cmd := &cobra.Command{
        Use:   "update",
4 unmodified lines

return err
            }
            return runTrailUpdate(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), trailUpdateInputs{
                Status:        statusStr,
                StatusChanged: cmd.Flags().Changed("status"),
                Title:         title,
                TitleChanged:  cmd.Flags().Changed("title"),
                Body:          body,
                BodyChanged:   cmd.Flags().Changed("body"),
                Branch:        branch,
                Repo:          trailRepoFlag(cmd),
                LabelAdd:      labelAdd,
                LabelRemove:   labelRemove,
                Status:          statusStr,
                StatusChanged:   cmd.Flags().Changed("status"),
                Title:           title,
                TitleChanged:    cmd.Flags().Changed("title"),
                Body:            body,
                BodyChanged:     cmd.Flags().Changed("body"),
                Branch:          branch,
                Repo:            trailRepoFlag(cmd),
                LabelAdd:        labelAdd,
                LabelRemove:     labelRemove,
                AssigneeAdd:     assigneeAdd,
                AssigneeRemove:  assigneeRemove,
                ReviewerAdd:     reviewerAdd,
                ReviewerRemove:  reviewerRemove,
                Type:            typeStr,
                TypeChanged:     cmd.Flags().Changed("type"),
                Priority:        priorityStr,
                PriorityChanged: cmd.Flags().Changed("priority"),
            })
        },
    }
4 unmodified lines

cmd.Flags().StringVar(&branch, "branch", "", "Branch to update trail for (defaults to current)")
    cmd.Flags().StringSliceVar(&labelAdd, "add-label", nil, "Add label(s)")
    cmd.Flags().StringSliceVar(&labelRemove, "remove-label", nil, "Remove label(s)")
    cmd.Flags().StringSliceVar(&assigneeAdd, "add-assignee", nil, "Add assignee(s) by login")
    cmd.Flags().StringSliceVar(&assigneeRemove, "remove-assignee", nil, "Remove assignee(s) by login")
    cmd.Flags().StringSliceVar(&reviewerAdd, "add-reviewer", nil, "Request reviewer(s) by login")
    cmd.Flags().StringSliceVar(&reviewerRemove, "remove-reviewer", nil, "Remove requested reviewer(s) by login")
    cmd.Flags().StringVar(&typeStr, "type", "", fmt.Sprintf("Set type (%s)", formatValidTypes()))
    cmd.Flags().StringVar(&priorityStr, "priority", "", fmt.Sprintf("Set priority (%s)", formatValidPriorities()))

return cmd
}

type trailUpdateInputs struct {
    Status        string
    StatusChanged bool
    Title         string
    TitleChanged  bool
    Body          string
    BodyChanged   bool
    Branch        string
    Repo          string
    LabelAdd      []string
    LabelRemove   []string
    Status          string
    StatusChanged   bool
    Title           string
    TitleChanged    bool
    Body            string
    BodyChanged     bool
    Branch          string
    Repo            string
    LabelAdd        []string
    LabelRemove     []string
    AssigneeAdd     []string
    AssigneeRemove  []string
    ReviewerAdd     []string
    ReviewerRemove  []string
    Type            string
    TypeChanged     bool
    Priority        string
    PriorityChanged bool
}

func runTrailUpdate(ctx context.Context, w, errW io.Writer, insecureHTTP bool, inputs trailUpdateInputs) error {
25 unmodified lines

statusStr := inputs.Status
        title := inputs.Title
        body := inputs.Body
        noFlags := !inputs.StatusChanged && !inputs.TitleChanged && !inputs.BodyChanged && inputs.LabelAdd == nil && inputs.LabelRemove == nil
        noFlags := !inputs.StatusChanged && !inputs.TitleChanged && !inputs.BodyChanged &&
            inputs.LabelAdd == nil && inputs.LabelRemove == nil &&
            inputs.AssigneeAdd == nil && inputs.AssigneeRemove == nil &&
            inputs.ReviewerAdd == nil && inputs.ReviewerRemove == nil &&
            !inputs.TypeChanged && !inputs.PriorityChanged
        if noFlags {
            metadata := found.ToMetadata()
            // Build status options with current value as default.
37 unmodified lines

statusStr = strings.TrimSpace(statusStr)
        title = strings.TrimSpace(title)
        if err := validateTrailUpdateFields(trailUpdateInputs{
            Status:        statusStr,
            StatusChanged: inputs.StatusChanged,
            Title:         title,
            TitleChanged:  inputs.TitleChanged,
            Status:          statusStr,
            StatusChanged:   inputs.StatusChanged,
            Title:           title,
            TitleChanged:    inputs.TitleChanged,
            Type:            inputs.Type,
            TypeChanged:     inputs.TypeChanged,
            Priority:        inputs.Priority,
            PriorityChanged: inputs.PriorityChanged,
        }); err != nil {
            return err
        }

// Build update request with only changed fields.
        updateReq := buildTrailUpdateRequest(found, trailUpdateInputs{
            Status:        statusStr,
            StatusChanged: inputs.StatusChanged,
            Title:         title,
            TitleChanged:  inputs.TitleChanged,
            Body:          body,
            BodyChanged:   inputs.BodyChanged,
            LabelAdd:      inputs.LabelAdd,
            LabelRemove:   inputs.LabelRemove,
            Status:          statusStr,
            StatusChanged:   inputs.StatusChanged,
            Title:           title,
            TitleChanged:    inputs.TitleChanged,
            Body:            body,
            BodyChanged:     inputs.BodyChanged,
            LabelAdd:        inputs.LabelAdd,
            LabelRemove:     inputs.LabelRemove,
            AssigneeAdd:     inputs.AssigneeAdd,
            AssigneeRemove:  inputs.AssigneeRemove,
            ReviewerAdd:     inputs.ReviewerAdd,
            ReviewerRemove:  inputs.ReviewerRemove,
            Type:            inputs.Type,
            TypeChanged:     inputs.TypeChanged,
            Priority:        inputs.Priority,
            PriorityChanged: inputs.PriorityChanged,
        })

// The single-trail endpoint is keyed by trail number, not id; the server
1 unmodified line

if found.Number <= 0 {
            return fmt.Errorf("trail for branch %q has no number yet; cannot update", branch)
        }
        resp, err := client.Patch(ctx, trailNumberPath(forge, owner, repoName, found.Number), updateReq)
        if err != nil {
            return fmt.Errorf("failed to update trail: %w", err)
        }
        defer resp.Body.Close()
        if err := checkTrailResponse(resp); err != nil {
            return err
        }
        path := trailNumberPath(forge, owner, repoName, found.Number)

var updateResp api.TrailUpdateResponse
        if err := api.DecodeJSON(resp, &updateResp); err != nil {
            return fmt.Errorf("failed to decode update response: %w", err)
        // The server rejects body + metadata in one PATCH, so send them as
        // separate requests when both are present. These two calls are not
        // atomic: if the metadata PATCH lands and the body PATCH then fails, the
        // metadata change persists. Report that partial state explicitly so the
        // caller knows the metadata already applied and only the body needs a
        // retry, rather than assuming nothing changed.
        meta, hasMeta, bodyReq := splitTrailUpdate(updateReq)
        if hasMeta {
            if err := sendTrailPatch(ctx, client, path, meta); err != nil {
                return err
            }
        }
        if bodyReq != nil {
            if err := sendTrailPatch(ctx, client, path, *bodyReq); err != nil {
                if hasMeta {
                    return fmt.Errorf("trail metadata was updated, but the body update failed (the metadata change already applied; retry only the --body change): %w", err)
                }
                return err
            }
        }

fmt.Fprintf(w, "Updated trail for branch %s\n", branch)
11 unmodified lines

return fmt.Errorf("invalid status %q: valid values are %s", inputs.Status, formatValidStatuses())
        }
    }
    if inputs.TypeChanged {
        if typ := trail.Type(strings.TrimSpace(inputs.Type)); !typ.IsValid() {
            return fmt.Errorf("invalid type %q: valid values are %s", inputs.Type, formatValidTypes())
        }
    }
    if inputs.PriorityChanged {
        if p := trail.Priority(strings.TrimSpace(inputs.Priority)); !p.IsValid() {
            return fmt.Errorf("invalid priority %q: valid values are %s", inputs.Priority, formatValidPriorities())
        }
    }
    return nil
}

// formatValidTypes lists the valid trail types for error messages.
func formatValidTypes() string {
    parts := make([]string, 0, len(trail.ValidTypes()))
    for _, t := range trail.ValidTypes() {
        parts = append(parts, string(t))
    }
    return strings.Join(parts, ", ")
}

// formatValidPriorities lists the valid trail priorities for error messages.
func formatValidPriorities() string {
    parts := make([]string, 0, len(trail.ValidPriorities()))
    for _, p := range trail.ValidPriorities() {
        parts = append(parts, string(p))
    }
    return strings.Join(parts, ", ")
}

// mergeStringSet returns current with add appended (dedup, order-preserving)
// and remove entries deleted. Used for replace-set fields (labels, assignees,
// requested reviewers) whose PATCH endpoint takes the full new list.
func mergeStringSet(current, add, remove []string) []string {
    out := make([]string, 0, len(current)+len(add))
    out = append(out, current...)
    for _, a := range add {
        found := false
        for _, e := range out {
            if e == a {
                found = true
                break
            }
        }
        if !found {
            out = append(out, a)
        }
    }
    for _, r := range remove {
        for i, e := range out {
            if e == r {
                out = append(out[:i], out[i+1:]...)
                break
            }
        }
    }
    return out
}

// buildTrailUpdateRequest constructs a PATCH request body from the current trail and the requested changes.
func buildTrailUpdateRequest(current *api.TrailResource, inputs trailUpdateInputs) api.TrailUpdateRequest {
    var req api.TrailUpdateRequest
7 unmodified lines

if inputs.BodyChanged {
        req.Body = &inputs.Body
    }

// Handle label changes: merge adds, remove removes.
    if inputs.TypeChanged {
        typ := strings.TrimSpace(inputs.Type)
        req.Type = &typ
    }
    if inputs.PriorityChanged {
        priority := strings.TrimSpace(inputs.Priority)
        req.Priority = &priority
    }
    // Replace-set fields: compute the full new list from the current trail.
    if len(inputs.LabelAdd) > 0 || len(inputs.LabelRemove) > 0 {
        labels := make([]string, 0, len(current.Labels)+len(inputs.LabelAdd))
        labels = append(labels, current.Labels...)
        for _, l := range inputs.LabelAdd {
            found := false
            for _, existing := range labels {
                if existing == l {
                    found = true
                    break
                }
            }
            if !found {
                labels = append(labels, l)
            }
        }
        for _, l := range inputs.LabelRemove {
            for i, existing := range labels {
                if existing == l {
                    labels = append(labels[:i], labels[i+1:]...)
                    break
                }
            }
        }
        labels := mergeStringSet(current.Labels, inputs.LabelAdd, inputs.LabelRemove)
        req.Labels = &labels
    }
    if len(inputs.AssigneeAdd) > 0 || len(inputs.AssigneeRemove) > 0 {
        assignees := mergeStringSet(current.Assignees, inputs.AssigneeAdd, inputs.AssigneeRemove)
        req.Assignees = &assignees
    }
    if len(inputs.ReviewerAdd) > 0 || len(inputs.ReviewerRemove) > 0 {
        reviewers := mergeStringSet(current.RequestedReviewers, inputs.ReviewerAdd, inputs.ReviewerRemove)
        req.RequestedReviewers = &reviewers
    }

return req
}

// splitTrailUpdate separates a full update into a metadata request and an
// optional body request. The server rejects a body update combined with
// status/title/assignees/reviewers/type/priority (trails.ts:4384), so the two
// must be sent as separate PATCH calls. Labels are exempt server-side, but for
// simplicity they travel in the metadata request too; the only cost is one
// extra PATCH in the rare body+labels-only update, which is harmless. hasMeta
// reports whether the metadata request has any field set.
func splitTrailUpdate(full api.TrailUpdateRequest) (meta api.TrailUpdateRequest, hasMeta bool, bodyReq *api.TrailUpdateRequest) {
    if full.Body != nil {
        b := *full.Body
        bodyReq = &api.TrailUpdateRequest{Body: &b}
    }
    meta = full
    meta.Body = nil
    hasMeta = meta.Status != nil || meta.Title != nil || meta.Labels != nil ||
        meta.Assignees != nil || meta.RequestedReviewers != nil ||
        meta.Type != nil || meta.Priority != nil
    return meta, hasMeta, bodyReq
}

// sendTrailPatch issues a single trail PATCH and validates the response.
func sendTrailPatch(ctx context.Context, client *api.Client, path string, req api.TrailUpdateRequest) error {
    resp, err := client.Patch(ctx, path, req)
    if err != nil {
        return fmt.Errorf("failed to update trail: %w", err)
    }
    defer resp.Body.Close()
    if err := checkTrailResponse(resp); err != nil {
        return err
    }
    var updateResp api.TrailUpdateResponse
    if err := api.DecodeJSON(resp, &updateResp); err != nil {
        return fmt.Errorf("failed to decode update response: %w", err)
    }
    return nil
}

func newTrailCheckoutCmd() *cobra.Command {
    var trailSelector string
    var force bool

Mcmd/entire/cli/trail_cmd.go+253/-86

37 unmodified lines

38
39
40
41
41
42
43
44
6 unmodified lines

51
52
53
54
54
55
56
57
1392 unmodified lines

1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599

37 unmodified lines

)

func TestNewTrailCreateRequestUsesLinkBranchAction(t *testing.T) {
    req := newTrailCreateRequest("title", "body", "feature/x", "main", "open")
    req := newTrailCreateRequest("title", "body", "feature/x", "main", "open", "", "", nil)

require.Equal(t, api.TrailCreateRequest{
        Title:        "title",
6 unmodified lines

}

func TestNewTrailCreateRequestCanBeBranchless(t *testing.T) {
    req := newTrailCreateRequest("title", "body", "", "main", "open")
    req := newTrailCreateRequest("title", "body", "", "main", "open", "", "", nil)

require.Equal(t, api.TrailCreateRequest{
        Title:  "title",
1392 unmodified lines

t.Fatalf("error should mention the --author fallback hint, got: %v", err)
    }
}

func TestMergeStringSetAddsAndRemoves(t *testing.T) {
    t.Parallel()
    got := mergeStringSet([]string{"a", "b"}, []string{"c", "a"}, []string{"b"})
    want := []string{"a", "c"}
    if len(got) != len(want) {
        t.Fatalf("got %v, want %v", got, want)
    }
    for i := range want {
        if got[i] != want[i] {
            t.Fatalf("got %v, want %v", got, want)
        }
    }
}

func TestBuildTrailUpdateRequestAssigneesReviewersTypePriority(t *testing.T) {
    t.Parallel()
    current := &api.TrailResource{
        Assignees:          []string{"alice"},
        RequestedReviewers: []string{"bob"},
    }
    req := buildTrailUpdateRequest(current, trailUpdateInputs{
        AssigneeAdd:     []string{"carol"},
        ReviewerRemove:  []string{"bob"},
        Type:            string(trail.TypeBug),
        TypeChanged:     true,
        Priority:        string(trail.PriorityHigh),
        PriorityChanged: true,
    })
    if req.Assignees == nil || len(*req.Assignees) != 2 {
        t.Fatalf("Assignees = %v, want [alice carol]", req.Assignees)
    }
    if req.RequestedReviewers == nil || len(*req.RequestedReviewers) != 0 {
        t.Fatalf("RequestedReviewers = %v, want []", req.RequestedReviewers)
    }
    if req.Type == nil || *req.Type != string(trail.TypeBug) {
        t.Fatalf("Type = %v, want bug", req.Type)
    }
    if req.Priority == nil || *req.Priority != string(trail.PriorityHigh) {
        t.Fatalf("Priority = %v, want high", req.Priority)
    }
}

func TestValidateTrailUpdateFieldsRejectsInvalidTypePriority(t *testing.T) {
    t.Parallel()
    if err := validateTrailUpdateFields(trailUpdateInputs{TypeChanged: true, Type: "epic"}); err == nil {
        t.Error("expected invalid type to be rejected")
    }
    if err := validateTrailUpdateFields(trailUpdateInputs{PriorityChanged: true, Priority: "critical"}); err == nil {
        t.Error("expected invalid priority to be rejected")
    }
    if err := validateTrailUpdateFields(trailUpdateInputs{TypeChanged: true, Type: "bug", PriorityChanged: true, Priority: "low"}); err != nil {
        t.Errorf("valid type/priority rejected: %v", err)
    }
}

func TestSplitTrailUpdateSeparatesBodyFromMetadata(t *testing.T) {
    t.Parallel()
    body := "new body"
    title := "new title"
    full := api.TrailUpdateRequest{Body: &body, Title: &title}
    meta, hasMeta, bodyReq := splitTrailUpdate(full)
    if !hasMeta || meta.Title == nil || *meta.Title != "new title" {
        t.Fatalf("meta = %#v, hasMeta = %v, want title-only metadata", meta, hasMeta)
    }
    if meta.Body != nil {
        t.Fatal("metadata request must not carry body")
    }
    if bodyReq == nil || bodyReq.Body == nil || *bodyReq.Body != "new body" {
        t.Fatalf("bodyReq = %#v, want body-only request", bodyReq)
    }

_, hasMeta2, bodyReq2 := splitTrailUpdate(api.TrailUpdateRequest{Body: &body})
    if hasMeta2 {
        t.Error("body-only update must not produce a metadata request")
    }
    if bodyReq2 == nil {
        t.Error("body-only update must produce a body request")
    }
}

func TestTrailUpdateCmdHasCollaborationFlags(t *testing.T) {
    t.Parallel()
    cmd := newTrailUpdateCmd()
    for _, name := range []string{"add-assignee", "remove-assignee", "add-reviewer", "remove-reviewer", "type", "priority"} {
        if cmd.Flags().Lookup(name) == nil {
            t.Errorf("trail update missing --%s flag", name)
        }
    }
}

func TestPrintTrailDetailsShowsTypePriorityReviewers(t *testing.T) {
    t.Parallel()
    var out bytes.Buffer
    printTrailDetails(&out, &trail.Metadata{
        Title:     "T",
        Branch:    "b",
        Base:      "main",
        Status:    trail.StatusOpen,
        Type:      trail.TypeBug,
        Priority:  trail.PriorityHigh,
        Reviewers: []trail.Reviewer{{Login: "rev1", Status: trail.ReviewerApproved}},
    }, "", "")
    s := out.String()
    for _, want := range []string{"Type:", "bug", "Priority:", "high", "Reviewers:", "rev1", "approved"} {
        if !strings.Contains(s, want) {
            t.Errorf("output missing %q:\n%s", want, s)
        }
    }
}

func TestTrailCreateCmdHasMetadataFlags(t *testing.T) {
    t.Parallel()
    cmd := newTrailCreateCmd()
    for _, name := range []string{"type", "priority", "add-assignee"} {
        if cmd.Flags().Lookup(name) == nil {
            t.Errorf("trail create missing --%s flag", name)
        }
    }
}

func TestNewTrailCreateRequestCarriesMetadata(t *testing.T) {
    t.Parallel()
    req := newTrailCreateRequest("Title", "body", "b", "main", "open", string(trail.TypeBug), string(trail.PriorityHigh), []string{"alice"})
    if req.Type != string(trail.TypeBug) || req.Priority != string(trail.PriorityHigh) {
        t.Fatalf("type/priority = %q/%q, want bug/high", req.Type, req.Priority)
    }
    if len(req.Assignees) != 1 || req.Assignees[0] != "alice" {
        t.Fatalf("assignees = %v, want [alice]", req.Assignees)
    }
}

func TestBuildTrailUpdateRequestTrimsTypeAndPriority(t *testing.T) {
    t.Parallel()
    req := buildTrailUpdateRequest(&api.TrailResource{}, trailUpdateInputs{
        Type:            "  bug  ",
        TypeChanged:     true,
        Priority:        "  high ",
        PriorityChanged: true,
    })
    if req.Type == nil || *req.Type != string(trail.TypeBug) {
        t.Fatalf("Type on wire = %v, want trimmed bug", req.Type)
    }
    if req.Priority == nil || *req.Priority != string(trail.PriorityHigh) {
        t.Fatalf("Priority on wire = %v, want trimmed high", req.Priority)
    }
}

Mcmd/entire/cli/trail_cmd_test.go+149/-2

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

package cli

import (
    "strings"
    "testing"
)

func TestTrailThreadPathBuilders(t *testing.T) {
    t.Parallel()
    if got := trailThreadsPath("gh", "acme", "widgets", 7); !strings.HasSuffix(got, "/7/threads") {
        t.Errorf("threads path = %q", got)
    }
    if got := trailThreadPath("gh", "acme", "widgets", 7, "th1"); !strings.HasSuffix(got, "/7/threads/th1") {
        t.Errorf("thread path = %q", got)
    }
    if got := trailThreadMessagesPath("gh", "acme", "widgets", 7, "th1"); !strings.HasSuffix(got, "/threads/th1/messages") {
        t.Errorf("messages path = %q", got)
    }
    if got := trailThreadMessagePath("gh", "acme", "widgets", 7, "th1", "m1"); !strings.HasSuffix(got, "/threads/th1/messages/m1") {
        t.Errorf("message path = %q", got)
    }
}

func TestTrailCommentSubtreeWiring(t *testing.T) {
    t.Parallel()
    cmd := newTrailCommentCmd()
    want := map[string]bool{"list": false, "show": false, "add": false, "reply": false, "edit": false, "delete": false, "resolve": false, "unresolve": false}
    for _, c := range cmd.Commands() {
        want[c.Name()] = true
    }
    for name, found := range want {
        if !found {
            t.Errorf("trail comment missing subcommand %q", name)
        }
    }
    if cmd.PersistentFlags().Lookup("trail") == nil || cmd.PersistentFlags().Lookup("branch") == nil {
        t.Error("trail comment missing --trail/--branch persistent flags")
    }
}

Acmd/entire/cli/trail_collaboration_cmd_test.go+39

package cli

import ( "context" "encoding/json" "errors" "fmt" "io" "strings" "time"

"charm.land/huh/v2" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/spf13/cobra" )

// Thread subresource path builders (keyed by trail number). func trailThreadsPath(forge, owner, repo string, number int) string { return trailNumberPath(forge, owner, repo, number) + "/threads" }

func trailThreadPath(forge, owner, repo string, number int, threadID string) string { return trailThreadsPath(forge, owner, repo, number) + "/" + threadID }

func trailThreadMessagesPath(forge, owner, repo string, number int, threadID string) string { return trailThreadPath(forge, owner, repo, number, threadID) + "/messages" }

func trailThreadMessagePath(forge, owner, repo string, number int, threadID, messageID string) string { return trailThreadMessagesPath(forge, owner, repo, number, threadID) + "/" + messageID }

// trailSubcommandSelector reads the subtree's persistent --trail flag. func trailSubcommandSelector(cmd *cobra.Command) string { v, _ := cmd.Flags().GetString("trail") //nolint:errcheck // flag registered on the subtree parent return v }

// withNumberedTrail resolves a numbered trail (by --trail selector or current // branch / --branch) and invokes fn inside an authenticated API context. It // centralizes the resolution boilerplate for the comment subtree. func withNumberedTrail(cmd *cobra.Command, fn func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error) error { repoOverride := trailRepoFlag(cmd) selector := trailSubcommandSelector(cmd) branch := trailBranchFlag(cmd) if selector != "" && strings.TrimSpace(branch) != "" { return errors.New("pass --trail or --branch, not both") } if err := ensureTrailRepoHasTarget(cmd, selector != "" || strings.TrimSpace(branch) != "", "pass --trail or --branch"); err != nil { return err } // Auth/not-logged-in messages go to stderr; w carries command output only. return runAuthenticatedTrailAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), repoOverride, func(ctx context.Context, client *api.Client) error { found, forge, owner, repo, err := resolveNumberedTrail(ctx, client, repoOverride, selector, branch) if err != nil { return err } return fn(ctx, client, found, forge, owner, repo) }) }

func newTrailCommentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "comment", Short: "Manage discussion threads on a trail", Long: `Manage discussion threads (comments) on a trail.

A thread is a titled conversation with one or more messages; messages can have replies. Code-review comments are managed separately under 'entire trail finding'.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } cmd.PersistentFlags().String("trail", "", "Trail number, id, or branch (defaults to the current branch)") cmd.PersistentFlags().String("branch", "", "Branch of the trail (defaults to current); cannot be combined with --trail")

cmd.AddCommand(newTrailCommentListCmd()) cmd.AddCommand(newTrailCommentShowCmd()) cmd.AddCommand(newTrailCommentAddCmd()) cmd.AddCommand(newTrailCommentReplyCmd()) cmd.AddCommand(newTrailCommentEditCmd()) cmd.AddCommand(newTrailCommentDeleteCmd()) cmd.AddCommand(newTrailCommentResolveCmd("resolve", true, "Resolve", "Resolved")) cmd.AddCommand(newTrailCommentResolveCmd("unresolve", false, "Reopen", "Reopened")) return cmd }

func newTrailCommentListCmd() *cobra.Command { var jsonOut, all bool cmd := &cobra.Command{ Use: "list", Short: "List discussion threads on a trail", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { resp, err := client.Get(ctx, trailThreadsPath(forge, owner, repo, found.Number)) if err != nil { return fmt.Errorf("failed to list threads: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } var out api.TrailThreadsResponse if err := api.DecodeJSON(resp, &out); err != nil { return fmt.Errorf("failed to decode threads response: %w", err) } return printTrailThreads(cmd.OutOrStdout(), out.Items, found.Number, jsonOut, all) }) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") cmd.Flags().BoolVar(&all, "all", false, "Include code-review threads (managed via 'trail finding')") return cmd }

func printTrailThreads(w io.Writer, items []api.TrailThreadSummary, number int, jsonOut, all bool) error { filtered := items if !all { filtered = make([]api.TrailThreadSummary, 0, len(items)) for _, it := range items { if it.Kind == "discussion" { filtered = append(filtered, it) } } } if jsonOut { enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(api.TrailThreadsResponse{Items: filtered}); err != nil { return fmt.Errorf("encode threads JSON: %w", err) } return nil } if len(filtered) == 0 { fmt.Fprintf(w, "No discussion threads on trail #%d\n", number) return nil } for _, it := range filtered { marker := "unresolved" if it.Resolved { marker = "resolved" } fmt.Fprintf(w, "%s [%s] %s (%d message(s))\n", it.ID, marker, it.Title, it.MessageCount) if it.LastMessageAuthor != nil && it.LastMessageAt != nil { fmt.Fprintf(w, " last: %s at %s\n", *it.LastMessageAuthor, it.LastMessageAt.Format(time.RFC3339)) } } return nil }

func newTrailCommentShowCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "show ", Short: "Show a discussion thread and its messages", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { threadID := args[0] return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { resp, err := client.Get(ctx, trailThreadPath(forge, owner, repo, found.Number, threadID)) if err != nil { return fmt.Errorf("failed to fetch thread: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } var out api.TrailThreadDetailResponse if err := api.DecodeJSON(resp, &out); err != nil { return fmt.Errorf("failed to decode thread response: %w", err) } return printTrailThreadDetail(cmd.OutOrStdout(), out, jsonOut) }) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd }

func printTrailThreadDetail(w io.Writer, out api.TrailThreadDetailResponse, jsonOut bool) error { if jsonOut { enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(out); err != nil { return fmt.Errorf("encode thread JSON: %w", err) } return nil } t := out.Thread marker := "unresolved" if t.Resolved { marker = "resolved" } fmt.Fprintf(w, "Thread %s [%s]: %s\n\n", t.ID, marker, t.Title) for _, m := range out.Messages { fmt.Fprintf(w, "%s %s\n%s\n", m.Author, m.CreatedAt.Format(time.RFC3339), m.Body) for _, r := range m.Replies { fmt.Fprintf(w, " ↳ %s %s\n %s\n", r.Author, r.CreatedAt.Format(time.RFC3339), r.Body) } fmt.Fprintln(w) } return nil }

func newTrailCommentAddCmd() *cobra.Command { var body, title string var jsonOut bool cmd := &cobra.Command{ Use: "add", Short: "Start a discussion thread on a trail", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if strings.TrimSpace(body) == "" { return errors.New("--body is required") } return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { req := api.TrailThreadCreateRequest{Title: strings.TrimSpace(title), Body: body} resp, err := client.Post(ctx, trailThreadsPath(forge, owner, repo, found.Number), req) if err != nil { return fmt.Errorf("failed to create thread: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } var out api.TrailThreadCreateResponse if err := api.DecodeJSON(resp, &out); err != nil { return fmt.Errorf("failed to decode thread response: %w", err) } if jsonOut { enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") return enc.Encode(out) } fmt.Fprintf(cmd.OutOrStdout(), "Created thread %s on trail #%d\n", out.Thread.ID, found.Number) return nil }) }, } cmd.Flags().StringVarP(&body, "body", "m", "", "Message body (required)") cmd.Flags().StringVar(&title, "title", "", "Thread title (optional)") cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd }

func newTrailCommentReplyCmd() *cobra.Command { var body string cmd := &cobra.Command{ Use: "reply ", Short: "Reply to a discussion thread", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { threadID := args[0] return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { if strings.TrimSpace(body) == "" { return errors.New("--body is required") } req := api.TrailThreadMessageRequest{Body: body} resp, err := client.Post(ctx, trailThreadMessagesPath(forge, owner, repo, found.Number, threadID), req) if err != nil { return fmt.Errorf("failed to reply: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } var out api.TrailThreadMessageResponse if err := api.DecodeJSON(resp, &out); err != nil { return fmt.Errorf("failed to decode message response: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "Added message %s to thread %s\n", out.Message.ID, threadID) return nil }) }, } cmd.Flags().StringVarP(&body, "body", "m", "", "Message body (required)") return cmd }

func newTrailCommentEditCmd() *cobra.Command { var body string cmd := &cobra.Command{ Use: "edit ", Short: "Edit a message in a discussion thread", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { threadID, messageID := args[0], args[1] return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { if strings.TrimSpace(body) == "" { return errors.New("--body is required") } req := api.TrailThreadMessageRequest{Body: body} resp, err := client.Patch(ctx, trailThreadMessagePath(forge, owner, repo, found.Number, threadID, messageID), req) if err != nil { return fmt.Errorf("failed to edit message: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } var out api.TrailThreadMessageResponse if err := api.DecodeJSON(resp, &out); err != nil { return fmt.Errorf("failed to decode message response: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "Edited message %s\n", out.Message.ID) return nil }) }, } cmd.Flags().StringVarP(&body, "body", "m", "", "New message body (required)") return cmd }

func newTrailCommentDeleteCmd() *cobra.Command { var force bool cmd := &cobra.Command{ Use: "delete ", Short: "Delete a message from a discussion thread", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { threadID, messageID := args[0], args[1] if !force && !interactive.CanPromptInteractively() { return fmt.Errorf("refusing to delete message %s without confirmation; pass --force", messageID) } if !force { confirmed := false form := NewAccessibleForm( huh.NewGroup(huh.NewConfirm().Title(fmt.Sprintf("Delete message %s?", messageID)).Value(&confirmed)), ) if err := form.RunWithContext(cmd.Context()); err != nil { if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, context.Canceled) { return nil } return fmt.Errorf("message deletion prompt: %w", err) } if !confirmed { fmt.Fprintln(cmd.OutOrStdout(), "Deletion cancelled.") return nil } } return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { resp, err := client.Delete(ctx, trailThreadMessagePath(forge, owner, repo, found.Number, threadID, messageID)) if err != nil { return fmt.Errorf("failed to delete message: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Deleted message %s\n", messageID) return nil }) }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip the confirmation prompt") return cmd }

func newTrailCommentResolveCmd(use string, resolved bool, shortVerb, successVerb string) *cobra.Command { return &cobra.Command{ Use: use + " ", Short: shortVerb + " a discussion thread", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { threadID := args[0] return withNumberedTrail(cmd, func(ctx context.Context, client *api.Client, found *api.TrailResource, forge, owner, repo string) error { req := api.TrailThreadUpdateRequest{Resolved: &resolved} resp, err := client.Patch(ctx, trailThreadPath(forge, owner, repo, found.Number, threadID), req) if err != nil { return fmt.Errorf("failed to update thread: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { return err } var out api.TrailThreadUpdateResponse if err := api.DecodeJSON(resp, &out); err != nil { return fmt.Errorf("failed to decode thread response: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "%s thread %s\n", successVerb, out.Thread.ID) return nil }) }, } }


Acmd/entire/cli/trail\_comment\_cmd.go+387

135 unmodified lines

136 137 138 139 140 139 140 141 142 5 unmodified lines

148 149 150 151 152 153 154 155 156 157 158

135 unmodified lines

} }

// --repo must not silently fall back to the local checkout's branch: the // branch-defaulting commands require an explicit branch or selector alongside it. // --repo requires an explicit branch or selector rather than defaulting to the local branch. func TestTrailRepoRequiresExplicitTarget(t *testing.T) { t.Parallel() tests := []struct { 5 unmodified lines

{name: "update", args: []string{"update", "--repo", "gh/acme/app"}}, {name: "delete", args: []string{"delete", "--repo", "gh/acme/app"}}, {name: "finding list", args: []string{"finding", "list", "--repo", "gh/acme/app"}}, {name: "approve", args: []string{"approve", "--repo", "gh/acme/app"}}, {name: "request-changes", args: []string{"request-changes", "--repo", "gh/acme/app", "-m", "why"}}, {name: "approvals", args: []string{"approvals", "--repo", "gh/acme/app"}}, {name: "comment list", args: []string{"comment", "list", "--repo", "gh/acme/app"}}, {name: "comment add", args: []string{"comment", "add", "--repo", "gh/acme/app", "-m", "hi"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {


Mcmd/entire/cli/trail\_repo\_flag\_test.go+6/-2