diff --git a/models/project/project.go b/models/project/project.go index 050ccf44e02e4..5c1fafa20505a 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -91,6 +91,7 @@ type Project struct { RepoID int64 `xorm:"INDEX"` Repo *repo_model.Repository `xorm:"-"` CreatorID int64 `xorm:"NOT NULL"` + Creator *user_model.User `xorm:"-"` IsClosed bool `xorm:"INDEX"` TemplateType TemplateType `xorm:"'board_type'"` // TODO: rename the column to template_type CardType CardType @@ -118,6 +119,14 @@ func (p *Project) LoadOwner(ctx context.Context) (err error) { return err } +func (p *Project) LoadCreator(ctx context.Context) (err error) { + if p.Creator != nil { + return nil + } + p.Creator, err = user_model.GetUserByID(ctx, p.CreatorID) + return err +} + func (p *Project) LoadRepo(ctx context.Context) (err error) { if p.RepoID == 0 || p.Repo != nil { return nil diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000000..26f23d6fd6740 --- /dev/null +++ b/modules/structs/project.go @@ -0,0 +1,43 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// swagger:model +type NewProjectPayload struct { + // required:true + Title string `json:"title" binding:"Required"` + // required:true + BoardType uint8 `json:"board_type"` + // required:true + CardType uint8 `json:"card_type"` + Description string `json:"description"` +} + +// swagger:model +type UpdateProjectPayload struct { + // required:true + Title string `json:"title" binding:"Required"` + Description string `json:"description"` +} + +// swagger:model +type Project struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + TemplateType uint8 `json:"board_type"` + IsClosed bool `json:"is_closed"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` + // swagger:strfmt date-time + Closed time.Time `json:"closed_at"` + + Repo *RepositoryMeta `json:"repository"` + Creator *User `json:"creator"` + Owner *User `json:"owner"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index be67ec1695b3b..16179f184d476 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -89,6 +89,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/notify" "code.gitea.io/gitea/routers/api/v1/org" "code.gitea.io/gitea/routers/api/v1/packages" + "code.gitea.io/gitea/routers/api/v1/projects" "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/settings" "code.gitea.io/gitea/routers/api/v1/user" @@ -1454,6 +1455,10 @@ func Routes() *web.Router { Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) }) + m.Group("/projects", func() { + m.Combo("").Get(projects.ListRepoProjects). + Post(bind(api.NewProjectPayload{}), projects.CreateRepoProject) + }, mustEnableIssues) }, repoAssignment()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) @@ -1598,6 +1603,11 @@ func Routes() *web.Router { }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) + m.Group("/projects", func() { + m.Combo("/{id}").Get(projects.GetProject). + Patch(bind(api.UpdateProjectPayload{}), projects.UpdateProject). + Delete(projects.DeleteProject) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), reqToken()) m.Group("/topics", func() { m.Get("/search", repo.TopicSearch) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) diff --git a/routers/api/v1/projects/project.go b/routers/api/v1/projects/project.go new file mode 100644 index 0000000000000..277ca6b58386b --- /dev/null +++ b/routers/api/v1/projects/project.go @@ -0,0 +1,422 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package projects + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/setting" + 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" +) + +func innerCreateProject(ctx *context.APIContext, projectType project_model.Type) { + form := web.GetForm(ctx).(*api.NewProjectPayload) + project := &project_model.Project{ + RepoID: 0, + OwnerID: ctx.Doer.ID, + Title: form.Title, + Description: form.Description, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.BoardType), + Type: projectType, + } + + if ctx.ContextUser != nil { + project.OwnerID = ctx.ContextUser.ID + } + + if projectType == project_model.TypeRepository { + project.RepoID = ctx.Repo.Repository.ID + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + project, err := project_model.GetProjectByID(ctx, project.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + projectResponse, err := convert.ToAPIProject(ctx, project) + if err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + ctx.JSON(http.StatusCreated, projectResponse) +} + +func CreateUserProject(ctx *context.APIContext) { + // swagger:operation POST /user/projects project projectCreateUserProject + // --- + // summary: Create a user project + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: project + // in: body + // required: true + // schema: { "$ref": "#/definitions/NewProjectPayload" } + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + innerCreateProject(ctx, project_model.TypeIndividual) +} + +func CreateOrgProject(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/projects project projectCreateOrgProject + // --- + // summary: Create a organization project + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: org + // in: path + // description: owner of repo + // type: string + // required: true + // - name: project + // in: body + // required: true + // schema: { "$ref": "#/definitions/NewProjectPayload" } + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + innerCreateProject(ctx, project_model.TypeOrganization) +} + +func CreateRepoProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects project projectCreateRepositoryProject + // --- + // summary: Create a repository project + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of repo + // type: string + // required: true + // - name: repo + // in: path + // description: repo + // type: string + // required: true + // - name: project + // in: body + // required: true + // schema: { "$ref": "#/definitions/NewProjectPayload" } + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + innerCreateProject(ctx, project_model.TypeRepository) +} + +func GetProject(ctx *context.APIContext) { + // swagger:operation GET /projects/{id} project projectGetProject + // --- + // summary: Get project + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + project, err := project_model.GetProjectByID(ctx, ctx.FormInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetProjectByID", err) + } + return + } + + projectResponse, err := convert.ToAPIProject(ctx, project) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProjectByID", err) + return + } + ctx.JSON(http.StatusOK, projectResponse) +} + +func UpdateProject(ctx *context.APIContext) { + // swagger:operation PATCH /projects/{id} project projectUpdateProject + // --- + // summary: Update project + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: string + // required: true + // - name: project + // in: body + // required: true + // schema: { "$ref": "#/definitions/UpdateProjectPayload" } + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.UpdateProjectPayload) + project, err := project_model.GetProjectByID(ctx, ctx.FormInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "UpdateProject", err) + } + return + } + if project.Title != form.Title { + project.Title = form.Title + } + if project.Description != form.Description { + project.Description = form.Description + } + + err = project_model.UpdateProject(ctx, project) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProject", err) + return + } + projectResponse, err := convert.ToAPIProject(ctx, project) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProject", err) + return + } + ctx.JSON(http.StatusOK, projectResponse) +} + +func DeleteProject(ctx *context.APIContext) { + // swagger:operation DELETE /projects/{id} project projectDeleteProject + // --- + // summary: Delete project + // parameters: + // - name: id + // in: path + // description: id of the project + // type: string + // required: true + // responses: + // "204": + // "description": "Deleted the project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if err := project_model.DeleteProjectByID(ctx, ctx.FormInt64(":id")); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteProjectByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func ListUserProjects(ctx *context.APIContext) { + // swagger:operation GET /user/projects project projectListUserProjects + // --- + // summary: List user projects + // produces: + // - application/json + // parameters: + // - name: closed + // in: query + // description: include closed projects or not + // type: boolean + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + Type: project_model.TypeIndividual, + IsClosed: ctx.FormOptionalBool("closed"), + OwnerID: ctx.Doer.ID, + ListOptions: db.ListOptions{Page: ctx.FormInt("page")}, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListUserProjets", err) + return + } + + ctx.SetLinkHeader(int(count), setting.UI.IssuePagingNum) + ctx.SetTotalCountHeader(count) + + apiProjects, err := convert.ToAPIProjectList(ctx, projects) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListUserProjects", err) + return + } + + ctx.JSON(http.StatusOK, apiProjects) +} + +func ListOrgProjects(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/projects project projectListOrgProjects + // --- + // summary: List org projects + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: closed + // in: query + // description: include closed projects or not + // type: boolean + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.Org.Organization.AsUser().ID, + ListOptions: db.ListOptions{Page: ctx.FormInt("page")}, + IsClosed: ctx.FormOptionalBool("closed"), + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListOrgProjects", err) + return + } + + ctx.SetLinkHeader(int(count), setting.UI.IssuePagingNum) + ctx.SetTotalCountHeader(count) + + apiProjects, err := convert.ToAPIProjectList(ctx, projects) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListOrgProjects", err) + return + } + + ctx.JSON(http.StatusOK, apiProjects) +} + +func ListRepoProjects(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects project projectListRepositoryProjects + // --- + // summary: List repository projects + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: repo + // in: path + // description: repo + // type: string + // required: true + // - name: closed + // in: query + // description: include closed projects or not + // type: boolean + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + page := ctx.FormInt("page") + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsClosed: ctx.FormOptionalBool("closed"), + Type: project_model.TypeRepository, + ListOptions: db.ListOptions{Page: page}, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListRepoProjects", err) + return + } + + ctx.SetLinkHeader(int(count), page) + ctx.SetTotalCountHeader(count) + + apiProjects, err := convert.ToAPIProjectList(ctx, projects) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListRepoProjects", err) + return + } + + ctx.JSON(http.StatusOK, apiProjects) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1de58632d57fa..f717a49094b4e 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -205,4 +205,10 @@ type swaggerParameterBodies struct { // in:body UpdateVariableOption api.UpdateVariableOption + + // in:body + NewProjectPayload api.NewProjectPayload + + // in:body + UpdateProjectPayload api.UpdateProjectPayload } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go new file mode 100644 index 0000000000000..cfa00c7faeab3 --- /dev/null +++ b/routers/api/v1/swagger/project.go @@ -0,0 +1,22 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// Project +// swagger:response Project +type swaggerResponseProject struct { + // in:body + Body api.Project `json:"body"` +} + +// ProjectList +// swagger:response ProjectList +type swaggerResponseProjectList struct { + // in:body + Body []api.Project `json:"body"` +} diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000000..1cb00696abd15 --- /dev/null +++ b/services/convert/project.go @@ -0,0 +1,74 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + project_model "code.gitea.io/gitea/models/project" + api "code.gitea.io/gitea/modules/structs" +) + +func ToAPIProject(ctx context.Context, project *project_model.Project) (*api.Project, error) { + apiProject := &api.Project{ + Title: project.Title, + Description: project.Description, + TemplateType: uint8(project.TemplateType), + IsClosed: project.IsClosed, + Created: project.CreatedUnix.AsTime(), + Updated: project.UpdatedUnix.AsTime(), + Closed: project.ClosedDateUnix.AsTime(), + } + + if err := project.LoadRepo(ctx); err != nil { + return nil, err + } + if project.Repo != nil { + apiProject.Repo = &api.RepositoryMeta{ + ID: project.RepoID, + Name: project.Repo.Name, + Owner: project.Repo.OwnerName, + FullName: project.Repo.FullName(), + } + } + + if err := project.LoadCreator(ctx); err != nil { + return nil, err + } + if project.Creator != nil { + apiProject.Creator = &api.User{ + ID: project.Creator.ID, + UserName: project.Creator.Name, + FullName: project.Creator.FullName, + } + } + + if err := project.LoadOwner(ctx); err != nil { + return nil, err + } + if project.Owner != nil { + apiProject.Owner = &api.User{ + ID: project.Owner.ID, + UserName: project.Owner.Name, + FullName: project.Owner.FullName, + } + } + + return apiProject, nil +} + +func ToAPIProjectList(ctx context.Context, projects []*project_model.Project) ([]*api.Project, error) { + result := make([]*api.Project, len(projects)) + var err error + for i := range projects { + result[i], err = ToAPIProject(ctx, projects[i]) + if err != nil { + break + } + } + if err != nil { + return nil, err + } + return result, nil +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d9f1a8f5e931f..f30c8b70e85e4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2831,6 +2831,94 @@ } } }, + "/orgs/{org}/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "List org projects", + "operationId": "projectListOrgProjects", + "parameters": [ + { + "type": "string", + "description": "owner of the repository", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "include closed projects or not", + "name": "closed", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a organization project", + "operationId": "projectCreateOrgProject", + "parameters": [ + { + "type": "string", + "description": "owner of repo", + "name": "org", + "in": "path", + "required": true + }, + { + "name": "project", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewProjectPayload" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -3432,6 +3520,106 @@ } } }, + "/projects/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get project", + "operationId": "projectGetProject", + "parameters": [ + { + "type": "string", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "project" + ], + "summary": "Delete project", + "operationId": "projectDeleteProject", + "parameters": [ + { + "type": "string", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deleted the project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update project", + "operationId": "projectUpdateProject", + "parameters": [ + { + "type": "string", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "project", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateProjectPayload" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/issues/search": { "get": { "produces": [ @@ -11160,6 +11348,111 @@ } } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "List repository projects", + "operationId": "projectListRepositoryProjects", + "parameters": [ + { + "type": "string", + "description": "owner of the repository", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "include closed projects or not", + "name": "closed", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a repository project", + "operationId": "projectCreateRepositoryProject", + "parameters": [ + { + "type": "string", + "description": "owner of repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "project", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewProjectPayload" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -17121,6 +17414,83 @@ } } }, + "/user/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "List user projects", + "operationId": "projectListUserProjects", + "parameters": [ + { + "type": "boolean", + "description": "include closed projects or not", + "name": "closed", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a user project", + "operationId": "projectCreateUserProject", + "parameters": [ + { + "name": "project", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewProjectPayload" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/user/repos": { "get": { "produces": [ @@ -22775,6 +23145,35 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "NewProjectPayload": { + "type": "object", + "required": [ + "title", + "board_type", + "card_type" + ], + "properties": { + "board_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "BoardType" + }, + "card_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NodeInfo": { "description": "NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks", "type": "object", @@ -23345,6 +23744,58 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Project": { + "type": "object", + "properties": { + "board_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "BoardType" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Closed" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "creator": { + "$ref": "#/definitions/User" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "is_closed": { + "type": "boolean", + "x-go-name": "IsClosed" + }, + "owner": { + "$ref": "#/definitions/User" + }, + "repository": { + "$ref": "#/definitions/RepositoryMeta" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -24826,6 +25277,23 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateProjectPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string", + "x-go-name": "Description" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UpdateRepoAvatarOption": { "description": "UpdateRepoAvatarUserOption options when updating the repo avatar", "type": "object", @@ -25837,6 +26305,21 @@ } } }, + "Project": { + "description": "Project", + "schema": { + "$ref": "#/definitions/Project" + } + }, + "ProjectList": { + "description": "ProjectList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { @@ -26262,7 +26745,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/UpdateVariableOption" + "$ref": "#/definitions/UpdateProjectPayload" } }, "redirect": { @@ -26361,4 +26844,4 @@ "TOTPHeader": [] } ] -} +} \ No newline at end of file diff --git a/tests/integration/api_project_test.go b/tests/integration/api_project_test.go new file mode 100644 index 0000000000000..740610050a4cd --- /dev/null +++ b/tests/integration/api_project_test.go @@ -0,0 +1,170 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPICreateUserProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const title, description, boardType = "project_name", "project_description", uint8(project_model.TemplateTypeBasicKanban) + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteUser) + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/projects", &api.NewProjectPayload{ + Title: title, + Description: description, + BoardType: boardType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, title, apiProject.Title) + assert.Equal(t, description, apiProject.Description) + assert.Equal(t, boardType, apiProject.TemplateType) + assert.Equal(t, "user2", apiProject.Creator.UserName) +} + +func TestAPICreateOrgProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const title, description, boardType = "project_name", "project_description", uint8(project_model.TemplateTypeBasicKanban) + + orgName := "org17" + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteOrganization) + urlStr := fmt.Sprintf("/api/v1/orgs/%s/projects", orgName) + + req := NewRequestWithJSON(t, "POST", urlStr, &api.NewProjectPayload{ + Title: title, + Description: description, + BoardType: boardType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, title, apiProject.Title) + assert.Equal(t, description, apiProject.Description) + assert.Equal(t, boardType, apiProject.TemplateType) + assert.Equal(t, "user2", apiProject.Creator.UserName) + assert.Equal(t, "org17", apiProject.Owner.UserName) +} + +func TestAPICreateRepoProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const title, description, boardType = "project_name", "project_description", uint8(project_model.TemplateTypeBasicKanban) + + ownerName := "user2" + repoName := "repo1" + token := getUserToken(t, ownerName, auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteOrganization) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/projects", ownerName, repoName) + + req := NewRequestWithJSON(t, "POST", urlStr, &api.NewProjectPayload{ + Title: title, + Description: description, + BoardType: boardType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, title, apiProject.Title) + assert.Equal(t, description, apiProject.Description) + assert.Equal(t, boardType, apiProject.TemplateType) + assert.Equal(t, "repo1", apiProject.Repo.Name) +} + +func TestAPIListUserProjects(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadIssue) + link, _ := url.Parse("/api/v1/user/projects") + + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + var apiProjects []*api.Project + + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiProjects) + assert.Len(t, apiProjects, 1) +} + +func TestAPIListOrgProjects(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + orgName := "org17" + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/orgs/%s/projects", orgName)) + + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + var apiProjects []*api.Project + + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiProjects) + assert.Len(t, apiProjects, 1) +} + +func TestAPIListRepoProjects(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + ownerName := "user2" + repoName := "repo1" + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/projects", ownerName, repoName)) + + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + var apiProjects []*api.Project + + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiProjects) + assert.Len(t, apiProjects, 1) +} + +func TestAPIGetProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d", 1)) + + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + var apiProject *api.Project + + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, "First project", apiProject.Title) + assert.Equal(t, "repo1", apiProject.Repo.Name) + assert.Equal(t, "user2", apiProject.Creator.UserName) +} + +func TestAPIUpdateProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d", 1)) + + req := NewRequestWithJSON(t, "PATCH", link.String(), &api.UpdateProjectPayload{Title: "First project updated"}).AddTokenAuth(token) + + var apiProject *api.Project + + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, "First project updated", apiProject.Title) +} + +func TestAPIDeleteProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d", 1)) + + req := NewRequest(t, "DELETE", link.String()).AddTokenAuth(token) + + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertNotExistsBean(t, &project_model.Project{ID: 1}) +}