Compare commits

..

23 Commits
1.1 ... 1.2

Author SHA1 Message Date
takezoe
777142b992 (refs #20)Ignore response.setCharacterEncoding(). 2013-07-09 02:02:28 +09:00
takezoe
daa54029ed (refs #20)Remove charset from Content-Type header. 2013-07-08 21:36:18 +09:00
shimamoto
136a654639 Improve mapping of custom column type. 2013-07-08 17:25:31 +09:00
takezoe
f13e2c0d71 Insert issue comment from commit message as 'commit' action. 2013-07-08 15:33:53 +09:00
takezoe
97101248a2 (refs #22)Fix constraint for adding collaborator. 2013-07-08 15:23:40 +09:00
takezoe
5150b4b1b6 Small fix about presentation. 2013-07-08 15:22:37 +09:00
takezoe
a6d2381a68 Small fix about presentation. 2013-07-08 15:13:51 +09:00
Naoki Takezoe
29161feb49 Update README.md 2013-07-08 01:44:08 +09:00
Naoki Takezoe
1a4a1c2ccb Update README.md 2013-07-08 01:42:38 +09:00
takezoe
96dac65e31 (refs #4)Add 'News Feed' to the index page. 2013-07-07 14:05:01 +09:00
takezoe
6005282d9f (refs #4)The base of activity timeline is completed. 2013-07-07 13:40:04 +09:00
takezoe
129020dbc4 (refs #4)Implementing activity recording for git push. 2013-07-07 03:50:11 +09:00
takezoe
54e0242030 (refs #4)Record wiki activity. 2013-07-07 01:24:08 +09:00
takezoe
0e57f4064f Ignore IDEA configuration files. 2013-07-07 01:23:14 +09:00
takezoe
e50c4528a6 (refs #4)Add issue close, reopen and comment activity. 2013-07-06 22:07:51 +09:00
takezoe
342810aa3a (refs #4)Record issue creation activity. 2013-07-06 20:14:49 +09:00
takezoe
f84078c7ca (refs #4)Add 'Public Activity' tab to the account information page. 2013-07-06 20:03:34 +09:00
takezoe
eba81a6065 Remove unnecessary code. 2013-07-06 19:01:03 +09:00
takezoe
427e9197d8 Fix presentation when there are no repositories. 2013-07-06 17:02:17 +09:00
takezoe
67d6cf37a5 Display the commit count at the repository viewer. 2013-07-06 16:49:06 +09:00
takezoe
e6451c7ede (refs #21)Allow multi-byte chars in label name. 2013-07-06 04:19:35 +09:00
takezoe
bcd88a1342 (refs #24)Add GitBucket version to the header. 2013-07-06 03:11:05 +09:00
takezoe
5ea250d89d Improve issue id detection in Markdown. 2013-07-05 23:25:09 +09:00
38 changed files with 566 additions and 201 deletions

4
.gitignore vendored
View File

@@ -14,3 +14,7 @@ project/plugins/project/
.classpath
.project
.cache
# IntelliJ specific
.idea/
.idea_modules/

View File

@@ -34,6 +34,11 @@ The default administrator account is **root** and password is **root**.
Release Notes
--------
### 1.1 - 05 Jul 2013
- Fixed some bugs.
- Upgrade to JGit 3.0.
### 1.0 - 04 Jul 2013

View File

@@ -0,0 +1,24 @@
CREATE TABLE ACTIVITY(
ACTIVITY_ID INT AUTO_INCREMENT,
USER_NAME VARCHAR(100) NOT NULL,
REPOSITORY_NAME VARCHAR(100) NOT NULL,
ACTIVITY_USER_NAME VARCHAR(100) NOT NULL,
ACTIVITY_TYPE VARCHAR(100) NOT NULL,
MESSAGE TEXT NOT NULL,
ADDITIONAL_INFO TEXT,
ACTIVITY_DATE TIMESTAMP NOT NULL
);
CREATE TABLE COMMIT_LOG (
USER_NAME VARCHAR(100) NOT NULL,
REPOSITORY_NAME VARCHAR(100) NOT NULL,
COMMIT_ID VARCHAR(40) NOT NULL
);
ALTER TABLE ACTIVITY ADD CONSTRAINT IDX_ACTIVITY_PK PRIMARY KEY (ACTIVITY_ID);
ALTER TABLE ACTIVITY ADD CONSTRAINT IDX_ACTIVITY_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME);
ALTER TABLE ACTIVITY ADD CONSTRAINT IDX_ACTIVITY_FK1 FOREIGN KEY (ACTIVITY_USER_NAME) REFERENCES ACCOUNT (USER_NAME);
ALTER TABLE COMMIT_LOG ADD CONSTRAINT IDX_COMMIT_LOG_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, COMMIT_ID);
ALTER TABLE COMMIT_LOG ADD CONSTRAINT IDX_COMMIT_LOG_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME);

View File

@@ -6,10 +6,12 @@ import util.StringUtil._
import jp.sf.amateras.scalatra.forms._
class AccountController extends AccountControllerBase
with SystemSettingsService with AccountService with RepositoryService with OneselfAuthenticator
with SystemSettingsService with AccountService with RepositoryService with ActivityService
with OneselfAuthenticator
trait AccountControllerBase extends ControllerBase {
self: SystemSettingsService with AccountService with RepositoryService with OneselfAuthenticator =>
self: SystemSettingsService with AccountService with RepositoryService with ActivityService
with OneselfAuthenticator =>
case class AccountNewForm(userName: String, password: String,mailAddress: String, url: Option[String])
@@ -33,8 +35,13 @@ trait AccountControllerBase extends ControllerBase {
*/
get("/:userName") {
val userName = params("userName")
getAccountByUserName(userName).map {
account.html.info(_, getVisibleRepositories(userName, baseUrl, context.loginAccount.map(_.userName)))
getAccountByUserName(userName).map { x =>
params.getOrElse("tab", "repositories") match {
// Public Activity
case "activity" => account.html.activity(x, getActivitiesByUser(userName, true))
// Repositories
case _ => account.html.repositories(x, getVisibleRepositories(userName, baseUrl, context.loginAccount.map(_.userName)))
}
} getOrElse NotFound
}

View File

@@ -10,13 +10,15 @@ import org.apache.commons.io._
import jp.sf.amateras.scalatra.forms._
class CreateRepositoryController extends CreateRepositoryControllerBase
with RepositoryService with AccountService with WikiService with LabelsService with UsersAuthenticator
with RepositoryService with AccountService with WikiService with LabelsService with ActivityService
with UsersAuthenticator
/**
* Creates new repository.
*/
trait CreateRepositoryControllerBase extends ControllerBase {
self: RepositoryService with WikiService with LabelsService with UsersAuthenticator =>
self: RepositoryService with WikiService with LabelsService with ActivityService
with UsersAuthenticator =>
case class RepositoryCreationForm(name: String, description: Option[String])
@@ -36,7 +38,8 @@ trait CreateRepositoryControllerBase extends ControllerBase {
* Create new repository.
*/
post("/new", form)(usersOnly { form =>
val loginUserName = context.loginAccount.get.userName
val loginAccount = context.loginAccount.get
val loginUserName = loginAccount.userName
// Insert to the database at first
createRepository(form.name, loginUserName, form.description)
@@ -82,7 +85,10 @@ trait CreateRepositoryControllerBase extends ControllerBase {
}
// Create Wiki repository
createWikiRepository(context.loginAccount.get, form.name)
createWikiRepository(loginAccount, form.name)
// Record activity
recordCreateRepositoryActivity(loginUserName, form.name, loginUserName)
// redirect to the repository
redirect("/%s/%s".format(loginUserName, form.name))

View File

@@ -2,12 +2,16 @@ package app
import service._
class IndexController extends IndexControllerBase with RepositoryService with AccountService with SystemSettingsService
class IndexController extends IndexControllerBase
with RepositoryService with AccountService with SystemSettingsService with ActivityService
trait IndexControllerBase extends ControllerBase { self: RepositoryService with SystemSettingsService =>
trait IndexControllerBase extends ControllerBase { self: RepositoryService
with SystemSettingsService with ActivityService =>
get("/"){
html.index(getAccessibleRepositories(context.loginAccount, baseUrl), loadSystemSettings(),
html.index(getRecentActivities(),
getAccessibleRepositories(context.loginAccount, baseUrl),
loadSystemSettings(),
context.loginAccount.map{ account => getRepositoryNamesOfUser(account.userName) }.getOrElse(Nil))
}

View File

@@ -8,11 +8,11 @@ import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, ReadableUsersAut
import org.scalatra.Ok
class IssuesController extends IssuesControllerBase
with IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService
with IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator
trait IssuesControllerBase extends ControllerBase {
self: IssuesService with RepositoryService with LabelsService with MilestonesService
self: IssuesService with RepositoryService with LabelsService with MilestonesService with ActivityService
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator =>
case class IssueCreateForm(title: String, content: Option[String],
@@ -86,11 +86,14 @@ trait IssuesControllerBase extends ControllerBase {
val owner = repository.owner
val name = repository.name
val writable = hasWritePermission(owner, name, context.loginAccount)
val userName = context.loginAccount.get.userName
val issueId = createIssue(owner, name, context.loginAccount.get.userName, form.title, form.content,
// insert issue
val issueId = createIssue(owner, name, userName, form.title, form.content,
if(writable) form.assignedUserName else None,
if(writable) form.milestoneId else None)
// insert labels
if(writable){
form.labelNames.map { value =>
val labels = getLabels(owner, name)
@@ -102,6 +105,9 @@ trait IssuesControllerBase extends ControllerBase {
}
}
// record activity
recordCreateIssueActivity(owner, name, userName, issueId, form.title)
redirect("/%s/%s/issues/%d".format(owner, name, issueId))
})
@@ -118,21 +124,28 @@ trait IssuesControllerBase extends ControllerBase {
})
post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) =>
val owner = repository.owner
val name = repository.name
val owner = repository.owner
val name = repository.name
val userName = context.loginAccount.get.userName
getIssue(owner, name, form.issueId.toString).map { issue =>
redirect("/%s/%s/issues/%d#comment-%d".format(
owner, name, form.issueId,
createComment(owner, name, context.loginAccount.get.userName,
form.issueId,
form.content,
if(isEditable(owner, name, issue.openedUserName)){
params.get("action") filter { action =>
updateClosed(owner, name, form.issueId, if(action == "close") true else false) > 0
}
} else None)
))
val action = if(isEditable(owner, name, issue.openedUserName)){
params.get("action") filter { action =>
updateClosed(owner, name, form.issueId, if(action == "close") true else false) > 0
}
} else None
val commentId = createComment(owner, name, userName, form.issueId, form.content, action)
// record activity
recordCommentIssueActivity(owner, name, userName, issue.issueId, form.content)
action match {
case Some("reopen") => recordReopenIssueActivity(owner, name, userName, issue.issueId, issue.title)
case Some("close") => recordCloseIssueActivity(owner, name, userName, issue.issueId, issue.title)
case _ =>
}
redirect("/%s/%s/issues/%d#comment-%d".format(owner, name, form.issueId, commentId))
}
})

View File

@@ -13,12 +13,12 @@ trait LabelsControllerBase extends ControllerBase {
case class LabelForm(labelName: String, color: String)
val newForm = mapping(
"newLabelName" -> trim(label("Label name", text(required, identifier, maxlength(100)))),
"newLabelName" -> trim(label("Label name", text(required, labelName, maxlength(100)))),
"newColor" -> trim(label("Color", text(required, color)))
)(LabelForm.apply)
val editForm = mapping(
"editLabelName" -> trim(label("Label name", text(required, identifier, maxlength(100)))),
"editLabelName" -> trim(label("Label name", text(required, labelName, maxlength(100)))),
"editColor" -> trim(label("Color", text(required, color)))
)(LabelForm.apply)
@@ -47,4 +47,18 @@ trait LabelsControllerBase extends ControllerBase {
issues.labels.html.editlist(getLabels(repository.owner, repository.name), repository)
})
/**
* Constraint for the identifier such as user name, repository name or page name.
*/
private def labelName: Constraint = new Constraint(){
def validate(name: String, value: String): Option[String] =
if(!value.matches("^[^,]+$")){
Some("%s contains invalid character.".format(name))
} else if(value.startsWith("_") || value.startsWith("-")){
Some("%s starts with invalid character.".format(name))
} else {
None
}
}
}

View File

@@ -210,40 +210,38 @@ trait RepositoryViewerControllerBase extends ControllerBase {
* @return HTML of the file list
*/
private def fileList(repository: RepositoryService.RepositoryInfo, revstr: String = "", path: String = ".") = {
getRepository(repository.owner, repository.name, baseUrl).map { repositoryInfo =>
val revision = if(revstr.isEmpty){
repositoryInfo.repository.defaultBranch
} else {
revstr
val revision = if(revstr.isEmpty){
repository.repository.defaultBranch
} else {
revstr
}
JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git =>
// get latest commit
val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(revision))
val files = JGitUtil.getFileList(git, revision, path)
// process README.md
val readme = files.find(_.name == "README.md").map { file =>
new String(JGitUtil.getContent(Git.open(getRepositoryDir(repository.owner, repository.name)), file.id, true).get, "UTF-8")
}
JGitUtil.withGit(getRepositoryDir(repositoryInfo.owner, repositoryInfo.name)){ git =>
// get latest commit
val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(revision))
val files = JGitUtil.getFileList(git, revision, path)
// process README.md
val readme = files.find(_.name == "README.md").map { file =>
new String(JGitUtil.getContent(Git.open(getRepositoryDir(repositoryInfo.owner, repositoryInfo.name)), file.id, true).get, "UTF-8")
}
repo.html.files(
// current branch
revision,
// repository
repositoryInfo,
// current path
if(path == ".") Nil else path.split("/").toList,
// latest commit
new JGitUtil.CommitInfo(revCommit),
// file list
files,
// readme
readme
)
}
} getOrElse NotFound
repo.html.files(
// current branch
revision,
// repository
repository,
// current path
if(path == ".") Nil else path.split("/").toList,
// latest commit
new JGitUtil.CommitInfo(revCommit),
// file list
files,
// readme
readme
)
}
}
}

View File

@@ -104,17 +104,12 @@ trait SettingsControllerBase extends ControllerBase {
*/
private def collaborator: Constraint = new Constraint(){
def validate(name: String, value: String): Option[String] = {
val paths = request.getRequestURI.split("/")
getAccountByUserName(value) match {
case None => Some("User does not exist.")
case Some(x) if(x.userName == context.loginAccount.get.userName) => Some("User can access this repository already.")
case Some(x) => {
val paths = request.getRequestURI.split("/")
if(getCollaborators(paths(1), paths(2)).contains(x.userName)){
Some("User can access this repository already.")
} else {
None
}
}
case Some(x) if(x.userName == paths(1) || getCollaborators(paths(1), paths(2)).contains(x.userName))
=> Some("User can access this repository already.")
case _ => None
}
}
}

View File

@@ -6,10 +6,12 @@ import util.Directory._
import jp.sf.amateras.scalatra.forms._
class WikiController extends WikiControllerBase
with WikiService with RepositoryService with AccountService with CollaboratorsAuthenticator with ReferrerAuthenticator
with WikiService with RepositoryService with AccountService with ActivityService
with CollaboratorsAuthenticator with ReferrerAuthenticator
trait WikiControllerBase extends ControllerBase {
self: WikiService with RepositoryService with CollaboratorsAuthenticator with ReferrerAuthenticator =>
self: WikiService with RepositoryService with ActivityService
with CollaboratorsAuthenticator with ReferrerAuthenticator =>
case class WikiPageEditForm(pageName: String, content: String, message: Option[String], currentPageName: String)
@@ -72,9 +74,13 @@ trait WikiControllerBase extends ControllerBase {
})
post("/:owner/:repository/wiki/_edit", editForm)(collaboratorsOnly { (form, repository) =>
val loginAccount = context.loginAccount.get
saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName,
form.content, context.loginAccount.get, form.message.getOrElse(""))
form.content, loginAccount, form.message.getOrElse(""))
updateLastActivityDate(repository.owner, repository.name)
recordEditWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName)
redirect("/%s/%s/wiki/%s".format(repository.owner, repository.name, form.pageName))
})
@@ -84,9 +90,13 @@ trait WikiControllerBase extends ControllerBase {
})
post("/:owner/:repository/wiki/_new", newForm)(collaboratorsOnly { (form, repository) =>
val loginAccount = context.loginAccount.get
saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName,
form.content, context.loginAccount.get, form.message.getOrElse(""))
updateLastActivityDate(repository.owner, repository.name)
recordCreateWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName)
redirect("/%s/%s/wiki/%s".format(repository.owner, repository.name, form.pageName))
})

View File

@@ -2,7 +2,7 @@ package model
import scala.slick.driver.H2Driver.simple._
object Accounts extends Table[Account]("ACCOUNT") with Functions {
object Accounts extends Table[Account]("ACCOUNT") {
def userName = column[String]("USER_NAME", O PrimaryKey)
def mailAddress = column[String]("MAIL_ADDRESS")
def password = column[String]("PASSWORD")

View File

@@ -0,0 +1,31 @@
package model
import scala.slick.driver.H2Driver.simple._
object Activities extends Table[Activity]("ACTIVITY") with BasicTemplate {
def activityId = column[Int]("ACTIVITY_ID", O AutoInc)
def activityUserName = column[String]("ACTIVITY_USER_NAME")
def activityType = column[String]("ACTIVITY_TYPE")
def message = column[String]("MESSAGE")
def additionalInfo = column[String]("ADDITIONAL_INFO")
def activityDate = column[java.util.Date]("ACTIVITY_DATE")
def * = activityId ~ userName ~ repositoryName ~ activityUserName ~ activityType ~ message ~ additionalInfo.? ~ activityDate <> (Activity, Activity.unapply _)
def autoInc = userName ~ repositoryName ~ activityUserName ~ activityType ~ message ~ additionalInfo.? ~ activityDate returning activityId
}
object CommitLog extends Table[(String, String, String)]("COMMIT_LOG") with BasicTemplate {
def commitId = column[String]("COMMIT_ID")
def * = userName ~ repositoryName ~ commitId
def byPrimaryKey(userName: String, repositoryName: String, commitId: String) = byRepository(userName, repositoryName) && (this.commitId is commitId.bind)
}
case class Activity(
activityId: Int,
userName: String,
repositoryName: String,
activityUserName: String,
activityType: String,
message: String,
additionalInfo: Option[String],
activityDate: java.util.Date
)

View File

@@ -7,7 +7,7 @@ object IssueId extends Table[(String, String, Int)]("ISSUE_ID") with IssueTempla
def byPrimaryKey(owner: String, repository: String) = byRepository(owner, repository)
}
object Issues extends Table[Issue]("ISSUE") with IssueTemplate with MilestoneTemplate with Functions {
object Issues extends Table[Issue]("ISSUE") with IssueTemplate with MilestoneTemplate {
def openedUserName = column[String]("OPENED_USER_NAME")
def assignedUserName = column[String]("ASSIGNED_USER_NAME")
def title = column[String]("TITLE")

View File

@@ -2,7 +2,7 @@ package model
import scala.slick.driver.H2Driver.simple._
object IssueComments extends Table[IssueComment]("ISSUE_COMMENT") with IssueTemplate with Functions {
object IssueComments extends Table[IssueComment]("ISSUE_COMMENT") with IssueTemplate {
def commentId = column[Int]("COMMENT_ID", O AutoInc)
def action = column[String]("ACTION")
def commentedUserName = column[String]("COMMENTED_USER_NAME")

View File

@@ -2,7 +2,7 @@ package model
import scala.slick.driver.H2Driver.simple._
object Milestones extends Table[Milestone]("MILESTONE") with MilestoneTemplate with Functions {
object Milestones extends Table[Milestone]("MILESTONE") with MilestoneTemplate {
def title = column[String]("TITLE")
def description = column[String]("DESCRIPTION")
def dueDate = column[java.util.Date]("DUE_DATE")

View File

@@ -2,7 +2,7 @@ package model
import scala.slick.driver.H2Driver.simple._
object Repositories extends Table[Repository]("REPOSITORY") with BasicTemplate with Functions {
object Repositories extends Table[Repository]("REPOSITORY") with BasicTemplate {
def isPrivate = column[Boolean]("PRIVATE")
def description = column[String]("DESCRIPTION")
def defaultBranch = column[String]("DEFAULT_BRANCH")

View File

@@ -1,8 +1,6 @@
package model
package object model {
import scala.slick.lifted.MappedTypeMapper
import scala.slick.lifted.MappedTypeMapper
protected[model] trait Functions {
// java.util.Date TypeMapper
implicit val dateTypeMapper = MappedTypeMapper.base[java.util.Date, java.sql.Timestamp](
d => new java.sql.Timestamp(d.getTime),

View File

@@ -1,7 +1,6 @@
package service
import model._
import Accounts._
import scala.slick.driver.H2Driver.simple._
import Database.threadLocalSession

View File

@@ -0,0 +1,115 @@
package service
import model._
import scala.slick.driver.H2Driver.simple._
import Database.threadLocalSession
trait ActivityService {
def getActivitiesByUser(activityUserName: String, isPublic: Boolean): List[Activity] = {
val q = Query(Activities)
.innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName))
(if(isPublic){
q filter { case (t1, t2) => (t1.activityUserName is activityUserName.bind) && (t2.isPrivate is false.bind) }
} else {
q filter { case (t1, t2) => t1.activityUserName is activityUserName.bind }
})
.sortBy { case (t1, t2) => t1.activityId desc }
.map { case (t1, t2) => t1 }
.take(30)
.list
}
def getRecentActivities(): List[Activity] =
Query(Activities)
.innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName))
.filter { case (t1, t2) => t2.isPrivate is false.bind }
.sortBy { case (t1, t2) => t1.activityId desc }
.map { case (t1, t2) => t1 }
.take(30)
.list
def recordCreateRepositoryActivity(userName: String, repositoryName: String, activityUserName: String): Unit =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"create_repository",
s"[user:${activityUserName}] created [repo:${userName}/${repositoryName}]",
None,
currentDate)
def recordCreateIssueActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"open_issue",
s"[user:${activityUserName}] opened issue [issue:${userName}/${repositoryName}#${issueId}]",
Some(title),
currentDate)
def recordCloseIssueActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"close_issue",
s"[user:${activityUserName}] closed issue [issue:${userName}/${repositoryName}#${issueId}]",
Some(title),
currentDate)
def recordReopenIssueActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"reopen_issue",
s"[user:${activityUserName}] closed reopened [issue:${userName}/${repositoryName}#${issueId}]",
Some(title),
currentDate)
def recordCommentIssueActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, comment: String): Unit =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"comment_issue",
s"[user:${activityUserName}] commented on issue [issue:${userName}/${repositoryName}#${issueId}]",
Some(cut(comment, 200)),
currentDate)
def recordCreateWikiPageActivity(userName: String, repositoryName: String, activityUserName: String, pageName: String) =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"create_wiki",
s"[user:${activityUserName}] created the [repo:${userName}/${repositoryName}] wiki",
Some(pageName),
currentDate)
def recordEditWikiPageActivity(userName: String, repositoryName: String, activityUserName: String, pageName: String) =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"edit_wiki",
s"[user:${activityUserName}] edited the [repo:${userName}/${repositoryName}] wiki",
Some(pageName),
currentDate)
def recordPushActivity(userName: String, repositoryName: String, activityUserName: String,
branchName: String, commits: List[util.JGitUtil.CommitInfo]) =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"push",
s"[user:${activityUserName}] pushed to [branch:${userName}/${repositoryName}#${branchName}] at [repo:${userName}/${repositoryName}]",
Some(commits.map { commit => commit.id + ":" + commit.shortMessage }.mkString("\n")),
currentDate)
def recordCreateTagActivity(userName: String, repositoryName: String, activityUserName: String,
tagName: String, commits: List[util.JGitUtil.CommitInfo]) =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"create_tag",
s"[user:${activityUserName}] created tag [tag:${userName}/${repositoryName}#${tagName}] at [repo:${userName}/${repositoryName}]",
None,
currentDate)
def recordCreateBranchActivity(userName: String, repositoryName: String, activityUserName: String, branchName: String) =
Activities.autoInc insert(userName, repositoryName, activityUserName,
"create_tag",
s"[user:${activityUserName}] created branch [tag:${userName}/${repositoryName}#${branchName}] at [repo:${userName}/${repositoryName}]",
None,
currentDate)
def insertCommitId(userName: String, repositoryName: String, commitId: String) = {
CommitLog insert (userName, repositoryName, commitId)
}
def existsCommitId(userName: String, repositoryName: String, commitId: String): Boolean =
Query(CommitLog).filter(_.byPrimaryKey(userName, repositoryName, commitId)).firstOption.isDefined
private def cut(value: String, length: Int): String =
if(value.length > length) value.substring(0, length) + "..." else value
}

View File

@@ -6,7 +6,6 @@ import scala.slick.jdbc.{StaticQuery => Q}
import Q.interpolation
import model._
import Issues._
import util.Implicits._
trait IssuesService {

View File

@@ -4,7 +4,6 @@ import scala.slick.driver.H2Driver.simple._
import Database.threadLocalSession
import model._
import Milestones._
trait MilestonesService {

View File

@@ -1,7 +1,6 @@
package service
import model._
import Repositories._
import scala.slick.driver.H2Driver.simple._
import Database.threadLocalSession
import util.JGitUtil
@@ -83,8 +82,7 @@ trait RepositoryService { self: AccountService =>
}
q1.union(q2).filter(visibleFor(_, loginUserName)).sortBy(_.lastActivityDate desc).list map { repository =>
val repositoryInfo = JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl)
RepositoryInfo(repositoryInfo.owner, repositoryInfo.name, repositoryInfo.url, repository, repositoryInfo.branchList, repositoryInfo.tags)
new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository)
}
}
@@ -98,8 +96,7 @@ trait RepositoryService { self: AccountService =>
*/
def getRepository(userName: String, repositoryName: String, baseUrl: String): Option[RepositoryInfo] = {
(Query(Repositories) filter { t => t.byRepository(userName, repositoryName) } firstOption) map { repository =>
val repositoryInfo = JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl)
RepositoryInfo(repositoryInfo.owner, repositoryInfo.name, repositoryInfo.url, repository, repositoryInfo.branchList, repositoryInfo.tags)
new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository)
}
}
@@ -112,9 +109,8 @@ trait RepositoryService { self: AccountService =>
*/
def getAccessibleRepositories(account: Option[Account], baseUrl: String): List[RepositoryInfo] = {
def createRepositoryInfo(repository: Repository): RepositoryInfo = {
val repositoryInfo = JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl)
RepositoryInfo(repositoryInfo.owner, repositoryInfo.name, repositoryInfo.url, repository, repositoryInfo.branchList, repositoryInfo.tags)
def newRepositoryInfo(repository: Repository): RepositoryInfo = {
new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository)
}
(account match {
@@ -127,7 +123,7 @@ trait RepositoryService { self: AccountService =>
}
// for Guests
case None => Query(Repositories) filter(_.isPrivate is false.bind)
}).sortBy(_.lastActivityDate desc).list.map(createRepositoryInfo _)
}).sortBy(_.lastActivityDate desc).list.map(newRepositoryInfo _)
}
/**
@@ -189,6 +185,12 @@ trait RepositoryService { self: AccountService =>
object RepositoryService {
case class RepositoryInfo(owner: String, name: String, url: String, repository: Repository,
branchList: List[String], tags: List[util.JGitUtil.TagInfo])
commitCount: Int, branchList: List[String], tags: List[util.JGitUtil.TagInfo]){
def this(repo: JGitUtil.RepositoryInfo, model: Repository) = {
this(repo.owner, repo.name, repo.url, model, repo.commitCount, repo.branchList, repo.tags)
}
}
}

View File

@@ -49,8 +49,9 @@ object AutoUpdate {
* The history of versions. A head of this sequence is the current BitBucket version.
*/
val versions = Seq(
Version(1, 1),
Version(1, 0)
Version(1, 2),
Version(1, 1),
Version(1, 0)
)
/**

View File

@@ -21,6 +21,10 @@ class BasicAuthenticationFilter extends Filter with RepositoryService with Accou
val request = req.asInstanceOf[HttpServletRequest]
val response = res.asInstanceOf[HttpServletResponse]
val wrappedResponse = new HttpServletResponseWrapper(response){
override def setCharacterEncoding(encoding: String) = {}
}
try {
val paths = request.getRequestURI.substring(request.getContextPath.length).split("/")
val repositoryOwner = paths(2)
@@ -29,14 +33,14 @@ class BasicAuthenticationFilter extends Filter with RepositoryService with Accou
getRepository(repositoryOwner, repositoryName.replaceFirst("\\.wiki", ""), "") match {
case Some(repository) => {
if(!request.getRequestURI.endsWith("/git-receive-pack") && !repository.repository.isPrivate){
chain.doFilter(req, res)
chain.doFilter(req, wrappedResponse)
} else {
request.getHeader("Authorization") match {
case null => requireAuth(response)
case auth => decodeAuthHeader(auth).split(":") match {
case Array(username, password) if(isWritableUser(username, password, repository)) => {
request.setAttribute("USER_NAME", username)
chain.doFilter(req, res)
chain.doFilter(req, wrappedResponse)
}
case _ => requireAuth(response)
}

View File

@@ -49,7 +49,7 @@ class GitBucketReceivePackFactory extends ReceivePackFactory[HttpServletRequest]
override def create(request: HttpServletRequest, db: Repository): ReceivePack = {
val receivePack = new ReceivePack(db)
val userName = request.getAttribute("USER_NAME")
val userName = request.getAttribute("USER_NAME").asInstanceOf[String]
logger.debug("requestURI: " + request.getRequestURI)
logger.debug("userName:" + userName)
@@ -60,27 +60,52 @@ class GitBucketReceivePackFactory extends ReceivePackFactory[HttpServletRequest]
logger.debug("repository:" + owner + "/" + repository)
receivePack.setPostReceiveHook(new CommitLogHook(owner, repository))
receivePack.setPostReceiveHook(new CommitLogHook(owner, repository, userName))
receivePack
}
}
import scala.collection.JavaConverters._
class CommitLogHook(owner: String, repository: String) extends PostReceiveHook
with RepositoryService with AccountService with IssuesService {
class CommitLogHook(owner: String, repository: String, userName: String) extends PostReceiveHook
with RepositoryService with AccountService with IssuesService with ActivityService {
private val logger = LoggerFactory.getLogger(classOf[CommitLogHook])
def onPostReceive(receivePack: ReceivePack, commands: java.util.Collection[ReceiveCommand]): Unit = {
JGitUtil.withGit(Directory.getRepositoryDir(owner, repository)) { git =>
commands.asScala.foreach { command =>
JGitUtil.getCommitLog(git, command.getOldId.name, command.getNewId.name).foreach { commit =>
"(^|\\W)#(\\d+)(\\W|$)".r.findAllIn(commit.fullMessage).matchData.foreach { matchData =>
val issueId = matchData.group(2)
if(getAccountByUserName(commit.committer).isDefined && getIssue(owner, repository, issueId).isDefined){
createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage, None)
val commits = JGitUtil.getCommitLog(git, command.getOldId.name, command.getNewId.name)
val refName = command.getRefName.split("/")
// apply issue comment
val newCommits = commits.flatMap { commit =>
if(!existsCommitId(owner, repository, commit.id)){
insertCommitId(owner, repository, commit.id)
"(^|\\W)#(\\d+)(\\W|$)".r.findAllIn(commit.fullMessage).matchData.foreach { matchData =>
val issueId = matchData.group(2)
if(getAccountByUserName(commit.committer).isDefined && getIssue(owner, repository, issueId).isDefined){
createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage, Some("commit"))
}
}
Some(commit)
} else None
}.toList
// record activity
if(refName(1) == "heads"){
command.getType match {
case ReceiveCommand.Type.CREATE => {
recordCreateBranchActivity(owner, repository, userName, refName(2))
recordPushActivity(owner, repository, userName, refName(2), newCommits)
}
case ReceiveCommand.Type.UPDATE => recordPushActivity(owner, repository, userName, refName(2), newCommits)
case _ =>
}
} else if(refName(1) == "tags"){
command.getType match {
case ReceiveCommand.Type.CREATE => recordCreateTagActivity(owner, repository, userName, refName(2), newCommits)
case _ =>
}
}
}

View File

@@ -26,10 +26,11 @@ object JGitUtil {
* @param owner the user name of the repository owner
* @param name the repository name
* @param url the repository URL
* @param commitCount the commit count. If the repository has over 1000 commits then this property is 1001.
* @param branchList the list of branch names
* @param tags the list of tags
*/
case class RepositoryInfo(owner: String, name: String, url: String, branchList: List[String], tags: List[TagInfo])
case class RepositoryInfo(owner: String, name: String, url: String, commitCount: Int, branchList: List[String], tags: List[TagInfo])
/**
* The file data for the file list of the repository viewer.
@@ -141,8 +142,18 @@ object JGitUtil {
*/
def getRepositoryInfo(owner: String, repository: String, baseUrl: String): RepositoryInfo = {
withGit(getRepositoryDir(owner, repository)){ git =>
// get commit count
val i = git.log.all.call.iterator
var commitCount = 0
while(i.hasNext && commitCount <= 1000){
i.next
commitCount = commitCount + 1
}
RepositoryInfo(
owner, repository, baseUrl + "/git/%s/%s.git".format(owner, repository),
// commit count
commitCount,
// branches
git.branchList.call.asScala.map { ref =>
ref.getName.replaceFirst("^refs/heads/", "")

View File

@@ -74,15 +74,6 @@ class GitBucketHtmlSerializer(
Map[String, VerbatimSerializer](VerbatimSerializer.DEFAULT -> new GitBucketVerbatimSerializer).asJava
) {
override def toHtml(rootNode: RootNode): String = {
val html = super.toHtml(rootNode)
if(enableIssueLink){
// convert marked issue id to link.
html.replaceAll("#\\{\\{\\{\\{(\\d+)\\}\\}\\}\\}",
"<a href=\"%s/%s/%s/issues/$1\">#$1</a>".format(context.path, repository.owner, repository.name))
} else html
}
override protected def printImageTag(imageNode: SuperNode, url: String): Unit =
printer.print("<img src=\"").print(fixUrl(url)).print("\" alt=\"").printEncoded(printChildrenToString(imageNode)).print("\"/>")
@@ -109,27 +100,23 @@ class GitBucketHtmlSerializer(
override def visit(node: TextNode) {
// convert commit id to link.
val text1 = if(enableCommitLink) node.getText.replaceAll("(^|\\W)([0-9a-f]{40})(\\W|$)",
val text = if(enableCommitLink) node.getText.replaceAll("(^|\\W)([0-9a-f]{40})(\\W|$)",
"<a href=\"%s/%s/%s/commit/$2\">$2</a>".format(context.path, repository.owner, repository.name))
else node.getText
// mark issue id to link
val startIndex = node.getStartIndex
val text2 = if(enableIssueLink && startIndex > 0 && markdown.charAt(startIndex - 1) == '#'){
text1.replaceFirst("^(\\d+)(\\W|$)", "{{{{$1}}}}")
} else text1
if (abbreviations.isEmpty) {
printer.print(text2)
printer.print(text)
} else {
printWithAbbreviations(text2)
printWithAbbreviations(text)
}
}
override def visit(node: HeaderNode) {
if(enableIssueLink && markdown.substring(node.getStartIndex, node.getEndIndex - 1).startsWith("#")){
printer.print("#" * node.getLevel)
visitChildren(node)
val text = markdown.substring(node.getStartIndex, node.getEndIndex - 1).trim
if(enableIssueLink && text.matches("#[\\d]+")){
// convert issue id to link
val issueId = text.substring(1).toInt
printer.print("<a href=\"%s/%s/%s/issues/%d\">#%d</a>".format(context.path, repository.owner, repository.name, issueId, issueId))
} else {
printTag(node, "h" + node.getLevel)
}

View File

@@ -33,6 +33,18 @@ object helpers {
Html(Markdown.toHtml(value, repository, enableWikiLink, enableCommitLink, enableIssueLink))
}
def activityMessage(message: String)(implicit context: app.Context): Html = {
val a = s"a $message aa $$1 a"
Html(message
.replaceAll("\\[issue:([^\\s]+?)/([^\\s]+?)#((\\d+))\\]" , s"""<a href="${context.path}/$$1/$$2/issues/$$3">$$1/$$2#$$3</a>""")
.replaceAll("\\[repo:([^\\s]+?)/([^\\s]+?)\\]" , s"""<a href="${context.path}/$$1/$$2\">$$1/$$2</a>""")
.replaceAll("\\[branch:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]", s"""<a href="${context.path}/$$1/$$2/tree/$$3">$$3</a>""")
.replaceAll("\\[tag:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]" , s"""<a href="${context.path}/$$1/$$2/tree/$$3">$$3</a>""")
.replaceAll("\\[user:([^\\s]+?)\\]" , s"""<a href="${context.path}/$$1">$$1</a>""")
)
}
/**
* Generates the url to the repository.
*/
@@ -61,6 +73,9 @@ object helpers {
// convert commit id to link
.replaceAll("(^|\\W)([a-f0-9]{40})(\\W|$)", "$1<a href=\"%s/%s/%s/commit/$2\">$2</a>$3").format(context.path, repository.owner, repository.name))
/**
* Implicit conversion to add mkHtml() to Seq[Html].
*/
implicit def extendsHtmlSeq(seq: Seq[Html]) = new {
def mkHtml(separator: String) = Html(seq.mkString(separator))
def mkHtml(separator: scala.xml.Elem) = Html(seq.mkString(separator.toString))

View File

@@ -0,0 +1,22 @@
@(account: model.Account, activities: List[model.Activity])(implicit context: app.Context)
@import context._
@import view.helpers._
@html.main(account.userName){
<div class="container-fluid">
<div class="row-fluid">
<div class="span4">
<div class="block">
<div class="block-header">@account.userName</div>
</div>
<div class="block">
<div><i class="icon-home"></i> <a href="@account.url">@account.url</a></div>
<div><i class="icon-time"></i> <span class="muted">Joined on</span> @date(account.registeredDate)</div>
</div>
</div>
<div class="span8">
@tab(account, "activity")
@helper.html.activities(activities)
</div>
</div>
</div>
}

View File

@@ -1,49 +0,0 @@
@(account: model.Account, repositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context)
@import context._
@import view.helpers._
@html.main(account.userName){
<div class="container-fluid">
<div class="row-fluid">
<div class="span4">
<div class="block">
<div class="block-header">@account.userName</div>
</div>
<div class="block">
<div><i class="icon-home"></i> <a href="@account.url">@account.url</a></div>
<div><i class="icon-time"></i> <span class="muted">Joined on</span> @date(account.registeredDate)</div>
</div>
</div>
<div class="span8">
<ul class="nav nav-tabs">
<li class="active"><a href="#">Repositories</a></li>
<!--
<li><a href="#">Activity</a></li>
-->
@if(loginAccount.isDefined && loginAccount.get.userName == account.userName){
<li class="pull-right">
<div class="button-group">
<a href="@url(account.userName)/_edit" class="btn">Edit Your Profile</a>
</div>
</li>
}
</ul>
@repositories.map { repository =>
<div class="block">
<div class="block-header">
<a href="@url(repository.owner)">@repository.owner</a>
/
<a href="@url(repository)">@repository.name</a>
@if(repository.repository.isPrivate){
<i class="icon-lock"></i>
}
</div>
@if(repository.repository.description.isDefined){
<div>@repository.repository.description</div>
}
<div><span class="muted small">Last updated: @datetime(repository.repository.lastActivityDate)</span></div>
</div>
}
</div>
</div>
</div>
}

View File

@@ -0,0 +1,41 @@
@(account: model.Account, repositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context)
@import context._
@import view.helpers._
@html.main(account.userName){
<div class="container-fluid">
<div class="row-fluid">
<div class="span4">
<div class="block">
<div class="block-header">@account.userName</div>
</div>
<div class="block">
<div><i class="icon-home"></i> <a href="@account.url">@account.url</a></div>
<div><i class="icon-time"></i> <span class="muted">Joined on</span> @date(account.registeredDate)</div>
</div>
</div>
<div class="span8">
@tab(account, "repositories")
@if(repositories.isEmpty){
No repositories
} else {
@repositories.map { repository =>
<div class="block">
<div class="block-header">
<a href="@url(repository.owner)">@repository.owner</a>
/
<a href="@url(repository)">@repository.name</a>
@if(repository.repository.isPrivate){
<i class="icon-lock"></i>
}
</div>
@if(repository.repository.description.isDefined){
<div>@repository.repository.description</div>
}
<div><span class="muted small">Last updated: @datetime(repository.repository.lastActivityDate)</span></div>
</div>
}
}
</div>
</div>
</div>
}

View File

@@ -0,0 +1,14 @@
@(account: model.Account, active: String)(implicit context: app.Context)
@import context._
@import view.helpers._
<ul class="nav nav-tabs">
<li@if(active == "repositories"){ class="active"}><a href="@url(account.userName)?tab=repositories">Repositories</a></li>
<li@if(active == "activity"){ class="active"}><a href="@url(account.userName)?tab=activity">Public Activity</a></li>
@if(loginAccount.isDefined && loginAccount.get.userName == account.userName){
<li class="pull-right">
<div class="button-group">
<a href="@url(account.userName)/_edit" class="btn">Edit Your Profile</a>
</div>
</li>
}
</ul>

View File

@@ -0,0 +1,36 @@
@(activities: List[model.Activity])(implicit context: app.Context)
@import context._
@import view.helpers._
@if(activities.isEmpty){
No activity
} else {
@activities.map { activity =>
<div class="block">
<div class="muted small">@datetime(activity.activityDate)</div>
<div class="strong">@activityMessage(activity.message)</div>
@activity.additionalInfo.map { additionalInfo =>
@(activity.activityType match {
case "create_wiki" => {
<div class="small">Created <a href={"%s/%s/%s/wiki/%s".format(path, activity.userName, activity.repositoryName, additionalInfo)}>{additionalInfo}</a>.</div>
}
case "edit_wiki" => {
<div class="small">Edited <a href={"%s/%s/%s/wiki/%s".format(path, activity.userName, activity.repositoryName, additionalInfo)}>{additionalInfo}</a>.</div>
}
case "push" => {
<div class="small">
{additionalInfo.split("\n").map{ commit =>
<div>
<a href={"%s/%s/%s/commit/%s".format(path, activity.userName, activity.repositoryName, commit.substring(0, 40))} class="monospace">{commit.substring(0, 7)}</a>
<span>{commit.substring(41)}</span>
</div>
}}
</div>
}
case _ => {
<div>{additionalInfo}</div>
}
})
}
</div>
}
}

View File

@@ -1,27 +1,14 @@
@(repositories: List[service.RepositoryService.RepositoryInfo], systemSettings: service.SystemSettingsService.SystemSettings,
@(activities: List[model.Activity],
repositories: List[service.RepositoryService.RepositoryInfo],
systemSettings: service.SystemSettingsService.SystemSettings,
userRepositories: List[String])(implicit context: app.Context)
@import context._
@import view.helpers._
@main("GitBucket"){
<div class="row-fluid">
<div class="span8">
<h3>Recent updated repositories</h3>
@repositories.map { repository =>
<div class="block">
<div class="block-header">
<a href="@url(repository.owner)">@repository.owner</a>
/
<a href="@url(repository)">@repository.name</a>
@if(repository.repository.isPrivate){
<i class="icon-lock"></i>
}
</div>
@if(repository.repository.description.isDefined){
<div>@repository.repository.description</div>
}
<div><span class="muted small">Last updated: @datetime(repository.repository.lastActivityDate)</span></div>
</div>
}
<h3>News Feed</h3>
@helper.html.activities(activities)
</div>
<div class="span4">
@if(loginAccount.isEmpty){
@@ -29,15 +16,47 @@
} else {
<table class="table table-bordered">
<tr>
<th class="metal">Your repositories (@userRepositories.size)</th>
<th class="metal">
<div class="pull-right">
<a href="@path/new" class="btn btn-success btn-mini">New repository</a>
</div>
Your repositories (@userRepositories.size)
</th>
</tr>
@userRepositories.map { repositoryName =>
@if(userRepositories.isEmpty){
<tr>
<td><a href="@path/@loginAccount.get.userName/@repositoryName">@repositoryName</a></td>
<td>No repositories</td>
</tr>
} else {
@userRepositories.map { repositoryName =>
<tr>
<td><a href="@path/@loginAccount.get.userName/@repositoryName"><strong>@repositoryName</strong></a></td>
</tr>
}
}
</table>
}
<table class="table table-bordered">
<tr>
<th class="metal">
Recent updated repositories
</th>
</tr>
@if(repositories.isEmpty){
<tr>
<td>No repositories</td>
</tr>
} else {
@repositories.map { repository =>
<tr>
<td>
<a href="@url(repository)">@repository.owner/<strong>@repository.name</strong></a>
</td>
</tr>
}
}
</table>
</div>
</div>

View File

@@ -52,6 +52,9 @@
</div><!--/.nav-collapse -->
</div>
</div>
@defining(servlet.AutoUpdate.getCurrentVersion){ version =>
<div class="gitbucket-version">version @version.majorVersion.@version.minorVersion</div>
}
</div>
<div class="container body">
@body

View File

@@ -10,6 +10,11 @@
@html.header("code", repository)
@tab(branch, repository, "files")
<div class="head">
<div class="pull-right">
@defining(repository.commitCount){ commitCount =>
<a href="@url(repository)/commits/@branch">@if(commitCount > 1000){ @commitCount+ } else { @commitCount } @plural(commitCount, "commit")</a>&nbsp;
}
</div>
<a href="@url(repository)/tree/@branch">@repository.name</a> /
@pathList.zipWithIndex.map { case (section, i) =>
<a href="@url(repository)/tree/@branch/@pathList.take(i + 1).mkString("/")">@section</a> /

View File

@@ -5,6 +5,13 @@ body {
color: #333;
}
div.gitbucket-version {
font-size: small;
color: silver;
float: right;
margin-right: 10px;
}
table.global-nav {
width: 920px;
margin-bottom: 10px;
@@ -97,7 +104,7 @@ div.block-header {
div.block {
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 1px solid silver;
border-bottom: 1px solid #ddd;
}
h3 {
@@ -478,6 +485,7 @@ ul.collaborator li {
border: 1px solid #ccc;
border-radius: 3px;
padding: 6px;
margin-bottom: 2px;
}
ul.collaborator li:hover {