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
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))))
+5 -4
View File
@@ -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)
+36 -18
View File
@@ -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) {