diff --git a/models/issues/issue.go b/models/issues/issue.go index 838d41a300556..8cf45a2cffebe 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 f78daf77f8858..e691ab59b4599 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 fd29727a4365e..034e469ad4774 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -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"` 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 cd2ffbe719595..db59f2d426a8a 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"` + Projects []*ProjectMeta `json:"projects"` // 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 61f11d8f191bf..ac8a260607a1d 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -22,7 +22,7 @@ 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 @@ -30,10 +30,10 @@ func ToIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss // 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{} } @@ -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{} } @@ -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 } diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000000..3d5276a30ee2b --- /dev/null +++ b/services/convert/project.go @@ -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 +} diff --git a/services/convert/pull.go b/services/convert/pull.go index 5c7c99f2cef78..a587dcc83be01 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, + Projects: apiIssue.Projects, Assignee: apiIssue.Assignee, Assignees: util.SliceNilAsEmpty(apiIssue.Assignees), State: apiIssue.State, @@ -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, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 48a40eae08237..f65ff71e1d33d 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -26381,6 +26381,13 @@ "format": "int64", "x-go-name": "PinOrder" }, + "projects": { + "type": "array", + "items": { + "$ref": "#/definitions/ProjectMeta" + }, + "x-go-name": "Projects" + }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -27727,6 +27734,59 @@ }, "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": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" + }, + "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", @@ -27942,6 +28002,13 @@ "format": "int64", "x-go-name": "PinOrder" }, + "projects": { + "type": "array", + "items": { + "$ref": "#/definitions/ProjectMeta" + }, + "x-go-name": "Projects" + }, "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 8dc9e31cfa72d..2a58b4b0392d0 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -14,6 +14,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -23,6 +24,7 @@ import ( "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIIssue(t *testing.T) { @@ -500,3 +502,129 @@ func testAPIIssueContentVersion(t *testing.T) { MakeRequest(t, req, http.StatusCreated) }) } + +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) + + require.Len(t, apiIssue.Projects, 1) + assert.Equal(t, int64(1), apiIssue.Projects[0].ID) + assert.Equal(t, "First project", apiIssue.Projects[0].Title) + assert.Equal(t, int64(1), apiIssue.Projects[0].ColumnID) + assert.Equal(t, "To Do", apiIssue.Projects[0].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) + + require.Len(t, apiIssue.Projects, 1) + assert.Equal(t, int64(1), apiIssue.Projects[0].ID) + assert.Equal(t, int64(0), apiIssue.Projects[0].ColumnID) + assert.Empty(t, apiIssue.Projects[0].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.Empty(t, apiIssue.Projects) + }) + + t.Run("PublicOrgProjectVisibleToNonMember", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // org3 (public) has repo32 (public) with issue 16 + // user2 is in org3, user8 is not + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + orgRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32}) + issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + orgProject := project_model.Project{ + Title: "public org project", + OwnerID: org3.ID, + Type: project_model.TypeOrganization, + } + require.NoError(t, project_model.NewProject(t.Context(), &orgProject)) + require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user2, orgProject.ID, 0)) + + // user8 (not in org3) should still see the project because org3 is public + token8 := getTokenForLoggedInUser(t, loginUser(t, "user8"), auth_model.AccessTokenScopeReadIssue) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", orgRepo.OwnerName, orgRepo.Name, issue16.Index)).AddTokenAuth(token8) + resp := MakeRequest(t, req, http.StatusOK) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + require.Len(t, apiIssue.Projects, 1, "public org project should be visible to non-members") + assert.Equal(t, orgProject.ID, apiIssue.Projects[0].ID) + }) +} + +func TestAPIIssuePrivateOrgProjectHidden(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // privated_org (id=23, visibility=private) has public repo (id=40) + // user5 is in privated_org, user2 is not + privateOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) + publicRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 40}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + issue := &issues_model.Issue{ + RepoID: publicRepo.ID, + Title: "test for private org project", + PosterID: user1.ID, + } + require.NoError(t, issues_model.NewIssue(t.Context(), publicRepo, issue, nil, nil)) + + orgProject := project_model.Project{ + Title: "private org project", + OwnerID: privateOrg.ID, + Type: project_model.TypeOrganization, + } + require.NoError(t, project_model.NewProject(t.Context(), &orgProject)) + require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, user1, orgProject.ID, 0)) + + t.Run("AdminCanSee", func(t *testing.T) { + token1 := getTokenForLoggedInUser(t, loginUser(t, "user1"), auth_model.AccessTokenScopeReadIssue) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", publicRepo.OwnerName, publicRepo.Name, issue.Index)).AddTokenAuth(token1) + resp := MakeRequest(t, req, http.StatusOK) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + require.Len(t, apiIssue.Projects, 1, "admin should see private org project") + assert.Equal(t, orgProject.ID, apiIssue.Projects[0].ID) + }) + + t.Run("MemberWithoutProjectsAccess", func(t *testing.T) { + // user5 is in org23 (team17) but team17 only has Actions (type=9) access, + // not Projects (type=8). So user5 can access the repo but not org projects. + token5 := getTokenForLoggedInUser(t, loginUser(t, "user5"), auth_model.AccessTokenScopeReadIssue) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", publicRepo.OwnerName, publicRepo.Name, issue.Index)).AddTokenAuth(token5) + resp := MakeRequest(t, req, http.StatusOK) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + assert.Empty(t, apiIssue.Projects, "org member without projects unit access should not see project") + }) +}