Allow multiple projects per issue and pull requests (#36784)
Add ability to add and remove multiple projects per issue and pull request. Resolve #12974 --------- Signed-off-by: Icy Avocado <avocado@ovacoda.com> Co-authored-by: Tyrone Yeh <siryeh@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: OpenCode (gpt-5.2-codex) <opencode@openai.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ func TestAPIIssue(t *testing.T) {
|
||||
t.Run("IssueContentVersion", testAPIIssueContentVersion)
|
||||
t.Run("CreateIssue", testAPICreateIssue)
|
||||
t.Run("CreateIssueParallel", testAPICreateIssueParallel)
|
||||
t.Run("IssueProjects", testAPIIssueProjects)
|
||||
}
|
||||
|
||||
func testAPIListIssues(t *testing.T) {
|
||||
@@ -496,3 +497,66 @@ func testAPIIssueContentVersion(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
})
|
||||
}
|
||||
|
||||
func testAPIIssueProjects(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, owner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
||||
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name)
|
||||
|
||||
// Create issue with a project
|
||||
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with project",
|
||||
Body: "test body",
|
||||
Projects: []int64{1},
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var apiIssue api.Issue
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Get issue should include projects
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index)).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Edit issue to remove projects
|
||||
emptyProjects := []int64{}
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index), &api.EditIssueOption{
|
||||
Projects: &emptyProjects,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Empty(t, apiIssue.Projects)
|
||||
|
||||
// Edit issue to add project back
|
||||
projects := []int64{1}
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index), &api.EditIssueOption{
|
||||
Projects: &projects,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Test invalid project ID
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with invalid project",
|
||||
Body: "test body",
|
||||
Projects: []int64{99999},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
|
||||
// Test project from different repo (project 2 is for repo 3)
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with inaccessible project",
|
||||
Body: "test body",
|
||||
Projects: []int64{2},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,26 @@ func TestMoveRepoProjectColumns(t *testing.T) {
|
||||
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
|
||||
}
|
||||
|
||||
func TestUpdateIssueProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
t.Run("AssignAndRemove", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
|
||||
"id": "",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateIssueProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
@@ -160,13 +180,13 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards := htmlDoc.Find(".sidebar-project-card")
|
||||
cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
|
||||
assert.Equal(t, 1, cards.Length())
|
||||
|
||||
title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis")
|
||||
title := cards.Find("a span.gt-ellipsis")
|
||||
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
|
||||
|
||||
columnCombo := cards.Find(".sidebar-project-column-combo")
|
||||
columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo")
|
||||
assert.Equal(t, 1, columnCombo.Length())
|
||||
|
||||
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
||||
@@ -181,16 +201,14 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "3", comboVal)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
|
||||
"id": "0",
|
||||
})
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp = sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards = htmlDoc.Find(".sidebar-project-card")
|
||||
cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
|
||||
assert.Equal(t, 0, cards.Length())
|
||||
}
|
||||
|
||||
@@ -293,15 +311,9 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), &project))
|
||||
|
||||
// Get the default column
|
||||
columns, err := project.GetColumns(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, columns)
|
||||
defaultColumnID := columns[0].ID
|
||||
|
||||
// Add issues to the project
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, project.ID, defaultColumnID))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID}))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))
|
||||
|
||||
sess := loginUser(t, "user1")
|
||||
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)
|
||||
|
||||
Reference in New Issue
Block a user