Multiple assignees (#3705)
This commit is contained in:
		
							parent
							
								
									238a997ec0
								
							
						
					
					
						commit
						95f2e2b57b
					
				
					 36 changed files with 1012 additions and 451 deletions
				
			
		|  | @ -733,6 +733,22 @@ func (err ErrRepoFileAlreadyExist) Error() string { | |||
| 	return fmt.Sprintf("repository file already exists [file_name: %s]", err.FileName) | ||||
| } | ||||
| 
 | ||||
| // ErrUserDoesNotHaveAccessToRepo represets an error where the user doesn't has access to a given repo
 | ||||
| type ErrUserDoesNotHaveAccessToRepo struct { | ||||
| 	UserID   int64 | ||||
| 	RepoName string | ||||
| } | ||||
| 
 | ||||
| // IsErrUserDoesNotHaveAccessToRepo checks if an error is a ErrRepoFileAlreadyExist.
 | ||||
| func IsErrUserDoesNotHaveAccessToRepo(err error) bool { | ||||
| 	_, ok := err.(ErrUserDoesNotHaveAccessToRepo) | ||||
| 	return ok | ||||
| } | ||||
| 
 | ||||
| func (err ErrUserDoesNotHaveAccessToRepo) Error() string { | ||||
| 	return fmt.Sprintf("user doesn't have acces to repo [user_id: %d, repo_name: %s]", err.UserID, err.RepoName) | ||||
| } | ||||
| 
 | ||||
| // __________                             .__
 | ||||
| // \______   \____________    ____   ____ |  |__
 | ||||
| //  |    |  _/\_  __ \__  \  /    \_/ ___\|  |  \
 | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ | |||
|   repo_id: 1 | ||||
|   index: 1 | ||||
|   poster_id: 1 | ||||
|   assignee_id: 1 | ||||
|   name: issue1 | ||||
|   content: content for the first issue | ||||
|   is_closed: false | ||||
|  | @ -67,7 +66,6 @@ | |||
|   repo_id: 3 | ||||
|   index: 1 | ||||
|   poster_id: 1 | ||||
|   assignee_id: 1 | ||||
|   name: issue6 | ||||
|   content: content6 | ||||
|   is_closed: false | ||||
|  |  | |||
							
								
								
									
										8
									
								
								models/fixtures/issue_assignees.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								models/fixtures/issue_assignees.yml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| - | ||||
|   id: 1 | ||||
|   assignee_id: 1 | ||||
|   issue_id: 1 | ||||
| - | ||||
|   id: 2 | ||||
|   assignee_id: 1 | ||||
|   issue_id: 6 | ||||
|  | @ -3,7 +3,6 @@ | |||
|   uid: 1 | ||||
|   issue_id: 1 | ||||
|   is_read: true | ||||
|   is_assigned: true | ||||
|   is_mentioned: false | ||||
| 
 | ||||
| - | ||||
|  | @ -11,7 +10,6 @@ | |||
|   uid: 2 | ||||
|   issue_id: 1 | ||||
|   is_read: true | ||||
|   is_assigned: false | ||||
|   is_mentioned: false | ||||
| 
 | ||||
| - | ||||
|  | @ -19,5 +17,4 @@ | |||
|   uid: 4 | ||||
|   issue_id: 1 | ||||
|   is_read: false | ||||
|   is_assigned: false | ||||
|   is_mentioned: false | ||||
|  |  | |||
							
								
								
									
										159
									
								
								models/issue.go
									
									
									
									
									
								
							
							
						
						
									
										159
									
								
								models/issue.go
									
									
									
									
									
								
							|  | @ -37,7 +37,7 @@ type Issue struct { | |||
| 	MilestoneID     int64       `xorm:"INDEX"` | ||||
| 	Milestone       *Milestone  `xorm:"-"` | ||||
| 	Priority        int | ||||
| 	AssigneeID      int64        `xorm:"INDEX"` | ||||
| 	AssigneeID      int64        `xorm:"-"` | ||||
| 	Assignee        *User        `xorm:"-"` | ||||
| 	IsClosed        bool         `xorm:"INDEX"` | ||||
| 	IsRead          bool         `xorm:"-"` | ||||
|  | @ -56,6 +56,7 @@ type Issue struct { | |||
| 	Comments         []*Comment    `xorm:"-"` | ||||
| 	Reactions        ReactionList  `xorm:"-"` | ||||
| 	TotalTrackedTime int64         `xorm:"-"` | ||||
| 	Assignees        []*User       `xorm:"-"` | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
|  | @ -140,22 +141,6 @@ func (issue *Issue) loadPoster(e Engine) (err error) { | |||
| 	return | ||||
| } | ||||
| 
 | ||||
| func (issue *Issue) loadAssignee(e Engine) (err error) { | ||||
| 	if issue.Assignee == nil && issue.AssigneeID > 0 { | ||||
| 		issue.Assignee, err = getUserByID(e, issue.AssigneeID) | ||||
| 		if err != nil { | ||||
| 			issue.AssigneeID = -1 | ||||
| 			issue.Assignee = NewGhostUser() | ||||
| 			if !IsErrUserNotExist(err) { | ||||
| 				return fmt.Errorf("getUserByID.(assignee) [%d]: %v", issue.AssigneeID, err) | ||||
| 			} | ||||
| 			err = nil | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| func (issue *Issue) loadPullRequest(e Engine) (err error) { | ||||
| 	if issue.IsPull && issue.PullRequest == nil { | ||||
| 		issue.PullRequest, err = getPullRequestByIssueID(e, issue.ID) | ||||
|  | @ -231,7 +216,7 @@ func (issue *Issue) loadAttributes(e Engine) (err error) { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = issue.loadAssignee(e); err != nil { | ||||
| 	if err = issue.loadAssignees(e); err != nil { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | @ -343,8 +328,11 @@ func (issue *Issue) APIFormat() *api.Issue { | |||
| 	if issue.Milestone != nil { | ||||
| 		apiIssue.Milestone = issue.Milestone.APIFormat() | ||||
| 	} | ||||
| 	if issue.Assignee != nil { | ||||
| 		apiIssue.Assignee = issue.Assignee.APIFormat() | ||||
| 	if len(issue.Assignees) > 0 { | ||||
| 		for _, assignee := range issue.Assignees { | ||||
| 			apiIssue.Assignees = append(apiIssue.Assignees, assignee.APIFormat()) | ||||
| 		} | ||||
| 		apiIssue.Assignee = issue.Assignees[0].APIFormat() // For compatibility, we're keeping the first assignee as `apiIssue.Assignee`
 | ||||
| 	} | ||||
| 	if issue.IsPull { | ||||
| 		apiIssue.PullRequest = &api.PullRequestMeta{ | ||||
|  | @ -605,19 +593,6 @@ func (issue *Issue) ReplaceLabels(labels []*Label, doer *User) (err error) { | |||
| 	return sess.Commit() | ||||
| } | ||||
| 
 | ||||
| // GetAssignee sets the Assignee attribute of this issue.
 | ||||
| func (issue *Issue) GetAssignee() (err error) { | ||||
| 	if issue.AssigneeID == 0 || issue.Assignee != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	issue.Assignee, err = GetUserByID(issue.AssigneeID) | ||||
| 	if IsErrUserNotExist(err) { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // ReadBy sets issue to be read by given user.
 | ||||
| func (issue *Issue) ReadBy(userID int64) error { | ||||
| 	if err := UpdateIssueUserByRead(userID, issue.ID); err != nil { | ||||
|  | @ -823,55 +798,6 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // ChangeAssignee changes the Assignee field of this issue.
 | ||||
| func (issue *Issue) ChangeAssignee(doer *User, assigneeID int64) (err error) { | ||||
| 	var oldAssigneeID = issue.AssigneeID | ||||
| 	issue.AssigneeID = assigneeID | ||||
| 	if err = UpdateIssueUserByAssignee(issue); err != nil { | ||||
| 		return fmt.Errorf("UpdateIssueUserByAssignee: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 
 | ||||
| 	if err = issue.loadRepo(sess); err != nil { | ||||
| 		return fmt.Errorf("loadRepo: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err = createAssigneeComment(sess, doer, issue.Repo, issue, oldAssigneeID, assigneeID); err != nil { | ||||
| 		return fmt.Errorf("createAssigneeComment: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	issue.Assignee, err = GetUserByID(issue.AssigneeID) | ||||
| 	if err != nil && !IsErrUserNotExist(err) { | ||||
| 		log.Error(4, "GetUserByID [assignee_id: %v]: %v", issue.AssigneeID, err) | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	// Error not nil here means user does not exist, which is remove assignee.
 | ||||
| 	isRemoveAssignee := err != nil | ||||
| 	if issue.IsPull { | ||||
| 		issue.PullRequest.Issue = issue | ||||
| 		apiPullRequest := &api.PullRequestPayload{ | ||||
| 			Index:       issue.Index, | ||||
| 			PullRequest: issue.PullRequest.APIFormat(), | ||||
| 			Repository:  issue.Repo.APIFormat(AccessModeNone), | ||||
| 			Sender:      doer.APIFormat(), | ||||
| 		} | ||||
| 		if isRemoveAssignee { | ||||
| 			apiPullRequest.Action = api.HookIssueUnassigned | ||||
| 		} else { | ||||
| 			apiPullRequest.Action = api.HookIssueAssigned | ||||
| 		} | ||||
| 		if err := PrepareWebhooks(issue.Repo, HookEventPullRequest, apiPullRequest); err != nil { | ||||
| 			log.Error(4, "PrepareWebhooks [is_pull: %v, remove_assignee: %v]: %v", issue.IsPull, isRemoveAssignee, err) | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
| 	go HookQueue.Add(issue.RepoID) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // GetTasks returns the amount of tasks in the issues content
 | ||||
| func (issue *Issue) GetTasks() int { | ||||
| 	return len(issueTasksPat.FindAllStringIndex(issue.Content, -1)) | ||||
|  | @ -887,6 +813,7 @@ type NewIssueOptions struct { | |||
| 	Repo        *Repository | ||||
| 	Issue       *Issue | ||||
| 	LabelIDs    []int64 | ||||
| 	AssigneeIDs []int64 | ||||
| 	Attachments []string // In UUID format.
 | ||||
| 	IsPull      bool | ||||
| } | ||||
|  | @ -909,14 +836,32 @@ func newIssue(e *xorm.Session, doer *User, opts NewIssueOptions) (err error) { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if assigneeID := opts.Issue.AssigneeID; assigneeID > 0 { | ||||
| 		valid, err := hasAccess(e, assigneeID, opts.Repo, AccessModeWrite) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("hasAccess [user_id: %d, repo_id: %d]: %v", assigneeID, opts.Repo.ID, err) | ||||
| 	// Keep the old assignee id thingy for compatibility reasons
 | ||||
| 	if opts.Issue.AssigneeID > 0 { | ||||
| 		isAdded := false | ||||
| 		// Check if the user has already been passed to issue.AssigneeIDs, if not, add it
 | ||||
| 		for _, aID := range opts.AssigneeIDs { | ||||
| 			if aID == opts.Issue.AssigneeID { | ||||
| 				isAdded = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if !valid { | ||||
| 			opts.Issue.AssigneeID = 0 | ||||
| 			opts.Issue.Assignee = nil | ||||
| 
 | ||||
| 		if !isAdded { | ||||
| 			opts.AssigneeIDs = append(opts.AssigneeIDs, opts.Issue.AssigneeID) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Check for and validate assignees
 | ||||
| 	if len(opts.AssigneeIDs) > 0 { | ||||
| 		for _, assigneeID := range opts.AssigneeIDs { | ||||
| 			valid, err := hasAccess(e, assigneeID, opts.Repo, AccessModeWrite) | ||||
| 			if err != nil { | ||||
| 				return fmt.Errorf("hasAccess [user_id: %d, repo_id: %d]: %v", assigneeID, opts.Repo.ID, err) | ||||
| 			} | ||||
| 			if !valid { | ||||
| 				return ErrUserDoesNotHaveAccessToRepo{UserID: assigneeID, RepoName: opts.Repo.Name} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  | @ -931,11 +876,10 @@ func newIssue(e *xorm.Session, doer *User, opts NewIssueOptions) (err error) { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if opts.Issue.AssigneeID > 0 { | ||||
| 		if err = opts.Issue.loadRepo(e); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if _, err = createAssigneeComment(e, doer, opts.Issue.Repo, opts.Issue, -1, opts.Issue.AssigneeID); err != nil { | ||||
| 	// Insert the assignees
 | ||||
| 	for _, assigneeID := range opts.AssigneeIDs { | ||||
| 		err = opts.Issue.changeAssignee(e, doer, assigneeID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | @ -995,7 +939,7 @@ func newIssue(e *xorm.Session, doer *User, opts NewIssueOptions) (err error) { | |||
| } | ||||
| 
 | ||||
| // NewIssue creates new issue with labels for repository.
 | ||||
| func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string) (err error) { | ||||
| func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, assigneeIDs []int64, uuids []string) (err error) { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err = sess.Begin(); err != nil { | ||||
|  | @ -1007,7 +951,11 @@ func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string) | |||
| 		Issue:       issue, | ||||
| 		LabelIDs:    labelIDs, | ||||
| 		Attachments: uuids, | ||||
| 		AssigneeIDs: assigneeIDs, | ||||
| 	}); err != nil { | ||||
| 		if IsErrUserDoesNotHaveAccessToRepo(err) { | ||||
| 			return err | ||||
| 		} | ||||
| 		return fmt.Errorf("newIssue: %v", err) | ||||
| 	} | ||||
| 
 | ||||
|  | @ -1150,7 +1098,8 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) error { | |||
| 	} | ||||
| 
 | ||||
| 	if opts.AssigneeID > 0 { | ||||
| 		sess.And("issue.assignee_id=?", opts.AssigneeID) | ||||
| 		sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 			And("issue_assignees.assignee_id = ?", opts.AssigneeID) | ||||
| 	} | ||||
| 
 | ||||
| 	if opts.PosterID > 0 { | ||||
|  | @ -1372,7 +1321,8 @@ func GetIssueStats(opts *IssueStatsOptions) (*IssueStats, error) { | |||
| 		} | ||||
| 
 | ||||
| 		if opts.AssigneeID > 0 { | ||||
| 			sess.And("issue.assignee_id = ?", opts.AssigneeID) | ||||
| 			sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 				And("issue_assignees.assignee_id = ?", opts.AssigneeID) | ||||
| 		} | ||||
| 
 | ||||
| 		if opts.PosterID > 0 { | ||||
|  | @ -1438,13 +1388,15 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) { | |||
| 		} | ||||
| 	case FilterModeAssign: | ||||
| 		stats.OpenCount, err = x.Where(cond).And("is_closed = ?", false). | ||||
| 			And("assignee_id = ?", opts.UserID). | ||||
| 			Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 			And("issue_assignees.assignee_id = ?", opts.UserID). | ||||
| 			Count(new(Issue)) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		stats.ClosedCount, err = x.Where(cond).And("is_closed = ?", true). | ||||
| 			And("assignee_id = ?", opts.UserID). | ||||
| 			Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 			And("issue_assignees.assignee_id = ?", opts.UserID). | ||||
| 			Count(new(Issue)) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
|  | @ -1466,7 +1418,8 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) { | |||
| 
 | ||||
| 	cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed}) | ||||
| 	stats.AssignCount, err = x.Where(cond). | ||||
| 		And("assignee_id = ?", opts.UserID). | ||||
| 		Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 		And("issue_assignees.assignee_id = ?", opts.UserID). | ||||
| 		Count(new(Issue)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -1505,8 +1458,10 @@ func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen | |||
| 
 | ||||
| 	switch filterMode { | ||||
| 	case FilterModeAssign: | ||||
| 		openCountSession.And("assignee_id = ?", uid) | ||||
| 		closedCountSession.And("assignee_id = ?", uid) | ||||
| 		openCountSession.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 			And("issue_assignees.assignee_id = ?", uid) | ||||
| 		closedCountSession.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). | ||||
| 			And("issue_assignees.assignee_id = ?", uid) | ||||
| 	case FilterModeCreate: | ||||
| 		openCountSession.And("poster_id = ?", uid) | ||||
| 		closedCountSession.And("poster_id = ?", uid) | ||||
|  |  | |||
							
								
								
									
										263
									
								
								models/issue_assignees.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								models/issue_assignees.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,263 @@ | |||
| // 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 ( | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 
 | ||||
| 	api "code.gitea.io/sdk/gitea" | ||||
| 	"github.com/go-xorm/xorm" | ||||
| ) | ||||
| 
 | ||||
| // IssueAssignees saves all issue assignees
 | ||||
| type IssueAssignees struct { | ||||
| 	ID         int64 `xorm:"pk autoincr"` | ||||
| 	AssigneeID int64 `xorm:"INDEX"` | ||||
| 	IssueID    int64 `xorm:"INDEX"` | ||||
| } | ||||
| 
 | ||||
| // This loads all assignees of an issue
 | ||||
| func (issue *Issue) loadAssignees(e Engine) (err error) { | ||||
| 	// Reset maybe preexisting assignees
 | ||||
| 	issue.Assignees = []*User{} | ||||
| 
 | ||||
| 	err = e.Table("`user`"). | ||||
| 		Join("INNER", "issue_assignees", "assignee_id = `user`.id"). | ||||
| 		Where("issue_assignees.issue_id = ?", issue.ID). | ||||
| 		Find(&issue.Assignees) | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Check if we have at least one assignee and if yes put it in as `Assignee`
 | ||||
| 	if len(issue.Assignees) > 0 { | ||||
| 		issue.Assignee = issue.Assignees[0] | ||||
| 	} | ||||
| 
 | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // GetAssigneesByIssue returns everyone assigned to that issue
 | ||||
| func GetAssigneesByIssue(issue *Issue) (assignees []*User, err error) { | ||||
| 	err = issue.loadAssignees(x) | ||||
| 	if err != nil { | ||||
| 		return assignees, err | ||||
| 	} | ||||
| 
 | ||||
| 	return issue.Assignees, nil | ||||
| } | ||||
| 
 | ||||
| // IsUserAssignedToIssue returns true when the user is assigned to the issue
 | ||||
| func IsUserAssignedToIssue(issue *Issue, user *User) (isAssigned bool, err error) { | ||||
| 	isAssigned, err = x.Exist(&IssueAssignees{IssueID: issue.ID, AssigneeID: user.ID}) | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // DeleteNotPassedAssignee deletes all assignees who aren't passed via the "assignees" array
 | ||||
| func DeleteNotPassedAssignee(issue *Issue, doer *User, assignees []*User) (err error) { | ||||
| 	var found bool | ||||
| 
 | ||||
| 	for _, assignee := range issue.Assignees { | ||||
| 
 | ||||
| 		found = false | ||||
| 		for _, alreadyAssignee := range assignees { | ||||
| 			if assignee.ID == alreadyAssignee.ID { | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if !found { | ||||
| 			// This function also does comments and hooks, which is why we call it seperatly instead of directly removing the assignees here
 | ||||
| 			if err := UpdateAssignee(issue, doer, assignee.ID); err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // MakeAssigneeList concats a string with all names of the assignees. Useful for logs.
 | ||||
| func MakeAssigneeList(issue *Issue) (assigneeList string, err error) { | ||||
| 	err = issue.loadAssignees(x) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 
 | ||||
| 	for in, assignee := range issue.Assignees { | ||||
| 		assigneeList += assignee.Name | ||||
| 
 | ||||
| 		if len(issue.Assignees) > (in + 1) { | ||||
| 			assigneeList += ", " | ||||
| 		} | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // ClearAssigneeByUserID deletes all assignments of an user
 | ||||
| func clearAssigneeByUserID(sess *xorm.Session, userID int64) (err error) { | ||||
| 	_, err = sess.Delete(&IssueAssignees{AssigneeID: userID}) | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // AddAssigneeIfNotAssigned adds an assignee only if he isn't aleady assigned to the issue
 | ||||
| func AddAssigneeIfNotAssigned(issue *Issue, doer *User, assigneeID int64) (err error) { | ||||
| 	// Check if the user is already assigned
 | ||||
| 	isAssigned, err := IsUserAssignedToIssue(issue, &User{ID: assigneeID}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if !isAssigned { | ||||
| 		return issue.ChangeAssignee(doer, assigneeID) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // UpdateAssignee deletes or adds an assignee to an issue
 | ||||
| func UpdateAssignee(issue *Issue, doer *User, assigneeID int64) (err error) { | ||||
| 	return issue.ChangeAssignee(doer, assigneeID) | ||||
| } | ||||
| 
 | ||||
| // ChangeAssignee changes the Assignee of this issue.
 | ||||
| func (issue *Issue) ChangeAssignee(doer *User, assigneeID int64) (err error) { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 
 | ||||
| 	if err := sess.Begin(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := issue.changeAssignee(sess, doer, assigneeID); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| } | ||||
| 
 | ||||
| func (issue *Issue) changeAssignee(sess *xorm.Session, doer *User, assigneeID int64) (err error) { | ||||
| 
 | ||||
| 	// Update the assignee
 | ||||
| 	removed, err := updateIssueAssignee(sess, issue, assigneeID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("UpdateIssueUserByAssignee: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Repo infos
 | ||||
| 	if err = issue.loadRepo(sess); err != nil { | ||||
| 		return fmt.Errorf("loadRepo: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Comment
 | ||||
| 	if _, err = createAssigneeComment(sess, doer, issue.Repo, issue, assigneeID, removed); err != nil { | ||||
| 		return fmt.Errorf("createAssigneeComment: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if issue.IsPull { | ||||
| 		issue.PullRequest = &PullRequest{Issue: issue} | ||||
| 		apiPullRequest := &api.PullRequestPayload{ | ||||
| 			Index:       issue.Index, | ||||
| 			PullRequest: issue.PullRequest.APIFormat(), | ||||
| 			Repository:  issue.Repo.APIFormat(AccessModeNone), | ||||
| 			Sender:      doer.APIFormat(), | ||||
| 		} | ||||
| 		if removed { | ||||
| 			apiPullRequest.Action = api.HookIssueUnassigned | ||||
| 		} else { | ||||
| 			apiPullRequest.Action = api.HookIssueAssigned | ||||
| 		} | ||||
| 		if err := PrepareWebhooks(issue.Repo, HookEventPullRequest, apiPullRequest); err != nil { | ||||
| 			log.Error(4, "PrepareWebhooks [is_pull: %v, remove_assignee: %v]: %v", issue.IsPull, removed, err) | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
| 	go HookQueue.Add(issue.RepoID) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // UpdateAPIAssignee is a helper function to add or delete one or multiple issue assignee(s)
 | ||||
| // Deleting is done the Github way (quote from their api documentation):
 | ||||
| // https://developer.github.com/v3/issues/#edit-an-issue
 | ||||
| // "assignees" (array): Logins for Users to assign to this issue.
 | ||||
| // Pass one or more user logins to replace the set of assignees on this Issue.
 | ||||
| // Send an empty array ([]) to clear all assignees from the Issue.
 | ||||
| func UpdateAPIAssignee(issue *Issue, oneAssignee string, multipleAssignees []string, doer *User) (err error) { | ||||
| 	var allNewAssignees []*User | ||||
| 
 | ||||
| 	// Keep the old assignee thingy for compatibility reasons
 | ||||
| 	if oneAssignee != "" { | ||||
| 		// Prevent double adding assignees
 | ||||
| 		var isDouble bool | ||||
| 		for _, assignee := range multipleAssignees { | ||||
| 			if assignee == oneAssignee { | ||||
| 				isDouble = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if !isDouble { | ||||
| 			multipleAssignees = append(multipleAssignees, oneAssignee) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Loop through all assignees to add them
 | ||||
| 	for _, assigneeName := range multipleAssignees { | ||||
| 		assignee, err := GetUserByName(assigneeName) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		allNewAssignees = append(allNewAssignees, assignee) | ||||
| 	} | ||||
| 
 | ||||
| 	// Delete all old assignees not passed
 | ||||
| 	if err = DeleteNotPassedAssignee(issue, doer, allNewAssignees); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Add all new assignees
 | ||||
| 	// Update the assignee. The function will check if the user exists, is already
 | ||||
| 	// assigned (which he shouldn't as we deleted all assignees before) and
 | ||||
| 	// has access to the repo.
 | ||||
| 	for _, assignee := range allNewAssignees { | ||||
| 		// Extra method to prevent double adding (which would result in removing)
 | ||||
| 		err = AddAssigneeIfNotAssigned(issue, doer, assignee.ID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // MakeIDsFromAPIAssigneesToAdd returns an array with all assignee IDs
 | ||||
| func MakeIDsFromAPIAssigneesToAdd(oneAssignee string, multipleAssignees []string) (assigneeIDs []int64, err error) { | ||||
| 
 | ||||
| 	// Keeping the old assigning method for compatibility reasons
 | ||||
| 	if oneAssignee != "" { | ||||
| 
 | ||||
| 		// Prevent double adding assignees
 | ||||
| 		var isDouble bool | ||||
| 		for _, assignee := range multipleAssignees { | ||||
| 			if assignee == oneAssignee { | ||||
| 				isDouble = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if !isDouble { | ||||
| 			multipleAssignees = append(multipleAssignees, oneAssignee) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Get the IDs of all assignees
 | ||||
| 	assigneeIDs = GetUserIDsByNames(multipleAssignees) | ||||
| 
 | ||||
| 	return | ||||
| } | ||||
							
								
								
									
										71
									
								
								models/issue_assignees_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								models/issue_assignees_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,71 @@ | |||
| // 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 TestUpdateAssignee(t *testing.T) { | ||||
| 	assert.NoError(t, PrepareTestDatabase()) | ||||
| 
 | ||||
| 	// Fake issue with assignees
 | ||||
| 	issue, err := GetIssueByID(1) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	// Assign multiple users
 | ||||
| 	user2, err := GetUserByID(2) | ||||
| 	assert.NoError(t, err) | ||||
| 	err = UpdateAssignee(issue, &User{ID: 1}, user2.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	user3, err := GetUserByID(3) | ||||
| 	assert.NoError(t, err) | ||||
| 	err = UpdateAssignee(issue, &User{ID: 1}, user3.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	user1, err := GetUserByID(1) // This user is already assigned (see the definition in fixtures), so running  UpdateAssignee should unassign him
 | ||||
| 	assert.NoError(t, err) | ||||
| 	err = UpdateAssignee(issue, &User{ID: 1}, user1.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	// Check if he got removed
 | ||||
| 	isAssigned, err := IsUserAssignedToIssue(issue, user1) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.False(t, isAssigned) | ||||
| 
 | ||||
| 	// Check if they're all there
 | ||||
| 	assignees, err := GetAssigneesByIssue(issue) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	var expectedAssignees []*User | ||||
| 	expectedAssignees = append(expectedAssignees, user2) | ||||
| 	expectedAssignees = append(expectedAssignees, user3) | ||||
| 
 | ||||
| 	for in, assignee := range assignees { | ||||
| 		assert.Equal(t, assignee.ID, expectedAssignees[in].ID) | ||||
| 	} | ||||
| 
 | ||||
| 	// Check if the user is assigned
 | ||||
| 	isAssigned, err = IsUserAssignedToIssue(issue, user2) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.True(t, isAssigned) | ||||
| 
 | ||||
| 	// This user should not be assigned
 | ||||
| 	isAssigned, err = IsUserAssignedToIssue(issue, &User{ID: 4}) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.False(t, isAssigned) | ||||
| 
 | ||||
| 	// Clean everyone
 | ||||
| 	err = DeleteNotPassedAssignee(issue, user1, []*User{}) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	// Check they're gone
 | ||||
| 	assignees, err = GetAssigneesByIssue(issue) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Equal(t, 0, len(assignees)) | ||||
| } | ||||
|  | @ -81,23 +81,22 @@ 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"` | ||||
| 	LabelID        int64 | ||||
| 	Label          *Label `xorm:"-"` | ||||
| 	OldMilestoneID int64 | ||||
| 	MilestoneID    int64 | ||||
| 	OldMilestone   *Milestone `xorm:"-"` | ||||
| 	Milestone      *Milestone `xorm:"-"` | ||||
| 	OldAssigneeID  int64 | ||||
| 	AssigneeID     int64 | ||||
| 	Assignee       *User `xorm:"-"` | ||||
| 	OldAssignee    *User `xorm:"-"` | ||||
| 	OldTitle       string | ||||
| 	NewTitle       string | ||||
| 	ID              int64 `xorm:"pk autoincr"` | ||||
| 	Type            CommentType | ||||
| 	PosterID        int64 `xorm:"INDEX"` | ||||
| 	Poster          *User `xorm:"-"` | ||||
| 	IssueID         int64 `xorm:"INDEX"` | ||||
| 	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 | ||||
| 
 | ||||
| 	CommitID        int64 | ||||
| 	Line            int64 | ||||
|  | @ -247,18 +246,9 @@ func (c *Comment) LoadMilestone() error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // LoadAssignees if comment.Type is CommentTypeAssignees, then load assignees
 | ||||
| func (c *Comment) LoadAssignees() error { | ||||
| // LoadAssigneeUser if comment.Type is CommentTypeAssignees, then load assignees
 | ||||
| func (c *Comment) LoadAssigneeUser() error { | ||||
| 	var err error | ||||
| 	if c.OldAssigneeID > 0 { | ||||
| 		c.OldAssignee, err = getUserByID(x, c.OldAssigneeID) | ||||
| 		if err != nil { | ||||
| 			if !IsErrUserNotExist(err) { | ||||
| 				return err | ||||
| 			} | ||||
| 			c.OldAssignee = NewGhostUser() | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if c.AssigneeID > 0 { | ||||
| 		c.Assignee, err = getUserByID(x, c.AssigneeID) | ||||
|  | @ -324,21 +314,21 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | |||
| 		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, | ||||
| 		OldAssigneeID:  opts.OldAssigneeID, | ||||
| 		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, | ||||
| 	} | ||||
| 	if _, err = e.Insert(comment); err != nil { | ||||
| 		return nil, err | ||||
|  | @ -480,14 +470,14 @@ func createMilestoneComment(e *xorm.Session, doer *User, repo *Repository, issue | |||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func createAssigneeComment(e *xorm.Session, doer *User, repo *Repository, issue *Issue, oldAssigneeID, assigneeID int64) (*Comment, error) { | ||||
| func createAssigneeComment(e *xorm.Session, doer *User, repo *Repository, issue *Issue, assigneeID int64, removedAssignee bool) (*Comment, error) { | ||||
| 	return createComment(e, &CreateCommentOptions{ | ||||
| 		Type:          CommentTypeAssignees, | ||||
| 		Doer:          doer, | ||||
| 		Repo:          repo, | ||||
| 		Issue:         issue, | ||||
| 		OldAssigneeID: oldAssigneeID, | ||||
| 		AssigneeID:    assigneeID, | ||||
| 		Type:            CommentTypeAssignees, | ||||
| 		Doer:            doer, | ||||
| 		Repo:            repo, | ||||
| 		Issue:           issue, | ||||
| 		RemovedAssignee: removedAssignee, | ||||
| 		AssigneeID:      assigneeID, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
|  | @ -548,17 +538,17 @@ type CreateCommentOptions struct { | |||
| 	Issue *Issue | ||||
| 	Label *Label | ||||
| 
 | ||||
| 	OldMilestoneID int64 | ||||
| 	MilestoneID    int64 | ||||
| 	OldAssigneeID  int64 | ||||
| 	AssigneeID     int64 | ||||
| 	OldTitle       string | ||||
| 	NewTitle       string | ||||
| 	CommitID       int64 | ||||
| 	CommitSHA      string | ||||
| 	LineNum        int64 | ||||
| 	Content        string | ||||
| 	Attachments    []string // UUIDs of attachments
 | ||||
| 	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.
 | ||||
|  |  | |||
|  | @ -154,38 +154,38 @@ func (issues IssueList) loadMilestones(e Engine) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (issues IssueList) getAssigneeIDs() []int64 { | ||||
| 	var ids = make(map[int64]struct{}, len(issues)) | ||||
| 	for _, issue := range issues { | ||||
| 		if _, ok := ids[issue.AssigneeID]; !ok { | ||||
| 			ids[issue.AssigneeID] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
| 	return keysInt64(ids) | ||||
| } | ||||
| 
 | ||||
| func (issues IssueList) loadAssignees(e Engine) error { | ||||
| 	assigneeIDs := issues.getAssigneeIDs() | ||||
| 	if len(assigneeIDs) == 0 { | ||||
| 	if len(issues) == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	assigneeMaps := make(map[int64]*User, len(assigneeIDs)) | ||||
| 	err := e. | ||||
| 		In("id", assigneeIDs). | ||||
| 		Find(&assigneeMaps) | ||||
| 	type AssigneeIssue struct { | ||||
| 		IssueAssignee *IssueAssignees `xorm:"extends"` | ||||
| 		Assignee      *User           `xorm:"extends"` | ||||
| 	} | ||||
| 
 | ||||
| 	var assignees = make(map[int64][]*User, len(issues)) | ||||
| 	rows, err := e.Table("issue_assignees"). | ||||
| 		Join("INNER", "user", "`user`.id = `issue_assignees`.assignee_id"). | ||||
| 		In("`issue_assignees`.issue_id", issues.getIssueIDs()). | ||||
| 		Rows(new(AssigneeIssue)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 
 | ||||
| 	for rows.Next() { | ||||
| 		var assigneeIssue AssigneeIssue | ||||
| 		err = rows.Scan(&assigneeIssue) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		assignees[assigneeIssue.IssueAssignee.IssueID] = append(assignees[assigneeIssue.IssueAssignee.IssueID], assigneeIssue.Assignee) | ||||
| 	} | ||||
| 
 | ||||
| 	for _, issue := range issues { | ||||
| 		if issue.AssigneeID <= 0 { | ||||
| 			continue | ||||
| 		} | ||||
| 		var ok bool | ||||
| 		if issue.Assignee, ok = assigneeMaps[issue.AssigneeID]; !ok { | ||||
| 			issue.Assignee = NewGhostUser() | ||||
| 		} | ||||
| 		issue.Assignees = assignees[issue.ID] | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  |  | |||
|  | @ -46,9 +46,16 @@ func mailIssueCommentToParticipants(e Engine, issue *Issue, doer *User, content | |||
| 		participants = append(participants, issue.Poster) | ||||
| 	} | ||||
| 
 | ||||
| 	// Assignee must receive any communications
 | ||||
| 	if issue.Assignee != nil && issue.AssigneeID > 0 && issue.AssigneeID != doer.ID { | ||||
| 		participants = append(participants, issue.Assignee) | ||||
| 	// Assignees must receive any communications
 | ||||
| 	assignees, err := GetAssigneesByIssue(issue) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	for _, assignee := range assignees { | ||||
| 		if assignee.ID != doer.ID { | ||||
| 			participants = append(participants, assignee) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	tos := make([]string, 0, len(watchers)) // List of email addresses.
 | ||||
|  |  | |||
|  | @ -6,6 +6,8 @@ package models | |||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"github.com/go-xorm/xorm" | ||||
| ) | ||||
| 
 | ||||
| // IssueUser represents an issue-user relation.
 | ||||
|  | @ -14,7 +16,6 @@ type IssueUser struct { | |||
| 	UID         int64 `xorm:"INDEX"` // User ID.
 | ||||
| 	IssueID     int64 | ||||
| 	IsRead      bool | ||||
| 	IsAssigned  bool | ||||
| 	IsMentioned bool | ||||
| } | ||||
| 
 | ||||
|  | @ -32,9 +33,8 @@ func newIssueUsers(e Engine, repo *Repository, issue *Issue) error { | |||
| 	issueUsers := make([]*IssueUser, 0, len(assignees)+1) | ||||
| 	for _, assignee := range assignees { | ||||
| 		issueUsers = append(issueUsers, &IssueUser{ | ||||
| 			IssueID:    issue.ID, | ||||
| 			UID:        assignee.ID, | ||||
| 			IsAssigned: assignee.ID == issue.AssigneeID, | ||||
| 			IssueID: issue.ID, | ||||
| 			UID:     assignee.ID, | ||||
| 		}) | ||||
| 		isPosterAssignee = isPosterAssignee || assignee.ID == issue.PosterID | ||||
| 	} | ||||
|  | @ -51,34 +51,38 @@ func newIssueUsers(e Engine, repo *Repository, issue *Issue) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func updateIssueUserByAssignee(e Engine, issue *Issue) (err error) { | ||||
| 	if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE issue_id = ?", false, issue.ID); err != nil { | ||||
| 		return err | ||||
| func updateIssueAssignee(e *xorm.Session, issue *Issue, assigneeID int64) (removed bool, err error) { | ||||
| 
 | ||||
| 	// Check if the user exists
 | ||||
| 	_, err = GetUserByID(assigneeID) | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
| 
 | ||||
| 	// Assignee ID equals to 0 means clear assignee.
 | ||||
| 	if issue.AssigneeID > 0 { | ||||
| 		if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE uid = ? AND issue_id = ?", true, issue.AssigneeID, issue.ID); err != nil { | ||||
| 			return err | ||||
| 	// Check if the submitted user is already assigne, if yes delete him otherwise add him
 | ||||
| 	var toBeDeleted bool | ||||
| 	for _, assignee := range issue.Assignees { | ||||
| 		if assignee.ID == assigneeID { | ||||
| 			toBeDeleted = true | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return updateIssue(e, issue) | ||||
| } | ||||
| 	assigneeIn := IssueAssignees{AssigneeID: assigneeID, IssueID: issue.ID} | ||||
| 
 | ||||
| // UpdateIssueUserByAssignee updates issue-user relation for assignee.
 | ||||
| func UpdateIssueUserByAssignee(issue *Issue) (err error) { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err = sess.Begin(); err != nil { | ||||
| 		return err | ||||
| 	if toBeDeleted { | ||||
| 		_, err = e.Delete(assigneeIn) | ||||
| 		if err != nil { | ||||
| 			return toBeDeleted, err | ||||
| 		} | ||||
| 	} else { | ||||
| 		_, err = e.Insert(assigneeIn) | ||||
| 		if err != nil { | ||||
| 			return toBeDeleted, err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = updateIssueUserByAssignee(sess, issue); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| 	return toBeDeleted, nil | ||||
| } | ||||
| 
 | ||||
| // UpdateIssueUserByRead updates issue-user relation for reading.
 | ||||
|  |  | |||
|  | @ -32,23 +32,6 @@ func Test_newIssueUsers(t *testing.T) { | |||
| 	AssertExistsAndLoadBean(t, &IssueUser{IssueID: newIssue.ID, UID: repo.OwnerID}) | ||||
| } | ||||
| 
 | ||||
| func TestUpdateIssueUserByAssignee(t *testing.T) { | ||||
| 	assert.NoError(t, PrepareTestDatabase()) | ||||
| 	issue := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue) | ||||
| 
 | ||||
| 	// artificially change assignee in issue_user table
 | ||||
| 	AssertSuccessfulInsert(t, &IssueUser{IssueID: issue.ID, UID: 5, IsAssigned: true}) | ||||
| 	_, err := x.Cols("is_assigned"). | ||||
| 		Update(&IssueUser{IsAssigned: false}, &IssueUser{IssueID: issue.ID, UID: issue.AssigneeID}) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	assert.NoError(t, UpdateIssueUserByAssignee(issue)) | ||||
| 
 | ||||
| 	// issue_user table should now be correct again
 | ||||
| 	AssertExistsAndLoadBean(t, &IssueUser{IssueID: issue.ID, UID: issue.AssigneeID}, "is_assigned=1") | ||||
| 	AssertExistsAndLoadBean(t, &IssueUser{IssueID: issue.ID, UID: 5}, "is_assigned=0") | ||||
| } | ||||
| 
 | ||||
| func TestUpdateIssueUserByRead(t *testing.T) { | ||||
| 	assert.NoError(t, PrepareTestDatabase()) | ||||
| 	issue := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue) | ||||
|  |  | |||
|  | @ -180,6 +180,8 @@ var migrations = []Migration{ | |||
| 	NewMigration("add last used passcode column for TOTP", addLastUsedPasscodeTOTP), | ||||
| 	// v63 -> v64
 | ||||
| 	NewMigration("add language column for user setting", addLanguageSetting), | ||||
| 	// v64 -> v65
 | ||||
| 	NewMigration("add multiple assignees", addMultipleAssignees), | ||||
| } | ||||
| 
 | ||||
| // Migrate database to current version
 | ||||
|  | @ -229,7 +231,7 @@ Please try to upgrade to a lower version (>= v0.6.0) first, then upgrade to curr | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func dropTableColumns(x *xorm.Engine, tableName string, columnNames ...string) (err error) { | ||||
| func dropTableColumns(sess *xorm.Session, tableName string, columnNames ...string) (err error) { | ||||
| 	if tableName == "" || len(columnNames) == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
|  | @ -245,17 +247,10 @@ func dropTableColumns(x *xorm.Engine, tableName string, columnNames ...string) ( | |||
| 			} | ||||
| 			cols += "DROP COLUMN `" + col + "`" | ||||
| 		} | ||||
| 		if _, err := x.Exec(fmt.Sprintf("ALTER TABLE `%s` %s", tableName, cols)); err != nil { | ||||
| 		if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` %s", tableName, cols)); err != nil { | ||||
| 			return fmt.Errorf("Drop table `%s` columns %v: %v", tableName, columnNames, err) | ||||
| 		} | ||||
| 	case setting.UseMSSQL: | ||||
| 		sess := x.NewSession() | ||||
| 		defer sess.Close() | ||||
| 
 | ||||
| 		if err = sess.Begin(); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		cols := "" | ||||
| 		for _, col := range columnNames { | ||||
| 			if cols != "" { | ||||
|  |  | |||
|  | @ -9,5 +9,15 @@ import ( | |||
| ) | ||||
| 
 | ||||
| func removeIsOwnerColumnFromOrgUser(x *xorm.Engine) (err error) { | ||||
| 	return dropTableColumns(x, "org_user", "is_owner", "num_teams") | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 
 | ||||
| 	if err = sess.Begin(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := dropTableColumns(sess, "org_user", "is_owner", "num_teams"); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return sess.Commit() | ||||
| } | ||||
|  |  | |||
							
								
								
									
										129
									
								
								models/migrations/v64.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								models/migrations/v64.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,129 @@ | |||
| // 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 ( | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 
 | ||||
| 	"github.com/go-xorm/xorm" | ||||
| ) | ||||
| 
 | ||||
| func addMultipleAssignees(x *xorm.Engine) error { | ||||
| 
 | ||||
| 	// Redeclare issue struct
 | ||||
| 	type Issue struct { | ||||
| 		ID          int64  `xorm:"pk autoincr"` | ||||
| 		RepoID      int64  `xorm:"INDEX UNIQUE(repo_index)"` | ||||
| 		Index       int64  `xorm:"UNIQUE(repo_index)"` // Index in one repository.
 | ||||
| 		PosterID    int64  `xorm:"INDEX"` | ||||
| 		Title       string `xorm:"name"` | ||||
| 		Content     string `xorm:"TEXT"` | ||||
| 		MilestoneID int64  `xorm:"INDEX"` | ||||
| 		Priority    int | ||||
| 		AssigneeID  int64 `xorm:"INDEX"` | ||||
| 		IsClosed    bool  `xorm:"INDEX"` | ||||
| 		IsPull      bool  `xorm:"INDEX"` // Indicates whether is a pull request or not.
 | ||||
| 		NumComments int | ||||
| 		Ref         string | ||||
| 
 | ||||
| 		DeadlineUnix util.TimeStamp `xorm:"INDEX"` | ||||
| 		CreatedUnix  util.TimeStamp `xorm:"INDEX created"` | ||||
| 		UpdatedUnix  util.TimeStamp `xorm:"INDEX updated"` | ||||
| 		ClosedUnix   util.TimeStamp `xorm:"INDEX"` | ||||
| 	} | ||||
| 
 | ||||
| 	allIssues := []Issue{} | ||||
| 	err := x.Find(&allIssues) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Create the table
 | ||||
| 	type IssueAssignees struct { | ||||
| 		ID         int64 `xorm:"pk autoincr"` | ||||
| 		AssigneeID int64 `xorm:"INDEX"` | ||||
| 		IssueID    int64 `xorm:"INDEX"` | ||||
| 	} | ||||
| 	err = x.Sync2(IssueAssignees{}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Range over all issues and insert a new entry for each issue/assignee
 | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 
 | ||||
| 	err = sess.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	for _, issue := range allIssues { | ||||
| 		if issue.AssigneeID != 0 { | ||||
| 			_, err := sess.Insert(IssueAssignees{IssueID: issue.ID, AssigneeID: issue.AssigneeID}) | ||||
| 			if err != nil { | ||||
| 				sess.Rollback() | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Updated the comment table
 | ||||
| 	type Comment struct { | ||||
| 		ID              int64 `xorm:"pk autoincr"` | ||||
| 		Type            int | ||||
| 		PosterID        int64 `xorm:"INDEX"` | ||||
| 		IssueID         int64 `xorm:"INDEX"` | ||||
| 		LabelID         int64 | ||||
| 		OldMilestoneID  int64 | ||||
| 		MilestoneID     int64 | ||||
| 		OldAssigneeID   int64 | ||||
| 		AssigneeID      int64 | ||||
| 		RemovedAssignee bool | ||||
| 		OldTitle        string | ||||
| 		NewTitle        string | ||||
| 
 | ||||
| 		CommitID        int64 | ||||
| 		Line            int64 | ||||
| 		Content         string `xorm:"TEXT"` | ||||
| 		RenderedContent string `xorm:"-"` | ||||
| 
 | ||||
| 		CreatedUnix util.TimeStamp `xorm:"INDEX created"` | ||||
| 		UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | ||||
| 
 | ||||
| 		// Reference issue in commit message
 | ||||
| 		CommitSHA string `xorm:"VARCHAR(40)"` | ||||
| 	} | ||||
| 	if err := x.Sync2(Comment{}); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Migrate comments
 | ||||
| 	// First update everything to not have nulls in db
 | ||||
| 	if _, err := sess.Where("type = ?", 9).Cols("removed_assignee").Update(Comment{RemovedAssignee: false}); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	allAssignementComments := []Comment{} | ||||
| 	if err := sess.Where("type = ?", 9).Find(&allAssignementComments); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	for _, comment := range allAssignementComments { | ||||
| 		// Everytime where OldAssigneeID is > 0, the assignement was removed.
 | ||||
| 		if comment.OldAssigneeID > 0 { | ||||
| 			_, err = sess.ID(comment.ID).Update(Comment{RemovedAssignee: true}) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err := dropTableColumns(sess, "issue", "assignee_id"); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := dropTableColumns(sess, "issue_user", "is_assigned"); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return sess.Commit() | ||||
| } | ||||
|  | @ -119,6 +119,7 @@ func init() { | |||
| 		new(RepoIndexerStatus), | ||||
| 		new(LFSLock), | ||||
| 		new(Reaction), | ||||
| 		new(IssueAssignees), | ||||
| 	) | ||||
| 
 | ||||
| 	gonicNames := []string{"SSL", "UID"} | ||||
|  |  | |||
|  | @ -198,6 +198,7 @@ func (pr *PullRequest) APIFormat() *api.PullRequest { | |||
| 		Labels:    apiIssue.Labels, | ||||
| 		Milestone: apiIssue.Milestone, | ||||
| 		Assignee:  apiIssue.Assignee, | ||||
| 		Assignees: apiIssue.Assignees, | ||||
| 		State:     apiIssue.State, | ||||
| 		Comments:  apiIssue.Comments, | ||||
| 		HTMLURL:   pr.Issue.HTMLURL(), | ||||
|  | @ -719,7 +720,7 @@ func (pr *PullRequest) testPatch() (err error) { | |||
| } | ||||
| 
 | ||||
| // NewPullRequest creates new pull request with labels for repository.
 | ||||
| func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []string, pr *PullRequest, patch []byte) (err error) { | ||||
| func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []string, pr *PullRequest, patch []byte, assigneeIDs []int64) (err error) { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err = sess.Begin(); err != nil { | ||||
|  | @ -732,7 +733,11 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str | |||
| 		LabelIDs:    labelIDs, | ||||
| 		Attachments: uuids, | ||||
| 		IsPull:      true, | ||||
| 		AssigneeIDs: assigneeIDs, | ||||
| 	}); err != nil { | ||||
| 		if IsErrUserDoesNotHaveAccessToRepo(err) { | ||||
| 			return err | ||||
| 		} | ||||
| 		return fmt.Errorf("newIssue: %v", err) | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -600,9 +600,9 @@ func (repo *Repository) GetAssignees() (_ []*User, err error) { | |||
| 	return repo.getAssignees(x) | ||||
| } | ||||
| 
 | ||||
| // GetAssigneeByID returns the user that has write access of repository by given ID.
 | ||||
| func (repo *Repository) GetAssigneeByID(userID int64) (*User, error) { | ||||
| 	return GetAssigneeByID(repo, userID) | ||||
| // GetUserIfHasWriteAccess returns the user that has write access of repository by given ID.
 | ||||
| func (repo *Repository) GetUserIfHasWriteAccess(userID int64) (*User, error) { | ||||
| 	return GetUserIfHasWriteAccess(repo, userID) | ||||
| } | ||||
| 
 | ||||
| // GetMilestoneByID returns the milestone belongs to repository by given ID.
 | ||||
|  |  | |||
|  | @ -993,7 +993,7 @@ func deleteUser(e *xorm.Session, u *User) error { | |||
| 	// ***** END: PublicKey *****
 | ||||
| 
 | ||||
| 	// Clear assignee.
 | ||||
| 	if _, err = e.Exec("UPDATE `issue` SET assignee_id=0 WHERE assignee_id=?", u.ID); err != nil { | ||||
| 	if err = clearAssigneeByUserID(e, u.ID); err != nil { | ||||
| 		return fmt.Errorf("clear assignee: %v", err) | ||||
| 	} | ||||
| 
 | ||||
|  | @ -1110,8 +1110,8 @@ func GetUserByID(id int64) (*User, error) { | |||
| 	return getUserByID(x, id) | ||||
| } | ||||
| 
 | ||||
| // GetAssigneeByID returns the user with write access of repository by given ID.
 | ||||
| func GetAssigneeByID(repo *Repository, userID int64) (*User, error) { | ||||
| // GetUserIfHasWriteAccess returns the user with write access of repository by given ID.
 | ||||
| func GetUserIfHasWriteAccess(repo *Repository, userID int64) (*User, error) { | ||||
| 	has, err := HasAccess(userID, repo, AccessModeWrite) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  |  | |||
|  | @ -118,8 +118,12 @@ func getDingtalkPullRequestPayload(p *api.PullRequestPayload) (*DingtalkPayload, | |||
| 		title = fmt.Sprintf("[%s] Pull request edited: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) | ||||
| 		text = p.PullRequest.Body | ||||
| 	case api.HookIssueAssigned: | ||||
| 		list, err := MakeAssigneeList(&Issue{ID: p.PullRequest.ID}) | ||||
| 		if err != nil { | ||||
| 			return &DingtalkPayload{}, err | ||||
| 		} | ||||
| 		title = fmt.Sprintf("[%s] Pull request assigned to %s: #%d %s", p.Repository.FullName, | ||||
| 			p.PullRequest.Assignee.UserName, p.Index, p.PullRequest.Title) | ||||
| 			list, p.Index, p.PullRequest.Title) | ||||
| 		text = p.PullRequest.Body | ||||
| 	case api.HookIssueUnassigned: | ||||
| 		title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) | ||||
|  |  | |||
|  | @ -191,8 +191,12 @@ func getDiscordPullRequestPayload(p *api.PullRequestPayload, meta *DiscordMeta) | |||
| 		text = p.PullRequest.Body | ||||
| 		color = warnColor | ||||
| 	case api.HookIssueAssigned: | ||||
| 		list, err := MakeAssigneeList(&Issue{ID: p.PullRequest.ID}) | ||||
| 		if err != nil { | ||||
| 			return &DiscordPayload{}, err | ||||
| 		} | ||||
| 		title = fmt.Sprintf("[%s] Pull request assigned to %s: #%d %s", p.Repository.FullName, | ||||
| 			p.PullRequest.Assignee.UserName, p.Index, p.PullRequest.Title) | ||||
| 			list, p.Index, p.PullRequest.Title) | ||||
| 		text = p.PullRequest.Body | ||||
| 		color = successColor | ||||
| 	case api.HookIssueUnassigned: | ||||
|  |  | |||
|  | @ -172,8 +172,12 @@ func getSlackPullRequestPayload(p *api.PullRequestPayload, slack *SlackMeta) (*S | |||
| 		text = fmt.Sprintf("[%s] Pull request edited: %s by %s", p.Repository.FullName, titleLink, senderLink) | ||||
| 		attachmentText = SlackTextFormatter(p.PullRequest.Body) | ||||
| 	case api.HookIssueAssigned: | ||||
| 		list, err := MakeAssigneeList(&Issue{ID: p.PullRequest.ID}) | ||||
| 		if err != nil { | ||||
| 			return &SlackPayload{}, err | ||||
| 		} | ||||
| 		text = fmt.Sprintf("[%s] Pull request assigned to %s: %s by %s", p.Repository.FullName, | ||||
| 			SlackLinkFormatter(setting.AppURL+p.PullRequest.Assignee.UserName, p.PullRequest.Assignee.UserName), | ||||
| 			SlackLinkFormatter(setting.AppURL+list, list), | ||||
| 			titleLink, senderLink) | ||||
| 	case api.HookIssueUnassigned: | ||||
| 		text = fmt.Sprintf("[%s] Pull request unassigned: %s by %s", p.Repository.FullName, titleLink, senderLink) | ||||
|  |  | |||
|  | @ -254,6 +254,7 @@ func (f *NewDingtalkHookForm) Validate(ctx *macaron.Context, errs binding.Errors | |||
| type CreateIssueForm struct { | ||||
| 	Title       string `binding:"Required;MaxSize(255)"` | ||||
| 	LabelIDs    string `form:"label_ids"` | ||||
| 	AssigneeIDs string `form:"assignee_ids"` | ||||
| 	Ref         string `form:"ref"` | ||||
| 	MilestoneID int64 | ||||
| 	AssigneeID  int64 | ||||
|  |  | |||
|  | @ -99,8 +99,9 @@ func (r *Repository) CanUseTimetracker(issue *models.Issue, user *models.User) b | |||
| 	// Checking for following:
 | ||||
| 	// 1. Is timetracker enabled
 | ||||
| 	// 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this?
 | ||||
| 	isAssigned, _ := models.IsUserAssignedToIssue(issue, user) | ||||
| 	return r.Repository.IsTimetrackerEnabled() && (!r.Repository.AllowOnlyContributorsToTrackTime() || | ||||
| 		r.IsWriter() || issue.IsPoster(user.ID) || issue.AssigneeID == user.ID) | ||||
| 		r.IsWriter() || issue.IsPoster(user.ID) || isAssigned) | ||||
| } | ||||
| 
 | ||||
| // GetCommitsCount returns cached commit count for current view
 | ||||
|  |  | |||
|  | @ -624,9 +624,9 @@ issues.new.no_milestone = No Milestone | |||
| issues.new.clear_milestone = Clear milestone | ||||
| issues.new.open_milestone = Open Milestones | ||||
| issues.new.closed_milestone = Closed Milestones | ||||
| issues.new.assignee = Assignee | ||||
| issues.new.clear_assignee = Clear assignee | ||||
| issues.new.no_assignee = No assignee | ||||
| issues.new.assignees = Assignees | ||||
| issues.new.clear_assignees = Clear assignees | ||||
| issues.new.no_assignees = Nobody assigned | ||||
| issues.no_ref = No Branch/Tag Specified | ||||
| issues.create = Create Issue | ||||
| issues.new_label = New Label | ||||
|  |  | |||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -179,81 +179,115 @@ function initCommentForm() { | |||
|     initBranchSelector(); | ||||
|     initCommentPreviewTab($('.comment.form')); | ||||
| 
 | ||||
|     // Labels
 | ||||
|     var $list = $('.ui.labels.list'); | ||||
|     var $noSelect = $list.find('.no-select'); | ||||
|     var $labelMenu = $('.select-label .menu'); | ||||
|     var hasLabelUpdateAction = $labelMenu.data('action') == 'update'; | ||||
|     // Listsubmit
 | ||||
|     function initListSubmits(selector, outerSelector) { | ||||
|         var $list = $('.ui.' + outerSelector + '.list'); | ||||
|         var $noSelect = $list.find('.no-select'); | ||||
|         var $listMenu = $('.' + selector + ' .menu'); | ||||
|         var hasLabelUpdateAction = $listMenu.data('action') == 'update'; | ||||
| 
 | ||||
|     $('.select-label').dropdown('setting', 'onHide', function(){ | ||||
|         if (hasLabelUpdateAction) { | ||||
|             location.reload(); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     $labelMenu.find('.item:not(.no-select)').click(function () { | ||||
|         if ($(this).hasClass('checked')) { | ||||
|             $(this).removeClass('checked'); | ||||
|             $(this).find('.octicon').removeClass('octicon-check'); | ||||
|         $('.' + selector).dropdown('setting', 'onHide', function(){ | ||||
|             hasLabelUpdateAction = $listMenu.data('action') == 'update'; // Update the var
 | ||||
|             if (hasLabelUpdateAction) { | ||||
|                 location.reload(); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         $listMenu.find('.item:not(.no-select)').click(function () { | ||||
| 
 | ||||
|             // we don't need the action attribute when updating assignees
 | ||||
|             if (selector == 'select-assignees-modify') { | ||||
| 
 | ||||
|                 // UI magic. We need to do this here, otherwise it would destroy the functionality of
 | ||||
|                 // adding/removing labels
 | ||||
|                 if ($(this).hasClass('checked')) { | ||||
|                     $(this).removeClass('checked'); | ||||
|                     $(this).find('.octicon').removeClass('octicon-check'); | ||||
|                 } else { | ||||
|                     $(this).addClass('checked'); | ||||
|                     $(this).find('.octicon').addClass('octicon-check'); | ||||
|                 } | ||||
| 
 | ||||
|                 updateIssuesMeta( | ||||
|                     $labelMenu.data('update-url'), | ||||
|                     "detach", | ||||
|                     $labelMenu.data('issue-id'), | ||||
|                     $listMenu.data('update-url'), | ||||
|                     "", | ||||
|                     $listMenu.data('issue-id'), | ||||
|                     $(this).data('id') | ||||
|                 ); | ||||
|                 $listMenu.data('action', 'update'); // Update to reload the page when we updated items
 | ||||
|                 return false; | ||||
|             } | ||||
|         } else { | ||||
|             $(this).addClass('checked'); | ||||
|             $(this).find('.octicon').addClass('octicon-check'); | ||||
|             if (hasLabelUpdateAction) { | ||||
|                 updateIssuesMeta( | ||||
|                     $labelMenu.data('update-url'), | ||||
|                     "attach", | ||||
|                     $labelMenu.data('issue-id'), | ||||
|                     $(this).data('id') | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         var labelIds = []; | ||||
|         $(this).parent().find('.item').each(function () { | ||||
|             if ($(this).hasClass('checked')) { | ||||
|                 labelIds.push($(this).data('id')); | ||||
|                 $($(this).data('id-selector')).removeClass('hide'); | ||||
|                 $(this).removeClass('checked'); | ||||
|                 $(this).find('.octicon').removeClass('octicon-check'); | ||||
|                 if (hasLabelUpdateAction) { | ||||
|                     updateIssuesMeta( | ||||
|                         $listMenu.data('update-url'), | ||||
|                         "detach", | ||||
|                         $listMenu.data('issue-id'), | ||||
|                         $(this).data('id') | ||||
|                     ); | ||||
|                 } | ||||
|             } else { | ||||
|                 $($(this).data('id-selector')).addClass('hide'); | ||||
|                 $(this).addClass('checked'); | ||||
|                 $(this).find('.octicon').addClass('octicon-check'); | ||||
|                 if (hasLabelUpdateAction) { | ||||
|                     updateIssuesMeta( | ||||
|                         $listMenu.data('update-url'), | ||||
|                         "attach", | ||||
|                         $listMenu.data('issue-id'), | ||||
|                         $(this).data('id') | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             var listIds = []; | ||||
|             $(this).parent().find('.item').each(function () { | ||||
|                 if ($(this).hasClass('checked')) { | ||||
|                     listIds.push($(this).data('id')); | ||||
|                     $($(this).data('id-selector')).removeClass('hide'); | ||||
|                 } else { | ||||
|                     $($(this).data('id-selector')).addClass('hide'); | ||||
|                 } | ||||
|             }); | ||||
|             if (listIds.length == 0) { | ||||
|                 $noSelect.removeClass('hide'); | ||||
|             } else { | ||||
|                 $noSelect.addClass('hide'); | ||||
|             } | ||||
|             $($(this).parent().data('id')).val(listIds.join(",")); | ||||
|             return false; | ||||
|         }); | ||||
|         if (labelIds.length == 0) { | ||||
|         $listMenu.find('.no-select.item').click(function () { | ||||
|             if (hasLabelUpdateAction || selector == 'select-assignees-modify') { | ||||
|                 updateIssuesMeta( | ||||
|                     $listMenu.data('update-url'), | ||||
|                     "clear", | ||||
|                     $listMenu.data('issue-id'), | ||||
|                     "" | ||||
|                 ); | ||||
|                 $listMenu.data('action', 'update'); // Update to reload the page when we updated items
 | ||||
|             } | ||||
| 
 | ||||
|             $(this).parent().find('.item').each(function () { | ||||
|                 $(this).removeClass('checked'); | ||||
|                 $(this).find('.octicon').removeClass('octicon-check'); | ||||
|             }); | ||||
| 
 | ||||
|             $list.find('.item').each(function () { | ||||
|                 $(this).addClass('hide'); | ||||
|             }); | ||||
|             $noSelect.removeClass('hide'); | ||||
|         } else { | ||||
|             $noSelect.addClass('hide'); | ||||
|         } | ||||
|         $($(this).parent().data('id')).val(labelIds.join(",")); | ||||
|         return false; | ||||
|     }); | ||||
|     $labelMenu.find('.no-select.item').click(function () { | ||||
|         if (hasLabelUpdateAction) { | ||||
|             updateIssuesMeta( | ||||
|                 $labelMenu.data('update-url'), | ||||
|                 "clear", | ||||
|                 $labelMenu.data('issue-id'), | ||||
|                 "" | ||||
|             ); | ||||
|         } | ||||
|             $($(this).parent().data('id')).val(''); | ||||
| 
 | ||||
|         $(this).parent().find('.item').each(function () { | ||||
|             $(this).removeClass('checked'); | ||||
|             $(this).find('.octicon').removeClass('octicon-check'); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|         $list.find('.item').each(function () { | ||||
|             $(this).addClass('hide'); | ||||
|         }); | ||||
|         $noSelect.removeClass('hide'); | ||||
|         $($(this).parent().data('id')).val(''); | ||||
|     }); | ||||
|     // Init labels and assignees
 | ||||
|     initListSubmits('select-label', 'labels'); | ||||
|     initListSubmits('select-assignees', 'assignees'); | ||||
|     initListSubmits('select-assignees-modify', 'assignees'); | ||||
| 
 | ||||
|     function selectItem(select_id, input_id) { | ||||
|         var $menu = $(select_id + ' .menu'); | ||||
|  |  | |||
|  | @ -119,8 +119,11 @@ | |||
|         } | ||||
|         .octicon { | ||||
|             float: left; | ||||
|             margin-left: -5px; | ||||
|             margin-right: -7px; | ||||
|             margin: 5px -7px 0 -5px; | ||||
|             width: 16px; | ||||
|         } | ||||
|         .text{ | ||||
|           margin-left: 0.9em; | ||||
|         } | ||||
|         .menu { | ||||
|             max-height: 300px; | ||||
|  | @ -1745,4 +1748,4 @@ tbody.commit-list { | |||
| 
 | ||||
| #repo-topic { | ||||
|     margin-top: 5px; | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -178,25 +178,22 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) { | |||
| 		DeadlineUnix: deadlineUnix, | ||||
| 	} | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() { | ||||
| 		if len(form.Assignee) > 0 { | ||||
| 			assignee, err := models.GetUserByName(form.Assignee) | ||||
| 			if err != nil { | ||||
| 				if models.IsErrUserNotExist(err) { | ||||
| 					ctx.Error(422, "", fmt.Sprintf("Assignee does not exist: [name: %s]", form.Assignee)) | ||||
| 				} else { | ||||
| 					ctx.Error(500, "GetUserByName", err) | ||||
| 				} | ||||
| 				return | ||||
| 			} | ||||
| 			issue.AssigneeID = assignee.ID | ||||
| 	// Get all assignee IDs
 | ||||
| 	assigneeIDs, err := models.MakeIDsFromAPIAssigneesToAdd(form.Assignee, form.Assignees) | ||||
| 	if err != nil { | ||||
| 		if models.IsErrUserNotExist(err) { | ||||
| 			ctx.Error(422, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) | ||||
| 		} else { | ||||
| 			ctx.Error(500, "AddAssigneeByName", err) | ||||
| 		} | ||||
| 		issue.MilestoneID = form.Milestone | ||||
| 	} else { | ||||
| 		form.Labels = nil | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if err := models.NewIssue(ctx.Repo.Repository, issue, form.Labels, nil); err != nil { | ||||
| 	if err := models.NewIssue(ctx.Repo.Repository, issue, form.Labels, assigneeIDs, nil); err != nil { | ||||
| 		if models.IsErrUserDoesNotHaveAccessToRepo(err) { | ||||
| 			ctx.Error(400, "UserDoesNotHaveAccessToRepo", err) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.Error(500, "NewIssue", err) | ||||
| 		return | ||||
| 	} | ||||
|  | @ -209,7 +206,6 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) { | |||
| 	} | ||||
| 
 | ||||
| 	// Refetch from database to assign some automatic values
 | ||||
| 	var err error | ||||
| 	issue, err = models.GetIssueByID(issue.ID) | ||||
| 	if err != nil { | ||||
| 		ctx.Error(500, "GetIssueByID", err) | ||||
|  | @ -272,6 +268,7 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) { | |||
| 		issue.Content = *form.Body | ||||
| 	} | ||||
| 
 | ||||
| 	// Update the deadline
 | ||||
| 	var deadlineUnix util.TimeStamp | ||||
| 	if form.Deadline != nil && !form.Deadline.IsZero() { | ||||
| 		deadlineUnix = util.TimeStamp(form.Deadline.Unix()) | ||||
|  | @ -282,28 +279,28 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() && form.Assignee != nil && | ||||
| 		(issue.Assignee == nil || issue.Assignee.LowerName != strings.ToLower(*form.Assignee)) { | ||||
| 		if len(*form.Assignee) == 0 { | ||||
| 			issue.AssigneeID = 0 | ||||
| 		} else { | ||||
| 			assignee, err := models.GetUserByName(*form.Assignee) | ||||
| 			if err != nil { | ||||
| 				if models.IsErrUserNotExist(err) { | ||||
| 					ctx.Error(422, "", fmt.Sprintf("assignee does not exist: [name: %s]", *form.Assignee)) | ||||
| 				} else { | ||||
| 					ctx.Error(500, "GetUserByName", err) | ||||
| 				} | ||||
| 				return | ||||
| 			} | ||||
| 			issue.AssigneeID = assignee.ID | ||||
| 	// Add/delete assignees
 | ||||
| 
 | ||||
| 	// Deleting is done the Github way (quote from their api documentation):
 | ||||
| 	// https://developer.github.com/v3/issues/#edit-an-issue
 | ||||
| 	// "assignees" (array): Logins for Users to assign to this issue.
 | ||||
| 	// Pass one or more user logins to replace the set of assignees on this Issue.
 | ||||
| 	// Send an empty array ([]) to clear all assignees from the Issue.
 | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() && (form.Assignees != nil || form.Assignee != nil) { | ||||
| 
 | ||||
| 		oneAssignee := "" | ||||
| 		if form.Assignee != nil { | ||||
| 			oneAssignee = *form.Assignee | ||||
| 		} | ||||
| 
 | ||||
| 		if err = models.UpdateIssueUserByAssignee(issue); err != nil { | ||||
| 			ctx.Error(500, "UpdateIssueUserByAssignee", err) | ||||
| 		err = models.UpdateAPIAssignee(issue, oneAssignee, form.Assignees, ctx.User) | ||||
| 		if err != nil { | ||||
| 			ctx.Error(500, "UpdateAPIAssignee", err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() && form.Milestone != nil && | ||||
| 		issue.MilestoneID != *form.Milestone { | ||||
| 		oldMilestoneID := issue.MilestoneID | ||||
|  |  | |||
|  | @ -211,26 +211,6 @@ func CreatePullRequest(ctx *context.APIContext, form api.CreatePullRequestOption | |||
| 		milestoneID = milestone.ID | ||||
| 	} | ||||
| 
 | ||||
| 	if len(form.Assignee) > 0 { | ||||
| 		assigneeUser, err := models.GetUserByName(form.Assignee) | ||||
| 		if err != nil { | ||||
| 			if models.IsErrUserNotExist(err) { | ||||
| 				ctx.Error(422, "", fmt.Sprintf("assignee does not exist: [name: %s]", form.Assignee)) | ||||
| 			} else { | ||||
| 				ctx.Error(500, "GetUserByName", err) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		assignee, err := repo.GetAssigneeByID(assigneeUser.ID) | ||||
| 		if err != nil { | ||||
| 			ctx.Error(500, "GetAssigneeByID", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		assigneeID = assignee.ID | ||||
| 	} | ||||
| 
 | ||||
| 	patch, err := headGitRepo.GetPatch(prInfo.MergeBase, headBranch) | ||||
| 	if err != nil { | ||||
| 		ctx.Error(500, "GetPatch", err) | ||||
|  | @ -266,7 +246,22 @@ func CreatePullRequest(ctx *context.APIContext, form api.CreatePullRequestOption | |||
| 		Type:         models.PullRequestGitea, | ||||
| 	} | ||||
| 
 | ||||
| 	if err := models.NewPullRequest(repo, prIssue, labelIDs, []string{}, pr, patch); err != nil { | ||||
| 	// Get all assignee IDs
 | ||||
| 	assigneeIDs, err := models.MakeIDsFromAPIAssigneesToAdd(form.Assignee, form.Assignees) | ||||
| 	if err != nil { | ||||
| 		if models.IsErrUserNotExist(err) { | ||||
| 			ctx.Error(422, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) | ||||
| 		} else { | ||||
| 			ctx.Error(500, "AddAssigneeByName", err) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if err := models.NewPullRequest(repo, prIssue, labelIDs, []string{}, pr, patch, assigneeIDs); err != nil { | ||||
| 		if models.IsErrUserDoesNotHaveAccessToRepo(err) { | ||||
| 			ctx.Error(400, "UserDoesNotHaveAccessToRepo", err) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.Error(500, "NewPullRequest", err) | ||||
| 		return | ||||
| 	} else if err := pr.PushToBaseRepo(); err != nil { | ||||
|  | @ -335,6 +330,7 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) { | |||
| 		issue.Content = form.Body | ||||
| 	} | ||||
| 
 | ||||
| 	// Update Deadline
 | ||||
| 	var deadlineUnix util.TimeStamp | ||||
| 	if form.Deadline != nil && !form.Deadline.IsZero() { | ||||
| 		deadlineUnix = util.TimeStamp(form.Deadline.Unix()) | ||||
|  | @ -345,28 +341,27 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() && len(form.Assignee) > 0 && | ||||
| 		(issue.Assignee == nil || issue.Assignee.LowerName != strings.ToLower(form.Assignee)) { | ||||
| 		if len(form.Assignee) == 0 { | ||||
| 			issue.AssigneeID = 0 | ||||
| 		} else { | ||||
| 			assignee, err := models.GetUserByName(form.Assignee) | ||||
| 			if err != nil { | ||||
| 				if models.IsErrUserNotExist(err) { | ||||
| 					ctx.Error(422, "", fmt.Sprintf("assignee does not exist: [name: %s]", form.Assignee)) | ||||
| 				} else { | ||||
| 					ctx.Error(500, "GetUserByName", err) | ||||
| 				} | ||||
| 				return | ||||
| 			} | ||||
| 			issue.AssigneeID = assignee.ID | ||||
| 		} | ||||
| 	// Add/delete assignees
 | ||||
| 
 | ||||
| 		if err = models.UpdateIssueUserByAssignee(issue); err != nil { | ||||
| 			ctx.Error(500, "UpdateIssueUserByAssignee", err) | ||||
| 	// Deleting is done the Github way (quote from their api documentation):
 | ||||
| 	// https://developer.github.com/v3/issues/#edit-an-issue
 | ||||
| 	// "assignees" (array): Logins for Users to assign to this issue.
 | ||||
| 	// Pass one or more user logins to replace the set of assignees on this Issue.
 | ||||
| 	// Send an empty array ([]) to clear all assignees from the Issue.
 | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() && (form.Assignees != nil || len(form.Assignee) > 0) { | ||||
| 
 | ||||
| 		err = models.UpdateAPIAssignee(issue, form.Assignee, form.Assignees, ctx.User) | ||||
| 		if err != nil { | ||||
| 			if models.IsErrUserNotExist(err) { | ||||
| 				ctx.Error(422, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) | ||||
| 			} else { | ||||
| 				ctx.Error(500, "UpdateAPIAssignee", err) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if ctx.Repo.IsWriter() && form.Milestone != 0 && | ||||
| 		issue.MilestoneID != form.Milestone { | ||||
| 		oldMilestoneID := issue.MilestoneID | ||||
|  |  | |||
|  | @ -364,7 +364,7 @@ func NewIssue(ctx *context.Context) { | |||
| } | ||||
| 
 | ||||
| // ValidateRepoMetas check and returns repository's meta informations
 | ||||
| func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64, int64, int64) { | ||||
| func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64, []int64, int64) { | ||||
| 	var ( | ||||
| 		repo = ctx.Repo.Repository | ||||
| 		err  error | ||||
|  | @ -372,11 +372,11 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64 | |||
| 
 | ||||
| 	labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository) | ||||
| 	if ctx.Written() { | ||||
| 		return nil, 0, 0 | ||||
| 		return nil, nil, 0 | ||||
| 	} | ||||
| 
 | ||||
| 	if !ctx.Repo.IsWriter() { | ||||
| 		return nil, 0, 0 | ||||
| 		return nil, nil, 0 | ||||
| 	} | ||||
| 
 | ||||
| 	var labelIDs []int64 | ||||
|  | @ -385,7 +385,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64 | |||
| 	if len(form.LabelIDs) > 0 { | ||||
| 		labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) | ||||
| 		if err != nil { | ||||
| 			return nil, 0, 0 | ||||
| 			return nil, nil, 0 | ||||
| 		} | ||||
| 		labelIDMark := base.Int64sToMap(labelIDs) | ||||
| 
 | ||||
|  | @ -407,23 +407,35 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64 | |||
| 		ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetMilestoneByID", err) | ||||
| 			return nil, 0, 0 | ||||
| 			return nil, nil, 0 | ||||
| 		} | ||||
| 		ctx.Data["milestone_id"] = milestoneID | ||||
| 	} | ||||
| 
 | ||||
| 	// Check assignee.
 | ||||
| 	assigneeID := form.AssigneeID | ||||
| 	if assigneeID > 0 { | ||||
| 		ctx.Data["Assignee"], err = repo.GetAssigneeByID(assigneeID) | ||||
| 	// Check assignees
 | ||||
| 	var assigneeIDs []int64 | ||||
| 	if len(form.AssigneeIDs) > 0 { | ||||
| 		assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetAssigneeByID", err) | ||||
| 			return nil, 0, 0 | ||||
| 			return nil, nil, 0 | ||||
| 		} | ||||
| 
 | ||||
| 		// Check if the passed assignees actually exists and has write access to the repo
 | ||||
| 		for _, aID := range assigneeIDs { | ||||
| 			_, err = repo.GetUserIfHasWriteAccess(aID) | ||||
| 			if err != nil { | ||||
| 				ctx.ServerError("GetUserIfHasWriteAccess", err) | ||||
| 				return nil, nil, 0 | ||||
| 			} | ||||
| 		} | ||||
| 		ctx.Data["assignee_id"] = assigneeID | ||||
| 	} | ||||
| 
 | ||||
| 	return labelIDs, milestoneID, assigneeID | ||||
| 	// Keep the old assignee id thingy for compatibility reasons
 | ||||
| 	if form.AssigneeID > 0 { | ||||
| 		assigneeIDs = append(assigneeIDs, form.AssigneeID) | ||||
| 	} | ||||
| 
 | ||||
| 	return labelIDs, assigneeIDs, milestoneID | ||||
| } | ||||
| 
 | ||||
| // NewIssuePost response for creating new issue
 | ||||
|  | @ -440,7 +452,7 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { | |||
| 		attachments []string | ||||
| 	) | ||||
| 
 | ||||
| 	labelIDs, milestoneID, assigneeID := ValidateRepoMetas(ctx, form) | ||||
| 	labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | @ -460,11 +472,14 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { | |||
| 		PosterID:    ctx.User.ID, | ||||
| 		Poster:      ctx.User, | ||||
| 		MilestoneID: milestoneID, | ||||
| 		AssigneeID:  assigneeID, | ||||
| 		Content:     form.Content, | ||||
| 		Ref:         form.Ref, | ||||
| 	} | ||||
| 	if err := models.NewIssue(repo, issue, labelIDs, attachments); err != nil { | ||||
| 	if err := models.NewIssue(repo, issue, labelIDs, assigneeIDs, attachments); err != nil { | ||||
| 		if models.IsErrUserDoesNotHaveAccessToRepo(err) { | ||||
| 			ctx.Error(400, "UserDoesNotHaveAccessToRepo", err.Error()) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.ServerError("NewIssue", err) | ||||
| 		return | ||||
| 	} | ||||
|  | @ -702,8 +717,8 @@ func ViewIssue(ctx *context.Context) { | |||
| 				comment.Milestone = ghostMilestone | ||||
| 			} | ||||
| 		} else if comment.Type == models.CommentTypeAssignees { | ||||
| 			if err = comment.LoadAssignees(); err != nil { | ||||
| 				ctx.ServerError("LoadAssignees", err) | ||||
| 			if err = comment.LoadAssigneeUser(); err != nil { | ||||
| 				ctx.ServerError("LoadAssigneeUser", err) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
|  | @ -912,13 +927,20 @@ func UpdateIssueAssignee(ctx *context.Context) { | |||
| 	} | ||||
| 
 | ||||
| 	assigneeID := ctx.QueryInt64("id") | ||||
| 	action := ctx.Query("action") | ||||
| 
 | ||||
| 	for _, issue := range issues { | ||||
| 		if issue.AssigneeID == assigneeID { | ||||
| 			continue | ||||
| 		} | ||||
| 		if err := issue.ChangeAssignee(ctx.User, assigneeID); err != nil { | ||||
| 			ctx.ServerError("ChangeAssignee", err) | ||||
| 			return | ||||
| 		switch action { | ||||
| 		case "clear": | ||||
| 			if err := models.DeleteNotPassedAssignee(issue, ctx.User, []*models.User{}); err != nil { | ||||
| 				ctx.ServerError("ClearAssignees", err) | ||||
| 				return | ||||
| 			} | ||||
| 		default: | ||||
| 			if err := issue.ChangeAssignee(ctx.User, assigneeID); err != nil { | ||||
| 				ctx.ServerError("ChangeAssignee", err) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	ctx.JSON(200, map[string]interface{}{ | ||||
|  |  | |||
|  | @ -775,7 +775,7 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	labelIDs, milestoneID, assigneeID := ValidateRepoMetas(ctx, form) | ||||
| 	labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | @ -811,7 +811,6 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) | |||
| 		PosterID:    ctx.User.ID, | ||||
| 		Poster:      ctx.User, | ||||
| 		MilestoneID: milestoneID, | ||||
| 		AssigneeID:  assigneeID, | ||||
| 		IsPull:      true, | ||||
| 		Content:     form.Content, | ||||
| 	} | ||||
|  | @ -828,7 +827,12 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) | |||
| 	} | ||||
| 	// FIXME: check error in the case two people send pull request at almost same time, give nice error prompt
 | ||||
| 	// instead of 500.
 | ||||
| 	if err := models.NewPullRequest(repo, pullIssue, labelIDs, attachments, pullRequest, patch); err != nil { | ||||
| 
 | ||||
| 	if err := models.NewPullRequest(repo, pullIssue, labelIDs, attachments, pullRequest, patch, assigneeIDs); err != nil { | ||||
| 		if models.IsErrUserDoesNotHaveAccessToRepo(err) { | ||||
| 			ctx.Error(400, "UserDoesNotHaveAccessToRepo", err.Error()) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.ServerError("NewPullRequest", err) | ||||
| 		return | ||||
| 	} else if err := pullRequest.PushToBaseRepo(); err != nil { | ||||
|  |  | |||
|  | @ -156,7 +156,7 @@ | |||
| 						</div> | ||||
| 					</div> | ||||
| 
 | ||||
| 					<!-- Assignee --> | ||||
| 					<!-- Assignees --> | ||||
| 					<div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item"> | ||||
| 						<span class="text"> | ||||
| 							{{.i18n.Tr "repo.issues.action_assignee"}} | ||||
|  | @ -220,9 +220,9 @@ | |||
| 							<span class="octicon octicon-calendar"></span> | ||||
| 							<span{{if .IsOverdue}} class="overdue"{{end}}>{{.DeadlineUnix.FormatShort}}</span> | ||||
| 						{{end}} | ||||
| 						{{if .Assignee}} | ||||
| 							<a class="ui right assignee poping up" href="{{.Assignee.HomeLink}}" data-content="{{.Assignee.Name}}" data-variation="inverted" data-position="left center"> | ||||
| 								<img class="ui avatar image" src="{{.Assignee.RelAvatarLink}}"> | ||||
| 						{{range .Assignees}} | ||||
| 							<a class="ui right assignee poping up" href="{{.HomeLink}}" data-content="{{.Name}}" data-variation="inverted" data-position="left center"> | ||||
| 								<img class="ui avatar image" src="{{.RelAvatarLink}}"> | ||||
| 							</a> | ||||
| 						{{end}} | ||||
| 					</p> | ||||
|  |  | |||
|  | @ -97,27 +97,56 @@ | |||
| 
 | ||||
| 			<div class="ui divider"></div> | ||||
| 
 | ||||
| 			<input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> | ||||
| 				<input id="assignee_ids" name="assignee_ids" type="hidden" value="{{.assignee_ids}}"> | ||||
| 				<div class="ui {{if not .Assignees}}disabled{{end}} floating jump select-assignees dropdown"> | ||||
| 					<span class="text"> | ||||
| 						<strong>{{.i18n.Tr "repo.issues.new.assignees"}}</strong> | ||||
| 						<span class="octicon octicon-gear"></span> | ||||
| 					</span> | ||||
| 					<div class="filter menu" data-id="#assignee_ids"> | ||||
| 						<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignees"}}</div> | ||||
| 						{{range .Assignees}} | ||||
| 							<a class="item" href="#" data-id="{{.ID}}" data-id-selector="#assignee_{{.ID}}"> | ||||
| 								<span class="octicon"></span> | ||||
| 								<span class="text"> | ||||
| 									<img class="ui avatar image" src="{{.RelAvatarLink}}"> {{.Name}} | ||||
| 								</span> | ||||
| 							</a> | ||||
| 						{{end}} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="ui assignees list"> | ||||
| 					<span class="no-select item {{if .HasSelectedLabel}}hide{{end}}"> | ||||
| 						{{.i18n.Tr "repo.issues.new.no_assignees"}} | ||||
| 					</span> | ||||
| 					{{range .Assignees}} | ||||
| 						<a style="padding: 5px;color:rgba(0, 0, 0, 0.87);" class="hide item" id="assignee_{{.ID}}" href="{{$.RepoLink}}/issues?assignee={{.ID}}"> | ||||
| 							<img class="ui avatar image" src="{{.RelAvatarLink}}" style="vertical-align: middle;"> {{.Name}} | ||||
| 						</a> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 
 | ||||
| 			<!-- input id="assignee_ids" name="assignee_ids" type="hidden" value="{{.assignee_id}}"> | ||||
| 			<div class="ui {{if not .Assignees}}disabled{{end}} floating jump select-assignee dropdown"> | ||||
| 				<span class="text"> | ||||
| 					<strong>{{.i18n.Tr "repo.issues.new.assignee"}}</strong> | ||||
| 					<strong>{{.i18n.Tr "repo.issues.new.assignees"}}</strong> | ||||
| 					<span class="octicon octicon-gear"></span> | ||||
| 				</span> | ||||
| 				<div class="menu"> | ||||
| 					<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignee"}}</div> | ||||
| 				<div class="filter menu"> | ||||
| 					<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignees"}}</div> | ||||
| 					{{range .Assignees}} | ||||
| 						<div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?assignee={{.ID}}" data-avatar="{{.RelAvatarLink}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</div> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="ui select-assignee list"> | ||||
| 				<span class="no-select item {{if .Assignee}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_assignee"}}</span> | ||||
| 				<span class="no-select item {{if .Assignee}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_assignees"}}</span> | ||||
| 				<div class="selected"> | ||||
| 					{{if .Assignee}} | ||||
| 						<a class="item" href="{{.RepoLink}}/issues?assignee={{.Assignee.ID}}"><img class="ui avatar image" src="{{.Assignee.RelAvatarLink}}"> {{.Assignee.Name}}</a> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			</div>--> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </form> | ||||
|  |  | |||
|  | @ -118,15 +118,29 @@ | |||
| 	{{else if eq .Type 9}} | ||||
| 		<div class="event"> | ||||
| 			<span class="octicon octicon-primitive-dot"></span> | ||||
| 			{{if gt .AssigneeID 0}}{{if eq .Poster.ID .AssigneeID}}<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | ||||
| 				<img src="{{.Poster.RelAvatarLink}}"> | ||||
| 			</a> <span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.self_assign_at" $createdStr | Safe}} </span> | ||||
| 			{{else}}<a class="ui avatar image" href="{{.Assignee.HomeLink}}"> | ||||
| 				<img src="{{.Assignee.RelAvatarLink}}"> | ||||
| 			</a><span class="text grey"><a href="{{.Assignee.HomeLink}}">{{.Assignee.Name}}</a> {{$.i18n.Tr "repo.issues.add_assignee_at" .Poster.Name $createdStr | Safe}} </span>{{end}}{{else if gt .OldAssigneeID 0}} | ||||
| 			<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | ||||
| 				<img src="{{.Poster.RelAvatarLink}}"> | ||||
| 			</a> <span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.remove_assignee_at" $createdStr | Safe}} </span>{{end}} | ||||
| 			{{if gt .AssigneeID 0}} | ||||
| 				{{if .RemovedAssignee}} | ||||
| 					<a class="ui avatar image" href="{{.Assignee.HomeLink}}"> | ||||
| 						<img src="{{.Assignee.RelAvatarLink}}"> | ||||
| 					</a> | ||||
| 					<span class="text grey"> | ||||
| 						<a href="{{.Assignee.HomeLink}}">{{.Assignee.Name}}</a> | ||||
| 						{{$.i18n.Tr "repo.issues.remove_assignee_at" $createdStr | Safe}} | ||||
| 					</span> | ||||
| 				{{else}} | ||||
| 					<a class="ui avatar image" href="{{.Assignee.HomeLink}}"> | ||||
| 						<img src="{{.Assignee.RelAvatarLink}}"> | ||||
| 					</a> | ||||
| 					<span class="text grey"> | ||||
| 						<a href="{{.Assignee.HomeLink}}">{{.Assignee.Name}}</a> | ||||
| 						{{if eq .Poster.ID .AssigneeID}} | ||||
| 							{{$.i18n.Tr "repo.issues.self_assign_at" $createdStr | Safe}} | ||||
| 						{{else}} | ||||
| 							{{$.i18n.Tr "repo.issues.add_assignee_at" .Poster.Name $createdStr | Safe}} | ||||
| 						{{end}} | ||||
| 					</span> | ||||
| 				{{end}} | ||||
| 			{{end}} | ||||
| 		</div> | ||||
| 	{{else if eq .Type 10}} | ||||
| 		<div class="event"> | ||||
|  |  | |||
|  | @ -68,23 +68,40 @@ | |||
| 		<div class="ui divider"></div> | ||||
| 
 | ||||
| 		<input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> | ||||
| 		<div class="ui {{if not .IsRepositoryWriter}}disabled{{end}} floating jump select-assignee dropdown"> | ||||
| 		<div class="ui {{if not .IsRepositoryWriter}}disabled{{end}} floating jump select-assignees-modify dropdown"> | ||||
| 			<span class="text"> | ||||
| 				<strong>{{.i18n.Tr "repo.issues.new.assignee"}}</strong> | ||||
| 				<strong>{{.i18n.Tr "repo.issues.new.assignees"}}</strong> | ||||
| 				<span class="octicon octicon-gear"></span> | ||||
| 			</span> | ||||
| 			<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee"> | ||||
| 				<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignee"}}</div> | ||||
| 			<div class="filter menu" data-action="" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee"> | ||||
| 				<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignees"}}</div> | ||||
| 				{{range .Assignees}} | ||||
| 					<div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?assignee={{.ID}}" data-avatar="{{.RelAvatarLink}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</div> | ||||
| 
 | ||||
| 					{{$AssigneeID := .ID}} | ||||
| 					<a class="item{{range $.Issue.Assignees}} | ||||
| 						{{if eq .ID $AssigneeID}} | ||||
| 						 checked | ||||
| 						{{end}} | ||||
| 					{{end}}" href="#" data-id="{{.ID}}" data-id-selector="#assignee_{{.ID}}"> | ||||
| 						<span class="octicon{{range $.Issue.Assignees}} | ||||
| 						{{if eq .ID $AssigneeID}} | ||||
| 						 octicon-check | ||||
| 						{{end}} | ||||
| 					{{end}}"></span> | ||||
| 						<span class="text"> | ||||
| 							<img class="ui avatar image" src="{{.RelAvatarLink}}"> {{.Name}} | ||||
| 						</span> | ||||
| 					</a> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div class="ui select-assignee list"> | ||||
| 			<span class="no-select item {{if .Issue.Assignee}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_assignee"}}</span> | ||||
| 		<div class="ui assignees list"> | ||||
| 			<span class="no-select item {{if .Issue.Assignees}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_assignees"}}</span> | ||||
| 			<div class="selected"> | ||||
| 				{{if .Issue.Assignee}} | ||||
| 					<a class="item" href="{{$.RepoLink}}/issues?assignee={{.Issue.Assignee.ID}}"><img class="ui avatar image" src="{{.Issue.Assignee.RelAvatarLink}}"> {{.Issue.Assignee.Name}}</a> | ||||
| 				{{range .Issue.Assignees}} | ||||
| 					<div class="item" style="margin-bottom: 10px;"> | ||||
| 						<a href="{{$.RepoLink}}/issues?assignee={{.ID}}"><img class="ui avatar image" src="{{.RelAvatarLink}}"> {{.Name}}</a> | ||||
| 					</div> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 		</div> | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue