diff --git a/models/fixtures/issue_dependency.yml b/models/fixtures/issue_dependency.yml new file mode 100644 index 0000000000000..1c43932745acc --- /dev/null +++ b/models/fixtures/issue_dependency.yml @@ -0,0 +1,17 @@ +- + id: 1 + user_id: 40 + issue_id: 21 + dependency_id: 20 + +- + id: 2 + user_id: 40 + issue_id: 21 + dependency_id: 22 + +- + id: 3 + user_id: 40 + issue_id: 20 + dependency_id: 22 diff --git a/models/issues/dependency.go b/models/issues/dependency.go index 146dd1887dae4..842556c2dc960 100644 --- a/models/issues/dependency.go +++ b/models/issues/dependency.go @@ -107,8 +107,8 @@ func (err ErrUnknownDependencyType) Unwrap() error { type IssueDependency struct { ID int64 `xorm:"pk autoincr"` UserID int64 `xorm:"NOT NULL"` - IssueID int64 `xorm:"UNIQUE(issue_dependency) NOT NULL"` - DependencyID int64 `xorm:"UNIQUE(issue_dependency) NOT NULL"` + IssueID int64 `xorm:"UNIQUE(issue_dependency) NOT NULL index"` + DependencyID int64 `xorm:"UNIQUE(issue_dependency) NOT NULL index"` CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } diff --git a/models/issues/issue.go b/models/issues/issue.go index 87c1c86eb15be..464f3d9d4d40b 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -580,9 +580,14 @@ func IsUserParticipantsOfIssue(ctx context.Context, user *user_model.User, issue } // DependencyInfo represents high level information about an issue which is a dependency of another issue. +// this type is used in func `BlockingDependenciesMap` and `BlockedByDependenciesMap` as xorm intermediate type to retrieve info from joined tables type DependencyInfo struct { - Issue `xorm:"extends"` - repo_model.Repository `xorm:"extends"` + Issue `xorm:"extends"` // an issue/pull that depend on issue_id or is blocked by issue_id. the exact usage is determined by the function using this type + repo_model.Repository `xorm:"extends"` // the repo, that owns Issue + + // fields from `IssueDependency` + IssueID int64 `xorm:"NOT NULL"` // id of the issue/pull the that is used for the selection of dependent issues + DependencyID int64 `xorm:"NOT NULL"` // id of the issue/pull the that is used for the selection of blocked issues } // GetParticipantIDsByIssue returns all userIDs who are participated in comments of an issue and issue author diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go index 218891ad35771..469dd72e53f84 100644 --- a/models/issues/issue_list.go +++ b/models/issues/issue_list.go @@ -631,3 +631,55 @@ func (issues IssueList) LoadIsRead(ctx context.Context, userID int64) error { return nil } + +func (issues IssueList) BlockingDependenciesMap(ctx context.Context) (issueDepsMap map[int64][]*DependencyInfo, err error) { + var issueDeps []*DependencyInfo + + err = db.GetEngine(ctx). + Table("issue"). + Join("INNER", "repository", "repository.id = issue.repo_id"). + Join("INNER", "issue_dependency", "issue_dependency.issue_id = issue.id"). + Where(builder.In("issue_dependency.dependency_id", issues.getIssueIDs())). + // sort by repo id then index + Asc("`issue`.`repo_id`"). + Asc("`issue`.`index`"). + Find(&issueDeps) + if err != nil { + return nil, err + } + + issueDepsMap = make(map[int64][]*DependencyInfo, len(issues)) + for _, depInfo := range issueDeps { + depInfo.Issue.Repo = &depInfo.Repository + + issueDepsMap[depInfo.DependencyID] = append(issueDepsMap[depInfo.DependencyID], depInfo) + } + + return issueDepsMap, nil +} + +func (issues IssueList) BlockedByDependenciesMap(ctx context.Context) (issueDepsMap map[int64][]*DependencyInfo, err error) { + var issueDeps []*DependencyInfo + + err = db.GetEngine(ctx). + Table("issue"). + Join("INNER", "repository", "repository.id = issue.repo_id"). + Join("INNER", "issue_dependency", "issue_dependency.dependency_id = issue.id"). + Where(builder.In("issue_dependency.issue_id", issues.getIssueIDs())). + // sort by repo id then index + Asc("`issue`.`repo_id`"). + Asc("`issue`.`index`"). + Find(&issueDeps) + if err != nil { + return nil, err + } + + issueDepsMap = make(map[int64][]*DependencyInfo, len(issues)) + for _, depInfo := range issueDeps { + depInfo.Issue.Repo = &depInfo.Repository + + issueDepsMap[depInfo.IssueID] = append(issueDepsMap[depInfo.IssueID], depInfo) + } + + return issueDepsMap, nil +} diff --git a/models/issues/issue_list_test.go b/models/issues/issue_list_test.go index 9069e1012da53..3dbd907794291 100644 --- a/models/issues/issue_list_test.go +++ b/models/issues/issue_list_test.go @@ -4,10 +4,13 @@ package issues_test import ( + "cmp" + "slices" "testing" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/setting" @@ -73,3 +76,136 @@ func TestIssueList_LoadAttributes(t *testing.T) { } } } + +func TestIssueList_BlockingDependenciesMap(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + issueList := issues_model.IssueList{ + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 20}), + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 21}), + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 22}), + } + + blockingDependenciesMap, err := issueList.BlockingDependenciesMap(db.DefaultContext) + assert.NoError(t, err) + if assert.Len(t, blockingDependenciesMap, 2) { + var keys []int64 + for k := range blockingDependenciesMap { + keys = append(keys, k) + } + slices.Sort(keys) + assert.EqualValues(t, []int64{20, 22}, keys) + + if assert.Len(t, blockingDependenciesMap[20], 1) { + expectIssuesDependencyInfo(t, + &issues_model.DependencyInfo{ + IssueID: 21, + DependencyID: 20, + Issue: issues_model.Issue{ID: 21}, + Repository: repo_model.Repository{ID: 60}, + }, + blockingDependenciesMap[20][0]) + } + if assert.Len(t, blockingDependenciesMap[22], 2) { + list := sortIssuesDependencyInfos(blockingDependenciesMap[22]) + expectIssuesDependencyInfo(t, &issues_model.DependencyInfo{ + IssueID: 20, + DependencyID: 22, + Issue: issues_model.Issue{ID: 20}, + Repository: repo_model.Repository{ID: 23}, + }, list[0]) + expectIssuesDependencyInfo(t, &issues_model.DependencyInfo{ + IssueID: 21, + DependencyID: 22, + Issue: issues_model.Issue{ID: 21}, + Repository: repo_model.Repository{ID: 60}, + }, list[1]) + } + } + + issueList = issues_model.IssueList{ + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 21}), + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 22}), + } + + blockingDependenciesMap, err = issueList.BlockingDependenciesMap(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, blockingDependenciesMap, 1) + assert.Len(t, blockingDependenciesMap[22], 2) +} + +func TestIssueList_BlockedByDependenciesMap(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + issueList := issues_model.IssueList{ + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 20}), + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 21}), + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 22}), + } + + blockedByDependenciesMap, err := issueList.BlockedByDependenciesMap(db.DefaultContext) + assert.NoError(t, err) + if assert.Len(t, blockedByDependenciesMap, 2) { + var keys []int64 + for k := range blockedByDependenciesMap { + keys = append(keys, k) + } + slices.Sort(keys) + assert.EqualValues(t, []int64{20, 21}, keys) + + if assert.Len(t, blockedByDependenciesMap[20], 1) { + expectIssuesDependencyInfo(t, + &issues_model.DependencyInfo{ + IssueID: 20, + DependencyID: 22, + Issue: issues_model.Issue{ID: 22}, + Repository: repo_model.Repository{ID: 61}, + }, + blockedByDependenciesMap[20][0]) + } + if assert.Len(t, blockedByDependenciesMap[21], 2) { + list := sortIssuesDependencyInfos(blockedByDependenciesMap[21]) + expectIssuesDependencyInfo(t, &issues_model.DependencyInfo{ + IssueID: 21, + DependencyID: 20, + Issue: issues_model.Issue{ID: 20}, + Repository: repo_model.Repository{ID: 23}, + }, list[0]) + expectIssuesDependencyInfo(t, &issues_model.DependencyInfo{ + IssueID: 21, + DependencyID: 22, + Issue: issues_model.Issue{ID: 22}, + Repository: repo_model.Repository{ID: 61}, + }, list[1]) + } + } + + issueList = issues_model.IssueList{ + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 21}), + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 22}), + } + + blockedByDependenciesMap, err = issueList.BlockedByDependenciesMap(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, blockedByDependenciesMap, 1) + assert.Len(t, blockedByDependenciesMap[21], 2) +} + +func expectIssuesDependencyInfo(t *testing.T, expect, got *issues_model.DependencyInfo) { + if expect == nil { + assert.Nil(t, got) + return + } + if !assert.NotNil(t, got) { + return + } + assert.EqualValues(t, expect.DependencyID, got.DependencyID, "DependencyID") + assert.EqualValues(t, expect.IssueID, got.IssueID, "IssueID") + assert.EqualValues(t, expect.Issue.ID, got.Issue.ID, "RelatedIssueID") + assert.EqualValues(t, expect.Repository.ID, got.Repository.ID, "RelatedIssueRepoID") +} + +func sortIssuesDependencyInfos(in []*issues_model.DependencyInfo) []*issues_model.DependencyInfo { + slices.SortFunc(in, func(a, b *issues_model.DependencyInfo) int { + return cmp.Compare(a.DependencyID, b.DependencyID) + }) + return in +} diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 5a841f4d312e8..291bf3772b83b 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -118,6 +118,7 @@ func (cfg *IssuesConfig) ToDB() ([]byte, error) { // PullRequestsConfig describes pull requests config type PullRequestsConfig struct { IgnoreWhitespaceConflicts bool + ShowDependencies bool AllowMerge bool AllowRebase bool AllowRebaseMerge bool diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 39b9855186145..dd5fd6cb3bd4c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1696,7 +1696,9 @@ issues.dependency.issue_close_blocked = You need to close all issues blocking th issues.dependency.issue_batch_close_blocked = "Cannot batch close issues that you choose, because issue #%d still has open dependencies" issues.dependency.pr_close_blocked = You need to close all issues blocking this pull request before you can merge it. issues.dependency.blocks_short = Blocks +issues.dependency.blocks_following = blocks: issues.dependency.blocked_by_short = Depends on +issues.dependency.blocked_by_following = depends on: issues.dependency.remove_header = Remove Dependency issues.dependency.issue_remove_text = This will remove the dependency from this issue. Continue? issues.dependency.pr_remove_text = This will remove the dependency from this pull request. Continue? @@ -2118,6 +2120,7 @@ settings.enable_timetracker = Enable Time Tracking settings.allow_only_contributors_to_track_time = Let Only Contributors Track Time settings.pulls_desc = Enable Repository Pull Requests settings.pulls.ignore_whitespace = Ignore Whitespace for Conflicts +settings.pulls.show_dependencies = Show depends on and blocks in Pull Requests list settings.pulls.enable_autodetect_manual_merge = Enable autodetect manual merge (Note: In some special cases, misjudgments can occur) settings.pulls.allow_rebase_update = Enable updating pull request branch by rebase settings.pulls.default_delete_branch_after_merge = Delete pull request branch after merge by default diff --git a/public/assets/img/svg/gitea-issue-dependency.svg b/public/assets/img/svg/gitea-issue-dependency.svg new file mode 100644 index 0000000000000..82864deb6443c --- /dev/null +++ b/public/assets/img/svg/gitea-issue-dependency.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-issue-dependent.svg b/public/assets/img/svg/gitea-issue-dependent.svg new file mode 100644 index 0000000000000..9a367acfbc707 --- /dev/null +++ b/public/assets/img/svg/gitea-issue-dependent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 6c2d4a73902a7..1fb1748bf4c89 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -323,6 +323,25 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt return } + if unit, err := repo.GetUnit(ctx, unit.TypePullRequests); err == nil { + if config := unit.PullRequestsConfig(); config.ShowDependencies { + blockingDependenciesMap, err := issues.BlockingDependenciesMap(ctx) + if err != nil { + ctx.ServerError("BlockingDependenciesMap", err) + return + } + + blockedByDependenciesMap, err := issues.BlockedByDependenciesMap(ctx) + if err != nil { + ctx.ServerError("BlockedByDependenciesMap", err) + return + } + + ctx.Data["BlockingDependenciesMap"] = blockingDependenciesMap + ctx.Data["BlockedByDependenciesMap"] = blockedByDependenciesMap + } + } + if ctx.IsSigned { if err := issues.LoadIsRead(ctx, ctx.Doer.ID); err != nil { ctx.ServerError("LoadIsRead", err) diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 00a5282f34e77..3776380c028fa 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -584,6 +584,7 @@ func SettingsPost(ctx *context.Context) { Type: unit_model.TypePullRequests, Config: &repo_model.PullRequestsConfig{ IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, + ShowDependencies: form.PullsShowDependencies, AllowMerge: form.PullsAllowMerge, AllowRebase: form.PullsAllowRebase, AllowRebaseMerge: form.PullsAllowRebaseMerge, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e45a2a1695522..fd8497ff72b2a 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -149,6 +149,7 @@ type RepoSettingForm struct { EnablePulls bool EnableActions bool PullsIgnoreWhitespace bool + PullsShowDependencies bool PullsAllowMerge bool PullsAllowRebase bool PullsAllowRebaseMerge bool diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index b8fa4759b1326..d0de1f428da4b 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -645,6 +645,12 @@ +
+
+ + +
+
{{end}} diff --git a/templates/shared/issue_dependency.tmpl b/templates/shared/issue_dependency.tmpl new file mode 100644 index 0000000000000..df57f83881d39 --- /dev/null +++ b/templates/shared/issue_dependency.tmpl @@ -0,0 +1,9 @@ +{{if .Dependencies}} +
+ {{ctx.Locale.Tr .TitleKey}} + {{range $i, $dependency := .Dependencies}} + {{if gt $i 0}}, {{end}} + {{template "shared/issue_link" $dependency.Issue}} + {{end}} +
+{{end}} diff --git a/templates/shared/issue_link.tmpl b/templates/shared/issue_link.tmpl new file mode 100644 index 0000000000000..c8af8fc50b205 --- /dev/null +++ b/templates/shared/issue_link.tmpl @@ -0,0 +1 @@ +#{{.Index}} diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index 1c0dfcc5511e2..e6824d31ad4ec 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -118,6 +118,16 @@ {{end}} + {{if $.BlockedByDependenciesMap}} + {{template "shared/issue_dependency" (dict + "Dependencies" (index $.BlockedByDependenciesMap .ID) + "TitleKey" "repo.issues.dependency.blocked_by_following")}} + {{end}} + {{if $.BlockingDependenciesMap}} + {{template "shared/issue_dependency" (dict + "Dependencies" (index $.BlockingDependenciesMap .ID) + "TitleKey" "repo.issues.dependency.blocks_following")}} + {{end}} {{if .IsPull}} {{$approveOfficial := call $approvalCounts .ID "approve"}} {{$rejectOfficial := call $approvalCounts .ID "reject"}} diff --git a/web_src/svg/gitea-issue-dependency.svg b/web_src/svg/gitea-issue-dependency.svg new file mode 100644 index 0000000000000..de88a40c7873d --- /dev/null +++ b/web_src/svg/gitea-issue-dependency.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_src/svg/gitea-issue-dependent.svg b/web_src/svg/gitea-issue-dependent.svg new file mode 100644 index 0000000000000..558d169f99ecb --- /dev/null +++ b/web_src/svg/gitea-issue-dependent.svg @@ -0,0 +1 @@ + \ No newline at end of file