Mijeong Park Mijeong Park 2018-02-06
Merge branch 'feature/issue-share' into 'next'
from pull-request 3

* feature/issue-share:
  my-issues: Show shared issue list
  issue: Issue sharing feature - support notification
  issue: Issue sharing feature - view and message
  issue: Issue sharing feature - Build api
  issue: Issue sharing feature - Add model

Reviewed-by: Mijeong Park 
@3cc43f2193f605974bcf6816caa0e41e3509c76f
app/assets/stylesheets/less/_common.less
--- app/assets/stylesheets/less/_common.less
+++ app/assets/stylesheets/less/_common.less
@@ -219,6 +219,7 @@
 .mt4 { margin-top:4px; }
 .mr3 { margin-right:3px; }
 .pb4 { padding-bottom: 4px}
+.pl0 { padding-left: 0}
 
 .margin-top-20   { margin-top:20px;   }
 .margin-left-20  { margin-left: 20px; }
@@ -281,3 +282,21 @@
 .va-text-top {
     vertical-align: text-top !important;
 }
+
+.width100p {
+    width: 100%
+}
+
+.text-ellipsis {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.z-index-1 {
+    z-index: 1 !important;
+}
+
+.hideFromDisplayOnly {
+    display: none;
+}
app/assets/stylesheets/less/_override.less
--- app/assets/stylesheets/less/_override.less
+++ app/assets/stylesheets/less/_override.less
@@ -182,6 +182,23 @@
     .box-shadow(none);
 }
 
+.sharer-list {
+    .select2-container{
+        border: none;
+        box-shadow: none;
+        border-radius: 0 !important;
+        border-bottom: 1px solid rgba(0, 0, 0, 0.15);
+    }
+    .select2-container-multi {
+        .select2-choices {
+            .select2-search-choice {
+                background-color: #ececec;
+                border: 1px solid #dfdfdf;
+            }
+        }
+    }
+}
+
 .select2-dropdown-open {
     .select2-choice {
         border-bottom-color: transparent;
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -3066,6 +3066,14 @@
                 &.rejected {background: #F39C12;}
                 &.conflict {background: #C0392B;}
                 &.resolved {background: #468847;}
+                &.sharer-added {
+                    background-color: #B2EBF2;
+                    color: #0097A7;
+                }
+                &.sharer-deleted {
+                    background-color: #FFCCBC;
+                    color: #F4511E;
+                }
              }
 
              em { font-size:12px; color:#7F8C8D; margin-right:5px;}
@@ -3745,8 +3753,15 @@
     .comments-count-color {
         color: @darkmagenta;
     }
+    .sharer-color {
+        color: @green;
+    }
 
     a:nth-child(2) {
+        margin-left: -5px;
+    }
+
+    a:nth-child(3) {
         margin-left: -5px;
     }
 
@@ -7070,3 +7085,19 @@
         border-radius: 4px;
     }
 }
+
+.sharer-list {
+    margin-top: 40px;
+    padding: 10px;
+
+    .issue-share-title {
+        font-size: 16px;
+    }
+    .sharer-item{
+        display: inline-block;
+        background-color: #ececec;
+        border: 1px solid #dfdfdf;
+        border-radius: 3px;
+        padding: 1px 8px;
+    }
+}
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -108,7 +108,7 @@
 
     private static boolean hasNotConditions(models.support.SearchCondition searchCondition) {
         return searchCondition.assigneeId == null && searchCondition.authorId == null && searchCondition.mentionId == null
-                && searchCondition.commenterId == null;
+                && searchCondition.commenterId == null && searchCondition.sharerId == null;
     }
 
     @Transactional
app/controllers/api/IssueApi.java
--- app/controllers/api/IssueApi.java
+++ app/controllers/api/IssueApi.java
@@ -39,6 +39,7 @@
 import java.util.*;
 
 import static controllers.UserApp.MAX_FETCH_USERS;
+import static controllers.UserApp.currentUser;
 import static controllers.api.UserApi.createUserNode;
 import static play.libs.Json.toJson;
 
@@ -385,7 +386,7 @@
         }
     }
 
-    private static void addUserToUsers(User user, List<ObjectNode> users) {
+    static void addUserToUsers(User user, List<ObjectNode> users) {
         ObjectNode userNode = Json.newObject();
         userNode.put("loginId", user.loginId);
         userNode.put("name", user.getDisplayName());
@@ -535,4 +536,151 @@
                     return ok(node);
                 });
     }
