From 20fde317b33197a68c5dbdae1dca48a4b6eaf2c8 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Tue, 9 Jul 2024 22:43:50 +0300 Subject: [PATCH 01/20] feat: add projects/ endpoint for initiating extension of api --- modules/structs/project.go | 20 +++++++++++ routers/api/v1/api.go | 30 ++++++++++++++++ routers/api/v1/org/project.go | 39 +++++++++++++++++++++ services/convert/project.go | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 modules/structs/project.go create mode 100644 routers/api/v1/org/project.go create mode 100644 services/convert/project.go diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000000..2163d15980adc --- /dev/null +++ b/modules/structs/project.go @@ -0,0 +1,20 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// Project represents a project +type Project struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + TemplateType string `json:"template_type"` + CardType string `json:"card_type"` +} + +type CreateProjectOption struct { + Title string `json:"title" binding:"Required;MaxSize(100)"` + Content string `json:"content"` + TemplateType uint8 `json:"template_type"` + CardType uint8 `json:"card_type"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index be67ec1695b3b..5df1a7f9048bb 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -864,6 +864,36 @@ func Routes() *web.Router { }) } + m.Group("/{username}", func() { + m.Group("/projects", func() { + // m.Group("", func() { + // m.Get("", org.Projects) + // m.Get("/{id}", org.ViewProject) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { //nolint:dupl + // m.Get("/new", org.RenderNewProject) + m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + // m.Group("/{id}", func() { + // m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) + // m.Post("/move", project.MoveColumns) + // m.Post("/delete", org.DeleteProject) + + // m.Get("/edit", org.RenderEditProject) + // m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) + // m.Post("/{action:open|close}", org.ChangeProjectStatus) + + // m.Group("/{columnID}", func() { + // m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) + // m.Delete("", org.DeleteProjectColumn) + // m.Post("/default", org.SetDefaultProjectColumn) + // m.Post("/move", org.MoveIssues) + // }) + // }) + }) + }, repoAssignment()) + + }) + m.Group("", func() { // Miscellaneous (no scope required) if setting.API.EnableSwagger { diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go new file mode 100644 index 0000000000000..a7344cfa4dfd8 --- /dev/null +++ b/routers/api/v1/org/project.go @@ -0,0 +1,39 @@ +package org + +import ( + "log" + "net/http" + + 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/services/context" +) + +func CreateProject(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateProjectOption) + + log.Println(ctx.ContextUser.ID) + + project := &project_model.Project{ + OwnerID: ctx.ContextUser.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.TemplateType), + CardType: project_model.CardType(form.CardType), + } + + if ctx.ContextUser.IsOrganization() { + project.Type = project_model.TypeOrganization + } else { + project.Type = project_model.TypeIndividual + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + ctx.JSON(http.StatusCreated, map[string]int64{"id": project.ID}) +} diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000000..12cb1358cdfbc --- /dev/null +++ b/services/convert/project.go @@ -0,0 +1,65 @@ +package convert + +// use this as reference to create the ToProject function: +/* +// ToLabel converts Label to API format +func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_model.User) *api.Label { + result := &api.Label{ + ID: label.ID, + Name: label.Name, + Exclusive: label.Exclusive, + Color: strings.TrimLeft(label.Color, "#"), + Description: label.Description, + IsArchived: label.IsArchived(), + } + + labelBelongsToRepo := label.BelongsToRepo() + + // calculate URL + if labelBelongsToRepo && repo != nil { + result.URL = fmt.Sprintf("%s/labels/%d", repo.APIURL(), label.ID) + } else { // BelongsToOrg + if org != nil { + result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/labels/%d", setting.AppURL, url.PathEscape(org.Name), label.ID) + } else { + log.Error("ToLabel did not get org to calculate url for label with id '%d'", label.ID) + } + } + + if labelBelongsToRepo && repo == nil { + log.Error("ToLabel did not get repo to calculate url for label with id '%d'", label.ID) + } + + return result +} +*/ + +// ToProject converts Project to API format +// func ToProject(project *project_model.Project, repo *repo_model.Repository, org *user_model.User) *api.Project { +// result := &api.Project{ +// ID: project.ID, +// Title: project.Title, +// Description: project.Description, +// TemplateType: project.TemplateType, +// CardType: project.CardType, +// } + +// projectBelongsToRepo := project.BelongsToRepo() + +// // calculate URL +// if projectBelongsToRepo && repo != nil { +// result.URL = fmt.Sprintf("%s/projects/%d", repo.APIURL(), project.ID) +// } else { // BelongsToOrg +// if org != nil { +// result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/projects/%d", setting.AppURL, url.PathEscape(org.Name), project.ID) +// } else { +// log.Error("ToProject did not get org to calculate url for project with id '%d'", project.ID) +// } +// } + +// if projectBelongsToRepo && repo == nil { +// log.Error("ToProject did not get repo to calculate url for project with id '%d'", project.ID) +// } + +// return result +// } From 5814b6d590a7bc78128d2edf2a7a152ca09f0e47 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 10 Jul 2024 09:15:42 +0300 Subject: [PATCH 02/20] refactor: refactor logic of adding project to user, update endpoint --- routers/api/v1/api.go | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5df1a7f9048bb..be72565a16576 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -864,36 +864,6 @@ func Routes() *web.Router { }) } - m.Group("/{username}", func() { - m.Group("/projects", func() { - // m.Group("", func() { - // m.Get("", org.Projects) - // m.Get("/{id}", org.ViewProject) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - m.Group("", func() { //nolint:dupl - // m.Get("/new", org.RenderNewProject) - m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) - // m.Group("/{id}", func() { - // m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) - // m.Post("/move", project.MoveColumns) - // m.Post("/delete", org.DeleteProject) - - // m.Get("/edit", org.RenderEditProject) - // m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) - // m.Post("/{action:open|close}", org.ChangeProjectStatus) - - // m.Group("/{columnID}", func() { - // m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) - // m.Delete("", org.DeleteProjectColumn) - // m.Post("/default", org.SetDefaultProjectColumn) - // m.Post("/move", org.MoveIssues) - // }) - // }) - }) - }, repoAssignment()) - - }) - m.Group("", func() { // Miscellaneous (no scope required) if setting.API.EnableSwagger { @@ -987,6 +957,10 @@ func Routes() *web.Router { m.Get("/starred", user.GetStarredRepos) m.Get("/subscriptions", user.GetWatchedRepos) + + m.Group("/projects", func() { + m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + }) }, context.UserAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) From 773bd91232ea880ba83841a3d2492c7c794d3ef0 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 10 Jul 2024 10:32:46 +0300 Subject: [PATCH 03/20] api: create endpoint for changing project status --- routers/api/v1/api.go | 32 +++++++++++++++++++++++++++++++- routers/api/v1/org/project.go | 22 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index be72565a16576..90ba816b65bd5 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -811,6 +811,28 @@ func checkDeprecatedAuthMethods(ctx *context.APIContext) { } } +func reqUnitAccess(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + // only check global disabled units when ignoreGlobal is false + if !ignoreGlobal && unitType.UnitGlobalDisabled() { + ctx.NotFound("Repo unit is is disabled: "+unitType.LogString(), nil) + return + } + + if ctx.ContextUser == nil { + ctx.NotFound("ContextUser is nil", nil) + return + } + + if ctx.ContextUser.IsOrganization() { + if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) < accessMode { + ctx.NotFound("ContextUser is org but doer has no access to unit", nil) + return + } + } + } +} + // Routes registers all v1 APIs routes to web application. func Routes() *web.Router { m := web.NewRouter() @@ -960,8 +982,16 @@ func Routes() *web.Router { m.Group("/projects", func() { m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + m.Group("/{id}", func() { + m.Post("/{action:open|close}", org.ChangeProjectStatus) + }) }) - }, context.UserAssignmentAPI()) + }, context.UserAssignmentAPI(), reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Users (requires user scope) diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index a7344cfa4dfd8..9415727b28f02 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/services/context" ) +// CreateProject creates a new project func CreateProject(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateProjectOption) @@ -37,3 +38,24 @@ func CreateProject(ctx *context.APIContext) { ctx.JSON(http.StatusCreated, map[string]int64{"id": project.ID}) } + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.APIContext) { + var toClose bool + switch ctx.PathParam(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.NotFound("ChangeProjectStatus", nil) + return + } + id := ctx.PathParamInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) +} From 5877fabc841f571eb63368818fe2d8af184c303a Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 10 Jul 2024 12:58:12 +0300 Subject: [PATCH 04/20] api: create endpoint to get standalone projects created by user --- routers/api/v1/api.go | 81 +++++++++++++++++++++++++++++------ routers/api/v1/org/project.go | 42 ++++++++++++++++++ 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 90ba816b65bd5..572abcdb313ce 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -964,6 +964,73 @@ func Routes() *web.Router { }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) + // m.Group("/projects", func() { + // m.Group("", func() { + // m.Get("", org.Projects) + // m.Get("/{id}", org.ViewProject) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + // m.Group("", func() { //nolint:dupl + // m.Get("/new", org.RenderNewProject) + // m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) + // m.Group("/{id}", func() { + // m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) + // m.Post("/move", project.MoveColumns) + // m.Post("/delete", org.DeleteProject) + + // m.Get("/edit", org.RenderEditProject) + // m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) + // m.Post("/{action:open|close}", org.ChangeProjectStatus) + + // m.Group("/{columnID}", func() { + // m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) + // m.Delete("", org.DeleteProjectColumn) + // m.Post("/default", org.SetDefaultProjectColumn) + // m.Post("/move", org.MoveIssues) + // }) + // }) + // }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { + // if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + // ctx.NotFound("NewProject", nil) + // return + // } + // }) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + // Users (requires user scope) + m.Group("/{username}/-", func() { + m.Group("/projects", func() { + m.Group("", func() { + m.Get("", org.GetProjects) + // m.Get("/{id}", org.ViewProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { + m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + m.Group("/{id}", func() { + m.Post("/{action:open|close}", org.ChangeProjectStatus) + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) + + m.Group("/{org}/-", func() { + m.Group("/projects", func() { + m.Group("", func() { + // m.Get("", org.Projects) + // m.Get("/{id}", org.ViewProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { + m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + m.Group("/{id}", func() { + m.Post("/{action:open|close}", org.ChangeProjectStatus) + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) + // Users (requires user scope) m.Group("/users", func() { m.Group("/{username}", func() { @@ -979,19 +1046,7 @@ func Routes() *web.Router { m.Get("/starred", user.GetStarredRepos) m.Get("/subscriptions", user.GetWatchedRepos) - - m.Group("/projects", func() { - m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) - m.Group("/{id}", func() { - m.Post("/{action:open|close}", org.ChangeProjectStatus) - }) - }) - }, context.UserAssignmentAPI(), reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { - if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - ctx.NotFound("NewProject", nil) - return - } - }) + }, context.UserAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Users (requires user scope) diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 9415727b28f02..1b0f96c7f3a8c 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -3,8 +3,12 @@ package org import ( "log" "net/http" + "strings" + "code.gitea.io/gitea/models/db" project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/optional" + "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" @@ -59,3 +63,41 @@ func ChangeProjectStatus(ctx *context.APIContext) { } ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) } + +// Projects renders the home page of projects +func GetProjects(ctx *context.APIContext) { + ctx.Data["Title"] = ctx.Tr("repo.projects") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + keyword := ctx.FormTrim("q") + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + var projectType project_model.Type + if ctx.ContextUser.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + Type: projectType, + Title: keyword, + }) + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + ctx.JSON(http.StatusOK, projects) +} From f8ae9f730b0234b9877615bc3f3155a19c8e1d97 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 10 Jul 2024 16:12:59 +0300 Subject: [PATCH 05/20] api: implement logic for all project/user related endpoints --- modules/structs/project.go | 4 +- modules/structs/project_column.go | 18 ++ routers/api/v1/api.go | 76 +++--- routers/api/v1/org/project.go | 399 +++++++++++++++++++++++++++++- services/convert/project.go | 65 ----- 5 files changed, 444 insertions(+), 118 deletions(-) create mode 100644 modules/structs/project_column.go delete mode 100644 services/convert/project.go diff --git a/modules/structs/project.go b/modules/structs/project.go index 2163d15980adc..15dfb0e28e4d0 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -8,8 +8,8 @@ type Project struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` - TemplateType string `json:"template_type"` - CardType string `json:"card_type"` + TemplateType uint8 `json:"template_type"` + CardType uint8 `json:"card_type"` } type CreateProjectOption struct { diff --git a/modules/structs/project_column.go b/modules/structs/project_column.go new file mode 100644 index 0000000000000..4138717d77630 --- /dev/null +++ b/modules/structs/project_column.go @@ -0,0 +1,18 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// Column represents a project column +type Column struct { + ID int64 `json:"id"` + Title string `json:"title"` + Color string `json:"color"` +} + +// EditProjectColumnOption options for editing a project column +type EditProjectColumnOption struct { + Title string `binding:"Required;MaxSize(100)"` + Sorting int8 + Color string `binding:"MaxSize(7)"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 572abcdb313ce..8606a27d22b55 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -964,48 +964,29 @@ func Routes() *web.Router { }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) - // m.Group("/projects", func() { - // m.Group("", func() { - // m.Get("", org.Projects) - // m.Get("/{id}", org.ViewProject) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - // m.Group("", func() { //nolint:dupl - // m.Get("/new", org.RenderNewProject) - // m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) - // m.Group("/{id}", func() { - // m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) - // m.Post("/move", project.MoveColumns) - // m.Post("/delete", org.DeleteProject) - - // m.Get("/edit", org.RenderEditProject) - // m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) - // m.Post("/{action:open|close}", org.ChangeProjectStatus) - - // m.Group("/{columnID}", func() { - // m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) - // m.Delete("", org.DeleteProjectColumn) - // m.Post("/default", org.SetDefaultProjectColumn) - // m.Post("/move", org.MoveIssues) - // }) - // }) - // }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { - // if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - // ctx.NotFound("NewProject", nil) - // return - // } - // }) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) // Users (requires user scope) - m.Group("/{username}/-", func() { + m.Group("users/{username}/-", func() { m.Group("/projects", func() { m.Group("", func() { m.Get("", org.GetProjects) - // m.Get("/{id}", org.ViewProject) + m.Get("/{id}", org.GetProject) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) m.Group("/{id}", func() { + m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) + m.Post("/move", org.MoveColumns) + m.Post("/delete", org.DeleteProject) + m.Post("/edit", bind(api.CreateProjectOption{}), org.EditProject) m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", bind(api.EditProjectColumnOption{}), org.EditProjectColumn) + m.Delete("", org.DeleteProjectColumn) + m.Post("/default", org.SetDefaultProjectColumn) + m.Post("/move", org.MoveIssues) + }) }) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { @@ -1014,22 +995,23 @@ func Routes() *web.Router { } }) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) - m.Group("/{org}/-", func() { - m.Group("/projects", func() { - m.Group("", func() { - // m.Get("", org.Projects) - // m.Get("/{id}", org.ViewProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) - m.Group("/{id}", func() { - m.Post("/{action:open|close}", org.ChangeProjectStatus) - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) + // m.Group("orgs/{org}/-", func() { + // m.Group("/projects", func() { + // m.Group("", func() { + // // m.Get("", org.Projects) + // // m.Get("/{id}", org.ViewProject) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + // m.Group("", func() { + // m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + // m.Group("/{id}", func() { + // m.Post("/{action:open|close}", org.ChangeProjectStatus) + // }) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + // }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) // Users (requires user scope) m.Group("/users", func() { diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 1b0f96c7f3a8c..7429c5c943dbc 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -1,12 +1,16 @@ package org import ( - "log" + "encoding/json" + "errors" + "fmt" "net/http" "strings" "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -18,8 +22,6 @@ import ( func CreateProject(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateProjectOption) - log.Println(ctx.ContextUser.ID) - project := &project_model.Project{ OwnerID: ctx.ContextUser.ID, Title: form.Title, @@ -40,7 +42,7 @@ func CreateProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, map[string]int64{"id": project.ID}) + ctx.JSON(http.StatusCreated, project) } // ChangeProjectStatus updates the status of a project between "open" and "close" @@ -101,3 +103,392 @@ func GetProjects(ctx *context.APIContext) { ctx.JSON(http.StatusOK, projects) } + +// TODO: Send issues as well +// GetProject returns a project by ID +func GetProject(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + issues := make(map[int64][]*issues_model.Issue) + + for _, column := range columns { + if empty := issuesMap[column.ID]; len(empty) == 0 { + continue + } + issues[column.ID] = issuesMap[column.ID] + + } + + data := map[string]any{ + "project": project, + "columns": columns, + } + + ctx.JSON(http.StatusOK, data) +} + +// AddColumnToProject adds a new column to a project +func AddColumnToProject(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + column := &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Sorting: form.Sorting, + Color: form.Color, + } + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSON(http.StatusCreated, column) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.APIContext) { + p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + err = project_model.DeleteProjectByID(ctx, p.ID) + + if err != nil { + ctx.ServerError("DeleteProjectByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) +} + +// EditProject updates a project +func EditProject(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateProjectOption) + projectID := ctx.PathParamInt64(":id") + + ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + p.CardType = project_model.CardType(form.CardType) + + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.JSON(http.StatusOK, p) +} + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) +} + +// CheckProjectColumnChangePermissions check permission +func CheckProjectColumnChangePermissions(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.PathParamInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + _, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, column) +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.APIContext) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.PathParamInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "column deleted successfully"}) +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.APIContext) { + project, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "default column set successfully"}) +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.APIContext) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) +} diff --git a/services/convert/project.go b/services/convert/project.go deleted file mode 100644 index 12cb1358cdfbc..0000000000000 --- a/services/convert/project.go +++ /dev/null @@ -1,65 +0,0 @@ -package convert - -// use this as reference to create the ToProject function: -/* -// ToLabel converts Label to API format -func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_model.User) *api.Label { - result := &api.Label{ - ID: label.ID, - Name: label.Name, - Exclusive: label.Exclusive, - Color: strings.TrimLeft(label.Color, "#"), - Description: label.Description, - IsArchived: label.IsArchived(), - } - - labelBelongsToRepo := label.BelongsToRepo() - - // calculate URL - if labelBelongsToRepo && repo != nil { - result.URL = fmt.Sprintf("%s/labels/%d", repo.APIURL(), label.ID) - } else { // BelongsToOrg - if org != nil { - result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/labels/%d", setting.AppURL, url.PathEscape(org.Name), label.ID) - } else { - log.Error("ToLabel did not get org to calculate url for label with id '%d'", label.ID) - } - } - - if labelBelongsToRepo && repo == nil { - log.Error("ToLabel did not get repo to calculate url for label with id '%d'", label.ID) - } - - return result -} -*/ - -// ToProject converts Project to API format -// func ToProject(project *project_model.Project, repo *repo_model.Repository, org *user_model.User) *api.Project { -// result := &api.Project{ -// ID: project.ID, -// Title: project.Title, -// Description: project.Description, -// TemplateType: project.TemplateType, -// CardType: project.CardType, -// } - -// projectBelongsToRepo := project.BelongsToRepo() - -// // calculate URL -// if projectBelongsToRepo && repo != nil { -// result.URL = fmt.Sprintf("%s/projects/%d", repo.APIURL(), project.ID) -// } else { // BelongsToOrg -// if org != nil { -// result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/projects/%d", setting.AppURL, url.PathEscape(org.Name), project.ID) -// } else { -// log.Error("ToProject did not get org to calculate url for project with id '%d'", project.ID) -// } -// } - -// if projectBelongsToRepo && repo == nil { -// log.Error("ToProject did not get repo to calculate url for project with id '%d'", project.ID) -// } - -// return result -// } From 0bd9d5bf026098082713dc8c5f3897376026fa7f Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Fri, 12 Jul 2024 01:28:59 +0300 Subject: [PATCH 06/20] api: implement endpoints for all org-project logic --- routers/api/v1/api.go | 51 +++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8606a27d22b55..417e75a8c0722 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -976,9 +976,9 @@ func Routes() *web.Router { m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) m.Group("/{id}", func() { m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) + m.Delete("", org.DeleteProject) + m.Put("", bind(api.CreateProjectOption{}), org.EditProject) m.Post("/move", org.MoveColumns) - m.Post("/delete", org.DeleteProject) - m.Post("/edit", bind(api.CreateProjectOption{}), org.EditProject) m.Post("/{action:open|close}", org.ChangeProjectStatus) m.Group("/{columnID}", func() { @@ -998,20 +998,39 @@ func Routes() *web.Router { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) - // m.Group("orgs/{org}/-", func() { - // m.Group("/projects", func() { - // m.Group("", func() { - // // m.Get("", org.Projects) - // // m.Get("/{id}", org.ViewProject) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - // m.Group("", func() { - // m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) - // m.Group("/{id}", func() { - // m.Post("/{action:open|close}", org.ChangeProjectStatus) - // }) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) - // }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) + // Organizations (requires orgs scope) + m.Group("orgs/{org}/-", func() { + m.Group("/projects", func() { + m.Group("", func() { + m.Get("", org.GetProjects) + m.Get("/{id}", org.GetProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + + m.Group("", func() { + m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + m.Group("/{id}", func() { + m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) + m.Delete("", org.DeleteProject) + m.Put("", bind(api.CreateProjectOption{}), org.EditProject) + m.Post("/move", org.MoveColumns) + m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", bind(api.EditProjectColumnOption{}), org.EditProjectColumn) + m.Delete("", org.DeleteProjectColumn) + m.Post("/default", org.SetDefaultProjectColumn) + m.Post("/move", org.MoveIssues) + }) + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) // Users (requires user scope) m.Group("/users", func() { From b2992372a562893891c592f700318bc0e237d9e5 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Fri, 12 Jul 2024 20:45:48 +0300 Subject: [PATCH 07/20] api: implement logic for all (user and org)/reponame/projects endpoints and crud operations create endpoints for each operation in the web router and corresponding handler and tested them manually --- routers/api/v1/api.go | 116 +++++- routers/api/v1/org/project.go | 106 ++---- routers/api/v1/repo/project.go | 460 +++++++++++++++++++++++ routers/api/v1/shared/project/project.go | 47 +++ 4 files changed, 640 insertions(+), 89 deletions(-) create mode 100644 routers/api/v1/repo/project.go create mode 100644 routers/api/v1/shared/project/project.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 417e75a8c0722..a3b65db97d0f8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -91,6 +91,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/packages" "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/settings" + "code.gitea.io/gitea/routers/api/v1/shared/project" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/actions" @@ -137,6 +138,7 @@ func sudo() func(ctx *context.APIContext) { func repoAssignment() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { userName := ctx.PathParam("username") + orgName := ctx.PathParam("org") repoName := ctx.PathParam("reponame") var ( @@ -145,25 +147,49 @@ func repoAssignment() func(ctx *context.APIContext) { ) // Check if the user is the same as the repository owner. - if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { - owner = ctx.Doer - } else { - owner, err = user_model.GetUserByName(ctx, userName) + if userName != "" { + if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { + owner = ctx.Doer + } else { + owner, err = user_model.GetUserByName(ctx, userName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { + context.RedirectToUser(ctx.Base, userName, redirectUserID) + } else if user_model.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + } + } + + if orgName != "" { + org, err := organization.GetOrgByName(ctx, orgName) if err != nil { - if user_model.IsErrUserNotExist(err) { - if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { - context.RedirectToUser(ctx.Base, userName, redirectUserID) + if organization.IsErrOrgNotExist(err) { + redirectUserID, err := user_model.LookupUserRedirect(ctx, orgName) + if err == nil { + context.RedirectToUser(ctx.Base, orgName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { - ctx.NotFound("GetUserByName", err) + ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) } } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) } return } + ctx.Org.Organization = org + owner = org.AsUser() } + ctx.Repo.Owner = owner ctx.ContextUser = owner @@ -978,7 +1004,7 @@ func Routes() *web.Router { m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) m.Delete("", org.DeleteProject) m.Put("", bind(api.CreateProjectOption{}), org.EditProject) - m.Post("/move", org.MoveColumns) + m.Post("/move", project.MoveColumns) m.Post("/{action:open|close}", org.ChangeProjectStatus) m.Group("/{columnID}", func() { @@ -998,6 +1024,40 @@ func Routes() *web.Router { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) + // Users (requires user scope) + m.Group("users/{username}/{reponame}", func() { + m.Group("/projects", func() { + m.Group("", func() { + m.Get("", repo.GetProjects) + m.Get("/{id}", repo.GetProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + + m.Group("", func() { + m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) + m.Group("/{id}", func() { + m.Post("", bind(api.EditProjectColumnOption{}), repo.AddColumnToProject) + m.Delete("", repo.DeleteProject) + m.Put("", bind(api.CreateProjectOption{}), repo.EditProject) + m.Post("/move", project.MoveColumns) + m.Post("/{action:open|close}", repo.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", bind(api.EditProjectColumnOption{}), repo.EditProjectColumn) + m.Delete("", repo.DeleteProjectColumn) + m.Post("/default", repo.SetDefaultProjectColumn) + m.Post("/move", repo.MoveIssues) + }) + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment()) + // Organizations (requires orgs scope) m.Group("orgs/{org}/-", func() { m.Group("/projects", func() { @@ -1012,7 +1072,7 @@ func Routes() *web.Router { m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) m.Delete("", org.DeleteProject) m.Put("", bind(api.CreateProjectOption{}), org.EditProject) - m.Post("/move", org.MoveColumns) + m.Post("/move", project.MoveColumns) m.Post("/{action:open|close}", org.ChangeProjectStatus) m.Group("/{columnID}", func() { @@ -1032,6 +1092,40 @@ func Routes() *web.Router { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) + // Organizations (requires orgs scope) + m.Group("orgs/{org}/{reponame}", func() { + m.Group("/projects", func() { + m.Group("", func() { + m.Get("", repo.GetProjects) + m.Get("/{id}", repo.GetProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + + m.Group("", func() { + m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) + m.Group("/{id}", func() { + m.Post("", bind(api.EditProjectColumnOption{}), repo.AddColumnToProject) + m.Delete("", repo.DeleteProject) + m.Put("", bind(api.CreateProjectOption{}), repo.EditProject) + m.Post("/move", project.MoveColumns) + m.Post("/{action:open|close}", repo.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", bind(api.EditProjectColumnOption{}), repo.EditProjectColumn) + m.Delete("", repo.DeleteProjectColumn) + m.Post("/default", repo.SetDefaultProjectColumn) + m.Post("/move", repo.MoveIssues) + }) + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment()) + // Users (requires user scope) m.Group("/users", func() { m.Group("/{username}", func() { diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 7429c5c943dbc..a687f51d9bc1a 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -12,7 +12,6 @@ import ( project_model "code.gitea.io/gitea/models/project" attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/optional" - "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" @@ -68,16 +67,10 @@ func ChangeProjectStatus(ctx *context.APIContext) { // Projects renders the home page of projects func GetProjects(ctx *context.APIContext) { - ctx.Data["Title"] = ctx.Tr("repo.projects") - sortType := ctx.FormTrim("sort") isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" keyword := ctx.FormTrim("q") - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } var projectType project_model.Type if ctx.ContextUser.IsOrganization() { @@ -86,10 +79,6 @@ func GetProjects(ctx *context.APIContext) { projectType = project_model.TypeIndividual } projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ - ListOptions: db.ListOptions{ - Page: page, - PageSize: setting.UI.IssuePagingNum, - }, OwnerID: ctx.ContextUser.ID, IsClosed: optional.Some(isShowClosed), OrderBy: project_model.GetSearchOrderByBySortType(sortType), @@ -180,31 +169,31 @@ func GetProject(ctx *context.APIContext) { ctx.JSON(http.StatusOK, data) } -// AddColumnToProject adds a new column to a project -func AddColumnToProject(ctx *context.APIContext) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) +// EditProject updates a project +func EditProject(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateProjectOption) + projectID := ctx.PathParamInt64(":id") + + p, err := project_model.GetProjectByID(ctx, projectID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if project.OwnerID != ctx.ContextUser.ID { + if p.OwnerID != ctx.ContextUser.ID { ctx.NotFound("", nil) return } - form := web.GetForm(ctx).(*api.EditProjectColumnOption) - column := &project_model.Column{ - ProjectID: project.ID, - Title: form.Title, - Sorting: form.Sorting, - Color: form.Color, - } - if err := project_model.NewColumn(ctx, column); err != nil { - ctx.ServerError("NewProjectColumn", err) + p.Title = form.Title + p.Description = form.Content + p.CardType = project_model.CardType(form.CardType) + + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) return } - ctx.JSON(http.StatusCreated, column) + ctx.JSON(http.StatusOK, p) } // DeleteProject delete a project @@ -229,71 +218,32 @@ func DeleteProject(ctx *context.APIContext) { ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) } -// EditProject updates a project -func EditProject(ctx *context.APIContext) { - form := web.GetForm(ctx).(*api.CreateProjectOption) - projectID := ctx.PathParamInt64(":id") - - ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) - - p, err := project_model.GetProjectByID(ctx, projectID) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) - return - } - - p.Title = form.Title - p.Description = form.Content - p.CardType = project_model.CardType(form.CardType) - - if err = project_model.UpdateProject(ctx, p); err != nil { - ctx.ServerError("UpdateProjects", err) - return - } - - ctx.JSON(http.StatusOK, p) -} - -// MoveColumns moves or keeps columns in a project and sorts them inside that project -func MoveColumns(ctx *context.APIContext) { +// AddColumnToProject adds a new column to a project +func AddColumnToProject(ctx *context.APIContext) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { - ctx.NotFound("CanBeAccessedByOwnerRepo", nil) - return - } - - type movedColumnsForm struct { - Columns []struct { - ColumnID int64 `json:"columnID"` - Sorting int64 `json:"sorting"` - } `json:"columns"` - } - - form := &movedColumnsForm{} - if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedColumnsForm", err) + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) return } - sortedColumnIDs := make(map[int64]int64) - for _, column := range form.Columns { - sortedColumnIDs[column.Sorting] = column.ColumnID + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + column := &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Sorting: form.Sorting, + Color: form.Color, + CreatorID: ctx.Doer.ID, } - - if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { - ctx.ServerError("MoveColumnsOnProject", err) + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.ServerError("NewProjectColumn", err) return } - ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) + ctx.JSON(http.StatusCreated, column) } // CheckProjectColumnChangePermissions check permission diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go new file mode 100644 index 0000000000000..42e4fe1e5677f --- /dev/null +++ b/routers/api/v1/repo/project.go @@ -0,0 +1,460 @@ +package repo + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/perm" + project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// CreateProject creates a new project +func CreateProject(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateProjectOption) + + project := &project_model.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.TemplateType), + CardType: project_model.CardType(form.CardType), + Type: project_model.TypeRepository, + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + ctx.JSON(http.StatusCreated, project) +} + +// Projects renders the home page of projects +func GetProjects(ctx *context.APIContext) { + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + keyword := ctx.FormTrim("q") + repo := ctx.Repo.Repository + + projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + Type: project_model.TypeRepository, + Title: keyword, + }) + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + ctx.JSON(http.StatusOK, projects) +} + +// TODO: Send issues as well +// GetProject returns a project by ID +func GetProject(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + issues := make(map[int64][]*issues_model.Issue) + + for _, column := range columns { + if empty := issuesMap[column.ID]; len(empty) == 0 { + continue + } + issues[column.ID] = issuesMap[column.ID] + + } + + data := map[string]any{ + "project": project, + "columns": columns, + } + + ctx.JSON(http.StatusOK, data) +} + +// EditProject updates a project +func EditProject(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateProjectOption) + projectID := ctx.PathParamInt64(":id") + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + p.CardType = project_model.CardType(form.CardType) + + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.JSON(http.StatusOK, p) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.APIContext) { + p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + err = project_model.DeleteProjectByID(ctx, p.ID) + + if err != nil { + ctx.ServerError("DeleteProjectByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.APIContext) { + var toClose bool + switch ctx.PathParam(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.NotFound("ChangeProjectStatus", nil) + return + } + id := ctx.PathParamInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) +} + +// AddColumnToProject adds a new column to a project +func AddColumnToProject(ctx *context.APIContext) { + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + column := &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Sorting: form.Sorting, + Color: form.Color, + CreatorID: ctx.Doer.ID, + } + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSON(http.StatusCreated, column) +} + +// CheckProjectColumnChangePermissions check permission +func checkProjectColumnChangePermissions(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.PathParamInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + _, column := checkProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, column) +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.APIContext) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.PathParamInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "column deleted successfully"}) +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.APIContext) { + project, column := checkProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "default column set successfully"}) +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.APIContext) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) +} diff --git a/routers/api/v1/shared/project/project.go b/routers/api/v1/shared/project/project.go new file mode 100644 index 0000000000000..d3a52a1f6f90a --- /dev/null +++ b/routers/api/v1/shared/project/project.go @@ -0,0 +1,47 @@ +package project + +import ( + "encoding/json" + "net/http" + + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/services/context" +) + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) +} From 49a1961b038d3977d09d6d74b038b547a67bc56f Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sat, 13 Jul 2024 00:14:47 +0300 Subject: [PATCH 08/20] api(refactor): refactor middlware assignments to each group --- routers/api/v1/api.go | 68 ++++++++++++++---------------- routers/api/v1/repo/project.go | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 38 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a3b65db97d0f8..3788a0117ee4d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -614,6 +614,21 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { } } +func mustEnableRepoProjects(ctx *context.APIContext) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableRepoProjects", nil) + return + } + + if ctx.Repo.Repository != nil { + projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects) + if !ctx.Repo.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { + ctx.NotFound("MustEnableRepoProjects", nil) + return + } + } +} + func mustEnableIssues(ctx *context.APIContext) { if !ctx.Repo.CanRead(unit.TypeIssues) { if log.IsTrace() { @@ -996,7 +1011,7 @@ func Routes() *web.Router { m.Group("", func() { m.Get("", org.GetProjects) m.Get("/{id}", org.GetProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + }) m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) @@ -1014,13 +1029,8 @@ func Routes() *web.Router { m.Post("/move", org.MoveIssues) }) }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { - if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - ctx.NotFound("NewProject", nil) - return - } - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + }, reqSelfOrAdmin()) + }, individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) @@ -1030,7 +1040,7 @@ func Routes() *web.Router { m.Group("", func() { m.Get("", repo.GetProjects) m.Get("/{id}", repo.GetProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + }) m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) @@ -1048,15 +1058,9 @@ func Routes() *web.Router { m.Post("/move", repo.MoveIssues) }) }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { - if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - ctx.NotFound("NewProject", nil) - return - } - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) - - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment()) + }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) + }, individualPermsChecker) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) // Organizations (requires orgs scope) m.Group("orgs/{org}/-", func() { @@ -1064,7 +1068,7 @@ func Routes() *web.Router { m.Group("", func() { m.Get("", org.GetProjects) m.Get("/{id}", org.GetProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + }) m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) @@ -1082,15 +1086,9 @@ func Routes() *web.Router { m.Post("/move", org.MoveIssues) }) }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { - if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - ctx.NotFound("NewProject", nil) - return - } - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) - - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true), reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) // Organizations (requires orgs scope) m.Group("orgs/{org}/{reponame}", func() { @@ -1098,7 +1096,7 @@ func Routes() *web.Router { m.Group("", func() { m.Get("", repo.GetProjects) m.Get("/{id}", repo.GetProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + }) m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) @@ -1116,15 +1114,9 @@ func Routes() *web.Router { m.Post("/move", repo.MoveIssues) }) }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { - if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - ctx.NotFound("NewProject", nil) - return - } - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) - - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment()) + }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) // Users (requires user scope) m.Group("/users", func() { diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 42e4fe1e5677f..268842d43be45 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" ) @@ -458,3 +459,78 @@ func MoveIssues(ctx *context.APIContext) { ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) } + +func getActionIssues(ctx *context.APIContext) issues_model.IssueList { + type updateIssuesForm struct { + Issues []int64 `json:"issues"` + } + + form := &updateIssuesForm{} + + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return nil + } + + if len(form.Issues) == 0 { + return nil + } + + issueIDs := form.Issues + issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + for _, issue := range issues { + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + return nil + } + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.APIContext) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + if err := issues.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + + projectID := ctx.FormInt64("project_id") + for _, issue := range issues { + if issue.Project != nil && issue.Project.ID == projectID { + continue + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { + continue + } + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) +} From 7bfa54672f3fc3ff796015f7ee55d0c7de9fa8d6 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sat, 13 Jul 2024 01:01:04 +0300 Subject: [PATCH 09/20] api: implement logic and endpoint for moving issues between projects in repos --- routers/api/v1/api.go | 20 +++++++++++++++++++ routers/api/v1/repo/project.go | 36 +++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3788a0117ee4d..4ca08d3fc6c1c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -417,6 +417,18 @@ func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { } } +// reqRepoWriterOr returns a middleware for requiring repository write to one of the unit permission +func reqRepoWriterOr(unitTypes ...unit.Type) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + for _, unitType := range unitTypes { + if ctx.Repo.CanWrite(unitType) { + return + } + } + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + } +} + // reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin func reqRepoBranchWriter(ctx *context.APIContext) { options, ok := web.GetForm(ctx).(api.FileOptionInterface) @@ -1060,6 +1072,10 @@ func Routes() *web.Router { }) }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) }, individualPermsChecker) + + m.Group("/{type:issues|pulls}", func() { + m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), repo.UpdateIssueProject) + }, individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) // Organizations (requires orgs scope) @@ -1116,6 +1132,10 @@ func Routes() *web.Router { }) }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) }) + + m.Group("/{type:issues|pulls}", func() { + m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), repo.UpdateIssueProject) + }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) // Users (requires user scope) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 268842d43be45..aa81407c85ee1 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "strings" @@ -460,24 +461,13 @@ func MoveIssues(ctx *context.APIContext) { ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) } -func getActionIssues(ctx *context.APIContext) issues_model.IssueList { - type updateIssuesForm struct { - Issues []int64 `json:"issues"` - } - - form := &updateIssuesForm{} - - if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedIssuesForm", err) - return nil - } +func getActionIssues(ctx *context.APIContext, issuesIDs []int64) issues_model.IssueList { - if len(form.Issues) == 0 { + if len(issuesIDs) == 0 { return nil } - issueIDs := form.Issues - issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + issues, err := issues_model.GetIssuesByIDs(ctx, issuesIDs) if err != nil { ctx.ServerError("GetIssuesByIDs", err) return nil @@ -504,7 +494,21 @@ func getActionIssues(ctx *context.APIContext) issues_model.IssueList { // UpdateIssueProject change an issue's project func UpdateIssueProject(ctx *context.APIContext) { - issues := getActionIssues(ctx) + type updateIssuesForm struct { + ProjectID int64 `json:"project_id"` + Issues []int64 `json:"issues"` + } + + form := &updateIssuesForm{} + + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + log.Println("form", form) + log.Println(ctx.Repo.Repository.ID) + issues := getActionIssues(ctx, form.Issues) if ctx.Written() { return } @@ -518,7 +522,7 @@ func UpdateIssueProject(ctx *context.APIContext) { return } - projectID := ctx.FormInt64("project_id") + projectID := form.ProjectID for _, issue := range issues { if issue.Project != nil && issue.Project.ID == projectID { continue From bc0fca4ff33e7d98bd0476ed25daf8fdd6b528e5 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sat, 13 Jul 2024 02:09:21 +0300 Subject: [PATCH 10/20] api: create model to api format convertor for project --- routers/api/v1/org/project.go | 103 ++++++++++------------------- routers/api/v1/repo/project.go | 61 ++++------------- services/convert/project.go | 39 +++++++++++ services/convert/project_column.go | 36 ++++++++++ 4 files changed, 122 insertions(+), 117 deletions(-) create mode 100644 services/convert/project.go create mode 100644 services/convert/project_column.go diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index a687f51d9bc1a..065752b843b74 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -10,11 +10,11 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" - attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/optional" 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" ) // CreateProject creates a new project @@ -41,28 +41,7 @@ func CreateProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, project) -} - -// ChangeProjectStatus updates the status of a project between "open" and "close" -func ChangeProjectStatus(ctx *context.APIContext) { - var toClose bool - switch ctx.PathParam(":action") { - case "open": - toClose = false - case "close": - toClose = true - default: - ctx.NotFound("ChangeProjectStatus", nil) - return - } - id := ctx.PathParamInt64(":id") - - if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { - ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) - return - } - ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) } // Projects renders the home page of projects @@ -90,7 +69,7 @@ func GetProjects(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, projects) + ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } // TODO: Send issues as well @@ -118,55 +97,20 @@ func GetProject(ctx *context.APIContext) { return } - if project.CardType != project_model.CardTypeTextOnly { - issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) - for _, issuesList := range issuesMap { - for _, issue := range issuesList { - if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { - issuesAttachmentMap[issue.ID] = issueAttachment - } - } - } - ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap - } - - linkedPrsMap := make(map[int64][]*issues_model.Issue) - for _, issuesList := range issuesMap { - for _, issue := range issuesList { - var referencedIDs []int64 - for _, comment := range issue.Comments { - if comment.RefIssueID != 0 && comment.RefIsPull { - referencedIDs = append(referencedIDs, comment.RefIssueID) - } - } - - if len(referencedIDs) > 0 { - if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - IssueIDs: referencedIDs, - IsPull: optional.Some(true), - }); err == nil { - linkedPrsMap[issue.ID] = linkedPrs - } - } - } - } - - issues := make(map[int64][]*issues_model.Issue) + issues := issues_model.IssueList{} for _, column := range columns { if empty := issuesMap[column.ID]; len(empty) == 0 { continue } - issues[column.ID] = issuesMap[column.ID] - - } - - data := map[string]any{ - "project": project, - "columns": columns, + issues = append(issues, issuesMap[column.ID]...) } - ctx.JSON(http.StatusOK, data) + ctx.JSON(http.StatusOK, map[string]any{ + "project": convert.ToProject(ctx, project), + "columns": convert.ToColumns(ctx, columns), + "issues": convert.ToAPIIssueList(ctx, ctx.Doer, issues), + }) } // EditProject updates a project @@ -193,7 +137,7 @@ func EditProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, p) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, p)) } // DeleteProject delete a project @@ -218,6 +162,27 @@ func DeleteProject(ctx *context.APIContext) { ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) } +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.APIContext) { + var toClose bool + switch ctx.PathParam(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.NotFound("ChangeProjectStatus", nil) + return + } + id := ctx.PathParamInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) +} + // AddColumnToProject adds a new column to a project func AddColumnToProject(ctx *context.APIContext) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) @@ -243,7 +208,7 @@ func AddColumnToProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, column) + ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) } // CheckProjectColumnChangePermissions check permission @@ -303,7 +268,7 @@ func EditProjectColumn(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, column) + ctx.JSON(http.StatusOK, convert.ToColumn(ctx, column)) } // DeleteProjectColumn allows for the deletion of a project column diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index aa81407c85ee1..d0bc3fcdf828d 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -12,13 +12,13 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/perm" project_model "code.gitea.io/gitea/models/project" - attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" ) // CreateProject creates a new project @@ -40,7 +40,7 @@ func CreateProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, project) + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) } // Projects renders the home page of projects @@ -63,7 +63,7 @@ func GetProjects(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, projects) + ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } // TODO: Send issues as well @@ -91,55 +91,20 @@ func GetProject(ctx *context.APIContext) { return } - if project.CardType != project_model.CardTypeTextOnly { - issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) - for _, issuesList := range issuesMap { - for _, issue := range issuesList { - if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { - issuesAttachmentMap[issue.ID] = issueAttachment - } - } - } - ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap - } - - linkedPrsMap := make(map[int64][]*issues_model.Issue) - for _, issuesList := range issuesMap { - for _, issue := range issuesList { - var referencedIDs []int64 - for _, comment := range issue.Comments { - if comment.RefIssueID != 0 && comment.RefIsPull { - referencedIDs = append(referencedIDs, comment.RefIssueID) - } - } - - if len(referencedIDs) > 0 { - if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - IssueIDs: referencedIDs, - IsPull: optional.Some(true), - }); err == nil { - linkedPrsMap[issue.ID] = linkedPrs - } - } - } - } - - issues := make(map[int64][]*issues_model.Issue) + issues := issues_model.IssueList{} for _, column := range columns { if empty := issuesMap[column.ID]; len(empty) == 0 { continue } - issues[column.ID] = issuesMap[column.ID] - + issues = append(issues, issuesMap[column.ID]...) } - data := map[string]any{ - "project": project, - "columns": columns, - } - - ctx.JSON(http.StatusOK, data) + ctx.JSON(http.StatusOK, map[string]any{ + "project": convert.ToProject(ctx, project), + "columns": convert.ToColumns(ctx, columns), + "issues": convert.ToAPIIssueList(ctx, ctx.Doer, issues), + }) } // EditProject updates a project @@ -166,7 +131,7 @@ func EditProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, p) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, p)) } // DeleteProject delete a project @@ -240,7 +205,7 @@ func AddColumnToProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, column) + ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) } // CheckProjectColumnChangePermissions check permission @@ -307,7 +272,7 @@ func EditProjectColumn(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, column) + ctx.JSON(http.StatusOK, convert.ToColumn(ctx, column)) } // DeleteProjectColumn allows for the deletion of a project column diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000000..db3d7991b5028 --- /dev/null +++ b/services/convert/project.go @@ -0,0 +1,39 @@ +// Copyright 2020 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" +) + +// ToProject converts a models.Project to api.Project +func ToProject(ctx context.Context, project *project_model.Project) *api.Project { + if project == nil { + return nil + } + + return &api.Project{ + ID: project.ID, + Title: project.Title, + Description: project.Description, + TemplateType: uint8(project.TemplateType), + CardType: uint8(project.CardType), + } +} + +// ToProjects converts a slice of models.Project to a slice of api.Project +func ToProjects(ctx context.Context, projects []*project_model.Project) []*api.Project { + if projects == nil { + return nil + } + + result := make([]*api.Project, len(projects)) + for i, project := range projects { + result[i] = ToProject(ctx, project) + } + return result +} diff --git a/services/convert/project_column.go b/services/convert/project_column.go new file mode 100644 index 0000000000000..bcd9632c01776 --- /dev/null +++ b/services/convert/project_column.go @@ -0,0 +1,36 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + column_model "code.gitea.io/gitea/models/project" + api "code.gitea.io/gitea/modules/structs" +) + +// ToProject converts a models.Project to api.Project +func ToColumn(ctx context.Context, column *column_model.Column) *api.Column { + if column == nil { + return nil + } + + return &api.Column{ + ID: column.ID, + Title: column.Title, + Color: column.Color, + } +} + +func ToColumns(ctx context.Context, columns column_model.ColumnList) []*api.Column { + if columns == nil { + return nil + } + + var apiColumns []*api.Column + for _, column := range columns { + apiColumns = append(apiColumns, ToColumn(ctx, column)) + } + return apiColumns +} From d5a1ce1dfddad41d3f81e172ae6277d62c5c3638 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sun, 14 Jul 2024 11:43:05 +0300 Subject: [PATCH 11/20] api(refactor): minimize number of endpoints to 2 by refactoring current logic --- routers/api/v1/api.go | 176 +++++++++++---------------------- routers/api/v1/org/project.go | 1 - routers/api/v1/repo/project.go | 1 - 3 files changed, 59 insertions(+), 119 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 4ca08d3fc6c1c..0adf2c424ff70 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -138,7 +138,6 @@ func sudo() func(ctx *context.APIContext) { func repoAssignment() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { userName := ctx.PathParam("username") - orgName := ctx.PathParam("org") repoName := ctx.PathParam("reponame") var ( @@ -147,47 +146,24 @@ func repoAssignment() func(ctx *context.APIContext) { ) // Check if the user is the same as the repository owner. - if userName != "" { - if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { - owner = ctx.Doer - } else { - owner, err = user_model.GetUserByName(ctx, userName) - if err != nil { - if user_model.IsErrUserNotExist(err) { - if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { - context.RedirectToUser(ctx.Base, userName, redirectUserID) - } else if user_model.IsErrUserRedirectNotExist(err) { - ctx.NotFound("GetUserByName", err) - } else { - ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) - } - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) - } - return - } - } - } - - if orgName != "" { - org, err := organization.GetOrgByName(ctx, orgName) + if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { + owner = ctx.Doer + } else { + owner, err = user_model.GetUserByName(ctx, userName) if err != nil { - if organization.IsErrOrgNotExist(err) { - redirectUserID, err := user_model.LookupUserRedirect(ctx, orgName) - if err == nil { - context.RedirectToUser(ctx.Base, orgName, redirectUserID) + if user_model.IsErrUserNotExist(err) { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { + context.RedirectToUser(ctx.Base, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { - ctx.NotFound() + ctx.NotFound("GetUserByName", err) } else { ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) } } else { - ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) } return } - ctx.Org.Organization = org - owner = org.AsUser() } ctx.Repo.Owner = owner @@ -390,9 +366,11 @@ func reqOwner() func(ctx *context.APIContext) { // reqSelfOrAdmin doer should be the same as the contextUser or site admin func reqSelfOrAdmin() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if !ctx.IsUserSiteAdmin() && ctx.ContextUser != ctx.Doer { - ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser") - return + if ctx.ContextUser.IsIndividual() { + if !ctx.IsUserSiteAdmin() && ctx.ContextUser != ctx.Doer { + ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser") + return + } } } } @@ -591,24 +569,24 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { ctx.Org = new(context.APIOrganization) + if ctx.ContextUser == nil { + if ctx.Org.Organization == nil { + getOrganizationByParams(ctx) + ctx.ContextUser = ctx.Org.Organization.AsUser() + } + } else if ctx.ContextUser.IsOrganization() { + if ctx.Org == nil { + ctx.Org = &context.APIOrganization{} + } + ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) + } else { + // ContextUser is an individual User + return + } + var err error if assignOrg { - ctx.Org.Organization, err = organization.GetOrgByName(ctx, ctx.PathParam(":org")) - if err != nil { - if organization.IsErrOrgNotExist(err) { - redirectUserID, err := user_model.LookupUserRedirect(ctx, ctx.PathParam(":org")) - if err == nil { - context.RedirectToUser(ctx.Base, ctx.PathParam(":org"), redirectUserID) - } else if user_model.IsErrUserRedirectNotExist(err) { - ctx.NotFound("GetOrgByName", err) - } else { - ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) - } - } else { - ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) - } - return - } + getOrganizationByParams(ctx) ctx.ContextUser = ctx.Org.Organization.AsUser() } @@ -626,6 +604,29 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { } } +func getOrganizationByParams(ctx *context.APIContext) { + orgName := ctx.PathParam(":org") + + var err error + + ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName) + if err != nil { + if organization.IsErrOrgNotExist(err) { + redirectUserID, err := user_model.LookupUserRedirect(ctx, ctx.PathParam(":org")) + if err == nil { + context.RedirectToUser(ctx.Base, ctx.PathParam(":org"), redirectUserID) + } else if user_model.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetOrgByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) + } + return + } +} + func mustEnableRepoProjects(ctx *context.APIContext) { if unit.TypeProjects.UnitGlobalDisabled() { ctx.NotFound("EnableRepoProjects", nil) @@ -1018,7 +1019,7 @@ func Routes() *web.Router { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) // Users (requires user scope) - m.Group("users/{username}/-", func() { + m.Group("/{username}/-", func() { m.Group("/projects", func() { m.Group("", func() { m.Get("", org.GetProjects) @@ -1041,13 +1042,13 @@ func Routes() *web.Router { m.Post("/move", org.MoveIssues) }) }) - }, reqSelfOrAdmin()) + }, reqSelfOrAdmin(), reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) }, individualPermsChecker) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), reqToken(), context.UserAssignmentAPI(), orgAssignment(), reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) // Users (requires user scope) - m.Group("users/{username}/{reponame}", func() { + m.Group("/{username}/{reponame}", func() { m.Group("/projects", func() { m.Group("", func() { m.Get("", repo.GetProjects) @@ -1076,67 +1077,7 @@ func Routes() *web.Router { m.Group("/{type:issues|pulls}", func() { m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), repo.UpdateIssueProject) }, individualPermsChecker) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) - - // Organizations (requires orgs scope) - m.Group("orgs/{org}/-", func() { - m.Group("/projects", func() { - m.Group("", func() { - m.Get("", org.GetProjects) - m.Get("/{id}", org.GetProject) - }) - - m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) - m.Group("/{id}", func() { - m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) - m.Delete("", org.DeleteProject) - m.Put("", bind(api.CreateProjectOption{}), org.EditProject) - m.Post("/move", project.MoveColumns) - m.Post("/{action:open|close}", org.ChangeProjectStatus) - - m.Group("/{columnID}", func() { - m.Put("", bind(api.EditProjectColumnOption{}), org.EditProjectColumn) - m.Delete("", org.DeleteProjectColumn) - m.Post("/default", org.SetDefaultProjectColumn) - m.Post("/move", org.MoveIssues) - }) - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) - }) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true), reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - - // Organizations (requires orgs scope) - m.Group("orgs/{org}/{reponame}", func() { - m.Group("/projects", func() { - m.Group("", func() { - m.Get("", repo.GetProjects) - m.Get("/{id}", repo.GetProject) - }) - - m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) - m.Group("/{id}", func() { - m.Post("", bind(api.EditProjectColumnOption{}), repo.AddColumnToProject) - m.Delete("", repo.DeleteProject) - m.Put("", bind(api.CreateProjectOption{}), repo.EditProject) - m.Post("/move", project.MoveColumns) - m.Post("/{action:open|close}", repo.ChangeProjectStatus) - - m.Group("/{columnID}", func() { - m.Put("", bind(api.EditProjectColumnOption{}), repo.EditProjectColumn) - m.Delete("", repo.DeleteProjectColumn) - m.Post("/default", repo.SetDefaultProjectColumn) - m.Post("/move", repo.MoveIssues) - }) - }) - }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) - }) - - m.Group("/{type:issues|pulls}", func() { - m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), repo.UpdateIssueProject) - }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) // Users (requires user scope) m.Group("/users", func() { @@ -1672,6 +1613,7 @@ func Routes() *web.Router { m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create) m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization)) m.Group("/orgs/{org}", func() { + m.Combo("").Get(org.Get). Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit). Delete(reqToken(), reqOrgOwnership(), org.Delete) diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 065752b843b74..c7892722676d3 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -72,7 +72,6 @@ func GetProjects(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } -// TODO: Send issues as well // GetProject returns a project by ID func GetProject(ctx *context.APIContext) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index d0bc3fcdf828d..a7cf7f4589afb 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -66,7 +66,6 @@ func GetProjects(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } -// TODO: Send issues as well // GetProject returns a project by ID func GetProject(ctx *context.APIContext) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) From 3a5911af1dcfec8d4ccab8fb457712b58c060663 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sun, 14 Jul 2024 11:49:19 +0300 Subject: [PATCH 12/20] api(refactor): remove unnessesary condition from orgAssignment --- routers/api/v1/api.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 0adf2c424ff70..9842cdb02374e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -579,9 +579,6 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { ctx.Org = &context.APIOrganization{} } ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) - } else { - // ContextUser is an individual User - return } var err error From 10c0766a7735d1cdda56c17e16aa143ab6cdd7c3 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sun, 14 Jul 2024 14:59:53 +0300 Subject: [PATCH 13/20] api(refactor): create a common handler for org and repo projects edit project api response format to include more fields --- modules/structs/project.go | 9 + routers/api/v1/api.go | 52 +-- routers/api/v1/org/project.go | 408 --------------------- routers/api/v1/{repo => shared}/project.go | 338 ++++++++++++----- routers/api/v1/shared/project/project.go | 47 --- services/convert/project.go | 26 +- 6 files changed, 298 insertions(+), 582 deletions(-) delete mode 100644 routers/api/v1/org/project.go rename routers/api/v1/{repo => shared}/project.go (58%) delete mode 100644 routers/api/v1/shared/project/project.go diff --git a/modules/structs/project.go b/modules/structs/project.go index 15dfb0e28e4d0..d546021910a6f 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -10,6 +10,15 @@ type Project struct { Description string `json:"description"` TemplateType uint8 `json:"template_type"` CardType uint8 `json:"card_type"` + OwnerID int64 `json:"owner_id"` + RepoID int64 `json:"repo_id"` + CreatorID int64 `json:"creator_id"` + IsClosed bool `json:"is_closed"` + Type uint8 `json:"type"` + + CreatedUnix int64 `json:"created_unix"` + UpdatedUnix int64 `json:"updated_unix"` + ClosedDateUnix int64 `json:"closed_date_unix"` } type CreateProjectOption struct { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9842cdb02374e..f621456a5d132 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -91,7 +91,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/packages" "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/settings" - "code.gitea.io/gitea/routers/api/v1/shared/project" + project_shared "code.gitea.io/gitea/routers/api/v1/shared" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/actions" @@ -1019,24 +1019,24 @@ func Routes() *web.Router { m.Group("/{username}/-", func() { m.Group("/projects", func() { m.Group("", func() { - m.Get("", org.GetProjects) - m.Get("/{id}", org.GetProject) + m.Get("", project_shared.ProjectHandler("org", project_shared.GetProjects)) + m.Get("/{id}", project_shared.ProjectHandler("org", project_shared.GetProject)) }) m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + m.Post("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("org", project_shared.CreateProject)) m.Group("/{id}", func() { - m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) - m.Delete("", org.DeleteProject) - m.Put("", bind(api.CreateProjectOption{}), org.EditProject) - m.Post("/move", project.MoveColumns) - m.Post("/{action:open|close}", org.ChangeProjectStatus) + m.Post("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("org", project_shared.AddColumnToProject)) + m.Delete("", project_shared.ProjectHandler("org", project_shared.DeleteProject)) + m.Put("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("org", project_shared.EditProject)) + m.Post("/move", project_shared.MoveColumns) + m.Post("/{action:open|close}", project_shared.ChangeProjectStatus) m.Group("/{columnID}", func() { - m.Put("", bind(api.EditProjectColumnOption{}), org.EditProjectColumn) - m.Delete("", org.DeleteProjectColumn) - m.Post("/default", org.SetDefaultProjectColumn) - m.Post("/move", org.MoveIssues) + m.Put("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("org", project_shared.EditProjectColumn)) + m.Delete("", project_shared.ProjectHandler("org", project_shared.DeleteProjectColumn)) + m.Post("/default", project_shared.ProjectHandler("org", project_shared.SetDefaultProjectColumn)) + m.Post("/move", project_shared.ProjectHandler("org", project_shared.MoveIssues)) }) }) }, reqSelfOrAdmin(), reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) @@ -1048,31 +1048,31 @@ func Routes() *web.Router { m.Group("/{username}/{reponame}", func() { m.Group("/projects", func() { m.Group("", func() { - m.Get("", repo.GetProjects) - m.Get("/{id}", repo.GetProject) + m.Get("", project_shared.ProjectHandler("repo", project_shared.GetProjects)) + m.Get("/{id}", project_shared.ProjectHandler("repo", project_shared.GetProject)) }) m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) + m.Post("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("repo", project_shared.CreateProject)) m.Group("/{id}", func() { - m.Post("", bind(api.EditProjectColumnOption{}), repo.AddColumnToProject) - m.Delete("", repo.DeleteProject) - m.Put("", bind(api.CreateProjectOption{}), repo.EditProject) - m.Post("/move", project.MoveColumns) - m.Post("/{action:open|close}", repo.ChangeProjectStatus) + m.Post("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("repo", project_shared.AddColumnToProject)) + m.Delete("", project_shared.ProjectHandler("repo", project_shared.DeleteProject)) + m.Put("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("repo", project_shared.EditProject)) + m.Post("/move", project_shared.MoveColumns) + m.Post("/{action:open|close}", project_shared.ChangeProjectStatus) m.Group("/{columnID}", func() { - m.Put("", bind(api.EditProjectColumnOption{}), repo.EditProjectColumn) - m.Delete("", repo.DeleteProjectColumn) - m.Post("/default", repo.SetDefaultProjectColumn) - m.Post("/move", repo.MoveIssues) + m.Put("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("repo", project_shared.EditProjectColumn)) + m.Delete("", project_shared.ProjectHandler("repo", project_shared.DeleteProjectColumn)) + m.Post("/default", project_shared.ProjectHandler("repo", project_shared.SetDefaultProjectColumn)) + m.Post("/move", project_shared.ProjectHandler("repo", project_shared.MoveIssues)) }) }) }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) }, individualPermsChecker) m.Group("/{type:issues|pulls}", func() { - m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), repo.UpdateIssueProject) + m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), project_shared.UpdateIssueProject) }, individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go deleted file mode 100644 index c7892722676d3..0000000000000 --- a/routers/api/v1/org/project.go +++ /dev/null @@ -1,408 +0,0 @@ -package org - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - - "code.gitea.io/gitea/models/db" - issues_model "code.gitea.io/gitea/models/issues" - project_model "code.gitea.io/gitea/models/project" - "code.gitea.io/gitea/modules/optional" - 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" -) - -// CreateProject creates a new project -func CreateProject(ctx *context.APIContext) { - form := web.GetForm(ctx).(*api.CreateProjectOption) - - project := &project_model.Project{ - OwnerID: ctx.ContextUser.ID, - Title: form.Title, - Description: form.Content, - CreatorID: ctx.Doer.ID, - TemplateType: project_model.TemplateType(form.TemplateType), - CardType: project_model.CardType(form.CardType), - } - - if ctx.ContextUser.IsOrganization() { - project.Type = project_model.TypeOrganization - } else { - project.Type = project_model.TypeIndividual - } - - if err := project_model.NewProject(ctx, project); err != nil { - ctx.Error(http.StatusInternalServerError, "NewProject", err) - return - } - - ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) -} - -// Projects renders the home page of projects -func GetProjects(ctx *context.APIContext) { - sortType := ctx.FormTrim("sort") - - isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" - keyword := ctx.FormTrim("q") - - var projectType project_model.Type - if ctx.ContextUser.IsOrganization() { - projectType = project_model.TypeOrganization - } else { - projectType = project_model.TypeIndividual - } - projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ - OwnerID: ctx.ContextUser.ID, - IsClosed: optional.Some(isShowClosed), - OrderBy: project_model.GetSearchOrderByBySortType(sortType), - Type: projectType, - Title: keyword, - }) - if err != nil { - ctx.ServerError("FindProjects", err) - return - } - - ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) -} - -// GetProject returns a project by ID -func GetProject(ctx *context.APIContext) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) - return - } - - columns, err := project.GetColumns(ctx) - if err != nil { - ctx.ServerError("GetProjectColumns", err) - return - } - - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) - if err != nil { - ctx.ServerError("LoadIssuesOfColumns", err) - return - } - - issues := issues_model.IssueList{} - - for _, column := range columns { - if empty := issuesMap[column.ID]; len(empty) == 0 { - continue - } - issues = append(issues, issuesMap[column.ID]...) - } - - ctx.JSON(http.StatusOK, map[string]any{ - "project": convert.ToProject(ctx, project), - "columns": convert.ToColumns(ctx, columns), - "issues": convert.ToAPIIssueList(ctx, ctx.Doer, issues), - }) -} - -// EditProject updates a project -func EditProject(ctx *context.APIContext) { - form := web.GetForm(ctx).(*api.CreateProjectOption) - projectID := ctx.PathParamInt64(":id") - - p, err := project_model.GetProjectByID(ctx, projectID) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) - return - } - - p.Title = form.Title - p.Description = form.Content - p.CardType = project_model.CardType(form.CardType) - - if err = project_model.UpdateProject(ctx, p); err != nil { - ctx.ServerError("UpdateProjects", err) - return - } - - ctx.JSON(http.StatusOK, convert.ToProject(ctx, p)) -} - -// DeleteProject delete a project -func DeleteProject(ctx *context.APIContext) { - p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) - return - } - - err = project_model.DeleteProjectByID(ctx, p.ID) - - if err != nil { - ctx.ServerError("DeleteProjectByID", err) - return - } - - ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) -} - -// ChangeProjectStatus updates the status of a project between "open" and "close" -func ChangeProjectStatus(ctx *context.APIContext) { - var toClose bool - switch ctx.PathParam(":action") { - case "open": - toClose = false - case "close": - toClose = true - default: - ctx.NotFound("ChangeProjectStatus", nil) - return - } - id := ctx.PathParamInt64(":id") - - if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { - ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) - return - } - ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) -} - -// AddColumnToProject adds a new column to a project -func AddColumnToProject(ctx *context.APIContext) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) - return - } - - form := web.GetForm(ctx).(*api.EditProjectColumnOption) - column := &project_model.Column{ - ProjectID: project.ID, - Title: form.Title, - Sorting: form.Sorting, - Color: form.Color, - CreatorID: ctx.Doer.ID, - } - if err := project_model.NewColumn(ctx, column); err != nil { - ctx.ServerError("NewProjectColumn", err) - return - } - - ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) -} - -// CheckProjectColumnChangePermissions check permission -func CheckProjectColumnChangePermissions(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { - if ctx.Doer == nil { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in users are allowed to perform this action.", - }) - return nil, nil - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return nil, nil - } - - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) - if err != nil { - ctx.ServerError("GetProjectColumn", err) - return nil, nil - } - if column.ProjectID != ctx.PathParamInt64(":id") { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), - }) - return nil, nil - } - - if project.OwnerID != ctx.ContextUser.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), - }) - return nil, nil - } - return project, column -} - -// EditProjectColumn allows a project column's to be updated -func EditProjectColumn(ctx *context.APIContext) { - form := web.GetForm(ctx).(*api.EditProjectColumnOption) - _, column := CheckProjectColumnChangePermissions(ctx) - if ctx.Written() { - return - } - - if form.Title != "" { - column.Title = form.Title - } - column.Color = form.Color - if form.Sorting != 0 { - column.Sorting = form.Sorting - } - - if err := project_model.UpdateColumn(ctx, column); err != nil { - ctx.ServerError("UpdateProjectColumn", err) - return - } - - ctx.JSON(http.StatusOK, convert.ToColumn(ctx, column)) -} - -// DeleteProjectColumn allows for the deletion of a project column -func DeleteProjectColumn(ctx *context.APIContext) { - if ctx.Doer == nil { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in users are allowed to perform this action.", - }) - return - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - - pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) - if err != nil { - ctx.ServerError("GetProjectColumn", err) - return - } - if pb.ProjectID != ctx.PathParamInt64(":id") { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), - }) - return - } - - if project.OwnerID != ctx.ContextUser.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), - }) - return - } - - if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":columnID")); err != nil { - ctx.ServerError("DeleteProjectColumnByID", err) - return - } - - ctx.JSON(http.StatusOK, map[string]string{"message": "column deleted successfully"}) -} - -// SetDefaultProjectColumn set default column for uncategorized issues/pulls -func SetDefaultProjectColumn(ctx *context.APIContext) { - project, column := CheckProjectColumnChangePermissions(ctx) - if ctx.Written() { - return - } - - if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { - ctx.ServerError("SetDefaultColumn", err) - return - } - - ctx.JSON(http.StatusOK, map[string]string{"message": "default column set successfully"}) -} - -// MoveIssues moves or keeps issues in a column and sorts them inside that column -func MoveIssues(ctx *context.APIContext) { - if ctx.Doer == nil { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in users are allowed to perform this action.", - }) - return - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("InvalidRepoID", nil) - return - } - - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) - return - } - - if column.ProjectID != project.ID { - ctx.NotFound("ColumnNotInProject", nil) - return - } - - type movedIssuesForm struct { - Issues []struct { - IssueID int64 `json:"issueID"` - Sorting int64 `json:"sorting"` - } `json:"issues"` - } - - form := &movedIssuesForm{} - if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedIssuesForm", err) - return - } - - issueIDs := make([]int64, 0, len(form.Issues)) - sortedIssueIDs := make(map[int64]int64) - for _, issue := range form.Issues { - issueIDs = append(issueIDs, issue.IssueID) - sortedIssueIDs[issue.Sorting] = issue.IssueID - } - movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) - if err != nil { - ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) - return - } - - if len(movedIssues) != len(form.Issues) { - ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) - return - } - - if _, err = movedIssues.LoadRepositories(ctx); err != nil { - ctx.ServerError("LoadRepositories", err) - return - } - - for _, issue := range movedIssues { - if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { - ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) - return - } - } - - if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { - ctx.ServerError("MoveIssuesOnProjectColumn", err) - return - } - - ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) -} diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/shared/project.go similarity index 58% rename from routers/api/v1/repo/project.go rename to routers/api/v1/shared/project.go index a7cf7f4589afb..63fb629fdfde0 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/shared/project.go @@ -1,10 +1,12 @@ -package repo +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared import ( "encoding/json" "errors" "fmt" - "log" "net/http" "strings" @@ -21,18 +23,51 @@ import ( "code.gitea.io/gitea/services/convert" ) +var errInvalidModelType = errors.New("invalid model type") + +func checkModelType(model string) error { + if model != "repo" && model != "org" { + return errInvalidModelType + } + return nil +} + +// ProjectHandler is a handler for project actions +func ProjectHandler(model string, fn func(ctx *context.APIContext, model string)) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + fn(ctx, model) + } +} + // CreateProject creates a new project -func CreateProject(ctx *context.APIContext) { +func CreateProject(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "CreateProject", err) + return + } + form := web.GetForm(ctx).(*api.CreateProjectOption) project := &project_model.Project{ - RepoID: ctx.Repo.Repository.ID, Title: form.Title, Description: form.Content, CreatorID: ctx.Doer.ID, TemplateType: project_model.TemplateType(form.TemplateType), CardType: project_model.CardType(form.CardType), - Type: project_model.TypeRepository, + } + + if model == "repo" { + project.Type = project_model.TypeRepository + project.RepoID = ctx.Repo.Repository.ID + } else { + if ctx.ContextUser.IsOrganization() { + project.Type = project_model.TypeOrganization + } else { + project.Type = project_model.TypeIndividual + } + project.OwnerID = ctx.ContextUser.ID } if err := project_model.NewProject(ctx, project); err != nil { @@ -43,21 +78,40 @@ func CreateProject(ctx *context.APIContext) { ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) } -// Projects renders the home page of projects -func GetProjects(ctx *context.APIContext) { +// GetProjects returns a list of projects +func GetProjects(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProjects", err) + return + } + sortType := ctx.FormTrim("sort") isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" - keyword := ctx.FormTrim("q") - repo := ctx.Repo.Repository - projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ - RepoID: repo.ID, + searchOptions := project_model.SearchOptions{ IsClosed: optional.Some(isShowClosed), OrderBy: project_model.GetSearchOrderByBySortType(sortType), - Type: project_model.TypeRepository, - Title: keyword, - }) + } + + if model == "repo" { + repo := ctx.Repo.Repository + searchOptions.RepoID = repo.ID + searchOptions.Type = project_model.TypeRepository + } else { + searchOptions.OwnerID = ctx.ContextUser.ID + + if ctx.ContextUser.IsOrganization() { + searchOptions.Type = project_model.TypeOrganization + } else { + searchOptions.Type = project_model.TypeIndividual + } + } + + projects, err := db.Find[project_model.Project](ctx, &searchOptions) + if err != nil { ctx.ServerError("FindProjects", err) return @@ -66,15 +120,18 @@ func GetProjects(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } -// GetProject returns a project by ID -func GetProject(ctx *context.APIContext) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) +// GetProject returns a project +func GetProject(ctx *context.APIContext, model string) { + err := checkModelType(model) + if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + ctx.Error(http.StatusInternalServerError, "GetProject", err) return } - if project.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } @@ -106,46 +163,52 @@ func GetProject(ctx *context.APIContext) { }) } -// EditProject updates a project -func EditProject(ctx *context.APIContext) { +// EditProject edits a project +func EditProject(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "EditProject", err) + return + } + form := web.GetForm(ctx).(*api.CreateProjectOption) projectID := ctx.PathParamInt64(":id") - p, err := project_model.GetProjectByID(ctx, projectID) + project, err := project_model.GetProjectByID(ctx, projectID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if p.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) - return - } - p.Title = form.Title - p.Description = form.Content - p.CardType = project_model.CardType(form.CardType) + project.Title = form.Title + project.Description = form.Content + project.CardType = project_model.CardType(form.CardType) - if err = project_model.UpdateProject(ctx, p); err != nil { + if err = project_model.UpdateProject(ctx, project); err != nil { ctx.ServerError("UpdateProjects", err) return } - ctx.JSON(http.StatusOK, convert.ToProject(ctx, p)) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) } -// DeleteProject delete a project -func DeleteProject(ctx *context.APIContext) { - p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) +// DeleteProject deletes a project +func DeleteProject(ctx *context.APIContext, model string) { + err := checkModelType(model) + if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + ctx.Error(http.StatusInternalServerError, "DeleteProject", err) return } - if p.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - err = project_model.DeleteProjectByID(ctx, p.ID) + err = project_model.DeleteProjectByID(ctx, project.ID) if err != nil { ctx.ServerError("DeleteProjectByID", err) @@ -169,25 +232,40 @@ func ChangeProjectStatus(ctx *context.APIContext) { } id := ctx.PathParamInt64(":id") - if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) return } ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) } -// AddColumnToProject adds a new column to a project -func AddColumnToProject(ctx *context.APIContext) { - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) +func AddColumnToProject(ctx *context.APIContext, model string) { + var err error + err = checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "AddColumnToProject", err) return } - project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":id")) + if model == "repo" { + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + } + + var project *project_model.Project + if model == "repo" { + project, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":id")) + } else { + project, err = project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + } + if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + ctx.NotFoundOrServerError("GetProjectForRepoByID", project_model.IsErrProjectNotExist, err) return } @@ -207,8 +285,8 @@ func AddColumnToProject(ctx *context.APIContext) { ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) } -// CheckProjectColumnChangePermissions check permission -func checkProjectColumnChangePermissions(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { +// checkProjectColumnChangePermissions check permission +func checkProjectColumnChangePermissions(ctx *context.APIContext, model string) (*project_model.Project, *project_model.Column) { if ctx.Doer == nil { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only signed in users are allowed to perform this action.", @@ -216,11 +294,13 @@ func checkProjectColumnChangePermissions(ctx *context.APIContext) (*project_mode return nil, nil } - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return nil, nil + if model == "repo" { + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return nil, nil + } } project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) @@ -241,19 +321,35 @@ func checkProjectColumnChangePermissions(ctx *context.APIContext) (*project_mode return nil, nil } - if project.RepoID != ctx.Repo.Repository.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), - }) - return nil, nil + if model == "repo" { + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + } else { + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } } return project, column } // EditProjectColumn allows a project column's to be updated -func EditProjectColumn(ctx *context.APIContext) { +func EditProjectColumn(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "EditProjectColumn", err) + return + } + form := web.GetForm(ctx).(*api.EditProjectColumnOption) - _, column := checkProjectColumnChangePermissions(ctx) + _, column := checkProjectColumnChangePermissions(ctx, model) if ctx.Written() { return } @@ -275,7 +371,14 @@ func EditProjectColumn(ctx *context.APIContext) { } // DeleteProjectColumn allows for the deletion of a project column -func DeleteProjectColumn(ctx *context.APIContext) { +func DeleteProjectColumn(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteProjectColumn", err) + return + } + if ctx.Doer == nil { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only signed in users are allowed to perform this action.", @@ -283,11 +386,13 @@ func DeleteProjectColumn(ctx *context.APIContext) { return } - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return + if model == "repo" { + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } } project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) @@ -308,11 +413,20 @@ func DeleteProjectColumn(ctx *context.APIContext) { return } - if project.RepoID != ctx.Repo.Repository.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), - }) - return + if model == "repo" { + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + } else { + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } } if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":columnID")); err != nil { @@ -324,8 +438,15 @@ func DeleteProjectColumn(ctx *context.APIContext) { } // SetDefaultProjectColumn set default column for uncategorized issues/pulls -func SetDefaultProjectColumn(ctx *context.APIContext) { - project, column := checkProjectColumnChangePermissions(ctx) +func SetDefaultProjectColumn(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "SetDefaultProjectColumn", err) + return + } + + project, column := checkProjectColumnChangePermissions(ctx, model) if ctx.Written() { return } @@ -339,7 +460,14 @@ func SetDefaultProjectColumn(ctx *context.APIContext) { } // MoveIssues moves or keeps issues in a column and sorts them inside that column -func MoveIssues(ctx *context.APIContext) { +func MoveIssues(ctx *context.APIContext, model string) { + err := checkModelType(model) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "MoveIssues", err) + return + } + if ctx.Doer == nil { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only signed in users are allowed to perform this action.", @@ -347,11 +475,13 @@ func MoveIssues(ctx *context.APIContext) { return } - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return + if model == "repo" { + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } } project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) @@ -359,10 +489,6 @@ func MoveIssues(ctx *context.APIContext) { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if project.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("InvalidRepoID", nil) - return - } column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) if err != nil { @@ -436,7 +562,7 @@ func getActionIssues(ctx *context.APIContext, issuesIDs []int64) issues_model.Is ctx.ServerError("GetIssuesByIDs", err) return nil } - // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) for _, issue := range issues { @@ -470,8 +596,6 @@ func UpdateIssueProject(ctx *context.APIContext) { return } - log.Println("form", form) - log.Println(ctx.Repo.Repository.ID) issues := getActionIssues(ctx, form.Issues) if ctx.Written() { return @@ -502,3 +626,41 @@ func UpdateIssueProject(ctx *context.APIContext) { ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) } + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) +} diff --git a/routers/api/v1/shared/project/project.go b/routers/api/v1/shared/project/project.go deleted file mode 100644 index d3a52a1f6f90a..0000000000000 --- a/routers/api/v1/shared/project/project.go +++ /dev/null @@ -1,47 +0,0 @@ -package project - -import ( - "encoding/json" - "net/http" - - project_model "code.gitea.io/gitea/models/project" - "code.gitea.io/gitea/services/context" -) - -// MoveColumns moves or keeps columns in a project and sorts them inside that project -func MoveColumns(ctx *context.APIContext) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { - ctx.NotFound("CanBeAccessedByOwnerRepo", nil) - return - } - - type movedColumnsForm struct { - Columns []struct { - ColumnID int64 `json:"columnID"` - Sorting int64 `json:"sorting"` - } `json:"columns"` - } - - form := &movedColumnsForm{} - if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedColumnsForm", err) - return - } - - sortedColumnIDs := make(map[int64]int64) - for _, column := range form.Columns { - sortedColumnIDs[column.Sorting] = column.ColumnID - } - - if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { - ctx.ServerError("MoveColumnsOnProject", err) - return - } - - ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) -} diff --git a/services/convert/project.go b/services/convert/project.go index db3d7991b5028..8f0daa1460bb7 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -12,25 +12,25 @@ import ( // ToProject converts a models.Project to api.Project func ToProject(ctx context.Context, project *project_model.Project) *api.Project { - if project == nil { - return nil - } - return &api.Project{ - ID: project.ID, - Title: project.Title, - Description: project.Description, - TemplateType: uint8(project.TemplateType), - CardType: uint8(project.CardType), + ID: project.ID, + Title: project.Title, + Description: project.Description, + TemplateType: uint8(project.TemplateType), + CardType: uint8(project.CardType), + OwnerID: project.OwnerID, + RepoID: project.RepoID, + CreatorID: project.CreatorID, + IsClosed: project.IsClosed, + Type: uint8(project.Type), + CreatedUnix: int64(project.CreatedUnix), + UpdatedUnix: int64(project.UpdatedUnix), + ClosedDateUnix: int64(project.ClosedDateUnix), } } // ToProjects converts a slice of models.Project to a slice of api.Project func ToProjects(ctx context.Context, projects []*project_model.Project) []*api.Project { - if projects == nil { - return nil - } - result := make([]*api.Project, len(projects)) for i, project := range projects { result[i] = ToProject(ctx, project) From 9ca2cdfa39585b83656f651d9b206f18219d451d Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Sun, 14 Jul 2024 16:50:32 +0300 Subject: [PATCH 14/20] docs: set up project files for swagger documentation --- modules/structs/project.go | 1 + routers/api/v1/shared/project.go | 28 ++++++++++++++++++++++++++++ routers/api/v1/swagger/options.go | 3 +++ routers/api/v1/swagger/project.go | 15 +++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 routers/api/v1/swagger/project.go diff --git a/modules/structs/project.go b/modules/structs/project.go index d546021910a6f..1b850504d9d7b 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -22,6 +22,7 @@ type Project struct { } type CreateProjectOption struct { + // required:true Title string `json:"title" binding:"Required;MaxSize(100)"` Content string `json:"content"` TemplateType uint8 `json:"template_type"` diff --git a/routers/api/v1/shared/project.go b/routers/api/v1/shared/project.go index 63fb629fdfde0..4858ec06763a3 100644 --- a/routers/api/v1/shared/project.go +++ b/routers/api/v1/shared/project.go @@ -41,6 +41,34 @@ func ProjectHandler(model string, fn func(ctx *context.APIContext, model string) // CreateProject creates a new project func CreateProject(ctx *context.APIContext, model string) { + // swagger: operation POST /users/{username}/{reponame}/projects project createProject + // --- + // summary: Create a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // type: string + // required: true + // - name: reponame + // in: path + // description: repository name + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + err := checkModelType(model) if err != nil { diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1de58632d57fa..c9ee3102fc601 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -205,4 +205,7 @@ type swaggerParameterBodies struct { // in:body UpdateVariableOption api.UpdateVariableOption + + // in:body + CreateProjectOption api.CreateProjectOption } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go new file mode 100644 index 0000000000000..90e1abd338cfd --- /dev/null +++ b/routers/api/v1/swagger/project.go @@ -0,0 +1,15 @@ +// Copyright 2017 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"` +} From 1b47d915bc1dc442c125386245a8876d46763d5b Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Mon, 15 Jul 2024 13:33:26 +0300 Subject: [PATCH 15/20] docs: create swagger api documentation for endpoints --- modules/structs/issue.go | 14 + modules/structs/project.go | 16 + modules/structs/project_column.go | 8 + routers/api/v1/api.go | 8 +- routers/api/v1/shared/project.go | 564 ++++++++++++++--- routers/api/v1/swagger/options.go | 15 + routers/api/v1/swagger/project.go | 14 + templates/swagger/v1_json.tmpl | 980 +++++++++++++++++++++++++++++- 8 files changed, 1546 insertions(+), 73 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 3c06e383560a3..232d3cc08fe47 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -115,6 +115,20 @@ type EditIssueOption struct { RemoveDeadline *bool `json:"unset_due_date"` } +// MoveIssuesOption options for moving issues +type MovedIssuesOption struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` +} + +// UpdateIssuesOption options for updating issues +type UpdateIssuesOption struct { + ProjectID int64 `json:"project_id"` + Issues []int64 `json:"issues"` +} + // EditDeadlineOption options for creating a deadline type EditDeadlineOption struct { // required:true diff --git a/modules/structs/project.go b/modules/structs/project.go index 1b850504d9d7b..02ebd2fe52313 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -21,6 +21,7 @@ type Project struct { ClosedDateUnix int64 `json:"closed_date_unix"` } +// CreateProjectOption options for creating a project type CreateProjectOption struct { // required:true Title string `json:"title" binding:"Required;MaxSize(100)"` @@ -28,3 +29,18 @@ type CreateProjectOption struct { TemplateType uint8 `json:"template_type"` CardType uint8 `json:"card_type"` } + +// EditProjectOption options for editing a project +type EditProjectOption struct { + Title string `json:"title" binding:"MaxSize(100)"` + Content string `json:"content"` + CardType uint8 `json:"card_type"` +} + +// MoveColumnsOption options for moving columns +type MovedColumnsOption struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` +} diff --git a/modules/structs/project_column.go b/modules/structs/project_column.go index 4138717d77630..b4e5d18144204 100644 --- a/modules/structs/project_column.go +++ b/modules/structs/project_column.go @@ -12,6 +12,14 @@ type Column struct { // EditProjectColumnOption options for editing a project column type EditProjectColumnOption struct { + Title string `binding:"MaxSize(100)"` + Sorting int8 + Color string `binding:"MaxSize(7)"` +} + +// CreateProjectColumnOption options for creating a project column +type CreateProjectColumnOption struct { + // required:true Title string `binding:"Required;MaxSize(100)"` Sorting int8 Color string `binding:"MaxSize(7)"` diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f621456a5d132..6ea6c096fe1e9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1026,9 +1026,9 @@ func Routes() *web.Router { m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("org", project_shared.CreateProject)) m.Group("/{id}", func() { - m.Post("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("org", project_shared.AddColumnToProject)) + m.Post("", bind(api.CreateProjectColumnOption{}), project_shared.ProjectHandler("org", project_shared.AddColumnToProject)) m.Delete("", project_shared.ProjectHandler("org", project_shared.DeleteProject)) - m.Put("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("org", project_shared.EditProject)) + m.Put("", bind(api.EditProjectOption{}), project_shared.ProjectHandler("org", project_shared.EditProject)) m.Post("/move", project_shared.MoveColumns) m.Post("/{action:open|close}", project_shared.ChangeProjectStatus) @@ -1055,9 +1055,9 @@ func Routes() *web.Router { m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("repo", project_shared.CreateProject)) m.Group("/{id}", func() { - m.Post("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("repo", project_shared.AddColumnToProject)) + m.Post("", bind(api.CreateProjectColumnOption{}), project_shared.ProjectHandler("repo", project_shared.AddColumnToProject)) m.Delete("", project_shared.ProjectHandler("repo", project_shared.DeleteProject)) - m.Put("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("repo", project_shared.EditProject)) + m.Put("", bind(api.EditProjectOption{}), project_shared.ProjectHandler("repo", project_shared.EditProject)) m.Post("/move", project_shared.MoveColumns) m.Post("/{action:open|close}", project_shared.ChangeProjectStatus) diff --git a/routers/api/v1/shared/project.go b/routers/api/v1/shared/project.go index 4858ec06763a3..31c2e34c98f53 100644 --- a/routers/api/v1/shared/project.go +++ b/routers/api/v1/shared/project.go @@ -41,9 +41,10 @@ func ProjectHandler(model string, fn func(ctx *context.APIContext, model string) // CreateProject creates a new project func CreateProject(ctx *context.APIContext, model string) { - // swagger: operation POST /users/{username}/{reponame}/projects project createProject + // swagger:operation POST /{username}/{repo}/projects project createProject // --- - // summary: Create a project + // summary: Create a new project + // description: Creates a new project for a given user and repository. // consumes: // - application/json // produces: @@ -52,22 +53,30 @@ func CreateProject(ctx *context.APIContext, model string) { // - name: username // in: path // description: owner of the project - // type: string // required: true - // - name: reponame - // in: path - // description: repository name // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the project will be created for the user // required: true + // type: string // - name: body // in: body + // description: Project data + // required: true // schema: // "$ref": "#/definitions/CreateProjectOption" // responses: - // "200": + // "201": // "$ref": "#/responses/Project" - // "404": - // "$ref": "#/responses/notFound" + // "403": + // "$ref": "#/responses/forbidden" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" err := checkModelType(model) @@ -108,6 +117,33 @@ func CreateProject(ctx *context.APIContext, model string) { // GetProjects returns a list of projects func GetProjects(ctx *context.APIContext, model string) { + // swagger:operation GET /{username}/{repo}/projects project getProjects + // --- + // summary: Get a list of projects + // description: Returns a list of projects for a given user and repository. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the projects will be returned for the user + // required: true + // type: string + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -150,6 +186,37 @@ func GetProjects(ctx *context.APIContext, model string) { // GetProject returns a project func GetProject(ctx *context.APIContext, model string) { + // swagger:operation GET /{username}/{repo}/projects/{id} project getProject + // --- + // summary: Get a project + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the project will be returned for the user + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -193,6 +260,39 @@ func GetProject(ctx *context.APIContext, model string) { // EditProject edits a project func EditProject(ctx *context.APIContext, model string) { + // swagger:operation PUT /{username}/{repo}/projects/{id} project editProject + // --- + // summary: Edit a project + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the project will be edited for the user + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -223,6 +323,36 @@ func EditProject(ctx *context.APIContext, model string) { // DeleteProject deletes a project func DeleteProject(ctx *context.APIContext, model string) { + // swagger:operation DELETE /{username}/{repo}/projects/{id} project deleteProject + // --- + // summary: Delete a project + // description: Deletes a specific project for a given user and repository. + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the project will be deleted for the user + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -243,11 +373,50 @@ func DeleteProject(ctx *context.APIContext, model string) { return } - ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) + ctx.Status(http.StatusNoContent) } // ChangeProjectStatus updates the status of a project between "open" and "close" func ChangeProjectStatus(ctx *context.APIContext) { + // swagger:operation POST /{username}/{repo}/projects/{id}/{action} project changeProjectStatus + // --- + // summary: Change the status of a project + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the project status will be changed for the user + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: action + // in: path + // description: action to perform (open or close) + // required: true + // type: string + // enum: + // - open + // - close + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + var toClose bool switch ctx.PathParam(":action") { case "open": @@ -264,10 +433,54 @@ func ChangeProjectStatus(ctx *context.APIContext) { ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) return } - ctx.JSON(http.StatusOK, map[string]any{"message": "project status updated successfully"}) + ctx.Status(http.StatusNoContent) } +// AddColumnToProject adds a new column to a project func AddColumnToProject(ctx *context.APIContext, model string) { + // swagger:operation POST /{username}/{repo}/projects/{id} project addColumnToProject + // --- + // summary: Add a column to a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the column will be added to the user's project + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: body + // in: body + // description: column data + // required: true + // schema: + // "$ref": "#/definitions/CreateProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/Column" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + var err error err = checkModelType(model) @@ -313,7 +526,6 @@ func AddColumnToProject(ctx *context.APIContext, model string) { ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) } -// checkProjectColumnChangePermissions check permission func checkProjectColumnChangePermissions(ctx *context.APIContext, model string) (*project_model.Project, *project_model.Column) { if ctx.Doer == nil { ctx.JSON(http.StatusForbidden, map[string]string{ @@ -369,6 +581,54 @@ func checkProjectColumnChangePermissions(ctx *context.APIContext, model string) // EditProjectColumn allows a project column's to be updated func EditProjectColumn(ctx *context.APIContext, model string) { + // swagger:operation PUT /{username}/{repo}/projects/{id}/{column-id} project editProjectColumn + // --- + // summary: Edit a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the column will be edited for the user's project + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: column-id + // in: path + // description: column ID + // required: true + // type: integer + // - name: body + // in: body + // description: column data + // required: true + // schema: + // "$ref": "#/definitions/EditProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/Column" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -400,6 +660,40 @@ func EditProjectColumn(ctx *context.APIContext, model string) { // DeleteProjectColumn allows for the deletion of a project column func DeleteProjectColumn(ctx *context.APIContext, model string) { + // swagger:operation DELETE /{username}/{repo}/projects/{id}/{column-id} project deleteProjectColumn + // --- + // summary: Delete a project column + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the column will be deleted for the user's project + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: column-id + // in: path + // description: column ID + // required: true + // type: integer + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -462,11 +756,45 @@ func DeleteProjectColumn(ctx *context.APIContext, model string) { return } - ctx.JSON(http.StatusOK, map[string]string{"message": "column deleted successfully"}) + ctx.Status(http.StatusNoContent) } -// SetDefaultProjectColumn set default column for uncategorized issues/pulls +// SetDefaultProjectColumn set default column for issues/pulls func SetDefaultProjectColumn(ctx *context.APIContext, model string) { + // swagger:operation POST /{username}/{repo}/projects/{id}/{column-id}/default project setDefaultProjectColumn + // --- + // summary: Set default column for issues/pulls + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the column will be set as default for the user's project + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: column-id + // in: path + // description: column ID + // required: true + // type: integer + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -484,11 +812,121 @@ func SetDefaultProjectColumn(ctx *context.APIContext, model string) { return } - ctx.JSON(http.StatusOK, map[string]string{"message": "default column set successfully"}) + ctx.Status(http.StatusNoContent) +} + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.APIContext) { + // swagger:operation PUT /{username}/{repo}/projects/{id}/move project moveColumns + // --- + // summary: Move columns in a project + // consumes: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. If left '-', the columns will be moved for the user's project + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: body + // in: body + // description: columns data + // required: true + // schema: + // "$ref": "#/definitions/MovedColumnsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + form := &api.MovedColumnsOption{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.Status(http.StatusNoContent) } // MoveIssues moves or keeps issues in a column and sorts them inside that column func MoveIssues(ctx *context.APIContext, model string) { + // swagger:operation POST /{username}/{repo}/projects/{id}/{column-id}/move project moveIssues + // --- + // summary: Move issues in a column + // consumes: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. + // required: true + // type: string + // - name: id + // in: path + // description: project ID + // required: true + // type: integer + // - name: column-id + // in: path + // description: column ID + // required: true + // type: integer + // - name: body + // in: body + // description: issues data + // required: true + // schema: + // "$ref": "#/definitions/MovedIssuesOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + err := checkModelType(model) if err != nil { @@ -529,14 +967,7 @@ func MoveIssues(ctx *context.APIContext, model string) { return } - type movedIssuesForm struct { - Issues []struct { - IssueID int64 `json:"issueID"` - Sorting int64 `json:"sorting"` - } `json:"issues"` - } - - form := &movedIssuesForm{} + form := &api.MovedIssuesOption{} if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { ctx.ServerError("DecodeMovedIssuesForm", err) return @@ -576,7 +1007,7 @@ func MoveIssues(ctx *context.APIContext, model string) { return } - ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) + ctx.Status(http.StatusNoContent) } func getActionIssues(ctx *context.APIContext, issuesIDs []int64) issues_model.IssueList { @@ -612,12 +1043,47 @@ func getActionIssues(ctx *context.APIContext, issuesIDs []int64) issues_model.Is // UpdateIssueProject change an issue's project func UpdateIssueProject(ctx *context.APIContext) { - type updateIssuesForm struct { - ProjectID int64 `json:"project_id"` - Issues []int64 `json:"issues"` - } + // swagger:operation POST /{username}/{repo}/{type}/projects project updateIssueProject + // --- + // summary: Change an issue's project + // consumes: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // - name: repo + // in: path + // description: repository name. + // required: true + // type: string + // - name: type + // in: path + // description: issue type (issues or pulls) + // required: true + // type: string + // enum: + // - issues + // - pulls + // - name: body + // in: body + // description: issues data + // required: true + // schema: + // "$ref": "#/definitions/UpdateIssuesOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" - form := &updateIssuesForm{} + form := &api.UpdateIssuesOption{} if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { ctx.ServerError("DecodeMovedIssuesForm", err) @@ -652,43 +1118,5 @@ func UpdateIssueProject(ctx *context.APIContext) { } } - ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) -} - -// MoveColumns moves or keeps columns in a project and sorts them inside that project -func MoveColumns(ctx *context.APIContext) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { - ctx.NotFound("CanBeAccessedByOwnerRepo", nil) - return - } - - type movedColumnsForm struct { - Columns []struct { - ColumnID int64 `json:"columnID"` - Sorting int64 `json:"sorting"` - } `json:"columns"` - } - - form := &movedColumnsForm{} - if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedColumnsForm", err) - return - } - - sortedColumnIDs := make(map[int64]int64) - for _, column := range form.Columns { - sortedColumnIDs[column.Sorting] = column.ColumnID - } - - if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { - ctx.ServerError("MoveColumnsOnProject", err) - return - } - - ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index c9ee3102fc601..472aea0e16d3f 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -208,4 +208,19 @@ type swaggerParameterBodies struct { // in:body CreateProjectOption api.CreateProjectOption + + // in:body + CreateProjectColumnOption api.CreateProjectColumnOption + + // in:body + EditProjectColumnOption api.EditProjectColumnOption + + // in:body + MovedColumnsOption api.MovedColumnsOption + + // in:body + MovedIssuesOption api.MovedIssuesOption + + // in:body + UpdateIssuesOption api.UpdateIssuesOption } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go index 90e1abd338cfd..26a6743e68bb9 100644 --- a/routers/api/v1/swagger/project.go +++ b/routers/api/v1/swagger/project.go @@ -13,3 +13,17 @@ type swaggerResponseProject struct { // in:body Body api.Project `json:"body"` } + +// ProjectList +// swagger:response ProjectList +type swaggerResponseProjectList struct { + // in:body + Body []api.Project `json:"body"` +} + +// Column +// swagger:response Column +type swaggerResponseColumn struct { + // in:body + Body api.Column `json:"body"` +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 5c46c3c9b8aa1..beba3296f8a99 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -18206,6 +18206,737 @@ } } } + }, + "/{username}/{repo}/projects": { + "get": { + "description": "Returns a list of projects for a given user and repository.", + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get a list of projects", + "operationId": "getProjects", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the projects will be returned for the user", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "post": { + "description": "Creates a new project for a given user and repository.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new project", + "operationId": "createProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the project will be created for the user", + "name": "repo", + "in": "path", + "required": true + }, + { + "description": "Project data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/projects/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get a project", + "operationId": "getProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the project will be returned for the user", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Edit a project", + "operationId": "editProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the project will be edited for the user", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Add a column to a project", + "operationId": "addColumnToProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the column will be added to the user's project", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "column data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Column" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "description": "Deletes a specific project for a given user and repository.", + "tags": [ + "project" + ], + "summary": "Delete a project", + "operationId": "deleteProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the project will be deleted for the user", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/projects/{id}/move": { + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Move columns in a project", + "operationId": "moveColumns", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the columns will be moved for the user's project", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "columns data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MovedColumnsOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/projects/{id}/{action}": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Change the status of a project", + "operationId": "changeProjectStatus", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the project status will be changed for the user", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "close" + ], + "type": "string", + "description": "action to perform (open or close)", + "name": "action", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/projects/{id}/{column-id}": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Edit a project column", + "operationId": "editProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the column will be edited for the user's project", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "column ID", + "name": "column-id", + "in": "path", + "required": true + }, + { + "description": "column data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Column" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "tags": [ + "project" + ], + "summary": "Delete a project column", + "operationId": "deleteProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the column will be deleted for the user's project", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "column ID", + "name": "column-id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/projects/{id}/{column-id}/default": { + "post": { + "tags": [ + "project" + ], + "summary": "Set default column for issues/pulls", + "operationId": "setDefaultProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name. If left '-', the column will be set as default for the user's project", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "column ID", + "name": "column-id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/projects/{id}/{column-id}/move": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Move issues in a column", + "operationId": "moveIssues", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name.", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "column ID", + "name": "column-id", + "in": "path", + "required": true + }, + { + "description": "issues data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MovedIssuesOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/{username}/{repo}/{type}/projects": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Change an issue's project", + "operationId": "updateIssueProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name.", + "name": "repo", + "in": "path", + "required": true + }, + { + "enum": [ + "issues", + "pulls" + ], + "type": "string", + "description": "issue type (issues or pulls)", + "name": "type", + "in": "path", + "required": true + }, + { + "description": "issues data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateIssuesOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } } }, "definitions": { @@ -18962,6 +19693,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Column": { + "description": "Column represents a project column", + "type": "object", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CombinedStatus": { "description": "CombinedStatus holds the combined state of several statuses for a single commit", "type": "object", @@ -19942,6 +20693,54 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateProjectColumnOption": { + "description": "CreateProjectColumnOption options for creating a project column", + "type": "object", + "required": [ + "Title" + ], + "properties": { + "Color": { + "type": "string" + }, + "Sorting": { + "type": "integer", + "format": "int8" + }, + "Title": { + "type": "string" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "CreateProjectOption": { + "description": "CreateProjectOption options for creating a project", + "type": "object", + "required": [ + "title" + ], + "properties": { + "card_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "CardType" + }, + "content": { + "type": "string", + "x-go-name": "Content" + }, + "template_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "type": "object", @@ -20936,6 +21735,23 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditProjectColumnOption": { + "description": "EditProjectColumnOption options for editing a project column", + "type": "object", + "properties": { + "Color": { + "type": "string" + }, + "Sorting": { + "type": "integer", + "format": "int8" + }, + "Title": { + "type": "string" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "type": "object", @@ -22731,6 +23547,58 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "MovedColumnsOption": { + "description": "MoveColumnsOption options for moving columns", + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "columnID": { + "type": "integer", + "format": "int64", + "x-go-name": "ColumnID" + }, + "sorting": { + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + } + } + }, + "x-go-name": "Columns" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "MovedIssuesOption": { + "description": "MoveIssuesOption options for moving issues", + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "issueID": { + "type": "integer", + "format": "int64", + "x-go-name": "IssueID" + }, + "sorting": { + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + } + } + }, + "x-go-name": "Issues" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "type": "object", @@ -23312,6 +24180,75 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Project": { + "description": "Project represents a project", + "type": "object", + "properties": { + "card_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "CardType" + }, + "closed_date_unix": { + "type": "integer", + "format": "int64", + "x-go-name": "ClosedDateUnix" + }, + "created_unix": { + "type": "integer", + "format": "int64", + "x-go-name": "CreatedUnix" + }, + "creator_id": { + "type": "integer", + "format": "int64", + "x-go-name": "CreatorID" + }, + "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_id": { + "type": "integer", + "format": "int64", + "x-go-name": "OwnerID" + }, + "repo_id": { + "type": "integer", + "format": "int64", + "x-go-name": "RepoID" + }, + "template_type": { + "type": "integer", + "format": "uint8", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "type": { + "type": "integer", + "format": "uint8", + "x-go-name": "Type" + }, + "updated_unix": { + "type": "integer", + "format": "int64", + "x-go-name": "UpdatedUnix" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -24786,6 +25723,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateIssuesOption": { + "description": "UpdateIssuesOption options for updating issues", + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Issues" + }, + "project_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ProjectID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UpdateRepoAvatarOption": { "description": "UpdateRepoAvatarUserOption options when updating the repo avatar", "type": "object", @@ -25338,6 +26295,12 @@ } } }, + "Column": { + "description": "Column", + "schema": { + "$ref": "#/definitions/Column" + } + }, "CombinedStatus": { "description": "CombinedStatus", "schema": { @@ -25797,6 +26760,21 @@ } } }, + "Project": { + "description": "Project", + "schema": { + "$ref": "#/definitions/Project" + } + }, + "ProjectList": { + "description": "ProjectList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { @@ -26222,7 +27200,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/UpdateVariableOption" + "$ref": "#/definitions/UpdateIssuesOption" } }, "redirect": { From d41dc95f1080a2abc16e9bbca759ee47fb37da3c Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 31 Jul 2024 14:13:21 +0300 Subject: [PATCH 16/20] refactor: refactor endpoints to comply with github api introduce new logic to handle project and column assignment, refactor everything and removed unnecessary code, create swagger docs --- routers/api/v1/api.go | 254 +- routers/api/v1/org/project.go | 114 + routers/api/v1/project/project.go | 208 ++ routers/api/v1/project/project_column.go | 425 +++ routers/api/v1/repo/project.go | 242 ++ routers/api/v1/shared/project.go | 1122 -------- routers/api/v1/swagger/project.go | 7 + routers/api/v1/user/project.go | 109 + templates/swagger/v1_json.tmpl | 3261 +++++++++++----------- 9 files changed, 2951 insertions(+), 2791 deletions(-) create mode 100644 routers/api/v1/org/project.go create mode 100644 routers/api/v1/project/project.go create mode 100644 routers/api/v1/project/project_column.go create mode 100644 routers/api/v1/repo/project.go delete mode 100644 routers/api/v1/shared/project.go create mode 100644 routers/api/v1/user/project.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 6ea6c096fe1e9..de674a3364261 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -76,6 +76,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -89,9 +90,9 @@ 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/project" "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/settings" - project_shared "code.gitea.io/gitea/routers/api/v1/shared" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/actions" @@ -135,6 +136,114 @@ func sudo() func(ctx *context.APIContext) { } } +func projectIDAssignmentAPI() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.PathParam(":project_id") == "" { + return + } + + projectAssignment(ctx, ctx.PathParamInt64(":project_id")) + } +} + +func projectAssignment(ctx *context.APIContext, projectID int64) { + var ( + owner *user_model.User + err error + ) + + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.Error(http.StatusNotFound, "GetProjectByID", err) + return + } + + if project.Type == project_model.TypeIndividual || project.Type == project_model.TypeOrganization { + if err := project.LoadOwner(ctx); err != nil { + ctx.Error(http.StatusNotFound, "LoadOwner", err) + return + } + + if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(project.Owner.Name) { + owner = ctx.Doer + } else { + owner = project.Owner + } + + if project.Type == project_model.TypeOrganization { + ctx.Org.Organization = (*organization.Organization)(owner) + } + } else { + if err := project.LoadRepo(ctx); err != nil { + ctx.Error(http.StatusNotFound, "LoadRepo", err) + } + + repo := project.Repo + + if err := repo.LoadOwner(ctx); err != nil { + ctx.Error(http.StatusNotFound, "LoadOwner", err) + return + } + + ctx.Repo.Repository = repo + owner = repo.Owner + + if ctx.Doer != nil && ctx.Doer.ID == user_model.ActionsUserID { + taskID := ctx.Data["ActionsTaskID"].(int64) + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "actions_model.GetTaskByID", err) + return + } + if task.RepoID != repo.ID { + ctx.NotFound() + return + } + + if task.IsForkPullRequest { + ctx.Repo.Permission.AccessMode = perm.AccessModeRead + } else { + ctx.Repo.Permission.AccessMode = perm.AccessModeWrite + } + + if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUnits", err) + return + } + ctx.Repo.Permission.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.Repo.Permission.AccessMode) + } else { + ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + } + + if !ctx.Repo.Permission.HasAnyUnitAccess() { + ctx.NotFound() + return + } + } + ctx.ContextUser = owner +} + +func columnAssignment() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.PathParam("column_id") == "" { + return + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":column_id")) + + if err != nil { + ctx.Error(http.StatusNotFound, "GetColumn", err) + } + + projectAssignment(ctx, column.ProjectID) + + } +} + func repoAssignment() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { userName := ctx.PathParam("username") @@ -169,6 +278,10 @@ func repoAssignment() func(ctx *context.APIContext) { ctx.Repo.Owner = owner ctx.ContextUser = owner + if owner.IsOrganization() { + ctx.Org.Organization = (*organization.Organization)(owner) + } + // Get repository. repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) if err != nil { @@ -388,6 +501,9 @@ func reqAdmin() func(ctx *context.APIContext) { // reqRepoWriter user should have a permission to write to a repo, or be a site admin func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if ctx.Repo.Repository == nil { + return + } if !ctx.IsUserRepoWriter(unitTypes) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { ctx.Error(http.StatusForbidden, "reqRepoWriter", "user should have a permission to write to a repo") return @@ -395,18 +511,6 @@ func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { } } -// reqRepoWriterOr returns a middleware for requiring repository write to one of the unit permission -func reqRepoWriterOr(unitTypes ...unit.Type) func(ctx *context.APIContext) { - return func(ctx *context.APIContext) { - for _, unitType := range unitTypes { - if ctx.Repo.CanWrite(unitType) { - return - } - } - ctx.NotFound(ctx.Req.URL.RequestURI(), nil) - } -} - // reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin func reqRepoBranchWriter(ctx *context.APIContext) { options, ok := web.GetForm(ctx).(api.FileOptionInterface) @@ -419,6 +523,10 @@ func reqRepoBranchWriter(ctx *context.APIContext) { // reqRepoReader user should have specific read permission or be a repo admin or a site admin func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if ctx.Repo.Repository == nil { + return + } + if !ctx.Repo.CanRead(unitType) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { ctx.Error(http.StatusForbidden, "reqRepoReader", "user should have specific read permission or be a repo admin or a site admin") return @@ -555,6 +663,15 @@ func reqWebhooksEnabled() func(ctx *context.APIContext) { } } +func reqProjectOwner() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.Repo.Repository == nil && ctx.ContextUser.IsIndividual() && ctx.ContextUser != ctx.Doer { + ctx.Error(http.StatusForbidden, "", "must be the project owner") + return + } + } +} + func orgAssignment(args ...bool) func(ctx *context.APIContext) { var ( assignOrg bool @@ -569,18 +686,6 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { ctx.Org = new(context.APIOrganization) - if ctx.ContextUser == nil { - if ctx.Org.Organization == nil { - getOrganizationByParams(ctx) - ctx.ContextUser = ctx.Org.Organization.AsUser() - } - } else if ctx.ContextUser.IsOrganization() { - if ctx.Org == nil { - ctx.Org = &context.APIOrganization{} - } - ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) - } - var err error if assignOrg { getOrganizationByParams(ctx) @@ -639,6 +744,12 @@ func mustEnableRepoProjects(ctx *context.APIContext) { } } +func getAuthenticatedUser(ctx *context.APIContext) { + if ctx.IsSigned { + ctx.ContextUser = ctx.Doer + } +} + func mustEnableIssues(ctx *context.APIContext) { if !ctx.Repo.CanRead(unit.TypeIssues) { if log.IsTrace() { @@ -719,6 +830,10 @@ func mustEnableWiki(ctx *context.APIContext) { } func mustNotBeArchived(ctx *context.APIContext) { + if ctx.Repo.Repository == nil { + return + } + if ctx.Repo.Repository.IsArchived { ctx.Error(http.StatusLocked, "RepoArchived", fmt.Errorf("%s is archived", ctx.Repo.Repository.LogString())) return @@ -1015,66 +1130,51 @@ func Routes() *web.Router { }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) - // Users (requires user scope) - m.Group("/{username}/-", func() { - m.Group("/projects", func() { - m.Group("", func() { - m.Get("", project_shared.ProjectHandler("org", project_shared.GetProjects)) - m.Get("/{id}", project_shared.ProjectHandler("org", project_shared.GetProject)) - }) + // Projects + m.Group("/orgs/{org}/projects", func() { + m.Get("", reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), org.GetProjects) + m.Post("", reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), bind(api.CreateProjectOption{}), org.CreateProject) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true)) + + m.Group("/projects", func() { + m.Group("/{project_id}", func() { + m.Get("", project.GetProject) + m.Get("/columns", project.GetProjectColumns) m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("org", project_shared.CreateProject)) - m.Group("/{id}", func() { - m.Post("", bind(api.CreateProjectColumnOption{}), project_shared.ProjectHandler("org", project_shared.AddColumnToProject)) - m.Delete("", project_shared.ProjectHandler("org", project_shared.DeleteProject)) - m.Put("", bind(api.EditProjectOption{}), project_shared.ProjectHandler("org", project_shared.EditProject)) - m.Post("/move", project_shared.MoveColumns) - m.Post("/{action:open|close}", project_shared.ChangeProjectStatus) - - m.Group("/{columnID}", func() { - m.Put("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("org", project_shared.EditProjectColumn)) - m.Delete("", project_shared.ProjectHandler("org", project_shared.DeleteProjectColumn)) - m.Post("/default", project_shared.ProjectHandler("org", project_shared.SetDefaultProjectColumn)) - m.Post("/move", project_shared.ProjectHandler("org", project_shared.MoveIssues)) - }) + m.Patch("", bind(api.EditProjectOption{}), project.EditProject) + m.Delete("", project.DeleteProject) + m.Post("/{action:open|close}", project.ChangeProjectStatus) + m.Group("/columns", func() { + m.Post("", bind(api.CreateProjectColumnOption{}), project.AddColumnToProject) + m.Patch("/move", project.MoveColumns) + m.Patch("/{column_id}/move", project.MoveIssues) }) - }, reqSelfOrAdmin(), reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) - }, individualPermsChecker) + }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), reqProjectOwner()) + }) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), reqToken(), context.UserAssignmentAPI(), orgAssignment(), reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("/columns/{column_id}", func() { + m.Get("", project.GetProjectColumn) - // Users (requires user scope) - m.Group("/{username}/{reponame}", func() { - m.Group("/projects", func() { m.Group("", func() { - m.Get("", project_shared.ProjectHandler("repo", project_shared.GetProjects)) - m.Get("/{id}", project_shared.ProjectHandler("repo", project_shared.GetProject)) - }) + m.Patch("", bind(api.EditProjectColumnOption{}), project.EditProjectColumn) + m.Delete("", project.DeleteProjectColumn) + m.Post("/default", project.SetDefaultProjectColumn) + }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), reqProjectOwner()) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), projectIDAssignmentAPI(), columnAssignment(), individualPermsChecker, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), project_shared.ProjectHandler("repo", project_shared.CreateProject)) - m.Group("/{id}", func() { - m.Post("", bind(api.CreateProjectColumnOption{}), project_shared.ProjectHandler("repo", project_shared.AddColumnToProject)) - m.Delete("", project_shared.ProjectHandler("repo", project_shared.DeleteProject)) - m.Put("", bind(api.EditProjectOption{}), project_shared.ProjectHandler("repo", project_shared.EditProject)) - m.Post("/move", project_shared.MoveColumns) - m.Post("/{action:open|close}", project_shared.ChangeProjectStatus) - - m.Group("/{columnID}", func() { - m.Put("", bind(api.EditProjectColumnOption{}), project_shared.ProjectHandler("repo", project_shared.EditProjectColumn)) - m.Delete("", project_shared.ProjectHandler("repo", project_shared.DeleteProjectColumn)) - m.Post("/default", project_shared.ProjectHandler("repo", project_shared.SetDefaultProjectColumn)) - m.Post("/move", project_shared.ProjectHandler("repo", project_shared.MoveIssues)) - }) - }) - }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) - }, individualPermsChecker) + m.Group("/repos/{username}/{reponame}/projects", func() { + m.Get("", repo.GetProjects) + m.Group("", func() { + m.Post("", bind(api.CreateProjectOption{}), repo.CreateProject) + m.Put("/{type:issues|pulls}", reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.UpdateIssueProject) + }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), individualPermsChecker, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) + + m.Post("/user/projects", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), getAuthenticatedUser, reqSelfOrAdmin(), bind(api.CreateProjectOption{}), user.CreateProject) - m.Group("/{type:issues|pulls}", func() { - m.Post("/projects", reqRepoWriterOr(unit.TypeIssues, unit.TypePullRequests), reqRepoWriter(unit.TypeProjects), project_shared.UpdateIssueProject) - }, individualPermsChecker) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), repoAssignment(), reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) + m.Get("/users/{username}/projects", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI(), individualPermsChecker, user.GetProjects) // Users (requires user scope) m.Group("/users", func() { diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go new file mode 100644 index 0000000000000..fbd8e05f96f4e --- /dev/null +++ b/routers/api/v1/org/project.go @@ -0,0 +1,114 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/optional" + 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" +) + +// CreateProject creates a new project for organization +func CreateProject(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/projects project createProject + // --- + // summary: Create a new project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: organization name that the project belongs to + // required: true + // type: string + // - name: body + // in: body + // description: Project data + // required: true + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateProjectOption) + + project := &project_model.Project{ + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.TemplateType), + CardType: project_model.CardType(form.CardType), + Type: project_model.TypeOrganization, + OwnerID: ctx.ContextUser.ID, + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) +} + +// GetProjects returns a list of projects that belong to an organization +func GetProjects(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/projects project getProjects + // --- + // summary: Get a list of projects + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: organization name that the project belongs to + // required: true + // type: string + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + + searchOptions := project_model.SearchOptions{ + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + OwnerID: ctx.ContextUser.ID, + Type: project_model.TypeOrganization, + } + + projects, err := db.Find[project_model.Project](ctx, &searchOptions) + + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) +} diff --git a/routers/api/v1/project/project.go b/routers/api/v1/project/project.go new file mode 100644 index 0000000000000..53827358f59ab --- /dev/null +++ b/routers/api/v1/project/project.go @@ -0,0 +1,208 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + 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/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetProject returns a project +func GetProject(ctx *context.APIContext) { + // swagger:operation GET /projects/{project_id} project getProject + // --- + // summary: Get a project + // produces: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":project_id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + issues := issues_model.IssueList{} + + for _, column := range columns { + if empty := issuesMap[column.ID]; len(empty) == 0 { + continue + } + issues = append(issues, issuesMap[column.ID]...) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "project": convert.ToProject(ctx, project), + "columns": convert.ToColumns(ctx, columns), + "issues": convert.ToAPIIssueList(ctx, ctx.Doer, issues), + }) +} + +// EditProject edits a project +func EditProject(ctx *context.APIContext) { + // swagger:operation PATCH /projects/{project_id} project editProject + // --- + // summary: Edit a project + // produces: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.EditProjectOption) + projectID := ctx.PathParamInt64(":project_id") + + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + project.Title = form.Title + project.Description = form.Content + project.CardType = project_model.CardType(form.CardType) + + if err = project_model.UpdateProject(ctx, project); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) +} + +// DeleteProject deletes a project +func DeleteProject(ctx *context.APIContext) { + // swagger:operation DELETE /projects/{project_id} project deleteProject + // --- + // summary: Delete a project + // description: Deletes a specific project for a given user and repository. + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + err := project_model.DeleteProjectByID(ctx, ctx.PathParamInt64(":project_id")) + + if err != nil { + ctx.ServerError("DeleteProjectByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.APIContext) { + // swagger:operation PATCH /projects/{project_id}/{action} project changeProjectStatus + // --- + // summary: Change the status of a project + // produces: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // - name: action + // in: path + // description: action to perform (open or close) + // required: true + // type: string + // enum: + // - open + // - close + // responses: + // "200": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + var toClose bool + switch ctx.PathParam(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.NotFound("ChangeProjectStatus", nil) + return + } + id := ctx.PathParamInt64(":project_id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + + project, err := project_model.GetProjectByID(ctx, id) + + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) +} diff --git a/routers/api/v1/project/project_column.go b/routers/api/v1/project/project_column.go new file mode 100644 index 0000000000000..088a9ede3148d --- /dev/null +++ b/routers/api/v1/project/project_column.go @@ -0,0 +1,425 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "encoding/json" + "errors" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + 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/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetProjectColumn returns a project column +func GetProjectColumn(ctx *context.APIContext) { + // swagger:operation GET /projects/columns/{column_id} project getProject + // --- + // summary: Get a project column + // produces: + // - application/json + // parameters: + // - name: column_id + // in: path + // description: column ID + // required: true + // type: integer + // responses: + // "200": + // "$ref": "#/responses/Column" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":column_id")) + + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + ctx.JSON(http.StatusOK, convert.ToColumn(ctx, column)) +} + +// GetProjectColumns returns a list of project columns +func GetProjectColumns(ctx *context.APIContext) { + // swagger:operation GET /projects/{project_id}/columns project getProject + // --- + // summary: Get a list of project columns + // produces: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ColumnList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":project_id")) + + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + columns, err := project.GetColumns(ctx) + + if err != nil { + ctx.ServerError("GetColumnsByProjectID", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToColumns(ctx, columns)) +} + +// AddColumnToProject adds a new column to a project +func AddColumnToProject(ctx *context.APIContext) { + // swagger:operation POST /projects/{project_id}/columns project addColumnToProject + // --- + // summary: Add a column to a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // - name: body + // in: body + // description: column data + // required: true + // schema: + // "$ref": "#/definitions/CreateProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/Column" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + var project *project_model.Project + + projectID := ctx.PathParamInt64(":project_id") + + project, err := project_model.GetProjectByID(ctx, projectID) + + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + column := &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Sorting: form.Sorting, + Color: form.Color, + CreatorID: ctx.Doer.ID, + } + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) +} + +// EditProjectColumn edits a project column +func EditProjectColumn(ctx *context.APIContext) { + // swagger:operation PATCH /projects/columns/{column_id} project editProjectColumn + // --- + // summary: Edit a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: column_id + // in: path + // description: column ID + // required: true + // type: integer + // - name: body + // in: body + // description: column data + // required: true + // schema: + // "$ref": "#/definitions/EditProjectColumnOption" + // responses: + // "200": + // "$ref": "#/responses/Column" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":column_id")) + + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToColumn(ctx, column)) +} + +// DeleteProjectColumn deletes a project column +func DeleteProjectColumn(ctx *context.APIContext) { + // swagger:operation DELETE /projects/columns/{column_id} project deleteProjectColumn + // --- + // summary: Delete a project column + // parameters: + // - name: column_id + // in: path + // description: column ID + // required: true + // type: integer + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":column_id")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// SetDefaultProjectColumn set default column for issues/pulls +func SetDefaultProjectColumn(ctx *context.APIContext) { + // swagger:operation PUT /projects/columns/{column_id}/default project setDefaultProjectColumn + // --- + // summary: Set default column for issues/pulls + // parameters: + // - name: column_id + // in: path + // description: column ID + // required: true + // type: integer + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":column_id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if err := project_model.SetDefaultColumn(ctx, column.ProjectID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.APIContext) { + // swagger:operation PATCH /projects/{project_id}/columns/move project moveColumns + // --- + // summary: Move columns in a project + // consumes: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // - name: body + // in: body + // description: columns data + // required: true + // schema: + // "$ref": "#/definitions/MovedColumnsOption" + // responses: + // "200": + // "$ref": "#/responses/ColumnList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":project_id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + form := &api.MovedColumnsOption{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + columns, err := project.GetColumns(ctx) + + if err != nil { + ctx.ServerError("GetColumns", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToColumns(ctx, columns)) +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.APIContext) { + // swagger:operation PATCH /projects/{project_id}/columns/{column_id}/move project moveIssues + // --- + // summary: Move issues in a column + // consumes: + // - application/json + // parameters: + // - name: project_id + // in: path + // description: project ID + // required: true + // type: integer + // - name: column_id + // in: path + // description: column ID + // required: true + // type: integer + // - name: body + // in: body + // description: issues data + // required: true + // schema: + // "$ref": "#/definitions/MovedIssuesOption" + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":project_id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":column_id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + form := &api.MovedIssuesOption{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, movedIssues)) +} diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go new file mode 100644 index 0000000000000..4e37ce9300ffa --- /dev/null +++ b/routers/api/v1/repo/project.go @@ -0,0 +1,242 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetProjects returns a list of projects for a given user and repository. +func GetProjects(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{reponame}/projects project getProjects + // --- + // summary: Get a list of projects + // description: Returns a list of projects for a given user and repository. + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the project + // required: true + // type: string + // - name: reponame + // in: path + // description: repository name. + // required: true + // type: string + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + + searchOptions := project_model.SearchOptions{ + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + RepoID: ctx.Repo.Repository.ID, + Type: project_model.TypeRepository, + } + + projects, err := db.Find[project_model.Project](ctx, &searchOptions) + + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) +} + +// CreateProject creates a new project +func CreateProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{reponame}/projects project createProject + // --- + // summary: Create a new project + // description: Creates a new project for a given user and repository. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the project + // required: true + // type: string + // - name: reponame + // in: path + // description: repository name. + // required: true + // type: string + // - name: body + // in: body + // description: Project data + // required: true + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateProjectOption) + + project := &project_model.Project{ + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.TemplateType), + CardType: project_model.CardType(form.CardType), + Type: project_model.TypeRepository, + RepoID: ctx.Repo.Repository.ID, + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) +} + +// UpdateIssueProject change an issue's project to another project in a repository +func UpdateIssueProject(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{reponame}/projects/{type} project updateIssueProject + // --- + // summary: Change an issue's project + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the project + // required: true + // type: string + // - name: reponame + // in: path + // description: repository name. + // required: true + // type: string + // - name: type + // in: path + // description: issue type (issues or pulls) + // required: true + // type: string + // enum: + // - issues + // - pulls + // - name: body + // in: body + // description: issues data + // required: true + // schema: + // "$ref": "#/definitions/UpdateIssuesOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := &api.UpdateIssuesOption{} + + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issues := getActionIssues(ctx, form.Issues) + if ctx.Written() { + return + } + + if err := issues.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + + projectID := form.ProjectID + for _, issue := range issues { + if issue.Project != nil && issue.Project.ID == projectID { + continue + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { + continue + } + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + + ctx.Status(http.StatusNoContent) +} + +func getActionIssues(ctx *context.APIContext, issuesIDs []int64) issues_model.IssueList { + + if len(issuesIDs) == 0 { + return nil + } + + issues, err := issues_model.GetIssuesByIDs(ctx, issuesIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + for _, issue := range issues { + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + return nil + } + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} diff --git a/routers/api/v1/shared/project.go b/routers/api/v1/shared/project.go deleted file mode 100644 index 31c2e34c98f53..0000000000000 --- a/routers/api/v1/shared/project.go +++ /dev/null @@ -1,1122 +0,0 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package shared - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - - "code.gitea.io/gitea/models/db" - issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/perm" - project_model "code.gitea.io/gitea/models/project" - "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/optional" - api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/convert" -) - -var errInvalidModelType = errors.New("invalid model type") - -func checkModelType(model string) error { - if model != "repo" && model != "org" { - return errInvalidModelType - } - return nil -} - -// ProjectHandler is a handler for project actions -func ProjectHandler(model string, fn func(ctx *context.APIContext, model string)) func(ctx *context.APIContext) { - return func(ctx *context.APIContext) { - fn(ctx, model) - } -} - -// CreateProject creates a new project -func CreateProject(ctx *context.APIContext, model string) { - // swagger:operation POST /{username}/{repo}/projects project createProject - // --- - // summary: Create a new project - // description: Creates a new project for a given user and repository. - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the project will be created for the user - // required: true - // type: string - // - name: body - // in: body - // description: Project data - // required: true - // schema: - // "$ref": "#/definitions/CreateProjectOption" - // responses: - // "201": - // "$ref": "#/responses/Project" - // "403": - // "$ref": "#/responses/forbidden" - // "412": - // "$ref": "#/responses/error" - // "422": - // "$ref": "#/responses/validationError" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "CreateProject", err) - return - } - - form := web.GetForm(ctx).(*api.CreateProjectOption) - - project := &project_model.Project{ - Title: form.Title, - Description: form.Content, - CreatorID: ctx.Doer.ID, - TemplateType: project_model.TemplateType(form.TemplateType), - CardType: project_model.CardType(form.CardType), - } - - if model == "repo" { - project.Type = project_model.TypeRepository - project.RepoID = ctx.Repo.Repository.ID - } else { - if ctx.ContextUser.IsOrganization() { - project.Type = project_model.TypeOrganization - } else { - project.Type = project_model.TypeIndividual - } - project.OwnerID = ctx.ContextUser.ID - } - - if err := project_model.NewProject(ctx, project); err != nil { - ctx.Error(http.StatusInternalServerError, "NewProject", err) - return - } - - ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) -} - -// GetProjects returns a list of projects -func GetProjects(ctx *context.APIContext, model string) { - // swagger:operation GET /{username}/{repo}/projects project getProjects - // --- - // summary: Get a list of projects - // description: Returns a list of projects for a given user and repository. - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the projects will be returned for the user - // required: true - // type: string - // responses: - // "200": - // "$ref": "#/responses/ProjectList" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetProjects", err) - return - } - - sortType := ctx.FormTrim("sort") - - isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" - - searchOptions := project_model.SearchOptions{ - IsClosed: optional.Some(isShowClosed), - OrderBy: project_model.GetSearchOrderByBySortType(sortType), - } - - if model == "repo" { - repo := ctx.Repo.Repository - searchOptions.RepoID = repo.ID - searchOptions.Type = project_model.TypeRepository - } else { - searchOptions.OwnerID = ctx.ContextUser.ID - - if ctx.ContextUser.IsOrganization() { - searchOptions.Type = project_model.TypeOrganization - } else { - searchOptions.Type = project_model.TypeIndividual - } - } - - projects, err := db.Find[project_model.Project](ctx, &searchOptions) - - if err != nil { - ctx.ServerError("FindProjects", err) - return - } - - ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) -} - -// GetProject returns a project -func GetProject(ctx *context.APIContext, model string) { - // swagger:operation GET /{username}/{repo}/projects/{id} project getProject - // --- - // summary: Get a project - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the project will be returned for the user - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // responses: - // "200": - // "$ref": "#/responses/Project" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetProject", err) - return - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - - columns, err := project.GetColumns(ctx) - if err != nil { - ctx.ServerError("GetProjectColumns", err) - return - } - - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) - if err != nil { - ctx.ServerError("LoadIssuesOfColumns", err) - return - } - - issues := issues_model.IssueList{} - - for _, column := range columns { - if empty := issuesMap[column.ID]; len(empty) == 0 { - continue - } - issues = append(issues, issuesMap[column.ID]...) - } - - ctx.JSON(http.StatusOK, map[string]any{ - "project": convert.ToProject(ctx, project), - "columns": convert.ToColumns(ctx, columns), - "issues": convert.ToAPIIssueList(ctx, ctx.Doer, issues), - }) -} - -// EditProject edits a project -func EditProject(ctx *context.APIContext, model string) { - // swagger:operation PUT /{username}/{repo}/projects/{id} project editProject - // --- - // summary: Edit a project - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the project will be edited for the user - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // responses: - // "201": - // "$ref": "#/responses/Project" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "412": - // "$ref": "#/responses/error" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "EditProject", err) - return - } - - form := web.GetForm(ctx).(*api.CreateProjectOption) - projectID := ctx.PathParamInt64(":id") - - project, err := project_model.GetProjectByID(ctx, projectID) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - - project.Title = form.Title - project.Description = form.Content - project.CardType = project_model.CardType(form.CardType) - - if err = project_model.UpdateProject(ctx, project); err != nil { - ctx.ServerError("UpdateProjects", err) - return - } - - ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) -} - -// DeleteProject deletes a project -func DeleteProject(ctx *context.APIContext, model string) { - // swagger:operation DELETE /{username}/{repo}/projects/{id} project deleteProject - // --- - // summary: Delete a project - // description: Deletes a specific project for a given user and repository. - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the project will be deleted for the user - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "DeleteProject", err) - return - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - - err = project_model.DeleteProjectByID(ctx, project.ID) - - if err != nil { - ctx.ServerError("DeleteProjectByID", err) - return - } - - ctx.Status(http.StatusNoContent) -} - -// ChangeProjectStatus updates the status of a project between "open" and "close" -func ChangeProjectStatus(ctx *context.APIContext) { - // swagger:operation POST /{username}/{repo}/projects/{id}/{action} project changeProjectStatus - // --- - // summary: Change the status of a project - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the project status will be changed for the user - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: action - // in: path - // description: action to perform (open or close) - // required: true - // type: string - // enum: - // - open - // - close - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - var toClose bool - switch ctx.PathParam(":action") { - case "open": - toClose = false - case "close": - toClose = true - default: - ctx.NotFound("ChangeProjectStatus", nil) - return - } - id := ctx.PathParamInt64(":id") - - if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { - ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) - return - } - ctx.Status(http.StatusNoContent) -} - -// AddColumnToProject adds a new column to a project -func AddColumnToProject(ctx *context.APIContext, model string) { - // swagger:operation POST /{username}/{repo}/projects/{id} project addColumnToProject - // --- - // summary: Add a column to a project - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the column will be added to the user's project - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: body - // in: body - // description: column data - // required: true - // schema: - // "$ref": "#/definitions/CreateProjectColumnOption" - // responses: - // "201": - // "$ref": "#/responses/Column" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "412": - // "$ref": "#/responses/error" - // "422": - // "$ref": "#/responses/validationError" - // "423": - // "$ref": "#/responses/repoArchivedError" - - var err error - err = checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "AddColumnToProject", err) - return - } - - if model == "repo" { - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return - } - } - - var project *project_model.Project - if model == "repo" { - project, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":id")) - } else { - project, err = project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - } - - if err != nil { - ctx.NotFoundOrServerError("GetProjectForRepoByID", project_model.IsErrProjectNotExist, err) - return - } - - form := web.GetForm(ctx).(*api.EditProjectColumnOption) - column := &project_model.Column{ - ProjectID: project.ID, - Title: form.Title, - Sorting: form.Sorting, - Color: form.Color, - CreatorID: ctx.Doer.ID, - } - if err := project_model.NewColumn(ctx, column); err != nil { - ctx.ServerError("NewProjectColumn", err) - return - } - - ctx.JSON(http.StatusCreated, convert.ToColumn(ctx, column)) -} - -func checkProjectColumnChangePermissions(ctx *context.APIContext, model string) (*project_model.Project, *project_model.Column) { - if ctx.Doer == nil { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in users are allowed to perform this action.", - }) - return nil, nil - } - - if model == "repo" { - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return nil, nil - } - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return nil, nil - } - - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) - if err != nil { - ctx.ServerError("GetProjectColumn", err) - return nil, nil - } - if column.ProjectID != ctx.PathParamInt64(":id") { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), - }) - return nil, nil - } - - if model == "repo" { - if project.RepoID != ctx.Repo.Repository.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), - }) - return nil, nil - } - } else { - if project.OwnerID != ctx.ContextUser.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), - }) - return nil, nil - } - } - return project, column -} - -// EditProjectColumn allows a project column's to be updated -func EditProjectColumn(ctx *context.APIContext, model string) { - // swagger:operation PUT /{username}/{repo}/projects/{id}/{column-id} project editProjectColumn - // --- - // summary: Edit a project column - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the column will be edited for the user's project - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: column-id - // in: path - // description: column ID - // required: true - // type: integer - // - name: body - // in: body - // description: column data - // required: true - // schema: - // "$ref": "#/definitions/EditProjectColumnOption" - // responses: - // "201": - // "$ref": "#/responses/Column" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "412": - // "$ref": "#/responses/error" - // "422": - // "$ref": "#/responses/validationError" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "EditProjectColumn", err) - return - } - - form := web.GetForm(ctx).(*api.EditProjectColumnOption) - _, column := checkProjectColumnChangePermissions(ctx, model) - if ctx.Written() { - return - } - - if form.Title != "" { - column.Title = form.Title - } - column.Color = form.Color - if form.Sorting != 0 { - column.Sorting = form.Sorting - } - - if err := project_model.UpdateColumn(ctx, column); err != nil { - ctx.ServerError("UpdateProjectColumn", err) - return - } - - ctx.JSON(http.StatusOK, convert.ToColumn(ctx, column)) -} - -// DeleteProjectColumn allows for the deletion of a project column -func DeleteProjectColumn(ctx *context.APIContext, model string) { - // swagger:operation DELETE /{username}/{repo}/projects/{id}/{column-id} project deleteProjectColumn - // --- - // summary: Delete a project column - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the column will be deleted for the user's project - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: column-id - // in: path - // description: column ID - // required: true - // type: integer - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "DeleteProjectColumn", err) - return - } - - if ctx.Doer == nil { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in users are allowed to perform this action.", - }) - return - } - - if model == "repo" { - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return - } - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - - pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) - if err != nil { - ctx.ServerError("GetProjectColumn", err) - return - } - if pb.ProjectID != ctx.PathParamInt64(":id") { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), - }) - return - } - - if model == "repo" { - if project.RepoID != ctx.Repo.Repository.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), - }) - return - } - } else { - if project.OwnerID != ctx.ContextUser.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), - }) - return - } - } - - if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":columnID")); err != nil { - ctx.ServerError("DeleteProjectColumnByID", err) - return - } - - ctx.Status(http.StatusNoContent) -} - -// SetDefaultProjectColumn set default column for issues/pulls -func SetDefaultProjectColumn(ctx *context.APIContext, model string) { - // swagger:operation POST /{username}/{repo}/projects/{id}/{column-id}/default project setDefaultProjectColumn - // --- - // summary: Set default column for issues/pulls - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the column will be set as default for the user's project - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: column-id - // in: path - // description: column ID - // required: true - // type: integer - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "SetDefaultProjectColumn", err) - return - } - - project, column := checkProjectColumnChangePermissions(ctx, model) - if ctx.Written() { - return - } - - if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { - ctx.ServerError("SetDefaultColumn", err) - return - } - - ctx.Status(http.StatusNoContent) -} - -// MoveColumns moves or keeps columns in a project and sorts them inside that project -func MoveColumns(ctx *context.APIContext) { - // swagger:operation PUT /{username}/{repo}/projects/{id}/move project moveColumns - // --- - // summary: Move columns in a project - // consumes: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. If left '-', the columns will be moved for the user's project - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: body - // in: body - // description: columns data - // required: true - // schema: - // "$ref": "#/definitions/MovedColumnsOption" - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { - ctx.NotFound("CanBeAccessedByOwnerRepo", nil) - return - } - - form := &api.MovedColumnsOption{} - if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedColumnsForm", err) - return - } - - sortedColumnIDs := make(map[int64]int64) - for _, column := range form.Columns { - sortedColumnIDs[column.Sorting] = column.ColumnID - } - - if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { - ctx.ServerError("MoveColumnsOnProject", err) - return - } - - ctx.Status(http.StatusNoContent) -} - -// MoveIssues moves or keeps issues in a column and sorts them inside that column -func MoveIssues(ctx *context.APIContext, model string) { - // swagger:operation POST /{username}/{repo}/projects/{id}/{column-id}/move project moveIssues - // --- - // summary: Move issues in a column - // consumes: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. - // required: true - // type: string - // - name: id - // in: path - // description: project ID - // required: true - // type: integer - // - name: column-id - // in: path - // description: column ID - // required: true - // type: integer - // - name: body - // in: body - // description: issues data - // required: true - // schema: - // "$ref": "#/definitions/MovedIssuesOption" - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - err := checkModelType(model) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "MoveIssues", err) - return - } - - if ctx.Doer == nil { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in users are allowed to perform this action.", - }) - return - } - - if model == "repo" { - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only authorized users are allowed to perform this action.", - }) - return - } - } - - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) - return - } - - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) - if err != nil { - ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) - return - } - - if column.ProjectID != project.ID { - ctx.NotFound("ColumnNotInProject", nil) - return - } - - form := &api.MovedIssuesOption{} - if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedIssuesForm", err) - return - } - - issueIDs := make([]int64, 0, len(form.Issues)) - sortedIssueIDs := make(map[int64]int64) - for _, issue := range form.Issues { - issueIDs = append(issueIDs, issue.IssueID) - sortedIssueIDs[issue.Sorting] = issue.IssueID - } - movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) - if err != nil { - ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) - return - } - - if len(movedIssues) != len(form.Issues) { - ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) - return - } - - if _, err = movedIssues.LoadRepositories(ctx); err != nil { - ctx.ServerError("LoadRepositories", err) - return - } - - for _, issue := range movedIssues { - if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { - ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) - return - } - } - - if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { - ctx.ServerError("MoveIssuesOnProjectColumn", err) - return - } - - ctx.Status(http.StatusNoContent) -} - -func getActionIssues(ctx *context.APIContext, issuesIDs []int64) issues_model.IssueList { - - if len(issuesIDs) == 0 { - return nil - } - - issues, err := issues_model.GetIssuesByIDs(ctx, issuesIDs) - if err != nil { - ctx.ServerError("GetIssuesByIDs", err) - return nil - } - - issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) - prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) - for _, issue := range issues { - if issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) - return nil - } - if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { - ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) - return nil - } - if err = issue.LoadAttributes(ctx); err != nil { - ctx.ServerError("LoadAttributes", err) - return nil - } - } - return issues -} - -// UpdateIssueProject change an issue's project -func UpdateIssueProject(ctx *context.APIContext) { - // swagger:operation POST /{username}/{repo}/{type}/projects project updateIssueProject - // --- - // summary: Change an issue's project - // consumes: - // - application/json - // parameters: - // - name: username - // in: path - // description: owner of the project - // required: true - // type: string - // - name: repo - // in: path - // description: repository name. - // required: true - // type: string - // - name: type - // in: path - // description: issue type (issues or pulls) - // required: true - // type: string - // enum: - // - issues - // - pulls - // - name: body - // in: body - // description: issues data - // required: true - // schema: - // "$ref": "#/definitions/UpdateIssuesOption" - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" - - form := &api.UpdateIssuesOption{} - - if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.ServerError("DecodeMovedIssuesForm", err) - return - } - - issues := getActionIssues(ctx, form.Issues) - if ctx.Written() { - return - } - - if err := issues.LoadProjects(ctx); err != nil { - ctx.ServerError("LoadProjects", err) - return - } - if _, err := issues.LoadRepositories(ctx); err != nil { - ctx.ServerError("LoadProjects", err) - return - } - - projectID := form.ProjectID - for _, issue := range issues { - if issue.Project != nil && issue.Project.ID == projectID { - continue - } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { - if errors.Is(err, util.ErrPermissionDenied) { - continue - } - ctx.ServerError("IssueAssignOrRemoveProject", err) - return - } - } - - ctx.Status(http.StatusNoContent) -} diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go index 26a6743e68bb9..c21bb532d818e 100644 --- a/routers/api/v1/swagger/project.go +++ b/routers/api/v1/swagger/project.go @@ -27,3 +27,10 @@ type swaggerResponseColumn struct { // in:body Body api.Column `json:"body"` } + +// ColumnList +// swagger:response ColumnList +type swaggerResponseColumnList struct { + // in:body + Body []api.Column `json:"body"` +} diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go new file mode 100644 index 0000000000000..533199dbb6b7e --- /dev/null +++ b/routers/api/v1/user/project.go @@ -0,0 +1,109 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/optional" + 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" +) + +// CreateProject creates a new project for a user +func CreateProject(ctx *context.APIContext) { + // swagger:operation POST /user/projects project createProject + // --- + // summary: Create a new project for user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // description: Project data + // required: true + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateProjectOption) + + project := &project_model.Project{ + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.TemplateType), + CardType: project_model.CardType(form.CardType), + Type: project_model.TypeIndividual, + OwnerID: ctx.ContextUser.ID, + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.Error(http.StatusInternalServerError, "NewProject", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) +} + +// GetProjects returns a list of projects that belong to a user +func GetProjects(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/projects project getProjects + // --- + // summary: Get a list of projects + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: owner of the project + // required: true + // type: string + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + + searchOptions := project_model.SearchOptions{ + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + OwnerID: ctx.ContextUser.ID, + Type: project_model.TypeIndividual, + } + + projects, err := db.Find[project_model.Project](ctx, &searchOptions) + + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index beba3296f8a99..872561ca8d493 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2831,6 +2831,89 @@ } } }, + "/orgs/{org}/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get a list of projects", + "operationId": "getProjects", + "parameters": [ + { + "type": "string", + "description": "organization name that the project belongs to", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new project", + "operationId": "createProject", + "parameters": [ + { + "type": "string", + "description": "organization name that the project belongs to", + "name": "org", + "in": "path", + "required": true + }, + { + "description": "Project data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -3432,132 +3515,71 @@ } } }, - "/repos/issues/search": { + "/projects/columns/{column_id}": { "get": { "produces": [ "application/json" ], "tags": [ - "issue" + "project" ], - "summary": "Search for issues across the repositories that the user has access to", - "operationId": "issueSearchIssues", + "summary": "Get a project column", + "operationId": "getProject", "parameters": [ - { - "type": "string", - "description": "whether issue is open or closed", - "name": "state", - "in": "query" - }, - { - "type": "string", - "description": "comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded", - "name": "labels", - "in": "query" - }, - { - "type": "string", - "description": "comma separated list of milestone names. Fetch only issues that have any of this milestones. Non existent are discarded", - "name": "milestones", - "in": "query" - }, - { - "type": "string", - "description": "search string", - "name": "q", - "in": "query" - }, { "type": "integer", - "format": "int64", - "description": "repository to prioritize in the results", - "name": "priority_repo_id", - "in": "query" - }, - { - "type": "string", - "description": "filter by type (issues / pulls) if set", - "name": "type", - "in": "query" - }, - { - "type": "string", - "format": "date-time", - "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format", - "name": "since", - "in": "query" - }, - { - "type": "string", - "format": "date-time", - "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format", - "name": "before", - "in": "query" - }, - { - "type": "boolean", - "description": "filter (issues / pulls) assigned to you, default is false", - "name": "assigned", - "in": "query" - }, - { - "type": "boolean", - "description": "filter (issues / pulls) created by you, default is false", - "name": "created", - "in": "query" - }, - { - "type": "boolean", - "description": "filter (issues / pulls) mentioning you, default is false", - "name": "mentioned", - "in": "query" - }, - { - "type": "boolean", - "description": "filter pulls requesting your review, default is false", - "name": "review_requested", - "in": "query" - }, - { - "type": "boolean", - "description": "filter pulls reviewed by you, default is false", - "name": "reviewed", - "in": "query" - }, - { - "type": "string", - "description": "filter by owner", - "name": "owner", - "in": "query" + "description": "column ID", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Column" }, - { - "type": "string", - "description": "filter by team (requires organization owner parameter to be provided)", - "name": "team", - "in": "query" + "403": { + "$ref": "#/responses/forbidden" }, - { - "type": "integer", - "description": "page number of results to return (1-based)", - "name": "page", - "in": "query" + "404": { + "$ref": "#/responses/notFound" }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "tags": [ + "project" + ], + "summary": "Delete a project column", + "operationId": "deleteProjectColumn", + "parameters": [ { "type": "integer", - "description": "page size of results", - "name": "limit", - "in": "query" + "description": "column ID", + "name": "column_id", + "in": "path", + "required": true } ], "responses": { - "200": { - "$ref": "#/responses/IssueList" + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" } } - } - }, - "/repos/migrate": { - "post": { + }, + "patch": { "consumes": [ "application/json" ], @@ -3565,28 +3587,566 @@ "application/json" ], "tags": [ - "repository" + "project" ], - "summary": "Migrate a remote git repository", - "operationId": "repoMigrate", + "summary": "Edit a project column", + "operationId": "editProjectColumn", "parameters": [ { + "type": "integer", + "description": "column ID", + "name": "column_id", + "in": "path", + "required": true + }, + { + "description": "column data", "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/MigrateRepoOptions" + "$ref": "#/definitions/EditProjectColumnOption" } } ], "responses": { - "201": { - "$ref": "#/responses/Repository" + "200": { + "$ref": "#/responses/Column" }, "403": { "$ref": "#/responses/forbidden" }, - "409": { - "description": "The repository with the same name already exists." + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/projects/columns/{column_id}/default": { + "put": { + "tags": [ + "project" + ], + "summary": "Set default column for issues/pulls", + "operationId": "setDefaultProjectColumn", + "parameters": [ + { + "type": "integer", + "description": "column ID", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/projects/{project_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get a project", + "operationId": "getProject", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "description": "Deletes a specific project for a given user and repository.", + "tags": [ + "project" + ], + "summary": "Delete a project", + "operationId": "deleteProject", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Edit a project", + "operationId": "editProject", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/projects/{project_id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get a list of project columns", + "operationId": "getProject", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ColumnList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Add a column to a project", + "operationId": "addColumnToProject", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + }, + { + "description": "column data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Column" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/projects/{project_id}/columns/move": { + "patch": { + "consumes": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Move columns in a project", + "operationId": "moveColumns", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + }, + { + "description": "columns data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MovedColumnsOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ColumnList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/projects/{project_id}/columns/{column_id}/move": { + "patch": { + "consumes": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Move issues in a column", + "operationId": "moveIssues", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "column ID", + "name": "column_id", + "in": "path", + "required": true + }, + { + "description": "issues data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MovedIssuesOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/projects/{project_id}/{action}": { + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Change the status of a project", + "operationId": "changeProjectStatus", + "parameters": [ + { + "type": "integer", + "description": "project ID", + "name": "project_id", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "close" + ], + "type": "string", + "description": "action to perform (open or close)", + "name": "action", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/issues/search": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Search for issues across the repositories that the user has access to", + "operationId": "issueSearchIssues", + "parameters": [ + { + "type": "string", + "description": "whether issue is open or closed", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded", + "name": "labels", + "in": "query" + }, + { + "type": "string", + "description": "comma separated list of milestone names. Fetch only issues that have any of this milestones. Non existent are discarded", + "name": "milestones", + "in": "query" + }, + { + "type": "string", + "description": "search string", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "repository to prioritize in the results", + "name": "priority_repo_id", + "in": "query" + }, + { + "type": "string", + "description": "filter by type (issues / pulls) if set", + "name": "type", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format", + "name": "since", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format", + "name": "before", + "in": "query" + }, + { + "type": "boolean", + "description": "filter (issues / pulls) assigned to you, default is false", + "name": "assigned", + "in": "query" + }, + { + "type": "boolean", + "description": "filter (issues / pulls) created by you, default is false", + "name": "created", + "in": "query" + }, + { + "type": "boolean", + "description": "filter (issues / pulls) mentioning you, default is false", + "name": "mentioned", + "in": "query" + }, + { + "type": "boolean", + "description": "filter pulls requesting your review, default is false", + "name": "review_requested", + "in": "query" + }, + { + "type": "boolean", + "description": "filter pulls reviewed by you, default is false", + "name": "reviewed", + "in": "query" + }, + { + "type": "string", + "description": "filter by owner", + "name": "owner", + "in": "query" + }, + { + "type": "string", + "description": "filter by team (requires organization owner parameter to be provided)", + "name": "team", + "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/IssueList" + } + } + } + }, + "/repos/migrate": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Migrate a remote git repository", + "operationId": "repoMigrate", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MigrateRepoOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Repository" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "409": { + "description": "The repository with the same name already exists." }, "422": { "$ref": "#/responses/validationError" @@ -3689,35 +4249,196 @@ }, { "type": "string", - "description": "sort repos by attribute. Supported values are \"alpha\", \"created\", \"updated\", \"size\", \"git_size\", \"lfs_size\", \"stars\", \"forks\" and \"id\". Default is \"alpha\"", - "name": "sort", - "in": "query" + "description": "sort repos by attribute. Supported values are \"alpha\", \"created\", \"updated\", \"size\", \"git_size\", \"lfs_size\", \"stars\", \"forks\" and \"id\". Default is \"alpha\"", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\", ignored if \"sort\" is not specified.", + "name": "order", + "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/SearchResults" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{reponame}/projects": { + "get": { + "description": "Returns a list of projects for a given user and repository.", + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get a list of projects", + "operationId": "getProjects", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name.", + "name": "reponame", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "post": { + "description": "Creates a new project for a given user and repository.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new project", + "operationId": "createProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repository name.", + "name": "reponame", + "in": "path", + "required": true + }, + { + "description": "Project data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{reponame}/projects/{type}": { + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Change an issue's project", + "operationId": "updateIssueProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "owner", + "in": "path", + "required": true }, { "type": "string", - "description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\", ignored if \"sort\" is not specified.", - "name": "order", - "in": "query" + "description": "repository name.", + "name": "reponame", + "in": "path", + "required": true }, { - "type": "integer", - "description": "page number of results to return (1-based)", - "name": "page", - "in": "query" + "enum": [ + "issues", + "pulls" + ], + "type": "string", + "description": "issue type (issues or pulls)", + "name": "type", + "in": "path", + "required": true }, { - "type": "integer", - "description": "page size of results", - "name": "limit", - "in": "query" + "description": "issues data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateIssuesOption" + } } ], "responses": { - "200": { - "$ref": "#/responses/SearchResults" + "204": { + "$ref": "#/responses/empty" }, - "422": { - "$ref": "#/responses/validationError" + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" } } } @@ -16105,208 +16826,14 @@ }, "400": { "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, - "/user/applications/oauth2": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "List the authenticated user's oauth2 applications", - "operationId": "userGetOauth2Application", - "parameters": [ - { - "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/OAuth2ApplicationList" - } - } - }, - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "creates a new OAuth2 application", - "operationId": "userCreateOAuth2Application", - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateOAuth2ApplicationOptions" - } - } - ], - "responses": { - "201": { - "$ref": "#/responses/OAuth2Application" - }, - "400": { - "$ref": "#/responses/error" - } - } - } - }, - "/user/applications/oauth2/{id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "get an OAuth2 Application", - "operationId": "userGetOAuth2Application", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "Application ID to be found", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/OAuth2Application" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "delete an OAuth2 Application", - "operationId": "userDeleteOAuth2Application", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "token to be deleted", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "patch": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "update an OAuth2 Application, this includes regenerating the client secret", - "operationId": "userUpdateOAuth2Application", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "application to be updated", - "name": "id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateOAuth2ApplicationOptions" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/OAuth2Application" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, - "/user/avatar": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Update Avatar", - "operationId": "userUpdateAvatar", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/UpdateUserAvatarOption" - } - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Delete Avatar", - "operationId": "userDeleteAvatar", - "responses": { - "204": { - "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" } } } }, - "/user/blocks": { + "/user/applications/oauth2": { "get": { "produces": [ "application/json" @@ -16314,8 +16841,8 @@ "tags": [ "user" ], - "summary": "List users blocked by the authenticated user", - "operationId": "userListBlocks", + "summary": "List the authenticated user's oauth2 applications", + "operationId": "userGetOauth2Application", "parameters": [ { "type": "integer", @@ -16332,80 +16859,83 @@ ], "responses": { "200": { - "$ref": "#/responses/UserList" + "$ref": "#/responses/OAuth2ApplicationList" } } - } - }, - "/user/blocks/{username}": { - "get": { + }, + "post": { + "produces": [ + "application/json" + ], "tags": [ "user" ], - "summary": "Check if a user is blocked by the authenticated user", - "operationId": "userCheckUserBlock", + "summary": "creates a new OAuth2 application", + "operationId": "userCreateOAuth2Application", "parameters": [ { - "type": "string", - "description": "user to check", - "name": "username", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateOAuth2ApplicationOptions" + } } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "201": { + "$ref": "#/responses/OAuth2Application" }, - "404": { - "$ref": "#/responses/notFound" + "400": { + "$ref": "#/responses/error" } } - }, - "put": { + } + }, + "/user/applications/oauth2/{id}": { + "get": { + "produces": [ + "application/json" + ], "tags": [ "user" ], - "summary": "Block a user", - "operationId": "userBlockUser", + "summary": "get an OAuth2 Application", + "operationId": "userGetOAuth2Application", "parameters": [ { - "type": "string", - "description": "user to block", - "name": "username", + "type": "integer", + "format": "int64", + "description": "Application ID to be found", + "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "optional note for the block", - "name": "note", - "in": "query" } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "200": { + "$ref": "#/responses/OAuth2Application" }, "404": { "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" } } }, "delete": { + "produces": [ + "application/json" + ], "tags": [ "user" ], - "summary": "Unblock a user", - "operationId": "userUnblockUser", + "summary": "delete an OAuth2 Application", + "operationId": "userDeleteOAuth2Application", "parameters": [ { - "type": "string", - "description": "user to unblock", - "name": "username", + "type": "integer", + "format": "int64", + "description": "token to be deleted", + "name": "id", "in": "path", "required": true } @@ -16416,116 +16946,88 @@ }, "404": { "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } - } - }, - "/user/emails": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "List the authenticated user's email addresses", - "operationId": "userListEmails", - "responses": { - "200": { - "$ref": "#/responses/EmailList" } } }, - "post": { + "patch": { "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Add email addresses", - "operationId": "userAddEmail", + "summary": "update an OAuth2 Application, this includes regenerating the client secret", + "operationId": "userUpdateOAuth2Application", "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "application to be updated", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/CreateEmailOption" + "$ref": "#/definitions/CreateOAuth2ApplicationOptions" } } ], "responses": { - "201": { - "$ref": "#/responses/EmailList" + "200": { + "$ref": "#/responses/OAuth2Application" }, - "422": { - "$ref": "#/responses/validationError" + "404": { + "$ref": "#/responses/notFound" } } - }, - "delete": { + } + }, + "/user/avatar": { + "post": { "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Delete email addresses", - "operationId": "userDeleteEmail", + "summary": "Update Avatar", + "operationId": "userUpdateAvatar", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/DeleteEmailOption" + "$ref": "#/definitions/UpdateUserAvatarOption" } } ], "responses": { "204": { "$ref": "#/responses/empty" - }, - "404": { - "$ref": "#/responses/notFound" } } - } - }, - "/user/followers": { - "get": { + }, + "delete": { "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "List the authenticated user's followers", - "operationId": "userCurrentListFollowers", - "parameters": [ - { - "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" - } - ], + "summary": "Delete Avatar", + "operationId": "userDeleteAvatar", "responses": { - "200": { - "$ref": "#/responses/UserList" + "204": { + "$ref": "#/responses/empty" } } } }, - "/user/following": { + "/user/blocks": { "get": { "produces": [ "application/json" @@ -16533,8 +17035,8 @@ "tags": [ "user" ], - "summary": "List the users that the authenticated user is following", - "operationId": "userCurrentListFollowing", + "summary": "List users blocked by the authenticated user", + "operationId": "userListBlocks", "parameters": [ { "type": "integer", @@ -16556,17 +17058,17 @@ } } }, - "/user/following/{username}": { + "/user/blocks/{username}": { "get": { "tags": [ "user" ], - "summary": "Check whether a user is followed by the authenticated user", - "operationId": "userCurrentCheckFollowing", + "summary": "Check if a user is blocked by the authenticated user", + "operationId": "userCheckUserBlock", "parameters": [ { "type": "string", - "description": "username of followed user", + "description": "user to check", "name": "username", "in": "path", "required": true @@ -16585,42 +17087,21 @@ "tags": [ "user" ], - "summary": "Follow a user", - "operationId": "userCurrentPutFollow", + "summary": "Block a user", + "operationId": "userBlockUser", "parameters": [ { "type": "string", - "description": "username of user to follow", + "description": "user to block", "name": "username", "in": "path", "required": true - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "delete": { - "tags": [ - "user" - ], - "summary": "Unfollow a user", - "operationId": "userCurrentDeleteFollow", - "parameters": [ { "type": "string", - "description": "username of user to unfollow", - "name": "username", - "in": "path", - "required": true + "description": "optional note for the block", + "name": "note", + "in": "query" } ], "responses": { @@ -16629,46 +17110,30 @@ }, "404": { "$ref": "#/responses/notFound" - } - } - } - }, - "/user/gpg_key_token": { - "get": { - "produces": [ - "text/plain" - ], - "tags": [ - "user" - ], - "summary": "Get a Token to verify", - "operationId": "getVerificationToken", - "responses": { - "200": { - "$ref": "#/responses/string" }, - "404": { - "$ref": "#/responses/notFound" + "422": { + "$ref": "#/responses/validationError" } } - } - }, - "/user/gpg_key_verify": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + }, + "delete": { "tags": [ "user" ], - "summary": "Verify a GPG key", - "operationId": "userVerifyGPGKey", + "summary": "Unblock a user", + "operationId": "userUnblockUser", + "parameters": [ + { + "type": "string", + "description": "user to unblock", + "name": "username", + "in": "path", + "required": true + } + ], "responses": { - "201": { - "$ref": "#/responses/GPGKey" + "204": { + "$ref": "#/responses/empty" }, "404": { "$ref": "#/responses/notFound" @@ -16679,7 +17144,7 @@ } } }, - "/user/gpg_keys": { + "/user/emails": { "get": { "produces": [ "application/json" @@ -16687,124 +17152,101 @@ "tags": [ "user" ], - "summary": "List the authenticated user's GPG keys", - "operationId": "userCurrentListGPGKeys", - "parameters": [ - { - "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" - } - ], + "summary": "List the authenticated user's email addresses", + "operationId": "userListEmails", "responses": { "200": { - "$ref": "#/responses/GPGKeyList" + "$ref": "#/responses/EmailList" } } }, "post": { - "consumes": [ - "application/json" - ], "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Create a GPG key", - "operationId": "userCurrentPostGPGKey", + "summary": "Add email addresses", + "operationId": "userAddEmail", "parameters": [ { - "name": "Form", + "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/CreateGPGKeyOption" + "$ref": "#/definitions/CreateEmailOption" } } ], "responses": { "201": { - "$ref": "#/responses/GPGKey" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/EmailList" }, "422": { "$ref": "#/responses/validationError" } } - } - }, - "/user/gpg_keys/{id}": { - "get": { + }, + "delete": { "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Get a GPG key", - "operationId": "userCurrentGetGPGKey", + "summary": "Delete email addresses", + "operationId": "userDeleteEmail", "parameters": [ { - "type": "integer", - "format": "int64", - "description": "id of key to get", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteEmailOption" + } } ], "responses": { - "200": { - "$ref": "#/responses/GPGKey" + "204": { + "$ref": "#/responses/empty" }, "404": { "$ref": "#/responses/notFound" } } - }, - "delete": { + } + }, + "/user/followers": { + "get": { "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Remove a GPG key", - "operationId": "userCurrentDeleteGPGKey", + "summary": "List the authenticated user's followers", + "operationId": "userCurrentListFollowers", "parameters": [ { "type": "integer", - "format": "int64", - "description": "id of key to delete", - "name": "id", - "in": "path", - "required": true + "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": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" + "200": { + "$ref": "#/responses/UserList" } } } }, - "/user/hooks": { + "/user/following": { "get": { "produces": [ "application/json" @@ -16812,8 +17254,8 @@ "tags": [ "user" ], - "summary": "List the authenticated user's webhooks", - "operationId": "userListHooks", + "summary": "List the users that the authenticated user is following", + "operationId": "userCurrentListFollowing", "parameters": [ { "type": "integer", @@ -16830,80 +17272,74 @@ ], "responses": { "200": { - "$ref": "#/responses/HookList" + "$ref": "#/responses/UserList" } } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + } + }, + "/user/following/{username}": { + "get": { "tags": [ "user" ], - "summary": "Create a hook", - "operationId": "userCreateHook", + "summary": "Check whether a user is followed by the authenticated user", + "operationId": "userCurrentCheckFollowing", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateHookOption" - } + "type": "string", + "description": "username of followed user", + "name": "username", + "in": "path", + "required": true } ], "responses": { - "201": { - "$ref": "#/responses/Hook" + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" } } - } - }, - "/user/hooks/{id}": { - "get": { - "produces": [ - "application/json" - ], + }, + "put": { "tags": [ "user" ], - "summary": "Get a hook", - "operationId": "userGetHook", + "summary": "Follow a user", + "operationId": "userCurrentPutFollow", "parameters": [ { - "type": "integer", - "format": "int64", - "description": "id of the hook to get", - "name": "id", + "type": "string", + "description": "username of user to follow", + "name": "username", "in": "path", "required": true } ], "responses": { - "200": { - "$ref": "#/responses/Hook" + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" } } }, "delete": { - "produces": [ - "application/json" - ], "tags": [ "user" ], - "summary": "Delete a hook", - "operationId": "userDeleteHook", + "summary": "Unfollow a user", + "operationId": "userCurrentDeleteFollow", "parameters": [ { - "type": "integer", - "format": "int64", - "description": "id of the hook to delete", - "name": "id", + "type": "string", + "description": "username of user to unfollow", + "name": "username", "in": "path", "required": true } @@ -16911,10 +17347,35 @@ "responses": { "204": { "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" } } - }, - "patch": { + } + }, + "/user/gpg_key_token": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "user" + ], + "summary": "Get a Token to verify", + "operationId": "getVerificationToken", + "responses": { + "200": { + "$ref": "#/responses/string" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/user/gpg_key_verify": { + "post": { "consumes": [ "application/json" ], @@ -16924,33 +17385,22 @@ "tags": [ "user" ], - "summary": "Update a hook", - "operationId": "userEditHook", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "id of the hook to update", - "name": "id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/EditHookOption" - } - } - ], + "summary": "Verify a GPG key", + "operationId": "userVerifyGPGKey", "responses": { - "200": { - "$ref": "#/responses/Hook" + "201": { + "$ref": "#/responses/GPGKey" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" } } } }, - "/user/keys": { + "/user/gpg_keys": { "get": { "produces": [ "application/json" @@ -16958,15 +17408,9 @@ "tags": [ "user" ], - "summary": "List the authenticated user's public keys", - "operationId": "userCurrentListKeys", + "summary": "List the authenticated user's GPG keys", + "operationId": "userCurrentListGPGKeys", "parameters": [ - { - "type": "string", - "description": "fingerprint of the key", - "name": "fingerprint", - "in": "query" - }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -16982,7 +17426,7 @@ ], "responses": { "200": { - "$ref": "#/responses/PublicKeyList" + "$ref": "#/responses/GPGKeyList" } } }, @@ -16996,20 +17440,23 @@ "tags": [ "user" ], - "summary": "Create a public key", - "operationId": "userCurrentPostKey", + "summary": "Create a GPG key", + "operationId": "userCurrentPostGPGKey", "parameters": [ { - "name": "body", + "name": "Form", "in": "body", "schema": { - "$ref": "#/definitions/CreateKeyOption" + "$ref": "#/definitions/CreateGPGKeyOption" } } ], "responses": { "201": { - "$ref": "#/responses/PublicKey" + "$ref": "#/responses/GPGKey" + }, + "404": { + "$ref": "#/responses/notFound" }, "422": { "$ref": "#/responses/validationError" @@ -17017,7 +17464,7 @@ } } }, - "/user/keys/{id}": { + "/user/gpg_keys/{id}": { "get": { "produces": [ "application/json" @@ -17025,8 +17472,8 @@ "tags": [ "user" ], - "summary": "Get a public key", - "operationId": "userCurrentGetKey", + "summary": "Get a GPG key", + "operationId": "userCurrentGetGPGKey", "parameters": [ { "type": "integer", @@ -17039,7 +17486,7 @@ ], "responses": { "200": { - "$ref": "#/responses/PublicKey" + "$ref": "#/responses/GPGKey" }, "404": { "$ref": "#/responses/notFound" @@ -17053,8 +17500,8 @@ "tags": [ "user" ], - "summary": "Delete a public key", - "operationId": "userCurrentDeleteKey", + "summary": "Remove a GPG key", + "operationId": "userCurrentDeleteGPGKey", "parameters": [ { "type": "integer", @@ -17078,41 +17525,7 @@ } } }, - "/user/orgs": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "List the current user's organizations", - "operationId": "orgListCurrentUserOrgs", - "parameters": [ - { - "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/OrganizationList" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, - "/user/repos": { + "/user/hooks": { "get": { "produces": [ "application/json" @@ -17120,8 +17533,8 @@ "tags": [ "user" ], - "summary": "List the repos that the authenticated user owns", - "operationId": "userCurrentListRepos", + "summary": "List the authenticated user's webhooks", + "operationId": "userListHooks", "parameters": [ { "type": "integer", @@ -17138,7 +17551,7 @@ ], "responses": { "200": { - "$ref": "#/responses/RepositoryList" + "$ref": "#/responses/HookList" } } }, @@ -17150,37 +17563,28 @@ "application/json" ], "tags": [ - "repository", "user" ], - "summary": "Create a repository", - "operationId": "createCurrentUserRepo", + "summary": "Create a hook", + "operationId": "userCreateHook", "parameters": [ { "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/CreateRepoOption" + "$ref": "#/definitions/CreateHookOption" } } ], "responses": { "201": { - "$ref": "#/responses/Repository" - }, - "400": { - "$ref": "#/responses/error" - }, - "409": { - "description": "The repository with the same name already exists." - }, - "422": { - "$ref": "#/responses/validationError" + "$ref": "#/responses/Hook" } } } }, - "/user/settings": { + "/user/hooks/{id}": { "get": { "produces": [ "application/json" @@ -17188,40 +17592,86 @@ "tags": [ "user" ], - "summary": "Get user settings", - "operationId": "getUserSettings", + "summary": "Get a hook", + "operationId": "userGetHook", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the hook to get", + "name": "id", + "in": "path", + "required": true + } + ], "responses": { "200": { - "$ref": "#/responses/UserSettings" + "$ref": "#/responses/Hook" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a hook", + "operationId": "userDeleteHook", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the hook to delete", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" } } }, "patch": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Update user settings", - "operationId": "updateUserSettings", + "summary": "Update a hook", + "operationId": "userEditHook", "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the hook to update", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/UserSettingsOptions" + "$ref": "#/definitions/EditHookOption" } } ], "responses": { "200": { - "$ref": "#/responses/UserSettings" + "$ref": "#/responses/Hook" } } } }, - "/user/starred": { + "/user/keys": { "get": { "produces": [ "application/json" @@ -17229,9 +17679,15 @@ "tags": [ "user" ], - "summary": "The repos that the authenticated user has starred", - "operationId": "userCurrentListStarred", + "summary": "List the authenticated user's public keys", + "operationId": "userCurrentListKeys", "parameters": [ + { + "type": "string", + "description": "fingerprint of the key", + "name": "fingerprint", + "in": "query" + }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -17247,71 +17703,64 @@ ], "responses": { "200": { - "$ref": "#/responses/RepositoryList" + "$ref": "#/responses/PublicKeyList" } } - } - }, - "/user/starred/{owner}/{repo}": { - "get": { + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ "user" ], - "summary": "Whether the authenticated is starring the repo", - "operationId": "userCurrentCheckStarring", + "summary": "Create a public key", + "operationId": "userCurrentPostKey", "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/CreateKeyOption" + } } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "201": { + "$ref": "#/responses/PublicKey" }, - "404": { - "$ref": "#/responses/notFound" + "422": { + "$ref": "#/responses/validationError" } } - }, - "put": { + } + }, + "/user/keys/{id}": { + "get": { + "produces": [ + "application/json" + ], "tags": [ "user" ], - "summary": "Star the given repo", - "operationId": "userCurrentPutStar", + "summary": "Get a public key", + "operationId": "userCurrentGetKey", "parameters": [ { - "type": "string", - "description": "owner of the repo to star", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo to star", - "name": "repo", + "type": "integer", + "format": "int64", + "description": "id of key to get", + "name": "id", "in": "path", "required": true } ], "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" + "200": { + "$ref": "#/responses/PublicKey" }, "404": { "$ref": "#/responses/notFound" @@ -17319,23 +17768,20 @@ } }, "delete": { + "produces": [ + "application/json" + ], "tags": [ "user" ], - "summary": "Unstar the given repo", - "operationId": "userCurrentDeleteStar", + "summary": "Delete a public key", + "operationId": "userCurrentDeleteKey", "parameters": [ { - "type": "string", - "description": "owner of the repo to unstar", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo to unstar", - "name": "repo", + "type": "integer", + "format": "int64", + "description": "id of key to delete", + "name": "id", "in": "path", "required": true } @@ -17344,25 +17790,25 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } } } }, - "/user/stopwatches": { + "/user/orgs": { "get": { - "consumes": [ - "application/json" - ], "produces": [ "application/json" ], "tags": [ - "user" + "organization" ], - "summary": "Get list of all existing stopwatches", - "operationId": "userGetStopWatches", + "summary": "List the current user's organizations", + "operationId": "orgListCurrentUserOrgs", "parameters": [ { "type": "integer", @@ -17379,43 +17825,58 @@ ], "responses": { "200": { - "$ref": "#/responses/StopWatchList" + "$ref": "#/responses/OrganizationList" + }, + "404": { + "$ref": "#/responses/notFound" } } } }, - "/user/subscriptions": { - "get": { + "/user/projects": { + "post": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "user" + "project" ], - "summary": "List repositories watched by the authenticated user", - "operationId": "userCurrentListSubscriptions", + "summary": "Create a new project for user", + "operationId": "createProject", "parameters": [ { - "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" + "description": "Project data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } } ], "responses": { - "200": { - "$ref": "#/responses/RepositoryList" + "201": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "412": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" } } } }, - "/user/teams": { + "/user/repos": { "get": { "produces": [ "application/json" @@ -17423,8 +17884,8 @@ "tags": [ "user" ], - "summary": "List all the teams a user belongs to", - "operationId": "userListTeams", + "summary": "List the repos that the authenticated user owns", + "operationId": "userCurrentListRepos", "parameters": [ { "type": "integer", @@ -17441,115 +17902,49 @@ ], "responses": { "200": { - "$ref": "#/responses/TeamList" + "$ref": "#/responses/RepositoryList" } } - } - }, - "/user/times": { - "get": { + }, + "post": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ + "repository", "user" ], - "summary": "List the current user's tracked times", - "operationId": "userCurrentTrackedTimes", + "summary": "Create a repository", + "operationId": "createCurrentUserRepo", "parameters": [ { - "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" - }, - { - "type": "string", - "format": "date-time", - "description": "Only show times updated after the given time. This is a timestamp in RFC 3339 format", - "name": "since", - "in": "query" - }, - { - "type": "string", - "format": "date-time", - "description": "Only show times updated before the given time. This is a timestamp in RFC 3339 format", - "name": "before", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateRepoOption" + } } ], "responses": { - "200": { - "$ref": "#/responses/TrackedTimeList" - } - } - } - }, - "/users/search": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Search for users", - "operationId": "userSearch", - "parameters": [ - { - "type": "string", - "description": "keyword", - "name": "q", - "in": "query" + "201": { + "$ref": "#/responses/Repository" }, - { - "type": "integer", - "format": "int64", - "description": "ID of the user to search for", - "name": "uid", - "in": "query" + "400": { + "$ref": "#/responses/error" }, - { - "type": "integer", - "description": "page number of results to return (1-based)", - "name": "page", - "in": "query" + "409": { + "description": "The repository with the same name already exists." }, - { - "type": "integer", - "description": "page size of results", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "SearchResults of a successful search", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/User" - } - }, - "ok": { - "type": "boolean" - } - } - } + "422": { + "$ref": "#/responses/validationError" } } } }, - "/users/{username}": { + "/user/settings": { "get": { "produces": [ "application/json" @@ -17557,82 +17952,40 @@ "tags": [ "user" ], - "summary": "Get a user", - "operationId": "userGet", - "parameters": [ - { - "type": "string", - "description": "username of user to get", - "name": "username", - "in": "path", - "required": true - } - ], + "summary": "Get user settings", + "operationId": "getUserSettings", "responses": { "200": { - "$ref": "#/responses/User" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/UserSettings" } } - } - }, - "/users/{username}/activities/feeds": { - "get": { + }, + "patch": { "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "List a user's activity feeds", - "operationId": "userListActivityFeeds", - "parameters": [ - { - "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true - }, - { - "type": "boolean", - "description": "if true, only show actions performed by the requested user", - "name": "only-performed-by", - "in": "query" - }, - { - "type": "string", - "format": "date", - "description": "the date of the activities to be found", - "name": "date", - "in": "query" - }, - { - "type": "integer", - "description": "page number of results to return (1-based)", - "name": "page", - "in": "query" - }, + "summary": "Update user settings", + "operationId": "updateUserSettings", + "parameters": [ { - "type": "integer", - "description": "page size of results", - "name": "limit", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UserSettingsOptions" + } } ], "responses": { "200": { - "$ref": "#/responses/ActivityFeedsList" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/UserSettings" } } } }, - "/users/{username}/followers": { + "/user/starred": { "get": { "produces": [ "application/json" @@ -17640,16 +17993,9 @@ "tags": [ "user" ], - "summary": "List the given user's followers", - "operationId": "userListFollowers", + "summary": "The repos that the authenticated user has starred", + "operationId": "userCurrentListStarred", "parameters": [ - { - "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true - }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -17665,74 +18011,95 @@ ], "responses": { "200": { - "$ref": "#/responses/UserList" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/RepositoryList" } } } }, - "/users/{username}/following": { + "/user/starred/{owner}/{repo}": { "get": { - "produces": [ - "application/json" - ], "tags": [ "user" ], - "summary": "List the users that the given user is following", - "operationId": "userListFollowing", + "summary": "Whether the authenticated is starring the repo", + "operationId": "userCurrentCheckStarring", "parameters": [ { "type": "string", - "description": "username of user", - "name": "username", + "description": "owner of the repo", + "name": "owner", "in": "path", "required": true }, { - "type": "integer", - "description": "page number of results to return (1-based)", - "name": "page", - "in": "query" + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Star the given repo", + "operationId": "userCurrentPutStar", + "parameters": [ + { + "type": "string", + "description": "owner of the repo to star", + "name": "owner", + "in": "path", + "required": true }, { - "type": "integer", - "description": "page size of results", - "name": "limit", - "in": "query" + "type": "string", + "description": "name of the repo to star", + "name": "repo", + "in": "path", + "required": true } ], "responses": { - "200": { - "$ref": "#/responses/UserList" + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" }, "404": { "$ref": "#/responses/notFound" } } - } - }, - "/users/{username}/following/{target}": { - "get": { + }, + "delete": { "tags": [ "user" ], - "summary": "Check if one user is following another user", - "operationId": "userCheckFollowing", + "summary": "Unstar the given repo", + "operationId": "userCurrentDeleteStar", "parameters": [ { "type": "string", - "description": "username of following user", - "name": "username", + "description": "owner of the repo to unstar", + "name": "owner", "in": "path", "required": true }, { "type": "string", - "description": "username of followed user", - "name": "target", + "description": "name of the repo to unstar", + "name": "repo", "in": "path", "required": true } @@ -17747,24 +18114,20 @@ } } }, - "/users/{username}/gpg_keys": { + "/user/stopwatches": { "get": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "List the given user's GPG keys", - "operationId": "userListGPGKeys", + "summary": "Get list of all existing stopwatches", + "operationId": "userGetStopWatches", "parameters": [ - { - "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true - }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -17780,15 +18143,12 @@ ], "responses": { "200": { - "$ref": "#/responses/GPGKeyList" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/StopWatchList" } } } }, - "/users/{username}/heatmap": { + "/user/subscriptions": { "get": { "produces": [ "application/json" @@ -17796,28 +18156,30 @@ "tags": [ "user" ], - "summary": "Get a user's heatmap", - "operationId": "userGetHeatmapData", + "summary": "List repositories watched by the authenticated user", + "operationId": "userCurrentListSubscriptions", "parameters": [ { - "type": "string", - "description": "username of user to get", - "name": "username", - "in": "path", - "required": true + "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/UserHeatmapData" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/RepositoryList" } } } }, - "/users/{username}/keys": { + "/user/teams": { "get": { "produces": [ "application/json" @@ -17825,22 +18187,9 @@ "tags": [ "user" ], - "summary": "List the given user's public keys", - "operationId": "userListKeys", + "summary": "List all the teams a user belongs to", + "operationId": "userListTeams", "parameters": [ - { - "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "fingerprint of the key", - "name": "fingerprint", - "in": "query" - }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -17856,32 +18205,22 @@ ], "responses": { "200": { - "$ref": "#/responses/PublicKeyList" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/TeamList" } } } }, - "/users/{username}/orgs": { + "/user/times": { "get": { "produces": [ "application/json" ], "tags": [ - "organization" + "user" ], - "summary": "List a user's organizations", - "operationId": "orgListUserOrgs", + "summary": "List the current user's tracked times", + "operationId": "userCurrentTrackedTimes", "parameters": [ - { - "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true - }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -17893,58 +18232,88 @@ "description": "page size of results", "name": "limit", "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show times updated after the given time. This is a timestamp in RFC 3339 format", + "name": "since", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show times updated before the given time. This is a timestamp in RFC 3339 format", + "name": "before", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/OrganizationList" - }, - "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/TrackedTimeList" } } } }, - "/users/{username}/orgs/{org}/permissions": { + "/users/search": { "get": { "produces": [ "application/json" ], "tags": [ - "organization" + "user" ], - "summary": "Get user permissions in organization", - "operationId": "orgGetUserPermissions", + "summary": "Search for users", + "operationId": "userSearch", "parameters": [ { "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true + "description": "keyword", + "name": "q", + "in": "query" }, { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true + "type": "integer", + "format": "int64", + "description": "ID of the user to search for", + "name": "uid", + "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/OrganizationPermissions" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" + "description": "SearchResults of a successful search", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + }, + "ok": { + "type": "boolean" + } + } + } } } } }, - "/users/{username}/repos": { + "/users/{username}": { "get": { "produces": [ "application/json" @@ -17952,32 +18321,20 @@ "tags": [ "user" ], - "summary": "List the repos owned by the given user", - "operationId": "userListRepos", + "summary": "Get a user", + "operationId": "userGet", "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of user to get", "name": "username", "in": "path", "required": true - }, - { - "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/RepositoryList" + "$ref": "#/responses/User" }, "404": { "$ref": "#/responses/notFound" @@ -17985,7 +18342,7 @@ } } }, - "/users/{username}/starred": { + "/users/{username}/activities/feeds": { "get": { "produces": [ "application/json" @@ -17993,8 +18350,8 @@ "tags": [ "user" ], - "summary": "The repos that the given user has starred", - "operationId": "userListStarred", + "summary": "List a user's activity feeds", + "operationId": "userListActivityFeeds", "parameters": [ { "type": "string", @@ -18003,6 +18360,19 @@ "in": "path", "required": true }, + { + "type": "boolean", + "description": "if true, only show actions performed by the requested user", + "name": "only-performed-by", + "in": "query" + }, + { + "type": "string", + "format": "date", + "description": "the date of the activities to be found", + "name": "date", + "in": "query" + }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -18018,7 +18388,7 @@ ], "responses": { "200": { - "$ref": "#/responses/RepositoryList" + "$ref": "#/responses/ActivityFeedsList" }, "404": { "$ref": "#/responses/notFound" @@ -18026,7 +18396,7 @@ } } }, - "/users/{username}/subscriptions": { + "/users/{username}/followers": { "get": { "produces": [ "application/json" @@ -18034,12 +18404,12 @@ "tags": [ "user" ], - "summary": "List the repositories watched by a user", - "operationId": "userListSubscriptions", + "summary": "List the given user's followers", + "operationId": "userListFollowers", "parameters": [ { "type": "string", - "description": "username of the user", + "description": "username of user", "name": "username", "in": "path", "required": true @@ -18059,7 +18429,7 @@ ], "responses": { "200": { - "$ref": "#/responses/RepositoryList" + "$ref": "#/responses/UserList" }, "404": { "$ref": "#/responses/notFound" @@ -18067,7 +18437,7 @@ } } }, - "/users/{username}/tokens": { + "/users/{username}/following": { "get": { "produces": [ "application/json" @@ -18075,8 +18445,8 @@ "tags": [ "user" ], - "summary": "List the authenticated user's access tokens", - "operationId": "userGetTokens", + "summary": "List the users that the given user is following", + "operationId": "userListFollowing", "parameters": [ { "type": "string", @@ -18100,76 +18470,33 @@ ], "responses": { "200": { - "$ref": "#/responses/AccessTokenList" - }, - "403": { - "$ref": "#/responses/forbidden" - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Create an access token", - "operationId": "userCreateToken", - "parameters": [ - { - "type": "string", - "description": "username of user", - "name": "username", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/CreateAccessTokenOption" - } - } - ], - "responses": { - "201": { - "$ref": "#/responses/AccessToken" - }, - "400": { - "$ref": "#/responses/error" + "$ref": "#/responses/UserList" }, - "403": { - "$ref": "#/responses/forbidden" + "404": { + "$ref": "#/responses/notFound" } } } }, - "/users/{username}/tokens/{token}": { - "delete": { - "produces": [ - "application/json" - ], + "/users/{username}/following/{target}": { + "get": { "tags": [ "user" ], - "summary": "delete an access token", - "operationId": "userDeleteAccessToken", + "summary": "Check if one user is following another user", + "operationId": "userCheckFollowing", "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of following user", "name": "username", "in": "path", "required": true }, { "type": "string", - "description": "token to be deleted, identified by ID and if not available by name", - "name": "token", + "description": "username of followed user", + "name": "target", "in": "path", "required": true } @@ -18178,353 +18505,219 @@ "204": { "$ref": "#/responses/empty" }, - "403": { - "$ref": "#/responses/forbidden" - }, "404": { "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/error" - } - } - } - }, - "/version": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "miscellaneous" - ], - "summary": "Returns the version of the Gitea application", - "operationId": "getVersion", - "responses": { - "200": { - "$ref": "#/responses/ServerVersion" } } } }, - "/{username}/{repo}/projects": { + "/users/{username}/gpg_keys": { "get": { - "description": "Returns a list of projects for a given user and repository.", - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Get a list of projects", - "operationId": "getProjects", - "parameters": [ - { - "type": "string", - "description": "owner of the project", - "name": "username", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "repository name. If left '-', the projects will be returned for the user", - "name": "repo", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/ProjectList" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" - } - } - }, - "post": { - "description": "Creates a new project for a given user and repository.", - "consumes": [ - "application/json" - ], "produces": [ "application/json" ], "tags": [ - "project" + "user" ], - "summary": "Create a new project", - "operationId": "createProject", + "summary": "List the given user's GPG keys", + "operationId": "userListGPGKeys", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, { - "type": "string", - "description": "repository name. If left '-', the project will be created for the user", - "name": "repo", - "in": "path", - "required": true + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" }, { - "description": "Project data", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateProjectOption" - } + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" } ], "responses": { - "201": { - "$ref": "#/responses/Project" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "412": { - "$ref": "#/responses/error" - }, - "422": { - "$ref": "#/responses/validationError" + "200": { + "$ref": "#/responses/GPGKeyList" }, - "423": { - "$ref": "#/responses/repoArchivedError" + "404": { + "$ref": "#/responses/notFound" } } } }, - "/{username}/{repo}/projects/{id}": { + "/users/{username}/heatmap": { "get": { "produces": [ "application/json" ], "tags": [ - "project" + "user" ], - "summary": "Get a project", - "operationId": "getProject", + "summary": "Get a user's heatmap", + "operationId": "userGetHeatmapData", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user to get", "name": "username", "in": "path", "required": true - }, - { - "type": "string", - "description": "repository name. If left '-', the project will be returned for the user", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true } ], "responses": { "200": { - "$ref": "#/responses/Project" - }, - "403": { - "$ref": "#/responses/forbidden" + "$ref": "#/responses/UserHeatmapData" }, "404": { "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } - }, - "put": { + } + }, + "/users/{username}/keys": { + "get": { "produces": [ "application/json" ], "tags": [ - "project" + "user" ], - "summary": "Edit a project", - "operationId": "editProject", + "summary": "List the given user's public keys", + "operationId": "userListKeys", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, { "type": "string", - "description": "repository name. If left '-', the project will be edited for the user", - "name": "repo", - "in": "path", - "required": true + "description": "fingerprint of the key", + "name": "fingerprint", + "in": "query" }, { "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true + "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": { - "201": { - "$ref": "#/responses/Project" - }, - "403": { - "$ref": "#/responses/forbidden" + "200": { + "$ref": "#/responses/PublicKeyList" }, "404": { "$ref": "#/responses/notFound" - }, - "412": { - "$ref": "#/responses/error" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } - }, - "post": { - "consumes": [ - "application/json" - ], + } + }, + "/users/{username}/orgs": { + "get": { "produces": [ "application/json" ], "tags": [ - "project" + "organization" ], - "summary": "Add a column to a project", - "operationId": "addColumnToProject", + "summary": "List a user's organizations", + "operationId": "orgListUserOrgs", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, - { - "type": "string", - "description": "repository name. If left '-', the column will be added to the user's project", - "name": "repo", - "in": "path", - "required": true - }, { "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" }, { - "description": "column data", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateProjectColumnOption" - } + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" } ], "responses": { - "201": { - "$ref": "#/responses/Column" - }, - "403": { - "$ref": "#/responses/forbidden" + "200": { + "$ref": "#/responses/OrganizationList" }, "404": { "$ref": "#/responses/notFound" - }, - "412": { - "$ref": "#/responses/error" - }, - "422": { - "$ref": "#/responses/validationError" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } - }, - "delete": { - "description": "Deletes a specific project for a given user and repository.", + } + }, + "/users/{username}/orgs/{org}/permissions": { + "get": { + "produces": [ + "application/json" + ], "tags": [ - "project" + "organization" ], - "summary": "Delete a project", - "operationId": "deleteProject", + "summary": "Get user permissions in organization", + "operationId": "orgGetUserPermissions", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, { "type": "string", - "description": "repository name. If left '-', the project will be deleted for the user", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "project ID", - "name": "id", + "description": "name of the organization", + "name": "org", "in": "path", "required": true } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "200": { + "$ref": "#/responses/OrganizationPermissions" }, "403": { "$ref": "#/responses/forbidden" }, "404": { "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } } }, - "/{username}/{repo}/projects/{id}/move": { - "put": { - "consumes": [ + "/users/{username}/projects": { + "get": { + "produces": [ "application/json" ], "tags": [ "project" ], - "summary": "Move columns in a project", - "operationId": "moveColumns", + "summary": "Get a list of projects", + "operationId": "getProjects", "parameters": [ { "type": "string", @@ -18532,34 +18725,11 @@ "name": "username", "in": "path", "required": true - }, - { - "type": "string", - "description": "repository name. If left '-', the columns will be moved for the user's project", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "columns data", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/MovedColumnsOption" - } } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "200": { + "$ref": "#/responses/ProjectList" }, "403": { "$ref": "#/responses/forbidden" @@ -18573,353 +18743,234 @@ } } }, - "/{username}/{repo}/projects/{id}/{action}": { - "post": { + "/users/{username}/repos": { + "get": { "produces": [ "application/json" ], "tags": [ - "project" + "user" ], - "summary": "Change the status of a project", - "operationId": "changeProjectStatus", + "summary": "List the repos owned by the given user", + "operationId": "userListRepos", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, - { - "type": "string", - "description": "repository name. If left '-', the project status will be changed for the user", - "name": "repo", - "in": "path", - "required": true - }, { "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" }, { - "enum": [ - "open", - "close" - ], - "type": "string", - "description": "action to perform (open or close)", - "name": "action", - "in": "path", - "required": true + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" } ], "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" + "200": { + "$ref": "#/responses/RepositoryList" }, "404": { "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } } }, - "/{username}/{repo}/projects/{id}/{column-id}": { - "put": { - "consumes": [ - "application/json" - ], + "/users/{username}/starred": { + "get": { "produces": [ "application/json" ], "tags": [ - "project" + "user" ], - "summary": "Edit a project column", - "operationId": "editProjectColumn", + "summary": "The repos that the given user has starred", + "operationId": "userListStarred", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, - { - "type": "string", - "description": "repository name. If left '-', the column will be edited for the user's project", - "name": "repo", - "in": "path", - "required": true - }, { "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" }, { "type": "integer", - "description": "column ID", - "name": "column-id", - "in": "path", - "required": true - }, - { - "description": "column data", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/EditProjectColumnOption" - } + "description": "page size of results", + "name": "limit", + "in": "query" } ], "responses": { - "201": { - "$ref": "#/responses/Column" - }, - "403": { - "$ref": "#/responses/forbidden" + "200": { + "$ref": "#/responses/RepositoryList" }, "404": { "$ref": "#/responses/notFound" - }, - "412": { - "$ref": "#/responses/error" - }, - "422": { - "$ref": "#/responses/validationError" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } - }, - "delete": { + } + }, + "/users/{username}/subscriptions": { + "get": { + "produces": [ + "application/json" + ], "tags": [ - "project" + "user" ], - "summary": "Delete a project column", - "operationId": "deleteProjectColumn", + "summary": "List the repositories watched by a user", + "operationId": "userListSubscriptions", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of the user", "name": "username", "in": "path", "required": true }, - { - "type": "string", - "description": "repository name. If left '-', the column will be deleted for the user's project", - "name": "repo", - "in": "path", - "required": true - }, { "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" }, { "type": "integer", - "description": "column ID", - "name": "column-id", - "in": "path", - "required": true + "description": "page size of results", + "name": "limit", + "in": "query" } ], "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" + "200": { + "$ref": "#/responses/RepositoryList" }, "404": { "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } } }, - "/{username}/{repo}/projects/{id}/{column-id}/default": { - "post": { + "/users/{username}/tokens": { + "get": { + "produces": [ + "application/json" + ], "tags": [ - "project" + "user" ], - "summary": "Set default column for issues/pulls", - "operationId": "setDefaultProjectColumn", + "summary": "List the authenticated user's access tokens", + "operationId": "userGetTokens", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, - { - "type": "string", - "description": "repository name. If left '-', the column will be set as default for the user's project", - "name": "repo", - "in": "path", - "required": true - }, { "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" }, { "type": "integer", - "description": "column ID", - "name": "column-id", - "in": "path", - "required": true + "description": "page size of results", + "name": "limit", + "in": "query" } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "200": { + "$ref": "#/responses/AccessTokenList" }, "403": { "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } - } - }, - "/{username}/{repo}/projects/{id}/{column-id}/move": { + }, "post": { "consumes": [ "application/json" ], + "produces": [ + "application/json" + ], "tags": [ - "project" + "user" ], - "summary": "Move issues in a column", - "operationId": "moveIssues", + "summary": "Create an access token", + "operationId": "userCreateToken", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, { - "type": "string", - "description": "repository name.", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "project ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "column ID", - "name": "column-id", - "in": "path", - "required": true - }, - { - "description": "issues data", "name": "body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/MovedIssuesOption" + "$ref": "#/definitions/CreateAccessTokenOption" } } ], "responses": { - "204": { - "$ref": "#/responses/empty" + "201": { + "$ref": "#/responses/AccessToken" + }, + "400": { + "$ref": "#/responses/error" }, "403": { "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } } }, - "/{username}/{repo}/{type}/projects": { - "post": { - "consumes": [ + "/users/{username}/tokens/{token}": { + "delete": { + "produces": [ "application/json" ], "tags": [ - "project" + "user" ], - "summary": "Change an issue's project", - "operationId": "updateIssueProject", + "summary": "delete an access token", + "operationId": "userDeleteAccessToken", "parameters": [ { "type": "string", - "description": "owner of the project", + "description": "username of user", "name": "username", "in": "path", "required": true }, { "type": "string", - "description": "repository name.", - "name": "repo", - "in": "path", - "required": true - }, - { - "enum": [ - "issues", - "pulls" - ], - "type": "string", - "description": "issue type (issues or pulls)", - "name": "type", + "description": "token to be deleted, identified by ID and if not available by name", + "name": "token", "in": "path", "required": true - }, - { - "description": "issues data", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateIssuesOption" - } } ], "responses": { @@ -18932,8 +18983,25 @@ "404": { "$ref": "#/responses/notFound" }, - "423": { - "$ref": "#/responses/repoArchivedError" + "422": { + "$ref": "#/responses/error" + } + } + } + }, + "/version": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "miscellaneous" + ], + "summary": "Returns the version of the Gitea application", + "operationId": "getVersion", + "responses": { + "200": { + "$ref": "#/responses/ServerVersion" } } } @@ -26301,6 +26369,15 @@ "$ref": "#/definitions/Column" } }, + "ColumnList": { + "description": "ColumnList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Column" + } + } + }, "CombinedStatus": { "description": "CombinedStatus", "schema": { From f3f69085d9669fdfd00431dee6cafc2ac9ad97b0 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 31 Jul 2024 14:21:22 +0300 Subject: [PATCH 17/20] refactor: remove unnessary condition in reqSelfOrAdmin middleware --- routers/api/v1/api.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index de674a3364261..b289ce670e026 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -479,12 +479,11 @@ func reqOwner() func(ctx *context.APIContext) { // reqSelfOrAdmin doer should be the same as the contextUser or site admin func reqSelfOrAdmin() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if ctx.ContextUser.IsIndividual() { - if !ctx.IsUserSiteAdmin() && ctx.ContextUser != ctx.Doer { - ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser") - return - } + if !ctx.IsUserSiteAdmin() && ctx.ContextUser != ctx.Doer { + ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser") + return } + } } From 42c54d0396719b4128e7c112cf528d01178c2e07 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Thu, 1 Aug 2024 16:02:36 +0300 Subject: [PATCH 18/20] feat: add pagination support and update swagger documentation --- modules/structs/project_column.go | 12 +-- routers/api/v1/api.go | 2 +- routers/api/v1/org/project.go | 29 +++++-- routers/api/v1/project/project.go | 8 +- routers/api/v1/project/project_column.go | 16 ++-- routers/api/v1/repo/project.go | 31 +++++-- routers/api/v1/user/project.go | 29 +++++-- templates/swagger/v1_json.tmpl | 106 ++++++++++++++++------- 8 files changed, 157 insertions(+), 76 deletions(-) diff --git a/modules/structs/project_column.go b/modules/structs/project_column.go index b4e5d18144204..b57d23bd91387 100644 --- a/modules/structs/project_column.go +++ b/modules/structs/project_column.go @@ -12,15 +12,15 @@ type Column struct { // EditProjectColumnOption options for editing a project column type EditProjectColumnOption struct { - Title string `binding:"MaxSize(100)"` - Sorting int8 - Color string `binding:"MaxSize(7)"` + Title string `json:"title" binding:"MaxSize(100)"` + Sorting int8 `json:"sorting"` + Color string `json:"color" binding:"MaxSize(7)"` } // CreateProjectColumnOption options for creating a project column type CreateProjectColumnOption struct { // required:true - Title string `binding:"Required;MaxSize(100)"` - Sorting int8 - Color string `binding:"MaxSize(7)"` + Title string `json:"title" binding:"Required;MaxSize(100)"` + Sorting int8 `json:"sorting"` + Color string `json:"color" binding:"MaxSize(7)"` } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b289ce670e026..1db3e18f7cbe9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1158,7 +1158,7 @@ func Routes() *web.Router { m.Group("", func() { m.Patch("", bind(api.EditProjectColumnOption{}), project.EditProjectColumn) m.Delete("", project.DeleteProjectColumn) - m.Post("/default", project.SetDefaultProjectColumn) + m.Put("/default", project.SetDefaultProjectColumn) }, reqRepoWriter(unit.TypeProjects), mustNotBeArchived, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), reqProjectOwner()) }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), projectIDAssignmentAPI(), columnAssignment(), individualPermsChecker, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index fbd8e05f96f4e..1dbf429bc65b6 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -12,13 +12,14 @@ import ( "code.gitea.io/gitea/modules/optional" 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" ) // CreateProject creates a new project for organization func CreateProject(ctx *context.APIContext) { - // swagger:operation POST /orgs/{org}/projects project createProject + // swagger:operation POST /orgs/{org}/projects project orgCreateProject // --- // summary: Create a new project // consumes: @@ -71,7 +72,7 @@ func CreateProject(ctx *context.APIContext) { // GetProjects returns a list of projects that belong to an organization func GetProjects(ctx *context.APIContext) { - // swagger:operation GET /orgs/{org}/projects project getProjects + // swagger:operation GET /orgs/{org}/projects project orgGetProjects // --- // summary: Get a list of projects // produces: @@ -82,6 +83,14 @@ func GetProjects(ctx *context.APIContext) { // description: organization name that the project belongs to // required: true // type: string + // - 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" @@ -92,23 +101,27 @@ func GetProjects(ctx *context.APIContext) { // "423": // "$ref": "#/responses/repoArchivedError" + listOptions := utils.GetListOptions(ctx) sortType := ctx.FormTrim("sort") isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" searchOptions := project_model.SearchOptions{ - IsClosed: optional.Some(isShowClosed), - OrderBy: project_model.GetSearchOrderByBySortType(sortType), - OwnerID: ctx.ContextUser.ID, - Type: project_model.TypeOrganization, + ListOptions: listOptions, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + OwnerID: ctx.ContextUser.ID, + Type: project_model.TypeOrganization, } - projects, err := db.Find[project_model.Project](ctx, &searchOptions) + projects, maxResults, err := db.FindAndCount[project_model.Project](ctx, &searchOptions) if err != nil { - ctx.ServerError("FindProjects", err) + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[project_model.Project]", err) return } + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } diff --git a/routers/api/v1/project/project.go b/routers/api/v1/project/project.go index 53827358f59ab..47d2d01945d2d 100644 --- a/routers/api/v1/project/project.go +++ b/routers/api/v1/project/project.go @@ -16,7 +16,7 @@ import ( // GetProject returns a project func GetProject(ctx *context.APIContext) { - // swagger:operation GET /projects/{project_id} project getProject + // swagger:operation GET /projects/{project_id} project projectGetProject // --- // summary: Get a project // produces: @@ -73,7 +73,7 @@ func GetProject(ctx *context.APIContext) { // EditProject edits a project func EditProject(ctx *context.APIContext) { - // swagger:operation PATCH /projects/{project_id} project editProject + // swagger:operation PATCH /projects/{project_id} project projectEditProject // --- // summary: Edit a project // produces: @@ -119,7 +119,7 @@ func EditProject(ctx *context.APIContext) { // DeleteProject deletes a project func DeleteProject(ctx *context.APIContext) { - // swagger:operation DELETE /projects/{project_id} project deleteProject + // swagger:operation DELETE /projects/{project_id} project projectDeleteProject // --- // summary: Delete a project // description: Deletes a specific project for a given user and repository. @@ -151,7 +151,7 @@ func DeleteProject(ctx *context.APIContext) { // ChangeProjectStatus updates the status of a project between "open" and "close" func ChangeProjectStatus(ctx *context.APIContext) { - // swagger:operation PATCH /projects/{project_id}/{action} project changeProjectStatus + // swagger:operation PATCH /projects/{project_id}/{action} project projectProjectChangeProjectStatus // --- // summary: Change the status of a project // produces: diff --git a/routers/api/v1/project/project_column.go b/routers/api/v1/project/project_column.go index 088a9ede3148d..cb247bbdbfc29 100644 --- a/routers/api/v1/project/project_column.go +++ b/routers/api/v1/project/project_column.go @@ -18,7 +18,7 @@ import ( // GetProjectColumn returns a project column func GetProjectColumn(ctx *context.APIContext) { - // swagger:operation GET /projects/columns/{column_id} project getProject + // swagger:operation GET /projects/columns/{column_id} project projectGetProjectColumn // --- // summary: Get a project column // produces: @@ -51,7 +51,7 @@ func GetProjectColumn(ctx *context.APIContext) { // GetProjectColumns returns a list of project columns func GetProjectColumns(ctx *context.APIContext) { - // swagger:operation GET /projects/{project_id}/columns project getProject + // swagger:operation GET /projects/{project_id}/columns project projectGetProjectColumns // --- // summary: Get a list of project columns // produces: @@ -91,7 +91,7 @@ func GetProjectColumns(ctx *context.APIContext) { // AddColumnToProject adds a new column to a project func AddColumnToProject(ctx *context.APIContext) { - // swagger:operation POST /projects/{project_id}/columns project addColumnToProject + // swagger:operation POST /projects/{project_id}/columns project projectAddColumnToProject // --- // summary: Add a column to a project // consumes: @@ -153,7 +153,7 @@ func AddColumnToProject(ctx *context.APIContext) { // EditProjectColumn edits a project column func EditProjectColumn(ctx *context.APIContext) { - // swagger:operation PATCH /projects/columns/{column_id} project editProjectColumn + // swagger:operation PATCH /projects/columns/{column_id} project projectEditProjectColumn // --- // summary: Edit a project column // consumes: @@ -212,7 +212,7 @@ func EditProjectColumn(ctx *context.APIContext) { // DeleteProjectColumn deletes a project column func DeleteProjectColumn(ctx *context.APIContext) { - // swagger:operation DELETE /projects/columns/{column_id} project deleteProjectColumn + // swagger:operation DELETE /projects/columns/{column_id} project projectDeleteProjectColumn // --- // summary: Delete a project column // parameters: @@ -241,7 +241,7 @@ func DeleteProjectColumn(ctx *context.APIContext) { // SetDefaultProjectColumn set default column for issues/pulls func SetDefaultProjectColumn(ctx *context.APIContext) { - // swagger:operation PUT /projects/columns/{column_id}/default project setDefaultProjectColumn + // swagger:operation PUT /projects/columns/{column_id}/default project projectSetDefaultProjectColumn // --- // summary: Set default column for issues/pulls // parameters: @@ -276,7 +276,7 @@ func SetDefaultProjectColumn(ctx *context.APIContext) { // MoveColumns moves or keeps columns in a project and sorts them inside that project func MoveColumns(ctx *context.APIContext) { - // swagger:operation PATCH /projects/{project_id}/columns/move project moveColumns + // swagger:operation PATCH /projects/{project_id}/columns/move project projectMoveColumns // --- // summary: Move columns in a project // consumes: @@ -337,7 +337,7 @@ func MoveColumns(ctx *context.APIContext) { // MoveIssues moves or keeps issues in a column and sorts them inside that column func MoveIssues(ctx *context.APIContext) { - // swagger:operation PATCH /projects/{project_id}/columns/{column_id}/move project moveIssues + // swagger:operation PATCH /projects/{project_id}/columns/{column_id}/move project projectMoveIssues // --- // summary: Move issues in a column // consumes: diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 4e37ce9300ffa..2d42640eaef28 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -17,13 +17,14 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "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" ) // GetProjects returns a list of projects for a given user and repository. func GetProjects(ctx *context.APIContext) { - // swagger:operation GET /repos/{owner}/{reponame}/projects project getProjects + // swagger:operation GET /repos/{owner}/{reponame}/projects project repoGetProjects // --- // summary: Get a list of projects // description: Returns a list of projects for a given user and repository. @@ -40,6 +41,14 @@ func GetProjects(ctx *context.APIContext) { // description: repository name. // required: true // type: string + // - 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" @@ -50,30 +59,34 @@ func GetProjects(ctx *context.APIContext) { // "423": // "$ref": "#/responses/repoArchivedError" + listOptions := utils.GetListOptions(ctx) sortType := ctx.FormTrim("sort") isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" searchOptions := project_model.SearchOptions{ - IsClosed: optional.Some(isShowClosed), - OrderBy: project_model.GetSearchOrderByBySortType(sortType), - RepoID: ctx.Repo.Repository.ID, - Type: project_model.TypeRepository, + ListOptions: listOptions, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + RepoID: ctx.Repo.Repository.ID, + Type: project_model.TypeRepository, } - projects, err := db.Find[project_model.Project](ctx, &searchOptions) + projects, maxResults, err := db.FindAndCount[project_model.Project](ctx, &searchOptions) if err != nil { - ctx.ServerError("FindProjects", err) + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[project_model.Project]", err) return } + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } // CreateProject creates a new project func CreateProject(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{reponame}/projects project createProject + // swagger:operation POST /repos/{owner}/{reponame}/projects project repoCreateProject // --- // summary: Create a new project // description: Creates a new project for a given user and repository. @@ -132,7 +145,7 @@ func CreateProject(ctx *context.APIContext) { // UpdateIssueProject change an issue's project to another project in a repository func UpdateIssueProject(ctx *context.APIContext) { - // swagger:operation PUT /repos/{owner}/{reponame}/projects/{type} project updateIssueProject + // swagger:operation PUT /repos/{owner}/{reponame}/projects/{type} project repoUpdateIssueProject // --- // summary: Change an issue's project // consumes: diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go index 533199dbb6b7e..f71607e26e1ab 100644 --- a/routers/api/v1/user/project.go +++ b/routers/api/v1/user/project.go @@ -12,13 +12,14 @@ import ( "code.gitea.io/gitea/modules/optional" 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" ) // CreateProject creates a new project for a user func CreateProject(ctx *context.APIContext) { - // swagger:operation POST /user/projects project createProject + // swagger:operation POST /user/projects project userCreateProject // --- // summary: Create a new project for user // consumes: @@ -66,7 +67,7 @@ func CreateProject(ctx *context.APIContext) { // GetProjects returns a list of projects that belong to a user func GetProjects(ctx *context.APIContext) { - // swagger:operation GET /users/{username}/projects project getProjects + // swagger:operation GET /users/{username}/projects project userGetProjects // --- // summary: Get a list of projects // produces: @@ -77,6 +78,14 @@ func GetProjects(ctx *context.APIContext) { // description: owner of the project // required: true // type: string + // - 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" @@ -87,23 +96,27 @@ func GetProjects(ctx *context.APIContext) { // "423": // "$ref": "#/responses/repoArchivedError" + listOptions := utils.GetListOptions(ctx) sortType := ctx.FormTrim("sort") isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" searchOptions := project_model.SearchOptions{ - IsClosed: optional.Some(isShowClosed), - OrderBy: project_model.GetSearchOrderByBySortType(sortType), - OwnerID: ctx.ContextUser.ID, - Type: project_model.TypeIndividual, + ListOptions: listOptions, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + OwnerID: ctx.ContextUser.ID, + Type: project_model.TypeIndividual, } - projects, err := db.Find[project_model.Project](ctx, &searchOptions) + projects, maxResults, err := db.FindAndCount[project_model.Project](ctx, &searchOptions) if err != nil { - ctx.ServerError("FindProjects", err) + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[project_model.Project]", err) return } + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) ctx.JSON(http.StatusOK, convert.ToProjects(ctx, projects)) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 872561ca8d493..7fdf8a72a8205 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2840,7 +2840,7 @@ "project" ], "summary": "Get a list of projects", - "operationId": "getProjects", + "operationId": "orgGetProjects", "parameters": [ { "type": "string", @@ -2848,6 +2848,18 @@ "name": "org", "in": "path", "required": true + }, + { + "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": { @@ -2876,7 +2888,7 @@ "project" ], "summary": "Create a new project", - "operationId": "createProject", + "operationId": "orgCreateProject", "parameters": [ { "type": "string", @@ -3524,7 +3536,7 @@ "project" ], "summary": "Get a project column", - "operationId": "getProject", + "operationId": "projectGetProjectColumn", "parameters": [ { "type": "integer", @@ -3554,7 +3566,7 @@ "project" ], "summary": "Delete a project column", - "operationId": "deleteProjectColumn", + "operationId": "projectDeleteProjectColumn", "parameters": [ { "type": "integer", @@ -3590,7 +3602,7 @@ "project" ], "summary": "Edit a project column", - "operationId": "editProjectColumn", + "operationId": "projectEditProjectColumn", "parameters": [ { "type": "integer", @@ -3637,7 +3649,7 @@ "project" ], "summary": "Set default column for issues/pulls", - "operationId": "setDefaultProjectColumn", + "operationId": "projectSetDefaultProjectColumn", "parameters": [ { "type": "integer", @@ -3672,7 +3684,7 @@ "project" ], "summary": "Get a project", - "operationId": "getProject", + "operationId": "projectGetProject", "parameters": [ { "type": "integer", @@ -3703,7 +3715,7 @@ "project" ], "summary": "Delete a project", - "operationId": "deleteProject", + "operationId": "projectDeleteProject", "parameters": [ { "type": "integer", @@ -3736,7 +3748,7 @@ "project" ], "summary": "Edit a project", - "operationId": "editProject", + "operationId": "projectEditProject", "parameters": [ { "type": "integer", @@ -3774,7 +3786,7 @@ "project" ], "summary": "Get a list of project columns", - "operationId": "getProject", + "operationId": "projectGetProjectColumns", "parameters": [ { "type": "integer", @@ -3810,7 +3822,7 @@ "project" ], "summary": "Add a column to a project", - "operationId": "addColumnToProject", + "operationId": "projectAddColumnToProject", "parameters": [ { "type": "integer", @@ -3860,7 +3872,7 @@ "project" ], "summary": "Move columns in a project", - "operationId": "moveColumns", + "operationId": "projectMoveColumns", "parameters": [ { "type": "integer", @@ -3904,7 +3916,7 @@ "project" ], "summary": "Move issues in a column", - "operationId": "moveIssues", + "operationId": "projectMoveIssues", "parameters": [ { "type": "integer", @@ -3955,7 +3967,7 @@ "project" ], "summary": "Change the status of a project", - "operationId": "changeProjectStatus", + "operationId": "projectProjectChangeProjectStatus", "parameters": [ { "type": "integer", @@ -4292,7 +4304,7 @@ "project" ], "summary": "Get a list of projects", - "operationId": "getProjects", + "operationId": "repoGetProjects", "parameters": [ { "type": "string", @@ -4307,6 +4319,18 @@ "name": "reponame", "in": "path", "required": true + }, + { + "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": { @@ -4336,7 +4360,7 @@ "project" ], "summary": "Create a new project", - "operationId": "createProject", + "operationId": "repoCreateProject", "parameters": [ { "type": "string", @@ -4390,7 +4414,7 @@ "project" ], "summary": "Change an issue's project", - "operationId": "updateIssueProject", + "operationId": "repoUpdateIssueProject", "parameters": [ { "type": "string", @@ -17845,7 +17869,7 @@ "project" ], "summary": "Create a new project for user", - "operationId": "createProject", + "operationId": "userCreateProject", "parameters": [ { "description": "Project data", @@ -18717,7 +18741,7 @@ "project" ], "summary": "Get a list of projects", - "operationId": "getProjects", + "operationId": "userGetProjects", "parameters": [ { "type": "string", @@ -18725,6 +18749,18 @@ "name": "username", "in": "path", "required": true + }, + { + "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": { @@ -20765,18 +20801,21 @@ "description": "CreateProjectColumnOption options for creating a project column", "type": "object", "required": [ - "Title" + "title" ], "properties": { - "Color": { - "type": "string" + "color": { + "type": "string", + "x-go-name": "Color" }, - "Sorting": { + "sorting": { "type": "integer", - "format": "int8" + "format": "int8", + "x-go-name": "Sorting" }, - "Title": { - "type": "string" + "title": { + "type": "string", + "x-go-name": "Title" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -21807,15 +21846,18 @@ "description": "EditProjectColumnOption options for editing a project column", "type": "object", "properties": { - "Color": { - "type": "string" + "color": { + "type": "string", + "x-go-name": "Color" }, - "Sorting": { + "sorting": { "type": "integer", - "format": "int8" + "format": "int8", + "x-go-name": "Sorting" }, - "Title": { - "type": "string" + "title": { + "type": "string", + "x-go-name": "Title" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" From cc57d51768ec8245661f8f2e0471afa3c46aa818 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Fri, 20 Sep 2024 11:24:17 +0300 Subject: [PATCH 19/20] tests(api): create tests for project and column handlers --- models/fixtures/project.yml | 44 ++++ models/fixtures/project_issue.yml | 6 + routers/api/v1/org/project.go | 4 - routers/api/v1/project/project.go | 2 +- routers/api/v1/project/project_column.go | 3 +- routers/api/v1/repo/project.go | 6 +- routers/api/v1/user/project.go | 4 - tests/integration/api_org_projects_test.go | 185 +++++++++++++++ tests/integration/api_project_columns_test.go | 209 +++++++++++++++++ tests/integration/api_project_test.go | 131 +++++++++++ tests/integration/api_repo_projects_test.go | 210 ++++++++++++++++++ tests/integration/api_user_projects_test.go | 174 +++++++++++++++ 12 files changed, 965 insertions(+), 13 deletions(-) create mode 100644 tests/integration/api_org_projects_test.go create mode 100644 tests/integration/api_project_columns_test.go create mode 100644 tests/integration/api_project_test.go create mode 100644 tests/integration/api_repo_projects_test.go create mode 100644 tests/integration/api_user_projects_test.go diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml index 44d87bce04674..6faea09d4aab4 100644 --- a/models/fixtures/project.yml +++ b/models/fixtures/project.yml @@ -69,3 +69,47 @@ type: 2 created_unix: 1688973000 updated_unix: 1688973000 + +- id: 7 + title: project in archived repository + owner_id: 0 + repo_id: 51 + is_closed: false + creator_id: 30 + board_type: 1 + type: 2 + created_unix: 1688973000 + updated_unix: 1688973000 + +- id: 8 + title: project1 belongs to org3 + owner_id: 3 + repo_id: 0 + is_closed: true + creator_id: 3 + board_type: 1 + type: 2 + created_unix: 1688973000 + updated_unix: 1688973000 + +- id: 9 + title: project2 belongs to org3 + owner_id: 3 + repo_id: 0 + is_closed: false + creator_id: 3 + board_type: 1 + type: 2 + created_unix: 1688973000 + updated_unix: 1688973000 + +- id: 10 + title: project2 on repo1 + owner_id: 0 + repo_id: 1 + is_closed: false + creator_id: 2 + board_type: 1 + type: 2 + created_unix: 1688973010 + updated_unix: 1688973010 \ No newline at end of file diff --git a/models/fixtures/project_issue.yml b/models/fixtures/project_issue.yml index b1af05908aafb..ad60488af91ad 100644 --- a/models/fixtures/project_issue.yml +++ b/models/fixtures/project_issue.yml @@ -21,3 +21,9 @@ issue_id: 5 project_id: 1 project_board_id: 3 + +- + id: 5 + issue_id: 5 + project_id: 1 + project_board_id: 1 \ No newline at end of file diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 1dbf429bc65b6..f9ad550a725aa 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -47,8 +47,6 @@ func CreateProject(ctx *context.APIContext) { // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" - // "423": - // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateProjectOption) @@ -98,8 +96,6 @@ func GetProjects(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" listOptions := utils.GetListOptions(ctx) sortType := ctx.FormTrim("sort") diff --git a/routers/api/v1/project/project.go b/routers/api/v1/project/project.go index 47d2d01945d2d..1d68b8de10419 100644 --- a/routers/api/v1/project/project.go +++ b/routers/api/v1/project/project.go @@ -49,7 +49,7 @@ func GetProject(ctx *context.APIContext) { return } - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{}) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) return diff --git a/routers/api/v1/project/project_column.go b/routers/api/v1/project/project_column.go index cb247bbdbfc29..ff6dad89d4223 100644 --- a/routers/api/v1/project/project_column.go +++ b/routers/api/v1/project/project_column.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + project_service "code.gitea.io/gitea/services/projects" ) // GetProjectColumn returns a project column @@ -416,7 +417,7 @@ func MoveIssues(ctx *context.APIContext) { } } - if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil { ctx.ServerError("MoveIssuesOnProjectColumn", err) return } diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 2d42640eaef28..64a882b2700ed 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -143,11 +143,11 @@ func CreateProject(ctx *context.APIContext) { ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project)) } -// UpdateIssueProject change an issue's project to another project in a repository +// UpdateIssueProject moves issues from a project to another in a repository func UpdateIssueProject(ctx *context.APIContext) { // swagger:operation PUT /repos/{owner}/{reponame}/projects/{type} project repoUpdateIssueProject // --- - // summary: Change an issue's project + // summary: Moves issues from a project to another in a repository // consumes: // - application/json // parameters: @@ -202,7 +202,7 @@ func UpdateIssueProject(ctx *context.APIContext) { return } if _, err := issues.LoadRepositories(ctx); err != nil { - ctx.ServerError("LoadProjects", err) + ctx.ServerError("LoadRepositories", err) return } diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go index f71607e26e1ab..590c8c0b73bb0 100644 --- a/routers/api/v1/user/project.go +++ b/routers/api/v1/user/project.go @@ -42,8 +42,6 @@ func CreateProject(ctx *context.APIContext) { // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" - // "423": - // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateProjectOption) @@ -93,8 +91,6 @@ func GetProjects(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - // "423": - // "$ref": "#/responses/repoArchivedError" listOptions := utils.GetListOptions(ctx) sortType := ctx.FormTrim("sort") diff --git a/tests/integration/api_org_projects_test.go b/tests/integration/api_org_projects_test.go new file mode 100644 index 0000000000000..ca4dc72c088c9 --- /dev/null +++ b/tests/integration/api_org_projects_test.go @@ -0,0 +1,185 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" +) + +func TestAPICreateOrgProject(t *testing.T) { + createOrgProjectSuccessTestCases := []struct { + testName string + orgName string + ctxUserID int64 + doerID int64 + title string + content string + templateType uint8 + cardType uint8 + }{ + { + testName: "site admin create project successfully", + ctxUserID: 3, + doerID: 1, + title: "site-admin", + content: "project_description", + templateType: 1, + cardType: 2, + }, + { + testName: "org owner create project successfully", + ctxUserID: 3, + doerID: 2, + title: "org-owner", + content: "project_description", + templateType: 1, + cardType: 2, + }, + { + testName: "member create project successfully with write access", + ctxUserID: 3, + doerID: 4, + title: "member-with-write-access", + content: "project_description", + templateType: 1, + cardType: 2, + }, + } + + createOrgProjectFailTestCases := []struct { + testName string + orgName string + ctxUserID int64 + doerID int64 + title string + expectedStatus int + }{ + { + testName: "user is not in organization", + orgName: "org3", + ctxUserID: 3, + doerID: 5, + title: "user-not-in-org", + expectedStatus: http.StatusForbidden, + }, + { + testName: "user is member but not sufficient access", + orgName: "org17", + ctxUserID: 17, + doerID: 20, + title: "member-not-sufficient-access", + expectedStatus: http.StatusForbidden, + }, + { + testName: "project not created as title is empty", + orgName: "org3", + ctxUserID: 3, + doerID: 2, + title: "", + expectedStatus: http.StatusUnprocessableEntity, + }, + { + testName: "project not created as title is too long", + orgName: "org3", + ctxUserID: 3, + doerID: 2, + title: "This is a very long title that will exceed the maximum allowed size of 100 characters. It keeps going beyond the limit.", + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + defer tests.PrepareTestEnv(t)() + + for _, tt := range createOrgProjectFailTestCases { + t.Run(tt.testName, func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tt.doerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteOrganization) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/projects", tt.orgName), &api.CreateProjectOption{ + Title: tt.title, + }).AddTokenAuth(token) + MakeRequest(t, req, tt.expectedStatus) + }) + } + + for _, tt := range createOrgProjectSuccessTestCases { + t.Run(tt.testName, func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tt.doerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteOrganization) + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{ + Title: tt.title, + Content: tt.content, + TemplateType: tt.templateType, + CardType: tt.cardType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, tt.title, apiProject.Title) + assert.Equal(t, tt.content, apiProject.Description) + assert.Equal(t, tt.templateType, apiProject.TemplateType) + assert.Equal(t, tt.cardType, apiProject.CardType) + assert.Equal(t, tt.ctxUserID, apiProject.OwnerID) + assert.Equal(t, tt.doerID, apiProject.CreatorID) + }) + } +} + +func TestAPIGetOrgProjects(t *testing.T) { + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadOrganization) + + expectedProjects := []*api.Project{ + { + Title: "project1 belongs to org3", + OwnerID: 3, + IsClosed: true, + CreatorID: 3, + TemplateType: 1, + CardType: 2, + }, + { + Title: "project2 belongs to org3", + OwnerID: 3, + IsClosed: false, + CreatorID: 3, + TemplateType: 1, + CardType: 2, + }, + } + + t.Run("failed to get projects org not found", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/orgs/org90/projects").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("get projects successfully", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiProjects []*api.Project + DecodeJSON(t, resp, &apiProjects) + assert.Equal(t, len(expectedProjects), len(apiProjects)) + for i, expectedProject := range expectedProjects { + assert.Equal(t, expectedProject.Title, apiProjects[i].Title) + assert.Equal(t, expectedProject.OwnerID, apiProjects[i].OwnerID) + assert.Equal(t, expectedProject.IsClosed, apiProjects[i].IsClosed) + assert.Equal(t, expectedProject.CreatorID, apiProjects[i].CreatorID) + assert.Equal(t, expectedProject.TemplateType, apiProjects[i].TemplateType) + assert.Equal(t, expectedProject.CardType, apiProjects[i].CardType) + } + }) +} diff --git a/tests/integration/api_project_columns_test.go b/tests/integration/api_project_columns_test.go new file mode 100644 index 0000000000000..5b6676a0be3f3 --- /dev/null +++ b/tests/integration/api_project_columns_test.go @@ -0,0 +1,209 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" +) + +func TestAPIGetProjectColumn(t *testing.T) { + expectedColumn := &api.Column{ + ID: 1, + Title: "To Do", + } + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization) + + t.Run("get column not found", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/projects/columns/20")).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("get column successfully", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/projects/columns/1")).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiColumn *api.Column + DecodeJSON(t, resp, &apiColumn) + assert.Equal(t, expectedColumn.ID, apiColumn.ID) + assert.Equal(t, expectedColumn.Title, apiColumn.Title) + }) +} + +func TestAPIGetProjectColumns(t *testing.T) { + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization) + + expectedColumns := []*api.Column{ + { + ID: 1, + Title: "To Do", + }, + { + ID: 2, + Title: "In Progress", + }, + { + ID: 3, + Title: "Done", + }, + } + + t.Run("failed to get columns project not found", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/projects/70/columns").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("get columns successfully", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/projects/1/columns").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiColumns []*api.Column + DecodeJSON(t, resp, &apiColumns) + assert.Equal(t, len(expectedColumns), len(apiColumns)) + for i, expectedColumn := range expectedColumns { + assert.Equal(t, expectedColumn.ID, apiColumns[i].ID) + assert.Equal(t, expectedColumn.Title, apiColumns[i].Title) + } + }) +} + +func TestAPIAddColumnToProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("add column to project successfully", func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", "/api/v1/projects/1/columns", &api.CreateProjectColumnOption{ + Title: "New Column", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiColumn *api.Column + DecodeJSON(t, resp, &apiColumn) + assert.Equal(t, "New Column", apiColumn.Title) + }) +} + +func TestAPIEditProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("edit column successfully", func(t *testing.T) { + req := NewRequestWithJSON(t, "PATCH", "/api/v1/projects/columns/1", &api.EditProjectColumnOption{ + Title: "Updated Column", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiColumn *api.Column + DecodeJSON(t, resp, &apiColumn) + assert.Equal(t, "Updated Column", apiColumn.Title) + }) +} + +func TestAPIDeleteProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("delete column successfully", func(t *testing.T) { + req := NewRequest(t, "DELETE", "/api/v1/projects/columns/2").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) +} + +func TestAPISetDefaultProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("set default column successfully", func(t *testing.T) { + req := NewRequestWithJSON(t, "PUT", "/api/v1/projects/columns/2/default", nil).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) +} + +func TestAPIMoveColumns(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("move columns successfully", func(t *testing.T) { + req := NewRequestWithJSON(t, "PATCH", "/api/v1/projects/1/columns/move", &api.MovedColumnsOption{ + Columns: []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + }{ + { + ColumnID: 3, + Sorting: 1, + }, + { + ColumnID: 2, + Sorting: 2, + }, + }, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiColumns []*api.Column + DecodeJSON(t, resp, &apiColumns) + assert.Equal(t, 2, len(apiColumns)) + assert.Equal(t, int64(3), apiColumns[0].ID) + assert.Equal(t, int64(2), apiColumns[1].ID) + }) +} + +func TestAPIMoveIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("move issues successfully", func(t *testing.T) { + req := NewRequestWithJSON(t, "PATCH", "/api/v1/projects/1/columns/1/move", &api.MovedIssuesOption{ + Issues: []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + }{ + { + IssueID: 1, + Sorting: 1, + }, + { + IssueID: 5, + Sorting: 2, + }, + }, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiIssues []*api.Issue + DecodeJSON(t, resp, &apiIssues) + assert.Equal(t, 2, len(apiIssues)) + assert.Equal(t, int64(1), apiIssues[0].ID) + assert.Equal(t, int64(2), apiIssues[1].ID) + }) +} diff --git a/tests/integration/api_project_test.go b/tests/integration/api_project_test.go new file mode 100644 index 0000000000000..8dfc2f4e173a6 --- /dev/null +++ b/tests/integration/api_project_test.go @@ -0,0 +1,131 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" +) + +func TestAPIGetProject(t *testing.T) { + getProjectTestCases := []struct { + testName string + projectID int64 + expectedStatus int + }{ + { + testName: "get project successfully", + projectID: 1, + expectedStatus: http.StatusOK, + }, + { + testName: "project not found", + projectID: 20, + expectedStatus: http.StatusNotFound, + }, + } + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization) + + for _, tt := range getProjectTestCases { + t.Run(tt.testName, func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/projects/%d", tt.projectID)).AddTokenAuth(token) + MakeRequest(t, req, tt.expectedStatus) + }) + } +} + +func TestAPIEditProject(t *testing.T) { + editProjectFailTestCases := []struct { + testName string + projectID int64 + expectedStatus int + }{ + { + testName: "repo is archived", + projectID: 7, + expectedStatus: http.StatusLocked, + }, + { + testName: "insufficient access", + projectID: 2, + expectedStatus: http.StatusForbidden, + }, + } + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + for _, tt := range editProjectFailTestCases { + t.Run(tt.testName, func(t *testing.T) { + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/projects/%d", tt.projectID), + &api.EditProjectOption{ + Title: "title", + }).AddTokenAuth(token) + MakeRequest(t, req, tt.expectedStatus) + }) + } + + t.Run("edit project successfully", func(t *testing.T) { + expectedProject := api.Project{ + Title: "new title", + Description: "new content", + } + req := NewRequestWithJSON(t, "PATCH", "/api/v1/projects/1", &api.EditProjectOption{ + Title: expectedProject.Title, + Content: expectedProject.Description, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, expectedProject.Title, apiProject.Title) + assert.Equal(t, expectedProject.Description, apiProject.Description) + }) +} + +func TestAPIDeleteProject(t *testing.T) { + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("delete project successfully", func(t *testing.T) { + req := NewRequest(t, "DELETE", "/api/v1/projects/1").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) +} + +func TestAPIChangeProjectStatus(t *testing.T) { + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("change project status successfully", func(t *testing.T) { + req := NewRequest(t, "PATCH", "/api/v1/projects/1/close").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, true, apiProject.IsClosed) + }) +} diff --git a/tests/integration/api_repo_projects_test.go b/tests/integration/api_repo_projects_test.go new file mode 100644 index 0000000000000..7668216a3320a --- /dev/null +++ b/tests/integration/api_repo_projects_test.go @@ -0,0 +1,210 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" +) + +func TestAPICreateRepoProject(t *testing.T) { + createRepoProjectSuccessTestCases := []struct { + testName string + ownerName string + repoName string + repoID int64 + doerID int64 + title string + content string + templateType uint8 + cardType uint8 + }{ + { + testName: "member create project successfully with write access", + ownerName: "org3", + repoName: "repo3", + repoID: 3, + doerID: 4, + title: "member-with-write-access", + content: "project_description", + templateType: 1, + cardType: 2, + }, + { + testName: "collaborator create project successfully with write access", + ownerName: "privated_org", + repoName: "public_repo_on_private_org", + repoID: 40, + doerID: 4, + title: "collaborator-with-write-access", + content: "project_description", + templateType: 1, + cardType: 2, + }, + } + + createRepoProjectFailTestCases := []struct { + testName string + ownerName string + repoName string + repoID int64 + doerID int64 + title string + expectedStatus int + }{ + { + testName: "user is not in organization", + ownerName: "org3", + repoName: "repo3", + repoID: 3, + doerID: 5, + title: "user-not-in-org", + expectedStatus: http.StatusForbidden, + }, + { + testName: "user is not collaborator", + ownerName: "org3", + repoName: "repo3", + repoID: 3, + doerID: 4, + title: "user-not-collaborator", + expectedStatus: http.StatusForbidden, + }, + { + testName: "user is member but not sufficient access", + ownerName: "org17", + repoName: "big_test_private_4", + repoID: 24, + doerID: 20, + title: "member-not-sufficient-access", + expectedStatus: http.StatusForbidden, + }, + { + testName: "project not created as title is empty", + ownerName: "org3", + repoName: "repo3", + repoID: 3, + doerID: 2, + title: "", + expectedStatus: http.StatusUnprocessableEntity, + }, + { + testName: "project not created as title is too long", + ownerName: "org3", + repoName: "repo3", + repoID: 3, + doerID: 2, + title: "This is a very long title that will exceed the maximum allowed size of 100 characters. It keeps going beyond the limit.", + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + defer tests.PrepareTestEnv(t)() + + for _, tt := range createRepoProjectFailTestCases { + t.Run(tt.testName, func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tt.doerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", tt.ownerName, tt.repoName), &api.CreateProjectOption{ + Title: tt.title, + }).AddTokenAuth(token) + MakeRequest(t, req, tt.expectedStatus) + }) + } + + for _, tt := range createRepoProjectSuccessTestCases { + t.Run(tt.testName, func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tt.doerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", tt.ownerName, tt.repoName), &api.CreateProjectOption{ + Title: tt.title, + Content: tt.content, + TemplateType: tt.templateType, + CardType: tt.cardType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, tt.title, apiProject.Title) + assert.Equal(t, tt.content, apiProject.Description) + assert.Equal(t, tt.templateType, apiProject.TemplateType) + assert.Equal(t, tt.cardType, apiProject.CardType) + assert.Equal(t, tt.repoID, apiProject.RepoID) + assert.Equal(t, tt.doerID, apiProject.CreatorID) + }) + } +} + +func TestAPIGetRepoProjects(t *testing.T) { + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository) + + expectedProjects := []*api.Project{ + { + Title: "First Project", + RepoID: 1, + IsClosed: false, + CreatorID: 2, + TemplateType: 1, + CardType: 2, + }, + { + Title: "project2 on repo1", + RepoID: 1, + IsClosed: false, + CreatorID: 2, + TemplateType: 1, + CardType: 2, + }, + } + + t.Run("failed to get projects repo not found", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo-not-found/projects").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("get projects successfully", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/projects").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiProjects []*api.Project + DecodeJSON(t, resp, &apiProjects) + assert.Equal(t, len(expectedProjects), len(apiProjects)) + for i, expectedProject := range expectedProjects { + assert.Equal(t, expectedProject.Title, apiProjects[i].Title) + assert.Equal(t, expectedProject.RepoID, apiProjects[i].RepoID) + assert.Equal(t, expectedProject.IsClosed, apiProjects[i].IsClosed) + assert.Equal(t, expectedProject.CreatorID, apiProjects[i].CreatorID) + assert.Equal(t, expectedProject.TemplateType, apiProjects[i].TemplateType) + assert.Equal(t, expectedProject.CardType, apiProjects[i].CardType) + } + }) +} + +func TestAPIUpdateIssueProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", "/api/v1/repos/user2/repo1/projects/issues", &api.UpdateIssuesOption{ + ProjectID: 10, + Issues: []int64{1, 2}, + }).AddTokenAuth(token) + + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_user_projects_test.go b/tests/integration/api_user_projects_test.go new file mode 100644 index 0000000000000..905f38550116c --- /dev/null +++ b/tests/integration/api_user_projects_test.go @@ -0,0 +1,174 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" +) + +func TestAPICreateUserProject(t *testing.T) { + createUserProjectSuccessTestCases := []struct { + testName string + ctxUserID int64 + doerID int64 + title string + content string + templateType uint8 + cardType uint8 + }{ + { + testName: "admin create project successfully", + ctxUserID: 1, + doerID: 1, + title: "site-admin", + content: "project_description", + templateType: 1, + cardType: 2, + }, + { + testName: "user create project successfully", + ctxUserID: 2, + doerID: 2, + title: "user", + content: "project_description", + templateType: 1, + cardType: 2, + }, + } + + createUserProjectFailTestCases := []struct { + testName string + ctxUserID int64 + doerID int64 + title string + expectedStatus int + }{ + { + testName: "failed to create project user is not admin and not owner", + ctxUserID: 1, + doerID: 2, + title: "user-not-admin-or-owner", + expectedStatus: http.StatusForbidden, + }, + { + testName: "project not created as title is empty", + ctxUserID: 2, + doerID: 2, + title: "", + expectedStatus: http.StatusUnprocessableEntity, + }, + { + testName: "project not created as title is too long", + ctxUserID: 2, + doerID: 2, + title: "This is a very long title that will exceed the maximum allowed size of 100 characters. It keeps going beyond the limit.", + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + defer tests.PrepareTestEnv(t)() + + for _, tt := range createUserProjectFailTestCases { + t.Run(tt.testName, func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tt.doerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteUser) + req := NewRequestWithJSON(t, "POST", "/api/v1/user/projects", &api.CreateProjectOption{ + Title: tt.title, + }).AddTokenAuth(token) + MakeRequest(t, req, tt.expectedStatus) + }) + } + + for _, tt := range createUserProjectSuccessTestCases { + t.Run(tt.testName, func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tt.doerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteUser) + req := NewRequestWithJSON(t, "POST", "/api/v1/user/projects", &api.CreateProjectOption{ + Title: tt.title, + Content: tt.content, + TemplateType: tt.templateType, + CardType: tt.cardType, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, tt.title, apiProject.Title) + assert.Equal(t, tt.content, apiProject.Description) + assert.Equal(t, tt.templateType, apiProject.TemplateType) + assert.Equal(t, tt.cardType, apiProject.CardType) + assert.Equal(t, tt.ctxUserID, apiProject.OwnerID) + assert.Equal(t, tt.doerID, apiProject.CreatorID) + + if tt.doerID != 1 { + assert.Equal(t, apiProject.CreatorID, apiProject.OwnerID) + } + }) + } +} + +func TestAPIGetUserProjects(t *testing.T) { + + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadUser) + + expectedProjects := []*api.Project{ + { + Title: "project on user2", + OwnerID: 2, + IsClosed: false, + CreatorID: 2, + TemplateType: 1, + CardType: 2, + }, + { + Title: "project without default column", + OwnerID: 2, + IsClosed: false, + CreatorID: 2, + TemplateType: 1, + CardType: 2, + }, + { + Title: "project with multiple default columns", + OwnerID: 2, + IsClosed: false, + CreatorID: 2, + TemplateType: 1, + CardType: 2, + }, + } + + t.Run("failed to get projects user not found", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/users/user-not-found/projects").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("get projects successfully", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/users/user2/projects").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiProjects []*api.Project + DecodeJSON(t, resp, &apiProjects) + assert.Equal(t, len(expectedProjects), len(apiProjects)) + for i, expectedProject := range expectedProjects { + assert.Equal(t, expectedProject.Title, apiProjects[i].Title) + assert.Equal(t, expectedProject.OwnerID, apiProjects[i].OwnerID) + assert.Equal(t, expectedProject.IsClosed, apiProjects[i].IsClosed) + assert.Equal(t, expectedProject.CreatorID, apiProjects[i].CreatorID) + assert.Equal(t, expectedProject.TemplateType, apiProjects[i].TemplateType) + assert.Equal(t, expectedProject.CardType, apiProjects[i].CardType) + } + }) +} From 6bd6adb6bd238e330cee2c04236222b9786f1da7 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Fri, 20 Sep 2024 11:32:14 +0300 Subject: [PATCH 20/20] docs(api): update swagger documentation for projects --- templates/swagger/v1_json.tmpl | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f8e8d68103c93..35cc26513de65 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2871,9 +2871,6 @@ }, "404": { "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } }, @@ -2919,9 +2916,6 @@ }, "422": { "$ref": "#/responses/validationError" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } } @@ -4413,7 +4407,7 @@ "tags": [ "project" ], - "summary": "Change an issue's project", + "summary": "Moves issues from a project to another in a repository", "operationId": "repoUpdateIssueProject", "parameters": [ { @@ -17902,9 +17896,6 @@ }, "422": { "$ref": "#/responses/validationError" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } } @@ -18781,9 +18772,6 @@ }, "404": { "$ref": "#/responses/notFound" - }, - "423": { - "$ref": "#/responses/repoArchivedError" } } }