From 5c141d3c9be02ad86141b60e03a5df0e49db9633 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 2 Apr 2026 20:58:09 -0700 Subject: [PATCH] some improvements --- modules/structs/project.go | 8 -- routers/api/v1/api.go | 4 +- routers/api/v1/repo/project.go | 156 +++++++++++++++++++-- routers/api/v1/swagger/options.go | 3 - tests/integration/api_repo_project_test.go | 131 +++++++++++++++-- 5 files changed, 267 insertions(+), 35 deletions(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index 558395aff8..5966ef0065 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -121,11 +121,3 @@ type EditProjectColumnOption struct { // Sorting order Sorting *int `json:"sorting,omitempty"` } - -// AddIssueToProjectColumnOption represents options for adding an issue to a project column -// swagger:model -type AddIssueToProjectColumnOption struct { - // Issue ID to add to the column - // required: true - IssueID int64 `json:"issue_id" binding:"Required"` -} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8f32f47305..ece09d4900 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1585,7 +1585,9 @@ func Routes() *web.Router { m.Combo(""). Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn) - m.Post("/issues", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.AddIssueToProjectColumnOption{}), repo.AddIssueToProjectColumn) + m.Get("/issues", repo.ListProjectColumnIssues) + m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) + m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) }) }, reqRepoReader(unit.TypeProjects)) }, repoAssignment(), checkTokenPublicOnly()) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 619ecde57e..17c194c7fc 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -572,9 +572,78 @@ func DeleteProjectColumn(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +// ListProjectColumnIssues lists all issues in a project column +func ListProjectColumnIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoListProjectColumnIssues + // --- + // summary: List issues in a project column + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + + listOptions := utils.GetListOptions(ctx) + issuesOpts := &issues_model.IssuesOptions{ + Paginator: &listOptions, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + ProjectID: column.ProjectID, + ProjectColumnID: column.ID, + SortType: "project-column-sorting", + } + + count, err := issues_model.CountIssues(ctx, issuesOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + issues, err := issues_model.Issues(ctx, issuesOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(count, listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + // AddIssueToProjectColumn adds an issue to a project column func AddIssueToProjectColumn(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoAddIssueToProjectColumn + // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoAddIssueToProjectColumn // --- // summary: Add an issue to a project column // consumes: @@ -598,10 +667,12 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // type: integer // format: int64 // required: true - // - name: body - // in: body - // schema: - // "$ref": "#/definitions/AddIssueToProjectColumnOption" + // - name: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true // responses: // "201": // "$ref": "#/responses/empty" @@ -617,9 +688,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { return } - form := web.GetForm(ctx).(*api.AddIssueToProjectColumnOption) - - issue, err := issues_model.GetIssueByID(ctx, form.IssueID) + issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id")) if err != nil { if issues_model.IsErrIssueNotExist(err) { ctx.APIError(http.StatusUnprocessableEntity, "issue not found") @@ -641,3 +710,74 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { ctx.Status(http.StatusCreated) } + +// RemoveIssueFromProjectColumn remove an issue from a project column +func RemoveIssueFromProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoAddIssueToProjectColumn + // --- + // summary: Add an issue to a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + + issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, "issue not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusUnprocessableEntity, "issue does not belong to this repository") + return + } + + // 0 means remove + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, 0, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 90aaf8d1b7..093cb42159 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -240,7 +240,4 @@ type swaggerParameterBodies struct { CreateProjectColumnOption api.CreateProjectColumnOption // in:body EditProjectColumnOption api.EditProjectColumnOption - - // in:body - AddIssueToProjectColumnOption api.AddIssueToProjectColumnOption } diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 78d50753aa..0cbf66d4a2 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -538,9 +538,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) // Test adding issue to column - req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{ - IssueID: issue.ID, - }).AddTokenAuth(token) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column1.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Verify issue is in the column @@ -551,9 +549,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { assert.Equal(t, column1.ID, projectIssue.ProjectColumnID) // Test moving issue to another column - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column2.ID), &api.AddIssueToProjectColumnOption{ - IssueID: issue.ID, - }).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column2.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Verify issue moved to new column @@ -564,24 +560,129 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { assert.Equal(t, column2.ID, projectIssue.ProjectColumnID) // Test adding same issue to same column (should be idempotent) - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column2.ID), &api.AddIssueToProjectColumnOption{ - IssueID: issue.ID, - }).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column2.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Test adding non-existent issue - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{ - IssueID: 99999, - }).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column1.ID, 99999), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusUnprocessableEntity) // Test adding to non-existent column - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999/issues", owner.Name, repo.Name), &api.AddIssueToProjectColumnOption{ - IssueID: issue.ID, - }).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999/issues/%d", owner.Name, repo.Name, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } +func TestAPIListProjectColumnIssues(t *testing.T) { + defer tests.PrepareTestEnv(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{RepoID: repo.ID, IsPull: false}) + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID, IsPull: true}) + + project := &project_model.Project{ + Title: "Project for Column Issues", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + column := &project_model.Column{ + Title: "Column for Issues", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) + assert.NoError(t, err) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, owner, project.ID, column.ID) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + 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) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues?type=issues", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &issues) + assert.Len(t, issues, 1) + assert.Equal(t, issue.ID, issues[0].ID) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues?type=pulls", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &issues) + assert.Len(t, issues, 1) + assert.Equal(t, pull.ID, issues[0].ID) +} + +func TestAPIRemoveIssueFromProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(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{RepoID: repo.ID}) + + project := &project_model.Project{ + Title: "Project for Issue Removal", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + column := &project_model.Column{ + Title: "Column for Issue Removal", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column.ID, issue.ID), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) +} + func TestAPIProjectPermissions(t *testing.T) { defer tests.PrepareTestEnv(t)()