diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000..84a8060d0d --- /dev/null +++ b/modules/structs/project.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import ( + "time" +) + +// Project represents a project +// 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"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` + // swagger:strfmt date-time + Closed *time.Time `json:"closed_at,omitempty"` +} + +// CreateProjectOption options for creating a project +// swagger:model +type CreateProjectOption struct { + // required: true + Title string `json:"title" binding:"Required"` + // Description of the project + Description string `json:"description"` + // BoardType: 0=none, 1=basic kanban, 2=bug triage + BoardType uint8 `json:"board_type"` + // CardType: 0=text only, 1=images and text + CardType uint8 `json:"card_type"` +} + +// EditProjectOption options for editing a project +// swagger:model +type EditProjectOption struct { + Title *string `json:"title"` + Description *string `json:"description"` +} + +// ProjectColumn represents a project column (board) +// swagger:model +type ProjectColumn struct { + ID int64 `json:"id"` + Title string `json:"title"` + Sorting int8 `json:"sorting"` + Color string `json:"color"` + ProjectID int64 `json:"project_id"` + Default bool `json:"default"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` +} + +// CreateProjectColumnOption options for creating a project column +// swagger:model +type CreateProjectColumnOption struct { + // required: true + Title string `json:"title" binding:"Required"` + Color string `json:"color"` +} + +// EditProjectColumnOption options for editing a project column +// swagger:model +type EditProjectColumnOption struct { + Title *string `json:"title"` + Color *string `json:"color"` + Sorting *int8 `json:"sorting"` +} + +// ProjectColumnIssue represents an issue on a project column +// swagger:model +type ProjectColumnIssue struct { + ID int64 `json:"id"` + IssueID int64 `json:"issue_id"` + ProjectID int64 `json:"project_id"` + ColumnID int64 `json:"column_id"` + Sorting int64 `json:"sorting"` +} + +// AddProjectColumnIssueOption options for adding an issue to a project column +// swagger:model +type AddProjectColumnIssueOption struct { + // required: true + IssueID int64 `json:"issue_id" binding:"Required"` +} + +// MoveProjectColumnIssueOption options for moving an issue between columns +// swagger:model +type MoveProjectColumnIssueOption struct { + // required: true + ColumnID int64 `json:"column_id" binding:"Required"` + Sorting int64 `json:"sorting"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2e7f057659..7e933ae922 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1602,6 +1602,29 @@ func Routes() *web.Router { Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) }) + m.Group("/projects", func() { + m.Combo("").Get(repo.ListProjects). + Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectOption{}), repo.CreateProject) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetProject). + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject) + m.Post("/{action}", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ChangeProjectStatus) + m.Group("/columns", func() { + m.Combo("").Get(repo.ListProjectColumns). + Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) + m.Group("/{columnId}", func() { + m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn) + m.Combo("/issues").Get(repo.ListProjectColumnIssues). + Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.AddProjectColumnIssueOption{}), repo.AddIssueToColumn) + }) + }) + m.Group("/issues/{issueId}", func() { + m.Patch("/move", reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.MoveProjectColumnIssueOption{}), repo.MoveIssueOnColumn) + m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.RemoveIssueFromProject) + }) + }) + }) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go new file mode 100644 index 0000000000..e55e852329 --- /dev/null +++ b/routers/api/v1/repo/project.go @@ -0,0 +1,766 @@ +// Copyright 2026 Moko Consulting. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "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/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListProjects lists projects for a repository +func ListProjects(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects project projectListProjects + // --- + // summary: List a repository's projects + // 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: state + // in: query + // description: state of the projects (open, closed, all) + // type: string + // - name: page + // in: query + // description: page number + // type: integer + // - name: limit + // in: query + // description: page size + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + state := ctx.FormString("state") + + opts := project_model.SearchOptions{ + ListOptions: listOptions, + RepoID: ctx.Repo.Repository.ID, + Type: project_model.TypeRepository, + } + switch state { + case "closed": + opts.IsClosed = optional.Some(true) + case "all": + // no filter + default: + opts.IsClosed = optional.Some(false) + } + + projects, total, err := db.FindAndCount[project_model.Project](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToAPIProjectList(projects)) +} + +// GetProject gets a project by ID +func GetProject(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id} project projectGetProject + // --- + // summary: Get a project + // 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 + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIProject(project)) +} + +// CreateProject creates a new project for a repository +func CreateProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects project projectCreateProject + // --- + // summary: Create a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateProjectOption) + + project := &project_model.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Description, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.BoardType), + CardType: project_model.CardType(form.CardType), + Type: project_model.TypeRepository, + } + + if err := project_model.NewProject(ctx, project); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIProject(project)) +} + +// EditProject edits a project +func EditProject(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/projects/{id} project projectEditProject + // --- + // summary: Edit a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectOption" + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditProjectOption) + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if form.Title != nil { + project.Title = *form.Title + } + if form.Description != nil { + project.Description = *form.Description + } + + if err := project_model.UpdateProject(ctx, project); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIProject(project)) +} + +// DeleteProject deletes a project +func DeleteProject(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id} project projectDeleteProject + // --- + // summary: Delete a project + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ChangeProjectStatus closes or reopens a project +func ChangeProjectStatus(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/{action} project projectChangeStatus + // --- + // summary: Close or reopen a project + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // - name: action + // in: path + // description: action (close or reopen) + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + action := ctx.PathParam("action") + isClosed := action == "close" + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"), isClosed); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListProjectColumns lists columns for a project +func ListProjectColumns(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns project projectListColumns + // --- + // summary: List a project's columns + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // description: project id + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectColumnList" + // "404": + // "$ref": "#/responses/notFound" + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiColumns := make([]*api.ProjectColumn, len(columns)) + for i, col := range columns { + apiColumns[i] = &api.ProjectColumn{ + ID: col.ID, + Title: col.Title, + Sorting: col.Sorting, + Color: col.Color, + ProjectID: col.ProjectID, + Default: col.Default, + Created: col.CreatedUnix.AsTime(), + Updated: col.UpdatedUnix.AsTime(), + } + } + + ctx.JSON(http.StatusOK, apiColumns) +} + +// CreateProjectColumn creates a new column in a project +func CreateProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns project projectCreateColumn + // --- + // summary: Create a column in a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // description: project id + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectColumn" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + column := &project_model.Column{ + Title: form.Title, + Color: form.Color, + ProjectID: project.ID, + CreatorID: ctx.Doer.ID, + } + + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, &api.ProjectColumn{ + ID: column.ID, + Title: column.Title, + Color: column.Color, + ProjectID: column.ProjectID, + Default: column.Default, + Created: column.CreatedUnix.AsTime(), + Updated: column.UpdatedUnix.AsTime(), + }) +} + +// DeleteProjectColumn deletes a project column +func DeleteProjectColumn(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/columns/{columnId} project projectDeleteColumn + // --- + // summary: Delete a project column + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // - name: columnId + // in: path + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + columnID := ctx.PathParamInt64("columnId") + projectID := ctx.PathParamInt64("id") + + // Verify project belongs to repo + if _, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, projectID); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if err := project_model.DeleteColumnByID(ctx, columnID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListProjectColumnIssues lists issues in a project column +func ListProjectColumnIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{columnId}/issues project projectListColumnIssues + // --- + // summary: List issues in a project column + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // - name: columnId + // in: path + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectColumnIssueList" + // "404": + // "$ref": "#/responses/notFound" + + projectID := ctx.PathParamInt64("id") + columnID := ctx.PathParamInt64("columnId") + + // Verify project belongs to repo + if _, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, projectID); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + column, err := project_model.GetColumnByIDAndProjectID(ctx, columnID, projectID) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + issues, err := column.GetIssues(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiIssues := make([]*api.ProjectColumnIssue, len(issues)) + for i, issue := range issues { + apiIssues[i] = &api.ProjectColumnIssue{ + ID: issue.ID, + IssueID: issue.IssueID, + ProjectID: issue.ProjectID, + ColumnID: issue.ProjectColumnID, + Sorting: issue.Sorting, + } + } + + ctx.JSON(http.StatusOK, apiIssues) +} + +// AddIssueToColumn adds an issue to a project column +func AddIssueToColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/{columnId}/issues project projectAddIssueToColumn + // --- + // summary: Add an issue to a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // - name: columnId + // in: path + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/AddProjectColumnIssueOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectColumnIssue" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.AddProjectColumnIssueOption) + projectID := ctx.PathParamInt64("id") + columnID := ctx.PathParamInt64("columnId") + + // Verify project belongs to repo + if _, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, projectID); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Get next sorting value + sorting, err := project_model.GetColumnIssueNextSorting(ctx, projectID, columnID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + pi := &project_model.ProjectIssue{ + IssueID: form.IssueID, + ProjectID: projectID, + ProjectColumnID: columnID, + Sorting: sorting, + } + + if _, err := db.GetEngine(ctx).Insert(pi); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, &api.ProjectColumnIssue{ + ID: pi.ID, + IssueID: pi.IssueID, + ProjectID: pi.ProjectID, + ColumnID: pi.ProjectColumnID, + Sorting: pi.Sorting, + }) +} + +// MoveIssueOnColumn moves an issue to a different column +func MoveIssueOnColumn(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/projects/{id}/issues/{issueId}/move project projectMoveIssue + // --- + // summary: Move an issue to a different project column + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // description: project id + // type: integer + // format: int64 + // required: true + // - name: issueId + // in: path + // description: issue id + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectColumnIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.MoveProjectColumnIssueOption) + projectID := ctx.PathParamInt64("id") + issueID := ctx.PathParamInt64("issueId") + + // Verify project belongs to repo + if _, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, projectID); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Update the column and sorting + _, err := db.GetEngine(ctx).Where("project_id = ? AND issue_id = ?", projectID, issueID). + Update(&project_model.ProjectIssue{ + ProjectColumnID: form.ColumnID, + Sorting: form.Sorting, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// RemoveIssueFromProject removes an issue from a project +func RemoveIssueFromProject(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/issues/{issueId} project projectRemoveIssue + // --- + // summary: Remove an issue from a project + // parameters: + // - name: owner + // in: path + // type: string + // required: true + // - name: repo + // in: path + // type: string + // required: true + // - name: id + // in: path + // type: integer + // format: int64 + // required: true + // - name: issueId + // in: path + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + projectID := ctx.PathParamInt64("id") + issueID := ctx.PathParamInt64("issueId") + + _, err := db.GetEngine(ctx).Where("project_id = ? AND issue_id = ?", projectID, issueID). + Delete(&project_model.ProjectIssue{}) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000..b66de746ca --- /dev/null +++ b/services/convert/project.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + project_model "code.gitea.io/gitea/models/project" + api "code.gitea.io/gitea/modules/structs" +) + +// ToAPIProject converts a Project to API format +func ToAPIProject(p *project_model.Project) *api.Project { + apiProject := &api.Project{ + ID: p.ID, + Title: p.Title, + Description: p.Description, + OwnerID: p.OwnerID, + RepoID: p.RepoID, + CreatorID: p.CreatorID, + IsClosed: p.IsClosed, + Created: p.CreatedUnix.AsTime(), + Updated: p.UpdatedUnix.AsTime(), + } + if p.IsClosed && p.ClosedDateUnix > 0 { + apiProject.Closed = p.ClosedDateUnix.AsTimePtr() + } + return apiProject +} + +// ToAPIProjectList converts a list of Projects to API format +func ToAPIProjectList(projects []*project_model.Project) []*api.Project { + result := make([]*api.Project, len(projects)) + for i := range projects { + result[i] = ToAPIProject(projects[i]) + } + return result +}