* move error-related code for groups to its own file

* update group avatar logic

remove unused/duplicate logic

* update `FindGroupsOptions.ToConds()`

allow passing `-1` as the `ParentGroupID`, meaning "find matching groups regardless of the parent group id"

* add `DedupeBy` function to container module

this removes duplicate items from a slice using a custom function

* add `SliceMap` util

works like javascripts's `Array.prototoype.map`, taking in a slice and transforming each element with the provided function

* add group service

functions included so far:
- avatar uploading/deletion
- group deletion
- group creation
- group moving (including moving item inside a group)
- group update
- team management
  - add team
  - remove team
  - update team permissions
  - recalculating team access (in event of group move)
- group searching (only used in frontend/web components for now)
This commit is contained in:
☙◦ The Tablet ❀ GamerGirlandCo ◦❧
2026-04-02 20:00:45 -04:00
parent 1246721523
commit 96feb682fe
11 changed files with 739 additions and 90 deletions
+67
View File
@@ -0,0 +1,67 @@
package group
import (
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/modules/avatar"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
"context"
"errors"
"fmt"
"io"
"os"
)
// UploadAvatar saves custom icon for group.
func UploadAvatar(ctx context.Context, g *group_model.Group, data []byte) error {
avatarData, err := avatar.ProcessAvatarImage(data)
if err != nil {
return err
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
g.Avatar = avatar.HashAvatar(g.ID, data)
if err = UpdateGroup(ctx, g, &UpdateOptions{}); err != nil {
return fmt.Errorf("updateGroup: %w", err)
}
if err = storage.SaveFrom(storage.Avatars, g.CustomAvatarRelativePath(), func(w io.Writer) error {
_, err = w.Write(avatarData)
return err
}); err != nil {
return fmt.Errorf("Failed to create dir %s: %w", g.CustomAvatarRelativePath(), err)
}
return committer.Commit()
}
// DeleteAvatar deletes the user's custom avatar.
func DeleteAvatar(ctx context.Context, g *group_model.Group) error {
aPath := g.CustomAvatarRelativePath()
log.Trace("DeleteAvatar[%d]: %s", g.ID, aPath)
return db.WithTx(ctx, func(ctx context.Context) error {
hasAvatar := len(g.Avatar) > 0
g.Avatar = ""
if _, err := db.GetEngine(ctx).ID(g.ID).Cols("avatar, use_custom_avatar").Update(g); err != nil {
return fmt.Errorf("DeleteAvatar: %w", err)
}
if hasAvatar {
if err := storage.Avatars.Delete(aPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove %s: %w", aPath, err)
}
log.Warn("Deleting avatar %s but it doesn't exist", aPath)
}
}
return nil
})
}
+84
View File
@@ -0,0 +1,84 @@
package group
import (
"context"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
repo_model "code.gitea.io/gitea/models/repo"
)
func DeleteGroup(ctx context.Context, gid int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
sess := db.GetEngine(ctx)
toDelete, err := group_model.GetGroupByID(ctx, gid)
if err != nil {
return err
}
// remove team permissions and units for deleted group
if _, err = sess.Where("group_id = ?", gid).Delete(new(group_model.GroupTeam)); err != nil {
return err
}
if _, err = sess.Where("group_id = ?", gid).Delete(new(group_model.GroupUnit)); err != nil {
return err
}
// move all repos in the deleted group to its immediate parent
repos, cnt, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
GroupID: gid,
})
if err != nil {
return err
}
_, inParent, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
GroupID: toDelete.ParentGroupID,
})
if err != nil {
return err
}
if cnt > 0 {
for i, repo := range repos {
repo.GroupID = toDelete.ParentGroupID
repo.GroupSortOrder = int(inParent + int64(i) + 1)
}
if _, err = sess.Where("group_id = ?", gid).Update(&repos); err != nil {
return err
}
}
// move all child groups to the deleted group's immediate parent
childGroups, err := group_model.FindGroups(ctx, &group_model.FindGroupsOptions{
ParentGroupID: gid,
})
if err != nil {
return err
}
if len(childGroups) > 0 {
inParent, err = group_model.CountGroups(ctx, &group_model.FindGroupsOptions{
ParentGroupID: toDelete.ParentGroupID,
})
if err != nil {
return err
}
for i, group := range childGroups {
group.ParentGroupID = toDelete.ParentGroupID
group.SortOrder = int(inParent) + i + 1
}
if _, err = sess.Where("parent_group_id = ?", gid).Update(&childGroups); err != nil {
return err
}
}
// finally, delete the group itself
if _, err = sess.ID(gid).Delete(new(group_model.Group)); err != nil {
return err
}
return committer.Commit()
}
+90
View File
@@ -0,0 +1,90 @@
package group
import (
"context"
"strings"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
func NewGroup(ctx context.Context, g *group_model.Group) (err error) {
if len(g.Name) == 0 {
return util.NewInvalidArgumentErrorf("empty group name")
}
has, err := db.ExistByID[user_model.User](ctx, g.OwnerID)
if err != nil {
return err
}
if !has {
return organization.ErrOrgNotExist{ID: g.OwnerID}
}
g.LowerName = strings.ToLower(g.Name)
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err = db.Insert(ctx, g); err != nil {
return
}
if err = RecalculateGroupAccess(ctx, g, true); err != nil {
return
}
return committer.Commit()
}
func MoveRepositoryToGroup(ctx context.Context, repo *repo_model.Repository, newGroupID int64, groupSortOrder int) error {
sess := db.GetEngine(ctx)
repo.GroupID = newGroupID
repo.GroupSortOrder = groupSortOrder
cnt, err := sess.
Table("repository").
ID(repo.ID).
MustCols("group_id").
Update(repo)
log.Info("updated %d rows?", cnt)
return err
}
func MoveGroupItem(ctx context.Context, itemID, newParent int64, isGroup bool, newPos int) (err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if isGroup {
group, err := group_model.GetGroupByID(ctx, itemID)
if err != nil {
return err
}
if group.ParentGroupID != newParent || group.SortOrder != newPos {
if err = group_model.MoveGroup(ctx, group, newParent, newPos); err != nil {
return err
}
if err = RecalculateGroupAccess(ctx, group, false); err != nil {
return err
}
}
} else {
repo, err := repo_model.GetRepositoryByID(ctx, itemID)
if err != nil {
return err
}
if repo.GroupID != newParent || repo.GroupSortOrder != newPos {
if err = MoveRepositoryToGroup(ctx, repo, newParent, newPos); err != nil {
return err
}
}
}
return committer.Commit()
}
+199
View File
@@ -0,0 +1,199 @@
package group
import (
"context"
"slices"
"code.gitea.io/gitea/models/git"
group_model "code.gitea.io/gitea/models/group"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/convert"
repo_service "code.gitea.io/gitea/services/repository"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
)
type WebSearchGroup struct {
Group *structs.Group `json:"group,omitempty"`
LatestCommitStatus *git.CommitStatus `json:"latest_commit_status"`
LocaleLatestCommitStatus string `json:"locale_latest_commit_status"`
Subgroups []*WebSearchGroup `json:"subgroups"`
Repos []*repo_service.WebSearchRepository `json:"repos"`
}
type GroupWebSearchResult struct {
OK bool `json:"ok"`
Data *WebSearchGroup `json:"data"`
}
type GroupWebSearchOptions struct {
Ctx context.Context
Locale translation.Locale
Recurse bool
Actor *user_model.User
RepoOpts *repo_model.SearchRepoOptions
GroupOpts *group_model.FindGroupsOptions
OrgID int64
}
// results for root-level queries //
type WebSearchGroupRoot struct {
Groups []*WebSearchGroup
Repos []*repo_service.WebSearchRepository
}
type GroupWebSearchRootResult struct {
OK bool `json:"ok"`
Data *WebSearchGroupRoot `json:"data"`
}
func ToWebSearchRepo(ctx context.Context, repo *repo_model.Repository) *repo_service.WebSearchRepository {
return &repo_service.WebSearchRepository{
Repository: &structs.Repository{
ID: repo.ID,
FullName: repo.FullName(),
Fork: repo.IsFork,
Private: repo.IsPrivate,
Template: repo.IsTemplate,
Mirror: repo.IsMirror,
Stars: repo.NumStars,
HTMLURL: repo.HTMLURL(ctx),
Link: repo.Link(),
Internal: !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePrivate,
GroupSortOrder: repo.GroupSortOrder,
GroupID: repo.GroupID,
},
}
}
func (w *WebSearchGroup) doLoadChildren(opts *GroupWebSearchOptions) error {
opts.RepoOpts.OwnerID = opts.OrgID
opts.RepoOpts.GroupID = 0
opts.GroupOpts.OwnerID = opts.OrgID
opts.GroupOpts.ParentGroupID = 0
if w.Group != nil {
opts.RepoOpts.GroupID = w.Group.ID
opts.RepoOpts.ListAll = true
opts.GroupOpts.ParentGroupID = w.Group.ID
opts.GroupOpts.ListAll = true
}
repos, _, err := repo_model.SearchRepository(opts.Ctx, opts.RepoOpts)
if err != nil {
return err
}
slices.SortStableFunc(repos, func(a, b *repo_model.Repository) int {
return a.GroupSortOrder - b.GroupSortOrder
})
latestCommitStatuses, err := commitstatus_service.FindReposLastestCommitStatuses(opts.Ctx, repos)
if err != nil {
log.Error("FindReposLastestCommitStatuses: %v", err)
return err
}
latestIdx := -1
for i, r := range repos {
wsr := ToWebSearchRepo(opts.Ctx, r)
if latestCommitStatuses[i] != nil {
wsr.LatestCommitStatus = latestCommitStatuses[i]
wsr.LocaleLatestCommitStatus = latestCommitStatuses[i].LocaleString(opts.Locale)
if latestIdx > -1 {
if latestCommitStatuses[i].UpdatedUnix.AsLocalTime().Unix() > int64(latestCommitStatuses[latestIdx].UpdatedUnix.AsLocalTime().Unix()) {
latestIdx = i
}
} else {
latestIdx = i
}
}
w.Repos = append(w.Repos, wsr)
}
if w.Group != nil && latestIdx > -1 {
w.LatestCommitStatus = latestCommitStatuses[latestIdx]
}
w.Subgroups = make([]*WebSearchGroup, 0)
groups, err := group_model.FindGroupsByCond(opts.Ctx, opts.GroupOpts, group_model.AccessibleGroupCondition(opts.Actor, unit.TypeInvalid))
if err != nil {
return err
}
for _, g := range groups {
toAppend, err := ToWebSearchGroup(g, opts)
if err != nil {
return err
}
w.Subgroups = append(w.Subgroups, toAppend)
}
if opts.Recurse {
for _, sg := range w.Subgroups {
err = sg.doLoadChildren(opts)
if err != nil {
return err
}
}
}
return nil
}
func ToWebSearchGroup(group *group_model.Group, opts *GroupWebSearchOptions) (*WebSearchGroup, error) {
res := new(WebSearchGroup)
res.Repos = make([]*repo_service.WebSearchRepository, 0)
res.Subgroups = make([]*WebSearchGroup, 0)
var err error
if group != nil {
if res.Group, err = convert.ToAPIGroup(opts.Ctx, group, opts.Actor); err != nil {
return nil, err
}
}
return res, nil
}
func SearchRepoGroupWeb(group *group_model.Group, opts *GroupWebSearchOptions) (*GroupWebSearchResult, error) {
res := new(WebSearchGroup)
var err error
res, err = ToWebSearchGroup(group, opts)
if err != nil {
return nil, err
}
err = res.doLoadChildren(opts)
if err != nil {
return nil, err
}
return &GroupWebSearchResult{
Data: res,
OK: true,
}, nil
}
/* func SearchRootItems(ctx context.Context, oid int64, groupSearchOptions *group_model.FindGroupsOptions, repoSearchOptions *repo_model.SearchRepoOptions, actor *user_model.User, recursive bool) (*WebSearchGroupRoot, error) {
root := &WebSearchGroupRoot{
Repos: make([]*repo_service.WebSearchRepository, 0),
Groups: make([]*WebSearchGroup, 0),
}
groupSearchOptions.ParentGroupID = 0
groups, err := group_model.FindGroupsByCond(ctx, groupSearchOptions, group_model.AccessibleGroupCondition(actor, unit.TypeInvalid))
if err != nil {
return nil, err
}
for _, g := range groups {
toAppend, err := ToWebSearchGroup(ctx, g, actor, oid)
if err != nil {
return nil, err
}
root.Groups = append(root.Groups, toAppend)
}
repos, _, err := repo_model.SearchRepositoryByCondition(ctx, repoSearchOptions, repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid), true)
if err != nil {
return nil, err
}
for _, r := range repos {
root.Repos = append(root.Repos, ToWebSearchRepo(ctx, r))
}
return root, nil
}
*/
+147
View File
@@ -0,0 +1,147 @@
package group
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"xorm.io/builder"
)
func AddTeamToGroup(ctx context.Context, group *group_model.Group, tname string) error {
t, err := org_model.GetTeam(ctx, group.OwnerID, tname)
if err != nil {
return err
}
has := group_model.HasTeamGroup(ctx, group.OwnerID, t.ID, group.ID)
if has {
return fmt.Errorf("team '%s' already exists in group[%d]", tname, group.ID)
} else {
parentGroup, err := group_model.FindGroupTeamByTeamID(ctx, group.ID, t.ID)
if err != nil {
return err
}
mode := t.AccessMode
canCreateIn := t.CanCreateOrgRepo
if parentGroup != nil {
mode = max(t.AccessMode, parentGroup.AccessMode)
canCreateIn = parentGroup.CanCreateIn || t.CanCreateOrgRepo
}
if err = group.LoadParentGroup(ctx); err != nil {
return err
}
err = group_model.AddTeamGroup(ctx, group.ID, t.ID, group.ID, mode, canCreateIn)
if err != nil {
return err
}
}
return nil
}
func DeleteTeamFromGroup(ctx context.Context, group *group_model.Group, org int64, teamName string) error {
team, err := org_model.GetTeam(ctx, org, teamName)
if err != nil {
return err
}
if err = group_model.RemoveTeamGroup(ctx, org, team.ID, group.ID); err != nil {
return err
}
return nil
}
func UpdateGroupTeam(ctx context.Context, gt *group_model.GroupTeam) (err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
sess := db.GetEngine(ctx)
if _, err = sess.ID(gt.ID).AllCols().Update(gt); err != nil {
return fmt.Errorf("update: %w", err)
}
for _, unit := range gt.Units {
unit.TeamID = gt.TeamID
if _, err = sess.
Where("team_id=?", gt.TeamID).
And("group_id=?", gt.GroupID).
And("type = ?", unit.Type).
Update(unit); err != nil {
return
}
}
return committer.Commit()
}
// RecalculateGroupAccess recalculates team access to a group.
// should only be called if and only if a group was moved from another group.
func RecalculateGroupAccess(ctx context.Context, g *group_model.Group, isNew bool) (err error) {
sess := db.GetEngine(ctx)
if err = g.LoadParentGroup(ctx); err != nil {
return
}
var teams []*org_model.Team
if g.ParentGroup == nil {
teams, err = org_model.FindOrgTeams(ctx, g.OwnerID)
if err != nil {
return
}
} else {
teams, err = org_model.GetTeamsWithAccessToGroup(ctx, g.OwnerID, g.ParentGroupID, perm.AccessModeRead)
}
for _, t := range teams {
var gt *group_model.GroupTeam = nil
if gt, err = group_model.FindGroupTeamByTeamID(ctx, g.ParentGroupID, t.ID); err != nil {
return
}
if gt != nil {
if err = group_model.UpdateTeamGroup(ctx, g.OwnerID, t.ID, g.ID, gt.AccessMode, gt.CanCreateIn, isNew); err != nil {
return
}
} else {
if err = group_model.UpdateTeamGroup(ctx, g.OwnerID, t.ID, g.ID, t.AccessMode, t.IsOwnerTeam() || t.AccessMode >= perm.AccessModeAdmin || t.CanCreateOrgRepo, isNew); err != nil {
return
}
}
if err = t.LoadUnits(ctx); err != nil {
return
}
for _, u := range t.Units {
newAccessMode := u.AccessMode
if g.ParentGroup == nil {
gu, err := group_model.GetGroupUnit(ctx, g.ID, t.ID, u.Type)
if err != nil {
return err
}
newAccessMode = min(newAccessMode, gu.AccessMode)
}
if isNew {
if _, err = sess.Table("group_unit").Insert(&group_model.GroupUnit{
Type: u.Type,
TeamID: t.ID,
GroupID: g.ID,
AccessMode: newAccessMode,
}); err != nil {
return
}
} else {
if _, err = sess.Table("group_unit").Where(builder.Eq{
"type": u.Type,
"team_id": t.ID,
"group_id": g.ID,
}).Update(&group_model.GroupUnit{
AccessMode: newAccessMode,
}); err != nil {
return err
}
}
}
}
return
}
+31
View File
@@ -0,0 +1,31 @@
package group
import (
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/structs"
"context"
"strings"
)
type UpdateOptions struct {
Name optional.Option[string]
Description optional.Option[string]
Visibility optional.Option[structs.VisibleType]
}
func UpdateGroup(ctx context.Context, g *group_model.Group, opts *UpdateOptions) error {
if opts.Name.Has() {
g.Name = opts.Name.Value()
g.LowerName = strings.ToLower(g.Name)
}
if opts.Description.Has() {
g.Description = opts.Description.Value()
}
if opts.Visibility.Has() {
g.Visibility = opts.Visibility.Value()
}
_, err := db.GetEngine(ctx).ID(g.ID).Update(g)
return err
}