@@ -316,6 +316,9 @@ DEFAULT_KEEP_EMAIL_PRIVATE = false | |||||
; Default value for AllowCreateOrganization | ; Default value for AllowCreateOrganization | ||||
; Every new user will have rights set to create organizations depending on this setting | ; Every new user will have rights set to create organizations depending on this setting | ||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true | DEFAULT_ALLOW_CREATE_ORGANIZATION = true | ||||
; Default value for EnableDependencies | |||||
; Repositories will use depencies by default depending on this setting | |||||
DEFAULT_ENABLE_DEPENDENCIES = true | |||||
; Enable Timetracking | ; Enable Timetracking | ||||
ENABLE_TIMETRACKING = true | ENABLE_TIMETRACKING = true | ||||
; Default value for EnableTimetracking | ; Default value for EnableTimetracking | ||||
@@ -182,6 +182,7 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. | |||||
- `CAPTCHA_TYPE`: **image**: \[image, recaptcha\] | - `CAPTCHA_TYPE`: **image**: \[image, recaptcha\] | ||||
- `RECAPTCHA_SECRET`: **""**: Go to https://www.google.com/recaptcha/admin to get a secret for recaptcha | - `RECAPTCHA_SECRET`: **""**: Go to https://www.google.com/recaptcha/admin to get a secret for recaptcha | ||||
- `RECAPTCHA_SITEKEY`: **""**: Go to https://www.google.com/recaptcha/admin to get a sitekey for recaptcha | - `RECAPTCHA_SITEKEY`: **""**: Go to https://www.google.com/recaptcha/admin to get a sitekey for recaptcha | ||||
- `DEFAULT_ENABLE_DEPENDENCIES`: **true** Enable this to have dependencies enabled by default. | |||||
## Webhook (`webhook`) | ## Webhook (`webhook`) | ||||
@@ -477,6 +477,10 @@ func UpdateIssuesCommit(doer *User, repo *Repository, commits []*PushCommit) err | |||||
} | } | ||||
if err = issue.ChangeStatus(doer, repo, true); err != nil { | if err = issue.ChangeStatus(doer, repo, true); err != nil { | ||||
// Don't return an error when dependencies are open as this would let the push fail | |||||
if IsErrDependenciesLeft(err) { | |||||
return nil | |||||
} | |||||
return err | return err | ||||
} | } | ||||
} | } | ||||
@@ -1259,3 +1259,88 @@ func IsErrU2FRegistrationNotExist(err error) bool { | |||||
_, ok := err.(ErrU2FRegistrationNotExist) | _, ok := err.(ErrU2FRegistrationNotExist) | ||||
return ok | return ok | ||||
} | } | ||||
// .___ ________ .___ .__ | |||||
// | | ______ ________ __ ____ \______ \ ____ ______ ____ ____ __| _/____ ____ ____ |__| ____ ______ | |||||
// | |/ ___// ___/ | \_/ __ \ | | \_/ __ \\____ \_/ __ \ / \ / __ |/ __ \ / \_/ ___\| |/ __ \ / ___/ | |||||
// | |\___ \ \___ \| | /\ ___/ | ` \ ___/| |_> > ___/| | \/ /_/ \ ___/| | \ \___| \ ___/ \___ \ | |||||
// |___/____ >____ >____/ \___ >_______ /\___ > __/ \___ >___| /\____ |\___ >___| /\___ >__|\___ >____ > | |||||
// \/ \/ \/ \/ \/|__| \/ \/ \/ \/ \/ \/ \/ \/ | |||||
// ErrDependencyExists represents a "DependencyAlreadyExists" kind of error. | |||||
type ErrDependencyExists struct { | |||||
IssueID int64 | |||||
DependencyID int64 | |||||
} | |||||
// IsErrDependencyExists checks if an error is a ErrDependencyExists. | |||||
func IsErrDependencyExists(err error) bool { | |||||
_, ok := err.(ErrDependencyExists) | |||||
return ok | |||||
} | |||||
func (err ErrDependencyExists) Error() string { | |||||
return fmt.Sprintf("issue dependency does already exist [issue id: %d, dependency id: %d]", err.IssueID, err.DependencyID) | |||||
} | |||||
// ErrDependencyNotExists represents a "DependencyAlreadyExists" kind of error. | |||||
type ErrDependencyNotExists struct { | |||||
IssueID int64 | |||||
DependencyID int64 | |||||
} | |||||
// IsErrDependencyNotExists checks if an error is a ErrDependencyExists. | |||||
func IsErrDependencyNotExists(err error) bool { | |||||
_, ok := err.(ErrDependencyNotExists) | |||||
return ok | |||||
} | |||||
func (err ErrDependencyNotExists) Error() string { | |||||
return fmt.Sprintf("issue dependency does not exist [issue id: %d, dependency id: %d]", err.IssueID, err.DependencyID) | |||||
} | |||||
// ErrCircularDependency represents a "DependencyCircular" kind of error. | |||||
type ErrCircularDependency struct { | |||||
IssueID int64 | |||||
DependencyID int64 | |||||
} | |||||
// IsErrCircularDependency checks if an error is a ErrCircularDependency. | |||||
func IsErrCircularDependency(err error) bool { | |||||
_, ok := err.(ErrCircularDependency) | |||||
return ok | |||||
} | |||||
func (err ErrCircularDependency) Error() string { | |||||
return fmt.Sprintf("circular dependencies exists (two issues blocking each other) [issue id: %d, dependency id: %d]", err.IssueID, err.DependencyID) | |||||
} | |||||
// ErrDependenciesLeft represents an error where the issue you're trying to close still has dependencies left. | |||||
type ErrDependenciesLeft struct { | |||||
IssueID int64 | |||||
} | |||||
// IsErrDependenciesLeft checks if an error is a ErrDependenciesLeft. | |||||
func IsErrDependenciesLeft(err error) bool { | |||||
_, ok := err.(ErrDependenciesLeft) | |||||
return ok | |||||
} | |||||
func (err ErrDependenciesLeft) Error() string { | |||||
return fmt.Sprintf("issue has open dependencies [issue id: %d]", err.IssueID) | |||||
} | |||||
// ErrUnknownDependencyType represents an error where an unknown dependency type was passed | |||||
type ErrUnknownDependencyType struct { | |||||
Type DependencyType | |||||
} | |||||
// IsErrUnknownDependencyType checks if an error is ErrUnknownDependencyType | |||||
func IsErrUnknownDependencyType(err error) bool { | |||||
_, ok := err.(ErrUnknownDependencyType) | |||||
return ok | |||||
} | |||||
func (err ErrUnknownDependencyType) Error() string { | |||||
return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) | |||||
} |
@@ -649,6 +649,20 @@ func (issue *Issue) changeStatus(e *xorm.Session, doer *User, repo *Repository, | |||||
if issue.IsClosed == isClosed { | if issue.IsClosed == isClosed { | ||||
return nil | return nil | ||||
} | } | ||||
// Check for open dependencies | |||||
if isClosed && issue.Repo.IsDependenciesEnabled() { | |||||
// only check if dependencies are enabled and we're about to close an issue, otherwise reopening an issue would fail when there are unsatisfied dependencies | |||||
noDeps, err := IssueNoDependenciesLeft(issue) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
if !noDeps { | |||||
return ErrDependenciesLeft{issue.ID} | |||||
} | |||||
} | |||||
issue.IsClosed = isClosed | issue.IsClosed = isClosed | ||||
if isClosed { | if isClosed { | ||||
issue.ClosedUnix = util.TimeStampNow() | issue.ClosedUnix = util.TimeStampNow() | ||||
@@ -1598,3 +1612,33 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix util.TimeStamp, doer *User) | |||||
return sess.Commit() | return sess.Commit() | ||||
} | } | ||||
// Get Blocked By Dependencies, aka all issues this issue is blocked by. | |||||
func (issue *Issue) getBlockedByDependencies(e Engine) (issueDeps []*Issue, err error) { | |||||
return issueDeps, e. | |||||
Table("issue_dependency"). | |||||
Select("issue.*"). | |||||
Join("INNER", "issue", "issue.id = issue_dependency.dependency_id"). | |||||
Where("issue_id = ?", issue.ID). | |||||
Find(&issueDeps) | |||||
} | |||||
// Get Blocking Dependencies, aka all issues this issue blocks. | |||||
func (issue *Issue) getBlockingDependencies(e Engine) (issueDeps []*Issue, err error) { | |||||
return issueDeps, e. | |||||
Table("issue_dependency"). | |||||
Select("issue.*"). | |||||
Join("INNER", "issue", "issue.id = issue_dependency.issue_id"). | |||||
Where("dependency_id = ?", issue.ID). | |||||
Find(&issueDeps) | |||||
} | |||||
// BlockedByDependencies finds all Dependencies an issue is blocked by | |||||
func (issue *Issue) BlockedByDependencies() ([]*Issue, error) { | |||||
return issue.getBlockedByDependencies(x) | |||||
} | |||||
// BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks | |||||
func (issue *Issue) BlockingDependencies() ([]*Issue, error) { | |||||
return issue.getBlockingDependencies(x) | |||||
} |
@@ -66,6 +66,10 @@ const ( | |||||
CommentTypeModifiedDeadline | CommentTypeModifiedDeadline | ||||
// Removed a due date | // Removed a due date | ||||
CommentTypeRemovedDeadline | CommentTypeRemovedDeadline | ||||
// Dependency added | |||||
CommentTypeAddDependency | |||||
//Dependency removed | |||||
CommentTypeRemoveDependency | |||||
) | ) | ||||
// CommentTag defines comment tag type | // CommentTag defines comment tag type | ||||
@@ -81,23 +85,25 @@ const ( | |||||
// Comment represents a comment in commit and issue page. | // Comment represents a comment in commit and issue page. | ||||
type Comment struct { | type Comment struct { | ||||
ID int64 `xorm:"pk autoincr"` | |||||
Type CommentType | |||||
PosterID int64 `xorm:"INDEX"` | |||||
Poster *User `xorm:"-"` | |||||
IssueID int64 `xorm:"INDEX"` | |||||
Issue *Issue `xorm:"-"` | |||||
LabelID int64 | |||||
Label *Label `xorm:"-"` | |||||
OldMilestoneID int64 | |||||
MilestoneID int64 | |||||
OldMilestone *Milestone `xorm:"-"` | |||||
Milestone *Milestone `xorm:"-"` | |||||
AssigneeID int64 | |||||
RemovedAssignee bool | |||||
Assignee *User `xorm:"-"` | |||||
OldTitle string | |||||
NewTitle string | |||||
ID int64 `xorm:"pk autoincr"` | |||||
Type CommentType | |||||
PosterID int64 `xorm:"INDEX"` | |||||
Poster *User `xorm:"-"` | |||||
IssueID int64 `xorm:"INDEX"` | |||||
Issue *Issue `xorm:"-"` | |||||
LabelID int64 | |||||
Label *Label `xorm:"-"` | |||||
OldMilestoneID int64 | |||||
MilestoneID int64 | |||||
OldMilestone *Milestone `xorm:"-"` | |||||
Milestone *Milestone `xorm:"-"` | |||||
AssigneeID int64 | |||||
RemovedAssignee bool | |||||
Assignee *User `xorm:"-"` | |||||
OldTitle string | |||||
NewTitle string | |||||
DependentIssueID int64 | |||||
DependentIssue *Issue `xorm:"-"` | |||||
CommitID int64 | CommitID int64 | ||||
Line int64 | Line int64 | ||||
@@ -281,6 +287,15 @@ func (c *Comment) LoadAssigneeUser() error { | |||||
return nil | return nil | ||||
} | } | ||||
// LoadDepIssueDetails loads Dependent Issue Details | |||||
func (c *Comment) LoadDepIssueDetails() (err error) { | |||||
if c.DependentIssueID <= 0 || c.DependentIssue != nil { | |||||
return nil | |||||
} | |||||
c.DependentIssue, err = getIssueByID(x, c.DependentIssueID) | |||||
return err | |||||
} | |||||
// MailParticipants sends new comment emails to repository watchers | // MailParticipants sends new comment emails to repository watchers | ||||
// and mentioned people. | // and mentioned people. | ||||
func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (err error) { | func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (err error) { | ||||
@@ -332,22 +347,24 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | |||||
if opts.Label != nil { | if opts.Label != nil { | ||||
LabelID = opts.Label.ID | LabelID = opts.Label.ID | ||||
} | } | ||||
comment := &Comment{ | comment := &Comment{ | ||||
Type: opts.Type, | |||||
PosterID: opts.Doer.ID, | |||||
Poster: opts.Doer, | |||||
IssueID: opts.Issue.ID, | |||||
LabelID: LabelID, | |||||
OldMilestoneID: opts.OldMilestoneID, | |||||
MilestoneID: opts.MilestoneID, | |||||
RemovedAssignee: opts.RemovedAssignee, | |||||
AssigneeID: opts.AssigneeID, | |||||
CommitID: opts.CommitID, | |||||
CommitSHA: opts.CommitSHA, | |||||
Line: opts.LineNum, | |||||
Content: opts.Content, | |||||
OldTitle: opts.OldTitle, | |||||
NewTitle: opts.NewTitle, | |||||
Type: opts.Type, | |||||
PosterID: opts.Doer.ID, | |||||
Poster: opts.Doer, | |||||
IssueID: opts.Issue.ID, | |||||
LabelID: LabelID, | |||||
OldMilestoneID: opts.OldMilestoneID, | |||||
MilestoneID: opts.MilestoneID, | |||||
RemovedAssignee: opts.RemovedAssignee, | |||||
AssigneeID: opts.AssigneeID, | |||||
CommitID: opts.CommitID, | |||||
CommitSHA: opts.CommitSHA, | |||||
Line: opts.LineNum, | |||||
Content: opts.Content, | |||||
OldTitle: opts.OldTitle, | |||||
NewTitle: opts.NewTitle, | |||||
DependentIssueID: opts.DependentIssueID, | |||||
} | } | ||||
if _, err = e.Insert(comment); err != nil { | if _, err = e.Insert(comment); err != nil { | ||||
return nil, err | return nil, err | ||||
@@ -549,6 +566,39 @@ func createDeleteBranchComment(e *xorm.Session, doer *User, repo *Repository, is | |||||
}) | }) | ||||
} | } | ||||
// Creates issue dependency comment | |||||
func createIssueDependencyComment(e *xorm.Session, doer *User, issue *Issue, dependentIssue *Issue, add bool) (err error) { | |||||
cType := CommentTypeAddDependency | |||||
if !add { | |||||
cType = CommentTypeRemoveDependency | |||||
} | |||||
// Make two comments, one in each issue | |||||
_, err = createComment(e, &CreateCommentOptions{ | |||||
Type: cType, | |||||
Doer: doer, | |||||
Repo: issue.Repo, | |||||
Issue: issue, | |||||
DependentIssueID: dependentIssue.ID, | |||||
}) | |||||
if err != nil { | |||||
return | |||||
} | |||||
_, err = createComment(e, &CreateCommentOptions{ | |||||
Type: cType, | |||||
Doer: doer, | |||||
Repo: issue.Repo, | |||||
Issue: dependentIssue, | |||||
DependentIssueID: issue.ID, | |||||
}) | |||||
if err != nil { | |||||
return | |||||
} | |||||
return | |||||
} | |||||
// CreateCommentOptions defines options for creating comment | // CreateCommentOptions defines options for creating comment | ||||
type CreateCommentOptions struct { | type CreateCommentOptions struct { | ||||
Type CommentType | Type CommentType | ||||
@@ -557,17 +607,18 @@ type CreateCommentOptions struct { | |||||
Issue *Issue | Issue *Issue | ||||
Label *Label | Label *Label | ||||
OldMilestoneID int64 | |||||
MilestoneID int64 | |||||
AssigneeID int64 | |||||
RemovedAssignee bool | |||||
OldTitle string | |||||
NewTitle string | |||||
CommitID int64 | |||||
CommitSHA string | |||||
LineNum int64 | |||||
Content string | |||||
Attachments []string // UUIDs of attachments | |||||
DependentIssueID int64 | |||||
OldMilestoneID int64 | |||||
MilestoneID int64 | |||||
AssigneeID int64 | |||||
RemovedAssignee bool | |||||
OldTitle string | |||||
NewTitle string | |||||
CommitID int64 | |||||
CommitSHA string | |||||
LineNum int64 | |||||
Content string | |||||
Attachments []string // UUIDs of attachments | |||||
} | } | ||||
// CreateComment creates comment of issue or commit. | // CreateComment creates comment of issue or commit. | ||||
@@ -0,0 +1,137 @@ | |||||
// 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 ( | |||||
"code.gitea.io/gitea/modules/log" | |||||
"code.gitea.io/gitea/modules/setting" | |||||
"code.gitea.io/gitea/modules/util" | |||||
) | |||||
// IssueDependency represents an issue dependency | |||||
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"` | |||||
CreatedUnix util.TimeStamp `xorm:"created"` | |||||
UpdatedUnix util.TimeStamp `xorm:"updated"` | |||||
} | |||||
// DependencyType Defines Dependency Type Constants | |||||
type DependencyType int | |||||
// Define Dependency Types | |||||
const ( | |||||
DependencyTypeBlockedBy DependencyType = iota | |||||
DependencyTypeBlocking | |||||
) | |||||
// CreateIssueDependency creates a new dependency for an issue | |||||
func CreateIssueDependency(user *User, issue, dep *Issue) error { | |||||
sess := x.NewSession() | |||||
defer sess.Close() | |||||
if err := sess.Begin(); err != nil { | |||||
return err | |||||
} | |||||
// Check if it aleready exists | |||||
exists, err := issueDepExists(sess, issue.ID, dep.ID) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
if exists { | |||||
return ErrDependencyExists{issue.ID, dep.ID} | |||||
} | |||||
// And if it would be circular | |||||
circular, err := issueDepExists(sess, dep.ID, issue.ID) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
if circular { | |||||
return ErrCircularDependency{issue.ID, dep.ID} | |||||
} | |||||
if _, err := sess.Insert(&IssueDependency{ | |||||
UserID: user.ID, | |||||
IssueID: issue.ID, | |||||
DependencyID: dep.ID, | |||||
}); err != nil { | |||||
return err | |||||
} | |||||
// Add comment referencing the new dependency | |||||
if err = createIssueDependencyComment(sess, user, issue, dep, true); err != nil { | |||||
return err | |||||
} | |||||
return sess.Commit() | |||||
} | |||||
// RemoveIssueDependency removes a dependency from an issue | |||||
func RemoveIssueDependency(user *User, issue *Issue, dep *Issue, depType DependencyType) (err error) { | |||||
sess := x.NewSession() | |||||
defer sess.Close() | |||||
if err = sess.Begin(); err != nil { | |||||
return err | |||||
} | |||||
var issueDepToDelete IssueDependency | |||||
switch depType { | |||||
case DependencyTypeBlockedBy: | |||||
issueDepToDelete = IssueDependency{IssueID: issue.ID, DependencyID: dep.ID} | |||||
case DependencyTypeBlocking: | |||||
issueDepToDelete = IssueDependency{IssueID: dep.ID, DependencyID: issue.ID} | |||||
default: | |||||
return ErrUnknownDependencyType{depType} | |||||
} | |||||
affected, err := sess.Delete(&issueDepToDelete) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
// If we deleted nothing, the dependency did not exist | |||||
if affected <= 0 { | |||||
return ErrDependencyNotExists{issue.ID, dep.ID} | |||||
} | |||||
// Add comment referencing the removed dependency | |||||
if err = createIssueDependencyComment(sess, user, issue, dep, false); err != nil { | |||||
return err | |||||
} | |||||
return sess.Commit() | |||||
} | |||||
// Check if the dependency already exists | |||||
func issueDepExists(e Engine, issueID int64, depID int64) (bool, error) { | |||||
return e.Where("(issue_id = ? AND dependency_id = ?)", issueID, depID).Exist(&IssueDependency{}) | |||||
} | |||||
// IssueNoDependenciesLeft checks if issue can be closed | |||||
func IssueNoDependenciesLeft(issue *Issue) (bool, error) { | |||||
exists, err := x. | |||||
Table("issue_dependency"). | |||||
Select("issue.*"). | |||||
Join("INNER", "issue", "issue.id = issue_dependency.dependency_id"). | |||||
Where("issue_dependency.issue_id = ?", issue.ID). | |||||
And("issue.is_closed = ?", "0"). | |||||
Exist(&Issue{}) | |||||
return !exists, err | |||||
} | |||||
// IsDependenciesEnabled returns if dependecies are enabled and returns the default setting if not set. | |||||
func (repo *Repository) IsDependenciesEnabled() bool { | |||||
var u *RepoUnit | |||||
var err error | |||||
if u, err = repo.GetUnit(UnitTypeIssues); err != nil { | |||||
log.Trace("%s", err) | |||||
return setting.Service.DefaultEnableDependencies | |||||
} | |||||
return u.IssuesConfig().EnableDependencies | |||||
} |
@@ -0,0 +1,57 @@ | |||||
// 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" | |||||
"github.com/stretchr/testify/assert" | |||||
) | |||||
func TestCreateIssueDependency(t *testing.T) { | |||||
// Prepare | |||||
assert.NoError(t, PrepareTestDatabase()) | |||||
user1, err := 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 = CreateIssueDependency(user1, issue1, issue2) | |||||
assert.NoError(t, err) | |||||
// Do it again to see if it will check if the dependency already exists | |||||
err = CreateIssueDependency(user1, issue1, issue2) | |||||
assert.Error(t, err) | |||||
assert.True(t, IsErrDependencyExists(err)) | |||||
// Check for circular dependencies | |||||
err = CreateIssueDependency(user1, issue2, issue1) | |||||
assert.Error(t, err) | |||||
assert.True(t, IsErrCircularDependency(err)) | |||||
_ = AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeAddDependency, PosterID: user1.ID, IssueID: issue1.ID}) | |||||
// Check if dependencies left is correct | |||||
left, err := IssueNoDependenciesLeft(issue1) | |||||
assert.NoError(t, err) | |||||
assert.False(t, left) | |||||
// Close #2 and check again | |||||
err = issue2.ChangeStatus(user1, issue2.Repo, true) | |||||
assert.NoError(t, err) | |||||
left, err = IssueNoDependenciesLeft(issue1) | |||||
assert.NoError(t, err) | |||||
assert.True(t, left) | |||||
// Test removing the dependency | |||||
err = RemoveIssueDependency(user1, issue1, issue2, DependencyTypeBlockedBy) | |||||
assert.NoError(t, err) | |||||
} |
@@ -192,6 +192,8 @@ var migrations = []Migration{ | |||||
NewMigration("Reformat and remove incorrect topics", reformatAndRemoveIncorrectTopics), | NewMigration("Reformat and remove incorrect topics", reformatAndRemoveIncorrectTopics), | ||||
// v69 -> v70 | // v69 -> v70 | ||||
NewMigration("move team units to team_unit table", moveTeamUnitsToTeamUnitTable), | NewMigration("move team units to team_unit table", moveTeamUnitsToTeamUnitTable), | ||||
// v70 -> v71 | |||||
NewMigration("add issue_dependencies", addIssueDependencies), | |||||
} | } | ||||
// Migrate database to current version | // Migrate database to current version | ||||
@@ -0,0 +1,100 @@ | |||||
// 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 migrations | |||||
import ( | |||||
"fmt" | |||||
"time" | |||||
"code.gitea.io/gitea/modules/setting" | |||||
"github.com/go-xorm/xorm" | |||||
) | |||||
func addIssueDependencies(x *xorm.Engine) (err error) { | |||||
type IssueDependency struct { | |||||
ID int64 `xorm:"pk autoincr"` | |||||
UserID int64 `xorm:"NOT NULL"` | |||||
IssueID int64 `xorm:"NOT NULL"` | |||||
DependencyID int64 `xorm:"NOT NULL"` | |||||
Created time.Time `xorm:"-"` | |||||
CreatedUnix int64 `xorm:"created"` | |||||
Updated time.Time `xorm:"-"` | |||||
UpdatedUnix int64 `xorm:"updated"` | |||||
} | |||||
if err = x.Sync(new(IssueDependency)); err != nil { | |||||
return fmt.Errorf("Error creating issue_dependency_table column definition: %v", err) | |||||
} | |||||
// Update Comment definition | |||||
// This (copied) struct does only contain fields used by xorm as the only use here is to update the database | |||||
// CommentType defines the comment type | |||||
type CommentType int | |||||
// TimeStamp defines a timestamp | |||||
type TimeStamp int64 | |||||
type Comment struct { | |||||
ID int64 `xorm:"pk autoincr"` | |||||
Type CommentType | |||||
PosterID int64 `xorm:"INDEX"` | |||||
IssueID int64 `xorm:"INDEX"` | |||||
LabelID int64 | |||||
OldMilestoneID int64 | |||||
MilestoneID int64 | |||||
OldAssigneeID int64 | |||||
AssigneeID int64 | |||||
OldTitle string | |||||
NewTitle string | |||||
DependentIssueID int64 | |||||
CommitID int64 | |||||
Line int64 | |||||
Content string `xorm:"TEXT"` | |||||
CreatedUnix TimeStamp `xorm:"INDEX created"` | |||||
UpdatedUnix TimeStamp `xorm:"INDEX updated"` | |||||
// Reference issue in commit message | |||||
CommitSHA string `xorm:"VARCHAR(40)"` | |||||
} | |||||
if err = x.Sync(new(Comment)); err != nil { | |||||
return fmt.Errorf("Error updating issue_comment table column definition: %v", err) | |||||
} | |||||
// RepoUnit describes all units of a repository | |||||
type RepoUnit struct { | |||||
ID int64 | |||||
RepoID int64 `xorm:"INDEX(s)"` | |||||
Type int `xorm:"INDEX(s)"` | |||||
Config map[string]interface{} `xorm:"JSON"` | |||||
CreatedUnix int64 `xorm:"INDEX CREATED"` | |||||
Created time.Time `xorm:"-"` | |||||
} | |||||
//Updating existing issue units | |||||
units := make([]*RepoUnit, 0, 100) | |||||
err = x.Where("`type` = ?", V16UnitTypeIssues).Find(&units) | |||||
if err != nil { | |||||
return fmt.Errorf("Query repo units: %v", err) | |||||
} | |||||
for _, unit := range units { | |||||
if unit.Config == nil { | |||||
unit.Config = make(map[string]interface{}) | |||||
} | |||||
if _, ok := unit.Config["EnableDependencies"]; !ok { | |||||
unit.Config["EnableDependencies"] = setting.Service.DefaultEnableDependencies | |||||
} | |||||
if _, err := x.ID(unit.ID).Cols("config").Update(unit); err != nil { | |||||
return err | |||||
} | |||||
} | |||||
return err | |||||
} |
@@ -118,6 +118,7 @@ func init() { | |||||
new(TrackedTime), | new(TrackedTime), | ||||
new(DeletedBranch), | new(DeletedBranch), | ||||
new(RepoIndexerStatus), | new(RepoIndexerStatus), | ||||
new(IssueDependency), | |||||
new(LFSLock), | new(LFSLock), | ||||
new(Reaction), | new(Reaction), | ||||
new(IssueAssignees), | new(IssueAssignees), | ||||
@@ -1345,7 +1345,11 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err | |||||
units = append(units, RepoUnit{ | units = append(units, RepoUnit{ | ||||
RepoID: repo.ID, | RepoID: repo.ID, | ||||
Type: tp, | Type: tp, | ||||
Config: &IssuesConfig{EnableTimetracker: setting.Service.DefaultEnableTimetracking, AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime}, | |||||
Config: &IssuesConfig{ | |||||
EnableTimetracker: setting.Service.DefaultEnableTimetracking, | |||||
AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime, | |||||
EnableDependencies: setting.Service.DefaultEnableDependencies, | |||||
}, | |||||
}) | }) | ||||
} else if tp == UnitTypePullRequests { | } else if tp == UnitTypePullRequests { | ||||
units = append(units, RepoUnit{ | units = append(units, RepoUnit{ | ||||
@@ -73,6 +73,7 @@ func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) { | |||||
type IssuesConfig struct { | type IssuesConfig struct { | ||||
EnableTimetracker bool | EnableTimetracker bool | ||||
AllowOnlyContributorsToTrackTime bool | AllowOnlyContributorsToTrackTime bool | ||||
EnableDependencies bool | |||||
} | } | ||||
// FromDB fills up a IssuesConfig from serialized format. | // FromDB fills up a IssuesConfig from serialized format. | ||||
@@ -165,7 +166,6 @@ func (r *RepoUnit) IssuesConfig() *IssuesConfig { | |||||
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig { | func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig { | ||||
return r.Config.(*ExternalTrackerConfig) | return r.Config.(*ExternalTrackerConfig) | ||||
} | } | ||||
func getUnitsByRepoID(e Engine, repoID int64) (units []*RepoUnit, err error) { | func getUnitsByRepoID(e Engine, repoID int64) (units []*RepoUnit, err error) { | ||||
return units, e.Where("repo_id = ?", repoID).Find(&units) | return units, e.Where("repo_id = ?", repoID).Find(&units) | ||||
} | } | ||||
@@ -113,6 +113,7 @@ type RepoSettingForm struct { | |||||
PullsAllowSquash bool | PullsAllowSquash bool | ||||
EnableTimetracker bool | EnableTimetracker bool | ||||
AllowOnlyContributorsToTrackTime bool | AllowOnlyContributorsToTrackTime bool | ||||
EnableIssueDependencies bool | |||||
// Admin settings | // Admin settings | ||||
EnableHealthCheck bool | EnableHealthCheck bool | ||||
@@ -104,6 +104,11 @@ func (r *Repository) CanUseTimetracker(issue *models.Issue, user *models.User) b | |||||
r.IsWriter() || issue.IsPoster(user.ID) || isAssigned) | r.IsWriter() || issue.IsPoster(user.ID) || isAssigned) | ||||
} | } | ||||
// CanCreateIssueDependencies returns whether or not a user can create dependencies. | |||||
func (r *Repository) CanCreateIssueDependencies(user *models.User) bool { | |||||
return r.Repository.IsDependenciesEnabled() && r.IsWriter() | |||||
} | |||||
// GetCommitsCount returns cached commit count for current view | // GetCommitsCount returns cached commit count for current view | ||||
func (r *Repository) GetCommitsCount() (int64, error) { | func (r *Repository) GetCommitsCount() (int64, error) { | ||||
var contextName string | var contextName string | ||||
@@ -1180,6 +1180,7 @@ var Service struct { | |||||
DefaultAllowCreateOrganization bool | DefaultAllowCreateOrganization bool | ||||
EnableTimetracking bool | EnableTimetracking bool | ||||
DefaultEnableTimetracking bool | DefaultEnableTimetracking bool | ||||
DefaultEnableDependencies bool | |||||
DefaultAllowOnlyContributorsToTrackTime bool | DefaultAllowOnlyContributorsToTrackTime bool | ||||
NoReplyAddress string | NoReplyAddress string | ||||
@@ -1210,6 +1211,7 @@ func newService() { | |||||
if Service.EnableTimetracking { | if Service.EnableTimetracking { | ||||
Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true) | Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true) | ||||
} | } | ||||
Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true) | |||||
Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true) | Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true) | ||||
Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org") | Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org") | ||||
@@ -782,6 +782,33 @@ issues.due_date_modified = "modified the due date to %s from %s %s" | |||||
issues.due_date_remove = "removed the due date %s %s" | issues.due_date_remove = "removed the due date %s %s" | ||||
issues.due_date_overdue = "Overdue" | issues.due_date_overdue = "Overdue" | ||||
issues.due_date_invalid = "The due date is invalid or out of range. Please use the format yyyy-mm-dd." | issues.due_date_invalid = "The due date is invalid or out of range. Please use the format yyyy-mm-dd." | ||||
issues.dependency.title = Dependencies | |||||
issues.dependency.issue_no_dependencies = This issue currently doesn't have any dependencies. | |||||
issues.dependency.pr_no_dependencies = This pull request currently doesn't have any dependencies. | |||||
issues.dependency.add = Add a new dependency... | |||||
issues.dependency.cancel = Cancel | |||||
issues.dependency.remove = Remove | |||||
issues.dependency.issue_number = Issuenumber | |||||
issues.dependency.added_dependency = `<a href="%[1]s">%[2]s</a> added a new dependency %[3]s` | |||||
issues.dependency.removed_dependency = `<a href="%[1]s">%[2]s</a> removed a dependency %[3]s` | |||||
issues.dependency.issue_closing_blockedby = Closing this pull request is blocked by the following issues | |||||
issues.dependency.pr_closing_blockedby = Closing this issue is blocked by the following issues | |||||
issues.dependency.issue_close_blocks = This issue blocks closing of the following issues | |||||
issues.dependency.pr_close_blocks = This pull request blocks closing of the following issues | |||||
issues.dependency.issue_close_blocked = You need to close all issues blocking this issue before you can close it! | |||||
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.blocked_by_short = Depends on | |||||
issues.dependency.remove_header = Remove Dependency | |||||
issues.dependency.issue_remove_text = This will remove the dependency to this issue. Are you sure? You cannot undo this! | |||||
issues.dependency.pr_remove_text = This will remove the dependency to this pull request. Are you sure? You cannot undo this! | |||||
issues.dependency.setting = Issues & PRs can have dependencies | |||||
issues.dependency.add_error_same_issue = You cannot make an issue depend on itself! | |||||
issues.dependency.add_error_dep_issue_not_exist = Dependent issue does not exist! | |||||
issues.dependency.add_error_dep_not_exist = Dependency does not exist! | |||||
issues.dependency.add_error_dep_exists = Dependency already exists! | |||||
issues.dependency.add_error_cannot_create_circular = You cannot create a dependency with two issues blocking each other! | |||||
issues.dependency.add_error_dep_not_same_repo = Both issues must be in the same repo! | |||||
pulls.desc = Enable merge requests and code reviews. | pulls.desc = Enable merge requests and code reviews. | ||||
pulls.new = New Pull Request | pulls.new = New Pull Request | ||||
@@ -1500,6 +1527,7 @@ config.enable_timetracking = Enable Time Tracking | |||||
config.default_enable_timetracking = Enable Time Tracking by Default | config.default_enable_timetracking = Enable Time Tracking by Default | ||||
config.default_allow_only_contributors_to_track_time = Let Only Contributors Track Time | config.default_allow_only_contributors_to_track_time = Let Only Contributors Track Time | ||||
config.no_reply_address = Hidden Email Domain | config.no_reply_address = Hidden Email Domain | ||||
config.default_enable_dependencies = Enable issue dependencies by default | |||||
config.webhook_config = Webhook Configuration | config.webhook_config = Webhook Configuration | ||||
config.queue_length = Queue Length | config.queue_length = Queue Length | ||||
@@ -1769,6 +1769,7 @@ $(document).ready(function () { | |||||
initTopicbar(); | initTopicbar(); | ||||
initU2FAuth(); | initU2FAuth(); | ||||
initU2FRegister(); | initU2FRegister(); | ||||
initIssueList(); | |||||
// Repo clone url. | // Repo clone url. | ||||
if ($('#repo-clone-url').length > 0) { | if ($('#repo-clone-url').length > 0) { | ||||
@@ -2488,3 +2489,41 @@ function updateDeadline(deadlineString) { | |||||
} | } | ||||
}); | }); | ||||
} | } | ||||
function deleteDependencyModal(id, type) { | |||||
$('.remove-dependency') | |||||
.modal({ | |||||
closable: false, | |||||
duration: 200, | |||||
onApprove: function () { | |||||
$('#removeDependencyID').val(id); | |||||
$('#dependencyType').val(type); | |||||
$('#removeDependencyForm').submit(); | |||||
} | |||||
}).modal('show') | |||||
; | |||||
} | |||||
function initIssueList() { | |||||
var repolink = $('#repolink').val(); | |||||
$('.new-dependency-drop-list') | |||||
.dropdown({ | |||||
apiSettings: { | |||||
url: '/api/v1/repos' + repolink + '/issues?q={query}', | |||||
onResponse: function(response) { | |||||
var filteredResponse = {'success': true, 'results': []}; | |||||
// Parse the response from the api to work with our dropdown | |||||
$.each(response, function(index, issue) { | |||||
filteredResponse.results.push({ | |||||
'name' : '#' + issue.number + ' ' + issue.title, | |||||
'value' : issue.id | |||||
}); | |||||
}); | |||||
return filteredResponse; | |||||
}, | |||||
}, | |||||
fullTextSearch: true | |||||
}) | |||||
; | |||||
} |
@@ -7,6 +7,7 @@ package repo | |||||
import ( | import ( | ||||
"fmt" | "fmt" | ||||
"net/http" | |||||
"strings" | "strings" | ||||
"code.gitea.io/gitea/models" | "code.gitea.io/gitea/models" | ||||
@@ -208,6 +209,10 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) { | |||||
if form.Closed { | if form.Closed { | ||||
if err := issue.ChangeStatus(ctx.User, ctx.Repo.Repository, true); err != nil { | if err := issue.ChangeStatus(ctx.User, ctx.Repo.Repository, true); err != nil { | ||||
if models.IsErrDependenciesLeft(err) { | |||||
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") | |||||
return | |||||
} | |||||
ctx.Error(500, "ChangeStatus", err) | ctx.Error(500, "ChangeStatus", err) | ||||
return | return | ||||
} | } | ||||
@@ -325,6 +330,10 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) { | |||||
} | } | ||||
if form.State != nil { | if form.State != nil { | ||||
if err = issue.ChangeStatus(ctx.User, ctx.Repo.Repository, api.StateClosed == api.StateType(*form.State)); err != nil { | if err = issue.ChangeStatus(ctx.User, ctx.Repo.Repository, api.StateClosed == api.StateType(*form.State)); err != nil { | ||||
if models.IsErrDependenciesLeft(err) { | |||||
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") | |||||
return | |||||
} | |||||
ctx.Error(500, "ChangeStatus", err) | ctx.Error(500, "ChangeStatus", err) | ||||
return | return | ||||
} | } | ||||
@@ -6,6 +6,7 @@ package repo | |||||
import ( | import ( | ||||
"fmt" | "fmt" | ||||
"net/http" | |||||
"strings" | "strings" | ||||
"code.gitea.io/git" | "code.gitea.io/git" | ||||
@@ -378,6 +379,10 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) { | |||||
} | } | ||||
if form.State != nil { | if form.State != nil { | ||||
if err = issue.ChangeStatus(ctx.User, ctx.Repo.Repository, api.StateClosed == api.StateType(*form.State)); err != nil { | if err = issue.ChangeStatus(ctx.User, ctx.Repo.Repository, api.StateClosed == api.StateType(*form.State)); err != nil { | ||||
if models.IsErrDependenciesLeft(err) { | |||||
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies") | |||||
return | |||||
} | |||||
ctx.Error(500, "ChangeStatus", err) | ctx.Error(500, "ChangeStatus", err) | ||||
return | return | ||||
} | } | ||||
@@ -10,6 +10,7 @@ import ( | |||||
"fmt" | "fmt" | ||||
"io" | "io" | ||||
"io/ioutil" | "io/ioutil" | ||||
"net/http" | |||||
"strconv" | "strconv" | ||||
"strings" | "strings" | ||||
"time" | "time" | ||||
@@ -302,6 +303,9 @@ func RetrieveRepoMetas(ctx *context.Context, repo *models.Repository) []*models. | |||||
} | } | ||||
ctx.Data["Branches"] = brs | ctx.Data["Branches"] = brs | ||||
// Contains true if the user can create issue dependencies | |||||
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User) | |||||
return labels | return labels | ||||
} | } | ||||
@@ -665,6 +669,9 @@ func ViewIssue(ctx *context.Context) { | |||||
} | } | ||||
} | } | ||||
// Check if the user can use the dependencies | |||||
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User) | |||||
// Render comments and and fetch participants. | // Render comments and and fetch participants. | ||||
participants[0] = issue.Poster | participants[0] = issue.Poster | ||||
for _, comment = range issue.Comments { | for _, comment = range issue.Comments { | ||||
@@ -721,6 +728,11 @@ func ViewIssue(ctx *context.Context) { | |||||
ctx.ServerError("LoadAssigneeUser", err) | ctx.ServerError("LoadAssigneeUser", err) | ||||
return | return | ||||
} | } | ||||
} else if comment.Type == models.CommentTypeRemoveDependency || comment.Type == models.CommentTypeAddDependency { | |||||
if err = comment.LoadDepIssueDetails(); err != nil { | |||||
ctx.ServerError("LoadDepIssueDetails", err) | |||||
return | |||||
} | |||||
} | } | ||||
} | } | ||||
@@ -774,6 +786,10 @@ func ViewIssue(ctx *context.Context) { | |||||
ctx.Data["IsPullBranchDeletable"] = canDelete && pull.HeadRepo != nil && git.IsBranchExist(pull.HeadRepo.RepoPath(), pull.HeadBranch) | ctx.Data["IsPullBranchDeletable"] = canDelete && pull.HeadRepo != nil && git.IsBranchExist(pull.HeadRepo.RepoPath(), pull.HeadBranch) | ||||
} | } | ||||
// Get Dependencies | |||||
ctx.Data["BlockedByDependencies"], err = issue.BlockedByDependencies() | |||||
ctx.Data["BlockingDependencies"], err = issue.BlockingDependencies() | |||||
ctx.Data["Participants"] = participants | ctx.Data["Participants"] = participants | ||||
ctx.Data["NumParticipants"] = len(participants) | ctx.Data["NumParticipants"] = len(participants) | ||||
ctx.Data["Issue"] = issue | ctx.Data["Issue"] = issue | ||||
@@ -971,6 +987,12 @@ func UpdateIssueStatus(ctx *context.Context) { | |||||
} | } | ||||
for _, issue := range issues { | for _, issue := range issues { | ||||
if err := issue.ChangeStatus(ctx.User, issue.Repo, isClosed); err != nil { | if err := issue.ChangeStatus(ctx.User, issue.Repo, isClosed); err != nil { | ||||
if models.IsErrDependenciesLeft(err) { | |||||
ctx.JSON(http.StatusPreconditionFailed, map[string]interface{}{ | |||||
"error": "cannot close this issue because it still has open dependencies", | |||||
}) | |||||
return | |||||
} | |||||
ctx.ServerError("ChangeStatus", err) | ctx.ServerError("ChangeStatus", err) | ||||
return | return | ||||
} | } | ||||
@@ -1034,6 +1056,17 @@ func NewComment(ctx *context.Context, form auth.CreateCommentForm) { | |||||
} else { | } else { | ||||
if err := issue.ChangeStatus(ctx.User, ctx.Repo.Repository, form.Status == "close"); err != nil { | if err := issue.ChangeStatus(ctx.User, ctx.Repo.Repository, form.Status == "close"); err != nil { | ||||
log.Error(4, "ChangeStatus: %v", err) | log.Error(4, "ChangeStatus: %v", err) | ||||
if models.IsErrDependenciesLeft(err) { | |||||
if issue.IsPull { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) | |||||
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther) | |||||
} else { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked")) | |||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther) | |||||
} | |||||
return | |||||
} | |||||
} else { | } else { | ||||
log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) | log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) | ||||
@@ -0,0 +1,119 @@ | |||||
// 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 repo | |||||
import ( | |||||
"fmt" | |||||
"net/http" | |||||
"code.gitea.io/gitea/models" | |||||
"code.gitea.io/gitea/modules/context" | |||||
) | |||||
// AddDependency adds new dependencies | |||||
func AddDependency(ctx *context.Context) { | |||||
// Check if the Repo is allowed to have dependencies | |||||
if !ctx.Repo.CanCreateIssueDependencies(ctx.User) { | |||||
ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") | |||||
return | |||||
} | |||||
depID := ctx.QueryInt64("newDependency") | |||||
issueIndex := ctx.ParamsInt64("index") | |||||
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) | |||||
if err != nil { | |||||
ctx.ServerError("GetIssueByIndex", err) | |||||
return | |||||
} | |||||
// Redirect | |||||
defer ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issueIndex), http.StatusSeeOther) | |||||
// Dependency | |||||
dep, err := models.GetIssueByID(depID) | |||||
if err != nil { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_issue_not_exist")) | |||||
return | |||||
} | |||||
// Check if both issues are in the same repo | |||||
if issue.RepoID != dep.RepoID { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) | |||||
return | |||||
} | |||||
// Check if issue and dependency is the same | |||||
if dep.Index == issueIndex { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_same_issue")) | |||||
return | |||||
} | |||||
err = models.CreateIssueDependency(ctx.User, issue, dep) | |||||
if err != nil { | |||||
if models.IsErrDependencyExists(err) { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_exists")) | |||||
return | |||||
} else if models.IsErrCircularDependency(err) { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_cannot_create_circular")) | |||||
return | |||||
} else { | |||||
ctx.ServerError("CreateOrUpdateIssueDependency", err) | |||||
return | |||||
} | |||||
} | |||||
} | |||||
// RemoveDependency removes the dependency | |||||
func RemoveDependency(ctx *context.Context) { | |||||
// Check if the Repo is allowed to have dependencies | |||||
if !ctx.Repo.CanCreateIssueDependencies(ctx.User) { | |||||
ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") | |||||
return | |||||
} | |||||
depID := ctx.QueryInt64("removeDependencyID") | |||||
issueIndex := ctx.ParamsInt64("index") | |||||
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) | |||||
if err != nil { | |||||
ctx.ServerError("GetIssueByIndex", err) | |||||
return | |||||
} | |||||
// Redirect | |||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issueIndex), http.StatusSeeOther) | |||||
// Dependency Type | |||||
depTypeStr := ctx.Req.PostForm.Get("dependencyType") | |||||
var depType models.DependencyType | |||||
switch depTypeStr { | |||||
case "blockedBy": | |||||
depType = models.DependencyTypeBlockedBy | |||||
case "blocking": | |||||
depType = models.DependencyTypeBlocking | |||||
default: | |||||
ctx.Error(http.StatusBadRequest, "GetDependecyType") | |||||
return | |||||
} | |||||
// Dependency | |||||
dep, err := models.GetIssueByID(depID) | |||||
if err != nil { | |||||
ctx.ServerError("GetIssueByID", err) | |||||
return | |||||
} | |||||
if err = models.RemoveIssueDependency(ctx.User, issue, dep, depType); err != nil { | |||||
if models.IsErrDependencyNotExists(err) { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist")) | |||||
return | |||||
} | |||||
ctx.ServerError("RemoveIssueDependency", err) | |||||
return | |||||
} | |||||
} |
@@ -524,6 +524,18 @@ func MergePullRequest(ctx *context.Context, form auth.MergePullRequestForm) { | |||||
pr.Issue = issue | pr.Issue = issue | ||||
pr.Issue.Repo = ctx.Repo.Repository | pr.Issue.Repo = ctx.Repo.Repository | ||||
noDeps, err := models.IssueNoDependenciesLeft(issue) | |||||
if err != nil { | |||||
return | |||||
} | |||||
if !noDeps { | |||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) | |||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index)) | |||||
return | |||||
} | |||||
if err = pr.Merge(ctx.User, ctx.Repo.GitRepo, models.MergeStyle(form.Do), message); err != nil { | if err = pr.Merge(ctx.User, ctx.Repo.GitRepo, models.MergeStyle(form.Do), message); err != nil { | ||||
if models.IsErrInvalidMergeStyle(err) { | if models.IsErrInvalidMergeStyle(err) { | ||||
ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) | ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) | ||||
@@ -202,6 +202,7 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { | |||||
Config: &models.IssuesConfig{ | Config: &models.IssuesConfig{ | ||||
EnableTimetracker: form.EnableTimetracker, | EnableTimetracker: form.EnableTimetracker, | ||||
AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, | AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, | ||||
EnableDependencies: form.EnableIssueDependencies, | |||||
}, | }, | ||||
}) | }) | ||||
} | } | ||||
@@ -523,6 +523,10 @@ func RegisterRoutes(m *macaron.Macaron) { | |||||
m.Post("/title", repo.UpdateIssueTitle) | m.Post("/title", repo.UpdateIssueTitle) | ||||
m.Post("/content", repo.UpdateIssueContent) | m.Post("/content", repo.UpdateIssueContent) | ||||
m.Post("/watch", repo.IssueWatch) | m.Post("/watch", repo.IssueWatch) | ||||
m.Group("/dependency", func() { | |||||
m.Post("/add", repo.AddDependency) | |||||
m.Post("/delete", repo.RemoveDependency) | |||||
}) | |||||
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment) | m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment) | ||||
m.Group("/times", func() { | m.Group("/times", func() { | ||||
m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually) | m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually) | ||||
@@ -150,6 +150,8 @@ | |||||
{{end}} | {{end}} | ||||
<dt>{{.i18n.Tr "admin.config.no_reply_address"}}</dt> | <dt>{{.i18n.Tr "admin.config.no_reply_address"}}</dt> | ||||
<dd>{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}</dd> | <dd>{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}</dd> | ||||
<dt>{{.i18n.Tr "admin.config.default_enable_dependencies"}}</dt> | |||||
<dd><i class="fa fa{{if .Service.DefaultEnableDependencies}}-check{{end}}-square-o"></i></dd> | |||||
<div class="ui divider"></div> | <div class="ui divider"></div> | ||||
<dt>{{.i18n.Tr "admin.config.active_code_lives"}}</dt> | <dt>{{.i18n.Tr "admin.config.active_code_lives"}}</dt> | ||||
<dd>{{.Service.ActiveCodeLives}} {{.i18n.Tr "tool.raw_minutes"}}</dd> | <dd>{{.Service.ActiveCodeLives}} {{.i18n.Tr "tool.raw_minutes"}}</dd> | ||||
@@ -1,7 +1,7 @@ | |||||
{{range .Issue.Comments}} | {{range .Issue.Comments}} | ||||
{{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | {{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | ||||
<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, 18 = REMOVED_DEADLINE --> | |||||
<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, 18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY --> | |||||
{{if eq .Type 0}} | {{if eq .Type 0}} | ||||
<div class="comment" id="{{.HashTag}}"> | <div class="comment" id="{{.HashTag}}"> | ||||
<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | <a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | ||||
@@ -65,7 +65,6 @@ | |||||
{{end}} | {{end}} | ||||
</div> | </div> | ||||
</div> | </div> | ||||
{{else if eq .Type 1}} | {{else if eq .Type 1}} | ||||
<div class="event"> | <div class="event"> | ||||
<span class="octicon octicon-primitive-dot"></span> | <span class="octicon octicon-primitive-dot"></span> | ||||
@@ -233,5 +232,33 @@ | |||||
{{$.i18n.Tr "repo.issues.due_date_remove" .Content $createdStr | Safe}} | {{$.i18n.Tr "repo.issues.due_date_remove" .Content $createdStr | Safe}} | ||||
</span> | </span> | ||||
</div> | </div> | ||||
{{end}} | |||||
{{else if eq .Type 19}} | |||||
<div class="event"> | |||||
<span class="octicon octicon-primitive-dot"></span> | |||||
<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | |||||
<img src="{{.Poster.RelAvatarLink}}"> | |||||
</a> | |||||
<span class="text grey"> | |||||
{{$.i18n.Tr "repo.issues.dependency.added_dependency" .Poster.HomeLink .Poster.Name $createdStr | Safe}} | |||||
</span> | |||||
<div class="detail"> | |||||
<span class="octicon octicon-plus"></span> | |||||
<span class="text grey"><a href="{{$.RepoLink}}/issues/{{.DependentIssue.Index}}">#{{.DependentIssue.Index}} {{.DependentIssue.Title}}</a></span> | |||||
</div> | |||||
</div> | |||||
{{else if eq .Type 20}} | |||||
<div class="event"> | |||||
<span class="octicon octicon-primitive-dot"></span> | |||||
<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | |||||
<img src="{{.Poster.RelAvatarLink}}"> | |||||
</a> | |||||
<span class="text grey"> | |||||
{{$.i18n.Tr "repo.issues.dependency.removed_dependency" .Poster.HomeLink .Poster.Name $createdStr | Safe}} | |||||
</span> | |||||
<div class="detail"> | |||||
<span class="text grey octicon octicon-trashcan"></span> | |||||
<span class="text grey"><a href="{{$.RepoLink}}/issues/{{.DependentIssue.Index}}">#{{.DependentIssue.Index}} {{.DependentIssue.Title}}</a></span> | |||||
</div> | |||||
</div> | |||||
{{end}} | |||||
{{end}} | {{end}} |
@@ -249,5 +249,142 @@ | |||||
</div> | </div> | ||||
{{end}} | {{end}} | ||||
</div> | </div> | ||||
{{if .Repository.IsDependenciesEnabled}} | |||||
<div class="ui divider"></div> | |||||
<div class="ui depending"> | |||||
<span class="text"><strong>{{.i18n.Tr "repo.issues.dependency.title"}}</strong></span> | |||||
<br> | |||||
{{if .BlockedByDependencies}} | |||||
<span class="text" data-tooltip="{{if .Issue.IsPull}} | |||||
{{.i18n.Tr "repo.issues.dependency.issue_closing_blockedby"}} | |||||
{{else}} | |||||
{{.i18n.Tr "repo.issues.dependency.pr_closing_blockedby"}} | |||||
{{end}}" data-inverted=""> | |||||
{{.i18n.Tr "repo.issues.dependency.blocked_by_short"}}: | |||||
</span> | |||||
<div class="ui relaxed divided list"> | |||||
{{range .BlockedByDependencies}} | |||||
<div class="item"> | |||||
<div class="right floated content"> | |||||
{{if $.CanCreateIssueDependencies}} | |||||
<a class="delete-dependency-button" onclick="deleteDependencyModal({{.ID}}, 'blockedBy');"> | |||||
<i class="delete icon text red"></i> | |||||
</a> | |||||
{{end}} | |||||
{{if .IsClosed}} | |||||
<div class="ui red mini label"> | |||||
<i class="octicon octicon-issue-closed"></i> | |||||
</div> | |||||
{{else}} | |||||
<div class="ui green mini label"> | |||||
<i class="octicon octicon-issue-opened"></i> | |||||
</div> | |||||
{{end}} | |||||
</div> | |||||
<div class="ui black label">#{{.Index}}</div> | |||||
<a class="title has-emoji" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title}}</a> | |||||
</div> | |||||
{{end}} | |||||
</div> | |||||
{{end}} | |||||
{{if .BlockingDependencies}} | |||||
<span class="text" data-tooltip="{{if .Issue.IsPull}} | |||||
{{.i18n.Tr "repo.issues.dependency.pr_close_blocks"}} | |||||
{{else}} | |||||
{{.i18n.Tr "repo.issues.dependency.issue_close_blocks"}} | |||||
{{end}}" data-inverted=""> | |||||
{{.i18n.Tr "repo.issues.dependency.blocks_short"}}: | |||||
</span> | |||||
<div class="ui relaxed divided list"> | |||||
{{range .BlockingDependencies}} | |||||
<div class="item"> | |||||
<div class="right floated content"> | |||||
{{if $.CanCreateIssueDependencies}} | |||||
<a class="delete-dependency-button" onclick="deleteDependencyModal({{.ID}}, 'blocking');"> | |||||
<i class="delete icon text red"></i> | |||||
</a> | |||||
{{end}} | |||||
{{if .IsClosed}} | |||||
<div class="ui red tiny label"> | |||||
<i class="octicon octicon-issue-closed"></i> | |||||
</div> | |||||
{{else}} | |||||
<div class="ui green mini label"> | |||||
<i class="octicon octicon-issue-opened"></i> | |||||
</div> | |||||
{{end}} | |||||
</div> | |||||
<div class="ui black label">#{{.Index}}</div> | |||||
<a class="title has-emoji" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title}}</a> | |||||
</div> | |||||
{{end}} | |||||
</div> | |||||
{{end}} | |||||
{{if (and (not .BlockedByDependencies) (not .BlockingDependencies))}} | |||||
<p>{{if .Issue.IsPull}} | |||||
{{.i18n.Tr "repo.issues.dependency.pr_no_dependencies"}} | |||||
{{else}} | |||||
{{.i18n.Tr "repo.issues.dependency.issue_no_dependencies"}} | |||||
{{end}}</p> | |||||
{{end}} | |||||
{{if .CanCreateIssueDependencies}} | |||||
<div> | |||||
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/dependency/add" id="addDependencyForm"> | |||||
{{$.CsrfTokenHtml}} | |||||
<div class="ui fluid action input"> | |||||
<div class="ui search selection dropdown new-dependency-drop-list" style="min-width: 13.9rem;border-radius: 4px 0 0 4px;border-right: 0;white-space: nowrap;"> | |||||
<input name="newDependency" type="hidden"> | |||||
<i class="dropdown icon"></i> | |||||
<input type="text" class="search"> | |||||
<div class="default text">{{.i18n.Tr "repo.issues.dependency.add"}}</div> | |||||
</div> | |||||
<button class="ui green icon button"> | |||||
<i class="plus icon"></i> | |||||
</button> | |||||
</div> | |||||
</form> | |||||
</div> | |||||
{{end}} | |||||
</div> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
{{if .CanCreateIssueDependencies}} | |||||
<input type="hidden" id="repolink" value="{{$.RepoLink}}"> | |||||
<!-- I know, there is probably a better way to do this --> | |||||
<input type="hidden" id="issueIndex" value="{{.Issue.Index}}"/> | |||||
<div class="ui basic modal remove-dependency"> | |||||
<div class="ui icon header"> | |||||
<i class="trash icon"></i> | |||||
{{.i18n.Tr "repo.issues.dependency.remove_header"}} | |||||
</div> | |||||
<div class="content"> | |||||
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/dependency/delete" id="removeDependencyForm"> | |||||
{{$.CsrfTokenHtml}} | |||||
<input type="hidden" value="" name="removeDependencyID" id="removeDependencyID"/> | |||||
<input type="hidden" value="" name="dependencyType" id="dependencyType"/> | |||||
</form> | |||||
<p>{{if .Issue.IsPull}} | |||||
{{.i18n.Tr "repo.issues.dependency.pr_remove_text"}} | |||||
{{else}} | |||||
{{.i18n.Tr "repo.issues.dependency.issue_remove_text"}} | |||||
{{end}}</p> | |||||
</div> | |||||
<div class="actions"> | |||||
<div class="ui basic red cancel inverted button"> | |||||
<i class="remove icon"></i> | |||||
{{.i18n.Tr "repo.issues.dependency.cancel"}} | |||||
</div> | |||||
<div class="ui basic green ok inverted button"> | |||||
<i class="checkmark icon"></i> | |||||
{{.i18n.Tr "repo.issues.dependency.remove"}} | |||||
</div> | |||||
</div> | |||||
</div> | |||||
{{end}} | |||||
{{end}} |
@@ -153,6 +153,12 @@ | |||||
</div> | </div> | ||||
</div> | </div> | ||||
{{end}} | {{end}} | ||||
<div class="field"> | |||||
<div class="ui checkbox"> | |||||
<input name="enable_issue_dependencies" type="checkbox" {{if (.Repository.IsDependenciesEnabled)}}checked{{end}}> | |||||
<label>{{.i18n.Tr "repo.issues.dependency.setting"}}</label> | |||||
</div> | |||||
</div> | |||||
</div> | </div> | ||||
<div class="field"> | <div class="field"> | ||||
<div class="ui radio checkbox"> | <div class="ui radio checkbox"> | ||||