fix(repo): README lookup like Gitea, clearer branch/org errors, clone-after-create report, mirror interval 0 and search sort (follow gh)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 20:03:55 +02:00
1 parent 8305a89531
commit aa181a2b5c
16 files changed
+211 -37

No files matched your search

+2 -1
View File
@@ -217,7 +217,7 @@ Subcommands:
| `times add` | `time create` | | `times add` | `time create` |
| `times list [user\|#issue]` | `time list [--username USER\|--issue N]` | | `times list [user\|#issue]` | `time list [--username USER\|--issue N]` |
| `repos search` | `search repo` | | `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 secrets create` | `secret set` (value: see below) |
| `actions variables set` | `variable set` (value: see below) | | `actions variables set` | `variable set` (value: see below) |
| `actions variables list --name NAME` | `variable view NAME` | | `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` | | `--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 | | `--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` | | `--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` | | `--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`) | | `--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` | | `--owner` / `-O` (repo fork) | `--org` |
+14
View File
@@ -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) { func TestCompletionInstallHelp(t *testing.T) {
deps, out, _ := testDeps(t) deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"completion", "bash", "--help"})) require.NoError(t, Execute(context.Background(), deps, []string{"completion", "bash", "--help"}))
+23 -9
View File
@@ -413,15 +413,29 @@ func (a *application) completeUsers() cobra.CompletionFunc {
// completeBranches completes the local branches, then the repository's branches. // completeBranches completes the local branches, then the repository's branches.
func (a *application) completeBranches() cobra.CompletionFunc { func (a *application) completeBranches() cobra.CompletionFunc {
return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, listBranches))
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 { // completeArgumentBranches is completeBranches for a command with a positional [<repository>]:
return item.Name, "protected" // once that argument is given, it completes that repository's branches, not those of the clone here.
} func (a *application) completeArgumentBranches() cobra.CompletionFunc {
return item.Name, "" inClone := a.completeBranches()
}), err 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. // completeTags completes the repository's tags.
+1
View File
@@ -281,6 +281,7 @@ func TestWriteTable(t *testing.T) {
stderr: "more results available; use --page 3\n", 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: "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: "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", 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"}, {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"},
+7
View File
@@ -4,6 +4,9 @@
package app package app
import ( import (
"fmt"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -52,5 +55,9 @@ func (a *application) requireInput(cmd *cobra.Command, value *string, flag, labe
if err != nil { if err != nil {
return false, err 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) return terminal.RequireInput(cmd.Context(), value, flag, label, validate, a.deps.Streams, a.policy(s), a.deps.Prompter)
} }
+1 -1
View File
@@ -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().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.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() view.ValidArgsFunction = a.completeRepos()
return view return view
} }
+8 -1
View File
@@ -147,7 +147,14 @@ $ tea repo create project --source . --push`,
target.Repo = item.FullName target.Repo = item.FullName
switch { switch {
case clone: 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 != "": case localSource != "":
err = a.publishRepository(cmd, target, item, localSource, remote, push) err = a.publishRepository(cmd, target, item, localSource, remote, push)
} }
+23
View File
@@ -63,6 +63,18 @@ func TestRepositoryCreateClonesNewRepository(t *testing.T) {
require.JSONEq(t, `{"id":7,"full_name":"alice/project"}`, out.String()) 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) { func TestRepositoryCreateValidatesTemplatesBeforeCreating(t *testing.T) {
var requests []string var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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") 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) { func TestRepositoryMigrateNamesTheRepositoryAfterItsSource(t *testing.T) {
for _, test := range []struct{ source, want string }{ for _, test := range []struct{ source, want string }{
{"https://github.com/go-gitea/tea.git", "tea"}, {"https://github.com/go-gitea/tea.git", "tea"},
+6 -3
View File
@@ -133,7 +133,10 @@ $ tea repo migrate https://github.com/acme/tool --service github --issues --pull
return err return err
} }
detail := "" detail := ""
interval, _ := time.ParseDuration(opts.MirrorInterval)
switch { switch {
case item.Mirror && opts.MirrorInterval != "" && interval == 0:
detail = " (pull mirror, synced manually)"
case item.Mirror && opts.MirrorInterval != "": case item.Mirror && opts.MirrorInterval != "":
detail = " (pull mirror, every " + opts.MirrorInterval + ")" detail = " (pull mirror, every " + opts.MirrorInterval + ")"
case item.Mirror: 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.PullRequests, "pull-requests", false, "Copy pull requests")
flags.BoolVar(&opts.Releases, "releases", false, "Copy releases") flags.BoolVar(&opts.Releases, "releases", false, "Copy releases")
flags.BoolVar(&opts.Milestones, "milestones", false, "Copy milestones") 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.BoolVar(&opts.LFS, "lfs", false, "Copy LFS objects")
flags.StringVar(&opts.LFSEndpoint, "lfs-endpoint", "", "Source LFS endpoint") flags.StringVar(&opts.LFSEndpoint, "lfs-endpoint", "", "Source LFS endpoint")
flags.StringVar(&opts.AuthUser, "auth-user", "", "Source authentication username") flags.StringVar(&opts.AuthUser, "auth-user", "", "Source authentication username")
@@ -201,8 +204,8 @@ func validateMigrationFlags(cmd *cobra.Command, opts services.RepositoryMigratio
} }
if opts.MirrorInterval != "" { if opts.MirrorInterval != "" {
interval, err := time.ParseDuration(opts.MirrorInterval) interval, err := time.ParseDuration(opts.MirrorInterval)
if err != nil || interval <= 0 || !opts.Mirror { if err != nil || interval < 0 || !opts.Mirror {
return clierr.Wrap(clierr.Usage, errors.New("--mirror-interval requires --mirror and a positive duration")) return clierr.Wrap(clierr.Usage, errors.New("--mirror-interval requires --mirror and a duration of 0 or more"))
} }
} }
return nil return nil
+1 -1
View File
@@ -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.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() command.ValidArgsFunction = a.completeRepos()
return command return command
} }
+1 -1
View File
@@ -62,7 +62,7 @@ $ tea search repo go --topic --sort updated --order desc`,
pageFlags(command, &opts.Limit, &opts.Page) pageFlags(command, &opts.Limit, &opts.Page)
flags.StringVar(&opts.Owner, "owner", "", "Only repositories of this user or organization") 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.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") enumFlag(command, &opts.Order, "order", "", "asc", "Sort order", "asc", "desc")
flags.BoolVar(&opts.Topic, "topic", false, "Match repository topics") flags.BoolVar(&opts.Topic, "topic", false, "Match repository topics")
flags.BoolVar(&private, "private", false, "Only private repositories (use =false for public)") flags.BoolVar(&private, "private", false, "Only private repositories (use =false for public)")
+3 -2
View File
@@ -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", "--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", "-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{"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", "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", "--name", "other"}, "supply name as an argument or --name, not both"},
{[]string{"repo", "create", "name", "--default-branch", "bad..branch", "--auto-init"}, "invalid branch name"}, {[]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", "list", "--type", "invalid"}, "--type must be fork, source, or mirror"},
{[]string{"repo", "set-default", "--json"}, "set-default configures Git"}, {[]string{"repo", "set-default", "--json"}, "set-default configures Git"},
{[]string{"repo", "sync", "--branch="}, "--branch cannot be empty"}, {[]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://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://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", "--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", "--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", "--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", "--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"}, {[]string{"repo", "migrate", "https://source.test/repo", "--name", "new", "--auth-password", "secret"}, "--auth-password requires --auth-user"},
+62 -17
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"slices" "slices"
"strconv"
"strings" "strings"
gitea "gitea.dev/sdk" 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, "") return item, Classify(err, "repository", repo, "")
} }
// readmeNames are the README file names that Gitea shows, in order of preference. // readmeDirs are the directories Gitea searches for a README, in order; "" is the repository root.
var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} var readmeDirs = []string{".gitea", ".github", "", "docs"}
// Readme returns the README in the top directory of the repository at ref: its file name and its // readmeRank orders README file names as Gitea does: Markdown first, then text, then any other
// content, or "" when there is none. // README.* such as README.rst. It returns -1 for names that are not a README.
func (s Repositories) Readme(ctx context.Context, repo, ref string) (name, content string, err error) { func readmeRank(name string) int {
entries, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.ContentsResponse, *gitea.Response, error) { switch lower := strings.ToLower(name); {
return c.Repositories.ListContents(ctx, owner, name, ref, "") 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 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. // A reader without code access sees the repository but not its files.
if IsNotFound(err) || IsForbidden(err) { if IsNotFound(err) || IsForbidden(err) {
return "", "", nil return "", "", nil
} }
return "", "", err return "", "", err
} }
rank := len(readmeNames) for _, dir := range readmeDirs {
for _, entry := range entries { entries := root
if i := slices.Index(readmeNames, strings.ToLower(entry.Name)); entry.Type == "file" && i >= 0 && i < rank { if dir != "" {
name, rank = entry.Name, i 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 return "", "", nil
} }
data, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]byte, *gitea.Response, error) { 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, repo, ref, name) 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. // 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) return c.Repositories.CreateRepo(ctx, options)
}) })
if err != nil { 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 Repository{}, Classify(err, "repository", "", "")
} }
return repositoryFrom(item), nil 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) { func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) {
var mergeStyle *gitea.MergeStyle var mergeStyle *gitea.MergeStyle
if opts.MergeStyle != nil { if opts.MergeStyle != nil {
+39
View File
@@ -79,3 +79,42 @@ func TestRepositoryAssigneesTagsAndMissingPermissions(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, RepositoryPermissions{}, permissions) 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`)
}
+4 -1
View File
@@ -121,7 +121,10 @@ func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions)
if err != nil { if err != nil {
return Page[Repository]{}, err 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) { 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 options.ListOptions = list
return c.Repositories.SearchRepos(ctx, options) return c.Repositories.SearchRepos(ctx, options)
@@ -118,6 +118,22 @@ func TestRepositorySearchSendsExplicitFalseFilters(t *testing.T) {
require.Empty(t, page.Items) 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) { func TestRepositorySearchScopesOwner(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {