Skip to content
61 changes: 61 additions & 0 deletions models/project/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,64 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs,
_, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{})
return err
}

// AddOrUpdateIssueToColumn adds an issue to a project column or moves an existing one
func AddOrUpdateIssueToColumn(ctx context.Context, issueID int64, column *Column) error {
// Check if the issue is already in this project
existingPI := &ProjectIssue{}
has, err := db.GetEngine(ctx).Where("project_id=? AND issue_id=?", column.ProjectID, issueID).Get(existingPI)
if err != nil {
return err
}

// If already exists, just update the column
if has {
if existingPI.ProjectColumnID == column.ID {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that the function name should be AddOrUpdatexxxx?

// Already in this column, nothing to do
return nil
}
// Move to new column - need to update sorting
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
Table("project_issue").
Where("project_id=?", column.ProjectID).
And("project_board_id=?", column.ID).
Get(&res); err != nil {
return err
}

nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
existingPI.ProjectColumnID = column.ID
existingPI.Sorting = nextSorting
_, err = db.GetEngine(ctx).ID(existingPI.ID).Cols("project_board_id", "sorting").Update(existingPI)
return err
}

// Calculate next sorting value
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
Table("project_issue").
Where("project_id=?", column.ProjectID).
And("project_board_id=?", column.ID).
Get(&res); err != nil {
return err
}

nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)

// Create new ProjectIssue
pi := &ProjectIssue{
IssueID: issueID,
ProjectID: column.ProjectID,
ProjectColumnID: column.ID,
Sorting: nextSorting,
}

return db.Insert(ctx, pi)
}
139 changes: 139 additions & 0 deletions modules/structs/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package structs

import (
"time"
)

// Project represents a project
// swagger:model
type Project struct {
// Unique identifier of the project
ID int64 `json:"id"`
// Project title
Title string `json:"title"`
// Project description
Description string `json:"description"`
// Owner ID (for organization or user projects)
OwnerID int64 `json:"owner_id,omitempty"`
// Repository ID (for repository projects)
RepoID int64 `json:"repo_id,omitempty"`
// Creator ID
CreatorID int64 `json:"creator_id"`
// Whether the project is closed
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"`
// Number of open issues
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
// Number of closed issues
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
// Total number of issues
NumIssues int64 `json:"num_issues,omitempty"`
// Created time
// swagger:strfmt date-time
Created time.Time `json:"created"`
// Updated time
// swagger:strfmt date-time
Updated time.Time `json:"updated"`
// Closed time
// swagger:strfmt date-time
ClosedDate *time.Time `json:"closed_date,omitempty"`
// Project URL
URL string `json:"url,omitempty"`
}

// CreateProjectOption represents options for creating a project
// swagger:model
type CreateProjectOption struct {
// required: true
Title string `json:"title" binding:"Required"`
// Project description
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"`
}

// EditProjectOption represents options for editing a project
// swagger:model
type EditProjectOption struct {
// Project title
Title *string `json:"title,omitempty"`
// Project description
Description *string `json:"description,omitempty"`
// Card type: 0=text_only, 1=images_and_text
CardType *int `json:"card_type,omitempty"`
// Whether the project is closed
IsClosed *bool `json:"is_closed,omitempty"`
}

// ProjectColumn represents a project column (board)
// swagger:model
type ProjectColumn struct {
// Unique identifier of the column
ID int64 `json:"id"`
// Column title
Title string `json:"title"`
// Whether this is the default column
Default bool `json:"default"`
// Sorting order
Sorting int `json:"sorting"`
// Column color (hex format)
Color string `json:"color,omitempty"`
// Project ID
ProjectID int64 `json:"project_id"`
// Creator ID
CreatorID int64 `json:"creator_id"`
// Number of issues in this column
NumIssues int64 `json:"num_issues,omitempty"`
// Created time
// swagger:strfmt date-time
Created time.Time `json:"created"`
// Updated time
// swagger:strfmt date-time
Updated time.Time `json:"updated"`
}

// CreateProjectColumnOption represents options for creating a project column
// swagger:model
type CreateProjectColumnOption struct {
// required: true
Title string `json:"title" binding:"Required"`
// Column color (hex format, e.g., #FF0000)
Color string `json:"color,omitempty"`
}

// EditProjectColumnOption represents options for editing a project column
// swagger:model
type EditProjectColumnOption struct {
// Column title
Title *string `json:"title,omitempty"`
// Column color (hex format)
Color *string `json:"color,omitempty"`
// Sorting order
Sorting *int `json:"sorting,omitempty"`
}

// MoveProjectColumnOption represents options for moving a project column
// swagger:model
type MoveProjectColumnOption struct {
// Position to move the column to (0-based index)
// required: true
Position int `json:"position" binding:"Required"`
}

// AddIssueToProjectColumnOption represents options for adding an issue to a project column
// swagger:model
type AddIssueToProjectColumnOption struct {
// Issue ID to add to the column
// required: true
IssueID int64 `json:"issue_id" binding:"Required"`
}
17 changes: 17 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1585,6 +1585,23 @@ 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), mustNotBeArchived, 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.Combo("/columns").Get(repo.ListProjectColumns).
Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
})
m.Group("/columns/{id}", func() {
m.Combo("").
Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectColumnOption{}), repo.EditProjectColumn).
Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn)
m.Post("/issues", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.AddIssueToProjectColumnOption{}), repo.AddIssueToProjectColumn)
})
}, reqRepoReader(unit.TypeProjects))
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))

Expand Down
Loading