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
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:
@@ -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))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user