From 6d27703f143b5f295f0ceff96fe6d98f5c8c5514 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Mon, 1 Feb 2021 22:57:12 +0100 Subject: [PATCH] [API] List, Check, Add & delete endpoints for repository teams (#13630) * List, Check, Add & delete endpoints for repository teams * return units on single team responce too * Add Tests --- integrations/api_repo_teams_test.go | 77 +++++++++ routers/api/v1/api.go | 6 + routers/api/v1/repo/teams.go | 233 ++++++++++++++++++++++++++++ templates/swagger/v1_json.tmpl | 167 ++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 integrations/api_repo_teams_test.go create mode 100644 routers/api/v1/repo/teams.go diff --git a/integrations/api_repo_teams_test.go b/integrations/api_repo_teams_test.go new file mode 100644 index 0000000000..a07b580346 --- /dev/null +++ b/integrations/api_repo_teams_test.go @@ -0,0 +1,77 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRepoTeams(t *testing.T) { + defer prepareTestEnv(t)() + + // publicOrgRepo = user3/repo21 + publicOrgRepo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 32}).(*models.Repository) + // user4 + user := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session) + + // ListTeams + url := fmt.Sprintf("/api/v1/repos/%s/teams?token=%s", publicOrgRepo.FullName(), token) + req := NewRequest(t, "GET", url) + res := session.MakeRequest(t, req, http.StatusOK) + var teams []*api.Team + DecodeJSON(t, res, &teams) + if assert.Len(t, teams, 2) { + assert.EqualValues(t, "Owners", teams[0].Name) + assert.EqualValues(t, false, teams[0].CanCreateOrgRepo) + assert.EqualValues(t, []string{"repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.wiki", "repo.ext_wiki", "repo.ext_issues"}, teams[0].Units) + assert.EqualValues(t, "owner", teams[0].Permission) + + assert.EqualValues(t, "test_team", teams[1].Name) + assert.EqualValues(t, false, teams[1].CanCreateOrgRepo) + assert.EqualValues(t, []string{"repo.issues"}, teams[1].Units) + assert.EqualValues(t, "write", teams[1].Permission) + } + + // IsTeam + url = fmt.Sprintf("/api/v1/repos/%s/teams/%s?token=%s", publicOrgRepo.FullName(), "Test_Team", token) + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusOK) + var team *api.Team + DecodeJSON(t, res, &team) + assert.EqualValues(t, teams[1], team) + + url = fmt.Sprintf("/api/v1/repos/%s/teams/%s?token=%s", publicOrgRepo.FullName(), "NonExistingTeam", token) + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusNotFound) + + // AddTeam with user4 + url = fmt.Sprintf("/api/v1/repos/%s/teams/%s?token=%s", publicOrgRepo.FullName(), "team1", token) + req = NewRequest(t, "PUT", url) + res = session.MakeRequest(t, req, http.StatusForbidden) + + // AddTeam with user2 + user = models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + session = loginUser(t, user.Name) + token = getTokenForLoggedInUser(t, session) + url = fmt.Sprintf("/api/v1/repos/%s/teams/%s?token=%s", publicOrgRepo.FullName(), "team1", token) + req = NewRequest(t, "PUT", url) + res = session.MakeRequest(t, req, http.StatusNoContent) + res = session.MakeRequest(t, req, http.StatusUnprocessableEntity) // test duplicate request + + // DeleteTeam + url = fmt.Sprintf("/api/v1/repos/%s/teams/%s?token=%s", publicOrgRepo.FullName(), "team1", token) + req = NewRequest(t, "DELETE", url) + res = session.MakeRequest(t, req, http.StatusNoContent) + res = session.MakeRequest(t, req, http.StatusUnprocessableEntity) // test duplicate request +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b78c55269e..42b52db936 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -727,6 +727,12 @@ func Routes() *web.Route { Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddCollaborator). Delete(reqAdmin(), repo.DeleteCollaborator) }, reqToken()) + m.Group("/teams", func() { + m.Get("", reqAnyRepoReader(), repo.ListTeams) + m.Combo("/{team}").Get(reqAnyRepoReader(), repo.IsTeam). + Put(reqAdmin(), repo.AddTeam). + Delete(reqAdmin(), repo.DeleteTeam) + }, reqToken()) m.Get("/raw/*", context.RepoRefForAPI, reqRepoReader(models.UnitTypeCode), repo.GetRawFile) m.Get("/archive/*", reqRepoReader(models.UnitTypeCode), repo.GetArchive) m.Combo("/forks").Get(repo.ListForks). diff --git a/routers/api/v1/repo/teams.go b/routers/api/v1/repo/teams.go new file mode 100644 index 0000000000..1348205ec9 --- /dev/null +++ b/routers/api/v1/repo/teams.go @@ -0,0 +1,233 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + api "code.gitea.io/gitea/modules/structs" +) + +// ListTeams list a repository's teams +func ListTeams(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/teams repository repoListTeams + // --- + // summary: List a repository's teams + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/TeamList" + + if !ctx.Repo.Owner.IsOrganization() { + ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") + return + } + + teams, err := ctx.Repo.Repository.GetRepoTeams() + if err != nil { + ctx.InternalServerError(err) + return + } + + apiTeams := make([]*api.Team, len(teams)) + for i := range teams { + if err := teams[i].GetUnits(); err != nil { + ctx.Error(http.StatusInternalServerError, "GetUnits", err) + return + } + + apiTeams[i] = convert.ToTeam(teams[i]) + } + + ctx.JSON(http.StatusOK, apiTeams) +} + +// IsTeam check if a team is assigned to a repository +func IsTeam(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/teams/{team} repository repoCheckTeam + // --- + // summary: Check if a team is assigned to a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" + // "405": + // "$ref": "#/responses/error" + + if !ctx.Repo.Owner.IsOrganization() { + ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") + return + } + + team := getTeamByParam(ctx) + if team == nil { + return + } + + if team.HasRepository(ctx.Repo.Repository.ID) { + if err := team.GetUnits(); err != nil { + ctx.Error(http.StatusInternalServerError, "GetUnits", err) + return + } + apiTeam := convert.ToTeam(team) + ctx.JSON(http.StatusOK, apiTeam) + return + } + + ctx.NotFound() +} + +// AddTeam add a team to a repository +func AddTeam(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/teams/{team} repository repoAddTeam + // --- + // summary: Add a team to a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "405": + // "$ref": "#/responses/error" + + changeRepoTeam(ctx, true) +} + +// DeleteTeam delete a team from a repository +func DeleteTeam(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/teams/{team} repository repoDeleteTeam + // --- + // summary: Delete a team from a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "405": + // "$ref": "#/responses/error" + + changeRepoTeam(ctx, false) +} + +func changeRepoTeam(ctx *context.APIContext, add bool) { + if !ctx.Repo.Owner.IsOrganization() { + ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") + } + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + ctx.Error(http.StatusForbidden, "noAdmin", "user is nor repo admin nor owner") + return + } + + team := getTeamByParam(ctx) + if team == nil { + return + } + + repoHasTeam := team.HasRepository(ctx.Repo.Repository.ID) + var err error + if add { + if repoHasTeam { + ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name)) + return + } + err = team.AddRepository(ctx.Repo.Repository) + } else { + if !repoHasTeam { + ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name)) + return + } + err = team.RemoveRepository(ctx.Repo.Repository.ID) + } + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func getTeamByParam(ctx *context.APIContext) *models.Team { + team, err := models.GetTeam(ctx.Repo.Owner.ID, ctx.Params(":team")) + if err != nil { + if models.IsErrTeamNotExist(err) { + ctx.Error(http.StatusNotFound, "TeamNotExit", err) + return nil + } + ctx.InternalServerError(err) + return nil + } + return team +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8c2b5948ea..36c1c43a00 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -8803,6 +8803,173 @@ } } }, + "/repos/{owner}/{repo}/teams": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a repository's teams", + "operationId": "repoListTeams", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/TeamList" + } + } + } + }, + "/repos/{owner}/{repo}/teams/{team}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Check if a team is assigned to a repository", + "operationId": "repoCheckTeam", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "team name", + "name": "team", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Team" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "405": { + "$ref": "#/responses/error" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Add a team to a repository", + "operationId": "repoAddTeam", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "team name", + "name": "team", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "405": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a team from a repository", + "operationId": "repoDeleteTeam", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "team name", + "name": "team", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "405": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/times": { "get": { "produces": [