THIS IS A TEST INSTANCE ONLY! REPOSITORIES CAN BE DELETED AT ANY TIME!

Browse Source

Merge branch 'next' for the intermission

tags/v1.11.0
Suwon Chae 1 year ago
parent
commit
03f4a1b909
66 changed files with 5905 additions and 1364 deletions
  1. +1
    -1
      .travis.yml
  2. +3
    -0
      AUTHORS
  3. +37
    -36
      README.md
  4. +6
    -1
      app/assets/stylesheets/less/_common.less
  5. +79
    -65
      app/assets/stylesheets/less/_markdown.less
  6. +61
    -2
      app/assets/stylesheets/less/_page.less
  7. +15
    -0
      app/assets/stylesheets/less/_yobiUI.less
  8. +5
    -0
      app/controllers/BoardApp.java
  9. +24
    -1
      app/controllers/IssueApp.java
  10. +14
    -11
      app/controllers/ProjectApp.java
  11. +76
    -2
      app/controllers/api/BoardApi.java
  12. +291
    -20
      app/controllers/api/IssueApi.java
  13. +54
    -17
      app/controllers/api/ProjectApi.java
  14. +78
    -5
      app/controllers/api/UserApi.java
  15. +3
    -0
      app/models/Comment.java
  16. +9
    -0
      app/models/Issue.java
  17. +40
    -7
      app/models/NotificationEvent.java
  18. +4
    -3
      app/models/NotificationMail.java
  19. +9
    -0
      app/models/Posting.java
  20. +3
    -0
      app/models/Project.java
  21. +73
    -34
      app/models/Webhook.java
  22. +3
    -1
      app/models/enumeration/EventType.java
  23. +24
    -0
      app/models/enumeration/IssueFilterType.java
  24. +17
    -0
      app/models/enumeration/WebhookType.java
  25. +119
    -0
      app/models/support/IssueSearchCondition.java
  26. +27
    -1
      app/utils/JodaDateUtil.java
  27. +6
    -1
      app/utils/Markdown.java
  28. +5
    -2
      app/views/board/partial_comments.scala.html
  29. +17
    -3
      app/views/board/view.scala.html
  30. +3
    -3
      app/views/common/commentUpdateForm.scala.html
  31. +3
    -0
      app/views/common/editor.scala.html
  32. +23
    -0
      app/views/common/scripts.scala.html
  33. +12
    -0
      app/views/common/tasklistBar.scala.html
  34. +1
    -0
      app/views/git/partial_search.scala.html
  35. +67
    -48
      app/views/help/markdown.scala.html
  36. +5
    -2
      app/views/issue/partial_comment.scala.html
  37. +30
    -10
      app/views/issue/view.scala.html
  38. +35
    -25
      app/views/project/partial_webhooks_list.scala.html
  39. +4
    -0
      app/views/project/webhooks.scala.html
  40. +10
    -4
      build.sbt
  41. +2
    -2
      conf/application.conf.default
  42. +6
    -0
      conf/evolutions/default/24.sql
  43. +7
    -0
      conf/evolutions/default/25.sql
  44. +1
    -0
      conf/messages
  45. +3
    -2
      conf/messages.ko-KR
  46. +1009
    -0
      conf/messages.ru-RU
  47. +1009
    -0
      conf/messages.uz-UZ
  48. +19
    -8
      conf/routes
  49. +3
    -1
      public/javascripts/common/yobi.CommentForm.js
  50. +12
    -24
      public/javascripts/common/yobi.Markdown.js
  51. +88
    -0
      public/javascripts/common/yona.KeyControl.js
  52. +9
    -0
      public/javascripts/common/yona.Sha1.js
  53. +136
    -0
      public/javascripts/common/yona.Tasklist.js
  54. +1
    -1
      public/javascripts/lib/elevator/jquery.elevator.css
  55. +1298
    -1012
      public/javascripts/lib/marked.js
  56. +2
    -0
      public/javascripts/lib/tasklist/gfm-task-list.css
  57. +182
    -0
      public/javascripts/lib/tasklist/gfm-task-list.js
  58. +4
    -1
      public/javascripts/service/yobi.issue.List.js
  59. +1
    -1
      public/javascripts/service/yobi.user.Setting.js
  60. +3
    -3
      public/javascripts/service/yona.temporarySaveHandler.js
  61. +4
    -4
      public/javascripts/yona-lib.js
  62. +171
    -0
      test/controllers/api/IssueApiGetIssueTest.java
  63. +178
    -0
      test/controllers/api/IssueApiNewIssueCommentTest.java
  64. +168
    -0
      test/controllers/api/IssueApiUpdateIssueCommentTest.java
  65. +153
    -0
      test/controllers/api/IssueApiUpdateIssueTest.java
  66. +140
    -0
      test/controllers/api/UserApiGetIssuesByUserTest.java

+ 1
- 1
.travis.yml View File

@@ -7,6 +7,6 @@ env:
before_script:
- wget http://downloads.typesafe.com/typesafe-activator/${ACTIVATOR_VERSION}/${ACTIVATOR_ZIP_FILE}
- unzip -q ${ACTIVATOR_ZIP_FILE}
- set SBT_OPTS= -Xms256m Xmx52m -XX:MaxPermSize=512m
- set SBT_OPTS= -Xms256m -Xmx512m
script:
- activator-${ACTIVATOR_VERSION}-minimal/activator compile

+ 3
- 0
AUTHORS View File

@@ -49,3 +49,6 @@ rimi <rimi@userinsight.co.kr>
kenu <kenu.heo@gmail.com>
DongHo Byun <cpascal@nextfloor.com>
Mijeong Park <p.mj@naverlabs.com>
Joonho Choi <timberx@naver.com>
GwanYeong Kim <gy741.kim@gmail.com>
Father Vlasie <fv@spots.school>

+ 37
- 36
README.md View File

