add CommitStatus api and views.

This commit is contained in:
nazoking
2015-02-12 20:20:27 +09:00
parent 97ceffe689
commit 0299cee5ec
8 changed files with 263 additions and 32 deletions

View File

@@ -18,16 +18,18 @@ import org.eclipse.jgit.errors.NoMergeBaseException
import service.WebHookService._
import util.JGitUtil.DiffInfo
import util.JGitUtil.CommitInfo
import model.{PullRequest, Issue}
import model.{PullRequest, Issue, CommitState}
class PullRequestsController extends PullRequestsControllerBase
with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with LabelsService
with CommitsService with ActivityService with WebHookPullRequestService with ReferrerAuthenticator with CollaboratorsAuthenticator
with CommitStatusService
trait PullRequestsControllerBase extends ControllerBase {
self: RepositoryService with AccountService with IssuesService with MilestonesService with LabelsService
with CommitsService with ActivityService with PullRequestService with WebHookPullRequestService with ReferrerAuthenticator with CollaboratorsAuthenticator =>
with CommitsService with ActivityService with PullRequestService with WebHookPullRequestService with ReferrerAuthenticator with CollaboratorsAuthenticator
with CommitStatusService =>
private val logger = LoggerFactory.getLogger(classOf[PullRequestsControllerBase])
@@ -95,7 +97,6 @@ trait PullRequestsControllerBase extends ControllerBase {
using(Git.open(getRepositoryDir(owner, name))){ git =>
val (commits, diffs) =
getRequestCompareInfo(owner, name, pullreq.commitIdFrom, owner, name, pullreq.commitIdTo)
pulls.html.pullreq(
issue, pullreq,
(commits.flatten.map(commit => getCommitComments(owner, name, commit.id, true)).flatten.toList ::: getComments(owner, name, issueId))
@@ -159,9 +160,16 @@ trait PullRequestsControllerBase extends ControllerBase {
val owner = repository.owner
val name = repository.name
getPullRequest(owner, name, issueId) map { case(issue, pullreq) =>
val statuses = getCommitStatues(owner, name, pullreq.commitIdTo)
val hasConfrict = checkConflictInPullRequest(owner, name, pullreq.branch, pullreq.requestUserName, name, pullreq.requestBranch, issueId)
val hasProblem = hasConfrict || (!statuses.isEmpty && CommitState.combine(statuses.map(_.state).toSet) != CommitState.SUCCESS)
pulls.html.mergeguide(
checkConflictInPullRequest(owner, name, pullreq.branch, pullreq.requestUserName, name, pullreq.requestBranch, issueId),
hasConfrict,
hasProblem,
issue,
pullreq,
statuses,
repository,
s"${context.baseUrl}/git/${pullreq.requestUserName}/${pullreq.requestRepositoryName}.git")
}
} getOrElse NotFound

View File

@@ -18,10 +18,11 @@ import jp.sf.amateras.scalatra.forms._
import org.eclipse.jgit.dircache.DirCache
import org.eclipse.jgit.revwalk.RevCommit
import service.WebHookService._
import model.CommitState
class RepositoryViewerController extends RepositoryViewerControllerBase
with RepositoryService with AccountService with ActivityService with IssuesService with WebHookService with CommitsService
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with CommitStatusService
/**
@@ -29,7 +30,7 @@ class RepositoryViewerController extends RepositoryViewerControllerBase
*/
trait RepositoryViewerControllerBase extends ControllerBase {
self: RepositoryService with AccountService with ActivityService with IssuesService with WebHookService with CommitsService
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService =>
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with CommitStatusService =>
ArchiveCommand.registerFormat("zip", new ZipFormat)
ArchiveCommand.registerFormat("tar.gz", new TgzFormat)
@@ -143,6 +144,56 @@ trait RepositoryViewerControllerBase extends ControllerBase {
}
})
/**
* https://developer.github.com/v3/repos/statuses/#create-a-status
*/
post("/api/v3/repos/:owner/:repo/statuses/:sha")(collaboratorsOnly { repository =>
(for{
ref <- params.get("sha")
sha <- JGitUtil.getShaByRef(repository.owner, repository.name, ref)
data <- extractFromJsonBody[CreateAStatus] if data.isValid
creator <- context.loginAccount
state <- model.CommitState.valueOf(data.state)
statusId = createCommitStatus(repository.owner, repository.name, sha, data.context.getOrElse("default"),
state, data.target_url, data.description, new java.util.Date(), creator)
status <- getCommitStatus(repository.owner, repository.name, statusId)
} yield {
apiJson(WebHookCommitStatus(status, WebHookApiUser(creator)))
}) getOrElse NotFound
})
/**
* https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref
*
* ref is Ref to list the statuses from. It can be a SHA, a branch name, or a tag name.
*/
get("/api/v3/repos/:owner/:repo/commits/:ref/statuses")(referrersOnly { repository =>
(for{
ref <- params.get("ref")
sha <- JGitUtil.getShaByRef(repository.owner, repository.name, ref)
} yield {
apiJson(getCommitStatuesWithCreator(repository.owner, repository.name, sha).map{ case(status, creator) =>
WebHookCommitStatus(status, WebHookApiUser(creator))
})
}) getOrElse NotFound
})
/**
* https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref
*
* ref is Ref to list the statuses from. It can be a SHA, a branch name, or a tag name.
*/
get("/api/v3/repos/:owner/:repo/commits/:ref/status")(referrersOnly { repository =>
(for{
ref <- params.get("ref")
owner <- getAccountByUserName(repository.owner)
sha <- JGitUtil.getShaByRef(repository.owner, repository.name, ref)
} yield {
val statuses = getCommitStatuesWithCreator(repository.owner, repository.name, sha)
apiJson(WebHookCombinedCommitStatus(sha, statuses, WebHookRepository(repository, owner)))
}) getOrElse NotFound
})
get("/:owner/:repository/new/*")(collaboratorsOnly { repository =>
val (branch, path) = splitPath(repository, multiParams("splat").head)
repo.html.editor(branch, repository, if(path.length == 0) Nil else path.split("/").toList,

View File

@@ -2,7 +2,7 @@ package service
import model.Profile._
import profile.simple._
import model.{WebHook, Account, Issue, PullRequest, IssueComment, Repository}
import model.{WebHook, Account, Issue, PullRequest, IssueComment, Repository, CommitStatus, CommitState}
import org.slf4j.LoggerFactory
import service.RepositoryService.RepositoryInfo
import util.JGitUtil
@@ -168,7 +168,8 @@ object WebHookService {
{ case x: Date => JString(parserISO.print(new DateTime(x).withZone(DateTimeZone.UTC))) }
)
) + FieldSerializer[WebHookApiUser]() + FieldSerializer[WebHookPullRequest]() + FieldSerializer[WebHookRepository]() +
FieldSerializer[WebHookCommitListItemParent]() + FieldSerializer[WebHookCommitListItem]() + FieldSerializer[WebHookCommitListItemCommit]()
FieldSerializer[WebHookCommitListItemParent]() + FieldSerializer[WebHookCommitListItem]() + FieldSerializer[WebHookCommitListItemCommit]() +
FieldSerializer[WebHookCommitStatus]() + FieldSerializer[WebHookCombinedCommitStatus]()
}
def apiPathSerializer(c:ApiContext) = new CustomSerializer[ApiPath](format =>
(
@@ -410,7 +411,7 @@ object WebHookService {
//val review_comments_url = ApiPath("${base.repo.url.path}/pulls/${number}/comments")
//val review_comment_url = ApiPath("${base.repo.url.path}/pulls/comments/{number}")
//val comments_url = ApiPath("${base.repo.url.path}/issues/${number}/comments")
//val statuses_url = ApiPath("${base.repo.url.path}/statuses/${head.sha}")
val statuses_url = ApiPath(s"${base.repo.url.path}/statuses/${head.sha}")
}
object WebHookPullRequest{
@@ -426,7 +427,7 @@ object WebHookService {
sha = pullRequest.commitIdFrom,
ref = pullRequest.branch,
repo = baseRepo),
mergeable = Some(true), // TODO: need check mergeable.
mergeable = None, // TODO: need check mergeable.
title = issue.title,
body = issue.content.getOrElse(""),
user = user
@@ -530,4 +531,75 @@ object WebHookService {
parents = commit.parents.map(WebHookCommitListItemParent(_)(repoFullName)))(repoFullName)
}
/**
* https://developer.github.com/v3/repos/statuses/#create-a-status
*/
case class CreateAStatus(
/* state is Required. The state of the status. Can be one of pending, success, error, or failure. */
state: String,
/* context is a string label to differentiate this status from the status of other systems. Default: "default" */
context: Option[String],
/* The target URL to associate with this status. This URL will be linked from the GitHub UI to allow users to easily see the source of the Status. */
target_url: Option[String],
/* description is a short description of the status.*/
description: Option[String]
) {
def isValid: Boolean = {
CommitState.valueOf(state).isDefined &&
target_url.filterNot(f => "\\Ahttps?://".r.findPrefixOf(f).isDefined && f.length<255).isEmpty &&
context.filterNot(f => f.length<255).isEmpty &&
description.filterNot(f => f.length<1000).isEmpty
}
}
/**
* https://developer.github.com/v3/repos/statuses/#create-a-status
* https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref
*/
case class WebHookCommitStatus(
created_at: Date,
updated_at: Date,
state: String,
target_url: Option[String],
description: Option[String],
id: Int,
context: String,
creator: WebHookApiUser
)(sha: String, repoFullName: String) {
def url = ApiPath(s"/api/v3/repos/${repoFullName}/commits/${sha}/statuses")
}
object WebHookCommitStatus {
def apply(status: CommitStatus, creator:WebHookApiUser): WebHookCommitStatus = WebHookCommitStatus(
created_at = status.registeredDate,
updated_at = status.updatedDate,
state = status.state.name,
target_url = status.targetUrl,
description= status.description,
id = status.commitStatusId,
context = status.context,
creator = creator
)(status.commitId, s"${status.userName}/${status.repositoryName}")
}
/**
* https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref
*/
case class WebHookCombinedCommitStatus(
state: String,
sha: String,
total_count: Int,
statuses: Iterable[WebHookCommitStatus],
repository: WebHookRepository){
// val commit_url = ApiPath(s"/api/v3/repos/${repository.full_name}/${sha}")
val url = ApiPath(s"/api/v3/repos/${repository.full_name}/commits/${sha}/status")
}
object WebHookCombinedCommitStatus {
def apply(sha:String, statuses: Iterable[(CommitStatus, Account)], repository:WebHookRepository): WebHookCombinedCommitStatus = WebHookCombinedCommitStatus(
state = CommitState.combine(statuses.map(_._1.state).toSet).name,
sha = sha,
total_count= statuses.size,
statuses = statuses.map{ case (s, a)=> WebHookCommitStatus(s, WebHookApiUser(a)) },
repository = repository)
}
}

View File

@@ -748,4 +748,17 @@ object JGitUtil {
}
}
}
/**
* Returns sha1
* @param owner repository owner
* @param name repository name
* @param revstr A git object references expression
* @return sha1
*/
def getShaByRef(owner:String, name:String,revstr: String): Option[String] = {
using(Git.open(getRepositoryDir(owner, name))){ git =>
Option(git.getRepository.resolve(revstr)).map(ObjectId.toString(_))
}
}
}

View File

@@ -4,6 +4,7 @@ import java.text.SimpleDateFormat
import play.twirl.api.Html
import util.StringUtil
import service.RequestCache
import model.CommitState
/**
* Provides helper methods for Twirl templates.
@@ -260,4 +261,17 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
def mkHtml(separator: scala.xml.Elem) = Html(seq.mkString(separator.toString))
}
def commitStateIcon(state: CommitState) = Html(state match {
case CommitState.PENDING => "●"
case CommitState.SUCCESS => "&#x2714;"
case CommitState.ERROR => "×"
case CommitState.FAILURE => "×"
})
def commitStateText(state: CommitState, commitId:String) = state match {
case CommitState.PENDING => "Waiting to hear about "+commitId.substring(0,8)
case CommitState.SUCCESS => "All is well"
case CommitState.ERROR => "Failed"
case CommitState.FAILURE => "Failed"
}
}

View File

@@ -8,7 +8,7 @@
hasWritePermission: Boolean,
repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context)
@import context._
@import model.IssueComment
@import model.{IssueComment, CommitState}
@import view.helpers._
<div class="row-fluid">
<div class="span10">
@@ -20,25 +20,10 @@
case other => None
}.exists(_.action == "merge")){ merged =>
@if(hasWritePermission && !issue.closed){
<div class="box issue-comment-box" style="background-color: #d8f5cd;">
<div class="box-content"class="issue-content" style="border: 1px solid #95c97e; padding: 10px;">
<div id="merge-pull-request">
<div class="check-conflict" style="display: none;">
<img src="@assets/common/images/indicator.gif"/> Checking...
</div>
</div>
<div id="confirm-merge-form" style="display: none;">
<form method="POST" action="@url(repository)/pull/@issue.issueId/merge">
<div class="strong">
Merge pull request #@issue.issueId from @{pullreq.requestUserName}/@{pullreq.requestBranch}
</div>
<span id="error-message" class="error"></span>
<textarea name="message" style="width: 635px; height: 80px;">@issue.title</textarea>
<div>
<input type="button" class="btn" value="Cancel" id="cancel-merge-pull-request"/>
<input type="submit" class="btn btn-success" value="Confirm merge"/>
</div>
</form>
<div class="check-conflict" style="display: none;">
<div class="box issue-comment-box" style="background-color: #fbeed5">
<div class="box-content"class="issue-content" style="border: 1px solid #c09853; padding: 10px;">
<img src="@assets/common/images/indicator.gif"/> Checking...
</div>
</div>
</div>

View File

@@ -1,17 +1,61 @@
@(hasConflict: Boolean,
hasProblem: Boolean,
issue: model.Issue,
pullreq: model.PullRequest,
statuses: List[model.CommitStatus],
repository: service.RepositoryService.RepositoryInfo,
requestRepositoryUrl: String)(implicit context: app.Context)
@import context._
@import view.helpers._
@import model.CommitState
<div class="box issue-comment-box" style="background-color: @if(hasProblem){ #fbeed5 }else{ #d8f5cd };">
<div class="box-content"class="issue-content" style="border: 1px solid @if(hasProblem){ #c09853 }else{ #95c97e }; padding: 10px;">
<div id="merge-pull-request">
@if(!statuses.isEmpty){
<div class="build-statuses">
@if(statuses.size==1){
@defining(statuses.head){ status =>
<div class="build-status-item">
@status.targetUrl.map{ url => <a class="pull-right" href="@url">Details</a> }
<span class="build-status-icon text-@{status.state.name}">@commitStateIcon(status.state)</span>
<strong class="text-@{status.state.name}">@commitStateText(status.state, pullreq.commitIdTo)</strong>
@status.description.map{ desc => <span class="muted">— @desc</span> }
</div>
}
}else{
@defining(statuses.groupBy(_.state)){ stateMap => @defining(CommitState.combine(stateMap.keySet)){ state =>
<div class="build-status-item">
<a class="pull-right" id="toggle-all-checks"></a>
<span class="build-status-icon text-@{state.name}">@commitStateIcon(state)</span>
<strong class="text-@{state.name}">@commitStateText(state, pullreq.commitIdTo)</strong>
<span class="text-@{state.name}">— @{stateMap.map{ case (keyState, states) => states.size+" "+keyState.name }.mkString(", ")} checks</span>
</div>
<div class="build-statuses-list" style="@if(state==CommitState.SUCCESS){ display:none; }else{ }">
@statuses.map{ status =>
<div class="build-status-item">
@status.targetUrl.map{ url => <a class="pull-right" href="@url">Details</a> }
<span class="build-status-icon text-@{status.state.name}">@commitStateIcon(status.state)</span>
<span class="text-@{status.state.name}">@status.context</span>
@status.description.map{ desc => <span class="muted">— @desc</span> }
</div>
}
</div>
} }
}
</div>
}
<div class="pull-right">
<input type="button" class="btn btn-success" id="merge-pull-request-button" value="Merge pull request"@if(hasConflict){ disabled="true"}/>
<input type="button" class="btn @if(!hasProblem){ btn-success }" id="merge-pull-request-button" value="Merge pull request"@if(hasConflict){ disabled="true"}/>
</div>
<div>
@if(hasConflict){
<span class="strong">We cant automatically merge this pull request.</span>
} else{ @if(hasProblem){
<span class="strong">Merge with caution!</span>
} else {
<span class="strong">This pull request can be automatically merged.</span>
}
} }
</div>
<div class="small">
@if(hasConflict){
@@ -69,12 +113,38 @@
}
</div>
</div>
</div>
<div id="confirm-merge-form" style="display: none;">
<form method="POST" action="@url(repository)/pull/@pullreq.issueId/merge">
<div class="strong">
Merge pull request #@issue.issueId from @{pullreq.requestUserName}/@{pullreq.requestBranch}
</div>
<span id="error-message" class="error"></span>
<textarea name="message" style="width: 635px; height: 80px;">@issue.title</textarea>
<div>
<input type="button" class="btn" value="Cancel" id="cancel-merge-pull-request"/>
<input type="submit" class="btn btn-success" value="Confirm merge"/>
</div>
</form>
</div>
</div>
</div>
<script>
$(function(){
$('#show-command-line').click(function(){
$('#command-line').show();
return false;
});
function setToggleAllChecksLabel(){
$("#toggle-all-checks").text($('.build-statuses-list').is(":visible") ? "Hide all checks" : "Show all checks");
}
setToggleAllChecksLabel();
$('#toggle-all-checks').click(function(){
$('.build-statuses-list').toggle();
setToggleAllChecksLabel();
})
$('#merge-pull-request-button').click(function(){
$('#merge-pull-request').hide();

View File

@@ -1006,6 +1006,24 @@ div.author-info div.committer {
font-size: 12px;
}
.text-pending{
color: #cea61b;
}
.text-failure{
color: #bd2c00;
}
.box-content .build-statuses{
margin: -10px -10px 10px -10px;
}
.build-statuses .build-status-item{
padding: 10px 15px 10px 12px;
border-bottom: 1px solid #eee;
}
.build-statuses-list .build-status-item{
background-color: #fafafa;
}
/****************************************************************************/
/* Diff */
/****************************************************************************/