diff --git a/docs/migration-1.0.md b/docs/migration-1.0.md index 54d61d87..216b907f 100644 --- a/docs/migration-1.0.md +++ b/docs/migration-1.0.md @@ -217,7 +217,7 @@ Subcommands: | `times add` | `time create` | | `times list [user\|#issue]` | `time list [--username USER\|--issue N]` | | `repos search` | `search repo` | -| `repos create-from-template` | `repo create --template OWNER/REPO` | +| `repos create-from-template` | `repo create --template OWNER/REPO`; `--content`, `--githooks`, `--avatar`, `--labels`, `--topics`, `--webhooks` are now `--copy-content`, `--copy-git-hooks`, `--copy-avatar`, `--copy-labels`, `--copy-topics`, `--copy-webhooks`, and `--owner` is `--org` | | `actions secrets create` | `secret set` (value: see below) | | `actions variables set` | `variable set` (value: see below) | | `actions variables list --name NAME` | `variable view NAME` | @@ -248,6 +248,7 @@ Renamed flags that appear on many commands: | `--page` / `-p`, `--limit` / `--lm` | `--page`, `--limit` / `-L` | | `--style` / `-s` (pr merge) | `--merge`, `--squash` or `--rebase`, or `--method` / `-s`; without one, the repository's default merge style | | `--owner` / `-O`, `--init`, `--labels`, `--trustmodel`, `--branch`, `--template` (repo create) | `--org`, `--auto-init`, `--label-template`, `--trust-model`, `--default-branch`, `--as-template` | +| `--trustmodel collaborator+committer` (repos create) | `--trust-model collaboratorcommitter` | | `--desc` (repo edit) | `--description` / `-d` | | `--template true\|false`, `--private true\|false`, `--archived true\|false` (repo edit) | `--as-template`, `--private`, `tea repo archive` / `unarchive`; these are on/off switches, so write `--private=false` (`--private false` names a repository `false`) | | `--owner` / `-O` (repo fork) | `--org` | diff --git a/internal/app/alias_completion_test.go b/internal/app/alias_completion_test.go index da8ce343..c25ffe18 100644 --- a/internal/app/alias_completion_test.go +++ b/internal/app/alias_completion_test.go @@ -385,6 +385,20 @@ func TestInstanceCompletions(t *testing.T) { } } +func TestCompleteArgumentBranchesUsesTheNamedRepository(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/bob/tool/branches", r.URL.Path) + _, _ = w.Write([]byte(`[{"name":"develop"}]`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + a := &application{deps: deps} + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + values, _ := a.completeArgumentBranches()(cmd, []string{"bob/tool"}, "") + require.Equal(t, []string{"develop"}, values) +} + func TestCompletionInstallHelp(t *testing.T) { deps, out, _ := testDeps(t) require.NoError(t, Execute(context.Background(), deps, []string{"completion", "bash", "--help"})) diff --git a/internal/app/completion.go b/internal/app/completion.go index 499763fc..7cf71750 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -413,15 +413,29 @@ func (a *application) completeUsers() cobra.CompletionFunc { // completeBranches completes the local branches, then the repository's branches. func (a *application) completeBranches() cobra.CompletionFunc { - return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { - items, err := services.Branches{Client: t.client}.ListPage(ctx, t.Repo, completionLimit, 1) - return describe(items.Items, func(item services.Branch) (string, string) { - if item.Protected { - return item.Name, "protected" - } - return item.Name, "" - }), err - })) + return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, listBranches)) +} + +// completeArgumentBranches is completeBranches for a command with a positional []: +// once that argument is given, it completes that repository's branches, not those of the clone here. +func (a *application) completeArgumentBranches() cobra.CompletionFunc { + inClone := a.completeBranches() + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) == 0 || a.options.repo != "" { + return inClone(cmd, args, toComplete) + } + return a.apiCompletionIn(args[0], true, listBranches)(cmd, args, toComplete) + } +} + +func listBranches(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.Branches{Client: t.client}.ListPage(ctx, t.Repo, completionLimit, 1) + return describe(items.Items, func(item services.Branch) (string, string) { + if item.Protected { + return item.Name, "protected" + } + return item.Name, "" + }), err } // completeTags completes the repository's tags. diff --git a/internal/app/human_output_test.go b/internal/app/human_output_test.go index a458c822..d73b3421 100644 --- a/internal/app/human_output_test.go +++ b/internal/app/human_output_test.go @@ -281,6 +281,7 @@ func TestWriteTable(t *testing.T) { stderr: "more results available; use --page 3\n", }, {name: "empty message on a terminal", tty: true, page: listPage{Number: 1}, stderr: "no open issues in alice/x\n"}, + {name: "next-page hint instead of the empty message on a filtered page", tty: true, page: listPage{Number: 1, More: true}, stderr: "more results available; use --page 2\n"}, {name: "nothing when empty and piped", page: listPage{Number: 1}}, {name: "next-page hint when an empty page has more", tty: true, page: listPage{Number: 1, More: true}, stderr: "more results available; use --page 2\n"}, {name: "next-page hint when an empty page has more and piped", page: listPage{Number: 1, More: true}, stderr: "more results available; use --page 2\n"}, diff --git a/internal/app/prompts.go b/internal/app/prompts.go index 72681fef..87740f60 100644 --- a/internal/app/prompts.go +++ b/internal/app/prompts.go @@ -4,6 +4,9 @@ package app import ( + "fmt" + + "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) @@ -52,5 +55,9 @@ func (a *application) requireInput(cmd *cobra.Command, value *string, flag, labe if err != nil { return false, err } + if hidden := cmd.Flags().Lookup(flag); *value == "" && hidden != nil && hidden.Hidden && !a.policy(s).CanPrompt(a.deps.Streams) { + // --help does not list the flag, so point at the argument it stands in for. + return false, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input: %s (pass it as an argument)", label)) + } return terminal.RequireInput(cmd.Context(), value, flag, label, validate, a.deps.Streams, a.policy(s), a.deps.Prompter) } diff --git a/internal/app/repos.go b/internal/app/repos.go index fb7005f9..07acb059 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -273,7 +273,7 @@ $ tea repo view alice/project --web`, } view.Flags().BoolVarP(&web, "web", "w", false, "Open the repository in the browser") view.Flags().StringVarP(&branch, "branch", "b", "", "Show the README of this branch (default: the default branch)") - _ = view.RegisterFlagCompletionFunc("branch", a.completeBranches()) + _ = view.RegisterFlagCompletionFunc("branch", a.completeArgumentBranches()) view.ValidArgsFunction = a.completeRepos() return view } diff --git a/internal/app/repository_create.go b/internal/app/repository_create.go index 0a0263f1..2a46ee7e 100644 --- a/internal/app/repository_create.go +++ b/internal/app/repository_create.go @@ -147,7 +147,14 @@ $ tea repo create project --source . --push`, target.Repo = item.FullName switch { case clone: - _, err = a.cloneRepository(cmd, target, item, cloneOptions{upstream: "upstream"}) + // Record the new repository first so a failed clone does not hide that it exists. + if err := a.created(cmd, item, item.URL); err != nil { + return err + } + if _, err := a.cloneRepository(cmd, target, item, cloneOptions{upstream: "upstream"}); err != nil { + return fmt.Errorf("created %s, but could not clone it: %w", item.FullName, err) + } + return nil case localSource != "": err = a.publishRepository(cmd, target, item, localSource, remote, push) } diff --git a/internal/app/repository_create_test.go b/internal/app/repository_create_test.go index f5315625..9e0e2ce5 100644 --- a/internal/app/repository_create_test.go +++ b/internal/app/repository_create_test.go @@ -63,6 +63,18 @@ func TestRepositoryCreateClonesNewRepository(t *testing.T) { require.JSONEq(t, `{"id":7,"full_name":"alice/project"}`, out.String()) } +func TestRepositoryCreateReportsFailedCloneAfterCreating(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `{"id":7,"name":"project","full_name":"alice/project","clone_url":%q}`, "http://"+r.Host+"/alice/project.git") + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + deps.Git = &fakeGit{} + err := Execute(context.Background(), deps, []string{"repo", "create", "project", "--clone", "--no-input", "--json=full_name"}) + require.ErrorContains(t, err, "created alice/project, but could not clone it") + require.JSONEq(t, `{"full_name":"alice/project"}`, out.String()) +} + func TestRepositoryCreateValidatesTemplatesBeforeCreating(t *testing.T) { var requests []string server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -108,6 +120,17 @@ func TestRepositoryCreatePublishesALocalRepository(t *testing.T) { require.ErrorContains(t, err, "--source publishes a local repository") } +func TestRepositoryMigrateAcceptsZeroMirrorInterval(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requirePayload(t, `{"repo_name":"tea","clone_addr":"https://github.com/go-gitea/tea.git","service":"git","mirror":true,"mirror_interval":"0"}`, r.Body) + _, _ = w.Write([]byte(`{"full_name":"alice/tea","mirror":true,"html_url":"https://gitea.test/alice/tea"}`)) + })) + defer server.Close() + deps, _, diagnostics := repoTTYDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "migrate", "https://github.com/go-gitea/tea.git", "--mirror", "--mirror-interval", "0"})) + require.Equal(t, "✓ Imported https://github.com/go-gitea/tea.git into alice/tea (pull mirror, synced manually)\n", diagnostics.String()) +} + func TestRepositoryMigrateNamesTheRepositoryAfterItsSource(t *testing.T) { for _, test := range []struct{ source, want string }{ {"https://github.com/go-gitea/tea.git", "tea"}, diff --git a/internal/app/repository_migrate.go b/internal/app/repository_migrate.go index 2de8d51a..73542aed 100644 --- a/internal/app/repository_migrate.go +++ b/internal/app/repository_migrate.go @@ -133,7 +133,10 @@ $ tea repo migrate https://github.com/acme/tool --service github --issues --pull return err } detail := "" + interval, _ := time.ParseDuration(opts.MirrorInterval) switch { + case item.Mirror && opts.MirrorInterval != "" && interval == 0: + detail = " (pull mirror, synced manually)" case item.Mirror && opts.MirrorInterval != "": detail = " (pull mirror, every " + opts.MirrorInterval + ")" case item.Mirror: @@ -161,7 +164,7 @@ $ tea repo migrate https://github.com/acme/tool --service github --issues --pull flags.BoolVar(&opts.PullRequests, "pull-requests", false, "Copy pull requests") flags.BoolVar(&opts.Releases, "releases", false, "Copy releases") flags.BoolVar(&opts.Milestones, "milestones", false, "Copy milestones") - flags.StringVar(&opts.MirrorInterval, "mirror-interval", "", "Mirror interval, for example 8h") + flags.StringVar(&opts.MirrorInterval, "mirror-interval", "", "Mirror interval, for example 8h (0 turns off periodic sync)") flags.BoolVar(&opts.LFS, "lfs", false, "Copy LFS objects") flags.StringVar(&opts.LFSEndpoint, "lfs-endpoint", "", "Source LFS endpoint") flags.StringVar(&opts.AuthUser, "auth-user", "", "Source authentication username") @@ -201,8 +204,8 @@ func validateMigrationFlags(cmd *cobra.Command, opts services.RepositoryMigratio } if opts.MirrorInterval != "" { interval, err := time.ParseDuration(opts.MirrorInterval) - if err != nil || interval <= 0 || !opts.Mirror { - return clierr.Wrap(clierr.Usage, errors.New("--mirror-interval requires --mirror and a positive duration")) + if err != nil || interval < 0 || !opts.Mirror { + return clierr.Wrap(clierr.Usage, errors.New("--mirror-interval requires --mirror and a duration of 0 or more")) } } return nil diff --git a/internal/app/repository_sync.go b/internal/app/repository_sync.go index 77f6f50e..d88166ef 100644 --- a/internal/app/repository_sync.go +++ b/internal/app/repository_sync.go @@ -100,7 +100,7 @@ $ tea repo sync bob/project`, }, } command.Flags().StringVarP(&branch, "branch", "b", "", "Branch to sync (default: the repository's default branch)") - _ = command.RegisterFlagCompletionFunc("branch", a.completeBranches()) + _ = command.RegisterFlagCompletionFunc("branch", a.completeArgumentBranches()) command.ValidArgsFunction = a.completeRepos() return command } diff --git a/internal/app/search.go b/internal/app/search.go index 1fb01912..71aafd37 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -62,7 +62,7 @@ $ tea search repo go --topic --sort updated --order desc`, pageFlags(command, &opts.Limit, &opts.Page) flags.StringVar(&opts.Owner, "owner", "", "Only repositories of this user or organization") enumFlag(command, &opts.Kind, "type", "", "", "Only repositories of this kind", "fork", "source", "mirror") - enumFlag(command, &opts.Sort, "sort", "", "alpha", "Sort by", "alpha", "created", "updated", "size", "id") + enumFlag(command, &opts.Sort, "sort", "", "", "Sort by (default: alphabetical, your own repositories first)", "alpha", "created", "updated", "size", "id") enumFlag(command, &opts.Order, "order", "", "asc", "Sort order", "asc", "desc") flags.BoolVar(&opts.Topic, "topic", false, "Match repository topics") flags.BoolVar(&private, "private", false, "Only private repositories (use =false for public)") diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 0fdb4754..7a4a8127 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -152,7 +152,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"release", "edit", "v1", "--draft", "false"}, "--draft takes no value; use --draft or --draft=false"}, {[]string{"release", "edit", "v1", "-d", "false", "-p", "False"}, "--draft takes no value; use --draft or --draft=false"}, {[]string{"release", "edit", "v1", "v2", "--prerelease", "TRUE"}, "--prerelease takes no value; use --prerelease or --prerelease=false"}, - {[]string{"repo", "create"}, "missing required input: supply --name"}, + {[]string{"repo", "create"}, "missing required input: Repository name (pass it as an argument)"}, {[]string{"repo", "create", "acme/bad/name"}, "repository name must be nonempty, at most 100 bytes, and contain no path separators or whitespace"}, {[]string{"repo", "create", "name", "--name", "other"}, "supply name as an argument or --name, not both"}, {[]string{"repo", "create", "name", "--default-branch", "bad..branch", "--auto-init"}, "invalid branch name"}, @@ -175,11 +175,12 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"repo", "list", "--type", "invalid"}, "--type must be fork, source, or mirror"}, {[]string{"repo", "set-default", "--json"}, "set-default configures Git"}, {[]string{"repo", "sync", "--branch="}, "--branch cannot be empty"}, + {[]string{"repo", "migrate", "--name", "new"}, "missing required input: Clone URL (pass it as an argument)"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--clone-url", "https://other.test/repo"}, "supply clone URL as an argument or --clone-url, not both"}, {[]string{"repo", "migrate", "https://user:secret@source.test/repo", "--name", "new"}, "clone URL must be an HTTP(S) or git URL without embedded credentials"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--service", "unknown"}, "--service must be git, gitea, gitlab, gogs, or github"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--issues"}, "plain Git migration cannot copy forge metadata"}, - {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--mirror-interval", "8h"}, "--mirror-interval requires --mirror and a positive duration"}, + {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--mirror-interval", "8h"}, "--mirror-interval requires --mirror and a duration of 0 or more"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--mirror", "--issues"}, "mirrors cannot copy issues, milestones, labels, pull requests, or releases"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--lfs-endpoint", "https://lfs.test"}, "--lfs-endpoint requires --lfs"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--auth-password", "secret"}, "--auth-password requires --auth-user"}, diff --git a/internal/services/repositories.go b/internal/services/repositories.go index 7520ed17..9b7f89a8 100644 --- a/internal/services/repositories.go +++ b/internal/services/repositories.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "slices" + "strconv" "strings" gitea "gitea.dev/sdk" @@ -99,35 +100,75 @@ func (s Repositories) get(ctx context.Context, repo string) (*gitea.Repository, return item, Classify(err, "repository", repo, "") } -// readmeNames are the README file names that Gitea shows, in order of preference. -var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} +// readmeDirs are the directories Gitea searches for a README, in order; "" is the repository root. +var readmeDirs = []string{".gitea", ".github", "", "docs"} -// Readme returns the README in the top directory of the repository at ref: its file name and its -// content, or "" when there is none. -func (s Repositories) Readme(ctx context.Context, repo, ref string) (name, content string, err error) { - entries, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.ContentsResponse, *gitea.Response, error) { - return c.Repositories.ListContents(ctx, owner, name, ref, "") - }) +// readmeRank orders README file names as Gitea does: Markdown first, then text, then any other +// README.* such as README.rst. It returns -1 for names that are not a README. +func readmeRank(name string) int { + switch lower := strings.ToLower(name); { + case lower == "readme.md": + return 0 + case lower == "readme.markdown": + return 1 + case lower == "readme.txt": + return 2 + case lower == "readme": + return 3 + case strings.HasPrefix(lower, "readme."): + return 4 + } + return -1 +} + +// Readme returns the README of the repository at ref as Gitea's web view finds it, searching .gitea, +// .github, the root and docs: its path and its content, or "" when there is none. An explicit ref +// that does not exist is an error. +func (s Repositories) Readme(ctx context.Context, repo, ref string) (path, content string, err error) { + list := func(dir string) ([]*gitea.ContentsResponse, error) { + return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.ContentsResponse, *gitea.Response, error) { + return c.Repositories.ListContents(ctx, owner, name, ref, dir) + }) + } + root, err := list("") if err != nil { + if IsNotFound(err) && ref != "" { + return "", "", NotFound("branch", strconv.Quote(ref), repo) + } // A reader without code access sees the repository but not its files. if IsNotFound(err) || IsForbidden(err) { return "", "", nil } return "", "", err } - rank := len(readmeNames) - for _, entry := range entries { - if i := slices.Index(readmeNames, strings.ToLower(entry.Name)); entry.Type == "file" && i >= 0 && i < rank { - name, rank = entry.Name, i + for _, dir := range readmeDirs { + entries := root + if dir != "" { + i := slices.IndexFunc(root, func(e *gitea.ContentsResponse) bool { return e.Type == "dir" && e.Name == dir }) + if i < 0 { + continue + } + if entries, err = list(dir); err != nil { + return "", "", err + } + } + rank := -1 + for _, entry := range entries { + if r := readmeRank(entry.Name); entry.Type == "file" && r >= 0 && (rank < 0 || r < rank) { + path, rank = strings.TrimPrefix(dir+"/"+entry.Name, "/"), r + } + } + if path != "" { + break } } - if name == "" { + if path == "" { return "", "", nil } - data, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]byte, *gitea.Response, error) { - return c.Repositories.GetFile(ctx, owner, repo, ref, name) + data, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]byte, *gitea.Response, error) { + return c.Repositories.GetFile(ctx, owner, name, ref, path) }) - return name, string(data), err + return path, string(data), err } // RepositoryPermissions holds server-side access flags used only for local decisions. @@ -204,12 +245,16 @@ func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOpt return c.Repositories.CreateRepo(ctx, options) }) if err != nil { + if org != "" && IsNotFound(err) { + // The repository cannot be missing before it exists: the organization is. + return Repository{}, &NotFoundError{Kind: "organization", Key: strconv.Quote(org), Err: err} + } return Repository{}, Classify(err, "repository", "", "") } return repositoryFrom(item), nil } -// Edit updates only explicitly supplied resource fields. +// Editupdates only explicitly supplied resource fields. func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) { var mergeStyle *gitea.MergeStyle if opts.MergeStyle != nil { diff --git a/internal/services/repositories_test.go b/internal/services/repositories_test.go index 8b4dd2c4..a67dd83b 100644 --- a/internal/services/repositories_test.go +++ b/internal/services/repositories_test.go @@ -79,3 +79,42 @@ func TestRepositoryAssigneesTagsAndMissingPermissions(t *testing.T) { require.NoError(t, err) require.Equal(t, RepositoryPermissions{}, permissions) } + +func TestRepositoryCreateReportsMissingOrganization(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"GetOrgByName"}`)) + })) + _, err := (Repositories{Client: client}).Create(context.Background(), "acme-typo", RepositoryOptions{Name: "tool"}) + require.EqualError(t, err, `organization "acme-typo" not found`) +} + +func TestRepositoryReadme(t *testing.T) { + dirEntry := func(name string) string { return `{"name":"` + name + `","path":"` + name + `","type":"dir"}` } + fileEntry := func(dir, name string) string { + return `{"name":"` + name + `","path":"` + dir + name + `","type":"file"}` + } + listings := map[string]string{ + "/api/v1/repos/alice/project/contents/": "[" + fileEntry("", "README.rst") + "," + fileEntry("", "readme.txt") + "," + dirEntry(".github") + "]", + "/api/v1/repos/alice/project/contents/.github": "[" + fileEntry(".github/", "README.md") + "]", + } + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") == "develp" { + w.WriteHeader(http.StatusNotFound) + return + } + if body, ok := listings[r.URL.Path]; ok { + _, _ = w.Write([]byte(body)) + return + } + assert.Equal(t, "/api/v1/repos/alice/project/raw/.github/README.md", r.URL.Path) + _, _ = w.Write([]byte("hello")) + })) + path, content, err := (Repositories{Client: client}).Readme(context.Background(), "alice/project", "") + require.NoError(t, err) + assert.Equal(t, ".github/README.md", path) + assert.Equal(t, "hello", content) + + _, _, err = (Repositories{Client: client}).Readme(context.Background(), "alice/project", "develp") + require.EqualError(t, err, `branch "develp" not found in alice/project`) +} diff --git a/internal/services/repository_search.go b/internal/services/repository_search.go index 628c77b0..01c475e3 100644 --- a/internal/services/repository_search.go +++ b/internal/services/repository_search.go @@ -121,7 +121,10 @@ func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions) if err != nil { return Page[Repository]{}, err } - options.PrioritizedByOwnerID = account.ID + // Gitea groups results by owner ahead of the requested sort, so an explicit sort cannot keep it. + if opts.Sort == "" { + options.PrioritizedByOwnerID = account.ID + } window, err := callPage(ctx, s.Client, opts.Limit, opts.Page, func(ctx context.Context, c *gitea.Client, list gitea.ListOptions) ([]*gitea.Repository, *gitea.Response, error) { options.ListOptions = list return c.Repositories.SearchRepos(ctx, options) diff --git a/internal/services/repository_search_test.go b/internal/services/repository_search_test.go index 32b5d673..d08fff4e 100644 --- a/internal/services/repository_search_test.go +++ b/internal/services/repository_search_test.go @@ -118,6 +118,22 @@ func TestRepositorySearchSendsExplicitFalseFilters(t *testing.T) { require.Empty(t, page.Items) } +func TestRepositorySearchSortDropsOwnerPriority(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/user" { + _, _ = w.Write([]byte(`{"id":7}`)) + return + } + query := r.URL.Query() + assert.False(t, query.Has("priority_owner_id")) + assert.Equal(t, "updated", query.Get("sort")) + assert.Equal(t, "desc", query.Get("order")) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + _, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Query: "go", Sort: "updated", Order: "desc", Limit: 30, Page: 1}) + require.NoError(t, err) +} + func TestRepositorySearchScopesOwner(t *testing.T) { client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path {