@@ -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 |
@@ -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"` | |||
} |
@@ -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 |
@@ -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 | |||
} |
@@ -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 | |||
} |
@@ -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 |
@@ -1703,7 +1703,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? | |||
@@ -2126,6 +2128,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 |
@@ -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> |
@@ -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> |
@@ -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) |
@@ -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, |
@@ -150,6 +150,7 @@ type RepoSettingForm struct { | |||
EnablePulls bool | |||
EnableActions bool | |||
PullsIgnoreWhitespace bool | |||
PullsShowDependencies bool | |||
PullsAllowMerge bool | |||
PullsAllowRebase bool | |||
PullsAllowRebaseMerge bool |
@@ -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}} | |||
@@ -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}} |
@@ -0,0 +1 @@ | |||
<a href="{{.Link}}" class="{{if .IsClosed}}tw-line-through {{end}}tw-ml-1 ref-issue">#{{.Index}}</a> |
@@ -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"}} |
@@ -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> |
@@ -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> |