+
+    @AnonymousCheck
+    public static Result findSharerByloginIds(String ownerName, String projectName, Long number,
+                                              String commaSeperatedIds) {
+        if (!request().accepts("application/json")) {
+            return status(Http.Status.NOT_ACCEPTABLE);
+        }
+        Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
+        Issue issue = Issue.findByNumber(project, number);
+
+        List<IssueSharer> list = getExpressionListByExtractingLoginIds(issue, commaSeperatedIds).findList();
+        sortListByAddedDate(list);
+
+        List<ObjectNode> users = new ArrayList<>();
+        for (IssueSharer sharer :list) {
+            addUserToUsers(sharer.user, users);
+        }
+        return ok(toJson(users));
+    }
+
+    private static void sortListByAddedDate(List<IssueSharer> list) {
+        list.sort(new Comparator<IssueSharer>() {
+            @Override
+            public int compare(IssueSharer o1, IssueSharer o2) {
+                return o1.created.compareTo(o2.created);
+            }
+        });
+    }
+
+    private static ExpressionList<IssueSharer> getExpressionListByExtractingLoginIds(Issue issue, String query) {
+        String[] queryItems = query.split(",");
+        ExpressionList<IssueSharer> el = IssueSharer.find
+                .where()
+                .in("loginId", Arrays.asList(queryItems))
+                .eq("issue.id", issue.id);
+        return el;
+    }
+
+    @IsAllowed(Operation.READ)
+    public static Result findSharableUsers(String ownerName, String projectName, Long number, String query) {
+        if (!request().accepts("application/json")) {
+            return status(Http.Status.NOT_ACCEPTABLE);
+        }
+
+        List<ObjectNode> users = new ArrayList<>();
+
+        ExpressionList<User> el = getUserExpressionList(query, request().getQueryString("type"));
+
+        int total = el.findRowCount();
+        if (total > MAX_FETCH_USERS) {
+            el.setMaxRows(MAX_FETCH_USERS);
+            response().setHeader("Content-Range", "items " + MAX_FETCH_USERS + "/" + total);
+        }
+
+        for (User user :el.findList()) {
+            addUserToUsers(user, users);
+        }
+
+        return ok(toJson(users));
+    }
+
+    public static Result updateSharer(String owner, String projectName, Long number){
+        JsonNode json = request().body().asJson();
+        if (json == null) {
+            return badRequest(Json.newObject().put("message", "Expecting Json data"));
+        }
+
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+        Issue issue = Issue.findByNumber(project, number);
+        if (!AccessControl.isAllowed(UserApp.currentUser(), issue.asResource(),
+                Operation.UPDATE)) {
+            return forbidden(Json.newObject().put("message", "Permission denied"));
+        }
+
+        JsonNode sharer = json.findValue("sharer");
+        if(noSharer(sharer)){
+            return badRequest(Json.newObject().put("message", "No sharer"));
+        }
+
+        final String action = json.findValue("action").asText();
+
+        ObjectNode result = changeSharer(sharer, issue, action);
+        sendNotification(sharer, issue, action);
+
+        return ok(result);
+    }
+
+    private static ObjectNode changeSharer(JsonNode sharer, Issue issue, String action) {
+        ObjectNode result = Json.newObject();
+        for (JsonNode sharerLoginId : sharer) {
+            if ("add".equalsIgnoreCase(action)) {
+                addSharer(issue, sharerLoginId.asText());
+                result.put("action", "added");
+            } else if ("delete".equalsIgnoreCase(action)) {
+                result.put("action", "deleted");
+                removeSharer(issue, sharerLoginId.asText());
+            } else {
+                play.Logger.error("Unknown issue sharing action: " + issue + ":" + action + " by " + currentUser());
+                result.put("action", "Do nothing. Unsupported action: " + action);
+            }
+            result.put("sharer", User.findByLoginId(sharerLoginId.asText()).getDisplayName());
+        }
+        return result;
+    }
+
+    private static void sendNotification(JsonNode sharer, Issue issue, String action) {
+        Runnable preUpdateHook = new Runnable() {
+            @Override
+            public void run() {
+                for(JsonNode sharerLoginId: sharer){
+                    addSharerChangedNotification(issue, sharerLoginId.asText(), action);
+                }
+            }
+        };
+        preUpdateHook.run();
+    }
+
+
+    private static void addSharerChangedNotification(Issue issue, String sharerLoginId, String action) {
+        NotificationEvent notiEvent = NotificationEvent.afterIssueSharerChanged(issue, sharerLoginId, action);
+        IssueEvent.addFromNotificationEventWithoutSkipEvent(notiEvent, issue, UserApp.currentUser().loginId);
+    }
+
+    private static boolean noSharer(JsonNode sharers) {
+        return sharers == null || sharers.size() == 0;
+    }
+
+    private static void addSharer(Issue issue, String loginId) {
+        IssueSharer issueSharer = IssueSharer.find.where()
+                .eq("loginId", loginId)
+                .eq("issue.id", issue.id).findUnique();
+        if(issueSharer == null) {
+            issueSharer = IssueSharer.createSharer(loginId, issue);
+            issueSharer.save();
+        }
+        issue.sharers.add(issueSharer);
+    }
+
+    private static void removeSharer(Issue issue, String loginId) {
+        IssueSharer issueSharer =
+                IssueSharer.find.where()
+                        .eq("loginId", loginId)
+                        .eq("issue.id", issue.id)
+                        .findUnique();
+        issueSharer.delete();
+        issue.sharers.remove(issueSharer);
+    }
 }
app/models/Issue.java
--- app/models/Issue.java
+++ app/models/Issue.java
@@ -75,6 +75,9 @@
     @OneToMany(cascade = CascadeType.ALL, mappedBy="issue")
     public List<IssueEvent> events;
 
+    @OneToMany(cascade = CascadeType.ALL, mappedBy = "issue")
+    public Set<IssueSharer> sharers = new LinkedHashSet<>();
+
     @ManyToMany(cascade = CascadeType.ALL)
     @JoinTable(
             name = "issue_voter",
@@ -675,4 +678,17 @@
                 .eq("state", State.OPEN)
                 .findRowCount();
     }
+
+    public IssueSharer findSharerByUserId(Long id){
+        for (IssueSharer sharer : sharers) {
+            if (sharer.user.id.equals(id)) {
+                return sharer;
+            }
+        }
+        return null;
+    }
+
+    public List<IssueSharer> getSortedSharer() {
+        return new ArrayList<>(sharers);
+    }
 }
app/models/IssueEvent.java
--- app/models/IssueEvent.java
+++ app/models/IssueEvent.java
@@ -1,23 +1,10 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2013 NAVER Corp.
- * http://yobi.io
- *
- * @author Yi EungJun
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
+
 package models;
 
 import models.enumeration.EventType;
