From 9a9ab9e0754afbf9032c59fd463f84a7fa595a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= <12143866+ondrajodas@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:17:12 +0200 Subject: [PATCH 1/2] Add manage:projects-add-feature-conditionally command Adds a target feature to projects that already have a given condition feature. Scope can be narrowed with mutually exclusive --maintainer-id, --organization-id or --project-id options; defaults to the whole stack. Runs as a dry run unless -f/--force is passed. --- README.md | 20 ++ cli.php | 2 + .../ProjectsAddFeatureConditionally.php | 269 ++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php diff --git a/README.md b/README.md index 86f2a94..5e564a1 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,26 @@ 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. + +``` +php cli.php manage:projects-add-feature-conditionally [-f|--force] [--maintainer-id=ID] [--organization-id=ID] [--project-id=ID] +``` + +The scope is the whole stack by default. You can narrow it down with one of the mutually exclusive options: +- `--project-id` – process a single project +- `--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. + +Examples: +- `manage:projects-add-feature-conditionally new-billing new-ui` – dry run over the whole stack +- `manage:projects-add-feature-conditionally -f new-billing new-ui --organization-id=123` + +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. + ### Bulk Project Remove Feature Removes a project feature from multiple projects diff --git a/cli.php b/cli.php index 0889535..ddc6d18 100644 --- a/cli.php +++ b/cli.php @@ -22,6 +22,7 @@ use Keboola\Console\Command\QueueMassTerminateJobs; use Keboola\Console\Command\ReactivateSchedules; use Keboola\Console\Command\ProjectsAddFeature; +use Keboola\Console\Command\ProjectsAddFeatureConditionally; use Keboola\Console\Command\ProjectsRemoveFeature; use Keboola\Console\Command\DeletedProjectsPurge; use Keboola\Console\Command\NotifyProjects; @@ -32,6 +33,7 @@ $application = new Application(); $application->add(new ProjectsAddFeature()); +$application->add(new ProjectsAddFeatureConditionally()); $application->add(new ProjectsRemoveFeature()); $application->add(new DeletedProjectsPurge()); $application->add(new NotifyProjects()); diff --git a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php new file mode 100644 index 0000000..e9d444c --- /dev/null +++ b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php @@ -0,0 +1,269 @@ +setName('manage:projects-add-feature-conditionally') + ->setDescription('Add a target feature to projects that already 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_TARGET_FEATURE, InputArgument::REQUIRED, 'feature to add') + ->addOption( + self::OPT_MAINTAINER_ID, + null, + InputOption::VALUE_REQUIRED, + 'Limit scope to projects of all organizations of this maintainer' + ) + ->addOption( + self::OPT_ORGANIZATION_ID, + null, + InputOption::VALUE_REQUIRED, + 'Limit scope to projects of this organization' + ) + ->addOption( + self::OPT_PROJECT_ID, + null, + InputOption::VALUE_REQUIRED, + 'Limit scope to this single project' + ) + ->addOption(self::OPT_FORCE, 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run'); + } + + 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)); + $targetFeature = $input->getArgument(self::ARG_TARGET_FEATURE); + assert(is_string($targetFeature)); + $url = $input->getArgument(self::ARG_URL); + assert(is_string($url)); + $token = $input->getArgument(self::ARG_TOKEN); + assert(is_string($token)); + + $maintainerId = $input->getOption(self::OPT_MAINTAINER_ID); + $organizationId = $input->getOption(self::OPT_ORGANIZATION_ID); + $projectId = $input->getOption(self::OPT_PROJECT_ID); + + $scopeOptions = array_filter( + [$maintainerId, $organizationId, $projectId], + fn($value) => $value !== null + ); + if (count($scopeOptions) > 1) { + $output->writeln('ERROR: Options --maintainer-id, --organization-id and --project-id are mutually exclusive.'); + return 1; + } + + $client = $this->createClient($url, $token); + + 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)); + return 1; + } + + if ($projectId !== null) { + assert(is_string($projectId)); + $this->processProject($client, $output, $projectId, $conditionFeature, $targetFeature, $force); + } elseif ($organizationId !== null) { + assert(is_string($organizationId)); + $this->processOrganization($client, $output, $organizationId, $conditionFeature, $targetFeature, $force); + } elseif ($maintainerId !== null) { + assert(is_string($maintainerId)); + $this->processMaintainer($client, $output, $maintainerId, $conditionFeature, $targetFeature, $force); + } else { + $this->processAllProjects($client, $output, $conditionFeature, $targetFeature, $force); + } + + $output->writeln("\nDONE with following results:\n"); + $this->printResult($output, $force); + + return 0; + } + + /** + * @param array{ + * id: int, + * isDisabled?: bool, + * disabled: array{reason: string}, + * features: string[] + * } $projectInfo + */ + protected function addFeatureToProjectConditionally( + Client $client, + OutputInterface $output, + array $projectInfo, + string $conditionFeature, + string $targetFeature, + bool $force + ): void { + $projectId = (string) $projectInfo['id']; + $output->writeln('Checking project ' . $projectId); + + if (isset($projectInfo['isDisabled']) && $projectInfo['isDisabled']) { + $output->writeln(' - project disabled: ' . $projectInfo['disabled']['reason']); + $this->projectsDisabled++; + $output->write("\n"); + return; + } + + $features = $projectInfo['features']; + + if (!in_array($conditionFeature, $features, true)) { + $output->writeln(sprintf(' - condition feature "%s" is NOT set, skipping.', $conditionFeature)); + $this->projectsWithoutCondition++; + $output->write("\n"); + return; + } + + if (in_array($targetFeature, $features, true)) { + $output->writeln(sprintf(' - target feature "%s" is already set.', $targetFeature)); + $this->projectsWithFeature++; + $output->write("\n"); + return; + } + + if ($force) { + $client->addProjectFeature($projectInfo['id'], $targetFeature); + $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.', + $targetFeature, + $conditionFeature + )); + } + $this->projectsUpdated++; + $output->write("\n"); + } + + protected function processProject( + Client $client, + OutputInterface $output, + string $projectId, + string $conditionFeature, + string $targetFeature, + bool $force + ): void { + try { + $project = $client->getProject($projectId); + /** + * @var array{ + * id: int, + * isDisabled?: bool, + * disabled: array{reason: string}, + * features: string[] + * } $project + */ + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $force); + } catch (ClientException $e) { + $output->writeln("Error while handling project {$projectId} : " . $e->getMessage()); + } + } + + protected function processOrganization( + Client $client, + OutputInterface $output, + string $organizationId, + string $conditionFeature, + string $targetFeature, + bool $force + ): void { + $this->orgsChecked++; + $projects = $client->listOrganizationProjects($organizationId); + foreach ($projects as $project) { + /** + * @var array{ + * id: int, + * isDisabled?: bool, + * disabled: array{reason: string}, + * features: string[] + * } $project + */ + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $force); + } + } + + protected function processMaintainer( + Client $client, + OutputInterface $output, + string $maintainerId, + string $conditionFeature, + string $targetFeature, + bool $force + ): void { + $this->maintainersChecked++; + $organizations = $client->listMaintainerOrganizations($maintainerId); + foreach ($organizations as $organization) { + $this->processOrganization( + $client, + $output, + (string) $organization['id'], + $conditionFeature, + $targetFeature, + $force + ); + } + } + + protected function processAllProjects( + Client $client, + OutputInterface $output, + string $conditionFeature, + string $targetFeature, + bool $force + ): void { + $maintainers = $client->listMaintainers(); + foreach ($maintainers as $maintainer) { + $this->processMaintainer( + $client, + $output, + (string) $maintainer['id'], + $conditionFeature, + $targetFeature, + $force + ); + } + } + + private function printResult(OutputInterface $output, bool $force): void + { + $output->writeln(sprintf( + "Checked %d maintainers\n" + . "Checked %d organizations\n" + . "%d projects were disabled\n" + . "%d projects do not have the condition feature\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->projectsWithFeature, + $this->projectsUpdated + )); + } +} From 60feb83387a17704fe6ebe059fdff511c405222a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= <12143866+ondrajodas@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:29:54 +0200 Subject: [PATCH 2/2] Handle ClientException per project in processOrganization loop --- .../Console/Command/ProjectsAddFeatureConditionally.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php index e9d444c..80d8dc1 100644 --- a/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php +++ b/src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php @@ -203,7 +203,11 @@ protected function processOrganization( * features: string[] * } $project */ - $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $force); + try { + $this->addFeatureToProjectConditionally($client, $output, $project, $conditionFeature, $targetFeature, $force); + } catch (ClientException $e) { + $output->writeln("Error while handling project {$project['id']} : " . $e->getMessage()); + } } }