Skip to content

Commit 0879510

Browse files
authored
Merge pull request #1832 from entireio/fix/trail-update-preserves-body
fix(trail): preserve body on interactive update
2 parents 6e14e0b + 3fcc9f5 commit 0879510

2 files changed

Lines changed: 104 additions & 4 deletions

File tree

cmd/entire/cli/trail_cmd.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,30 @@ func newTrailCreateRequest(title, body, branch, base, statusStr, typeStr, priori
10991099
return req
11001100
}
11011101

1102+
// resolveTrailUpdateBody returns the body text to seed the interactive update
1103+
// form with. The list resource omits the description (it lives in
1104+
// body_document, served only by the detail endpoint), so update must fetch the
1105+
// detail body — otherwise the form prefills from the empty list body and a
1106+
// user edit against that blank baseline can overwrite a description they never
1107+
// saw. Best-effort: on a failed detail fetch it returns the list body plus the
1108+
// error so the caller can warn (mirroring runTrailShow); an empty detail body
1109+
// (older/partial server) falls back to the list body with no error.
1110+
func resolveTrailUpdateBody(ctx context.Context, client *api.Client, forge, owner, repo string, found *api.TrailResource) (string, error) {
1111+
body := found.Body
1112+
if found.Number > 0 {
1113+
bt, err := fetchTrailDescription(ctx, client, forge, owner, repo, found.Number)
1114+
if err != nil {
1115+
return body, err
1116+
}
1117+
// fetchTrailDescription already trims; a non-empty result supersedes
1118+
// the list body, an empty one (older/partial server) leaves it intact.
1119+
if bt != "" {
1120+
body = bt
1121+
}
1122+
}
1123+
return body, nil
1124+
}
1125+
11021126
func newTrailUpdateCmd() *cobra.Command {
11031127
var statusStr, title, body, branch, typeStr, priorityStr string
11041128
var labelAdd, labelRemove, assigneeAdd, assigneeRemove, reviewerAdd, reviewerRemove []string
@@ -1221,7 +1245,16 @@ func runTrailUpdate(ctx context.Context, w, errW io.Writer, insecureHTTP bool, i
12211245
}
12221246
statusStr = string(metadata.Status)
12231247
title = metadata.Title
1224-
body = metadata.Body
1248+
// The list resource omits the description; fetch the detail body so
1249+
// the form prefills with the current text and change detection below
1250+
// compares against the real server value. Warn on a fetch failure so
1251+
// a blank baseline doesn't silently overwrite an unseen description.
1252+
seedBody, bodyErr := resolveTrailUpdateBody(ctx, client, forge, owner, repoName, found)
1253+
if bodyErr != nil {
1254+
fmt.Fprintf(errW, "Warning: could not load current trail body: %v\n", bodyErr)
1255+
}
1256+
body = seedBody
1257+
origStatus, origTitle, origBody := statusStr, title, body
12251258

12261259
form := NewAccessibleForm(
12271260
huh.NewGroup(
@@ -1240,9 +1273,12 @@ func runTrailUpdate(ctx context.Context, w, errW io.Writer, insecureHTTP bool, i
12401273
if formErr := form.Run(); formErr != nil {
12411274
return handleFormCancellation(w, "Trail update", formErr)
12421275
}
1243-
inputs.StatusChanged = true
1244-
inputs.TitleChanged = true
1245-
inputs.BodyChanged = true
1276+
// Only mark a field changed when the user actually edited it, so an
1277+
// untouched body/title/status isn't needlessly PATCHed (a no-op body
1278+
// PATCH would otherwise rewrite the description on every update).
1279+
inputs.StatusChanged = statusStr != origStatus
1280+
inputs.TitleChanged = title != origTitle
1281+
inputs.BodyChanged = body != origBody
12461282
}
12471283

12481284
statusStr = strings.TrimSpace(statusStr)

cmd/entire/cli/trail_cmd_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,70 @@ func TestFetchTrailDescription_ReadsNestedBodyDocument(t *testing.T) {
591591
}
592592
}
593593

594+
func TestResolveTrailUpdateBody_PrefersDetailSnapshot(t *testing.T) {
595+
t.Parallel()
596+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
597+
if _, err := io.WriteString(w, `{"trail":{"number":42,"body_document":{"text_snapshot":"the real body"}},"checkpoints":[],"has_write_permission":true}`); err != nil {
598+
t.Errorf("write response: %v", err)
599+
}
600+
}))
601+
defer srv.Close()
602+
603+
client := api.NewClientWithBaseURL("tok", srv.URL)
604+
// The list resource omits the description, so found.Body is empty. The
605+
// seed must come from the detail endpoint, not the empty list body.
606+
found := &api.TrailResource{Number: 42, Body: ""}
607+
body, err := resolveTrailUpdateBody(t.Context(), client, "gh", "acme", "repo", found)
608+
if err != nil {
609+
t.Fatalf("resolveTrailUpdateBody: %v", err)
610+
}
611+
if body != "the real body" {
612+
t.Fatalf("body = %q, want %q", body, "the real body")
613+
}
614+
}
615+
616+
func TestResolveTrailUpdateBody_FallsBackToListBody(t *testing.T) {
617+
t.Parallel()
618+
// Older/partial server: detail omits body_document (text_snapshot empty).
619+
// The seed must fall back to the list body rather than blanking it.
620+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
621+
if _, err := io.WriteString(w, `{"trail":{"number":42},"checkpoints":[],"has_write_permission":true}`); err != nil {
622+
t.Errorf("write response: %v", err)
623+
}
624+
}))
625+
defer srv.Close()
626+
627+
client := api.NewClientWithBaseURL("tok", srv.URL)
628+
found := &api.TrailResource{Number: 42, Body: "list body"}
629+
body, err := resolveTrailUpdateBody(t.Context(), client, "gh", "acme", "repo", found)
630+
if err != nil {
631+
t.Fatalf("resolveTrailUpdateBody: %v", err)
632+
}
633+
if body != "list body" {
634+
t.Fatalf("body = %q, want %q", body, "list body")
635+
}
636+
}
637+
638+
func TestResolveTrailUpdateBody_ReturnsErrorOnFetchFailure(t *testing.T) {
639+
t.Parallel()
640+
// A detail-fetch failure must be surfaced (not swallowed) so the caller can
641+
// warn: a blank baseline could otherwise silently overwrite an unseen body.
642+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
643+
w.WriteHeader(http.StatusInternalServerError)
644+
}))
645+
defer srv.Close()
646+
647+
client := api.NewClientWithBaseURL("tok", srv.URL)
648+
found := &api.TrailResource{Number: 42, Body: "list body"}
649+
body, err := resolveTrailUpdateBody(t.Context(), client, "gh", "acme", "repo", found)
650+
if err == nil {
651+
t.Fatal("expected error on fetch failure, got nil")
652+
}
653+
if body != "list body" {
654+
t.Fatalf("body = %q, want fallback %q", body, "list body")
655+
}
656+
}
657+
594658
func TestResolveCreateBranch(t *testing.T) {
595659
t.Parallel()
596660
tests := []struct {

0 commit comments

Comments
 (0)