@@ -91,8 +78,7 @@
                 .orderBy("id desc").setMaxRows(1).findUnique();
 
         if (lastEvent != null) {
-            if (lastEvent.eventType == event.eventType &&
-                    StringUtils.equals(event.senderLoginId, lastEvent.senderLoginId)) {
+            if (isSameUserEventAsPrevious(event, lastEvent)) {
                 // A -> B, B -> C ==> A -> C
                 event.oldValue = lastEvent.oldValue;
                 lastEvent.delete();
@@ -107,6 +93,45 @@
         }
 
         event.save();
+    }
+
+
+    /**
+     * It is nearly same as {@link #add(IssueEvent)} except that it doesn't skip waypoint events.
+     *
+     * For example, if events of an issue are occurred continuously
+     * A -> B -> C, then {@link #add(IssueEvent)} method skip B.
+     *
+     * This method doesn't skip B and leave it.
+     *
+     * @param event
+     */
+    public static void addWithoutSkipEvent(IssueEvent event) {
+        Date draftDate = DateTime.now().minusMillis(DRAFT_TIME_IN_MILLIS).toDate();
+
+        IssueEvent lastEvent = IssueEvent.find.where()
+                .eq("issue.id", event.issue.id)
+                .gt("created", draftDate)
+                .orderBy("id desc").setMaxRows(1).findUnique();
+
+        if (lastEvent != null) {
+            if (isSameUserEventAsPrevious(event, lastEvent) &&
+                    isRevertingTheValue(event, lastEvent)) {
+                lastEvent.delete();
+                return;
+            }
+        }
+        event.save();
+    }
+
+    private static boolean isRevertingTheValue(IssueEvent event, IssueEvent lastEvent) {
+        return StringUtils.equals(event.oldValue, lastEvent.newValue) &&
+                StringUtils.equals(event.newValue, lastEvent.oldValue);
+    }
+
+    private static boolean isSameUserEventAsPrevious(IssueEvent event, IssueEvent lastEvent) {
+        return lastEvent.eventType == event.eventType &&
+                StringUtils.equals(event.senderLoginId, lastEvent.senderLoginId);
     }
 
     /**
@@ -130,6 +155,18 @@
         add(event);
     }
 
+    public static void addFromNotificationEventWithoutSkipEvent(NotificationEvent notiEvent, Issue updatedIssue,
+                                                String senderLoginId) {
+        IssueEvent event = new IssueEvent();
+        event.created = notiEvent.created;
+        event.senderLoginId = senderLoginId;
+        event.issue = updatedIssue;
+        event.eventType = notiEvent.eventType;
+        event.oldValue = notiEvent.oldValue;
+        event.newValue = notiEvent.newValue;
+        addWithoutSkipEvent(event);
+    }
+
     @Override
     public Date getDate() {
         return created;
 
app/models/IssueSharer.java (added)
+++ app/models/IssueSharer.java
@@ -0,0 +1,60 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
+
+package models;
+
+import models.enumeration.State;
+import play.db.ebean.Model;
+
+import javax.persistence.*;
+import java.util.Date;
+
+@Entity
+public class IssueSharer extends Model {
+    private static final long serialVersionUID = 6199025373911652405L;
+
+    @Id
+    public Long id;
+
+    public Date created;
+
+    public String loginId;
+
+    @OneToOne
+    public User user;
+
+    @OneToOne
+    public Issue issue;
+
+    public static final String ADD = "add";
+    public static final String DELETE = "delete";
+
+    public static final Finder<Long, IssueSharer> find = new Finder<>(Long.class,
+            IssueSharer.class);
+
+    public static IssueSharer createSharer(String loginId, Issue issue) {
+        IssueSharer issueSharer = new IssueSharer();
+        issueSharer.loginId = loginId;
+        issueSharer.created = new Date();
+        issueSharer.issue = issue;
+        issueSharer.user = User.findByLoginId(loginId);
+
+        if (issueSharer.user == null) {
+            String errorMsg = "Wrong loginId for issue sharing: " + loginId;
+            play.Logger.error(errorMsg);
+            throw new IllegalArgumentException(errorMsg);
+        }
+        return issueSharer;
+    }
+
+    public static int getNumberOfIssuesSharedWithUser(Long userId){
+        return find.where()
+                .eq("user.id", userId)
+                .eq("issue.state", State.OPEN)
+                .findRowCount();
+    }
+}
app/models/NotificationEvent.java
--- app/models/NotificationEvent.java
+++ app/models/NotificationEvent.java
@@ -14,7 +14,6 @@
 import models.resource.GlobalResource;
 import models.resource.Resource;
 import models.resource.ResourceConvertible;
-import models.Webhook;
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.collections.Predicate;
 import org.apache.commons.lang3.StringUtils;
@@ -110,6 +109,10 @@
         return oldValue;
     }
 
+    public String getNewValue() {
+        return newValue;
+    }
+
     @Transient
     public String getMessage() {
         return getMessage(Lang.defaultLang());
@@ -192,7 +195,14 @@
                 }
             case ISSUE_MOVED:
                     return Messages.get(lang, "notification.type.issue.moved", oldValue, newValue);
+            case ISSUE_SHARER_CHANGED:
+                if (StringUtils.isNotBlank(newValue)) {
+                    return Messages.get(lang, "notification.issue.sharer.added", User.findByLoginId(newValue).getDisplayName());
+                } else if (StringUtils.isNotBlank(oldValue)) {
+                    return Messages.get(lang, "notification.issue.sharer.deleted");
+                }
             default:
+                play.Logger.error("Unknown event message: " + this);
                 return null;
         }
     }
@@ -378,8 +388,7 @@
                 .orderBy("id desc").setMaxRows(1).findUnique();
 
         if (lastEvent != null) {
-            if (lastEvent.eventType == event.eventType &&
-                    event.senderId.equals(lastEvent.senderId)) {
+            if (isSameUserEventAsPrevious(event, lastEvent)) {
                 // If the last event is A -> B and the current event is B -> C,
                 // they are merged into the new event A -> C.
                 event.oldValue = lastEvent.getOldValue();
@@ -399,6 +408,55 @@
         }
         event.save();
         event.saveManyToManyAssociations("receivers");
+    }
+
+    public static void addWithoutSkipEvent(NotificationEvent event) {
+        if (event.notificationMail == null) {
+            event.notificationMail = new NotificationMail();
+            event.notificationMail.notificationEvent = event;
+        }
+
+        Date draftDate = DateTime.now().minusMillis(EventConstants.DRAFT_TIME_IN_MILLIS).toDate();
+
+        NotificationEvent lastEvent = NotificationEvent.find.where()
+                .eq("resourceId", event.resourceId)
+                .eq("resourceType", event.resourceType)
+                .gt("created", draftDate)
+                .orderBy("id desc").setMaxRows(1).findUnique();
+
+        if (lastEvent != null) {
+            if (isSameUserEventAsPrevious(event, lastEvent) &&
+                    isRevertingTheValue(event, lastEvent)) {
+                lastEvent.delete();
+                return;
+            }
+        }
+
+        if(isAddingSharerEvent(event)){
+            filterReceivers(event);
+        }
+
+        if (event.receivers.isEmpty()) {
+            return;
+        }
+        event.save();
+        event.saveManyToManyAssociations("receivers");
+    }
+
+    private static boolean isSameUserEventAsPrevious(NotificationEvent event, NotificationEvent lastEvent) {
+        return lastEvent.eventType == event.eventType &&
+                event.senderId.equals(lastEvent.senderId);
+    }
+
+    private static boolean isRevertingTheValue(NotificationEvent event, NotificationEvent lastEvent) {
+        return StringUtils.equals(event.oldValue, lastEvent.newValue) &&
+                StringUtils.equals(event.newValue, lastEvent.oldValue);
+    }
+
+    private static boolean isAddingSharerEvent(NotificationEvent event) {
+        return event.eventType.equals(EventType.ISSUE_SHARER_CHANGED)
+            && StringUtils.isBlank(event.oldValue)
+            && StringUtils.isNotBlank(event.newValue);
     }
 
     private static void filterReceivers(final NotificationEvent event) {
@@ -809,6 +867,30 @@
         NotificationEvent.add(notiEvent);
 
         return notiEvent;
+    }
+
+    public static NotificationEvent afterIssueSharerChanged(Issue issue, String sharerLoginId, String action) {
+        NotificationEvent notiEvent = createFromCurrentUser(issue);
+        notiEvent.title = formatReplyTitle(issue);
+        notiEvent.receivers = findSharer(sharerLoginId);
+        notiEvent.eventType = ISSUE_SHARER_CHANGED;
+        if (IssueSharer.ADD.equalsIgnoreCase(action)) {
+            notiEvent.oldValue = "";
+            notiEvent.newValue = sharerLoginId;
+        } else if (IssueSharer.DELETE.equalsIgnoreCase(action)) {
+            notiEvent.oldValue = sharerLoginId;
+            notiEvent.newValue = "";
+        }
+
+        NotificationEvent.addWithoutSkipEvent(notiEvent);
+
+        return notiEvent;
+    }
+
+    private static Set<User> findSharer(String sharerLoginId) {
+        Set<User> receivers = new HashSet<>();
+        receivers.add(User.findByLoginId(sharerLoginId));
+        return receivers;
     }
 
     private static Set<User> getReceiversForIssueBodyChanged(String oldBody, Issue issue) {
@@ -1284,4 +1366,21 @@
         webhookRequest(COMMENT_UPDATED, comment, false);
         NotificationEvent.add(forUpdatedComment(comment, UserApp.currentUser()));
     }
+
+    @Override
+    public String toString() {
+        return "NotificationEvent{" +
+                "id=" + id +
+                ", title='" + title + '\'' +
+                ", senderId=" + senderId +
+                ", receivers=" + receivers +
+                ", created=" + created +
+                ", resourceType=" + resourceType +
+                ", resourceId='" + resourceId + '\'' +
+                ", eventType=" + eventType +
+                ", oldValue='" + oldValue + '\'' +
+                ", newValue='" + newValue + '\'' +
+                ", notificationMail=" + notificationMail +
+                '}';
+    }
 }
app/models/enumeration/EventType.java
--- app/models/enumeration/EventType.java
+++ app/models/enumeration/EventType.java
@@ -1,7 +1,7 @@
 /**
  * Yona, 21st Century Project Hosting SW
  * <p>
- * Copyright Yona & Yobi Authors & NAVER Corp.
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
  * https://yona.io
  **/
 package models.enumeration;
@@ -34,7 +34,8 @@
     REVIEW_THREAD_STATE_CHANGED("notification.type.review.state.changed", 18),
     ORGANIZATION_MEMBER_ENROLL_REQUEST("notification.organization.type.member.enroll",19),
     COMMENT_UPDATED("notification.type.comment.updated", 20),
-    ISSUE_MOVED("notification.type.issue.is.moved", 21);
+    ISSUE_MOVED("notification.type.issue.is.moved", 21),
+    ISSUE_SHARER_CHANGED("notification.type.issue.sharer.changed", 22);
 
     private String descr;
 
app/models/support/SearchCondition.java
--- app/models/support/SearchCondition.java
+++ app/models/support/SearchCondition.java
@@ -1,23 +1,9 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2012 NAVER Corp.
- * http://yobi.io
- *
- * @author Tae
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
 package models.support;
 
 import com.avaje.ebean.ExpressionList;
@@ -50,6 +36,7 @@
     public Project project;
 
     public Long mentionId;
+    public Long sharerId;
     public Organization organization;
     public List<String> projectNames;
 
@@ -76,6 +63,7 @@
         one.assigneeId = this.assigneeId;
         one.commenterId = this.commenterId;
         one.mentionId = this.mentionId;
+        one.sharerId = this.sharerId;
         one.dueDate = this.dueDate;
         one.projectNames = this.projectNames;
         return one;
@@ -151,6 +139,11 @@
         return this;
     }
 
