fix: send a team's permission before its units (Gitea 28), hidden private-org team reviews, long non-ASCII repo descriptions, -R NAME vs directory guard, flake fallback

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 21:30:21 +02:00
1 parent 33f3ff10b6
commit 757b5fa1b9
12 files changed
+150 -8

No files matched your search

+20 -1
View File
@@ -6,6 +6,7 @@ package services
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
@@ -238,6 +239,13 @@ func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOpt
Readme: opts.Readme, DefaultBranch: opts.DefaultBranch, TrustModel: gitea.TrustModel(opts.TrustModel),
ObjectFormatName: opts.ObjectFormat,
}
// TODO(sdk): CreateRepoOption.Validate counts the description in bytes, but Gitea allows 2048
// characters; a longer one is set by an edit once the repository exists.
description := opts.Description
later := len(description) > sdkDescriptionLimit
if later {
options.Description = ""
}
item, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Repository, *gitea.Response, error) {
if org != "" {
return c.Repositories.CreateOrgRepo(ctx, org, options)
@@ -251,9 +259,20 @@ func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOpt
}
return Repository{}, Classify(err, "repository", "", "")
}
return repositoryFrom(item), nil
created := repositoryFrom(item)
if later {
edited, err := s.Edit(ctx, created.FullName, RepositoryEdit{Description: &description})
if err != nil {
return created, fmt.Errorf("repository %s was created, but setting its description failed: %w", created.FullName, err)
}
return edited, nil
}
return created, nil
}
// sdkDescriptionLimit is the length in bytes that the SDK accepts for a new repository's description.
const sdkDescriptionLimit = 2048
// Edit updates only explicitly supplied resource fields.
func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) {
var mergeStyle *gitea.MergeStyle
+22
View File
@@ -6,6 +6,7 @@ package services
import (
"context"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -118,3 +119,24 @@ func TestRepositoryReadme(t *testing.T) {
_, _, err = (Repositories{Client: client}).Readme(context.Background(), "alice/project", "develp")
require.EqualError(t, err, `branch "develp" not found in alice/project`)
}
func TestRepositoryCreateSetsADescriptionBeyondTheSDKByteLimitByEdit(t *testing.T) {
description := strings.Repeat("日", 800) // 2400 bytes, 800 characters: Gitea accepts it, the SDK would not
var methods []string
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
body := requestJSON(t, r)
switch r.Method {
case http.MethodPost:
require.Empty(t, body["description"])
_, _ = w.Write([]byte(`{"id":1,"name":"project","full_name":"alice/project"}`))
case http.MethodPatch:
require.Equal(t, description, body["description"])
_, _ = w.Write([]byte(`{"id":1,"name":"project","full_name":"alice/project","description":"` + description + `"}`))
}
}))
created, err := (Repositories{Client: client}).Create(context.Background(), "", RepositoryOptions{Name: "project", Description: description})
require.NoError(t, err)
require.Equal(t, []string{http.MethodPost, http.MethodPatch}, methods)
require.Equal(t, description, created.Description)
}
+14 -2
View File
@@ -99,7 +99,8 @@ func (s Reviews) changeRequests(ctx context.Context, n int64, changes ReviewRequ
// CheckRequestTeams verifies that every named team exists in the repository's organization. Gitea
// lets only organization members list teams, though outside collaborators and the pull request
// author may request reviews; when the lookup is forbidden the server checks the names, as for gh.
// author may request reviews; when the lookup is forbidden, or the organization is hidden from the
// caller (a private organization answers 404 to non-members), the server checks the names, as for gh.
func (s Reviews) CheckRequestTeams(ctx context.Context, names []string) error {
owner, _, _ := strings.Cut(s.Repository, "/")
_, isOrg, err := organization(ctx, s.Client, owner)
@@ -110,10 +111,21 @@ func (s Reviews) CheckRequestTeams(ctx context.Context, names []string) error {
return err
}
if !isOrg {
// A private organization answers 404 to non-members, like a name nobody owns; a user that
// exists is certainly not an organization.
_, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) {
return c.Users.GetUserInfo(ctx, owner)
})
if IsNotFound(err) || IsForbidden(err) {
return nil
}
if err != nil {
return err
}
return errors.New("team reviews require an organization repository")
}
teams, err := (Teams{Client: s.Client}).ByName(ctx, owner)
if IsForbidden(err) {
if IsForbidden(err) || IsNotFound(err) {
return nil
}
if err != nil {
+22
View File
@@ -95,3 +95,25 @@ func TestUnknownReviewerTeamRejectsBeforeUserRemoval(t *testing.T) {
_, err := (Reviews{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).ChangeRequests(context.Background(), 1, changes)
require.ErrorContains(t, err, `team "missing" is unavailable`)
}
func TestReviewerTeamCheckSkippedForAHiddenPrivateOrganization(t *testing.T) {
var posted bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/owner" || r.URL.Path == "/api/v1/users/owner":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"GetUserByName"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/pulls/1/requested_reviewers":
posted = true
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`[]`))
default:
_, _ = w.Write([]byte(`{"number":1}`))
}
}))
defer server.Close()
changes := ReviewRequestChanges{Add: ReviewRequestInput{Teams: []string{"devs"}}}
_, err := (Reviews{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).ChangeRequests(context.Background(), 1, changes)
require.NoError(t, err)
require.True(t, posted, "a non-member cannot see the organization, so the server decides")
}
+17
View File
@@ -250,6 +250,7 @@ func (s Teams) Create(ctx context.Context, org string, opts TeamOptions) (Team,
// permissions when changing access to all repositories. Gitea replaces the whole policy on edit.
func (s Teams) Edit(ctx context.Context, id int64, opts TeamOptions) (Team, error) {
repositoryPolicy := opts.AllRepositories != nil && opts.Permission == nil
changesPermission := opts.Permission != nil
if repositoryPolicy || (opts.Units != nil && !opts.ReplaceUnits) {
current, err := s.View(ctx, id)
if err != nil {
@@ -274,6 +275,22 @@ func (s Teams) Edit(ctx context.Context, id int64, opts TeamOptions) (Team, erro
opts.Units = units
}
}
// Gitea 28 keeps an admin team admin when one request changes its permission and sets units_map,
// so change the permission alone first and then apply the units.
if changesPermission && opts.Units != nil {
first := TeamOptions{Name: opts.Name, Permission: opts.Permission}
if first.Name == nil {
current, err := s.View(ctx, id)
if err != nil {
return Team{}, err
}
first.Name = &current.Name
}
// TODO(sdk): EditTeamOption requires a name of at most 30 characters and a permission, and EditTeam should return the team.
if err := sendJSON(ctx, s.Client, http.MethodPatch, fmt.Sprintf("teams/%d", id), first, nil); err != nil {
return Team{}, err
}
}
var team gitea.Team
// TODO(sdk): EditTeamOption requires a name of at most 30 characters and a permission, and EditTeam should return the team.
if err := sendJSON(ctx, s.Client, http.MethodPatch, fmt.Sprintf("teams/%d", id), opts, &team); err != nil {
+19
View File
@@ -179,3 +179,22 @@ func TestTeamFindSearchesTheOrganization(t *testing.T) {
require.EqualError(t, err, `organization "nope" not found`)
require.Equal(t, []string{"devops", "devops", "", "", "DEV", "DEV"}, queries)
}
func TestTeamEditSendsThePermissionBeforeTheUnits(t *testing.T) {
var bodies []map[string]any
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/teams/5", r.URL.Path)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":5,"name":"devs","permission":"admin"}`))
return
}
bodies = append(bodies, requestJSON(t, r))
_, _ = w.Write([]byte(`{"id":5,"name":"devs","permission":"write"}`))
}))
permission := "write"
_, err := (Teams{Client: client}).Edit(context.Background(), 5, TeamOptions{Permission: &permission, Units: map[string]string{"repo.wiki": "read"}, ReplaceUnits: true})
require.NoError(t, err)
require.Len(t, bodies, 2)
require.Equal(t, map[string]any{"name": "devs", "permission": "write"}, bodies[0], "Gitea 28 keeps an admin team admin when one request sets both")
require.Equal(t, map[string]any{"repo.wiki": "read"}, bodies[1]["units_map"])
}