Merge feat/7-edit-title-and-body into main
8e1b39c parent: 93ced54 modified
README.md +6 -2 | @@ -163,8 +163,10 @@ rickub pr ready <number> [-R owner/repo] [--undo] # draft → ready for r | ||
| 163 | 163 | rickub pr merge <number> [-R owner/repo] [--method merge|squash|ff-only] |
| 164 | 164 | rickub pr close <number> [-R owner/repo] |
| 165 | 165 | rickub pr reopen <number> [-R owner/repo] |
| 166 | -rickub pr edit <number> [-R owner/repo] -b "text" | -b - | |
| 167 | - # replace the description (author or repo writer) | |
| 166 | +rickub pr edit <number> [-R owner/repo] [-t "title"] [-b "text" | -b -] | |
| 167 | + # correct the title and/or the description in one call (author or | |
| 168 | + # repo writer); a field you do not name is left untouched, and | |
| 169 | + # passing neither flag is refused without sending a request | |
| 168 | 170 | rickub pr comment <number> [-R owner/repo] -b "text" |
| 169 | 171 | # whole-MR conversation comment (any reader) |
| 170 | 172 | rickub pr comment <number> [-R owner/repo] -b "text" --file PATH --line N |
| @@ -191,6 +193,8 @@ rickub run watch <number> [-R owner/repo] [--interval 2s] [--timeout 30m] [-- | ||
| 191 | 193 | rickub issue list [-R owner/repo] [--state open|closed|all] [--page N] |
| 192 | 194 | rickub issue view <number> [-R owner/repo] |
| 193 | 195 | rickub issue create [-R owner/repo] -t "title" [-b "body" | -b - < file] |
| 196 | +rickub issue edit <number> [-R owner/repo] [-t "title"] [-b "body" | -b - < file] | |
| 197 | + # same rules as `pr edit`: name a field to change it, omit it to keep it | |
| 194 | 198 | rickub issue close <number> [-R owner/repo] |
| 195 | 199 | rickub issue reopen <number> [-R owner/repo] |
| 196 | 200 | rickub issue comment <number> [-R owner/repo] -b "text" |
| @@ -163,8 +163,10 @@ rickub pr ready <number> [-R owner/repo] [--undo] # draft → ready for r | |||
| 163 | rickub pr merge <number> [-R owner/repo] [--method merge|squash|ff-only] | 163 | rickub pr merge <number> [-R owner/repo] [--method merge|squash|ff-only] |
| 164 | rickub pr close <number> [-R owner/repo] | 164 | rickub pr close <number> [-R owner/repo] |
| 165 | rickub pr reopen <number> [-R owner/repo] | 165 | rickub pr reopen <number> [-R owner/repo] |
| 166 | -rickub pr edit <number> [-R owner/repo] -b "text" | -b - | 166 | +rickub pr edit <number> [-R owner/repo] [-t "title"] [-b "text" | -b -] |
| 167 | - # replace the description (author or repo writer) | 167 | + # correct the title and/or the description in one call (author or |
| 168 | + # repo writer); a field you do not name is left untouched, and | ||
| 169 | + # passing neither flag is refused without sending a request | ||
| 168 | rickub pr comment <number> [-R owner/repo] -b "text" | 170 | rickub pr comment <number> [-R owner/repo] -b "text" |
| 169 | # whole-MR conversation comment (any reader) | 171 | # whole-MR conversation comment (any reader) |
| 170 | rickub pr comment <number> [-R owner/repo] -b "text" --file PATH --line N | 172 | rickub pr comment <number> [-R owner/repo] -b "text" --file PATH --line N |
| @@ -191,6 +193,8 @@ rickub run watch <number> [-R owner/repo] [--interval 2s] [--timeout 30m] [-- | |||
| 191 | rickub issue list [-R owner/repo] [--state open|closed|all] [--page N] | 193 | rickub issue list [-R owner/repo] [--state open|closed|all] [--page N] |
| 192 | rickub issue view <number> [-R owner/repo] | 194 | rickub issue view <number> [-R owner/repo] |
| 193 | rickub issue create [-R owner/repo] -t "title" [-b "body" | -b - < file] | 195 | rickub issue create [-R owner/repo] -t "title" [-b "body" | -b - < file] |
| 196 | +rickub issue edit <number> [-R owner/repo] [-t "title"] [-b "body" | -b - < file] | ||
| 197 | + # same rules as `pr edit`: name a field to change it, omit it to keep it | ||
| 194 | rickub issue close <number> [-R owner/repo] | 198 | rickub issue close <number> [-R owner/repo] |
| 195 | rickub issue reopen <number> [-R owner/repo] | 199 | rickub issue reopen <number> [-R owner/repo] |
| 196 | rickub issue comment <number> [-R owner/repo] -b "text" | 200 | rickub issue comment <number> [-R owner/repo] -b "text" |
added
cmd/edit.go +94 -0 | new file mode 100644 | ||
| @@ -0,0 +1,94 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + | |
| 7 | + "github.com/spf13/cobra" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// --- --title/--body resolution for the `edit` commands ----------------------- | |
| 11 | +// | |
| 12 | +// `mr edit` and `issue edit` both drive a PATCH whose two fields are optional | |
| 13 | +// and independent: a field the user did not name must be ABSENT from the request | |
| 14 | +// so the server leaves it alone. Sending "" instead would be a change — a blank | |
| 15 | +// title is refused by the API (title_required) and a blank body would silently | |
| 16 | +// erase a description nobody asked to erase. | |
| 17 | +// | |
| 18 | +// Both commands resolve their flags here, for the same reason cmd/body.go exists: | |
| 19 | +// a second interpretation of "the user didn't pass --title" is how the two halves | |
| 20 | +// drift apart. | |
| 21 | + | |
| 22 | +// resolveEditFields turns an edit command's --title/--body flags into the two | |
| 23 | +// optional halves of a patch. A nil result means "not given, leave it alone". | |
| 24 | +// | |
| 25 | +// The rules, all enforced BEFORE any request is made: | |
| 26 | +// - neither flag given → error, and nothing is sent (the server would answer | |
| 27 | +// 400 empty_update; a local refusal is faster and says what to do instead) | |
| 28 | +// - --title given but blank → error; a title is the one field that cannot be | |
| 29 | +// empty, and a CLI that forwards it just to collect the server's 400 is worse | |
| 30 | +// - --body given but blank → error; issue #7's criterion, inherited from #94 — | |
| 31 | +// an edit must never silently blank a description. The stdin PARSING is the | |
| 32 | +// shared resolveBody (that shared parser is what #94 was really about); only | |
| 33 | +// the refusal message is written here, because `mr edit`'s is a different | |
| 34 | +// sentence from `mr comment`'s: on an edit, the way to leave a description | |
| 35 | +// alone is to omit --body, not to pass an empty one. | |
| 36 | +func resolveEditFields(cmd *cobra.Command, title, body string) (*string, *string, error) { | |
| 37 | + titleSet := cmd.Flags().Changed("title") | |
| 38 | + bodySet := cmd.Flags().Changed("body") | |
| 39 | + if !titleSet && !bodySet { | |
| 40 | + return nil, nil, fmt.Errorf("nothing to update: pass --title and/or --body") | |
| 41 | + } | |
| 42 | + | |
| 43 | + var titleOut, bodyOut *string | |
| 44 | + if titleSet { | |
| 45 | + t := strings.TrimSpace(title) | |
| 46 | + if t == "" { | |
| 47 | + return nil, nil, fmt.Errorf("--title must not be blank: a title cannot be empty (omit --title to leave it as it is)") | |
| 48 | + } | |
| 49 | + titleOut = &t | |
| 50 | + } | |
| 51 | + if bodySet { | |
| 52 | + b, err := resolveBody(cmd, body) | |
| 53 | + if err != nil { | |
| 54 | + return nil, nil, err | |
| 55 | + } | |
| 56 | + if strings.TrimSpace(b) == "" { | |
| 57 | + if body == "-" { | |
| 58 | + return nil, nil, fmt.Errorf("--body - read nothing from stdin: refusing to blank the description (omit --body to leave it as it is)") | |
| 59 | + } | |
| 60 | + return nil, nil, fmt.Errorf("--body must not be blank: refusing to blank the description (omit --body to leave it as it is)") | |
| 61 | + } | |
| 62 | + bodyOut = &b | |
| 63 | + } | |
| 64 | + return titleOut, bodyOut, nil | |
| 65 | +} | |
| 66 | + | |
| 67 | +// editSummary renders the success line from the state the SERVER echoed back, | |
| 68 | +// not from what was sent: a success line that does not prove the bytes landed is | |
| 69 | +// exactly what made issue #94 invisible. Only the fields the user actually | |
| 70 | +// patched are reported — sent and stored are the same pointers the patch carried. | |
| 71 | +// | |
| 72 | +// The echo is preferred but not trusted blindly: a response that arrives empty | |
| 73 | +// (Client.do returns without unmarshalling when the body is blank) or without the | |
| 74 | +// field would otherwise turn a successful rename into `title now ""`, which reads | |
| 75 | +// as "your title is now empty" — the opposite of what happened. When the echo has | |
| 76 | +// nothing to say, fall back to the value that was sent and mark it as such. | |
| 77 | +func editSummary(sentTitle, sentBody *string, storedTitle, storedBody string) string { | |
| 78 | + parts := make([]string, 0, 2) | |
| 79 | + if sentTitle != nil { | |
| 80 | + if storedTitle != "" { | |
| 81 | + parts = append(parts, fmt.Sprintf("title now %q", clean(storedTitle))) | |
| 82 | + } else { | |
| 83 | + parts = append(parts, fmt.Sprintf("title set to %q (not echoed back)", clean(*sentTitle))) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + if sentBody != nil { | |
| 87 | + if storedBody != "" { | |
| 88 | + parts = append(parts, fmt.Sprintf("description now %d bytes", len(storedBody))) | |
| 89 | + } else { | |
| 90 | + parts = append(parts, fmt.Sprintf("description set to %d bytes (not echoed back)", len(*sentBody))) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + return strings.Join(parts, ", ") | |
| 94 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | + | ||
| 7 | + "github.com/spf13/cobra" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// --- --title/--body resolution for the `edit` commands ----------------------- | ||
| 11 | +// | ||
| 12 | +// `mr edit` and `issue edit` both drive a PATCH whose two fields are optional | ||
| 13 | +// and independent: a field the user did not name must be ABSENT from the request | ||
| 14 | +// so the server leaves it alone. Sending "" instead would be a change — a blank | ||
| 15 | +// title is refused by the API (title_required) and a blank body would silently | ||
| 16 | +// erase a description nobody asked to erase. | ||
| 17 | +// | ||
| 18 | +// Both commands resolve their flags here, for the same reason cmd/body.go exists: | ||
| 19 | +// a second interpretation of "the user didn't pass --title" is how the two halves | ||
| 20 | +// drift apart. | ||
| 21 | + | ||
| 22 | +// resolveEditFields turns an edit command's --title/--body flags into the two | ||
| 23 | +// optional halves of a patch. A nil result means "not given, leave it alone". | ||
| 24 | +// | ||
| 25 | +// The rules, all enforced BEFORE any request is made: | ||
| 26 | +// - neither flag given → error, and nothing is sent (the server would answer | ||
| 27 | +// 400 empty_update; a local refusal is faster and says what to do instead) | ||
| 28 | +// - --title given but blank → error; a title is the one field that cannot be | ||
| 29 | +// empty, and a CLI that forwards it just to collect the server's 400 is worse | ||
| 30 | +// - --body given but blank → error; issue #7's criterion, inherited from #94 — | ||
| 31 | +// an edit must never silently blank a description. The stdin PARSING is the | ||
| 32 | +// shared resolveBody (that shared parser is what #94 was really about); only | ||
| 33 | +// the refusal message is written here, because `mr edit`'s is a different | ||
| 34 | +// sentence from `mr comment`'s: on an edit, the way to leave a description | ||
| 35 | +// alone is to omit --body, not to pass an empty one. | ||
| 36 | +func resolveEditFields(cmd *cobra.Command, title, body string) (*string, *string, error) { | ||
| 37 | + titleSet := cmd.Flags().Changed("title") | ||
| 38 | + bodySet := cmd.Flags().Changed("body") | ||
| 39 | + if !titleSet && !bodySet { | ||
| 40 | + return nil, nil, fmt.Errorf("nothing to update: pass --title and/or --body") | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + var titleOut, bodyOut *string | ||
| 44 | + if titleSet { | ||
| 45 | + t := strings.TrimSpace(title) | ||
| 46 | + if t == "" { | ||
| 47 | + return nil, nil, fmt.Errorf("--title must not be blank: a title cannot be empty (omit --title to leave it as it is)") | ||
| 48 | + } | ||
| 49 | + titleOut = &t | ||
| 50 | + } | ||
| 51 | + if bodySet { | ||
| 52 | + b, err := resolveBody(cmd, body) | ||
| 53 | + if err != nil { | ||
| 54 | + return nil, nil, err | ||
| 55 | + } | ||
| 56 | + if strings.TrimSpace(b) == "" { | ||
| 57 | + if body == "-" { | ||
| 58 | + return nil, nil, fmt.Errorf("--body - read nothing from stdin: refusing to blank the description (omit --body to leave it as it is)") | ||
| 59 | + } | ||
| 60 | + return nil, nil, fmt.Errorf("--body must not be blank: refusing to blank the description (omit --body to leave it as it is)") | ||
| 61 | + } | ||
| 62 | + bodyOut = &b | ||
| 63 | + } | ||
| 64 | + return titleOut, bodyOut, nil | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +// editSummary renders the success line from the state the SERVER echoed back, | ||
| 68 | +// not from what was sent: a success line that does not prove the bytes landed is | ||
| 69 | +// exactly what made issue #94 invisible. Only the fields the user actually | ||
| 70 | +// patched are reported — sent and stored are the same pointers the patch carried. | ||
| 71 | +// | ||
| 72 | +// The echo is preferred but not trusted blindly: a response that arrives empty | ||
| 73 | +// (Client.do returns without unmarshalling when the body is blank) or without the | ||
| 74 | +// field would otherwise turn a successful rename into `title now ""`, which reads | ||
| 75 | +// as "your title is now empty" — the opposite of what happened. When the echo has | ||
| 76 | +// nothing to say, fall back to the value that was sent and mark it as such. | ||
| 77 | +func editSummary(sentTitle, sentBody *string, storedTitle, storedBody string) string { | ||
| 78 | + parts := make([]string, 0, 2) | ||
| 79 | + if sentTitle != nil { | ||
| 80 | + if storedTitle != "" { | ||
| 81 | + parts = append(parts, fmt.Sprintf("title now %q", clean(storedTitle))) | ||
| 82 | + } else { | ||
| 83 | + parts = append(parts, fmt.Sprintf("title set to %q (not echoed back)", clean(*sentTitle))) | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + if sentBody != nil { | ||
| 87 | + if storedBody != "" { | ||
| 88 | + parts = append(parts, fmt.Sprintf("description now %d bytes", len(storedBody))) | ||
| 89 | + } else { | ||
| 90 | + parts = append(parts, fmt.Sprintf("description set to %d bytes (not echoed back)", len(*sentBody))) | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + return strings.Join(parts, ", ") | ||
| 94 | +} | ||
added
cmd/edit_test.go +277 -0 | new file mode 100644 | ||
| @@ -0,0 +1,277 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "io" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// editServer records every request it sees and answers a PATCH with the merged | |
| 13 | +// result, so a test can assert both what went on the wire and what the CLI | |
| 14 | +// printed from the response. | |
| 15 | +type editCall struct { | |
| 16 | + Method string | |
| 17 | + Path string | |
| 18 | + Body string | |
| 19 | +} | |
| 20 | + | |
| 21 | +func editServer(t *testing.T, reply string) (*httptest.Server, *[]editCall) { | |
| 22 | + t.Helper() | |
| 23 | + var calls []editCall | |
| 24 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | + b, _ := io.ReadAll(r.Body) | |
| 26 | + calls = append(calls, editCall{Method: r.Method, Path: r.URL.Path, Body: string(b)}) | |
| 27 | + w.Header().Set("Content-Type", "application/json") | |
| 28 | + _, _ = w.Write([]byte(reply)) | |
| 29 | + })) | |
| 30 | + t.Cleanup(srv.Close) | |
| 31 | + return srv, &calls | |
| 32 | +} | |
| 33 | + | |
| 34 | +func editEnv(t *testing.T, srv *httptest.Server) { | |
| 35 | + t.Helper() | |
| 36 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 37 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 38 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 39 | +} | |
| 40 | + | |
| 41 | +// TestPREditTitleOnlyLeavesBodyAlone is issue #7's first acceptance criterion: | |
| 42 | +// renaming a merge request must not touch its description, which on the wire | |
| 43 | +// means the payload carries no "body" key at all. | |
| 44 | +func TestPREditTitleOnlyLeavesBodyAlone(t *testing.T) { | |
| 45 | + srv, calls := editServer(t, `{"number":12,"title":"new title","body":"untouched","state":"open"}`) | |
| 46 | + editEnv(t, srv) | |
| 47 | + | |
| 48 | + out, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "--title", "new title") | |
| 49 | + if err != nil { | |
| 50 | + t.Fatalf("execute: %v", err) | |
| 51 | + } | |
| 52 | + if len(*calls) != 1 { | |
| 53 | + t.Fatalf("expected exactly one request, got %d", len(*calls)) | |
| 54 | + } | |
| 55 | + c := (*calls)[0] | |
| 56 | + if c.Method != http.MethodPatch || c.Path != "/api/v1/repos/o/r/merge-requests/12" { | |
| 57 | + t.Errorf("wrong route: %s %s", c.Method, c.Path) | |
| 58 | + } | |
| 59 | + var payload map[string]any | |
| 60 | + if err := json.Unmarshal([]byte(c.Body), &payload); err != nil { | |
| 61 | + t.Fatalf("payload is not JSON: %v (%s)", err, c.Body) | |
| 62 | + } | |
| 63 | + if payload["title"] != "new title" { | |
| 64 | + t.Errorf("title not sent: %s", c.Body) | |
| 65 | + } | |
| 66 | + if _, ok := payload["body"]; ok { | |
| 67 | + t.Errorf("body must be absent when --body was not passed: %s", c.Body) | |
| 68 | + } | |
| 69 | + if !strings.Contains(out, `title now "new title"`) { | |
| 70 | + t.Errorf("success line should echo the stored title:\n%s", out) | |
| 71 | + } | |
| 72 | + if strings.Contains(out, "description") { | |
| 73 | + t.Errorf("success line should not claim the description changed:\n%s", out) | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +// TestPREditBodyFromStdin keeps `-b -` working through the shared resolver and | |
| 78 | +// checks the byte count comes from the server's echo (issue #94's lesson). | |
| 79 | +func TestPREditBodyFromStdin(t *testing.T) { | |
| 80 | + srv, calls := editServer(t, `{"number":12,"title":"kept","body":"piped in","state":"open"}`) | |
| 81 | + editEnv(t, srv) | |
| 82 | + | |
| 83 | + rootCmd.SetIn(strings.NewReader("piped in\n")) | |
| 84 | + defer rootCmd.SetIn(nil) | |
| 85 | + | |
| 86 | + out, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "-b", "-") | |
| 87 | + if err != nil { | |
| 88 | + t.Fatalf("execute: %v", err) | |
| 89 | + } | |
| 90 | + c := (*calls)[0] | |
| 91 | + var payload map[string]any | |
| 92 | + _ = json.Unmarshal([]byte(c.Body), &payload) | |
| 93 | + if payload["body"] != "piped in" { | |
| 94 | + t.Errorf("stdin body not sent verbatim: %s", c.Body) | |
| 95 | + } | |
| 96 | + if _, ok := payload["title"]; ok { | |
| 97 | + t.Errorf("title must be absent when --title was not passed: %s", c.Body) | |
| 98 | + } | |
| 99 | + if !strings.Contains(out, "description now 8 bytes") { | |
| 100 | + t.Errorf("success line should report the stored byte count:\n%s", out) | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +// TestPREditBothFieldsOnePatch: naming both flags corrects both in ONE call. | |
| 105 | +func TestPREditBothFieldsOnePatch(t *testing.T) { | |
| 106 | + srv, calls := editServer(t, `{"number":12,"title":"t","body":"bb","state":"open"}`) | |
| 107 | + editEnv(t, srv) | |
| 108 | + | |
| 109 | + out, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "--title", "t", "--body", "bb") | |
| 110 | + if err != nil { | |
| 111 | + t.Fatalf("execute: %v", err) | |
| 112 | + } | |
| 113 | + if len(*calls) != 1 { | |
| 114 | + t.Fatalf("expected one PATCH, got %d requests", len(*calls)) | |
| 115 | + } | |
| 116 | + var payload map[string]any | |
| 117 | + _ = json.Unmarshal([]byte((*calls)[0].Body), &payload) | |
| 118 | + if payload["title"] != "t" || payload["body"] != "bb" { | |
| 119 | + t.Errorf("both fields expected: %s", (*calls)[0].Body) | |
| 120 | + } | |
| 121 | + if !strings.Contains(out, `title now "t"`) || !strings.Contains(out, "description now 2 bytes") { | |
| 122 | + t.Errorf("success line should report both:\n%s", out) | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +// TestEditWithNoFieldFlagsSendsNothing: the refusal is LOCAL. A command that | |
| 127 | +// forwarded an empty patch just to collect the server's 400 empty_update would | |
| 128 | +// still be wrong here — the assertion that matters is zero requests. | |
| 129 | +func TestEditWithNoFieldFlagsSendsNothing(t *testing.T) { | |
| 130 | + for _, args := range [][]string{ | |
| 131 | + {"mr", "edit", "12", "-R", "o/r"}, | |
| 132 | + {"issue", "edit", "7", "-R", "o/r"}, | |
| 133 | + } { | |
| 134 | + srv, calls := editServer(t, `{}`) | |
| 135 | + editEnv(t, srv) | |
| 136 | + _, _, err := execute(t, args...) | |
| 137 | + if err == nil { | |
| 138 | + t.Fatalf("%v: expected an error when no field flag is given", args) | |
| 139 | + } | |
| 140 | + if !strings.Contains(err.Error(), "nothing to update") { | |
| 141 | + t.Errorf("%v: unhelpful error: %v", args, err) | |
| 142 | + } | |
| 143 | + if len(*calls) != 0 { | |
| 144 | + t.Errorf("%v: %d request(s) sent; nothing should be sent", args, len(*calls)) | |
| 145 | + } | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +// TestEditRefusesBlankFields: a blank title is refused (it is the one field that | |
| 150 | +// cannot be empty) and so is a blank body (issue #94) — both before any request. | |
| 151 | +func TestEditRefusesBlankFields(t *testing.T) { | |
| 152 | + cases := [][]string{ | |
| 153 | + {"mr", "edit", "12", "-R", "o/r", "--title", " "}, | |
| 154 | + {"mr", "edit", "12", "-R", "o/r", "--body", ""}, | |
| 155 | + {"issue", "edit", "7", "-R", "o/r", "--title", ""}, | |
| 156 | + {"issue", "edit", "7", "-R", "o/r", "--body", " "}, | |
| 157 | + } | |
| 158 | + for _, args := range cases { | |
| 159 | + srv, calls := editServer(t, `{}`) | |
| 160 | + editEnv(t, srv) | |
| 161 | + if _, _, err := execute(t, args...); err == nil { | |
| 162 | + t.Errorf("%v: expected a refusal", args) | |
| 163 | + } | |
| 164 | + if len(*calls) != 0 { | |
| 165 | + t.Errorf("%v: %d request(s) sent; a blank field must not reach the server", args, len(*calls)) | |
| 166 | + } | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +// TestIssueEditPatchesIssueRoute is the issue half: same rules, the issues route. | |
| 171 | +func TestIssueEditPatchesIssueRoute(t *testing.T) { | |
| 172 | + srv, calls := editServer(t, `{"number":7,"title":"renamed","body":"kept","state":"open"}`) | |
| 173 | + editEnv(t, srv) | |
| 174 | + | |
| 175 | + out, _, err := execute(t, "issue", "edit", "7", "-R", "o/r", "--title", "renamed") | |
| 176 | + if err != nil { | |
| 177 | + t.Fatalf("execute: %v", err) | |
| 178 | + } | |
| 179 | + c := (*calls)[0] | |
| 180 | + if c.Method != http.MethodPatch || c.Path != "/api/v1/repos/o/r/issues/7" { | |
| 181 | + t.Errorf("wrong route: %s %s", c.Method, c.Path) | |
| 182 | + } | |
| 183 | + if !strings.Contains(out, `Updated issue #7: title now "renamed"`) { | |
| 184 | + t.Errorf("unexpected success line:\n%s", out) | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +// TestEditSurfacesServerRefusalCleanly: a caller who may not touch the resource | |
| 189 | +// gets the server's uniform 404 as a plain CLI error. | |
| 190 | +func TestEditSurfacesServerRefusalCleanly(t *testing.T) { | |
| 191 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 192 | + w.WriteHeader(http.StatusNotFound) | |
| 193 | + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"merge request not found"}}`)) | |
| 194 | + })) | |
| 195 | + defer srv.Close() | |
| 196 | + editEnv(t, srv) | |
| 197 | + | |
| 198 | + _, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "--title", "x") | |
| 199 | + if err == nil { | |
| 200 | + t.Fatal("expected an error") | |
| 201 | + } | |
| 202 | + if !strings.Contains(err.Error(), "merge request not found") || !strings.Contains(err.Error(), "not_found") { | |
| 203 | + t.Errorf("error should surface the server's message and code: %v", err) | |
| 204 | + } | |
| 205 | +} | |
| 206 | + | |
| 207 | +// TestEditValidatesNumberBeforeReadingStdin: a bad number must be rejected before | |
| 208 | +// anything drains stdin. `mr edit abc -b - <<'EOF' … EOF` used to swallow the | |
| 209 | +// heredoc and then report "invalid merge-request number", which is issue #94's | |
| 210 | +// failure mode — a body that vanishes with a plausible-looking error — reached by | |
| 211 | +// a different route. | |
| 212 | +func TestEditValidatesNumberBeforeReadingStdin(t *testing.T) { | |
| 213 | + for _, args := range [][]string{ | |
| 214 | + {"mr", "edit", "abc", "-R", "o/r", "-b", "-"}, | |
| 215 | + {"issue", "edit", "0", "-R", "o/r", "-b", "-"}, | |
| 216 | + } { | |
| 217 | + srv, calls := editServer(t, `{}`) | |
| 218 | + editEnv(t, srv) | |
| 219 | + | |
| 220 | + const piped = "a body nobody should lose\n" | |
| 221 | + stdin := strings.NewReader(piped) | |
| 222 | + rootCmd.SetIn(stdin) | |
| 223 | + | |
| 224 | + _, _, err := execute(t, args...) | |
| 225 | + rootCmd.SetIn(nil) | |
| 226 | + | |
| 227 | + if err == nil || !strings.Contains(err.Error(), "invalid") { | |
| 228 | + t.Errorf("%v: want an invalid-number error, got %v", args, err) | |
| 229 | + } | |
| 230 | + if len(*calls) != 0 { | |
| 231 | + t.Errorf("%v: %d request(s) sent for an invalid number", args, len(*calls)) | |
| 232 | + } | |
| 233 | + // The heredoc is still there to be read: nothing consumed it. | |
| 234 | + rest, _ := io.ReadAll(stdin) | |
| 235 | + if string(rest) != piped { | |
| 236 | + t.Errorf("%v: stdin was consumed before the number was validated (%q left)", args, rest) | |
| 237 | + } | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +// TestEditSummaryFallsBackToTheSentValue: an echo that arrives empty must not turn | |
| 242 | +// a successful rename into `title now ""`, which reads as "your title is now | |
| 243 | +// empty" — the opposite of what happened. | |
| 244 | +func TestEditSummaryFallsBackToTheSentValue(t *testing.T) { | |
| 245 | + title, body := "the new title", "the new body" | |
| 246 | + if got := editSummary(&title, nil, "stored title", ""); got != `title now "stored title"` { | |
| 247 | + t.Errorf("echo present: got %q", got) | |
| 248 | + } | |
| 249 | + if got := editSummary(&title, nil, "", ""); got != `title set to "the new title" (not echoed back)` { | |
| 250 | + t.Errorf("echo absent: got %q", got) | |
| 251 | + } | |
| 252 | + if got := editSummary(nil, &body, "", ""); got != "description set to 12 bytes (not echoed back)" { | |
| 253 | + t.Errorf("body echo absent: got %q", got) | |
| 254 | + } | |
| 255 | + if got := editSummary(nil, nil, "x", "y"); got != "" { | |
| 256 | + t.Errorf("nothing patched should report nothing: got %q", got) | |
| 257 | + } | |
| 258 | +} | |
| 259 | + | |
| 260 | +// TestEditBlankBodyMessageTellsYouWhatToDo: `edit`'s refusal is not `comment`'s. | |
| 261 | +// On an edit the way to leave a description alone is to OMIT --body, and the | |
| 262 | +// message must say so rather than "--body is required". | |
| 263 | +func TestEditBlankBodyMessageTellsYouWhatToDo(t *testing.T) { | |
| 264 | + srv, _ := editServer(t, `{}`) | |
| 265 | + editEnv(t, srv) | |
| 266 | + | |
| 267 | + _, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "-b", "") | |
| 268 | + if err == nil { | |
| 269 | + t.Fatal("a blank description was accepted") | |
| 270 | + } | |
| 271 | + if !strings.Contains(err.Error(), "omit --body") { | |
| 272 | + t.Errorf("message should point at omitting the flag: %v", err) | |
| 273 | + } | |
| 274 | + if strings.Contains(err.Error(), "comment") { | |
| 275 | + t.Errorf("message is `comment`'s, not `edit`'s: %v", err) | |
| 276 | + } | |
| 277 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,277 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "io" | ||
| 6 | + "net/http" | ||
| 7 | + "net/http/httptest" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// editServer records every request it sees and answers a PATCH with the merged | ||
| 13 | +// result, so a test can assert both what went on the wire and what the CLI | ||
| 14 | +// printed from the response. | ||
| 15 | +type editCall struct { | ||
| 16 | + Method string | ||
| 17 | + Path string | ||
| 18 | + Body string | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +func editServer(t *testing.T, reply string) (*httptest.Server, *[]editCall) { | ||
| 22 | + t.Helper() | ||
| 23 | + var calls []editCall | ||
| 24 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 25 | + b, _ := io.ReadAll(r.Body) | ||
| 26 | + calls = append(calls, editCall{Method: r.Method, Path: r.URL.Path, Body: string(b)}) | ||
| 27 | + w.Header().Set("Content-Type", "application/json") | ||
| 28 | + _, _ = w.Write([]byte(reply)) | ||
| 29 | + })) | ||
| 30 | + t.Cleanup(srv.Close) | ||
| 31 | + return srv, &calls | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func editEnv(t *testing.T, srv *httptest.Server) { | ||
| 35 | + t.Helper() | ||
| 36 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 37 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 38 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +// TestPREditTitleOnlyLeavesBodyAlone is issue #7's first acceptance criterion: | ||
| 42 | +// renaming a merge request must not touch its description, which on the wire | ||
| 43 | +// means the payload carries no "body" key at all. | ||
| 44 | +func TestPREditTitleOnlyLeavesBodyAlone(t *testing.T) { | ||
| 45 | + srv, calls := editServer(t, `{"number":12,"title":"new title","body":"untouched","state":"open"}`) | ||
| 46 | + editEnv(t, srv) | ||
| 47 | + | ||
| 48 | + out, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "--title", "new title") | ||
| 49 | + if err != nil { | ||
| 50 | + t.Fatalf("execute: %v", err) | ||
| 51 | + } | ||
| 52 | + if len(*calls) != 1 { | ||
| 53 | + t.Fatalf("expected exactly one request, got %d", len(*calls)) | ||
| 54 | + } | ||
| 55 | + c := (*calls)[0] | ||
| 56 | + if c.Method != http.MethodPatch || c.Path != "/api/v1/repos/o/r/merge-requests/12" { | ||
| 57 | + t.Errorf("wrong route: %s %s", c.Method, c.Path) | ||
| 58 | + } | ||
| 59 | + var payload map[string]any | ||
| 60 | + if err := json.Unmarshal([]byte(c.Body), &payload); err != nil { | ||
| 61 | + t.Fatalf("payload is not JSON: %v (%s)", err, c.Body) | ||
| 62 | + } | ||
| 63 | + if payload["title"] != "new title" { | ||
| 64 | + t.Errorf("title not sent: %s", c.Body) | ||
| 65 | + } | ||
| 66 | + if _, ok := payload["body"]; ok { | ||
| 67 | + t.Errorf("body must be absent when --body was not passed: %s", c.Body) | ||
| 68 | + } | ||
| 69 | + if !strings.Contains(out, `title now "new title"`) { | ||
| 70 | + t.Errorf("success line should echo the stored title:\n%s", out) | ||
| 71 | + } | ||
| 72 | + if strings.Contains(out, "description") { | ||
| 73 | + t.Errorf("success line should not claim the description changed:\n%s", out) | ||
| 74 | + } | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// TestPREditBodyFromStdin keeps `-b -` working through the shared resolver and | ||
| 78 | +// checks the byte count comes from the server's echo (issue #94's lesson). | ||
| 79 | +func TestPREditBodyFromStdin(t *testing.T) { | ||
| 80 | + srv, calls := editServer(t, `{"number":12,"title":"kept","body":"piped in","state":"open"}`) | ||
| 81 | + editEnv(t, srv) | ||
| 82 | + | ||
| 83 | + rootCmd.SetIn(strings.NewReader("piped in\n")) | ||
| 84 | + defer rootCmd.SetIn(nil) | ||
| 85 | + | ||
| 86 | + out, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "-b", "-") | ||
| 87 | + if err != nil { | ||
| 88 | + t.Fatalf("execute: %v", err) | ||
| 89 | + } | ||
| 90 | + c := (*calls)[0] | ||
| 91 | + var payload map[string]any | ||
| 92 | + _ = json.Unmarshal([]byte(c.Body), &payload) | ||
| 93 | + if payload["body"] != "piped in" { | ||
| 94 | + t.Errorf("stdin body not sent verbatim: %s", c.Body) | ||
| 95 | + } | ||
| 96 | + if _, ok := payload["title"]; ok { | ||
| 97 | + t.Errorf("title must be absent when --title was not passed: %s", c.Body) | ||
| 98 | + } | ||
| 99 | + if !strings.Contains(out, "description now 8 bytes") { | ||
| 100 | + t.Errorf("success line should report the stored byte count:\n%s", out) | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// TestPREditBothFieldsOnePatch: naming both flags corrects both in ONE call. | ||
| 105 | +func TestPREditBothFieldsOnePatch(t *testing.T) { | ||
| 106 | + srv, calls := editServer(t, `{"number":12,"title":"t","body":"bb","state":"open"}`) | ||
| 107 | + editEnv(t, srv) | ||
| 108 | + | ||
| 109 | + out, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "--title", "t", "--body", "bb") | ||
| 110 | + if err != nil { | ||
| 111 | + t.Fatalf("execute: %v", err) | ||
| 112 | + } | ||
| 113 | + if len(*calls) != 1 { | ||
| 114 | + t.Fatalf("expected one PATCH, got %d requests", len(*calls)) | ||
| 115 | + } | ||
| 116 | + var payload map[string]any | ||
| 117 | + _ = json.Unmarshal([]byte((*calls)[0].Body), &payload) | ||
| 118 | + if payload["title"] != "t" || payload["body"] != "bb" { | ||
| 119 | + t.Errorf("both fields expected: %s", (*calls)[0].Body) | ||
| 120 | + } | ||
| 121 | + if !strings.Contains(out, `title now "t"`) || !strings.Contains(out, "description now 2 bytes") { | ||
| 122 | + t.Errorf("success line should report both:\n%s", out) | ||
| 123 | + } | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// TestEditWithNoFieldFlagsSendsNothing: the refusal is LOCAL. A command that | ||
| 127 | +// forwarded an empty patch just to collect the server's 400 empty_update would | ||
| 128 | +// still be wrong here — the assertion that matters is zero requests. | ||
| 129 | +func TestEditWithNoFieldFlagsSendsNothing(t *testing.T) { | ||
| 130 | + for _, args := range [][]string{ | ||
| 131 | + {"mr", "edit", "12", "-R", "o/r"}, | ||
| 132 | + {"issue", "edit", "7", "-R", "o/r"}, | ||
| 133 | + } { | ||
| 134 | + srv, calls := editServer(t, `{}`) | ||
| 135 | + editEnv(t, srv) | ||
| 136 | + _, _, err := execute(t, args...) | ||
| 137 | + if err == nil { | ||
| 138 | + t.Fatalf("%v: expected an error when no field flag is given", args) | ||
| 139 | + } | ||
| 140 | + if !strings.Contains(err.Error(), "nothing to update") { | ||
| 141 | + t.Errorf("%v: unhelpful error: %v", args, err) | ||
| 142 | + } | ||
| 143 | + if len(*calls) != 0 { | ||
| 144 | + t.Errorf("%v: %d request(s) sent; nothing should be sent", args, len(*calls)) | ||
| 145 | + } | ||
| 146 | + } | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +// TestEditRefusesBlankFields: a blank title is refused (it is the one field that | ||
| 150 | +// cannot be empty) and so is a blank body (issue #94) — both before any request. | ||
| 151 | +func TestEditRefusesBlankFields(t *testing.T) { | ||
| 152 | + cases := [][]string{ | ||
| 153 | + {"mr", "edit", "12", "-R", "o/r", "--title", " "}, | ||
| 154 | + {"mr", "edit", "12", "-R", "o/r", "--body", ""}, | ||
| 155 | + {"issue", "edit", "7", "-R", "o/r", "--title", ""}, | ||
| 156 | + {"issue", "edit", "7", "-R", "o/r", "--body", " "}, | ||
| 157 | + } | ||
| 158 | + for _, args := range cases { | ||
| 159 | + srv, calls := editServer(t, `{}`) | ||
| 160 | + editEnv(t, srv) | ||
| 161 | + if _, _, err := execute(t, args...); err == nil { | ||
| 162 | + t.Errorf("%v: expected a refusal", args) | ||
| 163 | + } | ||
| 164 | + if len(*calls) != 0 { | ||
| 165 | + t.Errorf("%v: %d request(s) sent; a blank field must not reach the server", args, len(*calls)) | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +// TestIssueEditPatchesIssueRoute is the issue half: same rules, the issues route. | ||
| 171 | +func TestIssueEditPatchesIssueRoute(t *testing.T) { | ||
| 172 | + srv, calls := editServer(t, `{"number":7,"title":"renamed","body":"kept","state":"open"}`) | ||
| 173 | + editEnv(t, srv) | ||
| 174 | + | ||
| 175 | + out, _, err := execute(t, "issue", "edit", "7", "-R", "o/r", "--title", "renamed") | ||
| 176 | + if err != nil { | ||
| 177 | + t.Fatalf("execute: %v", err) | ||
| 178 | + } | ||
| 179 | + c := (*calls)[0] | ||
| 180 | + if c.Method != http.MethodPatch || c.Path != "/api/v1/repos/o/r/issues/7" { | ||
| 181 | + t.Errorf("wrong route: %s %s", c.Method, c.Path) | ||
| 182 | + } | ||
| 183 | + if !strings.Contains(out, `Updated issue #7: title now "renamed"`) { | ||
| 184 | + t.Errorf("unexpected success line:\n%s", out) | ||
| 185 | + } | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +// TestEditSurfacesServerRefusalCleanly: a caller who may not touch the resource | ||
| 189 | +// gets the server's uniform 404 as a plain CLI error. | ||
| 190 | +func TestEditSurfacesServerRefusalCleanly(t *testing.T) { | ||
| 191 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 192 | + w.WriteHeader(http.StatusNotFound) | ||
| 193 | + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"merge request not found"}}`)) | ||
| 194 | + })) | ||
| 195 | + defer srv.Close() | ||
| 196 | + editEnv(t, srv) | ||
| 197 | + | ||
| 198 | + _, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "--title", "x") | ||
| 199 | + if err == nil { | ||
| 200 | + t.Fatal("expected an error") | ||
| 201 | + } | ||
| 202 | + if !strings.Contains(err.Error(), "merge request not found") || !strings.Contains(err.Error(), "not_found") { | ||
| 203 | + t.Errorf("error should surface the server's message and code: %v", err) | ||
| 204 | + } | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +// TestEditValidatesNumberBeforeReadingStdin: a bad number must be rejected before | ||
| 208 | +// anything drains stdin. `mr edit abc -b - <<'EOF' … EOF` used to swallow the | ||
| 209 | +// heredoc and then report "invalid merge-request number", which is issue #94's | ||
| 210 | +// failure mode — a body that vanishes with a plausible-looking error — reached by | ||
| 211 | +// a different route. | ||
| 212 | +func TestEditValidatesNumberBeforeReadingStdin(t *testing.T) { | ||
| 213 | + for _, args := range [][]string{ | ||
| 214 | + {"mr", "edit", "abc", "-R", "o/r", "-b", "-"}, | ||
| 215 | + {"issue", "edit", "0", "-R", "o/r", "-b", "-"}, | ||
| 216 | + } { | ||
| 217 | + srv, calls := editServer(t, `{}`) | ||
| 218 | + editEnv(t, srv) | ||
| 219 | + | ||
| 220 | + const piped = "a body nobody should lose\n" | ||
| 221 | + stdin := strings.NewReader(piped) | ||
| 222 | + rootCmd.SetIn(stdin) | ||
| 223 | + | ||
| 224 | + _, _, err := execute(t, args...) | ||
| 225 | + rootCmd.SetIn(nil) | ||
| 226 | + | ||
| 227 | + if err == nil || !strings.Contains(err.Error(), "invalid") { | ||
| 228 | + t.Errorf("%v: want an invalid-number error, got %v", args, err) | ||
| 229 | + } | ||
| 230 | + if len(*calls) != 0 { | ||
| 231 | + t.Errorf("%v: %d request(s) sent for an invalid number", args, len(*calls)) | ||
| 232 | + } | ||
| 233 | + // The heredoc is still there to be read: nothing consumed it. | ||
| 234 | + rest, _ := io.ReadAll(stdin) | ||
| 235 | + if string(rest) != piped { | ||
| 236 | + t.Errorf("%v: stdin was consumed before the number was validated (%q left)", args, rest) | ||
| 237 | + } | ||
| 238 | + } | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +// TestEditSummaryFallsBackToTheSentValue: an echo that arrives empty must not turn | ||
| 242 | +// a successful rename into `title now ""`, which reads as "your title is now | ||
| 243 | +// empty" — the opposite of what happened. | ||
| 244 | +func TestEditSummaryFallsBackToTheSentValue(t *testing.T) { | ||
| 245 | + title, body := "the new title", "the new body" | ||
| 246 | + if got := editSummary(&title, nil, "stored title", ""); got != `title now "stored title"` { | ||
| 247 | + t.Errorf("echo present: got %q", got) | ||
| 248 | + } | ||
| 249 | + if got := editSummary(&title, nil, "", ""); got != `title set to "the new title" (not echoed back)` { | ||
| 250 | + t.Errorf("echo absent: got %q", got) | ||
| 251 | + } | ||
| 252 | + if got := editSummary(nil, &body, "", ""); got != "description set to 12 bytes (not echoed back)" { | ||
| 253 | + t.Errorf("body echo absent: got %q", got) | ||
| 254 | + } | ||
| 255 | + if got := editSummary(nil, nil, "x", "y"); got != "" { | ||
| 256 | + t.Errorf("nothing patched should report nothing: got %q", got) | ||
| 257 | + } | ||
| 258 | +} | ||
| 259 | + | ||
| 260 | +// TestEditBlankBodyMessageTellsYouWhatToDo: `edit`'s refusal is not `comment`'s. | ||
| 261 | +// On an edit the way to leave a description alone is to OMIT --body, and the | ||
| 262 | +// message must say so rather than "--body is required". | ||
| 263 | +func TestEditBlankBodyMessageTellsYouWhatToDo(t *testing.T) { | ||
| 264 | + srv, _ := editServer(t, `{}`) | ||
| 265 | + editEnv(t, srv) | ||
| 266 | + | ||
| 267 | + _, _, err := execute(t, "mr", "edit", "12", "-R", "o/r", "-b", "") | ||
| 268 | + if err == nil { | ||
| 269 | + t.Fatal("a blank description was accepted") | ||
| 270 | + } | ||
| 271 | + if !strings.Contains(err.Error(), "omit --body") { | ||
| 272 | + t.Errorf("message should point at omitting the flag: %v", err) | ||
| 273 | + } | ||
| 274 | + if strings.Contains(err.Error(), "comment") { | ||
| 275 | + t.Errorf("message is `comment`'s, not `edit`'s: %v", err) | ||
| 276 | + } | ||
| 277 | +} | ||
modified
cmd/issue.go +50 -1 | @@ -111,6 +111,24 @@ func init() { | ||
| 111 | 111 | RunE: runIssueLabelsList, |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | + editCmd := &cobra.Command{ | |
| 115 | + Use: "edit <number>", | |
| 116 | + Short: "Correct an issue's title and/or description", | |
| 117 | + Long: "Correct an issue's title (--title) and/or description (--body, or -b - to read it\n" + | |
| 118 | + "from stdin).\n\n" + | |
| 119 | + "Both flags are optional but at least one is required: a field you do not name is\n" + | |
| 120 | + "left exactly as it was, and passing neither is refused without sending a request.\n" + | |
| 121 | + "Naming both corrects both in a single call.\n\n" + | |
| 122 | + "The issue's author, or anyone with write access to the repository, may edit it.", | |
| 123 | + Example: " rickub issue edit 7 --title \"run logs has no --job filter\"\n" + | |
| 124 | + " rickub issue edit 7 -b - <<'EOF'\n the new description\n EOF\n" + | |
| 125 | + " rickub issue edit 7 --title \"…\" --body \"…\"", | |
| 126 | + Args: cobra.ExactArgs(1), | |
| 127 | + RunE: runIssueEdit, | |
| 128 | + } | |
| 129 | + editCmd.Flags().StringVarP(&issueTitle, "title", "t", "", "new title") | |
| 130 | + editCmd.Flags().StringVarP(&issueBody, "body", "b", "", "new description "+bodyFlagHelp) | |
| 131 | + | |
| 114 | 132 | deleteCommentCmd := &cobra.Command{ |
| 115 | 133 | Use: "delete-comment <number> <comment-id>", |
| 116 | 134 | Aliases: []string{"retract"}, |
| @@ -123,7 +141,7 @@ func init() { | ||
| 123 | 141 | RunE: runIssueDeleteComment, |
| 124 | 142 | } |
| 125 | 143 | |
| 126 | - issueCmd.AddCommand(listCmd, viewCmd, createCmd, closeCmd, reopenCmd, commentCmd, labelCmd, | |
| 144 | + issueCmd.AddCommand(listCmd, viewCmd, createCmd, editCmd, closeCmd, reopenCmd, commentCmd, labelCmd, | |
| 127 | 145 | milestoneCmd, assignCmd, labelsCmd, deleteCommentCmd) |
| 128 | 146 | rootCmd.AddCommand(issueCmd) |
| 129 | 147 | } |
| @@ -274,6 +292,37 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { | ||
| 274 | 292 | return nil |
| 275 | 293 | } |
| 276 | 294 | |
| 295 | +func runIssueEdit(cmd *cobra.Command, args []string) error { | |
| 296 | + // Number first, before anything reads stdin — see runPREdit. | |
| 297 | + n, err := issueNumber(args[0]) | |
| 298 | + if err != nil { | |
| 299 | + return err | |
| 300 | + } | |
| 301 | + // Same resolver as `mr edit`: a flag the user did not pass stays nil and is | |
| 302 | + // left out of the PATCH, so the other field survives untouched. | |
| 303 | + title, body, err := resolveEditFields(cmd, issueTitle, issueBody) | |
| 304 | + if err != nil { | |
| 305 | + return err | |
| 306 | + } | |
| 307 | + client, err := newClient() | |
| 308 | + if err != nil { | |
| 309 | + return err | |
| 310 | + } | |
| 311 | + owner, repo, err := resolveRepo(issueRepo) | |
| 312 | + if err != nil { | |
| 313 | + return err | |
| 314 | + } | |
| 315 | + i, err := client.UpdateIssue(cmd.Context(), owner, repo, n, api.IssuePatch{Title: title, Body: body}) | |
| 316 | + if err != nil { | |
| 317 | + return err | |
| 318 | + } | |
| 319 | + if flagJSON { | |
| 320 | + return printJSON(cmd.OutOrStdout(), i) | |
| 321 | + } | |
| 322 | + fmt.Fprintf(cmd.OutOrStdout(), "Updated issue #%d: %s\n", n, editSummary(title, body, i.Title, i.Body)) | |
| 323 | + return nil | |
| 324 | +} | |
| 325 | + | |
| 277 | 326 | func runIssueState(cmd *cobra.Command, args []string, state string) error { |
| 278 | 327 | client, err := newClient() |
| 279 | 328 | if err != nil { |
| @@ -111,6 +111,24 @@ func init() { | |||
| 111 | RunE: runIssueLabelsList, | 111 | RunE: runIssueLabelsList, |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | + editCmd := &cobra.Command{ | ||
| 115 | + Use: "edit <number>", | ||
| 116 | + Short: "Correct an issue's title and/or description", | ||
| 117 | + Long: "Correct an issue's title (--title) and/or description (--body, or -b - to read it\n" + | ||
| 118 | + "from stdin).\n\n" + | ||
| 119 | + "Both flags are optional but at least one is required: a field you do not name is\n" + | ||
| 120 | + "left exactly as it was, and passing neither is refused without sending a request.\n" + | ||
| 121 | + "Naming both corrects both in a single call.\n\n" + | ||
| 122 | + "The issue's author, or anyone with write access to the repository, may edit it.", | ||
| 123 | + Example: " rickub issue edit 7 --title \"run logs has no --job filter\"\n" + | ||
| 124 | + " rickub issue edit 7 -b - <<'EOF'\n the new description\n EOF\n" + | ||
| 125 | + " rickub issue edit 7 --title \"…\" --body \"…\"", | ||
| 126 | + Args: cobra.ExactArgs(1), | ||
| 127 | + RunE: runIssueEdit, | ||
| 128 | + } | ||
| 129 | + editCmd.Flags().StringVarP(&issueTitle, "title", "t", "", "new title") | ||
| 130 | + editCmd.Flags().StringVarP(&issueBody, "body", "b", "", "new description "+bodyFlagHelp) | ||
| 131 | + | ||
| 114 | deleteCommentCmd := &cobra.Command{ | 132 | deleteCommentCmd := &cobra.Command{ |
| 115 | Use: "delete-comment <number> <comment-id>", | 133 | Use: "delete-comment <number> <comment-id>", |
| 116 | Aliases: []string{"retract"}, | 134 | Aliases: []string{"retract"}, |
| @@ -123,7 +141,7 @@ func init() { | |||
| 123 | RunE: runIssueDeleteComment, | 141 | RunE: runIssueDeleteComment, |
| 124 | } | 142 | } |
| 125 | 143 | ||
| 126 | - issueCmd.AddCommand(listCmd, viewCmd, createCmd, closeCmd, reopenCmd, commentCmd, labelCmd, | 144 | + issueCmd.AddCommand(listCmd, viewCmd, createCmd, editCmd, closeCmd, reopenCmd, commentCmd, labelCmd, |
| 127 | milestoneCmd, assignCmd, labelsCmd, deleteCommentCmd) | 145 | milestoneCmd, assignCmd, labelsCmd, deleteCommentCmd) |
| 128 | rootCmd.AddCommand(issueCmd) | 146 | rootCmd.AddCommand(issueCmd) |
| 129 | } | 147 | } |
| @@ -274,6 +292,37 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { | |||
| 274 | return nil | 292 | return nil |
| 275 | } | 293 | } |
| 276 | 294 | ||
| 295 | +func runIssueEdit(cmd *cobra.Command, args []string) error { | ||
| 296 | + // Number first, before anything reads stdin — see runPREdit. | ||
| 297 | + n, err := issueNumber(args[0]) | ||
| 298 | + if err != nil { | ||
| 299 | + return err | ||
| 300 | + } | ||
| 301 | + // Same resolver as `mr edit`: a flag the user did not pass stays nil and is | ||
| 302 | + // left out of the PATCH, so the other field survives untouched. | ||
| 303 | + title, body, err := resolveEditFields(cmd, issueTitle, issueBody) | ||
| 304 | + if err != nil { | ||
| 305 | + return err | ||
| 306 | + } | ||
| 307 | + client, err := newClient() | ||
| 308 | + if err != nil { | ||
| 309 | + return err | ||
| 310 | + } | ||
| 311 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 312 | + if err != nil { | ||
| 313 | + return err | ||
| 314 | + } | ||
| 315 | + i, err := client.UpdateIssue(cmd.Context(), owner, repo, n, api.IssuePatch{Title: title, Body: body}) | ||
| 316 | + if err != nil { | ||
| 317 | + return err | ||
| 318 | + } | ||
| 319 | + if flagJSON { | ||
| 320 | + return printJSON(cmd.OutOrStdout(), i) | ||
| 321 | + } | ||
| 322 | + fmt.Fprintf(cmd.OutOrStdout(), "Updated issue #%d: %s\n", n, editSummary(title, body, i.Title, i.Body)) | ||
| 323 | + return nil | ||
| 324 | +} | ||
| 325 | + | ||
| 277 | func runIssueState(cmd *cobra.Command, args []string, state string) error { | 326 | func runIssueState(cmd *cobra.Command, args []string, state string) error { |
| 278 | client, err := newClient() | 327 | client, err := newClient() |
| 279 | if err != nil { | 328 | if err != nil { |
modified
cmd/pr.go +24 -13 | @@ -158,13 +158,21 @@ func init() { | ||
| 158 | 158 | |
| 159 | 159 | editCmd := &cobra.Command{ |
| 160 | 160 | Use: "edit <number>", |
| 161 | - Short: "Replace a merge request's description", | |
| 162 | - Long: "Replace a merge request's description with --body (or -b - to read it from stdin).\n\n" + | |
| 161 | + Short: "Correct a merge request's title and/or description", | |
| 162 | + Long: "Correct a merge request's title (--title) and/or description (--body, or -b - to\n" + | |
| 163 | + "read it from stdin).\n\n" + | |
| 164 | + "Both flags are optional but at least one is required: a field you do not name is\n" + | |
| 165 | + "left exactly as it was, and passing neither is refused without sending a request.\n" + | |
| 166 | + "Naming both corrects both in a single call.\n\n" + | |
| 163 | 167 | "The merge request's author, or anyone with write access to the repository, may edit it.", |
| 168 | + Example: " rickub mr edit 12 --title \"fix the retry budget, not the timeout\"\n" + | |
| 169 | + " rickub mr edit 12 -b - <<'EOF'\n the new description\n EOF\n" + | |
| 170 | + " rickub mr edit 12 --title \"…\" --body \"…\"", | |
| 164 | 171 | Args: cobra.ExactArgs(1), |
| 165 | 172 | RunE: runPREdit, |
| 166 | 173 | } |
| 167 | - editCmd.Flags().StringVarP(&prBody, "body", "b", "", "new description "+bodyFlagHelp+"; required") | |
| 174 | + editCmd.Flags().StringVarP(&prTitle, "title", "t", "", "new title") | |
| 175 | + editCmd.Flags().StringVarP(&prBody, "body", "b", "", "new description "+bodyFlagHelp) | |
| 168 | 176 | |
| 169 | 177 | prCmd.AddCommand(listCmd, viewCmd, createCmd, readyCmd, mergeCmd, closeCmd, reopenCmd, commentCmd, |
| 170 | 178 | lineCommentsCmd, resolveCmd, deleteCommentCmd, editCmd) |
| @@ -274,34 +282,37 @@ func prStateLabel(mr api.MergeRequest) string { | ||
| 274 | 282 | } |
| 275 | 283 | |
| 276 | 284 | func runPREdit(cmd *cobra.Command, args []string) error { |
| 277 | - // Same resolver as every other --body: `-b -` reads stdin, and a blank result | |
| 278 | - // is refused rather than silently blanking the description. | |
| 279 | - body, err := requireBody(cmd, prBody) | |
| 285 | + // The number is validated FIRST, before anything reads stdin: `mr edit abc -b -` | |
| 286 | + // must not swallow a heredoc on its way to "invalid merge-request number". A | |
| 287 | + // piped body that vanishes into a rejected command is issue #94's failure mode | |
| 288 | + // with a different trigger. | |
| 289 | + n, err := prNumber(args[0]) | |
| 280 | 290 | if err != nil { |
| 281 | 291 | return err |
| 282 | 292 | } |
| 283 | - client, err := newClient() | |
| 293 | + // Shared with `issue edit`: a flag the user did not pass stays nil and is | |
| 294 | + // left out of the PATCH, `-b -` still reads stdin, and a blank title or body | |
| 295 | + // is refused here rather than after a round trip. | |
| 296 | + title, body, err := resolveEditFields(cmd, prTitle, prBody) | |
| 284 | 297 | if err != nil { |
| 285 | 298 | return err |
| 286 | 299 | } |
| 287 | - owner, repo, err := resolveRepo(prRepo) | |
| 300 | + client, err := newClient() | |
| 288 | 301 | if err != nil { |
| 289 | 302 | return err |
| 290 | 303 | } |
| 291 | - n, err := prNumber(args[0]) | |
| 304 | + owner, repo, err := resolveRepo(prRepo) | |
| 292 | 305 | if err != nil { |
| 293 | 306 | return err |
| 294 | 307 | } |
| 295 | - mr, err := client.UpdateMergeRequestBody(cmd.Context(), owner, repo, n, body) | |
| 308 | + mr, err := client.UpdateMergeRequest(cmd.Context(), owner, repo, n, api.MergeRequestPatch{Title: title, Body: body}) | |
| 296 | 309 | if err != nil { |
| 297 | 310 | return err |
| 298 | 311 | } |
| 299 | 312 | if flagJSON { |
| 300 | 313 | return printJSON(cmd.OutOrStdout(), mr) |
| 301 | 314 | } |
| 302 | - // Report the stored length, not just "ok": a success line that does not prove | |
| 303 | - // the bytes landed is exactly what made issue #94 invisible. | |
| 304 | - fmt.Fprintf(cmd.OutOrStdout(), "Updated the description of #%d (%d bytes)\n", n, len(mr.Body)) | |
| 315 | + fmt.Fprintf(cmd.OutOrStdout(), "Updated #%d: %s\n", n, editSummary(title, body, mr.Title, mr.Body)) | |
| 305 | 316 | return nil |
| 306 | 317 | } |
| 307 | 318 | |
| @@ -158,13 +158,21 @@ func init() { | |||
| 158 | 158 | ||
| 159 | editCmd := &cobra.Command{ | 159 | editCmd := &cobra.Command{ |
| 160 | Use: "edit <number>", | 160 | Use: "edit <number>", |
| 161 | - Short: "Replace a merge request's description", | 161 | + Short: "Correct a merge request's title and/or description", |
| 162 | - Long: "Replace a merge request's description with --body (or -b - to read it from stdin).\n\n" + | 162 | + Long: "Correct a merge request's title (--title) and/or description (--body, or -b - to\n" + |
| 163 | + "read it from stdin).\n\n" + | ||
| 164 | + "Both flags are optional but at least one is required: a field you do not name is\n" + | ||
| 165 | + "left exactly as it was, and passing neither is refused without sending a request.\n" + | ||
| 166 | + "Naming both corrects both in a single call.\n\n" + | ||
| 163 | "The merge request's author, or anyone with write access to the repository, may edit it.", | 167 | "The merge request's author, or anyone with write access to the repository, may edit it.", |
| 168 | + Example: " rickub mr edit 12 --title \"fix the retry budget, not the timeout\"\n" + | ||
| 169 | + " rickub mr edit 12 -b - <<'EOF'\n the new description\n EOF\n" + | ||
| 170 | + " rickub mr edit 12 --title \"…\" --body \"…\"", | ||
| 164 | Args: cobra.ExactArgs(1), | 171 | Args: cobra.ExactArgs(1), |
| 165 | RunE: runPREdit, | 172 | RunE: runPREdit, |
| 166 | } | 173 | } |
| 167 | - editCmd.Flags().StringVarP(&prBody, "body", "b", "", "new description "+bodyFlagHelp+"; required") | 174 | + editCmd.Flags().StringVarP(&prTitle, "title", "t", "", "new title") |
| 175 | + editCmd.Flags().StringVarP(&prBody, "body", "b", "", "new description "+bodyFlagHelp) | ||
| 168 | 176 | ||
| 169 | prCmd.AddCommand(listCmd, viewCmd, createCmd, readyCmd, mergeCmd, closeCmd, reopenCmd, commentCmd, | 177 | prCmd.AddCommand(listCmd, viewCmd, createCmd, readyCmd, mergeCmd, closeCmd, reopenCmd, commentCmd, |
| 170 | lineCommentsCmd, resolveCmd, deleteCommentCmd, editCmd) | 178 | lineCommentsCmd, resolveCmd, deleteCommentCmd, editCmd) |
| @@ -274,34 +282,37 @@ func prStateLabel(mr api.MergeRequest) string { | |||
| 274 | } | 282 | } |
| 275 | 283 | ||
| 276 | func runPREdit(cmd *cobra.Command, args []string) error { | 284 | func runPREdit(cmd *cobra.Command, args []string) error { |
| 277 | - // Same resolver as every other --body: `-b -` reads stdin, and a blank result | 285 | + // The number is validated FIRST, before anything reads stdin: `mr edit abc -b -` |
| 278 | - // is refused rather than silently blanking the description. | 286 | + // must not swallow a heredoc on its way to "invalid merge-request number". A |
| 279 | - body, err := requireBody(cmd, prBody) | 287 | + // piped body that vanishes into a rejected command is issue #94's failure mode |
| 288 | + // with a different trigger. | ||
| 289 | + n, err := prNumber(args[0]) | ||
| 280 | if err != nil { | 290 | if err != nil { |
| 281 | return err | 291 | return err |
| 282 | } | 292 | } |
| 283 | - client, err := newClient() | 293 | + // Shared with `issue edit`: a flag the user did not pass stays nil and is |
| 294 | + // left out of the PATCH, `-b -` still reads stdin, and a blank title or body | ||
| 295 | + // is refused here rather than after a round trip. | ||
| 296 | + title, body, err := resolveEditFields(cmd, prTitle, prBody) | ||
| 284 | if err != nil { | 297 | if err != nil { |
| 285 | return err | 298 | return err |
| 286 | } | 299 | } |
| 287 | - owner, repo, err := resolveRepo(prRepo) | 300 | + client, err := newClient() |
| 288 | if err != nil { | 301 | if err != nil { |
| 289 | return err | 302 | return err |
| 290 | } | 303 | } |
| 291 | - n, err := prNumber(args[0]) | 304 | + owner, repo, err := resolveRepo(prRepo) |
| 292 | if err != nil { | 305 | if err != nil { |
| 293 | return err | 306 | return err |
| 294 | } | 307 | } |
| 295 | - mr, err := client.UpdateMergeRequestBody(cmd.Context(), owner, repo, n, body) | 308 | + mr, err := client.UpdateMergeRequest(cmd.Context(), owner, repo, n, api.MergeRequestPatch{Title: title, Body: body}) |
| 296 | if err != nil { | 309 | if err != nil { |
| 297 | return err | 310 | return err |
| 298 | } | 311 | } |
| 299 | if flagJSON { | 312 | if flagJSON { |
| 300 | return printJSON(cmd.OutOrStdout(), mr) | 313 | return printJSON(cmd.OutOrStdout(), mr) |
| 301 | } | 314 | } |
| 302 | - // Report the stored length, not just "ok": a success line that does not prove | 315 | + fmt.Fprintf(cmd.OutOrStdout(), "Updated #%d: %s\n", n, editSummary(title, body, mr.Title, mr.Body)) |
| 303 | - // the bytes landed is exactly what made issue #94 invisible. | ||
| 304 | - fmt.Fprintf(cmd.OutOrStdout(), "Updated the description of #%d (%d bytes)\n", n, len(mr.Body)) | ||
| 305 | return nil | 316 | return nil |
| 306 | } | 317 | } |
| 307 | 318 | ||
modified
cmd/pr_line_comments_test.go +5 -6 | @@ -294,10 +294,9 @@ func TestPRViewShowsCommentIDs(t *testing.T) { | ||
| 294 | 294 | } |
| 295 | 295 | } |
| 296 | 296 | |
| 297 | -// TestPREditSendsBodyAndReportsLength: `mr edit` posts the description to the | |
| 298 | -// merge-request body route (which did not exist on /api/v1 before this branch), | |
| 299 | -// resolves `-b -` from stdin through the shared resolver, and reports the STORED | |
| 300 | -// byte count rather than a bare success line. | |
| 297 | +// TestPREditSendsBodyAndReportsLength: `mr edit` PATCHes the description onto the | |
| 298 | +// merge-request route, resolves `-b -` from stdin through the shared resolver, and | |
| 299 | +// reports the STORED byte count rather than a bare success line. | |
| 301 | 300 | func TestPREditSendsBodyAndReportsLength(t *testing.T) { |
| 302 | 301 | var seen captured |
| 303 | 302 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| @@ -325,8 +324,8 @@ func TestPREditSendsBodyAndReportsLength(t *testing.T) { | ||
| 325 | 324 | if err != nil { |
| 326 | 325 | t.Fatalf("execute: %v", err) |
| 327 | 326 | } |
| 328 | - if seen.Path != "/api/v1/repos/rick/portal/merge-requests/12/body" { | |
| 329 | - t.Fatalf("path = %q", seen.Path) | |
| 327 | + if seen.Method != http.MethodPatch || seen.Path != "/api/v1/repos/rick/portal/merge-requests/12" { | |
| 328 | + t.Fatalf("route = %s %q", seen.Method, seen.Path) | |
| 330 | 329 | } |
| 331 | 330 | if seen.Body["body"] != desc { |
| 332 | 331 | t.Fatalf("sent body = %q, want the stdin text", seen.Body["body"]) |
| @@ -294,10 +294,9 @@ func TestPRViewShowsCommentIDs(t *testing.T) { | |||
| 294 | } | 294 | } |
| 295 | } | 295 | } |
| 296 | 296 | ||
| 297 | -// TestPREditSendsBodyAndReportsLength: `mr edit` posts the description to the | 297 | +// TestPREditSendsBodyAndReportsLength: `mr edit` PATCHes the description onto the |
| 298 | -// merge-request body route (which did not exist on /api/v1 before this branch), | 298 | +// merge-request route, resolves `-b -` from stdin through the shared resolver, and |
| 299 | -// resolves `-b -` from stdin through the shared resolver, and reports the STORED | 299 | +// reports the STORED byte count rather than a bare success line. |
| 300 | -// byte count rather than a bare success line. | ||
| 301 | func TestPREditSendsBodyAndReportsLength(t *testing.T) { | 300 | func TestPREditSendsBodyAndReportsLength(t *testing.T) { |
| 302 | var seen captured | 301 | var seen captured |
| 303 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 302 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| @@ -325,8 +324,8 @@ func TestPREditSendsBodyAndReportsLength(t *testing.T) { | |||
| 325 | if err != nil { | 324 | if err != nil { |
| 326 | t.Fatalf("execute: %v", err) | 325 | t.Fatalf("execute: %v", err) |
| 327 | } | 326 | } |
| 328 | - if seen.Path != "/api/v1/repos/rick/portal/merge-requests/12/body" { | 327 | + if seen.Method != http.MethodPatch || seen.Path != "/api/v1/repos/rick/portal/merge-requests/12" { |
| 329 | - t.Fatalf("path = %q", seen.Path) | 328 | + t.Fatalf("route = %s %q", seen.Method, seen.Path) |
| 330 | } | 329 | } |
| 331 | if seen.Body["body"] != desc { | 330 | if seen.Body["body"] != desc { |
| 332 | t.Fatalf("sent body = %q, want the stdin text", seen.Body["body"]) | 331 | t.Fatalf("sent body = %q, want the stdin text", seen.Body["body"]) |
modified
cmd/repo_test.go +18 -0 | @@ -7,6 +7,9 @@ import ( | ||
| 7 | 7 | "net/http/httptest" |
| 8 | 8 | "strings" |
| 9 | 9 | "testing" |
| 10 | + | |
| 11 | + "github.com/spf13/cobra" | |
| 12 | + "github.com/spf13/pflag" | |
| 10 | 13 | ) |
| 11 | 14 | |
| 12 | 15 | // execute runs the root command with args against a fresh output buffer, after |
| @@ -39,6 +42,7 @@ func execute(t *testing.T, args ...string) (string, string, error) { | ||
| 39 | 42 | authLoginScope = "all" |
| 40 | 43 | authLoginNoBrowser = false |
| 41 | 44 | prRepo = "" |
| 45 | + prTitle = "" | |
| 42 | 46 | prBody = "" |
| 43 | 47 | prFile = "" |
| 44 | 48 | prLine = 0 |
| @@ -53,6 +57,12 @@ func execute(t *testing.T, args ...string) (string, string, error) { | ||
| 53 | 57 | labelColor = "" |
| 54 | 58 | labelNewName = "" |
| 55 | 59 | |
| 60 | + // Cobra records "the user passed this flag" on the flag object itself, and the | |
| 61 | + // command tree is built once per process by init(). `edit` asks exactly that | |
| 62 | + // question to decide which fields go into its PATCH, so a stale Changed from an | |
| 63 | + // earlier test would make it send a field nobody passed. Clear it everywhere. | |
| 64 | + resetFlagsChanged(rootCmd) | |
| 65 | + | |
| 56 | 66 | var out, errBuf bytes.Buffer |
| 57 | 67 | rootCmd.SetOut(&out) |
| 58 | 68 | rootCmd.SetErr(&errBuf) |
| @@ -61,6 +71,14 @@ func execute(t *testing.T, args ...string) (string, string, error) { | ||
| 61 | 71 | return out.String(), errBuf.String(), err |
| 62 | 72 | } |
| 63 | 73 | |
| 74 | +// resetFlagsChanged clears the Changed bit on every flag in a command tree. | |
| 75 | +func resetFlagsChanged(c *cobra.Command) { | |
| 76 | + c.Flags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) | |
| 77 | + for _, sub := range c.Commands() { | |
| 78 | + resetFlagsChanged(sub) | |
| 79 | + } | |
| 80 | +} | |
| 81 | + | |
| 64 | 82 | func repoListServer(t *testing.T) *httptest.Server { |
| 65 | 83 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 | 84 | if r.URL.Path != "/api/v1/users/ricktester/repos" { |
| @@ -7,6 +7,9 @@ import ( | |||
| 7 | "net/http/httptest" | 7 | "net/http/httptest" |
| 8 | "strings" | 8 | "strings" |
| 9 | "testing" | 9 | "testing" |
| 10 | + | ||
| 11 | + "github.com/spf13/cobra" | ||
| 12 | + "github.com/spf13/pflag" | ||
| 10 | ) | 13 | ) |
| 11 | 14 | ||
| 12 | // execute runs the root command with args against a fresh output buffer, after | 15 | // execute runs the root command with args against a fresh output buffer, after |
| @@ -39,6 +42,7 @@ func execute(t *testing.T, args ...string) (string, string, error) { | |||
| 39 | authLoginScope = "all" | 42 | authLoginScope = "all" |
| 40 | authLoginNoBrowser = false | 43 | authLoginNoBrowser = false |
| 41 | prRepo = "" | 44 | prRepo = "" |
| 45 | + prTitle = "" | ||
| 42 | prBody = "" | 46 | prBody = "" |
| 43 | prFile = "" | 47 | prFile = "" |
| 44 | prLine = 0 | 48 | prLine = 0 |
| @@ -53,6 +57,12 @@ func execute(t *testing.T, args ...string) (string, string, error) { | |||
| 53 | labelColor = "" | 57 | labelColor = "" |
| 54 | labelNewName = "" | 58 | labelNewName = "" |
| 55 | 59 | ||
| 60 | + // Cobra records "the user passed this flag" on the flag object itself, and the | ||
| 61 | + // command tree is built once per process by init(). `edit` asks exactly that | ||
| 62 | + // question to decide which fields go into its PATCH, so a stale Changed from an | ||
| 63 | + // earlier test would make it send a field nobody passed. Clear it everywhere. | ||
| 64 | + resetFlagsChanged(rootCmd) | ||
| 65 | + | ||
| 56 | var out, errBuf bytes.Buffer | 66 | var out, errBuf bytes.Buffer |
| 57 | rootCmd.SetOut(&out) | 67 | rootCmd.SetOut(&out) |
| 58 | rootCmd.SetErr(&errBuf) | 68 | rootCmd.SetErr(&errBuf) |
| @@ -61,6 +71,14 @@ func execute(t *testing.T, args ...string) (string, string, error) { | |||
| 61 | return out.String(), errBuf.String(), err | 71 | return out.String(), errBuf.String(), err |
| 62 | } | 72 | } |
| 63 | 73 | ||
| 74 | +// resetFlagsChanged clears the Changed bit on every flag in a command tree. | ||
| 75 | +func resetFlagsChanged(c *cobra.Command) { | ||
| 76 | + c.Flags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) | ||
| 77 | + for _, sub := range c.Commands() { | ||
| 78 | + resetFlagsChanged(sub) | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | + | ||
| 64 | func repoListServer(t *testing.T) *httptest.Server { | 82 | func repoListServer(t *testing.T) *httptest.Server { |
| 65 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 83 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 | if r.URL.Path != "/api/v1/users/ricktester/repos" { | 84 | if r.URL.Path != "/api/v1/users/ricktester/repos" { |
modified
go.mod +2 -4 | @@ -6,10 +6,8 @@ toolchain go1.26.8 | ||
| 6 | 6 | |
| 7 | 7 | require ( |
| 8 | 8 | github.com/spf13/cobra v1.10.2 |
| 9 | + github.com/spf13/pflag v1.0.9 | |
| 9 | 10 | gopkg.in/yaml.v3 v3.0.1 |
| 10 | 11 | ) |
| 11 | 12 | |
| 12 | -require ( | |
| 13 | - github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 14 | - github.com/spf13/pflag v1.0.9 // indirect | |
| 15 | -) | |
| 13 | +require github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| @@ -6,10 +6,8 @@ toolchain go1.26.8 | |||
| 6 | 6 | ||
| 7 | require ( | 7 | require ( |
| 8 | github.com/spf13/cobra v1.10.2 | 8 | github.com/spf13/cobra v1.10.2 |
| 9 | + github.com/spf13/pflag v1.0.9 | ||
| 9 | gopkg.in/yaml.v3 v3.0.1 | 10 | gopkg.in/yaml.v3 v3.0.1 |
| 10 | ) | 11 | ) |
| 11 | 12 | ||
| 12 | -require ( | 13 | +require github.com/inconshreveable/mousetrap v1.1.0 // indirect |
| 13 | - github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 14 | - github.com/spf13/pflag v1.0.9 // indirect | ||
| 15 | -) | ||
modified
internal/api/endpoints.go +21 -6 | @@ -207,14 +207,16 @@ func lineCommentsPath(owner, repo string, number int) string { | ||
| 207 | 207 | return "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/line-comments" |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | -// UpdateMergeRequestBody replaces a merge request's description. The MR's author | |
| 211 | -// or any write-access user may do it. | |
| 212 | -func (c *Client) UpdateMergeRequestBody(ctx context.Context, owner, repo string, number int, body string) (*MergeRequest, error) { | |
| 210 | +// UpdateMergeRequest patches a merge request's title and/or description in one | |
| 211 | +// call. Fields left nil in the patch are not sent, so the server keeps them; a | |
| 212 | +// patch with neither field set is refused by the server as 400 empty_update. | |
| 213 | +// The MR's author or any write-access user may do it. | |
| 214 | +func (c *Client) UpdateMergeRequest(ctx context.Context, owner, repo string, number int, in MergeRequestPatch) (*MergeRequest, error) { | |
| 213 | 215 | var mr MergeRequest |
| 214 | 216 | err := c.do(ctx, Request{ |
| 215 | - Method: http.MethodPost, | |
| 216 | - Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/body", | |
| 217 | - Body: map[string]string{"body": body}, | |
| 217 | + Method: http.MethodPatch, | |
| 218 | + Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number), | |
| 219 | + Body: in, | |
| 218 | 220 | }, &mr) |
| 219 | 221 | return &mr, err |
| 220 | 222 | } |
| @@ -367,6 +369,19 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo, title, body strin | ||
| 367 | 369 | return &i, err |
| 368 | 370 | } |
| 369 | 371 | |
| 372 | +// UpdateIssue patches an issue's title and/or description in one call, with the | |
| 373 | +// same nil-means-untouched rule as UpdateMergeRequest. The issue's author or any | |
| 374 | +// write-access user may do it. | |
| 375 | +func (c *Client) UpdateIssue(ctx context.Context, owner, repo string, number int, in IssuePatch) (*IssueDetail, error) { | |
| 376 | + var i IssueDetail | |
| 377 | + err := c.do(ctx, Request{ | |
| 378 | + Method: http.MethodPatch, | |
| 379 | + Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number), | |
| 380 | + Body: in, | |
| 381 | + }, &i) | |
| 382 | + return &i, err | |
| 383 | +} | |
| 384 | + | |
| 370 | 385 | // SetIssueState closes ("closed") or reopens ("open") an issue. |
| 371 | 386 | func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*IssueDetail, error) { |
| 372 | 387 | var i IssueDetail |
| @@ -207,14 +207,16 @@ func lineCommentsPath(owner, repo string, number int) string { | |||
| 207 | return "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/line-comments" | 207 | return "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/line-comments" |
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | -// UpdateMergeRequestBody replaces a merge request's description. The MR's author | 210 | +// UpdateMergeRequest patches a merge request's title and/or description in one |
| 211 | -// or any write-access user may do it. | 211 | +// call. Fields left nil in the patch are not sent, so the server keeps them; a |
| 212 | -func (c *Client) UpdateMergeRequestBody(ctx context.Context, owner, repo string, number int, body string) (*MergeRequest, error) { | 212 | +// patch with neither field set is refused by the server as 400 empty_update. |
| 213 | +// The MR's author or any write-access user may do it. | ||
| 214 | +func (c *Client) UpdateMergeRequest(ctx context.Context, owner, repo string, number int, in MergeRequestPatch) (*MergeRequest, error) { | ||
| 213 | var mr MergeRequest | 215 | var mr MergeRequest |
| 214 | err := c.do(ctx, Request{ | 216 | err := c.do(ctx, Request{ |
| 215 | - Method: http.MethodPost, | 217 | + Method: http.MethodPatch, |
| 216 | - Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/body", | 218 | + Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number), |
| 217 | - Body: map[string]string{"body": body}, | 219 | + Body: in, |
| 218 | }, &mr) | 220 | }, &mr) |
| 219 | return &mr, err | 221 | return &mr, err |
| 220 | } | 222 | } |
| @@ -367,6 +369,19 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo, title, body strin | |||
| 367 | return &i, err | 369 | return &i, err |
| 368 | } | 370 | } |
| 369 | 371 | ||
| 372 | +// UpdateIssue patches an issue's title and/or description in one call, with the | ||
| 373 | +// same nil-means-untouched rule as UpdateMergeRequest. The issue's author or any | ||
| 374 | +// write-access user may do it. | ||
| 375 | +func (c *Client) UpdateIssue(ctx context.Context, owner, repo string, number int, in IssuePatch) (*IssueDetail, error) { | ||
| 376 | + var i IssueDetail | ||
| 377 | + err := c.do(ctx, Request{ | ||
| 378 | + Method: http.MethodPatch, | ||
| 379 | + Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number), | ||
| 380 | + Body: in, | ||
| 381 | + }, &i) | ||
| 382 | + return &i, err | ||
| 383 | +} | ||
| 384 | + | ||
| 370 | // SetIssueState closes ("closed") or reopens ("open") an issue. | 385 | // SetIssueState closes ("closed") or reopens ("open") an issue. |
| 371 | func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*IssueDetail, error) { | 386 | func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*IssueDetail, error) { |
| 372 | var i IssueDetail | 387 | var i IssueDetail |
added
internal/api/endpoints_patch_test.go +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// TestUpdateMergeRequestAndIssuePatchWire pins the wire contract of the two PATCH | |
| 11 | +// helpers: the method, the path, and — the load-bearing part — that a field the | |
| 12 | +// caller left nil is ABSENT from the JSON rather than sent as "". Sending an empty | |
| 13 | +// title would trip the API's title_required; sending an empty body would blank a | |
| 14 | +// description nobody asked to blank. | |
| 15 | +func TestUpdateMergeRequestAndIssuePatchWire(t *testing.T) { | |
| 16 | + srv, calls, responses := newIssuesServer(t) | |
| 17 | + responses["PATCH /api/v1/repos/o/r/merge-requests/12"] = `{"number":12,"title":"corrected","body":"kept","state":"open"}` | |
| 18 | + responses["PATCH /api/v1/repos/o/r/issues/7"] = `{"number":7,"title":"kept","body":"corrected","state":"open"}` | |
| 19 | + c := New(srv.URL, "rickub_pat_x") | |
| 20 | + ctx := context.Background() | |
| 21 | + | |
| 22 | + title := "corrected" | |
| 23 | + body := "new body" | |
| 24 | + | |
| 25 | + // Title only: the JSON must carry "title" and NOT "body". | |
| 26 | + mr, err := c.UpdateMergeRequest(ctx, "o", "r", 12, MergeRequestPatch{Title: &title}) | |
| 27 | + if err != nil || mr.Title != "corrected" { | |
| 28 | + t.Fatalf("UpdateMergeRequest: %+v err=%v", mr, err) | |
| 29 | + } | |
| 30 | + last := (*calls)[len(*calls)-1] | |
| 31 | + if last.Method != "PATCH" || last.Path != "/api/v1/repos/o/r/merge-requests/12" { | |
| 32 | + t.Errorf("wrong route: %s %s", last.Method, last.Path) | |
| 33 | + } | |
| 34 | + if !strings.Contains(last.Body, `"title":"corrected"`) { | |
| 35 | + t.Errorf("title missing from payload: %s", last.Body) | |
| 36 | + } | |
| 37 | + if strings.Contains(last.Body, `"body"`) { | |
| 38 | + t.Errorf("body must not appear when it was not set: %s", last.Body) | |
| 39 | + } | |
| 40 | + | |
| 41 | + // Body only: the mirror image. | |
| 42 | + iss, err := c.UpdateIssue(ctx, "o", "r", 7, IssuePatch{Body: &body}) | |
| 43 | + if err != nil || iss.Body != "corrected" { | |
| 44 | + t.Fatalf("UpdateIssue: %+v err=%v", iss, err) | |
| 45 | + } | |
| 46 | + last = (*calls)[len(*calls)-1] | |
| 47 | + if last.Method != "PATCH" || last.Path != "/api/v1/repos/o/r/issues/7" { | |
| 48 | + t.Errorf("wrong route: %s %s", last.Method, last.Path) | |
| 49 | + } | |
| 50 | + if !strings.Contains(last.Body, `"body":"new body"`) { | |
| 51 | + t.Errorf("body missing from payload: %s", last.Body) | |
| 52 | + } | |
| 53 | + if strings.Contains(last.Body, `"title"`) { | |
| 54 | + t.Errorf("title must not appear when it was not set: %s", last.Body) | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Both: one PATCH carries both fields. | |
| 58 | + if _, err := c.UpdateMergeRequest(ctx, "o", "r", 12, MergeRequestPatch{Title: &title, Body: &body}); err != nil { | |
| 59 | + t.Fatalf("UpdateMergeRequest both: %v", err) | |
| 60 | + } | |
| 61 | + last = (*calls)[len(*calls)-1] | |
| 62 | + if !strings.Contains(last.Body, `"title":"corrected"`) || !strings.Contains(last.Body, `"body":"new body"`) { | |
| 63 | + t.Errorf("both fields expected in one payload: %s", last.Body) | |
| 64 | + } | |
| 65 | + | |
| 66 | + // An empty string is a deliberate value, not an omission: a caller that | |
| 67 | + // really passes "" must see it on the wire (the server then answers | |
| 68 | + // title_required). This guards the pointer semantics from being "fixed" | |
| 69 | + // into omitempty-on-value. | |
| 70 | + empty := "" | |
| 71 | + _, _ = c.UpdateIssue(ctx, "o", "r", 7, IssuePatch{Body: &empty}) | |
| 72 | + last = (*calls)[len(*calls)-1] | |
| 73 | + if !strings.Contains(last.Body, `"body":""`) { | |
| 74 | + t.Errorf("an explicit empty body must be sent, not dropped: %s", last.Body) | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +// TestUpdateSurfacesAPIError checks the server's uniform 404 (not the author, no | |
| 79 | +// write access) arrives as a clean *APIError rather than something opaque. | |
| 80 | +func TestUpdateSurfacesAPIError(t *testing.T) { | |
| 81 | + srv, _, _ := newIssuesServer(t) // every unscripted route answers 404 not_found | |
| 82 | + c := New(srv.URL, "rickub_pat_x") | |
| 83 | + title := "nope" | |
| 84 | + _, err := c.UpdateMergeRequest(context.Background(), "o", "r", 99, MergeRequestPatch{Title: &title}) | |
| 85 | + var apiErr *APIError | |
| 86 | + if err == nil || !errors.As(err, &apiErr) || apiErr.Code != "not_found" { | |
| 87 | + t.Fatalf("expected an *APIError not_found, got %v", err) | |
| 88 | + } | |
| 89 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// TestUpdateMergeRequestAndIssuePatchWire pins the wire contract of the two PATCH | ||
| 11 | +// helpers: the method, the path, and — the load-bearing part — that a field the | ||
| 12 | +// caller left nil is ABSENT from the JSON rather than sent as "". Sending an empty | ||
| 13 | +// title would trip the API's title_required; sending an empty body would blank a | ||
| 14 | +// description nobody asked to blank. | ||
| 15 | +func TestUpdateMergeRequestAndIssuePatchWire(t *testing.T) { | ||
| 16 | + srv, calls, responses := newIssuesServer(t) | ||
| 17 | + responses["PATCH /api/v1/repos/o/r/merge-requests/12"] = `{"number":12,"title":"corrected","body":"kept","state":"open"}` | ||
| 18 | + responses["PATCH /api/v1/repos/o/r/issues/7"] = `{"number":7,"title":"kept","body":"corrected","state":"open"}` | ||
| 19 | + c := New(srv.URL, "rickub_pat_x") | ||
| 20 | + ctx := context.Background() | ||
| 21 | + | ||
| 22 | + title := "corrected" | ||
| 23 | + body := "new body" | ||
| 24 | + | ||
| 25 | + // Title only: the JSON must carry "title" and NOT "body". | ||
| 26 | + mr, err := c.UpdateMergeRequest(ctx, "o", "r", 12, MergeRequestPatch{Title: &title}) | ||
| 27 | + if err != nil || mr.Title != "corrected" { | ||
| 28 | + t.Fatalf("UpdateMergeRequest: %+v err=%v", mr, err) | ||
| 29 | + } | ||
| 30 | + last := (*calls)[len(*calls)-1] | ||
| 31 | + if last.Method != "PATCH" || last.Path != "/api/v1/repos/o/r/merge-requests/12" { | ||
| 32 | + t.Errorf("wrong route: %s %s", last.Method, last.Path) | ||
| 33 | + } | ||
| 34 | + if !strings.Contains(last.Body, `"title":"corrected"`) { | ||
| 35 | + t.Errorf("title missing from payload: %s", last.Body) | ||
| 36 | + } | ||
| 37 | + if strings.Contains(last.Body, `"body"`) { | ||
| 38 | + t.Errorf("body must not appear when it was not set: %s", last.Body) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + // Body only: the mirror image. | ||
| 42 | + iss, err := c.UpdateIssue(ctx, "o", "r", 7, IssuePatch{Body: &body}) | ||
| 43 | + if err != nil || iss.Body != "corrected" { | ||
| 44 | + t.Fatalf("UpdateIssue: %+v err=%v", iss, err) | ||
| 45 | + } | ||
| 46 | + last = (*calls)[len(*calls)-1] | ||
| 47 | + if last.Method != "PATCH" || last.Path != "/api/v1/repos/o/r/issues/7" { | ||
| 48 | + t.Errorf("wrong route: %s %s", last.Method, last.Path) | ||
| 49 | + } | ||
| 50 | + if !strings.Contains(last.Body, `"body":"new body"`) { | ||
| 51 | + t.Errorf("body missing from payload: %s", last.Body) | ||
| 52 | + } | ||
| 53 | + if strings.Contains(last.Body, `"title"`) { | ||
| 54 | + t.Errorf("title must not appear when it was not set: %s", last.Body) | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + // Both: one PATCH carries both fields. | ||
| 58 | + if _, err := c.UpdateMergeRequest(ctx, "o", "r", 12, MergeRequestPatch{Title: &title, Body: &body}); err != nil { | ||
| 59 | + t.Fatalf("UpdateMergeRequest both: %v", err) | ||
| 60 | + } | ||
| 61 | + last = (*calls)[len(*calls)-1] | ||
| 62 | + if !strings.Contains(last.Body, `"title":"corrected"`) || !strings.Contains(last.Body, `"body":"new body"`) { | ||
| 63 | + t.Errorf("both fields expected in one payload: %s", last.Body) | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + // An empty string is a deliberate value, not an omission: a caller that | ||
| 67 | + // really passes "" must see it on the wire (the server then answers | ||
| 68 | + // title_required). This guards the pointer semantics from being "fixed" | ||
| 69 | + // into omitempty-on-value. | ||
| 70 | + empty := "" | ||
| 71 | + _, _ = c.UpdateIssue(ctx, "o", "r", 7, IssuePatch{Body: &empty}) | ||
| 72 | + last = (*calls)[len(*calls)-1] | ||
| 73 | + if !strings.Contains(last.Body, `"body":""`) { | ||
| 74 | + t.Errorf("an explicit empty body must be sent, not dropped: %s", last.Body) | ||
| 75 | + } | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +// TestUpdateSurfacesAPIError checks the server's uniform 404 (not the author, no | ||
| 79 | +// write access) arrives as a clean *APIError rather than something opaque. | ||
| 80 | +func TestUpdateSurfacesAPIError(t *testing.T) { | ||
| 81 | + srv, _, _ := newIssuesServer(t) // every unscripted route answers 404 not_found | ||
| 82 | + c := New(srv.URL, "rickub_pat_x") | ||
| 83 | + title := "nope" | ||
| 84 | + _, err := c.UpdateMergeRequest(context.Background(), "o", "r", 99, MergeRequestPatch{Title: &title}) | ||
| 85 | + var apiErr *APIError | ||
| 86 | + if err == nil || !errors.As(err, &apiErr) || apiErr.Code != "not_found" { | ||
| 87 | + t.Fatalf("expected an *APIError not_found, got %v", err) | ||
| 88 | + } | ||
| 89 | +} | ||
modified
internal/api/models.go +20 -0 | @@ -91,6 +91,26 @@ type MergeRequestCreate struct { | ||
| 91 | 91 | Draft *bool `json:"draft,omitempty"` |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | +// MergeRequestPatch is the PATCH-a-merge-request request body (title and/or | |
| 95 | +// description). Both fields are POINTERS, and that is the load-bearing detail: | |
| 96 | +// a field the caller did not set is absent from the JSON entirely, so the server | |
| 97 | +// leaves it as it was. Sending "" instead would be a *change* — an empty title | |
| 98 | +// trips the API's title_required, and an empty body would blank a description | |
| 99 | +// the user never meant to touch. | |
| 100 | +// | |
| 101 | +// The server refuses a patch carrying neither field (400 empty_update); the CLI | |
| 102 | +// refuses it earlier, before a request is made. | |
| 103 | +type MergeRequestPatch struct { | |
| 104 | + Title *string `json:"title,omitempty"` | |
| 105 | + Body *string `json:"body,omitempty"` | |
| 106 | +} | |
| 107 | + | |
| 108 | +// IssuePatch is MergeRequestPatch's twin for an issue, with the same rules. | |
| 109 | +type IssuePatch struct { | |
| 110 | + Title *string `json:"title,omitempty"` | |
| 111 | + Body *string `json:"body,omitempty"` | |
| 112 | +} | |
| 113 | + | |
| 94 | 114 | // Comment is a conversation comment on an MR or an issue. |
| 95 | 115 | // |
| 96 | 116 | // ID is what makes it addressable — `mr delete-comment` / `issue delete-comment` |
| @@ -91,6 +91,26 @@ type MergeRequestCreate struct { | |||
| 91 | Draft *bool `json:"draft,omitempty"` | 91 | Draft *bool `json:"draft,omitempty"` |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | +// MergeRequestPatch is the PATCH-a-merge-request request body (title and/or | ||
| 95 | +// description). Both fields are POINTERS, and that is the load-bearing detail: | ||
| 96 | +// a field the caller did not set is absent from the JSON entirely, so the server | ||
| 97 | +// leaves it as it was. Sending "" instead would be a *change* — an empty title | ||
| 98 | +// trips the API's title_required, and an empty body would blank a description | ||
| 99 | +// the user never meant to touch. | ||
| 100 | +// | ||
| 101 | +// The server refuses a patch carrying neither field (400 empty_update); the CLI | ||
| 102 | +// refuses it earlier, before a request is made. | ||
| 103 | +type MergeRequestPatch struct { | ||
| 104 | + Title *string `json:"title,omitempty"` | ||
| 105 | + Body *string `json:"body,omitempty"` | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// IssuePatch is MergeRequestPatch's twin for an issue, with the same rules. | ||
| 109 | +type IssuePatch struct { | ||
| 110 | + Title *string `json:"title,omitempty"` | ||
| 111 | + Body *string `json:"body,omitempty"` | ||
| 112 | +} | ||
| 113 | + | ||
| 94 | // Comment is a conversation comment on an MR or an issue. | 114 | // Comment is a conversation comment on an MR or an issue. |
| 95 | // | 115 | // |
| 96 | // ID is what makes it addressable — `mr delete-comment` / `issue delete-comment` | 116 | // ID is what makes it addressable — `mr delete-comment` / `issue delete-comment` |