diff --git a/models/project/board.go b/models/project/board.go index 3e2d8e0472c51..9c5859b390ee4 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -50,11 +50,12 @@ var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$") // Board is used to represent boards on a project type Board struct { - ID int64 `xorm:"pk autoincr"` - Title string - Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board - Sorting int8 `xorm:"NOT NULL DEFAULT 0"` - Color string `xorm:"VARCHAR(7)"` + ID int64 `xorm:"pk autoincr"` + Title string + Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board + Sorting int8 `xorm:"NOT NULL DEFAULT 0"` + Color string `xorm:"VARCHAR(7)"` + CloseIssueOnMove bool `xorm:"DEFAULT false"` ProjectID int64 `xorm:"INDEX NOT NULL"` CreatorID int64 `xorm:"NOT NULL"` diff --git a/models/project/issue.go b/models/project/issue.go index ebc9719de55d0..50428a735f2b1 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -21,7 +21,8 @@ type ProjectIssue struct { //revive:disable-line:exported ProjectBoardID int64 `xorm:"INDEX"` // the sorting order on the board - Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + CloseIssueOnMove bool `xorm:"DEFAULT false"` } func init() { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index effb3896c25eb..b8683355f1f3a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2766,6 +2766,7 @@ dashboard.sync_external_users = Synchronize external user data dashboard.cleanup_hook_task_table = Cleanup hook_task table dashboard.cleanup_packages = Cleanup expired packages dashboard.cleanup_actions = Cleanup actions expired logs and artifacts +dashboard.project_board_actions = Cleanup project closed issues dashboard.server_uptime = Server Uptime dashboard.current_goroutine = Current Goroutines dashboard.current_memory_usage = Current Memory Usage diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 03798a712c2ee..d9a6e7ce3c508 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" ) const ( @@ -542,10 +543,11 @@ func AddBoardToProjectPost(ctx *context.Context) { } if err := project_model.NewBoard(ctx, &project_model.Board{ - ProjectID: project.ID, - Title: form.Title, - Color: form.Color, - CreatorID: ctx.Doer.ID, + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + CloseIssueOnMove: form.Close, }); err != nil { ctx.ServerError("NewProjectBoard", err) return @@ -747,5 +749,19 @@ func MoveIssues(ctx *context.Context) { return } + if board.CloseIssueOnMove { + issueID, err := strconv.ParseInt(ctx.FormString("issueID"), 10, 64) + if err != nil { + ctx.ServerError("moved issueID is required", err) + return + } + + err = issue_service.CloseIssue(ctx, issueID) + if err != nil { + ctx.ServerError("MoveIssuesOnProjectBoard unable to close issue", err) + return + } + } + ctx.JSONOK() } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 4908bb796d9dc..12ddc158675d9 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "code.gitea.io/gitea/models/db" @@ -25,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" ) const ( @@ -483,10 +485,11 @@ func AddBoardToProjectPost(ctx *context.Context) { } if err := project_model.NewBoard(ctx, &project_model.Board{ - ProjectID: project.ID, - Title: form.Title, - Color: form.Color, - CreatorID: ctx.Doer.ID, + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + CloseIssueOnMove: form.Close, }); err != nil { ctx.ServerError("NewProjectBoard", err) return @@ -696,5 +699,19 @@ func MoveIssues(ctx *context.Context) { return } + if board.CloseIssueOnMove { + issueID, err := strconv.ParseInt(ctx.FormString("issueID"), 10, 64) + if err != nil { + ctx.ServerError("moved issueID is required", err) + return + } + + err = issue_service.CloseIssue(ctx, issueID) + if err != nil { + ctx.ServerError("MoveIssuesOnProjectBoard unable to close issue", err) + return + } + } + ctx.JSONOK() } diff --git a/services/actions/projects.go b/services/actions/projects.go new file mode 100644 index 0000000000000..973027093343f --- /dev/null +++ b/services/actions/projects.go @@ -0,0 +1,119 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/log" + "xorm.io/builder" +) + +// Cleanup removes closed issues from project board with close on move flag set to true +func CleanupProjectIssues(taskCtx context.Context, olderThan time.Duration) error { + log.Info("CRON: remove closed issues from boards with close on move flag set to true starting...") + + projects, err := getAllProjects(taskCtx) + if err != nil { + return err + } + for _, project := range projects { + boards, err := getAllProjectBoardWithCloseOnMove(taskCtx, project) + if err != nil { + log.Error("Cannot get boards of project ID %d: %v", project.ID, err) + continue + } + log.Info("Found %d boards with close on move true", len(boards)) + for _, board := range boards { + issues, err := getAllIssuesOfBoard(taskCtx, board) + if err != nil { + log.Error("Cannot get issues of board ID %d: %v", board.ID, err) + continue + } + issuesToBeRemoved, err := getAllIssuesToBeRemoved(taskCtx, issues) + if err != nil { + log.Error("Cannot get issues of to be removed of board ID %d: %v", board.ID, err) + continue + } + for _, issueToBeRemoved := range issuesToBeRemoved { + err = removeIssueFromProject(taskCtx, issueToBeRemoved, project) + if err != nil { + log.Error("Cannot remove issue ID %d from board ID %d: %v", issueToBeRemoved.ID, board.ID, err) + continue + } + log.Info("Removed issue ID %d from board ID %d", issueToBeRemoved.ID, board.ID) + } + log.Info("completed removing closed issues from board ID %d", board.ID) + } + log.Info("completed removing closed issues project ID %d", project.ID) + } + + log.Info("CRON: remove closed issues from boards with close on move flag true completed.") + + return nil +} + +func getAllProjects(ctx context.Context) ([]project_model.Project, error) { + var projects []project_model.Project + + err := db.GetEngine(ctx).Table("project").Select("*").Find(&projects) + if err != nil { + log.Error("unable to read project db %v", err) + return projects, err + } + return projects, nil +} + +func getAllProjectBoardWithCloseOnMove(ctx context.Context, project project_model.Project) ([]project_model.Board, error) { + var boards []project_model.Board + + err := db.GetEngine(ctx).Table("project_board").Select("*").Where(builder.Eq{"project_id": project.ID}).Find(&boards) + if err != nil { + log.Error("unable to read project_board db %v", err) + return boards, err + } + return boards, nil +} + +func getAllIssuesOfBoard(ctx context.Context, board project_model.Board) ([]int64, error) { + var issueIDs []int64 + + err := db.GetEngine(ctx).Table("project_issue").Select("issue_id").Where(builder.Eq{"project_id": board.ProjectID, "project_board_id": board.ID}).Find(&issueIDs) + if err != nil { + log.Error("unable to read project_issue db %v", err) + return issueIDs, err + } + return issueIDs, nil +} + +func getAllIssuesToBeRemoved(ctx context.Context, issueIDs []int64) ([]issues_model.Issue, error) { + var issues []issues_model.Issue + + err := db.GetEngine(ctx).Table("issue").Select("*").Where(builder.Eq{"is_closed": 1}).Where(builder.In("id", issueIDs)).Find(&issues) + if err != nil { + log.Error("unable to read issue db %v", err) + return issues, err + } + + return issues, nil +} + +func removeIssueFromProject(ctx context.Context, issue issues_model.Issue, project project_model.Project) error { + projectIssue := &project_model.ProjectIssue{ + IssueID: issue.ID, + ProjectID: project.ID, + } + + _, err := db.GetEngine(ctx).Table("project_issue").Delete(&projectIssue) + if err != nil { + log.Error("unable to delete project_issue db %v", err) + return err + } + + return nil +} diff --git a/services/cron/tasks_basic.go b/services/cron/tasks_basic.go index 3869382d22363..8e4e5025a3550 100644 --- a/services/cron/tasks_basic.go +++ b/services/cron/tasks_basic.go @@ -171,6 +171,20 @@ func registerActionsCleanup() { }) } +func registerProjectsIssuesCleanup() { + RegisterTaskFatal("project_board_actions", &OlderThanConfig{ + BaseConfig: BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@weekly", + }, + OlderThan: 24 * time.Hour, + }, func(ctx context.Context, _ *user_model.User, config Config) error { + realConfig := config.(*OlderThanConfig) + return actions.CleanupProjectIssues(ctx, realConfig.OlderThan) + }) +} + func initBasicTasks() { if setting.Mirror.Enabled { registerUpdateMirrorTask() @@ -190,4 +204,5 @@ func initBasicTasks() { if setting.Actions.Enabled { registerActionsCleanup() } + registerProjectsIssuesCleanup() } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 845eccf817d3a..c99c0d4678b05 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -531,6 +531,7 @@ type EditProjectBoardForm struct { Title string `binding:"Required;MaxSize(100)"` Sorting int8 Color string `binding:"MaxSize(7)"` + Close bool } // _____ .__.__ __ diff --git a/services/issue/project.go b/services/issue/project.go new file mode 100644 index 0000000000000..6a8d05ccda5d5 --- /dev/null +++ b/services/issue/project.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "errors" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" +) + +func CloseIssue(ctx *context.Context, issueID int64) error { + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + return errors.New("failed getting issue") + } + + if err := ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil { + log.Error("ChangeStatus: %v", err) + + if issues_model.IsErrDependenciesLeft(err) { + ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked")) + return err + } + } + + return nil +} diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index b3ad03c354988..8d3bfc12bd733 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -48,6 +48,15 @@ +
+ +
+ +
+
+
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js index 5a2a7e72ef335..971eece6a9c89 100644 --- a/web_src/js/features/repo-projects.js +++ b/web_src/js/features/repo-projects.js @@ -11,10 +11,10 @@ function updateIssueCount(cards) { parent.getElementsByClassName('project-column-issue-count')[0].textContent = cnt; } -function createNewColumn(url, columnTitle, projectColorInput) { +function createNewColumn(url, columnTitle, projectColorInput, closeOnIssueMove) { $.ajax({ url, - data: JSON.stringify({title: columnTitle.val(), color: projectColorInput.val()}), + data: JSON.stringify({title: columnTitle.val(), color: projectColorInput.val(), close: closeOnIssueMove.prop('checked')}), headers: { 'X-Csrf-Token': csrfToken, }, @@ -39,7 +39,7 @@ function moveIssue({item, from, to, oldIndex}) { }; $.ajax({ - url: `${to.getAttribute('data-url')}/move`, + url: `${to.getAttribute('data-url')}/move?issueID=${item.getAttribute('data-issue')}`, data: JSON.stringify(columnSorting), headers: { 'X-Csrf-Token': csrfToken, @@ -187,11 +187,12 @@ export function initRepoProject() { e.preventDefault(); const columnTitle = $('#new_project_column'); const projectColorInput = $('#new_project_column_color_picker'); + const closeOnIssueMove = $('#new_project_column_project_close_on_move'); if (!columnTitle.val()) { return; } const url = $(this).data('url'); - createNewColumn(url, columnTitle, projectColorInput); + createNewColumn(url, columnTitle, projectColorInput, closeOnIssueMove); }); }