-
Notifications
You must be signed in to change notification settings - Fork 296
Add entire trail link subcommand
#919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,7 @@ branch, or lists all trails if no trail exists for the current branch.`, | |
| cmd.AddCommand(newTrailListCmd()) | ||
| cmd.AddCommand(newTrailCreateCmd()) | ||
| cmd.AddCommand(newTrailUpdateCmd()) | ||
| cmd.AddCommand(newTrailLinkCmd()) | ||
|
|
||
| return cmd | ||
| } | ||
|
|
@@ -91,7 +92,11 @@ func runTrailShow(ctx context.Context, w io.Writer, insecureHTTP bool) error { | |
| func printTrailDetails(w io.Writer, m *trail.Metadata) { | ||
| fmt.Fprintf(w, "Trail: %s\n", m.Title) | ||
| fmt.Fprintf(w, " ID: %s\n", m.TrailID) | ||
| fmt.Fprintf(w, " Branch: %s\n", m.Branch) | ||
| if m.Branch != "" { | ||
| fmt.Fprintf(w, " Branch: %s\n", m.Branch) | ||
| } else { | ||
| fmt.Fprintf(w, " Branch: (none)\n") | ||
| } | ||
| fmt.Fprintf(w, " Base: %s\n", m.Base) | ||
| fmt.Fprintf(w, " Status: %s\n", m.Status) | ||
| fmt.Fprintf(w, " Author: %s\n", m.Author) | ||
|
|
@@ -217,7 +222,11 @@ func runTrailListAll(ctx context.Context, w io.Writer, statusFilter string, json | |
| // Table output | ||
| fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", "BRANCH", "TITLE", "STATUS", "AUTHOR", "UPDATED") | ||
| for _, t := range trails { | ||
| branch := stringutil.TruncateRunes(t.Branch, 30, "...") | ||
| branchDisplay := t.Branch | ||
| if branchDisplay == "" { | ||
| branchDisplay = "(none)" | ||
| } | ||
| branch := stringutil.TruncateRunes(branchDisplay, 30, "...") | ||
| title := stringutil.TruncateRunes(t.Title, 40, "...") | ||
| fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", | ||
| branch, title, t.Status, stringutil.TruncateRunes(t.Author, 15, "..."), timeAgo(t.UpdatedAt)) | ||
|
|
@@ -542,6 +551,148 @@ func buildTrailUpdateRequest(current *api.TrailResource, statusStr, title, body | |
| return req | ||
| } | ||
|
|
||
| func newTrailLinkCmd() *cobra.Command { | ||
| var branch string | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "link [trail-id]", | ||
| Short: "Link a trail to a branch", | ||
| Long: `Link a trail that has no branch to a git branch. By default, the trail is | ||
| linked to the currently checked-out branch. Use --branch to specify a different branch. | ||
|
|
||
| Each branch can only be linked to one trail. If the branch is already linked | ||
| to another trail, the command will fail. | ||
|
|
||
| If no trail ID is provided, an interactive picker shows all trails without a branch.`, | ||
| Args: cobra.MaximumNArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| var trailID string | ||
| if len(args) > 0 { | ||
| trailID = args[0] | ||
| } | ||
| return runTrailLink(cmd, trailID, branch) | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVar(&branch, "branch", "", "Branch to link (defaults to current branch)") | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| func runTrailLink(cmd *cobra.Command, trailID, branch string) error { | ||
| ctx := cmd.Context() | ||
| w := cmd.OutOrStdout() | ||
|
|
||
| // Determine target branch | ||
| if branch == "" { | ||
| var err error | ||
| branch, err = GetCurrentBranch(ctx) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to determine current branch (use --branch to specify): %w", err) | ||
| } | ||
| } | ||
|
|
||
| client, err := NewAuthenticatedAPIClient(trailInsecureHTTP(cmd)) | ||
| if err != nil { | ||
| return fmt.Errorf("authentication required: %w", err) | ||
| } | ||
|
|
||
| host, owner, repoName, err := strategy.ResolveRemoteRepo(ctx, "origin") | ||
| if err != nil { | ||
| return fmt.Errorf("failed to resolve repository: %w", err) | ||
| } | ||
|
|
||
| // If no trail ID provided, show interactive picker of branchless trails | ||
| if trailID == "" { | ||
| picked, err := pickBranchlessTrail(ctx, cmd, client, host, owner, repoName) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| trailID = picked | ||
| } | ||
|
|
||
| // PATCH the trail to set the branch | ||
| updateReq := api.TrailUpdateRequest{ | ||
| Branch: &branch, | ||
| } | ||
|
|
||
| resp, err := client.Patch(ctx, trailsBasePath(host, owner, repoName)+"/"+trailID, updateReq) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to link trail: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode == http.StatusConflict { | ||
| return fmt.Errorf("branch %q is already linked to another trail — each branch can only be linked to one trail", branch) | ||
| } | ||
| 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 response: %w", err) | ||
| } | ||
|
|
||
| fmt.Fprintf(w, "Linked trail %q to branch %s\n", updateResp.Trail.Title, branch) | ||
| return nil | ||
| } | ||
|
|
||
| // pickBranchlessTrail fetches all trails and presents an interactive picker | ||
| // for trails that have no branch assigned. | ||
| func pickBranchlessTrail(ctx context.Context, cmd *cobra.Command, client *api.Client, host, owner, repo string) (string, error) { | ||
| w := cmd.OutOrStdout() | ||
|
|
||
| resp, err := client.Get(ctx, trailsBasePath(host, owner, repo)) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to list trails: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
| if err := checkTrailResponse(resp); err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| var listResp api.TrailListResponse | ||
| if err := api.DecodeJSON(resp, &listResp); err != nil { | ||
| return "", fmt.Errorf("failed to decode trail list: %w", err) | ||
| } | ||
|
|
||
| // Filter to trails without a branch | ||
| var branchless []api.TrailResource | ||
| for _, t := range listResp.Trails { | ||
| if t.Branch == "" { | ||
| branchless = append(branchless, t) | ||
| } | ||
| } | ||
|
|
||
| if len(branchless) == 0 { | ||
| fmt.Fprintln(w, "No trails without a branch found.") | ||
| fmt.Fprintln(w, "Use 'entire trail create' to create a new trail, or pass a trail ID directly.") | ||
| return "", NewSilentError(errors.New("no branchless trails")) | ||
|
Comment on lines
+668
to
+671
|
||
| } | ||
|
|
||
| // Build options for the picker | ||
| options := make([]huh.Option[string], 0, len(branchless)) | ||
| for _, t := range branchless { | ||
| label := fmt.Sprintf("%s (%s)", t.Title, t.TrailID) | ||
| options = append(options, huh.NewOption(label, t.TrailID)) | ||
| } | ||
|
|
||
| var selected string | ||
| form := NewAccessibleForm( | ||
| huh.NewGroup( | ||
| huh.NewSelect[string](). | ||
| Title("Select a trail to link"). | ||
| Options(options...). | ||
| Value(&selected), | ||
| ), | ||
| ) | ||
| if err := form.Run(); err != nil { | ||
| return "", handleFormCancellation(w, "Trail link", err) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Form cancellation proceeds with empty trail IDMedium Severity When the user cancels the interactive trail picker (Ctrl+C), Additional Locations (1)Reviewed by Cursor Bugbot for commit cf07d31. Configure here. |
||
| } | ||
| return selected, nil | ||
| } | ||
|
|
||
| // defaultBaseBranch is the fallback base branch name when it cannot be determined. | ||
| const defaultBaseBranch = "main" | ||
|
|
||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The command help/PR intent says this subcommand links branchless trails, but when a trail ID is provided it unconditionally PATCHes the branch and can effectively re-link an already-linked trail to a new branch. Consider fetching the trail first (GET the trail detail endpoint) and refusing when Trail.Branch is already set (or update the command description/introduce an explicit --force to make re-linking intentional).