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

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