From 235248620e05fa4fb85ca480b0dcdc32e0f36cb5 Mon Sep 17 00:00:00 2001 From: Oleks Date: Tue, 12 May 2026 16:12:42 +0300 Subject: [PATCH] fix(api): filter list-column-issues by project_board_id ListProjectColumnIssues was returning every issue in the project regardless of which column was requested. The handler set ProjectIDs but had no way to constrain to a specific column, and applyProjectCondition only joined project_issue on project_id. Add IssuesOptions.ProjectColumnID and extend the single-project branch of applyProjectCondition to include project_board_id in the JOIN when set. Strengthen the integration test: previously it placed two issues in the project (default column) and asserted a column listing returned 2, which masked the bug. Now create two distinct columns, move one issue into each, and verify each column's listing returns exactly the issue it owns. Reported during e2e validation on git.oleks.space against the production fork (1.26.0-unstable-2026-05-11). Worth back-porting to upstream PR #37518. --- models/issues/issue_search.go | 9 +++- routers/api/v1/repo/project.go | 9 ++-- tests/integration/api_repo_project_test.go | 54 ++++++++++++++-------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 554b11e4bf..888f8aa74e 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -42,6 +42,7 @@ type IssuesOptions struct { //nolint:revive // export stutter SubscriberID int64 MilestoneIDs []int64 ProjectIDs []int64 + ProjectColumnID int64 IsClosed optional.Option[bool] IsPull optional.Option[bool] LabelIDs []int64 @@ -206,7 +207,13 @@ func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) { if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue"))) } else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project - sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0]) + if opts.ProjectColumnID > 0 { + sess.Join("INNER", "project_issue", + "issue.id = project_issue.issue_id AND project_issue.project_id = ? AND project_issue.project_board_id = ?", + projectIDs[0], opts.ProjectColumnID) + } else { + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0]) + } } else if len(projectIDs) > 1 { // multiple projects // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs)))) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 1cbea1c400..ec042f369e 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -694,10 +694,11 @@ func ListProjectColumnIssues(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) issuesOpts := &issues_model.IssuesOptions{ - Paginator: &listOptions, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - ProjectIDs: []int64{column.ProjectID}, - SortType: issues_model.SortTypeProjectColumnSorting, + Paginator: &listOptions, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + ProjectIDs: []int64{column.ProjectID}, + ProjectColumnID: column.ID, + SortType: issues_model.SortTypeProjectColumnSorting, } count, err := issues_model.CountIssues(ctx, issuesOpts) diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index ec62d4e962..0fc9ea25d2 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + projects_service "code.gitea.io/gitea/services/projects" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -542,8 +543,8 @@ func testAPIAddIssueToProjectColumn(t *testing.T) { func testAPIListProjectColumnIssues(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) project := &project_model.Project{ Title: "Project for Column Issues", @@ -555,35 +556,52 @@ func testAPIListProjectColumnIssues(t *testing.T) { err := project_model.NewProject(t.Context(), project) assert.NoError(t, err) - column := &project_model.Column{ - Title: "Column for Issues", + columnA := &project_model.Column{ + Title: "Column A", ProjectID: project.ID, CreatorID: owner.ID, } - err = project_model.NewColumn(t.Context(), column) + err = project_model.NewColumn(t.Context(), columnA) assert.NoError(t, err) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID}) + columnB := &project_model.Column{ + Title: "Column B", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), columnB) assert.NoError(t, err) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, owner, []int64{project.ID}) + + // Place issueA in columnA, issueB in columnB. + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID}) + assert.NoError(t, err) + err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnA, map[int64]int64{0: issueA.ID}) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{project.ID}) + assert.NoError(t, err) + err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnB, map[int64]int64{0: issueB.ID}) assert.NoError(t, err) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, column.ID). + // Column A should contain only issueA. + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID). AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) + var issuesA []api.Issue + DecodeJSON(t, resp, &issuesA) + assert.Len(t, issuesA, 1) + assert.Equal(t, issueA.ID, issuesA[0].ID) - var issues []api.Issue - DecodeJSON(t, resp, &issues) - assert.Len(t, issues, 2) - - issueIDs := make(map[int64]struct{}, len(issues)) - for _, apiIssue := range issues { - issueIDs[apiIssue.ID] = struct{}{} - } - assert.Contains(t, issueIDs, issue.ID) - assert.Contains(t, issueIDs, pull.ID) + // Column B should contain only issueB. + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnB.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var issuesB []api.Issue + DecodeJSON(t, resp, &issuesB) + assert.Len(t, issuesB, 1) + assert.Equal(t, issueB.ID, issuesB[0].ID) } func testAPIRemoveIssueFromProjectColumn(t *testing.T) {