diff --git a/models/project/issue.go b/models/project/issue.go index c89f524305..85456b515c 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -33,6 +33,14 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } +func IsIssueInColumn(ctx context.Context, issueID, projectID, columnID int64) (bool, error) { + return db.GetEngine(ctx).Exist(&ProjectIssue{ + IssueID: issueID, + ProjectID: projectID, + ProjectColumnID: columnID, + }) +} + // GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column. func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) { res := struct { diff --git a/modules/structs/project.go b/modules/structs/project.go index e9eea60f5e..372d58ef97 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -7,32 +7,36 @@ import ( "time" ) -// Project represents a project +// Project represents a project. +// +// Gitea projects can only contain issues — note cards and pull requests are +// not modeled as project items. +// // swagger:model type Project struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - OwnerID int64 `json:"owner_id,omitempty"` - RepoID int64 `json:"repo_id,omitempty"` - CreatorID int64 `json:"creator_id"` - IsClosed bool `json:"is_closed"` - // Template type: 0=none, 1=basic_kanban, 2=bug_triage - TemplateType int `json:"template_type"` - // Card type: 0=text_only, 1=images_and_text - CardType int `json:"card_type"` - // Project type: 1=individual, 2=repository, 3=organization - Type int `json:"type"` - NumOpenIssues int64 `json:"num_open_issues,omitempty"` - NumClosedIssues int64 `json:"num_closed_issues,omitempty"` - NumIssues int64 `json:"num_issues,omitempty"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + OwnerID int64 `json:"owner_id,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + Creator *User `json:"creator,omitempty"` + State StateType `json:"state"` + // Template type: "none", "basic_kanban" or "bug_triage" + TemplateType string `json:"template_type"` + // Card type: "text_only" or "images_and_text" + CardType string `json:"card_type"` + // Project type: "individual", "repository" or "organization" + Type string `json:"type"` + NumOpenIssues int64 `json:"num_open_issues,omitempty"` + NumClosedIssues int64 `json:"num_closed_issues,omitempty"` + NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time - Created time.Time `json:"created"` + CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time - Updated time.Time `json:"updated"` + UpdatedAt time.Time `json:"updated_at"` // swagger:strfmt date-time - ClosedDate *time.Time `json:"closed_date,omitempty"` - URL string `json:"url,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + HTMLURL string `json:"html_url,omitempty"` } // CreateProjectOption represents options for creating a project @@ -41,10 +45,10 @@ type CreateProjectOption struct { // required: true Title string `json:"title" binding:"Required"` Description string `json:"description"` - // Template type: 0=none, 1=basic_kanban, 2=bug_triage - TemplateType int `json:"template_type"` - // Card type: 0=text_only, 1=images_and_text - CardType int `json:"card_type"` + // Template type: "none", "basic_kanban" or "bug_triage" + TemplateType string `json:"template_type"` + // Card type: "text_only" or "images_and_text" + CardType string `json:"card_type"` } // EditProjectOption represents options for editing a project @@ -52,10 +56,9 @@ type CreateProjectOption struct { type EditProjectOption struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` - // Card type: 0=text_only, 1=images_and_text - CardType *int `json:"card_type,omitempty"` - // State of the project (open or closed) - State *string `json:"state,omitempty"` + // Card type: "text_only" or "images_and_text" + CardType *string `json:"card_type,omitempty"` + State *StateType `json:"state,omitempty"` } // ProjectColumn represents a project column (board) @@ -67,12 +70,12 @@ type ProjectColumn struct { Sorting int `json:"sorting"` Color string `json:"color,omitempty"` ProjectID int64 `json:"project_id"` - CreatorID int64 `json:"creator_id"` + Creator *User `json:"creator,omitempty"` NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time - Created time.Time `json:"created"` + CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time - Updated time.Time `json:"updated"` + UpdatedAt time.Time `json:"updated_at"` } // CreateProjectColumnOption represents options for creating a project column @@ -80,7 +83,7 @@ type ProjectColumn struct { type CreateProjectColumnOption struct { // required: true Title string `json:"title" binding:"Required"` - // Column color (hex format, e.g. #FF0000) + // Column color in 6-digit hex format, e.g. #FF0000 Color string `json:"color,omitempty"` } @@ -88,7 +91,17 @@ type CreateProjectColumnOption struct { // swagger:model type EditProjectColumnOption struct { Title *string `json:"title,omitempty"` - // Column color (hex format) + // Column color in 6-digit hex format, e.g. #FF0000 Color *string `json:"color,omitempty"` Sorting *int `json:"sorting,omitempty"` } + +// MoveProjectIssueOption represents options for moving an issue between columns +// swagger:model +type MoveProjectIssueOption struct { + // Target column to move the issue into + // required: true + ColumnID int64 `json:"column_id" binding:"Required"` + // Optional sorting position within the target column + Sorting *int64 `json:"sorting,omitempty"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 33ade037b6..4ec826b8c2 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1592,6 +1592,7 @@ func Routes() *web.Router { m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) }) + m.Post("/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue) }) }, reqRepoReader(unit.TypeProjects)) }, repoAssignment(), checkTokenPublicOnly()) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index cc4110d139..ac323fefb8 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "net/http" "code.gitea.io/gitea/models/db" @@ -33,7 +34,7 @@ func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { return project } -func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { +func getRepoProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id")) if err != nil { if project_model.IsErrProjectColumnNotExist(err) { @@ -41,23 +42,42 @@ func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { } else { ctx.APIErrorInternal(err) } - return nil + return nil, nil } - p, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) if err != nil { if project_model.IsErrProjectNotExist(err) { ctx.APIErrorNotFound() } else { ctx.APIErrorInternal(err) } - return nil + return nil, nil } - if p.ID != ctx.PathParamInt64("id") { + if project.ID != ctx.PathParamInt64("id") { ctx.APIErrorNotFound() - return nil + return nil, nil } + project.Repo = ctx.Repo.Repository + return project, column +} - return column +func rejectIfClosed(ctx *context.APIContext, project *project_model.Project) bool { + if project.IsClosed { + ctx.APIError(http.StatusForbidden, "project is closed") + return true + } + return false +} + +func validateColumnColor(ctx *context.APIContext, color string) bool { + if color == "" { + return true + } + if !project_model.ColumnColorPattern.MatchString(color) { + ctx.APIError(http.StatusUnprocessableEntity, "color must be a 6-digit hex string like #FF0000") + return false + } + return true } // ListProjects lists all projects in a repository @@ -65,6 +85,7 @@ func ListProjects(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects // --- // summary: List projects in a repository + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. // produces: // - application/json // parameters: @@ -80,7 +101,7 @@ func ListProjects(ctx *context.APIContext) { // required: true // - name: state // in: query - // description: State of the project (open, closed) + // description: State of the project (open, closed, all) // type: string // enum: [open, closed, all] // default: open @@ -121,11 +142,10 @@ func ListProjects(ctx *context.APIContext) { for _, p := range projects { p.Repo = ctx.Repo.Repository } - apiProjects := convert.ToProjectList(ctx, projects) ctx.SetLinkHeader(count, listOptions.PageSize) ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, apiProjects) + ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer)) } // GetProject gets a single project @@ -133,6 +153,7 @@ func GetProject(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects/{id} repository repoGetProject // --- // summary: Get a single project + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. // produces: // - application/json // parameters: @@ -168,7 +189,7 @@ func GetProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer)) } // CreateProject creates a new project @@ -205,13 +226,24 @@ func CreateProject(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateProjectOption) + templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + cardType, err := convert.ProjectCardTypeFromString(form.CardType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + p := &project_model.Project{ RepoID: ctx.Repo.Repository.ID, Title: form.Title, Description: form.Description, CreatorID: ctx.Doer.ID, - TemplateType: project_model.TemplateType(form.TemplateType), - CardType: project_model.CardType(form.CardType), + TemplateType: templateType, + CardType: cardType, Type: project_model.TypeRepository, } @@ -221,7 +253,7 @@ func CreateProject(ctx *context.APIContext) { } p.Repo = ctx.Repo.Repository - ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p)) + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer)) } // EditProject updates a project @@ -274,10 +306,15 @@ func EditProject(ctx *context.APIContext) { Description: optional.FromPtr(form.Description), } if form.CardType != nil { - opts.CardType = optional.Some(project_model.CardType(*form.CardType)) + cardType, err := convert.ProjectCardTypeFromString(*form.CardType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + opts.CardType = optional.Some(cardType) } if form.State != nil { - switch api.StateType(*form.State) { + switch *form.State { case api.StateOpen: opts.IsClosed = optional.Some(false) case api.StateClosed: @@ -297,7 +334,7 @@ func EditProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer)) } // DeleteProject deletes a project @@ -399,7 +436,7 @@ func ListProjectColumns(ctx *context.APIContext) { ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) - ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns)) + ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer)) } // CreateProjectColumn creates a new column in a project @@ -435,6 +472,8 @@ func CreateProjectColumn(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": @@ -444,8 +483,14 @@ func CreateProjectColumn(ctx *context.APIContext) { if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + if !validateColumnColor(ctx, form.Color) { + return + } column := &project_model.Column{ Title: form.Title, @@ -459,7 +504,7 @@ func CreateProjectColumn(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column)) + ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer)) } // EditProjectColumn updates a column @@ -501,18 +546,27 @@ func EditProjectColumn(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" - column := getRepoProjectColumn(ctx) + project, column := getRepoProjectColumn(ctx) if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } form := web.GetForm(ctx).(*api.EditProjectColumnOption) + if form.Color != nil && !validateColumnColor(ctx, *form.Color) { + return + } + if form.Title != nil { column.Title = *form.Title } @@ -528,7 +582,7 @@ func EditProjectColumn(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column)) + ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer)) } // DeleteProjectColumn deletes a column @@ -562,13 +616,18 @@ func DeleteProjectColumn(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - column := getRepoProjectColumn(ctx) + project, column := getRepoProjectColumn(ctx) if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) @@ -583,6 +642,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues repository repoListProjectColumnIssues // --- // summary: List issues in a project column + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. // produces: // - application/json // parameters: @@ -622,7 +682,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - column := getRepoProjectColumn(ctx) + _, column := getRepoProjectColumn(ctx) if ctx.Written() { return } @@ -658,6 +718,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id} repository repoAddIssueToProjectColumn // --- // summary: Add an issue to a project column + // description: Gitea projects only contain issues — note cards and pull requests cannot be added. // consumes: // - application/json // produces: @@ -758,10 +819,13 @@ func RemoveIssueFromProjectColumn(ctx *context.APIContext) { // assignIssueToProjectColumn assigns an issue to a project column when add is true, // or removes the issue from any project assignment when add is false. func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { - column := getRepoProjectColumn(ctx) + project, column := getRepoProjectColumn(ctx) if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) if err != nil { @@ -778,11 +842,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { // Confirm the issue is currently in this specific column before removing, // since IssueAssignOrRemoveProject(projectID=0) clears the issue's project // assignment unconditionally. - exists, err := db.GetEngine(ctx).Exist(&project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: column.ProjectID, - ProjectColumnID: column.ID, - }) + exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID) if err != nil { ctx.APIErrorInternal(err) return @@ -804,3 +864,108 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { ctx.Status(http.StatusNoContent) } } + +// MoveProjectIssue moves an issue between columns of the same project (and optionally sets sorting). +func MoveProjectIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move repository repoMoveProjectIssue + // --- + // summary: Move an issue between columns of a project + // description: Atomically moves an existing project issue into a different column, optionally setting its sorting position. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + form := web.GetForm(ctx).(*api.MoveProjectIssueOption) + + column, err := project_model.GetColumn(ctx, form.ColumnID) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not exist") + } else { + ctx.APIErrorInternal(err) + } + return + } + if column.ProjectID != project.ID { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project") + return + } + + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + var sorting int64 + if form.Sorting != nil { + sorting = *form.Sorting + } else { + next, err := project_model.GetColumnIssueNextSorting(ctx, project.ID, column.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + sorting = next + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{sorting: issue.ID}); err != nil { + if errors.Is(err, project_service.ErrIssueNotInProject) { + ctx.APIErrorNotFound() + return + } + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 0fd6e36747..1598cc039b 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -243,4 +243,7 @@ type swaggerParameterBodies struct { CreateProjectColumnOption api.CreateProjectColumnOption // in:body EditProjectColumnOption api.EditProjectColumnOption + + // in:body + MoveProjectIssueOption api.MoveProjectIssueOption } diff --git a/services/convert/project.go b/services/convert/project.go index b9c81ccd35..c67c2c7157 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -5,76 +5,189 @@ package convert import ( "context" + "fmt" project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" ) -// ToProject converts a project_model.Project to api.Project -func ToProject(ctx context.Context, p *project_model.Project) *api.Project { +func ProjectTemplateTypeToString(t project_model.TemplateType) string { + switch t { + case project_model.TemplateTypeBasicKanban: + return "basic_kanban" + case project_model.TemplateTypeBugTriage: + return "bug_triage" + default: + return "none" + } +} + +func ProjectTemplateTypeFromString(s string) (project_model.TemplateType, error) { + switch s { + case "", "none": + return project_model.TemplateTypeNone, nil + case "basic_kanban": + return project_model.TemplateTypeBasicKanban, nil + case "bug_triage": + return project_model.TemplateTypeBugTriage, nil + default: + return 0, fmt.Errorf("invalid template_type %q (expected none, basic_kanban, bug_triage)", s) + } +} + +func ProjectCardTypeToString(t project_model.CardType) string { + switch t { + case project_model.CardTypeImagesAndText: + return "images_and_text" + default: + return "text_only" + } +} + +func ProjectCardTypeFromString(s string) (project_model.CardType, error) { + switch s { + case "", "text_only": + return project_model.CardTypeTextOnly, nil + case "images_and_text": + return project_model.CardTypeImagesAndText, nil + default: + return 0, fmt.Errorf("invalid card_type %q (expected text_only, images_and_text)", s) + } +} + +func ProjectTypeToString(t project_model.Type) string { + switch t { + case project_model.TypeIndividual: + return "individual" + case project_model.TypeRepository: + return "repository" + case project_model.TypeOrganization: + return "organization" + default: + return "" + } +} + +// loadProjectCreators batch-fetches creators for the given projects + columns and +// returns a map keyed by user ID. Errors are surfaced; missing users are silently +// skipped (their creator field stays nil), matching the convention of other list +// converters that tolerate deleted users. +func loadProjectCreators(ctx context.Context, projects []*project_model.Project, columns []*project_model.Column) (map[int64]*user_model.User, error) { + idSet := make(map[int64]struct{}) + for _, p := range projects { + if p.CreatorID > 0 { + idSet[p.CreatorID] = struct{}{} + } + } + for _, c := range columns { + if c.CreatorID > 0 { + idSet[c.CreatorID] = struct{}{} + } + } + if len(idSet) == 0 { + return map[int64]*user_model.User{}, nil + } + ids := make([]int64, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + users, err := user_model.GetUserByIDs(ctx, ids) + if err != nil { + return nil, err + } + result := make(map[int64]*user_model.User, len(users)) + for _, u := range users { + result[u.ID] = u + } + return result, nil +} + +// ToProject converts a project_model.Project to api.Project. +// Caller is expected to preload p.Repo / p.Owner to avoid N+1 lookups. +func ToProject(ctx context.Context, p *project_model.Project, doer *user_model.User) *api.Project { + creators, _ := loadProjectCreators(ctx, []*project_model.Project{p}, nil) + return toProject(ctx, p, doer, creators) +} + +func toProject(ctx context.Context, p *project_model.Project, doer *user_model.User, creators map[int64]*user_model.User) *api.Project { + state := api.StateOpen + if p.IsClosed { + state = api.StateClosed + } + project := &api.Project{ ID: p.ID, Title: p.Title, Description: p.Description, OwnerID: p.OwnerID, RepoID: p.RepoID, - CreatorID: p.CreatorID, - IsClosed: p.IsClosed, - TemplateType: int(p.TemplateType), - CardType: int(p.CardType), - Type: int(p.Type), + State: state, + TemplateType: ProjectTemplateTypeToString(p.TemplateType), + CardType: ProjectCardTypeToString(p.CardType), + Type: ProjectTypeToString(p.Type), NumOpenIssues: p.NumOpenIssues, NumClosedIssues: p.NumClosedIssues, NumIssues: p.NumIssues, - Created: p.CreatedUnix.AsTime(), - Updated: p.UpdatedUnix.AsTime(), + CreatedAt: p.CreatedUnix.AsTime(), + UpdatedAt: p.UpdatedUnix.AsTime(), } if p.ClosedDateUnix > 0 { t := p.ClosedDateUnix.AsTime() - project.ClosedDate = &t + project.ClosedAt = &t + } + + if creator, ok := creators[p.CreatorID]; ok { + project.Creator = ToUser(ctx, creator, doer) } - // Repo/Owner are expected to be preloaded by the caller to avoid N+1 lookups. if p.Type == project_model.TypeRepository && p.Repo != nil { - project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID) + project.HTMLURL = p.Repo.HTMLURL() + fmt.Sprintf("/projects/%d", p.ID) } else if p.Owner != nil { - project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID) + project.HTMLURL = p.Owner.HTMLURL(ctx) + fmt.Sprintf("/-/projects/%d", p.ID) } return project } -// ToProjectColumn converts a project_model.Column to api.ProjectColumn -func ToProjectColumn(ctx context.Context, column *project_model.Column) *api.ProjectColumn { - return &api.ProjectColumn{ +func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User) *api.ProjectColumn { + creators, _ := loadProjectCreators(ctx, nil, []*project_model.Column{column}) + return toProjectColumn(ctx, column, doer, creators) +} + +func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn { + apiColumn := &api.ProjectColumn{ ID: column.ID, Title: column.Title, Default: column.Default, Sorting: column.Sorting, Color: column.Color, ProjectID: column.ProjectID, - CreatorID: column.CreatorID, NumIssues: column.NumIssues, - Created: column.CreatedUnix.AsTime(), - Updated: column.UpdatedUnix.AsTime(), + CreatedAt: column.CreatedUnix.AsTime(), + UpdatedAt: column.UpdatedUnix.AsTime(), } + if creator, ok := creators[column.CreatorID]; ok { + apiColumn.Creator = ToUser(ctx, creator, doer) + } + return apiColumn } -// ToProjectList converts a list of project_model.Project to a list of api.Project -func ToProjectList(ctx context.Context, projects []*project_model.Project) []*api.Project { +func ToProjectList(ctx context.Context, projects []*project_model.Project, doer *user_model.User) []*api.Project { + creators, _ := loadProjectCreators(ctx, projects, nil) result := make([]*api.Project, len(projects)) for i, p := range projects { - result[i] = ToProject(ctx, p) + result[i] = toProject(ctx, p, doer, creators) } return result } -// ToProjectColumnList converts a list of project_model.Column to a list of api.ProjectColumn -func ToProjectColumnList(ctx context.Context, columns []*project_model.Column) []*api.ProjectColumn { +func ToProjectColumnList(ctx context.Context, columns []*project_model.Column, doer *user_model.User) []*api.ProjectColumn { + creators, _ := loadProjectCreators(ctx, nil, columns) result := make([]*api.ProjectColumn, len(columns)) for i, column := range columns { - result[i] = ToProjectColumn(ctx, column) + result[i] = toProjectColumn(ctx, column, doer, creators) } return result } diff --git a/services/projects/issue.go b/services/projects/issue.go index 6b4db40b82..b8e4390fef 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -17,6 +17,10 @@ import ( "code.gitea.io/gitea/modules/optional" ) +// ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move +// issues that aren't yet attached to the column's project. +var ErrIssueNotInProject = errors.New("all issues have to be added to a project first") + // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error { return db.WithTx(ctx, func(ctx context.Context) error { @@ -32,7 +36,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum return err } if int(count) != len(sortedIssueIDs) { - return errors.New("all issues have to be added to a project first") + return ErrIssueNotInProject } issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 6c0cd24f00..bec1272649 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13527,6 +13527,7 @@ }, "/repos/{owner}/{repo}/projects": { "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", "produces": [ "application/json" ], @@ -13558,7 +13559,7 @@ ], "type": "string", "default": "open", - "description": "State of the project (open, closed)", + "description": "State of the project (open, closed, all)", "name": "state", "in": "query" }, @@ -13634,6 +13635,7 @@ }, "/repos/{owner}/{repo}/projects/{id}": { "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", "produces": [ "application/json" ], @@ -13872,6 +13874,9 @@ "201": { "$ref": "#/responses/ProjectColumn" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -13924,6 +13929,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } @@ -13984,6 +13992,9 @@ "200": { "$ref": "#/responses/ProjectColumn" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -13995,6 +14006,7 @@ }, "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", "produces": [ "application/json" ], @@ -14059,6 +14071,7 @@ }, "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id}": { "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", "consumes": [ "application/json" ], @@ -14193,6 +14206,75 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Move an issue between columns of a project", + "operationId": "repoMoveProjectIssue", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -24160,7 +24242,7 @@ ], "properties": { "color": { - "description": "Column color (hex format, e.g. #FF0000)", + "description": "Column color in 6-digit hex format, e.g. #FF0000", "type": "string", "x-go-name": "Color" }, @@ -24179,9 +24261,8 @@ ], "properties": { "card_type": { - "description": "Card type: 0=text_only, 1=images_and_text", - "type": "integer", - "format": "int64", + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", "x-go-name": "CardType" }, "description": { @@ -24189,9 +24270,8 @@ "x-go-name": "Description" }, "template_type": { - "description": "Template type: 0=none, 1=basic_kanban, 2=bug_triage", - "type": "integer", - "format": "int64", + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", "x-go-name": "TemplateType" }, "title": { @@ -25326,7 +25406,7 @@ "type": "object", "properties": { "color": { - "description": "Column color (hex format)", + "description": "Column color in 6-digit hex format, e.g. #FF0000", "type": "string", "x-go-name": "Color" }, @@ -25347,9 +25427,8 @@ "type": "object", "properties": { "card_type": { - "description": "Card type: 0=text_only, 1=images_and_text", - "type": "integer", - "format": "int64", + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", "x-go-name": "CardType" }, "description": { @@ -25357,8 +25436,12 @@ "x-go-name": "Description" }, "state": { - "description": "State of the project (open or closed)", "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", "x-go-name": "State" }, "title": { @@ -27403,6 +27486,28 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "MoveProjectIssueOption": { + "description": "MoveProjectIssueOption represents options for moving an issue between columns", + "type": "object", + "required": [ + "column_id" + ], + "properties": { + "column_id": { + "description": "Target column to move the issue into", + "type": "integer", + "format": "int64", + "x-go-name": "ColumnID" + }, + "sorting": { + "description": "Optional sorting position within the target column", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + } + }, + "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", @@ -28066,43 +28171,41 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Gitea projects can only contain issues — note cards and pull requests are\nnot modeled as project items.", "type": "object", + "title": "Project represents a project.", "properties": { "card_type": { - "description": "Card type: 0=text_only, 1=images_and_text", - "type": "integer", - "format": "int64", + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", "x-go-name": "CardType" }, - "closed_date": { + "closed_at": { "type": "string", "format": "date-time", - "x-go-name": "ClosedDate" + "x-go-name": "ClosedAt" }, - "created": { + "created_at": { "type": "string", "format": "date-time", - "x-go-name": "Created" + "x-go-name": "CreatedAt" }, - "creator_id": { - "type": "integer", - "format": "int64", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/definitions/User" }, "description": { "type": "string", "x-go-name": "Description" }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, "id": { "type": "integer", "format": "int64", "x-go-name": "ID" }, - "is_closed": { - "type": "boolean", - "x-go-name": "IsClosed" - }, "num_closed_issues": { "type": "integer", "format": "int64", @@ -28128,10 +28231,18 @@ "format": "int64", "x-go-name": "RepoID" }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" + }, "template_type": { - "description": "Template type: 0=none, 1=basic_kanban, 2=bug_triage", - "type": "integer", - "format": "int64", + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", "x-go-name": "TemplateType" }, "title": { @@ -28139,19 +28250,14 @@ "x-go-name": "Title" }, "type": { - "description": "Project type: 1=individual, 2=repository, 3=organization", - "type": "integer", - "format": "int64", + "description": "Project type: \"individual\", \"repository\" or \"organization\"", + "type": "string", "x-go-name": "Type" }, - "updated": { + "updated_at": { "type": "string", "format": "date-time", - "x-go-name": "Updated" - }, - "url": { - "type": "string", - "x-go-name": "URL" + "x-go-name": "UpdatedAt" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -28164,15 +28270,13 @@ "type": "string", "x-go-name": "Color" }, - "created": { + "created_at": { "type": "string", "format": "date-time", - "x-go-name": "Created" + "x-go-name": "CreatedAt" }, - "creator_id": { - "type": "integer", - "format": "int64", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/definitions/User" }, "default": { "type": "boolean", @@ -28202,10 +28306,10 @@ "type": "string", "x-go-name": "Title" }, - "updated": { + "updated_at": { "type": "string", "format": "date-time", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -31499,7 +31603,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/EditProjectColumnOption" + "$ref": "#/definitions/MoveProjectIssueOption" } }, "redirect": { diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index f2740556e9..c3be285125 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -58,7 +58,7 @@ func testAPIListProjects(t *testing.T) { resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &projects) for _, project := range projects { - assert.False(t, project.IsClosed, "Project should be open") + assert.Equal(t, api.StateOpen, project.State, "Project should be open") } // Test state filter - all @@ -99,7 +99,7 @@ func testAPIGetProject(t *testing.T) { assert.Equal(t, project.Title, apiProject.Title) assert.Equal(t, project.ID, apiProject.ID) assert.Equal(t, repo.ID, apiProject.RepoID) - assert.NotEmpty(t, apiProject.URL) + assert.NotEmpty(t, apiProject.HTMLURL) // Test getting non-existent project req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name). @@ -117,8 +117,8 @@ func testAPICreateProject(t *testing.T) { req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ Title: "API Created Project", Description: "This is a test project created via API", - TemplateType: 1, // basic_kanban - CardType: 1, // images_and_text + TemplateType: "basic_kanban", + CardType: "images_and_text", }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) @@ -126,9 +126,9 @@ func testAPICreateProject(t *testing.T) { DecodeJSON(t, resp, &project) assert.Equal(t, "API Created Project", project.Title) assert.Equal(t, "This is a test project created via API", project.Description) - assert.Equal(t, 1, project.TemplateType) - assert.Equal(t, 1, project.CardType) - assert.False(t, project.IsClosed) + assert.Equal(t, "basic_kanban", project.TemplateType) + assert.Equal(t, "images_and_text", project.CardType) + assert.Equal(t, api.StateOpen, project.State) // Test creating with minimal data req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ @@ -209,8 +209,7 @@ func testAPIChangeProjectStatus(t *testing.T) { token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) - // Close via PATCH with state=closed - closed := "closed" + closed := api.StateClosed req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ State: &closed, }).AddTokenAuth(token) @@ -218,21 +217,19 @@ func testAPIChangeProjectStatus(t *testing.T) { var updatedProject api.Project DecodeJSON(t, resp, &updatedProject) - assert.True(t, updatedProject.IsClosed) - assert.NotNil(t, updatedProject.ClosedDate) + assert.Equal(t, api.StateClosed, updatedProject.State) + assert.NotNil(t, updatedProject.ClosedAt) - // Reopen via PATCH with state=open - open := "open" + open := api.StateOpen req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ State: &open, }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &updatedProject) - assert.False(t, updatedProject.IsClosed) + assert.Equal(t, api.StateOpen, updatedProject.State) - // Invalid state value must be rejected - bogus := "reopen" + bogus := api.StateType("reopen") req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ State: &bogus, }).AddTokenAuth(token)