doortts doortts 2016-11-17
migraion: Support yona to github migration
Suport yona to github migration.
It can be processed at /migration url path.

eg. https://repo.yona.io/migration
@b3ba0c4b20c14c33e038fa58f3c17255024b2885
.gitignore
--- .gitignore
+++ .gitignore
@@ -30,3 +30,4 @@
 conf/generated.keystore
 conf/application-logger.xml
 .java-version
+migration-client
 
app/assets/stylesheets/less/_migration.less (added)
+++ app/assets/stylesheets/less/_migration.less
@@ -0,0 +1,288 @@
+.yobi-migration {
+  .title-text-bg{
+    background: #333333;
+    border-top: 1px solid rgba(85, 85, 85, 0.53);
+    border-bottom: 1px solid #ccc;
+  }
+  .title-text {
+    font-family: 'Montserrat', sans-serif;
+    font-size: 20px;
+  }
+  .left-border {
+    border-left: 1px solid #ccc;
+  }
+  .comeback-text{
+    background: #333333;
+    color: whitesmoke;
+    font-family: 'Muli', sans-serif;
+    font-size: 30px;
+    text-align: right;
+    .midium-font {
+      font-size: 14px;
+    }
+    margin-right: 20px;
+    margin-top: 25px;
+  }
+  .head-title {
+    background-color: white;
+    padding-bottom: 0 !important;
+    word-wrap: break-word;
+    height: 60px;
+    .source-title {
+      text-align: right;
+    }
+    .destination-title {
+      text-align: left;
+      padding-left: 8px;
+    }
+    .project-name {
+      margin-top: 10px;
+      &.warn {
+        color: white;
+        font-size: 20px;
+        margin-top: 4px;
+        padding: 5px 10px;
+        font-weight: bold;
+        background-color: #b94a48;
+      }
+      font-size: 30px;
+      font-family: Consolas, monospace, Menlo;
+      line-height: normal;
+    }
+    .arrow {
+      text-align: center;
+      color: #b94a48;
+    }
+    i {
+      font-size: 20px;
+      padding: 10px;
+    }
+
+  }
+  .header-pannel {
+    .board {
+      font-size: 14px;
+      max-height: 60px;
+      overflow: hidden;
+      background-color: #333333 !important;
+      color: #fff;
+      border-radius: 0 !important;
+      margin-bottom: 0 !important;
+      border: none !important;
+      padding-left: 40px;
+    }
+    .buttons {
+      margin-left: 20px;
+    }
+    .error-data {
+      color: #ff4840;
+    }
+    .progress {
+      border-radius: 0 !important;
+      margin-bottom: 0 !important;
+    }
+  }
+
+  .search {
+    border-right: 1px solid #ccc;
+    border-bottom: none;
+    height: 40px;
+  }
+
+  .project-list{
+    padding: 5px;
+    margin: 5px;
+    &:hover {
+      background-color: #EEE;
+      cursor: pointer;
+    }
+  }
+}
+
+.source-destination {
+  margin-left: 20px;
+  .header {
+    padding: 10px;
+    background-color: #e36b23 ;
+    font-weight: bold;
+    font-size: 16px;
+    color: white;
+  }
+
+  .owner {
+    font-weight: bold;
+    color: black;
+    padding: 5px;
+    font-size: 14px;
+    margin-left: -10px;
+  }
+
+  .search {
+    input {
+      border: none;
+      height: 30px;
+      font-size: 18px;
+    }
+  }
+
+  .status{
+    .progress {
+      margin-left: 0;
+      .bar {
+        margin-left: 0;
+      }
+    }
+    .left-title {
+      font-size: 14px;
+      font-weight: bold;
+      min-width: 30px;
+    }
+    .yobicon-check-circle {
+      display: inline-block;
+      margin-left: -20px;
+      vertical-align: top;
+      margin-top: 8px;
+      color: green;
+      font-size: 16px;
+    }
+    .yobicon-delete-circle-alt {
+      display: inline-block;
+      margin-left: -20px;
+      vertical-align: top;
+      margin-top: 8px;
+      color: red;
+      font-size: 16px;
+    }
+    .caution{
+      border-radius: 3px;
+      padding: 5px;
+      background-color: #eee;
+      color: #333;
+      margin-bottom: 7px;
+      margin-top: 0;
+      a {
+        color: #1fb0ff;
+        font-size: 14px;
+      }
+    }
+    .btn-group {
+      width: 300px;
+    }
+    .btn{
+      font-weight: bold !important;
+      width: 100%;
+    }
+  }
+
+  .project-name {
+    font-size: 18px;
+    font-weight: bold;
+    a {
+      color: #0088cc;
+    }
+    margin-bottom: 5px;
+  }
+  .metainfo-sm {
+    font-size: 13px;
+    color: #999;
+  }
+  .metainfo {
+    font-size: 14px;
+    color: #999;
+  }
+
+  .project-list {
+    margin: 5px 10px;
+  }
+
+  .private {
+    font-size: 12px;
+    font-weight: normal !important;
+    padding: 0 2px !important;
+  }
+
+  td {
+    vertical-align: middle !important;
+    text-align: center;
+
+  }
+  .selected {
+    border-left: 3px solid #e36b23;
+    padding-left: 11px;
+    background-color: #eee;
+    margin-left: 1px;
+  }
+
+  .left-project-list {
+    height:60vh;
+    overflow: auto;
+    border: 1px solid #ccc;
+  }
+  .destination-project {
+    margin-left: 0 !important;
+  }
+  .destination-project-list {
+    height:60vh;
+    overflow: auto;
+    border: 1px solid #ccc;
+    border-left: none !important;
+  }
+
+  .dl-horizontal {
+    font-size: 12px !important;
+    dd {
+      margin-left: 120px !important;
+      width: 100% !important;
+    }
+    dt {
+      width: 100px !important;
+    }
+  }
+
+  .text-align-left {
+    text-align: left !important;
+  }
+
+  .td-title {
+    width: 100px !important;
+    vertical-align: top !important;
+  }
+
+  .alert-bg {
+    background-color: #333;
+  }
+
+  .alert-icon {
+    font-size: 20px;
+    color: #FD6956;
+    padding: 5px;
+    margin-bottom: 5px;
+    .description {
+      font-size: 12px !important;
+      color: #fafafa;
+    }
+  }
+
+  .alert-icon-text {
+    font-size: 16px !important;
+  }
+
+  .assignee {
+    font-size: 14px;
+    input {
+      font-size: 14px;
+      line-height: 30px;
+      height: 30px;
+      padding-bottom: 0;
+      margin-bottom: 0;
+      width: 90%;
+      border: 0;
+    }
+  }
+  .table-bordered {
+    width: 100%;
+  }
+  .warn-no-worker, .warn-user-project {
+    color: red;
+  }
+}
app/assets/stylesheets/yobi.less
--- app/assets/stylesheets/yobi.less
+++ app/assets/stylesheets/yobi.less
@@ -7,4 +7,5 @@
 @import "less/_yobiUI.less";
 @import "less/_temporary.less";
 @import "less/_markdown.less";
