diff --git a/models/issues/issue.go b/models/issues/issue.go index 655cdebdfc6b9..4a394663a51e6 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -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:"-"` @@ -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 diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go index 26b93189b8bed..bb932f75ad2cd 100644 --- a/models/issues/issue_list.go +++ b/models/issues/issue_list.go @@ -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 } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 01852447834c7..be570fd7e55bd 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -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 { diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 2540481d0ffcc..8187f58a3916c 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -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"` diff --git a/modules/structs/issue_project.go b/modules/structs/issue_project.go new file mode 100644 index 0000000000000..53f7880c447b2 --- /dev/null +++ b/modules/structs/issue_project.go @@ -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"` +} diff --git a/modules/structs/pull.go b/modules/structs/pull.go index 3ad2f78bd3444..5b02d45804f6e 100644 --- a/modules/structs/pull.go +++ b/modules/structs/pull.go @@ -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 diff --git a/services/convert/issue.go b/services/convert/issue.go index acd67fece4c47..1932a018919b9 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -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) + } + if err := issue.LoadAssignees(ctx); err != nil { return &api.Issue{} } diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000000..fe7b6769d1db9 --- /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 ( + 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 + result.Column = issue.ProjectBoardTitle + } + + return result +} diff --git a/services/convert/pull.go b/services/convert/pull.go index bb675811f2d38..bde504009a1fe 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -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, @@ -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, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index adc6c18175512..2c4319341680f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -26261,6 +26261,9 @@ "format": "int64", "x-go-name": "PinOrder" }, + "project": { + "$ref": "#/definitions/ProjectMeta" + }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -27574,6 +27577,53 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ProjectMeta": { + "description": "ProjectMeta represents a project board summary embedded in issue/PR responses", + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Closed" + }, + "column": { + "type": "string", + "x-go-name": "Column" + }, + "column_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ColumnID" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "state": { + "$ref": "#/definitions/StateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -27783,6 +27833,9 @@ "format": "int64", "x-go-name": "PinOrder" }, + "project": { + "$ref": "#/definitions/ProjectMeta" + }, "requested_reviewers": { "description": "The users requested to review the pull request", "type": "array", diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 8d85543dc8a83..3fc984dce0f9c 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -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) + }) +}