mirror of
https://github.com/ethauvin/kobalt.git
synced 2025-04-25 16:07:12 -07:00
Merge branch 'master' of github.com:cbeust/kobalt
This commit is contained in:
commit
8b192e31bf
20 changed files with 216 additions and 66 deletions
|
@ -1,7 +1,6 @@
|
|||
import com.beust.kobalt.TaskResult
|
||||
import com.beust.kobalt.*
|
||||
import com.beust.kobalt.api.Project
|
||||
import com.beust.kobalt.api.annotation.Task
|
||||
import com.beust.kobalt.homeDir
|
||||
import com.beust.kobalt.plugin.application.application
|
||||
import com.beust.kobalt.plugin.java.javaCompiler
|
||||
import com.beust.kobalt.plugin.kotlin.kotlinCompiler
|
||||
|
@ -9,8 +8,6 @@ import com.beust.kobalt.plugin.packaging.assemble
|
|||
import com.beust.kobalt.plugin.publish.autoGitTag
|
||||
import com.beust.kobalt.plugin.publish.bintray
|
||||
import com.beust.kobalt.plugin.publish.github
|
||||
import com.beust.kobalt.project
|
||||
import com.beust.kobalt.test
|
||||
import org.apache.maven.model.Developer
|
||||
import org.apache.maven.model.License
|
||||
import org.apache.maven.model.Model
|
||||
|
@ -20,6 +17,10 @@ import java.nio.file.Files
|
|||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
val bs = buildScript {
|
||||
repos("http://dl.bintray.com/cbeust/maven")
|
||||
}
|
||||
|
||||
object Versions {
|
||||
val okhttp = "3.2.0"
|
||||
val okio = "1.6.0"
|
||||
|
@ -110,7 +111,8 @@ val kobaltPluginApi = project {
|
|||
"org.eclipse.jgit:org.eclipse.jgit:4.5.0.201609210915-r",
|
||||
"org.slf4j:slf4j-simple:${Versions.slf4j}",
|
||||
*mavenResolver("api", "spi", "util", "impl", "connector-basic", "transport-http", "transport-file"),
|
||||
"org.apache.maven:maven-aether-provider:3.3.9"
|
||||
"org.apache.maven:maven-aether-provider:3.3.9",
|
||||
"org.testng.testng-remote:testng-remote:1.3.0"
|
||||
)
|
||||
exclude(*aether("impl", "spi", "util", "api"))
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
kobalt.version=1.0.18
|
||||
kobalt.version=1.0.19
|
|
@ -82,7 +82,11 @@ class Args {
|
|||
@Parameter(names = arrayOf("--noIncrementalKotlin"), description = "Disable incremental Kotlin compilation")
|
||||
var noIncrementalKotlin: Boolean = false
|
||||
|
||||
@Parameter(names = arrayOf("--sequential"), description = "Build all the projects in sequence")
|
||||
companion object {
|
||||
const val SEQUENTIAL = "--sequential"
|
||||
}
|
||||
|
||||
@Parameter(names = arrayOf(Args.SEQUENTIAL), description = "Build all the projects in sequence")
|
||||
var sequential: Boolean = false
|
||||
|
||||
@Parameter(names = arrayOf("--server"), description = "Run in server mode")
|
||||
|
|
|
@ -105,7 +105,7 @@ class AsciiArt {
|
|||
const val CYAN = "\u001B[36m"
|
||||
const val WHITE = "\u001B[37m"
|
||||
|
||||
private fun wrap(s: CharSequence, color: String) = color + s + RESET
|
||||
fun wrap(s: CharSequence, color: String) = color + s + RESET
|
||||
private fun blue(s: CharSequence) = wrap(s, BLUE)
|
||||
private fun red(s: CharSequence) = wrap(s, RED)
|
||||
private fun yellow(s: CharSequence) = wrap(s, YELLOW)
|
||||
|
|
|
@ -27,8 +27,13 @@ class BuildScriptConfig {
|
|||
@Directive
|
||||
fun buildFileClasspath(vararg bfc: String) = newBuildFileClasspath(*bfc)
|
||||
|
||||
// The following settings modify the compiler used to compile the build file.
|
||||
// Projects should use kotlinCompiler { compilerVersion } to configure the Kotin compiler for their source files.
|
||||
/** Options passed to Kobalt */
|
||||
@Directive
|
||||
fun kobaltOptions(vararg options: String) = Kobalt.addKobaltOptions(options)
|
||||
|
||||
// The following settings modify the compiler used to compile the build file, which regular users should
|
||||
// probably never need to do. Projects should use kotlinCompiler { compilerVersion } to configure the
|
||||
// Kotin compiler for their source files.
|
||||
var kobaltCompilerVersion : String? = null
|
||||
var kobaltCompilerRepo: String? = null
|
||||
var kobaltCompilerFlags: String? = null
|
||||
|
|
|
@ -118,5 +118,11 @@ class Kobalt {
|
|||
get() = Duration.parse( kobaltProperties.getProperty(PROPERTY_KOBALT_VERSION_CHECK_TIMEOUT) ?: "P1D")
|
||||
|
||||
fun findPlugin(name: String) = Plugins.findPlugin(name)
|
||||
|
||||
val optionsFromBuild = arrayListOf<String>()
|
||||
|
||||
fun addKobaltOptions(options: Array<out String>) {
|
||||
optionsFromBuild.addAll(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ open class Project(
|
|||
@Directive open var url: String? = null,
|
||||
@Directive open var pom: Model? = null,
|
||||
@Directive open var dependsOn: ArrayList<Project> = arrayListOf<Project>(),
|
||||
@Directive open var testsDependOnProjects: ArrayList<Project> = arrayListOf<Project>(),
|
||||
@Directive open var packageName: String? = group)
|
||||
: IBuildConfig, IDependencyHolder by DependencyHolder() {
|
||||
|
||||
|
@ -33,13 +34,15 @@ open class Project(
|
|||
this.project = this
|
||||
}
|
||||
|
||||
fun allProjectDependedOn() = project.dependsOn + project.testsDependOnProjects
|
||||
|
||||
class ProjectExtra(project: Project) {
|
||||
var isDirty = false
|
||||
|
||||
/**
|
||||
* @return true if any of the projects we depend on is dirty.
|
||||
*/
|
||||
fun dependsOnDirtyProjects(project: Project) = project.dependsOn.any { it.projectExtra.isDirty }
|
||||
fun dependsOnDirtyProjects(project: Project) = project.allProjectDependedOn().any { it.projectExtra.isDirty }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -96,6 +99,8 @@ open class Project(
|
|||
val testDependencies : ArrayList<IClasspathDependency> = arrayListOf()
|
||||
val testProvidedDependencies : ArrayList<IClasspathDependency> = arrayListOf()
|
||||
|
||||
fun testsDependOnProjects(vararg projects: Project) = testsDependOnProjects.addAll(projects)
|
||||
|
||||
/** Used to disambiguate various name properties */
|
||||
@Directive
|
||||
val projectName: String get() = name
|
||||
|
|
|
@ -98,6 +98,8 @@ abstract class GenericTestRunner: ITestRunnerContributor {
|
|||
configName: String) : Boolean {
|
||||
var result = false
|
||||
|
||||
context.logger.log(project.name, 1, "Running default TestNG runner")
|
||||
|
||||
val testConfig = project.testConfigs.firstOrNull { it.name == configName }
|
||||
|
||||
if (testConfig != null) {
|
||||
|
|
|
@ -92,7 +92,7 @@ open class JvmCompilerPlugin @Inject constructor(
|
|||
scopes = listOf(Scope.TEST))
|
||||
val compileDependencies = dependencyManager.calculateDependencies(project, context,
|
||||
scopes = listOf(Scope.COMPILE))
|
||||
val allDependencies = (compileDependencies + testDependencies).toHashSet()
|
||||
val allDependencies = (testDependencies + compileDependencies).distinct()
|
||||
return testContributor.run(project, context, configName, allDependencies.toList())
|
||||
} else {
|
||||
context.logger.log(project.name, 2,
|
||||
|
|
|
@ -94,7 +94,7 @@ class ParallelProjectRunner(val tasksByNames: (Project) -> ListMultimap<String,
|
|||
val projectGraph = DynamicGraph<ProjectTask>().apply {
|
||||
projects.forEach { project ->
|
||||
addNode(ProjectTask(project, args.dryRun))
|
||||
project.dependsOn.forEach {
|
||||
project.allProjectDependedOn().forEach {
|
||||
addEdge(ProjectTask(project, args.dryRun), ProjectTask(it, args.dryRun))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ class SequentialProjectRunner(val tasksByNames: (Project) -> ListMultimap<String
|
|||
klog(1, AsciiArt.logBox("Building $projectName", indent = 5))
|
||||
|
||||
// Does the current project depend on any failed projects?
|
||||
val fp = project.dependsOn.filter { failedProjects.contains(it.name) }.map(Project::name)
|
||||
val fp = project.allProjectDependedOn().filter { failedProjects.contains(it.name) }.map(Project::name)
|
||||
|
||||
if (fp.size > 0) {
|
||||
klog(2, "Marking project $projectName as skipped")
|
||||
|
|
|
@ -145,7 +145,7 @@ class TaskManager @Inject constructor(val args: Args,
|
|||
val topological = Topological<Project>().apply {
|
||||
projects.forEach { project ->
|
||||
addNode(project)
|
||||
project.dependsOn.forEach {
|
||||
project.allProjectDependedOn().forEach {
|
||||
addEdge(project, it)
|
||||
}
|
||||
}
|
||||
|
@ -160,7 +160,7 @@ class TaskManager @Inject constructor(val args: Args,
|
|||
return result
|
||||
} else {
|
||||
val rootProject = projects.find { it.name == ti.project }!!
|
||||
val allProjects = DynamicGraph.transitiveClosure(rootProject, { p -> p.dependsOn })
|
||||
val allProjects = DynamicGraph.transitiveClosure(rootProject, Project::allProjectDependedOn)
|
||||
val sortedProjects = sortProjectsTopologically(allProjects)
|
||||
val sortedMaps = sortedProjects.map { TaskInfo(it.name, "compile")}
|
||||
val result = sortedMaps.subList(0, sortedMaps.size - 1) + listOf(ti)
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
package com.beust.kobalt.internal
|
||||
|
||||
import com.beust.kobalt.AsciiArt
|
||||
import com.beust.kobalt.TestConfig
|
||||
import com.beust.kobalt.api.IClasspathDependency
|
||||
import com.beust.kobalt.api.KobaltContext
|
||||
import com.beust.kobalt.api.Project
|
||||
import com.beust.kobalt.maven.aether.AetherDependency
|
||||
import com.beust.kobalt.misc.KFiles
|
||||
import com.beust.kobalt.misc.Versions
|
||||
import com.beust.kobalt.misc.runCommand
|
||||
import com.beust.kobalt.misc.warn
|
||||
import org.testng.remote.RemoteArgs
|
||||
import org.testng.remote.strprotocol.JsonMessageSender
|
||||
import org.testng.remote.strprotocol.MessageHelper
|
||||
import org.testng.remote.strprotocol.MessageHub
|
||||
import org.testng.remote.strprotocol.TestResultMessage
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class TestNgRunner : GenericTestRunner() {
|
||||
|
||||
|
@ -39,7 +49,7 @@ class TestNgRunner : GenericTestRunner() {
|
|||
add("-testclass")
|
||||
add(testClasses.joinToString(","))
|
||||
} else {
|
||||
if (! testConfig.isDefault) warn("Couldn't find any test classes for ${project.name}")
|
||||
if (!testConfig.isDefault) warn("Couldn't find any test classes for ${project.name}")
|
||||
// else do nothing: since the user didn't specify an explicit test{} directive, not finding
|
||||
// any test sources is not a problem
|
||||
}
|
||||
|
@ -48,4 +58,126 @@ class TestNgRunner : GenericTestRunner() {
|
|||
addAll(testConfig.testArgs)
|
||||
}
|
||||
}
|
||||
|
||||
val VERSION_6_10 = 600100000L
|
||||
|
||||
override fun runTests(project: Project, context: KobaltContext, classpath: List<IClasspathDependency>,
|
||||
configName: String): Boolean {
|
||||
|
||||
context.logger.log(project.name, 1, "Running enhanced TestNG runner")
|
||||
|
||||
val testngDependency = (project.testDependencies.filter { it.id.contains("testng") }
|
||||
.firstOrNull() as AetherDependency).version
|
||||
val testngDependencyVersion = Versions.toLongVersion(testngDependency)
|
||||
val result =
|
||||
if (testngDependencyVersion >= VERSION_6_10) {
|
||||
context.logger.log(project.name, 1, "Modern TestNG, displaying colors")
|
||||
displayPrettyColors(project, context, classpath)
|
||||
} else {
|
||||
context.logger.log(project.name, 1, "Older TestNG ($testngDependencyVersion), using the old runner")
|
||||
super.runTests(project, context, classpath, configName)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun displayPrettyColors(project: Project, context: KobaltContext, classpath: List<IClasspathDependency>)
|
||||
: Boolean {
|
||||
val port = 2345
|
||||
|
||||
val dep = with(context.dependencyManager) {
|
||||
val jf = create("org.testng.testng-remote:testng-remote:1.3.0")
|
||||
val tr = create("org.testng.testng-remote:testng-remote6_10:1.3.0")
|
||||
val testng = create("org.testng:testng:6.10")
|
||||
transitiveClosure(listOf(jf, tr, testng))
|
||||
}
|
||||
|
||||
val v = Versions.toLongVersion("6.10")
|
||||
val cp = (classpath + dep).map { it.jarFile.get() }
|
||||
.joinToString(File.pathSeparator)
|
||||
val passedArgs = listOf(
|
||||
"-classpath",
|
||||
cp,
|
||||
"org.testng.remote.RemoteTestNG",
|
||||
"-serport", port.toString(),
|
||||
"-version", "6.10",
|
||||
"-dontexit",
|
||||
RemoteArgs.PROTOCOL,
|
||||
"json",
|
||||
"src/test/resources/testng.xml")
|
||||
|
||||
Thread {
|
||||
val exitCode = runCommand {
|
||||
command = "java"
|
||||
directory = File(project.directory)
|
||||
args = passedArgs
|
||||
}
|
||||
}.start()
|
||||
|
||||
// Thread {
|
||||
// val args2 = arrayOf("-serport", port.toString(), "-dontexit", RemoteArgs.PROTOCOL, "json",
|
||||
// "-version", "6.10",
|
||||
// "src/test/resources/testng.xml")
|
||||
// RemoteTestNG.main(args2)
|
||||
// }.start()
|
||||
|
||||
val mh = MessageHub(JsonMessageSender("localhost", port, true))
|
||||
mh.setDebug(true)
|
||||
mh.initReceiver()
|
||||
val passed = arrayListOf<String>()
|
||||
|
||||
data class FailedTest(val method: String, val cls: String, val stackTrace: String)
|
||||
|
||||
val failed = arrayListOf<FailedTest>()
|
||||
var skipped = arrayListOf<String>()
|
||||
|
||||
fun d(n: Int, color: String)
|
||||
= AsciiArt.wrap(String.format("%4d", n), color)
|
||||
|
||||
fun red(s: String) = AsciiArt.wrap(s, AsciiArt.RED)
|
||||
fun green(s: String) = AsciiArt.wrap(s, AsciiArt.GREEN)
|
||||
fun yellow(s: String) = AsciiArt.wrap(s, AsciiArt.YELLOW)
|
||||
|
||||
try {
|
||||
var message = mh.receiveMessage()
|
||||
println("")
|
||||
println(green("PASSED") + " | " + red("FAILED") + " | " + yellow("SKIPPED"))
|
||||
while (message != null) {
|
||||
message = mh.receiveMessage()
|
||||
if (message is TestResultMessage) {
|
||||
when (message.result) {
|
||||
MessageHelper.PASSED_TEST -> passed.add(message.name)
|
||||
MessageHelper.FAILED_TEST -> failed.add(FailedTest(message.testClass,
|
||||
message.method, message.stackTrace))
|
||||
MessageHelper.SKIPPED_TEST -> skipped.add(message.name)
|
||||
}
|
||||
}
|
||||
print("\r " + d(passed.size, AsciiArt.GREEN)
|
||||
+ " | " + d(failed.size, AsciiArt.RED)
|
||||
+ " | " + d(skipped.size, AsciiArt.YELLOW))
|
||||
// Thread.sleep(500)
|
||||
// print("\r" + String.format("%4d / %4d / %4d", passed.size, failed.size, skipped.size))
|
||||
// Thread.sleep(200)
|
||||
}
|
||||
} catch(ex: IOException) {
|
||||
println("Exception: ${ex.message}")
|
||||
}
|
||||
println("\nPassed: " + passed.size + ", Failed: " + failed.size + ", Skipped: " + skipped.size)
|
||||
failed.forEach {
|
||||
val top = it.stackTrace.substring(0, it.stackTrace.indexOf("\n"))
|
||||
println(" " + it.cls + "." + it.method + "\n " + top)
|
||||
}
|
||||
return failed.isEmpty() && skipped.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
fun d(n: Int, color: String)
|
||||
= AsciiArt.wrap(String.format("%4d", n), color)
|
||||
|
||||
println("PASSED | FAILED | SKIPPED")
|
||||
repeat(20) { i ->
|
||||
print("\r " + d(i, AsciiArt.GREEN) + " | " + d(i * 2, AsciiArt.RED) + " | " + d(i, AsciiArt.YELLOW))
|
||||
Thread.sleep(500)
|
||||
}
|
||||
println("")
|
||||
}
|
||||
|
|
|
@ -228,13 +228,20 @@ class DependencyManager @Inject constructor(val executors: KobaltExecutors,
|
|||
}
|
||||
}
|
||||
|
||||
val isTest = scopes.contains(Scope.TEST)
|
||||
|
||||
project.dependsOn.forEach { p ->
|
||||
maybeAddClassDir(KFiles.joinDir(p.directory, p.classesDir(context)))
|
||||
val isTest = scopes.contains(Scope.TEST)
|
||||
if (isTest) maybeAddClassDir(KFiles.makeOutputTestDir(project).path)
|
||||
val otherDependencies = calculateDependencies(p, context, dependencyFilter, scopes)
|
||||
result.addAll(otherDependencies)
|
||||
}
|
||||
|
||||
if (isTest) {
|
||||
project.testsDependOnProjects.forEach { p ->
|
||||
val otherDependencies = calculateDependencies(p, context, dependencyFilter, scopes)
|
||||
result.addAll(otherDependencies)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -161,6 +161,7 @@ private class Main @Inject constructor(
|
|||
} else {
|
||||
val allProjects = projectFinder.initForBuildFile(buildFile, args)
|
||||
|
||||
addOptionsFromBuild(args, Kobalt.optionsFromBuild)
|
||||
if (args.listTemplates) {
|
||||
// --listTemplates
|
||||
Templates().displayTemplates(pluginInfo)
|
||||
|
@ -213,6 +214,15 @@ private class Main @Inject constructor(
|
|||
return result
|
||||
}
|
||||
|
||||
private fun addOptionsFromBuild(args: Args, optionsFromBuild: ArrayList<String>) {
|
||||
optionsFromBuild.forEach {
|
||||
when(it) {
|
||||
Args.SEQUENTIAL -> args.sequential = true
|
||||
else -> throw IllegalArgumentException("Unsupported option found in kobaltOptions(): " + it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findBuildFile(): File {
|
||||
val deprecatedLocation = File(Constants.BUILD_FILE_NAME)
|
||||
val result: File =
|
||||
|
|
|
@ -5,7 +5,6 @@ import com.beust.kobalt.Variant
|
|||
import com.beust.kobalt.api.*
|
||||
import com.beust.kobalt.api.annotation.Directive
|
||||
import com.beust.kobalt.internal.BaseJvmPlugin
|
||||
import com.beust.kobalt.internal.JvmCompilerPlugin
|
||||
import com.beust.kobalt.misc.warn
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
@ -68,16 +67,6 @@ class JavaPlugin @Inject constructor(val javaCompiler: JavaCompiler, override va
|
|||
|
||||
}
|
||||
|
||||
@Directive
|
||||
fun javaProject(vararg projects: Project, init: Project.() -> Unit): Project {
|
||||
return Project().apply {
|
||||
warn("javaProject{} is deprecated, please use project{}")
|
||||
init()
|
||||
(Kobalt.findPlugin(JvmCompilerPlugin.PLUGIN_NAME) as JvmCompilerPlugin)
|
||||
.addDependentProjects(this, projects.toList())
|
||||
}
|
||||
}
|
||||
|
||||
class JavaConfig(val project: Project) {
|
||||
val compilerArgs = arrayListOf<String>()
|
||||
fun args(vararg options: String) = compilerArgs.addAll(options)
|
||||
|
|
|
@ -5,13 +5,11 @@ import com.beust.kobalt.Variant
|
|||
import com.beust.kobalt.api.*
|
||||
import com.beust.kobalt.api.annotation.Directive
|
||||
import com.beust.kobalt.internal.BaseJvmPlugin
|
||||
import com.beust.kobalt.internal.JvmCompilerPlugin
|
||||
import com.beust.kobalt.internal.KobaltSettings
|
||||
import com.beust.kobalt.internal.KotlinJarFiles
|
||||
import com.beust.kobalt.maven.DependencyManager
|
||||
import com.beust.kobalt.maven.dependency.FileDependency
|
||||
import com.beust.kobalt.misc.KobaltExecutors
|
||||
import com.beust.kobalt.misc.warn
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
@ -131,19 +129,6 @@ class KotlinPlugin @Inject constructor(val executors: KobaltExecutors, val depen
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param projects: the list of projects that need to be built before this one.
|
||||
*/
|
||||
@Directive
|
||||
fun kotlinProject(vararg projects: Project, init: Project.() -> Unit): Project {
|
||||
return Project().apply {
|
||||
warn("kotlinProject{} is deprecated, please use project{}")
|
||||
init()
|
||||
(Kobalt.findPlugin(JvmCompilerPlugin.PLUGIN_NAME) as JvmCompilerPlugin)
|
||||
.addDependentProjects(this, projects.toList())
|
||||
}
|
||||
}
|
||||
|
||||
class KotlinConfig(val project: Project) {
|
||||
val args = arrayListOf<String>()
|
||||
fun args(vararg options: String) = args.addAll(options)
|
||||
|
|
|
@ -1 +1 @@
|
|||
kobalt.version=1.0.18
|
||||
kobalt.version=1.0.19
|
||||
|
|
|
@ -45,7 +45,7 @@ class MavenResolverTest {
|
|||
assertThat(result[0].artifact.version).isEqualTo(expectedVersion)
|
||||
}
|
||||
|
||||
@Test(dataProvider = "rangeProvider")
|
||||
@Test(dataProvider = "rangeProvider", groups = arrayOf("mavenResolverBug"))
|
||||
fun kobaltRangeVersion(id: String, expectedVersion: String) {
|
||||
val artifact = resolver.resolveToArtifact(id)
|
||||
assertThat(artifact.version).isEqualTo(expectedVersion)
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd" >
|
||||
|
||||
<suite name="Kobalt" verbose="2">
|
||||
|
||||
<test name="Main">
|
||||
<groups>
|
||||
<run>
|
||||
<exclude name="mavenResolverBug"/>
|
||||
</run>
|
||||
</groups>
|
||||
<packages>
|
||||
<package name="com.beust.kobalt.*"/>
|
||||
</packages>
|
||||
</test>
|
||||
|
||||
</suite>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue