some improvements

This commit is contained in:
Lunny Xiao
2026-04-02 20:58:09 -07:00
committed by beardev-in
parent 478d068497
commit 5b663c413e
5 changed files with 337 additions and 70 deletions

View File

@@ -10,42 +10,64 @@ import (
// Project represents a project
// swagger:model
type Project struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
OwnerID int64 `json:"owner_id,omitempty"`
RepoID int64 `json:"repo_id,omitempty"`
CreatorID int64 `json:"creator_id"`
IsClosed bool `json:"is_closed"`
TemplateType int `json:"template_type"`
CardType int `json:"card_type"`
Type int `json:"type"`
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// Unique identifier of the project
ID int64 `json:"id"`
// Project title
Title string `json:"title"`
// Project description
Description string `json:"description"`
// Owner ID (for organization or user projects)
OwnerID int64 `json:"owner_id,omitempty"`
// Repository ID (for repository projects)
RepoID int64 `json:"repo_id,omitempty"`
// Creator ID
CreatorID int64 `json:"creator_id"`
// Whether the project is closed
IsClosed bool `json:"is_closed"`
// Template type: 0=none, 1=basic_kanban, 2=bug_triage
TemplateType int `json:"template_type"`
// Card type: 0=text_only, 1=images_and_text
CardType int `json:"card_type"`
// Project type: 1=individual, 2=repository, 3=organization
Type int `json:"type"`
// Number of open issues
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
// Number of closed issues
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
// Total number of issues
NumIssues int64 `json:"num_issues,omitempty"`
// Created time
// swagger:strfmt date-time
Created time.Time `json:"created"`
Created time.Time `json:"created"`
// Updated time
// swagger:strfmt date-time
Updated time.Time `json:"updated"`
Updated time.Time `json:"updated"`
// Closed time
// swagger:strfmt date-time
ClosedDate *time.Time `json:"closed_date,omitempty"`
URL string `json:"url,omitempty"`
// Project URL
URL string `json:"url,omitempty"`
}
// CreateProjectOption represents options for creating a project
// swagger:model
type CreateProjectOption struct {
// required: true
Title string `json:"title" binding:"Required"`
Description string `json:"description"`
TemplateType int `json:"template_type"`
CardType int `json:"card_type"`
Title string `json:"title" binding:"Required"`
// Project description
Description string `json:"description"`
// Template type: 0=none, 1=basic_kanban, 2=bug_triage
TemplateType int `json:"template_type"`
// Card type: 0=text_only, 1=images_and_text
CardType int `json:"card_type"`
}
// EditProjectOption represents options for editing a project
// swagger:model
type EditProjectOption struct {
Title *string `json:"title,omitempty"`
// Project title
Title *string `json:"title,omitempty"`
// Project description
Description *string `json:"description,omitempty"`
// Card type: 0=text_only, 1=images_and_text
CardType *int `json:"card_type,omitempty"`
@@ -56,18 +78,28 @@ type EditProjectOption struct {
// ProjectColumn represents a project column (board)
// swagger:model
type ProjectColumn struct {
ID int64 `json:"id"`
Title string `json:"title"`
Default bool `json:"default"`
Sorting int `json:"sorting"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
CreatorID int64 `json:"creator_id"`
NumIssues int64 `json:"num_issues,omitempty"`
// Unique identifier of the column
ID int64 `json:"id"`
// Column title
Title string `json:"title"`
// Whether this is the default column
Default bool `json:"default"`
// Sorting order
Sorting int `json:"sorting"`
// Column color (hex format)
Color string `json:"color,omitempty"`
// Project ID
ProjectID int64 `json:"project_id"`
// Creator ID
CreatorID int64 `json:"creator_id"`
// Number of issues in this column
NumIssues int64 `json:"num_issues,omitempty"`
// Created time
// swagger:strfmt date-time
Created time.Time `json:"created"`
Created time.Time `json:"created"`
// Updated time
// swagger:strfmt date-time
Updated time.Time `json:"updated"`
Updated time.Time `json:"updated"`
}
// CreateProjectColumnOption represents options for creating a project column
@@ -75,22 +107,17 @@ type ProjectColumn struct {
type CreateProjectColumnOption struct {
// required: true
Title string `json:"title" binding:"Required"`
// Column color (hex format, e.g., #FF0000)
Color string `json:"color,omitempty"`
}
// EditProjectColumnOption represents options for editing a project column
// swagger:model
type EditProjectColumnOption struct {
Title *string `json:"title,omitempty"`
Color *string `json:"color,omitempty"`
Sorting *int `json:"sorting,omitempty"`
// Column title
Title *string `json:"title,omitempty"`
// Column color (hex format)
Color *string `json:"color,omitempty"`
// Sorting order
Sorting *int `json:"sorting,omitempty"`
}
// AddIssueToProjectColumnOption represents options for adding an issue to a project column
// swagger:model
type AddIssueToProjectColumnOption struct {
// required: true
IssueIDs []int64 `json:"issue_ids" binding:"Required"`
}

View File

@@ -1589,7 +1589,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())

View File

@@ -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)
}

View File

@@ -243,7 +243,4 @@ type swaggerParameterBodies struct {
CreateProjectColumnOption api.CreateProjectColumnOption
// in:body
EditProjectColumnOption api.EditProjectColumnOption
// in:body
AddIssueToProjectColumnOption api.AddIssueToProjectColumnOption
}

View File

@@ -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)()