From 7b47ece4874318e9f0323263b6bcde00b51d16be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=98=99=E2=97=A6=20The=20Tablet=20=E2=9D=80=20GamerGirla?= =?UTF-8?q?ndCo=20=E2=97=A6=E2=9D=A7?= Date: Tue, 5 May 2026 20:52:42 -0400 Subject: [PATCH] feat: add api routes to add/update/remove teams to/from repo groups --- modules/structs/repo_group_team.go | 13 ++ routers/api/v1/api.go | 29 ++- routers/api/v1/group/team.go | 318 +++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 modules/structs/repo_group_team.go create mode 100644 routers/api/v1/group/team.go diff --git a/modules/structs/repo_group_team.go b/modules/structs/repo_group_team.go new file mode 100644 index 0000000000..749649d37e --- /dev/null +++ b/modules/structs/repo_group_team.go @@ -0,0 +1,13 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// CreateOrUpdateRepoGroupTeamOption options for adding a team to a repo group +type CreateOrUpdateRepoGroupTeamOption struct { + // Whether the team can create repositories and subgroups in the group + CanCreateIn *bool `json:"can_create_in"` + // example: {"repo.code":"read","repo.issues":"write","repo.ext_issues":"none","repo.wiki":"admin","repo.pulls":"owner","repo.releases":"none","repo.projects":"none","repo.ext_wiki":"none"} + UnitsMap map[string]string `json:"units_map"` + Permission *RepoWritePermission `json:"permission"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2f14c93b42..a131660f6d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -518,6 +518,25 @@ func reqGroupMembership(mode perm.AccessMode, needsCreatePerm bool) func(ctx *co ctx.APIErrorInternal(err) return } + isOrgOwner := false + isOrgAdmin := false + + if ctx.Doer != nil { + isOrgOwner, err = organization.IsOrganizationOwner(ctx, g.OwnerID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + isOrgAdmin, err = organization.IsOrganizationAdmin(ctx, g.OwnerID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if isOrgOwner || isOrgAdmin { + return + } + } + canAccess, err := g.CanAccessAtLevel(ctx, ctx.Doer, mode) if err != nil { ctx.APIErrorInternal(err) @@ -541,7 +560,7 @@ func reqGroupMembership(mode perm.AccessMode, needsCreatePerm bool) func(ctx *co return } } - if !canCreateIn { + if !(canCreateIn || isOrgOwner || isOrgAdmin) { ctx.APIError(http.StatusForbidden, fmt.Sprintf("User[%d] does not have permission to create new items in group[%d]", ctx.Doer.ID, gid)) return } @@ -1841,6 +1860,14 @@ func Routes() *web.Router { m.Post("/new", reqToken(), reqGroupMembership(perm.AccessModeWrite, true), bind(api.NewGroupOption{}), group.NewSubGroup) m.Get("/subgroups", reqGroupMembership(perm.AccessModeRead, false), group.GetGroupSubGroups) m.Get("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqGroupMembership(perm.AccessModeRead, false), group.GetGroupRepos) + m.Group("/teams", func() { + m.Get("", group.ListTeams) + m.Combo("/{team}"). + Get(group.IsTeam). + Put(group.AddTeam, reqGroupMembership(perm.AccessModeAdmin, false)). + Patch(group.EditTeam, reqGroupMembership(perm.AccessModeAdmin, false)). + Delete(group.DeleteTeam, reqGroupMembership(perm.AccessModeAdmin, false)) + }, reqToken(), reqGroupMembership(perm.AccessModeRead, false)) }, checkTokenPublicOnly()) }) return m diff --git a/routers/api/v1/group/team.go b/routers/api/v1/group/team.go new file mode 100644 index 0000000000..2570ea7136 --- /dev/null +++ b/routers/api/v1/group/team.go @@ -0,0 +1,318 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package group + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + group_model "code.gitea.io/gitea/models/group" + org_model "code.gitea.io/gitea/models/organization" + perm_model "code.gitea.io/gitea/models/perm" + org_group_model "code.gitea.io/gitea/models/shared/group" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + group_service "code.gitea.io/gitea/services/group" +) + +// ListTeams list a repository group's teams +func ListTeams(ctx *context.APIContext) { + // swagger:operation GET /groups/{group_id}/teams repository-group repoGroupListTeams + // --- + // summary: List a repository group's teams + // produces: + // - application/json + // parameters: + // - name: group_id + // in: path + // description: id of the group + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/TeamList" + // "404": + // "$ref": "#/responses/notFound" + var ( + err error + group *group_model.Group + ) + group, err = group_model.GetGroupByID(ctx, ctx.PathParamInt64("group_id")) + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + + teams, err := org_group_model.GetGroupTeams(ctx, group.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiTeams, err := convert.ToTeams(ctx, teams, false) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, apiTeams) +} + +// AddTeam add a team to a repository group +func AddTeam(ctx *context.APIContext) { + // swagger:operation PUT /groups/{group_id}/teams/{team} repository-group repoGroupAddTeam + // --- + // summary: Add a team to a repository group + // produces: + // - application/json + // parameters: + // - name: group_id + // in: path + // description: id of the group + // type: integer + // format: int64 + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateRepoGroupTeamOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.CreateOrUpdateRepoGroupTeamOption) + + changeGroupTeam(ctx, form, true) +} + +// EditTeam update a team assigned to a repository group +func EditTeam(ctx *context.APIContext) { + // swagger:operation PATCH /groups/{group_id}/teams/{team} repository-group repoGroupEditTeam + // --- + // summary: Update a team assigned to a repository group + // produces: + // - application/json + // parameters: + // - name: group_id + // in: path + // description: id of the group + // type: integer + // format: int64 + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateRepoGroupTeamOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.CreateOrUpdateRepoGroupTeamOption) + + gid := ctx.PathParamInt64("group_id") + group, err := group_model.GetGroupByID(ctx, gid) + if err != nil { + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + } + ctx.APIErrorInternal(err) + return + } + team := getTeamFromGroup(ctx, group) + gt, err := group_model.FindGroupTeamByTeamID(ctx, group.ID, team.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if gt == nil { + ctx.APIErrorNotFound() + return + } + if form.CanCreateIn != nil { + gt.CanCreateIn = *form.CanCreateIn + } + if form.Permission != nil { + gt.AccessMode = perm_model.ParseAccessMode(string(*form.Permission)) + } + err = group_service.UpdateGroupTeam(ctx, gt, form.UnitsMap) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteTeam delete a team from a repository group +func DeleteTeam(ctx *context.APIContext) { + // swagger:operation DELETE /groups/{group_id}/teams/{team} repository-group repoGroupDeleteTeam + // --- + // summary: Add a team to a repository group + // produces: + // - application/json + // parameters: + // - name: group_id + // in: path + // description: id of the group + // type: integer + // format: int64 + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + changeGroupTeam(ctx, nil, false) +} + +// IsTeam check if a team is assigned to a repository +func IsTeam(ctx *context.APIContext) { + // swagger:operation GET /groups/{group_id}/teams/{team} repository-group repoGroupCheckTeam + // --- + // summary: Check if a team is assigned to a repository group + // produces: + // - application/json + // parameters: + // - name: group_id + // in: path + // description: id of the group + // type: integer + // format: int64 + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" + + gid := ctx.PathParamInt64("group_id") + group, err := group_model.GetGroupByID(ctx, gid) + if err != nil { + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + ctx.APIErrorInternal(err) + return + } + team := getTeamFromGroup(ctx, group) + if team == nil { + return + } + + if group_model.HasTeamGroup(ctx, group.OwnerID, team.ID, gid) { + apiTeam, err := convert.ToTeam(ctx, team) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, apiTeam) + return + } + + ctx.APIErrorNotFound() +} + +func getTeamFromGroup(ctx *context.APIContext, group *group_model.Group) *org_model.Team { + teamName := ctx.PathParam("team") + + team, err := org_model.GetTeam(ctx, group.OwnerID, teamName) + if err != nil { + if org_model.IsErrTeamNotExist(err) { + ctx.APIErrorNotFound() + return nil + } + ctx.APIErrorInternal(err) + return nil + } + return team +} + +func changeGroupTeam(ctx *context.APIContext, options *api.CreateOrUpdateRepoGroupTeamOption, add bool) { + gid := ctx.PathParamInt64("group_id") + group, err := group_model.GetGroupByID(ctx, gid) + if err != nil { + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + ctx.APIErrorInternal(err) + return + } + err = group.LoadOwner(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + team := getTeamFromGroup(ctx, group) + if team == nil { + return + } + + groupHasTeam := group_model.HasTeamGroup(ctx, group.OwnerID, team.ID, gid) + + if add { + if groupHasTeam { + ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("team '%s' is already added to group", team.Name)) + return + } + var accessModeArg *perm_model.AccessMode + if options.Permission != nil { + accessModeArg = new(perm_model.ParseAccessMode(string(*options.Permission))) + } + err = group_service.AddTeamToGroup(ctx, group, team.Name, options.UnitsMap, options.CanCreateIn, accessModeArg) + if err != nil { + ctx.APIErrorInternal(err) + return + } + } else { + err = group_service.DeleteTeamFromGroup(ctx, group, group.OwnerID, team.Name) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if _, err = db.GetEngine(ctx).Where("group_id = ?", gid).Delete(new(group_model.RepoGroupUnit)); err != nil { + ctx.APIErrorInternal(err) + return + } + } + ctx.Status(http.StatusNoContent) +}