Mijeong Park Mijeong Park 2018-03-06
Merge branch 'next' before release v1.9.0
Reviewed-by: Suwon Chae
Reviewed-by: Mijeong Park
@033bbd48516253afec671807b4e55505af27c88d
AUTHORS
--- AUTHORS
+++ AUTHORS
@@ -48,3 +48,4 @@
 rimi <rimi@userinsight.co.kr>
 kenu <kenu.heo@gmail.com>
 DongHo Byun <cpascal@nextfloor.com>
+Mijeong Park <p.mj@naverlabs.com>
app/Global.java
--- app/Global.java
+++ app/Global.java
@@ -158,17 +158,27 @@
         isSecretInvalid = equalsDefaultSecret();
         insertInitialData();
 
+        Timestamp timestamp = new Timestamp("=== Yona server starting initialization ===");
         Config.onStart();
+        timestamp.logElapsedTime("--- Config reading: ok!");
         Property.onStart();
+        timestamp.logElapsedTime("--- Property reading: ok!");
         PullRequest.onStart();
+        timestamp.logElapsedTime("--- Pull request checking: ok!");
         NotificationMail.onStart();
+        timestamp.logElapsedTime("--- Notification mail scheduler: ok!");
         NotificationEvent.onStart();
+        timestamp.logElapsedTime("--- Notification event cleanup scheduler: ok!");
         Attachment.onStart();
+        timestamp.logElapsedTime("--- Temporary files cleanup scheduler: ok!");
         AccessControl.onStart();
+        timestamp.logElapsedTime("--- Basic access controller config reading: ok!");
 
         if (!isSecretInvalid) {
             YobiUpdate.onStart();
+            timestamp.logElapsedTime("--- Update checker run: ok! ");
             mailboxService.start();
+            timestamp.logElapsedTime("--- MailboxService checker run: ok!");
         }
 
         PlayAuthenticate.setResolver(new PlayAuthenticate.Resolver() {
app/actors/CommitsNotificationActor.java
--- app/actors/CommitsNotificationActor.java
+++ app/actors/CommitsNotificationActor.java
@@ -1,23 +1,10 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2013 NAVER Corp.
- * http://yobi.io
- *
- * @author Keesun Baik
- *
- * 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 actors;
 
 import models.*;
@@ -29,8 +16,6 @@
 
 /**
  * Creates new commit notifications.
- *
- * @author Keesun Baik
  */
 public class CommitsNotificationActor extends PostReceiveActor {
 
@@ -50,10 +35,7 @@
             title = Messages.get("notification.pushed.commits", project.name, commits.size());
         }
 
-        Set<User> watchers = Watch.findWatchers(project.asResource());
-        watchers.remove(sender);
-
-        NotificationEvent.afterNewCommits(commits, refNames, project, sender, title, watchers);
+        NotificationEvent.afterNewCommits(commits, refNames, project, sender, title);
     }
 
 }
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; }
@@ -277,3 +278,31 @@
 input.white:-moz-placeholder             { color:#fff; opacity:0.8; } /* Firefox 18- */
 input.white::-moz-placeholder            { color:#fff; opacity:0.8; } /* Firefox 19+ */
 input.white:-ms-input-placeholder        { color:#fff; opacity:0.8; }
+
+.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;
+}
+
+.fixed-height-my-issues-list {
+    line-height: 36px;
+}
+
+.dimgray { color:dimgray; }
(No newline at end of file)
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
@@ -419,6 +419,19 @@
                         font-size: 14px;
                         margin-left:5px;
                     }
+
+                    .user-project-list {
+                        .star {
+                            color: rgba(255, 255, 255, 0.22);
+                            &:hover {
+                                color: #e91e63;
+                                cursor: pointer;
+                            }
+                        }
+                        .starred {
+                            color: #e91e63 !important;
+                        }
+                    }
                 }
 
                 .project-origin {
@@ -1159,12 +1172,7 @@
 
                             &.nowrap {
                                 min-height:20px;
-                                height:20px;
-                                .message {
-                                    text-overflow: ellipsis;
-                                    white-space: nowrap;
-                                    overflow: hidden;
-                                }
+                                max-height:200px;
                             }
                         }
                     }
