Multiple assignees (#3705)

This commit is contained in:
kolaente 2018-05-09 18:29:04 +02:00 committed by Lauris BH
parent 238a997ec0
commit 95f2e2b57b
36 changed files with 1012 additions and 451 deletions

View file

@ -733,6 +733,22 @@ func (err ErrRepoFileAlreadyExist) Error() string {
return fmt.Sprintf("repository file already exists [file_name: %s]", err.FileName) 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)
}
// __________ .__ // __________ .__
// \______ \____________ ____ ____ | |__ // \______ \____________ ____ ____ | |__
// | | _/\_ __ \__ \ / \_/ ___\| | \ // | | _/\_ __ \__ \ / \_/ ___\| | \

View file

@ -3,7 +3,6 @@
repo_id: 1 repo_id: 1
index: 1 index: 1
poster_id: 1 poster_id: 1
assignee_id: 1
name: issue1 name: issue1
content: content for the first issue content: content for the first issue
is_closed: false is_closed: false
@ -67,7 +66,6 @@
repo_id: 3 repo_id: 3
index: 1 index: 1
poster_id: 1 poster_id: 1
assignee_id: 1
name: issue6 name: issue6
content: content6 content: content6
is_closed: false is_closed: false

View file

@ -0,0 +1,8 @@
-
id: 1
assignee_id: 1
issue_id: 1
-
id: 2
assignee_id: 1
issue_id: 6

View file

@ -3,7 +3,6 @@
uid: 1 uid: 1
issue_id: 1 issue_id: 1
is_read: true is_read: true
is_assigned: true
is_mentioned: false is_mentioned: false
- -
@ -11,7 +10,6 @@
uid: 2 uid: 2
issue_id: 1 issue_id: 1
is_read: true is_read: true
is_assigned: false
is_mentioned: false is_mentioned: false
- -
@ -19,5 +17,4 @@
uid: 4 uid: 4
issue_id: 1 issue_id: 1
is_read: false is_read: false
is_assigned: false
is_mentioned: false is_mentioned: false

View file

@ -37,7 +37,7 @@ type Issue struct {
MilestoneID int64 `xorm:"INDEX"` MilestoneID int64 `xorm:"INDEX"`
Milestone *Milestone `xorm:"-"` Milestone *Milestone `xorm:"-"`
Priority int Priority int
AssigneeID int64 `xorm:"INDEX"` AssigneeID int64 `xorm:"-"`
Assignee *User `xorm:"-"` Assignee *User `xorm:"-"`
IsClosed bool `xorm:"INDEX"` IsClosed bool `xorm:"INDEX"`
IsRead bool `xorm:"-"` IsRead bool `xorm:"-"`
@ -56,6 +56,7 @@ type Issue struct {
Comments []*Comment `xorm:"-"` Comments []*Comment `xorm:"-"`
Reactions ReactionList `xorm:"-"` Reactions ReactionList `xorm:"-"`
TotalTrackedTime int64 `xorm:"-"` TotalTrackedTime int64 `xorm:"-"`
Assignees []*User `xorm:"-"`
} }
var ( var (
@ -140,22 +141,6 @@ func (issue *Issue) loadPoster(e Engine) (err error) {
return 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) { func (issue *Issue) loadPullRequest(e Engine) (err error) {
if issue.IsPull && issue.PullRequest == nil { if issue.IsPull && issue.PullRequest == nil {
issue.PullRequest, err = getPullRequestByIssueID(e, issue.ID) 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 return
} }
@ -343,8 +328,11 @@ func (issue *Issue) APIFormat() *api.Issue {
if issue.Milestone != nil { if issue.Milestone != nil {
apiIssue.Milestone = issue.Milestone.APIFormat() apiIssue.Milestone = issue.Milestone.APIFormat()
} }
if issue.Assignee != nil { if len(issue.Assignees) > 0 {
apiIssue.Assignee = issue.Assignee.APIFormat() 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 { if issue.IsPull {
apiIssue.PullRequest = &api.PullRequestMeta{ apiIssue.PullRequest = &api.PullRequestMeta{
@ -605,19 +593,6 @@ func (issue *Issue) ReplaceLabels(labels []*Label, doer *User) (err error) {
return sess.Commit() 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. // ReadBy sets issue to be read by given user.
func (issue *Issue) ReadBy(userID int64) error { func (issue *Issue) ReadBy(userID int64) error {
if err := UpdateIssueUserByRead(userID, issue.ID); err != nil { if err := UpdateIssueUserByRead(userID, issue.ID); err != nil {
@ -823,55 +798,6 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
return nil 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 // GetTasks returns the amount of tasks in the issues content
func (issue *Issue) GetTasks() int { func (issue *Issue) GetTasks() int {
return len(issueTasksPat.FindAllStringIndex(issue.Content, -1)) return len(issueTasksPat.FindAllStringIndex(issue.Content, -1))
@ -887,6 +813,7 @@ type NewIssueOptions struct {
Repo *Repository Repo *Repository
Issue *Issue Issue *Issue
LabelIDs []int64 LabelIDs []int64
AssigneeIDs []int64
Attachments []string // In UUID format. Attachments []string // In UUID format.
IsPull bool IsPull bool
} }
@ -909,14 +836,32 @@ func newIssue(e *xorm.Session, doer *User, opts NewIssueOptions) (err error) {
} }
} }
if assigneeID := opts.Issue.AssigneeID; assigneeID > 0 { // Keep the old assignee id thingy for compatibility reasons
valid, err := hasAccess(e, assigneeID, opts.Repo, AccessModeWrite) if opts.Issue.AssigneeID > 0 {
if err != nil { isAdded := false
return fmt.Errorf("hasAccess [user_id: %d, repo_id: %d]: %v", assigneeID, opts.Repo.ID, err) // 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 if !isAdded {
opts.Issue.Assignee = nil 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 { // Insert the assignees
if err = opts.Issue.loadRepo(e); err != nil { for _, assigneeID := range opts.AssigneeIDs {
return err err = opts.Issue.changeAssignee(e, doer, assigneeID)
} if err != nil {
if _, err = createAssigneeComment(e, doer, opts.Issue.Repo, opts.Issue, -1, opts.Issue.AssigneeID); err != nil {
return err 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. // 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() sess := x.NewSession()
defer sess.Close() defer sess.Close()
if err = sess.Begin(); err != nil { if err = sess.Begin(); err != nil {
@ -1007,7 +951,11 @@ func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string)
Issue: issue, Issue: issue,
LabelIDs: labelIDs, LabelIDs: labelIDs,
Attachments: uuids, Attachments: uuids,
AssigneeIDs: assigneeIDs,
}); err != nil { }); err != nil {
if IsErrUserDoesNotHaveAccessToRepo(err) {
return err
}
return fmt.Errorf("newIssue: %v", err) return fmt.Errorf("newIssue: %v", err)
} }
@ -1150,7 +1098,8 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) error {
} }
if opts.AssigneeID > 0 { 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 { if opts.PosterID > 0 {
@ -1372,7 +1321,8 @@ func GetIssueStats(opts *IssueStatsOptions) (*IssueStats, error) {
} }
if opts.AssigneeID > 0 { 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 { if opts.PosterID > 0 {
@ -1438,13 +1388,15 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
} }
case FilterModeAssign: case FilterModeAssign:
stats.OpenCount, err = x.Where(cond).And("is_closed = ?", false). 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)) Count(new(Issue))
if err != nil { if err != nil {
return nil, err return nil, err
} }
stats.ClosedCount, err = x.Where(cond).And("is_closed = ?", true). 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)) Count(new(Issue))
if err != nil { if err != nil {
return nil, err return nil, err
@ -1466,7 +1418,8 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed}) cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed})
stats.AssignCount, err = x.Where(cond). 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)) Count(new(Issue))
if err != nil { if err != nil {
return nil, err return nil, err
@ -1505,8 +1458,10 @@ func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen
switch filterMode { switch filterMode {
case FilterModeAssign: case FilterModeAssign:
openCountSession.And("assignee_id = ?", uid) openCountSession.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
closedCountSession.And("assignee_id = ?", uid) 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: case FilterModeCreate:
openCountSession.And("poster_id = ?", uid) openCountSession.And("poster_id = ?", uid)
closedCountSession.And("poster_id = ?", uid) closedCountSession.And("poster_id = ?", uid)

263
models/issue_assignees.go Normal file
View 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
}

View 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))
}

View file

@ -81,23 +81,22 @@ const (
// Comment represents a comment in commit and issue page. // Comment represents a comment in commit and issue page.
type Comment struct { type Comment struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
Type CommentType Type CommentType
PosterID int64 `xorm:"INDEX"` PosterID int64 `xorm:"INDEX"`
Poster *User `xorm:"-"` Poster *User `xorm:"-"`
IssueID int64 `xorm:"INDEX"` IssueID int64 `xorm:"INDEX"`
LabelID int64 LabelID int64
Label *Label `xorm:"-"` Label *Label `xorm:"-"`
OldMilestoneID int64 OldMilestoneID int64
MilestoneID int64 MilestoneID int64
OldMilestone *Milestone `xorm:"-"` OldMilestone *Milestone `xorm:"-"`
Milestone *Milestone `xorm:"-"` Milestone *Milestone `xorm:"-"`
OldAssigneeID int64 AssigneeID int64
AssigneeID int64 RemovedAssignee bool
Assignee *User `xorm:"-"` Assignee *User `xorm:"-"`
OldAssignee *User `xorm:"-"` OldTitle string
OldTitle string NewTitle string
NewTitle string
CommitID int64 CommitID int64
Line int64 Line int64
@ -247,18 +246,9 @@ func (c *Comment) LoadMilestone() error {
return nil return nil
} }
// LoadAssignees if comment.Type is CommentTypeAssignees, then load assignees // LoadAssigneeUser if comment.Type is CommentTypeAssignees, then load assignees
func (c *Comment) LoadAssignees() error { func (c *Comment) LoadAssigneeUser() error {
var err 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 { if c.AssigneeID > 0 {
c.Assignee, err = getUserByID(x, c.AssigneeID) c.Assignee, err = getUserByID(x, c.AssigneeID)
@ -324,21 +314,21 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err
LabelID = opts.Label.ID LabelID = opts.Label.ID
} }
comment := &Comment{ comment := &Comment{
Type: opts.Type, Type: opts.Type,
PosterID: opts.Doer.ID, PosterID: opts.Doer.ID,
Poster: opts.Doer, Poster: opts.Doer,
IssueID: opts.Issue.ID, IssueID: opts.Issue.ID,
LabelID: LabelID, LabelID: LabelID,
OldMilestoneID: opts.OldMilestoneID, OldMilestoneID: opts.OldMilestoneID,
MilestoneID: opts.MilestoneID, MilestoneID: opts.MilestoneID,
OldAssigneeID: opts.OldAssigneeID, RemovedAssignee: opts.RemovedAssignee,
AssigneeID: opts.AssigneeID, AssigneeID: opts.AssigneeID,
CommitID: opts.CommitID, CommitID: opts.CommitID,
CommitSHA: opts.CommitSHA, CommitSHA: opts.CommitSHA,
Line: opts.LineNum, Line: opts.LineNum,
Content: opts.Content, Content: opts.Content,
OldTitle: opts.OldTitle, OldTitle: opts.OldTitle,
NewTitle: opts.NewTitle, NewTitle: opts.NewTitle,
} }
if _, err = e.Insert(comment); err != nil { if _, err = e.Insert(comment); err != nil {
return nil, err 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{ return createComment(e, &CreateCommentOptions{
Type: CommentTypeAssignees, Type: CommentTypeAssignees,
Doer: doer, Doer: doer,
Repo: repo, Repo: repo,
Issue: issue, Issue: issue,
OldAssigneeID: oldAssigneeID, RemovedAssignee: removedAssignee,
AssigneeID: assigneeID, AssigneeID: assigneeID,
}) })
} }
@ -548,17 +538,17 @@ type CreateCommentOptions struct {
Issue *Issue Issue *Issue
Label *Label Label *Label
OldMilestoneID int64 OldMilestoneID int64
MilestoneID int64 MilestoneID int64
OldAssigneeID int64 AssigneeID int64
AssigneeID int64 RemovedAssignee bool
OldTitle string OldTitle string
NewTitle string NewTitle string
CommitID int64 CommitID int64
CommitSHA string CommitSHA string
LineNum int64 LineNum int64
Content string Content string
Attachments []string // UUIDs of attachments Attachments []string // UUIDs of attachments
} }
// CreateComment creates comment of issue or commit. // CreateComment creates comment of issue or commit.

View file

@ -154,38 +154,38 @@ func (issues IssueList) loadMilestones(e Engine) error {
return nil 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 { func (issues IssueList) loadAssignees(e Engine) error {
assigneeIDs := issues.getAssigneeIDs() if len(issues) == 0 {
if len(assigneeIDs) == 0 {
return nil return nil
} }
assigneeMaps := make(map[int64]*User, len(assigneeIDs)) type AssigneeIssue struct {
err := e. IssueAssignee *IssueAssignees `xorm:"extends"`
In("id", assigneeIDs). Assignee *User `xorm:"extends"`
Find(&assigneeMaps) }
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 { if err != nil {
return err 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 { for _, issue := range issues {
if issue.AssigneeID <= 0 { issue.Assignees = assignees[issue.ID]
continue
}
var ok bool
if issue.Assignee, ok = assigneeMaps[issue.AssigneeID]; !ok {
issue.Assignee = NewGhostUser()
}
} }
return nil return nil
} }

View file

@ -46,9 +46,16 @@ func mailIssueCommentToParticipants(e Engine, issue *Issue, doer *User, content
participants = append(participants, issue.Poster) participants = append(participants, issue.Poster)
} }
// Assignee must receive any communications // Assignees must receive any communications
if issue.Assignee != nil && issue.AssigneeID > 0 && issue.AssigneeID != doer.ID { assignees, err := GetAssigneesByIssue(issue)
participants = append(participants, issue.Assignee) 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. tos := make([]string, 0, len(watchers)) // List of email addresses.

View file

@ -6,6 +6,8 @@ package models
import ( import (
"fmt" "fmt"
"github.com/go-xorm/xorm"
) )
// IssueUser represents an issue-user relation. // IssueUser represents an issue-user relation.
@ -14,7 +16,6 @@ type IssueUser struct {
UID int64 `xorm:"INDEX"` // User ID. UID int64 `xorm:"INDEX"` // User ID.
IssueID int64 IssueID int64
IsRead bool IsRead bool
IsAssigned bool
IsMentioned bool IsMentioned bool
} }
@ -32,9 +33,8 @@ func newIssueUsers(e Engine, repo *Repository, issue *Issue) error {
issueUsers := make([]*IssueUser, 0, len(assignees)+1) issueUsers := make([]*IssueUser, 0, len(assignees)+1)
for _, assignee := range assignees { for _, assignee := range assignees {
issueUsers = append(issueUsers, &IssueUser{ issueUsers = append(issueUsers, &IssueUser{
IssueID: issue.ID, IssueID: issue.ID,
UID: assignee.ID, UID: assignee.ID,
IsAssigned: assignee.ID == issue.AssigneeID,
}) })
isPosterAssignee = isPosterAssignee || assignee.ID == issue.PosterID isPosterAssignee = isPosterAssignee || assignee.ID == issue.PosterID
} }
@ -51,34 +51,38 @@ func newIssueUsers(e Engine, repo *Repository, issue *Issue) error {
return nil return nil
} }
func updateIssueUserByAssignee(e Engine, issue *Issue) (err error) { func updateIssueAssignee(e *xorm.Session, issue *Issue, assigneeID int64) (removed bool, err error) {
if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE issue_id = ?", false, issue.ID); err != nil {
return err // Check if the user exists
_, err = GetUserByID(assigneeID)
if err != nil {
return false, err
} }
// Assignee ID equals to 0 means clear assignee. // Check if the submitted user is already assigne, if yes delete him otherwise add him
if issue.AssigneeID > 0 { var toBeDeleted bool
if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE uid = ? AND issue_id = ?", true, issue.AssigneeID, issue.ID); err != nil { for _, assignee := range issue.Assignees {
return err 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. if toBeDeleted {
func UpdateIssueUserByAssignee(issue *Issue) (err error) { _, err = e.Delete(assigneeIn)
sess := x.NewSession() if err != nil {
defer sess.Close() return toBeDeleted, err
if err = sess.Begin(); err != nil { }
return err } else {
_, err = e.Insert(assigneeIn)
if err != nil {
return toBeDeleted, err
}
} }
if err = updateIssueUserByAssignee(sess, issue); err != nil { return toBeDeleted, nil
return err
}
return sess.Commit()
} }
// UpdateIssueUserByRead updates issue-user relation for reading. // UpdateIssueUserByRead updates issue-user relation for reading.

View file

@ -32,23 +32,6 @@ func Test_newIssueUsers(t *testing.T) {
AssertExistsAndLoadBean(t, &IssueUser{IssueID: newIssue.ID, UID: repo.OwnerID}) 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) { func TestUpdateIssueUserByRead(t *testing.T) {
assert.NoError(t, PrepareTestDatabase()) assert.NoError(t, PrepareTestDatabase())
issue := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue) issue := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue)

View file

@ -180,6 +180,8 @@ var migrations = []Migration{
NewMigration("add last used passcode column for TOTP", addLastUsedPasscodeTOTP), NewMigration("add last used passcode column for TOTP", addLastUsedPasscodeTOTP),
// v63 -> v64 // v63 -> v64
NewMigration("add language column for user setting", addLanguageSetting), NewMigration("add language column for user setting", addLanguageSetting),
// v64 -> v65
NewMigration("add multiple assignees", addMultipleAssignees),
} }
// Migrate database to current version // 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 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 { if tableName == "" || len(columnNames) == 0 {
return nil return nil
} }
@ -245,17 +247,10 @@ func dropTableColumns(x *xorm.Engine, tableName string, columnNames ...string) (
} }
cols += "DROP COLUMN `" + col + "`" 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) return fmt.Errorf("Drop table `%s` columns %v: %v", tableName, columnNames, err)
} }
case setting.UseMSSQL: case setting.UseMSSQL:
sess := x.NewSession()
defer sess.Close()
if err = sess.Begin(); err != nil {
return err
}
cols := "" cols := ""
for _, col := range columnNames { for _, col := range columnNames {
if cols != "" { if cols != "" {

View file

@ -9,5 +9,15 @@ import (
) )
func removeIsOwnerColumnFromOrgUser(x *xorm.Engine) (err error) { 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
View 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()
}

View file

@ -119,6 +119,7 @@ func init() {
new(RepoIndexerStatus), new(RepoIndexerStatus),
new(LFSLock), new(LFSLock),
new(Reaction), new(Reaction),
new(IssueAssignees),
) )
gonicNames := []string{"SSL", "UID"} gonicNames := []string{"SSL", "UID"}

View file

@ -198,6 +198,7 @@ func (pr *PullRequest) APIFormat() *api.PullRequest {
Labels: apiIssue.Labels, Labels: apiIssue.Labels,
Milestone: apiIssue.Milestone, Milestone: apiIssue.Milestone,
Assignee: apiIssue.Assignee, Assignee: apiIssue.Assignee,
Assignees: apiIssue.Assignees,
State: apiIssue.State, State: apiIssue.State,
Comments: apiIssue.Comments, Comments: apiIssue.Comments,
HTMLURL: pr.Issue.HTMLURL(), HTMLURL: pr.Issue.HTMLURL(),
@ -719,7 +720,7 @@ func (pr *PullRequest) testPatch() (err error) {
} }
// NewPullRequest creates new pull request with labels for repository. // 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() sess := x.NewSession()
defer sess.Close() defer sess.Close()
if err = sess.Begin(); err != nil { if err = sess.Begin(); err != nil {
@ -732,7 +733,11 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str
LabelIDs: labelIDs, LabelIDs: labelIDs,
Attachments: uuids, Attachments: uuids,
IsPull: true, IsPull: true,
AssigneeIDs: assigneeIDs,
}); err != nil { }); err != nil {
if IsErrUserDoesNotHaveAccessToRepo(err) {
return err
}
return fmt.Errorf("newIssue: %v", err) return fmt.Errorf("newIssue: %v", err)
} }

View file

@ -600,9 +600,9 @@ func (repo *Repository) GetAssignees() (_ []*User, err error) {
return repo.getAssignees(x) return repo.getAssignees(x)
} }
// GetAssigneeByID returns the user that has write access of repository by given ID. // GetUserIfHasWriteAccess returns the user that has write access of repository by given ID.
func (repo *Repository) GetAssigneeByID(userID int64) (*User, error) { func (repo *Repository) GetUserIfHasWriteAccess(userID int64) (*User, error) {
return GetAssigneeByID(repo, userID) return GetUserIfHasWriteAccess(repo, userID)
} }
// GetMilestoneByID returns the milestone belongs to repository by given ID. // GetMilestoneByID returns the milestone belongs to repository by given ID.

View file

@ -993,7 +993,7 @@ func deleteUser(e *xorm.Session, u *User) error {
// ***** END: PublicKey ***** // ***** END: PublicKey *****
// Clear assignee. // 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) return fmt.Errorf("clear assignee: %v", err)
} }
@ -1110,8 +1110,8 @@ func GetUserByID(id int64) (*User, error) {
return getUserByID(x, id) return getUserByID(x, id)
} }
// GetAssigneeByID returns the user with write access of repository by given ID. // GetUserIfHasWriteAccess returns the user with write access of repository by given ID.
func GetAssigneeByID(repo *Repository, userID int64) (*User, error) { func GetUserIfHasWriteAccess(repo *Repository, userID int64) (*User, error) {
has, err := HasAccess(userID, repo, AccessModeWrite) has, err := HasAccess(userID, repo, AccessModeWrite)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -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) title = fmt.Sprintf("[%s] Pull request edited: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
text = p.PullRequest.Body text = p.PullRequest.Body
case api.HookIssueAssigned: 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, 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 text = p.PullRequest.Body
case api.HookIssueUnassigned: case api.HookIssueUnassigned:
title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)

View file

@ -191,8 +191,12 @@ func getDiscordPullRequestPayload(p *api.PullRequestPayload, meta *DiscordMeta)
text = p.PullRequest.Body text = p.PullRequest.Body
color = warnColor color = warnColor
case api.HookIssueAssigned: 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, 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 text = p.PullRequest.Body
color = successColor color = successColor
case api.HookIssueUnassigned: case api.HookIssueUnassigned:

View file

@ -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) text = fmt.Sprintf("[%s] Pull request edited: %s by %s", p.Repository.FullName, titleLink, senderLink)
attachmentText = SlackTextFormatter(p.PullRequest.Body) attachmentText = SlackTextFormatter(p.PullRequest.Body)
case api.HookIssueAssigned: 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, 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) titleLink, senderLink)
case api.HookIssueUnassigned: case api.HookIssueUnassigned:
text = fmt.Sprintf("[%s] Pull request unassigned: %s by %s", p.Repository.FullName, titleLink, senderLink) text = fmt.Sprintf("[%s] Pull request unassigned: %s by %s", p.Repository.FullName, titleLink, senderLink)

View file

@ -254,6 +254,7 @@ func (f *NewDingtalkHookForm) Validate(ctx *macaron.Context, errs binding.Errors
type CreateIssueForm struct { type CreateIssueForm struct {
Title string `binding:"Required;MaxSize(255)"` Title string `binding:"Required;MaxSize(255)"`
LabelIDs string `form:"label_ids"` LabelIDs string `form:"label_ids"`
AssigneeIDs string `form:"assignee_ids"`
Ref string `form:"ref"` Ref string `form:"ref"`
MilestoneID int64 MilestoneID int64
AssigneeID int64 AssigneeID int64

View file

@ -99,8 +99,9 @@ func (r *Repository) CanUseTimetracker(issue *models.Issue, user *models.User) b
// Checking for following: // Checking for following:
// 1. Is timetracker enabled // 1. Is timetracker enabled
// 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this? // 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() || 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 // GetCommitsCount returns cached commit count for current view

View file

@ -624,9 +624,9 @@ issues.new.no_milestone = No Milestone
issues.new.clear_milestone = Clear milestone issues.new.clear_milestone = Clear milestone
issues.new.open_milestone = Open Milestones issues.new.open_milestone = Open Milestones
issues.new.closed_milestone = Closed Milestones issues.new.closed_milestone = Closed Milestones
issues.new.assignee = Assignee issues.new.assignees = Assignees
issues.new.clear_assignee = Clear assignee issues.new.clear_assignees = Clear assignees
issues.new.no_assignee = No assignee issues.new.no_assignees = Nobody assigned
issues.no_ref = No Branch/Tag Specified issues.no_ref = No Branch/Tag Specified
issues.create = Create Issue issues.create = Create Issue
issues.new_label = New Label issues.new_label = New Label

File diff suppressed because one or more lines are too long

View file

@ -179,81 +179,115 @@ function initCommentForm() {
initBranchSelector(); initBranchSelector();
initCommentPreviewTab($('.comment.form')); initCommentPreviewTab($('.comment.form'));
// Labels // Listsubmit
var $list = $('.ui.labels.list'); function initListSubmits(selector, outerSelector) {
var $noSelect = $list.find('.no-select'); var $list = $('.ui.' + outerSelector + '.list');
var $labelMenu = $('.select-label .menu'); var $noSelect = $list.find('.no-select');
var hasLabelUpdateAction = $labelMenu.data('action') == 'update'; var $listMenu = $('.' + selector + ' .menu');
var hasLabelUpdateAction = $listMenu.data('action') == 'update';
$('.select-label').dropdown('setting', 'onHide', function(){ $('.' + selector).dropdown('setting', 'onHide', function(){
if (hasLabelUpdateAction) { hasLabelUpdateAction = $listMenu.data('action') == 'update'; // Update the var
location.reload();
}
});
$labelMenu.find('.item:not(.no-select)').click(function () {
if ($(this).hasClass('checked')) {
$(this).removeClass('checked');
$(this).find('.octicon').removeClass('octicon-check');
if (hasLabelUpdateAction) { 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( updateIssuesMeta(
$labelMenu.data('update-url'), $listMenu.data('update-url'),
"detach", "",
$labelMenu.data('issue-id'), $listMenu.data('issue-id'),
$(this).data('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')) { if ($(this).hasClass('checked')) {
labelIds.push($(this).data('id')); $(this).removeClass('checked');
$($(this).data('id-selector')).removeClass('hide'); $(this).find('.octicon').removeClass('octicon-check');
if (hasLabelUpdateAction) {
updateIssuesMeta(
$listMenu.data('update-url'),
"detach",
$listMenu.data('issue-id'),
$(this).data('id')
);
}
} else { } 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'); $noSelect.removeClass('hide');
} else { $($(this).parent().data('id')).val('');
$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().find('.item').each(function () {
$(this).removeClass('checked');
$(this).find('.octicon').removeClass('octicon-check');
}); });
}
$list.find('.item').each(function () { // Init labels and assignees
$(this).addClass('hide'); initListSubmits('select-label', 'labels');
}); initListSubmits('select-assignees', 'assignees');
$noSelect.removeClass('hide'); initListSubmits('select-assignees-modify', 'assignees');
$($(this).parent().data('id')).val('');
});
function selectItem(select_id, input_id) { function selectItem(select_id, input_id) {
var $menu = $(select_id + ' .menu'); var $menu = $(select_id + ' .menu');

View file

@ -119,8 +119,11 @@
} }
.octicon { .octicon {
float: left; float: left;
margin-left: -5px; margin: 5px -7px 0 -5px;
margin-right: -7px; width: 16px;
}
.text{
margin-left: 0.9em;
} }
.menu { .menu {
max-height: 300px; max-height: 300px;
@ -1745,4 +1748,4 @@ tbody.commit-list {
#repo-topic { #repo-topic {
margin-top: 5px; margin-top: 5px;
} }

View file

@ -178,25 +178,22 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) {
DeadlineUnix: deadlineUnix, DeadlineUnix: deadlineUnix,
} }
if ctx.Repo.IsWriter() { // Get all assignee IDs
if len(form.Assignee) > 0 { assigneeIDs, err := models.MakeIDsFromAPIAssigneesToAdd(form.Assignee, form.Assignees)
assignee, err := models.GetUserByName(form.Assignee) if err != nil {
if err != nil { if models.IsErrUserNotExist(err) {
if models.IsErrUserNotExist(err) { ctx.Error(422, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err))
ctx.Error(422, "", fmt.Sprintf("Assignee does not exist: [name: %s]", form.Assignee)) } else {
} else { ctx.Error(500, "AddAssigneeByName", err)
ctx.Error(500, "GetUserByName", err)
}
return
}
issue.AssigneeID = assignee.ID
} }
issue.MilestoneID = form.Milestone return
} else {
form.Labels = nil
} }
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) ctx.Error(500, "NewIssue", err)
return return
} }
@ -209,7 +206,6 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) {
} }
// Refetch from database to assign some automatic values // Refetch from database to assign some automatic values
var err error
issue, err = models.GetIssueByID(issue.ID) issue, err = models.GetIssueByID(issue.ID)
if err != nil { if err != nil {
ctx.Error(500, "GetIssueByID", err) ctx.Error(500, "GetIssueByID", err)
@ -272,6 +268,7 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) {
issue.Content = *form.Body issue.Content = *form.Body
} }
// Update the deadline
var deadlineUnix util.TimeStamp var deadlineUnix util.TimeStamp
if form.Deadline != nil && !form.Deadline.IsZero() { if form.Deadline != nil && !form.Deadline.IsZero() {
deadlineUnix = util.TimeStamp(form.Deadline.Unix()) deadlineUnix = util.TimeStamp(form.Deadline.Unix())
@ -282,28 +279,28 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) {
return return
} }
if ctx.Repo.IsWriter() && form.Assignee != nil && // Add/delete assignees
(issue.Assignee == nil || issue.Assignee.LowerName != strings.ToLower(*form.Assignee)) {
if len(*form.Assignee) == 0 { // Deleting is done the Github way (quote from their api documentation):
issue.AssigneeID = 0 // https://developer.github.com/v3/issues/#edit-an-issue
} else { // "assignees" (array): Logins for Users to assign to this issue.
assignee, err := models.GetUserByName(*form.Assignee) // Pass one or more user logins to replace the set of assignees on this Issue.
if err != nil { // Send an empty array ([]) to clear all assignees from the Issue.
if models.IsErrUserNotExist(err) {
ctx.Error(422, "", fmt.Sprintf("assignee does not exist: [name: %s]", *form.Assignee)) if ctx.Repo.IsWriter() && (form.Assignees != nil || form.Assignee != nil) {
} else {
ctx.Error(500, "GetUserByName", err) oneAssignee := ""
} if form.Assignee != nil {
return oneAssignee = *form.Assignee
}
issue.AssigneeID = assignee.ID
} }
if err = models.UpdateIssueUserByAssignee(issue); err != nil { err = models.UpdateAPIAssignee(issue, oneAssignee, form.Assignees, ctx.User)
ctx.Error(500, "UpdateIssueUserByAssignee", err) if err != nil {
ctx.Error(500, "UpdateAPIAssignee", err)
return return
} }
} }
if ctx.Repo.IsWriter() && form.Milestone != nil && if ctx.Repo.IsWriter() && form.Milestone != nil &&
issue.MilestoneID != *form.Milestone { issue.MilestoneID != *form.Milestone {
oldMilestoneID := issue.MilestoneID oldMilestoneID := issue.MilestoneID

View file

@ -211,26 +211,6 @@ func CreatePullRequest(ctx *context.APIContext, form api.CreatePullRequestOption
milestoneID = milestone.ID 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) patch, err := headGitRepo.GetPatch(prInfo.MergeBase, headBranch)
if err != nil { if err != nil {
ctx.Error(500, "GetPatch", err) ctx.Error(500, "GetPatch", err)
@ -266,7 +246,22 @@ func CreatePullRequest(ctx *context.APIContext, form api.CreatePullRequestOption
Type: models.PullRequestGitea, 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) ctx.Error(500, "NewPullRequest", err)
return return
} else if err := pr.PushToBaseRepo(); err != nil { } else if err := pr.PushToBaseRepo(); err != nil {
@ -335,6 +330,7 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) {
issue.Content = form.Body issue.Content = form.Body
} }
// Update Deadline
var deadlineUnix util.TimeStamp var deadlineUnix util.TimeStamp
if form.Deadline != nil && !form.Deadline.IsZero() { if form.Deadline != nil && !form.Deadline.IsZero() {
deadlineUnix = util.TimeStamp(form.Deadline.Unix()) deadlineUnix = util.TimeStamp(form.Deadline.Unix())
@ -345,28 +341,27 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) {
return return
} }
if ctx.Repo.IsWriter() && len(form.Assignee) > 0 && // Add/delete assignees
(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
}
if err = models.UpdateIssueUserByAssignee(issue); err != nil { // Deleting is done the Github way (quote from their api documentation):
ctx.Error(500, "UpdateIssueUserByAssignee", err) // 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 return
} }
} }
if ctx.Repo.IsWriter() && form.Milestone != 0 && if ctx.Repo.IsWriter() && form.Milestone != 0 &&
issue.MilestoneID != form.Milestone { issue.MilestoneID != form.Milestone {
oldMilestoneID := issue.MilestoneID oldMilestoneID := issue.MilestoneID

View file

@ -364,7 +364,7 @@ func NewIssue(ctx *context.Context) {
} }
// ValidateRepoMetas check and returns repository's meta informations // 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 ( var (
repo = ctx.Repo.Repository repo = ctx.Repo.Repository
err error err error
@ -372,11 +372,11 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64
labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository) labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository)
if ctx.Written() { if ctx.Written() {
return nil, 0, 0 return nil, nil, 0
} }
if !ctx.Repo.IsWriter() { if !ctx.Repo.IsWriter() {
return nil, 0, 0 return nil, nil, 0
} }
var labelIDs []int64 var labelIDs []int64
@ -385,7 +385,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64
if len(form.LabelIDs) > 0 { if len(form.LabelIDs) > 0 {
labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
if err != nil { if err != nil {
return nil, 0, 0 return nil, nil, 0
} }
labelIDMark := base.Int64sToMap(labelIDs) labelIDMark := base.Int64sToMap(labelIDs)
@ -407,23 +407,35 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64
ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID) ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID)
if err != nil { if err != nil {
ctx.ServerError("GetMilestoneByID", err) ctx.ServerError("GetMilestoneByID", err)
return nil, 0, 0 return nil, nil, 0
} }
ctx.Data["milestone_id"] = milestoneID ctx.Data["milestone_id"] = milestoneID
} }
// Check assignee. // Check assignees
assigneeID := form.AssigneeID var assigneeIDs []int64
if assigneeID > 0 { if len(form.AssigneeIDs) > 0 {
ctx.Data["Assignee"], err = repo.GetAssigneeByID(assigneeID) assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ","))
if err != nil { if err != nil {
ctx.ServerError("GetAssigneeByID", err) return nil, nil, 0
return nil, 0, 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 // NewIssuePost response for creating new issue
@ -440,7 +452,7 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) {
attachments []string attachments []string
) )
labelIDs, milestoneID, assigneeID := ValidateRepoMetas(ctx, form) labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -460,11 +472,14 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) {
PosterID: ctx.User.ID, PosterID: ctx.User.ID,
Poster: ctx.User, Poster: ctx.User,
MilestoneID: milestoneID, MilestoneID: milestoneID,
AssigneeID: assigneeID,
Content: form.Content, Content: form.Content,
Ref: form.Ref, 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) ctx.ServerError("NewIssue", err)
return return
} }
@ -702,8 +717,8 @@ func ViewIssue(ctx *context.Context) {
comment.Milestone = ghostMilestone comment.Milestone = ghostMilestone
} }
} else if comment.Type == models.CommentTypeAssignees { } else if comment.Type == models.CommentTypeAssignees {
if err = comment.LoadAssignees(); err != nil { if err = comment.LoadAssigneeUser(); err != nil {
ctx.ServerError("LoadAssignees", err) ctx.ServerError("LoadAssigneeUser", err)
return return
} }
} }
@ -912,13 +927,20 @@ func UpdateIssueAssignee(ctx *context.Context) {
} }
assigneeID := ctx.QueryInt64("id") assigneeID := ctx.QueryInt64("id")
action := ctx.Query("action")
for _, issue := range issues { for _, issue := range issues {
if issue.AssigneeID == assigneeID { switch action {
continue case "clear":
} if err := models.DeleteNotPassedAssignee(issue, ctx.User, []*models.User{}); err != nil {
if err := issue.ChangeAssignee(ctx.User, assigneeID); err != nil { ctx.ServerError("ClearAssignees", err)
ctx.ServerError("ChangeAssignee", err) return
return }
default:
if err := issue.ChangeAssignee(ctx.User, assigneeID); err != nil {
ctx.ServerError("ChangeAssignee", err)
return
}
} }
} }
ctx.JSON(200, map[string]interface{}{ ctx.JSON(200, map[string]interface{}{

View file

@ -775,7 +775,7 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm)
return return
} }
labelIDs, milestoneID, assigneeID := ValidateRepoMetas(ctx, form) labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -811,7 +811,6 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm)
PosterID: ctx.User.ID, PosterID: ctx.User.ID,
Poster: ctx.User, Poster: ctx.User,
MilestoneID: milestoneID, MilestoneID: milestoneID,
AssigneeID: assigneeID,
IsPull: true, IsPull: true,
Content: form.Content, 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 // FIXME: check error in the case two people send pull request at almost same time, give nice error prompt
// instead of 500. // 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) ctx.ServerError("NewPullRequest", err)
return return
} else if err := pullRequest.PushToBaseRepo(); err != nil { } else if err := pullRequest.PushToBaseRepo(); err != nil {

View file

@ -156,7 +156,7 @@
</div> </div>
</div> </div>
<!-- Assignee --> <!-- Assignees -->
<div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item"> <div class="ui {{if not .Assignees}}disabled{{end}} dropdown jump item">
<span class="text"> <span class="text">
{{.i18n.Tr "repo.issues.action_assignee"}} {{.i18n.Tr "repo.issues.action_assignee"}}
@ -220,9 +220,9 @@
<span class="octicon octicon-calendar"></span> <span class="octicon octicon-calendar"></span>
<span{{if .IsOverdue}} class="overdue"{{end}}>{{.DeadlineUnix.FormatShort}}</span> <span{{if .IsOverdue}} class="overdue"{{end}}>{{.DeadlineUnix.FormatShort}}</span>
{{end}} {{end}}
{{if .Assignee}} {{range .Assignees}}
<a class="ui right assignee poping up" href="{{.Assignee.HomeLink}}" data-content="{{.Assignee.Name}}" data-variation="inverted" data-position="left center"> <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="{{.Assignee.RelAvatarLink}}"> <img class="ui avatar image" src="{{.RelAvatarLink}}">
</a> </a>
{{end}} {{end}}
</p> </p>

View file

@ -97,27 +97,56 @@
<div class="ui divider"></div> <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;">&nbsp;{{.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"> <div class="ui {{if not .Assignees}}disabled{{end}} floating jump select-assignee dropdown">
<span class="text"> <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 class="octicon octicon-gear"></span>
</span> </span>
<div class="menu"> <div class="filter menu">
<div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignee"}}</div> <div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignees"}}</div>
{{range .Assignees}} {{range .Assignees}}
<div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?assignee={{.ID}}" data-avatar="{{.RelAvatarLink}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</div> <div class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?assignee={{.ID}}" data-avatar="{{.RelAvatarLink}}"><img src="{{.RelAvatarLink}}"> {{.Name}}</div>
{{end}} {{end}}
</div> </div>
</div> </div>
<div class="ui select-assignee list"> <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"> <div class="selected">
{{if .Assignee}} {{if .Assignee}}
<a class="item" href="{{.RepoLink}}/issues?assignee={{.Assignee.ID}}"><img class="ui avatar image" src="{{.Assignee.RelAvatarLink}}"> {{.Assignee.Name}}</a> <a class="item" href="{{.RepoLink}}/issues?assignee={{.Assignee.ID}}"><img class="ui avatar image" src="{{.Assignee.RelAvatarLink}}"> {{.Assignee.Name}}</a>
{{end}} {{end}}
</div> </div>
</div> </div>-->
</div> </div>
</div> </div>
</form> </form>

View file

@ -118,15 +118,29 @@
{{else if eq .Type 9}} {{else if eq .Type 9}}
<div class="event"> <div class="event">
<span class="octicon octicon-primitive-dot"></span> <span class="octicon octicon-primitive-dot"></span>
{{if gt .AssigneeID 0}}{{if eq .Poster.ID .AssigneeID}}<a class="ui avatar image" href="{{.Poster.HomeLink}}"> {{if gt .AssigneeID 0}}
<img src="{{.Poster.RelAvatarLink}}"> {{if .RemovedAssignee}}
</a> <span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.self_assign_at" $createdStr | Safe}} </span> <a class="ui avatar image" href="{{.Assignee.HomeLink}}">
{{else}}<a class="ui avatar image" href="{{.Assignee.HomeLink}}"> <img src="{{.Assignee.RelAvatarLink}}">
<img src="{{.Assignee.RelAvatarLink}}"> </a>
</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}} <span class="text grey">
<a class="ui avatar image" href="{{.Poster.HomeLink}}"> <a href="{{.Assignee.HomeLink}}">{{.Assignee.Name}}</a>
<img src="{{.Poster.RelAvatarLink}}"> {{$.i18n.Tr "repo.issues.remove_assignee_at" $createdStr | Safe}}
</a> <span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.remove_assignee_at" $createdStr | Safe}} </span>{{end}} </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> </div>
{{else if eq .Type 10}} {{else if eq .Type 10}}
<div class="event"> <div class="event">

