diff --git a/intellij-plugin-structure/README.md b/intellij-plugin-structure/README.md index 1c9c6c3169..c9d069b52f 100644 --- a/intellij-plugin-structure/README.md +++ b/intellij-plugin-structure/README.md @@ -8,16 +8,16 @@ even errors like `Directory 'lib' must not be empty` according to verification [ There are different types of plugins: -| Plugin Type | Module | Manager -> .createPlugin(Path pluginFile) | API | -|------------------|------------------------------|-----------------------------------------------|-------------------------| -| IntelliJ | `structure-intellij` | `IdePluginManager.createManager()` | `IdePlugin` | -| ReSharper | `structure-dotnet` | `ReSharperPluginManager.createManager()` | `ReSharperPlugin` | -| EDU | `structure-edu` | `EduPluginManager.createManager()` | `EduPlugin` | -| Fleet | `structure-fleet` | `FleetPluginManager.createManager()` | `FleetPlugin` | -| Hub | `structure-hub` | `HubPluginManager.createManager()` | `HubPlugin` | -| TeamCity | `structure-teamcity` | `TeamcityPluginManager.createManager()` | `TeamcityPlugin` | -| TeamCity Actions | `structure-teamcity-actions` | `TeamCityActionPluginManager.createManager()` | `TeamCityActionPlugin` | -| YouTrack | `structure-youtrack` | `YouTrackPluginManager.createManager()` | `YouTrackPlugin` | +| Plugin Type | Module | Manager -> .createPlugin(Path pluginFile) | API | +|------------------|------------------------------|-----------------------------------------------|------------------------| +| IntelliJ | `structure-intellij` | `IdePluginManager.createManager()` | `IdePlugin` | +| ReSharper | `structure-dotnet` | `ReSharperPluginManager.createManager()` | `ReSharperPlugin` | +| EDU | `structure-edu` | `EduPluginManager.createManager()` | `EduPlugin` | +| Fleet | `structure-fleet` | `FleetPluginManager.createManager()` | `FleetPlugin` | +| Hub | `structure-hub` | `HubPluginManager.createManager()` | `HubPlugin` | +| TeamCity | `structure-teamcity` | `TeamcityPluginManager.createManager()` | `TeamcityPlugin` | +| TeamCity Recipes | `structure-teamcity-recipes` | `TeamCityRecipePluginManager.createManager()` | `TeamCityRecipePlugin` | +| YouTrack | `structure-youtrack` | `YouTrackPluginManager.createManager()` | `YouTrackPlugin` | *IntelliJ Plugins* may be in several forms: - single `.jar` file containing `/META-INF/plugin.xml` diff --git a/intellij-plugin-structure/build.gradle.kts b/intellij-plugin-structure/build.gradle.kts index 8c787ce655..fc5f179109 100644 --- a/intellij-plugin-structure/build.gradle.kts +++ b/intellij-plugin-structure/build.gradle.kts @@ -181,7 +181,7 @@ publishing { configurePublication("EduPublication", "structure-edu", "JetBrains Plugins Structure Edu", "Library for parsing JetBrains Edu plugins. Can be used to verify that plugin complies with JetBrains Marketplace requirements.") configurePublication("FleetPublication", "structure-fleet", "JetBrains Plugins Structure Fleet", "Library for parsing JetBrains Fleet plugins. Can be used to verify that plugin complies with JetBrains Marketplace requirements.") configurePublication("ToolboxPublication", "structure-toolbox", "JetBrains Plugins Structure Toolbox", "Library for parsing JetBrains Toolbox plugins. Can be used to verify that plugin complies with JetBrains Marketplace requirements.") - configurePublication("TeamCityActionsPublications", "structure-teamcity-actions", "JetBrains Plugins Structure TeamCity Actions", "Library for parsing JetBrains TeamCity actions. Can be used to verify that plugin complies with JetBrains Marketplace requirements.") + configurePublication("TeamCityRecipesPublications", "structure-teamcity-recipes", "JetBrains Plugins Structure TeamCity Recipes", "Library for parsing JetBrains TeamCity recipes. Can be used to verify that plugin complies with JetBrains Marketplace requirements.") configurePublication("YoutrackPublication", "structure-youtrack", "JetBrains Plugins Structure YouTrack Apps", "Library for parsing JetBrains YouTrack Apps. Can be used to verify that plugin complies with JetBrains Marketplace requirements.") } } @@ -206,7 +206,7 @@ signing { sign(publishing.publications["EduPublication"]) sign(publishing.publications["FleetPublication"]) sign(publishing.publications["ToolboxPublication"]) - sign(publishing.publications["TeamCityActionsPublications"]) + sign(publishing.publications["TeamCityRecipesPublications"]) sign(publishing.publications["YoutrackPublication"]) } } diff --git a/intellij-plugin-structure/settings.gradle.kts b/intellij-plugin-structure/settings.gradle.kts index fd520d5d90..f3623425eb 100644 --- a/intellij-plugin-structure/settings.gradle.kts +++ b/intellij-plugin-structure/settings.gradle.kts @@ -12,7 +12,7 @@ include("structure-toolbox") include("structure-ide") include("structure-ide-classes") include("structure-youtrack") -include("structure-teamcity-actions") +include("structure-teamcity-recipes") include("tests") dependencyResolutionManagement { diff --git a/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/plugin/Settings.kt b/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/plugin/Settings.kt index df353868cd..01ca020f4b 100644 --- a/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/plugin/Settings.kt +++ b/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/plugin/Settings.kt @@ -5,7 +5,6 @@ package com.jetbrains.plugin.structure.base.plugin import org.apache.commons.io.FileUtils -import java.io.File import java.nio.file.Path import java.nio.file.Paths @@ -15,7 +14,7 @@ enum class Settings(private val key: String, private val defaultValue: () -> Str FLEET_PLUGIN_SIZE_LIMIT("intellij.structure.fleet.plugin.size.limit", { FileUtils.ONE_GB.toString() }), TOOLBOX_PLUGIN_SIZE_LIMIT("intellij.structure.toolbox.plugin.size.limit", { FileUtils.ONE_GB.toString() }), TEAM_CITY_PLUGIN_SIZE_LIMIT("intellij.structure.team.city.plugin.size.limit", { FileUtils.ONE_GB.toString() }), - TEAM_CITY_ACTION_SIZE_LIMIT("intellij.structure.team.city.action.size.limit", { FileUtils.ONE_MB.toString() }), + TEAM_CITY_RECIPE_SIZE_LIMIT("intellij.structure.teamcity.recipe.size.limit", { FileUtils.ONE_MB.toString() }), RE_SHARPER_PLUGIN_SIZE_LIMIT("intellij.structure.re.sharper.plugin.size.limit", { FileUtils.ONE_GB.toString() }), HUB_PLUGIN_SIZE_LIMIT("intellij.structure.hub.plugin.size.limit", { (FileUtils.ONE_MB * 30).toString() }), HUB_PLUGIN_MAX_FILES_NUMBER("intellij.structure.hub.plugin.max.files.number", { 1000.toString() }), diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionDescriptor.kt b/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionDescriptor.kt deleted file mode 100644 index 637890ca4a..0000000000 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionDescriptor.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.jetbrains.plugin.structure.teamcity.action - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonProperty -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionDescription -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputDefault -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputDescription -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputLabel -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputOptions -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputRequired -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputType -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputs -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionCompositeName -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionRequirementType -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionRequirementValue -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionRequirements -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepName -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepParams -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepScript -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepWith -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionSteps -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionVersion - -data class TeamCityActionDescriptor( - @JsonProperty(ActionCompositeName.NAME) - val name: String? = null, - @JsonProperty(ActionVersion.NAME) - val version: String? = null, - @JsonProperty(ActionDescription.NAME) - val description: String? = null, - @JsonProperty(ActionInputs.NAME) - val inputs: List> = emptyList(), - @JsonProperty(ActionRequirements.NAME) - val requirements: List> = emptyList(), - @JsonProperty(ActionSteps.NAME) - val steps: List = emptyList(), -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class ActionInputDescriptor( - @JsonProperty(ActionInputType.NAME) - val type: String? = null, - @JsonProperty(ActionInputRequired.NAME) - val isRequired: String? = null, - @JsonProperty(ActionInputLabel.NAME) - val label: String? = null, - @JsonProperty(ActionInputDescription.NAME) - val description: String? = null, - @JsonProperty(ActionInputDefault.NAME) - val defaultValue: String? = null, - @JsonProperty(ActionInputOptions.NAME) - val selectOptions: List = emptyList(), -) - -@Suppress("EnumEntryName") -enum class ActionInputTypeDescriptor { - text, - boolean, - select, -} - -@JsonIgnoreProperties(ignoreUnknown = true) -data class ActionRequirementDescriptor( - @JsonProperty(ActionRequirementType.NAME) - val type: String? = null, - @JsonProperty(ActionRequirementValue.NAME) - val value: String? = null, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class ActionStepDescriptor( - @JsonProperty(ActionStepName.NAME) - val name: String? = null, - @JsonProperty(ActionStepWith.NAME) - val with: String? = null, - @JsonProperty(ActionStepScript.NAME) - val script: String? = null, - @JsonProperty(ActionStepParams.NAME) - val parameters: Map = emptyMap(), -) \ No newline at end of file diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionSpec.kt b/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionSpec.kt deleted file mode 100644 index 68477cb39d..0000000000 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionSpec.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.jetbrains.plugin.structure.teamcity.action - -object TeamCityActionSpec { - object ActionCompositeName { - const val NAME = "name" - const val DESCRIPTION = "the composite action name in the 'namespace/name' format" - - // Regular expression pattern for the action's composite name – the namespace and the name separated by '/' - private const val COMPOSITE_NAME_PATTERN = "^([^/]+)/([^/]+)$" - - /** - * Regular expression pattern for both the action's namespace and the action's name. - * - * The pattern enforces the following rules for both namespace and id: - * - cannot be empty. - * – can only contain latin letters, dashes and underscores. - * - cannot start or end with a dash or underscore. - * - cannot contain several consecutive dashes or underscores. - */ - private const val ID_AND_NAMESPACE_PATTERN = "^[a-zA-Z0-9]+([_-][a-zA-Z0-9]+)*\$" - val compositeNameRegex: Regex = Regex(COMPOSITE_NAME_PATTERN) - val idAndNamespaceRegex: Regex = Regex(ID_AND_NAMESPACE_PATTERN) - - object Namespace { - const val NAME = "namespace" - const val DESCRIPTION = "the first part of the composite `${ActionCompositeName.NAME}` field" - const val MIN_LENGTH = 5 - const val MAX_LENGTH = 30 - } - - object Name { - const val NAME = "name" - const val DESCRIPTION = "the second part of the composite `${ActionCompositeName.NAME}` field" - const val MIN_LENGTH = 5 - const val MAX_LENGTH = 30 - } - - fun getNamespace(actionName: String?): String? { - if (actionName == null) return null - val matchResult = compositeNameRegex.matchEntire(actionName) - return matchResult?.groupValues?.get(1) - } - - fun getNameInNamespace(actionName: String?): String? { - if (actionName == null) return null - val matchResult = compositeNameRegex.matchEntire(actionName) - return matchResult?.groupValues?.get(2) - } - } - - object ActionVersion { - const val NAME = "version" - const val DESCRIPTION = "action version" - } - - object ActionDescription { - const val NAME = "description" - const val DESCRIPTION = "action description" - const val MAX_LENGTH = 250 - } - - object ActionInputs { - const val NAME = "inputs" - } - - object ActionInputName { - const val NAME = "name" - const val DESCRIPTION = "action input name" - const val MAX_LENGTH = 50 - } - - object ActionInputType { - const val NAME = "type" - const val DESCRIPTION = "action input type" - } - - object ActionInputRequired { - const val NAME = "required" - const val DESCRIPTION = "indicates whether the input is required" - } - - object ActionInputLabel { - const val NAME = "label" - const val DESCRIPTION = "action input label" - const val MAX_LENGTH = 100 - } - - object ActionInputDescription { - const val NAME = "description" - const val DESCRIPTION = "action input description" - const val MAX_LENGTH = 250 - } - - object ActionInputDefault { - const val NAME = "default" - const val DESCRIPTION = "action input default value" - } - - object ActionInputOptions { - const val NAME = "options" - const val DESCRIPTION = "action input options" - } - - object ActionRequirements { - const val NAME = "requirements" - } - - object ActionRequirementName { - const val NAME = "name" - const val DESCRIPTION = "action requirement name" - const val MAX_LENGTH = 50 - } - - object ActionRequirementType { - const val NAME = "type" - const val DESCRIPTION = "action requirement type" - } - - object ActionRequirementValue { - const val NAME = "value" - } - - object ActionSteps { - const val NAME = "steps" - const val DESCRIPTION = "action steps" - } - - object ActionStepName { - const val NAME = "name" - const val DESCRIPTION = "action step name" - const val MAX_LENGTH = 50 - } - - object ActionStepWith { - const val NAME = "with" - const val DESCRIPTION = "runner or action reference" - const val RUNNER_PREFIX = "runner/" - private const val ACTION_PREFIX = "action/" - val allowedPrefixes = listOf(RUNNER_PREFIX, ACTION_PREFIX) - } - - object ActionStepScript { - const val NAME = "script" - const val DESCRIPTION = "executable script content" - const val MAX_LENGTH = 50_000 - } - - object ActionStepParams { - const val NAME = "params" - } -} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/Validator.kt b/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/Validator.kt deleted file mode 100644 index 92aa537055..0000000000 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/Validator.kt +++ /dev/null @@ -1,328 +0,0 @@ -package com.jetbrains.plugin.structure.teamcity.action - -import com.jetbrains.plugin.structure.base.problems.InvalidSemverFormat -import com.jetbrains.plugin.structure.base.problems.PluginProblem -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionDescription -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputDefault -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputDescription -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputLabel -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputName -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputOptions -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputRequired -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionInputType -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionCompositeName -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionRequirementName -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionRequirementValue -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepName -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepScript -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepWith -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionStepWith.RUNNER_PREFIX -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionSteps -import com.jetbrains.plugin.structure.teamcity.action.TeamCityActionSpec.ActionVersion -import com.vdurmont.semver4j.Semver -import com.vdurmont.semver4j.SemverException - -internal fun validateTeamCityAction(descriptor: TeamCityActionDescriptor) = sequence { - validateName(descriptor.name) - - validateExistsAndNotEmpty(descriptor.version, ActionVersion.NAME, ActionVersion.DESCRIPTION) - validateSemver(descriptor.version, ActionVersion.NAME) - - validateExistsAndNotEmpty(descriptor.description, ActionDescription.NAME, ActionDescription.DESCRIPTION) - validateMaxLength( - descriptor.description, - ActionDescription.NAME, - ActionDescription.DESCRIPTION, - ActionDescription.MAX_LENGTH, - ) - - validateNotEmptyIfExists(descriptor.steps, ActionSteps.NAME, ActionSteps.DESCRIPTION) - for (input in descriptor.inputs) validateActionInput(input) - for (requirement in descriptor.requirements) validateActionRequirement(requirement) - for (step in descriptor.steps) validateActionStep(step) -}.toList() - -private suspend fun SequenceScope.validateName(name: String?) { - validateExists(name, ActionCompositeName.NAME, ActionCompositeName.DESCRIPTION) - validateNotEmptyIfExists(name, ActionCompositeName.NAME, ActionCompositeName.DESCRIPTION) - validateMatchesRegexIfExistsAndNotEmpty( - name, ActionCompositeName.compositeNameRegex, ActionCompositeName.NAME, ActionCompositeName.DESCRIPTION, - "should consist of namespace and name parts. Both parts should only contain latin letters, numbers, dashes and underscores." - ) - - val namespace = ActionCompositeName.getNamespace(name) - if (namespace != null) { - validateMinLength(namespace, ActionCompositeName.Namespace.NAME, ActionCompositeName.Namespace.DESCRIPTION, ActionCompositeName.Namespace.MIN_LENGTH) - validateMaxLength(namespace, ActionCompositeName.Namespace.NAME, ActionCompositeName.Namespace.DESCRIPTION, ActionCompositeName.Namespace.MAX_LENGTH) - validateMatchesRegexIfExistsAndNotEmpty( - namespace, ActionCompositeName.idAndNamespaceRegex, ActionCompositeName.Namespace.NAME, ActionCompositeName.Namespace.DESCRIPTION, - "should only contain latin letters, numbers, dashes and underscores. " + - "The property cannot start or end with a dash or underscore, and cannot contain several consecutive dashes and underscores." - ) - } - - val nameInNamespace = ActionCompositeName.getNameInNamespace(name) - if (nameInNamespace != null) { - validateMinLength(nameInNamespace, ActionCompositeName.Name.NAME, ActionCompositeName.Name.DESCRIPTION, ActionCompositeName.Name.MIN_LENGTH) - validateMaxLength(nameInNamespace, ActionCompositeName.Name.NAME, ActionCompositeName.Name.DESCRIPTION, ActionCompositeName.Name.MAX_LENGTH) - validateMatchesRegexIfExistsAndNotEmpty( - nameInNamespace, ActionCompositeName.idAndNamespaceRegex, ActionCompositeName.Name.NAME, ActionCompositeName.Name.DESCRIPTION, - "should only contain latin letters, numbers, dashes and underscores. " + - "The property cannot start or end with a dash or underscore, and cannot contain several consecutive dashes and underscores." - ) - } -} - -private suspend fun SequenceScope.validateActionInput(input: Map) { - if (input.size != 1) { - yield(InvalidPropertyValueProblem("Wrong action input format. The input should consist of a name and body.")) - return - } - - val inputName = input.keys.first() - validateMaxLength(inputName, ActionInputName.NAME, ActionInputName.DESCRIPTION, ActionInputName.MAX_LENGTH) - - val value = input.values.first() - - validateExistsAndNotEmpty(value.type, ActionInputType.NAME, ActionInputType.DESCRIPTION) - if (value.type != null && !enumContains(value.type)) { - yield( - InvalidPropertyValueProblem( - "Wrong action input type: ${value.type}. Supported values are: ${ - ActionInputTypeDescriptor.values().joinToString() - }" - ) - ) - } - - validateBooleanIfExists(value.isRequired, ActionInputRequired.NAME, ActionInputRequired.DESCRIPTION) - - validateNotEmptyIfExists(value.label, ActionInputLabel.NAME, ActionInputLabel.DESCRIPTION) - validateMaxLength(value.label, ActionInputLabel.NAME, ActionInputLabel.DESCRIPTION, ActionInputLabel.MAX_LENGTH) - - validateNotEmptyIfExists(value.description, ActionInputDescription.NAME, ActionInputDescription.DESCRIPTION) - validateMaxLength( - value.description, - ActionInputDescription.NAME, - ActionInputDescription.DESCRIPTION, - ActionInputDescription.MAX_LENGTH, - ) - - validateNotEmptyIfExists(value.defaultValue, ActionInputDefault.NAME, ActionInputDefault.DESCRIPTION) - when (value.type) { - ActionInputTypeDescriptor.boolean.name -> validateBooleanIfExists( - value.defaultValue, - ActionInputDefault.NAME, - ActionInputDefault.DESCRIPTION, - ) - - ActionInputTypeDescriptor.select.name -> validateNotEmptyIfExists( - value.selectOptions, - ActionInputOptions.NAME, - ActionInputOptions.DESCRIPTION, - ) - } -} - -private suspend fun SequenceScope.validateActionRequirement(requirement: Map) { - if (requirement.size != 1) { - yield(InvalidPropertyValueProblem("Wrong action requirement format. The requirement should consist of a name and body.")) - return - } - - val requirementName = requirement.keys.first() - validateMaxLength( - requirementName, - ActionRequirementName.NAME, - ActionRequirementName.DESCRIPTION, - ActionRequirementName.MAX_LENGTH, - ) - - val value = requirement.values.first() - - validateExistsAndNotEmpty( - value.type, - TeamCityActionSpec.ActionRequirementType.NAME, - TeamCityActionSpec.ActionRequirementType.DESCRIPTION, - ) - if (value.type == null) { - return - } - val type: ActionRequirementType - try { - type = ActionRequirementType.from(value.type) - } catch (e: IllegalArgumentException) { - yield( - InvalidPropertyValueProblem( - "Wrong action requirement type '${value.type}'. " + - "Supported values are: ${ActionRequirementType.values().joinToString { it.type }}" - ) - ) - return - } - val description = "the value for ${value.type} requirement" - if (type.isValueRequired && value.value == null) { - yield(MissingValueProblem(ActionRequirementValue.NAME, description)) - } else if (!type.valueCanBeEmpty && value.value.isNullOrEmpty()) { - yield(EmptyValueProblem(ActionRequirementValue.NAME, description)) - } -} - -private suspend fun SequenceScope.validateActionStep(step: ActionStepDescriptor) { - validateExistsAndNotEmpty(step.name, ActionStepName.NAME, ActionStepName.DESCRIPTION) - validateMaxLength(step.name, ActionStepName.NAME, ActionStepName.DESCRIPTION, ActionStepName.MAX_LENGTH) - - if (step.with != null && step.script != null) { - yield( - PropertiesCombinationProblem( - "The properties " + - "<${ActionStepWith.NAME}> (${ActionStepWith.DESCRIPTION}) and " + - "<${ActionStepScript.NAME}> (${ActionStepScript.DESCRIPTION}) " + - "cannot be specified together for action step." - ) - ) - } else if (step.with == null && step.script == null) { - yield( - PropertiesCombinationProblem( - "One of the properties " + - "<${ActionStepWith.NAME}> (${ActionStepWith.DESCRIPTION}) or " + - "<${ActionStepScript.NAME}> (${ActionStepScript.DESCRIPTION}) should be specified for action step." - ) - ) - } else if (step.with != null) { - if (ActionStepWith.allowedPrefixes.none { step.with.startsWith(it) }) { - yield( - InvalidPropertyValueProblem( - "The property <${ActionStepWith.NAME}> (${ActionStepWith.DESCRIPTION}) should have " + - "a value starting with one of the following prefixes: ${ActionStepWith.allowedPrefixes.joinToString()}" - ) - ) - } - if (step.with.startsWith(RUNNER_PREFIX)) { - val runnerName = step.with.substringAfter(RUNNER_PREFIX) - val allowedParams = allowedRunnerToAllowedParams[runnerName] - if (allowedParams == null) { - yield(UnsupportedRunnerProblem(runnerName, allowedRunnerToAllowedParams.keys)) - } else { - val unsupportedParams = step.parameters.filter { !allowedParams.contains(it.key) }.keys - if (unsupportedParams.isNotEmpty()) { - yield(UnsupportedRunnerParamsProblem(runnerName, unsupportedParams, allowedParams)) - } - } - } - } else { - validateNotEmptyIfExists(step.script, ActionStepScript.NAME, ActionStepScript.DESCRIPTION) - validateMaxLength( - step.script, - ActionStepScript.NAME, - ActionStepScript.DESCRIPTION, - ActionStepScript.MAX_LENGTH, - ) - } -} - -private suspend fun SequenceScope.validateExists( - propertyValue: String?, - propertyName: String, - propertyDescription: String, -) { - if (propertyValue == null) { - yield(MissingValueProblem(propertyName, propertyDescription)) - } -} - -private suspend fun SequenceScope.validateNotEmptyIfExists( - propertyValue: String?, - propertyName: String, - propertyDescription: String, -) { - if (propertyValue != null && propertyValue.isEmpty()) { - yield(EmptyValueProblem(propertyName, propertyDescription)) - } -} - -private suspend fun SequenceScope.validateNotEmptyIfExists( - propertyValue: Iterable, - propertyName: String, - propertyDescription: String, -) { - if (!propertyValue.iterator().hasNext()) { - yield(EmptyCollectionProblem(propertyName, propertyDescription)) - } -} - -private suspend fun SequenceScope.validateExistsAndNotEmpty( - propertyValue: String?, - propertyName: String, - propertyDescription: String, -) { - validateExists(propertyValue, propertyName, propertyDescription) - if (propertyValue != null) { - validateNotEmptyIfExists(propertyValue, propertyName, propertyDescription) - } -} - -private suspend fun SequenceScope.validateMinLength( - propertyValue: String?, - propertyName: String, - propertyDescription: String, - minAllowedLength: Int, -) { - if (propertyValue != null && propertyValue.length < minAllowedLength) { - yield(TooShortValueProblem(propertyName, propertyDescription, propertyValue.length, minAllowedLength)) - } -} - -private suspend fun SequenceScope.validateMaxLength( - propertyValue: String?, - propertyName: String, - propertyDescription: String, - maxAllowedLength: Int, -) { - if (propertyValue != null && propertyValue.length > maxAllowedLength) { - yield(TooLongValueProblem(propertyName, propertyDescription, propertyValue.length, maxAllowedLength)) - } -} - -private suspend fun SequenceScope.validateSemver( - version: String?, - propertyName: String -): Semver? { - if (version != null) { - try { - return TeamCityActionSpecVersionUtils.getSemverFromString(version) - } catch (e: SemverException) { - yield(InvalidSemverFormat( - versionName = propertyName, - version = version - )) - } - } - return null -} - -private suspend fun SequenceScope.validateBooleanIfExists( - propertyValue: String?, - propertyName: String, - propertyDescription: String, -) { - if (propertyValue != null && propertyValue != "true" && propertyValue != "false") { - yield(InvalidBooleanProblem(propertyName, propertyDescription)) - } -} - -private suspend fun SequenceScope.validateMatchesRegexIfExistsAndNotEmpty( - propertyValue: String?, - regex: Regex, - propertyName: String, - propertyDescription: String, - validationFailureMessage: String, -) { - if (!propertyValue.isNullOrEmpty() && !regex.matches(propertyValue)) { - yield(InvalidPropertyValueProblem("The property <$propertyName> ($propertyDescription) $validationFailureMessage")) - } -} - -private inline fun > enumContains(name: String): Boolean { - return T::class.java.enumConstants.any { it.name == name } -} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-teamcity-actions/build.gradle.kts b/intellij-plugin-structure/structure-teamcity-recipes/build.gradle.kts similarity index 100% rename from intellij-plugin-structure/structure-teamcity-actions/build.gradle.kts rename to intellij-plugin-structure/structure-teamcity-recipes/build.gradle.kts diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/AllowedRunnerToAllowedParams.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/AllowedRunnerToAllowedParams.kt similarity index 93% rename from intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/AllowedRunnerToAllowedParams.kt rename to intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/AllowedRunnerToAllowedParams.kt index bccfd37410..4c2efda864 100644 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/AllowedRunnerToAllowedParams.kt +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/AllowedRunnerToAllowedParams.kt @@ -1,4 +1,4 @@ -package com.jetbrains.plugin.structure.teamcity.action +package com.jetbrains.plugin.structure.teamcity.recipe val allowedRunnerToAllowedParams = mapOf( "gradle" to setOf( diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/ActionRequirementType.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/RecipeRequirementType.kt similarity index 83% rename from intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/ActionRequirementType.kt rename to intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/RecipeRequirementType.kt index 7778c4c0c4..5912938678 100644 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/ActionRequirementType.kt +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/RecipeRequirementType.kt @@ -1,6 +1,6 @@ -package com.jetbrains.plugin.structure.teamcity.action +package com.jetbrains.plugin.structure.teamcity.recipe -enum class ActionRequirementType( +enum class RecipeRequirementType( val type: String, val isValueRequired: Boolean, val valueCanBeEmpty: Boolean, @@ -27,12 +27,12 @@ enum class ActionRequirementType( ; companion object { - fun from(type: String): ActionRequirementType { - return ActionRequirementType + fun from(type: String): RecipeRequirementType { + return RecipeRequirementType .values() .find { it.type.equals(type, ignoreCase = true) } ?: throw IllegalArgumentException( - "Unsupported requirement type $type. Supported values are: " + ActionRequirementType.values().joinToString() + "Unsupported requirement type $type. Supported values are: " + RecipeRequirementType.values().joinToString() ) } } diff --git a/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeDescriptor.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeDescriptor.kt new file mode 100644 index 0000000000..fd5c4ec371 --- /dev/null +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeDescriptor.kt @@ -0,0 +1,82 @@ +package com.jetbrains.plugin.structure.teamcity.recipe + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeCompositeName +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeDescription +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputDefault +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputDescription +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputLabel +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputOptions +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputRequired +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputType +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputs +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeRequirementType +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeRequirementValue +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeRequirements +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepName +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepParams +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepScript +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepWith +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeSteps +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeVersion + +data class TeamCityRecipeDescriptor( + @JsonProperty(RecipeCompositeName.NAME) + val name: String? = null, + @JsonProperty(RecipeVersion.NAME) + val version: String? = null, + @JsonProperty(RecipeDescription.NAME) + val description: String? = null, + @JsonProperty(RecipeInputs.NAME) + val inputs: List> = emptyList(), + @JsonProperty(RecipeRequirements.NAME) + val requirements: List> = emptyList(), + @JsonProperty(RecipeSteps.NAME) + val steps: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RecipeInputDescriptor( + @JsonProperty(RecipeInputType.NAME) + val type: String? = null, + @JsonProperty(RecipeInputRequired.NAME) + val required: String? = null, + @JsonProperty(RecipeInputLabel.NAME) + val label: String? = null, + @JsonProperty(RecipeInputDescription.NAME) + val description: String? = null, + @JsonProperty(RecipeInputDefault.NAME) + val defaultValue: String? = null, + @JsonProperty(RecipeInputOptions.NAME) + val selectOptions: List = emptyList(), +) + +@Suppress("EnumEntryName") +enum class RecipeInputTypeDescriptor { + text, + boolean, + number, + select, + password, +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RecipeRequirementDescriptor( + @JsonProperty(RecipeRequirementType.NAME) + val type: String? = null, + @JsonProperty(RecipeRequirementValue.NAME) + val value: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RecipeStepDescriptor( + @JsonProperty(RecipeStepName.NAME) + val name: String? = null, + @JsonProperty(RecipeStepWith.NAME) + val with: String? = null, + @JsonProperty(RecipeStepScript.NAME) + val script: String? = null, + @JsonProperty(RecipeStepParams.NAME) + val parameters: Map = emptyMap(), +) \ No newline at end of file diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionPlugin.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipePlugin.kt similarity index 90% rename from intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionPlugin.kt rename to intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipePlugin.kt index 6f6cfeb3cb..5d21aaf331 100644 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionPlugin.kt +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipePlugin.kt @@ -1,11 +1,11 @@ -package com.jetbrains.plugin.structure.teamcity.action +package com.jetbrains.plugin.structure.teamcity.recipe import com.jetbrains.plugin.structure.base.plugin.Plugin import com.jetbrains.plugin.structure.base.plugin.PluginFile import com.jetbrains.plugin.structure.base.plugin.PluginIcon import com.jetbrains.plugin.structure.base.plugin.ThirdPartyDependency -data class TeamCityActionPlugin( +data class TeamCityRecipePlugin( override val pluginName: String, override val description: String, override var vendor: String? = null, diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionPluginManager.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipePluginManager.kt similarity index 53% rename from intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionPluginManager.kt rename to intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipePluginManager.kt index 2608dbf320..4bdd6c5763 100644 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionPluginManager.kt +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipePluginManager.kt @@ -1,107 +1,88 @@ -package com.jetbrains.plugin.structure.teamcity.action +package com.jetbrains.plugin.structure.teamcity.recipe import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import com.jetbrains.plugin.structure.base.decompress.DecompressorSizeLimitExceededException import com.jetbrains.plugin.structure.base.plugin.* import com.jetbrains.plugin.structure.base.plugin.Settings.EXTRACT_DIRECTORY -import com.jetbrains.plugin.structure.base.problems.* +import com.jetbrains.plugin.structure.base.problems.FileTooBig +import com.jetbrains.plugin.structure.base.problems.UnableToReadDescriptor +import com.jetbrains.plugin.structure.base.problems.isError import com.jetbrains.plugin.structure.base.utils.* import org.slf4j.Logger import org.slf4j.LoggerFactory import java.nio.file.Files -import java.nio.file.Files.createTempDirectory import java.nio.file.Path import java.nio.file.Paths -class TeamCityActionPluginManager -private constructor(private val extractDirectory: Path) : PluginManager { +class TeamCityRecipePluginManager +private constructor(private val extractDirectory: Path) : PluginManager { private val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule().apply { - // We fail on unknown properties to minimize the chance that we improperly calculate the specification version of an uploaded action. - // Since the action's properties are used to calculate its specification version, + // We fail on unknown properties to minimize the chance that we improperly calculate the specification version of an uploaded recipe. + // Since the recipe's properties are used to calculate its specification version, // omitting any of the recipe's properties from calculation can lead to an error in calculation. configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) } companion object { - private val LOG: Logger = LoggerFactory.getLogger(TeamCityActionPluginManager::class.java) + private val LOG: Logger = LoggerFactory.getLogger(TeamCityRecipePluginManager::class.java) fun createManager(extractDirectory: Path = Paths.get(EXTRACT_DIRECTORY.get())) = - TeamCityActionPluginManager(extractDirectory.createDir()) + TeamCityRecipePluginManager(extractDirectory.createDir()) } - override fun createPlugin(pluginFile: Path): PluginCreationResult { - require(pluginFile.exists()) { "TeamCity Action file ${pluginFile.toAbsolutePath()} does not exist" } + override fun createPlugin(pluginFile: Path): PluginCreationResult { + require(pluginFile.exists()) { "TeamCity Recipe file ${pluginFile.toAbsolutePath()} does not exist" } return when { - pluginFile.isZip() || pluginFile.isYaml() -> createPluginFrom(pluginFile) - else -> fileFormatError(pluginFile) + pluginFile.isYaml() -> createPluginFrom(pluginFile) + else -> PluginCreationFail(NotYamlFileProblem) } } - private fun createPluginFrom(actionPath: Path): PluginCreationResult { - val sizeLimit = Settings.TEAM_CITY_ACTION_SIZE_LIMIT.getAsLong() - if (Files.size(actionPath) > sizeLimit) { - return PluginCreationFail(FileTooBig(actionPath.simpleName, sizeLimit)) + private fun createPluginFrom(recipePath: Path): PluginCreationResult { + val sizeLimit = Settings.TEAM_CITY_RECIPE_SIZE_LIMIT.getAsLong() + if (Files.size(recipePath) > sizeLimit) { + return PluginCreationFail(FileTooBig(recipePath.simpleName, sizeLimit)) } return when { - actionPath.isZip() -> extractActionFromZip(actionPath, sizeLimit) - actionPath.isYaml() -> parseYaml(actionPath) - else -> fileFormatError(actionPath) + recipePath.isYaml() -> parseYaml(recipePath) + else -> PluginCreationFail(NotYamlFileProblem) } } - private fun extractActionFromZip(zipPath: Path, sizeLimit: Long): PluginCreationResult { - val tempDirectory = createTempDirectory(extractDirectory, "teamcity_action_extracted_${zipPath.simpleName}_") - return try { - extractZip(zipPath, tempDirectory, sizeLimit) - val yaml = Files.walk(tempDirectory) - .filter { item -> item.isYaml() } - .findFirst() - if (yaml.isEmpty) { - return PluginCreationFail(MissedFile("action.yaml")) - } - parseYaml(yaml.get()) - } catch (e: DecompressorSizeLimitExceededException) { - return PluginCreationFail(PluginFileSizeIsTooLarge(e.sizeLimit)) - } finally { - tempDirectory.deleteLogged() - } - } - - private fun parseYaml(yamlPath: Path): PluginCreationResult { + private fun parseYaml(yamlPath: Path): PluginCreationResult { try { return parse(yamlPath) } catch (e: Exception) { e.rethrowIfInterrupted() - val errorMessage = "An unexpected error occurred while parsing the TeamCity Action descriptor" + val errorMessage = "An unexpected error occurred while parsing the TeamCity Recipe descriptor" LOG.warn(errorMessage, e) return PluginCreationFail(UnableToReadDescriptor(yamlPath.toAbsolutePath().toString(), errorMessage)) } } - private fun parse(yamlPath: Path): PluginCreationResult { + private fun parse(yamlPath: Path): PluginCreationResult { val descriptor = try { val yamlContent = yamlPath.readText() - objectMapper.readValue(yamlContent, TeamCityActionDescriptor::class.java) + objectMapper.readValue(yamlContent, TeamCityRecipeDescriptor::class.java) } catch (e: UnrecognizedPropertyException) { - LOG.warn("Failed to parse TeamCity Action. Encountered unknown property '${e.propertyName}'", e) + LOG.warn("Failed to parse TeamCity Recipe. Encountered unknown property '${e.propertyName}'", e) return PluginCreationFail(UnknownPropertyProblem(e.propertyName)) } catch (e: Exception) { - LOG.warn("Failed to parse TeamCity Action", e) + LOG.warn("Failed to parse TeamCity Recipe", e) return PluginCreationFail(ParseYamlProblem) } - val validationResult = validateTeamCityAction(descriptor) + val validationResult = validateTeamCityRecipe(descriptor) if (validationResult.any { it.isError }) { return PluginCreationFail(validationResult) } val plugin = with(descriptor) { - TeamCityActionPlugin( + TeamCityRecipePlugin( // All the fields are expected to be non-null due to the validations above pluginId = this.name!!, // composite id pluginName = this.name, @@ -109,7 +90,7 @@ private constructor(private val extractDirectory: Path) : PluginManager = - PluginCreationFail(IncorrectPluginFile(pluginFile.simpleName, "ZIP archive or YAML file")) - private fun Path.isYaml(): Boolean = this.hasExtension("yaml") || this.hasExtension("yml") \ No newline at end of file diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionProblems.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeProblems.kt similarity index 84% rename from intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionProblems.kt rename to intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeProblems.kt index 09691d7477..c017d92b76 100644 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionProblems.kt +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeProblems.kt @@ -1,13 +1,18 @@ -package com.jetbrains.plugin.structure.teamcity.action +package com.jetbrains.plugin.structure.teamcity.recipe import com.jetbrains.plugin.structure.base.problems.PluginProblem +object NotYamlFileProblem : PluginProblem() { + override val level: Level = Level.ERROR + override val message = "The file with recipe specification should be in YAML format" +} + abstract class InvalidPropertyProblem : PluginProblem() { override val level: Level = Level.ERROR } object ParseYamlProblem : InvalidPropertyProblem() { - override val message = "The action specification should follow valid YAML syntax." + override val message = "The recipe specification should follow valid YAML syntax." } data class UnknownPropertyProblem(val propertyName: String) : InvalidPropertyProblem() { @@ -32,6 +37,10 @@ class InvalidBooleanProblem(propertyName: String, propertyDescription: String) : override val message = "The property <$propertyName> ($propertyDescription) should be either 'true' or 'false'." } +class InvalidNumberProblem(propertyName: String, propertyDescription: String) : InvalidPropertyProblem() { + override val message = "The property <$propertyName> ($propertyDescription) should be a valid number." +} + class TooLongValueProblem( propertyName: String, propertyDescription: String, @@ -51,7 +60,7 @@ class TooShortValueProblem( ) : InvalidPropertyProblem() { override val message = "The property <$propertyName> ($propertyDescription) should not be shorter than $minAllowedLength characters. " + - "The current number of characters is $currentLength." + "The current number of characters is $currentLength." } data class UnsupportedRunnerProblem( diff --git a/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeSpec.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeSpec.kt new file mode 100644 index 0000000000..4f7963bc20 --- /dev/null +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeSpec.kt @@ -0,0 +1,147 @@ +package com.jetbrains.plugin.structure.teamcity.recipe + +object TeamCityRecipeSpec { + object RecipeCompositeName { + const val NAME = "name" + const val DESCRIPTION = "the composite recipe name in the 'namespace/name' format" + + // Regular expression for the recipe's composite name – the namespace and the name separated by '/' + val compositeNameRegex: Regex = Regex("^([^/]+)/([^/]+)$") + + /** + * Regular expression pattern for both the recipe's namespace and the recipe's name. + * + * The pattern enforces the following rules for both namespace and id: + * - cannot be empty. + * – can only contain latin letters, dashes and underscores. + * - cannot start or end with a dash or underscore. + * - cannot contain several consecutive dashes or underscores. + */ + val meaningfulPartRegex: Regex = Regex("^[a-zA-Z0-9]+([_-][a-zA-Z0-9]+)*\$") + + object Namespace { + const val NAME = "namespace" + const val DESCRIPTION = "the first part of the composite `${RecipeCompositeName.NAME}` field" + const val MIN_LENGTH = 5 + const val MAX_LENGTH = 30 + } + + object Name { + const val NAME = "name" + const val DESCRIPTION = "the second part of the composite `${RecipeCompositeName.NAME}` field" + const val MIN_LENGTH = 5 + const val MAX_LENGTH = 30 + } + + fun getNamespace(recipeName: String?): String? = getNamespaceAndNameGroups(recipeName)?.get(1) + + fun getNameInNamespace(recipeName: String?): String? = getNamespaceAndNameGroups(recipeName)?.get(2) + + private fun getNamespaceAndNameGroups(recipeName: String?): List? { + if (recipeName == null) return null + val matchResult = compositeNameRegex.matchEntire(recipeName) + return matchResult?.groupValues + } + } + + object RecipeVersion { + const val NAME = "version" + const val DESCRIPTION = "recipe version" + } + + object RecipeDescription { + const val NAME = "description" + const val DESCRIPTION = "recipe description" + const val MAX_LENGTH = 250 + } + + object RecipeInputs { + const val NAME = "inputs" + } + + object RecipeInputName { + const val NAME = "name" + const val DESCRIPTION = "recipe input name" + const val MAX_LENGTH = 50 + } + + object RecipeInputType { + const val NAME = "type" + const val DESCRIPTION = "recipe input type" + } + + object RecipeInputRequired { + const val NAME = "required" + const val DESCRIPTION = "indicates whether the input is required" + } + + object RecipeInputLabel { + const val NAME = "label" + const val DESCRIPTION = "recipe input label" + const val MAX_LENGTH = 100 + } + + object RecipeInputDescription { + const val NAME = "description" + const val DESCRIPTION = "recipe input description" + const val MAX_LENGTH = 250 + } + + object RecipeInputDefault { + const val NAME = "default" + const val DESCRIPTION = "recipe input default value" + } + + object RecipeInputOptions { + const val NAME = "options" + const val DESCRIPTION = "recipe input options" + } + + object RecipeRequirements { + const val NAME = "requirements" + } + + object RecipeRequirementName { + const val NAME = "name" + const val DESCRIPTION = "recipe requirement name" + const val MAX_LENGTH = 50 + } + + object RecipeRequirementType { + const val NAME = "type" + const val DESCRIPTION = "recipe requirement type" + } + + object RecipeRequirementValue { + const val NAME = "value" + } + + object RecipeSteps { + const val NAME = "steps" + const val DESCRIPTION = "recipe steps" + } + + object RecipeStepName { + const val NAME = "name" + const val DESCRIPTION = "recipe step name" + const val MAX_LENGTH = 50 + } + + object RecipeStepWith { + const val NAME = "with" + const val DESCRIPTION = "runner or recipe reference" + const val MAX_LENGTH = 100 + const val RUNNER_PREFIX = "runner/" + const val RECIPE_PREFIX = "recipe/" + } + + object RecipeStepScript { + const val NAME = "script" + const val DESCRIPTION = "executable script content" + const val MAX_LENGTH = 50_000 + } + + object RecipeStepParams { + const val NAME = "params" + } +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionSpecVersionUtils.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeSpecVersionUtils.kt similarity index 85% rename from intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionSpecVersionUtils.kt rename to intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeSpecVersionUtils.kt index d5322a7932..01a69930c5 100644 --- a/intellij-plugin-structure/structure-teamcity-actions/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/action/TeamCityActionSpecVersionUtils.kt +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/TeamCityRecipeSpecVersionUtils.kt @@ -1,8 +1,8 @@ -package com.jetbrains.plugin.structure.teamcity.action +package com.jetbrains.plugin.structure.teamcity.recipe import com.vdurmont.semver4j.Semver -object TeamCityActionSpecVersionUtils { +object TeamCityRecipeSpecVersionUtils { const val MAX_MAJOR_VALUE = 10000 const val VERSION_MINOR_LENGTH = 10000 const val VERSION_PATCH_LENGTH = 10000 diff --git a/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/Validator.kt b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/Validator.kt new file mode 100644 index 0000000000..f6e722d925 --- /dev/null +++ b/intellij-plugin-structure/structure-teamcity-recipes/src/main/kotlin/com/jetbrains/plugin/structure/teamcity/recipe/Validator.kt @@ -0,0 +1,394 @@ +package com.jetbrains.plugin.structure.teamcity.recipe + +import com.jetbrains.plugin.structure.base.problems.InvalidSemverFormat +import com.jetbrains.plugin.structure.base.problems.PluginProblem +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeCompositeName +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeDescription +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputDefault +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputDescription +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputLabel +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputName +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputOptions +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputRequired +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeInputType +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeRequirementName +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeRequirementValue +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepName +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepScript +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepWith +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepWith.RECIPE_PREFIX +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeStepWith.RUNNER_PREFIX +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeSteps +import com.jetbrains.plugin.structure.teamcity.recipe.TeamCityRecipeSpec.RecipeVersion +import com.vdurmont.semver4j.Semver +import com.vdurmont.semver4j.SemverException + +internal fun validateTeamCityRecipe(recipe: TeamCityRecipeDescriptor) = sequence { + validateName(recipe.name) + + validateExistsAndNotEmpty(recipe.version, RecipeVersion.NAME, RecipeVersion.DESCRIPTION) + validateSemver(recipe.version, RecipeVersion.NAME) + + validateExistsAndNotEmpty(recipe.description, RecipeDescription.NAME, RecipeDescription.DESCRIPTION) + validateMaxLength( + recipe.description, + RecipeDescription.NAME, + RecipeDescription.DESCRIPTION, + RecipeDescription.MAX_LENGTH, + ) + + validateNotEmptyIfExists(recipe.steps, RecipeSteps.NAME, RecipeSteps.DESCRIPTION) + for (input in recipe.inputs) validateRecipeInput(input) + for (requirement in recipe.requirements) validateRecipeRequirement(requirement) + for (step in recipe.steps) validateRecipeStep(step) +}.toList() + +private suspend fun SequenceScope.validateName(name: String?) { + validateExists(name, RecipeCompositeName.NAME, RecipeCompositeName.DESCRIPTION) + validateNotEmptyIfExists(name, RecipeCompositeName.NAME, RecipeCompositeName.DESCRIPTION) + validateMatchesRegexIfExistsAndNotEmpty( + name, RecipeCompositeName.compositeNameRegex, RecipeCompositeName.NAME, RecipeCompositeName.DESCRIPTION, + "should consist of namespace and name parts. Both parts should only contain latin letters, numbers, dashes and underscores." + ) + + val namespace = RecipeCompositeName.getNamespace(name) + if (namespace != null) { + validateMinLength( + namespace, + RecipeCompositeName.Namespace.NAME, + RecipeCompositeName.Namespace.DESCRIPTION, + RecipeCompositeName.Namespace.MIN_LENGTH, + ) + validateMaxLength( + namespace, + RecipeCompositeName.Namespace.NAME, + RecipeCompositeName.Namespace.DESCRIPTION, + RecipeCompositeName.Namespace.MAX_LENGTH, + ) + validateMatchesRegexIfExistsAndNotEmpty( + namespace, + RecipeCompositeName.meaningfulPartRegex, + RecipeCompositeName.Namespace.NAME, + RecipeCompositeName.Namespace.DESCRIPTION, + "should only contain latin letters, numbers, dashes and underscores. " + + "The property cannot start or end with a dash or underscore, and cannot contain several consecutive dashes and underscores.", + ) + } + + val nameInNamespace = RecipeCompositeName.getNameInNamespace(name) + if (nameInNamespace != null) { + validateMinLength( + nameInNamespace, + RecipeCompositeName.Name.NAME, + RecipeCompositeName.Name.DESCRIPTION, + RecipeCompositeName.Name.MIN_LENGTH, + ) + validateMaxLength( + nameInNamespace, + RecipeCompositeName.Name.NAME, + RecipeCompositeName.Name.DESCRIPTION, + RecipeCompositeName.Name.MAX_LENGTH, + ) + validateMatchesRegexIfExistsAndNotEmpty( + nameInNamespace, + RecipeCompositeName.meaningfulPartRegex, + RecipeCompositeName.Name.NAME, + RecipeCompositeName.Name.DESCRIPTION, + "should only contain latin letters, numbers, dashes and underscores. " + + "The property cannot start or end with a dash or underscore, and cannot contain several consecutive dashes and underscores.", + ) + } +} + +private suspend fun SequenceScope.validateRecipeInput(input: Map) { + if (input.size != 1) { + yield(InvalidPropertyValueProblem("Wrong recipe input format. The input should consist of a name and a body.")) + return + } + + val inputName = input.keys.first() + validateMaxLength(inputName, RecipeInputName.NAME, RecipeInputName.DESCRIPTION, RecipeInputName.MAX_LENGTH) + + val value = input.values.first() + + validateExistsAndNotEmpty(value.type, RecipeInputType.NAME, RecipeInputType.DESCRIPTION) + if (value.type != null && !enumContains(value.type)) { + yield( + InvalidPropertyValueProblem( + "Wrong recipe input type: ${value.type}. Supported values are: ${ + RecipeInputTypeDescriptor.values().joinToString() + }" + ) + ) + } + + validateBooleanIfExists(value.required, RecipeInputRequired.NAME, RecipeInputRequired.DESCRIPTION) + + validateNotEmptyIfExists(value.label, RecipeInputLabel.NAME, RecipeInputLabel.DESCRIPTION) + validateMaxLength(value.label, RecipeInputLabel.NAME, RecipeInputLabel.DESCRIPTION, RecipeInputLabel.MAX_LENGTH) + + validateNotEmptyIfExists(value.description, RecipeInputDescription.NAME, RecipeInputDescription.DESCRIPTION) + validateMaxLength( + value.description, + RecipeInputDescription.NAME, + RecipeInputDescription.DESCRIPTION, + RecipeInputDescription.MAX_LENGTH, + ) + + validateNotEmptyIfExists(value.defaultValue, RecipeInputDefault.NAME, RecipeInputDefault.DESCRIPTION) + when (value.type) { + RecipeInputTypeDescriptor.boolean.name -> validateBooleanIfExists( + value.defaultValue, + RecipeInputDefault.NAME, + RecipeInputDefault.DESCRIPTION, + ) + + RecipeInputTypeDescriptor.number.name -> validateNumberIfExists( + value.defaultValue, + RecipeInputDefault.NAME, + RecipeInputDefault.DESCRIPTION, + ) + + RecipeInputTypeDescriptor.select.name -> validateNotEmptyIfExists( + value.selectOptions, + RecipeInputOptions.NAME, + RecipeInputOptions.DESCRIPTION, + ) + } +} + +private suspend fun SequenceScope.validateRecipeRequirement(requirement: Map) { + if (requirement.size != 1) { + yield(InvalidPropertyValueProblem("Wrong recipe requirement format. The requirement should consist of a name and a body.")) + return + } + + val requirementName = requirement.keys.first() + validateMaxLength( + requirementName, + RecipeRequirementName.NAME, + RecipeRequirementName.DESCRIPTION, + RecipeRequirementName.MAX_LENGTH, + ) + + val value = requirement.values.first() + + validateExistsAndNotEmpty( + value.type, + TeamCityRecipeSpec.RecipeRequirementType.NAME, + TeamCityRecipeSpec.RecipeRequirementType.DESCRIPTION, + ) + if (value.type == null) { + return + } + val type: RecipeRequirementType + try { + type = RecipeRequirementType.from(value.type) + } catch (e: IllegalArgumentException) { + yield( + InvalidPropertyValueProblem( + "Wrong recipe requirement type '${value.type}'. " + + "Supported values are: ${RecipeRequirementType.values().joinToString { it.type }}" + ) + ) + return + } + val description = "the value for ${value.type} requirement" + if (type.isValueRequired && value.value == null) { + yield(MissingValueProblem(RecipeRequirementValue.NAME, description)) + } else if (!type.valueCanBeEmpty && value.value.isNullOrEmpty()) { + yield(EmptyValueProblem(RecipeRequirementValue.NAME, description)) + } +} + +private suspend fun SequenceScope.validateRecipeStep(step: RecipeStepDescriptor) { + validateExistsAndNotEmpty(step.name, RecipeStepName.NAME, RecipeStepName.DESCRIPTION) + validateMaxLength(step.name, RecipeStepName.NAME, RecipeStepName.DESCRIPTION, RecipeStepName.MAX_LENGTH) + + if (step.with != null && step.script != null) { + yield( + PropertiesCombinationProblem( + "The properties " + + "<${RecipeStepWith.NAME}> (${RecipeStepWith.DESCRIPTION}) and " + + "<${RecipeStepScript.NAME}> (${RecipeStepScript.DESCRIPTION}) " + + "cannot be specified together for recipe step." + ) + ) + } else if (step.with == null && step.script == null) { + yield( + PropertiesCombinationProblem( + "One of the properties " + + "<${RecipeStepWith.NAME}> (${RecipeStepWith.DESCRIPTION}) or " + + "<${RecipeStepScript.NAME}> (${RecipeStepScript.DESCRIPTION}) should be specified for recipe step." + ) + ) + } else if (step.with != null) { + validateStepReference(step) + } else { + validateNotEmptyIfExists(step.script, RecipeStepScript.NAME, RecipeStepScript.DESCRIPTION) + validateMaxLength( + step.script, + RecipeStepScript.NAME, + RecipeStepScript.DESCRIPTION, + RecipeStepScript.MAX_LENGTH, + ) + } +} + +private suspend fun SequenceScope.validateStepReference(step: RecipeStepDescriptor) { + validateMaxLength(step.with, RecipeStepWith.NAME, RecipeStepWith.DESCRIPTION, RecipeStepWith.MAX_LENGTH) + when { + step.with!!.startsWith(RUNNER_PREFIX) -> { + val runnerName = step.with.substringAfter(RUNNER_PREFIX) + val allowedParams = allowedRunnerToAllowedParams[runnerName] + if (allowedParams == null) { + yield(UnsupportedRunnerProblem(runnerName, allowedRunnerToAllowedParams.keys)) + } else { + val unsupportedParams = step.parameters.filter { !allowedParams.contains(it.key) }.keys + if (unsupportedParams.isNotEmpty()) { + yield(UnsupportedRunnerParamsProblem(runnerName, unsupportedParams, allowedParams)) + } + } + } + + step.with.startsWith(RECIPE_PREFIX) -> { + val recipeId = step.with.substringAfter(RECIPE_PREFIX) + val recipeIdParts = recipeId.split("@") + if (recipeIdParts.size != 2) { + yield( + InvalidPropertyValueProblem( + "The property <${RecipeStepWith.NAME}> (${RecipeStepWith.DESCRIPTION}) has an invalid recipe reference: $recipeId. " + + "The reference must follow '${RECIPE_PREFIX}name@version' format" + ) + ) + } + } + + else -> { + yield( + InvalidPropertyValueProblem( + "The property <${RecipeStepWith.NAME}> (${RecipeStepWith.DESCRIPTION}) should be either a runner or an recipe reference. " + + "The value should start with '$RUNNER_PREFIX' or '$RECIPE_PREFIX' prefix" + ) + ) + } + } +} + +private suspend fun SequenceScope.validateExists( + propertyValue: String?, + propertyName: String, + propertyDescription: String, +) { + if (propertyValue == null) { + yield(MissingValueProblem(propertyName, propertyDescription)) + } +} + +private suspend fun SequenceScope.validateNotEmptyIfExists( + propertyValue: String?, + propertyName: String, + propertyDescription: String, +) { + if (propertyValue != null && propertyValue.isEmpty()) { + yield(EmptyValueProblem(propertyName, propertyDescription)) + } +} + +private suspend fun SequenceScope.validateNotEmptyIfExists( + propertyValue: Iterable, + propertyName: String, + propertyDescription: String, +) { + if (!propertyValue.iterator().hasNext()) { + yield(EmptyCollectionProblem(propertyName, propertyDescription)) + } +} + +private suspend fun SequenceScope.validateExistsAndNotEmpty( + propertyValue: String?, + propertyName: String, + propertyDescription: String, +) { + validateExists(propertyValue, propertyName, propertyDescription) + if (propertyValue != null) { + validateNotEmptyIfExists(propertyValue, propertyName, propertyDescription) + } +} + +private suspend fun SequenceScope.validateMinLength( + propertyValue: String?, + propertyName: String, + propertyDescription: String, + minAllowedLength: Int, +) { + if (propertyValue != null && propertyValue.length < minAllowedLength) { + yield(TooShortValueProblem(propertyName, propertyDescription, propertyValue.length, minAllowedLength)) + } +} + +private suspend fun SequenceScope.validateMaxLength( + propertyValue: String?, + propertyName: String, + propertyDescription: String, + maxAllowedLength: Int, +) { + if (propertyValue != null && propertyValue.length > maxAllowedLength) { + yield(TooLongValueProblem(propertyName, propertyDescription, propertyValue.length, maxAllowedLength)) + } +} + +private suspend fun SequenceScope.validateSemver( + version: String?, + propertyName: String, +): Semver? { + if (version != null) { + try { + return TeamCityRecipeSpecVersionUtils.getSemverFromString(version) + } catch (e: SemverException) { + yield( + InvalidSemverFormat( + versionName = propertyName, + version = version, + ) + ) + } + } + return null +} + +private suspend fun SequenceScope.validateBooleanIfExists( + propertyValue: String?, + propertyName: String, + propertyDescription: String, +) { + if (propertyValue != null && propertyValue != "true" && propertyValue != "false") { + yield(InvalidBooleanProblem(propertyName, propertyDescription)) + } +} + +private suspend fun SequenceScope.validateNumberIfExists( + propertyValue: String?, + propertyName: String, + propertyDescription: String, +) { + if (propertyValue != null && propertyValue.toLongOrNull() == null) { + yield(InvalidNumberProblem(propertyName, propertyDescription)) + } +} + +private suspend fun SequenceScope.validateMatchesRegexIfExistsAndNotEmpty( + propertyValue: String?, + regex: Regex, + propertyName: String, + propertyDescription: String, + validationFailureMessage: String, +) { + if (!propertyValue.isNullOrEmpty() && !regex.matches(propertyValue)) { + yield(InvalidPropertyValueProblem("The property <$propertyName> ($propertyDescription) $validationFailureMessage")) + } +} + +private inline fun > enumContains(name: String): Boolean { + return T::class.java.enumConstants.any { it.name == name } +} \ No newline at end of file diff --git a/intellij-plugin-structure/tests/build.gradle.kts b/intellij-plugin-structure/tests/build.gradle.kts index 2c3773a224..70cb2aa6a8 100644 --- a/intellij-plugin-structure/tests/build.gradle.kts +++ b/intellij-plugin-structure/tests/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { testImplementation(project(":structure-edu")) testImplementation(project(":structure-toolbox")) testImplementation(project(":structure-youtrack")) - testImplementation(project(":structure-teamcity-actions")) + testImplementation(project(":structure-teamcity-recipes")) testImplementation(sharedLibs.junit) testImplementation(sharedLibs.jackson.module.kotlin) testImplementation(libs.jimfs) diff --git a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/teamcity/action/ParseInvalidActionTests.kt b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/teamcity/action/ParseInvalidActionTests.kt deleted file mode 100644 index ab0c210bac..0000000000 --- a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/teamcity/action/ParseInvalidActionTests.kt +++ /dev/null @@ -1,510 +0,0 @@ -package com.jetbrains.plugin.structure.teamcity.action - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory -import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import com.jetbrains.plugin.structure.base.problems.InvalidSemverFormat -import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildZipFile -import com.jetbrains.plugin.structure.base.utils.isFile -import com.jetbrains.plugin.structure.mocks.BasePluginManagerTest -import com.jetbrains.plugin.structure.rules.FileSystemType -import com.jetbrains.plugin.structure.teamcity.action.Actions.someAction -import com.jetbrains.plugin.structure.teamcity.action.Inputs.someActionTextInput -import com.jetbrains.plugin.structure.teamcity.action.Inputs.someBooleanTextInput -import com.jetbrains.plugin.structure.teamcity.action.Inputs.someSelectTextInput -import com.jetbrains.plugin.structure.teamcity.action.Requirements.someExistsRequirement -import com.jetbrains.plugin.structure.teamcity.action.Steps.someScriptStep -import com.jetbrains.plugin.structure.teamcity.action.Steps.someWithStep -import org.junit.Assert -import org.junit.Test -import java.nio.file.Files -import java.nio.file.Path -import java.util.* - -class ParseInvalidActionTests( - fileSystemType: FileSystemType, -) : BasePluginManagerTest(fileSystemType) { - - override fun createManager(extractDirectory: Path): TeamCityActionPluginManager = - TeamCityActionPluginManager.createManager(extractDirectory) - - @Test - fun `action with incorrect YAML`() { - assertProblematicPlugin( - prepareActionYaml("some random text"), - listOf(ParseYamlProblem), - ) - } - - @Test - fun `action with unknown property in YAML`() { - val actionYaml = """ - name: namespace/action_name - unknown_property: this property should fail deserialization - version: 1.2.3 - description: abc - steps: - - name: step_1 - script: echo "kek" - """.trimIndent() - assertProblematicPlugin( - prepareActionYaml(actionYaml), - listOf(UnknownPropertyProblem("unknown_property")), - ) - } - - @Test - fun `action with multiple problems`() { - assertProblematicPlugin( - prepareActionYaml( - someAction.copy( - name = null, - version = null, - description = "", - steps = emptyList(), - ) - ), - listOf( - MissingValueProblem("name", "the composite action name in the 'namespace/name' format"), - MissingValueProblem("version", "action version"), - EmptyValueProblem("description", "action description"), - EmptyCollectionProblem("steps", "action steps"), - ), - ) - } - - @Test - fun `action without a composite name`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = null)), - listOf( - MissingValueProblem("name", "the composite action name in the 'namespace/name' format") - ), - ) - } - - @Test - fun `action with the composite name in an invalid format`() { - val invalidActionNamesProvider = arrayOf( - "aaaaabbbbb", "/aaaaabbbbb", "aaaaabbbbb/", "/", "aaaaa/bbbbb/ccccc", "aaaaa//bbbbb", "aaaaa\\bbbbb" - ) - invalidActionNamesProvider.forEach { actionName -> - Files.walk(temporaryFolder.root).filter { it.isFile }.forEach { Files.delete(it) } - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = actionName)), - listOf( - InvalidPropertyValueProblem( - "The property (the composite action name in the 'namespace/name' format) " + - "should consist of namespace and name parts. Both parts should only contain latin letters, numbers, dashes and underscores." - ) - ), - ) - } - } - - @Test - fun `action with an invalid namespace`() { - val invalidActionNamesProvider = arrayOf( - "-aaaaa/aaaaaa", "_aaaaa/aaaaaa", "aaaaaa-/aaaaa", "aaaaa_/aaaaa", "a--aa/aaaaa", "a__aa/aaaaa", "a+aaa/aaaaa", "абв23/aaaaa", - ) - invalidActionNamesProvider.forEach { actionName -> - Files.walk(temporaryFolder.root).filter { it.isFile }.forEach { Files.delete(it) } - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = actionName)), - listOf( - InvalidPropertyValueProblem( - "The property (the first part of the composite `name` field) should only contain latin letters, " - + "numbers, dashes and underscores. The property cannot start or end with a dash or underscore, and " - + "cannot contain several consecutive dashes and underscores." - ) - ), - ) - } - } - - @Test - fun `action with an invalid name`() { - val invalidActionNamesProvider = arrayOf( - "aaaaaa/-aaaaa", "aaaaaa/_aaaaa", "aaaaaa/aaaaaa-", "aaaaaa/aaaaa_", "aaaaaa/aa--a", "aaaaaa/aa__a", "aaaaaa/aa+aa", "aaaaaa/абв23" - ) - invalidActionNamesProvider.forEach { actionName -> - Files.walk(temporaryFolder.root).filter { it.isFile }.forEach { Files.delete(it) } - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = actionName)), - listOf( - InvalidPropertyValueProblem( - "The property (the second part of the composite `name` field) should only contain latin letters, " - + "numbers, dashes and underscores. The property cannot start or end with a dash or underscore, and " - + "cannot contain several consecutive dashes and underscores." - ) - ), - ) - } - } - - @Test - fun `action with a namespace that is too short`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = "aaaa/${randomAlphanumeric(10)}")), - listOf(TooShortValueProblem( - propertyName = "namespace", - propertyDescription = "the first part of the composite `name` field", - currentLength = 4, - minAllowedLength = 5 - )), - ) - } - - @Test - fun `action with a namespace that is too long`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = "${randomAlphanumeric(31)}/${randomAlphanumeric(10)}")), - listOf(TooLongValueProblem( - propertyName = "namespace", - propertyDescription = "the first part of the composite `name` field", - currentLength = 31, - maxAllowedLength = 30 - )), - ) - } - - @Test - fun `action with a name that is too short`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = "${randomAlphanumeric(10)}/aaaa")), - listOf(TooShortValueProblem( - propertyName = "name", - propertyDescription = "the second part of the composite `name` field", - currentLength = 4, - minAllowedLength = 5 - )), - ) - } - - @Test - fun `action with a name that is too long`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(name = "${randomAlphanumeric(10)}/${randomAlphanumeric(31)}")), - listOf(TooLongValueProblem( - propertyName = "name", - propertyDescription = "the second part of the composite `name` field", - currentLength = 31, - maxAllowedLength = 30 - )), - ) - } - - @Test - fun `action without version`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(version = null)), - listOf(MissingValueProblem("version", "action version")), - ) - } - - @Test - fun `action with invalid version`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(version = "invalid_version")), - listOf( - InvalidSemverFormat( - "version", - "invalid_version", - ) - ) - ) - } - - @Test - fun `action without description`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(description = null)), - listOf(MissingValueProblem("description", "action description")), - ) - } - - @Test - fun `action with non-null but empty description`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(description = "")), - listOf(EmptyValueProblem("description", "action description")), - ) - } - - @Test - fun `action with too long description`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(description = randomAlphanumeric(251))), - listOf(TooLongValueProblem("description", "action description", 251, 250)), - ) - } - - @Test - fun `action without steps`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(steps = listOf())), - listOf(EmptyCollectionProblem("steps", "action steps")) - ) - } - - @Test - fun `action with too long input name`() { - val name = randomAlphanumeric(51) - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf(name to someActionTextInput.copy())))), - listOf(TooLongValueProblem("name", "action input name", 51, 50)) - ) - } - - @Test - fun `action with incorrect input type`() { - val input = someActionTextInput.copy(type = "wrongType") - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(InvalidPropertyValueProblem("Wrong action input type: wrongType. Supported values are: text, boolean, select")) - ) - } - - @Test - fun `action with incorrect input 'required' boolean flag`() { - val input = someActionTextInput.copy(required = "not boolean") - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(InvalidBooleanProblem("required", "indicates whether the input is required")) - ) - } - - @Test - fun `action with non-null but empty input label`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to someActionTextInput.copy(label = ""))))), - listOf(EmptyValueProblem("label", "action input label")) - ) - } - - @Test - fun `action with too long input label`() { - val input = someActionTextInput.copy(label = randomAlphanumeric(101)) - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(TooLongValueProblem("label", "action input label", 101, 100)) - ) - } - - @Test - fun `action with non-null but empty input description`() { - val input = someActionTextInput.copy(description = "") - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(EmptyValueProblem("description", "action input description")) - ) - } - - @Test - fun `action with too long input description`() { - val input = someActionTextInput.copy(description = randomAlphanumeric(251)) - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(TooLongValueProblem("description", "action input description", 251, 250)) - ) - } - - @Test - fun `action with boolean input and incorrect boolean default value`() { - val input = someBooleanTextInput.copy(defaultValue = "not boolean") - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(InvalidBooleanProblem("default", "action input default value")) - ) - } - - @Test - fun `action with select input and empty select options`() { - val input = someSelectTextInput.copy(selectOptions = emptyList()) - assertProblematicPlugin( - prepareActionYaml(someAction.copy(inputs = listOf(mapOf("input_name" to input)))), - listOf(EmptyCollectionProblem("options", "action input options")) - ) - } - - @Test - fun `action with too long requirement name`() { - val name = randomAlphanumeric(51) - assertProblematicPlugin( - prepareActionYaml(someAction.copy(requirements = listOf(mapOf(name to someExistsRequirement.copy())))), - listOf( - TooLongValueProblem("name", "action requirement name", 51, 50) - ) - ) - } - - @Test - fun `action with incorrect requirement type`() { - val requirement = someExistsRequirement.copy(type = "wrong_requirement_type") - assertProblematicPlugin( - prepareActionYaml(someAction.copy(requirements = listOf(mapOf("req_name" to requirement)))), - listOf( - InvalidPropertyValueProblem( - "Wrong action requirement type 'wrong_requirement_type'. " + - "Supported values are: exists, not-exists, equals, not-equals, more-than, not-more-than, less-than, " + - "not-less-than, starts-with, contains, does-not-contain, ends-with, matches, does-not-match, " + - "version-more-than, version-not-more-than, version-less-than, version-not-less-than, any" - ) - ) - ) - } - - @Test - fun `action without step name`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(steps = listOf(someWithStep.copy(stepName = null)))), - listOf( - MissingValueProblem("name", "action step name") - ) - ) - } - - @Test - fun `action with non-null but empty step name`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(steps = listOf(someWithStep.copy(stepName = "")))), - listOf( - EmptyValueProblem("name", "action step name") - ) - ) - } - - @Test - fun `action with too long step name`() { - assertProblematicPlugin( - prepareActionYaml(someAction.copy(steps = listOf(someWithStep.copy(stepName = randomAlphanumeric(51))))), - listOf( - TooLongValueProblem("name", "action step name", 51, 50) - ) - ) - } - - @Test - fun `action with both 'with' and 'script' properties for action step`() { - assertProblematicPlugin( - prepareActionYaml( - someAction.copy( - steps = listOf( - someWithStep.copy(script = "echo \"hello world\"") - ) - ) - ), - listOf( - PropertiesCombinationProblem( - "The properties " + - " (runner or action reference) and " + - "