@@ -3029,7 +3037,7 @@
         }
 
         .event {
-            padding: 10px 0 10px 55px;
+            padding: 2px 0 2px 55px;
             font-size: 1em;
             line-height: 30px;
 
@@ -3053,6 +3061,25 @@
                 &.rejected {background: #F39C12;}
                 &.conflict {background: #C0392B;}
                 &.resolved {background: #468847;}
+                &.sharer-added {
+                    background-color: #B2EBF2;
+                    color: #0097A7;
+                }
+                &.sharer-deleted {
+                    background-color: #FFCCBC;
+                    color: #F4511E;
+                }
+                &.label-added {
+                    background-color: #B2EBF2;
+                    color: #0097A7;
+                }
+                &.label-deleted {
+                    background-color: #FFCCBC;
+                    color: #F4511E;
+                }
+                &.milestone-changed {
+                    background-color: #0088cc;
+                }
              }
 
              em { font-size:12px; color:#7F8C8D; margin-right:5px;}
@@ -3652,6 +3679,7 @@
         line-height: 20px;
         font-size:12px;
         color:#999;
+        overflow: hidden;
 
         .infos-item {
             margin-right:6px;
@@ -3732,8 +3760,15 @@
     .comments-count-color {
         color: @darkmagenta;
     }
+    .sharer-color {
+        color: @green;
+    }
 
     a:nth-child(2) {
+        margin-left: -5px;
+    }
+
+    a:nth-child(3) {
         margin-left: -5px;
     }
 
@@ -3783,7 +3818,6 @@
 .mileston-tag {
     max-width: 135px;
     text-overflow: ellipsis;
-    white-space: nowrap;
     overflow: hidden;
     color: #2196f3;
     font-size:11px;
@@ -6984,7 +7018,7 @@
     -moz-shadow: inset 0 0 5px 5px #222;
     //box-shadow: inset 0 0 5px 5px #222;
     box-shadow: 2px 2px 8px #000;
-    background: #FFF url('/assets/images/loading-gif-2.gif') no-repeat center;
+    background: #FFF url('@{base-image-path}/loading-gif-2.gif') no-repeat center;
 }
 
 .myissues-search-input {
@@ -6997,6 +7031,7 @@
     font-size: 15px;
     color: rgba(0, 0, 0, 0.7);
     padding: 0 2px;
+    vertical-align: top;
 }
 
 .no-margin {
@@ -7005,11 +7040,56 @@
 
 .my-issues {
     .post-item {
-        padding: 6px 10px;
+        padding: 2px 10px;
+        color: #999999;
       
         .title-wrap {
             margin-top: 2px;
+            white-space: normal;
+            overflow: auto;
+            display:table;
+
+            .title-cell {
+                display:table-cell;
+                vertical-align:middle;
+            }
+
+            .item-count-groups {
+                font-size: 10px;
+            }
+            .title {
+                font-size: 14px;
+                font-weight: 400;
+            }
         }
+
+        .author {
+            display:table;
+
+            .author-cell {
+                display:table-cell;
+                vertical-align:middle;
+                text-overflow: ellipsis;
+                overflow: hidden;
+                white-space: nowrap;
+            }
+        }
+        .meta {
+            display:table;
+
+            .meta-cell {
+                display:table-cell;
+                vertical-align:middle;
+            }
+        }
+
+        .post-id {
+            color:#999;
+            margin-right:5px;
+            font-size: 12px;
+            font-weight: normal;
+        }
+
         .infos {
             margin-top: 4px;
         }
@@ -7056,3 +7136,34 @@
         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;
+    }
+}
+
+.project-name-in-my-issues {
+    display: flex !important;
+    flex-direction: row;
+    flex-wrap: nowrap;
+    flex-grow: 1;
+    justify-content: space-between;
+    align-items: center;
+
+    .project-name {
+        text-overflow: ellipsis;
+        overflow: hidden;
+        white-space: nowrap;
+    }
+}
app/assets/stylesheets/less/_yobiUI.less
--- app/assets/stylesheets/less/_yobiUI.less
+++ app/assets/stylesheets/less/_yobiUI.less
@@ -581,8 +581,8 @@
 .yobiToasts {
     position: fixed;
     overflow: hidden;
-    left: 20px !important;
-    top: 25px !important;
+    right: 20px !important;
+    bottom: 25px !important;
     margin: 10px;
     z-index:9999;
 
app/controllers/Application.java
--- app/controllers/Application.java
+++ app/controllers/Application.java
@@ -38,6 +38,8 @@
     public static String LOGIN_PAGE_LOGINID_PLACEHOLDER  = play.Configuration.root().getString("application.login.page.loginId.placeholder", "");
     public static String LOGIN_PAGE_PASSWORD_PLACEHOLDER  = play.Configuration.root().getString("application.login.page.password.placeholder", "");
     public static boolean SHOW_USER_EMAIL = play.Configuration.root().getBoolean("application.show.user.email", true);
+    public static String NAVBAR_CUSTOM_LINK_NAME  = play.Configuration.root().getString("application.navbar.custom.link.name", "");
+    public static String NAVBAR_CUSTOM_LINK_URL  = play.Configuration.root().getString("application.navbar.custom.link.url", "");
 
     @AnonymousCheck
     public static Result index() {
app/controllers/AttachmentApp.java
--- app/controllers/AttachmentApp.java
+++ app/controllers/AttachmentApp.java
@@ -1,8 +1,9 @@
 /**
- * Yona, Project Hosting SW
- *
- * Copyright 2016 the original author or authors.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
 package controllers;
 
 import com.fasterxml.jackson.databind.JsonNode;
@@ -67,7 +68,7 @@
 
         User uploader = findUploader(request().body().asMultipartFormData().asFormUrlEncoded());
         if (uploader.isAnonymous()) {
-            uploader = User.findByUserToken(request().getHeader("Yona-Token"));
+            uploader = User.findByUserToken(User.extractUserTokenFromRequestHeader(request()));
         }
 
         // Anonymous cannot upload a file.
app/controllers/BoardApp.java
--- app/controllers/BoardApp.java
+++ app/controllers/BoardApp.java
@@ -1,8 +1,9 @@
 /**
- * Yona, Project Hosting SW
- *
- * Copyright 2016 the original author or authors.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
 package controllers;
 
 import actions.NullProjectCheckAction;
@@ -353,7 +354,7 @@
             public void run() {
                 post.comments = original.comments;
                 if(isSelectedToSendNotificationMail() || !original.isAuthoredBy(UserApp.currentUser())){
-                    NotificationEvent.afterNewPost(post);
+                    NotificationEvent.afterUpdatePosting(original.body, post);
                 }
             }
         };
@@ -379,6 +380,7 @@
         Posting posting = Posting.findByNumber(project, number);
         Call redirectTo = routes.BoardApp.posts(project.owner, project.name, 1);
 
+        NotificationEvent.afterResourceDeleted(posting, UserApp.currentUser());
         return delete(posting, posting.asResource(), redirectTo);
     }
 
@@ -436,6 +438,7 @@
         return new Runnable() {
             @Override
             public void run() {
+                posting.updatedDate = JodaDateUtil.now();
                 comment.posting = posting;
             }
         };
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.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 controllers;
@@ -44,6 +44,7 @@
 
     @AnonymousCheck(requiresLogin = false, displaysFlashMessage = true)
     public static Result organizationIssues(@Nonnull String organizationName, @Nonnull String state, @Nonnull String format, int pageNum) throws WriteException, IOException {
+
         // SearchCondition from param
         Form<models.support.SearchCondition> issueParamForm = new Form<>(models.support.SearchCondition.class);
         models.support.SearchCondition searchCondition = issueParamForm.bindFromRequest().get();
@@ -107,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
@@ -384,49 +385,11 @@
                 continue;
             }
 
-            boolean assigneeChanged = false;
-            User oldAssignee = null;
-            if (issueMassUpdate.assignee != null) {
-                if(hasAssignee(issue)) {
-                    oldAssignee = issue.assignee.user;
-                }
-                Assignee newAssignee;
-                if (issueMassUpdate.assignee.isAnonymous()) {
-                    newAssignee = null;
-                } else {
-                    newAssignee = Assignee.add(issueMassUpdate.assignee.id, project.id);
-                }
-                assigneeChanged = !issue.assignedUserEquals(newAssignee);
-                issue.assignee = newAssignee;
-            }
-
-            boolean stateChanged = false;
-            State oldState = null;
-            if ((issueMassUpdate.state != null) && (issue.state != issueMassUpdate.state)) {
-                stateChanged = true;
-                oldState = issue.state;
-                issue.state = issueMassUpdate.state;
-            }
-
-            if (issueMassUpdate.milestone != null) {
-                if(issueMassUpdate.milestone.isNullMilestone()) {
-                    issue.milestone = null;
-                } else {
-                    issue.milestone = issueMassUpdate.milestone;
-                }
-            }
-
-            if (issueMassUpdate.attachingLabelIds != null) {
-                for (Long labelId : issueMassUpdate.attachingLabelIds) {
-                    issue.labels.add(IssueLabel.finder.byId(labelId));
-                }
-            }
-
-            if (issueMassUpdate.detachingLabelIds != null) {
-                for (Long labelId : issueMassUpdate.detachingLabelIds) {
-                    issue.labels.remove(IssueLabel.finder.byId(labelId));
-                }
-            }
+            updateAssigneeIfChanged(issueMassUpdate.assignee, project, issue);
+            updateStateIfChanged(issueMassUpdate.state, issue);
+            updateMilestoneIfChanged(issueMassUpdate.milestone, issue);
+            updateLabelIfChanged(issueMassUpdate.attachingLabelIds,
+                    issueMassUpdate.detachingLabelIds, issue);
 
             if (issueMassUpdate.isDueDateChanged) {
                 issue.dueDate = JodaDateUtil.lastSecondOfDay(issueMassUpdate.dueDate);
@@ -435,15 +398,6 @@
             issue.updatedDate = JodaDateUtil.now();
             issue.update();
             updatedItems++;
-
-            if(assigneeChanged) {
-                NotificationEvent notiEvent = NotificationEvent.afterAssigneeChanged(oldAssignee, issue);
-                IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
-            }
-            if(stateChanged) {
-                NotificationEvent notiEvent = NotificationEvent.afterStateChanged(oldState, issue);
-                IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
-            }
         }
 
         if (updatedItems == 0 && rejectedByPermission > 0) {
@@ -465,6 +419,106 @@
             }
         } else {
             return redirect(request().getHeader("Referer"));
+        }
+    }
+
+    private static void updateLabelIfChanged(List<Long> attachingLabelIds, List<Long> detachingLabelIds,
+                                             Issue issue) {
+        boolean isLabelChanged = false;
+        StringBuilder addedLabels = new StringBuilder();
+        StringBuilder deletedLabels = new StringBuilder();
+
+        if (attachingLabelIds != null) {
+            for (Long labelId : attachingLabelIds) {
+                IssueLabel label = IssueLabel.finder.byId(labelId);
+                issue.labels.add(label);
+                isLabelChanged = true;
+                addedLabels.append(label.category.name).append(" - ").append(label.name);
+            }
+        }
+
+        if (detachingLabelIds != null) {
+            for (Long labelId : detachingLabelIds) {
+                IssueLabel label = IssueLabel.finder.byId(labelId);
+                issue.labels.remove(label);
+                isLabelChanged = true;
+                deletedLabels.append(label.category.name).append(" - ").append(label.name);
+            }
+        }
+
+        if(isLabelChanged) {
+            NotificationEvent notiEvent = NotificationEvent.afterIssueLabelChanged(
+                    addedLabels.toString(),
+                    deletedLabels.toString(),
+                    issue);
+            IssueEvent.addFromNotificationEventWithoutSkipEvent(notiEvent, issue, UserApp.currentUser().loginId);
+        }
+    }
+
+    private static void updateMilestoneIfChanged(Milestone newMilestone, Issue issue) {
+
+        Long oldMilestoneId = issue.milestoneId();
+
+        if (!isMilestoneChanged(newMilestone, issue.milestone)) {
+            return;
+        }
+
+        if(newMilestone.isNullMilestone()) {
+            issue.milestone = null;
+        } else {
+            issue.milestone = newMilestone;
+        }
+        NotificationEvent notiEvent = NotificationEvent.afterMilestoneChanged(oldMilestoneId, issue);
+        IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
+    }
+
+    private static boolean isMilestoneChanged(Milestone newMilestone, Milestone oldMilestone) {
+        if (newMilestone == null) {
+            return false;
+        }
+
+        if (oldMilestone != null && oldMilestone.id.equals(newMilestone.id)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    private static void updateStateIfChanged(State newState, Issue issue) {
+        boolean stateChanged = false;
+        State oldState = null;
+        if ((newState != null) && (issue.state != newState)) {
+            stateChanged = true;
+            oldState = issue.state;
+            issue.state = newState;
+        }
+        if(stateChanged) {
+            NotificationEvent notiEvent = NotificationEvent.afterStateChanged(oldState, issue);
+            IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
+        }
+    }
+
+    private static void updateAssigneeIfChanged(User assignee, Project project, Issue issue) {
+        boolean assigneeChanged = false;
+        User oldAssignee = null;
+
+        if (assignee != null) {
+            if(hasAssignee(issue)) {
+                oldAssignee = issue.assignee.user;
+            }
+            Assignee newAssignee;
+            if (assignee.isAnonymous()) {
+                newAssignee = null;
+            } else {
+                newAssignee = Assignee.add(assignee.id, project.id);
+            }
+            assigneeChanged = !issue.assignedUserEquals(newAssignee);
+            issue.assignee = newAssignee;
+        }
+
+        if(assigneeChanged) {
+            NotificationEvent notiEvent = NotificationEvent.afterAssigneeChanged(oldAssignee, issue);
+            IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
         }
     }
 
@@ -796,6 +850,7 @@
         Call redirectTo =
             routes.IssueApp.issues(project.owner, project.name, State.OPEN.state(), "html", 1);
 
+        NotificationEvent.afterResourceDeleted(issue, UserApp.currentUser());
         return delete(issue, issue.asResource(), redirectTo);
     }
 
@@ -845,16 +900,16 @@
     private static Comment saveComment(Project project, Issue issue, IssueComment comment) {
         Comment savedComment;
         IssueComment existingComment = IssueComment.find.where().eq("id", comment.id).findUnique();
-        if (existingComment != null) {
+        if (existingComment == null) {
+            comment.projectId = project.id;
+            savedComment = saveComment(comment, getContainerUpdater(issue, comment));
+            NotificationEvent.afterNewComment(savedComment);
+        } else {
             existingComment.contents = comment.contents;
             savedComment = saveComment(existingComment, getContainerUpdater(issue, comment));
             if(isSelectedToSendNotificationMail() || !existingComment.isAuthoredBy(UserApp.currentUser())){
                 NotificationEvent.afterCommentUpdated(savedComment);
             }
-        } else {
-            comment.projectId = project.id;
-            savedComment = saveComment(comment, getContainerUpdater(issue, comment));
-            NotificationEvent.afterNewComment(savedComment);
         }
         return savedComment;
     }
app/controllers/LabelApp.java
--- app/controllers/LabelApp.java
+++ app/controllers/LabelApp.java
@@ -53,6 +53,10 @@
             return status(Http.Status.NOT_ACCEPTABLE);
         }
 
+        if (limit == null) {
+            return badRequest("No limit");
+        }
+
         ExpressionList<Label> el =
                 Label.find.where().and(icontains("category", category), icontains("name", query));
 
@@ -77,6 +81,10 @@
             return status(Http.Status.NOT_ACCEPTABLE);
         }
 
+        if (limit == null) {
+            return badRequest("No limit");
+        }
+
         SqlQuery sqlQuery;
         SqlQuery sqlCountQuery;
 
app/controllers/OrganizationApp.java
--- app/controllers/OrganizationApp.java
+++ app/controllers/OrganizationApp.java
@@ -1,14 +1,17 @@
 /**
- * Yona, Project Hosting SW
- *
- * Copyright 2017 the original author or authors.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
 package controllers;
 
 import com.avaje.ebean.ExpressionList;
 import com.avaje.ebean.Page;
 import controllers.annotation.AnonymousCheck;
 import controllers.annotation.GuestProhibit;
+import controllers.PullRequestApp.SearchCondition;
+import controllers.PullRequestApp.Category;
 import models.*;
 import models.enumeration.Operation;
 import models.enumeration.RequestState;
@@ -30,6 +33,7 @@
 import views.html.organization.members;
 import views.html.organization.setting;
 import views.html.organization.view;
+import views.html.organization.group_pullrequest_list;
 
 import javax.servlet.ServletException;
 import javax.validation.ConstraintViolation;
@@ -45,6 +49,31 @@
  */
 @AnonymousCheck
 public class OrganizationApp extends Controller {
+
+    @AnonymousCheck(requiresLogin = false, displaysFlashMessage = true)
+    public static Result organizationPullRequests(String organizationName, String category) {
+
+        Organization organization = Organization.findByName(organizationName);
+        if (organization == null) {
+            return notFound(ErrorViews.NotFound.render("error.notfound.organization"));
+        }
+
+        SearchCondition condition = Form.form(SearchCondition.class).bindFromRequest().get();
+        if (category.equals("open")) {
+            condition.setOrganization(organization).setCategory(Category.OPEN);
+        } else {
+            condition.setOrganization(organization).setCategory(Category.CLOSED);
+        }
+        Page<PullRequest> page = PullRequest.findPagingList(condition);
+
+        return ok(group_pullrequest_list.render("title.pullrequest",  organization, page, condition, category));
+    }
+
+    @AnonymousCheck(requiresLogin = false, displaysFlashMessage = true)
+    public static Result organizationClosedPullRequests(String organizationName) {
+        return organizationPullRequests(organizationName, "closed");
+    }
+
     /**
      * show New Group page
      * @return {@link Result}
app/controllers/ProjectApp.java
--- app/controllers/ProjectApp.java
+++ app/controllers/ProjectApp.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 controllers;
@@ -634,7 +634,6 @@
         return redirect(url);
     }
 
-    @Transactional
     @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
     public static synchronized Result acceptTransfer(Long id, String confirmKey) throws IOException, ServletException {
         ProjectTransfer pt = ProjectTransfer.findValidOne(id);
@@ -653,15 +652,25 @@
 
         // Change the project's name and move the repository.
         String newProjectName = Project.newProjectName(pt.destination, project.name);
-        PlayRepository repository = RepositoryService.getRepository(project);
-        repository.move(project.owner, project.name, pt.destination, newProjectName);
 
-        User newOwnerUser = User.findByLoginId(pt.destination);
-        Organization newOwnerOrg = Organization.findByName(pt.destination);
+        // Following three local variables are used for bottom of this method
+        String originalProjectOwner = project.owner;
+        String originalProjectName = project.name;
+        String destinationOwner = pt.destination;
+        Long senderId = pt.sender.id;
+
+        disableProjectTransferLink(pt, project, newProjectName);
+        PlayRepository repository = RepositoryService.getRepository(project);
+
+        // intentionally placed to the last of method
+        repository.move(originalProjectOwner, originalProjectName, destinationOwner, newProjectName);
+
+        User newOwnerUser = User.findByLoginId(destinationOwner);
+        Organization newOwnerOrg = Organization.findByName(destinationOwner);
 
         // Change the project's information.
         project.recordRenameOrTransferHistoryIfLastChangePassed24HoursFrom(project);
-        project.owner = pt.destination;
+        project.owner = destinationOwner;
         project.name = newProjectName;
         if (newOwnerOrg != null) {
             project.organization = newOwnerOrg;
@@ -671,25 +680,27 @@
         project.update();
 
         // Change roles.
-        if (ProjectUser.isManager(pt.sender.id, project.id)) {
-            ProjectUser.assignRole(pt.sender.id, project.id, RoleType.MEMBER);
+        if (ProjectUser.isManager(senderId, project.id)) {
+            ProjectUser.assignRole(senderId, project.id, RoleType.MEMBER);
         }
         if (!newOwnerUser.isAnonymous()) {
             ProjectUser.assignRole(newOwnerUser.id, project.id, RoleType.MANAGER);
         }
 
-        // Change the tranfer's status to be accepted.
-        pt.newProjectName = newProjectName;
-        pt.accepted = true;
-        pt.update();
-
-        // If the opposite request is exists, delete it.
-        ProjectTransfer.deleteExisting(project, pt.sender, pt.destination);
         CacheStore.refreshProjectMap();
 
         return redirect(routes.ProjectApp.project(project.owner, project.name));
     }
 
+    private static void disableProjectTransferLink(ProjectTransfer pt, Project project, String newProjectName) {
+        // Change the tranfer's status to be accepted.
+        pt.newProjectName = newProjectName;
+        pt.accepted = true;
+
+        // If the opposite request is exists, delete it.
+        ProjectTransfer.deleteExisting(project, pt.sender, pt.destination);
+    }
+
     @IsAllowed(Operation.UPDATE)
     public static Result changeVCSForm(String ownerId, String projectName) {
         Project project = Project.findByOwnerAndProjectName(ownerId, projectName);
app/controllers/PullRequestApp.java
--- app/controllers/PullRequestApp.java
+++ app/controllers/PullRequestApp.java
@@ -343,7 +343,7 @@
 
         // Only members can access code?
         if(project.isCodeAccessibleMemberOnly && !project.hasMember(UserApp.currentUser())) {
-                return forbidden(ErrorViews.Forbidden.render("error.forbidden", project));
+            return forbidden(ErrorViews.Forbidden.render("error.forbidden", project));
         }
 
         SearchCondition condition = Form.form(SearchCondition.class).bindFromRequest().get();
@@ -697,6 +697,12 @@
         public Long contributorId;
         public int pageNum = Constants.DEFAULT_PAGE;
         public Category category;
+        public Organization organization;
+
+        public SearchCondition setOrganization(Organization organization) {
+            this.organization = organization;
+            return this;
+        }
 
         public SearchCondition setProject(Project project) {
             this.project = project;
@@ -731,6 +737,7 @@
             clone.contributorId = this.contributorId;
             clone.pageNum = this.pageNum;
             clone.category = this.category;
+            clone.organization = this.organization;
             return clone;
         }
 
app/controllers/UserApp.java
--- app/controllers/UserApp.java
+++ app/controllers/UserApp.java
@@ -1177,6 +1177,10 @@
         } catch (CommunicationException e) {
             play.Logger.error("Cannot connect to ldap server \n" + e.getMessage());
             e.printStackTrace();
+            if(FALLBACK_TO_LOCAL_LOGIN){
+                play.Logger.warn("fallback to local login: " + loginIdOrEmail);
+                return authenticateWithPlainPassword(loginIdOrEmail, password);
+            }
             return User.anonymous;
         } catch (AuthenticationException e) {
             flash(Constants.WARNING, Messages.get("user.login.invalid"));
app/controllers/WatchProjectApp.java
--- app/controllers/WatchProjectApp.java
+++ app/controllers/WatchProjectApp.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 controllers;
@@ -20,6 +20,8 @@
 import play.mvc.Result;
 import utils.AccessControl;
 import utils.ErrorViews;
+
+import static models.UserProjectNotification.*;
 
 @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
 public class WatchProjectApp extends Controller {
@@ -55,11 +57,15 @@
             return badRequest(Messages.get("error.notfound.watch"));
         }
 
-        UserProjectNotification upn = UserProjectNotification.findOne(user, project, notiType);
-        if(upn == null) { // make the EventType OFF, because default is ON.
-            UserProjectNotification.unwatchExplictly(user, project, notiType);
+        UserProjectNotification userProjectNotification = findOne(user, project, notiType);
+        if(userProjectNotification == null) { // not specified yet
+            if (isNotifiedByDefault(notiType)) {
+                unwatchExplictly(user, project, notiType);
+            } else {
+                watchExplictly(user, project, notiType);
+            }
         } else {
-            upn.toggle();
+            userProjectNotification.toggle(notiType);
         }
 
         return ok();
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/mailbox/MailboxService.java
--- app/mailbox/MailboxService.java
+++ app/mailbox/MailboxService.java
@@ -1,23 +1,10 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2014 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 mailbox;
 
 import akka.actor.Cancellable;
@@ -29,6 +16,7 @@
 import play.Configuration;
 import play.Logger;
 import play.libs.Akka;
+import play.libs.F;
 import scala.concurrent.duration.Duration;
 import utils.Diagnostic;
 import utils.SimpleDiagnostic;
@@ -184,17 +172,7 @@
             return;
         }
 
-        try {
-            EmailHandler.handleNewMessages(folder);
-        } catch (MessagingException e) {
-            play.Logger.error("Failed to handle new messages");
-        }
-
-        try {
-            startEmailListener();
-        } catch (Exception e) {
-            startEmailPolling();
-        }
+        handleNewMessagesAndStartListener();
 
         Diagnostic.register(new SimpleDiagnostic() {
             @Override
@@ -210,6 +188,27 @@
         });
     }
 
+    private void handleNewMessagesAndStartListener() {
+        F.Promise<Void> promise = F.Promise.promise(
+                new F.Function0<Void>() {
+                    public Void apply() {
+                        try {
+                            EmailHandler.handleNewMessages(folder);
+                        } catch (MessagingException e) {
+                            Logger.error("Failed to handle new messages");
+                        }
+
+                        try {
+                            startEmailListener();
+                        } catch (Exception e) {
+                            startEmailPolling();
+                        }
+                        return null;
+                    }
+                }
+        );
+    }
+
     /**
      * Reopen the IMAP folder which is used by MailboxService.
      *
app/models/AbstractPosting.java
--- app/models/AbstractPosting.java
+++ app/models/AbstractPosting.java
@@ -254,12 +254,6 @@
         actualWatchers.addAll(baseWatchers);
 
         actualWatchers.add(getAuthor());
-        for (Comment c : getComments()) {
-            User user = User.find.byId(c.authorId);
-            if (user != null) {
-                actualWatchers.add(user);
-            }
-        }
 
         return Watch.findActualWatchers(actualWatchers, asResource(), allowedWatchersOnly);
     }
app/models/FavoriteProject.java
--- app/models/FavoriteProject.java
+++ app/models/FavoriteProject.java
@@ -49,4 +49,11 @@
             favoriteProject.update();
         }
     }
+
+    public static FavoriteProject findByProjectId(Long userId, Long projectId){
+        return finder.where()
+                .eq("user.id", userId)
+                .eq("project.id", projectId)
+                .findUnique();
+    }
 }
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",
@@ -130,6 +133,13 @@
 
     public String assigneeName() {
         return ((assignee != null && assignee.user != null) ? assignee.user.name : null);
+    }
+
+    public Long milestoneId() {
+        if (milestone == null) {
+            return Milestone.NULL_MILESTONE_ID;
+        }
+        return milestone.id;
     }
 
     public boolean hasAssignee() {
@@ -245,6 +255,7 @@
                 Messages.get("issue.label"),
                 Messages.get("issue.createdDate"),
                 Messages.get("issue.dueDate"),
+                Messages.get("milestone"),
                 "URL",
                 Messages.get("common.comment"),
                 Messages.get("common.comment.author"),
@@ -261,6 +272,7 @@
 
             lineNumber++;
             int columnPos = 0;
+            String milestoneName = issue.milestone != null ? issue.milestone.title : "";
             sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, issue.getNumber().toString(), bodyCellFormat));
             sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, issue.state.toString(), bodyCellFormat));
             sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, issue.title, bodyCellFormat));
@@ -269,6 +281,7 @@
             sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, getIssueLabels(issue), bodyCellFormat));
             sheet.addCell(new jxl.write.DateTime(columnPos++, lineNumber, issue.createdDate, dateCellFormat));
             sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, JodaDateUtil.geYMDDate(issue.dueDate), bodyCellFormat));
+            sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, milestoneName, bodyCellFormat));
             sheet.addCell(new jxl.write.Label(columnPos++, lineNumber, controllers.routes.IssueApp.issue(issue.project.owner, issue.project.name, issue.number).toString(), bodyCellFormat));
             if (comments.size() > 0) {
                 for (int j = 0; j < comments.size(); j++) {
@@ -675,4 +688,24 @@
                 .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);
+    }
+
+    public static int getCountOfMentionedOpenIssues(Long userId) {
+        return finder.where()
+                .in("id", Mention.getMentioningIssueIds(userId))
+                .eq("state", State.OPEN)
+                .findRowCount();
+    }
 }
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/Mention.java
--- app/models/Mention.java
+++ app/models/Mention.java
@@ -1,23 +1,10 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2014 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.ResourceType;
@@ -27,7 +14,13 @@
 import javax.persistence.Entity;
 import javax.persistence.Id;
 import javax.persistence.ManyToOne;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
+
+import static models.enumeration.ResourceType.ISSUE_COMMENT;
+import static models.enumeration.ResourceType.ISSUE_POST;
 
 @Entity
 public class Mention extends Model {
@@ -73,4 +66,37 @@
             mention.save();
         }
     }
+
+    public static List<Long> getMentioningIssueIds(Long mentionUserId) {
+        Set<Long> ids = new HashSet<>();
+        Set<Long> commentIds = new HashSet<>();
+
+        for (Mention mention : Mention.find.where()
+                .eq("user.id", mentionUserId)
+                .in("resourceType", ISSUE_POST, ISSUE_COMMENT)
+                .findList()) {
+
+            switch (mention.resourceType) {
+                case ISSUE_POST:
+                    ids.add(Long.valueOf(mention.resourceId));
+                    break;
+                case ISSUE_COMMENT:
+                    commentIds.add(Long.valueOf(mention.resourceId));
+                    break;
+                default:
+                    play.Logger.warn("'" + mention.resourceType + "' is not supported.");
+                    break;
+            }
+        }
+
+        if (!commentIds.isEmpty()) {
+            for (IssueComment comment : IssueComment.find.where()
+                    .idIn(new ArrayList<>(commentIds))
+                    .findList()) {
+                ids.add(comment.issue.id);
+            }
+        }
+
+        return new ArrayList<>(ids);
+    }
 }
app/models/NotificationEvent.java
--- app/models/NotificationEvent.java
+++ app/models/NotificationEvent.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;
@@ -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;
@@ -29,23 +28,26 @@
 import playRepository.*;
 import scala.concurrent.duration.Duration;
 import utils.AccessControl;
+import utils.DiffUtil;
 import utils.EventConstants;
 import utils.RouteUtil;
 
 import javax.naming.LimitExceededException;
 import javax.persistence.*;
 import javax.servlet.ServletException;
+import java.beans.Transient;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import static models.UserProjectNotification.findEventUnwatchersByEventType;
+import static models.UserProjectNotification.findEventWatchersByEventType;
+import static models.Watch.findUnwatchers;
+import static models.Watch.findWatchers;
 import static models.enumeration.EventType.*;
 
 @Entity
@@ -108,6 +110,10 @@
         return oldValue;
     }
 
+    public String getNewValue() {
+        return newValue;
+    }
+
     @Transient
     public String getMessage() {
         return getMessage(Lang.defaultLang());
@@ -128,14 +134,18 @@
                 } else {
                     return Messages.get(lang, "notification.issue.assigned", newValue);
                 }
+            case ISSUE_MILESTONE_CHANGED:
+                return Messages.get(lang, "notification.milestone.changed", newValue);
             case NEW_ISSUE:
             case NEW_POSTING:
             case NEW_COMMENT:
             case NEW_PULL_REQUEST:
             case NEW_COMMIT:
-            case ISSUE_BODY_CHANGED:
             case COMMENT_UPDATED:
                 return newValue;
+            case ISSUE_BODY_CHANGED:
+            case POSTING_BODY_CHANGED:
+                return DiffUtil.getDiffText(oldValue, newValue);
             case NEW_REVIEW_COMMENT:
                 try {
                     ReviewComment reviewComment = ReviewComment.find.byId(Long.valueOf(this.resourceId));
@@ -189,8 +199,44 @@
                 }
             case ISSUE_MOVED:
                     return Messages.get(lang, "notification.type.issue.moved", oldValue, newValue);
+            case ISSUE_SHARER_CHANGED:
+                if (StringUtils.isNotBlank(newValue)) {
+                    User user = User.findByLoginId(newValue);
+                    return Messages.get(lang, "notification.issue.sharer.added", user.getDisplayName(user));
+                } else if (StringUtils.isNotBlank(oldValue)) {
+                    return Messages.get(lang, "notification.issue.sharer.deleted");
+                }
+            case ISSUE_LABEL_CHANGED:
+                if (StringUtils.isNotBlank(newValue)) {
+                    User user = User.findByLoginId(newValue);
+                    return Messages.get(lang, "notification.issue.label.added", user.getDisplayName(user));
+                } else if (StringUtils.isNotBlank(oldValue)) {
+                    return Messages.get(lang, "notification.issue.label.deleted");
+                }
+            case RESOURCE_DELETED:
+                User user = User.findByLoginId(newValue);
+                return Messages.get(lang, "notification.resource.deleted", user.getDisplayName(user));
             default:
-                return null;
+                play.Logger.warn("Unknown event message: " + this);
+                play.Logger.warn("Event Type: " + eventType);
+                play.Logger.warn("See: NotificationEvent.getMessage");
+                return eventType.getDescr();
+        }
+    }
+
+    @Transient
+    public String getPlainMessage() {
+        return getPlainMessage(Lang.defaultLang());
+    }
+
+    @Transient
+    public String getPlainMessage(Lang lang) {
+        switch(eventType) {
+            case ISSUE_BODY_CHANGED:
+            case POSTING_BODY_CHANGED:
+                return DiffUtil.getDiffPlainText(oldValue, newValue);
+            default:
+                return getMessage(lang);
         }
     }
 
@@ -360,8 +406,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();
@@ -381,6 +426,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) {
@@ -638,10 +732,7 @@
         NotificationEvent notiEvent = createFrom(author, comment);
         notiEvent.title = formatReplyTitle(post);
         notiEvent.eventType = eventType;
-        Set<User> receivers = getReceivers(post, author);
-        receivers.addAll(getMentionedUsers(comment.contents));
-        receivers.remove(author);
-        notiEvent.receivers = receivers;
+        notiEvent.receivers = getMandatoryReceivers(comment, eventType);
         notiEvent.oldValue = null;
         notiEvent.newValue = comment.contents;
         notiEvent.resourceType = comment.asResource().getType();
@@ -680,7 +771,7 @@
 
         NotificationEvent notiEvent = createFromCurrentUser(issue);
         notiEvent.title = formatReplyTitle(issue);
-        notiEvent.receivers = getReceivers(issue);
+        notiEvent.receivers = getMandatoryReceivers(issue, EventType.ISSUE_STATE_CHANGED);
         notiEvent.eventType = ISSUE_STATE_CHANGED;
         notiEvent.oldValue = oldState != null ? oldState.state() : null;
         notiEvent.newValue = issue.state.state();
@@ -728,12 +819,9 @@
 
         NotificationEvent notiEvent = createFromCurrentUser(issue);
 
-        Set<User> receivers = getReceivers(issue);
+        Set<User> receivers = getReceiversWhenAssigneeChanged(oldAssignee, issue);
         if(oldAssignee != null) {
             notiEvent.oldValue = oldAssignee.loginId;
-            if(!oldAssignee.loginId.equals(UserApp.currentUser().loginId)) {
-                receivers.add(oldAssignee);
-            }
         }
 
         if (issue.assignee != null) {
@@ -748,6 +836,17 @@
         return notiEvent;
     }
 
+    private static Set<User> getReceiversWhenAssigneeChanged(User oldAssignee, Issue issue) {
+        Set<User> receivers = getMandatoryReceivers(issue, ISSUE_ASSIGNEE_CHANGED);
+
+        if (oldAssignee != null && !oldAssignee.isAnonymous()
+                && !oldAssignee.loginId.equals(UserApp.currentUser().loginId)) {
+            receivers.add(oldAssignee);
+        }
+
+        return receivers;
+    }
+
     public static void afterNewIssue(Issue issue) {
         NotificationEvent.add(forNewIssue(issue, UserApp.currentUser()));
         webhookRequest(NEW_ISSUE, issue, false);
@@ -760,6 +859,21 @@
         notiEvent.eventType = NEW_ISSUE;
         notiEvent.oldValue = null;
         notiEvent.newValue = issue.body;
+        return notiEvent;
+    }
+
+    public static NotificationEvent afterResourceDeleted(AbstractPosting item, User reuqestedUser) {
+        NotificationEvent notiEvent = createFrom(reuqestedUser, item.project);
+        notiEvent.title = formatNewTitle(item);
+        notiEvent.receivers = getReceivers(item, reuqestedUser);
+        notiEvent.eventType = RESOURCE_DELETED;
+        notiEvent.oldValue = item.body;
+        notiEvent.newValue = reuqestedUser.loginId;
+
+        NotificationEvent.add(notiEvent);
+        if (item instanceof Issue) {
+            webhookRequest(RESOURCE_DELETED, (Issue)item, false);
+        }
         return notiEvent;
     }
 
@@ -793,8 +907,140 @@
         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;
+    }
+
+    public static NotificationEvent afterIssueLabelChanged(String addedLabels, String deletedLabels, Issue issue) {
+        NotificationEvent notiEvent = createFromCurrentUser(issue);
+        notiEvent.title = formatReplyTitle(issue);
+        notiEvent.receivers = null; // no receivers
+        notiEvent.eventType = ISSUE_LABEL_CHANGED;
+        notiEvent.oldValue = deletedLabels;
+        notiEvent.newValue = addedLabels;
+
+        NotificationEvent.addWithoutSkipEvent(notiEvent);
+        return notiEvent;
+    }
+
+    public static NotificationEvent afterMilestoneChanged(Long oldMilestoneId, Issue issue) {
+        webhookRequest(ISSUE_MILESTONE_CHANGED, issue, false);
+
+        NotificationEvent notiEvent = createFromCurrentUser(issue);
+
+        Set<User> receivers = getMandatoryReceivers(issue, ISSUE_MILESTONE_CHANGED);
+
+        notiEvent.title = formatReplyTitle(issue);
+        notiEvent.receivers = receivers;
+        notiEvent.eventType = ISSUE_MILESTONE_CHANGED;
+        notiEvent.oldValue = oldMilestoneId.toString();
+        notiEvent.newValue = issue.milestoneId().toString();
+
+        NotificationEvent.add(notiEvent);
+
+        return notiEvent;
+    }
+
+    private static Set<User> getMandatoryReceivers(Issue issue, EventType eventType) {
+        Set<User> receivers = findWatchers(issue.asResource());
+        receivers.add(issue.getAuthor());
+
+        for (IssueSharer issueSharer : issue.sharers) {
+            receivers.add(User.findByLoginId(issueSharer.loginId));
+        }
+
+        if (issue.assignee != null) {
+            receivers.add(issue.assignee.user);
+        }
+
+        receivers.addAll(findWatchers(issue.asResource()));
+        receivers.addAll(findEventWatchersByEventType(issue.project.id, eventType));
+
+        receivers.removeAll(findUnwatchers(issue.asResource()));
+        receivers.removeAll(findEventUnwatchersByEventType(issue.project.id, eventType));
+        receivers.remove(UserApp.currentUser());
+
+        return receivers;
+    }
+
+    private static Set<User> getMandatoryReceivers(Posting posting, EventType eventType) {
+        Set<User> receivers = findWatchers(posting.asResource());
+        receivers.add(posting.getAuthor());
+        receivers.addAll(findWatchers(posting.asResource()));
+        receivers.addAll(findEventWatchersByEventType(posting.project.id, eventType));
+
+        receivers.removeAll(findUnwatchers(posting.asResource()));
+        receivers.removeAll(findEventUnwatchersByEventType(posting.project.id, eventType));
+        receivers.remove(UserApp.currentUser());
+
+        return receivers;
+    }
+
+    private static Set<User> getMandatoryReceivers(Comment comment, EventType eventType) {
+        AbstractPosting parent = comment.getParent();
+        Set<User> receivers = findWatchers(parent.asResource());
+        receivers.add(parent.getAuthor());
+        receivers.addAll(findEventWatchersByEventType(comment.projectId, eventType));
+        receivers.addAll(getMentionedUsers(comment.contents));
+        includeAssigneeIfExist(comment, receivers);
+
+        receivers.removeAll(findUnwatchers(parent.asResource()));
+        receivers.removeAll(findEventUnwatchersByEventType(comment.projectId, eventType));
+        receivers.remove(UserApp.currentUser());
+
+        return receivers;
+    }
+
+    private static Set<User> getProjectCommitReceivers(Project project, EventType eventType) {
+        Set<User> receivers = findMembersOnlyFromWatchers(project);
+        receivers.removeAll(findUnwatchers(project.asResource()));
+        receivers.removeAll(findEventUnwatchersByEventType(project.id, eventType));
+        receivers.remove(UserApp.currentUser());
+
+        return receivers;
+    }
+
+    private static Set<User> findMembersOnlyFromWatchers(Project project) {
+        Set<User> receivers = new HashSet<>();
+        Set<User> projectMembers = extractMembers(project);
+        for (User watcher : findWatchers(project.asResource())) {
+            if (projectMembers.contains(watcher)) {
+                receivers.add(watcher);
+            }
+        }
+        return receivers;
+    }
+
+    private static Set<User> extractMembers(Project project) {
+        Set<User> projectMembers = new HashSet<>();
+        for (ProjectUser projectUser : project.members()) {
+            projectMembers.add(projectUser.user);
+        }
+        return projectMembers;
+    }
+
     private static Set<User> getReceiversForIssueBodyChanged(String oldBody, Issue issue) {
-        Set<User> receivers = issue.getWatchers();
+        Set<User> receivers = getMandatoryReceivers(issue, ISSUE_BODY_CHANGED);
         receivers.addAll(getNewMentionedUsers(oldBody, issue.body));
         receivers.remove(UserApp.currentUser());
         return receivers;
@@ -804,12 +1050,26 @@
         NotificationEvent.add(forNewPosting(post, UserApp.currentUser()));
     }
 
+    public static void afterUpdatePosting(String oldValue, Posting post) {
+        NotificationEvent.add(forUpdatePosting(oldValue, post, UserApp.currentUser()));
+    }
+
     public static NotificationEvent forNewPosting(Posting post, User author) {
         NotificationEvent notiEvent = createFrom(author, post);
         notiEvent.title = formatNewTitle(post);
         notiEvent.receivers = getReceivers(post);
         notiEvent.eventType = NEW_POSTING;
         notiEvent.oldValue = null;
+        notiEvent.newValue = post.body;
+        return notiEvent;
+    }
+
+    public static NotificationEvent forUpdatePosting(String oldValue, Posting post, User author) {
+        NotificationEvent notiEvent = createFrom(author, post);
+        notiEvent.title = formatNewTitle(post);
+        notiEvent.receivers = getMandatoryReceivers(post, EventType.POSTING_BODY_CHANGED);
+        notiEvent.eventType = POSTING_BODY_CHANGED;
+        notiEvent.oldValue = oldValue;
         notiEvent.newValue = post.body;
         return notiEvent;
     }
@@ -920,10 +1180,10 @@
         NotificationEvent.add(notiEvent);
     }
 
-    public static void afterNewCommits(List<RevCommit> commits, List<String> refNames, Project project, User sender, String title, Set<User> watchers) {
+    public static void afterNewCommits(List<RevCommit> commits, List<String> refNames, Project project, User sender, String title) {
         NotificationEvent notiEvent = createFrom(sender, project);
         notiEvent.title = title;
-        notiEvent.receivers = watchers;
+        notiEvent.receivers = getProjectCommitReceivers(project, NEW_COMMIT);
         notiEvent.eventType = NEW_COMMIT;
         notiEvent.oldValue = null;
         notiEvent.newValue = newCommitsMessage(commits, refNames, project);
@@ -1018,6 +1278,15 @@
         receivers.addAll(getMentionedUsers(abstractPosting.body));
         receivers.remove(except);
         return receivers;
+    }
+
+    private static void includeAssigneeIfExist(Comment comment, Set<User> receivers) {
+        if (comment instanceof IssueComment) {
+            Assignee assignee = ((Issue) comment.getParent()).assignee;
+            if (assignee != null) {
+                receivers.add(assignee.user);
+            }
+        }
     }
 
     private static String getPrefixedNumber(AbstractPosting posting) {
@@ -1266,4 +1535,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/NotificationMail.java
--- app/models/NotificationMail.java
+++ app/models/NotificationMail.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;
@@ -533,8 +533,9 @@
             Lang lang = Lang.apply(langCode);
 
             String message = event.getMessage(lang);
+            String plainMessage = event.getPlainMessage(lang);