+    public SearchCondition setSharerId(Long sharerId) {
+        this.sharerId = sharerId;
+        return this;
+    }
+
     public ExpressionList<Issue> asExpressionList(@Nonnull Organization organization) {
         ExpressionList<Issue> el = Issue.finder.where();
 
@@ -163,6 +156,8 @@
         setAssigneeIfExists(el);
         setAuthorIfExist(el);
         setMentionedIssuesIfExist(el);
+        setSharedIssuesIfExist(el);
+
         setFilteredStringIfExist(el);
 
         if (commentedCheck) {
@@ -279,6 +274,7 @@
         setAuthorIfExist(el);
         setCommenterIfExist(el, null);
         setMentionedIssuesIfExist(el);
+        setSharedIssuesIfExist(el);
         setFilteredStringIfExist(el);
 
         if (commentedCheck) {
@@ -309,6 +305,23 @@
             User mentionUser = User.find.byId(mentionId);
             if(!mentionUser.isAnonymous()) {
                 List<Long> ids = getMentioningIssueIds(mentionUser);
+
+                if (ids.isEmpty()) {
+                    // No need to progress because the query matches nothing.
+                    el.idEq(-1);
+                } else {
+                    el.idIn(ids);
+                }
+            }
+        }
+    }
+
+    private void setSharedIssuesIfExist(ExpressionList<Issue> el) {
+
+        if (sharerId != null) {
+            User user = User.find.byId(sharerId);
+            if(!user.isAnonymous()) {
+                List<Long> ids = getSharedIssueIds(user);
 
                 if (ids.isEmpty()) {
                     // No need to progress because the query matches nothing.
@@ -373,6 +386,18 @@
         return new ArrayList<>(ids);
     }
 
+    private List<Long> getSharedIssueIds(User user) {
+        Set<Long> ids = new HashSet<>();
+        List<IssueSharer> issueSharers = IssueSharer.find.where()
+                .eq("user.id", user.id)
+                .findList();
+        for (IssueSharer issueSharer : issueSharers) {
+            ids.add(issueSharer.issue.id);
+        }
+
+        return new ArrayList<>(ids);
+    }
+
     public ExpressionList<Issue> asExpressionList(Project project) {
         ExpressionList<Issue> el = Issue.finder.where();
         if( project != null ){
@@ -417,6 +442,7 @@
         }
 
         setCommenterIfExist(el, project);
+        setSharedIssuesIfExist(el);
 
         if (milestoneId != null) {
             if (milestoneId.equals(Milestone.NULL_MILESTONE_ID)) {
@@ -457,4 +483,23 @@
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
         return sdf.format(this.dueDate);
     }
+
+    @Override
+    public String toString() {
+        return "SearchCondition{" +
+                "state='" + state + '\'' +
+                ", commentedCheck=" + commentedCheck +
+                ", milestoneId=" + milestoneId +
+                ", labelIds=" + labelIds +
+                ", authorId=" + authorId +
+                ", assigneeId=" + assigneeId +
+                ", project=" + project +
+                ", mentionId=" + mentionId +
+                ", sharerId=" + sharerId +
+                ", organization=" + organization +
+                ", projectNames=" + projectNames +
+                ", commenterId=" + commenterId +
+                ", dueDate=" + dueDate +
+                '}';
+    }
 }
app/utils/AccessControl.java
--- app/utils/AccessControl.java
+++ app/utils/AccessControl.java
@@ -27,6 +27,8 @@
 import models.resource.Resource;
 import org.apache.commons.lang.BooleanUtils;
 
+import java.util.Optional;
+
 import static models.OrganizationUser.isAdmin;
 import static models.OrganizationUser.isMember;
 
@@ -114,7 +116,8 @@
             return false;
         }
 
-        if (isAllowedIfAuthor(user, container) || isAllowedIfAssignee(user, container)) {
+        if (isAllowedIfAuthor(user, container) || isAllowedIfAssignee(user, container)
+                || isAllowedIfSharer(user, container)) {
             return true;
         }
 
@@ -285,6 +288,7 @@
         case READ:
             return project.isPublic() && !user.isGuest
                     || user.isMemberOf(project)
+                    || isAllowedIfSharer(user, resource)
                     || isAllowedIfGroupMember(project, user);
         case UPDATE:
             return user.isMemberOf(project)
@@ -374,6 +378,17 @@
         }
     }
 
+    private static boolean isAllowedIfSharer(User user, Resource resource) {
+        switch (resource.getType()) {
+            case ISSUE_POST:
+            case ISSUE_COMMENT:
+                Issue issue = Issue.finder.byId(Long.valueOf(resource.getId()));
+                return issue != null && Optional.ofNullable(issue.findSharerByUserId(user.id)).isPresent();
+            default:
+                return false;
+        }
+    }
+
     /**
      * Checks if an user has a permission to do something to the given
      * resource as an assignee.
 
app/views/common/sharerCount.scala.html (added)
+++ app/views/common/sharerCount.scala.html
@@ -0,0 +1,11 @@
+@**
+* Yona, 21st Century Project Hosting SW
+*
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+* https://yona.io
+**@
+@(countNumber:Int, showColorAlways:Boolean = false)
+<a class="@if(showColorAlways){sharer-color}" data-toggle="tooltip" data-placement="bottom" title="@Messages("issue.sharer")">
+    <span class="count-groups item-icon">
+        <i class="yobicon-friends"></i>
+    </span><span class="count-groups item-count strong">@countNumber</span></a>
app/views/issue/my_partial_list_quicksearch.scala.html
--- app/views/issue/my_partial_list_quicksearch.scala.html
+++ app/views/issue/my_partial_list_quicksearch.scala.html
@@ -1,66 +1,72 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2014 NAVER Corp.
-* http://yobi.io
-*
-* @author Jihan Kim
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*   http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+* https://yona.io
 **@
-@(param:models.support.SearchCondition)
+@import models.support.SearchCondition
+@(param:SearchCondition)
+
+@currentUserId = @{
+    UserApp.currentUser().id
+}
 
 <ul class="lst-stacked unstyled">
     @if(!UserApp.currentUser().isAnonymous()){
-    <li @if(param.assigneeId == UserApp.currentUser().id){ class="active"}>
+    <li @if(param.assigneeId == currentUserId){ class="active"}>
         <a pjax-filter href="#"
             data-author-id=""
-            data-assignee-id="@UserApp.currentUser.id"
+            data-assignee-id="@currentUserId"
             data-commenter-id=""
             data-milestone-id="@param.milestoneId"
-            data-mention-id="">
+            data-mention-id=""
+            data-sharer-id="">
             @Messages("issue.list.assignedToMe")
         </a>
     </li>
-    <li @if(param.authorId == UserApp.currentUser().id){ class="active"}>
+    <li @if(param.authorId == currentUserId){ class="active"}>
         <a pjax-filter href="#"
-            data-author-id="@UserApp.currentUser.id"
+            data-author-id="@currentUserId"
             data-assignee-id=""
             data-commenter-id=""
             data-milestone-id="@param.milestoneId"
-            data-mention-id="">
+            data-mention-id=""
+            data-sharer-id="">
             @Messages("issue.list.authoredByMe")
         </a>
     </li>
-    <li @if(param.commenterId == UserApp.currentUser().id){ class="active"}>
+    <li @if(param.commenterId == currentUserId){ class="active"}>
         <a pjax-filter href="#"
         data-author-id=""
         data-assignee-id=""
-        data-commenter-id="@UserApp.currentUser.id"
+        data-commenter-id="@currentUserId"
         data-milestone-id="@param.milestoneId"
-        data-mention-id="">
+        data-mention-id=""
+        data-sharer-id="">
         @Messages("issue.list.commentedByMe")
         </a>
     </li>
-    <li @if(param.mentionId == UserApp.currentUser().id){ class="active"}>
+    <li @if(param.mentionId == currentUserId){ class="active"}>
         <a pjax-filter href="#"
             data-author-id=""
             data-assignee-id=""
             data-commenter-id=""
             data-milestone-id="@param.milestoneId"
-            data-mention-id="@UserApp.currentUser.id">
+            data-mention-id="@currentUserId"
+            data-sharer-id="">
             @Messages("issue.list.mentionedOfMe")
         </a>
     </li>
+    <li @if(param.sharerId == currentUserId){ class="active"}>
+        <a pjax-filter href="#"
+        data-author-id=""
+        data-assignee-id=""
+        data-commenter-id=""
+        data-milestone-id="@param.milestoneId"
+        data-mention-id=""
+        data-sharer-id="@currentUserId">
+        @Messages("issue.list.sharedWithMe") (@IssueSharer.getNumberOfIssuesSharedWithUser(currentUserId))
+        </a>
+    </li>
     }
 </ul>
app/views/issue/my_partial_search.scala.html
--- app/views/issue/my_partial_search.scala.html
+++ app/views/issue/my_partial_search.scala.html
@@ -1,11 +1,13 @@
 @**
 * Yona, 21st Century Project Hosting SW
 *
-* Copyright Yona & Yobi Authors & NAVER Corp.
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
 * https://yona.io
 **@
 @import java.util
-@(title: String, currentPage: com.avaje.ebean.Page[Issue], param:models.support.SearchCondition, project:Project)
+@import com.avaje.ebean.Page
+@import models.support.SearchCondition
+@(title: String, currentPage: Page[Issue], param:SearchCondition, project:Project)
 
 @import helper._
 @import models.enumeration._
@@ -33,6 +35,7 @@
                 <input type="hidden" name="commenterId" value="@param.commenterId" data-search="commenterId">
                 <input type="hidden" name="assigneeId" value="@param.assigneeId" data-search="assigneeId">
                 <input type="hidden" name="mentionId" value="@param.mentionId" data-search="mentionId">
+                <input type="hidden" name="sharerId" value="@param.sharerId" data-search="sharerId">
                 <div class="search myissues-search-input">
                     <div class="search-bar">
                         <input name="filter" class="textbox full" type="text" value="@param.filter" placeholder="@Messages("issue.search")">
app/views/issue/partial_comments.scala.html
--- app/views/issue/partial_comments.scala.html
+++ app/views/issue/partial_comments.scala.html
@@ -1,7 +1,7 @@
 @**
 * Yona, 21st Century Project Hosting SW
 *
-* Copyright Yona & Yobi Authors & NAVER Corp.
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
 * https://yona.io
 **@
 @(project:Project, issue:Issue)
@@ -178,6 +178,15 @@
                                 @Html(Messages("issue.event.referred",linkToUser(user.loginId, user.getDisplayName),linkToPullRequest(pull)))
                             }
                         }
+                        case EventType.ISSUE_SHARER_CHANGED => {
+                            @if(StringUtils.isBlank(event.oldValue) && StringUtils.isNotBlank(event.newValue)){
+                                <span class="state sharer-added">@Messages("issue.sharer")</span>
+                                @Html(Messages("issue.event.sharer.added", linkToUser(user.loginId, user.getPureNameOnly), linkToUser(event.newValue, User.findByLoginId(event.newValue).getPureNameOnly)))
+                            } else {
+                                <span class="state sharer-deleted">@Messages("issue.event.sharer.deleted.title")</span>
+                                @Html(Messages("issue.event.sharer.deleted", linkToUser(user.loginId, user.getPureNameOnly), linkToUser(event.oldValue, User.findByLoginId(event.oldValue).getPureNameOnly)))
+                            }
+                        }
                         case _ => {
                             @event.newValue by @linkToUser(user.loginId, user.getDisplayName)
                         }
app/views/issue/partial_list.scala.html
--- app/views/issue/partial_list.scala.html
+++ app/views/issue/partial_list.scala.html
@@ -62,7 +62,7 @@
                     </span>
                     }
 
-                    @if(issue.comments.size>0 || issue.voters.size>0) {
+                    @if(issue.comments.size > 0 || issue.voters.size > 0 || issue.sharers.size > 0) {
                     <span class="infos-item item-count-groups">
                     @if(issue.comments.size>0){
                         @views.html.common.commentCount(routes.IssueApp.issue(project.owner, project.name, issue.getNumber).toString + "#comments", issue.comments.size, true)
@@ -70,6 +70,9 @@
                     @if(issue.voters.size>0){
                         @views.html.common.voteCount(routes.IssueApp.issue(project.owner, project.name, issue.getNumber).toString + "#vote", issue.voters.size, true)
                     }
+                    @if(issue.sharers.size > 0){
+                        @views.html.common.sharerCount(issue.sharers.size, true)
+                    }
                     </span>
                     }
 
app/views/issue/view.scala.html
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
@@ -5,6 +5,7 @@
 * https://yona.io
 **@
 @(title:String, issue:Issue, issueForm: play.data.Form[Issue], commentForm: play.data.Form[Comment],project:Project)
+@import scala.collection.mutable.ArrayBuffer
 @import org.apache.commons.lang.StringUtils
 @import models.enumeration.ResourceType
 @import models.enumeration.Operation
@@ -56,6 +57,22 @@
     } else {
         issue.id
     }
+}
+
+@hasAssignee = @{
+    issue.assigneeName != null
+}
+
+@hasSharer = @{
+    issue.sharers.size > 0
+}
+
+@sharers = @{
+    var sharerIds = ArrayBuffer[String]()
+    for( sharedUser <- issue.sharers ) {
+        sharerIds += sharedUser.loginId
+    }
+    sharerIds.mkString(",")
 }
 
 @VOTER_AVATAR_SHOW_LIMIT = @{ 5 }
@@ -175,6 +192,9 @@
                                 }
                                 </button>
                             }
+                            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE) && !hasSharer) {
+                                <button id="issue-share-button" type="button" class="ybtn">@Messages("button.share.issue")</button>
+                            }
 
                         </div>
                     </div>
@@ -219,6 +239,24 @@
                     }
                     </span>
                 </div>
+                <dl class="sharer-list @if(!hasSharer){hideFromDisplayOnly}">
+                    <dt class="issue-share-title mb10">
+                        @Messages("issue.sharer") <span class="num issue-sharer-count">@if(issue.sharers.size > 0) { @issue.sharers.size }</span>
+                    </dt>
+                    <dd id="sharer-list" class="@if(!hasSharer){hideFromDisplayOnly}">
+                    @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                        <input type="hidden" class="bigdrop width100p" id="issueSharer" name="issueSharer" placeholder="@Messages("issue.sharer.select")" value="@sharers" title="">
+                    } else {
+                        @for(sharer <- issue.getSortedSharer){
+                            <div class="text-ellipsis sharer-item">
+                                <a href="@userInfo(sharer.loginId)" class="usf-group">
+                                    <strong class="name">@sharer.user.getDisplayName</strong>
+                                </a>
+                            </div>
+                        }
+                    }
+                    </dd>
+                </dl>
                 <div class="watcher-list"></div>
                 <div class="subtasks">
                 @if(issue.parent == null) {
@@ -254,23 +292,21 @@
                             <dt>@Messages("issue.assignee")</dt>
 
                             <dd>
-                            @defining(issue.assigneeName != null) { isAssigned =>
-                                @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
-                                    @partial_assignee(project, issue)
+                            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                                @partial_assignee(project, issue)
+                            } else {
+                                @if(hasAssignee){
+                                <a href="@userInfo(issue.assignee.user.loginId)" class="usf-group">
+                                    <span class="avatar-wrap smaller">
+                                        <img src="@User.findByLoginId(issue.assignee.user.loginId).avatarUrl" width="20" height="20">
+                                    </span>
+                                    <strong class="name">@issue.assignee.user.getDisplayName</strong>
+                                    <span class="loginid"> <strong>@{"@"}</strong>@issue.assignee.user.loginId</span>
+                                </a>
                                 } else {
-                                    @if(isAssigned){
-                                    <a href="@userInfo(issue.assignee.user.loginId)" class="usf-group">
-                                        <span class="avatar-wrap smaller">
-                                            <img src="@User.findByLoginId(issue.assignee.user.loginId).avatarUrl" width="20" height="20">
-                                        </span>
-                                        <strong class="name">@issue.assignee.user.getDisplayName</strong>
-                                        <span class="loginid"> <strong>@{"@"}</strong>@issue.assignee.user.loginId</span>
-                                    </a>
-                                    } else {
-                                    <div>
-                                       @Messages("issue.noAssignee")
-                                    </div>
-                                    }
+                                <div>
+                                   @Messages("issue.noAssignee")
+                                </div>
                                 }
                             }
                             </dd>
@@ -427,6 +463,7 @@
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.caret.min.js")"></script>
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.js")"></script>
 <script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.issue.Assginee.js")"></script>
+<script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.issue.Sharer.js")"></script>
 <script type="text/javascript">
     $(function(){
         // yobi.issue.View
@@ -471,6 +508,18 @@
                     "@Messages("issue.assignee")"
             );
 
+            yonaIssueSharerModule(
+                    "@api.routes.IssueApi.findSharerByloginIds(project.owner, project.name, issue.getNumber)",
+                    "@api.routes.IssueApi.findSharableUsers(project.owner, project.name, issue.getNumber)",
+                    "@api.routes.IssueApi.updateSharer(project.owner, project.name, issue.getNumber)",
+                    "@Messages("issue.sharer")"
+            );
+
+            $('#issue-share-button').on('click', function () {
+                $('#sharer-list').show();
+                $('.sharer-list').show();
+            });
+
             $('#translate').one('click', function (e) {
                 var data = {
                     owner: "@project.owner",
 
conf/evolutions/default/19.sql (added)
+++ conf/evolutions/default/19.sql
@@ -0,0 +1,19 @@
+# --- !Ups
+CREATE TABLE issue_sharer (
+  id                        BIGINT AUTO_INCREMENT NOT NULL,
+  created                   DATE,
+  login_id                  VARCHAR(255),
+  user_id                   BIGINT,
+  issue_id                   BIGINT,
+  CONSTRAINT pk_issue_sharer PRIMARY KEY (id),
+  CONSTRAINT fk_issue_sharer_user FOREIGN KEY (user_id) REFERENCES n4user (id) on DELETE CASCADE,
+  CONSTRAINT fk_issue_sharer_issue FOREIGN KEY (issue_id) REFERENCES issue (id) on DELETE CASCADE
+)
+row_format=compressed, key_block_size=8;
+
+CREATE index ix_issue_sharer_login_id ON issue_sharer (login_id);
+CREATE index ix_issue_sharer_user_id ON issue_sharer (user_id);
+CREATE index ix_issue_sharer_issue_id ON issue_sharer (issue_id);
+
+# --- !Downs
+DROP TABLE issue_sharer;
conf/messages
--- conf/messages
+++ conf/messages
@@ -75,6 +75,8 @@
 button.selectFile = Select file
 button.setDefaultLoginPage = Set to default page
 button.setDefaultLoginPage.desc = Make current page to index page when logged in
+button.share.issue = Issue Sharing
+button.share.issue.desc = Allow others can see this issue and receive notifications
 button.show.original = See text
 button.signup = Sign up for {0}
 button.submitForm = Submit form
@@ -282,6 +284,10 @@
 issue.event.open = {0} reopened this issue
 issue.event.referred = {0} mentioned this issue in {1}
 issue.event.referred.title = mentioned
+issue.event.sharer.added = {0} shared current issue to {1}
+issue.event.sharer.added.title = Issue sharing
+issue.event.sharer.deleted = {0} cancelled issue sharing with {1}
+issue.event.sharer.deleted.title = Cancelled
 issue.event.unassigned = {0} set assignee to unassigned
 issue.is.empty = No issue found
 issue.label = Issue Label
@@ -292,6 +298,7 @@
 issue.list.authoredByMe = Created
 issue.list.commentedByMe = Commented
 issue.list.mentionedOfMe = Mentioned
+issue.list.sharedWithMe = Shared
 issue.menu.new = New issue
 issue.menu.new.mine = New issue - personal inbox
 issue.myIssue = My issues
@@ -302,6 +309,8 @@
 issue.noMilestone = No milestone
 issue.option = Option
 issue.search = Search Issues
+issue.sharer = Issue Sharer
+issue.sharer.select = Select Issue Sharer
 issue.state = Status
 issue.state.all = All
 issue.state.assigned = Assigned
@@ -401,6 +410,8 @@
 notification.issue.closed  = Issue has been closed
 notification.issue.reopened = Issue has been reopened
 notification.issue.unassigned = Issue has been unassigned
+notification.issue.sharer.added = Issue is shared with {0}
+notification.issue.sharer.deleted = Issue sharing state is changed
 notification.linkToView = View it on {0}
 notification.linkToViewHtml = <a href="{1}" target="{2}">View it on {0}</a>
 notification.member.enroll.accept = Accepted as a member.
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -75,6 +75,8 @@
 button.selectFile = 파일 선택
 button.setDefaultLoginPage = 기본 페이지로 지정
 button.setDefaultLoginPage.desc = 현재 페이지를 로그인 후 표시되는 기본 인덱스 페이지로 지정합니다
+button.share.issue = 이슈 공유
+button.share.issue.desc = 이 이슈에 대한 접근과 알림을 허용할 사용자를 지정합니다
 button.show.original = 원문 보기
 button.signup = {0} 시작 하기
 button.submitForm = 폼 전송
@@ -282,6 +284,10 @@
 issue.event.open = {0}님이 이 이슈를 다시 열었습니다.
 issue.event.referred = {0} 님이 {1} 에서 이 이슈를 언급했습니다.
 issue.event.referred.title = 언급됨
+issue.event.sharer.added = {0} 님이 현재 이슈를 {1}님에게 공유했습니다.
+issue.event.sharer.added.title = 이슈 공유
+issue.event.sharer.deleted = {0} 님이 {1}님을 공유대상에서 제외했습니다.
+issue.event.sharer.deleted.title = 공유 취소
 issue.event.unassigned = {0}님이 이 이슈의 담당자를 "없음"으로 설정하였습니다.
 issue.is.empty = 등록된 이슈가 없습니다.
 issue.label = 이슈 라벨
@@ -292,6 +298,7 @@
 issue.list.authoredByMe = 작성한 이슈
 issue.list.commentedByMe = 댓글 남긴 이슈
 issue.list.mentionedOfMe = 나를 언급한 이슈
+issue.list.sharedWithMe = 공유된 이슈
 issue.menu.new = 새 이슈
 issue.menu.new.mine = 새 이슈 - 개인 inbox
 issue.myIssue = 내 이슈
@@ -302,6 +309,8 @@
 issue.noMilestone = 마일스톤 없음
 issue.option = 이슈 옵션
 issue.search = 이슈 검색
+issue.sharer = 이슈 공유
+issue.sharer.select = 이슈 공유 대상 선택
 issue.state = 상태
 issue.state.all = 전체
 issue.state.assigned = 할당됨
@@ -400,6 +409,8 @@
 notification.issue.assigned = {0}에게 이슈 할당됨
 notification.issue.closed  = 이슈 닫힘
 notification.issue.reopened = 이슈 다시 열림
+notification.issue.sharer.added = {0} 님에게 이슈가 공유되었습니다.
+notification.issue.sharer.deleted = 이슈 공유 상태가 변경되었습니다.
 notification.issue.unassigned = 이슈 담당자 없음
 notification.linkToView = {0}에서 보기
 notification.linkToViewHtml = <a href="{1}" target="{2}">View it on {0}</a>
@@ -442,6 +453,7 @@
 notification.type.issue.moved = 이슈가 {0}에서 {1}로 이동되었습니다
 notification.type.issue.referred.from.commit = 커밋에서의 이슈 언급
 notification.type.issue.referred.from.pullrequest = 코드 주고받기에서의 이슈 언급
+notification.type.issue.sharer.changed = 이슈 공유 변경
 notification.type.issue.state.changed = 이슈 상태 변경
 notification.type.member.enroll = 멤버 등록 요청
 notification.type.new.comment = 새 댓글 등록
conf/routes
--- conf/routes
+++ conf/routes
@@ -56,6 +56,9 @@
 GET            /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/assignableUsers                                                   controllers.api.IssueApi.findAssignableUsers(owner:String, projectName:String, number:Long, query: String ?= "")
 GET            /-_-api/v1/owners/:owner/projects/:projectName/assignableUsers                                                   controllers.api.IssueApi.findAssignableUsersOfProject(owner:String, projectName:String, query: String ?= "")
 POST           /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/assignees                                                   controllers.api.IssueApi.updateAssginees(owner:String, projectName:String, number:Long)
+GET            /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/findSharer                                              controllers.api.IssueApi.findSharerByloginIds(owner:String, projectName:String, number:Long, query: String ?= "")
+GET            /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/sharableUsers                                                   controllers.api.IssueApi.findSharableUsers(owner:String, projectName:String, number:Long, query: String ?= "")
+POST           /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/share                                                   controllers.api.IssueApi.updateSharer(owner:String, projectName:String, number:Long)
 GET            /-_-api/v1/users                                                        controllers.UserApp.users(query: String ?= "")
 POST           /-_-api/v1/users                                                        controllers.api.UserApi.newUser()
 POST           /-_-api/v1/owners/:owner/projects                                       controllers.api.ProjectApi.newProject(owner:String)
 
public/javascripts/service/yona.issue.Sharer.js (added)
+++ public/javascripts/service/yona.issue.Sharer.js
@@ -0,0 +1,113 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
+
+function yonaIssueSharerModule(findUsersByloginIdsApiUrl, findSharableUsersApiUrl, updateSharingApiUrl, message){
+  function formatter(result){
+    if(!result.avatarUrl){
+      return "<div>" + result.name + "</div>";
+    }
+
+    // Template text. Also you can use predefined template: $("#tplSelect2FormatUser").text()
+    var tplUserItem = "<div class='usf-group' title='${name} ${loginId}'>" +
+        "<strong class='name'>${name}</strong>";
+
+    var formattedResult = $yobi.tmpl(tplUserItem, {
+      "avatarURL": result.avatarUrl,
+      "name"     : result.name,
+      "loginId"  : result.loginId
+    });
+
+    return formattedResult;
+  }
+
+  function matcher(term, formattedResult, result){
+    term = term.toLowerCase();
+    formattedResult = formattedResult.toLowerCase();
+
+    var loginId = (typeof result.loginId !== "undefined") ? result.loginId.toLowerCase() : "";
+
+    return (loginId.indexOf(term) > -1) || (formattedResult.indexOf(term) > -1);
+  }
+
+  var $issueSharer = $("#issueSharer");
+  $issueSharer.select2({
+    minimumInputLength: 1,
+    multiple: true,
+    id: function(obj) {
+      return obj.loginId; // use slug field for id
+    },
+    ajax: { // instead of writing the function to execute the request we use Select2's convenient helper
+      url: findSharableUsersApiUrl,
+      dataType: "json",
+      quietMillis: 300,
+      data: function (term, page) {
+        return {
+          query: term, // search term
+        };
+      },
+      results: function (data, page) { // parse the results into the format expected by Select2.
+        // since we are using custom formatting functions we do not need to alter the remote JSON data
+        return { results: data };
+      },
+      cache: true
+    },
+    initSelection: function(element, callback) {
+      // the input tag has a value attribute preloaded that points to a preselected repository's id
+      // this function resolves that id attribute to an object that select2 can render
+      // using its formatResult renderer - that way the repository name is shown preselected
+
+      var ids = $(element).val();
+      if (ids !== "") {
+        $.ajax(findUsersByloginIdsApiUrl+ "?query=" + ids, {
+          dataType: "json"
+        }).done(function(data) {
+          if(data && data.length > 0) {
+            callback(data);
+          }
+        });
+      }
+    },
+    formatResult: formatter, // omitted for brevity, see the source of this page
+    formatSelection: formatter,  // omitted for brevity, see the source of this page
+    matcher: matcher,
+    escapeMarkup: function (m) { return m; } // we do not want to escape markup since we are displaying html in results
+  });
+
+  $issueSharer.on("select2-selecting", function(selected) {
+    var data = { sharer: [selected.val], action: 'add' };
+
+    if(updateSharingApiUrl){
+        $.ajax(updateSharingApiUrl, {
+            method: "POST",
+            dataType: "json",
+            contentType: "application/json",
+            data: JSON.stringify(data)
+        }).done(function(response){
+            $yobi.notify(response.action + ": " + response.sharer, 3000);
+        });
+    }
+  });
+
+  $issueSharer.on("select2-removing", function(selected) {
+    var data = { sharer: [selected.val], action: 'delete' };
+
+    if(updateSharingApiUrl){
+      $.ajax(updateSharingApiUrl, {
+        method: "POST",
+        dataType: "json",
+        contentType: "application/json",
+        data: JSON.stringify(data)
+      }).done(function(response){
+        $yobi.notify(response.action + ": " + response.sharer, 3000);
+      });
+    }
+  });
+
+  $issueSharer.on('change', function (e) {
+    $(".issue-sharer-count").text(e.val.length);
+  });
+}
Add a comment
List