| @@ -8,6 +8,7 @@ import ( | |||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "regexp" | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| @@ -32,6 +33,20 @@ const ( | |||||
| OP_COMMENT_ISSUE | OP_COMMENT_ISSUE | ||||
| ) | ) | ||||
| var ( | |||||
| ErrNotImplemented = errors.New("Not implemented yet") | |||||
| ) | |||||
| var ( | |||||
| // Same as Github. See https://help.github.com/articles/closing-issues-via-commit-messages | |||||
| IssueKeywords = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"} | |||||
| IssueKeywordsPat *regexp.Regexp | |||||
| ) | |||||
| func init() { | |||||
| IssueKeywordsPat = regexp.MustCompile(fmt.Sprintf(`(?i)(?:%s) \S+`, strings.Join(IssueKeywords, "|"))) | |||||
| } | |||||
| // Action represents user operation type and other information to repository., | // Action represents user operation type and other information to repository., | ||||
| // it implemented interface base.Actioner so that can be used in template render. | // it implemented interface base.Actioner so that can be used in template render. | ||||
| type Action struct { | type Action struct { | ||||
| @@ -78,6 +93,52 @@ func (a Action) GetContent() string { | |||||
| return a.Content | return a.Content | ||||
| } | } | ||||
| func updateIssuesCommit(repoUserName, repoName string, commits []*base.PushCommit) error { | |||||
| for _, c := range commits { | |||||
| refs := IssueKeywordsPat.FindAllString(c.Message, -1) | |||||
| for _, ref := range refs { | |||||
| ref := ref[strings.IndexByte(ref, byte(' '))+1:] | |||||
| if len(ref) == 0 { | |||||
| continue | |||||
| } | |||||
| // Add repo name if missing | |||||
| if ref[0] == '#' { | |||||
| ref = fmt.Sprintf("%s/%s%s", repoUserName, repoName, ref) | |||||
| } else if strings.Contains(ref, "/") == false { | |||||
| // We don't support User#ID syntax yet | |||||
| // return ErrNotImplemented | |||||
| continue | |||||
| } | |||||
| issue, err := GetIssueByRef(ref) | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| if issue.IsClosed { | |||||
| continue | |||||
| } | |||||
| issue.IsClosed = true | |||||
| if err = UpdateIssue(issue); err != nil { | |||||
| return err | |||||
| } | |||||
| if err = ChangeMilestoneIssueStats(issue); err != nil { | |||||
| return err | |||||
| } | |||||
| } | |||||
| } | |||||
| return nil | |||||
| } | |||||
| // CommitRepoAction adds new action for committing repository. | // CommitRepoAction adds new action for committing repository. | ||||
| func CommitRepoAction(userId, repoUserId int64, userName, actEmail string, | func CommitRepoAction(userId, repoUserId int64, userName, actEmail string, | ||||
| repoId int64, repoUserName, repoName string, refFullName string, commit *base.PushCommits) error { | repoId int64, repoUserName, repoName string, refFullName string, commit *base.PushCommits) error { | ||||
| @@ -107,6 +168,12 @@ func CommitRepoAction(userId, repoUserId int64, userName, actEmail string, | |||||
| return errors.New("action.CommitRepoAction(UpdateRepository): " + err.Error()) | return errors.New("action.CommitRepoAction(UpdateRepository): " + err.Error()) | ||||
| } | } | ||||
| err = updateIssuesCommit(repoUserName, repoName, commit.Commits) | |||||
| if err != nil { | |||||
| log.Debug("action.CommitRepoAction(updateIssuesCommit): ", err) | |||||
| } | |||||
| if err = NotifyWatchers(&Action{ActUserId: userId, ActUserName: userName, ActEmail: actEmail, | if err = NotifyWatchers(&Action{ActUserId: userId, ActUserName: userName, ActEmail: actEmail, | ||||
| OpType: opType, Content: string(bs), RepoId: repoId, RepoUserName: repoUserName, | OpType: opType, Content: string(bs), RepoId: repoId, RepoUserName: repoUserName, | ||||
| RepoName: repoName, RefName: refName, | RepoName: repoName, RefName: refName, | ||||
| @@ -7,6 +7,7 @@ package models | |||||
| import ( | import ( | ||||
| "bytes" | "bytes" | ||||
| "errors" | "errors" | ||||
| "strconv" | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| @@ -16,10 +17,11 @@ import ( | |||||
| ) | ) | ||||
| var ( | var ( | ||||
| ErrIssueNotExist = errors.New("Issue does not exist") | |||||
| ErrLabelNotExist = errors.New("Label does not exist") | |||||
| ErrMilestoneNotExist = errors.New("Milestone does not exist") | |||||
| ErrWrongIssueCounter = errors.New("Invalid number of issues for this milestone") | |||||
| ErrIssueNotExist = errors.New("Issue does not exist") | |||||
| ErrLabelNotExist = errors.New("Label does not exist") | |||||
| ErrMilestoneNotExist = errors.New("Milestone does not exist") | |||||
| ErrWrongIssueCounter = errors.New("Invalid number of issues for this milestone") | |||||
| ErrMissingIssueNumber = errors.New("No issue number specified") | |||||
| ) | ) | ||||
| // Issue represents an issue or pull request of repository. | // Issue represents an issue or pull request of repository. | ||||
| @@ -122,6 +124,29 @@ func NewIssue(issue *Issue) (err error) { | |||||
| return | return | ||||
| } | } | ||||
| // GetIssueByRef returns an Issue specified by a GFM reference. | |||||
| // See https://help.github.com/articles/writing-on-github#references for more information on the syntax. | |||||
| func GetIssueByRef(ref string) (issue *Issue, err error) { | |||||
| var issueNumber int64 | |||||
| var repo *Repository | |||||
| n := strings.IndexByte(ref, byte('#')) | |||||
| if n == -1 { | |||||
| return nil, ErrMissingIssueNumber | |||||
| } | |||||
| if issueNumber, err = strconv.ParseInt(ref[n+1:], 10, 64); err != nil { | |||||
| return | |||||
| } | |||||
| if repo, err = GetRepositoryByRef(ref[:n]); err != nil { | |||||
| return | |||||
| } | |||||
| return GetIssueByIndex(repo.Id, issueNumber) | |||||
| } | |||||
| // GetIssueByIndex returns issue by given index in repository. | // GetIssueByIndex returns issue by given index in repository. | ||||
| func GetIssueByIndex(rid, index int64) (*Issue, error) { | func GetIssueByIndex(rid, index int64) (*Issue, error) { | ||||
| issue := &Issue{RepoId: rid, Index: index} | issue := &Issue{RepoId: rid, Index: index} | ||||
| @@ -400,6 +425,11 @@ func GetUserIssueStats(uid int64, filterMode int) *IssueStats { | |||||
| // UpdateIssue updates information of issue. | // UpdateIssue updates information of issue. | ||||
| func UpdateIssue(issue *Issue) error { | func UpdateIssue(issue *Issue) error { | ||||
| _, err := x.Id(issue.Id).AllCols().Update(issue) | _, err := x.Id(issue.Id).AllCols().Update(issue) | ||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| return err | return err | ||||
| } | } | ||||
| @@ -670,6 +700,32 @@ func ChangeMilestoneStatus(m *Milestone, isClosed bool) (err error) { | |||||
| return sess.Commit() | return sess.Commit() | ||||
| } | } | ||||
| // ChangeMilestoneIssueStats updates the open/closed issues counter and progress for the | |||||
| // milestone associated witht the given issue. | |||||
| func ChangeMilestoneIssueStats(issue *Issue) error { | |||||
| if issue.MilestoneId == 0 { | |||||
| return nil | |||||
| } | |||||
| m, err := GetMilestoneById(issue.MilestoneId) | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| if issue.IsClosed { | |||||
| m.NumOpenIssues-- | |||||
| m.NumClosedIssues++ | |||||
| } else { | |||||
| m.NumOpenIssues++ | |||||
| m.NumClosedIssues-- | |||||
| } | |||||
| m.Completeness = m.NumClosedIssues * 100 / m.NumIssues | |||||
| return UpdateMilestone(m) | |||||
| } | |||||
| // ChangeMilestoneAssign changes assignment of milestone for issue. | // ChangeMilestoneAssign changes assignment of milestone for issue. | ||||
| func ChangeMilestoneAssign(oldMid, mid int64, issue *Issue) (err error) { | func ChangeMilestoneAssign(oldMid, mid int64, issue *Issue) (err error) { | ||||
| sess := x.NewSession() | sess := x.NewSession() | ||||
| @@ -693,6 +749,7 @@ func ChangeMilestoneAssign(oldMid, mid int64, issue *Issue) (err error) { | |||||
| } else { | } else { | ||||
| m.Completeness = 0 | m.Completeness = 0 | ||||
| } | } | ||||
| if _, err = sess.Id(m.Id).Update(m); err != nil { | if _, err = sess.Id(m.Id).Update(m); err != nil { | ||||
| sess.Rollback() | sess.Rollback() | ||||
| return err | return err | ||||
| @@ -710,6 +767,7 @@ func ChangeMilestoneAssign(oldMid, mid int64, issue *Issue) (err error) { | |||||
| if err != nil { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| m.NumIssues++ | m.NumIssues++ | ||||
| if issue.IsClosed { | if issue.IsClosed { | ||||
| m.NumClosedIssues++ | m.NumClosedIssues++ | ||||
| @@ -731,6 +789,7 @@ func ChangeMilestoneAssign(oldMid, mid int64, issue *Issue) (err error) { | |||||
| return err | return err | ||||
| } | } | ||||
| } | } | ||||
| return sess.Commit() | return sess.Commit() | ||||
| } | } | ||||
| @@ -7,9 +7,9 @@ package models | |||||
| import ( | import ( | ||||
| "errors" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "io/ioutil" | |||||
| "html" | "html" | ||||
| "html/template" | "html/template" | ||||
| "io/ioutil" | |||||
| "os" | "os" | ||||
| "path" | "path" | ||||
| "path/filepath" | "path/filepath" | ||||
| @@ -43,6 +43,7 @@ var ( | |||||
| ErrRepoNameIllegal = errors.New("Repository name contains illegal characters") | ErrRepoNameIllegal = errors.New("Repository name contains illegal characters") | ||||
| ErrRepoFileNotLoaded = errors.New("Repository file not loaded") | ErrRepoFileNotLoaded = errors.New("Repository file not loaded") | ||||
| ErrMirrorNotExist = errors.New("Mirror does not exist") | ErrMirrorNotExist = errors.New("Mirror does not exist") | ||||
| ErrInvalidReference = errors.New("Invalid reference specified") | |||||
| ) | ) | ||||
| var ( | var ( | ||||
| @@ -837,6 +838,26 @@ func DeleteRepository(userId, repoId int64, userName string) error { | |||||
| return sess.Commit() | return sess.Commit() | ||||
| } | } | ||||
| // GetRepositoryByRef returns a Repository specified by a GFM reference. | |||||
| // See https://help.github.com/articles/writing-on-github#references for more information on the syntax. | |||||
| func GetRepositoryByRef(ref string) (*Repository, error) { | |||||
| n := strings.IndexByte(ref, byte('/')) | |||||
| if n < 2 { | |||||
| return nil, ErrInvalidReference | |||||
| } | |||||
| userName, repoName := ref[:n], ref[n+1:] | |||||
| user, err := GetUserByName(userName) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return GetRepositoryByName(user.Id, repoName) | |||||
| } | |||||
| // GetRepositoryByName returns the repository by given name under user if exists. | // GetRepositoryByName returns the repository by given name under user if exists. | ||||
| func GetRepositoryByName(userId int64, repoName string) (*Repository, error) { | func GetRepositoryByName(userId int64, repoName string) (*Repository, error) { | ||||
| repo := &Repository{ | repo := &Repository{ | ||||
| @@ -1017,4 +1038,4 @@ func IsWatching(uid, rid int64) bool { | |||||
| func ForkRepository(repoName string, uid int64) { | func ForkRepository(repoName string, uid int64) { | ||||
| } | |||||
| } | |||||
| @@ -644,24 +644,8 @@ func Comment(ctx *middleware.Context, params martini.Params) { | |||||
| // Change open/closed issue counter for the associated milestone | // Change open/closed issue counter for the associated milestone | ||||
| if issue.MilestoneId > 0 { | if issue.MilestoneId > 0 { | ||||
| l, err := models.GetMilestoneById(issue.MilestoneId) | |||||
| if err != nil { | |||||
| ctx.Handle(500, "issue.Comment(GetLabelById)", err) | |||||
| return | |||||
| } | |||||
| if issue.IsClosed { | |||||
| l.NumOpenIssues = l.NumOpenIssues - 1 | |||||
| l.NumClosedIssues = l.NumClosedIssues + 1 | |||||
| } else { | |||||
| l.NumOpenIssues = l.NumOpenIssues + 1 | |||||
| l.NumClosedIssues = l.NumClosedIssues - 1 | |||||
| } | |||||
| if err = models.UpdateMilestone(l); err != nil { | |||||
| ctx.Handle(500, "issue.Comment(UpdateLabel)", err) | |||||
| return | |||||
| if err = models.ChangeMilestoneIssueStats(issue); err != nil { | |||||
| ctx.Handle(500, "issue.Comment(ChangeMilestoneIssueStats)", err) | |||||
| } | } | ||||
| } | } | ||||