diff --git a/.gitignore b/.gitignore index 878c2cd5b..af6947cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .externalNativeBuild .cxx local.properties +musikus.properties \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b08db0238..f319d2cd9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,12 +1,14 @@ @file:Suppress("UnstableApiUsage") +import io.gitlab.arturbosch.detekt.Detekt import java.util.Properties +import java.util.Scanner -val properties = Properties() -file("$rootDir/build.properties").inputStream().use { properties.load(it) } -val importedVersionCode = properties["versionCode"] as String -val importedVersionName = properties["versionName"] as String -val commitHash = properties["commitHash"] as String +val buildProperties = Properties() +file("$rootDir/build.properties").inputStream().use { buildProperties.load(it) } +val importedVersionCode = buildProperties["versionCode"] as String +val importedVersionName = buildProperties["versionName"] as String +val commitHash = buildProperties["commitHash"] as String val reportsPath = "$projectDir/build/reports" @@ -43,6 +45,7 @@ android { } signingConfigs { + @Suppress("TooGenericExceptionCaught") try { create("release") { storeFile = file(System.getenv("SIGNING_KEY_STORE_PATH")) @@ -112,20 +115,55 @@ room { schemaDirectory("$projectDir/schemas") } +object DetektSettings { + const val VERSION = "1.23.6" + const val CONFIG_FILE = "config/detekt.yml" // Relative to the project root + const val BUILD_UPON_DEFAULT_CONFIG = true + const val REPORT_PATH = "lint" // Relative to the reports path +} + detekt { // Version of detekt that will be used. When unspecified the latest detekt // version found will be used. Override to stay on the same version. - toolVersion = "1.23.6" + toolVersion = DetektSettings.VERSION // Point to your custom config defining rules to run, overwriting default behavior - config.setFrom("$projectDir/config/detekt.yml") + config.setFrom("$projectDir/${DetektSettings.CONFIG_FILE}") // Applies the config files on top of detekt's default config file. `false` by default. - buildUponDefaultConfig = true + buildUponDefaultConfig = DetektSettings.BUILD_UPON_DEFAULT_CONFIG // Specify the base path for file paths in the formatted reports. // If not set, all file paths reported will be absolute file path. - basePath = "$reportsPath/lint" + basePath = "$reportsPath/${DetektSettings.REPORT_PATH}" +} + +tasks.register("detektOnFiles") { + description = "Runs detekt on the changed Kotlin files." + version = DetektSettings.VERSION + setSource(files("/")) + config.setFrom("$projectDir/${DetektSettings.CONFIG_FILE}") + buildUponDefaultConfig = true + basePath = "$reportsPath/${DetektSettings.REPORT_PATH}" + + doFirst { + // Step 1: Get the list of changed Kotlin files + val changedKotlinFiles = + System.getenv("CHANGED_FILES")?.split(":")?.filter { + it.endsWith(".kt") || it.endsWith(".kts") + } ?: emptyList() + + // Step 2: Check if there are any Kotlin files changed + if (changedKotlinFiles.isEmpty()) { + println("No Kotlin files changed in the last commit, skipping Detekt") + include("build.gradle.kts") // Include the build file to avoid a no-source error + } + + // Step 3: Include the changed Kotlin files in the detekt task + changedKotlinFiles.forEach { + include(it.removePrefix("app/")) + } + } } tasks.withType { @@ -148,6 +186,68 @@ tasks.withType().configureEach } } +tasks.register("setupMusikus") { + group = "setup" + description = "Sets up the Musikus project with pre-commit hooks, copyright header, and IDE settings." + + doFirst { + gradle.startParameter.consoleOutput = ConsoleOutput.Plain + } + + doLast { + println("Setting up Musikus project...") + + // Step 1: Execute the existing bash script to install the pre-commit hook + if (System.getProperty("os.name").lowercase().contains("win")) { + exec { + workingDir = file("$rootDir/tools/hooks") + commandLine("cmd", "/c", "setup_hooks.bat") + } + } else { + exec { + commandLine("bash", "$rootDir/tools/hooks/setup_hooks.sh") + } + } + println("Pre-commit hook installed.\n") + + // Step 2: Query and store a name for the copyright header + val scanner = Scanner(System.`in`) + print("Enter your name for the copyright header: ") + System.out.flush() // Needed to ensure the prompt is displayed + val name = scanner.nextLine() + require(!name.isNullOrBlank()) { "Name must not be empty." } + val propertiesFile = file("$rootDir/musikus.properties") + propertiesFile.writeText("copyrightName=$name") + println("Name stored for copyright header: $name\n") + } +} + +tasks.register("checkLicense") { + group = "verification" + description = "Checks if all files most in the HEAD commit have the correct license header." + + doLast { + // Execute python script to check license headers + exec { + workingDir = file("$rootDir/tools") + commandLine("python", "check_license_headers.py") + } + } +} + +tasks.register("fixLicense") { + group = "verification" + description = "Fixes the license header in all staged files." + + doLast { + // Execute python script to update license headers + exec { + workingDir = file("$rootDir/tools") + commandLine("python", "fix_license_headers.py") + } + } +} + dependencies { detektPlugins(libs.detekt.formatting) @@ -170,7 +270,7 @@ dependencies { implementation(libs.androidx.compose.animation.core) implementation(libs.androidx.compose.animation.graphics) - //Foundation + // Foundation implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation.layout) @@ -206,7 +306,7 @@ dependencies { // Immutable Lists implementation(libs.kotlinx.collections.immutable) - //Dagger - Hilt + // Dagger - Hilt implementation(libs.androidx.hilt.navigation.compose) implementation(libs.hilt.android) ksp(libs.hilt.compiler) diff --git a/gradle.properties b/gradle.properties index 5436bb111..323d6f132 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +# Necessary for input prompts to be properly displayed in the console +org.gradle.console=plain \ No newline at end of file diff --git a/tools/check_license_headers.py b/tools/check_license_headers.py new file mode 100644 index 000000000..31720ec6a --- /dev/null +++ b/tools/check_license_headers.py @@ -0,0 +1,101 @@ +""" +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. + +Copyright (c) 2024 Matthias Emde +""" + +import os +import re +import subprocess +import sys +from pathlib import Path +import datetime + +# ANSI escape codes for colors +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +RESET = '\033[0m' + +# Get the project path +file_path = os.path.abspath(__file__) +project_path = os.path.dirname(file_path) +while '.git' not in os.listdir(project_path): + project_path = os.path.dirname(project_path) + +# Get the current year +current_year = str(datetime.datetime.now().year) + +# Define the license header patterns +xml_license_pattern = re.compile(r'\n') + +kt_license_pattern = re.compile(r'/\*\n' + r' \* This Source Code Form is subject to the terms of the Mozilla Public\n' + r' \* License, v. 2.0. If a copy of the MPL was not distributed with this\n' + r' \* file, You can obtain one at https://mozilla.org/MPL/2.0/.\n' + r' \*\n' + r' \* Copyright \(c\) (' + current_year + r'|\d{4}-' + current_year + r') ([A-Za-z]+ [A-Za-z]+)(, [A-Za-z]+ [A-Za-z]+)*\n' + r'(' + r' \*\n' + r' \* Parts of this software are licensed under the MIT license\n' + r' \*\n' + r' \* Copyright \(c\) \d{4}, Javier Carbone, author ([A-Za-z]+ [A-Za-z]+)\n' + r'( \* Additions and modifications, author ([A-Za-z]+ [A-Za-z]+)\n)?' + r')?' + r' \*/\n' + r'\n') + + +# Function to fetch the files that were just commited to git +def get_committed_files(): + result = subprocess.run(['git', 'diff', '--name-only', 'origin/main', 'HEAD'], capture_output=True, text=True) + files = result.stdout.splitlines() + + return [f'{project_path}/{f}' for f in files] + + +# Function to check the license header in a file +def check_license_header(file_path): + try: + with open(file_path, 'r', encoding='utf-8') as file: + content = file.read(500) # Read the first 500 characters + if file_path.suffix == '.xml': + if not xml_license_pattern.search(content): + return False + elif file_path.suffix == '.kt': + if not kt_license_pattern.search(content): + return False + return True + except Exception as e: + print(f"{RED}Error reading {file_path}: {e}{RESET}") + return False + + +def main(): + all_files_have_correct_license_headers = True + + # Get the staged files to check for license headers + files_to_check = get_committed_files() + + # Check each file + for file_path in files_to_check: + path = Path(file_path) + if path.suffix in {'.xml', '.kt'} and not check_license_header(path): + print(f"{RED}License header missing or incorrect in {file_path}{RESET}") + all_files_have_correct_license_headers = False + + if not all_files_have_correct_license_headers: + print(f"\n{YELLOW}Some files have missing or incorrect license headers. :(\nFix them manually or using the fixLicense gradle task and amend your commit.{RESET}") + else: + print(f"\n{GREEN}All files have correct license headers. :){RESET}") + +if __name__ == "__main__": + main() diff --git a/tools/fix_license_headers.py b/tools/fix_license_headers.py new file mode 100644 index 000000000..b46c9892e --- /dev/null +++ b/tools/fix_license_headers.py @@ -0,0 +1,117 @@ +""" +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. + +Copyright (c) 2024 Matthias Emde +""" + +import os +import subprocess +import re +from datetime import datetime +import textwrap + +file_path = os.path.abspath(__file__) +project_path = os.path.dirname(file_path) +while '.git' not in os.listdir(project_path): + project_path = os.path.dirname(project_path) + +# Function to get the current year +def get_current_year(): + return datetime.now().year + + +# Function to fetch staged files in git +def get_staged_files(): + result = subprocess.run(['git', 'diff', '--name-only', '--cached'], capture_output=True, text=True) + files = result.stdout.splitlines() + + return [f'{project_path}/{f}' for f in files] + + +# Function to read the copyrightName from musikus.properties +def get_copyright_name(): + props_file = f'{project_path}/musikus.properties' + if not os.path.exists(props_file): + raise FileNotFoundError(f"{props_file} not found") + + with open(props_file, 'r') as f: + for line in f: + if line.startswith('copyrightName='): + return line.split('=')[1].strip() + raise ValueError("copyrightName not found in musikus.properties") + + +# Function to get the new header from the template +def get_kotlin_copyright_header(year, names): + return textwrap.dedent(f""" + /* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) {year} {names} + """).strip() + + +# Function to update the copyright header in a file +def update_copyright_in_kotlin_file(file_path, copyright_name, current_year): + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Define the regex to find the copyright block + header_regex = r"/\*.*?Copyright \(c\) (\d{4})(?:-(\d{4}))?,?\s+([A-Za-z0-9 ,.]*)([\s\*]*(?=\n \*\/))?" + + match = re.search(header_regex, content, re.DOTALL) + + if match: + start_year = int(match.group(1)) + names = match.group(3).strip() + + # Update the year if needed + new_year = str(current_year) if start_year == current_year else f"{start_year}-{current_year}" + + # Ensure the copyright_name is included in the list of names + if copyright_name not in names: + names += f", {copyright_name}" + + # Replace the header in the content + new_header = get_kotlin_copyright_header(new_year, names) + updated_content = re.sub(header_regex, new_header, content, flags=re.DOTALL) + + else: + # No existing header found + new_header = get_kotlin_copyright_header(current_year, copyright_name) + updated_content = new_header + "\n */\n\n" + content + + # Write the updated content back to the file + with open(file_path, 'w', encoding='utf-8', newline='\n') as f: + f.write(updated_content) + + +def main(): + # Get the current year + current_year = get_current_year() + + # Get the list of staged files + staged_files = get_staged_files() + + # Read the copyright name from musikus.properties + try: + copyright_name = get_copyright_name() + except (FileNotFoundError, ValueError) as e: + print(e) + return + + # Process each staged file + for file in staged_files: + if file.endswith('.kt'): # Adjust extensions based on the files you're targeting + print(f"Processing kotlin file: {file}") + update_copyright_in_kotlin_file(file, copyright_name, current_year) + else: + print(f"Skipping file: {file}") + + +if __name__ == '__main__': + main() diff --git a/tools/hooks/post-commit.hook b/tools/hooks/post-commit.hook new file mode 100644 index 000000000..d7d2543a1 --- /dev/null +++ b/tools/hooks/post-commit.hook @@ -0,0 +1,21 @@ +#!/bin/bash + +# Run the gradle task to check license headers +echo "Checking license headers..." +if ! ./gradlew checkLicense; then + echo "Errors found in license headers. Please fix them" + exit 1 +fi + +# Get the list of files changed in the most recent commit +CHANGED_FILES=$(git diff --name-only origin/main HEAD | tr '\n' ':') +export CHANGED_FILES + +# Run detekt on changed files +echo "Running code analysis..." +if ! ./gradlew detektOnFiles; then + echo "Errors found during code analysis. Please review the reports or try running ./gradlew detekt --auto-correct" + exit 1 +fi + +echo "Post-commit checks passed successfully!" \ No newline at end of file diff --git a/tools/hooks/setup_hooks.bat b/tools/hooks/setup_hooks.bat new file mode 100644 index 000000000..9317b8bf6 --- /dev/null +++ b/tools/hooks/setup_hooks.bat @@ -0,0 +1,10 @@ +@echo off +setlocal enabledelayedexpansion +set SCRIPT_DIR=%~dp0 + +REM Iterate over all .hook files in the SCRIPT_DIR +for %%f in ("%SCRIPT_DIR%*.hook") do ( + set "filename=%%~nf" + copy "%%f" "%SCRIPT_DIR%..\..\.git\hooks\!filename!" + attrib +x "%SCRIPT_DIR%..\..\.git\hooks\!filename!" +) \ No newline at end of file diff --git a/tools/hooks/setup_hooks.sh b/tools/hooks/setup_hooks.sh new file mode 100644 index 000000000..529ab8c33 --- /dev/null +++ b/tools/hooks/setup_hooks.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Get the directory of the script +SCRIPT_DIR=$(dirname "$0") + +# Iterate over all .hook files in the SCRIPT_DIR +for file in "$SCRIPT_DIR"/*.hook; do + filename=$(basename "$file" .hook) + cp "$file" "$SCRIPT_DIR/../../.git/hooks/$filename" + chmod +x "$SCRIPT_DIR/../../.git/hooks/$filename" +done \ No newline at end of file