fix(api): filter list-column-issues by project_board_id
release-nightly / nightly-binary (push) Cancelled after 0s
release-nightly / nightly-container (push) Cancelled after 0s
cache-seeder / gobuild (push) Cancelled after 0s
cache-seeder / lint (lint-backend, bindata, lint-backend) (push) Cancelled after 0s

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.
This commit is contained in:
Oleks
2026-05-12 16:12:42 +03:00
parent 5a886307fd
commit 013e844724
3 changed files with 49 additions and 23 deletions
+8 -1
View File
@@ -42,6 +42,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
SubscriberID int64 SubscriberID int64
MilestoneIDs []int64 MilestoneIDs []int64
ProjectIDs []int64 ProjectIDs []int64
ProjectColumnID int64
IsClosed optional.Option[bool] IsClosed optional.Option[bool]
IsPull optional.Option[bool] IsPull optional.Option[bool]
LabelIDs []int64 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 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"))) sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project } 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 } else if len(projectIDs) > 1 { // multiple projects
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" // 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)))) sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
+5 -4
View File
@@ -694,10 +694,11 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
listOptions := utils.GetListOptions(ctx) listOptions := utils.GetListOptions(ctx)
issuesOpts := &issues_model.IssuesOptions{ issuesOpts := &issues_model.IssuesOptions{
Paginator: &listOptions, Paginator: &listOptions,
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
ProjectIDs: []int64{column.ProjectID}, ProjectIDs: []int64{column.ProjectID},
SortType: issues_model.SortTypeProjectColumnSorting, ProjectColumnID: column.ID,
SortType: issues_model.SortTypeProjectColumnSorting,
} }
count, err := issues_model.CountIssues(ctx, issuesOpts) count, err := issues_model.CountIssues(ctx, issuesOpts)
+36 -18
View File
@@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -542,8 +543,8 @@ func testAPIAddIssueToProjectColumn(t *testing.T) {
func testAPIListProjectColumnIssues(t *testing.T) { func testAPIListProjectColumnIssues(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
project := &project_model.Project{ project := &project_model.Project{
Title: "Project for Column Issues", Title: "Project for Column Issues",
@@ -555,35 +556,52 @@ func testAPIListProjectColumnIssues(t *testing.T) {
err := project_model.NewProject(t.Context(), project) err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err) assert.NoError(t, err)
column := &project_model.Column{ columnA := &project_model.Column{
Title: "Column for Issues", Title: "Column A",
ProjectID: project.ID, ProjectID: project.ID,
CreatorID: owner.ID, CreatorID: owner.ID,
} }
err = project_model.NewColumn(t.Context(), column) err = project_model.NewColumn(t.Context(), columnA)
assert.NoError(t, err) 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) 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) assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) 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) AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK) 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 // Column B should contain only issueB.
DecodeJSON(t, resp, &issues) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnB.ID).
assert.Len(t, issues, 2) AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
issueIDs := make(map[int64]struct{}, len(issues)) var issuesB []api.Issue
for _, apiIssue := range issues { DecodeJSON(t, resp, &issuesB)
issueIDs[apiIssue.ID] = struct{}{} assert.Len(t, issuesB, 1)
} assert.Equal(t, issueB.ID, issuesB[0].ID)
assert.Contains(t, issueIDs, issue.ID)
assert.Contains(t, issueIDs, pull.ID)
} }
func testAPIRemoveIssueFromProjectColumn(t *testing.T) { func testAPIRemoveIssueFromProjectColumn(t *testing.T) {