Skip to content
Draft
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
1 change: 1 addition & 0 deletions modules/structs/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type Issue struct {
Attachments []*Attachment `json:"assets"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
Project *ProjectMeta `json:"project"`
// 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"`
Project *ProjectMeta `json:"project"`
// The primary assignee of the pull request
Assignee *User `json:"assignee"`
// The list of users assigned to the pull request
Expand Down
7 changes: 7 additions & 0 deletions services/convert/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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 {
apiIssue.Project = ToAPIProject(issue, issue.Project)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

afaik
owner/org-level projects are authorized separately from repo visibility, so an issue in a readable repo can still point to a project the caller is not allowed to read. Embedding issue.Project here without a project-read check can therefore leak private project metadata.

}

if err := issue.LoadAssignees(ctx); err != nil {
return &api.Issue{}
}
Expand Down
37 changes: 37 additions & 0 deletions services/convert/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package convert

import (
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
api "code.gitea.io/gitea/modules/structs"
)

// ToAPIProject converts a project to its API representation for embedding in issue/PR responses.
func ToAPIProject(issue *issues_model.Issue, p *project_model.Project) *api.ProjectMeta {
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 issue.ProjectBoardID > 0 {
result.ColumnID = issue.ProjectBoardID
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Will panic if called with nil.

result.Column = issue.ProjectBoardTitle
}

return result
}
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,
Project: apiIssue.Project,
Assignee: apiIssue.Assignee,
Assignees: util.SliceNilAsEmpty(apiIssue.Assignees),
State: apiIssue.State,
Expand Down Expand Up @@ -355,6 +356,7 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs
Body: apiIssue.Body,
Labels: apiIssue.Labels,
Milestone: apiIssue.Milestone,
Project: apiIssue.Project,
Assignee: apiIssue.Assignee,
Assignees: apiIssue.Assignees,
State: apiIssue.State,
Expand Down
53 changes: 53 additions & 0 deletions templates/swagger/v1_json.tmpl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions tests/integration/api_issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,55 @@ func TestAPISearchIssuesWithLabels(t *testing.T) {
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
}

func TestAPIIssueProjectMeta(t *testing.T) {
defer tests.PrepareTestEnv(t)()

repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getTokenForLoggedInUser(t, loginUser(t, owner.Name), auth_model.AccessTokenScopeReadIssue)

t.Run("IssueWithProject", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()

req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/1", owner.Name, repo.Name)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiIssue api.Issue
DecodeJSON(t, resp, &apiIssue)

assert.NotNil(t, apiIssue.Project)
assert.Equal(t, int64(1), apiIssue.Project.ID)
assert.Equal(t, "First project", apiIssue.Project.Title)
assert.Equal(t, int64(1), apiIssue.Project.ColumnID)
assert.Equal(t, "To Do", apiIssue.Project.Column)
})

t.Run("IssueWithProjectNoColumn", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()

req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/2", owner.Name, repo.Name)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiIssue api.Issue
DecodeJSON(t, resp, &apiIssue)

assert.NotNil(t, apiIssue.Project)
assert.Equal(t, int64(1), apiIssue.Project.ID)
assert.Equal(t, int64(0), apiIssue.Project.ColumnID)
assert.Empty(t, apiIssue.Project.Column)
})

t.Run("IssueWithoutProject", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()

repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID})
token2 := getTokenForLoggedInUser(t, loginUser(t, owner2.Name), auth_model.AccessTokenScopeReadIssue)

req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/1", owner2.Name, repo2.Name)).AddTokenAuth(token2)
resp := MakeRequest(t, req, http.StatusOK)
var apiIssue api.Issue
DecodeJSON(t, resp, &apiIssue)

assert.Nil(t, apiIssue.Project)
})
}