diff --git a/.gitignore b/.gitignore index 928ac4c2..f45d90bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,14 @@ .DS_Store .gradle/ .idea/ +.sourcerer build/ sourcerer-app.iml sourcerer-app.ipr sourcerer-app.iws +app.iml +app.ipr +app.iws /confluence/target /dependencies /dist @@ -16,4 +20,5 @@ sourcerer-app.iws /ultimate/dependencies /ultimate/ideaSDK /ultimate/out -/ultimate/tmp \ No newline at end of file +/ultimate/tmp +tmp_repo diff --git a/README.md b/README.md index 26a55ecd..1b859fd2 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,27 @@ -# sourcerer-app -Sourcerer hashes your git repos into intelligent engineering profiles. +

+
+ Sourcerer +
+ Sourcerer App +
+

+ +

+ Sourcerer app creates an intelligent profile for software engineers.
+ Get early access on Sourcerer. +

+
+ +Prerequirements +================= + +* Sourcerer alpha/beta access +* Linux or macOS +* Java Platform ([JRE](http://www.oracle.com/technetwork/java/javase/downloads/jre8-downloads-2133155.html) for Linux or [JDK](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html) for macOS) Install/uninstall ================= -Sourcerer requires Java Platform installed on the system. -* Proceed with JRE(http://www.oracle.com/technetwork/java/javase/downloads/jre8-downloads-2133155.html) if on Linux, -* or with JDK(http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html) if on OS X. - To install sourcerer run the following command: ``` diff --git a/build.gradle b/build.gradle index e9c81745..8c6e9c69 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,15 @@ // Copyright 2017 Sourcerer Inc. All Rights Reserved. buildscript { - ext.kotlin_version = '1.1.4-2' + ext.kotlin_version = '1.1.51' repositories { mavenCentral() jcenter() } dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.1" - classpath "org.junit.platform:junit-platform-gradle-plugin:1.0.0-RC2" + classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.3" + classpath "org.junit.platform:junit-platform-gradle-plugin:1.0.1" } } @@ -17,8 +17,6 @@ plugins { id 'de.fuerstenau.buildconfig' version '1.1.8' } - - apply plugin: "idea" apply plugin: "java" apply plugin: "kotlin" @@ -27,19 +25,11 @@ apply plugin: "com.google.protobuf" apply plugin: "org.junit.platform.gradle.plugin" buildConfig { - ext.environment = project.hasProperty('env') && - env == 'production' ? 'prod' : 'dev' - clsName = 'BuildConfig' packageName = 'app' // API. - def apiBasePath = 'https://staging.eng.sourcerer.io/api/commit' - if (ext.environment == 'prod') { - apiBasePath = 'https://sourcerer.io/api/commit' - } - apiBasePath = project.hasProperty('api') ? api : apiBasePath - + def apiBasePath = project.hasProperty('api') ? api : 'https://sourcerer.io/api/commit' buildConfigField 'String', 'API_BASE_PATH', apiBasePath // Common. @@ -49,11 +39,21 @@ buildConfig { buildConfigField 'int', 'VERSION_CODE', '1' buildConfigField 'String', 'VERSION', '0.0.1' + // Logging. + buildConfigField 'String', 'LOG_LEVEL', project.hasProperty('log') ? log : 'info' + // Google Analytics. buildConfigField 'String', 'GA_BASE_PATH', 'http://www.google-analytics.com' buildConfigField 'String', 'GA_TRACKING_ID', 'UA-107129190-2' buildConfigField 'boolean', 'IS_GA_ENABLED', 'true' + // Logging. + buildConfigField 'String', 'SENTRY_DSN', 'https://0263d6473bd24a9ba40e25aa5fb0a242:c5451dc815074bff8ce3fb9f0851f2f5@sentry.io/233260' + buildConfigField 'boolean', 'PRINT_STACK_TRACE', 'false' + + // Models storage path. + buildConfigField 'String', 'LIBRARY_MODELS_URL', 'https://storage.googleapis.com/sourcerer-app/library-models/v1/' + buildConfig } @@ -65,39 +65,49 @@ junitPlatform { } } +task cleanData { + delete 'build/libs/data' + delete 'build/kotlin/data' +} + +test.dependsOn cleanData + mainClassName = "app.MainKt" repositories { mavenCentral() jcenter() maven { url "http://dl.bintray.com/jetbrains/spek" } + flatDir { + dirs 'libs' + } } dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version" compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" compile "com.beust:jcommander:1.72" - compile 'com.google.protobuf:protobuf-java:3.0.0' - compile group: 'commons-codec', name: 'commons-codec', version: '1.5' + compile 'com.google.protobuf:protobuf-java:3.4.0' + compile 'commons-codec:commons-codec:1.5' compile 'com.fasterxml.jackson.core:jackson-databind:2.8.9' compile 'com.fasterxml.jackson.module:jackson-module-kotlin:2.8.9' compile 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.8.9' - compile "io.reactivex.rxjava2:rxjava:2.1.1" - compile 'com.github.kittinunf.fuel:fuel:1.9.0' - compile 'com.github.kittinunf.fuel:fuel-rxjava:1.9.0' - compile group: 'org.eclipse.jgit', name: 'org.eclipse.jgit', - version: '4.8.0.201706111038-r' - compile "org.slf4j:slf4j-nop:1.7.2" + compile 'io.reactivex.rxjava2:rxjava:2.1.6' + compile 'com.github.kittinunf.fuel:fuel:1.11.0' + compile 'com.github.kittinunf.fuel:fuel-rxjava:1.11.0' + compile 'org.eclipse.jgit:org.eclipse.jgit:4.9.0.201710071750-r' + compile 'org.slf4j:slf4j-nop:1.7.2' + compile 'io.sentry:sentry:1.6.0' testCompile 'org.jetbrains.kotlin:kotlin-test' - testCompile 'org.jetbrains.spek:spek-api:1.1.4' - testCompile 'org.junit.platform:junit-platform-runner:1.0.0-RC2' - testRuntime 'org.jetbrains.spek:spek-junit-platform-engine:1.1.4' + testCompile 'org.jetbrains.spek:spek-api:1.1.5' + testCompile 'org.junit.platform:junit-platform-runner:1.0.1' + testRuntime 'org.jetbrains.spek:spek-junit-platform-engine:1.1.5' } protobuf { protoc { - artifact = "com.google.protobuf:protoc:3.0.0" + artifact = "com.google.protobuf:protoc:3.4.0" } } diff --git a/deploy/Jenkinsfile b/deploy/Jenkinsfile index 6385668c..86703db0 100644 --- a/deploy/Jenkinsfile +++ b/deploy/Jenkinsfile @@ -1,6 +1,6 @@ podTemplate(label: 'build-pod-sourcerer-app', containers: [ - containerTemplate(name: 'jnlp', image: 'gcr.io/cloud-solutions-images/jenkins-k8s-slave', args: '${computer.jnlpmac} ${computer.name}'), + containerTemplate(name: 'jnlp', image: 'gcr.io/sourcerer-1377/jenkins-slave:v4', args: '${computer.jnlpmac} ${computer.name}'), containerTemplate(name: 'gradle', image: 'gcr.io/sourcerer-1377/gradle:4.2.0', ttyEnabled: true, command: 'tail -f /dev/null') ], envVars: [ @@ -12,11 +12,15 @@ podTemplate(label: 'build-pod-sourcerer-app', ] ) { node('build-pod-sourcerer-app') { - def namespace = 'staging' - def benv = 'development' + def namespace = 'sandbox' + def plog = 'debug' + if (env.BRANCH_NAME == 'master') { - benv = 'production' namespace = 'production' + plog = 'info' + } else if (env.BRANCH_NAME == 'develop') { + namespace = 'staging' + plog = 'info' } stage('checkout') { @@ -25,7 +29,7 @@ podTemplate(label: 'build-pod-sourcerer-app', stage('build jar and test') { container('gradle') { - sh("ENV=${benv} ./do.sh build_jar_inside") + sh("LOG=${plog} NAMESPACE=${namespace} ./do.sh build_jar_inside") } } diff --git a/deploy/production_env.sh b/deploy/production_env.sh new file mode 100644 index 00000000..5f4be620 --- /dev/null +++ b/deploy/production_env.sh @@ -0,0 +1 @@ +export REPLICAS="2" diff --git a/deploy/sandbox_env.sh b/deploy/sandbox_env.sh new file mode 100644 index 00000000..065d597f --- /dev/null +++ b/deploy/sandbox_env.sh @@ -0,0 +1 @@ +export REPLICAS="1" diff --git a/deploy/sourcerer-app-staging.yaml b/deploy/sourcerer-app-staging.yaml deleted file mode 100644 index d8b831f2..00000000 --- a/deploy/sourcerer-app-staging.yaml +++ /dev/null @@ -1,32 +0,0 @@ -kind: Service -metadata: - name: sourcerer-app - labels: - app: sourcerer-app -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - selector: - app: sourcerer-app ---- -apiVersion: apps/v1beta1 -kind: Deployment -metadata: - name: sourcerer-app -spec: - replicas: 1 - template: - metadata: - labels: - app: sourcerer-app - spec: - containers: - - name: sourcerer-app - image: gcr.io/sourcerer-1377/sourcerer-app:CONTAINER_TAG - imagePullPolicy: Always - resources: - requests: - cpu: 100m - memory: 100Mi diff --git a/deploy/sourcerer-app-production.yaml b/deploy/sourcerer-app.yaml similarity index 85% rename from deploy/sourcerer-app-production.yaml rename to deploy/sourcerer-app.yaml index 25e08d6a..f25aee41 100644 --- a/deploy/sourcerer-app-production.yaml +++ b/deploy/sourcerer-app.yaml @@ -16,7 +16,7 @@ kind: Deployment metadata: name: sourcerer-app spec: - replicas: 2 + replicas: $REPLICAS template: metadata: labels: @@ -24,7 +24,7 @@ spec: spec: containers: - name: sourcerer-app - image: gcr.io/sourcerer-1377/sourcerer-app:CONTAINER_TAG + image: gcr.io/sourcerer-1377/sourcerer-app:$CONTAINER_TAG imagePullPolicy: Always resources: requests: diff --git a/deploy/staging_env.sh b/deploy/staging_env.sh new file mode 100644 index 00000000..065d597f --- /dev/null +++ b/deploy/staging_env.sh @@ -0,0 +1 @@ +export REPLICAS="1" diff --git a/do.sh b/do.sh index 2acc09db..958dd401 100755 --- a/do.sh +++ b/do.sh @@ -4,6 +4,8 @@ #----- Helpers -----# #-------------------# +set -x + usage() { echo "$0 [COMMAND] [ARGUMENTS]" echo " Commands:" @@ -22,8 +24,8 @@ shift ARGUMENTS=${@} TAG="${CONTAINER_TAG:-latest}" -NAMESPACE="${NAMESPACE:-staging}" -ENV="${ENV:-development}" +NAMESPACE="${NAMESPACE:-sandbox}" +LOG="${LOG:-debug}" VOLUME="${BUILD_VOLUME:-$PWD}" PROJECT=sourcerer-app PORT=3182 @@ -36,12 +38,22 @@ GRADLE_VERSION=4.2.0 # run only inside build container build_jar_inside() { - gradle -Penv=$ENV build + if [ "$NAMESPACE" == "sandbox" ]; then + API="https://sandbox.eng.sourcerer.io/api/commit" + elif [ "$NAMESPACE" == "staging" ]; then + API="https://staging.eng.sourcerer.io/api/commit" + elif [ "$NAMESPACE" == "local" ]; then + API="http://localhost:3181" + else + API="https://sourcerer.io/api/commit" + fi + gradle -Plog=$LOG -Papi=$API build } build_jar() { - docker run -i -v $VOLUME:/home/gradle/app --workdir=/home/gradle/app -e ENV=$ENV \ - gradle:$GRADLE_VERSION \ + docker run -i -v $VOLUME:/home/gradle/app --workdir=/home/gradle/app \ + -e LOG=$LOG -e NAMESPACE=$NAMESPACE \ + gradle:$GRADLE_VERSION \ ./do.sh build_jar_inside } @@ -50,7 +62,8 @@ build_prod_inside() { } deploy() { - sed "s#CONTAINER_TAG#$TAG#" ./deploy/sourcerer-app-$NAMESPACE.yaml > /tmp/deploy.yaml + source ./deploy/${NAMESPACE}_env.sh + envsubst < ./deploy/sourcerer-app.yaml > /tmp/deploy.yaml kubectl --namespace=$NAMESPACE apply -f /tmp/deploy.yaml } diff --git a/src/install/install b/src/install/install index cb75f9f6..2e094e94 100644 --- a/src/install/install +++ b/src/install/install @@ -5,8 +5,8 @@ echo "Installing sourcerer app.." SERVER=$SERVER_EXT DOWNLOAD_URL=$SERVER/app/download -JAR_DIR=/usr/local/lib -SCRIPT_DIR=/usr/local/bin +SCRIPT_DIR=$HOME/.sourcerer +JAR_DIR=$SCRIPT_DIR if [ -f $SCRIPT_DIR/sourcerer ] ; then read -p "Previous version of sourcerer is detected. Reinstall it? [Y/n] " yesno < /dev/tty @@ -16,6 +16,8 @@ if [ -f $SCRIPT_DIR/sourcerer ] ; then fi fi +mkdir -p $SCRIPT_DIR +mkdir -p $JAR_DIR curl -s $DOWNLOAD_URL > $JAR_DIR/sourcerer.jar cat < $SCRIPT_DIR/sourcerer @@ -27,8 +29,9 @@ if [ "\$1" = "--uninstall" ] ; then exit fi + rm -f /usr/local/bin/sourcerer rm $SCRIPT_DIR/sourcerer - rm $JAR_DIR/sourcerer.jar + rm -r $JAR_DIR echo "Done!" exit @@ -45,10 +48,24 @@ if ! which java > /dev/null ; then exit 1 fi -java -jar $JAR_DIR/sourcerer.jar "\$@" +# Java 9 requires additional parameters. +version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}') +if [[ "$version" > "9." ]]; then # Version example for Java 9: 9.0.1 + java --add-modules java.activation --add-opens java.base/java.nio=ALL-UNNAMED -jar $JAR_DIR/sourcerer.jar "\$@" +else + java -jar $JAR_DIR/sourcerer.jar "\$@" +fi EOF chmod +x $SCRIPT_DIR/sourcerer -echo "Done!" -echo "Run sourcerer to start hashing your repos!" + +{ + rm -f /usr/local/bin/sourcerer 2> /dev/null && + ln -s $SCRIPT_DIR/sourcerer /usr/local/bin/sourcerer 2> /dev/null && + echo "Done!" && + echo "Run sourcerer to start hashing your repos!" +} || { + echo 'We installed app to $HOME/.sourcerer/sourcerer.' + echo 'You can add it to $PATH or ln it to /usr/local/bin' +} diff --git a/src/main/kotlin/app/Analytics.kt b/src/main/kotlin/app/Analytics.kt index ecbe740e..96b0da01 100644 --- a/src/main/kotlin/app/Analytics.kt +++ b/src/main/kotlin/app/Analytics.kt @@ -51,7 +51,7 @@ object Analytics { * - t: Hit Type - type of event * - dp: Document Path - virtual url */ - private fun trackEvent(event: String, params: List = listOf()) { + fun trackEvent(event: String, params: List = listOf()) { if (!IS_ENABLED || (username.isEmpty() && uuid.isEmpty())) { return } @@ -75,44 +75,16 @@ object Analytics { post(params + defaultParams.filter { !params.contains(it) } + idParams).responseString() } catch (e: Throwable) { - Logger.error("Error while sending error report", e, logOnly = true) + Logger.error(e, "Error while sending GA report", logOnly = true) } } - fun trackStart() { - trackEvent("start") - } - - fun trackAuth() { - trackEvent("auth") - } - - fun trackConfigSetup() { - trackEvent("config/setup") - } - - fun trackConfigChanged() { - trackEvent("config/changed") - } - - fun trackHashingRepoSuccess() { - trackEvent("hashing/repo/success") - } - - fun trackHashingSuccess() { - trackEvent("hashing/success") - } - - fun trackError(e: Throwable? = null, code: String = "") { - val url = if (e != null) getErrorUrl(e) else code + fun trackError(e: Throwable? = null) { + val url = if (e != null) getErrorUrl(e) else "" val separator = if (url.isNotEmpty()) "/" else "" trackEvent("error" + separator + url, listOf("t" to HIT_EXCEPTION)) } - fun trackExit() { - trackEvent("exit") - } - private fun getErrorUrl(e: Throwable): String { // Mapping for request exceptions. when (e) { diff --git a/src/main/kotlin/app/FactCodes.kt b/src/main/kotlin/app/FactCodes.kt index ccc5834a..572e1c0e 100644 --- a/src/main/kotlin/app/FactCodes.kt +++ b/src/main/kotlin/app/FactCodes.kt @@ -1,11 +1,20 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + package app object FactCodes { - val COMMITS_DAY_WEEK = 1 - val COMMITS_DAY_TIME = 2 - val LINE_LONGEVITY = 3 - val LINE_LONGEVITY_REPO = 4 - val REPO_DATE_START = 5 - val REPO_DATE_END = 6 - val REPO_TEAM_SIZE = 7 + val COMMIT_DAY_WEEK = 1 // Day of week fun fact and graph. + val COMMIT_DAY_TIME = 2 // Day time fun fact. + val COMMIT_LINE_NUM_AVG = 8 // Average number of lines per commit fun fact. + val COMMIT_NUM = 9 // Used for averaging COMMIT_LINE_NUM_AVG between repos. + // A map of line numbers to commits number. Used in a commit histogram. + val COMMIT_NUM_TO_LINE_NUM = 12 + val LINE_LONGEVITY = 3 // Used for longevity graph. + val LINE_LONGEVITY_REPO = 4 // Used for longevity graph. + val LINE_LEN_AVG = 10 // Average length of line fun fact. + val LINE_NUM = 11 // Used for averaging LINE_LEN_AVG between repos. + val REPO_DATE_START = 5 // Repo summary info. Date of first contribution. + val REPO_DATE_END = 6 // Repo summary info. Date of last contribution. + val REPO_TEAM_SIZE = 7 // Repo summary info. Number of contributors. } diff --git a/src/main/kotlin/app/Logger.kt b/src/main/kotlin/app/Logger.kt index 0b2d913f..ce29e243 100644 --- a/src/main/kotlin/app/Logger.kt +++ b/src/main/kotlin/app/Logger.kt @@ -3,77 +3,236 @@ package app +import io.sentry.Sentry +import io.sentry.context.Context +import io.sentry.event.Breadcrumb +import io.sentry.event.UserBuilder +import io.sentry.event.BreadcrumbBuilder +import java.util.* + + /** * Singleton class that logs events of different levels. */ object Logger { + object Events { + val START = "start" + val AUTH = "auth" + val CONFIG_SETUP = "config/setup" + val CONFIG_CHANGED = "config/changed" + val HASHING_REPO_SUCCESS = "hashing/repo/success" + val HASHING_SUCCESS = "hashing/success" + val EXIT = "exit" + } + /** * Current log level. All that higher than this level will not be displayed. */ - const val LEVEL = 3 + @kotlin.PublishedApi + internal val LEVEL : Int /** * Error level. */ - const val ERROR = 0 + @kotlin.PublishedApi + internal const val ERROR = 0 /** * Warning level. */ - const val WARN = 1 + @kotlin.PublishedApi + internal const val WARN = 1 /** * Information level. */ - const val INFO = 2 + @kotlin.PublishedApi + internal const val INFO = 2 /** * Debug level. */ - const val DEBUG = 3 + @kotlin.PublishedApi + internal const val DEBUG = 3 + + /** + * Trace level. For extremely detailed and high volume debug logs. + */ + @kotlin.PublishedApi + internal const val TRACE = 4 + + /** + * Print stack trace on error log. + */ + private const val PRINT_STACK_TRACE = BuildConfig.PRINT_STACK_TRACE + + /** + * Context of Sentry error reporting software for adding info. + */ + private val sentryContext: Context + + /** + * Username used for error reporting. + */ + var username: String? = null + set(value) { + sentryContext.user = UserBuilder().setUsername(value).build() + Analytics.username = value ?: "" + } + + var uuid: String? = null + set(value) { + Analytics.uuid = value ?: "" + } + + init { + Sentry.init(BuildConfig.SENTRY_DSN) + sentryContext = Sentry.getContext() + addTags() + LEVEL = configLevelValue() + } + + private fun configLevelValue() : Int { + val a = mapOf("trace" to TRACE, "debug" to DEBUG, "info" to INFO, "warn" to WARN, "error" to ERROR) + return a.getValue(BuildConfig.LOG_LEVEL) + } + + /** + * Utils. + */ + private fun Double.format(digits: Int, digitsFloating: Int) = + java.lang.String.format("%${digits}.${digitsFloating}f", this) + + private fun generateIndent(num: Int): String { + return 0.rangeTo(num).fold("") { ind, _ -> ind + " " } + } + + /** + * CLI messages and pretty printing. + */ + fun print(message: Any, indentLine: Boolean = false) { + print(message.toString(), indentLine) + } + + fun print(message: String, indentLine: Boolean = false) { + if (indentLine) { + println() + } + println(message) + } + + fun printCommit(commitMessage: String, commitHash: String, + percents: Double) { + val percentsStr = percents.format(6, 2) + val hash = commitHash.substring(0, 7) + val messageTrim = if (commitMessage.length > 59) { + commitMessage.substring(0, 56).plus("...") + } else commitMessage + println(" [$percentsStr%] * $hash $messageTrim") + } + + private val commitDetailIndent = generateIndent(10) + "|" + + generateIndent(8) + fun printCommitDetail(message: String) { + val messageTrim = if (message.length > 59) { + message.substring(0, 56).plus("...") + } else message + println(commitDetailIndent + messageTrim) + } /** * Log error message with exception info. + * Don't log private information with this method. * - * @property message the message for user and logs. * @property e the exception if presented. - * @property code the code of error if exception is not presented. + * @property message the message for user and logs. + * @property logOnly only log to console, no additional actions. */ - fun error(message: String, e: Throwable? = null, code: String = "", - logOnly: Boolean = false) { + fun error(e: Throwable, message: String = "", logOnly: Boolean = false) { + val finalMessage = if (message.isNotBlank()) { message + ": " } + else { "" } + e.message if (LEVEL >= ERROR) { - println("[e] $message" + if (e != null) ": $e" else "") + println("[e] $finalMessage") + if (PRINT_STACK_TRACE) { + e.printStackTrace() + } } if (!logOnly) { - Analytics.trackError(e = e, code = code) - //TODO(anatoly): Add error tracking software. + Analytics.trackError(e) + Sentry.capture(e) } + addBreadcrumb(finalMessage, Breadcrumb.Level.ERROR) } /** - * Log warning message. + * Log warning message. Don't log private information with this method. */ - fun warn(message: String) { + inline fun warn(message: () -> String) { + val msg = message() if (LEVEL >= WARN) { - println("[w] $message.") + println("[w] $msg.") } + addBreadcrumb(msg, Breadcrumb.Level.WARNING) } /** - * Log information message. + * Log information message. Don't log private information with this method. */ - fun info(message: String) { + inline fun info(event: String = "", message: () -> String) { + val msg = message() if (LEVEL >= INFO) { - println("[i] $message.") + println("[i] $msg.") + } + if (event.isNotBlank()) { + Analytics.trackEvent(event) } + addBreadcrumb(msg, Breadcrumb.Level.INFO) } /** * Log debug message. */ - fun debug(message: String) { + inline fun debug(message: () -> String) { if (LEVEL >= DEBUG) { - println("[d] $message.") + println("[d] ${message()}.") + } + } + + /** + * Log trace message. + */ + inline fun trace(message: () -> String) { + if (LEVEL >= TRACE) { + println("[t] ${message()}.") } } + + val isDebug: Boolean + inline get() = LEVEL >= DEBUG + + @kotlin.PublishedApi + internal fun addBreadcrumb(message: String, level: Breadcrumb.Level) { + sentryContext.recordBreadcrumb(BreadcrumbBuilder() + .setMessage(message) + .setLevel(level) + .setTimestamp(Date()) + .build()) + } + + private fun addTags() { + val default = "unavailable" + val osName = System.getProperty("os.name", default) + val osVersion = System.getProperty("os.version", default) + val javaVendor = System.getProperty("java.vendor", default) + val javaVersion = System.getProperty("java.version", default) + + sentryContext.addTag("log-level", BuildConfig.LOG_LEVEL) + sentryContext.addTag("version", BuildConfig.VERSION) + sentryContext.addTag("version-code", BuildConfig.VERSION_CODE + .toString()) + sentryContext.addTag("os-name", osName) + sentryContext.addTag("os-version", osVersion) + sentryContext.addTag("java-vendor", javaVendor) + sentryContext.addTag("java-version", javaVersion) + } } diff --git a/src/main/kotlin/app/Main.kt b/src/main/kotlin/app/Main.kt index 72be7309..110f0cb5 100644 --- a/src/main/kotlin/app/Main.kt +++ b/src/main/kotlin/app/Main.kt @@ -19,8 +19,8 @@ import com.beust.jcommander.JCommander import com.beust.jcommander.MissingCommandException fun main(argv : Array) { - Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable? -> - Logger.error("Uncaught exception", e) + Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable -> + Logger.error(e, "Uncaught exception") } Main(argv) } @@ -30,8 +30,8 @@ class Main(argv: Array) { private val api = ServerApi(configurator) init { - Analytics.uuid = configurator.getUuidPersistent() - Analytics.trackStart() + Logger.uuid = configurator.getUuidPersistent() + Logger.info(Logger.Events.START) { "App started" } val options = Options() val commandAdd = CommandAdd() @@ -64,13 +64,10 @@ class Main(argv: Array) { else -> startUi() } } catch (e: MissingCommandException) { - Logger.error( - message = "No such command: ${e.unknownCommand}", - code = "no-command" - ) + Logger.warn { "No such command: ${e.unknownCommand}" } } - Analytics.trackExit() + Logger.info(Logger.Events.EXIT) { "App finished" } } private fun startUi() { @@ -84,12 +81,10 @@ class Main(argv: Array) { localRepo.hashAllContributors = commandAdd.hashAll configurator.addLocalRepoPersistent(localRepo) configurator.saveToFile() - println("Added git repository at $path.") - - Analytics.trackConfigChanged() + Logger.print("Added git repository at $path.") + Logger.info(Logger.Events.CONFIG_CHANGED) { "Config changed" } } else { - Logger.error(message = "No valid git repository found at $path.", - code = "repo-invalid") + Logger.warn { "No valid git repository found at specified path" } } } @@ -97,8 +92,7 @@ class Main(argv: Array) { val (key, value) = commandOptions.pair if (!arrayListOf("username", "password").contains(key)) { - Logger.error(message = "No such key $key", - code = "invalid-params") + Logger.warn { "No such key $key" } return } @@ -109,7 +103,7 @@ class Main(argv: Array) { configurator.saveToFile() - Analytics.trackConfigChanged() + Logger.info(Logger.Events.CONFIG_CHANGED) { "Config changed" } } private fun doList() { @@ -124,11 +118,11 @@ class Main(argv: Array) { if (path != null) { configurator.removeLocalRepoPersistent(LocalRepo(path)) configurator.saveToFile() - println("Repository removed from tracking list.") + Logger.print("Repository removed from tracking list.") - Analytics.trackConfigChanged() + Logger.info(Logger.Events.CONFIG_CHANGED) { "Config changed" } } else { - println("Repository not found in tracking list.") + Logger.print("Repository not found in tracking list.") } } @@ -143,10 +137,12 @@ class Main(argv: Array) { } private fun showHelp(jc: JCommander) { - println("Sourcerer hashes your git repositories into intelligent " - + "engineering profiles. If you don't have an account, " - + "please, proceed to http://sourcerer.io/register. More info at " - + "http://sourcerer.io.") + Logger.print("Sourcerer hashes your git repositories into intelligent " + + "engineering profiles.") + Logger.print("If you don't have an account, please, proceed to " + + "https://sourcerer.io/join") + Logger.print("More info at https://sourcerer.io and " + + "https://github.com/sourcerer-io") jc.usage() // Will show detailed info about usage based on annotations. } } diff --git a/src/main/kotlin/app/api/Api.kt b/src/main/kotlin/app/api/Api.kt index 5b58a2e5..ea33d98e 100644 --- a/src/main/kotlin/app/api/Api.kt +++ b/src/main/kotlin/app/api/Api.kt @@ -3,17 +3,24 @@ package app.api +import app.model.Author import app.model.Commit import app.model.Fact import app.model.Repo import app.model.User interface Api { - fun authorize() - fun getUser(): User - fun getRepo(repoRehash: String): Repo - fun postRepo(repo: Repo) - fun postCommits(commitsList: List) - fun deleteCommits(commitsList: List) - fun postFacts(factsList: List) + companion object { + val OUT_OF_DATE = 1 + } + + fun authorize(): Result + fun getUser(): Result + fun postUser(user: User): Result + fun postRepo(repo: Repo): Result + fun postComplete(): Result + fun postCommits(commitsList: List): Result + fun deleteCommits(commitsList: List): Result + fun postFacts(factsList: List): Result + fun postAuthors(authorsList: List): Result } diff --git a/src/main/kotlin/app/api/ApiError.kt b/src/main/kotlin/app/api/ApiError.kt new file mode 100644 index 00000000..1093cf1e --- /dev/null +++ b/src/main/kotlin/app/api/ApiError.kt @@ -0,0 +1,75 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.api + +import app.Logger +import app.model.Error +import app.model.Errors +import com.github.kittinunf.fuel.core.FuelError +import com.google.protobuf.InvalidProtocolBufferException +import java.nio.charset.Charset +import java.security.InvalidParameterException + +class ApiError(exception: Exception) : Exception(exception.message) { + companion object { + private val AUTH_ERROR_CODES = listOf(401, 403) + } + + // Response content. + var httpStatusCode: Int = 0 + var httpResponseMessage: String = "" + var httpBodyMessage: String = "" + + // Server errors from response. + var serverErrors = listOf() + + // Type of errors. + var isParseError = false + var isAuthError: Boolean = false + get() = AUTH_ERROR_CODES.contains(httpStatusCode) + + constructor(fuelError: FuelError) : this(fuelError as Exception) { + httpStatusCode = fuelError.response.statusCode + httpResponseMessage = fuelError.response.responseMessage + if (fuelError.response.headers["Content-Type"] + ?.contains("application/octet-stream") == true) { + try { + serverErrors = Errors(fuelError.response.data).errors + } catch (e: Exception) { + Logger.error(e, "Error while parsing errors from server") + } + } else { + httpBodyMessage = fuelError.response.data + .toString(Charset.defaultCharset()) + } + } + + constructor(parseException: InvalidProtocolBufferException) : + this(parseException as Exception) { + isParseError = true + } + + constructor(parseException: InvalidParameterException) : + this(parseException as Exception) { + isParseError = true + } + + fun isWithServerCode(serverErrorCode: Int): Boolean { + return serverErrors.find { error -> + error.code == serverErrorCode } != null + } +} + +fun ApiError?.ifNotNullThrow() { + if (this != null) { + throw this + } +} + +fun ApiError?.isWithServerCode(serverErrorCode: Int): Boolean { + if (this != null) { + return this.isWithServerCode(serverErrorCode) + } + return false +} diff --git a/src/main/kotlin/app/api/MockApi.kt b/src/main/kotlin/app/api/MockApi.kt index 5ac03d23..667d38ad 100644 --- a/src/main/kotlin/app/api/MockApi.kt +++ b/src/main/kotlin/app/api/MockApi.kt @@ -4,6 +4,7 @@ package app.api import app.Logger +import app.model.Author import app.model.Commit import app.model.Repo import app.model.Fact @@ -17,43 +18,66 @@ class MockApi( // GET requests. var receivedRepos: MutableList = mutableListOf() var receivedAddedCommits: MutableList = mutableListOf() var receivedFacts: MutableList = mutableListOf() + var receivedAuthors: MutableList = mutableListOf() + var receivedUsers: MutableList = mutableListOf() + var receivedComplete: Int = 0 // DELETE requests. var receivedDeletedCommits: MutableList = mutableListOf() - override fun authorize() { - Logger.debug("MockApi: authorize request") + override fun authorize(): Result { + Logger.debug { "MockApi: authorize request" } + return Result() } - override fun getUser(): User { - Logger.debug("MockApi: getUser request") - return mockUser + override fun getUser(): Result { + Logger.debug { "MockApi: getUser request" } + return Result(mockUser) } - override fun getRepo(repoRehash: String): Repo { - Logger.debug("MockApi: getRepo request") - return mockRepo + override fun postUser(user: User): Result { + Logger.debug { "MockApi: postUser request" } + receivedUsers.add(user) + return Result() } - override fun postRepo(repo: Repo) { - Logger.debug("MockApi: postRepo request ($repo)") + override fun postRepo(repo: Repo): Result { + Logger.debug { "MockApi: postRepo request" } receivedRepos.add(repo) + return Result(mockRepo) } - override fun postCommits(commitsList: List) { - Logger.debug("MockApi: postCommits request " - + "(${commitsList.size} commits)") + override fun postComplete(): Result { + Logger.debug { "MockApi: postComplete request " } + receivedComplete++ + return Result() + } + + override fun postCommits(commitsList: List): Result { + Logger.debug { + "MockApi: postCommits request (${commitsList.size} commits)" + } receivedAddedCommits.addAll(commitsList) + return Result() } - override fun deleteCommits(commitsList: List) { - Logger.debug("MockApi: deleteCommits request " - + "(${commitsList.size} commits)") + override fun deleteCommits(commitsList: List): Result { + Logger.debug { + "MockApi: deleteCommits request (${commitsList.size} commits)" } receivedDeletedCommits.addAll(commitsList) + return Result() } - override fun postFacts(factsList: List) { - Logger.debug("MockApi: postStats request (${factsList.size} stats)") + override fun postFacts(factsList: List): Result { + Logger.debug { "MockApi: postFacts request (${factsList.size} facts)" } receivedFacts.addAll(factsList) + return Result() + } + + override fun postAuthors(authorsList: List): Result { + Logger.debug { "MockApi: postAuthors request (${authorsList.size} " + + "stats)" } + receivedAuthors.addAll(authorsList) + return Result() } } diff --git a/src/main/kotlin/app/api/Result.kt b/src/main/kotlin/app/api/Result.kt new file mode 100644 index 00000000..9a56a26a --- /dev/null +++ b/src/main/kotlin/app/api/Result.kt @@ -0,0 +1,19 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.api + +data class Result (val data: T? = null, val error: ApiError? = null) { + fun getOrThrow(): T { + if (error == null) { + return data!! + } + throw error + } + + fun onErrorThrow() { + if (error != null) { + throw error + } + } +} diff --git a/src/main/kotlin/app/api/ServerApi.kt b/src/main/kotlin/app/api/ServerApi.kt index 65ab05a0..25622c7e 100644 --- a/src/main/kotlin/app/api/ServerApi.kt +++ b/src/main/kotlin/app/api/ServerApi.kt @@ -6,13 +6,14 @@ package app.api import app.BuildConfig import app.Logger import app.config.Configurator +import app.model.Author +import app.model.AuthorGroup import app.model.Commit import app.model.CommitGroup import app.model.Fact import app.model.FactGroup import app.model.Repo import app.model.User -import app.utils.RequestException import com.github.kittinunf.fuel.core.FuelManager import com.github.kittinunf.fuel.core.Method import com.github.kittinunf.fuel.core.Request @@ -41,11 +42,10 @@ class ServerApi (private val configurator: Configurator) : Api { } private fun cookieResponseInterceptor() = { _: Request, res: Response -> - val newToken = res.httpResponseHeaders[HEADER_SET_COOKIE] + val newToken = res.headers[HEADER_SET_COOKIE] ?.find { it.startsWith(KEY_TOKEN) } if (newToken != null && newToken.isNotBlank()) { - token = newToken.substringAfter(KEY_TOKEN) - .substringBefore(';') + token = newToken.substringAfter(KEY_TOKEN).substringBefore(';') } res } @@ -76,15 +76,16 @@ class ServerApi (private val configurator: Configurator) : Api { private fun createRequestGetToken(): Request { return post("/auth").authenticate(username, password) - .header(getVersionCodeHeader()) + .header(getVersionCodeHeader()) } private fun createRequestGetUser(): Request { return get("/user") } - private fun createRequestGetRepo(repoRehash: String): Request { - return get("/repo/$repoRehash") + private fun createRequestPostUser(user: User): Request { + return post("/user").header(getContentTypeHeader()) + .body(user.serialize()) } private fun createRequestPostRepo(repo: Repo): Request { @@ -92,6 +93,10 @@ class ServerApi (private val configurator: Configurator) : Api { .body(repo.serialize()) } + private fun createRequestPostComplete(): Request { + return post("/complete").header(getContentTypeHeader()) + } + private fun createRequestPostCommits(commits: CommitGroup): Request { return post("/commits").header(getContentTypeHeader()) .body(commits.serialize()) @@ -107,27 +112,34 @@ class ServerApi (private val configurator: Configurator) : Api { .body(facts.serialize()) } + private fun createRequestPostAuthors(authors: AuthorGroup): Request { + return post("/authors").header(getContentTypeHeader()) + .body(authors.serialize()) + } + private fun makeRequest(request: Request, requestName: String, - parser: (ByteArray) -> T): T { + parser: (ByteArray) -> T): Result { + var error: ApiError? = null + var data: T? = null + try { - Logger.debug("Request $requestName initialized") + Logger.debug { "Request $requestName initialized" } val (_, res, result) = request.responseString() val (_, e) = result if (e == null) { - Logger.debug("Request $requestName success") - return parser(res.data) + Logger.debug { "Request $requestName success" } + data = parser(res.data) } else { - Logger.error("Request $requestName error", e) - throw RequestException(e) + error = ApiError(e) } } catch (e: InvalidProtocolBufferException) { - Logger.error("Request $requestName error while parsing", e) - throw RequestException(e) + error = ApiError(e) } catch (e: InvalidParameterException) { - Logger.error("Request $requestName error while parsing", e) - throw RequestException(e) + error = ApiError(e) } + + return Result(data, error) } private fun getVersionCodeHeader(): Pair { @@ -138,43 +150,52 @@ class ServerApi (private val configurator: Configurator) : Api { return Pair(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_PROTO) } - override fun authorize() { + override fun authorize(): Result { return makeRequest(createRequestGetToken(), "getToken", {}) } - override fun getUser(): User { + override fun getUser(): Result { return makeRequest(createRequestGetUser(), "getUser", { body -> User(body) }) } - override fun getRepo(repoRehash: String): Repo { - if (repoRehash.isBlank()) { + override fun postUser(user: User): Result { + return makeRequest(createRequestPostUser(user), "postUser", {}) + } + + override fun postRepo(repo: Repo): Result { + if (repo.rehash.isBlank()) { throw IllegalArgumentException() } - return makeRequest(createRequestGetRepo(repoRehash), "getRepo", + return makeRequest(createRequestPostRepo(repo), "getRepo", { body -> Repo(body) }) } - override fun postRepo(repo: Repo) { - makeRequest(createRequestPostRepo(repo), - "postRepo", {}) + override fun postComplete(): Result { + return makeRequest(createRequestPostComplete(), + "postComplete", {}) } - override fun postCommits(commitsList: List) { + override fun postCommits(commitsList: List): Result { val commits = CommitGroup(commitsList) - makeRequest(createRequestPostCommits(commits), - "postCommits", {}) + return makeRequest(createRequestPostCommits(commits), + "postCommits", {}) } - override fun deleteCommits(commitsList: List) { + override fun deleteCommits(commitsList: List): Result { val commits = CommitGroup(commitsList) - makeRequest(createRequestDeleteCommits(commits), - "deleteCommits", {}) + return makeRequest(createRequestDeleteCommits(commits), + "deleteCommits", {}) } - override fun postFacts(factsList: List) { + override fun postFacts(factsList: List): Result { val facts = FactGroup(factsList) - makeRequest(createRequestPostFacts(facts), "postFacts", {}) + return makeRequest(createRequestPostFacts(facts), "postFacts", {}) + } + + override fun postAuthors(authorsList: List): Result { + val authors = AuthorGroup(authorsList) + return makeRequest(createRequestPostAuthors(authors), "postAuthors", {}) } } diff --git a/src/main/kotlin/app/config/Configurator.kt b/src/main/kotlin/app/config/Configurator.kt index 0cab1036..c9960ed0 100644 --- a/src/main/kotlin/app/config/Configurator.kt +++ b/src/main/kotlin/app/config/Configurator.kt @@ -4,7 +4,7 @@ package app.config import app.model.LocalRepo -import app.model.Repo +import app.model.User import app.utils.Options interface Configurator { @@ -13,7 +13,7 @@ interface Configurator { fun getPassword(): String fun isValidCredentials(): Boolean fun getLocalRepos(): List - fun getRepos(): List + fun getUser(): User fun setUsernameCurrent(username: String) fun setPasswordCurrent(password: String) fun getUuidPersistent(): String @@ -21,7 +21,7 @@ interface Configurator { fun setPasswordPersistent(password: String) fun addLocalRepoPersistent(localRepo: LocalRepo) fun removeLocalRepoPersistent(localRepo: LocalRepo) - fun setRepos(repos: List) + fun setUser(user: User) fun isFirstLaunch(): Boolean fun loadFromFile() fun saveToFile() diff --git a/src/main/kotlin/app/config/FileConfigurator.kt b/src/main/kotlin/app/config/FileConfigurator.kt index 117e9b22..c90ebad2 100644 --- a/src/main/kotlin/app/config/FileConfigurator.kt +++ b/src/main/kotlin/app/config/FileConfigurator.kt @@ -5,7 +5,8 @@ package app.config import app.Logger import app.model.LocalRepo -import app.model.Repo +import app.model.User +import app.utils.FileHelper import app.utils.Options import app.utils.PasswordHelper import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility @@ -16,11 +17,9 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.module.kotlin.KotlinModule import java.io.IOException -import java.lang.IllegalStateException import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.NoSuchFileException -import java.nio.file.Paths import java.util.UUID /** @@ -30,7 +29,7 @@ class FileConfigurator : Configurator { /** * Persistent configuration file name. */ - private val CONFIG_FILE_NAME = ".sourcerer" + private val CONFIG_FILE_NAME = "config.yaml" // Config levels are presented in priority decreasing order. @@ -40,7 +39,7 @@ class FileConfigurator : Configurator { private var current: Config = Config() /** - * Persistent configuration saved in [userDir] in YAML format. + * Persistent configuration saved in config file in YAML format. */ private var persistent: Config = Config() @@ -63,18 +62,7 @@ class FileConfigurator : Configurator { /** * Used to temporarily save list of repos that known by server. */ - private var repos: List = listOf() - - /** - * User directory path is where persistent config stored. - */ - private val userDir = try { - System.getProperty("user.home") - } - catch (e: SecurityException) { - Logger.error("Cannot access user directory", e) - null - } + private var user: User = User() /** * Jackson's ObjectMapper. @@ -144,10 +132,10 @@ class FileConfigurator : Configurator { } /** - * Gets list of temprorary saved repos. + * Gets temprorary saved user information. */ - override fun getRepos(): List { - return repos + override fun getUser(): User { + return user } /** @@ -200,10 +188,10 @@ class FileConfigurator : Configurator { } /** - * Temporarily sets list of repos. + * Temporarily sets info about user. */ - override fun setRepos(repos: List) { - this.repos = repos + override fun setUser(user: User) { + this.user = user } /** @@ -216,62 +204,58 @@ class FileConfigurator : Configurator { } /** - * Loads [persistent] configuration from config file stored in [userDir]. + * Loads [persistent] configuration from config file. */ override fun loadFromFile() { - if (userDir == null) { - return - } - // Сonfig initialization in case an exception is thrown. var loadConfig = Config() try { - loadConfig = Files.newBufferedReader(Paths.get(userDir, - CONFIG_FILE_NAME)).use { + loadConfig = Files.newBufferedReader(FileHelper + .getPath(CONFIG_FILE_NAME)).use { mapper.readValue(it, Config::class.java) } } catch (e: IOException) { - if(e is NoSuchFileException){ - Logger.info("No config file found") + if (e is NoSuchFileException){ + Logger.warn { "No config file found" } } else { - Logger.error("Cannot access config file", e) + Logger.error(e, "Cannot access config file") } } catch (e: SecurityException) { - Logger.error("Cannot access config file", e) + Logger.error(e, "Cannot access config file") } catch (e: InvalidPathException) { - Logger.error("Cannot access config file", e) + Logger.error(e, "Cannot access config file") } catch (e: JsonParseException) { - Logger.error("Cannot parse config file", e) + Logger.error(e, "Cannot parse config file") } catch (e: JsonMappingException) { - Logger.error("Cannot parse config file", e) + Logger.error(e, "Cannot parse config file") } catch (e: IllegalStateException) { - Logger.error("Cannot parse config file", e) + Logger.error(e, "Cannot parse config file") } persistent = loadConfig } /** - * Saves [persistent] configuration to config file stored in [userDir]. + * Saves [persistent] configuration to config file. */ override fun saveToFile() { try { - Files.newBufferedWriter(Paths.get(userDir, CONFIG_FILE_NAME)).use { + Files.newBufferedWriter(FileHelper.getPath(CONFIG_FILE_NAME)).use { mapper.writeValue(it, persistent) } } catch (e: IOException) { - Logger.error("Cannot save config file", e) + Logger.error(e, "Cannot save config file") } catch (e: SecurityException) { - Logger.error("Cannot save config file", e) + Logger.error(e, "Cannot save config file") } catch (e: InvalidPathException) { - Logger.error("Cannot save config file", e) + Logger.error(e, "Cannot save config file") } catch (e: JsonParseException) { - Logger.error("Cannot parse config file", e) + Logger.error(e, "Cannot parse config file") } catch (e: JsonMappingException) { - Logger.error("Cannot parse config file", e) + Logger.error(e, "Cannot parse config file") } catch (e: IllegalStateException) { - Logger.error("Cannot parse config file", e) + Logger.error(e, "Cannot parse config file") } } diff --git a/src/main/kotlin/app/config/MockConfigurator.kt b/src/main/kotlin/app/config/MockConfigurator.kt index 1b482c23..75c30bbf 100644 --- a/src/main/kotlin/app/config/MockConfigurator.kt +++ b/src/main/kotlin/app/config/MockConfigurator.kt @@ -4,14 +4,14 @@ package app.config import app.model.LocalRepo -import app.model.Repo +import app.model.User import app.utils.Options class MockConfigurator(var mockUsername: String = "", var mockPassword: String = "", var mockIsValidCredentials: Boolean = true, var mockIsFirstLaunch: Boolean = true, - var mockRepos: MutableList = mutableListOf(), + var mockUser: User = User(), var mockLocalRepos: MutableList = mutableListOf(), var uuid: String = "") : Configurator { @@ -39,8 +39,8 @@ class MockConfigurator(var mockUsername: String = "", return mockLocalRepos } - override fun getRepos(): List { - return mockRepos + override fun getUser(): User { + return mockUser } override fun setUsernameCurrent(username: String) { @@ -72,8 +72,8 @@ class MockConfigurator(var mockUsername: String = "", mockPersistent.localRepos.remove(localRepo) } - override fun setRepos(repos: List) { - mockRepos = repos.toMutableList() + override fun setUser(user: User) { + mockUser = user } override fun isFirstLaunch(): Boolean { diff --git a/src/main/kotlin/app/extractors/CExtractor.kt b/src/main/kotlin/app/extractors/CExtractor.kt index f68539d1..1f01d4a6 100644 --- a/src/main/kotlin/app/extractors/CExtractor.kt +++ b/src/main/kotlin/app/extractors/CExtractor.kt @@ -11,6 +11,9 @@ class CExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "c" val FILE_EXTS = listOf("c") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -32,4 +35,18 @@ class CExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^([^\n]*#include)\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/CSharpExtractor.kt b/src/main/kotlin/app/extractors/CSharpExtractor.kt index 95395eba..8f446255 100644 --- a/src/main/kotlin/app/extractors/CSharpExtractor.kt +++ b/src/main/kotlin/app/extractors/CSharpExtractor.kt @@ -9,9 +9,12 @@ import app.model.DiffFile class CSharpExtractor : ExtractorInterface { companion object { - val LANGUAGE_NAME = "cs" + val LANGUAGE_NAME = "csharp" val FILE_EXTS = listOf("cs") val LIBRARIES = ExtractorInterface.getLibraries("cs") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -37,4 +40,18 @@ class CSharpExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^.*using\s+(\w+[.\w+]*)""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/Classifier.kt b/src/main/kotlin/app/extractors/Classifier.kt new file mode 100644 index 00000000..7c261ced --- /dev/null +++ b/src/main/kotlin/app/extractors/Classifier.kt @@ -0,0 +1,59 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Liubov Yaronskaya (lyaronskaya@sourcerer.io) + +package app.extractors + +import app.ModelsProtos +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + +class Classifier { + var tokens: List + var libraries: List + var idf: Map + var weights: Map> + var biases: Map + + @Throws(InvalidParameterException::class) + constructor(proto: ModelsProtos.Classifier) { + tokens = proto.tokensList + libraries = proto.librariesList + idf = tokens.zip(proto.idfList).toMap() + weights = libraries.zip(proto.weightsList.partition(tokens.size) + .map {it: List -> tokens.zip(it).toMap()}).toMap() + biases = libraries.zip(proto.biasesList).toMap() + } + + @Throws(InvalidProtocolBufferException::class) + constructor(bytes: ByteArray) : this(ModelsProtos.Classifier + .parseFrom(bytes)) + + fun evaluate(input: List): List { + val inputTokens = input.filter { it in tokens} + val tokensWithWeight = inputTokens.groupBy { it } + .map { (token, tokens) -> Pair(token, tokens.size * idf[token]!!) } + .toMap() + val norm = Math.sqrt(tokensWithWeight + .map { (_, tfidf) -> tfidf * tfidf } + .sum() + 1e-7) + val output = libraries.map { + Math.exp(tokensWithWeight + .map { (token, tfidf) -> tfidf / norm * weights[it]!![token]!! } + .sum() + biases[it]!!) + } + val norm2 = output.sum() + val probs = output.map { it / norm2 } + + return probs + } + + fun getCategories(): List { + return libraries + } + + private fun List.partition(size: Int): List> { + return this.withIndex() + .groupBy { it.index / size } + .map { group -> group.value.map { it.value } } + } +} diff --git a/src/main/kotlin/app/extractors/CommonExtractor.kt b/src/main/kotlin/app/extractors/CommonExtractor.kt new file mode 100644 index 00000000..d3036483 --- /dev/null +++ b/src/main/kotlin/app/extractors/CommonExtractor.kt @@ -0,0 +1,79 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.extractors + +import app.model.CommitStats +import app.model.DiffFile + +class CommonExtractor : ExtractorInterface { + companion object { + val FILE_EXTS_MAP = lazy { + val reversedMap = mutableMapOf>() + reversedMap.put("actionscript", listOf("as")) + reversedMap.put("arduino", listOf("ino")) + reversedMap.put("assembly", listOf("asm", "s", "S")) + reversedMap.put("clojure", listOf("clj", "cljs", "cljc", "edn")) + reversedMap.put("cobol", listOf("cbl", "cob", "cpy")) + reversedMap.put("coffeescript", listOf("coffee", "litcoffee")) + reversedMap.put("cuda", listOf("cu", "cuh")) + reversedMap.put("d", listOf("d")) + reversedMap.put("emacslisp", listOf("el", "elc")) + reversedMap.put("erlang", listOf("erl", "hrl")) + reversedMap.put("forth", listOf("forth", "4TH")) + reversedMap.put("fortran", listOf("f", "for", "f90", "f95", "f03", + "f08", "f15")) + reversedMap.put("gradle", listOf("gradle")) + reversedMap.put("groovy", listOf("groovy")) + reversedMap.put("haskell", listOf("hs", "lhs")) + reversedMap.put("html", listOf("html", "htm")) + reversedMap.put("j", listOf("ijs")) + reversedMap.put("julia", listOf("jl")) + reversedMap.put("kotlin", listOf("kt")) + reversedMap.put("lisp", listOf("lisp", "lsp", "l")) + reversedMap.put("lua", listOf("lua")) + reversedMap.put("makefile", listOf("makefile")) + reversedMap.put("matlab", listOf("m", "mlx")) + reversedMap.put("maven", listOf("pom")) + reversedMap.put("ocaml", listOf("ml", "mli")) + reversedMap.put("pascal", listOf("pas")) + reversedMap.put("perl", listOf("pl", "PL")) + reversedMap.put("powershell", listOf("ps1", "psm1", "psd1")) + reversedMap.put("processing", listOf("pde")) + reversedMap.put("prolog", listOf("pl", "pro", "P")) + reversedMap.put("puppet", listOf("pp")) + reversedMap.put("r", listOf("r", "R")) + reversedMap.put("rust", listOf("rs")) + reversedMap.put("sas", listOf("sas")) + reversedMap.put("scala", listOf("scala", "sc")) + reversedMap.put("scheme", listOf("scm", "ss")) + reversedMap.put("shell", listOf("sh")) + reversedMap.put("sql", listOf("sql")) + reversedMap.put("tcl", listOf("tcl")) + reversedMap.put("tex", listOf("tex")) + reversedMap.put("typescript", listOf("ts", "tsx")) + reversedMap.put("verilog", listOf("v")) + reversedMap.put("vhdl", listOf("vhdl")) + reversedMap.put("viml", listOf("vim")) + reversedMap.put("visualbasic", listOf("bas")) + reversedMap.put("vue", listOf("vue")) + + val map = hashMapOf() + reversedMap.forEach({ lang, exts -> + exts.forEach { ext -> map.put(ext, lang)} + }) + map + } + } + + override fun extract(files: List): List { + files.mapNotNull { file -> + val lang = FILE_EXTS_MAP.value[file.extension] + if (lang != null) { + file.language = lang + file + } else null + } + return super.extract(files) + } +} diff --git a/src/main/kotlin/app/extractors/CppExtractor.kt b/src/main/kotlin/app/extractors/CppExtractor.kt index 29828cbd..2e40cb6f 100644 --- a/src/main/kotlin/app/extractors/CppExtractor.kt +++ b/src/main/kotlin/app/extractors/CppExtractor.kt @@ -11,6 +11,11 @@ class CppExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "cpp" val FILE_EXTS = listOf("cc", "cpp", "cxx", "c++") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } + val MULTI_IMPORT_TO_LIB = + ExtractorInterface.getMultipleImportsToLibraryMap(LANGUAGE_NAME) } override fun extract(files: List): List { @@ -30,6 +35,21 @@ class CppExtractor : ExtractorInterface { } } - return imports.toList() + val libraries = imports.map { MULTI_IMPORT_TO_LIB.getOrDefault(it, it) } + return libraries + } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^([^\n]*#include)\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) } } diff --git a/src/main/kotlin/app/extractors/EmptyExtractor.kt b/src/main/kotlin/app/extractors/EmptyExtractor.kt deleted file mode 100644 index eeb4cb11..00000000 --- a/src/main/kotlin/app/extractors/EmptyExtractor.kt +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2017 Sourcerer Inc. All Rights Reserved. -// Author: Anatoly Kislov (anatoly@sourcerer.io) -// Author: Liubov Yaronskaya (lyaronskaya@sourcerer.io) - -package app.extractors - -import app.model.CommitStats -import app.model.DiffFile - -class EmptyExtractor : ExtractorInterface { - override fun extract(files: List): List { - return listOf() - } - - override fun extractImports(fileContent: List): List { - return listOf() - } -} diff --git a/src/main/kotlin/app/extractors/Extractor.kt b/src/main/kotlin/app/extractors/Extractor.kt index d90b537f..4fbc9d3b 100644 --- a/src/main/kotlin/app/extractors/Extractor.kt +++ b/src/main/kotlin/app/extractors/Extractor.kt @@ -10,7 +10,8 @@ import app.model.DiffFile class Extractor : ExtractorInterface { companion object { val TYPE_LANGUAGE = 1 - val TYPE_KEYWORD = 2 + val TYPE_LIBRARY = 2 + val TYPE_KEYWORD = 3 val SEPARATOR = ">" } @@ -27,7 +28,7 @@ class Extractor : ExtractorInterface { in GoExtractor.FILE_EXTS -> GoExtractor() in ObjectiveCExtractor.FILE_EXTS -> ObjectiveCExtractor() in SwiftExtractor.FILE_EXTS -> SwiftExtractor() - else -> EmptyExtractor() + else -> CommonExtractor() } } diff --git a/src/main/kotlin/app/extractors/ExtractorInterface.kt b/src/main/kotlin/app/extractors/ExtractorInterface.kt index 165c923d..6fd89467 100644 --- a/src/main/kotlin/app/extractors/ExtractorInterface.kt +++ b/src/main/kotlin/app/extractors/ExtractorInterface.kt @@ -4,39 +4,220 @@ package app.extractors +import app.BuildConfig +import app.Logger import app.model.DiffFile import app.model.CommitStats +import app.utils.FileHelper +import java.io.InputStream +import java.io.FileOutputStream +import org.apache.http.client.methods.HttpGet +import org.apache.http.impl.client.HttpClientBuilder interface ExtractorInterface { companion object { - fun getLibraries(name: String): Set { + private val librariesCache = hashMapOf>() + private val classifiersCache = hashMapOf() + private val modelsDir = "models" + private val pbExt = ".pb" + + private fun getResource(path: String): InputStream { return ExtractorInterface::class.java.classLoader - .getResourceAsStream("data/libraries/${name}_libraries.txt") + .getResourceAsStream(path) + } + + fun getLibraries(name: String): Set { + if (librariesCache.containsKey(name)) { + return librariesCache[name]!! + } + val libraries = getResource("data/libraries/${name}_libraries.txt") .bufferedReader().readLines().toSet() + librariesCache.put(name, libraries) + return libraries + } + + fun getMultipleImportsToLibraryMap(name: String): Map { + val importToLibrary = getResource("data/imports/$name.txt") + .bufferedReader().readLines().map { + val mapping = it.split(":") + Pair(mapping[0], mapping[1]) + }.toMap() + return importToLibrary + } + + private fun downloadModel(name: String) { + val url = BuildConfig.LIBRARY_MODELS_URL + "$name.pb" + + val file = FileHelper.getFile(name + pbExt, modelsDir) + val builder = HttpClientBuilder.create() + val client = builder.build() + try { + client.execute(HttpGet(url)).use { response -> + val entity = response.entity + if (entity != null) { + FileOutputStream(file).use { outstream -> + entity.writeTo(outstream) + outstream.flush() + outstream.close() + } + } + + } + } + catch (e: Exception) { + Logger.error(e, "Failed to download $name model") + } + } + + fun getLibraryClassifier(name: String): Classifier { + if (classifiersCache.containsKey(name)) { + return classifiersCache[name]!! + } + + val fileName = name + pbExt + if (FileHelper.notExists(fileName, modelsDir)) { + Logger.info { "Downloading " + fileName } + downloadModel(name) + Logger.info { "Downloaded " + fileName } + } + + Logger.info { "Loading $name evaluator" } + + val bytesArray = FileHelper.getFile(fileName, modelsDir).readBytes() + val classifier = Classifier(bytesArray) + classifiersCache.put(name, classifier) + + Logger.info { "$name evaluator ready" } + + return classifier } } fun extract(files: List): List { + val langStats = files.filter { file -> file.language.isNotBlank() } + .groupBy { file -> file.language } + .map { (language, files) -> CommitStats( + numLinesAdded = files.fold(0) { total, file -> + total + file.getAllAdded().size }, + numLinesDeleted = files.fold(0) { total, file -> + total + file.getAllDeleted().size }, + type = Extractor.TYPE_LANGUAGE, + tech = language) + } + files.map { file -> file.old.imports = extractImports(file.old.content) file.new.imports = extractImports(file.new.content) file } - return files.filter { file -> file.language.isNotBlank() } - .groupBy { file -> file.language } - .map { (language, files) -> CommitStats( - numLinesAdded = files.fold(0) { total, file -> - total + file.getAllAdded().size }, - numLinesDeleted = files.fold(0) { total, file -> - total + file.getAllDeleted().size }, - type = Extractor.TYPE_LANGUAGE, - tech = language)} + val oldLibraryToCount = mutableMapOf() + val newLibraryToCount = mutableMapOf() + val oldFilesImports = files.fold(mutableSetOf()) { acc, file -> + acc.addAll(file.old.imports) + acc + } + val newFilesImports = files.fold(mutableSetOf()) { acc, file -> + acc.addAll(file.new.imports) + acc + } + + // Skip library stats calculation if no imports found. + if (oldFilesImports.isEmpty() && newFilesImports.isEmpty()) { + return langStats + } + + oldFilesImports.forEach { oldLibraryToCount[it] = 0} + newFilesImports.forEach { newLibraryToCount[it] = 0} + + files.filter { file -> file.language.isNotBlank() } + .forEach { file -> + val oldFileLibraries = mutableListOf() + file.getAllDeleted().forEach { + val lineLibs = getLineLibraries(it, file.old.imports) + oldFileLibraries.addAll(lineLibs) + } + file.old.imports.forEach { import -> + val numLines = oldFileLibraries.count { it == import } + oldLibraryToCount[import] = + oldLibraryToCount[import] as Int + numLines + } + + val newFileLibraries = mutableListOf() + file.getAllAdded().forEach { + val lineLibs = getLineLibraries(it, file.new.imports) + newFileLibraries.addAll(lineLibs) + } + file.new.imports.forEach { import -> + val numLines = newFileLibraries.count { it == import } + newLibraryToCount[import] = + newLibraryToCount[import] as Int + numLines + } + } + + val allImports = mutableSetOf() + allImports.addAll(oldFilesImports + newFilesImports) + + val libraryStats = allImports.map { CommitStats( + numLinesAdded = newLibraryToCount.getOrDefault(it, 0), + numLinesDeleted = oldLibraryToCount.getOrDefault(it, 0), + type = Extractor.TYPE_LIBRARY, + tech = it) + }.filter { it.numLinesAdded > 0 || it.numLinesDeleted > 0 } + + return langStats + libraryStats } fun extractImports(fileContent: List): List { return listOf() } + fun tokenize(line: String): List { + val stringRegex = Regex("""(".+?"|'.+?')""") + val newLine = stringRegex.replace(line, "") + //TODO(lyaronskaya): multiline comment regex + val splitRegex = + Regex("""\s|,|;|\*|\n|\(|\)|\[|]|\{|}|\+|=|&|\$|!=|\.|>|<|#|@|:|\?|!""") + val tokens = splitRegex.split(newLine) + .filter { it.isNotBlank() && !it.contains('"') && !it.contains('\'') + && it != "-" && it != "@"} + return tokens + } + + fun getLineLibraries(line: String, fileLibraries: List): + List { + return listOf() + } + + fun getLineLibraries(line: String, + fileLibraries: List, + evaluator: Classifier, + languageLabel: String): List { + val probabilities = evaluator.evaluate(tokenize(line)) + val libraries = evaluator.getCategories() + + val maxProbability = probabilities.max() as Double + val maxProbabilityCategory = + libraries[probabilities.indexOf(maxProbability)] + val selectedCategories = libraries.filter { + probabilities[libraries.indexOf(it)] >= 0.2 * maxProbability + } + + if (maxProbabilityCategory == languageLabel) { + return emptyList() + } + + // For C language. + // Consider line with language label being the one with high probability + // as not having library. + // Keep it while the number of libraries is small. + if (languageLabel == CExtractor.LANGUAGE_NAME && + languageLabel in selectedCategories) { + return emptyList() + } + + val lineLibraries = fileLibraries.filter { it in selectedCategories } + return lineLibraries + } } diff --git a/src/main/kotlin/app/extractors/GoExtractor.kt b/src/main/kotlin/app/extractors/GoExtractor.kt index bd2862a4..c10a6184 100644 --- a/src/main/kotlin/app/extractors/GoExtractor.kt +++ b/src/main/kotlin/app/extractors/GoExtractor.kt @@ -11,6 +11,9 @@ class GoExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "go" val FILE_EXTS = listOf("go") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -41,4 +44,18 @@ class GoExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^(.*import)\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/JavaExtractor.kt b/src/main/kotlin/app/extractors/JavaExtractor.kt index f7a57e58..5b990421 100644 --- a/src/main/kotlin/app/extractors/JavaExtractor.kt +++ b/src/main/kotlin/app/extractors/JavaExtractor.kt @@ -20,10 +20,13 @@ class JavaExtractor : ExtractorInterface { "extends", "int", "short", "try", "char", "final", "interface", "static", "void", "class", "finally", "long", "strictfp", "volatile", "const", "float", "native", "super", "while") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { - files.map { file -> file.language = GoExtractor.LANGUAGE_NAME } + files.map { file -> file.language = LANGUAGE_NAME } val stats = super.extract(files).toMutableList() @@ -72,4 +75,18 @@ class JavaExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^(.*import)\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/JavascriptExtractor.kt b/src/main/kotlin/app/extractors/JavascriptExtractor.kt index a5d3a2df..8743b9a9 100644 --- a/src/main/kotlin/app/extractors/JavascriptExtractor.kt +++ b/src/main/kotlin/app/extractors/JavascriptExtractor.kt @@ -9,9 +9,12 @@ import app.model.DiffFile class JavascriptExtractor : ExtractorInterface { companion object { - val LANGUAGE_NAME = "js" + val LANGUAGE_NAME = "javascript" val FILE_EXTS = listOf("js") val LIBRARIES = ExtractorInterface.getLibraries("js") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -23,11 +26,17 @@ class JavascriptExtractor : ExtractorInterface { val imports = mutableSetOf() val splitRegex = - Regex("""\s+|,|;|:|\\*|\n|\(|\)|\\[|]|\{|}|\+|=|\.|>|<|#|@|\$""") - val fileTokens = fileContent.joinToString(separator = " ") + Regex("""\s+|,|;|:|\*|\n|\(|\)|\\[|]|\{|}|\+|=|\.|>|<|#|@|\$""") + val fileTokens = fileContent.joinToString(separator = " ").toLowerCase() .split(splitRegex) imports.addAll(fileTokens.filter { token -> token in LIBRARIES }) return imports.toList() } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/ObjectiveCExtractor.kt b/src/main/kotlin/app/extractors/ObjectiveCExtractor.kt index 4eda9282..62f934e5 100644 --- a/src/main/kotlin/app/extractors/ObjectiveCExtractor.kt +++ b/src/main/kotlin/app/extractors/ObjectiveCExtractor.kt @@ -11,6 +11,9 @@ class ObjectiveCExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "objectivec" val FILE_EXTS = listOf("m", "mm") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -36,4 +39,18 @@ class ObjectiveCExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^([^\n]*[#@](import|include))\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/PhpExtractor.kt b/src/main/kotlin/app/extractors/PhpExtractor.kt index 66832489..48fb5c51 100644 --- a/src/main/kotlin/app/extractors/PhpExtractor.kt +++ b/src/main/kotlin/app/extractors/PhpExtractor.kt @@ -11,6 +11,9 @@ class PhpExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "php" val FILE_EXTS = listOf("php", "phtml", "php4", "php3", "php5", "phps") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -34,4 +37,18 @@ class PhpExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^(.*require|require_once|include|include_once|use)\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/PythonExtractor.kt b/src/main/kotlin/app/extractors/PythonExtractor.kt index 139cc9f2..4b7fe0be 100644 --- a/src/main/kotlin/app/extractors/PythonExtractor.kt +++ b/src/main/kotlin/app/extractors/PythonExtractor.kt @@ -11,6 +11,11 @@ class PythonExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "python" val FILE_EXTS = listOf("py", "py3") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } + val MULTI_IMPORT_TO_LIB = + ExtractorInterface.getMultipleImportsToLibraryMap(LANGUAGE_NAME) } override fun extract(files: List): List { @@ -32,6 +37,26 @@ class PythonExtractor : ExtractorInterface { } } - return imports.toList() + var libraries = imports.map { MULTI_IMPORT_TO_LIB.getOrDefault(it, it) } + .filter { !it.endsWith("pb")}.toMutableList() + if (libraries.size < imports.size) { + libraries.add("protobuf") + } + return libraries + + } + + override fun tokenize(line: String): List { + val docImportRegex = Regex("""^([^\n]*#|\s*\"\"\"|\s*import|\s*from)[^\n]*""") + val commentRegex = Regex("""^(.*#).*""") + var newLine = docImportRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) } } diff --git a/src/main/kotlin/app/extractors/RubyExtractor.kt b/src/main/kotlin/app/extractors/RubyExtractor.kt index 95ec6a81..4166e52d 100644 --- a/src/main/kotlin/app/extractors/RubyExtractor.kt +++ b/src/main/kotlin/app/extractors/RubyExtractor.kt @@ -11,6 +11,9 @@ class RubyExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "ruby" val FILE_EXTS = listOf("rb", "rbw") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -32,4 +35,18 @@ class RubyExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""(require\s+'(\w+)'|load\s+'(\w+)\.\w+')""") + val commentRegex = Regex("""^([^\n]*#)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/extractors/SwiftExtractor.kt b/src/main/kotlin/app/extractors/SwiftExtractor.kt index 742f2c29..9c044a43 100644 --- a/src/main/kotlin/app/extractors/SwiftExtractor.kt +++ b/src/main/kotlin/app/extractors/SwiftExtractor.kt @@ -11,6 +11,9 @@ class SwiftExtractor : ExtractorInterface { companion object { val LANGUAGE_NAME = "swift" val FILE_EXTS = listOf("swift") + val evaluator by lazy { + ExtractorInterface.getLibraryClassifier(LANGUAGE_NAME) + } } override fun extract(files: List): List { @@ -32,4 +35,18 @@ class SwiftExtractor : ExtractorInterface { return imports.toList() } + + override fun tokenize(line: String): List { + val importRegex = Regex("""^(.*import)\s[^\n]*""") + val commentRegex = Regex("""^([^\n]*//)[^\n]*""") + var newLine = importRegex.replace(line, "") + newLine = commentRegex.replace(newLine, "") + return super.tokenize(newLine) + } + + override fun getLineLibraries(line: String, + fileLibraries: List): List { + + return super.getLineLibraries(line, fileLibraries, evaluator, LANGUAGE_NAME) + } } diff --git a/src/main/kotlin/app/hashers/CodeLongevity.kt b/src/main/kotlin/app/hashers/CodeLongevity.kt index 6553dab8..078ea535 100644 --- a/src/main/kotlin/app/hashers/CodeLongevity.kt +++ b/src/main/kotlin/app/hashers/CodeLongevity.kt @@ -9,6 +9,7 @@ import app.api.Api import app.model.Author import app.model.Repo import app.model.Fact +import app.utils.FileHelper import app.utils.RepoHelper import io.reactivex.Observable import org.eclipse.jgit.diff.DiffFormatter @@ -22,9 +23,9 @@ import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.treewalk.TreeWalk import org.eclipse.jgit.util.io.DisabledOutputStream -import java.io.InputStream import java.io.File import java.io.FileInputStream +import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.ObjectOutputStream import java.io.ObjectInputStream @@ -143,13 +144,16 @@ class CodeLineAges : Serializable { */ class CodeLongevity(private val serverRepo: Repo, private val emails: HashSet, - git: Git) { + git: Git, + private val onError: (Throwable) -> Unit) { val repo: Repository = git.repository + val revWalk = RevWalk(repo) val head: RevCommit = - RevWalk(repo).parseCommit(repo.resolve(RepoHelper.MASTER_BRANCH)) + try { revWalk.parseCommit(repo.resolve(RepoHelper.MASTER_BRANCH)) } + catch(e: Exception) { throw Exception("No branch") } + val df = DiffFormatter(DisabledOutputStream.INSTANCE) - val storageDir = ".sourcerer/data/longevity" - val storagePath = "$storageDir/${serverRepo.rehash}" + val dataPath = FileHelper.getPath(serverRepo.rehash, "longevity") init { df.setRepository(repo) @@ -192,8 +196,9 @@ class CodeLongevity(private val serverRepo: Repo, code = FactCodes.LINE_LONGEVITY_REPO, value = repoAvg.toString())) val repoAvgDays = repoAvg / secondsInDay - Logger.info("Repo average code line age is $repoAvgDays days, " - + "lines total: $repoTotal") + Logger.info { + "Repo average code line age is $repoAvgDays days, lines total: $repoTotal" + } for (email in emails) { val aggrAge = aggrAges[email] ?: CodeLineAges.AggrAge() @@ -206,8 +211,8 @@ class CodeLongevity(private val serverRepo: Repo, } if (stats.size > 0) { - api.postFacts(stats) - Logger.debug("Sent ${stats.size} stats to server") + api.postFacts(stats).onErrorThrow() + Logger.info { "Sent ${stats.size} facts to server" } } } @@ -215,19 +220,33 @@ class CodeLongevity(private val serverRepo: Repo, * Scans the repo to extract code line ages. */ fun scan() : CodeLineAges? { - // Load existing age data if any. - val iStream = try { ObjectInputStream(FileInputStream(storagePath)) } - catch(e : Exception ) { null } - val storedHead = iStream?.readObject() as RevCommit? - if (storedHead == head) { - return null + var storedHead: RevCommit? = null + var ageData = CodeLineAges() + + // Load existing age data if any. Expected format: commit id and + // CodeLineAges structure following it. + try { + val file = dataPath.toFile() + val iStream = ObjectInputStream(FileInputStream(file)) + val storedHeadId = iStream.readUTF() + Logger.debug { "Stored repo head: $storedHeadId" } + storedHead = revWalk.parseCommit(repo.resolve(storedHeadId)) + if (storedHead == head) { + return null + } + ageData = (iStream.readObject() ?: CodeLineAges()) as CodeLineAges + } + catch(e: FileNotFoundException) { } + catch(e: Exception) { + Logger.error( + e, + "Failed to read longevity data. CAUTION: data will be recomputed." + ) } - - val ageData = (iStream?.readObject() ?: CodeLineAges()) as CodeLineAges // Update ages. getLinesObservable(storedHead).blockingSubscribe { line -> - Logger.debug("Scanning: ${line}") + Logger.trace { "Scanning: ${line}" } if (line.to.isDeleted) { var age = line.age if (ageData.lastingLines.contains(line.oldId)) { @@ -249,12 +268,16 @@ class CodeLongevity(private val serverRepo: Repo, } // Store ages for subsequent runs. - if (Files.notExists(Paths.get(storageDir))) { - Files.createDirectories(Paths.get(storageDir)) + try { + val file = dataPath.toFile() + val oStream = ObjectOutputStream(FileOutputStream(file)) + oStream.writeUTF(head.getName()) + oStream.writeObject(ageData) + } + catch(e: Exception) { + Logger.error(e, "Failed to save longevity data. CAUTION: data " + + "will be recomputed on a next run.") } - val oStream = ObjectOutputStream(FileOutputStream(storagePath)) - oStream.writeObject(head) - oStream.writeObject(ageData) return ageData } @@ -262,7 +285,7 @@ class CodeLongevity(private val serverRepo: Repo, * Clears the stored age data if any. */ fun dropSavedData() { - File(storagePath).delete() + dataPath.toFile().delete() } /** @@ -292,33 +315,44 @@ class CodeLongevity(private val serverRepo: Repo, // Build a map of file names and their code lines. while (headWalk.next()) { - val path = headWalk.getPathString() - val fileId = headWalk.getObjectId(0) - val fileLoader = repo.open(fileId) - if (!RawText.isBinary(fileLoader.openStream())) { - val fileText = RawText(fileLoader.getBytes()) - val lines = ArrayList(fileText.size()) - for (idx in 0 .. fileText.size() - 1) { - lines.add(RevCommitLine(head, fileId, path, idx, false)) + try { + val path = headWalk.getPathString() + val fileId = headWalk.getObjectId(0) + val fileLoader = repo.open(fileId) + if (!RawText.isBinary(fileLoader.openStream())) { + val fileText = RawText(fileLoader.getBytes()) + val lines = ArrayList(fileText.size()) + for (idx in 0..fileText.size() - 1) { + lines.add(RevCommitLine(head, fileId, path, idx, false)) + } + files.put(path, lines) } - files.put(path, lines) + } catch (e: Exception) { + // TODO(anatoly): better fix of exceptions. } } - getDiffsObservable(tail).blockingSubscribe { (commit, diffs) -> + getDiffsObservable(tail).blockingSubscribe( { (commit, diffs) -> // A step back in commits history. Update the files map according - // to the diff. - for (diff in diffs) { + // to the diff. Traverse the diffs backwards to handle double + // renames properly. + // TODO(alex): cover file renames by tests (see APP-132 issue). + for (diff in diffs.asReversed()) { val oldPath = diff.getOldPath() val oldId = diff.getOldId().toObjectId() val newPath = diff.getNewPath() val newId = diff.getNewId().toObjectId() - Logger.debug("old: '$oldPath', new: '$newPath'") + Logger.trace { "old: '$oldPath', new: '$newPath'" } // Skip binary files. val fileId = if (newPath != DiffEntry.DEV_NULL) newId else oldId - if (RawText.isBinary(repo.open(fileId).openStream())) { + try { + if (RawText.isBinary(repo.open(fileId).openStream())) { + continue + } + } catch (e: Exception) { continue + //TODO(anatoly): better exception handling. } // TODO(alex): does it happen in the wilds? @@ -341,6 +375,7 @@ class CodeLongevity(private val serverRepo: Repo, } val lines = files.get(path)!! + // Update the lines array according to diff insertions. // Traverse the edit list backwards to keep indices of // the edit list and the lines array in sync. @@ -351,15 +386,22 @@ class CodeLongevity(private val serverRepo: Repo, if (insCount > 0) { val insStart = edit.getBeginB() val insEnd = edit.getEndB() - Logger.debug("ins ($insStart, $insEnd)") + Logger.trace { "ins ($insStart, $insEnd)" } for (idx in insStart .. insEnd - 1) { val from = RevCommitLine(commit, newId, newPath, idx, false) - val to = lines.get(idx) - val cl = CodeLine(repo, from, to) - Logger.debug("Collected: ${cl}") - subscriber.onNext(cl) + try { + val to = lines.get(idx) + val cl = CodeLine(repo, from, to) + Logger.trace { "Collected: ${cl}" } + subscriber.onNext(cl) + } + catch(e: IndexOutOfBoundsException) { + Logger.error(e, + "No line at ${idx}; commit: ${commit.getName()}; '${commit.getShortMessage()}'") + throw e + } } lines.subList(insStart, insEnd).clear() } @@ -373,7 +415,7 @@ class CodeLongevity(private val serverRepo: Repo, if (delCount > 0) { val delStart = edit.getBeginA() val delEnd = edit.getEndA() - Logger.debug("del ($delStart, $delEnd)") + Logger.trace { "del ($delStart, $delEnd)" } val tmpLines = ArrayList(delCount) for (idx in delStart .. delEnd - 1) { @@ -389,7 +431,7 @@ class CodeLongevity(private val serverRepo: Repo, files.set(oldPath, files.remove(newPath)!!) } } - } + }, onError) // If a tail revision was given then the map has to contain unclaimed // code lines, i.e. the lines added before the tail revision. Push @@ -409,7 +451,7 @@ class CodeLongevity(private val serverRepo: Repo, val from = RevCommitLine(tail, fileId, filePath, idx, false) val cl = CodeLine(repo, from, lines[idx]) - Logger.debug("Collected (tail): $cl") + Logger.trace { "Collected (tail): $cl" } subscriber.onNext(cl) } } @@ -426,21 +468,28 @@ class CodeLongevity(private val serverRepo: Repo, Observable>> = Observable.create { subscriber -> - val revWalk = RevWalk(repo) revWalk.markStart(head) - var commit: RevCommit? = revWalk.next() // Move the walker to the head. while (commit != null && commit != tail) { val parentCommit: RevCommit? = revWalk.next() - Logger.debug("commit: ${commit.getName()}; " + - "'${commit.getShortMessage()}'") - if (parentCommit != null) { - Logger.debug("parent commit: ${parentCommit.getName()}; " - + "'${parentCommit.getShortMessage()}'") - } - else { - Logger.debug("parent commit: null") + // Smart casts are not yet supported for a mutable variable captured + // in an inline lambda, see + // https://youtrack.jetbrains.com/issue/KT-7186. + if (Logger.isDebug) { + val commitName = commit.getName() + val commitMsg = commit.getShortMessage() + Logger.debug { "commit: $commitName; '$commitMsg'" } + if (parentCommit != null) { + val parentCommitName = parentCommit.getName() + val parentCommitMsg = parentCommit.getShortMessage() + Logger.debug { + "parent commit: ${parentCommitName}; '${parentCommitMsg}'" + } + } + else { + Logger.debug { "parent commit: null" } + } } subscriber.onNext(Pair(commit, df.scan(parentCommit, commit))) diff --git a/src/main/kotlin/app/hashers/CommitCrawler.kt b/src/main/kotlin/app/hashers/CommitCrawler.kt index db738c67..43a7cedf 100644 --- a/src/main/kotlin/app/hashers/CommitCrawler.kt +++ b/src/main/kotlin/app/hashers/CommitCrawler.kt @@ -22,34 +22,52 @@ import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.util.io.DisabledOutputStream object CommitCrawler { - fun getObservable(git: Git, repo: Repo): Observable = Observable - .create { subscriber -> - try { - val revWalk = RevWalk(git.repository) - val commitId = git.repository.resolve(RepoHelper.MASTER_BRANCH) - revWalk.markStart(revWalk.parseCommit(commitId)) - for (revCommit in revWalk) { - subscriber.onNext(Commit(revCommit)) + fun getObservable(git: Git, repo: Repo, + numCommits: Int = 0): Observable { + var curNumCommits = 0 + return Observable + .create { subscriber -> + try { + val revWalk = RevWalk(git.repository) + val commitId = git.repository.resolve(RepoHelper.MASTER_BRANCH) + revWalk.markStart(revWalk.parseCommit(commitId)) + for (revCommit in revWalk) { + subscriber.onNext(Commit(revCommit)) + } + // Commits are combined in pairs, an empty commit concatenated + // to calculate the diff of the initial commit. + subscriber.onNext(Commit()) + } catch (e: Exception) { + Logger.error(e, "Commit producing error") + subscriber.onError(e) + } + subscriber.onComplete() + } // TODO(anatoly): Rewrite diff calculation in non-weird way. + .pairWithNext() // Pair commits to get diff. + .map { (new, old) -> + curNumCommits++ + // Mapping and stats extraction. + val message = new.raw?.shortMessage ?: "" + val hash = new.raw?.name ?: "" + val perc = if (numCommits != 0) { + (curNumCommits.toDouble() / numCommits) * 100 + } else 0.0 + Logger.printCommit(message, hash, perc) + new.diffs = getDiffFiles(git, new, old) + Logger.debug { "Diff: ${new.diffs.size} entries" } + // Count lines on all non-binary files. This is additional + // statistics to CommitStats because not all file extensions + // may be supported. + new.numLinesAdded = new.diffs.fold(0) { total, file -> + total + file.getAllAdded().size + } + new.numLinesDeleted = new.diffs.fold(0) { total, file -> + total + file.getAllDeleted().size } - // Commits are combined in pairs, an empty commit concatenated - // to calculate the diff of the initial commit. - subscriber.onNext(Commit()) - } catch (e: Exception) { - Logger.error("Commit producing error", e) - subscriber.onError(e) + new.repo = repo + new } - subscriber.onComplete() - } // TODO(anatoly): Rewrite diff calculation in non-weird way. - .pairWithNext() // Pair commits to get diff. - .map { (new, old) -> - // Mapping and stats extraction. - Logger.debug("Commit: ${new.raw?.name ?: ""}: " - + new.raw?.shortMessage) - new.diffs = getDiffFiles(git, new, old) - Logger.debug("Diff: ${new.diffs.size} entries") - new.repo = repo - new - } + } private fun getDiffFiles(git: Git, commitNew: Commit, @@ -70,37 +88,70 @@ object CommitCrawler { } else { it.newId.toObjectId() } - !RawText.isBinary(git.repository.open(id).openStream()) + val stream = try { + git.repository.open(id).openStream() + } catch (e: Exception) { null } + stream != null && !RawText.isBinary(stream) } .map { diff -> - val new = getContentByObjectId(git, diff.newId.toObjectId()) - val old = getContentByObjectId(git, diff.oldId.toObjectId()) + // TODO(anatoly): Can produce exception for large object. + // Investigate for size. + val new = try { + getContentByObjectId(git, diff.newId.toObjectId()) + } catch (e: Exception) { + Logger.error(e) + null + } + val old = try { + getContentByObjectId(git, diff.oldId.toObjectId()) + } catch (e: Exception) { + Logger.error(e) + null + } - val edits = formatter.toFileHeader(diff).toEditList() - val path = when (diff.changeType) { - DiffEntry.ChangeType.DELETE -> diff.oldPath - else -> diff.newPath + val diffFiles = mutableListOf() + if (new != null && old != null) { + val header = try { + formatter.toFileHeader(diff) + } catch (e: Exception) { + Logger.error(e) + null + } + + if (header != null) { + val edits = header.toEditList() + val path = when (diff.changeType) { + DiffEntry.ChangeType.DELETE -> diff.oldPath + else -> diff.newPath + } + diffFiles.add(DiffFile(path = path, + changeType = diff.changeType, + old = DiffContent(old, edits.map { edit -> + DiffRange(edit.beginA, edit.endA) + }), + new = DiffContent(new, edits.map { edit -> + DiffRange(edit.beginB, edit.endB) + }))) + } } - DiffFile(path = path, - changeType = diff.changeType, - old = DiffContent(old, edits.map { edit -> - DiffRange(edit.beginA, edit.endA) }), - new = DiffContent(new, edits.map { edit -> - DiffRange(edit.beginB, edit.endB) })) + + diffFiles } + .flatten() } } private fun getContentByObjectId(git: Git, objectId: ObjectId): List { return try { - val rawText = RawText(git.repository.open(objectId).bytes) + val obj = git.repository.open(objectId) + val rawText = RawText(obj.bytes) val content = ArrayList(rawText.size()) for (i in 0..(rawText.size() - 1)) { content.add(rawText.getString(i)) } return content - } catch (e: MissingObjectException) { + } catch (e: Exception) { listOf() } } diff --git a/src/main/kotlin/app/hashers/CommitHasher.kt b/src/main/kotlin/app/hashers/CommitHasher.kt index a478c069..bc9ddff6 100644 --- a/src/main/kotlin/app/hashers/CommitHasher.kt +++ b/src/main/kotlin/app/hashers/CommitHasher.kt @@ -44,22 +44,22 @@ class CommitHasher(private val serverRepo: Repo = Repo(), // Hash only commits made by authors with specified emails. .filter { commit -> emails.contains(commit.author.email) } .map { commit -> + Logger.info { "Extracting stats" } + // Mapping and stats extraction. commit.stats = Extractor().extract(commit.diffs) - Logger.debug("Stats: ${commit.stats.size} entries") - - // Count lines on all non-binary files. This is additional - // statistics to CommitStats because not all file extensions - // may be supported. - commit.numLinesAdded = commit.diffs.fold(0) { total, file -> - total + file.getAllAdded().size - } - commit.numLinesDeleted = commit.diffs.fold(0) { total, file -> - total + file.getAllDeleted().size + if (commit.stats.isNotEmpty()) { + Logger.printCommitDetail("${commit.stats.size} " + + "technology stats found") } + Logger.debug { commit.stats.toString() } + commit } - .buffer(20, TimeUnit.SECONDS) // Group ready commits by time. + // Group ready commits by time and count. Max payload 10 mb, + // one commit with stats takes around 1 kb, so pack by max 1000 + // with 10x margin of safety. + .buffer(20, TimeUnit.SECONDS, 1000) .subscribe({ commitsBundle -> // OnNext. postCommitsToServer(commitsBundle) // Send ready commits. }, onError) @@ -76,15 +76,15 @@ class CommitHasher(private val serverRepo: Repo = Repo(), private fun postCommitsToServer(commits: List) { if (commits.isNotEmpty()) { - api.postCommits(commits) - Logger.debug("Sent ${commits.size} added commits to server") + api.postCommits(commits).onErrorThrow() + Logger.info { "Sent ${commits.size} added commits to server" } } } private fun deleteCommitsOnServer(commits: List) { if (commits.isNotEmpty()) { - api.deleteCommits(commits) - Logger.debug("Sent ${commits.size} deleted commits to server") + api.deleteCommits(commits).onErrorThrow() + Logger.info { "Sent ${commits.size} deleted commits to server" } } } } diff --git a/src/main/kotlin/app/hashers/FactHasher.kt b/src/main/kotlin/app/hashers/FactHasher.kt index 430bb66d..213c9a19 100644 --- a/src/main/kotlin/app/hashers/FactHasher.kt +++ b/src/main/kotlin/app/hashers/FactHasher.kt @@ -19,12 +19,17 @@ import java.time.ZoneOffset */ class FactHasher(private val serverRepo: Repo = Repo(), private val api: Api, + private val rehashes: List, private val emails: HashSet) { private val fsDayWeek = hashMapOf>() private val fsDayTime = hashMapOf>() private val fsRepoDateStart = hashMapOf() private val fsRepoDateEnd = hashMapOf() - private val fsRepoTeamSize = hashSetOf() + private val fsCommitLineNumAvg = hashMapOf() + private val fsCommitNum = hashMapOf() + private val fsLineLenAvg = hashMapOf() + private val fsLineNum = hashMapOf() + private val fsLinesPerCommits = hashMapOf>() init { for (author in emails) { @@ -32,6 +37,12 @@ class FactHasher(private val serverRepo: Repo = Repo(), fsDayTime.put(author, Array(24) { 0 }) fsRepoDateStart.put(author, -1) fsRepoDateEnd.put(author, -1) + fsCommitLineNumAvg.put(author, 0.0) + fsCommitNum.put(author, 0) + fsLineLenAvg.put(author, 0.0) + fsLineNum.put(author, 0) + // TODO(anatoly): Do the bin computations on the go. + fsLinesPerCommits.put(author, Array(rehashes.size) {0}) } } @@ -39,36 +50,7 @@ class FactHasher(private val serverRepo: Repo = Repo(), onError: (Throwable) -> Unit) { observable .filter { commit -> emails.contains(commit.author.email) } - .subscribe({ commit -> // OnNext. - // Calculate facts. - val email = commit.author.email - val timestamp = commit.dateTimestamp - val dateTime = LocalDateTime.ofEpochSecond(timestamp, 0, - ZoneOffset.ofTotalSeconds(commit.dateTimeZoneOffset * 60)) - - // DayWeek. - val factDayWeek = fsDayWeek[email] ?: Array(7) { 0 } - // The value is numbered from 1 (Monday) to 7 (Sunday). - factDayWeek[dateTime.dayOfWeek.value - 1] += 1 - fsDayWeek[email] = factDayWeek - - // DayTime. - val factDayTime = fsDayTime[email] ?: Array(24) { 0 } - // Hour from 0 to 23. - factDayTime[dateTime.hour] += 1 - fsDayTime[email] = factDayTime - - // RepoDateStart. - fsRepoDateStart[email] = timestamp - - // RepoDateEnd. - if ((fsRepoDateEnd[email] ?: -1) == -1L) { - fsRepoDateEnd[email] = timestamp - } - - // RepoTeamSize. - fsRepoTeamSize.add(email) - }, onError, { // OnComplete. + .subscribe(onNext, onError, { // OnComplete. try { postFactsToServer(createFacts()) } catch (e: Throwable) { @@ -77,32 +59,141 @@ class FactHasher(private val serverRepo: Repo = Repo(), }) } + private val onNext: (Commit) -> Unit = { commit -> + // Calculate facts. + val email = commit.author.email + val timestamp = commit.dateTimestamp + val dateTime = LocalDateTime.ofEpochSecond(timestamp, 0, + ZoneOffset.ofTotalSeconds(commit.dateTimeZoneOffset * 60)) + + // DayWeek. + val factDayWeek = fsDayWeek[email] ?: Array(7) { 0 } + // The value is numbered from 1 (Monday) to 7 (Sunday). + factDayWeek[dateTime.dayOfWeek.value - 1] += 1 + fsDayWeek[email] = factDayWeek + + // DayTime. + val factDayTime = fsDayTime[email] ?: Array(24) { 0 } + // Hour from 0 to 23. + factDayTime[dateTime.hour] += 1 + fsDayTime[email] = factDayTime + + // RepoDateStart. + fsRepoDateStart[email] = timestamp + + // RepoDateEnd. + if (fsRepoDateEnd[email]!! == -1L) { + fsRepoDateEnd[email] = timestamp + } + + // Commits. + val numCommits = fsCommitNum[email]!! + 1 + val numLinesCurrent = commit.numLinesAdded + commit.numLinesDeleted + + fsCommitNum[email] = numCommits + fsCommitLineNumAvg[email] = calcIncAvg(fsCommitLineNumAvg[email]!!, + numLinesCurrent.toDouble(), numCommits.toLong()) + + val lines = commit.getAllAdded() + commit.getAllDeleted() + lines.forEachIndexed { index, line -> + fsLineLenAvg[email] = calcIncAvg(fsLineLenAvg[email]!!, + line.length.toDouble(), fsLineNum[email]!! + index + 1) + } + fsLineNum[email] = fsLineNum[email]!! + lines.size + + fsLinesPerCommits[email]!![numCommits - 1] += lines.size + } + private fun createFacts(): List { val fs = mutableListOf() emails.forEach { email -> val author = Author(email = email) fsDayTime[email]?.forEachIndexed { hour, count -> if (count > 0) { - fs.add(Fact(serverRepo, FactCodes.COMMITS_DAY_TIME, hour, + fs.add(Fact(serverRepo, FactCodes.COMMIT_DAY_TIME, hour, count.toString(), author)) }} fsDayWeek[email]?.forEachIndexed { day, count -> if (count > 0) { - fs.add(Fact(serverRepo, FactCodes.COMMITS_DAY_WEEK, day, + fs.add(Fact(serverRepo, FactCodes.COMMIT_DAY_WEEK, day, count.toString(), author)) }} fs.add(Fact(serverRepo, FactCodes.REPO_DATE_START, 0, fsRepoDateStart[email].toString(), author)) fs.add(Fact(serverRepo, FactCodes.REPO_DATE_END, 0, fsRepoDateEnd[email].toString(), author)) + fs.add(Fact(serverRepo, FactCodes.COMMIT_NUM, 0, + fsCommitNum[email].toString(), author)) + fs.add(Fact(serverRepo, FactCodes.COMMIT_LINE_NUM_AVG, 0, + fsCommitLineNumAvg[email].toString(), author)) + fs.add(Fact(serverRepo, FactCodes.LINE_NUM, 0, + fsLineNum[email].toString(), author)) + fs.add(Fact(serverRepo, FactCodes.LINE_LEN_AVG, 0, + fsLineLenAvg[email].toString(), author)) + val linesPerCommits = fsLinesPerCommits[email]!! + .sliceArray(IntRange(0, fsCommitNum[email]!! - 1)) + addCommitsPerLinesFacts(fs, linesPerCommits, author) } fs.add(Fact(serverRepo, FactCodes.REPO_TEAM_SIZE, 0, - fsRepoTeamSize.size.toString())) + emails.size.toString())) return fs } private fun postFactsToServer(facts: List) { if (facts.isNotEmpty()) { - api.postFacts(facts) - Logger.debug("Sent ${facts.size} facts to server") + api.postFacts(facts).onErrorThrow() + Logger.info { "Sent ${facts.size} facts to server" } + } + } + + /** + * Computes the average of a numerical sequence. + * Calculated numbers is never bigger than maximum element of sequence. + * No overflow due to summing of elements. + * @param prev previous value of average + * @param element new element of sequence + * @param count number of element in sequence + * @return new value of average with considering of new element + */ + private fun calcIncAvg(prev: Double, element: Double, + count: Long): Double { + return prev * (1 - 1.0 / count) + element / count + } + + private fun addCommitsPerLinesFacts(fs: MutableList, + linesPerCommits: Array, + author: Author) { + if (linesPerCommits.isEmpty()) return + + var max = linesPerCommits[0] + var min = linesPerCommits[0] + for (lines in linesPerCommits) { + if (lines > max) { + max = lines + } + if (lines < min) { + min = lines + } + } + + val numBins = Math.min(10, max - min + 1) + val binSize = (max - min + 1) / numBins.toDouble() + val bins = Array(numBins) { 0 } + for (numLines in linesPerCommits) { + if (numLines == 0) { + continue + } + + val binId = Math.floor((numLines - min) / binSize).toInt() + bins[binId]++ + } + + for ((binId, numCommits) in bins.withIndex()) { + if (numCommits == 0) { + continue + } + + val numLines = Math.floor(min + binId * binSize).toInt() + fs.add(Fact(serverRepo, FactCodes.COMMIT_NUM_TO_LINE_NUM, + numLines, numCommits.toString(), author)) } } } diff --git a/src/main/kotlin/app/hashers/RepoHasher.kt b/src/main/kotlin/app/hashers/RepoHasher.kt index 93da5055..768e67c2 100644 --- a/src/main/kotlin/app/hashers/RepoHasher.kt +++ b/src/main/kotlin/app/hashers/RepoHasher.kt @@ -3,10 +3,11 @@ package app.hashers -import app.Analytics +import app.BuildConfig import app.Logger import app.api.Api import app.config.Configurator +import app.model.Author import app.model.LocalRepo import app.model.Repo import app.utils.HashingException @@ -31,63 +32,61 @@ class RepoHasher(private val localRepo: LocalRepo, private val api: Api, } fun update() { - println("Hashing $localRepo...") + Logger.info { "Hashing of repo started" } val git = loadGit(localRepo.path) try { val (rehashes, emails) = fetchRehashesAndEmails(git) localRepo.parseGitConfig(git.repository.config) - if (localRepo.author.email.isBlank()) { - throw IllegalStateException("Can't load email from Git config") - } - - val filteredEmails = filterEmails(emails) - initServerRepo(rehashes.last) + Logger.debug { "Local repo path: ${localRepo.path}" } + Logger.debug { "Repo remote: ${localRepo.remoteOrigin}" } + Logger.debug { "Repo rehash: ${serverRepo.rehash}" } - if (!isKnownRepo()) { - // Notify server about new contributor and his email. - postRepoToServer() - } // Get repo setup (commits, emails to hash) from server. - getRepoFromServer() + postRepoFromServer() + + // Send all repo emails for invites. + postAuthorsToServer(emails) + + val filteredEmails = filterEmails(emails) // Common error handling for subscribers. // Exceptions can't be thrown out of reactive chain. val errors = mutableListOf() val onError: (Throwable) -> Unit = { e -> errors.add(e) - Logger.error("Hashing error", e) + Logger.error(e, "Hashing error") } // Hash by all plugins. - val observable = CommitCrawler.getObservable(git, serverRepo) - .publish() + val observable = CommitCrawler.getObservable(git, serverRepo, + rehashes.size).publish() CommitHasher(serverRepo, api, rehashes, filteredEmails) .updateFromObservable(observable, onError) - FactHasher(serverRepo, api, filteredEmails) + FactHasher(serverRepo, api, rehashes, filteredEmails) .updateFromObservable(observable, onError) // Start and synchronously wait until all subscribers complete. observable.connect() // TODO(anatoly): CodeLongevity hash from observable. + Logger.print("Code longevity calculation. May take a while...") try { - CodeLongevity(serverRepo, filteredEmails, git).updateStats(api) + CodeLongevity(serverRepo, filteredEmails, git, onError) + .updateStats(api) } catch (e: Throwable) { onError(e) } - - // Confirm hashing completion. - postRepoToServer() + Logger.print("Finished.") if (errors.isNotEmpty()) { throw HashingException(errors) } - println("Hashing $localRepo successfully finished.") - Analytics.trackHashingRepoSuccess() + Logger.info(Logger.Events.HASHING_REPO_SUCCESS) + { "Hashing repo completed" } } finally { closeGit(git) @@ -107,26 +106,25 @@ class RepoHasher(private val localRepo: LocalRepo, private val api: Api, git.close() } - private fun isKnownRepo(): Boolean { - return configurator.getRepos() - .find { it.rehash == serverRepo.rehash } != null - } - - private fun getRepoFromServer() { - serverRepo = api.getRepo(serverRepo.rehash) - Logger.debug("Received repo from server with " + - serverRepo.commits.size + " commits") + private fun postRepoFromServer() { + val repo = api.postRepo(serverRepo).getOrThrow() + serverRepo.commits = repo.commits + Logger.info { + "Received repo from server with ${serverRepo.commits.size} commits" + } + Logger.debug { serverRepo.toString() } } - private fun postRepoToServer() { - api.postRepo(serverRepo) + private fun postAuthorsToServer(emails: HashSet) { + api.postAuthors(emails.map { email -> + Author(email = email, repo = serverRepo) + }).onErrorThrow() } private fun initServerRepo(initCommitRehash: String) { - serverRepo = Repo(userEmail = localRepo.author.email) - serverRepo.initialCommitRehash = initCommitRehash - serverRepo.rehash = RepoHelper.calculateRepoRehash( - serverRepo.initialCommitRehash, localRepo) + serverRepo = Repo(initialCommitRehash = initCommitRehash, + rehash = RepoHelper.calculateRepoRehash( + initCommitRehash, localRepo)) } private fun fetchRehashesAndEmails(git: Git): @@ -157,8 +155,8 @@ class RepoHasher(private val localRepo: LocalRepo, private val api: Api, return emails } - val knownEmails = mutableListOf() - knownEmails.add(serverRepo.userEmail) + val knownEmails = hashSetOf() + knownEmails.addAll(configurator.getUser().emails.map { it.email }) knownEmails.addAll(serverRepo.emails) return knownEmails.filter { emails.contains(it) }.toHashSet() diff --git a/src/main/kotlin/app/model/Author.kt b/src/main/kotlin/app/model/Author.kt index 6f690f53..f302cc11 100644 --- a/src/main/kotlin/app/model/Author.kt +++ b/src/main/kotlin/app/model/Author.kt @@ -3,10 +3,40 @@ package app.model +import app.Protos +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + /** * Commit author. */ -data class Author(var name: String = "", var email: String = "") { +data class Author( + var name: String = "", + var email: String = "", + var repo: Repo = Repo() +) { + @Throws(InvalidParameterException::class) + constructor(proto: Protos.Author) : this() { + email = proto.email + repo = Repo(proto.repoRehash) + } + + @Throws(InvalidProtocolBufferException::class) + constructor(bytes: ByteArray) : this(Protos.Author.parseFrom(bytes)) + + constructor(serialized: String) : this(serialized.toByteArray()) + + fun getProto(): Protos.Author { + return Protos.Author.newBuilder() + .setEmail(email) + .setRepoRehash(repo.rehash) + .build() + } + + fun serialize(): ByteArray { + return getProto().toByteArray() + } + // Email defines user identity. override fun equals(other: Any?): Boolean { if (other is Author) { diff --git a/src/main/kotlin/app/model/AuthorGroup.kt b/src/main/kotlin/app/model/AuthorGroup.kt new file mode 100644 index 00000000..bf252d78 --- /dev/null +++ b/src/main/kotlin/app/model/AuthorGroup.kt @@ -0,0 +1,32 @@ +package app.model + +import app.Protos +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + +/** + * Group of commit authors. + */ +data class AuthorGroup( + var authors: List = listOf() +) { + @Throws(InvalidParameterException::class) + constructor(proto: Protos.AuthorGroup) : this() { + authors = proto.authorsList.map { it -> Author(it) } + } + + @Throws(InvalidProtocolBufferException::class) + constructor(bytes: ByteArray) : this(Protos.AuthorGroup.parseFrom(bytes)) + + constructor(serialized: String) : this(serialized.toByteArray()) + + fun getProto(): Protos.AuthorGroup { + return Protos.AuthorGroup.newBuilder() + .addAllAuthors(authors.map { it -> it.getProto() }) + .build() + } + + fun serialize(): ByteArray { + return getProto().toByteArray() + } +} diff --git a/src/main/kotlin/app/model/Commit.kt b/src/main/kotlin/app/model/Commit.kt index 9810a77a..32835788 100644 --- a/src/main/kotlin/app/model/Commit.kt +++ b/src/main/kotlin/app/model/Commit.kt @@ -86,4 +86,12 @@ data class Commit( override fun hashCode(): Int { return rehash.hashCode() } + + fun getAllAdded(): List { + return diffs.map { it.getAllAdded() }.flatten() + } + + fun getAllDeleted(): List { + return diffs.map { it.getAllDeleted() }.flatten() + } } diff --git a/src/main/kotlin/app/model/DiffContent.kt b/src/main/kotlin/app/model/DiffContent.kt index 82cb7d1e..212eb978 100644 --- a/src/main/kotlin/app/model/DiffContent.kt +++ b/src/main/kotlin/app/model/DiffContent.kt @@ -1,3 +1,6 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + package app.model class DiffContent( diff --git a/src/main/kotlin/app/model/Error.kt b/src/main/kotlin/app/model/Error.kt new file mode 100644 index 00000000..0baf8997 --- /dev/null +++ b/src/main/kotlin/app/model/Error.kt @@ -0,0 +1,35 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.model + +import app.Protos +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + +data class Error( + var code: Int = 0, + var message: String = "" +) { + @Throws(InvalidParameterException::class) + constructor(proto: Protos.Error) : this() { + code = proto.code + message = proto.message + } + + @Throws(InvalidProtocolBufferException::class) + constructor(bytes: ByteArray) : this(Protos.Error.parseFrom(bytes)) + + constructor(serialized: String) : this(serialized.toByteArray()) + + fun getProto(): Protos.Error { + return Protos.Error.newBuilder() + .setCode(code) + .setMessage(message) + .build() + } + + fun serialize(): ByteArray { + return getProto().toByteArray() + } +} diff --git a/src/main/kotlin/app/model/Errors.kt b/src/main/kotlin/app/model/Errors.kt new file mode 100644 index 00000000..59b9625a --- /dev/null +++ b/src/main/kotlin/app/model/Errors.kt @@ -0,0 +1,32 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.model + +import app.Protos +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + +data class Errors ( + var errors: List = listOf() +) { + @Throws(InvalidParameterException::class) + constructor(proto: Protos.Errors) : this() { + errors = proto.errorsList.map { error -> Error(error) } + } + + @Throws(InvalidProtocolBufferException::class) + constructor(bytes: ByteArray) : this(Protos.Errors.parseFrom(bytes)) + + constructor(serialized: String) : this(serialized.toByteArray()) + + fun getProto(): Protos.Errors { + return Protos.Errors.newBuilder() + .addAllErrors(errors.map { error -> error.getProto() }) + .build() + } + + fun serialize(): ByteArray { + return getProto().toByteArray() + } +} diff --git a/src/main/kotlin/app/model/Fact.kt b/src/main/kotlin/app/model/Fact.kt index 91daf5a8..e137f1d9 100644 --- a/src/main/kotlin/app/model/Fact.kt +++ b/src/main/kotlin/app/model/Fact.kt @@ -1,3 +1,6 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + package app.model import app.Protos diff --git a/src/main/kotlin/app/model/FactGroup.kt b/src/main/kotlin/app/model/FactGroup.kt index 9c00871f..681196d1 100644 --- a/src/main/kotlin/app/model/FactGroup.kt +++ b/src/main/kotlin/app/model/FactGroup.kt @@ -1,3 +1,6 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + package app.model import app.Protos diff --git a/src/main/kotlin/app/model/LocalRepo.kt b/src/main/kotlin/app/model/LocalRepo.kt index 66291679..8339d1b1 100644 --- a/src/main/kotlin/app/model/LocalRepo.kt +++ b/src/main/kotlin/app/model/LocalRepo.kt @@ -18,4 +18,8 @@ data class LocalRepo(var path: String = "") { email = config.getString("user", null, "email") ?: "") remoteOrigin = config.getString("remote", "origin", "url") ?: "" } + + override fun toString(): String { + return path + } } diff --git a/src/main/kotlin/app/model/Repo.kt b/src/main/kotlin/app/model/Repo.kt index 62e35562..b609d23b 100644 --- a/src/main/kotlin/app/model/Repo.kt +++ b/src/main/kotlin/app/model/Repo.kt @@ -15,8 +15,6 @@ data class Repo( var rehash: String = "", var initialCommitRehash: String = "", - var userEmail: String = "", - // Authors' email filter for hashed commits. If empty list then hash // only commits that created by current user. var emails: List = listOf(), @@ -28,7 +26,6 @@ data class Repo( constructor(proto: Protos.Repo) : this() { rehash = proto.rehash initialCommitRehash = proto.initialCommitRehash - userEmail = proto.userEmail emails = proto.emailsList commits = proto.commitsList.map { Commit(it) } } @@ -42,7 +39,6 @@ data class Repo( return Protos.Repo.newBuilder() .setRehash(rehash) .setInitialCommitRehash(rehash) - .setUserEmail(userEmail) .addAllEmails(emails) .addAllCommits(commits.map { it.getProto() }) .build() diff --git a/src/main/kotlin/app/model/User.kt b/src/main/kotlin/app/model/User.kt index c9181490..78f731bc 100644 --- a/src/main/kotlin/app/model/User.kt +++ b/src/main/kotlin/app/model/User.kt @@ -11,12 +11,14 @@ import java.security.InvalidParameterException * User information. */ data class User ( - var repos: MutableList = mutableListOf() + var repos: MutableList = mutableListOf(), + var emails: HashSet = hashSetOf() ) { @Throws(InvalidParameterException::class) constructor(proto: Protos.User) : this() { repos = proto.reposList.map { repo -> Repo(repo) } - .toMutableList() + .toMutableList() + emails = proto.emailsList.map { email -> UserEmail(email) }.toHashSet() } @Throws(InvalidProtocolBufferException::class) @@ -26,8 +28,9 @@ data class User ( fun getProto(): Protos.User { return Protos.User.newBuilder() - .addAllRepos(repos.map { repo -> repo.getProto() }) - .build() + .addAllRepos(repos.map { repo -> repo.getProto() }) + .addAllEmails(emails.map { email -> email.getProto() }) + .build() } fun serialize(): ByteArray { diff --git a/src/main/kotlin/app/model/UserEmail.kt b/src/main/kotlin/app/model/UserEmail.kt new file mode 100644 index 00000000..d03219e0 --- /dev/null +++ b/src/main/kotlin/app/model/UserEmail.kt @@ -0,0 +1,58 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.model + +import app.Protos +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + +/** + * User information. + */ +class UserEmail( + var email: String = "", + var primary: Boolean = false, + var verified: Boolean = false +) { + @Throws(InvalidParameterException::class) + constructor(proto: Protos.UserEmail) : this() { + email = proto.email + primary = proto.primary + verified = proto.verified + } + + @Throws(InvalidProtocolBufferException::class) + constructor(bytes: ByteArray) : this(Protos.UserEmail.parseFrom(bytes)) + + constructor(serialized: String) : this(serialized.toByteArray()) + + fun getProto(): Protos.UserEmail { + return Protos.UserEmail.newBuilder() + .setEmail(email) + .setPrimary(primary) + .setVerified(verified) + .build() + } + + fun serialize(): ByteArray { + return getProto().toByteArray() + } + + override fun toString(): String { + val primary = if (this.primary) " (Primary)" else "" + val verified = if (this.verified) "Confirmed" else "Not confirmed" + return "${this.email}$primary — $verified" + } + + override fun equals(other: Any?): Boolean { + if (other is UserEmail) { + return email == other.email + } + return false + } + + override fun hashCode(): Int { + return email.hashCode() + } +} diff --git a/src/main/kotlin/app/ui/AddRepoState.kt b/src/main/kotlin/app/ui/AddRepoState.kt index e36156db..1ebc3cee 100644 --- a/src/main/kotlin/app/ui/AddRepoState.kt +++ b/src/main/kotlin/app/ui/AddRepoState.kt @@ -3,7 +3,7 @@ package app.ui -import app.Analytics +import app.Logger import app.api.Api import app.config.Configurator import app.model.LocalRepo @@ -21,19 +21,18 @@ class AddRepoState constructor(private val context: Context, if (configurator.getLocalRepos().isNotEmpty()) return while (true) { - println("Type a path to repository, or hit Enter to start " - + "hashing.") + Logger.print("Type a path to repository, or hit Enter to continue.") val pathString = readLine() ?: "" if (pathString.isEmpty()) { if (configurator.getLocalRepos().isEmpty()) { - println("Add at least one valid repository.") + Logger.print("Add at least one valid repository.") } else { break // User finished to add repos. } } else { if (RepoHelper.isValidRepo(pathString)) { - println("Added git repository at $pathString.") + Logger.print("Added git repository at $pathString.") val localRepo = LocalRepo(pathString) localRepo.hashAllContributors = UiHelper.confirm("Do you " + "want to hash commits of all contributors?", @@ -41,15 +40,18 @@ class AddRepoState constructor(private val context: Context, configurator.addLocalRepoPersistent(localRepo) configurator.saveToFile() } else { - println("No valid git repository found at $pathString.") + Logger.print("Directory should contain a valid git " + + "repository.") + Logger.print("Make sure that master branch with at least " + + "one commit exists.") } } } - Analytics.trackConfigSetup() + Logger.info(Logger.Events.CONFIG_SETUP) { "Config setup" } } override fun next() { - context.changeState(UpdateRepoState(context, api, configurator)) + context.changeState(EmailState(context, api, configurator)) } } diff --git a/src/main/kotlin/app/ui/AuthState.kt b/src/main/kotlin/app/ui/AuthState.kt index 627b63e1..44208bb4 100644 --- a/src/main/kotlin/app/ui/AuthState.kt +++ b/src/main/kotlin/app/ui/AuthState.kt @@ -3,12 +3,14 @@ package app.ui -import app.Analytics import app.BuildConfig +import app.Logger import app.api.Api import app.config.Configurator import app.utils.PasswordHelper -import app.utils.RequestException +import app.api.ApiError +import app.api.ifNotNullThrow +import app.api.isWithServerCode /** * Authorization console UI state. @@ -19,7 +21,8 @@ class AuthState constructor(private val context: Context, : ConsoleState { var username = "" var password = "" - var connectionError = false + var retry = true + var authorized = false override fun doAction() { if (!configurator.isValidCredentials()) { @@ -27,13 +30,15 @@ class AuthState constructor(private val context: Context, getPassword() } - while (!tryAuth() && !connectionError) { + authorized = tryAuth() + while (!authorized && retry) { getPassword() + authorized = tryAuth() } } override fun next() { - if (!connectionError) { + if (authorized) { context.changeState(ListRepoState(context, api, configurator)) } else { context.changeState(CloseState()) @@ -41,13 +46,13 @@ class AuthState constructor(private val context: Context, } fun getUsername() { - println("Enter username:") + Logger.print("Enter username:") username = readLine() ?: "" configurator.setUsernameCurrent(username) } fun getPassword() { - println("Enter password:") + Logger.print("Enter password:") password = PasswordHelper.readPassword() configurator.setPasswordCurrent(password) } @@ -66,32 +71,42 @@ class AuthState constructor(private val context: Context, fun tryAuth(): Boolean { try { - println("Authenticating...") - api.authorize() + Logger.print("Signing in...") + val (_, error) = api.authorize() + if (error.isWithServerCode(Api.OUT_OF_DATE)) { + Logger.print("App is out of date. Please get new version at " + + "https://sourcerer.io") + retry = false + return false + } + // Other request errors should be processed by try/catch. + error.ifNotNullThrow() - val user = api.getUser() - configurator.setRepos(user.repos) + val user = api.getUser().getOrThrow() + configurator.setUser(user) - println("You are successfully authenticated. Your profile page is " - + BuildConfig.PROFILE_URL + configurator.getUsername()) - saveCredentialsIfChanged() + Logger.print("Signed in successfully. Your profile page is " + + BuildConfig.PROFILE_URL + configurator.getUsername()) - Analytics.username = configurator.getUsername() - Analytics.trackAuth() + saveCredentialsIfChanged() + Logger.username = configurator.getUsername() + Logger.info(Logger.Events.AUTH) { "Auth success" } return true - } catch (e: RequestException) { + } catch (e: ApiError) { if (e.isAuthError) { if(e.httpBodyMessage.isNotBlank()) { - println(e.httpBodyMessage) + Logger.print(e.httpBodyMessage) } else { - println("Authentication error. Try again.") + Logger.print("Authentication error. Try again.") } } else { - connectionError = true - println("Connection problems. Try again later.") + Logger.print("Connection problems. Try again later.") + Logger.error(e) + retry = false } } + return false } } diff --git a/src/main/kotlin/app/ui/CloseState.kt b/src/main/kotlin/app/ui/CloseState.kt index 6d91f4f7..2b6403bb 100644 --- a/src/main/kotlin/app/ui/CloseState.kt +++ b/src/main/kotlin/app/ui/CloseState.kt @@ -3,17 +3,19 @@ package app.ui +import app.Logger + /** * On application close console UI state. */ class CloseState : ConsoleState { override fun doAction() { - println("You could use console commands to control repositories. To " - + "setup again run application with flag --setup. For more " - + "info run application with flag --help.") + Logger.print("You could use console commands to control repositories.", + indentLine = true) + Logger.print("For more info run application with flag --help.") // TODO(anatoly): Check for problems for display support message. - println("Feel free to contact us on any problem by " - + "support@sourcerer.io.") + Logger.print("Feel free to contact us on any problem by " + + "support@sourcerer.io.") } override fun next() { diff --git a/src/main/kotlin/app/ui/EmailState.kt b/src/main/kotlin/app/ui/EmailState.kt new file mode 100644 index 00000000..2f7a0530 --- /dev/null +++ b/src/main/kotlin/app/ui/EmailState.kt @@ -0,0 +1,96 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app.ui + +import app.Logger +import app.api.Api +import app.config.Configurator +import app.model.User +import app.model.UserEmail +import app.utils.UiHelper +import org.eclipse.jgit.api.Git +import java.io.File + +/** + * Update repositories console UI state. + */ +class EmailState constructor(private val context: Context, + private val api: Api, + private val configurator: Configurator) + : ConsoleState { + override fun doAction() { + val user = configurator.getUser() + + if (user.emails.isNotEmpty()) { + Logger.print("List of your emails:", indentLine = true) + user.emails.forEach { email -> println(email) } + } else { + // Shouldn't really happen. User always have primary email. + Logger.print("Add at least one email to build your profile.", + indentLine = true) + } + + val knownEmails = user.emails.map { it.email } + val newEmails = hashSetOf() + val configEmails = hashSetOf() + // TODO(anatoly): Tell about web editing emails, when it's ready. + // TODO(anatoly): Add global config parsing. + // TODO(anatoly): Add user config parsing. + + // Add emails from git configs. + for (repo in configurator.getLocalRepos()) { + try { + val git = Git.open(File(repo.path)) + val email = git.repository + .config.getString("user", null, "email") ?: "" + if (!knownEmails.contains(email)) { + configEmails.add(email) + } + } catch (e: Exception) { + Logger.error(e, "Error while parsing git config") + } + } + + if (configEmails.isNotEmpty()) { + Logger.print("Your git config contains untracked emails:") + configEmails.forEach { email -> println(email) } + if (UiHelper.confirm("Do you want to add this emails to your " + + "account?", defaultIsYes = true)) { + newEmails.addAll(configEmails) + } + } + + // Ask user to enter his emails. + if (UiHelper.confirm("Do you want to specify additional emails " + + "that you use in repositories?", defaultIsYes = false)) { + while (true) { + Logger.print("Type a email, or hit Enter to continue.") + val email = readLine() ?: "" + if (email.isBlank()) break + if (!knownEmails.contains(email)) newEmails.add(email) + } + } + + if (newEmails.isNotEmpty()) { + val newUserEmails = newEmails.map { UserEmail(email = it) } + // We will need new emails during hashing. + user.emails.addAll(newUserEmails) + + // Send new emails to server. + val userNewEmails = User(emails = newUserEmails.toHashSet()) + api.postUser(userNewEmails) + } + + // Warn user about need of confirmation. + if (user.emails.filter { email -> !email.verified }.isNotEmpty() || + newEmails.isNotEmpty()) { + Logger.print("Confirm your emails to show all statistics in " + + "profile.") + } + } + + override fun next() { + context.changeState(UpdateRepoState(context, api, configurator)) + } +} diff --git a/src/main/kotlin/app/ui/OpenState.kt b/src/main/kotlin/app/ui/OpenState.kt index 7c04d729..44165dff 100644 --- a/src/main/kotlin/app/ui/OpenState.kt +++ b/src/main/kotlin/app/ui/OpenState.kt @@ -3,6 +3,7 @@ package app.ui +import app.Logger import app.api.Api import app.config.Configurator @@ -15,11 +16,13 @@ class OpenState constructor(private val context: Context, : ConsoleState { override fun doAction() { if (!configurator.isValidCredentials()) { - println("Sourcerer hashes your git repositories into intelligent " - + "engineering profiles. If you don't have an account, " - + "please, proceed to http://sourcerer.io/register.") + Logger.print("Sourcerer hashes your git repositories into " + + "intelligent engineering profiles.") + Logger.print("If you don't have an account, please, sign up at " + + "https://sourcerer.io/join") } else { - println("Sourcerer. Use flag --help to list available commands.") + Logger.print("Sourcerer. Use flag --help to list available " + + "commands.") } } diff --git a/src/main/kotlin/app/ui/UpdateRepoState.kt b/src/main/kotlin/app/ui/UpdateRepoState.kt index 925a4c23..4e4b52bf 100644 --- a/src/main/kotlin/app/ui/UpdateRepoState.kt +++ b/src/main/kotlin/app/ui/UpdateRepoState.kt @@ -3,13 +3,12 @@ package app.ui -import app.Analytics +import app.BuildConfig import app.hashers.RepoHasher import app.Logger import app.api.Api import app.config.Configurator import app.utils.HashingException -import app.utils.RequestException /** * Update repositories console UI state. @@ -19,23 +18,28 @@ class UpdateRepoState constructor(private val context: Context, private val configurator: Configurator) : ConsoleState { override fun doAction() { - println("Hashing your git repositories.") + Logger.info { "Hashing started" } + for (repo in configurator.getLocalRepos()) { try { + Logger.print("Hashing $repo repository...", indentLine = true) RepoHasher(repo, api, configurator).update() + Logger.print("Hashing $repo completed.") } catch (e: HashingException) { - Logger.error("During hashing ${e.errors.size} errors occurred:") e.errors.forEach { error -> - Logger.error("", error) + Logger.error(error, "Error while hashing") } } catch (e: Exception) { - Logger.error("Error while hashing $repo", e) + Logger.error(e, "Error while hashing") } } - println("The repositories have been hashed. See result online on your " - + "Sourcerer profile.") - Analytics.trackHashingSuccess() + api.postComplete().onErrorThrow() + Logger.print("The repositories have been hashed.") + Logger.print("Take a look at the updates in your profile at " + + BuildConfig.PROFILE_URL + configurator.getUsername(), + indentLine = true) + Logger.info(Logger.Events.HASHING_SUCCESS) { "Hashing success" } } override fun next() { diff --git a/src/main/kotlin/app/utils/FileHelper.kt b/src/main/kotlin/app/utils/FileHelper.kt index 0ec2a3e8..668348f1 100644 --- a/src/main/kotlin/app/utils/FileHelper.kt +++ b/src/main/kotlin/app/utils/FileHelper.kt @@ -3,12 +3,48 @@ package app.utils +import java.io.File +import java.net.URLDecoder +import java.nio.file.Files import java.nio.file.Paths +import java.nio.file.Path +/* + * Wrapper around Java Path and File classes to work the sourcerer's files. + */ object FileHelper { + private val dirName = "data" + private val jarPath = getJarPath() + private val settingsPath = jarPath.resolve(dirName) + + fun getPath(name: String, vararg parts: String): Path { + val path = settingsPath.resolve(Paths.get("", *parts)) + if (Files.notExists(path)) { + Files.createDirectories(path) + } + return path.resolve(name) + } + + fun getFile(name: String, vararg parts: String): File { + return getPath(name, *parts).toFile() + } + + fun notExists(name:String, vararg parts: String): Boolean { + return Files.notExists(getPath(name, *parts)) + } + fun getFileExtension(path: String): String { val fileName = Paths.get(path).fileName.toString() - return fileName.substringAfterLast( - delimiter = '.', missingDelimiterValue = "") + return fileName.substringAfterLast(delimiter = '.', + missingDelimiterValue = "") + } + + fun getJarPath(): Path { + val fullPathString = URLDecoder.decode(FileHelper::class.java + .protectionDomain.codeSource.location.toURI().path, "UTF-8") + val fullPath = Paths.get(fullPathString) + val root = fullPath.root + // Removing jar filename. + return root.resolve(fullPath.subpath(0, fullPath.nameCount - 1)) } } diff --git a/src/main/kotlin/app/utils/Metrics.kt b/src/main/kotlin/app/utils/Metrics.kt new file mode 100644 index 00000000..7476ccaa --- /dev/null +++ b/src/main/kotlin/app/utils/Metrics.kt @@ -0,0 +1,82 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Alexander Surkov (alex@sourcerer.io) + +package app.utils + +import kotlin.system.* + +object Metrics { + override fun toString(): String { + var str: String = "" + + var total: Long = 0 + for ((_, item) in timing.items) { + total += item.time + } + + str += timing.toStringHelper(total) + + for ((tag, value) in numerical) { + str += "$tag: $value\n" + } + + return str + } + + fun recordTime(tag: String, block: () -> T) : T { + val parentItem = timingStack.get(timingStack.lastIndex) + var item = parentItem.items.getOrPut(tag, { TimingData() }) + + timingStack.add(item) + + val start = System.currentTimeMillis() + val result = block() + item.time += System.currentTimeMillis() - start + + timingStack.subList(timingStack.lastIndex, timingStack.lastIndex + 1).clear() + + return result + } + + fun recordMetric(tag: String, metric: Long) + { + var v = numerical.getOrDefault(tag, 0) + numerical.put(tag, v + metric); + } + + class TimingData { + var time: Long = 0 + var items = mutableMapOf() + + fun toStringHelper(total: Long, offset: String = ""): String { + var str = "" + for ((tag, item) in items) { + str += "$offset$tag: ${item.time} ms, ${item.time * 100 / total}%\n" + str += item.toStringHelper(total, "$offset ") + } + return str + } + } + + private val timing = TimingData() + private val timingStack = mutableListOf(timing) + private var nestedTiming: Boolean = true + + private val numerical = mutableMapOf() +} + +/** + * Records a block's execution time in milliseconds and assigns it a tag in + * calls hierarchy. + */ +fun recordTime(tag: String, block: () -> T) : T { + return Metrics.recordTime(tag, block) +} + +/** + * Records a numeric metric of given tag. + */ +fun recordMetric(tag: String, metric: Long) +{ + return Metrics.recordMetric(tag, metric) +} diff --git a/src/main/kotlin/app/utils/RepoHelper.kt b/src/main/kotlin/app/utils/RepoHelper.kt index 2cb5f3de..411eba84 100644 --- a/src/main/kotlin/app/utils/RepoHelper.kt +++ b/src/main/kotlin/app/utils/RepoHelper.kt @@ -32,7 +32,7 @@ object RepoHelper { repository = git.repository commitId = repository.resolve(MASTER_BRANCH) } catch (e: Exception) { - Logger.error("Cannot access repository at path $path", e) + Logger.error(e, "Cannot access repository at specified path") return false } finally { repository?.close() @@ -49,13 +49,13 @@ object RepoHelper { return try { Paths.get(path).toFile().isDirectory } catch (e: InvalidPathException) { - Logger.error("Invalid path $path", e) + Logger.error(e, "Invalid path") false } catch (e: UnsupportedOperationException) { - Logger.error("Invalid path $path", e) + Logger.error(e, "Invalid path") false } catch (e: SecurityException) { - Logger.error("Cannot access repository at path $path", e) + Logger.error(e, "Cannot access repository at specified path") false } } @@ -86,23 +86,23 @@ object RepoHelper { fun printRepos(localRepos: List) { for (repo in localRepos) { - println(repo) + Logger.print(repo) } } fun printRepos(localRepos: List, title: String) { if (localRepos.isNotEmpty()) { - println(title) + Logger.print(title, indentLine = true) printRepos(localRepos) } } fun printRepos(localRepos: List, title: String, empty: String) { if (localRepos.isNotEmpty()) { - println(title) + Logger.print(title, indentLine = true) printRepos(localRepos) } else { - println(empty) + Logger.print(empty, indentLine = true) } } } diff --git a/src/main/kotlin/app/utils/RequestException.kt b/src/main/kotlin/app/utils/RequestException.kt index 7a6f57db..fd56e7ef 100644 --- a/src/main/kotlin/app/utils/RequestException.kt +++ b/src/main/kotlin/app/utils/RequestException.kt @@ -21,8 +21,8 @@ class RequestException(exception: Exception) : Exception(exception.message) { var isParseError = false constructor(fuelError: FuelError) : this(fuelError as Exception) { - httpStatusCode = fuelError.response.httpStatusCode - httpResponseMessage = fuelError.response.httpResponseMessage + httpStatusCode = fuelError.response.statusCode + httpResponseMessage = fuelError.response.responseMessage httpBodyMessage = fuelError.response.data .toString(Charset.defaultCharset()) } diff --git a/src/main/kotlin/app/utils/UiHelper.kt b/src/main/kotlin/app/utils/UiHelper.kt index c7d9671d..a7b067d6 100644 --- a/src/main/kotlin/app/utils/UiHelper.kt +++ b/src/main/kotlin/app/utils/UiHelper.kt @@ -3,11 +3,13 @@ package app.utils +import app.Logger + object UiHelper { fun confirm(message: String, defaultIsYes: Boolean): Boolean { val yes = if (defaultIsYes) "Y" else "y" val no = if (!defaultIsYes) "N" else "n" - println("$message [$yes/$no]") + Logger.print("$message [$yes/$no]") val oppositeDefaultValue = if (defaultIsYes) no else yes if ((readLine() ?: "").toLowerCase() == oppositeDefaultValue) { return !defaultIsYes diff --git a/src/main/proto/classifier.proto b/src/main/proto/classifier.proto new file mode 100644 index 00000000..dcefcaf0 --- /dev/null +++ b/src/main/proto/classifier.proto @@ -0,0 +1,21 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Liubov Yaronskaya (lyaronskaya@sourcerer.io) + +syntax = "proto3"; + +// Java compiler settings. +package app; +option java_package = "app"; +option java_outer_classname = "ModelsProtos"; + +message Classifier { + repeated string tokens = 1; + + repeated string libraries = 2; + + repeated float idf = 3; + + repeated float weights = 4; + + repeated float biases = 5; +} diff --git a/src/main/proto/sourcerer.proto b/src/main/proto/sourcerer.proto index efa1ca90..153ea403 100644 --- a/src/main/proto/sourcerer.proto +++ b/src/main/proto/sourcerer.proto @@ -72,10 +72,29 @@ message FactGroup { repeated Fact facts = 1; } +// Used to authors for invitational procedures. +message Author { + string email = 1; + string repo_rehash = 2; +} + +message AuthorGroup { + repeated Author authors = 1; +} + // User properties and indentity information about repos. message User { // List of known repos containing basic information for indentifying repo. repeated Repo repos = 1; + // List of known emails. + repeated UserEmail emails = 2; +} + +// Email of user with its status information. +message UserEmail { + string email = 1; + bool primary = 2; + bool verified = 3; } // Repository parameters for hashing. @@ -87,9 +106,6 @@ message Repo { // Rehash of first commit in repo. Used for indentifying forks. string initial_commit_rehash = 2; - // Email of app user from git config of repo. - string user_email = 3; - // Authors' email filter for hashed commits. If empty list then hash only // commits that created by current user. repeated string emails = 4; @@ -98,3 +114,13 @@ message Repo { // starts after last commit from the overlap. repeated Commit commits = 5; } + +// Errors from server. +message Error { + uint32 code = 1; + string message = 2; +} + +message Errors { + repeated Error errors = 1; +} diff --git a/src/main/resources/data/imports/cpp.txt b/src/main/resources/data/imports/cpp.txt new file mode 100644 index 00000000..f8b23502 --- /dev/null +++ b/src/main/resources/data/imports/cpp.txt @@ -0,0 +1,4 @@ +opencv:opencv +opencv2:opencv +protobuf:protobuf +pb:protobuf \ No newline at end of file diff --git a/src/main/resources/data/imports/python.txt b/src/main/resources/data/imports/python.txt new file mode 100644 index 00000000..e8a45a71 --- /dev/null +++ b/src/main/resources/data/imports/python.txt @@ -0,0 +1,2 @@ +cv2:opencv +cv:opencv \ No newline at end of file diff --git a/src/test/kotlin/test/tests/extractors/ExtractorTest.kt b/src/test/kotlin/test/tests/extractors/ExtractorTest.kt new file mode 100644 index 00000000..ccc80a88 --- /dev/null +++ b/src/test/kotlin/test/tests/extractors/ExtractorTest.kt @@ -0,0 +1,191 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Liubov Yaronskaya (lyaronskaya@sourcerer.io) + +package test.tests.extractors + +import app.extractors.* +import junit.framework.TestCase.assertTrue +import org.jetbrains.spek.api.Spek +import org.jetbrains.spek.api.dsl.given +import org.jetbrains.spek.api.dsl.it +import kotlin.test.assertEquals + +fun assertExtractsLineLibraries(expectedLibrary: String, actualLine: String, + extractor: ExtractorInterface) { + val actualLineLibraries = + extractor.getLineLibraries(actualLine, listOf(expectedLibrary)) + assertTrue(expectedLibrary in actualLineLibraries) +} + +fun assertExtractsNoLibraries(actualLine: String, + extractor: ExtractorInterface) { + val actualLineLibraries = + extractor.getLineLibraries(actualLine, listOf()) + assertEquals(listOf(), actualLineLibraries) +} + +fun assertExtractsImport(expectedImport: String, actualLine: String, + extractor: ExtractorInterface) { + val actualLineImport = extractor.extractImports(listOf(actualLine)) + assertTrue(expectedImport in actualLineImport) +} + +class ExtractorTest : Spek({ + + given(" code line contains library code" ) { + it("python extractor extracts the library") { + val line = "with tf.Session() as sess" + assertExtractsLineLibraries("tensorflow", + line, PythonExtractor()) + } + + it("java extractor extracts the library") { + val line = "private JdbcTemplate jdbcTemplate=new JdbcTemplate();" + assertExtractsLineLibraries("org.springframework", + line, JavaExtractor()) + } + + it("javascript extractor extracts the library") { + val line = "new Vue({" + assertExtractsLineLibraries("vue", + line, JavascriptExtractor()) + } + + it("ruby extractor extracts the library") { + val line1 = "img = Magick::Image.read_inline(Base64.encode64(image)).first" + assertExtractsLineLibraries("RMagick", + line1, RubyExtractor()) + val line2 = "fximages << {image: img.adaptive_threshold(3, 3, 0), name: \"Adaptive Threshold\"}" + assertExtractsLineLibraries("RMagick", + line2, RubyExtractor()) + } + + it("go extractor extracts the library") { + val line = "if DB, found = revel.Config.String(\"bloggo.db\"); !found {" + assertExtractsLineLibraries("revel", + line, GoExtractor()) + } + + it("objectiveC extractor extracts the library") { + val line = "[[NSFileManager defaultManager] removeItemAtURL:[RLMRealmConfiguration defaultConfiguration].fileURL error:nil];" + assertExtractsLineLibraries("Realm", + line, ObjectiveCExtractor()) + } + + it("swift extractor extracts the library") { + val line = "class City: RLMObject {" + assertExtractsLineLibraries("Realm", + line, SwiftExtractor()) + } + + it("cpp extractor extracts the library") { + val line1 = "leveldb::Options options;" + assertExtractsLineLibraries("leveldb", + line1, CppExtractor()) + val line2 = "leveldb::Status status = leveldb::DB::Open(options, \"./testdb\", &tmp);" + assertExtractsLineLibraries("leveldb", + line2, CppExtractor()) + } + + it("csharp extractor extracts the library") { + val line = "Algorithm = (h, v, i) => new ContrastiveDivergenceLearning(h, v)" + assertExtractsLineLibraries("Accord", + line, CSharpExtractor()) + } + + it("php extractor extracts the library") { + val line = "public function listRepos(string \$user, int \$limit): Call;" + assertExtractsLineLibraries("Tebru\\Retrofit", + line, PhpExtractor()) + } + + it("c extractor extracts the library") { + val line = "grpc_test_init(argc, argv);" + assertExtractsLineLibraries("grpc", + line, CExtractor()) + } + } + + given("code line doesn't use libraries" ) { + it("python extractor returns empty list") { + val line = "from collections import Counter" + assertExtractsNoLibraries(line, PythonExtractor()) + } + + it("java extractor returns empty list") { + val line = "throw new RuntimeException(e);" + assertExtractsNoLibraries(line, JavaExtractor()) + } + + it("javascript extractor returns empty list") { + val line = "console.log(self.commits[0].html_url)" + assertExtractsNoLibraries(line, JavascriptExtractor()) + } + + it("ruby extractor returns empty list") { + val line = "require \"RMagick\"" + assertExtractsNoLibraries(line, RubyExtractor()) + } + + it("go extractor returns empty list") { + val line = "var found bool" + assertExtractsNoLibraries(line, GoExtractor()) + } + + it("objectivec extractor returns empty list") { + val line = "@end" + assertExtractsNoLibraries(line, ObjectiveCExtractor()) + } + + it("php extractor returns empty list") { + val line = " fail("exception") }).getLinesList() it("'t1: initial insertion'") { assertEquals(2, lines1.size) @@ -85,7 +89,8 @@ class CodeLongevityTest : Spek({ // t2: subsequent insertion testRepo.insertLines(fileName, 1, listOf("line in the middle")) val rev2 = testRepo.commit("insert line") - val lines2 = CodeLongevity(Repo(), emails, testRepo.git).getLinesList() + val lines2 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() it("'t2: subsequent insertion'") { assertEquals(3, lines2.size) @@ -106,7 +111,8 @@ class CodeLongevityTest : Spek({ // t3: subsequent deletion testRepo.deleteLines(fileName, 2, 2) val rev3 = testRepo.commit("delete line") - val lines3 = CodeLongevity(Repo(), emails, testRepo.git).getLinesList() + val lines3 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() it("'t3: subsequent deletion'") { assertEquals(3, lines3.size) @@ -127,7 +133,8 @@ class CodeLongevityTest : Spek({ // t4: file deletion testRepo.deleteFile(fileName) val rev4 = testRepo.commit("delete file") - val lines4 = CodeLongevity(Repo(), emails, testRepo.git).getLinesList() + val lines4 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() it("'t4: file deletion'") { assertEquals(3, lines4.size) @@ -181,7 +188,8 @@ class CodeLongevityTest : Spek({ ) testRepo.createFile(fileName, fileContent) val rev1 = testRepo.commit("initial commit") - val lines1 = CodeLongevity(Repo(), emails, testRepo.git).getLinesList() + val lines1 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() it("'t2.1: initial insertion'") { assertEquals(fileContent.size, lines1.size) @@ -227,7 +235,8 @@ class CodeLongevityTest : Spek({ testRepo.insertLines(fileName, 11, listOf("Proof addition 3")) val rev2 = testRepo.commit("insert+delete") - val lines2 = CodeLongevity(Repo(), emails, testRepo.git).getLinesList() + val lines2 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() it("'t2.2: ins+del'") { assertEquals(22, lines2.size) @@ -299,7 +308,8 @@ class CodeLongevityTest : Spek({ testRepo.deleteLines(fileName, 2, 2) val rev3 = testRepo.commit("delete line2") - val lines1 = CodeLongevity(Repo(), emails, testRepo.git).getLinesList() + val lines1 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() val lines1_line15 = lines1[0] val lines1_line1 = lines1[1] val lines1_line2 = lines1[2] @@ -323,8 +333,8 @@ class CodeLongevityTest : Spek({ testRepo.deleteLines(fileName, 0, 0) val rev4 = testRepo.commit("delete line1") - val lines2 = - CodeLongevity(Repo(), emails, testRepo.git).getLinesList(rev3) + val lines2 = CodeLongevity(Repo(), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList(rev3) val lines2_line1 = lines2[0] val lines2_line15 = lines2[1] @@ -367,10 +377,12 @@ class CodeLongevityTest : Spek({ val t1_rev = testRepo.commit(message = "insert line", date = Calendar.Builder().setTimeOfDay(0, 1, 0).build().time) - val t1_ages = CodeLongevity(Repo(rehash = testRehash), - emails, testRepo.git).scan()!! - val t1_lines = CodeLongevity(Repo(rehash = testRehash), - emails, testRepo.git).getLinesList() + val t1_ages = CodeLongevity( + Repo(rehash = testRehash), emails, testRepo.git, + { _ -> fail("exception") }).scan()!! + val t1_lines = CodeLongevity( + Repo(rehash = testRehash), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList() it("'t1'") { assertTrue(t1_ages.aggrAges.isEmpty(), @@ -387,10 +399,12 @@ class CodeLongevityTest : Spek({ testRepo.commit(message = "delete line2", date = Calendar.Builder().setTimeOfDay(0, 3, 0).build().time) - val t2_ages = CodeLongevity(Repo(rehash = testRehash), - emails, testRepo.git).scan()!! - val t2_lines = CodeLongevity(Repo(rehash = testRehash), - emails, testRepo.git).getLinesList(t1_rev) + val t2_ages = CodeLongevity( + Repo(rehash = testRehash), emails, testRepo.git, + { _ -> fail("exception") }).scan()!! + val t2_lines = CodeLongevity( + Repo(rehash = testRehash), emails, testRepo.git, + { _ -> fail("exception") }).getLinesList(t1_rev) it("'t2'") { assertEquals(1, t2_ages.aggrAges[email]!!.count, @@ -408,8 +422,9 @@ class CodeLongevityTest : Spek({ } afterGroup { - CodeLongevity(Repo(rehash = testRehash), - emails, testRepo.git).dropSavedData() + CodeLongevity( + Repo(rehash = testRehash), emails, testRepo.git, + { _ -> fail("exception") }).dropSavedData() testRepo.destroy() } } @@ -446,7 +461,8 @@ class CodeLongevityTest : Spek({ author = author1, date = Calendar.Builder().setTimeOfDay(0, 4, 0).build().time) - CodeLongevity(serverRepo, emails, testRepo.git).updateStats(mockApi) + CodeLongevity(serverRepo, emails, testRepo.git, + { _ -> fail("exception") }).updateStats(mockApi) it("'t1'") { assertTrue(mockApi.receivedFacts.contains( @@ -464,8 +480,9 @@ class CodeLongevityTest : Spek({ } afterGroup { - CodeLongevity(Repo(rehash = testRehash), - emails, testRepo.git).dropSavedData() + CodeLongevity( + Repo(rehash = testRehash), emails, testRepo.git, + { _ -> fail("exception") }).dropSavedData() testRepo.destroy() } } diff --git a/src/test/kotlin/test/tests/hashers/FactHasherTest.kt b/src/test/kotlin/test/tests/hashers/FactHasherTest.kt index 3a39b6c2..f6302188 100644 --- a/src/test/kotlin/test/tests/hashers/FactHasherTest.kt +++ b/src/test/kotlin/test/tests/hashers/FactHasherTest.kt @@ -16,6 +16,7 @@ import org.jetbrains.spek.api.dsl.it import test.utils.TestRepo import java.util.* import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class FactHasherTest : Spek({ @@ -35,6 +36,27 @@ class FactHasherTest : Spek({ return cal.time } + fun getFact(code: Int, key: Int, author: Author, facts: List): Fact { + val fact = facts.find { fact -> + fact.code == code && fact.key == key && fact.author == author + } + assertNotNull(fact) + return fact!! + } + + fun assertFactInt(code: Int, key: Int, value: Int, author: Author, + facts: List) { + val fact = getFact(code, key, author, facts) + assertEquals(value, fact.value.toInt()) + } + + fun assertFactDouble(code: Int, key: Int, value: Double, author: Author, + facts: List) { + val fact = getFact(code, key, author, facts) + assertTrue(Math.abs(value - fact.value.toDouble()) < 0.1, + "Expected approximately <$value>, actual <${fact.value}>") + } + given("commits for date facts") { val testRepo = TestRepo(repoPath + "date-facts") val emails = hashSetOf(authorEmail1, authorEmail2) @@ -54,13 +76,13 @@ class FactHasherTest : Spek({ val errors = mutableListOf() val observable = CommitCrawler.getObservable(testRepo.git, repo) - FactHasher(repo, mockApi, emails) + FactHasher(repo, mockApi, listOf("r1"), emails) .updateFromObservable(observable, { e -> errors.add(e) }) assertEquals(0, errors.size) - assertTrue(facts.contains(Fact(repo, FactCodes.COMMITS_DAY_TIME, 13, + assertTrue(facts.contains(Fact(repo, FactCodes.COMMIT_DAY_TIME, 13, "1", author1))) - assertTrue(facts.contains(Fact(repo, FactCodes.COMMITS_DAY_WEEK, 6, + assertTrue(facts.contains(Fact(repo, FactCodes.COMMIT_DAY_WEEK, 6, "1", author1))) } @@ -79,17 +101,17 @@ class FactHasherTest : Spek({ val errors = mutableListOf() val observable = CommitCrawler.getObservable(testRepo.git, repo) - FactHasher(repo, mockApi, emails) + FactHasher(repo, mockApi, listOf("r1", "r2"), emails) .updateFromObservable(observable, { e -> errors.add(e) }) assertEquals(0, errors.size) - assertTrue(facts.contains(Fact(repo, FactCodes.COMMITS_DAY_TIME, 18, + assertTrue(facts.contains(Fact(repo, FactCodes.COMMIT_DAY_TIME, 18, "1", author2))) - assertTrue(facts.contains(Fact(repo, FactCodes.COMMITS_DAY_WEEK, 0, + assertTrue(facts.contains(Fact(repo, FactCodes.COMMIT_DAY_WEEK, 0, "1", author2))) - assertTrue(facts.contains(Fact(repo, FactCodes.COMMITS_DAY_TIME, 13, + assertTrue(facts.contains(Fact(repo, FactCodes.COMMIT_DAY_TIME, 13, "2", author1))) - assertTrue(facts.contains(Fact(repo, FactCodes.COMMITS_DAY_WEEK, 0, + assertTrue(facts.contains(Fact(repo, FactCodes.COMMIT_DAY_WEEK, 0, "1", author1))) } @@ -154,9 +176,11 @@ class FactHasherTest : Spek({ val errors = mutableListOf() val observable = CommitCrawler.getObservable(testRepo.git, repo) - FactHasher(repo, mockApi, emails) + FactHasher(repo, mockApi, + listOf("r1", "r2", "r3", "r4", "r5", "r6"), emails) .updateFromObservable(observable, { e -> errors.add(e) }) + assertEquals(0, errors.size) assertTrue(facts.contains(Fact(repo, FactCodes.REPO_DATE_START, 0, (startAuthor1.time/1000).toString(), author1))) assertTrue(facts.contains(Fact(repo, FactCodes.REPO_DATE_START, 0, @@ -169,6 +193,90 @@ class FactHasherTest : Spek({ "2"))) } + afterGroup { + testRepo.destroy() + } + } + + given("test of commit facts") { + val testRepo = TestRepo(repoPath + "commit-facts") + val emails = hashSetOf(authorEmail1, authorEmail2) + val mockApi = MockApi(mockRepo = repo) + val facts = mockApi.receivedFacts + val lines = listOf( + "All my rap, if shortly, is about the thing that", + "For so many years so many cities have been under hoof", + "To go uphill when gets lucky. Then downhill when feels sick", + "I'm not really a Gulliver, but still the city is under hoof", + "City under hoof, city under hoof", + "Traffic lights, state duties, charges and customs", + "I don't know whether this path is wade or to the bottom", + "You live under a thumb, I have a city under my hoof", + "All my rap, if shortly, is about the thing that", + "For so many years so many cities have been under hoof", + "To go uphill when gets lucky. Then downhill when feels sick", + "I'm not really a Gulliver, but still the city is under hoof", + "City under hoof, city under hoof", + "Traffic lights, state duties, charges and customs", + "I don't know whether this path is wade or to the bottom", + "You live under a thumb, I have a city under my hoof", + "All my rap, if shortly, is about the thing that", + "For so many years so many cities have been under hoof", + "To go uphill when gets lucky. Then downhill when feels sick", + "I'm not really a Gulliver, but still the city is under hoof", + "City under hoof, city under hoof", + "Traffic lights, state duties, charges and customs", + "I don't know whether this path is wade or to the bottom", + "You live under a thumb, I have a city under my hoof" + ) + val linesLenAvg = lines.fold (0) { acc, s -> acc + s.length } / + lines.size.toDouble() + + afterEachTest { + facts.clear() + } + + it("sends facts") { + testRepo.createFile("test1.txt", listOf()) + + testRepo.insertLines("test1.txt", 0, lines.subList(0, 3)) + testRepo.commit(message = "Commit 1", author = author1) + + testRepo.insertLines("test1.txt", 0, lines.subList(3, 6)) + testRepo.commit(message = "Commit 2", author = author1) + + testRepo.insertLines("test1.txt", 0, lines.subList(6, 9)) + testRepo.commit(message = "Commit 3", author = author1) + + testRepo.insertLines("test1.txt", 0, lines.subList(9, 12)) + testRepo.commit(message = "Commit 4", author = author1) + + testRepo.insertLines("test1.txt", 0, lines.subList(12, 16)) + testRepo.commit(message = "Commit 5", author = author1) + + testRepo.insertLines("test1.txt", 0, lines.subList(16, 24)) + testRepo.commit(message = "Commit 6", author = author1) + + val errors = mutableListOf() + val observable = CommitCrawler.getObservable(testRepo.git, repo) + FactHasher(repo, mockApi, + listOf("r1", "r2", "r3", "r4", "r5", "r6"), emails) + .updateFromObservable(observable, { e -> errors.add(e) }) + + assertEquals(0, errors.size) + assertFactInt(FactCodes.COMMIT_NUM, 0, 6, author1, facts) + assertFactDouble(FactCodes.COMMIT_LINE_NUM_AVG, 0, 4.0, author1, + facts) + assertFactInt(FactCodes.LINE_NUM, 0, 24, author1, facts) + assertFactDouble(FactCodes.LINE_LEN_AVG, 0, linesLenAvg, author1, + facts) + assertFactInt(FactCodes.COMMIT_NUM_TO_LINE_NUM, 3, 4, author1, + facts) + assertFactInt(FactCodes.COMMIT_NUM_TO_LINE_NUM, 4, 1, author1, + facts) + assertFactInt(FactCodes.COMMIT_NUM_TO_LINE_NUM, 8, 1, author1, + facts) + } afterGroup { testRepo.destroy() diff --git a/src/test/kotlin/test/utils/TestRepo.kt b/src/test/kotlin/test/utils/TestRepo.kt index 448fddcd..d5d123df 100644 --- a/src/test/kotlin/test/utils/TestRepo.kt +++ b/src/test/kotlin/test/utils/TestRepo.kt @@ -24,7 +24,7 @@ class TestRepo(val repoPath: String) { val userName = "Contributor" val userEmail = "test@sourcerer.com" - val git = Git.init().setDirectory(File(repoPath)).call() + val git = initGit() init { val config = git.repository.config @@ -33,6 +33,11 @@ class TestRepo(val repoPath: String) { config.save() } + private fun initGit(): Git { + destroy() // Remove repo directory if exists. + return Git.init().setDirectory(File(repoPath)).call() + } + fun createFile(fileName: String, content: List) { val file = File("$repoPath/$fileName") val writer = BufferedWriter(FileWriter(file))