diff --git a/models/auth/access_token_scope.go b/models/auth/access_token_scope.go index 3eae19b2a53cf..84bf2db43b1d7 100644 --- a/models/auth/access_token_scope.go +++ b/models/auth/access_token_scope.go @@ -24,6 +24,7 @@ const ( AccessTokenScopeCategoryIssue AccessTokenScopeCategoryRepository AccessTokenScopeCategoryUser + AccessTokenScopeCategoryProject ) // AllAccessTokenScopeCategories contains all access token scope categories @@ -37,6 +38,7 @@ var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{ AccessTokenScopeCategoryIssue, AccessTokenScopeCategoryRepository, AccessTokenScopeCategoryUser, + AccessTokenScopeCategoryProject, } // AccessTokenScopeLevel represents the access levels without a given scope category @@ -82,6 +84,9 @@ const ( AccessTokenScopeReadUser AccessTokenScope = "read:user" AccessTokenScopeWriteUser AccessTokenScope = "write:user" + + AccessTokenScopeReadProject AccessTokenScope = "read:project" + AccessTokenScopeWriteProject AccessTokenScope = "write:project" ) // accessTokenScopeBitmap represents a bitmap of access token scopes. @@ -124,6 +129,9 @@ const ( accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1< 64 scopes, // refactoring the whole implementation in this file (and only this file) is needed. @@ -142,6 +150,7 @@ var allAccessTokenScopes = []AccessTokenScope{ AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue, AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository, AccessTokenScopeWriteUser, AccessTokenScopeReadUser, + AccessTokenScopeWriteProject, AccessTokenScopeReadProject, } // allAccessTokenScopeBits contains all access token scopes. @@ -166,6 +175,8 @@ var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{ AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits, AccessTokenScopeReadUser: accessTokenScopeReadUserBits, AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits, + AccessTokenScopeReadProject: accessTokenScopeReadProjectBits, + AccessTokenScopeWriteProject: accessTokenScopeWriteProjectBits, } // readAccessTokenScopes maps a scope category to the read permission scope @@ -180,6 +191,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue, AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository, AccessTokenScopeCategoryUser: AccessTokenScopeReadUser, + AccessTokenScopeCategoryProject: AccessTokenScopeReadProject, }, Write: { AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub, @@ -191,6 +203,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue, AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository, AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser, + AccessTokenScopeCategoryProject: AccessTokenScopeWriteProject, }, } diff --git a/models/auth/access_token_scope_test.go b/models/auth/access_token_scope_test.go index b93c25528fe71..6c804a295d371 100644 --- a/models/auth/access_token_scope_test.go +++ b/models/auth/access_token_scope_test.go @@ -17,7 +17,7 @@ type scopeTestNormalize struct { } func TestAccessTokenScope_Normalize(t *testing.T) { - assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories()) + assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "project", "repository", "user"}, GetAccessTokenCategories()) tests := []scopeTestNormalize{ {"", "", nil}, {"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil}, diff --git a/models/project/column.go b/models/project/column.go index 9b9d874997edd..319eb3df7472a 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -34,6 +34,28 @@ const ( CardTypeImagesAndText ) +func (p CardType) ToString() string { + switch p { + case CardTypeImagesAndText: + return "ImagesAndText" + case CardTypeTextOnly: + fallthrough + default: + return "TextOnly" + } +} + +func ToCardType(s string) CardType { + switch s { + case "ImagesAndText": + return CardTypeImagesAndText + case "TextOnly": + fallthrough + default: + return CardTypeTextOnly + } +} + // ColumnColorPattern is a regexp witch can validate ColumnColor var ColumnColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$") diff --git a/models/project/project.go b/models/project/project.go index f516466854936..ab2e1c66f1d0b 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 @@ -121,6 +122,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/models/project/template.go b/models/project/template.go index 06d5d2af1482b..9846a334639dc 100644 --- a/models/project/template.go +++ b/models/project/template.go @@ -25,6 +25,31 @@ const ( TemplateTypeBugTriage ) +func (p TemplateType) ToString() string { + switch p { + case TemplateTypeBasicKanban: + return "BasicKanban" + case TemplateTypeBugTriage: + return "BugTriage" + case TemplateTypeNone: + fallthrough + default: + return "" + } +} + +// ToTemplateType converts a string to a TemplateType +func ToTemplateType(s string) TemplateType { + switch s { + case "BasicKanban": + return TemplateTypeBasicKanban + case "BugTriage": + return TemplateTypeBugTriage + default: + return TemplateTypeNone + } +} + // GetTemplateConfigs retrieves the template configs of configurations project columns could have func GetTemplateConfigs() []TemplateConfig { return []TemplateConfig{ diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000000..193fa3743a674 --- /dev/null +++ b/modules/structs/project.go @@ -0,0 +1,59 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// NewProjectOption options when creating a new project +// swagger:model +type NewProjectOption struct { + // required:true + // Keep compatibility with Github API to use "name" instead of "title" + Name string `json:"name" binding:"Required"` + // required:true + // enum: , BasicKanban, BugTriage + // Note: this is the same as TemplateType in models/project/template.go + TemplateType string `json:"template_type"` + // required:true + // enum: TextOnly, ImagesAndText + CardType string `json:"card_type"` + // Keep compatibility with Github API to use "body" instead of "description" + Body string `json:"body"` +} + +// UpdateProjectOption options when updating a project +// swagger:model +type UpdateProjectOption struct { + // required:true + // Keep compatibility with Github API to use "name" instead of "title" + Name string `json:"name" binding:"Required"` + // Keep compatibility with Github API to use "body" instead of "description" + Body string `json:"body"` +} + +// Project represents a project +// swagger:model +type Project struct { + ID int64 `json:"id"` + // Keep compatibility with Github API to use "name" instead of "title" + Name string `json:"name"` + // Keep compatibility with Github API to use "body" instead of "description" + Body string `json:"body"` + // required:true + // enum: , BasicKanban, BugTriage + // Note: this is the same as TemplateType in models/project/template.go + TemplateType string `json:"template_type"` + // enum: open, closed + State string `json:"state"` + // 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 f412e8a06caca..c935789de8dbc 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" @@ -1041,6 +1042,13 @@ func Routes() *web.Router { m.Get("/subscriptions", user.GetWatchedRepos) }, context.UserAssignmentAPI(), checkTokenPublicOnly()) + + m.Group("/{username}", func() { + m.Group("/projects", func() { + m.Get("", projects.ListUserProjects) + m.Post("", bind(api.NewProjectOption{}), projects.CreateUserProject) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryProject)) + }, context.UserAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Users (requires user scope) @@ -1467,6 +1475,10 @@ func Routes() *web.Router { }, reqAdmin(), reqToken()) m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) + + m.Group("/projects", func() { + m.Post("", bind(api.NewProjectOption{}), projects.CreateRepoProject) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryProject)) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) @@ -1687,6 +1699,11 @@ func Routes() *web.Router { m.Delete("", org.UnblockUser) }) }, reqToken(), reqOrgOwnership()) + + m.Group("/projects", func() { + m.Post("", bind(api.NewProjectOption{}), projects.CreateOrgProject) + m.Get("", projects.ListOrgProjects) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryProject)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) m.Group("/teams/{teamid}", func() { m.Combo("").Get(reqToken(), org.GetTeam). @@ -1709,6 +1726,13 @@ func Routes() *web.Router { m.Get("/activities/feeds", org.ListTeamActivityFeeds) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly()) + // Projects + m.Group("/projects", func() { + m.Get("{project_id}", projects.GetProject) + m.Patch("{project_id}", bind(api.UpdateProjectOption{}), projects.UpdateProject) + m.Delete("{project_id}", projects.DeleteProject) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryProject), reqToken()) + m.Group("/admin", func() { m.Group("/cron", func() { m.Get("", admin.ListCronTasks) diff --git a/routers/api/v1/projects/project.go b/routers/api/v1/projects/project.go new file mode 100644 index 0000000000000..a8643006b6543 --- /dev/null +++ b/routers/api/v1/projects/project.go @@ -0,0 +1,426 @@ +// 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" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "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.NewProjectOption) + project := &project_model.Project{ + Title: form.Name, + Description: form.Body, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.ToTemplateType(form.TemplateType), + Type: projectType, + } + + if ctx.ContextUser == nil { + ctx.APIError(http.StatusForbidden, "Not authenticated") + return + } + 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.APIErrorInternal(err) + return + } + + project, err := project_model.GetProjectByID(ctx, project.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + projectResponse, err := convert.ToAPIProject(ctx, project) + if err != nil { + ctx.APIErrorInternal(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/NewProjectOption" } + // 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/NewProjectOption" } + // 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/NewProjectOption" } + // 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/{project_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("project_id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIError(http.StatusNotFound, err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + projectResponse, err := convert.ToAPIProject(ctx, project) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, projectResponse) +} + +func UpdateProject(ctx *context.APIContext) { + // swagger:operation PATCH /projects/{project_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/UpdateProjectOption" } + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.UpdateProjectOption) + project, err := project_model.GetProjectByID(ctx, ctx.FormInt64("project_id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIError(http.StatusNotFound, err) + } else { + ctx.APIErrorInternal(err) + } + return + } + if project.Title != form.Name { + project.Title = form.Name + } + if project.Description != form.Body { + project.Description = form.Body + } + + err = project_model.UpdateProject(ctx, project) + if err != nil { + ctx.APIErrorInternal(err) + return + } + projectResponse, err := convert.ToAPIProject(ctx, project) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, projectResponse) +} + +func DeleteProject(ctx *context.APIContext) { + // swagger:operation DELETE /projects/{project_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("project_id")); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func ListUserProjects(ctx *context.APIContext) { + // swagger:operation GET /users/{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" + + listOptions := utils.GetListOptions(ctx) + 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: listOptions, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(int(count), listOptions.PageSize) + ctx.SetTotalCountHeader(count) + + apiProjects, err := convert.ToAPIProjectList(ctx, projects) + if err != nil { + ctx.APIErrorInternal(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" + + listOptions := utils.GetListOptions(ctx) + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.Org.Organization.AsUser().ID, + ListOptions: listOptions, + IsClosed: ctx.FormOptionalBool("closed"), + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(int(count), listOptions.PageSize) + ctx.SetTotalCountHeader(count) + + apiProjects, err := convert.ToAPIProjectList(ctx, projects) + if err != nil { + ctx.APIErrorInternal(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" + + listOptions := utils.GetListOptions(ctx) + 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: listOptions, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(int(count), listOptions.PageSize) + ctx.SetTotalCountHeader(count) + + apiProjects, err := convert.ToAPIProjectList(ctx, projects) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, apiProjects) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index bafd5e04a2af3..19af6d82595df 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -222,4 +222,10 @@ type swaggerParameterBodies struct { // in:body LockIssueOption api.LockIssueOption + + // in:body + NewProjectOption api.NewProjectOption + + // in:body + UpdateProjectOption api.UpdateProjectOption } 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..94215a62e0d22 --- /dev/null +++ b/services/convert/project.go @@ -0,0 +1,78 @@ +// 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" + "code.gitea.io/gitea/modules/util" +) + +func ToAPIProject(ctx context.Context, project *project_model.Project) (*api.Project, error) { + apiProject := &api.Project{ + Name: project.Title, + Body: project.Description, + TemplateType: project.TemplateType.ToString(), + State: util.Iif(project.IsClosed, "closed", "open"), + Created: project.CreatedUnix.AsTime(), + Updated: project.UpdatedUnix.AsTime(), + } + if !project.ClosedDateUnix.IsZero() { + tm := project.ClosedDateUnix.AsTime() + apiProject.Closed = &tm + } + + 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 1cc3735b2b1da..f5e7b0820e9ae 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3311,6 +3311,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/NewProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -4137,6 +4225,106 @@ } } }, + "/projects/{project_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/UpdateProjectOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/issues/search": { "get": { "produces": [ @@ -13411,6 +13599,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/NewProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -19661,6 +19954,42 @@ } } }, + "/user/projects": { + "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/NewProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/user/repos": { "get": { "produces": [ @@ -20751,6 +21080,49 @@ } } }, + "/users/{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" + } + } + } + }, "/version": { "get": { "produces": [ @@ -26010,6 +26382,44 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "NewProjectOption": { + "description": "NewProjectOption options when creating a new project", + "type": "object", + "required": [ + "name", + "template_type", + "card_type" + ], + "properties": { + "body": { + "description": "Keep compatibility with Github API to use \"body\" instead of \"description\"", + "type": "string", + "x-go-name": "Body" + }, + "card_type": { + "type": "string", + "enum": [ + "TextOnly", + " ImagesAndText" + ], + "x-go-name": "CardType" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "template_type": { + "type": "string", + "enum": [ + "", + " BasicKanban", + " BugTriage" + ], + "x-go-name": "TemplateType" + } + }, + "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", @@ -26582,6 +26992,72 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Project": { + "description": "Project represents a project", + "type": "object", + "required": [ + "template_type" + ], + "properties": { + "body": { + "description": "Keep compatibility with Github API to use \"body\" instead of \"description\"", + "type": "string", + "x-go-name": "Body" + }, + "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" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Keep compatibility with Github API to use \"name\" instead of \"title\"", + "type": "string", + "x-go-name": "Name" + }, + "owner": { + "$ref": "#/definitions/User" + }, + "repository": { + "$ref": "#/definitions/RepositoryMeta" + }, + "state": { + "type": "string", + "enum": [ + "open", + " closed" + ], + "x-go-name": "State" + }, + "template_type": { + "type": "string", + "enum": [ + "", + " BasicKanban", + " BugTriage" + ], + "x-go-name": "TemplateType" + }, + "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", @@ -28136,6 +28612,25 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateProjectOption": { + "description": "UpdateProjectOption options when updating a project", + "type": "object", + "required": [ + "name" + ], + "properties": { + "body": { + "description": "Keep compatibility with Github API to use \"body\" instead of \"description\"", + "type": "string", + "x-go-name": "Body" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UpdateRepoAvatarOption": { "description": "UpdateRepoAvatarUserOption options when updating the repo avatar", "type": "object", @@ -29206,6 +29701,21 @@ } } }, + "Project": { + "description": "Project", + "schema": { + "$ref": "#/definitions/Project" + } + }, + "ProjectList": { + "description": "ProjectList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { @@ -29667,7 +30177,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/LockIssueOption" + "$ref": "#/definitions/UpdateProjectOption" } }, "redirect": { diff --git a/tests/integration/api_project_test.go b/tests/integration/api_project_test.go new file mode 100644 index 0000000000000..66c3baefb2018 --- /dev/null +++ b/tests/integration/api_project_test.go @@ -0,0 +1,173 @@ +// 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 = "project_name", "project_description" + templateType := project_model.TemplateTypeBasicKanban.ToString() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteProject, auth_model.AccessTokenScopeWriteUser) + + req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/projects", &api.NewProjectOption{ + Name: title, + Body: description, + TemplateType: templateType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, title, apiProject.Name) + assert.Equal(t, description, apiProject.Body) + assert.Equal(t, templateType, apiProject.TemplateType) + assert.Equal(t, "user2", apiProject.Creator.UserName) +} + +func TestAPICreateOrgProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const title, description = "project_name", "project_description" + templateType := project_model.TemplateTypeBasicKanban.ToString() + + 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.NewProjectOption{ + Name: title, + Body: description, + TemplateType: templateType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, title, apiProject.Name) + assert.Equal(t, description, apiProject.Body) + assert.Equal(t, templateType, 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 = "project_name", "project_description" + templateType := project_model.TemplateTypeBasicKanban.ToString() + + 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.NewProjectOption{ + Name: title, + Body: description, + TemplateType: templateType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, title, apiProject.Name) + assert.Equal(t, description, apiProject.Body) + assert.Equal(t, templateType, 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/users/user2/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.AccessTokenScopeReadProject) + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d", 4)) + + 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.Name) + 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.AccessTokenScopeWriteProject) + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d", 4)) + + req := NewRequestWithJSON(t, "PATCH", link.String(), &api.UpdateProjectOption{Name: "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.Name) +} + +func TestAPIDeleteProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteProject) + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d", 4)) + + req := NewRequest(t, "DELETE", link.String()).AddTokenAuth(token) + + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertNotExistsBean(t, &project_model.Project{ID: 1}) +}