From e5039e99560b54827f4ff312fa9ed31a1de78a93 Mon Sep 17 00:00:00 2001 From: Alexei Bezborodov Date: Thu, 23 Jun 2022 17:55:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=20#6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom/conf/app.example.ini | 7 ++ integrations/api_repo_edit_test.go | 2 + models/error.go | 85 ++++++++++++++++ models/issue.go | 52 ++++++++++ models/issue_comment.go | 38 +++++++ models/issue_parent.go | 134 +++++++++++++++++++++++++ models/issue_parent_test.go | 61 +++++++++++ models/migrations/v70.go | 3 + models/repo.go | 1 + models/repo/issue.go | 16 +++ models/repo/repo_unit.go | 1 + modules/context/repo.go | 5 + modules/convert/repository.go | 1 + modules/doctor/fix16961.go | 4 + modules/doctor/fix16961_test.go | 4 +- modules/setting/service.go | 4 + modules/structs/repo.go | 2 + options/locale/locale_ru-RU.ini | 28 ++++++ routers/api/v1/repo/repo.go | 2 + routers/web/repo/issue.go | 21 ++++ routers/web/repo/issue_parent.go | 129 ++++++++++++++++++++++++ routers/web/repo/setting.go | 1 + routers/web/web.go | 4 + services/forms/repo_form.go | 1 + templates/admin/config.tmpl | 2 + templates/repo/issue/view_content/sidebar.tmpl | 125 +++++++++++++++++++++++ templates/repo/settings/options.tmpl | 6 ++ templates/swagger/v1_json.tmpl | 5 + web_src/js/features/repo-issue.js | 46 +++++++++ web_src/js/features/repo-legacy.js | 3 +- web_src/less/_repository.less | 15 +++ 31 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 models/issue_parent.go create mode 100644 models/issue_parent_test.go create mode 100644 routers/web/repo/issue_parent.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 8d7946145f..dcced2772d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -701,6 +701,13 @@ PATH = ;; Dependencies can be added from any repository where the user is granted access or only from the current repository depending on this setting. ;ALLOW_CROSS_REPOSITORY_DEPENDENCIES = true ;; +;; Default value for EnableParents +;; Repositories will use parents by default depending on this setting +;DEFAULT_ENABLE_PARENTS = true +;; +;; Parents can be added from any repository where the user is granted access or only from the current repository depending on this setting. +;ALLOW_CROSS_REPOSITORY_PARENTS = true +;; ;; Enable heatmap on users profiles. ;ENABLE_USER_HEATMAP = true ;; diff --git a/integrations/api_repo_edit_test.go b/integrations/api_repo_edit_test.go index 91ec4c699e..3f428065ef 100644 --- a/integrations/api_repo_edit_test.go +++ b/integrations/api_repo_edit_test.go @@ -35,6 +35,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption EnableTimeTracker: config.EnableTimetracker, AllowOnlyContributorsToTrackTime: config.AllowOnlyContributorsToTrackTime, EnableIssueDependencies: config.EnableDependencies, + EnableIssueParents: config.EnableParents, } } else if unit, err := repo.GetUnit(unit_model.TypeExternalTracker); err == nil { config := unit.ExternalTrackerConfig() @@ -182,6 +183,7 @@ func TestAPIRepoEdit(t *testing.T) { EnableTimeTracker: false, AllowOnlyContributorsToTrackTime: false, EnableIssueDependencies: false, + EnableIssueParents: false, } *repoEditOption.HasWiki = true repoEditOption.ExternalWiki = nil diff --git a/models/error.go b/models/error.go index 1d0f658eb8..1afac85109 100644 --- a/models/error.go +++ b/models/error.go @@ -1340,6 +1340,91 @@ func (err ErrUnknownDependencyType) Error() string { return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) } +// .___ ________ .___ .__ +// | | ______ ________ __ ____ \______ \ ____ ______ ____ ____ __| _/____ ____ ____ |__| ____ ______ +// | |/ ___// ___/ | \_/ __ \ | | \_/ __ \\____ \_/ __ \ / \ / __ |/ __ \ / \_/ ___\| |/ __ \ / ___/ +// | |\___ \ \___ \| | /\ ___/ | ` \ ___/| |_> > ___/| | \/ /_/ \ ___/| | \ \___| \ ___/ \___ \ +// |___/____ >____ >____/ \___ >_______ /\___ > __/ \___ >___| /\____ |\___ >___| /\___ >__|\___ >____ > +// \/ \/ \/ \/ \/|__| \/ \/ \/ \/ \/ \/ \/ \/ + +// ErrParentExists represents a "ParentAlreadyExists" kind of error. +type ErrParentExists struct { + IssueID int64 + ParentID int64 +} + +// IsErrParentExists checks if an error is a ErrParentExists. +func IsErrParentExists(err error) bool { + _, ok := err.(ErrParentExists) + return ok +} + +func (err ErrParentExists) Error() string { + return fmt.Sprintf("issue parent does already exist [issue id: %d, parent id: %d]", err.IssueID, err.ParentID) +} + +// ErrParentNotExists represents a "ParentAlreadyExists" kind of error. +type ErrParentNotExists struct { + IssueID int64 + ParentID int64 +} + +// IsErrParentNotExists checks if an error is a ErrParentExists. +func IsErrParentNotExists(err error) bool { + _, ok := err.(ErrParentNotExists) + return ok +} + +func (err ErrParentNotExists) Error() string { + return fmt.Sprintf("issue parent does not exist [issue id: %d, parent id: %d]", err.IssueID, err.ParentID) +} + +// ErrCircularParent represents a "ParentCircular" kind of error. +type ErrCircularParent struct { + IssueID int64 + ParentID int64 +} + +// IsErrCircularParent checks if an error is a ErrCircularParent. +func IsErrCircularParent(err error) bool { + _, ok := err.(ErrCircularParent) + return ok +} + +func (err ErrCircularParent) Error() string { + return fmt.Sprintf("circular parents exists (two issues blocking each other) [issue id: %d, parent id: %d]", err.IssueID, err.ParentID) +} + +// ErrParentsLeft represents an error where the issue you're trying to close still has parents left. +type ErrParentsLeft struct { + IssueID int64 +} + +// IsErrParentsLeft checks if an error is a ErrParentsLeft. +func IsErrParentsLeft(err error) bool { + _, ok := err.(ErrParentsLeft) + return ok +} + +func (err ErrParentsLeft) Error() string { + return fmt.Sprintf("issue has open parents [issue id: %d]", err.IssueID) +} + +// ErrUnknownParentType represents an error where an unknown parent type was passed +type ErrUnknownParentType struct { + Type ParentType +} + +// IsErrUnknownParentType checks if an error is ErrUnknownParentType +func IsErrUnknownParentType(err error) bool { + _, ok := err.(ErrUnknownParentType) + return ok +} + +func (err ErrUnknownParentType) Error() string { + return fmt.Sprintf("unknown parent type [type: %d]", err.Type) +} + // __________ .__ // \______ \ _______ _|__| ______ _ __ // | _// __ \ \/ / |/ __ \ \/ \/ / diff --git a/models/issue.go b/models/issue.go index f2552c0a1e..e4dd358ad9 100644 --- a/models/issue.go +++ b/models/issue.go @@ -1990,6 +1990,12 @@ type DependencyInfo struct { repo_model.Repository `xorm:"extends"` } +// ParentInfo represents high level information about an issue which is a parent of another issue. +type ParentInfo struct { + Issue `xorm:"extends"` + repo_model.Repository `xorm:"extends"` +} + // getParticipantIDsByIssue returns all userIDs who are participated in comments of an issue and issue author func (issue *Issue) getParticipantIDsByIssue(e db.Engine) ([]int64, error) { if issue == nil { @@ -2048,6 +2054,42 @@ func (issue *Issue) getBlockingDependencies(e db.Engine) (issueDeps []*Dependenc return issueDeps, err } +// Get Blocked By Parents, aka all issues this issue is blocked by. +func (issue *Issue) getBlockedByParents(e db.Engine) (issueParents []*ParentInfo, err error) { + err = e. + Table("issue"). + Join("INNER", "repository", "repository.id = issue.repo_id"). + Join("INNER", "issue_parent", "issue_parent.parent_id = issue.id"). + Where("issue_id = ?", issue.ID). + // sort by repo id then created date, with the issues of the same repo at the beginning of the list + OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(issue.RepoID, 10) + " THEN 0 ELSE issue.repo_id END, issue.created_unix DESC"). + Find(&issueParents) + + for _, parentInfo := range issueParents { + parentInfo.Issue.Repo = &parentInfo.Repository + } + + return issueParents, err +} + +// Get Blocking Parents, aka all issues this issue blocks. +func (issue *Issue) getBlockingParents(e db.Engine) (issueParents []*ParentInfo, err error) { + err = e. + Table("issue"). + Join("INNER", "repository", "repository.id = issue.repo_id"). + Join("INNER", "issue_parent", "issue_parent.issue_id = issue.id"). + Where("parent_id = ?", issue.ID). + // sort by repo id then created date, with the issues of the same repo at the beginning of the list + OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(issue.RepoID, 10) + " THEN 0 ELSE issue.repo_id END, issue.created_unix DESC"). + Find(&issueParents) + + for _, parentInfo := range issueParents { + parentInfo.Issue.Repo = &parentInfo.Repository + } + + return issueParents, err +} + // BlockedByDependencies finds all Dependencies an issue is blocked by func (issue *Issue) BlockedByDependencies() ([]*DependencyInfo, error) { return issue.getBlockedByDependencies(db.GetEngine(db.DefaultContext)) @@ -2058,6 +2100,16 @@ func (issue *Issue) BlockingDependencies() ([]*DependencyInfo, error) { return issue.getBlockingDependencies(db.GetEngine(db.DefaultContext)) } +// BlockedByParents finds all Parents an issue is blocked by +func (issue *Issue) BlockedByParents() ([]*ParentInfo, error) { + return issue.getBlockedByParents(db.GetEngine(db.DefaultContext)) +} + +// BlockingParents returns all blocking dependencies, aka all other issues a given issue blocks +func (issue *Issue) BlockingParents() ([]*ParentInfo, error) { + return issue.getBlockingParents(db.GetEngine(db.DefaultContext)) +} + func (issue *Issue) updateClosedNum(ctx context.Context) (err error) { if issue.IsPull { err = repoStatsCorrectNumClosed(ctx, issue.RepoID, true, "num_closed_pulls") diff --git a/models/issue_comment.go b/models/issue_comment.go index f4a6b3ce13..8b6b204008 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -82,6 +82,10 @@ const ( CommentTypeAddDependency // 20 Dependency removed CommentTypeRemoveDependency + // 19 Parent added + CommentTypeAddParent + // 20 Parent removed + CommentTypeRemoveParent // 21 Comment a line of code CommentTypeCode // 22 Reviews a pull request by giving general feedback @@ -933,6 +937,39 @@ func createIssueDependencyComment(ctx context.Context, doer *user_model.User, is return } +// Creates issue parent comment +func createIssueParentComment(ctx context.Context, doer *user_model.User, issue, parentIssue *Issue, add bool) (err error) { + cType := CommentTypeAddParent + if !add { + cType = CommentTypeRemoveParent + } + if err = issue.loadRepo(ctx); err != nil { + return + } + + // Make two comments, one in each issue + opts := &CreateCommentOptions{ + Type: cType, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + ParentIssueID: parentIssue.ID, + } + if _, err = createComment(ctx, opts); err != nil { + return + } + + opts = &CreateCommentOptions{ + Type: cType, + Doer: doer, + Repo: issue.Repo, + Issue: parentIssue, + ParentIssueID: issue.ID, + } + _, err = createComment(ctx, opts) + return +} + // CreateCommentOptions defines options for creating comment type CreateCommentOptions struct { Type CommentType @@ -942,6 +979,7 @@ type CreateCommentOptions struct { Label *Label DependentIssueID int64 + ParentIssueID int64 OldMilestoneID int64 MilestoneID int64 OldProjectID int64 diff --git a/models/issue_parent.go b/models/issue_parent.go new file mode 100644 index 0000000000..9e4780fbfb --- /dev/null +++ b/models/issue_parent.go @@ -0,0 +1,134 @@ +// Copyright 2018-2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" +) + +// IssueParent represents an issue parent +type IssueParent struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + IssueID int64 `xorm:"UNIQUE(issue_parent) NOT NULL"` + ParentID int64 `xorm:"UNIQUE(issue_parent) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(IssueParent)) +} + +// ParentType Defines Parent Type Constants +type ParentType int + +// Define Parent Types +const ( + ParentTypeFather ParentType = iota + ParentTypeChild +) + +// CreateIssueParent creates a new parent for an issue +func CreateIssueParent(user *user_model.User, issue, parent *Issue) error { + ctx, committer, err := db.TxContext() + if err != nil { + return err + } + defer committer.Close() + sess := db.GetEngine(ctx) + + // Check if it aleready exists + exists, err := issueParentExists(sess, issue.ID, parent.ID) + if err != nil { + return err + } + if exists { + return ErrParentExists{issue.ID, parent.ID} + } + // And if it would be circular + circular, err := issueParentExists(sess, parent.ID, issue.ID) + if err != nil { + return err + } + if circular { + return ErrCircularParent{issue.ID, parent.ID} + } + + if err := db.Insert(ctx, &IssueParent{ + UserID: user.ID, + IssueID: issue.ID, + ParentID: parent.ID, + }); err != nil { + return err + } + + // Add comment referencing the new parent + if err = createIssueParentComment(ctx, user, issue, parent, true); err != nil { + return err + } + + return committer.Commit() +} + +// RemoveIssueParent removes a parent from an issue +func RemoveIssueParent(user *user_model.User, issue, parent *Issue, parentType ParentType) (err error) { + ctx, committer, err := db.TxContext() + if err != nil { + return err + } + defer committer.Close() + + var issueParentToDelete IssueParent + + switch parentType { + case ParentTypeFather: + issueParentToDelete = IssueParent{IssueID: issue.ID, ParentID: parent.ID} + case ParentTypeChild: + issueParentToDelete = IssueParent{IssueID: parent.ID, ParentID: issue.ID} + default: + return ErrUnknownParentType{parentType} + } + + affected, err := db.GetEngine(ctx).Delete(&issueParentToDelete) + if err != nil { + return err + } + + // If we deleted nothing, the parent did not exist + if affected <= 0 { + return ErrParentNotExists{issue.ID, parent.ID} + } + + // Add comment referencing the removed parent + if err = createIssueParentComment(ctx, user, issue, parent, false); err != nil { + return err + } + return committer.Commit() +} + +// Check if the parent already exists +func issueParentExists(e db.Engine, issueID, depID int64) (bool, error) { + return e.Where("(issue_id = ? AND parent_id = ?)", issueID, depID).Exist(&IssueParent{}) +} + +// IssueNoParentsLeft checks if issue can be closed +func IssueNoParentsLeft(issue *Issue) (bool, error) { + return issueNoParentsLeft(db.GetEngine(db.DefaultContext), issue) +} + +func issueNoParentsLeft(e db.Engine, issue *Issue) (bool, error) { + exists, err := e. + Table("issue_parent"). + Select("issue.*"). + Join("INNER", "issue", "issue.id = issue_parent.parent_id"). + Where("issue_parent.issue_id = ?", issue.ID). + And("issue.is_closed = ?", "0"). + Exist(&Issue{}) + + return !exists, err +} diff --git a/models/issue_parent_test.go b/models/issue_parent_test.go new file mode 100644 index 0000000000..1fd28fd47b --- /dev/null +++ b/models/issue_parent_test.go @@ -0,0 +1,61 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestCreateIssueParent(t *testing.T) { + // Prepare + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1, err := user_model.GetUserByID(1) + assert.NoError(t, err) + + issue1, err := GetIssueByID(1) + assert.NoError(t, err) + + issue2, err := GetIssueByID(2) + assert.NoError(t, err) + + // Create a dependency and check if it was successful + err = CreateIssueParent(user1, issue1, issue2) + assert.NoError(t, err) + + // Do it again to see if it will check if the dependency already exists + err = CreateIssueParent(user1, issue1, issue2) + assert.Error(t, err) + assert.True(t, IsErrParentExists(err)) + + // Check for circular dependencies + err = CreateIssueParent(user1, issue2, issue1) + assert.Error(t, err) + assert.True(t, IsErrCircularParent(err)) + + _ = unittest.AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeAddParent, PosterID: user1.ID, IssueID: issue1.ID}) + + // Check if dependencies left is correct + left, err := IssueNoParentsLeft(issue1) + assert.NoError(t, err) + assert.False(t, left) + + // Close #2 and check again + _, err = issue2.ChangeStatus(user1, true) + assert.NoError(t, err) + + left, err = IssueNoParentsLeft(issue1) + assert.NoError(t, err) + assert.True(t, left) + + // Test removing the dependency + err = RemoveIssueParent(user1, issue1, issue2, ParentTypeFather) + assert.NoError(t, err) +} diff --git a/models/migrations/v70.go b/models/migrations/v70.go index 7d34c89d11..868d43295b 100644 --- a/models/migrations/v70.go +++ b/models/migrations/v70.go @@ -102,6 +102,9 @@ func addIssueDependencies(x *xorm.Engine) (err error) { if _, ok := unit.Config["EnableDependencies"]; !ok { unit.Config["EnableDependencies"] = setting.Service.DefaultEnableDependencies } + if _, ok := unit.Config["EnableParents"]; !ok { + unit.Config["EnableParents"] = setting.Service.DefaultEnableParents + } if _, err := x.ID(unit.ID).Cols("config").Update(unit); err != nil { return err } diff --git a/models/repo.go b/models/repo.go index 27ae10688a..606a89008c 100644 --- a/models/repo.go +++ b/models/repo.go @@ -515,6 +515,7 @@ func CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_ EnableTimetracker: setting.Service.DefaultEnableTimetracking, AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime, EnableDependencies: setting.Service.DefaultEnableDependencies, + EnableParents: setting.Service.DefaultEnableParents, }, }) } else if tp == unit.TypePullRequests { diff --git a/models/repo/issue.go b/models/repo/issue.go index 9f0fa3bad9..e09532ca60 100644 --- a/models/repo/issue.go +++ b/models/repo/issue.go @@ -70,3 +70,19 @@ func (repo *Repository) IsDependenciesEnabledCtx(ctx context.Context) bool { } return u.IssuesConfig().EnableDependencies } + +// IsParentsEnabled returns if parents are enabled and returns the default setting if not set. +func (repo *Repository) IsParentsEnabled() bool { + return repo.IsParentsEnabledCtx(db.DefaultContext) +} + +// IsParentsEnabledCtx returns if parents are enabled and returns the default setting if not set. +func (repo *Repository) IsParentsEnabledCtx(ctx context.Context) bool { + var u *RepoUnit + var err error + if u, err = repo.GetUnitCtx(ctx, unit.TypeIssues); err != nil { + log.Trace("%s", err) + return setting.Service.DefaultEnableParents + } + return u.IssuesConfig().EnableParents +} diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index f526cbdf8b..2414c43462 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -94,6 +94,7 @@ type IssuesConfig struct { EnableTimetracker bool AllowOnlyContributorsToTrackTime bool EnableDependencies bool + EnableParents bool } // FromDB fills up a IssuesConfig from serialized format. diff --git a/modules/context/repo.go b/modules/context/repo.go index 4eeab710ff..cd5259d4f8 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -164,6 +164,11 @@ func (r *Repository) CanCreateIssueDependencies(user *user_model.User, isPull bo return r.Repository.IsDependenciesEnabled() && r.Permission.CanWriteIssuesOrPulls(isPull) } +// CanCreateIssueParents returns whether or not a user can create parents. +func (r *Repository) CanCreateIssueParents(user *user_model.User, isPull bool) bool { + return r.Repository.IsParentsEnabled() && r.Permission.CanWriteIssuesOrPulls(isPull) +} + // GetCommitsCount returns cached commit count for current view func (r *Repository) GetCommitsCount() (int64, error) { var contextName string diff --git a/modules/convert/repository.go b/modules/convert/repository.go index 459f98f396..ee320eaf6f 100644 --- a/modules/convert/repository.go +++ b/modules/convert/repository.go @@ -51,6 +51,7 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo EnableTimeTracker: config.EnableTimetracker, AllowOnlyContributorsToTrackTime: config.AllowOnlyContributorsToTrackTime, EnableIssueDependencies: config.EnableDependencies, + EnableIssueParents: config.EnableParents, } } else if unit, err := repo.GetUnit(unit_model.TypeExternalTracker); err == nil { config := unit.ExternalTrackerConfig() diff --git a/modules/doctor/fix16961.go b/modules/doctor/fix16961.go index 56d02ae92e..7843645d00 100644 --- a/modules/doctor/fix16961.go +++ b/modules/doctor/fix16961.go @@ -206,6 +206,10 @@ func fixIssuesConfig16961(bs []byte, cfg *repo_model.IssuesConfig) (fixed bool, if parseErr != nil { return } + cfg.EnableParents, parseErr = parseBool16961(parts[3]) + if parseErr != nil { + return + } return true, nil } diff --git a/modules/doctor/fix16961_test.go b/modules/doctor/fix16961_test.go index f5e5667c09..380833cd1c 100644 --- a/modules/doctor/fix16961_test.go +++ b/modules/doctor/fix16961_test.go @@ -237,11 +237,12 @@ func Test_fixIssuesConfig_16961(t *testing.T) { }{ { name: "normal", - bs: `{"EnableTimetracker":true,"AllowOnlyContributorsToTrackTime":true,"EnableDependencies":true}`, + bs: `{"EnableTimetracker":true,"AllowOnlyContributorsToTrackTime":true,"EnableDependencies":true,"EnableParents":true}`, expected: repo_model.IssuesConfig{ EnableTimetracker: true, AllowOnlyContributorsToTrackTime: true, EnableDependencies: true, + EnableParents: true, }, }, { @@ -251,6 +252,7 @@ func Test_fixIssuesConfig_16961(t *testing.T) { EnableTimetracker: true, AllowOnlyContributorsToTrackTime: true, EnableDependencies: true, + EnableParents: true, }, wantFixed: true, }, diff --git a/modules/setting/service.go b/modules/setting/service.go index a391926382..6505f444a4 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -53,7 +53,9 @@ var Service = struct { EnableTimetracking bool DefaultEnableTimetracking bool DefaultEnableDependencies bool + DefaultEnableParents bool AllowCrossRepositoryDependencies bool + AllowCrossRepositoryParents bool DefaultAllowOnlyContributorsToTrackTime bool NoReplyAddress string EnableUserHeatmap bool @@ -141,7 +143,9 @@ func newService() { Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true) } Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true) + Service.DefaultEnableParents = sec.Key("DEFAULT_ENABLE_PARENTS").MustBool(true) Service.AllowCrossRepositoryDependencies = sec.Key("ALLOW_CROSS_REPOSITORY_DEPENDENCIES").MustBool(true) + Service.AllowCrossRepositoryParents = sec.Key("ALLOW_CROSS_REPOSITORY_PARENTS").MustBool(true) Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true) Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply." + Domain) Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true) diff --git a/modules/structs/repo.go b/modules/structs/repo.go index b8f72a411c..4ef1d5a2f6 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -25,6 +25,8 @@ type InternalTracker struct { AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"` // Enable dependencies for issues and pull requests (Built-in issue tracker) EnableIssueDependencies bool `json:"enable_issue_dependencies"` + // Enable parents for issues and pull requests (Built-in issue tracker) + EnableIssueParents bool `json:"enable_issue_parents"` } // ExternalTracker represents settings for external tracker diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini index 1977db5b6d..1436b152bd 100644 --- a/options/locale/locale_ru-RU.ini +++ b/options/locale/locale_ru-RU.ini @@ -1372,6 +1372,33 @@ issues.dependency.add_error_dep_not_exist=Зависимости не сущес issues.dependency.add_error_dep_exists=Зависимость уже существует. issues.dependency.add_error_cannot_create_circular=Вы не можете создать зависимость с двумя задачами, блокирующими друг друга. issues.dependency.add_error_dep_not_same_repo=Обе задачи должны находиться в одном репозитории. + +issues.parent.title=Родительские задачи +issues.parent.issue_no_parents=В настоящее время эта задача не имеет родителей. +issues.parent.pr_no_parents=Этот запрос на слияние в настоящее время не имеет никаких родителей. +issues.parent.add=Добавить родителя… +issues.parent.cancel=Отменить +issues.parent.remove=Удалить +issues.parent.remove_info=Удалить этого родителя +issues.parent.added_parent=`добавить нового родителя %s` +issues.parent.removed_parent=`убрал родителя %s` +issues.parent.pr_closing_blockedby=Этот запрос на слияние имеет родителей +issues.parent.issue_closing_blockedby=Эта задача имеет родителей +issues.parent.issue_close_blocks=Эта задача имеет следующих детей +issues.parent.pr_close_blocks=Этот запрос на слияние имеет следующих детей +issues.parent.blocks_short=Дети +issues.parent.blocked_by_short=Родители +issues.parent.remove_header=Удалить родителя +issues.parent.issue_remove_text=Это приведет к удалению родителя от этой задачи. Продолжить? +issues.parent.pr_remove_text=Это приведёт к удалению родителя от этого запроса на слияние. Продолжить? +issues.parent.setting=Включение родителя для задач и запросов на слияние +issues.parent.add_error_same_issue=Вы не можете указать родителя задачи на саму себя. +issues.parent.add_error_dep_issue_not_exist=Родительсткая задача не существует. +issues.parent.add_error_dep_not_exist=Родительсткой задачи не существует. +issues.parent.add_error_dep_exists=Привязка к родителю уже существует. +issues.parent.add_error_cannot_create_circular=Вы не можете создать родителей с двумя задачами, блокирующими друг друга. +issues.parent.add_error_dep_not_same_repo=Обе задачи должны находиться в одном репозитории. + issues.review.self.approval=Вы не можете одобрить собственный запрос на слияние. issues.review.self.rejection=Невозможно запрашивать изменения своего запроса на слияние. issues.review.approve=одобрил(а) эти изменения %s @@ -2623,6 +2650,7 @@ config.default_allow_only_contributors_to_track_time=Учитывать толь config.no_reply_address=No-reply адрес config.default_visibility_organization=Видимость по умолчанию для новых организаций config.default_enable_dependencies=Включение зависимостей для задач по умолчанию +config.default_enable_parents=Включение родителей для задач по умолчанию config.webhook_config=Конфигурация вебхуков config.queue_length=Длина очереди diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 851505fe4a..7449ecabcf 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -776,6 +776,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { EnableTimetracker: opts.InternalTracker.EnableTimeTracker, AllowOnlyContributorsToTrackTime: opts.InternalTracker.AllowOnlyContributorsToTrackTime, EnableDependencies: opts.InternalTracker.EnableIssueDependencies, + EnableParents: opts.InternalTracker.EnableIssueParents, } } else if unit, err := repo.GetUnit(unit_model.TypeIssues); err != nil { // Unit type doesn't exist so we make a new config file with default values @@ -783,6 +784,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { EnableTimetracker: true, AllowOnlyContributorsToTrackTime: true, EnableDependencies: true, + EnableParents: true, } } else { config = unit.IssuesConfig() diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 248743471b..daaa09a43e 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -701,6 +701,9 @@ func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull // Contains true if the user can create issue dependencies ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User, isPull) + // Contains true if the user can create issue parents + ctx.Data["CanCreateIssueParents"] = ctx.Repo.CanCreateIssueParents(ctx.User, isPull) + return labels } @@ -1324,6 +1327,12 @@ func ViewIssue(ctx *context.Context) { // check if dependencies can be created across repositories ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies + // Check if the user can use the parents + ctx.Data["CanCreateIssueParents"] = ctx.Repo.CanCreateIssueParents(ctx.User, issue.IsPull) + + // check if parents can be created across repositories + ctx.Data["AllowCrossRepositoryParents"] = setting.Service.AllowCrossRepositoryParents + if issue.ShowRole, err = roleDescriptor(repo, issue.Poster, issue); err != nil { ctx.ServerError("roleDescriptor", err) return @@ -1650,6 +1659,18 @@ func ViewIssue(ctx *context.Context) { return } + // Get Parents + ctx.Data["BlockedByParents"], err = issue.BlockedByParents() + if err != nil { + ctx.ServerError("BlockedByParents", err) + return + } + ctx.Data["BlockingParents"], err = issue.BlockingParents() + if err != nil { + ctx.ServerError("BlockingParents", err) + return + } + ctx.Data["Participants"] = participants ctx.Data["NumParticipants"] = len(participants) ctx.Data["Issue"] = issue diff --git a/routers/web/repo/issue_parent.go b/routers/web/repo/issue_parent.go new file mode 100644 index 0000000000..355d5272cc --- /dev/null +++ b/routers/web/repo/issue_parent.go @@ -0,0 +1,129 @@ +// Copyright 2018-2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +// AddParent adds new parents +func AddParent(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + + // Check if the Repo is allowed to have parents + if !ctx.Repo.CanCreateIssueParents(ctx.User, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueParents") + return + } + + parentID := ctx.FormInt64("newParent") + + if err = issue.LoadRepo(); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Redirect + defer ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) + + // Parent + parent, err := models.GetIssueByID(parentID) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.issues.parent.add_error_dep_issue_not_exist")) + return + } + + // Check if both issues are in the same repo if cross repository parents is not enabled + if issue.RepoID != parent.RepoID && !setting.Service.AllowCrossRepositoryParents { + ctx.Flash.Error(ctx.Tr("repo.issues.parent.add_error_dep_not_same_repo")) + return + } + + // Check if issue and parent is the same + if parent.ID == issue.ID { + ctx.Flash.Error(ctx.Tr("repo.issues.parent.add_error_same_issue")) + return + } + + err = models.CreateIssueParent(ctx.User, issue, parent) + if err != nil { + if models.IsErrParentExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.parent.add_error_dep_exists")) + return + } else if models.IsErrCircularParent(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.parent.add_error_cannot_create_circular")) + return + } else { + ctx.ServerError("CreateOrUpdateIssueParent", err) + return + } + } +} + +// RemoveParent removes the parent +func RemoveParent(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + + // Check if the Repo is allowed to have dependencies + if !ctx.Repo.CanCreateIssueParents(ctx.User, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueParents") + return + } + + parentID := ctx.FormInt64("removeParentID") + + if err = issue.LoadRepo(); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Parent Type + parentTypeStr := ctx.Req.PostForm.Get("parentType") + + var parentType models.ParentType + + switch parentTypeStr { + case "father": + parentType = models.ParentTypeFather + case "child": + parentType = models.ParentTypeChild + default: + ctx.Error(http.StatusBadRequest, "GetDependecyType") + return + } + + // Parent + parent, err := models.GetIssueByID(parentID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = models.RemoveIssueParent(ctx.User, issue, parent, parentType); err != nil { + if models.IsErrParentNotExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.parent.add_error_dep_not_exist")) + return + } + ctx.ServerError("RemoveIssueParent", err) + return + } + + // Redirect + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) +} diff --git a/routers/web/repo/setting.go b/routers/web/repo/setting.go index f89bffb00f..f0548aaffe 100644 --- a/routers/web/repo/setting.go +++ b/routers/web/repo/setting.go @@ -434,6 +434,7 @@ func SettingsPost(ctx *context.Context) { EnableTimetracker: form.EnableTimetracker, AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, EnableDependencies: form.EnableIssueDependencies, + EnableParents: form.EnableIssueParents, }, }) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) diff --git a/routers/web/web.go b/routers/web/web.go index ef1b83ed7d..59629cb528 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -741,6 +741,10 @@ func RegisterRoutes(m *web.Route) { m.Post("/add", repo.AddDependency) m.Post("/delete", repo.RemoveDependency) }) + m.Group("/parent", func() { + m.Post("/add", repo.AddParent) + m.Post("/delete", repo.RemoveParent) + }) m.Combo("/comments").Post(repo.MustAllowUserComment, bindIgnErr(forms.CreateCommentForm{}), repo.NewComment) m.Group("/times", func() { m.Post("/add", bindIgnErr(forms.AddTimeManuallyForm{}), repo.AddTimeManually) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 3fab9af1f1..550375bd7a 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -155,6 +155,7 @@ type RepoSettingForm struct { EnableTimetracker bool AllowOnlyContributorsToTrackTime bool EnableIssueDependencies bool + EnableIssueParents bool IsArchived bool // Signing Settings diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 2a27baf535..e31fe3cae6 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -186,6 +186,8 @@
{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}
{{.i18n.Tr "admin.config.default_enable_dependencies"}}
{{if .Service.DefaultEnableDependencies}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
+
{{.i18n.Tr "admin.config.default_enable_parents"}}
+
{{if .Service.DefaultEnableParents}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
{{.i18n.Tr "admin.config.active_code_lives"}}
{{.Service.ActiveCodeLives}} {{.i18n.Tr "tool.raw_minutes"}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 6198b6a621..292b681b23 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -443,6 +443,131 @@ {{end}} + + {{if .Repository.IsParentsEnabled}} +
+ +
+ {{if (and (not .BlockedByParents) (not .BlockingParents))}} + {{.i18n.Tr "repo.issues.parent.title"}} +
+

+ {{if .Issue.IsPull}} + {{.i18n.Tr "repo.issues.parent.pr_no_parents"}} + {{else}} + {{.i18n.Tr "repo.issues.parent.issue_no_parents"}} + {{end}} +

+ {{end}} + + {{if .BlockingParents}} + + {{.i18n.Tr "repo.issues.parent.blocks_short"}} + +
+ {{range .BlockingParents}} +
+
+ + #{{.Issue.Index}} {{.Issue.Title | RenderEmoji}} + +
+ {{.Repository.OwnerName}}/{{.Repository.Name}} +
+
+
+ {{if and $.CanCreateIssueParents (not $.Repository.IsArchived)}} + + {{svg "octicon-trash" 16}} + + {{end}} +
+
+ {{end}} +
+ {{end}} + + {{if .BlockedByParents}} + + {{.i18n.Tr "repo.issues.parent.blocked_by_short"}} + +
+ {{range .BlockedByParents}} +
+
+ + #{{.Issue.Index}} {{.Issue.Title | RenderEmoji}} + +
+ {{.Repository.OwnerName}}/{{.Repository.Name}} +
+
+
+ {{if and $.CanCreateIssueParents (not $.Repository.IsArchived)}} + + {{svg "octicon-trash" 16}} + + {{end}} +
+
+ {{end}} +
+ {{end}} + + {{if and .CanCreateIssueParents (not .Repository.IsArchived)}} +
+
+ {{$.CsrfTokenHtml}} +
+ + +
+
+
+ {{end}} +
+ + {{if and .CanCreateIssueParents (not .Repository.IsArchived)}} + + + + {{end}} + {{end}} + {{if .Repository.IsDependenciesEnabled}}
diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 4389b63559..2dc4ce17c6 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -329,6 +329,12 @@ +
+
+ + +
+
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 791ada03fd..1d45d6e59c 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -15592,6 +15592,11 @@ "type": "boolean", "x-go-name": "EnableIssueDependencies" }, + "enable_issue_parents": { + "description": "Enable parents for issues and pull requests (Built-in issue tracker)", + "type": "boolean", + "x-go-name": "EnableIssueParents" + }, "enable_time_tracker": { "description": "Enable time tracking (Built-in issue tracker)", "type": "boolean", diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 9ee5e4f04e..a663e79880 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -121,6 +121,34 @@ export function initRepoIssueList() { fullTextSearch: true, }); + $('#new-parent-drop-list') + .dropdown({ + apiSettings: { + url: issueSearchUrl, + onResponse(response) { + const filteredResponse = {success: true, results: []}; + const currIssueId = $('#new-parent-drop-list').data('issue-id'); + // Parse the response from the api to work with our dropdown + $.each(response, (_i, issue) => { + // Don't list current issue in the parent list. + if (issue.id === currIssueId) { + return; + } + filteredResponse.results.push({ + name: `#${issue.number} ${htmlEscape(issue.title) + }
${htmlEscape(issue.repository.full_name)}
`, + value: issue.id, + }); + }); + return filteredResponse; + }, + cache: false, + }, + + fullTextSearch: true, + }); + + function excludeLabel(item) { const href = $(item).attr('href'); const id = $(item).data('label-id'); @@ -196,6 +224,24 @@ export function initRepoIssueDependencyDelete() { }); } +export function initRepoIssueParentDelete() { + // Delete Issue parent + $(document).on('click', '.delete-parent-button', (e) => { + const id = e.currentTarget.getAttribute('data-id'); + const type = e.currentTarget.getAttribute('data-type'); + + $('.remove-parent').modal({ + closable: false, + duration: 200, + onApprove: () => { + $('#removeParentID').val(id); + $('#parentType').val(type); + $('#removeParentForm').trigger('submit'); + }, + }).modal('show'); + }); +} + export function initRepoIssueCodeCommentCancel() { // Cancel inline code comment $(document).on('click', '.cancel-code-comment', (e) => { diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index f30345bfee..b8aea6268c 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -4,7 +4,7 @@ import {initCompImagePaste, initEasyMDEImagePaste} from './comp/ImagePaste.js'; import { initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, - initRepoIssueComments, initRepoIssueDependencyDelete, + initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueParentDelete, initRepoIssueReferenceIssue, initRepoIssueStatusButton, initRepoIssueTitleEdit, initRepoIssueWipToggle, initRepoPullRequestMerge, initRepoPullRequestUpdate, @@ -516,6 +516,7 @@ export function initRepository() { initRepoIssueCommentDelete(); initRepoIssueDependencyDelete(); + initRepoIssueParentDelete(); initRepoIssueCodeCommentCancel(); initRepoIssueStatusButton(); initRepoPullRequestMerge(); diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 6cf70abdf7..897d94a401 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -2929,6 +2929,21 @@ tbody.commit-list { } } +#new-parent-drop-list { + &.ui.selection.dropdown { + min-width: 0; + width: 100%; + border-radius: 4px 0 0 4px; + border-right: 0; + white-space: nowrap; + } + + .text { + width: 100%; + overflow: hidden; + } +} + #manage_topic { font-size: 12px; }