diff --git a/CHANGELOG.md b/CHANGELOG.md index 108fd1b5..9aa033fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog] and Pipelines adheres to ## [unreleased] ### Add +- `--show` and `--show-pipelines`: Annotate steps' conditions with `*C` - Schema for `.artifacts.download` and `paths` properties Mar 2021 ([Tina Yu]) - Support for step artifacts with paths attribute diff --git a/src/File/Info/StepInfo.php b/src/File/Info/StepInfo.php index 9a4fc21c..8d45ef14 100644 --- a/src/File/Info/StepInfo.php +++ b/src/File/Info/StepInfo.php @@ -15,6 +15,7 @@ final class StepInfo { const NO_NAME = 'no-name'; const CHAR_ARTIFACTS = 'A'; + const CHAR_CONDITION = 'C'; const CHAR_MANUAL = 'M'; /** @@ -70,6 +71,7 @@ public function getAnnotations() $annotations = array(); $this->step->getArtifacts() && $annotations[] = self::CHAR_ARTIFACTS; + $this->step->getCondition() && $annotations[] = self::CHAR_CONDITION; $this->step->isManual() && $annotations[] = self::CHAR_MANUAL; return $annotations; diff --git a/src/File/Pipeline/Step.php b/src/File/Pipeline/Step.php index c7d68743..184a6d7d 100644 --- a/src/File/Pipeline/Step.php +++ b/src/File/Pipeline/Step.php @@ -44,15 +44,8 @@ class Step implements FileNode */ public function __construct(Pipeline $pipeline, $index, array $step, array $env = array()) { - // quick validation: image name - Image::validate($step); - - // quick validation: trigger - $this->validateTrigger($step, (bool)$env); - - // quick validation: script + after-script - $this->parseScript($step); - $this->parseAfterScript($step); + // validate step + StepParser::validate($step, $env); $this->pipeline = $pipeline; $this->index = $index; @@ -72,6 +65,16 @@ public function getArtifacts() : null; } + /** + * @return null|StepCondition + */ + public function getCondition() + { + return isset($this->step['condition']) + ? new StepCondition($this->step['condition']) + : null; + } + /** * @throws ParseException * @@ -196,95 +199,4 @@ public function getFile() { return $this->pipeline->getFile(); } - - /** - * validate step trigger (none, manual, automatic) - * - * @param array $array - * @param bool $isParallelStep - * - * @return void - */ - private function validateTrigger(array $array, $isParallelStep) - { - if (!array_key_exists('trigger', $array)) { - return; - } - - $trigger = $array['trigger']; - if ($isParallelStep) { - throw new ParseException("Unexpected property 'trigger' in parallel step"); - } - - if (!in_array($trigger, array('manual', 'automatic'), true)) { - throw new ParseException("'trigger' expects either 'manual' or 'automatic'"); - } - } - - /** - * Parse a step script section - * - * @param array $step - * - * @throws ParseException - * - * @return void - */ - private function parseScript(array $step) - { - if (!isset($step['script'])) { - throw new ParseException("'step' requires a script"); - } - $this->parseNamedScript('script', $step); - } - - /** - * @param array $step - * - * @return void - */ - private function parseAfterScript(array $step) - { - if (isset($step['after-script'])) { - $this->parseNamedScript('after-script', $step); - } - } - - /** - * @param string $name - * @param $script - * - * @return void - */ - private function parseNamedScript($name, array $script) - { - if (!is_array($script[$name]) || !count($script[$name])) { - throw new ParseException("'${name}' requires a list of commands"); - } - - foreach ($script[$name] as $index => $line) { - $this->parseNamedScriptLine($name, $index, $line); - } - } - - /** - * @param string $name - * @param int $index - * @param null|array|bool|float|int|string $line - * - * @return void - */ - private function parseNamedScriptLine($name, $index, $line) - { - $standard = is_scalar($line) || null === $line; - $pipe = is_array($line) && isset($line['pipe']) && is_string($line['pipe']); - - if (!($standard || $pipe)) { - throw new ParseException(sprintf( - "'%s' requires a list of commands, step #%d is not a command", - $name, - $index - )); - } - } } diff --git a/src/File/Pipeline/StepCondition.php b/src/File/Pipeline/StepCondition.php new file mode 100644 index 00000000..0c7b54a3 --- /dev/null +++ b/src/File/Pipeline/StepCondition.php @@ -0,0 +1,85 @@ + + */ + private $includePaths; + + /** + * @param array $definition + */ + public function __construct(array $definition) + { + $this->parseCondition($definition); + } + + /** + * @return array + */ + public function getIncludePaths() + { + return $this->includePaths ?: array(); + } + + private function parseCondition(array $definition) + { + $this->array = array(); + + if (!isset($definition['changesets']) || !is_array($definition['changesets'])) { + throw new ParseException('Condition with no "changesets"'); + } + $changeSets = $definition['changesets']; + if (!isset($changeSets['includePaths']) || !is_array($changeSets['includePaths'])) { + throw new ParseException('Condition "changesets" with no "includePaths"'); + } + $includePaths = $changeSets['includePaths']; + if (1 > count($includePaths)) { + throw new ParseException('Condition "changesets" "includePaths" must not be empty'); + } + + $this->parseIncludePaths($includePaths); + } + + private function parseIncludePaths(array $includePaths) + { + $count = 0; + foreach ($includePaths as $index => $path) { + if ($index !== $count) { + throw new ParseException('Condition "changesets" "includePaths" must be a list'); + } + if (!is_string($path)) { + throw new ParseException( + sprintf( + 'Condition "changesets" "includePaths" must be string at index #%d, got %s (%s)', + $index, + gettype($path), + (string)$path + ) + ); + } + if ('' === $path) { + throw new ParseException( + sprintf( + 'Condition "changesets" "includePaths" empty path at index #%d', + $index + ) + ); + } + $this->includePaths[$count] = $path; + $count++; + } + } +} diff --git a/src/File/Pipeline/StepParser.php b/src/File/Pipeline/StepParser.php new file mode 100644 index 00000000..7da63686 --- /dev/null +++ b/src/File/Pipeline/StepParser.php @@ -0,0 +1,120 @@ +validateTrigger($step, (bool)$env); + + // quick validation: script + after-script + $this->parseScript($step); + $this->parseAfterScript($step); + } + + /** + * validate step trigger (none, manual, automatic) + * + * @param array $array + * @param bool $isParallelStep + * + * @return void + */ + private function validateTrigger(array $array, $isParallelStep) + { + if (!array_key_exists('trigger', $array)) { + return; + } + + $trigger = $array['trigger']; + if ($isParallelStep) { + throw new ParseException("Unexpected property 'trigger' in parallel step"); + } + + if (!in_array($trigger, array('manual', 'automatic'), true)) { + throw new ParseException("'trigger' expects either 'manual' or 'automatic'"); + } + } + + /** + * Parse a step script section + * + * @param array $step + * + * @throws ParseException + * + * @return void + */ + private function parseScript(array $step) + { + if (!isset($step['script'])) { + throw new ParseException("'step' requires a script"); + } + $this->parseNamedScript('script', $step); + } + + /** + * @param array $step + * + * @return void + */ + private function parseAfterScript(array $step) + { + if (isset($step['after-script'])) { + $this->parseNamedScript('after-script', $step); + } + } + + /** + * @param string $name + * @param $script + * + * @return void + */ + private function parseNamedScript($name, array $script) + { + if (!is_array($script[$name]) || !count($script[$name])) { + throw new ParseException("'${name}' requires a list of commands"); + } + + foreach ($script[$name] as $index => $line) { + $this->parseNamedScriptLine($name, $index, $line); + } + } + + /** + * @param string $name + * @param int $index + * @param null|array|bool|float|int|string $line + * + * @return void + */ + private function parseNamedScriptLine($name, $index, $line) + { + $standard = is_scalar($line) || null === $line; + $pipe = is_array($line) && isset($line['pipe']) && is_string($line['pipe']); + + if (!($standard || $pipe)) { + throw new ParseException(sprintf( + "'%s' requires a list of commands, step #%d is not a command", + $name, + $index + )); + } + } +} diff --git a/test/data/yml/condition-usage-example.yml b/test/data/yml/condition-usage-example.yml new file mode 100644 index 00000000..063ec081 --- /dev/null +++ b/test/data/yml/condition-usage-example.yml @@ -0,0 +1,27 @@ +# this file is part of pipelines + +image: ktomk/pipelines:busybox + +pipelines: + pull-requests: + '**': # runs as any branch by default + - step: &phpunit + script: + - phpunit + condition: + changesets: + includePaths: + - src/ + - composer.lock + + - step: + script: + - eslint + condition: + changesets: + includePaths: + - js/ + + branches: + master: + - step: *phpunit diff --git a/test/unit/File/Pipeline/StepConditionTest.php b/test/unit/File/Pipeline/StepConditionTest.php new file mode 100644 index 00000000..7c806a40 --- /dev/null +++ b/test/unit/File/Pipeline/StepConditionTest.php @@ -0,0 +1,73 @@ +getById('default'); + self::assertNotNull($pipeline); + $condition = $pipeline->getSteps()->offsetGet(0)->getCondition(); + self::assertInstanceOf(__NAMESPACE__ . '\StepCondition', $condition); + self::assertSame(array('path1/*.xml', 'path2/**'), $condition->getIncludePaths()); + } + + public function testUsageExample() + { + $file = File::createFromFile(__DIR__ . '/../../../data/yml/condition-usage-example.yml'); + self::assertNotNull($file); + $expected = 3; + $actual = 0; + foreach ($file->getPipelines()->getPipelines() as $id => $pipeline) { + foreach ($pipeline->getSteps() as $index => $step) { + $condition = $step->getCondition(); + self::assertNotNull($condition, "${id}: #${index}"); + self::assertNotEmpty($condition->getIncludePaths()); + $actual++; + } + } + self::assertSame($expected, $actual, 'count of condition(s)'); + } + + public function provideCreationErrors() + { + return array( + array(array()), + array(array('condition')), + array(array('changesets' => 1)), + array(array('changesets' => array())), + array(array('changesets' => array('includePaths' => null))), + array(array('changesets' => array('includePaths' => array()))), + array(array('changesets' => array('includePaths' => array(1)))), + array(array('changesets' => array('includePaths' => array('')))), + array(array('changesets' => array('includePaths' => array(2 => 1)))), + array(array('changesets' => array('includePaths' => array('path', 1)))), + ); + } + + /** + * @dataProvider provideCreationErrors() + * + * @return void + */ + public function testCreationErrors(array $definition) + { + $this->expectException('Ktomk\Pipelines\File\ParseException'); + new StepCondition($definition); + } +} diff --git a/test/unit/File/Pipeline/StepTest.php b/test/unit/File/Pipeline/StepTest.php index 65d6b1c9..4d97e237 100644 --- a/test/unit/File/Pipeline/StepTest.php +++ b/test/unit/File/Pipeline/StepTest.php @@ -11,6 +11,7 @@ /** * @covers \Ktomk\Pipelines\File\Pipeline\Step + * @covers \Ktomk\Pipelines\File\Pipeline\StepParser */ class StepTest extends TestCase { @@ -99,6 +100,12 @@ public function testInvalidImageName() )); } + public function testGetConditionFallback() + { + $step = $this->createStep(); + self::assertNull($step->getCondition()); + } + public function testGetImageFallback() { $step = $this->createStep(); diff --git a/test/unit/File/Pipeline/StepsTest.php b/test/unit/File/Pipeline/StepsTest.php index 70b6d426..f4d17a35 100644 --- a/test/unit/File/Pipeline/StepsTest.php +++ b/test/unit/File/Pipeline/StepsTest.php @@ -30,7 +30,7 @@ public function testCreation() } /** - * @covers \Ktomk\Pipelines\File\Pipeline\Step + * @covers \Ktomk\Pipelines\File\Pipeline\StepParser */ public function testParseErrors() { diff --git a/test/unit/Utility/Show/FileShowerTest.php b/test/unit/Utility/Show/FileShowerTest.php index f466aa01..5d8b77eb 100644 --- a/test/unit/Utility/Show/FileShowerTest.php +++ b/test/unit/Utility/Show/FileShowerTest.php @@ -118,6 +118,7 @@ public function testShowFileByMethod($path, $method, $expected, $parseExceptionM public function provideHappyFilesForShowFileMethod() { $stepsFile = __DIR__ . '/../../../data/yml/steps.yml'; + $conditionFile = __DIR__ . '/../../../data/yml/condition.yml'; return array( 'steps.manual-trigger-annotation' => array( @@ -138,6 +139,24 @@ public function provideHappyFilesForShowFileMethod() <<<'TEXT' PIPELINE ID IMAGES STEPS default ktomk/pipelines:busybox 4 ("step #1"; "step #2"; "step #3"; no-name *M) +TEXT + , + ), + 'condition.condition-annotation' => array( + $conditionFile, + 'showFile', + <<<'TEXT' +PIPELINE ID STEP IMAGE NAME +default 1 *C ktomk/pipelines:busybox no-name +TEXT + , + ), + 'condition.condition-after-name-annotation' => array( + $conditionFile, + 'showPipelines', + <<<'TEXT' +PIPELINE ID IMAGES STEPS +default ktomk/pipelines:busybox 1 (no-name *C) TEXT , ),