@@ -14,7 +14,7 @@ Official Site: [http://yona.io](http://yona.io)
Yona?
--
- Git 저장소 기능이 내장된 설치형 이슈트래커
- Naver를 비롯하여 게임회사, 통신회사 고객센터, 투자사, 학교, 기업등에서 수년 간 실제로 사용되어 왔고 개선되어 온(Real world battled) 애플리케이션입니다
- Naver, Naver Labs 를 비롯하여 게임회사, 통신회사 고객센터, 공공기관, 투자사, 학교, 기업등에서 수년 간 실제로 사용되어 왔고 개선되어 온(Real world battled) 애플리케이션입니다

주요기능
---
@@ -65,11 +65,11 @@ Yona 배포판

- MariaDB 버전
- 기본 권장 버전
- `yona-v1.8.0-bin.zip` 같은 형식으로 파일로 배포
- `yona-v1.10.0-bin.zip` 같은 형식으로 파일로 배포
- DB 설치에 약간의 시간이 필요하지만 안정적으로 운영이 가능
- H2 DB 내장형
- DB 설정없이 내려받아서 바로 실행해서 쓸 수 있는 버전
- `yona-h2-v1.8.0-bin.zip` 같은 형식으로 파일로 배포
- `yona-h2-v1.10.0-bin.zip` 같은 형식으로 파일로 배포
- USB 등에 담아서 이동해가면서 사용하거나 작업후 통째로 zip으로 묶어서 들고 다니는 것이 가능함
- 대규모 사이트에서 사용하기에는 적합하지 않음. 참고: [Yona가 MariaDB를 기본 DB로 사용하게 된 이유](https://repo.yona.io/yona-projects/yona/post/4)

@@ -96,7 +96,7 @@ Yona 실행 및 업그레이드/백업 및 복구/문제 해결
- [발생 가능한 문제상황들과 해결방법](docs/ko/trouble-shootings.md)


소스코드를 직접 내려 받아서 빌드 실행하
소스코드를 직접 내려 받아서 빌드하거나 자신만의 배포판을 만들
---
자신의 입맛에 맛게 코드를 직접 수정해서 작업하거나 코드를 기여하고 싶을 경우에는 코드 저장소로부터 코드를 직접 내려받아서 빌드/실행하는 것도 가능합니다.
[소스코드를 직접 내려 받아서 실행하기](https://repo.yona.io/yona-projects/yona/post/5)를 참고해 주세요
@@ -135,7 +135,6 @@ Contribution
- `next`브랜치는 내부 개발용입니다. 어떠한 기능들이 추가되고 있는지 현장을 보고 싶으시면 `next`브랜치를 참고해주세요.
- 코드리뷰 후 merge 되면 Yona Author로 파일에 기록되며 작은 기념품을 보내드립니다.


<br/>

<a name="english"></a>[[한국어]](#korean)
@@ -145,38 +144,40 @@ Yona
=======
Yona is a web-based project hosting software.

What is Yona?
What you can do with Yona:
--
Yona is designed to increase the speed and efficiency of your team's work and development.
Yona is designed to increase the speed and efficiency of team work and team development.

- Issue tracker
- Transferable Issue
- Issue change history
- Issues can be transferred to other projects
- Issues' change histories can be viewed
- Bulletin board
- Embedded Git/SVN respository features
- Pull-request & Block based code review
- Online Commit
- Pull requests & Block-based code review
- Online Commits
- LDAP support
- Social Login
- Migration from/to another service/instance
- Github/Github Enterprise, Redmine, Yona

- Social login
- Migration to/from other services or Yona instances
- Github/Github Enterprise, Redmine, Yona

Yona Distribution
Requirements
---
Currently, Yona offers two distributions per version.
- Java 8+
- System Memory 2Gb+ (Recommendation: 4Gb+)

#### MariaDB Version
- Recommended version.
- Distributed as a file in the similar format as yona-v1.3.0-bin.zip
- It takes a little effort to install DB, but it can be operated stably.
Distribution
---
Currently, There are two distribution types.

#### Embedded H2 DB Version
- Portable version that can be downloaded and run immediately. No need to setting a DB.
- Distributed as a file in the similar format as yona-h2-v1.3.0-bin.zip
- It can be used portable. For example, along with USB etc. And it can be carried around with zip as a whole of work.
 - Not suitable for large-scale team. (over 500 people)
#### MariaDB version
- Recommended version
- It takes a little effort to install DB, but it guarantees stable operation

#### Embedded H2 DB version
- Portable version that can be downloaded and run immediately.
- Setting a DB is not required.
- Also, can run the software directly from a USB device
- Suitable for small teams (under 500 users).

How to install
---
@@ -189,7 +190,7 @@ Basically, Yona installation is in two steps:
If you want to use [Docker](https://www.docker.com/), See https://github.com/pokev25/docker-yona by [pokev25](https://github.com/pokev25)


Yona Start/Upgrade/Backup/Trouble Shootings
Start/Upgrade/Backup/Trouble Shootings
---
- [Start and Restart](docs/yona-run-and-restart.md)
- [Start Options](docs/yona-run-options.md) for stable operation
@@ -206,17 +207,17 @@ Server Settings
Migration
---
- [Yona Export](https://github.com/yona-projects/yona-export)
- Support repository local backup
- Yona to another Yoan instance
- Any source corresponding to export format, can be imported into Yona
- Support Yona to Github/Github Enterprise migration
- Local backup
- Move projects to another Yona instance
- If you can match the format, anything can be imported into Yona
- Github/Github Enterprise migration
- [See here](https://github.com/yona-projects/yona/blob/master/conf/application.conf.default#L297)
Google Analytics
---
- Basically, distributed Yona include Google Analytics.
- This data is used to better understand how users use Yona and make constantly improving Yona development.
- To disable this for any reason, set the following option to false in conf/application.conf file.
- Distributed Yona includes Google Analytics
- This data is used for making us to improve Yona
- If you want to disable this for any reason, set the following option to false in conf/application.conf file.
```
application.send.yona.usage = true
```
@@ -224,8 +225,8 @@ application.send.yona.usage = true
Contribution
---
- The branch for contributions is `master`.
- Fork the repository, then work on the `master` branch and send a pull request to the` master` branch.
   - The `next` branch is for internal development. If you want to see what features are being added, please refer to the `next` branch.
- At first, fork the repository, then work on the `master` branch. And send a pull request to the`master` branch.
- The `next` branch is for internal development. If you want to see what features are being added, please refer to the `next` branch.


License


+ 6
- 1
app/assets/stylesheets/less/_common.less View File

@@ -305,4 +305,9 @@ input.white:-ms-input-placeholder { color:#fff; opacity:0.8; }
line-height: 36px;
}

.dimgray { color:dimgray; }
.dimgray { color:dimgray; }

.vertical-align {
display: flex;
align-items: center;
}

+ 79
- 65
app/assets/stylesheets/less/_markdown.less View File

@@ -15,11 +15,20 @@ code {
-o-font-feature-settings: "kern" 1;
font-feature-settings: "kern" 1;
font-kerning: normal;
padding: 15px 20px !important;

word-wrap: break-word;

> *:first-child {
margin-top: 0 !important;
}

> *:last-child {
margin-bottom: 0 !important;
}

ul, ol {
padding: 5px 0 5px 2.5em;
padding: 0 0 5px 2.5em;
font-weight: normal;
margin-left: 0;
}
@@ -43,27 +52,22 @@ code {
margin-bottom: 12px;
}

> ul {
margin-top: 14px;
margin-bottom: 7px;
}

a {
color:#4183c4;
text-decoration:none;
color: #4183c4;
text-decoration: none;
}

a:hover {
color:#4183c4;
text-decoration:underline;
color: #4183c4;
text-decoration: underline;
}

a:active {
color:#4183c4;
text-decoration:none;
color: #4183c4;
text-decoration: none;
}

.anchor(){
.anchor() {
.head-anchor {
margin-left: 3px;
opacity: 0;
@@ -75,10 +79,15 @@ code {
}
}

h1,
h2,
h3 {
line-height: 40px;
margin-bottom: 16px;
}

h1 {
font-size:2.0em;
margin-top: 1.5em;
margin-bottom: 15px;
font-size: 2.0em;
padding-bottom: 0.3em;
border-bottom: 1px solid #eee;
.anchor();
@@ -87,16 +96,17 @@ code {
}

h2 {
margin: 1em 0 5px;
font-size:1.5em;
line-height: 1.25;
font-size: 1.5em;
width: 95%;
padding: 0 0 0.3em 0;
border-bottom: 1px solid #eaecef;
.anchor();
}

h3 {
margin:1em 0 5px;
font-size:1.4em;
margin: 1em 0 5px;
font-size: 1.4em;
padding: 0;
.anchor();
}
@@ -115,115 +125,119 @@ code {
}

hr {
height:1px;
margin:10px 0;
border:0;color:#ccc;
background-color:#ccc;
height: 1px;
margin: 10px 0;
border: 0;
color: #ccc;
background-color: #ccc;
}

p {
margin : 10px 0;
margin: 0 0 16px 0;
line-height: 1.6em;
}

blockquote {
p {
font-size: 0.9em;
font-weight: normal;
}
p {
font-size: 0.9em;
font-weight: normal;
}
}

code {
padding: 5px 5px 2px 5px;
border: 1px solid #ddd;
border-radius: 3px;
font-family:@fixed-font-family;
font-family: @fixed-font-family;
font-size: 13px;

.title {
font-size:inherit;
font-size: inherit;
}
}

blockquote{
blockquote {
border-left: 4px solid #DDD;
padding: 0 15px;
color: #777;
}

li > img {
max-width:80%;
max-width: 80%;
}
img{
max-width:100%;
margin:10px 0;

li > input[type='checkbox'] {
vertical-align: text-top;
}

img {
max-width: 100%;
margin: 10px 0;
padding: 5px;
border:1px solid rgba(0,0,0,0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
.box-sizing(border-box);
max-height: 300px;
}

ul {
line-height:20px;
list-style:disc;
> ul {
line-height: 20px;
list-style: disc;
margin-bottom: 16px;
}

li{
line-height: 1.6em;
li {
line-height: 1.6em;
}

ul ul, ol ul {
list-style:circle;
list-style: circle;
}

ul ul ul, ol ul ul, ol ol ul, ul ol ul {
list-style:square;
list-style: square;
}


ol{
line-height: 1.6em;
list-style:decimal;
ol {
line-height: 1.6em;
list-style: decimal;
}

pre {
font-size: 1em;
background-color:#EFEFEF;
background-color: #EFEFEF;
padding: 10px;
margin:10px 0;
word-break : normal;
margin: 10px 0;
word-break: normal;
border: none;

code {
margin : 0;
margin: 0;
padding: 0;
border : none;
border: none;
}
}

table {
border-collapse:collapse;
margin: 15px 15px;
border-collapse: collapse;
margin: 15px 15px;

th {
padding:5px;
border:1px solid #dcddde;
background-color:#f7f7f7;
padding: 5px;
border: 1px solid #dcddde;
background-color: #f7f7f7;
min-width: 45px;
}

td {
padding:5px;
border:1px solid #dcddde;
padding: 5px;
border: 1px solid #dcddde;
word-break: break-all;
}

}
&.markdown-before {
visibility : hidden;
}
&.markdown-before {
visibility: hidden;
}

.popover {
max-width: 400px;


+ 61
- 2
app/assets/stylesheets/less/_page.less View File

@@ -2888,6 +2888,16 @@ label.inline-list {
padding: 10px 0px;
position: relative;

&:target {
.media-body {
border: 2px solid #03a9f4;
&::before {
border-color: #03a9f4;
border-width: 0 0 2px 2px;
}
}
}

.comment-avatar {
float: left;
padding-left: 5px;
@@ -5526,7 +5536,7 @@ label.issue-item-row {
border-bottom: none;
li {
display: inline-block;
padding:5px 10px;
padding:5px 7px;
line-height: 20px;

.label {
@@ -5537,7 +5547,7 @@ label.issue-item-row {
&.help-nav {
cursor: pointer;
position: relative;
color:#788ba7;
color:#9e9e9e;

&:hover {
color:#333;
@@ -7311,3 +7321,52 @@ div.diff-body[data-outdated="true"] tr:hover .icon-comment {
color: #e91e63 !important;
}
}

@keyframes showNav {
from {opacity: 0;}
to {opacity: 1;}
}

.task-list-button {
margin-top: 2px;

button {
margin-top: 1px;
}
}

.tasklist {
padding: 10px 20px 0 20px;
box-shadow: none;
filter: none;
display: none;

&.task-show {
display: block;
animation: showNav 250ms ease-in-out both;
}

.task-title {
font-weight: 500;

.done-counter {
margin-left: 5px;
}
}

.task-progress{
background-color: #D4D4D4;

.bar {
transition: .2s;
height: 2px;
width: 100%;
}
.red {
background-color: red;
}
.green {
background-color: #8bc34a;
}
}
}

+ 15
- 0
app/assets/stylesheets/less/_yobiUI.less View File

@@ -843,6 +843,21 @@ span.issue-label {
}
}

&.ybtn-danger-no-outline {
font-weight: 600;
box-shadow: none;
color: #666;
padding: 1px 10px !important;
border: 1px solid transparent;
background-color: #eee;

&:hover, &:focus {
color: @yobi-btn-danger;
background-color: #fbe9e7;
border: 1px solid #EF9A9A;
}
}

&.ybtn-inverse {
background-color : @yobi-btn-inverse !important;
border:1px solid @yobi-btn-inverse-hover;


+ 5
- 0
app/controllers/BoardApp.java View File

@@ -417,6 +417,11 @@ public class BoardApp extends AbstractPostingApp {
return redirect(RouteUtil.getUrl(savedComment));
}

// Just made for compatibility. No meanings.
public static Result updateComment(String ownerName, String projectName, Long number, Long commentId) throws IOException {
return newComment(ownerName, projectName, number);
}

private static Comment saveComment(Project project, Posting posting, PostingComment comment) {
Comment savedComment;
PostingComment existingComment = PostingComment.find.where().eq("id", comment.id).findUnique();


+ 24
- 1
app/controllers/IssueApp.java View File

@@ -37,6 +37,8 @@ import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.*;

import static utils.JodaDateUtil.getOptionalShortDate;

@AnonymousCheck
public class IssueApp extends AbstractPostingApp {
private static final String EXCEL_EXT = "xls";
@@ -221,7 +223,9 @@ public class IssueApp extends AbstractPostingApp {
result.put("title", issue.title);
result.put("state", issue.state.toString());
result.put("createdDate", issue.createdDate.toString());
result.put("link", routes.IssueApp.issue(project.owner, project.name, issueId).toString());
if (project != null) {
result.put("link", routes.IssueApp.issue(project.owner, project.name, issueId).toString());
}
listData.put(issue.id.toString(), result);
}

@@ -895,6 +899,16 @@ public class IssueApp extends AbstractPostingApp {
if(StringUtils.isNotEmpty(comment.parentCommentId)){
comment.setParentComment(IssueComment.find.byId(Long.valueOf(comment.parentCommentId)));
}

if(issue.comments.size() == 0) {
User user = User.find.byId(issue.authorId);
comment.previousContents = getPrevious("Original issue", issue.body, issue.updatedDate, user.loginId);
} else {
Comment previousComment = issue.comments.get(issue.comments.size() - 1);
User user = User.find.byId(previousComment.authorId);
comment.previousContents = getPrevious("Previous comment", previousComment.contents, previousComment.createdDate, user.loginId);
}

Comment savedComment = saveComment(project, issue, comment);

if( containsStateTransitionRequest() ){
@@ -910,6 +924,15 @@ public class IssueApp extends AbstractPostingApp {
return redirect(RouteUtil.getUrl(savedComment));
}

private static String getPrevious(String templateTitle, String contents, Date updatedDate, String authorLoginId) {
return "\n\n<br />\n\n--- " + templateTitle + " from @" + authorLoginId + " " + getOptionalShortDate(updatedDate) + " ---\n\n<br />\n\n" + contents;
}

// Just made for compatibility. No meanings.
public static Result updateComment(String ownerName, String projectName, Long number, Long commentId) throws IOException {
return newComment(ownerName, projectName, number);
}

private static Comment saveComment(Project project, Issue issue, IssueComment comment) {
Comment savedComment;
IssueComment existingComment = IssueComment.find.where().eq("id", comment.id).findUnique();


+ 14
- 11
app/controllers/ProjectApp.java View File

@@ -18,7 +18,6 @@ import models.*;
import models.enumeration.*;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.mail.HtmlEmail;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -49,7 +48,6 @@ import views.html.project.setting;
import views.html.project.transfer;
import views.html.project.change_vcs;

import javax.annotation.Nonnull;
import javax.servlet.ServletException;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
@@ -1291,22 +1289,27 @@ public class ProjectApp extends Controller {
return notFound(ErrorViews.NotFound.render("error.notfound"));
}

Form<Webhook> addWebhookForm = form(Webhook.class).bindFromRequest();
if (addWebhookForm == null) {
Form<Webhook> addNewWebhookForm = form(Webhook.class).bindFromRequest();
if (addNewWebhookForm == null) {
Logger.warn("Failed creating webhook: got null form from newWebhook request");
return badRequest();
} else if (addWebhookForm.hasErrors()) {
return badRequest(ErrorViews.BadRequest.render());
return badRequest("Failed creating webhook: got null form from newWebhook request");
} else if (addNewWebhookForm.hasErrors()) {
return badRequest(ErrorViews.BadRequest.render(addNewWebhookForm.errorsAsJson().toString()));
}

Webhook webhook = addWebhookForm.get();

Webhook.create(project.id, webhook.payloadUrl.trim(), webhook.secret,
BooleanUtils.toBooleanDefaultIfNull(webhook.gitPushOnly, false));
createWebhook(project, addNewWebhookForm);

return redirect(routes.ProjectApp.webhooks(project.owner, project.name));
}

private static void createWebhook(Project project, Form<Webhook> forms) {
Webhook webhook = forms.get();
webhook.project = project;
if(webhook.gitPushOnly == null) webhook.gitPushOnly = false;
webhook.createdAt = new Date();
webhook.save();
}

@Transactional
@IsAllowed(Operation.UPDATE)
public static Result deleteWebhook(String ownerId, String projectName, Long id) {


+ 76
- 2
app/controllers/api/BoardApi.java View File

@@ -14,7 +14,9 @@ import controllers.annotation.IsCreatable;
import models.*;
import models.enumeration.Operation;
import models.enumeration.ResourceType;
import org.apache.commons.codec.digest.DigestUtils;
import org.joda.time.DateTime;
import play.api.mvc.Codec;
import play.db.ebean.Transactional;
import play.libs.Json;
import play.mvc.Result;
@@ -26,8 +28,7 @@ import utils.RouteUtil;
import java.io.IOException;
import java.util.*;

import static controllers.api.IssueApi.findAuthor;
import static controllers.api.IssueApi.parseDateString;
import static controllers.api.IssueApi.*;
import static play.libs.Json.toJson;

public class BoardApi extends AbstractPostingApp {
@@ -120,6 +121,38 @@ public class BoardApi extends AbstractPostingApp {

}

@Transactional
public static Result updatePostingContent(String owner, String projectName, Long number) {
User user = UserApp.currentUser();
if (user.isAnonymous()) {
return unauthorized(Json.newObject().put("message", "unauthorized request"));
}

JsonNode json = request().body().asJson();
if(json == null) {
return badRequest(Json.newObject().put("message", "Expecting Json data"));
}

Project project = Project.findByOwnerAndProjectName(owner, projectName);
final Posting posting = Posting.findByNumber(project, number);

if (!AccessControl.isAllowed(user, posting.asResource(), Operation.UPDATE)) {
return forbidden(Json.newObject().put("message", "Forbidden request"));
}

String content = json.findValue("content").asText();
String rememberedChecksum = json.findValue("sha1").asText();

if (isModifiedByOthers(posting.body, rememberedChecksum)) {
return conflicted(posting.body);
}

posting.body = content;
posting.update();

return ok(ProjectApi.getResult(posting));
}

@Transactional
@IsCreatable(ResourceType.NONISSUE_COMMENT)
public static Result newPostingComment(String ownerName, String projectName, Long number)
@@ -156,4 +189,45 @@ public class BoardApi extends AbstractPostingApp {

return created(result);
}

@Transactional
public static Result updatePostingComment(String ownerName, String projectName, Long number, Long commentId) {
ObjectNode result = Json.newObject();

User user = UserApp.currentUser();
if (user.isAnonymous()) {
return unauthorized(result.put("message", "unauthorized request"));
}

JsonNode json = request().body().asJson();
if(json == null) {
return badRequest(result.put("message", "Expecting Json data"));
}

String comment = json.findValue("content").asText();
String rememberedChecksum = json.findValue("sha1").asText();

Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
final Posting posting = Posting.findByNumber(project, number);
PostingComment postingComment = posting.findCommentByCommentId(commentId);

if (!AccessControl.isAllowed(user, postingComment.asResource(), Operation.UPDATE)) {
return forbidden(Json.newObject().put("message", "Forbidden request"));
}

if (isModifiedByOthers(postingComment.contents, rememberedChecksum)) {
return conflicted(postingComment.contents);
}

postingComment.contents = comment;
postingComment.save();

ObjectNode commentNode = getCommentJsonNode(postingComment);
ObjectNode authorNode = getAuthorJsonNode(user);

commentNode.set("author", toJson(authorNode));
result.set("result", commentNode);

return ok(result);
}
}

+ 291
- 20
app/controllers/api/IssueApi.java View File

@@ -9,6 +9,7 @@ package controllers.api;

import com.avaje.ebean.ExpressionList;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import controllers.AbstractPostingApp;
import controllers.UserApp;
@@ -18,7 +19,9 @@ import controllers.annotation.IsCreatable;
import controllers.routes;
import models.*;
import models.enumeration.*;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import play.api.mvc.Codec;
import play.db.ebean.Transactional;
import play.i18n.Messages;
import play.libs.F;
@@ -37,7 +40,7 @@ import java.util.*;

import static controllers.UserApp.MAX_FETCH_USERS;
import static controllers.UserApp.currentUser;
import static controllers.api.UserApi.createUserNode;
import static controllers.api.UserApi.*;
import static play.libs.Json.toJson;

public class IssueApi extends AbstractPostingApp {
@@ -69,12 +72,52 @@ public class IssueApi extends AbstractPostingApp {
return ok(result);
}

@IsAllowed(value = Operation.READ, resourceType = ResourceType.BOARD_POST)
@Transactional
public static Result getIssue(String owner, String projectName, Long number) {
ObjectNode result = Json.newObject();
if (!isAuthored(request())) {
return unauthorized(result.put("message", "unauthorized request"));
}

Project project = Project.findByOwnerAndProjectName(owner, projectName);
if (project == null) {
return badRequest(result.put("message", "no project by request"));
}

Issue issue = Issue.findByNumber(project, number);
JsonNode json = ProjectApi.getResult(issue);
return ok(json);
if (issue == null) {
return badRequest(result.put("message", "no issue by request"));
}
ObjectNode json = ProjectApi.getResult(issue);

return ok(Json.newObject().set("result", toJson(addIssueEvents(issue, json))));
}

private static ObjectNode addIssueEvents(Issue issue, ObjectNode json) {
if (issue.events.size() > 0) {
json.put("events", getIssueEvents(issue));
}

return json;
}

private static ArrayNode getIssueEvents(Issue issue) {
ArrayNode array = Json.newObject().arrayNode();

if (issue.events.size() > 0) {
for (IssueEvent event: issue.events) {
ObjectNode result = Json.newObject();
result.put("id", event.id);
result.put("createdDate", JodaDateUtil.getDateString(event.created, JodaDateUtil.ISO_FORMAT));
result.put("eventType", event.eventType.toString());
result.put("eventDescription", event.eventType.getDescr());
result.put("oldValue", event.oldValue);
result.put("newValue", event.newValue);
array.add(result);
}
}

return array;
}

@Transactional
@@ -91,7 +134,7 @@ public class IssueApi extends AbstractPostingApp {
return badRequest(result.put("message", "No issues key exists or value wasn't array!"));
}

boolean sendNotification = json.findValue("sendNotification") != null;
boolean sendNotification = json.findValue("sendNotification") != null && json.findValue("sendNotification").asBoolean();

Project project = Project.findByOwnerAndProjectName(owner, projectName);

@@ -103,6 +146,128 @@ public class IssueApi extends AbstractPostingApp {
return created(toJson(createdIssues));
}

@Transactional
public static Result updateIssue(String owner, String projectName, Long number) {
ObjectNode result = Json.newObject();

if (!isAuthored(request())) {
return unauthorized(result.put("message", "unauthorized request"));
}

JsonNode json = request().body().asJson();
if(json == null) {
return badRequest(result.put("message", "Expecting Json data"));
}

User user = getAuthorizedUser(getAuthorizationToken(request()));

Project project = Project.findByOwnerAndProjectName(owner, projectName);
final Issue issue = Issue.findByNumber(project, number);

return updateIssueNode(json, project, issue, user);
}

@Transactional
public static Result updateIssueState(String owner, String projectName, Long number) {
ObjectNode result = Json.newObject();

if (!isAuthored(request())) {
return unauthorized(result.put("message", "unauthorized request"));
}

JsonNode json = request().body().asJson();
if(json == null) {
return badRequest(result.put("message", "Expecting Json data"));
}

User user = getAuthorizedUser(getAuthorizationToken(request()));

Project project = Project.findByOwnerAndProjectName(owner, projectName);
final Issue issue = Issue.findByNumber(project, number);
State newIssueState = findIssueState(json);
if (!newIssueState.equals(issue.state)) {
addNewIssueEvent(issue, user, EventType.ISSUE_STATE_CHANGED, issue.state.state(), newIssueState.state());
}
issue.state = newIssueState;
issue.save();

result = ProjectApi.getResult(issue);
return ok(Json.newObject().set("result", toJson(addIssueEvents(issue, result))));
}

@Transactional
public static Result updateIssueContent(String owner, String projectName, Long number) {

User user = UserApp.currentUser();
if (user.isAnonymous()) {
return unauthorized(Json.newObject().put("message", "unauthorized request"));
}

JsonNode json = request().body().asJson();
if(json == null) {
return badRequest(Json.newObject().put("message", "Expecting Json data"));
}

Project project = Project.findByOwnerAndProjectName(owner, projectName);
final Issue issue = Issue.findByNumber(project, number);

if (!AccessControl.isAllowed(user, issue.asResource(), Operation.UPDATE)) {
return forbidden(Json.newObject().put("message", "Forbidden request"));
}

String content = json.findValue("content").asText();
String rememberedChecksum = json.findValue("sha1").asText();

if (isModifiedByOthers(issue.body, rememberedChecksum)) {
return conflicted(issue.body);
}

issue.body = content;
issue.update();

return ok(ProjectApi.getResult(issue));
}

private static Result updateIssueNode(JsonNode json, Project project, Issue issue, User user) {

issue.title = json.findValue("title").asText();
issue.body = json.findValue("body").asText();
issue.milestone = findMilestone(json.findValue("milestoneTitle"), project);
issue.updatedDate = JodaDateUtil.now();

// TODO: Separate function for adding possible events
String state = json.findValue("state").asText();
if (!state.equals(issue.state.toString())) {
addNewIssueEvent(issue, user, EventType.ISSUE_STATE_CHANGED, issue.state.state(), State.valueOf(state).state());
}
issue.state = findIssueState(json);

JsonNode assigneeNode = json.findValue("assignees").get(0);
String oldAssignee = issue.assignee != null ? issue.assignee.user.loginId : "";
String newAssignee = assigneeNode != null ? assigneeNode.findValue("loginId").asText() : "";
if (!oldAssignee.equals(newAssignee)) {
oldAssignee = oldAssignee.length() == 0 ? null : oldAssignee;
newAssignee = newAssignee.length() == 0 ? null : newAssignee;
addNewIssueEvent(issue, user, EventType.ISSUE_ASSIGNEE_CHANGED, oldAssignee, newAssignee);
}
issue.assignee = findAssginee(json.findValue("assignees"), project);
issue.save();

ObjectNode issueNode = ProjectApi.getResult(issue);
return ok(Json.newObject().set("result", toJson(addIssueEvents(issue, issueNode))));
}

private static void addNewIssueEvent(Issue issue, User user, EventType eventType, String oldValue, String newValue) {
IssueEvent issueEvent = new IssueEvent();
issueEvent.issue = issue;
issueEvent.senderLoginId = user.loginId;
issueEvent.oldValue = oldValue;
issueEvent.newValue = newValue;
issueEvent.created = new Date();
issueEvent.eventType = eventType;
issueEvent.save();
}

private static JsonNode createIssuesNode(JsonNode json, Project project, boolean sendNotification) {
JsonNode files = json.findValue("temporaryUploadFiles");

@@ -177,17 +342,17 @@ public class IssueApi extends AbstractPostingApp {

private static State findIssueState(JsonNode json){
JsonNode issueNode = json.findValue("state");
State state = State.OPEN;
if(issueNode != null) {
if ("CLOSED".equalsIgnoreCase(issueNode.asText())) {
state = State.CLOSED;
}
if( issueNode == null) {
return State.OPEN;
}
if ("OPEN".equalsIgnoreCase(issueNode.asText())) {
return State.OPEN;
} else {
return State.CLOSED;
}
return state;
}

@Transactional
@IsCreatable(ResourceType.ISSUE_COMMENT)
public static Result newIssueComment(String ownerName, String projectName, Long number)
throws IOException {
JsonNode json = request().body().asJson();
@@ -198,6 +363,80 @@ public class IssueApi extends AbstractPostingApp {
Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
final Issue issue = Issue.findByNumber(project, number);

if (request().getHeader("Authorization") != null) {
ObjectNode result = Json.newObject();
if (!isAuthored(request())) {
return unauthorized(result.put("message", "unauthorized request"));
}

User user = getAuthorizedUser(getAuthorizationToken(request()));
String comment = json.findValue("comment").asText();
return createCommentUsingToken(issue, user, comment);
} else {
return createCommentByUser(project, issue, json);
}
}

public static boolean isModifiedByOthers(String current, String rememberedChecksum){
// At present, using .val() on textarea elements strips carriage return characters
// https://stackoverflow.com/a/8601601/1450196
// At first, I added hook of above link at the front page.
// But I found that it introduce another problem, cursor location detection error.
// So, decided to calculate sha1 without \r char.
String currentChecksum = DigestUtils.sha1Hex(current.replaceAll("\r","").trim());

return !currentChecksum.equals(rememberedChecksum);
}

public static Status conflicted(String content) {
ObjectNode result = Json.newObject();
result.put("message", "Already modified by someone.");
result.put("storedContent", content);
return new Status(play.core.j.JavaResults.Conflict(), result, Codec.javaSupported("utf-8"));
}

@Transactional
public static Result updateIssueComment(String ownerName, String projectName, Long number, Long commentId) {
User user = UserApp.currentUser();
if (user.isAnonymous()) {
return unauthorized(Json.newObject().put("message", "unauthorized request"));
}

JsonNode json = request().body().asJson();
if(json == null) {
return badRequest(Json.newObject().put("message", "Expecting Json data"));
}

String comment = json.findValue("content").asText();
String rememberedChecksum = json.findValue("sha1").asText();

Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
final Issue issue = Issue.findByNumber(project, number);
IssueComment issueComment = issue.findCommentByCommentId(commentId);

if (isModifiedByOthers(issueComment.contents, rememberedChecksum)) {
return conflicted(issueComment.contents);
}

if (!AccessControl.isAllowed(user, issueComment.asResource(), Operation.UPDATE)) {
return forbidden(Json.newObject().put("message", "Forbidden request"));
}

issueComment.contents = comment;
issueComment.save();

ObjectNode commentNode = getCommentJsonNode(issueComment);
ObjectNode authorNode = getAuthorJsonNode(user);

commentNode.set("author", toJson(authorNode));

ObjectNode result = Json.newObject();
result.set("result", commentNode);

return ok(result);
}

private static Result createCommentByUser(Project project, Issue issue, JsonNode json) {
if (!AccessControl.isResourceCreatable(
UserApp.currentUser(), issue.asResource(), ResourceType.ISSUE_COMMENT)) {
return forbidden(ErrorViews.Forbidden.render("error.forbidden", project));
@@ -206,22 +445,54 @@ public class IssueApi extends AbstractPostingApp {
User user = findAuthor(json.findValue("author"));
String body = json.findValue("body").asText();

final IssueComment comment = new IssueComment(issue, user, body);

comment.createdDate = parseDateString(json.findValue("createdAt"));
comment.setAuthor(user);
comment.issue = issue;
comment.save();
IssueComment issueComment = createComment(issue, user, body, json.findValue("createdAt"));

attachUploadFilesToPost(json.findValue("temporaryUploadFiles"), comment.asResource());
attachUploadFilesToPost(json.findValue("temporaryUploadFiles"), issueComment.asResource());

ObjectNode result = Json.newObject();
result.put("status", 201);
result.put("location", RouteUtil.getUrl(comment));
result.put("location", RouteUtil.getUrl(issueComment));

return created(result);
}

private static Result createCommentUsingToken(Issue issue, User user, String comment) {
createComment(issue, user, comment, null);
ObjectNode result = ProjectApi.getResult(issue);
return created(Json.newObject().set("result", toJson(addIssueEvents(issue, result))));
}

private static IssueComment createComment(Issue issue, User user, String comment, JsonNode dateNode) {
final IssueComment issueComment = new IssueComment(issue, user, comment);

issueComment.createdDate = dateNode == null ? JodaDateUtil.now() : parseDateString(dateNode);
issueComment.setAuthor(user);
issueComment.issue = issue;
issueComment.save();

return issueComment;
}

public static ObjectNode getCommentJsonNode(Comment comment) {
ObjectNode commentNode = Json.newObject();

commentNode.put("id", comment.id);
commentNode.put("contents", comment.contents);
commentNode.put("createdDate", JodaDateUtil.getDateString(comment.createdDate, JodaDateUtil.ISO_FORMAT));

return commentNode;
}

public static ObjectNode getAuthorJsonNode(User user) {
ObjectNode authorNode = Json.newObject();

authorNode.put("id", user.id);
authorNode.put("loginId", user.loginId);
authorNode.put("name", user.name);

return authorNode;
}

public static User findAuthor(JsonNode authorNode){
if (authorNode != null) {
String email = authorNode.findValue("email").asText();


+ 54
- 17
app/controllers/api/ProjectApi.java View File

@@ -27,6 +27,8 @@ import play.mvc.Http;
import play.mvc.Result;
import playRepository.RepositoryService;
import utils.AccessControl;
import utils.Config;
import utils.JodaDateUtil;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
@@ -279,9 +281,11 @@ public class ProjectApi extends Controller {
json.put("title", posting.title);
json.put("type", posting.asResource().getType().toString());
json.put("author", composeAuthorJson(posting.getAuthor()));
json.put("createdAt", getDateString(posting.createdDate));
json.put("updatedAt", getDateString(posting.updatedDate));
json.put("createdAt", JodaDateUtil.getDateString(posting.createdDate, JodaDateUtil.ISO_FORMAT));
json.put("updatedAt", JodaDateUtil.getDateString(posting.updatedDate, JodaDateUtil.ISO_FORMAT));
json.put("body", posting.body);
json.put("owner", posting.project.owner);
json.put("projectName", posting.project.name);

if (posting.asResource().getType() == ResourceType.ISSUE_POST) {
Issue issue = ((Issue) posting);
@@ -300,6 +304,9 @@ public class ProjectApi extends Controller {
Optional.ofNullable(issue.dueDate).ifPresent(dueDate ->
json.put("dueDate", getDateString(dueDate)));

String refUrl = Config.getScheme() + "://" + Config.getHostport()
+ controllers.routes.IssueApp.issue(posting.project.owner, posting.project.name, posting.getNumber()).url();
json.put("refUrl", refUrl);
}
List<Attachment> attachments = Attachment.findByContainer(posting.asResource());
if (attachments.size() > 0) {
@@ -383,23 +390,53 @@ public class ProjectApi extends Controller {
}

public static List<ObjectNode> composePlainCommentsJson(AbstractPosting posting) {
List<ObjectNode> comments = new ArrayList<>();
for (Comment comment : posting.getComments()) {
ObjectNode commentNode = Json.newObject();
commentNode.put("id", comment.id);
commentNode.put("type", comment.asResource().getType().toString());
User commentAuthor = User.find.byId(comment.authorId);
commentNode.put("author", composeAuthorJson(commentAuthor));
commentNode.put("createdAt", getDateString(comment.createdDate));
commentNode.put("body", comment.contents);

List<Attachment> attachments = Attachment.findByContainer(comment.asResource());
if (attachments.size() > 0) {
commentNode.put("attachments", toJson(attachments));
Map<Long, ObjectNode> commentMap = new HashMap<>();
Map<Long, List<ObjectNode>> childCommentMap = new HashMap<>();

for (Comment comment: posting.getComments()) {
Comment parentComment = comment.getParentComment();

if (parentComment != null) {
Long parentId = comment.getParentComment().id;
ObjectNode childCommentNode = getCommentNode(comment);
List<ObjectNode> childCommentList = childCommentMap.get(parentId);

if (childCommentList == null) {
childCommentList = new ArrayList<>();
}

childCommentList.add(childCommentNode);
childCommentMap.put(parentId, childCommentList);
} else {
ObjectNode commentNode = getCommentNode(comment);
commentMap.put(comment.id, commentNode);
}
comments.add(commentNode);
}
return comments;

for (Long key: childCommentMap.keySet()) {
ObjectNode comment = commentMap.get(key);
comment.set("childComments", toJson(childCommentMap.get(key)));
}

return new ArrayList<ObjectNode>(commentMap.values());
}

private static ObjectNode getCommentNode(Comment comment) {
ObjectNode commentNode = Json.newObject();

commentNode.put("id", comment.id);
commentNode.put("type", comment.asResource().getType().toString());
User commentAuthor = User.find.byId(comment.authorId);
commentNode.put("author", composeAuthorJson(commentAuthor));
commentNode.put("createdAt", JodaDateUtil.getDateString(comment.createdDate, JodaDateUtil.ISO_FORMAT));
commentNode.put("body", comment.contents);

List<Attachment> attachments = Attachment.findByContainer(comment.asResource());
if (attachments.size() > 0) {
commentNode.put("attachments", toJson(attachments));
}

return commentNode;
}

public static ObjectNode getMilestoneNode(Milestone m) {


+ 78
- 5
app/controllers/api/UserApi.java View File

@@ -6,29 +6,32 @@
**/
package controllers.api;

import com.avaje.ebean.ExpressionList;
import com.avaje.ebean.Page;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import controllers.UserApp;
import models.FavoriteIssue;
import models.FavoriteOrganization;
import models.FavoriteProject;
import models.User;
import models.*;
import models.enumeration.IssueFilterType;
import models.enumeration.UserState;
import models.support.IssueSearchCondition;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.util.ByteSource;
import play.db.ebean.Transactional;
import play.i18n.Messages;
import play.libs.F;
import play.libs.Json;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;
import utils.JodaDateUtil;
import utils.SHA256Util;

import java.util.ArrayList;
import java.util.List;

import static controllers.UserApp.addUserInfoToSession;
import static controllers.UserApp.createNewUser;
import static models.NotificationMail.isAllowedEmailDomains;
import static play.libs.Json.toJson;
@@ -38,6 +41,7 @@ public class UserApi extends Controller {
private static final int HASH_ITERATIONS = 1024;
private static final String AUTHORIZATION_HEADER_PREFIX = "token";
private static final int AUTHORIZATION_HEADER_MIN_LENGTH = 2;
private static final String HOSTNAME = play.Configuration.root().getString("application.hostname", "http://localhost");

@Transactional
public static Result toggleFoveriteProject(String projectId) {
@@ -106,6 +110,68 @@ public class UserApi extends Controller {
return ok(json);
}

@Transactional
public static Result getIssuesByUser(String filter, int page, int pageNum) {
ObjectNode result = Json.newObject();

if (!isAuthored(request())) {
return unauthorized(result.put("message", "unauthorized request"));
}

String token = request().getHeader("Authorization").split(AUTHORIZATION_HEADER_PREFIX)[1].replaceAll("\\s", "");
User user = User.findByUserToken(token);

IssueSearchCondition issueSearchCondition = new IssueSearchCondition();
issueSearchCondition.pageNum = page - 1;
ExpressionList<Issue> el = issueSearchCondition.getExpressionListByFilter(IssueFilterType.getValue(filter), user);
Page<Issue> issues = el.findPagingList(pageNum).getPage(issueSearchCondition.pageNum);

return issuesAsJson(issues);
}

private static Result issuesAsJson(Page<Issue> issues) {
ObjectNode listData = Json.newObject();
ArrayNode array = Json.newObject().arrayNode();

List<Issue> issueList = issues.getList();
for (Issue issue : issueList){
ObjectNode result = Json.newObject();
result.put("id", issue.id);
result.put("number", issue.getNumber());
result.put("state", issue.state.toString());
result.put("title", issue.title);
result.put("createdDate", JodaDateUtil.getDateString(issue.createdDate, JodaDateUtil.ISO_FORMAT));
result.put("updatedDate", JodaDateUtil.getDateString(issue.updatedDate, JodaDateUtil.ISO_FORMAT));

ObjectNode authorNode = Json.newObject();
authorNode.put("id", issue.authorId);
authorNode.put("loginId", issue.authorLoginId);
authorNode.put("name", issue.authorName);
result.put("author", authorNode);

ObjectNode assigneeNode = Json.newObject();
if (issue.assignee != null) {
assigneeNode.put("id", issue.assignee.id);
assigneeNode.put("loginId", issue.assignee.user.loginId);
assigneeNode.put("name", issue.assignee.user.name);
}
result.put("assignee", assigneeNode);

ObjectNode projectNode = Json.newObject();
projectNode.put("id", issue.project.id);
projectNode.put("name", issue.project.name);
result.put("project", projectNode);

result.put("owner", issue.project.owner);
result.put("refUrl", HOSTNAME + "/" + issue.project.owner + "/" + issue.project.name + "/issue/" + issue.getNumber());

array.add(result);
}

listData.put("result", array);
return ok(listData);
}

@Transactional
public static Result toggleFoveriteOrganization(String organizationId) {
if (organizationId == null) {
@@ -179,6 +245,7 @@ public class UserApi extends Controller {
if (!checkUserPassword(user, password))
return unauthorized(result.put("message", "No user by id and password"));

addUserInfoToSession(user);
result.put("access_token", getNewUserToken(user));
return ok(toJson(result));
}
@@ -199,6 +266,12 @@ public class UserApi extends Controller {
return true;
}

public static String getAuthorizationToken(Http.Request request) {
String header = request.getHeader("Authorization");
String[] tokenValues = header.split(AUTHORIZATION_HEADER_PREFIX);
return tokenValues[1].replaceAll("\\s", "");
}

public static User getAuthorizedUser(String token) {
return User.findByUserToken(token);
}


+ 3
- 0
app/models/Comment.java View File

@@ -44,6 +44,9 @@ abstract public class Comment extends Model implements TimelineItem, ResourceCon
@Transient
public String parentCommentId;

@Transient
public String previousContents;

public Comment() {
createdDate = new Date();
}


+ 9
- 0
app/models/Issue.java View File

@@ -698,6 +698,15 @@ public class Issue extends AbstractPosting implements LabelOwner {
return null;
}

public IssueComment findCommentByCommentId(Long id) {
for (IssueComment comment: comments) {
if (comment.id.equals(id)) {
return comment;
}
}
return null;
}

public List<IssueSharer> getSortedSharer() {
return new ArrayList<>(sharers);
}


+ 40
- 7
app/models/NotificationEvent.java View File

@@ -135,14 +135,19 @@ public class NotificationEvent extends Model implements INotificationEvent {
return Messages.get(lang, "notification.issue.assigned", newValue);
}
case ISSUE_MILESTONE_CHANGED:
return Messages.get(lang, "notification.milestone.changed", newValue);
if (Milestone.findById(Long.parseLong(newValue)) == null) {
return Messages.get(lang, "notification.milestone.changed", Messages.get(Lang.defaultLang(), "issue.noMilestone"));
} else {
return Messages.get(lang, "notification.milestone.changed", Milestone.findById(Long.parseLong(newValue)).title);
}
case NEW_ISSUE:
case NEW_POSTING:
case NEW_COMMENT:
case NEW_PULL_REQUEST:
case NEW_COMMIT:
case COMMENT_UPDATED:
return newValue;
case NEW_COMMENT:
return newValue + oldValue;
case ISSUE_BODY_CHANGED:
case POSTING_BODY_CHANGED:
return DiffUtil.getDiffText(oldValue, newValue);
@@ -236,7 +241,7 @@ public class NotificationEvent extends Model implements INotificationEvent {
case POSTING_BODY_CHANGED:
return DiffUtil.getDiffPlainText(oldValue, newValue);
default:
return getMessage(lang);
return getMessage(lang).replaceAll("\n\n<br />\n", "\n\n");
}
}

@@ -527,6 +532,7 @@ public class NotificationEvent extends Model implements INotificationEvent {
}

public String getUrlToView() {
Organization organization;
switch(eventType) {
case MEMBER_ENROLL_REQUEST:
if (getProject() == null) {
@@ -535,13 +541,25 @@ public class NotificationEvent extends Model implements INotificationEvent {
return routes.ProjectApp.members(
getProject().owner, getProject().name).url();
}
case MEMBER_ENROLL_ACCEPT:
if (getProject() == null) {
return null;
} else {
return routes.ProjectApp.project(
getProject().owner, getProject().name).url();
}
case ORGANIZATION_MEMBER_ENROLL_REQUEST:
Organization organization = getOrganization();
organization = getOrganization();
if (organization == null) {
return null;
}
return routes.OrganizationApp.members(organization.name).url();

case ORGANIZATION_MEMBER_ENROLL_ACCEPT:
organization = getOrganization();
if (organization == null) {
return null;
}
return routes.OrganizationApp.organization(organization.name).url();
case NEW_COMMIT:
if (getProject() == null) {
return null;
@@ -609,6 +627,16 @@ public class NotificationEvent extends Model implements INotificationEvent {
}
}

private static void webhookRequest(EventType eventTypes, Issue issue, Project previous, Boolean gitPushOnly) {
List<Webhook> webhookList = Webhook.findByProject(issue.project.id);
for (Webhook webhook : webhookList) {
if (gitPushOnly == webhook.gitPushOnly) {
// Send push event via webhook payload URLs.
webhook.sendRequestToPayloadUrl(eventTypes, UserApp.currentUser(), issue, previous);
}
}
}

private static void webhookRequest(EventType eventTypes, Comment comment, Boolean gitPushOnly) {
List<Webhook> webhookList = Webhook.findByProject(comment.projectId);
for (Webhook webhook : webhookList) {
@@ -733,7 +761,7 @@ public class NotificationEvent extends Model implements INotificationEvent {
notiEvent.title = formatReplyTitle(post);
notiEvent.eventType = eventType;
notiEvent.receivers = getMandatoryReceivers(comment, eventType);
notiEvent.oldValue = null;
notiEvent.oldValue = comment.previousContents;
notiEvent.newValue = comment.contents;
notiEvent.resourceType = comment.asResource().getType();
notiEvent.resourceId = comment.asResource().getId();
@@ -893,7 +921,7 @@ public class NotificationEvent extends Model implements INotificationEvent {
}

public static NotificationEvent afterIssueMoved(Project previous, Issue issue) {
webhookRequest(ISSUE_MOVED, issue, false);
webhookRequest(ISSUE_MOVED, issue, previous, false);

NotificationEvent notiEvent = createFromCurrentUser(issue);
notiEvent.title = formatReplyTitle(issue);
@@ -944,6 +972,9 @@ public class NotificationEvent extends Model implements INotificationEvent {
}

public static NotificationEvent afterMilestoneChanged(Long oldMilestoneId, Issue issue) {
if (issue.milestone != null) {
issue.milestone.refresh();
}
webhookRequest(ISSUE_MILESTONE_CHANGED, issue, false);

NotificationEvent notiEvent = createFromCurrentUser(issue);
@@ -1162,6 +1193,7 @@ public class NotificationEvent extends Model implements INotificationEvent {
break;
case ACCEPT:
notiEvent.title = formatMemberAcceptTitle(project, user);
notiEvent.eventType = MEMBER_ENROLL_ACCEPT;
notiEvent.oldValue = RequestState.REQUEST.name();
break;
}
@@ -1192,6 +1224,7 @@ public class NotificationEvent extends Model implements INotificationEvent {
break;
case ACCEPT:
notiEvent.title = formatMemberAcceptTitle(organization, user);
notiEvent.eventType = ORGANIZATION_MEMBER_ENROLL_ACCEPT;
notiEvent.oldValue = RequestState.REQUEST.name();
break;
}


+ 4
- 3
app/models/NotificationMail.java View File

@@ -52,6 +52,7 @@ import java.util.*;
import java.util.concurrent.TimeUnit;

import static models.enumeration.EventType.*;
import static models.enumeration.ResourceType.ORGANIZATION;

@Entity
public class NotificationMail extends Model {
@@ -620,10 +621,10 @@ public class NotificationMail extends Model {

String renderred = null;

if(resource != null) {
renderred = Markdown.render(message, resource.getProject(), lang.code());
} else {
if (resource == null || resource.getType() == ORGANIZATION) {
renderred = Markdown.render(message);
} else {
renderred = Markdown.render(message, resource.getProject(), lang.code());
}

return getRenderedMail(lang, renderred, urlToView, resource, acceptsReply);


+ 9
- 0
app/models/Posting.java View File

@@ -150,4 +150,13 @@ public class Posting extends AbstractPosting {
.add(eq("readme", true))
.findUnique();
}

public PostingComment findCommentByCommentId(Long id) {
for (PostingComment comment: comments) {
if (comment.id.equals(id)) {
return comment;
}
}
return null;
}
}

+ 3
- 0
app/models/Project.java View File

@@ -164,6 +164,9 @@ public class Project extends Model implements LabelOwner {
if(projectId == null || projectId == 0){
Project project= find.where().ieq("owner", decodeUrlString(loginId)).ieq("name", decodeUrlString(projectName))
.findUnique();
if( project == null) {
project = findByPreviousPlaceOf(decodeUrlString(loginId), decodeUrlString(projectName));
}
if(project != null){
CacheStore.projectMap.put(key, project.id);
}


+ 73
- 34
app/models/Webhook.java View File

@@ -1,23 +1,10 @@
/**
* Yobi, Project Hosting SW
*
* Copyright 2015 NAVER Corp.
* http://yobi.io
*
* @author Jihwan Chun
*
* 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 com.fasterxml.jackson.databind.ObjectMapper;
@@ -26,6 +13,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import models.enumeration.EventType;
import models.enumeration.PullRequestReviewAction;
import models.enumeration.ResourceType;
import models.enumeration.WebhookType;
import models.resource.GlobalResource;
import models.resource.Resource;
import models.resource.ResourceConvertible;
@@ -86,10 +74,12 @@ public class Webhook extends Model implements ResourceConvertible {
public String secret;

/**
* Type of webhook (true = git only push, false = all cases)
* Condition of sending webhook (true: git only push, false: all cases)
*/
public Boolean gitPushOnly;

public WebhookType webhookType = WebhookType.SIMPLE;

/**
* Payload URL of webhook.
*/
@@ -199,6 +189,11 @@ public class Webhook extends Model implements ResourceConvertible {
sendRequest(requestBodyString);
}

public void sendRequestToPayloadUrl(EventType eventType, User sender, Issue eventIssue, Project previous) {
String requestBodyString = buildRequestBody(eventType, sender, eventIssue, previous);
sendRequest(requestBodyString);
}

public void sendRequestToPayloadUrl(EventType eventType, User sender, PullRequest eventPullRequest) {
String requestBodyString = buildRequestBody(eventType, sender, eventPullRequest);
sendRequest(requestBodyString);
@@ -260,11 +255,18 @@ public class Webhook extends Model implements ResourceConvertible {
}
requestMessage += " <" + utils.Config.getScheme() + "://" + utils.Config.getHostport("localhost:9000") + RouteUtil.getUrl(eventPullRequest) + "|#" + eventPullRequest.number + ": " + eventPullRequest.title + ">";

if (this.webhookType == WebhookType.DETAIL_SLACK) {
return buildJsonWithPullReqtuestDetails(eventPullRequest, detailFields, attachments, requestMessage);
} else {
return buildTextPropertyOnlyJSON(requestMessage);
}
}

private String buildJsonWithPullReqtuestDetails(PullRequest eventPullRequest, ArrayNode detailFields, ArrayNode attachments, String requestMessage) {
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.sender"), eventPullRequest.contributor.name, false));
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.from"), eventPullRequest.fromBranch, true));
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.to"), eventPullRequest.toBranch,true));
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.to"), eventPullRequest.toBranch, true));
attachments.add(buildAttachmentJSON(eventPullRequest.body, detailFields));

return Json.stringify(buildRequestJSON(requestMessage, attachments));
}

@@ -285,12 +287,11 @@ public class Webhook extends Model implements ResourceConvertible {
}
requestMessage += " <" + utils.Config.getScheme() + "://" + utils.Config.getHostport("localhost:9000") + RouteUtil.getUrl(eventPullRequest) + "|#" + eventPullRequest.number + ": " + eventPullRequest.title + ">";

detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.sender"), eventPullRequest.contributor.name, false));
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.from"), eventPullRequest.fromBranch, true));
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "pullRequest.to"), eventPullRequest.toBranch, true));
attachments.add(buildAttachmentJSON(eventPullRequest.body, detailFields));

return Json.stringify(buildRequestJSON(requestMessage, attachments));
if (this.webhookType == WebhookType.SIMPLE) {
return buildTextPropertyOnlyJSON(requestMessage);
} else {
return buildJsonWithPullReqtuestDetails(eventPullRequest, detailFields, attachments, requestMessage);
}
}

private String buildRequestBody(EventType eventType, User sender, Issue eventIssue) {
@@ -322,13 +323,41 @@ public class Webhook extends Model implements ResourceConvertible {
play.Logger.warn("Unknown webhook event: " + eventType);
}

requestMessage += " <" + utils.Config.getScheme() + "://" + utils.Config.getHostport("localhost:9000") + RouteUtil.getUrl(eventIssue) + "|#" + eventIssue.number + ": " + eventIssue.title + ">";
String eventIssueUrl = controllers.routes.IssueApp.issue(eventIssue.project.owner, eventIssue.project.name, eventIssue.getNumber()).url();
requestMessage += " <" + utils.Config.getScheme() + "://" + utils.Config.getHostport("localhost:9000") + eventIssueUrl + "|#" + eventIssue.number + ": " + eventIssue.title + ">";

if (this.webhookType == WebhookType.SIMPLE) {
return buildTextPropertyOnlyJSON(requestMessage);
} else {
return buildJsonWithIssueEventDetails(eventIssue, detailFields, attachments, requestMessage);
}
}

detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "notification.type.milestone.changed"), eventIssue.milestoneId().toString(), true));
private String buildRequestBody(EventType eventType, User sender, Issue eventIssue, Project previous) {
ObjectMapper mapper = new ObjectMapper();
ArrayNode detailFields = mapper.createArrayNode();
ArrayNode attachments = mapper.createArrayNode();

String requestMessage = "[" + project.name + "] "+ sender.name + " ";
requestMessage += Messages.get(Lang.defaultLang(), "notification.type.issue.moved", previous.name, project.name);

String eventIssueUrl = controllers.routes.IssueApp.issue(eventIssue.project.owner, eventIssue.project.name, eventIssue.getNumber()).url();
requestMessage += " <" + utils.Config.getScheme() + "://" + utils.Config.getHostport("localhost:9000") + eventIssueUrl + "|#" + eventIssue.number + ": " + eventIssue.title + ">";

if (this.webhookType == WebhookType.SIMPLE) {
return buildTextPropertyOnlyJSON(requestMessage);
} else {
return buildJsonWithIssueEventDetails(eventIssue, detailFields, attachments, requestMessage);
}
}

private String buildJsonWithIssueEventDetails(Issue eventIssue, ArrayNode detailFields, ArrayNode attachments, String requestMessage) {
if (eventIssue.milestone != null ) {
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "notification.type.milestone.changed"), eventIssue.milestone.title, true));
}
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), ""), eventIssue.assigneeName(), true));
detailFields.add(buildTitleValueJSON(Messages.get(Lang.defaultLang(), "issue.state"), eventIssue.state.toString(), true));
attachments.add(buildAttachmentJSON(eventIssue.body, detailFields));

return Json.stringify(buildRequestJSON(requestMessage, attachments));
}

@@ -354,9 +383,12 @@ public class Webhook extends Model implements ResourceConvertible {
break;
}

attachments.add(buildAttachmentJSON(eventComment.contents, null));

return Json.stringify(buildRequestJSON(requestMessage, attachments));
if (this.webhookType == WebhookType.SIMPLE) {
return buildTextPropertyOnlyJSON(requestMessage);
} else {
attachments.add(buildAttachmentJSON(eventComment.contents, null));
return Json.stringify(buildRequestJSON(requestMessage, attachments));
}
}

private ObjectNode buildJSONFromCommit(Project project, RevCommit commit) {
@@ -409,6 +441,12 @@ public class Webhook extends Model implements ResourceConvertible {
return requestBody;
}

private String buildTextPropertyOnlyJSON(String requestMessage) {
ObjectNode requestBody = Json.newObject();
requestBody.put("text", requestMessage);
return Json.stringify(requestBody);
}

private ObjectNode buildSenderJSON(User sender) {
ObjectNode senderJSON = Json.newObject();
senderJSON.put("login", sender.loginId);
@@ -457,6 +495,7 @@ public class Webhook extends Model implements ResourceConvertible {
", payloadUrl='" + payloadUrl + '\'' +
", secret='" + secret + '\'' +
", gitPushOnly=" + gitPushOnly +
", webhookType=" + webhookType +
", createdAt=" + createdAt +
'}';
}


+ 3
- 1
app/models/enumeration/EventType.java View File

@@ -39,7 +39,9 @@ public enum EventType {
ISSUE_LABEL_CHANGED("notification.type.issue.label.changed", 23),
ISSUE_MILESTONE_CHANGED("notification.type.milestone.changed", 24),
POSTING_BODY_CHANGED("notification.type.posting.body.changed", 25),
RESOURCE_DELETED("notification.type.resource.deleted", 26);
RESOURCE_DELETED("notification.type.resource.deleted", 26),
MEMBER_ENROLL_ACCEPT("notification.member.enroll.accept", 27),
ORGANIZATION_MEMBER_ENROLL_ACCEPT("notification.member.enroll.accept", 28);

private String descr;



+ 24
- 0
app/models/enumeration/IssueFilterType.java View File

@@ -0,0 +1,24 @@
package models.enumeration;

public enum IssueFilterType {
ASSIGNED("assigned"),
CREATED("created"),
MENTIONED("mentioned"),
FAVORITE("favorite"),
ALL("all");

private String issueFilter;

IssueFilterType(String issueFilter) {
this.issueFilter = issueFilter;
}

public static IssueFilterType getValue(String value) {
for (IssueFilterType issueFilterType : IssueFilterType.values()) {
if (issueFilterType.issueFilter.equals(value)) {
return issueFilterType;
}
}
throw new IllegalArgumentException("No matching issue filter type found for [" + value + "]");
}
}

+ 17
- 0
app/models/enumeration/WebhookType.java View File

@@ -0,0 +1,17 @@
/**
* Yona, 21st Century Project Hosting SW
* <p>
* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
* https://yona.io
**/
package models.enumeration;

public enum WebhookType {
SIMPLE(0), DETAIL_SLACK(1);

private int type;

WebhookType(int type) {
this.type = type;
}
}

+ 119
- 0
app/models/support/IssueSearchCondition.java View File

@@ -0,0 +1,119 @@
package models.support;

import com.avaje.ebean.Expr;
import com.avaje.ebean.ExpressionList;
import com.avaje.ebean.Junction;
import controllers.AbstractPostingApp;
import models.*;

import java.util.*;
import models.enumeration.IssueFilterType;

public class IssueSearchCondition extends AbstractPostingApp.SearchCondition {
public Long authorId;
public Long assigneeId;
public Long mentionId;
public Long favoriteId;

public ExpressionList<Issue> getExpressionListByFilter(IssueFilterType filter, User user) {
if (filter.equals(IssueFilterType.ALL)) {
this.assigneeId = user.id;
this.authorId = user.id;
this.mentionId = user.id;
this.favoriteId = user.id;

return asExpressionListForAll();
} else {
switch (filter) {
case ASSIGNED:
this.assigneeId = user.id;
break;
case CREATED:
this.authorId = user.id;
break;
case MENTIONED:
this.mentionId = user.id;
break;
case FAVORITE: