postfix if the name is already taken
-func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) string {
+func getUniqueRepositoryName(ctx context.Context, ownerID, groupID int64, name string) string {
uniqueName := name
for i := 1; i < 1000; i++ {
- _, err := repo_model.GetRepositoryByName(ctx, ownerID, uniqueName)
+ _, err := repo_model.GetRepositoryByName(ctx, ownerID, groupID, uniqueName)
if err != nil || repo_model.IsErrRepoNotExist(err) {
return uniqueName
}
diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go
index deb3ae4f3a6f8..d9c68980bbd95 100644
--- a/routers/web/repo/githttp.go
+++ b/routers/web/repo/githttp.go
@@ -104,7 +104,7 @@ func httpBase(ctx *context.Context) *serviceHandler {
}
repoExist := true
- repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
+ repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, ctx.PathParamInt64("group_id"), reponame)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
ctx.ServerError("GetRepositoryByName", err)
diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go
index 1b700aa6da0a4..90cb3dba77a27 100644
--- a/routers/web/repo/repo.go
+++ b/routers/web/repo/repo.go
@@ -289,6 +289,7 @@ func CreatePost(ctx *context.Context) {
IsTemplate: form.Template,
TrustModel: repo_model.DefaultTrustModel,
ObjectFormatName: form.ObjectFormatName,
+ GroupID: form.ParentGroupID,
})
if err == nil {
log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name)
@@ -477,6 +478,7 @@ func SearchRepo(ctx *context.Context) {
Template: optional.None[bool](),
StarredByID: ctx.FormInt64("starredBy"),
IncludeDescription: ctx.FormBool("includeDesc"),
+ GroupID: ctx.FormInt64("group_id"),
}
if ctx.FormString("template") != "" {
@@ -567,16 +569,18 @@ func SearchRepo(ctx *context.Context) {
for i, repo := range repos {
results[i] = &repo_service.WebSearchRepository{
Repository: &api.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 == api.VisibleTypePrivate,
+ 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 == api.VisibleTypePrivate,
+ GroupSortOrder: repo.GroupSortOrder,
+ GroupID: repo.GroupID,
},
}
diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go
index af6708e841f46..b0b346e6b9b08 100644
--- a/routers/web/repo/setting/lfs.go
+++ b/routers/web/repo/setting/lfs.go
@@ -271,7 +271,7 @@ func LFSFileGet(ctx *context.Context) {
ctx.Data["IsTextFile"] = st.IsText()
ctx.Data["FileSize"] = meta.Size
// FIXME: the last field is the URL-base64-encoded filename, it should not be "direct"
- ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct")
+ ctx.Data["RawFileLink"] = fmt.Sprintf("%s/%s%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct")
switch {
case st.IsRepresentableAsText():
if meta.Size >= setting.UI.MaxDisplayFileSize {
diff --git a/routers/web/repo/star.go b/routers/web/repo/star.go
index 00c06b7d02d84..f9301e15d0192 100644
--- a/routers/web/repo/star.go
+++ b/routers/web/repo/star.go
@@ -21,7 +21,7 @@ func ActionStar(ctx *context.Context) {
}
ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
- ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
+ ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.GroupID, ctx.Repo.Repository.Name)
if err != nil {
ctx.ServerError("GetRepositoryByName", err)
return
diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go
index 70c548b8cea7f..c00b1d1b1fcfa 100644
--- a/routers/web/repo/watch.go
+++ b/routers/web/repo/watch.go
@@ -21,7 +21,7 @@ func ActionWatch(ctx *context.Context) {
}
ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
- ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
+ ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.GroupID, ctx.Repo.Repository.Name)
if err != nil {
ctx.ServerError("GetRepositoryByName", err)
return
diff --git a/routers/web/shared/group/header.go b/routers/web/shared/group/header.go
new file mode 100644
index 0000000000000..7aac072ab8071
--- /dev/null
+++ b/routers/web/shared/group/header.go
@@ -0,0 +1,24 @@
+package group
+
+import (
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+func LoadHeaderCount(ctx *context.Context) error {
+ repoCount, err := repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{
+ Actor: ctx.Doer,
+ Private: ctx.IsSigned,
+ GroupID: ctx.RepoGroup.Group.ID,
+ Collaborate: optional.Some(false),
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ })
+ if err != nil {
+ return err
+ }
+ ctx.Data["RepoCount"] = repoCount
+
+ return nil
+}
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
index 2bd0abc4c03f7..4f928b233925b 100644
--- a/routers/web/shared/user/header.go
+++ b/routers/web/shared/user/header.go
@@ -93,7 +93,7 @@ func prepareContextForProfileBigAvatar(ctx *context.Context) {
func FindOwnerProfileReadme(ctx *context.Context, doer *user_model.User, optProfileRepoName ...string) (profileDbRepo *repo_model.Repository, profileReadmeBlob *git.Blob) {
profileRepoName := util.OptionalArg(optProfileRepoName, RepoNameProfile)
- profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, profileRepoName)
+ profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParamInt64("group_id"), profileRepoName)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
log.Error("FindOwnerProfileReadme failed to GetRepositoryByName: %v", err)
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index b53a3daedb5ea..592bca592b7ea 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -110,7 +110,7 @@ func Dashboard(ctx *context.Context) {
}
if setting.Service.EnableUserHeatmap {
- data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
+ data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.RepoGroup.Group, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
return
@@ -122,6 +122,7 @@ func Dashboard(ctx *context.Context) {
feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctxUser,
RequestedTeam: ctx.Org.Team,
+ RequestedGroup: ctx.RepoGroup.Group,
Actor: ctx.Doer,
IncludePrivate: true,
OnlyPerformedBy: false,
@@ -170,6 +171,9 @@ func Milestones(ctx *context.Context) {
Archived: optional.Some(false),
HasMilestones: optional.Some(true), // Just needs display repos has milestones
}
+ if ctx.RepoGroup.Group != nil {
+ repoOpts.GroupID = ctx.RepoGroup.Group.ID
+ }
if ctxUser.IsOrganization() && ctx.Org.Team != nil {
repoOpts.TeamID = ctx.Org.Team.ID
@@ -474,6 +478,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
if opts.Team != nil {
repoOpts.TeamID = opts.Team.ID
}
+ if ctx.RepoGroup.Group != nil {
+ repoOpts.GroupID = ctx.RepoGroup.Group.ID
+ }
accessibleRepos := container.Set[int64]{}
{
ids, _, err := repo_model.SearchRepositoryIDs(ctx, repoOpts)
diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go
index 171c1933d4f49..08d6db7c31157 100644
--- a/routers/web/user/setting/adopt.go
+++ b/routers/web/user/setting/adopt.go
@@ -5,6 +5,8 @@ package setting
import (
"path/filepath"
+ "strconv"
+ "strings"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@@ -24,13 +26,17 @@ func AdoptOrDeleteRepository(ctx *context.Context) {
ctx.Data["allowDelete"] = allowDelete
dir := ctx.FormString("id")
+ var gid int64
+ if len(strings.Split(dir, "/")) > 1 {
+ gid, _ = strconv.ParseInt(strings.Split(dir, "/")[1], 10, 64)
+ }
action := ctx.FormString("action")
ctxUser := ctx.Doer
root := user_model.UserPath(ctxUser.LowerName)
// check not a repo
- has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir)
+ has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir, 0)
if err != nil {
ctx.ServerError("IsRepositoryExist", err)
return
@@ -53,7 +59,7 @@ func AdoptOrDeleteRepository(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
} else if action == "delete" && allowDelete {
- if err := repo_service.DeleteUnadoptedRepository(ctx, ctxUser, ctxUser, dir); err != nil {
+ if err := repo_service.DeleteUnadoptedRepository(ctx, ctxUser, ctxUser, dir, gid); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
diff --git a/routers/web/web.go b/routers/web/web.go
index 4a274c171a3a0..926945db40268 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -30,6 +30,7 @@ import (
"code.gitea.io/gitea/routers/web/events"
"code.gitea.io/gitea/routers/web/explore"
"code.gitea.io/gitea/routers/web/feed"
+ "code.gitea.io/gitea/routers/web/group"
"code.gitea.io/gitea/routers/web/healthcheck"
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/routers/web/org"
@@ -876,6 +877,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/org", func() {
m.Group("/{org}", func() {
+ m.Get("/-/search_team_candidates", optExploreSignIn, group.SearchTeamCandidates)
m.Get("/members", org.Members)
}, context.OrgAssignment(context.OrgAssignmentOptions{}))
}, optSignIn)
@@ -895,14 +897,43 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{org}", func() {
m.Get("/dashboard", user.Dashboard)
m.Get("/dashboard/{team}", user.Dashboard)
+ m.Get("/dashboard/group/{group_id}", ctxDataSet("PageIsGroupDashboard", true), context.GroupAssignment(context.GroupAssignmentOptions{RequireMember: true}), user.Dashboard)
m.Get("/issues", user.Issues)
m.Get("/issues/{team}", user.Issues)
+ m.Get("/issues/group/{group_id}", context.GroupAssignment(context.GroupAssignmentOptions{RequireMember: true}), user.Issues)
m.Get("/pulls", user.Pulls)
m.Get("/pulls/{team}", user.Pulls)
+ m.Get("/pulls/group/{group_id}", context.GroupAssignment(context.GroupAssignmentOptions{RequireMember: true}), user.Pulls)
m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones)
m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones)
+ m.Get("/milestones/group/{group_id}", reqMilestonesDashboardPageEnabled, user.Milestones)
m.Post("/members/action/{action}", org.MembersAction)
m.Get("/teams", org.Teams)
+
+ m.Group("/groups", func() {
+ m.Combo("/new").
+ Get(group.NewGroup).
+ Post(web.Bind(forms.CreateGroupForm{}), group.NewGroupPost)
+ m.Group("/{group_id}", func() {
+ m.Get("/teams", group.Teams)
+ m.Post("/teams/add", group.TeamAddPost)
+ m.Combo("/teams/{team}/edit").
+ Get(group.EditTeam).
+ Post(web.Bind(forms.CreateGroupTeamForm{}), group.EditTeamPost)
+ m.Post("/teams/{team}/remove", group.TeamRemove)
+ m.Group("/settings", func() {
+ m.Combo("").
+ Get(group.Settings).
+ Post(web.Bind(forms.UpdateGroupSettingForm{}), group.SettingsPost)
+ m.Post("/avatar", web.Bind(forms.AvatarForm{}), group.SettingsAvatar)
+ m.Post("/avatar/delete", group.SettingsDeleteAvatar)
+ }, ctxDataSet("PageIsGroupSettings", true))
+ }, context.GroupAssignment(context.GroupAssignmentOptions{
+ RequireMember: true,
+ RequireOwner: false,
+ RequireGroupAdmin: true,
+ }))
+ })
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
m.Group("/{org}", func() {
@@ -1001,6 +1032,11 @@ func registerWebRoutes(m *web.Router) {
}, reqSignIn)
// end "/org": most org routes
+ m.Group("/group", func() {
+ m.Get("/search", group.SearchGroup)
+ }, reqSignIn)
+ // end "/group": search
+
m.Group("/repo", func() {
m.Get("/create", repo.Create)
m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost)
@@ -1070,15 +1106,29 @@ func registerWebRoutes(m *web.Router) {
}, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false), individualPermsChecker)
}, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment(context.OrgAssignmentOptions{}))
// end "/{username}/-": packages, projects, code
+ m.Group("/{username}/groups", func() {
+ m.Group("/{group_id}", func() {
+ m.Get("", group.Home)
+ }, context.GroupAssignment(context.GroupAssignmentOptions{}))
+ }, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment(context.OrgAssignmentOptions{}))
- m.Group("/{username}/{reponame}/-", func() {
+ m.Group("/{username}/groups", func() {
+ m.Post("/items/move", group.MoveGroupItem)
+ }, context.UserAssignmentWeb(), context.OrgAssignment(context.OrgAssignmentOptions{
+ RequireMember: true,
+ }))
+ // end "/{username}/groups"
+
+ repoDashFn := func() {
m.Group("/migrate", func() {
m.Get("/status", repo.MigrateStatus)
})
- }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
- // end "/{username}/{reponame}/-": migrate
+ }
+ m.Group("/{username}/{reponame}/-", repoDashFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ m.Group("/{username}/group/{group_id}/{reponame}/-", repoDashFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ // end "/{username}/{group_id}/{reponame}/-": migrate
- m.Group("/{username}/{reponame}/settings", func() {
+ settingsFn := func() {
m.Group("", func() {
m.Combo("").Get(repo_setting.Settings).
Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
@@ -1166,18 +1216,24 @@ func registerWebRoutes(m *web.Router) {
m.Post("/retry", repo.MigrateRetryPost)
m.Post("/cancel", repo.MigrateCancelPost)
})
- },
+ }
+ m.Group("/{username}/{reponame}/settings", settingsFn,
+ reqSignIn, context.RepoAssignment, reqRepoAdmin,
+ ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer),
+ )
+ m.Group("/{username}/group/{group_id}/{reponame}/settings", settingsFn,
reqSignIn, context.RepoAssignment, reqRepoAdmin,
ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer),
)
- // end "/{username}/{reponame}/settings"
+ // end "/{username}/{group_id}/{reponame}/settings"
- // user/org home, including rss feeds like "/{username}/{reponame}.rss"
+ // user/org home, including rss feeds like "/{username}/{group_id}/{reponame}.rss"
m.Get("/{username}/{reponame}", optSignIn, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home)
+ m.Get("/{username}/group/{group_id}/{reponame}", optSignIn, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home)
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
-
- m.Group("/{username}/{reponame}", func() {
+ m.Post("/{username}/group/{group_id}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
+ rootRepoFn := func() {
m.Get("/find/*", repo.FindFiles)
m.Group("/tree-list", func() {
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
@@ -1194,11 +1250,13 @@ func registerWebRoutes(m *web.Router) {
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
m.Get("/pulls/new/*", repo.PullsNewRedirect)
- }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
- // end "/{username}/{reponame}": repo code: find, compare, list
+ }
+ m.Group("/{username}/{reponame}", rootRepoFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ m.Group("/{username}/group/{group_id}/{reponame}", rootRepoFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ // end "/{username}/{group_id}/{reponame}": repo code: find, compare, list
addIssuesPullsViewRoutes := func() {
- // for /{username}/{reponame}/issues" or "/{username}/{reponame}/pulls"
+ // for /{username}/{group_id}/{reponame}/issues" or "/{username}/{group_id}/{reponame}/pulls"
m.Get("/posters", repo.IssuePullPosters)
m.Group("/{index}", func() {
m.Get("/info", repo.GetIssueInfo)
@@ -1212,25 +1270,32 @@ func registerWebRoutes(m *web.Router) {
})
}
// FIXME: many "pulls" requests are sent to "issues" endpoints correctly, so the issue endpoints have to tolerate pull request permissions at the moment
+ m.Group("/{username}/group/{group_id}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests))
m.Group("/{username}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests))
+ m.Group("/{username}/group/{group_id}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader)
m.Group("/{username}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader)
- m.Group("/{username}/{reponame}", func() {
+ repoIssueAttachmentFn := func() {
m.Get("/comments/{id}/attachments", repo.GetCommentAttachments)
m.Get("/labels", repo.RetrieveLabelsForList, repo.Labels)
m.Get("/milestones", repo.Milestones)
m.Get("/milestone/{id}", repo.MilestoneIssuesAndPulls)
m.Get("/issues/suggestions", repo.IssueSuggestions)
- }, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones
- // end "/{username}/{reponame}": view milestone, label, issue, pull, etc
+ }
- m.Group("/{username}/{reponame}/{type:issues}", func() {
+ m.Group("/{username}/group/{group_id}/{reponame}", repoIssueAttachmentFn, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones
+ m.Group("/{username}/{reponame}", repoIssueAttachmentFn, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones
+ // end "/{username}/{group_id}/{reponame}": view milestone, label, issue, pull, etc
+
+ issueViewFn := func() {
m.Get("", repo.Issues)
m.Get("/{index}", repo.ViewIssue)
- }, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypeExternalTracker))
- // end "/{username}/{reponame}": issue/pull list, issue/pull view, external tracker
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}/{type:issues}", issueViewFn, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypeExternalTracker))
+ m.Group("/{username}/{reponame}/{type:issues}", issueViewFn, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypeExternalTracker))
+ // end "/{username}/{group_id}/{reponame}": issue/pull list, issue/pull view, external tracker
- m.Group("/{username}/{reponame}", func() { // edit issues, pulls, labels, milestones, etc
+ editIssueFn := func() { // edit issues, pulls, labels, milestones, etc
m.Group("/issues", func() {
m.Group("/new", func() {
m.Combo("").Get(repo.NewIssue).
@@ -1241,7 +1306,7 @@ func registerWebRoutes(m *web.Router) {
}, reqUnitIssuesReader)
addIssuesPullsUpdateRoutes := func() {
- // for "/{username}/{reponame}/issues" or "/{username}/{reponame}/pulls"
+ // for "/{username}/{group_id}/{reponame}/issues" or "/{username}/{group_id}/{reponame}/pulls"
m.Group("/{index}", func() {
m.Post("/title", repo.UpdateIssueTitle)
m.Post("/content", repo.UpdateIssueContent)
@@ -1317,10 +1382,12 @@ func registerWebRoutes(m *web.Router) {
m.Post("/resolve_conversation", repo.SetShowOutdatedComments, repo.UpdateResolveConversation)
}, reqUnitPullsReader)
m.Post("/pull/{index}/target_branch", reqUnitPullsReader, repo.UpdatePullRequestTarget)
- }, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
- // end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", editIssueFn, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
+ m.Group("/{username}/{reponame}", editIssueFn, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
+ // end "/{username}/{group_id}/{reponame}": create or edit issues, pulls, labels, milestones
- m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader")
+ codeFn := func() { // repo code (at least "code reader")
m.Group("", func() {
m.Group("", func() {
// "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission.
@@ -1368,10 +1435,12 @@ func registerWebRoutes(m *web.Router) {
}, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty)
m.Combo("/fork").Get(repo.Fork).Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost)
- }, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
- // end "/{username}/{reponame}": repo code
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", codeFn, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
+ m.Group("/{username}/{reponame}", codeFn, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
+ // end "/{username}/{group_id}/{reponame}": repo code
- m.Group("/{username}/{reponame}", func() { // repo tags
+ repoTagFn := func() { // repo tags
m.Group("/tags", func() {
m.Get("", context.RepoRefByDefaultBranch() /* for the "commits" tab */, repo.TagsList)
m.Get(".rss", feedEnabled, repo.TagsListFeedRSS)
@@ -1379,10 +1448,12 @@ func registerWebRoutes(m *web.Router) {
m.Get("/list", repo.GetTagList)
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed))
m.Post("/tags/delete", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.DeleteTag)
- }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
- // end "/{username}/{reponame}": repo tags
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoTagFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
+ m.Group("/{username}/{reponame}", repoTagFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
+ // end "/{username}/{group_id}/{reponame}": repo tags
- m.Group("/{username}/{reponame}", func() { // repo releases
+ repoReleaseFn := func() { // repo releases
m.Group("/releases", func() {
m.Get("", repo.Releases)
m.Get(".rss", feedEnabled, repo.ReleasesFeedRSS)
@@ -1403,25 +1474,33 @@ func registerWebRoutes(m *web.Router) {
m.Get("/edit/*", repo.EditRelease)
m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost)
}, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache)
- }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
- // end "/{username}/{reponame}": repo releases
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoReleaseFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
+ m.Group("/{username}/{reponame}", repoReleaseFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
+ // end "/{username}/{group_id}/{reponame}": repo releases
- m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
+ repoAttachmentsFn := func() { // to maintain compatibility with old attachments
m.Get("/attachments/{uuid}", repo.GetAttachment)
- }, optSignIn, context.RepoAssignment)
- // end "/{username}/{reponame}": compatibility with old attachments
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoAttachmentsFn, optSignIn, context.RepoAssignment)
+ m.Group("/{username}/{reponame}", repoAttachmentsFn, optSignIn, context.RepoAssignment)
+ // end "/{username}/{group_id}/{reponame}": compatibility with old attachments
- m.Group("/{username}/{reponame}", func() {
+ repoTopicFn := func() {
m.Post("/topics", repo.TopicsPost)
- }, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoTopicFn, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
+ m.Group("/{username}/{reponame}", repoTopicFn, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
- m.Group("/{username}/{reponame}", func() {
+ repoPackageFn := func() {
if setting.Packages.Enabled {
m.Get("/packages", repo.Packages)
}
- }, optSignIn, context.RepoAssignment)
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoPackageFn, optSignIn, context.RepoAssignment)
+ m.Group("/{username}/{reponame}", repoPackageFn, optSignIn, context.RepoAssignment)
- m.Group("/{username}/{reponame}/projects", func() {
+ repoProjectsFn := func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
@@ -1445,10 +1524,12 @@ func registerWebRoutes(m *web.Router) {
})
})
}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
- }, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
- // end "/{username}/{reponame}/projects"
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}/projects", repoProjectsFn, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
+ m.Group("/{username}/{reponame}/projects", repoProjectsFn, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
+ // end "/{username}/{group_id}/{reponame}/projects"
- m.Group("/{username}/{reponame}/actions", func() {
+ repoActionsFn := func() {
m.Get("", actions.List)
m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile)
m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
@@ -1477,10 +1558,12 @@ func registerWebRoutes(m *web.Router) {
m.Group("/workflows/{workflow_name}", func() {
m.Get("/badge.svg", actions.GetWorkflowBadge)
})
- }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
- // end "/{username}/{reponame}/actions"
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}/actions", repoActionsFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
+ m.Group("/{username}/{reponame}/actions", repoActionsFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
+ // end "/{username}/{group_id}/{reponame}/actions"
- m.Group("/{username}/{reponame}/wiki", func() {
+ repoWikiFn := func() {
m.Combo("").
Get(repo.Wiki).
Post(context.RepoMustNotBeArchived(), reqSignIn, reqUnitWikiWriter, web.Bind(forms.NewWikiForm{}), repo.WikiPost)
@@ -1491,13 +1574,18 @@ func registerWebRoutes(m *web.Router) {
m.Get("/commit/{sha:[a-f0-9]{7,64}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff)
m.Get("/commit/{sha:[a-f0-9]{7,64}}.{ext:patch|diff}", repo.RawDiff)
m.Get("/raw/*", repo.WikiRaw)
- }, optSignIn, context.RepoAssignment, repo.MustEnableWiki, reqUnitWikiReader, func(ctx *context.Context) {
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}/wiki", repoWikiFn, optSignIn, context.RepoAssignment, repo.MustEnableWiki, reqUnitWikiReader, func(ctx *context.Context) {
+ ctx.Data["PageIsWiki"] = true
+ ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink(ctx, ctx.Doer)
+ })
+ m.Group("/{username}/{reponame}/wiki", repoWikiFn, optSignIn, context.RepoAssignment, repo.MustEnableWiki, reqUnitWikiReader, func(ctx *context.Context) {
ctx.Data["PageIsWiki"] = true
ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink(ctx, ctx.Doer)
})
- // end "/{username}/{reponame}/wiki"
+ // end "/{username}/{group_id}/{reponame}/wiki"
- m.Group("/{username}/{reponame}/activity", func() {
+ activityFn := func() {
// activity has its own permission checks
m.Get("", repo.Activity)
m.Get("/{period}", repo.Activity)
@@ -1516,13 +1604,18 @@ func registerWebRoutes(m *web.Router) {
m.Get("/data", repo.CodeFrequencyData) // "recent-commits" also uses the same data as "code-frequency"
})
}, reqUnitCodeReader)
- },
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}/activity", activityFn,
optSignIn, context.RepoAssignment, repo.MustBeNotEmpty,
context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases),
)
- // end "/{username}/{reponame}/activity"
+ m.Group("/{username}/{reponame}/activity", activityFn,
+ optSignIn, context.RepoAssignment, repo.MustBeNotEmpty,
+ context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases),
+ )
+ // end "/{username}/{group_id}/{reponame}/activity"
- m.Group("/{username}/{reponame}", func() {
+ repoPullFn := func() {
m.Get("/{type:pulls}", repo.Issues)
m.Group("/{type:pulls}/{index}", func() {
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue)
@@ -1549,10 +1642,12 @@ func registerWebRoutes(m *web.Router) {
}, context.RepoMustNotBeArchived())
})
})
- }, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
- // end "/{username}/{reponame}/pulls/{index}": repo pull request
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoPullFn, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
+ m.Group("/{username}/{reponame}", repoPullFn, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
+ // end "/{username}/{group_id}/{reponame}/pulls/{index}": repo pull request
- m.Group("/{username}/{reponame}", func() {
+ repoCodeFn := func() {
m.Group("/activity_author_data", func() {
m.Get("", repo.ActivityAuthors)
m.Get("/{period}", repo.ActivityAuthors)
@@ -1631,21 +1726,25 @@ func registerWebRoutes(m *web.Router) {
m.Get("/forks", repo.Forks)
m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff)
m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
- }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
- // end "/{username}/{reponame}": repo code
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", repoCodeFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ m.Group("/{username}/{reponame}", repoCodeFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ // end "/{username}/{group_id}/{reponame}": repo code
- m.Group("/{username}/{reponame}", func() {
+ fn := func() {
m.Get("/stars", starsEnabled, repo.Stars)
m.Get("/watchers", repo.Watchers)
m.Get("/search", reqUnitCodeReader, repo.Search)
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch)
m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
- }, optSignIn, context.RepoAssignment)
+ }
+ m.Group("/{username}/group/{group_id}/{reponame}", fn, optSignIn, context.RepoAssignment)
+ m.Group("/{username}/{reponame}", fn, optSignIn, context.RepoAssignment)
- common.AddOwnerRepoGitLFSRoutes(m, optSignInIgnoreCsrf, lfsServerEnabled) // "/{username}/{reponame}/{lfs-paths}": git-lfs support
+ common.AddOwnerRepoGitLFSRoutes(m, optSignInIgnoreCsrf, lfsServerEnabled) // "/{username}/{group_id}/{reponame}/{lfs-paths}": git-lfs support
- addOwnerRepoGitHTTPRouters(m) // "/{username}/{reponame}/{git-paths}": git http support
+ addOwnerRepoGitHTTPRouters(m) // "/{username}/{group_id}/{reponame}/{git-paths}": git http support
m.Group("/notifications", func() {
m.Get("", user.Notifications)
diff --git a/services/context/context.go b/services/context/context.go
index 32ec260aab931..b8ec77854f17c 100644
--- a/services/context/context.go
+++ b/services/context/context.go
@@ -60,9 +60,10 @@ type Context struct {
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
- Repo *Repository
- Org *Organization
- Package *Package
+ RepoGroup *RepoGroup
+ Repo *Repository
+ Org *Organization
+ Package *Package
}
type TemplateContext map[string]any
@@ -129,10 +130,11 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context {
Render: render,
Session: session,
- Cache: cache.GetCache(),
- Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
- Repo: &Repository{PullRequest: &PullRequest{}},
- Org: &Organization{},
+ Cache: cache.GetCache(),
+ Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
+ Repo: &Repository{PullRequest: &PullRequest{}},
+ Org: &Organization{},
+ RepoGroup: &RepoGroup{},
}
ctx.TemplateContext = NewTemplateContextForWeb(ctx)
ctx.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}}
diff --git a/services/context/group.go b/services/context/group.go
new file mode 100644
index 0000000000000..027417257ceb8
--- /dev/null
+++ b/services/context/group.go
@@ -0,0 +1,281 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "context"
+ "strings"
+
+ group_model "code.gitea.io/gitea/models/group"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ shared_group "code.gitea.io/gitea/models/shared/group"
+ "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/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+)
+
+type RepoGroup struct {
+ IsOwner bool
+ IsMember bool
+ IsGroupAdmin bool
+ Group *group_model.Group
+ GroupLink string
+ OrgGroupLink string
+ CanCreateRepoOrGroup bool
+ Team *organization.Team
+ Teams []*organization.Team
+ GroupTeam *group_model.RepoGroupTeam
+}
+
+func (g *RepoGroup) CanWriteUnit(ctx *Context, unitType unit.Type) bool {
+ return g.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeWrite
+}
+
+func (g *RepoGroup) CanReadUnit(ctx *Context, unitType unit.Type) bool {
+ return g.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeRead
+}
+
+func (g *RepoGroup) UnitPermission(ctx context.Context, doer *user_model.User, unitType unit.Type) perm.AccessMode {
+ if doer != nil {
+ teams, err := organization.GetUserGroupTeams(ctx, g.Group.ID, doer.ID)
+ if err != nil {
+ log.Error("GetUserOrgTeams: %v", err)
+ return perm.AccessModeNone
+ }
+
+ if err := teams.LoadUnits(ctx); err != nil {
+ log.Error("LoadUnits: %v", err)
+ return perm.AccessModeNone
+ }
+
+ if len(teams) > 0 {
+ return teams.UnitMaxAccess(unitType)
+ }
+ }
+
+ if g.Group.Visibility.IsPublic() {
+ return perm.AccessModeRead
+ }
+
+ return perm.AccessModeNone
+}
+
+func GetGroupByParams(ctx *Context) {
+ groupID := ctx.PathParamInt64("group_id")
+
+ var err error
+ ctx.RepoGroup.Group, err = group_model.GetGroupByID(ctx, groupID)
+ if err != nil {
+ if group_model.IsErrGroupNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.ServerError("GetUserByName", err)
+ }
+ return
+ }
+ if err = ctx.RepoGroup.Group.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ }
+}
+
+type GroupAssignmentOptions struct {
+ RequireMember bool
+ RequireOwner bool
+ RequireGroupAdmin bool
+}
+
+func GroupAssignment(args GroupAssignmentOptions) func(ctx *Context) {
+ return func(ctx *Context) {
+ var err error
+
+ if ctx.RepoGroup.Group == nil {
+ GetGroupByParams(ctx)
+ if ctx.Written() {
+ return
+ }
+ }
+
+ group := ctx.RepoGroup.Group
+ if ctx.RepoGroup.Group.Visibility != structs.VisibleTypePublic && !ctx.IsSigned {
+ ctx.NotFound(err)
+ return
+ }
+ if ctx.RepoGroup.Group.Visibility == structs.VisibleTypePrivate {
+ args.RequireMember = true
+ } else if ctx.IsSigned && ctx.Doer.IsRestricted {
+ args.RequireMember = true
+ }
+ if ctx.IsSigned && ctx.Doer.IsAdmin {
+ ctx.RepoGroup.IsOwner = true
+ ctx.RepoGroup.IsMember = true
+ ctx.RepoGroup.IsGroupAdmin = true
+ ctx.RepoGroup.CanCreateRepoOrGroup = true
+ } else if ctx.IsSigned {
+ ctx.RepoGroup.IsOwner, err = group.IsOwnedBy(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("IsOwnedBy", err)
+ return
+ }
+
+ if ctx.RepoGroup.IsOwner {
+ ctx.RepoGroup.IsMember = true
+ ctx.RepoGroup.IsGroupAdmin = true
+ ctx.RepoGroup.CanCreateRepoOrGroup = true
+ } else {
+ ctx.RepoGroup.IsMember, err = shared_group.IsGroupMember(ctx, group.ID, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("IsOrgMember", err)
+ return
+ }
+ ctx.RepoGroup.CanCreateRepoOrGroup, err = group.CanCreateIn(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("CanCreateIn", err)
+ return
+ }
+ }
+ } else {
+ ctx.Data["SignedUser"] = &user_model.User{}
+ }
+ if (args.RequireMember && !ctx.RepoGroup.IsMember) ||
+ (args.RequireOwner && !ctx.RepoGroup.IsOwner) {
+ ctx.NotFound(err)
+ return
+ }
+ ctx.Data["EnableFeed"] = setting.Other.EnableFeed
+ ctx.Data["FeedURL"] = ctx.RepoGroup.Group.GroupLink()
+ ctx.Data["IsGroupOwner"] = ctx.RepoGroup.IsOwner
+ ctx.Data["IsGroupMember"] = ctx.RepoGroup.IsMember
+ ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
+ ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+ ctx.Data["IsPublicMember"] = func(uid int64) bool {
+ is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
+ return is
+ }
+ ctx.Data["CanReadProjects"] = ctx.RepoGroup.CanReadUnit(ctx, unit.TypeProjects)
+ ctx.Data["CanCreateOrgRepo"] = ctx.RepoGroup.CanCreateRepoOrGroup
+
+ ctx.RepoGroup.GroupLink = group.GroupLink()
+ ctx.RepoGroup.OrgGroupLink = group.OrgGroupLink()
+
+ if ctx.RepoGroup.IsMember {
+ shouldSeeAllTeams := false
+ if ctx.RepoGroup.IsOwner {
+ shouldSeeAllTeams = true
+ } else {
+ teams, err := shared_group.GetGroupTeams(ctx, group.ID)
+ if err != nil {
+ ctx.ServerError("GetUserTeams", err)
+ return
+ }
+ for _, team := range teams {
+ if team.IncludesAllRepositories && team.AccessMode >= perm.AccessModeAdmin {
+ shouldSeeAllTeams = true
+ break
+ }
+ }
+ }
+ if shouldSeeAllTeams {
+ ctx.RepoGroup.Teams, err = shared_group.GetGroupTeams(ctx, group.ID)
+ if err != nil {
+ ctx.ServerError("LoadTeams", err)
+ return
+ }
+ } else {
+ ctx.RepoGroup.Teams, err = organization.GetUserGroupTeams(ctx, group.ID, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetUserTeams", err)
+ return
+ }
+ }
+ ctx.Data["NumTeams"] = len(ctx.RepoGroup.Teams)
+ }
+
+ teamName := ctx.PathParam("team")
+ if len(teamName) > 0 {
+ teamExists := false
+ for _, team := range ctx.RepoGroup.Teams {
+ if strings.EqualFold(team.LowerName, strings.ToLower(teamName)) {
+ teamExists = true
+ var groupTeam *group_model.RepoGroupTeam
+ groupTeam, err = group_model.FindGroupTeamByTeamID(ctx, group.ID, team.ID)
+ if err != nil {
+ ctx.ServerError("FindGroupTeamByTeamID", err)
+ return
+ }
+ ctx.RepoGroup.GroupTeam = groupTeam
+ ctx.RepoGroup.Team = team
+ ctx.RepoGroup.IsMember = true
+ ctx.Data["Team"] = ctx.RepoGroup.Team
+ break
+ }
+ }
+
+ if !teamExists {
+ ctx.NotFound(err)
+ return
+ }
+
+ ctx.Data["IsTeamMember"] = ctx.RepoGroup.IsMember
+ if args.RequireMember && !ctx.RepoGroup.IsMember {
+ ctx.NotFound(err)
+ return
+ }
+
+ ctx.RepoGroup.IsGroupAdmin = ctx.RepoGroup.Team.IsOwnerTeam() || ctx.RepoGroup.Team.AccessMode >= perm.AccessModeAdmin
+ } else {
+ for _, team := range ctx.RepoGroup.Teams {
+ if team.AccessMode >= perm.AccessModeAdmin {
+ ctx.RepoGroup.IsGroupAdmin = true
+ break
+ }
+ }
+ }
+ if ctx.IsSigned {
+ isAdmin, err := group.IsAdminOf(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("IsAdminOf", err)
+ return
+ }
+ ctx.RepoGroup.IsGroupAdmin = ctx.RepoGroup.IsGroupAdmin || isAdmin
+ }
+
+ ctx.Data["IsGroupAdmin"] = ctx.RepoGroup.IsGroupAdmin
+ if args.RequireGroupAdmin && !ctx.RepoGroup.IsGroupAdmin {
+ ctx.NotFound(err)
+ return
+ }
+
+ if len(ctx.RepoGroup.Group.Description) != 0 {
+ ctx.Data["RenderedDescription"], err = markdown.RenderString(markup.NewRenderContext(ctx), ctx.RepoGroup.Group.Description)
+ if err != nil {
+ ctx.ServerError("RenderString", err)
+ return
+ }
+ }
+ ctx.Data["Group"] = ctx.RepoGroup.Group
+ ctx.Data["ContextGroup"] = ctx.RepoGroup
+ ctx.Data["Doer"] = ctx.Doer
+ ctx.Data["GroupLink"] = ctx.RepoGroup.Group.GroupLink()
+ ctx.Data["OrgGroupLink"] = ctx.RepoGroup.OrgGroupLink
+ ctx.Data["Breadcrumbs"], err = group_model.GetParentGroupChain(ctx, group.ID)
+ if err != nil {
+ ctx.ServerError("GetParentGroupChain", err)
+ return
+ }
+ }
+}
+
+func groupIsCurrent(ctx *Context) func(groupID int64) bool {
+ return func(groupID int64) bool {
+ if ctx.RepoGroup.Group == nil {
+ return false
+ }
+ return ctx.RepoGroup.Group.ID == groupID
+ }
+}
diff --git a/services/context/org.go b/services/context/org.go
index 1cd89231785aa..9e7046552a433 100644
--- a/services/context/org.go
+++ b/services/context/org.go
@@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
+ shared_group "code.gitea.io/gitea/models/shared/group"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup"
@@ -250,5 +251,19 @@ func OrgAssignment(opts OrgAssignmentOptions) func(ctx *Context) {
}
ctx.Data["RenderedDescription"] = content
}
+ ctx.Data["AsGroupItem"] = func(v any) shared_group.Item {
+ if gi, ok := v.(shared_group.Item); ok {
+ return gi
+ }
+ return nil
+ }
+ ctx.Data["GroupNavItems"] = shared_group.GetTopLevelGroupItemList(ctx, ctx.ContextUser.ID, ctx.Doer)
+ ctx.Data["GroupIsCurrent"] = groupIsCurrent(ctx)
+ ctx.Data["GroupHasChild"] = func(it shared_group.Item) bool {
+ if ctx.RepoGroup == nil || ctx.RepoGroup.Group == nil {
+ return false
+ }
+ return shared_group.ItemHasChild(ctx, it, ctx.RepoGroup.Group.ID, ctx.Doer)
+ }
}
}
diff --git a/services/context/repo.go b/services/context/repo.go
index afc6de9b1666d..f5c448b9bcaaa 100644
--- a/services/context/repo.go
+++ b/services/context/repo.go
@@ -5,6 +5,7 @@
package context
import (
+ group_model "code.gitea.io/gitea/models/group"
"context"
"errors"
"fmt"
@@ -12,6 +13,8 @@ import (
"net/http"
"net/url"
"path"
+ "regexp"
+ "strconv"
"strings"
"code.gitea.io/gitea/models/db"
@@ -328,6 +331,7 @@ func ComposeGoGetImport(ctx context.Context, owner, repo string) string {
func EarlyResponseForGoGetMeta(ctx *Context) {
username := ctx.PathParam("username")
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
+ groupID := ctx.PathParamInt64("group_id")
if username == "" || reponame == "" {
ctx.PlainText(http.StatusBadRequest, "invalid repository path")
return
@@ -335,15 +339,17 @@ func EarlyResponseForGoGetMeta(ctx *Context) {
var cloneURL string
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
- cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, username, reponame)
+ cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, username, reponame, groupID)
} else {
- cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, username, reponame)
+ cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, username, reponame, groupID)
}
goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(ctx, username, reponame), cloneURL)
htmlMeta := fmt.Sprintf(` `, html.EscapeString(goImportContent))
ctx.PlainText(http.StatusOK, htmlMeta)
}
+var pathRegex = regexp.MustCompile(`(?i).*/[a-z\-0-9_]+/(\d+/)?[a-z\-0-9_]`)
+
// RedirectToRepo redirect to a differently-named repository
func RedirectToRepo(ctx *Base, redirectRepoID int64) {
ownerName := ctx.PathParam("username")
@@ -355,6 +361,8 @@ func RedirectToRepo(ctx *Base, redirectRepoID int64) {
ctx.HTTPError(http.StatusInternalServerError, "GetRepositoryByID")
return
}
+ pathRegex.ReplaceAllString(ctx.Req.URL.EscapedPath(),
+ url.PathEscape(repo.OwnerName)+"/$1"+url.PathEscape(repo.Name))
redirectPath := strings.Replace(
ctx.Req.URL.EscapedPath(),
@@ -421,6 +429,20 @@ func RepoAssignment(ctx *Context) {
var err error
userName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
+ group := ctx.PathParam("group_id")
+ var gid int64
+ if group != "" {
+ gid, _ = strconv.ParseInt(group, 10, 64)
+ if gid == 0 {
+ q := ctx.Req.URL.RawQuery
+ if q != "" {
+ q = "?" + q
+ }
+ ctx.Redirect(strings.Replace(ctx.Link, "/0/", "/", 1)+q, 307)
+ return
+ }
+ group += "/"
+ }
repoName = strings.TrimSuffix(repoName, ".git")
if setting.Other.EnableFeed {
ctx.Data["EnableFeed"] = true
@@ -467,7 +489,7 @@ func RepoAssignment(ctx *Context) {
redirectRepoName += originalRepoName[len(redirectRepoName)+5:]
redirectPath := strings.Replace(
ctx.Req.URL.EscapedPath(),
- url.PathEscape(userName)+"/"+url.PathEscape(originalRepoName),
+ url.PathEscape(userName)+"/"+group+url.PathEscape(originalRepoName),
url.PathEscape(userName)+"/"+url.PathEscape(redirectRepoName)+"/wiki",
1,
)
@@ -479,7 +501,7 @@ func RepoAssignment(ctx *Context) {
}
// Get repository.
- repo, err := repo_model.GetRepositoryByName(ctx, ctx.Repo.Owner.ID, repoName)
+ repo, err := repo_model.GetRepositoryByName(ctx, ctx.Repo.Owner.ID, gid, repoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
redirectRepoID, err := repo_model.LookupRedirect(ctx, ctx.Repo.Owner.ID, repoName)
@@ -499,6 +521,9 @@ func RepoAssignment(ctx *Context) {
}
return
}
+ if repo.GroupID != gid {
+ ctx.NotFound(nil)
+ }
repo.Owner = ctx.Repo.Owner
repoAssignment(ctx, repo)
@@ -537,6 +562,15 @@ func RepoAssignment(ctx *Context) {
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
ctx.Data["Repository"] = repo
+ if repo.GroupID > 0 {
+ if ctx.Data["Breadcrumbs"], err = group_model.GetParentGroupChain(ctx, repo.GroupID); err != nil {
+ ctx.ServerError("GetParentGroupChain", err)
+ return
+ }
+ } else {
+ ctx.Data["Breadcrumbs"] = nil
+ }
+
ctx.Data["Owner"] = ctx.Repo.Repository.Owner
ctx.Data["CanWriteCode"] = ctx.Repo.CanWrite(unit_model.TypeCode)
ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues)
diff --git a/services/convert/repo_group.go b/services/convert/repo_group.go
new file mode 100644
index 0000000000000..25d39ffbec79e
--- /dev/null
+++ b/services/convert/repo_group.go
@@ -0,0 +1,44 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package convert
+
+import (
+ "context"
+
+ 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"
+ api "code.gitea.io/gitea/modules/structs"
+)
+
+func ToAPIGroup(ctx context.Context, g *group_model.Group, actor *user_model.User) (*api.Group, error) {
+ err := g.LoadAttributes(ctx)
+ if err != nil {
+ return nil, err
+ }
+ apiGroup := &api.Group{
+ ID: g.ID,
+ Owner: ToUser(ctx, g.Owner, actor),
+ Name: g.Name,
+ Description: g.Description,
+ ParentGroupID: g.ParentGroupID,
+ Link: g.GroupLink(),
+ SortOrder: g.SortOrder,
+ AvatarURL: g.AvatarLink(ctx),
+ }
+ if apiGroup.NumSubgroups, err = group_model.CountGroups(ctx, &group_model.FindGroupsOptions{
+ ParentGroupID: g.ID,
+ }); err != nil {
+ return nil, err
+ }
+ if _, apiGroup.NumRepos, err = repo_model.SearchRepositoryByCondition(ctx, repo_model.SearchRepoOptions{
+ GroupID: g.ID,
+ Actor: actor,
+ OwnerID: g.OwnerID,
+ }, repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid), true); err != nil {
+ return nil, err
+ }
+ return apiGroup, nil
+}
diff --git a/services/convert/repository.go b/services/convert/repository.go
index a364591bb8f9b..d7bea22f20cf9 100644
--- a/services/convert/repository.go
+++ b/services/convert/repository.go
@@ -252,6 +252,8 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
Topics: util.SliceNilAsEmpty(repo.Topics),
ObjectFormatName: repo.ObjectFormatName,
Licenses: util.SliceNilAsEmpty(repoLicenses.StringList()),
+ GroupID: repo.GroupID,
+ GroupSortOrder: repo.GroupSortOrder,
}
}
diff --git a/services/forms/group.go b/services/forms/group.go
new file mode 100644
index 0000000000000..f07a0087e8ba7
--- /dev/null
+++ b/services/forms/group.go
@@ -0,0 +1,33 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forms
+
+import "code.gitea.io/gitea/modules/structs"
+
+// CreateGroupForm form for creating a repository group
+type CreateGroupForm struct {
+ GroupName string `binding:"Required;MaxSize(255)"`
+ Description string `binding:"MaxSize(2048)"`
+ Permission string
+ CanCreateGroupRepo bool
+ ParentGroupID int64
+}
+
+type MovedGroupItemForm struct {
+ IsGroup bool `json:"isGroup"`
+ ItemID int64 `json:"id"`
+ NewParent int64 `json:"newParent"`
+ NewPos int `json:"newPos"`
+}
+type CreateGroupTeamForm struct {
+ Permission string
+ Access string
+ CanCreateRepoOrSubGroup bool
+}
+
+type UpdateGroupSettingForm struct {
+ Name string `binding:"Required;MaxSize(255)" locale:"group.group_name_holder"`
+ Description string `binding:"MaxSize(2048)"`
+ Visibility structs.VisibleType
+}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index cb267f891ccb7..3515c19767064 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -43,6 +43,7 @@ type CreateRepoForm struct {
ForkSingleBranch string
ObjectFormatName string
+ ParentGroupID int64
}
// Validate validates the fields
diff --git a/services/group/avatar.go b/services/group/avatar.go
new file mode 100644
index 0000000000000..0d5eaf35959a4
--- /dev/null
+++ b/services/group/avatar.go
@@ -0,0 +1,71 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package group
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "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"
+)
+
+// 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
+ })
+}
diff --git a/services/group/delete.go b/services/group/delete.go
new file mode 100644
index 0000000000000..1a8b133bcb241
--- /dev/null
+++ b/services/group/delete.go
@@ -0,0 +1,87 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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.RepoGroupTeam)); err != nil {
+ return err
+ }
+ if _, err = sess.Where("group_id = ?", gid).Delete(new(group_model.RepoGroupUnit)); 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()
+}
diff --git a/services/group/group.go b/services/group/group.go
new file mode 100644
index 0000000000000..0f2e9d7ce913e
--- /dev/null
+++ b/services/group/group.go
@@ -0,0 +1,149 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package group
+
+import (
+ "code.gitea.io/gitea/modules/gitrepo"
+ "context"
+ "errors"
+ "fmt"
+ "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) error {
+ var 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 err
+ }
+
+ if err = RecalculateGroupAccess(ctx, g, true); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+func MoveRepositoryToGroup(ctx context.Context, repo *repo_model.Repository, newGroupID int64, groupSortOrder int) error {
+ sess := db.GetEngine(ctx)
+ if newGroupID > 0 {
+ newGroup, err := group_model.GetGroupByID(ctx, newGroupID)
+ if err != nil {
+ return err
+ }
+ if newGroup.OwnerID != repo.OwnerID {
+ return fmt.Errorf("repo[%d]'s ownerID is not equal to new parent group[%d]'s owner ID", repo.ID, newGroup.ID)
+ }
+ }
+ 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
+}
+
+type MoveGroupOptions struct {
+ NewParent, ItemID int64
+ IsGroup bool
+ NewPos int
+}
+
+func MoveGroupItem(ctx context.Context, opts MoveGroupOptions, doer *user_model.User) (err error) {
+ var committer db.Committer
+ ctx, committer, err = db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ var parentGroup *group_model.Group
+ if opts.NewParent > 0 {
+ parentGroup, err = group_model.GetGroupByID(ctx, opts.NewParent)
+ if err != nil {
+ return err
+ }
+ canAccessNewParent, err := parentGroup.CanAccess(ctx, doer)
+ if err != nil {
+ return err
+ }
+ if !canAccessNewParent {
+ return errors.New("cannot access new parent group")
+ }
+
+ err = parentGroup.LoadSubgroups(ctx, false)
+ if err != nil {
+ return err
+ }
+ }
+ if opts.IsGroup {
+ var group *group_model.Group
+ group, err = group_model.GetGroupByID(ctx, opts.ItemID)
+ if err != nil {
+ return err
+ }
+ if opts.NewPos < 0 && parentGroup != nil {
+ opts.NewPos = len(parentGroup.Subgroups)
+ }
+ if group.ParentGroupID != opts.NewParent || group.SortOrder != opts.NewPos {
+ if err = group_model.MoveGroup(ctx, group, opts.NewParent, opts.NewPos); err != nil {
+ return err
+ }
+ if err = RecalculateGroupAccess(ctx, group, false); err != nil {
+ return err
+ }
+ }
+ } else {
+ var repo *repo_model.Repository
+ repo, err = repo_model.GetRepositoryByID(ctx, opts.ItemID)
+ if err != nil {
+ return err
+ }
+ if opts.NewPos < 0 {
+ var repoCount int64
+ repoCount, err = repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{
+ GroupID: opts.NewParent,
+ })
+ if err != nil {
+ return err
+ }
+ opts.NewPos = int(repoCount)
+ }
+ if repo.GroupID != opts.NewParent || repo.GroupSortOrder != opts.NewPos {
+ if err = gitrepo.RenameRepository(ctx, repo, repo_model.StorageRepo(repo_model.RelativePath(repo.OwnerName, repo.Name, opts.NewParent))); err != nil {
+ return err
+ }
+ if err = MoveRepositoryToGroup(ctx, repo, opts.NewParent, opts.NewPos); err != nil {
+ return err
+ }
+ }
+ }
+ return committer.Commit()
+}
diff --git a/services/group/group_test.go b/services/group/group_test.go
new file mode 100644
index 0000000000000..d98ee68f39bc9
--- /dev/null
+++ b/services/group/group_test.go
@@ -0,0 +1,78 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package group
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ group_model "code.gitea.io/gitea/models/group"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// group 12 is private
+// team 23 are owners
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
+
+func TestNewGroup(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ const groupName = "group x"
+ group := &group_model.Group{
+ Name: groupName,
+ OwnerID: 3,
+ }
+ assert.NoError(t, NewGroup(db.DefaultContext, group))
+ unittest.AssertExistsAndLoadBean(t, &group_model.Group{Name: groupName})
+}
+
+func TestMoveGroup(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ ID: 28,
+ })
+ testfn := func(gid int64) {
+ cond := &group_model.FindGroupsOptions{
+ ParentGroupID: 123,
+ OwnerID: 3,
+ }
+ origCount := unittest.GetCount(t, new(group_model.Group), cond.ToConds())
+
+ assert.NoError(t, MoveGroupItem(t.Context(), MoveGroupOptions{
+ NewParent: 123,
+ ItemID: gid,
+ IsGroup: true,
+ NewPos: -1,
+ }, doer))
+ unittest.AssertCountByCond(t, "repo_group", cond.ToConds(), origCount+1)
+ }
+ testfn(124)
+ testfn(132)
+ testfn(150)
+}
+
+func TestMoveRepo(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ ID: 28,
+ })
+ cond := repo_model.SearchRepositoryCondition(repo_model.SearchRepoOptions{
+ GroupID: 123,
+ })
+ origCount := unittest.GetCount(t, new(repo_model.Repository), cond)
+
+ assert.NoError(t, MoveGroupItem(db.DefaultContext, MoveGroupOptions{
+ NewParent: 123,
+ ItemID: 32,
+ IsGroup: false,
+ NewPos: -1,
+ }, doer))
+ unittest.AssertCountByCond(t, "repository", cond, origCount+1)
+}
diff --git a/services/group/search.go b/services/group/search.go
new file mode 100644
index 0000000000000..5194c7394c1fc
--- /dev/null
+++ b/services/group/search.go
@@ -0,0 +1,174 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package group
+
+import (
+ "context"
+ "slices"
+
+ "code.gitea.io/gitea/models/git"
+ group_model "code.gitea.io/gitea/models/group"
+ "code.gitea.io/gitea/models/perm"
+ 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 WebSearchResult struct {
+ OK bool `json:"ok"`
+ Data *WebSearchGroup `json:"data"`
+}
+
+type WebSearchOptions 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 WebSearchGroupRootResult 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 *WebSearchOptions) 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() > 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, perm.AccessModeRead))
+ 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 *WebSearchOptions) (*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 *WebSearchOptions) (*WebSearchResult, error) {
+ var res *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 &WebSearchResult{
+ Data: res,
+ OK: true,
+ }, nil
+}
diff --git a/services/group/team.go b/services/group/team.go
new file mode 100644
index 0000000000000..691752a0e20af
--- /dev/null
+++ b/services/group/team.go
@@ -0,0 +1,147 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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)
+ }
+ 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
+ }
+ return group_model.RemoveTeamGroup(ctx, org, team.ID, group.ID)
+}
+
+func UpdateGroupTeam(ctx context.Context, gt *group_model.RepoGroupTeam) (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 err
+ }
+ }
+ 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) error {
+ var err error
+ sess := db.GetEngine(ctx)
+ if err = g.LoadParentGroup(ctx); err != nil {
+ return err
+ }
+ var teams []*org_model.Team
+ if g.ParentGroup == nil {
+ teams, err = org_model.FindOrgTeams(ctx, g.OwnerID)
+ if err != nil {
+ return err
+ }
+ } else {
+ teams, err = org_model.GetTeamsWithAccessToGroup(ctx, g.OwnerID, g.ParentGroupID, perm.AccessModeRead)
+ }
+ for _, t := range teams {
+ var gt *group_model.RepoGroupTeam
+ if gt, err = group_model.FindGroupTeamByTeamID(ctx, g.ParentGroupID, t.ID); err != nil {
+ return err
+ }
+ if gt != nil {
+ if err = group_model.UpdateTeamGroup(ctx, g.OwnerID, t.ID, g.ID, gt.AccessMode, gt.CanCreateIn, isNew); err != nil {
+ return err
+ }
+ } 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 err
+ }
+ }
+
+ if err = t.LoadUnits(ctx); err != nil {
+ return err
+ }
+ 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("repo_group_unit").Insert(&group_model.RepoGroupUnit{
+ Type: u.Type,
+ TeamID: t.ID,
+ GroupID: g.ID,
+ AccessMode: newAccessMode,
+ }); err != nil {
+ return err
+ }
+ } else {
+ if _, err = sess.Table("repo_group_unit").Where(builder.Eq{
+ "type": u.Type,
+ "team_id": t.ID,
+ "group_id": g.ID,
+ }).Cols("access_mode").Update(&group_model.RepoGroupUnit{
+ AccessMode: newAccessMode,
+ }); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ return err
+}
diff --git a/services/group/update.go b/services/group/update.go
new file mode 100644
index 0000000000000..9e64d48dbd3d8
--- /dev/null
+++ b/services/group/update.go
@@ -0,0 +1,35 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package group
+
+import (
+ "context"
+ "strings"
+
+ "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"
+)
+
+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
+}
diff --git a/services/issue/commit.go b/services/issue/commit.go
index 963d0359fd35d..becaa33c7cb14 100644
--- a/services/issue/commit.go
+++ b/services/issue/commit.go
@@ -120,7 +120,7 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m
for _, ref := range references.FindAllIssueReferences(c.Message) {
// issue is from another repo
if len(ref.Owner) > 0 && len(ref.Name) > 0 {
- refRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Name)
+ refRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Name, ref.GroupID)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
log.Warn("Repository referenced in commit but does not exist: %v", err)
diff --git a/services/lfs/locks.go b/services/lfs/locks.go
index 264001f0f984f..149ff612ec511 100644
--- a/services/lfs/locks.go
+++ b/services/lfs/locks.go
@@ -48,7 +48,7 @@ func handleLockListOut(ctx *context.Context, repo *repo_model.Repository, lock *
func GetListLockHandler(ctx *context.Context) {
rv := getRequestContext(ctx)
- repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, rv.User, rv.Repo)
+ repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, rv.User, rv.Repo, rv.GroupID)
if err != nil {
log.Debug("Could not find repository: %s/%s - %s", rv.User, rv.Repo, err)
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
@@ -135,9 +135,10 @@ func GetListLockHandler(ctx *context.Context) {
func PostLockHandler(ctx *context.Context) {
userName := ctx.PathParam("username")
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
+ groupID := ctx.PathParamInt64("group_id")
authorization := ctx.Req.Header.Get("Authorization")
- repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
+ repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName, groupID)
if err != nil {
log.Error("Unable to get repository: %s/%s Error: %v", userName, repoName, err)
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
@@ -207,9 +208,10 @@ func PostLockHandler(ctx *context.Context) {
func VerifyLockHandler(ctx *context.Context) {
userName := ctx.PathParam("username")
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
+ groupID := ctx.PathParamInt64("group_id")
authorization := ctx.Req.Header.Get("Authorization")
- repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
+ repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName, groupID)
if err != nil {
log.Error("Unable to get repository: %s/%s Error: %v", userName, repoName, err)
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
@@ -275,9 +277,10 @@ func VerifyLockHandler(ctx *context.Context) {
func UnLockHandler(ctx *context.Context) {
userName := ctx.PathParam("username")
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
+ groupID := ctx.PathParamInt64("group_id")
authorization := ctx.Req.Header.Get("Authorization")
- repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
+ repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName, groupID)
if err != nil {
log.Error("Unable to get repository: %s/%s Error: %v", userName, repoName, err)
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
diff --git a/services/lfs/server.go b/services/lfs/server.go
index 9f2e532f23ae8..800859d4c74e1 100644
--- a/services/lfs/server.go
+++ b/services/lfs/server.go
@@ -42,6 +42,7 @@ import (
type requestContext struct {
User string
Repo string
+ GroupID int64
Authorization string
Method string
}
@@ -397,6 +398,7 @@ func getRequestContext(ctx *context.Context) *requestContext {
return &requestContext{
User: ctx.PathParam("username"),
Repo: strings.TrimSuffix(ctx.PathParam("reponame"), ".git"),
+ GroupID: ctx.PathParamInt64("group_id"),
Authorization: ctx.Req.Header.Get("Authorization"),
Method: ctx.Req.Method,
}
@@ -425,7 +427,7 @@ func getAuthenticatedMeta(ctx *context.Context, rc *requestContext, p lfs_module
}
func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requireWrite bool) *repo_model.Repository {
- repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, rc.User, rc.Repo)
+ repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, rc.User, rc.Repo, rc.GroupID)
if err != nil {
log.Error("Unable to get repository: %s/%s Error: %v", rc.User, rc.Repo, err)
writeStatus(ctx, http.StatusNotFound)
diff --git a/services/markup/renderhelper_codepreview.go b/services/markup/renderhelper_codepreview.go
index fa1eb824a2f54..58e4b44ec771c 100644
--- a/services/markup/renderhelper_codepreview.go
+++ b/services/markup/renderhelper_codepreview.go
@@ -31,7 +31,7 @@ func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePrevie
opts.LineStop = opts.LineStart + lineCount
}
- dbRepo, err := repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName)
+ dbRepo, err := repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName, opts.GroupID)
if err != nil {
return "", err
}
diff --git a/services/markup/renderhelper_issueicontitle.go b/services/markup/renderhelper_issueicontitle.go
index 27b5595fa9998..103411976ddd2 100644
--- a/services/markup/renderhelper_issueicontitle.go
+++ b/services/markup/renderhelper_issueicontitle.go
@@ -27,7 +27,7 @@ func renderRepoIssueIconTitle(ctx context.Context, opts markup.RenderIssueIconTi
textIssueIndex := fmt.Sprintf("(#%d)", opts.IssueIndex)
dbRepo := webCtx.Repo.Repository
if opts.OwnerName != "" {
- dbRepo, err = repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName)
+ dbRepo, err = repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName, opts.GroupID)
if err != nil {
return "", err
}
diff --git a/services/packages/cargo/index.go b/services/packages/cargo/index.go
index 605335d0f171a..59b70348f8035 100644
--- a/services/packages/cargo/index.go
+++ b/services/packages/cargo/index.go
@@ -109,7 +109,7 @@ func UpdatePackageIndexIfExists(ctx context.Context, doer, owner *user_model.Use
// We do not want to force the creation of the repo here
// cargo http index does not rely on the repo itself,
// so if the repo does not exist, we just do nothing.
- repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner.Name, IndexRepositoryName)
+ repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner.Name, IndexRepositoryName, 0)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
return nil
@@ -208,7 +208,7 @@ func addOrUpdatePackageIndex(ctx context.Context, t *files_service.TemporaryUplo
}
func getOrCreateIndexRepository(ctx context.Context, doer, owner *user_model.User) (*repo_model.Repository, error) {
- repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner.Name, IndexRepositoryName)
+ repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner.Name, IndexRepositoryName, 0)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
repo, err = repo_service.CreateRepositoryDirectly(ctx, doer, owner, repo_service.CreateRepoOptions{
diff --git a/services/repository/adopt.go b/services/repository/adopt.go
index 2bd1c55de48ae..56e91ec12b24a 100644
--- a/services/repository/adopt.go
+++ b/services/repository/adopt.go
@@ -209,12 +209,12 @@ func adoptRepository(ctx context.Context, repo *repo_model.Repository, defaultBr
}
// DeleteUnadoptedRepository deletes unadopted repository files from the filesystem
-func DeleteUnadoptedRepository(ctx context.Context, doer, u *user_model.User, repoName string) error {
+func DeleteUnadoptedRepository(ctx context.Context, doer, u *user_model.User, repoName string, groupID int64) error {
if err := repo_model.IsUsableRepoName(repoName); err != nil {
return err
}
- repoPath := repo_model.RepoPath(u.Name, repoName)
+ repoPath := repo_model.RepoPath(u.Name, repoName, groupID)
isExist, err := util.IsExist(repoPath)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
@@ -227,7 +227,7 @@ func DeleteUnadoptedRepository(ctx context.Context, doer, u *user_model.User, re
}
}
- if exist, err := repo_model.IsRepositoryModelExist(ctx, u, repoName); err != nil {
+ if exist, err := repo_model.IsRepositoryModelExist(ctx, u, repoName, groupID); err != nil {
return err
} else if exist {
return repo_model.ErrRepoAlreadyExist{
diff --git a/services/repository/branch.go b/services/repository/branch.go
index 6e0065b2776d5..e7cb906a48c76 100644
--- a/services/repository/branch.go
+++ b/services/repository/branch.go
@@ -568,6 +568,7 @@ func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.R
PusherID: doer.ID,
PusherName: doer.Name,
RepoUserName: repo.OwnerName,
+ RepoGroupID: repo.GroupID,
RepoName: repo.Name,
}); err != nil {
log.Error("Update: %v", err)
diff --git a/services/repository/create.go b/services/repository/create.go
index c415a24353894..29e4abee3f523 100644
--- a/services/repository/create.go
+++ b/services/repository/create.go
@@ -13,6 +13,7 @@ import (
"time"
"code.gitea.io/gitea/models/db"
+ group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
@@ -50,6 +51,7 @@ type CreateRepoOptions struct {
TrustModel repo_model.TrustModelType
MirrorInterval string
ObjectFormatName string
+ GroupID int64
}
func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir string, opts CreateRepoOptions) error {
@@ -227,6 +229,24 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User,
if opts.ObjectFormatName == "" {
opts.ObjectFormatName = git.Sha1ObjectFormat.Name()
}
+ if opts.GroupID < 0 {
+ opts.GroupID = 0
+ }
+
+ // ensure that the parent group is owned by same user
+ if opts.GroupID > 0 {
+ newGroup, err := group_model.GetGroupByID(ctx, opts.GroupID)
+ if err != nil {
+ if group_model.IsErrGroupNotExist(err) {
+ opts.GroupID = 0
+ } else {
+ return nil, err
+ }
+ }
+ if newGroup.OwnerID != owner.ID {
+ return nil, fmt.Errorf("group[%d] is not owned by user[%d]", newGroup.ID, owner.ID)
+ }
+ }
repo := &repo_model.Repository{
OwnerID: owner.ID,
@@ -248,6 +268,7 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User,
DefaultBranch: opts.DefaultBranch,
DefaultWikiBranch: setting.Repository.DefaultBranch,
ObjectFormatName: opts.ObjectFormatName,
+ GroupID: opts.GroupID,
}
// 1 - create the repository database operations first
@@ -339,7 +360,7 @@ func createRepositoryInDB(ctx context.Context, doer, u *user_model.User, repo *r
return err
}
- has, err := repo_model.IsRepositoryModelExist(ctx, u, repo.Name)
+ has, err := repo_model.IsRepositoryModelExist(ctx, u, repo.Name, repo.GroupID)
if err != nil {
return fmt.Errorf("IsRepositoryExist: %w", err)
} else if has {
diff --git a/services/repository/lfs_test.go b/services/repository/lfs_test.go
index 78ff8c853ee8f..f403a9dd2a9bb 100644
--- a/services/repository/lfs_test.go
+++ b/services/repository/lfs_test.go
@@ -27,7 +27,7 @@ func TestGarbageCollectLFSMetaObjects(t *testing.T) {
err := storage.Init()
assert.NoError(t, err)
- repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1", 0)
assert.NoError(t, err)
// add lfs object
diff --git a/services/repository/migrate.go b/services/repository/migrate.go
index 0a3dc45339fd8..5243ae8b18ffd 100644
--- a/services/repository/migrate.go
+++ b/services/repository/migrate.go
@@ -27,7 +27,7 @@ import (
)
func cloneWiki(ctx context.Context, u *user_model.User, opts migration.MigrateOptions, migrateTimeout time.Duration) (string, error) {
- wikiPath := repo_model.WikiPath(u.Name, opts.RepoName)
+ wikiPath := repo_model.WikiPath(u.Name, opts.RepoName, 0)
wikiRemotePath := repo_module.WikiRemoteURL(ctx, opts.CloneAddr)
if wikiRemotePath == "" {
return "", nil
@@ -72,7 +72,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
repo *repo_model.Repository, opts migration.MigrateOptions,
httpTransport *http.Transport,
) (*repo_model.Repository, error) {
- repoPath := repo_model.RepoPath(u.Name, opts.RepoName)
+ repoPath := repo_model.RepoPath(u.Name, opts.RepoName, 0)
if u.IsOrganization() {
t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx)
diff --git a/services/repository/push.go b/services/repository/push.go
index 7c68a7f176308..7bf0eba1bf2e4 100644
--- a/services/repository/push.go
+++ b/services/repository/push.go
@@ -82,7 +82,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("PushUpdates: %s/%s", optsList[0].RepoUserName, optsList[0].RepoName))
defer finished()
- repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, optsList[0].RepoUserName, optsList[0].RepoName)
+ repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, optsList[0].RepoUserName, optsList[0].RepoName, optsList[0].RepoGroupID)
if err != nil {
return fmt.Errorf("GetRepositoryByOwnerAndName failed: %w", err)
}
diff --git a/services/repository/transfer.go b/services/repository/transfer.go
index 5ad63cca6763d..53952cf29d370 100644
--- a/services/repository/transfer.go
+++ b/services/repository/transfer.go
@@ -107,16 +107,16 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
}
if repoRenamed {
- if err := util.Rename(repo_model.RepoPath(newOwnerName, repo.Name), repo_model.RepoPath(oldOwnerName, repo.Name)); err != nil {
+ if err := util.Rename(repo_model.RepoPath(newOwnerName, repo.Name, 0), repo_model.RepoPath(oldOwnerName, repo.Name, repo.GroupID)); err != nil {
log.Critical("Unable to move repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name,
- repo_model.RepoPath(newOwnerName, repo.Name), repo_model.RepoPath(oldOwnerName, repo.Name), err)
+ repo_model.RepoPath(newOwnerName, repo.Name, 0), repo_model.RepoPath(oldOwnerName, repo.Name, repo.GroupID), err)
}
}
if wikiRenamed {
- if err := util.Rename(repo_model.WikiPath(newOwnerName, repo.Name), repo_model.WikiPath(oldOwnerName, repo.Name)); err != nil {
+ if err := util.Rename(repo_model.WikiPath(newOwnerName, repo.Name, 0), repo_model.WikiPath(oldOwnerName, repo.Name, repo.GroupID)); err != nil {
log.Critical("Unable to move wiki for repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name,
- repo_model.WikiPath(newOwnerName, repo.Name), repo_model.WikiPath(oldOwnerName, repo.Name), err)
+ repo_model.WikiPath(newOwnerName, repo.Name, 0), repo_model.WikiPath(oldOwnerName, repo.Name, repo.GroupID), err)
}
}
@@ -141,7 +141,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
newOwnerName = newOwner.Name // ensure capitalisation matches
// Check if new owner has repository with same name.
- if has, err := repo_model.IsRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil {
+ if has, err := repo_model.IsRepositoryModelOrDirExist(ctx, newOwner, repo.Name, 0); err != nil {
return fmt.Errorf("IsRepositoryExist: %w", err)
} else if has {
return repo_model.ErrRepoAlreadyExist{
@@ -283,18 +283,18 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
return fmt.Errorf("Failed to create dir %s: %w", dir, err)
}
- if err := util.Rename(repo_model.RepoPath(oldOwner.Name, repo.Name), repo_model.RepoPath(newOwner.Name, repo.Name)); err != nil {
+ if err := util.Rename(repo_model.RepoPath(oldOwner.Name, repo.Name, 0), repo_model.RepoPath(newOwner.Name, repo.Name, 0)); err != nil {
return fmt.Errorf("rename repository directory: %w", err)
}
repoRenamed = true
// Rename remote wiki repository to new path and delete local copy.
- wikiPath := repo_model.WikiPath(oldOwner.Name, repo.Name)
+ wikiPath := repo_model.WikiPath(oldOwner.Name, repo.Name, repo.GroupID)
if isExist, err := util.IsExist(wikiPath); err != nil {
log.Error("Unable to check if %s exists. Error: %v", wikiPath, err)
return err
} else if isExist {
- if err := util.Rename(wikiPath, repo_model.WikiPath(newOwner.Name, repo.Name)); err != nil {
+ if err := util.Rename(wikiPath, repo_model.WikiPath(newOwner.Name, repo.Name, repo.GroupID)); err != nil {
return fmt.Errorf("rename repository wiki: %w", err)
}
wikiRenamed = true
@@ -343,7 +343,7 @@ func changeRepositoryName(ctx context.Context, repo *repo_model.Repository, newR
return err
}
- has, err := repo_model.IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName)
+ has, err := repo_model.IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName, repo.GroupID)
if err != nil {
return fmt.Errorf("IsRepositoryExist: %w", err)
} else if has {
@@ -354,7 +354,7 @@ func changeRepositoryName(ctx context.Context, repo *repo_model.Repository, newR
}
if err = gitrepo.RenameRepository(ctx, repo,
- repo_model.StorageRepo(repo_model.RelativePath(repo.OwnerName, newRepoName))); err != nil {
+ repo_model.StorageRepo(repo_model.RelativePath(repo.OwnerName, newRepoName, 0))); err != nil {
return fmt.Errorf("rename repository directory: %w", err)
}
@@ -365,7 +365,7 @@ func changeRepositoryName(ctx context.Context, repo *repo_model.Repository, newR
return err
}
if isExist {
- if err = util.Rename(wikiPath, repo_model.WikiPath(repo.Owner.Name, newRepoName)); err != nil {
+ if err = util.Rename(wikiPath, repo_model.WikiPath(repo.Owner.Name, newRepoName, repo.GroupID)); err != nil {
return fmt.Errorf("rename repository wiki: %w", err)
}
}
diff --git a/services/user/user.go b/services/user/user.go
index c7252430dea03..f6ac3ed5a0e2c 100644
--- a/services/user/user.go
+++ b/services/user/user.go
@@ -11,6 +11,7 @@ import (
"time"
"code.gitea.io/gitea/models/db"
+ group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
repo_model "code.gitea.io/gitea/models/repo"
@@ -80,6 +81,10 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string) err
return err
}
+ if err = group_model.UpdateGroupOwnerName(ctx, oldUserName, newUserName); err != nil {
+ return err
+ }
+
if err = user_model.NewUserRedirect(ctx, u.ID, oldUserName, newUserName); err != nil {
return err
}
diff --git a/templates/group/create.tmpl b/templates/group/create.tmpl
new file mode 100644
index 0000000000000..03e0236aeef4c
--- /dev/null
+++ b/templates/group/create.tmpl
@@ -0,0 +1,38 @@
+{{template "base/head" .}}
+
+{{template "base/footer" .}}
diff --git a/templates/group/dashboard/menu.tmpl b/templates/group/dashboard/menu.tmpl
new file mode 100644
index 0000000000000..c82dda2c0c7b6
--- /dev/null
+++ b/templates/group/dashboard/menu.tmpl
@@ -0,0 +1,18 @@
+
+
+ {{if .Group}}
+ {{.Group.Name}}
+ {{else}}
+ {{ctx.Locale.Tr "org.groups"}}
+ {{end}}
+
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+
+
diff --git a/templates/group/dashboard/menu_item.tmpl b/templates/group/dashboard/menu_item.tmpl
new file mode 100644
index 0000000000000..b5f2f6bc4d0a1
--- /dev/null
+++ b/templates/group/dashboard/menu_item.tmpl
@@ -0,0 +1,19 @@
+
+
+ {{.Item.Title}}
+
+
+{{$d := .Depth}}
+{{$one := 1}}
+{{$d1 := Eval $d "+" $one}}
+{{template "group/dashboard/submenu" dict "." . "Items" (.Item.Children .SignedUser) "Depth" $d1}}
diff --git a/templates/group/dashboard/submenu.tmpl b/templates/group/dashboard/submenu.tmpl
new file mode 100644
index 0000000000000..f7437d9411f69
--- /dev/null
+++ b/templates/group/dashboard/submenu.tmpl
@@ -0,0 +1,6 @@
+{{range.Items}}
+{{if .IsGroup}}
+
+{{template "group/dashboard/menu_item" dict "." $ "Item" . "Depth" $.Depth}}
+{{end}}
+{{end}}
diff --git a/templates/group/header.tmpl b/templates/group/header.tmpl
new file mode 100644
index 0000000000000..6d0536b10f0b9
--- /dev/null
+++ b/templates/group/header.tmpl
@@ -0,0 +1,49 @@
+
+
+
+
+ {{ctx.AvatarUtils.Avatar .Group 100 "group-avatar"}}
+
+
+ {{if .RenderedDescription}}
+
{{.RenderedDescription}}
+ {{end}}
+
+
+
+{{template "group/menu" .}}
diff --git a/templates/group/home.tmpl b/templates/group/home.tmpl
new file mode 100644
index 0000000000000..beb19cd33727c
--- /dev/null
+++ b/templates/group/home.tmpl
@@ -0,0 +1,18 @@
+{{template "base/head" .}}
+
+ {{template "group/header" .}}
+
+
+
+
+ {{template "shared/repo/search" .}}
+ {{template "shared/repo/list" .}}
+ {{template "base/paginate" .}}
+
+
+ {{template "group/sidebar/menu" .}}
+
+
+
+
+{{template "base/footer" .}}
diff --git a/templates/group/menu.tmpl b/templates/group/menu.tmpl
new file mode 100644
index 0000000000000..721dd36a1024e
--- /dev/null
+++ b/templates/group/menu.tmpl
@@ -0,0 +1,26 @@
+
+
+
diff --git a/templates/group/selector.tmpl b/templates/group/selector.tmpl
new file mode 100644
index 0000000000000..70246da80a2e4
--- /dev/null
+++ b/templates/group/selector.tmpl
@@ -0,0 +1,27 @@
+{{define "group-selection-item"}}
+
+ {{ctx.AvatarUtils.Avatar . 28 "mini"}}
+ {{.ShortName 40}}
+
+ {{range .Subgroups}}
+ {{block "group-selection-item" .}}
+ {{end}}
+ {{end}}
+{{end}}
+
+
{{ctx.Locale.Tr "group.parent"}}
+
+
+
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+
+
+
diff --git a/templates/group/settings/layout_footer.tmpl b/templates/group/settings/layout_footer.tmpl
new file mode 100644
index 0000000000000..bd72b35c4f330
--- /dev/null
+++ b/templates/group/settings/layout_footer.tmpl
@@ -0,0 +1,11 @@
+{{if false}}{{/* to make html structure "likely" complete to prevent IDE warnings */}}
+
+
+
+ {{/* block: repo-setting-content */}}
+ {{end}}
+
+
+
+
+{{template "base/footer" .}}
diff --git a/templates/group/settings/layout_head.tmpl b/templates/group/settings/layout_head.tmpl
new file mode 100644
index 0000000000000..64eb6becdcaef
--- /dev/null
+++ b/templates/group/settings/layout_head.tmpl
@@ -0,0 +1,14 @@
+{{template "base/head" .ctxData}}
+
+ {{template "group/header" .ctxData}}
+
+ {{template "group/settings/navbar" .ctxData}}
+
+ {{template "base/alert" .ctxData}}
+ {{/* block: group-setting-content */}}
+
+ {{if false}}{{/* to make html structure "likely" complete to prevent IDE warnings */}}
+
+
+
+{{end}}
diff --git a/templates/group/settings/navbar.tmpl b/templates/group/settings/navbar.tmpl
new file mode 100644
index 0000000000000..c5f01ae6eee27
--- /dev/null
+++ b/templates/group/settings/navbar.tmpl
@@ -0,0 +1,27 @@
+
+
+
diff --git a/templates/group/settings/options.tmpl b/templates/group/settings/options.tmpl
new file mode 100644
index 0000000000000..8cd3c4afbe1be
--- /dev/null
+++ b/templates/group/settings/options.tmpl
@@ -0,0 +1,56 @@
+{{template "group/settings/layout_head" (dict "ctxData" . "pageClass" "repo-group settings options")}}
+
+
+
+ {{.CsrfTokenHtml}}
+
+
+ {{ctx.Locale.Tr "group.group_name_holder"}}
+
+
+
+
+
+
{{ctx.Locale.Tr "group.settings.visibility"}}
+
+
+
+ {{ctx.Locale.Tr "group.settings.visibility.public"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "group.settings.visibility.limited"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "group.settings.visibility.private"}}
+
+
+
+
+ {{ctx.Locale.Tr "group.settings.update_settings"}}
+
+
+
+
+
+ {{.CsrfTokenHtml}}
+
+ {{ctx.Locale.Tr "settings.choose_new_avatar"}}
+
+
+
+
+ {{ctx.Locale.Tr "settings.update_avatar"}}
+ {{ctx.Locale.Tr "settings.delete_current_avatar"}}
+
+
+
+{{template "group/settings/layout_footer" .}}
diff --git a/templates/group/sidebar/menu.tmpl b/templates/group/sidebar/menu.tmpl
new file mode 100644
index 0000000000000..f25375b799741
--- /dev/null
+++ b/templates/group/sidebar/menu.tmpl
@@ -0,0 +1,18 @@
+
+
diff --git a/templates/group/sidebar/sidebar_item.tmpl b/templates/group/sidebar/sidebar_item.tmpl
new file mode 100644
index 0000000000000..a75016a3f857f
--- /dev/null
+++ b/templates/group/sidebar/sidebar_item.tmpl
@@ -0,0 +1,25 @@
+{{$item := (call (index $.root "AsGroupItem") .item)}}
+{{$parent := $item.Parent}}
+
+
+ {{$active := (and $item.IsGroup (call $.root.GroupIsCurrent $item.ID))}}
+ {{$childContains := (call $.root.GroupHasChild $item)}}
+
+ {{svg "octicon-chevron-right" 16 "collapse-icon"}}
+
+ {{$item.Title}}
+
+
+
+
+
+ {{- range $i, $childItem := $item.Children $.root.Doer -}}
+ {{- template "group/sidebar/sidebar_item" dict "item" $childItem "root" $.root -}}
+ {{- end -}}
+
+
+
+
diff --git a/templates/group/team/new.tmpl b/templates/group/team/new.tmpl
new file mode 100644
index 0000000000000..ea4beeadc4c16
--- /dev/null
+++ b/templates/group/team/new.tmpl
@@ -0,0 +1,144 @@
+{{template "base/head" .}}
+
+ {{template "group/header" .}}
+
+
+
+
+ {{.CsrfTokenHtml}}
+
+
+ {{template "base/alert" .}}
+ {{if not (eq .Team.LowerName "owners")}}
+
+
{{ctx.Locale.Tr "org.team_access_desc"}}
+
+
+
+
+ {{ctx.Locale.Tr "org.teams.specific_repositories"}}
+ {{ctx.Locale.Tr "org.teams.specific_repositories_helper"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "org.teams.all_repositories"}}
+ {{ctx.Locale.Tr "org.teams.all_repositories_helper"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "org.teams.can_create_org_repo"}}
+
+ {{ctx.Locale.Tr "org.teams.can_create_org_repo_helper"}}
+
+
+
+
+
{{ctx.Locale.Tr "org.team_permission_desc"}}
+
+
+
+
+ {{ctx.Locale.Tr "org.teams.general_access"}}
+ {{ctx.Locale.Tr "org.teams.general_access_helper"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "org.teams.admin_access"}}
+ {{ctx.Locale.Tr "org.teams.admin_access_helper"}}
+
+
+
+
+
+
+
{{ctx.Locale.Tr "org.team_unit_desc"}}
+
+ {{range $t, $unit := $.Units}}
+ {{if lt $unit.MaxPerm 2}}
+
+
+
+ {{ctx.Locale.Tr $unit.NameKey}}{{if $unit.Type.UnitGlobalDisabled}} {{ctx.Locale.Tr "org.team_unit_disabled"}}{{end}}
+ {{ctx.Locale.Tr $unit.DescKey}}
+
+
+ {{end}}
+ {{end}}
+
+ {{end}}
+
+
+ {{ctx.Locale.Tr "org.teams.update_settings"}}
+ {{if not (eq .Team.LowerName "owners")}}
+ {{ctx.Locale.Tr "org.teams.delete_team"}}
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
{{ctx.Locale.Tr "group.teams.delete_team_desc"}}
+
+ {{template "base/modal_actions_confirm" .}}
+
+{{template "base/footer" .}}
diff --git a/templates/group/team/teams.tmpl b/templates/group/team/teams.tmpl
new file mode 100644
index 0000000000000..cabbbd6ee9343
--- /dev/null
+++ b/templates/group/team/teams.tmpl
@@ -0,0 +1,104 @@
+{{template "base/head" .}}
+
+ {{template "group/header" .}}
+
+ {{template "base/alert" .}}
+
+
+
+
+ {{if or .IsGroupAdmin .IsGroupOwner}}
+
+
+
+
+ {{.CsrfTokenHtml}}
+
+
+ {{ctx.Locale.Tr "group.teams.add"}}
+
+
+
+ {{end}}
+ {{range .Teams}}
+
+
+
+ {{range .Members}}
+ {{template "shared/user/avatarlink" dict "user" .}}
+ {{end}}
+
+
+
+ {{end}}
+
+
+
+ {{template "group/sidebar/menu" .}}
+
+
+
+
+
+
+
+
{{ctx.Locale.Tr "org.teams.leave.detail" (` `)}}
+
+ {{template "base/modal_actions_confirm" .}}
+
+
+
+
+
{{ctx.Locale.Tr "group.teams.remove.detail" (` `)}}
+
+ {{template "base/modal_actions_confirm" .}}
+
+{{template "base/footer" .}}
diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl
index 3cde3554c94e6..5c29b842d86c0 100644
--- a/templates/org/home.tmpl
+++ b/templates/org/home.tmpl
@@ -6,7 +6,8 @@
{{if .ProfileReadmeContent}}
-
{{.ProfileReadmeContent}}
+
{{.ProfileReadmeContent}}
{{end}}
{{template "shared/repo/search" .}}
{{template "shared/repo/list" .}}
@@ -16,15 +17,21 @@
{{if .ShowMemberAndTeamTab}}
{{if .CanCreateOrgRepo}}
-
-
+
+
{{end}}
-
+
+
+ {{template "group/sidebar/menu" .}}
+
+
{{if and .ShowMemberAndTeamTab .ShowOrgProfileReadmeSelector}}
@@ -47,43 +54,50 @@
{{end}}
{{if .NumMembers}}
-
-
- {{$isMember := .IsOrganizationMember}}
- {{range .Members}}
- {{if or $isMember (call $.IsPublicMember .ID)}}
-
{{ctx.AvatarUtils.Avatar . 48}}
- {{end}}
- {{end}}
-
+
+
+ {{$isMember := .IsOrganizationMember}}
+ {{range .Members}}
+ {{if or $isMember (call $.IsPublicMember .ID)}}
+
{{ctx.AvatarUtils.Avatar . 48}}
+ {{end}}
+ {{end}}
+
{{end}}
{{if .IsOrganizationMember}}
-
-
- {{range .Teams}}
-
- {{end}}
+
+
+ {{range .Teams}}
+
- {{if .IsOrganizationOwner}}
-
{{end}}
+
+ {{if .IsOrganizationOwner}}
+
+ {{end}}
{{end}}
-
{{end}}
+
diff --git a/templates/org/team/teams.tmpl b/templates/org/team/teams.tmpl
index cdd2789128dfa..21deb08a08fcb 100644
--- a/templates/org/team/teams.tmpl
+++ b/templates/org/team/teams.tmpl
@@ -1,6 +1,10 @@
{{template "base/head" .}}
+ {{if .PageIsOrgTeams}}
{{template "org/header" .}}
+ {{else if .PageIsGroupTeams}}
+ {{template "group/header" .}}
+ {{end}}
{{end}}
diff --git a/templates/repo/create.tmpl b/templates/repo/create.tmpl
index ada7e0c0920d6..4fa99130f97b9 100644
--- a/templates/repo/create.tmpl
+++ b/templates/repo/create.tmpl
@@ -43,6 +43,16 @@
{{ctx.Locale.Tr "repo.repo_name_profile_public_hint"}}
{{ctx.Locale.Tr "repo.repo_name_profile_private_hint"}}
+
+
{{ctx.Locale.Tr "repo.group"}}
+
+
+
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+
+
+
{{ctx.Locale.Tr "repo.visibility"}}
diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl
index b61076ff4637e..2a61d14385f21 100644
--- a/templates/repo/header.tmpl
+++ b/templates/repo/header.tmpl
@@ -8,7 +8,12 @@
-
{{.Owner.Name}} /
{{.Name}}
+
{{.Owner.Name}}
+ {{- range $.Breadcrumbs -}}
+ /
{{.Name}}
+ {{- end -}}
+ /
{{.Name}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 749d86901de93..b4a60870c6c56 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1279,6 +1279,256 @@
}
}
},
+ "/groups/{group_id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "gets a group in an organization",
+ "operationId": "groupGet",
+ "parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "description": "id of the group to retrieve",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/Group"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repositoryGroup"
+ ],
+ "summary": "Delete a repository group",
+ "operationId": "groupDelete",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the group to delete",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "$ref": "#/responses/empty"
+ },
+ "403": {
+ "$ref": "#/responses/forbidden"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ },
+ "patch": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "edits a group in an organization. only fields that are set will be changed.",
+ "operationId": "groupEdit",
+ "parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "description": "id of the group to edit",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/EditGroupOption"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/Group"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ },
+ "422": {
+ "$ref": "#/responses/validationError"
+ }
+ }
+ }
+ },
+ "/groups/{group_id}/move": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "move a group to a different parent group",
+ "operationId": "groupMove",
+ "parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "description": "id of the group to move",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/MoveGroupOption"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/Group"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ },
+ "422": {
+ "$ref": "#/responses/validationError"
+ }
+ }
+ }
+ },
+ "/groups/{group_id}/new": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "create a subgroup inside a group",
+ "operationId": "groupNewSubGroup",
+ "parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "description": "id of the group to create a subgroup in",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/NewGroupOption"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "$ref": "#/responses/Group"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ },
+ "422": {
+ "$ref": "#/responses/validationError"
+ }
+ }
+ }
+ },
+ "/groups/{group_id}/repos": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "gets the repos contained within a group",
+ "operationId": "groupGetRepos",
+ "parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "description": "id of the group containing the repositories",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/RepositoryList"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
+ "/groups/{group_id}/subgroups": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "gets the subgroups contained within a group",
+ "operationId": "groupGetSubGroups",
+ "parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "description": "id of the parent group",
+ "name": "group_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/GroupList"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
"/label/templates": {
"get": {
"produces": [
@@ -2806,6 +3056,49 @@
}
}
},
+ "/orgs/{org}/groups/new": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository-group"
+ ],
+ "summary": "create a root-level repository group for an organization",
+ "operationId": "groupNew",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "name of the organization",
+ "name": "org",
+ "in": "path",
+ "required": true
+ },
+ {
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/NewGroupOption"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "$ref": "#/responses/Group"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ },
+ "422": {
+ "$ref": "#/responses/validationError"
+ }
+ }
+ }
+ },
"/orgs/{org}/hooks": {
"get": {
"produces": [
@@ -23313,6 +23606,12 @@
"type": "string",
"x-go-name": "Gitignores"
},
+ "group_id": {
+ "description": "GroupID of the group which will contain this repository. ignored if the repo owner is not an organization.",
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "GroupID"
+ },
"issue_labels": {
"description": "Label-Set to use",
"type": "string",
@@ -23950,6 +24249,26 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "EditGroupOption": {
+ "description": "EditGroupOption represents options for editing a repository group",
+ "type": "object",
+ "properties": {
+ "description": {
+ "description": "the new description of the group",
+ "type": "string",
+ "x-go-name": "Description"
+ },
+ "name": {
+ "description": "the new name of the group",
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "visibility": {
+ "$ref": "#/definitions/VisibleType"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"EditHookOption": {
"description": "EditHookOption options when modify one hook",
"type": "object",
@@ -25215,6 +25534,57 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "Group": {
+ "description": "Group represents a group of repositories and subgroups in an organization",
+ "type": "object",
+ "properties": {
+ "avatar_url": {
+ "type": "string",
+ "x-go-name": "AvatarURL"
+ },
+ "description": {
+ "type": "string",
+ "x-go-name": "Description"
+ },
+ "id": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "ID"
+ },
+ "link": {
+ "type": "string",
+ "x-go-name": "Link"
+ },
+ "name": {
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "num_repos": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "NumRepos"
+ },
+ "num_subgroups": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "NumSubgroups"
+ },
+ "owner": {
+ "$ref": "#/definitions/User"
+ },
+ "parentGroupID": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "ParentGroupID"
+ },
+ "sort_order": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "SortOrder"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"Hook": {
"description": "Hook a hook is a web hook when one repository changed",
"type": "object",
@@ -26019,6 +26389,51 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "MoveGroupOption": {
+ "description": "MoveGroupOption represents options for changing a group or repo's parent and sort order",
+ "type": "object",
+ "required": [
+ "newParent"
+ ],
+ "properties": {
+ "newParent": {
+ "description": "the new parent group. can be 0 to specify no parent",
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "NewParent"
+ },
+ "newPos": {
+ "description": "the position of this group in its new parent",
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "NewPos"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
+ "NewGroupOption": {
+ "description": "NewGroupOption represents options for creating a new group in an organization",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "description": {
+ "description": "the description of the newly created group",
+ "type": "string",
+ "x-go-name": "Description"
+ },
+ "name": {
+ "description": "the name for the newly created group",
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "visibility": {
+ "$ref": "#/definitions/VisibleType"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"NewIssuePinsAllowed": {
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
"type": "object",
@@ -27376,6 +27791,16 @@
"type": "string",
"x-go-name": "FullName"
},
+ "group_id": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "GroupID"
+ },
+ "group_sort_order": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "GroupSortOrder"
+ },
"has_actions": {
"type": "boolean",
"x-go-name": "HasActions"
@@ -28455,6 +28880,12 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "VisibleType": {
+ "description": "VisibleType defines the visibility of user and org",
+ "type": "integer",
+ "format": "int64",
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"WatchInfo": {
"description": "WatchInfo represents an API watch status of one repository",
"type": "object",
@@ -28987,6 +29418,21 @@
}
}
},
+ "Group": {
+ "description": "Group",
+ "schema": {
+ "$ref": "#/definitions/Group"
+ }
+ },
+ "GroupList": {
+ "description": "GroupList",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Group"
+ }
+ }
+ },
"Hook": {
"description": "Hook",
"schema": {
@@ -29692,7 +30138,7 @@
"parameterBodies": {
"description": "parameterBodies",
"schema": {
- "$ref": "#/definitions/LockIssueOption"
+ "$ref": "#/definitions/MoveGroupOption"
}
},
"redirect": {
diff --git a/templates/user/dashboard/navbar.tmpl b/templates/user/dashboard/navbar.tmpl
index 7eb845833fec4..53ae80ffbcab1 100644
--- a/templates/user/dashboard/navbar.tmpl
+++ b/templates/user/dashboard/navbar.tmpl
@@ -74,25 +74,34 @@
+
+ {{template "group/dashboard/menu" .}}
+
{{end}}
{{if .ContextUser.IsOrganization}}
+ {{$suffix := ""}}
+ {{if .Team}}
+ {{$suffix = PathEscape .Team.Name}}
+ {{else if .Group}}
+ {{$suffix = URLJoin "group" (StringUtils.ToString .Group.ID)}}
+ {{end}}