This commit is contained in:
Tim-Niclas Oelschläger 2024-04-19 16:36:04 +05:00 committed by GitHub
commit fc9b04cf84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 269 additions and 4 deletions

View File

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

View File

@ -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"`
}

View File

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

View File

@ -626,3 +626,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
}

View File

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

View File

@ -120,6 +120,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

View File

@ -1701,7 +1701,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?
@ -2124,6 +2126,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 <b>depends on</b> and <b>blocks</b> 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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg gitea-issue-dependency" width="16" height="16" aria-hidden="true"><path d="m11.804 14.134-1.727-1.816h4.787a1.105 1.105 0 0 0 0-2.209h-4.787l1.727-1.815a1.105 1.105 0 1 0-1.6-1.523L6.7 10.453a1.105 1.105 0 0 0 0 1.522l3.502 3.682a1.105 1.105 0 0 0 1.601-1.523z"/><path d="M15.964 2.535A2.535 2.535 0 0 0 13.43 0H2.567A2.536 2.536 0 0 0 .032 2.535v10.862a2.535 2.535 0 0 0 2.535 2.535H4.74a1.086 1.086 0 0 0 0-2.173H2.567a.36.36 0 0 1-.362-.362V2.535a.36.36 0 0 1 .362-.362H13.43a.36.36 0 0 1 .362.362v2.172a1.086 1.086 0 0 0 2.172 0z"/></svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg gitea-issue-dependent" width="16" height="16" aria-hidden="true"><path d="m10.56 14.134 1.728-1.816H7.5a1.105 1.105 0 0 1 0-2.209h4.788L10.56 8.294a1.105 1.105 0 1 1 1.601-1.523l3.502 3.682a1.105 1.105 0 0 1 0 1.522l-3.502 3.682a1.105 1.105 0 0 1-1.6-1.523Z"/><path d="M15.964 2.535A2.535 2.535 0 0 0 13.43 0H2.567A2.536 2.536 0 0 0 .032 2.535v10.862a2.535 2.535 0 0 0 2.535 2.535H4.74a1.086 1.086 0 0 0 0-2.173H2.567a.36.36 0 0 1-.362-.362V2.535a.36.36 0 0 1 .362-.362H13.43a.36.36 0 0 1 .362.362v2.172a1.086 1.086 0 0 0 2.172 0z"/></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

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

View File

@ -586,6 +586,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,

View File

@ -150,6 +150,7 @@ type RepoSettingForm struct {
EnablePulls bool
EnableActions bool
PullsIgnoreWhitespace bool
PullsShowDependencies bool
PullsAllowMerge bool
PullsAllowRebase bool
PullsAllowRebaseMerge bool

View File

@ -659,6 +659,12 @@
<label>{{ctx.Locale.Tr "repo.settings.pulls.ignore_whitespace"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_show_dependencies" type="checkbox" {{if and $pullRequestEnabled ($prUnit.PullRequestsConfig.ShowDependencies)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.show_dependencies"}}</label>
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,9 @@
{{if .Dependencies}}
<div class="flex-text-inline">
{{ctx.Locale.Tr .TitleKey}}
{{range $i, $dependency := .Dependencies}}
{{if gt $i 0}}<span class="-tw-ml-1">, </span>{{end}}
{{template "shared/issue_link" $dependency.Issue}}
{{end}}
</div>
{{end}}

View File

@ -0,0 +1 @@
<a href="{{.Link}}" class="{{if .IsClosed}}tw-line-through {{end}}tw-ml-1 ref-issue">#{{.Index}}</a>

View File

@ -121,6 +121,16 @@
</span>
</span>
{{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"}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m11.804 14.134-1.727-1.816h4.787a1.105 1.105 0 0 0 0-2.209h-4.787l1.727-1.815a1.105 1.105 0 1 0-1.6-1.523L6.7 10.453a1.105 1.105 0 0 0 0 1.522l3.502 3.682a1.105 1.105 0 0 0 1.601-1.523z"/><path d="M15.964 2.535A2.535 2.535 0 0 0 13.43 0H2.567A2.536 2.536 0 0 0 .032 2.535v10.862a2.535 2.535 0 0 0 2.535 2.535H4.74a1.086 1.086 0 0 0 0-2.173H2.567a.362.362 0 0 1-.362-.362V2.535a.362.362 0 0 1 .362-.362H13.43a.362.362 0 0 1 .362.362v2.172a1.086 1.086 0 0 0 2.172 0z"/></svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m10.56 14.134 1.728-1.816H7.5a1.105 1.105 0 0 1 0-2.209h4.788L10.56 8.294a1.105 1.105 0 1 1 1.601-1.523l3.502 3.682a1.105 1.105 0 0 1 0 1.522l-3.502 3.682a1.105 1.105 0 0 1-1.6-1.523Z"/><path d="M15.964 2.535A2.535 2.535 0 0 0 13.43 0H2.567A2.536 2.536 0 0 0 .032 2.535v10.862a2.535 2.535 0 0 0 2.535 2.535H4.74a1.086 1.086 0 0 0 0-2.173H2.567a.362.362 0 0 1-.362-.362V2.535a.362.362 0 0 1 .362-.362H13.43a.362.362 0 0 1 .362.362v2.172a1.086 1.086 0 0 0 2.172 0z"/></svg>

After

Width:  |  Height:  |  Size: 540 B