From d4e4226c3cbfa62a6adf15f4466747468eb208c7 Mon Sep 17 00:00:00 2001 From: mzroot Date: Fri, 14 Jun 2024 19:56:10 +0300 Subject: [PATCH] Add tag protection via rest api #17862 (#31295) Add tag protection manage via rest API. --------- Co-authored-by: Alexander Kogay Co-authored-by: Giteabot --- models/git/protected_tag.go | 13 ++ modules/structs/repo_tag.go | 28 +++ routers/api/v1/api.go | 9 + routers/api/v1/repo/tag.go | 350 ++++++++++++++++++++++++++++++ routers/api/v1/swagger/options.go | 6 + routers/api/v1/swagger/repo.go | 14 ++ services/convert/convert.go | 26 +++ templates/swagger/v1_json.tmpl | 332 ++++++++++++++++++++++++++++ 8 files changed, 778 insertions(+) diff --git a/models/git/protected_tag.go b/models/git/protected_tag.go index 8a05045651..9a6646c742 100644 --- a/models/git/protected_tag.go +++ b/models/git/protected_tag.go @@ -110,6 +110,19 @@ func GetProtectedTagByID(ctx context.Context, id int64) (*ProtectedTag, error) { return tag, nil } +// GetProtectedTagByNamePattern gets protected tag by name_pattern +func GetProtectedTagByNamePattern(ctx context.Context, repoID int64, pattern string) (*ProtectedTag, error) { + tag := &ProtectedTag{NamePattern: pattern, RepoID: repoID} + has, err := db.GetEngine(ctx).Get(tag) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return tag, nil +} + // IsUserAllowedToControlTag checks if a user can control the specific tag. // It returns true if the tag name is not protected or the user is allowed to control it. func IsUserAllowedToControlTag(ctx context.Context, tags []*ProtectedTag, tagName string, userID int64) (bool, error) { diff --git a/modules/structs/repo_tag.go b/modules/structs/repo_tag.go index 4a7d895288..5722513f4f 100644 --- a/modules/structs/repo_tag.go +++ b/modules/structs/repo_tag.go @@ -3,6 +3,8 @@ package structs +import "time" + // Tag represents a repository tag type Tag struct { Name string `json:"name"` @@ -38,3 +40,29 @@ type CreateTagOption struct { Message string `json:"message"` Target string `json:"target"` } + +// TagProtection represents a tag protection +type TagProtection struct { + ID int64 `json:"id"` + NamePattern string `json:"name_pattern"` + WhitelistUsernames []string `json:"whitelist_usernames"` + WhitelistTeams []string `json:"whitelist_teams"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` +} + +// CreateTagProtectionOption options for creating a tag protection +type CreateTagProtectionOption struct { + NamePattern string `json:"name_pattern"` + WhitelistUsernames []string `json:"whitelist_usernames"` + WhitelistTeams []string `json:"whitelist_teams"` +} + +// EditTagProtectionOption options for editing a tag protection +type EditTagProtectionOption struct { + NamePattern *string `json:"name_pattern"` + WhitelistUsernames []string `json:"whitelist_usernames"` + WhitelistTeams []string `json:"whitelist_teams"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 74062c44ac..5363489939 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1168,6 +1168,15 @@ func Routes() *web.Route { m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), repo.CreateTag) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) + m.Group("/tag_protections", func() { + m.Combo("").Get(repo.ListTagProtection). + Post(bind(api.CreateTagProtectionOption{}), mustNotBeArchived, repo.CreateTagProtection) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetTagProtection). + Patch(bind(api.EditTagProtectionOption{}), mustNotBeArchived, repo.EditTagProtection). + Delete(repo.DeleteTagProtection) + }) + }, reqToken(), reqAdmin()) m.Group("/actions", func() { m.Get("/tasks", repo.ListActionTasks) }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go index 8577a0e896..f72034950f 100644 --- a/routers/api/v1/repo/tag.go +++ b/routers/api/v1/repo/tag.go @@ -7,9 +7,13 @@ import ( "errors" "fmt" "net/http" + "strings" "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" @@ -287,3 +291,349 @@ func DeleteTag(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// ListTagProtection lists tag protections for a repo +func ListTagProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tag_protections repository repoListTagProtection + // --- + // summary: List tag protections for 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 + // responses: + // "200": + // "$ref": "#/responses/TagProtectionList" + + repo := ctx.Repo.Repository + pts, err := git_model.GetProtectedTags(ctx, repo.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTags", err) + return + } + apiPts := make([]*api.TagProtection, len(pts)) + for i := range pts { + apiPts[i] = convert.ToTagProtection(ctx, pts[i], repo) + } + + ctx.JSON(http.StatusOK, apiPts) +} + +// GetTagProtection gets a tag protection +func GetTagProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tag_protections/{id} repository repoGetTagProtection + // --- + // summary: Get a specific tag protection for the 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: id + // in: path + // description: id of the tag protect to get + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/TagProtection" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || repo.ID != pt.RepoID { + ctx.NotFound() + return + } + + ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo)) +} + +// CreateTagProtection creates a tag protection for a repo +func CreateTagProtection(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/tag_protections repository repoCreateTagProtection + // --- + // summary: Create a tag protections for a repository + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateTagProtectionOption" + // responses: + // "201": + // "$ref": "#/responses/TagProtection" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateTagProtectionOption) + repo := ctx.Repo.Repository + + namePattern := strings.TrimSpace(form.NamePattern) + if namePattern == "" { + ctx.Error(http.StatusBadRequest, "name_pattern are empty", "name_pattern are empty") + return + } + + if len(form.WhitelistUsernames) == 0 && len(form.WhitelistTeams) == 0 { + ctx.Error(http.StatusBadRequest, "both whitelist_usernames and whitelist_teams are empty", "both whitelist_usernames and whitelist_teams are empty") + return + } + + pt, err := git_model.GetProtectedTagByNamePattern(ctx, repo.ID, namePattern) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectTagOfRepo", err) + return + } else if pt != nil { + ctx.Error(http.StatusForbidden, "Create tag protection", "Tag protection already exist") + return + } + + var whitelistUsers, whitelistTeams []int64 + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + + protectTag := &git_model.ProtectedTag{ + RepoID: repo.ID, + NamePattern: strings.TrimSpace(namePattern), + AllowlistUserIDs: whitelistUsers, + AllowlistTeamIDs: whitelistTeams, + } + if err := git_model.InsertProtectedTag(ctx, protectTag); err != nil { + ctx.Error(http.StatusInternalServerError, "InsertProtectedTag", err) + return + } + + pt, err = git_model.GetProtectedTagByID(ctx, protectTag.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.Error(http.StatusInternalServerError, "New tag protection not found", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToTagProtection(ctx, pt, repo)) +} + +// EditTagProtection edits a tag protection for a repo +func EditTagProtection(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/tag_protections/{id} repository repoEditTagProtection + // --- + // summary: Edit a tag protections for a repository. Only fields that are set will be changed + // consumes: + // - application/json + // 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: id + // in: path + // description: id of protected tag + // type: integer + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditTagProtectionOption" + // responses: + // "200": + // "$ref": "#/responses/TagProtection" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + repo := ctx.Repo.Repository + form := web.GetForm(ctx).(*api.EditTagProtectionOption) + + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.NotFound() + return + } + + if form.NamePattern != nil { + pt.NamePattern = *form.NamePattern + } + + var whitelistUsers, whitelistTeams []int64 + if form.WhitelistTeams != nil { + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + pt.AllowlistTeamIDs = whitelistTeams + } + + if form.WhitelistUsernames != nil { + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + pt.AllowlistUserIDs = whitelistUsers + } + + err = git_model.UpdateProtectedTag(ctx, pt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProtectedTag", err) + return + } + + pt, err = git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.Error(http.StatusInternalServerError, "New tag protection not found", "New tag protection not found") + return + } + + ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo)) +} + +// DeleteTagProtection +func DeleteTagProtection(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/tag_protections/{id} repository repoDeleteTagProtection + // --- + // summary: Delete a specific tag protection for the 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: id + // in: path + // description: id of protected tag + // type: integer + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.NotFound() + return + } + + err = git_model.DeleteProtectedTag(ctx, pt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteProtectedTag", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index cd551cbdfa..1de58632d5 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -170,6 +170,12 @@ type swaggerParameterBodies struct { // in:body CreateTagOption api.CreateTagOption + // in:body + CreateTagProtectionOption api.CreateTagProtectionOption + + // in:body + EditTagProtectionOption api.EditTagProtectionOption + // in:body CreateAccessTokenOption api.CreateAccessTokenOption diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index fcd34a63a9..345835f9a5 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -70,6 +70,20 @@ type swaggerResponseAnnotatedTag struct { Body api.AnnotatedTag `json:"body"` } +// TagProtectionList +// swagger:response TagProtectionList +type swaggerResponseTagProtectionList struct { + // in:body + Body []api.TagProtection `json:"body"` +} + +// TagProtection +// swagger:response TagProtection +type swaggerResponseTagProtection struct { + // in:body + Body api.TagProtection `json:"body"` +} + // Reference // swagger:response Reference type swaggerResponseReference struct { diff --git a/services/convert/convert.go b/services/convert/convert.go index c44179632e..5db33ad85d 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -408,6 +408,32 @@ func ToAnnotatedTagObject(repo *repo_model.Repository, commit *git.Commit) *api. } } +// ToTagProtection convert a git.ProtectedTag to an api.TagProtection +func ToTagProtection(ctx context.Context, pt *git_model.ProtectedTag, repo *repo_model.Repository) *api.TagProtection { + readers, err := access_model.GetRepoReaders(ctx, repo) + if err != nil { + log.Error("GetRepoReaders: %v", err) + } + + whitelistUsernames := getWhitelistEntities(readers, pt.AllowlistUserIDs) + + teamReaders, err := organization.OrgFromUser(repo.Owner).TeamsWithAccessToRepo(ctx, repo.ID, perm.AccessModeRead) + if err != nil { + log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err) + } + + whitelistTeams := getWhitelistEntities(teamReaders, pt.AllowlistTeamIDs) + + return &api.TagProtection{ + ID: pt.ID, + NamePattern: pt.NamePattern, + WhitelistUsernames: whitelistUsernames, + WhitelistTeams: whitelistTeams, + Created: pt.CreatedUnix.AsTime(), + Updated: pt.UpdatedUnix.AsTime(), + } +} + // ToTopicResponse convert from models.Topic to api.TopicResponse func ToTopicResponse(topic *repo_model.Topic) *api.TopicResponse { return &api.TopicResponse{ diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 09efbd4aa1..ebfdcb6a8f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13797,6 +13797,233 @@ } } }, + "/repos/{owner}/{repo}/tag_protections": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List tag protections for a repository", + "operationId": "repoListTagProtection", + "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/TagProtectionList" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a tag protections for a repository", + "operationId": "repoCreateTagProtection", + "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 + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateTagProtectionOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/TagProtection" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/tag_protections/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a specific tag protection for the repository", + "operationId": "repoGetTagProtection", + "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": "integer", + "description": "id of the tag protect to get", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/TagProtection" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a specific tag protection for the repository", + "operationId": "repoDeleteTagProtection", + "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": "integer", + "description": "id of protected tag", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Edit a tag protections for a repository. Only fields that are set will be changed", + "operationId": "repoEditTagProtection", + "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": "integer", + "description": "id of protected tag", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditTagProtectionOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/TagProtection" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, "/repos/{owner}/{repo}/tags": { "get": { "produces": [ @@ -19954,6 +20181,31 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateTagProtectionOption": { + "description": "CreateTagProtectionOption options for creating a tag protection", + "type": "object", + "properties": { + "name_pattern": { + "type": "string", + "x-go-name": "NamePattern" + }, + "whitelist_teams": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistTeams" + }, + "whitelist_usernames": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistUsernames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateTeamOption": { "description": "CreateTeamOption options for creating a team", "type": "object", @@ -20870,6 +21122,31 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditTagProtectionOption": { + "description": "EditTagProtectionOption options for editing a tag protection", + "type": "object", + "properties": { + "name_pattern": { + "type": "string", + "x-go-name": "NamePattern" + }, + "whitelist_teams": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistTeams" + }, + "whitelist_usernames": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistUsernames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditTeamOption": { "description": "EditTeamOption options for editing a team", "type": "object", @@ -24024,6 +24301,46 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "TagProtection": { + "description": "TagProtection represents a tag protection", + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name_pattern": { + "type": "string", + "x-go-name": "NamePattern" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + }, + "whitelist_teams": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistTeams" + }, + "whitelist_usernames": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistUsernames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Team": { "description": "Team represents a team in an organization", "type": "object", @@ -25635,6 +25952,21 @@ } } }, + "TagProtection": { + "description": "TagProtection", + "schema": { + "$ref": "#/definitions/TagProtection" + } + }, + "TagProtectionList": { + "description": "TagProtectionList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/TagProtection" + } + } + }, "TasksList": { "description": "TasksList", "schema": {