-
-
Save anonymous/dd53ece6a47bb6d96e96 to your computer and use it in GitHub Desktop.
main build
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import java.util.jar.JarFile | |
import static groovy.io.FileType.FILES | |
apply plugin: 'idea' | |
apply plugin: 'java' | |
apply plugin: 'groovy' | |
apply from: 'libraries.gradle' | |
defaultTasks 'assemble' | |
configurations { | |
bnd | |
} | |
// setup project wide configurations and variables | |
project.ext { | |
mainProjectDir = project.projectDir | |
bundleDir = new File(mainProjectDir, "/bin/java/bundle/pim/") | |
jarBundleDir = new File(mainProjectDir, "/bin/java/bundle/lib/") | |
javaSubprojects = [] | |
// called by all java-based sub-projects | |
addJavaSubproject = { subproject -> | |
javaSubprojects.add(subproject) | |
} | |
} | |
task wrapper(type: Wrapper) { | |
gradleVersion = '1.4' | |
} | |
buildscript { | |
repositories { | |
mavenLocal() | |
mavenCentral() | |
mavenRepo(name: 'zt-public-snapshots', | |
url: 'http://repos.zeroturnaround.com/nexus/content/groups/zt-public/') | |
} | |
dependencies { | |
classpath group: 'org.zeroturnaround', name: 'gradle-jrebel-plugin', version: '1.0.2-SNAPSHOT' | |
} | |
} | |
allprojects { | |
apply plugin: 'maven' | |
apply plugin: 'java' | |
apply plugin: 'project-report' | |
group = 'com.stibo' | |
version = '1.0-SNAPSHOT' | |
sourceCompatibility = 1.7 | |
targetCompatibility = 1.7 | |
repositories { | |
mavenRepo url: "http://nexus.stibo.dk/content/groups/public/" | |
} | |
} | |
clean << { | |
new File(projectDir, "bin").deleteDir() | |
} | |
idea { | |
project { project -> | |
project.wildcards += '/**/!?*.xml' | |
project.wildcards += '/**/!?*.properties' | |
ipr { | |
withXml { provider -> | |
provider.node.component.find { it.@name == 'VcsDirectoryMappings' }.mapping.@vcs = 'svn' | |
provider.asNode().appendNode('component', [name: 'JavacSettings']).appendNode('option', [name: 'MAXIMUM_HEAP_SIZE', value: '257']) | |
} | |
} | |
} | |
} | |
tasks.idea.dependsOn(cleanIdea) | |
def depFile = new File('DepFile.txt') | |
depFile.setText('') | |
subprojects { subproject -> | |
if (new File(subproject.projectDir, "build.gradle").exists() && !subproject.name.equals('bndextra')) { | |
addJavaSubproject subproject | |
apply plugin: 'java' | |
apply plugin: 'idea' | |
apply plugin: 'rebel' | |
subproject.ext { | |
skipBundling = Boolean.getBoolean('skipBundling')?:false; | |
mainProject = mainProjectDir | |
uploadingArchivesInProgress = false | |
bundleVersion = '1.0.0' // fallback if none has been supplied in the bnd file | |
} | |
configurations { | |
bndArchives | |
bnd | |
bootstrap | |
compile { | |
transitive = false | |
} | |
} | |
rebel { | |
rebelXmlDirectory = "build/classes/main" | |
} | |
gradle.taskGraph.whenReady {taskGraph -> | |
taskGraph.allTasks.each { | |
if (it.name.endsWith('test')) { | |
skipBundling = true | |
return | |
} | |
} | |
} | |
uploadBndArchives { | |
subproject.ext.uploadingArchivesInProgress = true | |
repositories { | |
mavenDeployer { | |
repository(url: "http://nexus.stibo.dk/content/repositories/snapshots") { | |
authentication(userName: "jtpe", password: "jtpe") | |
} | |
} | |
} | |
doFirst { | |
repositories { | |
mavenDeployer { | |
def projectVersion = subproject.version | |
if (subproject.ext.bundleVersion != null) { | |
projectVersion = subproject.ext.bundleVersion | |
} | |
artifacts.each{ | |
def artifactName = it.name - '.jar' | |
addFilter(artifactName){artifact,file -> | |
artifact.name == artifactName | |
} | |
if (!projectVersion.endsWith('SNAPSHOT')){ | |
pom(artifactName).version = projectVersion + '-SNAPSHOT' | |
} | |
} | |
} | |
} | |
} | |
} | |
processResources << { | |
if (!subproject.ext.skipBundling) { | |
bundleDir.mkdirs() | |
jarBundleDir.mkdirs() | |
depFile.append(" MODULE $subproject\n") | |
def dependentJarsList = [] | |
def addDependendJars | |
addDependendJars = {resolvedDep, space -> | |
def moduleVersion = resolvedDep.moduleVersion | |
if (!moduleVersion.contains("SNAPSHOT")) { | |
def moduleName = resolvedDep.moduleName | |
def moduleGroup = resolvedDep.moduleGroup | |
resolvedDep.allModuleArtifacts.collect {depFile.append("$space --> $it.name\n")} | |
depFile.append("$space -> and looking for artifact: $moduleGroup:$moduleName:$moduleVersion ") | |
def moduleArtifact = resolvedDep.allModuleArtifacts.find {it.name == moduleName && it.file != null} | |
if (moduleArtifact != null) { | |
def version = osgiVersion(moduleGroup, moduleName, moduleVersion) | |
if (version && version - 'osgify' != 'no') { | |
dependentJarsList.add([jarFile: moduleArtifact.file, bundleVersion: version, bundleName: "$moduleName"]) | |
depFile.append(" \n ") | |
} | |
// we skip the transitive dependencies for now | |
// resolvedDep.children.collect {dep -> addDependendJars(dep, space + ' ')} | |
} else { | |
depFile.append(" -- \n") | |
} | |
} | |
} | |
subproject.configurations.runtime.resolvedConfiguration.firstLevelModuleDependencies.collect { addDependendJars(it, ' ')} | |
subproject.configurations.bootstrap.resolvedConfiguration.firstLevelModuleDependencies.collect { addDependendJars(it, ' ')} | |
ant { | |
taskdef(resource: "aQute/bnd/ant/taskdef.properties", classpath: configurations.bnd.asPath) | |
} | |
// use a set to avoid bundling jar files more than once | |
def dependentJarsSet = dependentJarsList as Set | |
// during assembly, all third party libraries are bundled using either the default bundling mechanism or | |
// specific bnd files found in the ConfigurationFiles/lib-bnd-files directory | |
def bndFileLocation = "${mainProjectDir}/ConfigurationFiles/lib-bnd-files" | |
dependentJarsSet.collect { bundle -> | |
def jar = bundle.jarFile | |
def osgiBundleVersion = bundle.bundleVersion | |
def osgiBundleName = bundle.bundleName | |
def bundledJarFile = new File(jarBundleDir, jar.name) | |
def bundleVersion = null; | |
def manifest = new JarFile(jar).manifest | |
if (manifest != null && manifest.getMainAttributes() != null) { | |
bundleVersion = manifest.getMainAttributes().find { it.key.toString().equals('Bundle-Version') }?.toString() | |
} | |
if (bundleVersion != null && !osgiBundleVersion.endsWith('osgify')) { | |
println " -> Skipping bundling for ${jar.name}, as it is already bundled as version $bundleVersion" | |
copy {from jar into jarBundleDir } | |
} else { | |
osgiBundleVersion = osgiBundleVersion - 'osgify' | |
ant.setProperty("Export-Package", "*;version=" + osgiBundleVersion); | |
ant.setProperty("Bundle-Version", osgiBundleVersion); | |
ant.setProperty("Bundle-SymbolicName", "stibo." + osgiBundleName); | |
ant.setProperty("Import-Package", "*;resolution:=optional"); // same as bnd command line wrap task. | |
def jarBndFile = new File(bndFileLocation, jar.name.replace(".jar", "") + ".bnd") | |
try { | |
if (jarBndFile.exists()) { | |
println "Bundlifying ${jar.name} to ${jarBundleDir} with existing bndfile" | |
ant.bnd(basedir: ".", files: jarBndFile, failok: "false", classpath: jar, eclipse: "false", | |
exceptions: "true", output: jarBundleDir, pedantic: "true", trace: "false") | |
} else { | |
println "Bundlifying ${jar.name} to ${jarBundleDir}" | |
ant.bndwrap(jars: jar, definitions: bndFileLocation, output: jarBundleDir, | |
failok: "false", exceptions: "true", trace: "true", | |
version: osgiBundleVersion, bsn: osgiBundleName) | |
} | |
} catch (Exception ex) { | |
println "ERROR: $ex.message" | |
} | |
} | |
if (jar.name.startsWith("bindex")) { | |
// special treatment for specific files | |
bundledJarFile.delete() | |
ant.zip(destfile: "${mainProjectDir}/bin/java/bindex.jar") { | |
zipfileset(src: "${jar}", excludes: "org/osgi/framework/**/*.class", includes: "**/*") | |
} | |
} | |
} | |
} | |
} | |
task copyExternalDependencies() << { | |
copy { | |
// copy files for possible inclusion with bnd-bundling | |
// TODO: could be done below, when reading from the bnd-file anyway, so only necessary dependecies are copied... | |
from configurations.runtime.files { it instanceof ExternalDependency } | |
into "$subproject.buildDir/lib" | |
} | |
} | |
classes.dependsOn copyExternalDependencies | |
classes.dependsOn generateRebel | |
task bndTask(dependsOn: classes) { | |
def subProjectDir = file("${subproject.projectDir}") | |
subProjectDir.traverse(type: FILES, | |
nameFilter: ~/.*\.bnd/, | |
sort: { a, b -> b.name <=> a.name }, maxDepth: 0) { bndFile -> | |
if (bndFile.canRead()) { | |
def gradleBnd = new File(subProjectDir, bndFile.name + '_grdl') | |
if (gradleBnd.canRead()) { | |
bndFile = gradleBnd | |
} | |
def bndProps = new Properties() | |
bndFile.withInputStream { InputStream input -> bndProps.load(input) } | |
def bundleName = bndProps.get("name") | |
def bundleJarName = bundleName + ".jar" | |
def bundleJarFile = new File(bundleDir, bundleJarName) | |
if (subproject.ext.uploadingArchivesInProgress) { | |
bundleJarFile = new File(buildDir,"libs/$bundleJarName") | |
artifacts{ | |
// the bndArchives are used for uploading artifacts to nexus | |
bndArchives file: bundleJarFile, name: bundleName, builtBy: bndTask | |
} | |
} | |
// TODO: this bndVersion should be calculated instead of reading it from the file | |
def bndVersion = bndProps.get('Bundle-Version') | |
if (bndVersion != null){ | |
subproject.ext.bundleVersion = bndVersion | |
subproject.version = bndVersion | |
} | |
inputs.dir files(subproject.projectDir).minus(files(subproject.buildDir)) | |
outputs.file bundleJarFile | |
doLast { | |
if (!subproject.ext.skipBundling) { | |
ant { | |
def outputPath = "" | |
sourceSets.main.output.collect { if (it.exists()) outputPath += "$it;"} | |
taskdef(resource: "aQute/bnd/ant/taskdef.properties", classpath: configurations.bnd.asPath) | |
path(id: "bundlePath") { | |
pathelement(path: outputPath) | |
pathelement(path: configurations.compile.asPath) | |
} | |
bundleJarFile.parentFile.mkdirs() | |
bnd(basedir: subProjectDir, files: bndFile.name, output: bundleJarFile, | |
classpath: ant.references['bundlePath'], | |
failok: "false", eclipse: "false", exceptions: "true", | |
pedantic: "true", trace: "false") | |
signjar(alias: "Stibo", storepass: "hemmeligt",lazy: "true",jar: bundleJarFile, | |
keystore: "${mainProjectDir}/ConfigurationFiles/keystore.jks") | |
} | |
} | |
} | |
} | |
} | |
} | |
jar.dependsOn(bndTask) | |
sourceSets { | |
main { | |
java { | |
srcDir 'java' | |
} | |
resources { | |
srcDir 'java' | |
srcDir 'resources' | |
} | |
} | |
test { | |
java { | |
srcDir 'java-test' | |
} | |
} | |
} | |
compileJava << { | |
copy { | |
from('java') { | |
include '**/*.xml' | |
include '**/*.properties' | |
} | |
into 'build/classes/main' | |
} | |
} | |
compileTestJava << { | |
copy { | |
from('java-test') { | |
include '**/*.xml' | |
include '**/*.properties' | |
} | |
into 'build/classes/test' | |
} | |
} | |
test { | |
if (System.getProperty('stepdebug') != null) { | |
doFirst { | |
jvmArgs '-Xdebug', | |
'-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005' | |
} | |
} | |
def simultaneousTests = 0 | |
beforeTest { descriptor -> | |
simultaneousTests += 1 | |
println("Running: $descriptor (test nr. $simultaneousTests)") | |
} | |
afterTest { descriptor -> | |
println(" -> finished: " + descriptor) | |
simultaneousTests -= 1 | |
} | |
systemProperty 'overloadconfigfile', "$mainProjectDir/unittestConfig.properties" | |
systemProperty 'configfile', "$mainProjectDir/idea/config.properties" | |
maxHeapSize = "2048m" | |
ignoreFailures = true | |
testReport = true | |
testResultsDir = file("$rootProject.testResultsDir") | |
systemProperty 'convertKodoExceptions', System.getProperty('convertKodoExceptions') | |
def dbTestConnectionFileName = System.getProperty('dbTestConnections', "$mainProjectDir/dbTestConnections.txt"); | |
File dbTestConnections = new File(dbTestConnectionFileName) | |
if (dbTestConnections.canRead()) { | |
def lines = dbTestConnections.readLines() | |
def parallelForks = 0 | |
lines.eachWithIndex { line, index -> | |
if (!line.startsWith("#")) { | |
parallelForks += 1 | |
def lineParts = line.split() | |
def indexNo = index == 0 ? "" : index + 1 | |
systemProperty "STEP_ConnectionURL" + indexNo, lineParts[0] | |
systemProperty "STEP_ConnectionUser" + indexNo, lineParts[1] | |
systemProperty "STEP_ConnectionPassword" + indexNo, lineParts[2] | |
} | |
} | |
maxParallelForks = parallelForks | |
systemProperty 'maxParallelForks', parallelForks | |
} else { | |
println "ERROR: couldn't read a file with db connections (filename: $dbTestConnectionFileName)" | |
} | |
} | |
tasks.idea.dependsOn(cleanIdea) | |
idea { | |
module { module -> | |
if (new File(subproject.projectDir, 'resources').isDirectory()) | |
module.scopes.RUNTIME.plus += configurations.detachedConfiguration(dependencies.create(files(new File(subproject.projectDir, 'resources')))) | |
if (new File(subproject.projectDir, 'test-resources').isDirectory()) | |
module.scopes.TEST.plus += configurations.detachedConfiguration(dependencies.create(files(new File(subproject.projectDir, 'test-resources')))) | |
module.scopes.PROVIDED.plus += configurations.bootstrap | |
def originalModuleName = module.name | |
def newModuleName = getModuleName(module, subproject.projectDir) | |
module.name = newModuleName | |
iml { | |
if (buildDir.canRead()) { | |
inheritOutputDirs = false | |
outputDir = new File(buildDir, "classes/main") | |
testOutputDir = new File(buildDir, "classes/test") | |
} | |
withXml { moduleXML -> | |
println "Creating IntelliJ Idea module for $subproject" | |
def generatedSrcDir = new File(projectDir, 'generated-src') | |
def mainDirFinder = { searchDir -> | |
def foundDir | |
searchDir.eachDirRecurse { dir -> | |
if (dir.name.equals('main')) { | |
foundDir = dir; | |
} | |
} | |
return foundDir | |
} | |
Node rootManager = node.find { it.@name == 'NewModuleRootManager' } | |
/* | |
if (generatedSrcDir.exists()) { | |
def srcDir = mainDirFinder(generatedSrcDir) | |
rootManager.appendNode('orderEntry', [type: 'module-library', exported: '']).appendNode('library').appendNode('CLASSES').appendNode('root', [url: "file://${srcDir}"]) | |
} | |
*/ | |
def genClassesDir = new File(buildDir, "genclasses") | |
if (genClassesDir.exists()) { | |
rootManager.appendNode('orderEntry', [type: 'module-library', exported: '']).appendNode('library').appendNode('CLASSES').appendNode('root', [url: "file://${genClassesDir}"]) | |
} | |
def bndFile | |
def count = 0 | |
// look for a single bnd in the subproject dir | |
def bndSuffix = ".bnd" | |
def grdlBndSuffix = ".bnd_grdl" | |
contentRoot.eachFileMatch({ it.endsWith(bndSuffix) && !it.contains('beans') }, { bndFile = it; count++ }) | |
if (count == 0) { | |
// less restrictive | |
contentRoot.eachFileMatch({ it.endsWith(bndSuffix)}, { bndFile = it; count++ }) | |
} | |
if (count == 1) { | |
def grdlBnd = new File(bndFile.parentFile, (bndFile.name - bndSuffix) + grdlBndSuffix) | |
if (grdlBnd.exists()) { | |
bndFile = grdlBnd | |
bndSuffix = grdlBndSuffix | |
} | |
println " -> Adding bundle for $subproject ($bndFile.name)" | |
addOsmorcFacet(moduleXML, bndFile.name, "$mainProjectDir/build/production/${newModuleName}.jar") | |
} else if (count > 1) { | |
// if more than one bnd, we try to determine the correct BND file by convention | |
def bndFileName = originalModuleName + bndSuffix | |
def altBndFileName = newModuleName - '_g' + bndSuffix | |
if (new File(contentRoot, bndFileName).canRead()) { | |
println " -> Adding bundle for $subproject ($bndFileName)" | |
addOsmorcFacet(moduleXML, bndFileName, "$mainProjectDir/build/production/${newModuleName}.jar") | |
} else if (new File(contentRoot, altBndFileName).canRead()) { | |
println " -> Adding bundle for $subproject ($altBndFileName)*" | |
addOsmorcFacet(moduleXML, altBndFileName, "$mainProjectDir/build/production/${newModuleName}.jar") | |
} else { | |
println "!!! PROBLEM: couldn't determine which bnd to choose!" | |
println " -> expected default bnd named either $bndFileName or $altBndFileName" | |
} | |
} else { | |
println "!!! PROBLEM: couldn't find a bnd file in module $module.name!" | |
} | |
} | |
} | |
} | |
dependencies { | |
bnd withLibrary("stiboWraptask") | |
bnd withLibrary("bndlib") | |
testCompile withLibrary("junit") | |
} | |
} | |
} else { | |
if (subproject.name.equals('bndextra')) { | |
apply plugin: 'idea' | |
apply plugin: 'java' | |
// create extra module for the special idea-bnd-files | |
idea { | |
module { module -> | |
def bndFile | |
contentRoot.parentFile.eachFileMatch({ it.endsWith(".ideabnd") }, { bndFile = it }) | |
def bndName = bndFile.name - '.ideabnd' | |
module.name = bndName + '_g' | |
def grdlBndFile = new File(bndFile.parentFile, bndName + '.ideabnd_grdl') | |
if (grdlBndFile.exists()) { | |
bndFile = grdlBndFile | |
} | |
iml { | |
withXml { moduleXML -> | |
if (bndFile.canRead()) { | |
addOsmorcFacet(moduleXML, bndFile, "$mainProjectDir/build/production/${bndName}.jar") | |
def imlFile | |
bndFile.parentFile.eachFileMatch({ it.endsWith("_g.iml") }, { imlFile = it }) | |
if (imlFile != null) | |
moduleXML.node.component.find { it.@name == 'NewModuleRootManager' }.appendNode('orderEntry', [type: 'module', 'module-name': imlFile.name - ".iml"]) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
afterEvaluate { | |
proj -> | |
proj.configurations { | |
stibocompile.extendsFrom compile, archives | |
stiboTestCompile | |
} | |
def compileDependencies = proj.configurations.compile.dependencies | |
def orgdependencies = new ArrayList(compileDependencies) | |
orgdependencies.each { | |
dependency -> | |
if (dependency instanceof ProjectDependency) | |
proj.dependencies { | |
def path = dependency.dependencyProject.path | |
compile project(path: path, configuration: 'stibocompile') | |
} | |
} | |
def testCompileDependencies = proj.configurations.testCompile.dependencies | |
new ArrayList(testCompileDependencies + compileDependencies).each { dependency -> | |
if (dependency instanceof ProjectDependency) | |
proj.dependencies { | |
if (subproject.gradle.startParameter.buildProjectDependencies) { | |
testCompile dependency.dependencyProject.sourceSets.test.output | |
} else { | |
testCompile files(dependency.dependencyProject.sourceSets.test.output.classesDir) | |
} | |
} | |
} | |
compileDependencies.removeAll { | |
it instanceof ProjectDependency && orgdependencies.contains(it) | |
} | |
} | |
} | |
private String getModuleName(module, projectDir) { | |
def parentDirName = projectDir.parentFile.name | |
def moduleName = module.name + '_g' | |
if (!parentDirName.equals("stepx")) { | |
moduleName = "$parentDirName-${module.name}_g" | |
} | |
return moduleName | |
} | |
private def addOsmorcFacet(moduleXML, bndFileName, jarFileLocation) { | |
Node node = moduleXML.asNode() | |
Node facetManager = node.children().find { it.@name == "FacetManager" } | |
if (facetManager == null) { | |
facetManager = new Node(node, 'component', [name: 'FacetManager']) | |
} | |
Node facetNode = facetManager.children().find { it.@name == "OSGi" } | |
if (facetNode == null) { | |
facetNode = new Node(facetManager, 'facet', [type: 'Osmorc', name: 'OSGi']) | |
//facetManager.append(facetNode) | |
} | |
def conf = new NodeBuilder() | |
def config = conf.configuration( | |
osmorcControlsManifest: "false", | |
manifestLocation: "", | |
jarfileLocation: jarFileLocation, | |
outputPathType: "SpecificOutputPath", | |
useBndFile: "true", | |
bndFileLocation: bndFileName, | |
useBundlorFile: "false", | |
bundlorFileLocation: "", | |
bundleActivator: "", | |
bundleSymbolicName: "", | |
bundleVersion: "1.0.0", | |
ignoreFilePattern: "", | |
useProjectDefaultManifestFileLocation: "true", | |
alwaysRebuildBundleJAR: "false") { | |
additionalProperties() | |
additionalJARContents() | |
} | |
facetNode.children().clear() | |
facetNode.children().add(config) | |
} | |
// Task for putting modules in module groups based on directory structure | |
task createIdeaSetup(dependsOn: tasks.idea) { | |
idea.project {project -> | |
ipr { | |
def locateGroupName | |
locateGroupName = { dir -> | |
if (dir != null) { | |
def groupFile = new File(file(dir), "ideagroup.txt") | |
if (groupFile.canRead()) { | |
return groupFile.readLines().get(0).toLowerCase() | |
} else { | |
if (dir.name != "stepx") { | |
return locateGroupName(dir.getParentFile()) | |
} | |
} | |
} | |
} | |
withXml { provider -> | |
def modules = provider.node.component.find { it.@name == 'ProjectModuleManager' }.modules | |
modules.module.collect { module -> | |
def moduleParentDir = file(module.@filepath.replace("\$PROJECT_DIR\$", mainProjectDir.path)).parentFile | |
def parentDirParentDir = moduleParentDir.parentFile | |
if (module.@fileurl.contains('bndextra')) { | |
module.@group = 'bndextra' | |
} else { | |
def name = locateGroupName(moduleParentDir) | |
module.@group = name ? name : parentDirParentDir.name | |
} | |
} | |
} | |
} | |
} | |
} | |
task checkJpacks() << { | |
println "Checking all modules i jpack.xml" | |
def modules = new XmlSlurper().parseText(new File('ConfigurationFiles/jpacks.xml').getText()) | |
modules.package.each { pack -> | |
def buildFile = new File('../' + pack.toString() + '/build.gradle') | |
if (!buildFile.canRead()) { | |
println "No buildfile for $pack " | |
} else { | |
if (buildFile.getText().startsWith(' TODO')) { | |
println "Buildfile in module $pack is not implemented yet" | |
} | |
} | |
} | |
} | |
// make sure all tests are run | |
test { | |
dependsOn subprojects*.test | |
testReport = true | |
beforeTest { descriptor -> | |
println("Running test: " + descriptor) | |
} | |
} | |
dependencies { | |
bnd withLibrary("stiboWraptask") | |
bnd withLibrary("bndlib") | |
compile gradleApi() | |
groovy localGroovy() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment