feat(trail): approve, request-changes, and approvals commands · Entire
feat(trail): approve, request-changes, and approvals commands
aba5e34→main·
computermode·1w ago·2 files·+242 added/-0 removed
Add 'trail approve', 'trail request-changes' (message required), and 'trail approvals' (list, with --json). Each resolves the trail by optional selector or current branch and posts to/reads the .../{number}/approvals endpoint. Reaches UI parity for native approval decisions.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Sessions
01KX6M8E36XW9E582KC7MPG5HSView transcript
[?
CLI Trail Commands and UI ParityClaude Code·Opus 4.8·2 steps](/content/gh/entireio/cli/session/a7c8f6a2-7a40-4558-aba8-a31927cc21d3#timeline-01KX6M8E36XW9E582KC7MPG5HS/index.html)
Changes
2
cmd/entire/cli
Atrail_approval_cmd.go+190
Atrail_approval_cmd_test.go+52
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
}
// 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) {
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; cannot submit an approval")
}
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 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
}
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)
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 {
return submitTrailApproval(cmd.Context(), cmd.OutOrStdout(), 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 {
return submitTrailApproval(cmd.Context(), cmd.OutOrStdout(), 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 {
return runTrailApprovals(cmd.Context(), cmd.OutOrStdout(), 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 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")
}
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)
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
})
}
Acmnd/entire/cli/trail_approval_cmd.go+190
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")
}
}