Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ type Issue struct {
Milestone *Milestone `xorm:"-"`
isMilestoneLoaded bool `xorm:"-"`
Project *project_model.Project `xorm:"-"`
ProjectBoardID int64 `xorm:"-"`
ProjectBoardTitle string `xorm:"-"`
isProjectLoaded bool `xorm:"-"`
Priority int
AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"`
Expand Down Expand Up @@ -377,6 +380,7 @@ func (issue *Issue) ResetAttributesLoaded() {
issue.isMilestoneLoaded = false
issue.isAttachmentsLoaded = false
issue.isAssigneeLoaded = false
issue.isProjectLoaded = false
}

// GetIsRead load the `IsRead` field of the issue
Expand Down
34 changes: 27 additions & 7 deletions models/issues/issue_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,36 +185,56 @@ func (issues IssueList) LoadMilestones(ctx context.Context) error {

func (issues IssueList) LoadProjects(ctx context.Context) error {
issueIDs := issues.getIssueIDs()
projectMaps := make(map[int64]*project_model.Project, len(issues))
left := len(issueIDs)

type projectWithIssueID struct {
*project_model.Project `xorm:"extends"`
IssueID int64
project_model.Project `xorm:"extends"`
IssueID int64
ProjectColumnID int64 `xorm:"'project_board_id'"`
ColumnTitle string `xorm:"'column_title'"`
}

type issueProjectInfo struct {
project *project_model.Project
columnID int64
columnTitle string
}

infoMap := make(map[int64]*issueProjectInfo, len(issues))

for left > 0 {
limit := min(left, db.DefaultMaxInSize)

projects := make([]*projectWithIssueID, 0, limit)
err := db.GetEngine(ctx).
Table("project").
Select("project.*, project_issue.issue_id").
Select("project.*, project_issue.issue_id, project_issue.project_board_id, project_board.title AS column_title").
Join("INNER", "project_issue", "project.id = project_issue.project_id").
Join("LEFT", "project_board", "project_board.id = project_issue.project_board_id").
In("project_issue.issue_id", issueIDs[:limit]).
Find(&projects)
if err != nil {
return err
}
for _, project := range projects {
projectMaps[project.IssueID] = project.Project
for _, row := range projects {
p := row.Project
infoMap[row.IssueID] = &issueProjectInfo{
project: &p,
columnID: row.ProjectColumnID,
columnTitle: row.ColumnTitle,
}
}
left -= limit
issueIDs = issueIDs[limit:]
}

for _, issue := range issues {
issue.Project = projectMaps[issue.ID]
if info, ok := infoMap[issue.ID]; ok {
issue.Project = info.project
issue.ProjectBoardID = info.columnID
issue.ProjectBoardTitle = info.columnTitle
}
issue.isProjectLoaded = true
}
return nil
}
Expand Down
39 changes: 28 additions & 11 deletions models/issues/issue_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,35 @@ import (

// LoadProject load the project the issue was assigned to
func (issue *Issue) LoadProject(ctx context.Context) (err error) {
if issue.Project == nil {
var p project_model.Project
has, err := db.GetEngine(ctx).Table("project").
Join("INNER", "project_issue", "project.id=project_issue.project_id").
Where("project_issue.issue_id = ?", issue.ID).Get(&p)
if err != nil {
return err
} else if has {
issue.Project = &p
}
if issue.isProjectLoaded {
return nil
}

type projectWithColumn struct {
project_model.Project `xorm:"extends"`
ProjectColumnID int64 `xorm:"'project_board_id'"`
ColumnTitle string `xorm:"'column_title'"`
}

var result projectWithColumn
has, err := db.GetEngine(ctx).
Table("project").
Select("project.*, project_issue.project_board_id, project_board.title AS column_title").
Join("INNER", "project_issue", "project.id = project_issue.project_id").
Join("LEFT", "project_board", "project_board.id = project_issue.project_board_id").
Where("project_issue.issue_id = ?", issue.ID).
Get(&result)
if err != nil {
return err
}
if has {
p := result.Project
issue.Project = &p
issue.ProjectBoardID = result.ProjectColumnID
issue.ProjectBoardTitle = result.ColumnTitle
}
return err
issue.isProjectLoaded = true
return nil
}

func (issue *Issue) projectID(ctx context.Context) int64 {
Expand Down
27 changes: 14 additions & 13 deletions modules/structs/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,20 @@ type RepositoryMeta struct {
// Issue represents an issue in a repository
// swagger:model
type Issue struct {
ID int64 `json:"id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
Index int64 `json:"number"`
Poster *User `json:"user"`
OriginalAuthor string `json:"original_author"`
OriginalAuthorID int64 `json:"original_author_id"`
Title string `json:"title"`
Body string `json:"body"`
Ref string `json:"ref"`
Attachments []*Attachment `json:"assets"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
ID int64 `json:"id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
Index int64 `json:"number"`
Poster *User `json:"user"`
OriginalAuthor string `json:"original_author"`
OriginalAuthorID int64 `json:"original_author_id"`
Title string `json:"title"`
Body string `json:"body"`
Ref string `json:"ref"`
Attachments []*Attachment `json:"assets"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
Projects []*ProjectMeta `json:"projects"`
// deprecated
Assignee *User `json:"assignee"`
Assignees []*User `json:"assignees"`
Expand Down
23 changes: 23 additions & 0 deletions modules/structs/issue_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package structs

import "time"

// ProjectMeta represents a project board summary embedded in issue/PR responses
// swagger:model
type ProjectMeta struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
State StateType `json:"state"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Updated *time.Time `json:"updated_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at"`
ColumnID int64 `json:"column_id"`
Column string `json:"column"`
}
3 changes: 2 additions & 1 deletion modules/structs/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ type PullRequest struct {
// The labels attached to the pull request
Labels []*Label `json:"labels"`
// The milestone associated with the pull request
Milestone *Milestone `json:"milestone"`
Milestone *Milestone `json:"milestone"`
Projects []*ProjectMeta `json:"projects"`
// The primary assignee of the pull request
Assignee *User `json:"assignee"`
// The list of users assigned to the pull request
Expand Down
16 changes: 12 additions & 4 deletions services/convert/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ import (
)

func ToIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) *api.Issue {
return toIssue(ctx, doer, issue, WebAssetDownloadURL)
return toIssue(ctx, doer, issue, cache.NewEphemeralCache(), WebAssetDownloadURL)
}

// ToAPIIssue converts an Issue to API format
// it assumes some fields assigned with values:
// Required - Poster, Labels,
// Optional - Milestone, Assignee, PullRequest
func ToAPIIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) *api.Issue {
return toIssue(ctx, doer, issue, APIAssetDownloadURL)
return toIssue(ctx, doer, issue, cache.NewEphemeralCache(), APIAssetDownloadURL)
}

func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Issue {
func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, permCache *cache.EphemeralCache, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Issue {
if err := issue.LoadPoster(ctx); err != nil {
return &api.Issue{}
}
Expand Down Expand Up @@ -95,6 +95,13 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss
apiIssue.Milestone = ToAPIMilestone(issue.Milestone)
}

if err := issue.LoadProject(ctx); err != nil {
return &api.Issue{}
}
if issue.Project != nil && canDoerSeeProject(ctx, permCache, doer, issue.Project) {
apiIssue.Projects = []*api.ProjectMeta{ToAPIProject(issue.Project, issue.ProjectBoardID, issue.ProjectBoardTitle)}
}

if err := issue.LoadAssignees(ctx); err != nil {
return &api.Issue{}
}
Expand Down Expand Up @@ -141,8 +148,9 @@ func ToIssueList(ctx context.Context, doer *user_model.User, il issues_model.Iss
func ToAPIIssueList(ctx context.Context, doer *user_model.User, il issues_model.IssueList) []*api.Issue {
result := make([]*api.Issue, len(il))
_ = il.LoadPinOrder(ctx)
permCache := cache.NewEphemeralCache()
for i := range il {
result[i] = ToAPIIssue(ctx, doer, il[i])
result[i] = toIssue(ctx, doer, il[i], permCache, APIAssetDownloadURL)
}
return result
}
Expand Down
74 changes: 74 additions & 0 deletions services/convert/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package convert

import (
"context"

"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache"
api "code.gitea.io/gitea/modules/structs"
)

// ToAPIProject converts a project to its API representation for embedding in issue/PR responses.
func ToAPIProject(p *project_model.Project, columnID int64, columnTitle string) *api.ProjectMeta {
if p == nil {
return nil
}

state := api.StateOpen
if p.IsClosed {
state = api.StateClosed
}

result := &api.ProjectMeta{
ID: p.ID,
Title: p.Title,
Description: p.Description,
State: state,
Created: p.CreatedUnix.AsTime(),
Updated: p.UpdatedUnix.AsTimePtr(),
}
if p.IsClosed {
result.Closed = p.ClosedDateUnix.AsTimePtr()
}

if columnID > 0 {
result.ColumnID = columnID
result.Column = columnTitle
}

return result
}

// canDoerSeeProject checks if the doer has permission to see a project.
// For repo-level projects, repo read access is sufficient (already checked by API handler).
// For org/user-level projects, checks org visibility and projects unit permission.
// Results are cached per owner ID in the provided EphemeralCache.
func canDoerSeeProject(ctx context.Context, permCache *cache.EphemeralCache, doer *user_model.User, p *project_model.Project) bool {
if p.RepoID > 0 {
return true
}
if p.OwnerID == 0 {
return false
}
if doer != nil && doer.IsAdmin {
return true
}
accessMode, _ := cache.GetWithEphemeralCache(ctx, permCache, "org-project-perm", p.OwnerID, func(ctx context.Context, ownerID int64) (perm.AccessMode, error) {
owner, err := user_model.GetUserByID(ctx, ownerID)
if err != nil {
return perm.AccessModeNone, err
}
if !organization.HasOrgOrUserVisible(ctx, owner, doer) {
return perm.AccessModeNone, nil
}
return organization.OrgFromUser(owner).UnitPermission(ctx, doer, unit.TypeProjects), nil
})
return accessMode >= perm.AccessModeRead
}
2 changes: 2 additions & 0 deletions services/convert/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
Body: apiIssue.Body,
Labels: apiIssue.Labels,
Milestone: apiIssue.Milestone,
Projects: apiIssue.Projects,
Assignee: apiIssue.Assignee,
Assignees: util.SliceNilAsEmpty(apiIssue.Assignees),
State: apiIssue.State,
Expand Down Expand Up @@ -356,6 +357,7 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs
Body: apiIssue.Body,
Labels: apiIssue.Labels,
Milestone: apiIssue.Milestone,
Projects: apiIssue.Projects,
Assignee: apiIssue.Assignee,
Assignees: apiIssue.Assignees,
State: apiIssue.State,
Expand Down
Loading