+@import "less/_migration.less";
 @import "less/_override.less";
app/controllers/BoardApi.java
--- app/controllers/BoardApi.java
+++ app/controllers/BoardApi.java
@@ -9,8 +9,10 @@
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import controllers.annotation.IsAllowed;
 import controllers.annotation.IsCreatable;
 import models.*;
+import models.enumeration.Operation;
 import models.enumeration.ResourceType;
 import org.joda.time.DateTime;
 import play.db.ebean.Transactional;
@@ -50,6 +52,18 @@
         return ok(result);
     }
 
+    @IsAllowed(value = Operation.READ, resourceType = ResourceType.BOARD_POST)
+    public static Result getPosts(String owner, String projectName, Long number) {
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+        Posting post = Posting.findByNumber(project, number);
+
+        ObjectNode json = Json.newObject();
+        json.put("title", post.title);
+        json.put("body", post.body);
+        json.put("author", post.authorLoginId);
+        return ok(json);
+    }
+
     @Transactional
     @IsCreatable(ResourceType.BOARD_POST)
     public static Result newPostByJson(String owner, String projectName) {
app/controllers/BoardApp.java
--- app/controllers/BoardApp.java
+++ app/controllers/BoardApp.java
@@ -1,22 +1,8 @@
 /**
- * Yobi, Project Hosting SW
+ * Yobire, Project Hosting SW
  *
- * Copyright 2012 NAVER Corp.
- * http://yobi.io
- *
- * @author Sangcheol Hwang
- *
- * 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.
+ * @author Suwon Chae
+ * Copyright 2016 the original author or authors.
  */
 package controllers;
 
@@ -36,6 +22,7 @@
 import play.db.ebean.Transactional;
 import play.libs.Json;
 import play.mvc.Call;
+import play.mvc.Http;
 import play.mvc.Result;
 import play.mvc.With;
 import playRepository.BareCommit;
@@ -53,6 +40,10 @@
 import java.util.*;
 
 import static com.avaje.ebean.Expr.icontains;
+import static controllers.MigrationApp.composeCommentsJson;
+import static controllers.MigrationApp.composePlainCommentsJson;
+import static controllers.MigrationApp.exportPosts;
+import static play.libs.Json.toJson;
 
 public class BoardApp extends AbstractPostingApp {
     public static class SearchCondition extends AbstractPostingApp.SearchCondition {
@@ -254,8 +245,12 @@
         if (request().getHeader("Accept").contains("application/json")) {
             ObjectNode json = Json.newObject();
             json.put("title", post.title);
+            json.put("created_at", post.createdDate.getTime());
             json.put("body", post.body);
             json.put("author", post.authorLoginId);
+            json.put("authorName", post.authorName);
+            json.put("attachments", toJson(Attachment.findByContainer(post.asResource())));
+            json.put("comments", toJson(composePlainCommentsJson(post, ResourceType.NONISSUE_COMMENT)));
             return ok(json);
         }
 
 
app/controllers/MigrationApp.java (added)
+++ app/controllers/MigrationApp.java
@@ -0,0 +1,426 @@
+/**
+ * Yobire, Project Hosting SW
+ *
+ * @author Suwon Chae
+ * Copyright 2016 the original author or authors.
+ */
+package controllers;
+
+import com.avaje.ebean.Ebean;
+import com.avaje.ebean.Query;
+import com.avaje.ebean.RawSql;
+import com.avaje.ebean.RawSqlBuilder;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import controllers.annotation.AnonymousCheck;
+import models.*;
+import models.enumeration.ResourceType;
+import models.support.IssueLabelAggregate;
+import org.apache.commons.lang.StringUtils;
+import play.libs.F;
+import play.libs.F.Promise;
+import play.libs.Json;
+import play.libs.ws.WS;
+import play.mvc.Result;
+import views.html.migration.home;
+
+import javax.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import java.util.stream.Collectors;
+
+import static play.libs.Json.toJson;
+import static play.mvc.Http.Context.Implicit.request;
+import static play.mvc.Results.ok;
+
+@AnonymousCheck
+public class MigrationApp {
+
+    static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
+    private static final String YONA_SERVER = "/";
+
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Promise<Result> migration() {
+        String authProcessingCode = request().getQueryString("code");
+
+        if(StringUtils.isNotBlank(authProcessingCode)){
+            return getOAuthToken(authProcessingCode).map((F.Function<String, Result>) token
+                    -> ok(home.render("Migration", authProcessingCode, token)));
+        } else {
+            return Promise.promise((F.Function0<Result>) ()
+                    -> ok(home.render("Migration", null, null)));
+        }
+    }
+
+    private static Promise<String> getOAuthToken(String code) {
+        final String CLIENT_ID = "e7f9ad76a3a4ba19b2a5";
+        final String CLIENT_SECRET = "32e7fb33ee5c42501cb2aac9a6f6c485bf285cf5";
+        final String ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
+
+        return WS.url(ACCESS_TOKEN_URL)
+                .setContentType("application/x-www-form-urlencoded")
+                .setHeader("Accept", "application/json,application/x-www-form-urlencoded,text/html,*/*")
+                .post("client_id=" + CLIENT_ID + "&client_secret=" + CLIENT_SECRET + "&code=" + code)
+                .map(response -> {
+                    play.Logger.debug(response.getBody());
+                    String accessToken = "";
+                    try {
+                        Pattern p = Pattern.compile("access_token=([^&]+)");
+                        Matcher m = p.matcher(response.getBody());
+                        if(m.find() ){
+                            accessToken = m.group(1);
+                        }
+                    } catch (PatternSyntaxException ex) {
+                        play.Logger.error("Couldn't find access_token");
+                    }
+                    play.Logger.error("token=" + accessToken);
+                    return accessToken;
+                });
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result projects(){
+        Set<Project> sourceProjects = new HashSet<>();
+
+        getheringOrgProjects(sourceProjects);
+        gatheringUserProjects(sourceProjects);
+
+        List<ObjectNode> projects = new ArrayList<>();
+        for(Project project: sortProjectsByOwnerAndName(sourceProjects)){
+            ObjectNode projectNode = Json.newObject();
+            projectNode.put("owner", project.owner);
+            projectNode.put("projectName", project.name);
+            projectNode.put("private", project.isPrivate());
+            projectNode.put("members", project.members().size());
+            projectNode.put("full_name", project.owner + "/" + project.name);
+            projects.add(projectNode);
+        }
+        return ok(toJson(projects));
+    }
+
+    private static List<Project> sortProjectsByOwnerAndName(Set<Project> projects) {
+        Comparator<Project> comparator = Comparator.comparing(project -> project.owner);
+        comparator = comparator.thenComparing(Comparator.comparing(project -> project.name));
+        List<Project> list = new ArrayList<>(projects);
+        Collections.sort(list, comparator);
+        return list;
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result project(String owner, String projectName){
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+
+        ObjectNode result = Json.newObject();
+        result.put("owner", project.owner);
+        result.put("projectName", project.name);
+        result.put("full_name", project.owner + "/" + project.name);
+        result.put("assignees", toJson(getAssginees(project).toArray()));
+        result.put("memberCount", project.members().size());
+        result.put("issueCount", project.issues.size());
+        result.put("postCount", project.posts.size());
+        result.put("milestoneCount", project.milestones.size());
+        return ok(result);
+    }
+
+    private static List<ObjectNode> getAssginees(Project project) {
+        List<ObjectNode> members = new ArrayList<>();
+        for(Assignee assignee: project.assignees){
+            ObjectNode member = Json.newObject();
+            member.put("name", assignee.user.name);
+            member.put("login", assignee.user.loginId);
+            members.add(member);
+        }
+        return members;
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result exportIssueLabelPairs(String owner, String projectName){
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+
+        Query<IssueLabelAggregate> query = Ebean.find(IssueLabelAggregate.class);
+        String sql = "select issue_id, issue_label_id \n" +
+                "from issue i, issue_issue_label iil \n" +
+                "where project_id = " + project.id + "\n" +
+                "and i.id = iil.issue_id";
+        RawSql rawSql = RawSqlBuilder.parse(sql).create();
+        query.setRawSql(rawSql);
+        List<IssueLabelAggregate> results = query.findList();
+
+        ObjectNode issueLabelPairs = Json.newObject();
+        issueLabelPairs.put("issueLabelPairs", toJson(results));
+        return ok(issueLabelPairs);
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result exportLabels(String owner, String projectName){
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+
+        ObjectNode labels = Json.newObject();
+        for (IssueLabel label : IssueLabel.findByProject(project)) {
+            ObjectNode node = Json.newObject();
+            node.put("id", label.id);
+            node.put("name", label.name);
+            node.put("categoryId", label.category.id);
+            node.put("categoryName", label.category.name);
+            labels.put(String.valueOf(label.id), node);
+        }
+
+        ObjectNode exportData = Json.newObject();
+        exportData.put("labels", toJson(labels));
+        return ok(exportData);
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result exportMilestones(String owner, String projectName){
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+
+        List<ObjectNode> milestones = project.milestones.stream()
+                .map(MigrationApp::composeMilestoneJson).collect(Collectors.toList());
+
+        ObjectNode exportData = Json.newObject();
+        exportData.put("milestones", toJson(milestones));
+        return ok(exportData);
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result exportPosts(String owner, String projectName){
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+
+        List<ObjectNode> issues = project.posts.stream()
+                .map(MigrationApp::composePostJson).collect(Collectors.toList());
+
+        ObjectNode exportData = Json.newObject();
+        exportData.put("issues", toJson(issues));
+        return ok(exportData);
+    }
+
+    @AnonymousCheck(requiresLogin = true, displaysFlashMessage = true)
+    public static Result exportIssues(String owner, String projectName){
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+
+        List<ObjectNode> issues = project.issues.stream()
+                .map(MigrationApp::composeIssueJson).collect(Collectors.toList());
+
+        ObjectNode exportData = Json.newObject();
+        exportData.put("issues", toJson(issues));
+        return ok(exportData);
+    }
+
+    private static ObjectNode composeMilestoneJson(Milestone m) {
+        ObjectNode node = Json.newObject();
+        node.put("id", m.id);
+        node.put("title", m.title);
+        node.put("state", m.state.state());
+        node.put("description", m.contents);
+        Optional.ofNullable(m.dueDate).ifPresent(dueDate -> node.put("due_on",
+                LocalDateTime.ofInstant(m.dueDate.toInstant(), ZoneId.systemDefault()).format(formatter)));
+
+        ObjectNode milestoneJson = Json.newObject();
+        milestoneJson.put("milestone", node);
+        return milestoneJson;
+    }
+
+    private static String addOriginalAuthorName(String bodyText, String authorLoginId,
+                                                String authorName, String type, String link){
+        return String.format("@%s (%s) 님이 작성한 [%s](%s)입니다. \n\\---\n\n%s",
+                authorLoginId, authorName, type, link, bodyText);
+    }
+
+    private static String relativeLinksToAbsolutePath(String text){
+        // replace relative img tag src to absolute path
+        // and replace relative markdown link path to absolute path
+        return text.replaceAll("(<img src=[\"\'])/(?<link>.*)([\"\']>)", "$1" + YONA_SERVER + "$2$3")
+                .replaceAll("\\[(?<text>[^\\]]*)\\]\\(/(?<link>[^\\)]*)\\)", "[$1](" + YONA_SERVER + "$2)");
+    }
+
+    private static String relativeLinksToWikiCommitPath(String text){
+        // replace relative img tag src to absolute path
+        // and replace relative markdown link path to wiki commit file path
+        return text.replaceAll("(<img src=[\"\'])/(?<link>.*)([\"\']>)", "$1" + YONA_SERVER + "$2$3")
+                .replaceAll("\\[(?<text>[^\\]]*)\\]\\(/(?<link>[^\\)]*)\\)", "[$1](../wiki/$2/$1)");
+    }
+
+    private static StringBuilder addAttachmentsString(@NotNull StringBuilder sb, ResourceType type, String id){
+        try {
+            List<Map<String, String>> attachments = AttachmentApp.getFileList(type.toString(), id).get("attachments");
+            if(attachments.size()>0){
+                addListHeader(sb);
+            }
+            for(Map<String, String> attachment: attachments){
+                sb.append(String.format("\n[%s](%s)", attachment.get("name"), YONA_SERVER + attachment.get("url")));
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return sb;
+    }
+
+    private static void addListHeader(@NotNull StringBuilder sb) {
+        sb.append("\n\n--- attachments ---");
+    }
+
+    private static StringBuilder addAttachmentsStringUsingWikiCommit(@NotNull StringBuilder sb, ResourceType type, String id){
+        try {
+            List<Map<String, String>> attachments = AttachmentApp.getFileList(type.toString(), id).get("attachments");
+            if(attachments.size()>0){
+                addListHeader(sb);
+            }
+            for(Map<String, String> attachment: attachments){
+                sb.append(String.format("\n[%s](../wiki/files/%s/%s)", attachment.get("name"), attachment.get("id"),
+                        attachment.get("name").replaceAll("#", "%23")));
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return sb;
+    }
+
+    private static StringBuilder addAttachmentsStringWithLocalDir(@NotNull StringBuilder sb, ResourceType type, String id){
+        try {
+            List<Map<String, String>> attachments = AttachmentApp.getFileList(type.toString(), id).get("attachments");
+            if(attachments.size()>0){
+                addListHeader(sb);
+            }
+            for(Map<String, String> attachment: attachments){
+                sb.append(String.format("\n[%s](./attachments/%s/%s)", attachment.get("name"), attachment.get("id"),
+                        attachment.get("name").replaceAll("#", "%23")));
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return sb;
+    }
+
+    private static ObjectNode composePostJson(Posting posting) {
+        String originalPostingLink = String.format("%s/%s/post/%s",
+                YONA_SERVER + posting.project.owner, posting.project.name, posting.getNumber());
+
+        ObjectNode node = Json.newObject();
+        node.put("title", posting.title);
+
+        // body 작성
+        StringBuilder sb = new StringBuilder();
+
+        if(usingWikiCommitForAttachment()){
+            sb.append(addOriginalAuthorName(
+                    relativeLinksToWikiCommitPath(posting.body), posting.authorLoginId,
+                    posting.authorName, "게시글", originalPostingLink));
+            sb = addAttachmentsStringUsingWikiCommit(sb, ResourceType.BOARD_POST, posting.id.toString());
+        } else {
+            sb.append(addOriginalAuthorName(
+                    relativeLinksToAbsolutePath(posting.body), posting.authorLoginId, posting.authorName,
+                    "게시글", originalPostingLink));
+            sb = addAttachmentsString(sb, ResourceType.BOARD_POST, posting.id.toString());
+        }
+        node.put("body", sb.toString());
+        node.put("created_at", LocalDateTime.ofInstant(posting.createdDate.toInstant(),
+                ZoneId.systemDefault()).format(formatter));
+
+        ObjectNode postingJson = Json.newObject();
+        postingJson.put("issue", node);  // intentionally 'issue' key name is used for Github api compatibility
+        postingJson.put("comments", toJson(composeCommentsJson(posting, originalPostingLink, ResourceType.NONISSUE_COMMENT)));
+        return postingJson;
+    }
+
+    private static boolean usingWikiCommitForAttachment() {
+        String withWikiCommit = request().getQueryString("withWikiCommit");
+        boolean usingWikiCommit = StringUtils.isNotBlank(withWikiCommit) && withWikiCommit.endsWith("true");
+        return usingWikiCommit;
+    }
+
+    private static ObjectNode composeIssueJson(Issue issue) {
+        String originalIssueLink = String.format("%s/%s/issue/%s",
+                YONA_SERVER + issue.project.owner, issue.project.name, issue.getNumber());
+
+        ObjectNode node = Json.newObject();
+        node.put("id", issue.id);
+        node.put("title", issue.title);
+
+        // body 작성
+        StringBuilder sb = new StringBuilder();
+
+        if(usingWikiCommitForAttachment()){
+            sb.append(addOriginalAuthorName(
+                    relativeLinksToWikiCommitPath(issue.body), issue.authorLoginId, issue.authorName, "이슈", originalIssueLink));
+            sb = addAttachmentsStringUsingWikiCommit(sb, ResourceType.ISSUE_POST, issue.id.toString());
+        } else {
+            sb.append(addOriginalAuthorName(
+                    relativeLinksToAbsolutePath(issue.body), issue.authorLoginId, issue.authorName, "이슈", originalIssueLink));
+            sb = addAttachmentsString(sb, ResourceType.ISSUE_POST, issue.id.toString());
+        }
+        node.put("body", sb.toString());
+
+        node.put("created_at", LocalDateTime.ofInstant(issue.createdDate.toInstant(),
+                ZoneId.systemDefault()).format(formatter));
+        Optional.ofNullable(issue.assignee).ifPresent(assignee -> node.put("assignee", assignee.user.loginId));
+        Optional.ofNullable(issue.milestone).ifPresent(milestone -> node.put("milestone", milestone.title));
+        Optional.ofNullable(issue.milestone).ifPresent(milestone -> node.put("milestoneId", milestone.id));
+
+        node.put("closed", issue.isClosed());
+
+        ObjectNode issueJson = Json.newObject();
+        issueJson.put("issue", node);
+        issueJson.put("comments", toJson(composeCommentsJson(issue, originalIssueLink, ResourceType.ISSUE_COMMENT)));
+        return issueJson;
+    }
+
+    public static List<ObjectNode> composeCommentsJson(AbstractPosting posting, String orgLink, ResourceType type) {
+        List<ObjectNode> comments = new ArrayList<>();
+        for (Comment comment : posting.getComments()) {
+            StringBuilder sb = new StringBuilder();
+            ObjectNode commentNode = Json.newObject();
+            commentNode.put("created_at", LocalDateTime.ofInstant(comment.createdDate.toInstant(),
+                    ZoneId.systemDefault()).format(formatter));
+
+            if(usingWikiCommitForAttachment()){
+                sb.append(addOriginalAuthorName(
+                        relativeLinksToWikiCommitPath(comment.contents), comment.authorLoginId, comment.authorName,
+                        "코멘트", orgLink + "#comment-" + comment.id));
+                sb = addAttachmentsStringUsingWikiCommit(sb, type, comment.id.toString());
+            } else {
+                sb.append(addOriginalAuthorName(
+                        relativeLinksToAbsolutePath(comment.contents), comment.authorLoginId, comment.authorName,
+                        "코멘트", orgLink + "#comment-" + comment.id));
+                sb = addAttachmentsString(sb, type, comment.id.toString());
+            }
+            commentNode.put("body", sb.toString());
+            comments.add(commentNode);
+        }
+        return comments;
+    }
+
+    public static List<ObjectNode> composePlainCommentsJson(AbstractPosting posting, ResourceType type) {
+        List<ObjectNode> comments = new ArrayList<>();
+        for (Comment comment : posting.getComments()) {
+            StringBuilder sb = new StringBuilder();
+            ObjectNode commentNode = Json.newObject();
+            commentNode.put("created_at",comment.createdDate.getTime());
+            sb = addAttachmentsStringWithLocalDir(sb, type, comment.id.toString());
+            commentNode.put("authorId", comment.authorLoginId);
+            commentNode.put("authorName", comment.authorName);
+            commentNode.put("body", sb.toString());
+            commentNode.put("attachments", toJson(Attachment.findByContainer(comment.asResource())));
+            comments.add(commentNode);
+        }
+        return comments;
+    }
+
+    private static void gatheringUserProjects(Set<Project> targetProjects) {
+        User worker = UserApp.currentUser();
+        targetProjects.addAll(worker.projectUser.stream().
+                filter(projectUser -> ProjectUser.isAllowedToSettings(worker.loginId, projectUser.project))
+                .map(projectUser -> projectUser.project).collect(Collectors.toList()));
+    }
+
+    private static void getheringOrgProjects(Set<Project> targetProjects) {
+        User worker = UserApp.currentUser();
+        for (OrganizationUser organizationUser : OrganizationUser.findByAdmin(worker.id)) {
+            targetProjects.addAll(organizationUser.organization.projects);
+        }
+    }
+}
app/models/Attachment.java
--- app/models/Attachment.java
+++ app/models/Attachment.java
@@ -1,25 +1,12 @@
 /**
- * Yobi, Project Hosting SW
+ * Yobire, Project Hosting SW
  *
- * Copyright 2012 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.
+ * @author Suwon Chae
+ * Copyright 2016 the original author or authors.
  */
 package models;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import controllers.AttachmentApp;
 import models.enumeration.ResourceType;
 import models.resource.GlobalResource;
@@ -279,6 +266,7 @@
      *
      * @return the file
      */
+    @JsonIgnore
     public File getFile() {
         return new File(getUploadDirectory(), this.hash);
     }
 
app/models/support/IssueLabelAggregate.java (added)
+++ app/models/support/IssueLabelAggregate.java
@@ -0,0 +1,20 @@
+/**
+ * Yona, 21c Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
+package models.support;
+
+import com.avaje.ebean.annotation.Sql;
+import play.db.ebean.Model;
+
+import javax.persistence.Entity;
+
+@Entity
+@Sql
+public class IssueLabelAggregate extends Model {
+    private static final long serialVersionUID = -8843323869004757091L;
+    public Long issueId;
+    public Long issueLabelId;
+}
 
app/views/migration/home.scala.html (added)
+++ app/views/migration/home.scala.html
@@ -0,0 +1,151 @@
+@import org.apache.commons.lang.StringUtils
+@(title: String, code:String, token:String)
+
+@migrationPageLayout(utils.Config.getSiteName)("") {
+    <div ng-app="yona.migration" ng-controller="MigrationController as vm" class="yobi-migration" ng-init="vm.setUserToken('@token');vm.yobiUser = '@UserApp.currentUser().loginId';vm.yobiUserEmail = '@UserApp.currentUser().email'">
+        <div class="header-pannel" ng-if="'@token'">
+            <div class="comeback-text pull-right">Yona to Github<span class="midium-font"></span></div>
+            <div class="row title-text-bg">
+                <div id="system-msg" class="well board" ng-cloak>
+                    <div class="messages" ng-repeat="msg in vm.systemMessages track by $index">
+                        {{::msg}}</div>
+                    <div class="error-data" ng-if="vm.importResult.errorData.length > 0">{{vm.importResult.errorData}}</div>
+                </div>
+            </div>
+            <div class="status">
+                <div class="row">
+                    <div class="head-title row-fluid" ng-cloak>
+                        <div class="source-title span5">
+                            <div class="project-name warn" ng-if="!vm.source.owner">Source 프로젝트를 선택해 주세요</div>
+                            <div class="project-name" ng-if="vm.source.owner">{{vm.source.owner}}/{{vm.source.projectName}}</div>
+                        </div>
+                        <div class="arrow span1"><i class="yobicon-arrow-right-alt"></i></div>
+                        <div class="destination-title span6">
+                            <div class="project-name warn" ng-if="!vm.destination.owner">Destination 프로젝트를 선택해 주세요</div>
+                            <div class="project-name" ng-if="vm.destination.owner">{{vm.destination.owner}}/{{vm.destination.projectName}}</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="row source-destination" >
+                <div class="source-project span4">
+                    <div class="header" ng-cloak>Source {{vm.sourceProjects.length}} 개</div>
+                    <div class="search left-border"><input tabindex="1" type="text" class="search-query" name="target-filter" ng-model="source.full_name" placeholder="Search.." autofocus></div>
+                    <div class="left-project-list">
+                        <div class="project-list" ng-repeat="repo in vm.sourceProjects | filter:source:strict" ng-click="vm.getSourceProject(repo.owner, repo.projectName);vm.selectSourceProject(repo.full_name)" ng-class="{'selected': repo.full_name == vm.selectedSourceName }" ng-cloak>
+                            <div class="owner" ng-if="vm.isNewOwner(repo)">{{repo.owner}}</div>
+                            <div class="project-name"><a href="http://yobi.navercorp.com/{{repo.full_name}}" target="_blank">
+                                {{repo.projectName}}</a></div>
+                            <div class="metainfo-sm">
+                                {{repo.owner}} {{repo.members}}명 <i class="yobicon-lock yobicon-small" ng-if="repo.private"></i></div>
+                        </div>
+                    </div>
+                </div>
+                <div class="destination-project span4" @if(StringUtils.isNotBlank(token)) {
+                    ng-init="vm.getDestinationProjects();vm.getCurrentGithubUser()"}>
+                    <div class="header" ng-cloak>Destination {{vm.destinationProjects.length}} 개</div>
+                    <div class="search"><input type="text" tabindex="2" class="search-query" name="target-filter" ng-model="destination.full_name" placeholder="Search.."></div>
+                    <div class="destination-project-list">
+                        <div class="project-list" ng-repeat="repo in vm.destinationProjects | filter:destination:strict" ng-click="vm.setDestination(repo);vm.selectDestinationProject(repo.full_name)" ng-class="{'selected': repo.full_name == vm.selectedDestinationName }" ng-cloak>
+                            <div class="owner" ng-if="vm.isNewOwner(repo)">{{::repo.owner.login}}</div>
+                            <div class="project-name"><a href="{{repo.html_url}}" target="_blank">{{repo.name}}</a></div>
+                            <div class="metainfo-sm">
+                                {{repo.owner.login}} <i class="yobicon-lock yobicon-small" ng-if="repo.private"></i>
+                            </div>
+                            <div class="warn-no-worker" ng-show="repo.full_name == vm.destination.full_name && vm.showWorkerWarning" ng-cloak>
+                                <div class="label label-important">Admin에 {{vm.CONFIG.DEFAULT_WORKER}} 유저가 없음</div>
+                                <div>{{vm.CONFIG.DEFAULT_WORKER}} 유저가 대상 프로젝트/그룹의 admin으로 추가되어 있어야 합니다. 그렇지 않을 경우 {{vm.CONFIG.DEFAULT_WORKER}} 대신 사용자 아이디가 작성자로 표시됩니다.
+                                </div>
+                                <div ng-if="repo.owner.type === 'Organization' && vm.showNotAdminWarning">
+                                    <div class="label label-warning">사용자가 Admin인 프로젝트가 아닙니다.</div>
+                                    <div>{{vm.CONFIG.DEFAULT_WORKER}}, {{vm.currentGithubUser.login}} 둘 다 Admin이 아닌 프로젝트로는 마이그레이션을 진행할 수 없습니다!</div>
+                                </div>
+                            </div>
+                            <div class="warn-user-project" ng-show="repo.full_name == vm.destination.full_name && vm.showUserProjectWarning" ng-cloak>
+                                <div class="label label-important">User Project</div>
+                                <div>Organization 소속의 프로젝트가 아닌 경우에는 마이그레이션시에 {{vm.CONFIG.DEFAULT_WORKER}} 대신 사용자 계정이 사용됩니다.</div>
+                            </div>
+                            <div ng-if="repo.owner.type === 'User' && repo.owner.login !== vm.currentGithubUser.login">
+                                <div class="label label-warning">사용자가 Admin인 프로젝트가 아닙니다.</div>
+                                <div>Admin이 아닌 프로젝트로는 마이그레이션을 진행할 수 없습니다!</div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="span6 status">
+                    <div class="progress row">
+                        <div class="bar span10" ng-cloak ng-class="vm.importResult.count/vm.expectedImportCount*100<100 ? 'bar-danger' : 'bar-success'" style="width: {{vm.importResult.count/vm.expectedImportCount*100 || 0 }}%">
+                            {{vm.importResult.count}}/{{vm.expectedImportCount}}</div>
+                    </div>
+                    <table class="table">
+                        <thead>
+                            <tr>
+                                <th colspan="2">Migration 대상</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+                        <tbody ng-cloak>
+                            <tr>
+                                <td class="left-title">마일스톤</td><td class="left-title">{{vm.source.milestoneCount}}</td>
+                                <td ng-class="vm.destination.milestones.length > 0?'alert-bg':''">
+                                    <import-warning type="마일스톤" data="vm.destination.milestones"></import-Warning>
+                                    <div class="btn-group"><button class="btn btn-danger" ng-click="vm.importMilestones()" ng-if="vm.destination.projectName" ng-disabled="!vm.source.milestoneCount || vm.importBtnDisabled" ng-cloak>
+                                        마일스톤 옮기기</button></div>
+                                </td>
+                            </tr>
+                            <tr>
+                                <td class="left-title">이슈</td><td class="left-title"><span>{{vm.source.issueCount}}</span></td>
+                                <td ng-class="vm.destination.issues.length > 0?'alert-bg':''">
+                                    <div class="text-align-left caution" ng-if="vm.destination.projectName">
+                                        마일스톤이 존재할 경우 마일스톤을 먼저 옮겨 놓지 않으면 마일스톤이 지정되지 않은 상태로 이슈가 이동됩니다.
+                                    </div>
+                                    <import-warning type="이슈" data="vm.destination.issues"></import-Warning>
+                                    <div class="btn-group"><button class="btn btn-danger" ng-click="vm.importIssues(vm.source)" ng-if="vm.destination.projectName" ng-disabled="!vm.source.issueCount || vm.importBtnDisabled" ng-cloak>
+                                        이슈 옮기기</button></div>
+                                </td>
+                            </tr>
+                            <tr>
+                                <td class="left-title">게시글</td><td class="left-title"><span>{{vm.source.postCount}}</span></td>
+                                <td ng-class="vm.destination.posts.length > 0?'alert-bg':''">
+                                    <div class="text-align-left caution" ng-if="vm.destination.projectName">
+                                        기존 게시글은 '게시글'라벨을 붙여 이슈로 옮겨집니다.
+                                    </div>
+                                    <div class="text-align-left caution" ng-if="vm.showMoreDesc">(만약 먼저 마이그레이션 작업을 진행한 이슈의 개수가 많을 경우 대상 프로젝트에 게시글이 보이는 시점까지는 상당한 시간이 소요될 수 있습니다. 진행바가 정상적으로 완료되었으면 차분히 기다려 주세요.)</div>
+                                    <import-warning type="게시글" data="vm.destination.posts"></import-Warning>
+                                    <div class="btn-group"><button class="btn btn-danger" ng-click="vm.showMoreDesc=!vm.showMoreDesc;vm.importPosts()" ng-if="vm.destination.projectName" ng-disabled="!vm.source.postCount || vm.importBtnDisabled" ng-cloak>
+                                        게시글 옮기기</button></div>
+                                </td>
+                            </tr>
+                            <tr>
+                                <td class="td-title left-title">첨부파일</td>
+                                <td colspan="2" class="text-align-left">
+                                    <div class="label label-warning">
+                                        단일 파일기준 50M 미만 크기의 첨부파일만 이동됩니다!
+                                    </div>
+                                    <div class="caution">
+                                        Yona to Githbub 마이그레이션 가이드를 꼭 읽어주세요.
+                                    </div>
+                                    <div class="btn-group pull-right"><button class="btn btn-danger" data-toggle="tooltip" data-placement="top" title="사전 wiki 생성을 잊었을 경우" ng-click="vm.showMoreDesc=!vm.showMoreDesc;vm.delegateAttachmentsMigration(true)" ng-if="vm.destination.projectName" ng-disabled="!vm.source.postCount && !vm.source.issueCount || vm.importBtnDisabled" ng-cloak>파일이동만 다시 한번 더 요청하기</button></div>
+                                </td>
+                            </tr>
+                    <tbody>
+                    </table>
+                    <div class="left-title" ng-cloak>
+                        기존 이슈 담당자<span ng-if="vm.source.assignees"> ({{vm.source.assignees.length}})</span>
+                    </div>
+                    <div class="caution" ng-if="vm.destination.projectName">
+                        대응되는 새 프로젝트 소속의 담당자 id를 입력해 주세요. 만약 지정하지 않으면 기존 담당자의 이슈는 담당자가 해제된 상태로 이전됩니다.</div>
+                    <div>
+                        <table class="table table-bordered" ng-if="vm.source.assignees.length > 0" ng-show="vm.destination.projectName" ng-cloak>
+                            <tr ng-repeat="assignee in vm.source.assignees" class="assignee">
+                                <td>{{assignee.name}}<br/>@@{{assignee.login}}</td>
+                                <td>
+                                    <input ng-keyup="vm.userExistAtDestinationProject(vm.destination, assignee.login)" type="text" placeholder="지정되지 않음" ng-model="vm.destination.assignees[assignee.login].login" ng-init="vm.getAssigneeFromLocal(assignee.login)"><i class="yobicon-check-circle" ng-if="vm.destination.assignees[assignee.login].login && vm.destination.assignees[assignee.login].confirmed"></i><i class="yobicon-delete-circle-alt" ng-if="vm.destination.assignees[assignee.login].login && vm.destination.assignees[assignee.login].confirmed === false"></i></td>
+                            </tr>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+}
 
app/views/migration/migrationPageLayout.scala.html (added)
+++ app/views/migration/migrationPageLayout.scala.html
@@ -0,0 +1,52 @@
+@**
+* Yona, 21c Project Hosting SW
+*
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
+**@
+@(title: String)(theme:String)(content: Html)
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <title>@title</title>
+        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+        <meta http-equiv="cache-control" content="no-cache">
+        <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.ico")">
+        <link href='https://fonts.googleapis.com/css?family=Montserrat' rel='stylesheet' type='text/css'>
+        <link href='https://fonts.googleapis.com/css?family=Indie+Flower' rel='stylesheet' type='text/css'>
+        <link href='https://fonts.googleapis.com/css?family=Muli' rel='stylesheet' type='text/css'>
+        <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("bootstrap/css/bootstrap.css")">
+        <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("stylesheets/yobicon/style.css")">
+        <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("javascripts/lib/select2/select2.css")"/>
+        <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("javascripts/lib/pikaday/pikaday.css")" />
+        <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("stylesheets/yobi.css")">
+        <link rel='stylesheet' href="@routes.Assets.at("javascripts/lib/nprogress/nprogress.css")"/>
+
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/nprogress/nprogress.js")"></script>
+        <script type="text/javascript">
+                NProgress.configure({ minimum: 0.6 });
+        </script>
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/jquery/jquery-1.9.0.js")"></script>
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/jquery/jquery.browser.js")"></script>
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/jquery/jquery.pjax.js")"></script>
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/common/yobi.Common.js")"></script>
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/vendor.js")"></script>
+        <script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.Migration.js")"></script>
+    </head>
+
+    <body class="@theme">
+        @if(UserApp.isSiteAdminLoggedInSession){
+            <div class="admin-logged-in-affix" data-spy="affix" data-offset-top="30">@Messages("user.siteAdminLoggedInAffix") <span class="small-font">@Messages("user.siteAdminLoggedInAffix.maxim")</span></div>
+        }
+        @partial_update_notification()
+        @common.navbar(utils.MenuType.SITE_HOME, null, null)
+
+        @content
+
+        @common.scripts()
+    </body>
+</html>
+
conf/routes
--- conf/routes
+++ conf/routes
@@ -9,6 +9,17 @@
 
 # Home page
 GET            /                                                                      controllers.Application.index()
+
+# Migration support page
+GET            /migration                                                             controllers.MigrationApp.migration()
+GET            /migration/projects                                                    controllers.MigrationApp.projects()
+GET            /migration/:owner/projects/:projectName                                controllers.MigrationApp.project(owner, projectName)
+GET            /migration/:owner/projects/:projectName/labels                         controllers.MigrationApp.exportLabels(owner, projectName)
+GET            /migration/:owner/projects/:projectName/issuelabel                     controllers.MigrationApp.exportIssueLabelPairs(owner, projectName)
+GET            /migration/:owner/projects/:projectName/milestones                     controllers.MigrationApp.exportMilestones(owner, projectName)
+GET            /migration/:owner/projects/:projectName/issues                         controllers.MigrationApp.exportIssues(owner, projectName)
+GET            /migration/:owner/projects/:projectName/posts                          controllers.MigrationApp.exportPosts(owner, projectName)
+
 # Map static resources from the /public folder to the /assets URL path
 GET            /messages.js                                                           controllers.Application.jsMessages()
 GET            /favicon.ico                                                           controllers.Assets.at(path="/public", file="images/favicon.ico")
 
public/javascripts/lib/vendor.js (added)
+++ public/javascripts/lib/vendor.js
This file is too big to display.
 
public/javascripts/service/yona.Migration.js (added)
+++ public/javascripts/service/yona.Migration.js
This diff is skipped because there are too many other diffs.
Add a comment
List