From 6f3a7dc096140a05832ebb1021b7ccea3920fcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= <12143866+ondrajodas@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:27:17 +0200 Subject: [PATCH 1/3] Add --condition-mode option to projects-add-feature-conditionally Allows inverting the condition check. With --condition-mode=present (default) the target feature is added to projects that have the condition feature; with --condition-mode=absent it is added to projects that do not have it. Invalid values are rejected. --- README.md | 13 ++-- .../ProjectsAddFeatureConditionally.php | 67 +++++++++++++++---- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9067a31..8327400 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,10 @@ By in argument `` you can Note: the feature has to exist before calling, and it has to be type of `project` ### Conditionally Add Feature -Adds a target feature only to projects that already have a given condition feature. +Adds a target feature to projects based on whether they have a given condition feature. ``` -php cli.php manage:projects-add-feature-conditionally [-f|--force] [--maintainer-id=ID] [--organization-id=ID] [--project-id=ID] +php cli.php manage:projects-add-feature-conditionally [-f|--force] [--maintainer-id=ID] [--organization-id=ID] [--project-id=ID] [--condition-mode=present|absent] ``` The scope is the whole stack by default. You can narrow it down with one of the mutually exclusive options: @@ -68,11 +68,16 @@ The scope is the whole stack by default. You can narrow it down with one of the - `--organization-id` – process all projects of one organization - `--maintainer-id` – process all projects of all organizations of one maintainer -For each project in scope, the target feature is added only when the project already has the condition feature (and does not yet have the target feature). Disabled projects are skipped. +The `--condition-mode` option controls how the condition feature is evaluated (default `present`): +- `present` – add the target feature only to projects that **have** the condition feature +- `absent` – add the target feature only to projects that **do not have** the condition feature + +In both modes projects that already have the target feature are skipped, and disabled projects are skipped. Examples: -- `manage:projects-add-feature-conditionally new-billing new-ui` – dry run over the whole stack +- `manage:projects-add-feature-conditionally new-billing new-ui` – dry run, add `new-ui` to projects that have `new-billing` - `manage:projects-add-feature-conditionally -f new-billing new-ui --organization-id=123` +- `manage:projects-add-feature-conditionally -f legacy-billing new-ui --condition-mode=absent` – add `new-ui` to projects that do NOT have `legacy-billing` Note: both the condition and target features have to exist before calling, and have to be of type `project`. The options `--maintainer-id`, `--organization-id` and `--project-id` are mutually exclusive. diff --git a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php index 80d8dc1..9aec3b9 100644 --- a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php +++ b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php @@ -16,8 +16,11 @@ class ProjectsAddFeatureConditionally extends ProjectsAddFeature const string OPT_MAINTAINER_ID = 'maintainer-id'; const string OPT_ORGANIZATION_ID = 'organization-id'; const string OPT_PROJECT_ID = 'project-id'; + const string OPT_CONDITION_MODE = 'condition-mode'; + const string CONDITION_MODE_PRESENT = 'present'; + const string CONDITION_MODE_ABSENT = 'absent'; - protected int $projectsWithoutCondition = 0; + protected int $projectsSkippedByCondition = 0; protected function configure(): void { @@ -46,6 +49,17 @@ protected function configure(): void InputOption::VALUE_REQUIRED, 'Limit scope to this single project' ) + ->addOption( + self::OPT_CONDITION_MODE, + null, + InputOption::VALUE_REQUIRED, + sprintf( + 'Whether the condition feature must be "%s" or "%s" on the project', + self::CONDITION_MODE_PRESENT, + self::CONDITION_MODE_ABSENT + ), + self::CONDITION_MODE_PRESENT + ) ->addOption(self::OPT_FORCE, 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run'); } @@ -74,6 +88,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + $conditionMode = $input->getOption(self::OPT_CONDITION_MODE); + assert(is_string($conditionMode)); + if (!in_array($conditionMode, [self::CONDITION_MODE_PRESENT, self::CONDITION_MODE_ABSENT], true)) { + $output->writeln(sprintf( + 'ERROR: Option --%s must be either "%s" or "%s".', + self::OPT_CONDITION_MODE, + self::CONDITION_MODE_PRESENT, + self::CONDITION_MODE_ABSENT + )); + return 1; + } + $client = $this->createClient($url, $token); if (!$this->checkIfFeatureExists($client, $conditionFeature)) { @@ -87,15 +113,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($projectId !== null) { assert(is_string($projectId)); - $this->processProject($client, $output, $projectId, $conditionFeature, $targetFeature, $force); + $this->processProject($client, $output, $projectId, $conditionFeature, $targetFeature, $conditionMode, $force); } elseif ($organizationId !== null) { assert(is_string($organizationId)); - $this->processOrganization($client, $output, $organizationId, $conditionFeature, $targetFeature, $force); + $this->processOrganization($client, $output, $organizationId, $conditionFeature, $targetFeature, $conditionMode, $force); } elseif ($maintainerId !== null) { assert(is_string($maintainerId)); - $this->processMaintainer($client, $output, $maintainerId, $conditionFeature, $targetFeature, $force); + $this->processMaintainer($client, $output, $maintainerId, $conditionFeature, $targetFeature, $conditionMode, $force); } else { - $this->processAllProjects($client, $output, $conditionFeature, $targetFeature, $force); + $this->processAllProjects($client, $output, $conditionFeature, $targetFeature, $conditionMode, $force); } $output->writeln("\nDONE with following results:\n"); @@ -118,6 +144,7 @@ protected function addFeatureToProjectConditionally( array $projectInfo, string $conditionFeature, string $targetFeature, + string $conditionMode, bool $force ): void { $projectId = (string) $projectInfo['id']; @@ -132,9 +159,16 @@ protected function addFeatureToProjectConditionally( $features = $projectInfo['features']; - if (!in_array($conditionFeature, $features, true)) { - $output->writeln(sprintf(' - condition feature "%s" is NOT set, skipping.', $conditionFeature)); - $this->projectsWithoutCondition++; + $hasCondition = in_array($conditionFeature, $features, true); + $conditionMet = $conditionMode === self::CONDITION_MODE_PRESENT ? $hasCondition : !$hasCondition; + + if (!$conditionMet) { + $output->writeln(sprintf( + ' - condition feature "%s" %s set, skipping.', + $conditionFeature, + $hasCondition ? 'IS' : 'is NOT' + )); + $this->projectsSkippedByCondition++; $output->write("\n"); return; } @@ -151,8 +185,9 @@ protected function addFeatureToProjectConditionally( $output->writeln(sprintf(' - target feature "%s" successfully added.', $targetFeature)); } else { $output->writeln(sprintf( - ' - target feature "%s" CAN be added (project has "%s"). Enable force mode with -f option.', + ' - target feature "%s" CAN be added (project %s condition feature "%s"). Enable force mode with -f option.', $targetFeature, + $hasCondition ? 'has' : 'does not have', $conditionFeature )); } @@ -166,6 +201,7 @@ protected function processProject( string $projectId, string $conditionFeature, string $targetFeature, + string $conditionMode, bool $force ): void { try { @@ -178,7 +214,7 @@ protected function processProject( * features: string[] * } $project */ - $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $force); + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $conditionMode, $force); } catch (ClientException $e) { $output->writeln("Error while handling project {$projectId} : " . $e->getMessage()); } @@ -190,6 +226,7 @@ protected function processOrganization( string $organizationId, string $conditionFeature, string $targetFeature, + string $conditionMode, bool $force ): void { $this->orgsChecked++; @@ -204,7 +241,7 @@ protected function processOrganization( * } $project */ try { - $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $force); + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $conditionMode, $force); } catch (ClientException $e) { $output->writeln("Error while handling project {$project['id']} : " . $e->getMessage()); } @@ -217,6 +254,7 @@ protected function processMaintainer( string $maintainerId, string $conditionFeature, string $targetFeature, + string $conditionMode, bool $force ): void { $this->maintainersChecked++; @@ -228,6 +266,7 @@ protected function processMaintainer( (string) $organization['id'], $conditionFeature, $targetFeature, + $conditionMode, $force ); } @@ -238,6 +277,7 @@ protected function processAllProjects( OutputInterface $output, string $conditionFeature, string $targetFeature, + string $conditionMode, bool $force ): void { $maintainers = $client->listMaintainers(); @@ -248,6 +288,7 @@ protected function processAllProjects( (string) $maintainer['id'], $conditionFeature, $targetFeature, + $conditionMode, $force ); } @@ -259,13 +300,13 @@ private function printResult(OutputInterface $output, bool $force): void "Checked %d maintainers\n" . "Checked %d organizations\n" . "%d projects were disabled\n" - . "%d projects do not have the condition feature\n" + . "%d projects skipped (condition not met)\n" . "%d projects have the target feature already\n" . '%d ' . ($force ? 'projects updated' : 'projects can be updated in force mode') . "\n", $this->maintainersChecked, $this->orgsChecked, $this->projectsDisabled, - $this->projectsWithoutCondition, + $this->projectsSkippedByCondition, $this->projectsWithFeature, $this->projectsUpdated )); From 285ec8c5e7be00442b18281a83a9c371f4f993e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= <12143866+ondrajodas@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:34:36 +0200 Subject: [PATCH 2/3] Address review: clarify command description and unify skip message casing --- .../Console/Command/ProjectsAddFeatureConditionally.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php index 9aec3b9..ff03c4a 100644 --- a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php +++ b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php @@ -26,7 +26,7 @@ protected function configure(): void { $this ->setName('manage:projects-add-feature-conditionally') - ->setDescription('Add a target feature to projects that already have a given condition feature') + ->setDescription('Add a target feature to projects based on whether they have a given condition feature') ->addArgument(self::ARG_TOKEN, InputArgument::REQUIRED, 'manage token') ->addArgument(self::ARG_URL, InputArgument::REQUIRED, 'Stack URL') ->addArgument(self::ARG_CONDITION_FEATURE, InputArgument::REQUIRED, 'feature a project must already have') @@ -166,7 +166,7 @@ protected function addFeatureToProjectConditionally( $output->writeln(sprintf( ' - condition feature "%s" %s set, skipping.', $conditionFeature, - $hasCondition ? 'IS' : 'is NOT' + $hasCondition ? 'is' : 'is not' )); $this->projectsSkippedByCondition++; $output->write("\n"); From 9c38bb5009297e4283897e493eb053759cf84c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= <12143866+ondrajodas@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:03:01 +0200 Subject: [PATCH 3/3] Support multiple condition features evaluated together The condition-feature argument now accepts a comma-separated list. With --condition-mode=present the target feature is added to projects that have all of them; with --condition-mode=absent to projects that have none of them. --- README.md | 7 +- .../ProjectsAddFeatureConditionally.php | 96 +++++++++++++------ 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8327400..2c5d390 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ The scope is the whole stack by default. You can narrow it down with one of the - `--organization-id` – process all projects of one organization - `--maintainer-id` – process all projects of all organizations of one maintainer -The `--condition-mode` option controls how the condition feature is evaluated (default `present`): -- `present` – add the target feature only to projects that **have** the condition feature -- `absent` – add the target feature only to projects that **do not have** the condition feature +The `` argument accepts one feature or a comma-separated list of features (e.g. `batch1,batch0`). The `--condition-mode` option controls how they are evaluated (default `present`): +- `present` – add the target feature only to projects that have **all** of the condition features +- `absent` – add the target feature only to projects that have **none** of the condition features In both modes projects that already have the target feature are skipped, and disabled projects are skipped. @@ -78,6 +78,7 @@ Examples: - `manage:projects-add-feature-conditionally new-billing new-ui` – dry run, add `new-ui` to projects that have `new-billing` - `manage:projects-add-feature-conditionally -f new-billing new-ui --organization-id=123` - `manage:projects-add-feature-conditionally -f legacy-billing new-ui --condition-mode=absent` – add `new-ui` to projects that do NOT have `legacy-billing` +- `manage:projects-add-feature-conditionally -f batch1,batch0 batch2 --condition-mode=absent` – add `batch2` to projects that have neither `batch1` nor `batch0` Note: both the condition and target features have to exist before calling, and have to be of type `project`. The options `--maintainer-id`, `--organization-id` and `--project-id` are mutually exclusive. diff --git a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php index ff03c4a..a7a2b8a 100644 --- a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php +++ b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php @@ -29,7 +29,11 @@ protected function configure(): void ->setDescription('Add a target feature to projects based on whether they have a given condition feature') ->addArgument(self::ARG_TOKEN, InputArgument::REQUIRED, 'manage token') ->addArgument(self::ARG_URL, InputArgument::REQUIRED, 'Stack URL') - ->addArgument(self::ARG_CONDITION_FEATURE, InputArgument::REQUIRED, 'feature a project must already have') + ->addArgument( + self::ARG_CONDITION_FEATURE, + InputArgument::REQUIRED, + 'condition feature(s), comma-separated for multiple; evaluated together (see --condition-mode)' + ) ->addArgument(self::ARG_TARGET_FEATURE, InputArgument::REQUIRED, 'feature to add') ->addOption( self::OPT_MAINTAINER_ID, @@ -66,8 +70,16 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $force = (bool) $input->getOption(self::OPT_FORCE); - $conditionFeature = $input->getArgument(self::ARG_CONDITION_FEATURE); - assert(is_string($conditionFeature)); + $conditionFeaturesArg = $input->getArgument(self::ARG_CONDITION_FEATURE); + assert(is_string($conditionFeaturesArg)); + $conditionFeatures = array_values(array_filter( + array_map('trim', explode(',', $conditionFeaturesArg)), + fn($feature) => $feature !== '' + )); + if (count($conditionFeatures) === 0) { + $output->writeln('ERROR: At least one condition feature must be provided.'); + return 1; + } $targetFeature = $input->getArgument(self::ARG_TARGET_FEATURE); assert(is_string($targetFeature)); $url = $input->getArgument(self::ARG_URL); @@ -102,9 +114,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $client = $this->createClient($url, $token); - if (!$this->checkIfFeatureExists($client, $conditionFeature)) { - $output->writeln(sprintf('Condition feature %s does NOT exist', $conditionFeature)); - return 1; + foreach ($conditionFeatures as $conditionFeature) { + if (!$this->checkIfFeatureExists($client, $conditionFeature)) { + $output->writeln(sprintf('Condition feature %s does NOT exist', $conditionFeature)); + return 1; + } } if (!$this->checkIfFeatureExists($client, $targetFeature)) { $output->writeln(sprintf('Target feature %s does NOT exist', $targetFeature)); @@ -113,15 +127,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($projectId !== null) { assert(is_string($projectId)); - $this->processProject($client, $output, $projectId, $conditionFeature, $targetFeature, $conditionMode, $force); + $this->processProject($client, $output, $projectId, $conditionFeatures, $targetFeature, $conditionMode, $force); } elseif ($organizationId !== null) { assert(is_string($organizationId)); - $this->processOrganization($client, $output, $organizationId, $conditionFeature, $targetFeature, $conditionMode, $force); + $this->processOrganization($client, $output, $organizationId, $conditionFeatures, $targetFeature, $conditionMode, $force); } elseif ($maintainerId !== null) { assert(is_string($maintainerId)); - $this->processMaintainer($client, $output, $maintainerId, $conditionFeature, $targetFeature, $conditionMode, $force); + $this->processMaintainer($client, $output, $maintainerId, $conditionFeatures, $targetFeature, $conditionMode, $force); } else { - $this->processAllProjects($client, $output, $conditionFeature, $targetFeature, $conditionMode, $force); + $this->processAllProjects($client, $output, $conditionFeatures, $targetFeature, $conditionMode, $force); } $output->writeln("\nDONE with following results:\n"); @@ -137,12 +151,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int * disabled: array{reason: string}, * features: string[] * } $projectInfo + * @param list $conditionFeatures */ protected function addFeatureToProjectConditionally( Client $client, OutputInterface $output, array $projectInfo, - string $conditionFeature, + array $conditionFeatures, string $targetFeature, string $conditionMode, bool $force @@ -159,15 +174,26 @@ protected function addFeatureToProjectConditionally( $features = $projectInfo['features']; - $hasCondition = in_array($conditionFeature, $features, true); - $conditionMet = $conditionMode === self::CONDITION_MODE_PRESENT ? $hasCondition : !$hasCondition; + $presentConditions = array_values(array_intersect($conditionFeatures, $features)); + $missingConditions = array_values(array_diff($conditionFeatures, $features)); + + // present: project must have ALL condition features; absent: project must have NONE of them + $conditionMet = $conditionMode === self::CONDITION_MODE_PRESENT + ? count($missingConditions) === 0 + : count($presentConditions) === 0; if (!$conditionMet) { - $output->writeln(sprintf( - ' - condition feature "%s" %s set, skipping.', - $conditionFeature, - $hasCondition ? 'is' : 'is not' - )); + if ($conditionMode === self::CONDITION_MODE_PRESENT) { + $output->writeln(sprintf( + ' - condition not met, project is missing feature(s): %s, skipping.', + implode(', ', $missingConditions) + )); + } else { + $output->writeln(sprintf( + ' - condition not met, project has feature(s): %s, skipping.', + implode(', ', $presentConditions) + )); + } $this->projectsSkippedByCondition++; $output->write("\n"); return; @@ -185,21 +211,22 @@ protected function addFeatureToProjectConditionally( $output->writeln(sprintf(' - target feature "%s" successfully added.', $targetFeature)); } else { $output->writeln(sprintf( - ' - target feature "%s" CAN be added (project %s condition feature "%s"). Enable force mode with -f option.', - $targetFeature, - $hasCondition ? 'has' : 'does not have', - $conditionFeature + ' - target feature "%s" CAN be added (condition met). Enable force mode with -f option.', + $targetFeature )); } $this->projectsUpdated++; $output->write("\n"); } + /** + * @param list $conditionFeatures + */ protected function processProject( Client $client, OutputInterface $output, string $projectId, - string $conditionFeature, + array $conditionFeatures, string $targetFeature, string $conditionMode, bool $force @@ -214,17 +241,20 @@ protected function processProject( * features: string[] * } $project */ - $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $conditionMode, $force); + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeatures, $targetFeature, $conditionMode, $force); } catch (ClientException $e) { $output->writeln("Error while handling project {$projectId} : " . $e->getMessage()); } } + /** + * @param list $conditionFeatures + */ protected function processOrganization( Client $client, OutputInterface $output, string $organizationId, - string $conditionFeature, + array $conditionFeatures, string $targetFeature, string $conditionMode, bool $force @@ -241,18 +271,21 @@ protected function processOrganization( * } $project */ try { - $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $conditionMode, $force); + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeatures, $targetFeature, $conditionMode, $force); } catch (ClientException $e) { $output->writeln("Error while handling project {$project['id']} : " . $e->getMessage()); } } } + /** + * @param list $conditionFeatures + */ protected function processMaintainer( Client $client, OutputInterface $output, string $maintainerId, - string $conditionFeature, + array $conditionFeatures, string $targetFeature, string $conditionMode, bool $force @@ -264,7 +297,7 @@ protected function processMaintainer( $client, $output, (string) $organization['id'], - $conditionFeature, + $conditionFeatures, $targetFeature, $conditionMode, $force @@ -272,10 +305,13 @@ protected function processMaintainer( } } + /** + * @param list $conditionFeatures + */ protected function processAllProjects( Client $client, OutputInterface $output, - string $conditionFeature, + array $conditionFeatures, string $targetFeature, string $conditionMode, bool $force @@ -286,7 +322,7 @@ protected function processAllProjects( $client, $output, (string) $maintainer['id'], - $conditionFeature, + $conditionFeatures, $targetFeature, $conditionMode, $force