View file

@ -68,23 +68,40 @@
<div class="ui divider"></div> <div class="ui divider"></div>
<input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> <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"> <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 class="octicon octicon-gear"></span>
</span> </span>
<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee"> <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_assignee"}}</div> <div class="no-select item">{{.i18n.Tr "repo.issues.new.clear_assignees"}}</div>
{{range .Assignees}} {{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}} {{end}}
</div> </div>
</div> </div>
<div class="ui select-assignee list"> <div class="ui assignees list">
<span class="no-select item {{if .Issue.Assignee}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_assignee"}}</span> <span class="no-select item {{if .Issue.Assignees}}hide{{end}}">{{.i18n.Tr "repo.issues.new.no_assignees"}}</span>
<div class="selected"> <div class="selected">
{{if .Issue.Assignee}} {{range .Issue.Assignees}}
<a class="item" href="{{$.RepoLink}}/issues?assignee={{.Issue.Assignee.ID}}"><img class="ui avatar image" src="{{.Issue.Assignee.RelAvatarLink}}"> {{.Issue.Assignee.Name}}</a> <div class="item" style="margin-bottom: 10px;">
<a href="{{$.RepoLink}}/issues?assignee={{.ID}}"><img class="ui avatar image" src="{{.RelAvatarLink}}">&nbsp;{{.Name}}</a>
</div>
{{end}} {{end}}
</div> </div>
</div> </div>