rickub/clipublic Fork
⑂ aada7be
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

feat: port `repo rename` and `repo transfer` from monorepo 4a5d5d2 (#10)Unverified

rickub/rickub!120 shipped repo rename and transfer across both halves. When it
landed, `cli/` had already moved to this repository, so the merge hit five
modify/delete conflicts — all under `cli/` — and they were resolved by dropping
the CLI half. The API has been live and uncallable from the terminal ever since.

Ported, not rewritten, from `4a5d5d2` as the issue asks — including
`cli/cmd/repo_test.go`'s cases, which came with the original work:

- `internal/api`: `RenameRepo` / `TransferRepo` over POST `.../rename` and
  `.../transfer`, with `RepoRename{name}` / `RepoTransfer{new_owner}` — checked
  against the live handlers' `repoRenameBody` / `repoTransferBody`.
- `cmd/repo.go`: `repo rename <owner/repo> <new-name>` and
  `repo transfer <owner/repo> <new-owner>`, both `ExactArgs(2)`, both routing the
  spec through the shared `parseOwnerRepo`.

Two deliberate divergences from the ported commit, both because this repo moved on
after the split: the printed `FullName` goes through `clean()` like every other
server-provided string (b7e1d1f), and there is one more test — a spec without a
slash must be refused before any request, the rule `repo view` and `repo delete`
already follow.

Verified against rickub.com end to end on a throwaway repo: rename, transfer into
an org, a rename in the new namespace, `--json`, the no-slash refusal, the
missing-argument refusal, and the server's `forbidden_owner` on a destination that
does not exist. The throwaway repo was deleted.

Closes #10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPPhRdTiUiiSvLjthotkT1
Olivier Girardot committed 2026-09-08T00:51:23+02:00 Browse files
aada7be parent: 1f3fca3
modified README.md +2 -0
@@ -146,6 +146,8 @@ 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> # needs ADMIN here and create rights there
149151 rickub repo delete <owner/repo> [--yes]
150152 rickub repo clone <owner/repo> [dir] [--protocol ssh|https] [-- git-args…]
151153 rickub repo files <owner/repo> [path] [--ref R]
@@ -146,6 +146,8 @@ 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> # needs ADMIN here and create rights there
149 rickub repo delete <owner/repo> [--yes]151 rickub repo delete <owner/repo> [--yes]
150 rickub repo clone <owner/repo> [dir] [--protocol ssh|https] [-- git-args…]152 rickub repo clone <owner/repo> [dir] [--protocol ssh|https] [-- git-args…]
151 rickub repo files <owner/repo> [path] [--ref R]153 rickub repo files <owner/repo> [path] [--ref R]
modified cmd/repo.go +68 -1
@@ -84,6 +84,33 @@ By default repositories are private; pass --public to create a public one.`,
8484 editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description")
8585 editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch")
8686
87+ // rename
88+ renameCmd := &cobra.Command{
89+ Use: "rename <owner/repo> <new-name>",
90+ Short: "Rename a repository",
91+ Long: `Rename a repository in place.
92+
93+Every clone URL, mirror and CI checkout pointing at the old name stops
94+working, so this is a move, not an edit. Renaming to a different
95+capitalisation of the same name is refused (rename via an intermediate
96+name instead).`,
97+ Args: cobra.ExactArgs(2),
98+ RunE: runRepoRename,
99+ }
100+
101+ // transfer
102+ transferCmd := &cobra.Command{
103+ Use: "transfer <owner/repo> <new-owner>",
104+ Short: "Transfer a repository to another user or org",
105+ Long: `Transfer a repository to another namespace, keeping its name.
106+
107+Requires ADMIN on the repository, a verified email address, and the right
108+to create repositories under the destination. A private repository counts
109+against the destination's plan's private-repo limit.`,
110+ Args: cobra.ExactArgs(2),
111+ RunE: runRepoTransfer,
112+ }
113+
87114 // delete
88115 deleteCmd := &cobra.Command{
89116 Use: "delete <owner/repo>",
@@ -143,7 +170,7 @@ Extra arguments after -- are passed through to git clone.`,
143170 RunE: runRepoCompare,
144171 }
145172
146- repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())
173+ repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, renameCmd, transferCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())
147174 rootCmd.AddCommand(repoCmd)
148175 }
149176
@@ -287,6 +314,46 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
287314 return nil
288315 }
289316
317+func runRepoRename(cmd *cobra.Command, args []string) error {
318+ client, err := newClient()
319+ if err != nil {
320+ return err
321+ }
322+ owner, repo, err := parseOwnerRepo(args[0])
323+ if err != nil {
324+ return err
325+ }
326+ r, err := client.RenameRepo(cmd.Context(), owner, repo, args[1])
327+ if err != nil {
328+ return err
329+ }
330+ if flagJSON {
331+ return printJSON(cmd.OutOrStdout(), r)
332+ }
333+ fmt.Fprintf(cmd.OutOrStdout(), "Renamed to %s\n", clean(r.FullName))
334+ return nil
335+}
336+
337+func runRepoTransfer(cmd *cobra.Command, args []string) error {
338+ client, err := newClient()
339+ if err != nil {
340+ return err
341+ }
342+ owner, repo, err := parseOwnerRepo(args[0])
343+ if err != nil {
344+ return err
345+ }
346+ r, err := client.TransferRepo(cmd.Context(), owner, repo, args[1])
347+ if err != nil {
348+ return err
349+ }
350+ if flagJSON {
351+ return printJSON(cmd.OutOrStdout(), r)
352+ }
353+ fmt.Fprintf(cmd.OutOrStdout(), "Transferred to %s\n", clean(r.FullName))
354+ return nil
355+}
356+
290357 func runRepoDelete(cmd *cobra.Command, args []string) error {
291358 client, err := newClient()
292359 if err != nil {
@@ -84,6 +84,33 @@ By default repositories are private; pass --public to create a public one.`,
84 editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description")84 editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description")
85 editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch")85 editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch")
86 86
87+ // rename
88+ renameCmd := &cobra.Command{
89+ Use: "rename <owner/repo> <new-name>",
90+ Short: "Rename a repository",
91+ Long: `Rename a repository in place.
92+
93+Every clone URL, mirror and CI checkout pointing at the old name stops
94+working, so this is a move, not an edit. Renaming to a different
95+capitalisation of the same name is refused (rename via an intermediate
96+name instead).`,
97+ Args: cobra.ExactArgs(2),
98+ RunE: runRepoRename,
99+ }
100+
101+ // transfer
102+ transferCmd := &cobra.Command{
103+ Use: "transfer <owner/repo> <new-owner>",
104+ Short: "Transfer a repository to another user or org",
105+ Long: `Transfer a repository to another namespace, keeping its name.
106+
107+Requires ADMIN on the repository, a verified email address, and the right
108+to create repositories under the destination. A private repository counts
109+against the destination's plan's private-repo limit.`,
110+ Args: cobra.ExactArgs(2),
111+ RunE: runRepoTransfer,
112+ }
113+
87 // delete114 // delete
88 deleteCmd := &cobra.Command{115 deleteCmd := &cobra.Command{
89 Use: "delete <owner/repo>",116 Use: "delete <owner/repo>",
@@ -143,7 +170,7 @@ Extra arguments after -- are passed through to git clone.`,
143 RunE: runRepoCompare,170 RunE: runRepoCompare,
144 }171 }
145 172
146- repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())173+ repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, renameCmd, transferCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd())
147 rootCmd.AddCommand(repoCmd)174 rootCmd.AddCommand(repoCmd)
148 }175 }
149 176
@@ -287,6 +314,46 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
287 return nil314 return nil
288 }315 }
289 316
317+func runRepoRename(cmd *cobra.Command, args []string) error {
318+ client, err := newClient()
319+ if err != nil {
320+ return err
321+ }
322+ owner, repo, err := parseOwnerRepo(args[0])
323+ if err != nil {
324+ return err
325+ }
326+ r, err := client.RenameRepo(cmd.Context(), owner, repo, args[1])
327+ if err != nil {
328+ return err
329+ }
330+ if flagJSON {
331+ return printJSON(cmd.OutOrStdout(), r)
332+ }
333+ fmt.Fprintf(cmd.OutOrStdout(), "Renamed to %s\n", clean(r.FullName))
334+ return nil
335+}
336+
337+func runRepoTransfer(cmd *cobra.Command, args []string) error {
338+ client, err := newClient()
339+ if err != nil {
340+ return err
341+ }
342+ owner, repo, err := parseOwnerRepo(args[0])
343+ if err != nil {
344+ return err
345+ }
346+ r, err := client.TransferRepo(cmd.Context(), owner, repo, args[1])
347+ if err != nil {
348+ return err
349+ }
350+ if flagJSON {
351+ return printJSON(cmd.OutOrStdout(), r)
352+ }
353+ fmt.Fprintf(cmd.OutOrStdout(), "Transferred to %s\n", clean(r.FullName))
354+ return nil
355+}
356+
290 func runRepoDelete(cmd *cobra.Command, args []string) error {357 func runRepoDelete(cmd *cobra.Command, args []string) error {
291 client, err := newClient()358 client, err := newClient()
292 if err != nil {359 if err != nil {
modified cmd/repo_test.go +104 -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"
@@ -164,6 +165,109 @@ func TestRepoViewNotFoundError(t *testing.T) {
164165 }
165166 }
166167
168+// repoMoveServer answers the rename and transfer endpoints and records what was
169+// called, so a test can assert which surface the CLI chose and what it sent.
170+func repoMoveServer(t *testing.T, seen *captured) *httptest.Server {
171+ t.Helper()
172+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173+ seen.Method, seen.Path = r.Method, r.URL.Path
174+ if b, _ := io.ReadAll(r.Body); len(b) > 0 {
175+ seen.Body = map[string]any{}
176+ _ = json.Unmarshal(b, &seen.Body)
177+ }
178+ switch r.URL.Path {
179+ case "/api/v1/repos/rick/portal/rename":
180+ _ = json.NewEncoder(w).Encode(map[string]any{
181+ "owner": "rick", "name": "portal-gun", "full_name": "rick/portal-gun", "visibility": "public",
182+ })
183+ case "/api/v1/repos/rick/portal/transfer":
184+ _ = json.NewEncoder(w).Encode(map[string]any{
185+ "owner": "acme", "name": "portal", "full_name": "acme/portal", "visibility": "public",
186+ })
187+ default:
188+ w.WriteHeader(http.StatusNotFound)
189+ _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}})
190+ }
191+ }))
192+}
193+
194+func TestRepoRenameCallsMoveEndpoint(t *testing.T) {
195+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
196+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
197+ seen := &captured{}
198+ srv := repoMoveServer(t, seen)
199+ defer srv.Close()
200+ t.Setenv("RICKUB_HOST", srv.URL)
201+
202+ out, _, err := execute(t, "repo", "rename", "rick/portal", "portal-gun")
203+ if err != nil {
204+ t.Fatalf("execute: %v", err)
205+ }
206+ // The rename endpoint must be the one called, with the new name in the body.
207+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/rename" {
208+ t.Fatalf("called %s %s", seen.Method, seen.Path)
209+ }
210+ if seen.Body["name"] != "portal-gun" {
211+ t.Fatalf("body = %v", seen.Body)
212+ }
213+ if !strings.Contains(out, "Renamed to rick/portal-gun") {
214+ t.Errorf("output missing new full name:\n%s", out)
215+ }
216+}
217+
218+func TestRepoTransferCallsMoveEndpoint(t *testing.T) {
219+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
220+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
221+ seen := &captured{}
222+ srv := repoMoveServer(t, seen)
223+ defer srv.Close()
224+ t.Setenv("RICKUB_HOST", srv.URL)
225+
226+ out, _, err := execute(t, "repo", "transfer", "rick/portal", "acme")
227+ if err != nil {
228+ t.Fatalf("execute: %v", err)
229+ }
230+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/transfer" {
231+ t.Fatalf("called %s %s", seen.Method, seen.Path)
232+ }
233+ if seen.Body["new_owner"] != "acme" {
234+ t.Fatalf("body = %v", seen.Body)
235+ }
236+ if !strings.Contains(out, "Transferred to acme/portal") {
237+ t.Errorf("output missing new full name:\n%s", out)
238+ }
239+}
240+
241+func TestRepoMoveArgCount(t *testing.T) {
242+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
243+ // Both commands are argument-strict: no destination, no request.
244+ if _, _, err := execute(t, "repo", "rename", "rick/portal"); err == nil {
245+ t.Error("rename without a new name should fail arg parsing")
246+ }
247+ if _, _, err := execute(t, "repo", "transfer", "rick/portal"); err == nil {
248+ t.Error("transfer without a destination owner should fail arg parsing")
249+ }
250+}
251+
252+// TestRepoMoveRejectsABadSpec: the owner/repo argument goes through the shared
253+// parser, so a spec with no slash is refused before any request is made — the
254+// same rule `repo view` and `repo delete` follow.
255+func TestRepoMoveRejectsABadSpec(t *testing.T) {
256+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
257+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
258+ seen := &captured{}
259+ srv := repoMoveServer(t, seen)
260+ defer srv.Close()
261+ t.Setenv("RICKUB_HOST", srv.URL)
262+
263+ if _, _, err := execute(t, "repo", "rename", "portal", "portal-gun"); err == nil {
264+ t.Error("a spec without a slash should be refused")
265+ }
266+ if seen.Path != "" {
267+ t.Errorf("a bad spec reached the server at %q", seen.Path)
268+ }
269+}
270+
167271 func TestHumanTime(t *testing.T) {
168272 cases := map[string]string{
169273 "": "-", // 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"
@@ -164,6 +165,109 @@ func TestRepoViewNotFoundError(t *testing.T) {
164 }165 }
165 }166 }
166 167
168+// repoMoveServer answers the rename and transfer endpoints and records what was
169+// called, so a test can assert which surface the CLI chose and what it sent.
170+func repoMoveServer(t *testing.T, seen *captured) *httptest.Server {
171+ t.Helper()
172+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173+ seen.Method, seen.Path = r.Method, r.URL.Path
174+ if b, _ := io.ReadAll(r.Body); len(b) > 0 {
175+ seen.Body = map[string]any{}
176+ _ = json.Unmarshal(b, &seen.Body)
177+ }
178+ switch r.URL.Path {
179+ case "/api/v1/repos/rick/portal/rename":
180+ _ = json.NewEncoder(w).Encode(map[string]any{
181+ "owner": "rick", "name": "portal-gun", "full_name": "rick/portal-gun", "visibility": "public",
182+ })
183+ case "/api/v1/repos/rick/portal/transfer":
184+ _ = json.NewEncoder(w).Encode(map[string]any{
185+ "owner": "acme", "name": "portal", "full_name": "acme/portal", "visibility": "public",
186+ })
187+ default:
188+ w.WriteHeader(http.StatusNotFound)
189+ _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}})
190+ }
191+ }))
192+}
193+
194+func TestRepoRenameCallsMoveEndpoint(t *testing.T) {
195+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
196+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
197+ seen := &captured{}
198+ srv := repoMoveServer(t, seen)
199+ defer srv.Close()
200+ t.Setenv("RICKUB_HOST", srv.URL)
201+
202+ out, _, err := execute(t, "repo", "rename", "rick/portal", "portal-gun")
203+ if err != nil {
204+ t.Fatalf("execute: %v", err)
205+ }
206+ // The rename endpoint must be the one called, with the new name in the body.
207+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/rename" {
208+ t.Fatalf("called %s %s", seen.Method, seen.Path)
209+ }
210+ if seen.Body["name"] != "portal-gun" {
211+ t.Fatalf("body = %v", seen.Body)
212+ }
213+ if !strings.Contains(out, "Renamed to rick/portal-gun") {
214+ t.Errorf("output missing new full name:\n%s", out)
215+ }
216+}
217+
218+func TestRepoTransferCallsMoveEndpoint(t *testing.T) {
219+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
220+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
221+ seen := &captured{}
222+ srv := repoMoveServer(t, seen)
223+ defer srv.Close()
224+ t.Setenv("RICKUB_HOST", srv.URL)
225+
226+ out, _, err := execute(t, "repo", "transfer", "rick/portal", "acme")
227+ if err != nil {
228+ t.Fatalf("execute: %v", err)
229+ }
230+ if seen.Method != http.MethodPost || seen.Path != "/api/v1/repos/rick/portal/transfer" {
231+ t.Fatalf("called %s %s", seen.Method, seen.Path)
232+ }
233+ if seen.Body["new_owner"] != "acme" {
234+ t.Fatalf("body = %v", seen.Body)
235+ }
236+ if !strings.Contains(out, "Transferred to acme/portal") {
237+ t.Errorf("output missing new full name:\n%s", out)
238+ }
239+}
240+
241+func TestRepoMoveArgCount(t *testing.T) {
242+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
243+ // Both commands are argument-strict: no destination, no request.
244+ if _, _, err := execute(t, "repo", "rename", "rick/portal"); err == nil {
245+ t.Error("rename without a new name should fail arg parsing")
246+ }
247+ if _, _, err := execute(t, "repo", "transfer", "rick/portal"); err == nil {
248+ t.Error("transfer without a destination owner should fail arg parsing")
249+ }
250+}
251+
252+// TestRepoMoveRejectsABadSpec: the owner/repo argument goes through the shared
253+// parser, so a spec with no slash is refused before any request is made — the
254+// same rule `repo view` and `repo delete` follow.
255+func TestRepoMoveRejectsABadSpec(t *testing.T) {
256+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
257+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
258+ seen := &captured{}
259+ srv := repoMoveServer(t, seen)
260+ defer srv.Close()
261+ t.Setenv("RICKUB_HOST", srv.URL)
262+
263+ if _, _, err := execute(t, "repo", "rename", "portal", "portal-gun"); err == nil {
264+ t.Error("a spec without a slash should be refused")
265+ }
266+ if seen.Path != "" {
267+ t.Errorf("a bad spec reached the server at %q", seen.Path)
268+ }
269+}
270+
167 func TestHumanTime(t *testing.T) {271 func TestHumanTime(t *testing.T) {
168 cases := map[string]string{272 cases := map[string]string{
169 "": "-", // empty -> dash273 "": "-", // 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"`