rickub/clipublic Fork
⑂ main
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

repo rename and transfer: port the CLI half from 4a5d5d2 (#10) #3

Merged@ogirardot wants to merge feat/10-repo-rename-transfer into main
Merged as a97c085d5a4f.
5 files changed
modified README.md +4 -0
@@ -146,6 +146,10 @@ rickub repo list [--user H | --org H] [--page N] [--per-page N]
146146 rickub repo create <name> [--org H] [--public|--private] [-d desc]
147147 rickub repo view <owner/repo>
148148 rickub repo edit <owner/repo> [--visibility public|private] [-d desc] [--default-branch B]
149+rickub repo rename <owner/repo> <new-name> # needs ADMIN; breaks every existing clone URL
150+rickub repo transfer <owner/repo> <new-owner> [--yes]
151+ # needs ADMIN here and create rights there;
152+ # confirms first — you may not be able to move it back
149153 rickub repo delete <owner/repo> [--yes]
150154 rickub repo clone <owner/repo> [dir] [--protocol ssh|https] [-- git-args…]
151155 rickub repo files <owner/repo> [path] [--ref R]
@@ -146,6 +146,10 @@ rickub repo list [--user H | --org H] [--page N] [--per-page N]
146 rickub repo create <name> [--org H] [--public|--private] [-d desc]146 rickub repo create <name> [--org H] [--public|--private] [-d desc]
147 rickub repo view <owner/repo>147 rickub repo view <owner/repo>
148 rickub repo edit <owner/repo> [--visibility public|private] [-d desc] [--default-branch B]148 rickub repo edit <owner/repo> [--visibility public|private] [-d desc] [--default-branch B]
149+rickub repo rename <owner/repo> <new-name> # needs ADMIN; breaks every existing clone URL
150+rickub repo transfer <owner/repo> <new-owner> [--yes]
151+ # needs ADMIN here and create rights there;
152+ # confirms first — you may not be able to move it back
149 rickub repo delete <owner/repo> [--yes]153 rickub repo delete <owner/repo> [--yes]
150 rickub repo clone <owner/repo> [dir] [--protocol ssh|https] [-- git-args…]154 rickub repo clone <owner/repo> [dir] [--protocol ssh|https] [-- git-args…]
151 rickub repo files <owner/repo> [path] [--ref R]155 rickub repo files <owner/repo> [path] [--ref R]
modified cmd/repo.go +85 -1
@@ -27,6 +27,7 @@ var (
2727 repoEditDesc string
2828 repoEditBranch string
2929 repoDeleteYes bool
30+ repoTransferYes bool
3031 repoContentsRef string
3132 pageFlag int
3233 perPageFlag int
@@ -84,6 +85,38 @@ By default repositories are private; pass --public to create a public one.`,
8485 editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description")
8586 editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch")
8687
88+ // rename
89+ renameCmd := &cobra.Command{
90+ Use: "rename <owner/repo> <new-name>",
91+ Short: "Rename a repository",
92+ Long: `Rename a repository in place.
93+
94+Every clone URL, mirror and CI checkout pointing at the old name stops
95+working, so this is a move, not an edit. Renaming to a different
96+capitalisation of the same name is refused (rename via an intermediate
97+name instead).`,
98+ Args: cobra.ExactArgs(2),
99+ RunE: runRepoRename,
100+ }
101+
102+ // transfer
103+ transferCmd := &cobra.Command{
104+ Use: "transfer <owner/repo> <new-owner>",
105+ Short: "Transfer a repository to another user or org",
106+ Long: `Transfer a repository to another namespace, keeping its name.
107+
108+Requires ADMIN on the repository, a verified email address, and the right
109+to create repositories under the destination. A private repository counts
110+against the destination's plan's private-repo limit.
111+
112+You are asked to confirm first: a transfer is not reversible by the person
113+who made it, since you may hold no ADMIN in the destination namespace
114+afterwards. Pass --yes to skip the prompt in a script.`,
115+ Args: cobra.ExactArgs(2),
116+ RunE: runRepoTransfer,
117+ }
118+ transferCmd.Flags().BoolVar(&repoTransferYes, "yes", false, "skip the confirmation prompt")
119+
87120 // delete
88121 deleteCmd := &cobra.Command{
89122 Use: "delete <owner/repo>",
@@ -143,7 +176,7 @@ Extra arguments after -- are passed through to git clone.`,
143176 RunE: runRepoCompare,
144177 }
145178
146- repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())
179+ repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, renameCmd, transferCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())
147180 rootCmd.AddCommand(repoCmd)
148181 }
149182
@@ -287,6 +320,57 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
287320 return nil
288321 }
289322
323+func runRepoRename(cmd *cobra.Command, args []string) error {
324+ client, err := newClient()
325+ if err != nil {
326+ return err
327+ }
328+ owner, repo, err := parseOwnerRepo(args[0])
329+ if err != nil {
330+ return err
331+ }
332+ r, err := client.RenameRepo(cmd.Context(), owner, repo, args[1])
333+ if err != nil {
334+ return err
335+ }
336+ if flagJSON {
337+ return printJSON(cmd.OutOrStdout(), r)
338+ }
339+ fmt.Fprintf(cmd.OutOrStdout(), "Renamed to %s\n", clean(r.FullName))
340+ return nil
341+}
342+
343+func runRepoTransfer(cmd *cobra.Command, args []string) error {
344+ client, err := newClient()
345+ if err != nil {
346+ return err
347+ }
348+ owner, repo, err := parseOwnerRepo(args[0])
349+ if err != nil {
350+ return err
351+ }
352+ // A transfer is not reversible by the person who made it: once the repo is in
353+ // the destination namespace the actor may hold no ADMIN there, and transferring
354+ // it back answers forbidden_owner. That is a stronger one-way door than
355+ // `repo delete`, which does prompt — so this one prompts too, with the same
356+ // --yes escape for scripts.
357+ if !repoTransferYes {
358+ ans := prompt(cmd, fmt.Sprintf("Transfer %s/%s to %s? You may not be able to move it back. Type the repo name to confirm: ", owner, repo, args[1]))
359+ if ans != repo {
360+ return fmt.Errorf("confirmation did not match; aborted")
361+ }
362+ }
363+ r, err := client.TransferRepo(cmd.Context(), owner, repo, args[1])
364+ if err != nil {
365+ return err
366+ }
367+ if flagJSON {
368+ return printJSON(cmd.OutOrStdout(), r)
369+ }
370+ fmt.Fprintf(cmd.OutOrStdout(), "Transferred to %s\n", clean(r.FullName))
371+ return nil
372+}
373+
290374 func runRepoDelete(cmd *cobra.Command, args []string) error {
291375 client, err := newClient()
292376 if err != nil {
@@ -27,6 +27,7 @@ var (
27 repoEditDesc string27 repoEditDesc string
28 repoEditBranch string28 repoEditBranch string
29 repoDeleteYes bool29 repoDeleteYes bool
30+ repoTransferYes bool
30 repoContentsRef string31 repoContentsRef string
31 pageFlag int32 pageFlag int
32 perPageFlag int33 perPageFlag int
@@ -84,6 +85,38 @@ By default repositories are private; pass --public to create a public one.`,
84 editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description")85 editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description")
85 editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch")86 editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch")
86 87
88+ // rename
89+ renameCmd := &cobra.Command{
90+ Use: "rename <owner/repo> <new-name>",
91+ Short: "Rename a repository",
92+ Long: `Rename a repository in place.
93+
94+Every clone URL, mirror and CI checkout pointing at the old name stops
95+working, so this is a move, not an edit. Renaming to a different
96+capitalisation of the same name is refused (rename via an intermediate
97+name instead).`,
98+ Args: cobra.ExactArgs(2),
99+ RunE: runRepoRename,
100+ }
101+
102+ // transfer
103+ transferCmd := &cobra.Command{
104+ Use: "transfer <owner/repo> <new-owner>",
105+ Short: "Transfer a repository to another user or org",
106+ Long: `Transfer a repository to another namespace, keeping its name.
107+
108+Requires ADMIN on the repository, a verified email address, and the right
109+to create repositories under the destination. A private repository counts
110+against the destination's plan's private-repo limit.
111+
112+You are asked to confirm first: a transfer is not reversible by the person
113+who made it, since you may hold no ADMIN in the destination namespace
114+afterwards. Pass --yes to skip the prompt in a script.`,
115+ Args: cobra.ExactArgs(2),
116+ RunE: runRepoTransfer,
117+ }
118+ transferCmd.Flags().BoolVar(&repoTransferYes, "yes", false, "skip the confirmation prompt")
119+
87 // delete120 // delete
88 deleteCmd := &cobra.Command{121 deleteCmd := &cobra.Command{
89 Use: "delete <owner/repo>",122 Use: "delete <owner/repo>",
@@ -143,7 +176,7 @@ Extra arguments after -- are passed through to git clone.`,
143 RunE: runRepoCompare,176 RunE: runRepoCompare,
144 }177 }
145 178
146- repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())179+ repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, renameCmd, transferCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())
147 rootCmd.AddCommand(repoCmd)180 rootCmd.AddCommand(repoCmd)
148 }181 }
149 182
@@ -287,6 +320,57 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
287 return nil320 return nil
288 }321 }
289 322
323+func runRepoRename(cmd *cobra.Command, args []string) error {
324+ client, err := newClient()
325+ if err != nil {
326+ return err
327+ }
328+ owner, repo, err := parseOwnerRepo(args[0])
329+ if err != nil {
330+ return err
331+ }
332+ r, err := client.RenameRepo(cmd.Context(), owner, repo, args[1])
333+ if err != nil {
334+ return err
335+ }
336+ if flagJSON {
337+ return printJSON(cmd.OutOrStdout(), r)
338+ }
339+ fmt.Fprintf(cmd.OutOrStdout(), "Renamed to %s\n", clean(r.FullName))
340+ return nil
341+}
342+
343+func runRepoTransfer(cmd *cobra.Command, args []string) error {
344+ client, err := newClient()
345+ if err != nil {
346+ return err
347+ }
348+ owner, repo, err := parseOwnerRepo(args[0])
349+ if err != nil {
350+ return err
351+ }
352+ // A transfer is not reversible by the person who made it: once the repo is in
353+ // the destination namespace the actor may hold no ADMIN there, and transferring
354+ // it back answers forbidden_owner. That is a stronger one-way door than
355+ // `repo delete`, which does prompt — so this one prompts too, with the same
356+ // --yes escape for scripts.
357+ if !repoTransferYes {
358+ ans := prompt(cmd, fmt.Sprintf("Transfer %s/%s to %s? You may not be able to move it back. Type the repo name to confirm: ", owner, repo, args[1]))
359+ if ans != repo {
360+ return fmt.Errorf("confirmation did not match; aborted")
361+ }
362+ }
363+ r, err := client.TransferRepo(cmd.Context(), owner, repo, args[1])
364+ if err != nil {
365+ return err
366+ }
367+ if flagJSON {
368+ return printJSON(cmd.OutOrStdout(), r)
369+ }
370+ fmt.Fprintf(cmd.OutOrStdout(), "Transferred to %s\n", clean(r.FullName))
371+ return nil
372+}
373+
290 func runRepoDelete(cmd *cobra.Command, args []string) error {374 func runRepoDelete(cmd *cobra.Command, args []string) error {
291 client, err := newClient()375 client, err := newClient()
292 if err != nil {376 if err != nil {
modified cmd/repo_test.go +194 -0
@@ -3,6 +3,7 @@ package cmd
33 import (
44 "bytes"
55 "encoding/json"
6+ "io"
67 "net/http"
78 "net/http/httptest"
89 "strings"
@@ -21,6 +22,7 @@ func execute(t *testing.T, args ...string) (string, string, error) {
2122 flagHost = ""
2223 flagToken = ""
2324 repoListUser = ""
25+ repoTransferYes = false
2426 repoListOrg = ""
2527 pageFlag = 0
2628 perPageFlag = 0
@@ -164,6 +166,198 @@ func TestRepoViewNotFoundError(t *testing.T) {
164166 }
165167 }
166168
169+// repoMoveServer answers the rename and transfer endpoints and records what was
170+// called, so a test can assert which surface the CLI chose and what it sent.
171+func repoMoveServer(t *testing.T, seen *captured) *httptest.Server {
172+ t.Helper()
173+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174+ seen.Method, seen.Path = r.Method, r.URL.Path
175+ if b, _ := io.ReadAll(r.Body); len(b) > 0 {
176+ seen.Body = map[string]any{}
177+ _ = json.Unmarshal(b, &seen.Body)
178+ }
179+ switch r.URL.Path {
180+ case "/api/v1/repos/rick/portal/rename":
181+ _ = json.NewEncoder(w).Encode(map[string]any{
182+ "owner": "rick", "name": "portal-gun", "full_name": "rick/portal-gun", "visibility": "public",
183+ })
184+ case "/api/v1/repos/rick/portal/transfer":
185+ _ = json.NewEncoder(w).Encode(map[string]any{
186+ "owner": "acme", "name": "portal", "full_name": "acme/portal", "visibility": "public",
187+ })
188+ default:
189+ w.WriteHeader(http.StatusNotFound)
190+ _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}})
191+ }
192+ }))
193+}
194+
195+func TestRepoRenameCallsMoveEndpoint(t *testing.T) {
196+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
197+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
198+ seen := &captured{}
199+ srv := repoMoveServer(t, seen)
200+ defer srv.Close()
201+ t.Setenv("RICKUB_HOST", srv.URL)
202+
203+ out, _, err := execute(t, "repo", "rename", "rick/portal", "portal-gun")
204+ if err != nil {
205+ t.Fatalf("execute: %v", err)
206+ }
207+ // The rename endpoint must be the one called, with the new name in the body.
208+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/rename" {
209+ t.Fatalf("called %s %s", seen.Method, seen.Path)
210+ }
211+ if seen.Body["name"] != "portal-gun" {
212+ t.Fatalf("body = %v", seen.Body)
213+ }
214+ if !strings.Contains(out, "Renamed to rick/portal-gun") {
215+ t.Errorf("output missing new full name:\n%s", out)
216+ }
217+}
218+
219+func TestRepoTransferCallsMoveEndpoint(t *testing.T) {
220+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
221+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
222+ seen := &captured{}
223+ srv := repoMoveServer(t, seen)
224+ defer srv.Close()
225+ t.Setenv("RICKUB_HOST", srv.URL)
226+
227+ // --yes: this test is about the wire call, not the confirmation, which
228+ // TestRepoTransferPromptsBeforeMoving covers.
229+ out, _, err := execute(t, "repo", "transfer", "rick/portal", "acme", "--yes")
230+ if err != nil {
231+ t.Fatalf("execute: %v", err)
232+ }
233+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/transfer" {
234+ t.Fatalf("called %s %s", seen.Method, seen.Path)
235+ }
236+ if seen.Body["new_owner"] != "acme" {
237+ t.Fatalf("body = %v", seen.Body)
238+ }
239+ if !strings.Contains(out, "Transferred to acme/portal") {
240+ t.Errorf("output missing new full name:\n%s", out)
241+ }
242+}
243+
244+func TestRepoMoveArgCount(t *testing.T) {
245+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
246+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
247+ seen := &captured{}
248+ srv := repoMoveServer(t, seen)
249+ defer srv.Close()
250+ t.Setenv("RICKUB_HOST", srv.URL)
251+
252+ // Both commands are argument-strict: no destination, no request. The token and
253+ // host are configured on purpose — without them `newClient()` would fail first
254+ // and this would pass even with the ExactArgs guard removed, which is what the
255+ // version ported from 4a5d5d2 did.
256+ for _, args := range [][]string{
257+ {"repo", "rename", "rick/portal"},
258+ {"repo", "transfer", "rick/portal"},
259+ } {
260+ _, _, err := execute(t, args...)
261+ if err == nil {
262+ t.Errorf("%v: a missing destination should fail arg parsing", args)
263+ continue
264+ }
265+ if !strings.Contains(err.Error(), "accepts 2 arg(s)") {
266+ t.Errorf("%v: want an arg-count error, got %v", args, err)
267+ }
268+ if seen.Path != "" {
269+ t.Errorf("%v: reached the server at %q", args, seen.Path)
270+ }
271+ }
272+}
273+
274+// TestRepoTransferPromptsBeforeMoving: a transfer is a one-way door for the person
275+// making it — after the move the actor may hold no ADMIN in the destination, so
276+// transferring back answers forbidden_owner. It confirms like `repo delete` does.
277+func TestRepoTransferPromptsBeforeMoving(t *testing.T) {
278+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
279+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
280+ seen := &captured{}
281+ srv := repoMoveServer(t, seen)
282+ defer srv.Close()
283+ t.Setenv("RICKUB_HOST", srv.URL)
284+
285+ // A wrong answer aborts, and nothing is sent.
286+ rootCmd.SetIn(strings.NewReader("nope\n"))
287+ _, _, err := execute(t, "repo", "transfer", "rick/portal", "acme")
288+ rootCmd.SetIn(nil)
289+ if err == nil {
290+ t.Fatal("a mistyped confirmation was accepted")
291+ }
292+ if seen.Path != "" {
293+ t.Fatalf("an unconfirmed transfer reached the server at %q", seen.Path)
294+ }
295+
296+ // Typing the repo name goes through.
297+ rootCmd.SetIn(strings.NewReader("portal\n"))
298+ out, _, err := execute(t, "repo", "transfer", "rick/portal", "acme")
299+ rootCmd.SetIn(nil)
300+ if err != nil {
301+ t.Fatalf("execute: %v", err)
302+ }
303+ if seen.Path != "/api/v1/repos/rick/portal/transfer" {
304+ t.Errorf("confirmed transfer did not reach the endpoint: %q", seen.Path)
305+ }
306+ if !strings.Contains(out, "Transferred to acme/portal") {
307+ t.Errorf("output missing new full name:\n%s", out)
308+ }
309+
310+ // --yes skips the prompt: scripts keep working without a tty.
311+ seen.Path = ""
312+ if _, _, err := execute(t, "repo", "transfer", "rick/portal", "acme", "--yes"); err != nil {
313+ t.Fatalf("--yes: %v", err)
314+ }
315+ if seen.Path != "/api/v1/repos/rick/portal/transfer" {
316+ t.Errorf("--yes did not reach the endpoint: %q", seen.Path)
317+ }
318+}
319+
320+// TestRepoRenameDoesNotPrompt: rename stays unprompted. It breaks clone URLs, but
321+// the actor keeps ADMIN and can rename straight back — a confirmation on every
322+// rename would be friction without a one-way door behind it.
323+func TestRepoRenameDoesNotPrompt(t *testing.T) {
324+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
325+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
326+ seen := &captured{}
327+ srv := repoMoveServer(t, seen)
328+ defer srv.Close()
329+ t.Setenv("RICKUB_HOST", srv.URL)
330+
331+ rootCmd.SetIn(strings.NewReader(""))
332+ _, _, err := execute(t, "repo", "rename", "rick/portal", "portal-gun")
333+ rootCmd.SetIn(nil)
334+ if err != nil {
335+ t.Fatalf("execute: %v", err)
336+ }
337+ if seen.Path != "/api/v1/repos/rick/portal/rename" {
338+ t.Errorf("rename should not need a confirmation: %q", seen.Path)
339+ }
340+}
341+
342+// TestRepoMoveRejectsABadSpec: the owner/repo argument goes through the shared
343+// parser, so a spec with no slash is refused before any request is made — the
344+// same rule `repo view` and `repo delete` follow.
345+func TestRepoMoveRejectsABadSpec(t *testing.T) {
346+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
347+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
348+ seen := &captured{}
349+ srv := repoMoveServer(t, seen)
350+ defer srv.Close()
351+ t.Setenv("RICKUB_HOST", srv.URL)
352+
353+ if _, _, err := execute(t, "repo", "rename", "portal", "portal-gun"); err == nil {
354+ t.Error("a spec without a slash should be refused")
355+ }
356+ if seen.Path != "" {
357+ t.Errorf("a bad spec reached the server at %q", seen.Path)
358+ }
359+}
360+
167361 func TestHumanTime(t *testing.T) {
168362 cases := map[string]string{
169363 "": "-", // empty -> dash
@@ -3,6 +3,7 @@ package cmd
3 import (3 import (
4 "bytes"4 "bytes"
5 "encoding/json"5 "encoding/json"
6+ "io"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
8 "strings"9 "strings"
@@ -21,6 +22,7 @@ func execute(t *testing.T, args ...string) (string, string, error) {
21 flagHost = ""22 flagHost = ""
22 flagToken = ""23 flagToken = ""
23 repoListUser = ""24 repoListUser = ""
25+ repoTransferYes = false
24 repoListOrg = ""26 repoListOrg = ""
25 pageFlag = 027 pageFlag = 0
26 perPageFlag = 028 perPageFlag = 0
@@ -164,6 +166,198 @@ func TestRepoViewNotFoundError(t *testing.T) {
164 }166 }
165 }167 }
166 168
169+// repoMoveServer answers the rename and transfer endpoints and records what was
170+// called, so a test can assert which surface the CLI chose and what it sent.
171+func repoMoveServer(t *testing.T, seen *captured) *httptest.Server {
172+ t.Helper()
173+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174+ seen.Method, seen.Path = r.Method, r.URL.Path
175+ if b, _ := io.ReadAll(r.Body); len(b) > 0 {
176+ seen.Body = map[string]any{}
177+ _ = json.Unmarshal(b, &seen.Body)
178+ }
179+ switch r.URL.Path {
180+ case "/api/v1/repos/rick/portal/rename":
181+ _ = json.NewEncoder(w).Encode(map[string]any{
182+ "owner": "rick", "name": "portal-gun", "full_name": "rick/portal-gun", "visibility": "public",
183+ })
184+ case "/api/v1/repos/rick/portal/transfer":
185+ _ = json.NewEncoder(w).Encode(map[string]any{
186+ "owner": "acme", "name": "portal", "full_name": "acme/portal", "visibility": "public",
187+ })
188+ default:
189+ w.WriteHeader(http.StatusNotFound)
190+ _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}})
191+ }
192+ }))
193+}
194+
195+func TestRepoRenameCallsMoveEndpoint(t *testing.T) {
196+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
197+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
198+ seen := &captured{}
199+ srv := repoMoveServer(t, seen)
200+ defer srv.Close()
201+ t.Setenv("RICKUB_HOST", srv.URL)
202+
203+ out, _, err := execute(t, "repo", "rename", "rick/portal", "portal-gun")
204+ if err != nil {
205+ t.Fatalf("execute: %v", err)
206+ }
207+ // The rename endpoint must be the one called, with the new name in the body.
208+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/rename" {
209+ t.Fatalf("called %s %s", seen.Method, seen.Path)
210+ }
211+ if seen.Body["name"] != "portal-gun" {
212+ t.Fatalf("body = %v", seen.Body)
213+ }
214+ if !strings.Contains(out, "Renamed to rick/portal-gun") {
215+ t.Errorf("output missing new full name:\n%s", out)
216+ }
217+}
218+
219+func TestRepoTransferCallsMoveEndpoint(t *testing.T) {
220+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
221+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
222+ seen := &captured{}
223+ srv := repoMoveServer(t, seen)
224+ defer srv.Close()
225+ t.Setenv("RICKUB_HOST", srv.URL)
226+
227+ // --yes: this test is about the wire call, not the confirmation, which
228+ // TestRepoTransferPromptsBeforeMoving covers.
229+ out, _, err := execute(t, "repo", "transfer", "rick/portal", "acme", "--yes")
230+ if err != nil {
231+ t.Fatalf("execute: %v", err)
232+ }
233+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/transfer" {
234+ t.Fatalf("called %s %s", seen.Method, seen.Path)
235+ }
236+ if seen.Body["new_owner"] != "acme" {
237+ t.Fatalf("body = %v", seen.Body)
238+ }
239+ if !strings.Contains(out, "Transferred to acme/portal") {
240+ t.Errorf("output missing new full name:\n%s", out)
241+ }
242+}
243+
244+func TestRepoMoveArgCount(t *testing.T) {
245+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
246+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
247+ seen := &captured{}
248+ srv := repoMoveServer(t, seen)
249+ defer srv.Close()
250+ t.Setenv("RICKUB_HOST", srv.URL)
251+
252+ // Both commands are argument-strict: no destination, no request. The token and
253+ // host are configured on purpose — without them `newClient()` would fail first
254+ // and this would pass even with the ExactArgs guard removed, which is what the
255+ // version ported from 4a5d5d2 did.
256+ for _, args := range [][]string{
257+ {"repo", "rename", "rick/portal"},
258+ {"repo", "transfer", "rick/portal"},
259+ } {
260+ _, _, err := execute(t, args...)
261+ if err == nil {
262+ t.Errorf("%v: a missing destination should fail arg parsing", args)
263+ continue
264+ }
265+ if !strings.Contains(err.Error(), "accepts 2 arg(s)") {
266+ t.Errorf("%v: want an arg-count error, got %v", args, err)
267+ }
268+ if seen.Path != "" {
269+ t.Errorf("%v: reached the server at %q", args, seen.Path)
270+ }
271+ }
272+}
273+
274+// TestRepoTransferPromptsBeforeMoving: a transfer is a one-way door for the person
275+// making it — after the move the actor may hold no ADMIN in the destination, so
276+// transferring back answers forbidden_owner. It confirms like `repo delete` does.
277+func TestRepoTransferPromptsBeforeMoving(t *testing.T) {
278+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
279+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
280+ seen := &captured{}
281+ srv := repoMoveServer(t, seen)
282+ defer srv.Close()
283+ t.Setenv("RICKUB_HOST", srv.URL)
284+
285+ // A wrong answer aborts, and nothing is sent.
286+ rootCmd.SetIn(strings.NewReader("nope\n"))
287+ _, _, err := execute(t, "repo", "transfer", "rick/portal", "acme")
288+ rootCmd.SetIn(nil)
289+ if err == nil {
290+ t.Fatal("a mistyped confirmation was accepted")
291+ }
292+ if seen.Path != "" {
293+ t.Fatalf("an unconfirmed transfer reached the server at %q", seen.Path)
294+ }
295+
296+ // Typing the repo name goes through.
297+ rootCmd.SetIn(strings.NewReader("portal\n"))
298+ out, _, err := execute(t, "repo", "transfer", "rick/portal", "acme")
299+ rootCmd.SetIn(nil)
300+ if err != nil {
301+ t.Fatalf("execute: %v", err)
302+ }
303+ if seen.Path != "/api/v1/repos/rick/portal/transfer" {
304+ t.Errorf("confirmed transfer did not reach the endpoint: %q", seen.Path)
305+ }
306+ if !strings.Contains(out, "Transferred to acme/portal") {
307+ t.Errorf("output missing new full name:\n%s", out)
308+ }
309+
310+ // --yes skips the prompt: scripts keep working without a tty.
311+ seen.Path = ""
312+ if _, _, err := execute(t, "repo", "transfer", "rick/portal", "acme", "--yes"); err != nil {
313+ t.Fatalf("--yes: %v", err)
314+ }
315+ if seen.Path != "/api/v1/repos/rick/portal/transfer" {
316+ t.Errorf("--yes did not reach the endpoint: %q", seen.Path)
317+ }
318+}
319+
320+// TestRepoRenameDoesNotPrompt: rename stays unprompted. It breaks clone URLs, but
321+// the actor keeps ADMIN and can rename straight back — a confirmation on every
322+// rename would be friction without a one-way door behind it.
323+func TestRepoRenameDoesNotPrompt(t *testing.T) {
324+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
325+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
326+ seen := &captured{}
327+ srv := repoMoveServer(t, seen)
328+ defer srv.Close()
329+ t.Setenv("RICKUB_HOST", srv.URL)
330+
331+ rootCmd.SetIn(strings.NewReader(""))
332+ _, _, err := execute(t, "repo", "rename", "rick/portal", "portal-gun")
333+ rootCmd.SetIn(nil)
334+ if err != nil {
335+ t.Fatalf("execute: %v", err)
336+ }
337+ if seen.Path != "/api/v1/repos/rick/portal/rename" {
338+ t.Errorf("rename should not need a confirmation: %q", seen.Path)
339+ }
340+}
341+
342+// TestRepoMoveRejectsABadSpec: the owner/repo argument goes through the shared
343+// parser, so a spec with no slash is refused before any request is made — the
344+// same rule `repo view` and `repo delete` follow.
345+func TestRepoMoveRejectsABadSpec(t *testing.T) {
346+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
347+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
348+ seen := &captured{}
349+ srv := repoMoveServer(t, seen)
350+ defer srv.Close()
351+ t.Setenv("RICKUB_HOST", srv.URL)
352+
353+ if _, _, err := execute(t, "repo", "rename", "portal", "portal-gun"); err == nil {
354+ t.Error("a spec without a slash should be refused")
355+ }
356+ if seen.Path != "" {
357+ t.Errorf("a bad spec reached the server at %q", seen.Path)
358+ }
359+}
360+
167 func TestHumanTime(t *testing.T) {361 func TestHumanTime(t *testing.T) {
168 cases := map[string]string{362 cases := map[string]string{
169 "": "-", // empty -> dash363 "": "-", // empty -> dash
modified internal/api/endpoints.go +15 -0
@@ -55,6 +55,21 @@ func (c *Client) UpdateRepo(ctx context.Context, owner, repo string, in RepoUpda
5555 return &r, err
5656 }
5757
58+// RenameRepo renames a repository. The response is the repo at its new name.
59+func (c *Client) RenameRepo(ctx context.Context, owner, repo, name string) (*Repo, error) {
60+ var r Repo
61+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/rename", Body: RepoRename{Name: name}}, &r)
62+ return &r, err
63+}
64+
65+// TransferRepo transfers a repository to another owner (user or org). The
66+// response is the repo at its new location.
67+func (c *Client) TransferRepo(ctx context.Context, owner, repo, newOwner string) (*Repo, error) {
68+ var r Repo
69+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/transfer", Body: RepoTransfer{NewOwner: newOwner}}, &r)
70+ return &r, err
71+}
72+
5873 // DeleteRepo deletes a repository.
5974 func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
6075 return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, nil)
@@ -55,6 +55,21 @@ func (c *Client) UpdateRepo(ctx context.Context, owner, repo string, in RepoUpda
55 return &r, err55 return &r, err
56 }56 }
57 57
58+// RenameRepo renames a repository. The response is the repo at its new name.
59+func (c *Client) RenameRepo(ctx context.Context, owner, repo, name string) (*Repo, error) {
60+ var r Repo
61+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/rename", Body: RepoRename{Name: name}}, &r)
62+ return &r, err
63+}
64+
65+// TransferRepo transfers a repository to another owner (user or org). The
66+// response is the repo at its new location.
67+func (c *Client) TransferRepo(ctx context.Context, owner, repo, newOwner string) (*Repo, error) {
68+ var r Repo
69+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/transfer", Body: RepoTransfer{NewOwner: newOwner}}, &r)
70+ return &r, err
71+}
72+
58 // DeleteRepo deletes a repository.73 // DeleteRepo deletes a repository.
59 func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {74 func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
60 return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, nil)75 return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, nil)
modified internal/api/models.go +10 -0
@@ -43,6 +43,16 @@ type RepoUpdate struct {
4343 DefaultBranch *string `json:"default_branch,omitempty"`
4444 }
4545
46+// RepoRename is the rename-repo request body.
47+type RepoRename struct {
48+ Name string `json:"name"`
49+}
50+
51+// RepoTransfer is the transfer-repo request body.
52+type RepoTransfer struct {
53+ NewOwner string `json:"new_owner"`
54+}
55+
4656 // Collaborator is a repo collaborator grant.
4757 type Collaborator struct {
4858 Handle string `json:"handle"`
@@ -43,6 +43,16 @@ type RepoUpdate struct {
43 DefaultBranch *string `json:"default_branch,omitempty"`43 DefaultBranch *string `json:"default_branch,omitempty"`
44 }44 }
45 45
46+// RepoRename is the rename-repo request body.
47+type RepoRename struct {
48+ Name string `json:"name"`
49+}
50+
51+// RepoTransfer is the transfer-repo request body.
52+type RepoTransfer struct {
53+ NewOwner string `json:"new_owner"`
54+}
55+
46 // Collaborator is a repo collaborator grant.56 // Collaborator is a repo collaborator grant.
47 type Collaborator struct {57 type Collaborator struct {
48 Handle string `json:"handle"`58 Handle string `json:"handle"`