| @@ -316,6 +316,9 @@ DEFAULT_KEEP_EMAIL_PRIVATE = false | |||
| ; Default value for AllowCreateOrganization | |||
| ; Every new user will have rights set to create organizations depending on this setting | |||
| 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 = true | |||
| ; Default value for EnableTimetracking | |||
| @@ -182,6 +182,7 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. | |||
| - `CAPTCHA_TYPE`: **image**: \[image, 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 | |||
| - `DEFAULT_ENABLE_DEPENDENCIES`: **true** Enable this to have dependencies enabled by default. | |||
| ## Webhook (`webhook`) | |||
| @@ -477,6 +477,10 @@ func UpdateIssuesCommit(doer *User, repo *Repository, commits []*PushCommit) err | |||
| } | |||
| 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 | |||
| } | |||
| } | |||
| @@ -1259,3 +1259,88 @@ func IsErrU2FRegistrationNotExist(err error) bool { | |||
| _, ok := err.(ErrU2FRegistrationNotExist) | |||
| 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 { | |||
| 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 | |||
| if isClosed { | |||
| issue.ClosedUnix = util.TimeStampNow() | |||
| @@ -1598,3 +1612,33 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix util.TimeStamp, doer *User) | |||
| 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 | |||
| // Removed a due date | |||
| CommentTypeRemovedDeadline | |||
| // Dependency added | |||
| CommentTypeAddDependency | |||
| //Dependency removed | |||
| CommentTypeRemoveDependency | |||
| ) | |||
| // CommentTag defines comment tag type | |||
| @@ -81,23 +85,25 @@ const ( | |||
| // Comment represents a comment in commit and issue page. | |||
| 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 | |||
| Line int64 | |||
| @@ -281,6 +287,15 @@ func (c *Comment) LoadAssigneeUser() error { | |||
| 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 | |||
| // and mentioned people. | |||
| 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 { | |||
| LabelID = opts.Label.ID | |||
| } | |||
| 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 { | |||
| 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 | |||
| type CreateCommentOptions struct { | |||
| Type CommentType | |||
| @@ -557,17 +607,18 @@ type CreateCommentOptions struct { | |||
| Issue *Issue | |||
| 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. | |||
| @@ -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), | |||
| // v69 -> v70 | |||
| NewMigration("move team units to team_unit table", moveTeamUnitsToTeamUnitTable), | |||
| // v70 -> v71 | |||
| NewMigration("add issue_dependencies", addIssueDependencies), | |||
| } | |||
| // 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(DeletedBranch), | |||
| new(RepoIndexerStatus), | |||
| new(IssueDependency), | |||
| new(LFSLock), | |||
| new(Reaction), | |||
| new(IssueAssignees), | |||
| @@ -1345,7 +1345,11 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err | |||
| units = append(units, RepoUnit{ | |||
| RepoID: repo.ID, | |||
| 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 { | |||
| units = append(units, RepoUnit{ | |||
| @@ -73,6 +73,7 @@ func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) { | |||
| type IssuesConfig struct { | |||
| EnableTimetracker bool | |||
| AllowOnlyContributorsToTrackTime bool | |||
| EnableDependencies bool | |||
| } | |||
| // FromDB fills up a IssuesConfig from serialized format. | |||
| @@ -165,7 +166,6 @@ func (r *RepoUnit) IssuesConfig() *IssuesConfig { | |||
| func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig { | |||
| return r.Config.(*ExternalTrackerConfig) | |||
| } | |||
| func getUnitsByRepoID(e Engine, repoID int64) (units []*RepoUnit, err error) { | |||
| return units, e.Where("repo_id = ?", repoID).Find(&units) | |||
| } | |||
| @@ -113,6 +113,7 @@ type RepoSettingForm struct { | |||
| PullsAllowSquash bool | |||
| EnableTimetracker bool | |||
| AllowOnlyContributorsToTrackTime bool | |||
| EnableIssueDependencies bool | |||
| // Admin settings | |||
| EnableHealthCheck bool | |||
| @@ -104,6 +104,11 @@ func (r *Repository) CanUseTimetracker(issue *models.Issue, user *models.User) b | |||
| 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 | |||
| func (r *Repository) GetCommitsCount() (int64, error) { | |||
| var contextName string | |||
| @@ -1180,6 +1180,7 @@ var Service struct { | |||
| DefaultAllowCreateOrganization bool | |||
| EnableTimetracking bool | |||
| DefaultEnableTimetracking bool | |||
| DefaultEnableDependencies bool | |||
| DefaultAllowOnlyContributorsToTrackTime bool | |||
| NoReplyAddress string | |||
| @@ -1210,6 +1211,7 @@ func newService() { | |||
| if Service.EnableTimetracking { | |||
| 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.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_overdue = "Overdue" | |||
| 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.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_allow_only_contributors_to_track_time = Let Only Contributors Track Time | |||
| config.no_reply_address = Hidden Email Domain | |||
| config.default_enable_dependencies = Enable issue dependencies by default | |||
| config.webhook_config = Webhook Configuration | |||
| config.queue_length = Queue Length | |||
| @@ -1769,6 +1769,7 @@ $(document).ready(function () { | |||
| initTopicbar(); | |||
| initU2FAuth(); | |||
| initU2FRegister(); | |||
| initIssueList(); | |||
| // Repo clone url. | |||
| 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 ( | |||
| "fmt" | |||
| "net/http" | |||
| "strings" | |||
| "code.gitea.io/gitea/models" | |||
| @@ -208,6 +209,10 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) { | |||
| if form.Closed { | |||
| 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) | |||
| return | |||
| } | |||
| @@ -325,6 +330,10 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) { | |||
| } | |||
| if form.State != 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) | |||
| return | |||
| } | |||
| @@ -6,6 +6,7 @@ package repo | |||
| import ( | |||
| "fmt" | |||
| "net/http" | |||
| "strings" | |||
| "code.gitea.io/git" | |||
| @@ -378,6 +379,10 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) { | |||
| } | |||
| if form.State != 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) | |||
| return | |||
| } | |||
| @@ -10,6 +10,7 @@ import ( | |||
| "fmt" | |||
| "io" | |||
| "io/ioutil" | |||
| "net/http" | |||
| "strconv" | |||
| "strings" | |||
| "time" | |||
| @@ -302,6 +303,9 @@ func RetrieveRepoMetas(ctx *context.Context, repo *models.Repository) []*models. | |||
| } | |||
| ctx.Data["Branches"] = brs | |||
| // Contains true if the user can create issue dependencies | |||
| ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User) | |||
| 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. | |||
| participants[0] = issue.Poster | |||
| for _, comment = range issue.Comments { | |||
| @@ -721,6 +728,11 @@ func ViewIssue(ctx *context.Context) { | |||
| ctx.ServerError("LoadAssigneeUser", err) | |||
| 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) | |||
| } | |||
| // Get Dependencies | |||
| ctx.Data["BlockedByDependencies"], err = issue.BlockedByDependencies() | |||
| ctx.Data["BlockingDependencies"], err = issue.BlockingDependencies() | |||
| ctx.Data["Participants"] = participants | |||
| ctx.Data["NumParticipants"] = len(participants) | |||
| ctx.Data["Issue"] = issue | |||
| @@ -971,6 +987,12 @@ func UpdateIssueStatus(ctx *context.Context) { | |||
| } | |||
| for _, issue := range issues { | |||
| 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) | |||
| return | |||
| } | |||
| @@ -1034,6 +1056,17 @@ func NewComment(ctx *context.Context, form auth.CreateCommentForm) { | |||
| } else { | |||
| if err := issue.ChangeStatus(ctx.User, ctx.Repo.Repository, form.Status == "close"); err != nil { | |||
| 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 { | |||
| 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.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 models.IsErrInvalidMergeStyle(err) { | |||
| 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{ | |||
| EnableTimetracker: form.EnableTimetracker, | |||
| AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, | |||
| EnableDependencies: form.EnableIssueDependencies, | |||
| }, | |||
| }) | |||
| } | |||
| @@ -523,6 +523,10 @@ func RegisterRoutes(m *macaron.Macaron) { | |||
| m.Post("/title", repo.UpdateIssueTitle) | |||
| m.Post("/content", repo.UpdateIssueContent) | |||
| 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.Group("/times", func() { | |||
| m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually) | |||
| @@ -150,6 +150,8 @@ | |||
| {{end}} | |||
| <dt>{{.i18n.Tr "admin.config.no_reply_address"}}</dt> | |||
| <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> | |||
| <dt>{{.i18n.Tr "admin.config.active_code_lives"}}</dt> | |||
| <dd>{{.Service.ActiveCodeLives}} {{.i18n.Tr "tool.raw_minutes"}}</dd> | |||
| @@ -1,7 +1,7 @@ | |||
| {{range .Issue.Comments}} | |||
| {{ $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}} | |||
| <div class="comment" id="{{.HashTag}}"> | |||
| <a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | |||
| @@ -65,7 +65,6 @@ | |||
| {{end}} | |||
| </div> | |||
| </div> | |||
| {{else if eq .Type 1}} | |||
| <div class="event"> | |||
| <span class="octicon octicon-primitive-dot"></span> | |||
| @@ -233,5 +232,33 @@ | |||
| {{$.i18n.Tr "repo.issues.due_date_remove" .Content $createdStr | Safe}} | |||
| </span> | |||
| </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}} | |||
| @@ -249,5 +249,142 @@ | |||
| </div> | |||
| {{end}} | |||
| </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> | |||
| {{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> | |||
| {{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 class="field"> | |||
| <div class="ui radio checkbox"> | |||