Merge branch 'master' into feature/webhook-scope

This commit is contained in:
nazoking
2015-11-06 14:48:03 +09:00
36 changed files with 524 additions and 221 deletions

View File

@@ -1,5 +1,7 @@
# Guideline for Issues # Guideline for Issues
- If you have any question about GitBucket, send it to [gitter room](https://gitter.im/takezoe/gitbucket) before raise an issue. - If you have any question about GitBucket, send it to [gitter room](https://gitter.im/gitbucket/gitbucket) before raise an issue.
- Make sure check whether there is a same question or request in the past.
- When raise a new issue, write subject in **English** at least. - When raise a new issue, write subject in **English** at least.
- We can also support in Japaneses other than English at [gitter room for Japanese](https://gitter.im/takezoe/gitbucket_ja). - We can also support in Japaneses other than English at [gitter room for Japanese](https://gitter.im/gitbucket/gitbucket_ja).
- First priority of GitBucket is easy installation and reproduce GitHub behavior, so we might reject if your request is against it.

View File

@@ -1,4 +1,4 @@
GitBucket [![Gitter chat](https://badges.gitter.im/takezoe/gitbucket.png)](https://gitter.im/takezoe/gitbucket) [![Build Status](https://travis-ci.org/takezoe/gitbucket.svg?branch=master)](https://travis-ci.org/takezoe/gitbucket) GitBucket [![Gitter chat](https://badges.gitter.im/gitbucket/gitbucket.png)](https://gitter.im/gitbucket/gitbucket) [![Build Status](https://travis-ci.org/gitbucket/gitbucket.svg?branch=master)](https://travis-ci.org/gitbucket/gitbucket)
========= =========
GitBucket is the easily installable GitHub clone powered by Scala. GitBucket is the easily installable GitHub clone powered by Scala.
@@ -20,12 +20,12 @@ The current version of GitBucket provides a basic features below:
- Gravatar support - Gravatar support
- Plug-in system - Plug-in system
If you want to try the development version of GitBucket, see the documentation for developers at [Wiki](https://github.com/takezoe/gitbucket/wiki). If you want to try the development version of GitBucket, see the documentation for developers at [Wiki](https://github.com/gitbucket/gitbucket/wiki).
Installation Installation
-------- --------
1. Download latest **gitbucket.war** from [the release page](https://github.com/takezoe/gitbucket/releases). 1. Download latest **gitbucket.war** from [the release page](https://github.com/gitbucket/gitbucket/releases).
2. Deploy it to the Servlet 3.0 container such as Tomcat 7.x, Jetty 8.x, GlassFish 3.x or higher. 2. Deploy it to the Servlet 3.0 container such as Tomcat 7.x, Jetty 8.x, GlassFish 3.x or higher.
3. Access **http://[hostname]:[port]/gitbucket/** using your web browser. 3. Access **http://[hostname]:[port]/gitbucket/** using your web browser.
@@ -42,7 +42,7 @@ or you can start GitBucket by `java -jar gitbucket.war` without servlet containe
To upgrade GitBucket, only replace gitbucket.war. All GitBucket data is stored in HOME/.gitbucket. So if you want to back up GitBucket data, copy this directory to the other disk. To upgrade GitBucket, only replace gitbucket.war. All GitBucket data is stored in HOME/.gitbucket. So if you want to back up GitBucket data, copy this directory to the other disk.
For Installation on Windows Server with IIS see [this wiki page](https://github.com/takezoe/gitbucket/wiki/Installation-on-IIS-and-Helicontech-Zoo) For Installation on Windows Server with IIS see [this wiki page](https://github.com/gitbucket/gitbucket/wiki/Installation-on-IIS-and-Helicontech-Zoo)
### Mac OS X ### Mac OS X
#### Installing Via Homebrew #### Installing Via Homebrew
@@ -65,7 +65,7 @@ Or, if you don't want/need launchctl, you can just run:
``` ```
#### Manual Installation #### Manual Installation
On OS X, copy the [gitbucket.plist](https://raw.github.com/takezoe/gitbucket/master/contrib/macosx/gitbucket.plist) file to `~/Library/LaunchAgents/` On OS X, generate `gitbucket.plist` by [this script](https://raw.githubusercontent.com/gitbucket/gitbucket/master/contrib/macosx/makePlist) and copy it to `~/Library/LaunchAgents/`
Run the following commands in `Terminal` to Run the following commands in `Terminal` to
@@ -81,18 +81,28 @@ GitBucket has the plug-in system to extend GitBucket from outside of GitBucket.
- [gitbucket-h2-backup-plugin](https://github.com/gitbucket-plugins/gitbucket-h2-backup-plugin) - [gitbucket-h2-backup-plugin](https://github.com/gitbucket-plugins/gitbucket-h2-backup-plugin)
- [gitbucket-desktopnotify-plugin](https://github.com/yoshiyoshifujii/gitbucket-desktopnotify-plugin) - [gitbucket-desktopnotify-plugin](https://github.com/yoshiyoshifujii/gitbucket-desktopnotify-plugin)
- [gitbucket-commitgraphs-plugin](https://github.com/yoshiyoshifujii/gitbucket-commitgraphs-plugin) - [gitbucket-commitgraphs-plugin](https://github.com/yoshiyoshifujii/gitbucket-commitgraphs-plugin)
- [gitbucket-asciidoctor-plugin](https://github.com/lefou/gitbucket-asciidoctor-plugin)
You can find community plugins other than them at [gitbucket community plugins](http://gitbucket-plugins.github.io/). You can find community plugins other than them at [gitbucket community plugins](http://gitbucket-plugins.github.io/).
Support Support
-------- --------
- If you have any question about GitBucket, send it to [gitter room](https://gitter.im/takezoe/gitbucket) before raise an issue. - If you have any question about GitBucket, send it to [gitter room](https://gitter.im/gitbucket/gitbucket) before raise an issue.
- Make sure check whether there is a same question or request in the past.
- When raise a new issue, write subject in **English** at least. - When raise a new issue, write subject in **English** at least.
- We can also support in Japaneses other than English at [gitter room for Japanese](https://gitter.im/takezoe/gitbucket_ja). - We can also support in Japaneses other than English at [gitter room for Japanese](https://gitter.im/gitbucket/gitbucket_ja).
- First priority of GitBucket is easy installation and reproduce GitHub behavior, so we might reject if your request is against it.
Release Notes Release Notes
-------- --------
### 3.8 - 31 Oct 2015
- Moved to GitHub organization
- Omit diff view for large differences
- Repository creation API
- Render url as link in repository description
- Expand attachable file types
### 3.7 - 3 Oct 2015 ### 3.7 - 3 Oct 2015
- Markdown processor has been switched to [markedj](https://github.com/gitbucket/markedj) from pegdown - Markdown processor has been switched to [markedj](https://github.com/gitbucket/markedj) from pegdown
- Clone in desktop button - Clone in desktop button

View File

@@ -38,7 +38,7 @@ createDir "$GITBUCKET_DIR"
createDir "$GITBUCKET_LOG_DIR" createDir "$GITBUCKET_LOG_DIR"
echo "Fetching GitBucket v$GITBUCKET_VERSION and saving as $GITBUCKET_WAR_FILE" echo "Fetching GitBucket v$GITBUCKET_VERSION and saving as $GITBUCKET_WAR_FILE"
sudo wget -qO "$GITBUCKET_WAR_FILE" https://github.com/takezoe/gitbucket/releases/download/$GITBUCKET_VERSION/gitbucket.war sudo wget -qO "$GITBUCKET_WAR_FILE" https://github.com/gitbucket/gitbucket/releases/download/$GITBUCKET_VERSION/gitbucket.war
sudo rm -f "$GITBUCKET_LOG_DIR/run.log" sudo rm -f "$GITBUCKET_LOG_DIR/run.log"

View File

@@ -3,7 +3,7 @@ Summary: GitHub clone written with Scala.
Version: 2.6 Version: 2.6
Release: 1%{?dist} Release: 1%{?dist}
License: Apache License: Apache
URL: https://github.com/takezoe/gitbucket URL: https://github.com/gitbucket/gitbucket
Group: System/Servers Group: System/Servers
Source0: %{name}.war Source0: %{name}.war
Source1: %{name}.init Source1: %{name}.init

View File

@@ -2,7 +2,7 @@ Automatic Schema Updating
======== ========
GitBucket uses H2 database to manage project and account data. GitBucket updates database schema automatically in the first run after the upgrading. GitBucket uses H2 database to manage project and account data. GitBucket updates database schema automatically in the first run after the upgrading.
To release a new version of GitBucket, add the version definition to the [gitbucket.core.servlet.AutoUpdate](https://github.com/takezoe/gitbucket/blob/master/src/main/scala/gitbucket/core/servlet/AutoUpdate.scala) at first. To release a new version of GitBucket, add the version definition to the [gitbucket.core.servlet.AutoUpdate](https://github.com/gitbucket/gitbucket/blob/master/src/main/scala/gitbucket/core/servlet/AutoUpdate.scala) at first.
```scala ```scala
object AutoUpdate { object AutoUpdate {
@@ -16,7 +16,7 @@ object AutoUpdate {
... ...
``` ```
Next, add a SQL file which updates database schema into [/src/main/resources/update/](https://github.com/takezoe/gitbucket/tree/master/src/main/resources/update) as ```MAJOR_MINOR.sql```. Next, add a SQL file which updates database schema into [/src/main/resources/update/](https://github.com/gitbucket/gitbucket/tree/master/src/main/resources/update) as ```MAJOR_MINOR.sql```.
GitBucket stores the current version to ```GITBUCKET_HOME/version``` and checks it at start-up. If the stored version differs from the actual version, it executes differences of SQL files between the stored version and the actual version. And ```GITBUCKET_HOME/version``` is updated by the actual version. GitBucket stores the current version to ```GITBUCKET_HOME/version``` and checks it at start-up. If the stored version differs from the actual version, it executes differences of SQL files between the stored version and the actual version. And ```GITBUCKET_HOME/version``` is updated by the actual version.

View File

@@ -10,7 +10,7 @@ import sbtassembly.AssemblyKeys._
object MyBuild extends Build { object MyBuild extends Build {
val Organization = "gitbucket" val Organization = "gitbucket"
val Name = "gitbucket" val Name = "gitbucket"
val Version = "3.7.0" val Version = "3.8.0"
val ScalaVersion = "2.11.6" val ScalaVersion = "2.11.6"
val ScalatraVersion = "2.3.1" val ScalatraVersion = "2.3.1"
@@ -51,11 +51,12 @@ object MyBuild extends Build {
"org.json4s" %% "json4s-jackson" % "3.2.11", "org.json4s" %% "json4s-jackson" % "3.2.11",
"jp.sf.amateras" %% "scalatra-forms" % "0.1.0", "jp.sf.amateras" %% "scalatra-forms" % "0.1.0",
"commons-io" % "commons-io" % "2.4", "commons-io" % "commons-io" % "2.4",
"io.github.gitbucket" % "markedj" % "1.0.4-SNAPSHOT", "io.github.gitbucket" % "markedj" % "1.0.4",
"org.apache.commons" % "commons-compress" % "1.9", "org.apache.commons" % "commons-compress" % "1.9",
"org.apache.commons" % "commons-email" % "1.3.3", "org.apache.commons" % "commons-email" % "1.3.3",
"org.apache.httpcomponents" % "httpclient" % "4.3.6", "org.apache.httpcomponents" % "httpclient" % "4.3.6",
"org.apache.sshd" % "apache-sshd" % "0.11.0", "org.apache.sshd" % "apache-sshd" % "0.11.0",
"org.apache.tika" % "tika-core" % "1.10",
"com.typesafe.slick" %% "slick" % "2.1.0", "com.typesafe.slick" %% "slick" % "2.1.0",
"com.novell.ldap" % "jldap" % "2009-10-07", "com.novell.ldap" % "jldap" % "2009-10-07",
"com.h2database" % "h2" % "1.4.180", "com.h2database" % "h2" % "1.4.180",

View File

@@ -128,7 +128,7 @@ INSERT INTO ACCOUNT (
'root@localhost', 'root@localhost',
'dc76e9f0c0006e8f919e0c515c66dbba3982f785', 'dc76e9f0c0006e8f919e0c515c66dbba3982f785',
true, true,
'https://github.com/takezoe/gitbucket', 'https://github.com/gitbucket/gitbucket',
SYSDATE, SYSDATE,
SYSDATE, SYSDATE,
NULL NULL

View File

@@ -20,13 +20,21 @@ case class ApiCommit(
removed: List[String], removed: List[String],
modified: List[String], modified: List[String],
author: ApiPersonIdent, author: ApiPersonIdent,
committer: ApiPersonIdent)(repositoryName:RepositoryName) extends FieldSerializable{ committer: ApiPersonIdent)(repositoryName:RepositoryName, urlIsHtmlUrl: Boolean) extends FieldSerializable{
val url = ApiPath(s"/api/v3/${repositoryName.fullName}/commits/${id}") val url = if(urlIsHtmlUrl){
val html_url = ApiPath(s"/${repositoryName.fullName}/commit/${id}") ApiPath(s"/${repositoryName.fullName}/commit/${id}")
}else{
ApiPath(s"/api/v3/${repositoryName.fullName}/commits/${id}")
}
val html_url = if(urlIsHtmlUrl){
None
}else{
Some(ApiPath(s"/${repositoryName.fullName}/commit/${id}"))
}
} }
object ApiCommit{ object ApiCommit{
def apply(git: Git, repositoryName: RepositoryName, commit: CommitInfo): ApiCommit = { def apply(git: Git, repositoryName: RepositoryName, commit: CommitInfo, urlIsHtmlUrl: Boolean = false): ApiCommit = {
val diffs = JGitUtil.getDiffs(git, commit.id, false) val diffs = JGitUtil.getDiffs(git, commit.id, false)
ApiCommit( ApiCommit(
id = commit.id, id = commit.id,
@@ -43,6 +51,7 @@ object ApiCommit{
}, },
author = ApiPersonIdent.author(commit), author = ApiPersonIdent.author(commit),
committer = ApiPersonIdent.committer(commit) committer = ApiPersonIdent.committer(commit)
)(repositoryName) )(repositoryName, urlIsHtmlUrl)
} }
def forPushPayload(git: Git, repositoryName: RepositoryName, commit: CommitInfo): ApiCommit = apply(git, repositoryName, commit, true)
} }

View File

@@ -1,39 +0,0 @@
package gitbucket.core.api
import gitbucket.core.util.JGitUtil
import gitbucket.core.util.JGitUtil.CommitInfo
import gitbucket.core.util.RepositoryName
import org.eclipse.jgit.diff.DiffEntry
import org.eclipse.jgit.api.Git
import java.util.Date
/**
* https://developer.github.com/v3/activity/events/types/#pushevent
*/
case class ApiPushCommit(
id: String,
message: String,
timestamp: Date,
added: List[String],
removed: List[String],
modified: List[String],
author: ApiPersonIdent,
committer: ApiPersonIdent)(repositoryName:RepositoryName) extends FieldSerializable {
val url = ApiPath(s"/${repositoryName.fullName}/commit/${id}")
}
object ApiPushCommit{
def apply(commit: ApiCommit, repositoryName: RepositoryName): ApiPushCommit = ApiPushCommit(
id = commit.id,
message = commit.message,
timestamp = commit.timestamp,
added = commit.added,
removed = commit.removed,
modified = commit.modified,
author = commit.author,
committer = commit.committer)(repositoryName)
def apply(git: Git, repositoryName: RepositoryName, commit: CommitInfo): ApiPushCommit =
ApiPushCommit(ApiCommit(git, repositoryName, commit), repositoryName)
}

View File

@@ -13,10 +13,14 @@ case class ApiRepository(
forks: Int, forks: Int,
`private`: Boolean, `private`: Boolean,
default_branch: String, default_branch: String,
owner: ApiUser) { owner: ApiUser)(urlIsHtmlUrl: Boolean) {
val forks_count = forks val forks_count = forks
val watchers_count = watchers val watchers_count = watchers
val url = ApiPath(s"/api/v3/repos/${full_name}") val url = if(urlIsHtmlUrl){
ApiPath(s"/${full_name}")
}else{
ApiPath(s"/api/v3/repos/${full_name}")
}
val http_url = ApiPath(s"/git/${full_name}.git") val http_url = ApiPath(s"/git/${full_name}.git")
val clone_url = ApiPath(s"/git/${full_name}.git") val clone_url = ApiPath(s"/git/${full_name}.git")
val html_url = ApiPath(s"/${full_name}") val html_url = ApiPath(s"/${full_name}")
@@ -27,7 +31,8 @@ object ApiRepository{
repository: Repository, repository: Repository,
owner: ApiUser, owner: ApiUser,
forkedCount: Int =0, forkedCount: Int =0,
watchers: Int = 0): ApiRepository = watchers: Int = 0,
urlIsHtmlUrl: Boolean = false): ApiRepository =
ApiRepository( ApiRepository(
name = repository.repositoryName, name = repository.repositoryName,
full_name = s"${repository.userName}/${repository.repositoryName}", full_name = s"${repository.userName}/${repository.repositoryName}",
@@ -37,7 +42,7 @@ object ApiRepository{
`private` = repository.isPrivate, `private` = repository.isPrivate,
default_branch = repository.defaultBranch, default_branch = repository.defaultBranch,
owner = owner owner = owner
) )(urlIsHtmlUrl)
def apply(repositoryInfo: RepositoryInfo, owner: ApiUser): ApiRepository = def apply(repositoryInfo: RepositoryInfo, owner: ApiUser): ApiRepository =
ApiRepository(repositoryInfo.repository, owner, forkedCount=repositoryInfo.forkedCount) ApiRepository(repositoryInfo.repository, owner, forkedCount=repositoryInfo.forkedCount)
@@ -45,4 +50,7 @@ object ApiRepository{
def apply(repositoryInfo: RepositoryInfo, owner: Account): ApiRepository = def apply(repositoryInfo: RepositoryInfo, owner: Account): ApiRepository =
this(repositoryInfo.repository, ApiUser(owner)) this(repositoryInfo.repository, ApiUser(owner))
def forPushPayload(repositoryInfo: RepositoryInfo, owner: ApiUser): ApiRepository =
ApiRepository(repositoryInfo.repository, owner, forkedCount=repositoryInfo.forkedCount, urlIsHtmlUrl=true)
} }

View File

@@ -0,0 +1,19 @@
package gitbucket.core.api
/**
* https://developer.github.com/v3/repos/#create
* api form
*/
case class CreateARepository(
name: String,
description: Option[String],
`private`: Boolean = false,
auto_init: Boolean = false
) {
def isValid: Boolean = {
name.length<=40 &&
name.matches("[a-zA-Z0-9\\-\\+_.]+") &&
!name.startsWith("_") &&
!name.startsWith("-")
}
}

View File

@@ -366,56 +366,7 @@ trait AccountControllerBase extends AccountManagementControllerBase {
post("/new", newRepositoryForm)(usersOnly { form => post("/new", newRepositoryForm)(usersOnly { form =>
LockUtil.lock(s"${form.owner}/${form.name}"){ LockUtil.lock(s"${form.owner}/${form.name}"){
if(getRepository(form.owner, form.name, context.baseUrl).isEmpty){ if(getRepository(form.owner, form.name, context.baseUrl).isEmpty){
val ownerAccount = getAccountByUserName(form.owner).get createRepository(form.owner, form.name, form.description, form.isPrivate, form.createReadme)
val loginAccount = context.loginAccount.get
val loginUserName = loginAccount.userName
// Insert to the database at first
createRepository(form.name, form.owner, form.description, form.isPrivate)
// Add collaborators for group repository
if(ownerAccount.isGroupAccount){
getGroupMembers(form.owner).foreach { member =>
addCollaborator(form.owner, form.name, member.userName)
}
}
// Insert default labels
insertDefaultLabels(form.owner, form.name)
// Create the actual repository
val gitdir = getRepositoryDir(form.owner, form.name)
JGitUtil.initRepository(gitdir)
if(form.createReadme){
using(Git.open(gitdir)){ git =>
val builder = DirCache.newInCore.builder()
val inserter = git.getRepository.newObjectInserter()
val headId = git.getRepository.resolve(Constants.HEAD + "^{commit}")
val content = if(form.description.nonEmpty){
form.name + "\n" +
"===============\n" +
"\n" +
form.description.get
} else {
form.name + "\n" +
"===============\n"
}
builder.add(JGitUtil.createDirCacheEntry("README.md", FileMode.REGULAR_FILE,
inserter.insert(Constants.OBJ_BLOB, content.getBytes("UTF-8"))))
builder.finish()
JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter),
Constants.HEAD, loginAccount.fullName, loginAccount.mailAddress, "Initial commit")
}
}
// Create Wiki repository
createWikiRepository(loginAccount, form.owner, form.name)
// Record activity
recordCreateRepositoryActivity(form.owner, form.name, loginUserName)
} }
// redirect to the repository // redirect to the repository
@@ -423,6 +374,54 @@ trait AccountControllerBase extends AccountManagementControllerBase {
} }
}) })
/**
* Create user repository
* https://developer.github.com/v3/repos/#create
*/
post("/api/v3/user/repos")(usersOnly {
val owner = context.loginAccount.get.userName
(for {
data <- extractFromJsonBody[CreateARepository] if data.isValid
} yield {
LockUtil.lock(s"${owner}/${data.name}") {
if(getRepository(owner, data.name, context.baseUrl).isEmpty){
createRepository(owner, data.name, data.description, data.`private`, data.auto_init)
val repository = getRepository(owner, data.name, context.baseUrl).get
JsonFormat(ApiRepository(repository, ApiUser(getAccountByUserName(owner).get)))
} else {
ApiError(
"A repository with this name already exists on this account",
Some("https://developer.github.com/v3/repos/#create")
)
}
}
}) getOrElse NotFound
})
/**
* Create group repository
* https://developer.github.com/v3/repos/#create
*/
post("/api/v3/orgs/:org/repos")(managersOnly {
val groupName = params("org")
(for {
data <- extractFromJsonBody[CreateARepository] if data.isValid
} yield {
LockUtil.lock(s"${groupName}/${data.name}") {
if(getRepository(groupName, data.name, context.baseUrl).isEmpty){
createRepository(groupName, data.name, data.description, data.`private`, data.auto_init)
val repository = getRepository(groupName, data.name, context.baseUrl).get
JsonFormat(ApiRepository(repository, ApiUser(getAccountByUserName(groupName).get)))
} else {
ApiError(
"A repository with this name already exists for this group",
Some("https://developer.github.com/v3/repos/#create")
)
}
}
}) getOrElse NotFound
})
get("/:owner/:repository/fork")(readableUsersOnly { repository => get("/:owner/:repository/fork")(readableUsersOnly { repository =>
val loginAccount = context.loginAccount.get val loginAccount = context.loginAccount.get
val loginUserName = loginAccount.userName val loginUserName = loginAccount.userName
@@ -496,6 +495,59 @@ trait AccountControllerBase extends AccountManagementControllerBase {
} }
}) })
private def createRepository(owner: String, name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean) {
val ownerAccount = getAccountByUserName(owner).get
val loginAccount = context.loginAccount.get
val loginUserName = loginAccount.userName
// Insert to the database at first
createRepository(name, owner, description, isPrivate)
// Add collaborators for group repository
if(ownerAccount.isGroupAccount){
getGroupMembers(owner).foreach { member =>
addCollaborator(owner, name, member.userName)
}
}
// Insert default labels
insertDefaultLabels(owner, name)
// Create the actual repository
val gitdir = getRepositoryDir(owner, name)
JGitUtil.initRepository(gitdir)
if(createReadme){
using(Git.open(gitdir)){ git =>
val builder = DirCache.newInCore.builder()
val inserter = git.getRepository.newObjectInserter()
val headId = git.getRepository.resolve(Constants.HEAD + "^{commit}")
val content = if(description.nonEmpty){
name + "\n" +
"===============\n" +
"\n" +
description.get
} else {
name + "\n" +
"===============\n"
}
builder.add(JGitUtil.createDirCacheEntry("README.md", FileMode.REGULAR_FILE,
inserter.insert(Constants.OBJ_BLOB, content.getBytes("UTF-8"))))
builder.finish()
JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter),
Constants.HEAD, loginAccount.fullName, loginAccount.mailAddress, "Initial commit")
}
}
// Create Wiki repository
createWikiRepository(loginAccount, owner, name)
// Record activity
recordCreateRepositoryActivity(owner, name, loginUserName)
}
private def insertDefaultLabels(userName: String, repositoryName: String): Unit = { private def insertDefaultLabels(userName: String, repositoryName: String): Unit = {
createLabel(userName, repositoryName, "bug", "fc2929") createLabel(userName, repositoryName, "bug", "fc2929")
createLabel(userName, repositoryName, "duplicate", "cccccc") createLabel(userName, repositoryName, "duplicate", "cccccc")

View File

@@ -17,22 +17,22 @@ class FileUploadController extends ScalatraServlet with FileUploadSupport {
configureMultipartHandling(MultipartConfig(maxFileSize = Some(3 * 1024 * 1024))) configureMultipartHandling(MultipartConfig(maxFileSize = Some(3 * 1024 * 1024)))
post("/image"){ post("/image"){
execute { (file, fileId) => execute({ (file, fileId) =>
FileUtils.writeByteArrayToFile(new java.io.File(getTemporaryDir(session.getId), fileId), file.get) FileUtils.writeByteArrayToFile(new java.io.File(getTemporaryDir(session.getId), fileId), file.get)
session += Keys.Session.Upload(fileId) -> file.name session += Keys.Session.Upload(fileId) -> file.name
} }, FileUtil.isImage)
} }
post("/image/:owner/:repository"){ post("/file/:owner/:repository"){
execute { (file, fileId) => execute({ (file, fileId) =>
FileUtils.writeByteArrayToFile(new java.io.File( FileUtils.writeByteArrayToFile(new java.io.File(
getAttachedDir(params("owner"), params("repository")), getAttachedDir(params("owner"), params("repository")),
fileId + "." + FileUtil.getExtension(file.getName)), file.get) fileId + "." + FileUtil.getExtension(file.getName)), file.get)
} }, FileUtil.isUploadableType)
} }
private def execute(f: (FileItem, String) => Unit) = fileParams.get("file") match { private def execute(f: (FileItem, String) => Unit, mimeTypeChcker: (String) => Boolean) = fileParams.get("file") match {
case Some(file) if(FileUtil.isImage(file.name)) => case Some(file) if(mimeTypeChcker(file.name)) =>
defining(FileUtil.generateFileId){ fileId => defining(FileUtil.generateFileId){ fileId =>
f(file, fileId) f(file, fileId)

View File

@@ -332,6 +332,7 @@ trait IssuesControllerBase extends ControllerBase {
(Directory.getAttachedDir(repository.owner, repository.name) match { (Directory.getAttachedDir(repository.owner, repository.name) match {
case dir if(dir.exists && dir.isDirectory) => case dir if(dir.exists && dir.isDirectory) =>
dir.listFiles.find(_.getName.startsWith(params("file") + ".")).map { file => dir.listFiles.find(_.getName.startsWith(params("file") + ".")).map { file =>
response.setHeader("Content-Disposition", f"""inline; filename=${file.getName}""")
RawData(FileUtil.getMimeType(file.getName), file) RawData(FileUtil.getMimeType(file.getName), file)
} }
case _ => None case _ => None
@@ -352,6 +353,7 @@ trait IssuesControllerBase extends ControllerBase {
} }
} }
// TODO Same method exists in PullRequestController. Should it moved to IssueService?
private def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String) = { private def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String) = {
StringUtil.extractIssueId(message).foreach { issueId => StringUtil.extractIssueId(message).foreach { issueId =>
val content = fromIssue.issueId + ":" + fromIssue.title val content = fromIssue.issueId + ":" + fromIssue.title

View File

@@ -294,6 +294,9 @@ trait PullRequestsControllerBase extends ControllerBase {
originRepositoryName <- if(originOwner == forkedOwner) { originRepositoryName <- if(originOwner == forkedOwner) {
// Self repository // Self repository
Some(forkedRepository.name) Some(forkedRepository.name)
} else if(forkedRepository.repository.originUserName.isEmpty){
// when ForkedRepository is the original repository
getForkedRepositories(forkedRepository.owner, forkedRepository.name).find(_._1 == originOwner).map(_._2)
} else if(Some(originOwner) == forkedRepository.repository.originUserName){ } else if(Some(originOwner) == forkedRepository.repository.originUserName){
// Original repository // Original repository
forkedRepository.repository.originRepositoryName forkedRepository.repository.originRepositoryName
@@ -436,8 +439,11 @@ trait PullRequestsControllerBase extends ControllerBase {
// call web hook // call web hook
callPullRequestWebHook("opened", repository, issueId, context.baseUrl, context.loginAccount.get) callPullRequestWebHook("opened", repository, issueId, context.baseUrl, context.loginAccount.get)
// notifications
getIssue(owner, name, issueId.toString) foreach { issue => getIssue(owner, name, issueId.toString) foreach { issue =>
// extract references and create refer comment
createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse(""))
// notifications
Notifier().toNotify(repository, issue, form.content.getOrElse("")){ Notifier().toNotify(repository, issue, form.content.getOrElse("")){
Notifier.msgPullRequest(s"${context.baseUrl}/${owner}/${name}/pull/${issueId}") Notifier.msgPullRequest(s"${context.baseUrl}/${owner}/${name}/pull/${issueId}")
} }
@@ -447,6 +453,19 @@ trait PullRequestsControllerBase extends ControllerBase {
} }
}) })
// TODO Same method exists in IssueController. Should it moved to IssueService?
private def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String) = {
StringUtil.extractIssueId(message).foreach { issueId =>
val content = fromIssue.issueId + ":" + fromIssue.title
if(getIssue(owner, repository, issueId).isDefined){
// Not add if refer comment already exist.
if(!getComments(owner, repository, issueId.toInt).exists { x => x.action == "refer" && x.content == content }) {
createComment(owner, repository, context.loginAccount.get.userName, issueId.toInt, content, "refer")
}
}
}
}
/** /**
* Parses branch identifier and extracts owner and branch name as tuple. * Parses branch identifier and extracts owner and branch name as tuple.
* *

View File

@@ -15,6 +15,7 @@ import org.scalatra.i18n.Messages
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.Constants import org.eclipse.jgit.lib.Constants
import scala.util.{Success, Failure} import scala.util.{Success, Failure}
import org.eclipse.jgit.lib.ObjectId
class RepositorySettingsController extends RepositorySettingsControllerBase class RepositorySettingsController extends RepositorySettingsControllerBase
@@ -182,11 +183,21 @@ trait RepositorySettingsControllerBase extends ControllerBase {
import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.ExecutionContext.Implicits.global
val url = params("url") val url = params("url")
val commits = if(repository.commitCount == 0) List.empty else git.log val dummyPayload = {
.add(git.getRepository.resolve(repository.repository.defaultBranch)) val ownerAccount = getAccountByUserName(repository.owner).get
.setMaxCount(3) val commits = if(repository.commitCount == 0) List.empty else git.log
.call.iterator.asScala.map(new CommitInfo(_)) .add(git.getRepository.resolve(repository.repository.defaultBranch))
val ownerAccount = getAccountByUserName(repository.owner).get .setMaxCount(4)
.call.iterator.asScala.map(new CommitInfo(_)).toList
val pushedCommit = commits.drop(1)
WebHookPushPayload(git, ownerAccount, "refs/heads/" + repository.repository.defaultBranch, repository, pushedCommit, ownerAccount,
oldId = commits.lastOption.map(_.id).map(ObjectId.fromString).getOrElse(ObjectId.zeroId()),
newId = commits.headOption.map(_.id).map(ObjectId.fromString).getOrElse(ObjectId.zeroId()))
}
val dummyWebHookInfo = WebHook(repository.owner, repository.name, url)
val (webHook, json, reqFuture, resFuture) = callWebHook(WebHook.Push, List(dummyWebHookInfo), dummyPayload).head
def headers(h: Array[org.apache.http.Header]): Array[Array[String]] = h.map{ h => Array(h.getName, h.getValue) } def headers(h: Array[org.apache.http.Header]): Array[Array[String]] = h.map{ h => Array(h.getName, h.getValue) }
val toErrorMap:PartialFunction[Throwable, Map[String,String]] = { val toErrorMap:PartialFunction[Throwable, Map[String,String]] = {
case e:java.net.UnknownHostException => Map("error"-> ("Unknown host "+ e.getMessage)) case e:java.net.UnknownHostException => Map("error"-> ("Unknown host "+ e.getMessage))
@@ -194,10 +205,6 @@ trait RepositorySettingsControllerBase extends ControllerBase {
case e:org.apache.http.client.ClientProtocolException => Map("error"-> ("invalid url")) case e:org.apache.http.client.ClientProtocolException => Map("error"-> ("invalid url"))
case NonFatal(e) => Map("error"-> (e.getClass + " "+ e.getMessage)) case NonFatal(e) => Map("error"-> (e.getClass + " "+ e.getMessage))
} }
val (webHook, json, reqFuture, resFuture) = callWebHook(WebHook.Push,
List(WebHook(repository.owner, repository.name, url)),
WebHookPushPayload(git, ownerAccount, "refs/heads/" + repository.repository.defaultBranch, repository, commits.toList, ownerAccount)
).head
contentType = formats("json") contentType = formats("json")
var result = Map( var result = Map(
"url" -> url, "url" -> url,

View File

@@ -293,8 +293,12 @@ trait RepositoryViewerControllerBase extends ControllerBase {
getPathObjectId(git, path, revCommit).map { objectId => getPathObjectId(git, path, revCommit).map { objectId =>
if(raw){ if(raw){
// Download // Download
JGitUtil.getContentFromId(git, objectId, true).map { bytes => JGitUtil.getObjectLoaderFromId(git, objectId){ loader =>
RawData("application/octet-stream", bytes) //RawData("application/octet-stream", bytes)
contentType = "application/octet-stream"
response.setContentLength(loader.getSize.toInt)
loader.copyTo(response.getOutputStream)
()
} getOrElse NotFound } getOrElse NotFound
} else { } else {
html.blob(id, repository, path.split("/").toList, html.blob(id, repository, path.split("/").toList,
@@ -665,7 +669,8 @@ trait RepositoryViewerControllerBase extends ControllerBase {
val commit = new JGitUtil.CommitInfo(JGitUtil.getRevCommitFromId(git, commitId)) val commit = new JGitUtil.CommitInfo(JGitUtil.getRevCommitFromId(git, commitId))
callWebHookOf(repository.owner, repository.name, WebHook.Push) { callWebHookOf(repository.owner, repository.name, WebHook.Push) {
getAccountByUserName(repository.owner).map{ ownerAccount => getAccountByUserName(repository.owner).map{ ownerAccount =>
WebHookPushPayload(git, loginAccount, headName, repository, List(commit), ownerAccount) WebHookPushPayload(git, loginAccount, headName, repository, List(commit), ownerAccount,
oldId = headTip, newId = commitId)
} }
} }
} }

View File

@@ -92,7 +92,7 @@ trait IssuesService {
def getCommitStatues(issueList:Seq[(String, String, Int)])(implicit s: Session) :Map[(String, String, Int), CommitStatusInfo] ={ def getCommitStatues(issueList:Seq[(String, String, Int)])(implicit s: Session) :Map[(String, String, Int), CommitStatusInfo] ={
if(issueList.isEmpty){ if(issueList.isEmpty){
Map.empty Map.empty
}else{ } else {
import scala.slick.jdbc._ import scala.slick.jdbc._
val issueIdQuery = issueList.map(i => "(PR.USER_NAME=? AND PR.REPOSITORY_NAME=? AND PR.ISSUE_ID=?)").mkString(" OR ") val issueIdQuery = issueList.map(i => "(PR.USER_NAME=? AND PR.REPOSITORY_NAME=? AND PR.ISSUE_ID=?)").mkString(" OR ")
implicit val qset = SetParameter[Seq[(String, String, Int)]] { implicit val qset = SetParameter[Seq[(String, String, Int)]] {

View File

@@ -66,10 +66,6 @@ trait RepositoryService { self: AccountService =>
(t.parentUserName === oldUserName.bind) && (t.parentRepositoryName === oldRepositoryName.bind) (t.parentUserName === oldUserName.bind) && (t.parentRepositoryName === oldRepositoryName.bind)
}.map { t => t.originUserName -> t.originRepositoryName }.update(newUserName, newRepositoryName) }.map { t => t.originUserName -> t.originRepositoryName }.update(newUserName, newRepositoryName)
PullRequests.filter { t =>
t.requestRepositoryName === oldRepositoryName.bind
}.map { t => t.requestUserName -> t.requestRepositoryName }.update(newUserName, newRepositoryName)
// Updates activity fk before deleting repository because activity is sorted by activityId // Updates activity fk before deleting repository because activity is sorted by activityId
// and it can't be changed by deleting-and-inserting record. // and it can't be changed by deleting-and-inserting record.
Activities.filter(_.byRepository(oldUserName, oldRepositoryName)).list.foreach { activity => Activities.filter(_.byRepository(oldUserName, oldRepositoryName)).list.foreach { activity =>
@@ -98,6 +94,11 @@ trait RepositoryService { self: AccountService =>
CommitComments.insertAll(commitComments.map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) CommitComments.insertAll(commitComments.map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*)
CommitStatuses.insertAll(commitStatuses.map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) CommitStatuses.insertAll(commitStatuses.map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*)
// Update source repository of pull requests
PullRequests.filter { t =>
(t.requestUserName === oldUserName.bind) && (t.requestRepositoryName === oldRepositoryName.bind)
}.map { t => t.requestUserName -> t.requestRepositoryName }.update(newUserName, newRepositoryName)
// Convert labelId // Convert labelId
val oldLabelMap = labels.map(x => (x.labelId, x.labelName)).toMap val oldLabelMap = labels.map(x => (x.labelId, x.labelName)).toMap
val newLabelMap = Labels.filter(_.byRepository(newUserName, newRepositoryName)).map(x => (x.labelName, x.labelId)).list.toMap val newLabelMap = Labels.filter(_.byRepository(newUserName, newRepositoryName)).map(x => (x.labelName, x.labelId)).list.toMap

View File

@@ -12,6 +12,7 @@ import org.apache.http.NameValuePair
import org.apache.http.client.entity.UrlEncodedFormEntity import org.apache.http.client.entity.UrlEncodedFormEntity
import org.apache.http.message.BasicNameValuePair import org.apache.http.message.BasicNameValuePair
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.ObjectId
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import scala.concurrent._ import scala.concurrent._
import org.apache.http.HttpRequest import org.apache.http.HttpRequest
@@ -244,21 +245,33 @@ object WebHookService {
case class WebHookPushPayload( case class WebHookPushPayload(
pusher: ApiUser, pusher: ApiUser,
ref: String, ref: String,
commits: List[ApiPushCommit], before: String,
after: String,
commits: List[ApiCommit],
repository: ApiRepository repository: ApiRepository
) extends WebHookPayload ) extends FieldSerializable with WebHookPayload {
val compare = commits.size match {
case 0 => ApiPath(s"/${repository.full_name}") // maybe test hook on un-initalied repository
case 1 => ApiPath(s"/${repository.full_name}/commit/${after}")
case _ if before.filterNot(_=='0').isEmpty => ApiPath(s"/${repository.full_name}/compare/${commits.head.id}^...${after}")
case _ => ApiPath(s"/${repository.full_name}/compare/${before}...${after}")
}
val head_commit = commits.lastOption
}
object WebHookPushPayload { object WebHookPushPayload {
def apply(git: Git, pusher: Account, refName: String, repositoryInfo: RepositoryInfo, def apply(git: Git, pusher: Account, refName: String, repositoryInfo: RepositoryInfo,
commits: List[CommitInfo], repositoryOwner: Account): WebHookPushPayload = commits: List[CommitInfo], repositoryOwner: Account,
newId: ObjectId, oldId: ObjectId): WebHookPushPayload =
WebHookPushPayload( WebHookPushPayload(
ApiUser(pusher), pusher = ApiUser(pusher),
refName, ref = refName,
commits.map{ commit => ApiPushCommit(git, RepositoryName(repositoryInfo), commit) }, before = ObjectId.toString(oldId),
ApiRepository( after = ObjectId.toString(newId),
commits = commits.map{ commit => ApiCommit.forPushPayload(git, RepositoryName(repositoryInfo), commit) },
repository = ApiRepository.forPushPayload(
repositoryInfo, repositoryInfo,
owner= ApiUser(repositoryOwner) owner= ApiUser(repositoryOwner))
)
) )
} }

View File

@@ -203,7 +203,8 @@ class CommitLogHook(owner: String, repository: String, pusher: String, baseUrl:
callWebHookOf(owner, repository, WebHook.Push){ callWebHookOf(owner, repository, WebHook.Push){
for(pusherAccount <- getAccountByUserName(pusher); for(pusherAccount <- getAccountByUserName(pusher);
ownerAccount <- getAccountByUserName(owner)) yield { ownerAccount <- getAccountByUserName(owner)) yield {
WebHookPushPayload(git, pusherAccount, command.getRefName, repositoryInfo, newCommits, ownerAccount) WebHookPushPayload(git, pusherAccount, command.getRefName, repositoryInfo, newCommits, ownerAccount,
newId = command.getNewId(), oldId = command.getOldId())
} }
} }
} }

View File

@@ -14,7 +14,7 @@ object SshServer {
private def configure(port: Int, baseUrl: String) = { private def configure(port: Int, baseUrl: String) = {
server.setPort(port) server.setPort(port)
server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(s"${Directory.GitBucketHome}/gitbucket.ser")) server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(s"${Directory.GitBucketHome}/gitbucket.ser", "RSA"))
server.setPublickeyAuthenticator(new PublicKeyAuthenticator) server.setPublickeyAuthenticator(new PublicKeyAuthenticator)
server.setCommandFactory(new GitCommandFactory(baseUrl)) server.setCommandFactory(new GitCommandFactory(baseUrl))
server.setShellFactory(new NoShell) server.setShellFactory(new NoShell)

View File

@@ -1,7 +1,7 @@
package gitbucket.core.util package gitbucket.core.util
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import java.net.URLConnection import org.apache.tika.Tika
import java.io.File import java.io.File
import ControlUtil._ import ControlUtil._
import scala.util.Random import scala.util.Random
@@ -9,8 +9,8 @@ import scala.util.Random
object FileUtil { object FileUtil {
def getMimeType(name: String): String = def getMimeType(name: String): String =
defining(URLConnection.getFileNameMap()){ fileNameMap => defining(new Tika()){ tika =>
fileNameMap.getContentTypeFor(name) match { tika.detect(name) match {
case null => "application/octet-stream" case null => "application/octet-stream"
case mimeType => mimeType case mimeType => mimeType
} }
@@ -28,6 +28,8 @@ object FileUtil {
def isImage(name: String): Boolean = getMimeType(name).startsWith("image/") def isImage(name: String): Boolean = getMimeType(name).startsWith("image/")
def isUploadableType(name: String): Boolean = mimeTypeWhiteList contains getMimeType(name)
def isLarge(size: Long): Boolean = (size > 1024 * 1000) def isLarge(size: Long): Boolean = (size > 1024 * 1000)
def isText(content: Array[Byte]): Boolean = !content.contains(0) def isText(content: Array[Byte]): Boolean = !content.contains(0)
@@ -50,4 +52,14 @@ object FileUtil {
FileUtils.deleteDirectory(dir) FileUtils.deleteDirectory(dir)
} }
} }
val mimeTypeWhiteList: Array[String] = Array(
"application/pdf",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"image/gif",
"image/jpeg",
"image/png",
"text/plain")
} }

View File

@@ -65,6 +65,7 @@ object Implicits {
def paths: Array[String] = (request.getRequestURI.substring(request.getContextPath.length + 1) match{ def paths: Array[String] = (request.getRequestURI.substring(request.getContextPath.length + 1) match{
case path if path.startsWith("api/v3/repos/") => path.substring(13/* "/api/v3/repos".length */) case path if path.startsWith("api/v3/repos/") => path.substring(13/* "/api/v3/repos".length */)
case path if path.startsWith("api/v3/orgs/") => path.substring(12/* "/api/v3/orgs".length */)
case path => path case path => path
}).split("/") }).split("/")

View File

@@ -100,8 +100,18 @@ object JGitUtil {
def isDifferentFromAuthor: Boolean = authorName != committerName || authorEmailAddress != committerEmailAddress def isDifferentFromAuthor: Boolean = authorName != committerName || authorEmailAddress != committerEmailAddress
} }
case class DiffInfo(changeType: ChangeType, oldPath: String, newPath: String, oldContent: Option[String], newContent: Option[String], case class DiffInfo(
oldIsImage: Boolean, newIsImage: Boolean, oldObjectId: Option[String], newObjectId: Option[String]) changeType: ChangeType,
oldPath: String,
newPath: String,
oldContent: Option[String],
newContent: Option[String],
oldIsImage: Boolean,
newIsImage: Boolean,
oldObjectId: Option[String],
newObjectId: Option[String],
tooLarge: Boolean
)
/** /**
* The file content data for the file content view of the repository viewer. * The file content data for the file content view of the repository viewer.
@@ -495,11 +505,31 @@ object JGitUtil {
while(treeWalk.next){ while(treeWalk.next){
val newIsImage = FileUtil.isImage(treeWalk.getPathString) val newIsImage = FileUtil.isImage(treeWalk.getPathString)
buffer.append((if(!fetchContent){ buffer.append((if(!fetchContent){
DiffInfo(ChangeType.ADD, null, treeWalk.getPathString, None, None, false, newIsImage, None, Option(treeWalk.getObjectId(0)).map(_.name)) DiffInfo(
changeType = ChangeType.ADD,
oldPath = null,
newPath = treeWalk.getPathString,
oldContent = None,
newContent = None,
oldIsImage = false,
newIsImage = newIsImage,
oldObjectId = None,
newObjectId = Option(treeWalk.getObjectId(0)).map(_.name),
tooLarge = false
)
} else { } else {
DiffInfo(ChangeType.ADD, null, treeWalk.getPathString, None, DiffInfo(
JGitUtil.getContentFromId(git, treeWalk.getObjectId(0), false).filter(FileUtil.isText).map(convertFromByteArray), changeType = ChangeType.ADD,
false, newIsImage, None, Option(treeWalk.getObjectId(0)).map(_.name)) oldPath = null,
newPath = treeWalk.getPathString,
oldContent = None,
newContent = JGitUtil.getContentFromId(git, treeWalk.getObjectId(0), false).filter(FileUtil.isText).map(convertFromByteArray),
oldIsImage = false,
newIsImage = newIsImage,
oldObjectId = None,
newObjectId = Option(treeWalk.getObjectId(0)).map(_.name),
tooLarge = false
)
})) }))
} }
(buffer.toList, None) (buffer.toList, None)
@@ -518,16 +548,52 @@ object JGitUtil {
import scala.collection.JavaConverters._ import scala.collection.JavaConverters._
git.getRepository.getConfig.setString("diff", null, "renames", "copies") git.getRepository.getConfig.setString("diff", null, "renames", "copies")
git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff =>
val oldIsImage = FileUtil.isImage(diff.getOldPath) val diffs = git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala
val newIsImage = FileUtil.isImage(diff.getNewPath) diffs.map { diff =>
if(!fetchContent || oldIsImage || newIsImage){ if(diffs.size > 100){
DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, None, None, oldIsImage, newIsImage, Option(diff.getOldId).map(_.name), Option(diff.getNewId).map(_.name)) DiffInfo(
changeType = diff.getChangeType,
oldPath = diff.getOldPath,
newPath = diff.getNewPath,
oldContent = None,
newContent = None,
oldIsImage = false,
newIsImage = false,
oldObjectId = Option(diff.getOldId).map(_.name),
newObjectId = Option(diff.getNewId).map(_.name),
tooLarge = true
)
} else { } else {
DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, val oldIsImage = FileUtil.isImage(diff.getOldPath)
JGitUtil.getContentFromId(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray), val newIsImage = FileUtil.isImage(diff.getNewPath)
JGitUtil.getContentFromId(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray), if(!fetchContent || oldIsImage || newIsImage){
oldIsImage, newIsImage, Option(diff.getOldId).map(_.name), Option(diff.getNewId).map(_.name)) DiffInfo(
changeType = diff.getChangeType,
oldPath = diff.getOldPath,
newPath = diff.getNewPath,
oldContent = None,
newContent = None,
oldIsImage = oldIsImage,
newIsImage = newIsImage,
oldObjectId = Option(diff.getOldId).map(_.name),
newObjectId = Option(diff.getNewId).map(_.name),
tooLarge = false
)
} else {
DiffInfo(
changeType = diff.getChangeType,
oldPath = diff.getOldPath,
newPath = diff.getNewPath,
oldContent = JGitUtil.getContentFromId(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray),
newContent = JGitUtil.getContentFromId(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray),
oldIsImage = oldIsImage,
newIsImage = newIsImage,
oldObjectId = Option(diff.getOldId).map(_.name),
newObjectId = Option(diff.getNewId).map(_.name),
tooLarge = false
)
}
} }
}.toList }.toList
} }
@@ -713,7 +779,7 @@ object JGitUtil {
def getContentFromId(git: Git, id: ObjectId, fetchLargeFile: Boolean): Option[Array[Byte]] = try { def getContentFromId(git: Git, id: ObjectId, fetchLargeFile: Boolean): Option[Array[Byte]] = try {
using(git.getRepository.getObjectDatabase){ db => using(git.getRepository.getObjectDatabase){ db =>
val loader = db.open(id) val loader = db.open(id)
if(fetchLargeFile == false && FileUtil.isLarge(loader.getSize)){ if(loader.isLarge || (fetchLargeFile == false && FileUtil.isLarge(loader.getSize))){
None None
} else { } else {
Some(loader.getBytes) Some(loader.getBytes)
@@ -723,6 +789,22 @@ object JGitUtil {
case e: MissingObjectException => None case e: MissingObjectException => None
} }
/**
* Get objectLoader of the given object id from the Git repository.
*
* @param git the Git object
* @param id the object id
* @param f the function process ObjectLoader
* @return None if object does not exist
*/
def getObjectLoaderFromId[A](git: Git, id: ObjectId)(f: ObjectLoader => A):Option[A] = try {
using(git.getRepository.getObjectDatabase){ db =>
Some(f(db.open(id)))
}
} catch {
case e: MissingObjectException => None
}
/** /**
* Returns all commit id in the specified repository. * Returns all commit id in the specified repository.
*/ */

View File

@@ -7,13 +7,31 @@ import gitbucket.core.util.Implicits.RichString
trait LinkConverter { self: RequestCache => trait LinkConverter { self: RequestCache =>
/** /**
* Converts issue id, username and commit id to link. * Creates a link to the issue or the pull request from the issue id.
*/ */
protected def convertRefsLinks(value: String, repository: RepositoryService.RepositoryInfo, protected def createIssueLink(repository: RepositoryService.RepositoryInfo, issueId: Int)(implicit context: Context): String = {
val userName = repository.repository.userName
val repositoryName = repository.repository.repositoryName
getIssue(userName, repositoryName, issueId.toString) match {
case Some(issue) if (issue.isPullRequest) =>
s"""<a href="${context.path}/${userName}/${repositoryName}/pull/${issueId}">Pull #${issueId}</a>"""
case Some(_) =>
s"""<a href="${context.path}/${userName}/${repositoryName}/issues/${issueId}">Issue #${issueId}</a>"""
case None =>
s"Unknown #${issueId}"
}
}
/**
* Converts issue id, username and commit id to link in the given text.
*/
protected def convertRefsLinks(text: String, repository: RepositoryService.RepositoryInfo,
issueIdPrefix: String = "#", escapeHtml: Boolean = true)(implicit context: Context): String = { issueIdPrefix: String = "#", escapeHtml: Boolean = true)(implicit context: Context): String = {
// escape HTML tags // escape HTML tags
val escaped = if(escapeHtml) value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;") else value val escaped = if(escapeHtml) text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;") else text
escaped escaped
// convert username/project@SHA to link // convert username/project@SHA to link
@@ -26,10 +44,12 @@ trait LinkConverter { self: RequestCache =>
// convert username/project#Num to link // convert username/project#Num to link
.replaceBy( ("(?<=(^|\\W))([a-zA-Z0-9\\-_]+)/([a-zA-Z0-9\\-_\\.]+)" + issueIdPrefix + "([0-9]+)(?=(\\W|$))").r){ m => .replaceBy( ("(?<=(^|\\W))([a-zA-Z0-9\\-_]+)/([a-zA-Z0-9\\-_\\.]+)" + issueIdPrefix + "([0-9]+)(?=(\\W|$))").r){ m =>
getIssue(m.group(2), m.group(3), m.group(4)) match { getIssue(m.group(2), m.group(3), m.group(4)) match {
case Some(issue) if (issue.isPullRequest) case Some(issue) if (issue.isPullRequest) =>
=> Some( s"""<a href="${context.path}/${m.group(2)}/${m.group(3)}/pull/${m.group(4)}">${m.group(2)}/${m.group(3)}#${m.group(4)}</a>""") Some(s"""<a href="${context.path}/${m.group(2)}/${m.group(3)}/pull/${m.group(4)}">${m.group(2)}/${m.group(3)}#${m.group(4)}</a>""")
case Some(_) => Some( s"""<a href="${context.path}/${m.group(2)}/${m.group(3)}/issues/${m.group(4)}">${m.group(2)}/${m.group(3)}#${m.group(4)}</a>""") case Some(_) =>
case None => Some( s"""${m.group(2)}/${m.group(3)}#${m.group(4)}""") Some(s"""<a href="${context.path}/${m.group(2)}/${m.group(3)}/issues/${m.group(4)}">${m.group(2)}/${m.group(3)}#${m.group(4)}</a>""")
case None =>
Some(s"""${m.group(2)}/${m.group(3)}#${m.group(4)}""")
} }
} }
@@ -43,10 +63,12 @@ trait LinkConverter { self: RequestCache =>
// convert username#Num to link // convert username#Num to link
.replaceBy( ("(?<=(^|\\W))([a-zA-Z0-9\\-_]+)" + issueIdPrefix + "([0-9]+)(?=(\\W|$))").r ) { m => .replaceBy( ("(?<=(^|\\W))([a-zA-Z0-9\\-_]+)" + issueIdPrefix + "([0-9]+)(?=(\\W|$))").r ) { m =>
getIssue(m.group(2), repository.name, m.group(3)) match { getIssue(m.group(2), repository.name, m.group(3)) match {
case Some(issue) if(issue.isPullRequest) case Some(issue) if(issue.isPullRequest) =>
=> Some(s"""<a href="${context.path}/${m.group(2)}/${repository.name}/pull/${m.group(3)}">${m.group(2)}#${m.group(3)}</a>""") Some(s"""<a href="${context.path}/${m.group(2)}/${repository.name}/pull/${m.group(3)}">${m.group(2)}#${m.group(3)}</a>""")
case Some(_) => Some(s"""<a href="${context.path}/${m.group(2)}/${repository.name}/issues/${m.group(3)}">${m.group(2)}#${m.group(3)}</a>""") case Some(_) =>
case None => Some(s"""${m.group(2)}#${m.group(3)}""") Some(s"""<a href="${context.path}/${m.group(2)}/${repository.name}/issues/${m.group(3)}">${m.group(2)}#${m.group(3)}</a>""")
case None =>
Some(s"""${m.group(2)}#${m.group(3)}""")
} }
} }
@@ -54,10 +76,12 @@ trait LinkConverter { self: RequestCache =>
.replaceBy(("(?<=(^|\\W))(GH-|" + issueIdPrefix + ")([0-9]+)(?=(\\W|$))").r){ m => .replaceBy(("(?<=(^|\\W))(GH-|" + issueIdPrefix + ")([0-9]+)(?=(\\W|$))").r){ m =>
val prefix = if(m.group(2) == "issue:") "#" else m.group(2) val prefix = if(m.group(2) == "issue:") "#" else m.group(2)
getIssue(repository.owner, repository.name, m.group(3)) match { getIssue(repository.owner, repository.name, m.group(3)) match {
case Some(issue) if(issue.isPullRequest) case Some(issue) if(issue.isPullRequest) =>
=> Some(s"""<a href="${context.path}/${repository.owner}/${repository.name}/pull/${m.group(3)}">${prefix}${m.group(3)}</a>""") Some(s"""<a href="${context.path}/${repository.owner}/${repository.name}/pull/${m.group(3)}">${prefix}${m.group(3)}</a>""")
case Some(_) => Some(s"""<a href="${context.path}/${repository.owner}/${repository.name}/issues/${m.group(3)}">${prefix}${m.group(3)}</a>""") case Some(_) =>
case None => Some(s"""${m.group(2)}${m.group(3)}""") Some(s"""<a href="${context.path}/${repository.owner}/${repository.name}/issues/${m.group(3)}">${prefix}${m.group(3)}</a>""")
case None =>
Some(s"""${m.group(2)}${m.group(3)}""")
} }
} }

View File

@@ -110,7 +110,7 @@ object Markdown {
} }
override def link(href: String, title: String, text: String): String = { override def link(href: String, title: String, text: String): String = {
super.link(fixUrl(href, true), title, text) super.link(fixUrl(href, false), title, text)
} }
override def image(href: String, title: String, text: String): String = { override def image(href: String, title: String, text: String): String = {

View File

@@ -93,6 +93,10 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
pages: List[String] = Nil)(implicit context: Context): Html = pages: List[String] = Nil)(implicit context: Context): Html =
Html(Markdown.toHtml(value, repository, enableWikiLink, enableRefsLink, true, enableTaskList, hasWritePermission, pages)) Html(Markdown.toHtml(value, repository, enableWikiLink, enableRefsLink, true, enableTaskList, hasWritePermission, pages))
/**
* Render the given source (only markdown is supported in default) as HTML.
* You can test if a file is renderable in this method by [[isRenderable()]].
*/
def renderMarkup(filePath: List[String], fileContent: String, branch: String, def renderMarkup(filePath: List[String], fileContent: String, branch: String,
repository: RepositoryService.RepositoryInfo, repository: RepositoryService.RepositoryInfo,
enableWikiLink: Boolean, enableRefsLink: Boolean, enableAnchor: Boolean)(implicit context: Context): Html = { enableWikiLink: Boolean, enableRefsLink: Boolean, enableAnchor: Boolean)(implicit context: Context): Html = {
@@ -103,10 +107,20 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
renderer.render(RenderRequest(filePath, fileContent, branch, repository, enableWikiLink, enableRefsLink, enableAnchor, context)) renderer.render(RenderRequest(filePath, fileContent, branch, repository, enableWikiLink, enableRefsLink, enableAnchor, context))
} }
/**
* Tests whether the given file is renderable. It's tested by the file extension.
*/
def isRenderable(fileName: String): Boolean = { def isRenderable(fileName: String): Boolean = {
PluginRegistry().renderableExtensions.exists(extension => fileName.toLowerCase.endsWith("." + extension)) PluginRegistry().renderableExtensions.exists(extension => fileName.toLowerCase.endsWith("." + extension))
} }
/**
* Creates a link to the issue or the pull request from the issue id.
*/
def issueLink(repository: RepositoryService.RepositoryInfo, issueId: Int)(implicit context: Context): Html = {
Html(createIssueLink(repository, issueId))
}
/** /**
* Returns &lt;img&gt; which displays the avatar icon for the given user name. * Returns &lt;img&gt; which displays the avatar icon for the given user name.
* This method looks up Gravatar if avatar icon has not been configured in user settings. * This method looks up Gravatar if avatar icon has not been configured in user settings.
@@ -275,4 +289,11 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
case CommitState.ERROR => "Failed" case CommitState.ERROR => "Failed"
case CommitState.FAILURE => "Failed" case CommitState.FAILURE => "Failed"
} }
// This pattern comes from: http://stackoverflow.com/a/4390768/1771641 (extract-url-from-string)
private[this] val detectAndRenderLinksRegex = """(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,13}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))""".r
def detectAndRenderLinks(text: String): Html = {
Html(detectAndRenderLinksRegex.replaceAllIn(text, m => s"""<a href="${m.group(0)}">${m.group(0)}</a>"""))
}
} }

View File

@@ -1,22 +1,24 @@
@(owner: String, repository: String)(textarea: Html)(implicit context: gitbucket.core.controller.Context) @(owner: String, repository: String)(textarea: Html)(implicit context: gitbucket.core.controller.Context)
@import context._ @import context._
@import gitbucket.core.util.FileUtil
<div class="muted attachable"> <div class="muted attachable">
@textarea @textarea
<div class="clickable">Attach images by dragging &amp; dropping, or selecting them.</div> <div class="clickable">Attach images or documents by dragging &amp; dropping, or selecting them.</div>
</div> </div>
@defining("(id=\")([\\w\\-]*)(\")".r.findFirstMatchIn(textarea.body).map(_.group(2))){ textareaId => @defining("(id=\")([\\w\\-]*)(\")".r.findFirstMatchIn(textarea.body).map(_.group(2))){ textareaId =>
<script> <script>
$(function(){ $(function(){
try { try {
$([$('#@textareaId').closest('div')[0], $('#@textareaId').next('div')[0]]).dropzone({ $([$('#@textareaId').closest('div')[0], $('#@textareaId').next('div')[0]]).dropzone({
url: '@path/upload/image/@owner/@repository', url: '@path/upload/file/@owner/@repository',
maxFilesize: 10, maxFilesize: 10,
acceptedFiles: 'image/*', acceptedFiles: @Html(FileUtil.mimeTypeWhiteList.mkString("'", ",", "'")),
dictInvalidFileType: 'Unfortunately, we don\'t support that file type. Try again with a PNG, GIF, or JPG.', dictInvalidFileType: 'Unfortunately, we don\'t support that file type. Try again with a PNG, GIF, JPG, DOCX, PPTX, XLSX, TXT, or PDF.',
previewTemplate: "<div class=\"dz-preview\">\n <div class=\"dz-progress\"><span class=\"dz-upload\" data-dz-uploadprogress>Uploading your images...</span></div>\n <div class=\"dz-error-message\"><span data-dz-errormessage></span></div>\n</div>", previewTemplate: "<div class=\"dz-preview\">\n <div class=\"dz-progress\"><span class=\"dz-upload\" data-dz-uploadprogress>Uploading your files...</span></div>\n <div class=\"dz-error-message\"><span data-dz-errormessage></span></div>\n</div>",
success: function(file, id) { success: function(file, id) {
var images = '\n![' + file.name.split('.')[0] + '](@baseUrl/@owner/@repository/_attached/' + id + ')'; var attachFile = (file.type.match(/image\/.*/) ? '\n![' + file.name.split('.')[0] : '\n[' + file.name) +
$('#@textareaId').val($('#@textareaId').val() + images); '](@baseUrl/@owner/@repository/_attached/' + id + ')';
$('#@textareaId').val($('#@textareaId').val() + attachFile);
$(file.previewElement).prevAll('div.dz-preview').addBack().remove(); $(file.previewElement).prevAll('div.dz-preview').addBack().remove();
} }
}); });

View File

@@ -92,30 +92,38 @@
<td style="padding: 0;"> <td style="padding: 0;">
@if(diff.oldObjectId == diff.newObjectId){ @if(diff.oldObjectId == diff.newObjectId){
<div class="diff-same">File renamed without changes</div> <div class="diff-same">File renamed without changes</div>
} else { @if(diff.newContent != None || diff.oldContent != None){
<div id="diffText-@i" class="diffText"></div>
<textarea id="newText-@i" style="display: none;" data-file-name="@diff.oldPath">@diff.newContent.getOrElse("")</textarea>
<textarea id="oldText-@i" style="display: none;" data-file-name="@diff.newPath">@diff.oldContent.getOrElse("")</textarea>
} else { @if(diff.newIsImage || diff.oldIsImage){
<div class="diff-image-render diff2up">
@if(oldCommitId.isDefined && diff.oldIsImage){
<div class="diff-image-frame diff-old"><img src="@url(repository)/blob/@oldCommitId.get/@diff.oldPath?raw=true" class="diff-image" onload="onLoadedDiffImages(this)" style="display:none" /></div>
} else {
@if(diff.changeType != ChangeType.ADD){
Not supported
}
}
@if(newCommitId.isDefined && diff.newIsImage){
<div class="diff-image-frame diff-new"><img src="@url(repository)/blob/@newCommitId.get/@diff.newPath?raw=true" class="diff-image" onload="onLoadedDiffImages(this)" style="display:none" /></div>
} else {
@if(diff.changeType != ChangeType.DELETE){
Not supported
}
}
</div>
} else { } else {
Not supported @if(diff.newContent != None || diff.oldContent != None){
} } } <div id="diffText-@i" class="diffText"></div>
<textarea id="newText-@i" style="display: none;" data-file-name="@diff.oldPath">@diff.newContent.getOrElse("")</textarea>
<textarea id="oldText-@i" style="display: none;" data-file-name="@diff.newPath">@diff.oldContent.getOrElse("")</textarea>
} else {
@if(diff.newIsImage || diff.oldIsImage){
<div class="diff-image-render diff2up">@diff.oldIsImage @diff.newIsImage
@if(oldCommitId.isDefined && diff.oldIsImage){
<div class="diff-image-frame diff-old"><img src="@url(repository)/blob/@oldCommitId.get/@diff.oldPath?raw=true" class="diff-image" onload="onLoadedDiffImages(this)" style="display:none" /></div>
} else {
@if(diff.changeType != ChangeType.ADD){
<div style="padding: 12px;">Not supported</div>
}
}
@if(newCommitId.isDefined && diff.newIsImage){
<div class="diff-image-frame diff-new"><img src="@url(repository)/blob/@newCommitId.get/@diff.newPath?raw=true" class="diff-image" onload="onLoadedDiffImages(this)" style="display:none" /></div>
} else {
@if(diff.changeType != ChangeType.DELETE){
<div style="padding: 12px;">Not supported</div>
}
}
</div>
} else {
@if(diff.tooLarge){
<div style="padding: 12px;">Too large</div>
} else {
<div style="padding: 12px;">Not supported</div>
}
}
}
}
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -36,7 +36,7 @@
</div> </div>
} else { } else {
<div class="box-content-bottom" style="padding: 0px;"> <div class="box-content-bottom" style="padding: 0px;">
@defining(3){ max => @defining(20){ max =>
@userRepositories.zipWithIndex.map { case (repository, i) => @userRepositories.zipWithIndex.map { case (repository, i) =>
<div class="box-content-row repo-link" style="@if(i > max - 1){display:none;}"> <div class="box-content-row repo-link" style="@if(i > max - 1){display:none;}">
@helper.html.repositoryicon(repository, false) @helper.html.repositoryicon(repository, false)

View File

@@ -55,7 +55,7 @@
} else { } else {
@if(comment.action == "refer"){ @if(comment.action == "refer"){
@defining(comment.content.split(":")){ case Array(issueId, rest @ _*) => @defining(comment.content.split(":")){ case Array(issueId, rest @ _*) =>
<strong><a href="@path/@repository.owner/@repository.name/issues/@issueId">Issue #@issueId</a>: @rest.mkString(":")</strong> <strong>@issueLink(repository, issueId.toInt): @rest.mkString(":")</strong>
} }
} else { } else {
<div class="markdown-body">@markdown(comment.content, repository, false, true, true, hasWritePermission)</div> <div class="markdown-body">@markdown(comment.content, repository, false, true, true, hasWritePermission)</div>

View File

@@ -104,7 +104,7 @@
<div style="margin-right: @if(expand){180px} else {50px};"> <div style="margin-right: @if(expand){180px} else {50px};">
@if(expand){ @if(expand){
@repository.repository.description.map { description => @repository.repository.description.map { description =>
<p class="description">@description</p> <p class="description">@detectAndRenderLinks(description)</p>
} }
<div style="margin-bottom: 10px;" class="box-content"> <div style="margin-bottom: 10px;" class="box-content">
<table class="fill-width"> <table class="fill-width">

View File

@@ -64,6 +64,11 @@ h6 {
.align-right { .align-right {
text-align: right; text-align: right;
} }
blockquote p {
font-size: 15px;
}
/* ======================================================================== */ /* ======================================================================== */
/* Global Header */ /* Global Header */
/* ======================================================================== */ /* ======================================================================== */

View File

@@ -45,7 +45,7 @@ class JsonFormatSpec extends Specification {
forks = 0, forks = 0,
`private` = false, `private` = false,
default_branch = "master", default_branch = "master",
owner = apiUser) owner = apiUser)(urlIsHtmlUrl = false)
val repositoryJson = s"""{ val repositoryJson = s"""{
"name" : "Hello-World", "name" : "Hello-World",
"full_name" : "octocat/Hello-World", "full_name" : "octocat/Hello-World",
@@ -85,7 +85,7 @@ class JsonFormatSpec extends Specification {
"url": "http://gitbucket.exmple.com/api/v3/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e/statuses" "url": "http://gitbucket.exmple.com/api/v3/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e/statuses"
}""" }"""
val apiPushCommit = ApiPushCommit( val apiPushCommit = ApiCommit(
id = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", id = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
message = "Update README.md", message = "Update README.md",
timestamp = date1, timestamp = date1,
@@ -93,7 +93,7 @@ class JsonFormatSpec extends Specification {
removed = Nil, removed = Nil,
modified = List("README.md"), modified = List("README.md"),
author = ApiPersonIdent("baxterthehacker","baxterthehacker@users.noreply.github.com",date1), author = ApiPersonIdent("baxterthehacker","baxterthehacker@users.noreply.github.com",date1),
committer = ApiPersonIdent("baxterthehacker","baxterthehacker@users.noreply.github.com",date1))(RepositoryName("baxterthehacker", "public-repo")) committer = ApiPersonIdent("baxterthehacker","baxterthehacker@users.noreply.github.com",date1))(RepositoryName("baxterthehacker", "public-repo"), true)
val apiPushCommitJson = s"""{ val apiPushCommitJson = s"""{
"id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", "id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
// "distinct": true, // "distinct": true,

View File

@@ -0,0 +1,38 @@
package gitbucket.core.view
import org.specs2.mutable._
class HelpersSpec extends Specification {
import helpers._
"detect and render links" should {
"pass identical string when no link is present" in {
val before = "Description"
val after = detectAndRenderLinks(before).toString()
after mustEqual before
}
"convert a single link" in {
val before = "http://example.com"
val after = detectAndRenderLinks(before).toString()
after mustEqual """<a href="http://example.com">http://example.com</a>"""
}
"convert a single link within trailing text" in {
val before = "Example Project. http://example.com"
val after = detectAndRenderLinks(before).toString()
after mustEqual """Example Project. <a href="http://example.com">http://example.com</a>"""
}
"convert a mulitple links within text" in {
val before = "Example Project. http://example.com. (See also https://github.com/)"
val after = detectAndRenderLinks(before).toString()
after mustEqual """Example Project. <a href="http://example.com">http://example.com</a>. (See also <a href="https://github.com/">https://github.com/</a>)"""
}
}
}