From 36ea65a1dfd90096650fe6fbd09ed26b0118fd1e Mon Sep 17 00:00:00 2001 From: Basavaraj S m Date: Sat, 31 Oct 2026 22:01:34 +0530 Subject: [PATCH] fix(projects): hide linked pull requests the viewer cannot read (#40016) Project board cards list the pull requests that reference their issue, but loaded them without checking the viewer's access. A private pull request mentioning a public issue showed its title, number and state on that issue's board to anyone who can view the board, anonymous visitors included. Repository and organization boards now skip linked pull requests in repositories where the viewer cannot read pull requests, matching how the issue page already hides such references. --------- Co-authored-by: silverwind Co-authored-by: bircni Co-authored-by: Giteabot --- routers/web/org/projects.go | 23 +++-------------- routers/web/repo/projects.go | 23 +++-------------- services/projects/issue.go | 43 +++++++++++++++++++++++++++++++ tests/integration/project_test.go | 17 ++++++++++++ 4 files changed, 68 insertions(+), 38 deletions(-) diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 1c681358198..367c3464714 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -367,25 +367,10 @@ func ViewProject(ctx *context.Context) { ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap } - linkedPrsMap := make(map[int64][]*issues_model.Issue) - for _, issuesList := range issuesMap { - for _, issue := range issuesList { - var referencedIDs []int64 - for _, comment := range issue.Comments { - if comment.RefIssueID != 0 && comment.RefIsPull { - referencedIDs = append(referencedIDs, comment.RefIssueID) - } - } - - if len(referencedIDs) > 0 { - if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - IssueIDs: referencedIDs, - IsPull: optional.Some(true), - }); err == nil { - linkedPrsMap[issue.ID] = linkedPrs - } - } - } + linkedPrsMap, err := project_service.LoadLinkedPullRequests(ctx, issuesMap, ctx.Doer) + if err != nil { + ctx.ServerError("LoadLinkedPullRequests", err) + return } // TODO: Add option to filter also by repository specific labels diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 3d1644ff08a..829801f506c 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -347,25 +347,10 @@ func ViewProject(ctx *context.Context) { ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap } - linkedPrsMap := make(map[int64][]*issues_model.Issue) - for _, issuesList := range issuesMap { - for _, issue := range issuesList { - var referencedIDs []int64 - for _, comment := range issue.Comments { - if comment.RefIssueID != 0 && comment.RefIsPull { - referencedIDs = append(referencedIDs, comment.RefIssueID) - } - } - - if len(referencedIDs) > 0 { - if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - IssueIDs: referencedIDs, - IsPull: optional.Some(true), - }); err == nil { - linkedPrsMap[issue.ID] = linkedPrs - } - } - } + linkedPrsMap, err := project_service.LoadLinkedPullRequests(ctx, issuesMap, ctx.Doer) + if err != nil { + ctx.ServerError("LoadLinkedPullRequests", err) + return } ctx.Data["LinkedPRs"] = linkedPrsMap diff --git a/services/projects/issue.go b/services/projects/issue.go index bcb5a2ff966..f17950f871f 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -10,6 +10,7 @@ import ( "gitea.dev/models/db" issues_model "gitea.dev/models/issues" + access_model "gitea.dev/models/perm/access" project_model "gitea.dev/models/project" user_model "gitea.dev/models/user" "gitea.dev/modules/optional" @@ -258,6 +259,48 @@ func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, return results, nil } +// LoadLinkedPullRequests returns, per issue, the pull requests referencing it that the doer can read +func LoadLinkedPullRequests(ctx context.Context, issuesMap map[int64]issues_model.IssueList, doer *user_model.User) (map[int64][]*issues_model.Issue, error) { + linkedPrsMap := make(map[int64][]*issues_model.Issue) + canReadPulls := make(map[int64]bool) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + if len(referencedIDs) == 0 { + continue + } + + linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }) + if err != nil { + return nil, err + } + for _, pr := range linkedPrs { + canRead, ok := canReadPulls[pr.RepoID] + if !ok { + perm, err := access_model.GetDoerRepoPermission(ctx, pr.Repo, doer) + if err != nil { + return nil, err + } + canRead = perm.CanReadIssuesOrPulls(true) + canReadPulls[pr.RepoID] = canRead + } + if canRead { + linkedPrsMap[issue.ID] = append(linkedPrsMap[issue.ID], pr) + } + } + } + } + return linkedPrsMap, nil +} + // NumClosedIssues return counter of closed issues assigned to a project func loadNumClosedIssues(ctx context.Context, p *project_model.Project) error { cnt, err := db.GetEngine(ctx).Table("project_issue"). diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 6123f41ffb8..ffbbdec9748 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -377,6 +377,23 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { func TestProjects(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("PrivateLinkedPullRequest", func(t *testing.T) { + require.NoError(t, db.Insert(t.Context(), &issues_model.Comment{ + Type: issues_model.CommentTypePullRef, + PosterID: 2, + IssueID: 1, + RefRepoID: 3, + RefIssueID: 12, + RefIsPull: true, + })) + const linkedPR = `href="/org3/repo3/pulls/2"` + + resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/projects/1"), http.StatusOK) + assert.NotContains(t, resp.Body.String(), linkedPR) + + resp = loginUser(t, "user2").MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/projects/1"), http.StatusOK) + assert.Contains(t, resp.Body.String(), linkedPR) + }) t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) { _ = db.TruncateBeans(t.Context(), "project_issue", "issue_assignees") _ = db.Insert(t.Context(),