diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index e3c355c34..fe3a14e97 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -496,28 +496,45 @@ private IEnumerable GetWorkflowDispatchInput Type = input.Type, Required = input.Required, Default = input.Default, - Options = input.Options, + Options = ResolveOptions(input), Description = input.Description }; } + // The one place options are resolved: the explicit list, or the names of the OptionsFrom enum in declaration order. + private static string[] ResolveOptions(GitHubActionsInputAttribute input) + { + if (input.OptionsFrom == null) + return input.Options; + + Assert.True(input.Options.Length == 0, + $"'{input.Name}' sets both '{nameof(GitHubActionsInputAttribute.Options)}' and '{nameof(GitHubActionsInputAttribute.OptionsFrom)}'; use one"); + Assert.True(input.OptionsFrom.IsEnum, + $"'{input.Name}' '{nameof(GitHubActionsInputAttribute.OptionsFrom)}' must be an enum, but '{input.OptionsFrom.Name}' is not"); + Assert.True(input.Type == GitHubActionsInputType.Choice, + $"'{input.Name}' sets '{nameof(GitHubActionsInputAttribute.OptionsFrom)}' but its type is not '{nameof(GitHubActionsInputType.Choice)}'"); + // Enum.GetNames sorts by value; field order follows the source declaration order. + return input.OptionsFrom.GetFields(BindingFlags.Public | BindingFlags.Static).Select(x => x.Name).ToArray(); + } + private void ValidateWorkflowDispatchInputs() { var declaredWorkflows = DeclaredWorkflowNames.Select(NormalizeWorkflowName).ToHashSet(); foreach (var input in DeclaredInputs) { + var options = ResolveOptions(input); if (input.Type == GitHubActionsInputType.Choice) - Assert.True(input.Options.Length > 0, - $"'{input.Name}' is a choice input and requires non-empty '{nameof(GitHubActionsInputAttribute.Options)}'"); + Assert.True(options.Length > 0, + $"'{input.Name}' is a choice input and requires non-empty '{nameof(GitHubActionsInputAttribute.Options)}' or '{nameof(GitHubActionsInputAttribute.OptionsFrom)}'"); else - Assert.True(input.Options.Length == 0, + Assert.True(options.Length == 0, $"'{input.Name}' sets '{nameof(GitHubActionsInputAttribute.Options)}' but its type is not '{nameof(GitHubActionsInputType.Choice)}'"); if (input.Default != null) { if (input.Type == GitHubActionsInputType.Choice) - Assert.True(input.Options.Contains(input.Default), + Assert.True(options.Contains(input.Default), $"'{input.Name}' default '{input.Default}' is not one of its options"); if (input.Type == GitHubActionsInputType.Number) Assert.True(double.TryParse(input.Default, NumberStyles.Any, CultureInfo.InvariantCulture, out _), diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs index e22b46449..75f1a9b00 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs @@ -29,6 +29,12 @@ public GitHubActionsInputAttribute(string name) public bool Required { get; set; } public string Default { get; set; } public string[] Options { get; set; } = new string[0]; + + /// + /// Takes the choice options from the names of this enum, in declaration order, so the list is declared once. + /// Requires = . Cannot be combined with . + /// + public Type OptionsFrom { get; set; } public string Description { get; set; } public string[] Workflows { get; set; } = new string[0]; } diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs index afbfb06d6..4642dc05b 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs @@ -52,6 +52,34 @@ public void Legacy_arrays_and_typed_string_inputs_emit_identical_yaml() typed.Should().Be(legacy); } + // Options taken from an enum emit the same YAML as the same names typed out, in declaration order. + [Fact] + public void Options_from_an_enum_emit_the_same_yaml_as_explicit_options() + { + string RenderChoice(GitHubActionsInputAttribute input) => Render(new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + Inputs = new[] { input } + }); + + var explicitOptions = RenderChoice(new GitHubActionsInputAttribute("Category") + { + Type = GitHubActionsInputType.Choice, + Options = new[] { "Write", "All", "ReadOnly" }, + Default = "ReadOnly" + }); + var fromEnum = RenderChoice(new GitHubActionsInputAttribute("Category") + { + Type = GitHubActionsInputType.Choice, + OptionsFrom = typeof(Category), + Default = "ReadOnly" + }); + + fromEnum.Should().Be(explicitOptions); + } + + private enum Category { Write = 2, All = 0, ReadOnly = 1 } + // Regression guard: a workflow name with spaces is normalized to underscores; an input scoped to the // same spelled name must still resolve (not silently drop, not throw "unknown workflow"). [Fact] diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs index d53a522bc..00ee27953 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs @@ -37,6 +37,59 @@ public void Well_formed_input_does_not_throw(GitHubActionsInputType type, string act.Should().NotThrow(); } + [Fact] + public void Options_from_a_non_enum_type_throws() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") { Type = GitHubActionsInputType.Choice, OptionsFrom = typeof(string) }); + + act.Should().Throw().WithMessage("*must be an enum*"); + } + + [Fact] + public void Options_and_options_from_together_throw() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") + { + Type = GitHubActionsInputType.Choice, Options = new[] { "A" }, OptionsFrom = typeof(Category) + }); + + act.Should().Throw().WithMessage("*both*"); + } + + [Fact] + public void Options_from_without_choice_type_throws() + { + var act = () => GetConfiguration(new GitHubActionsInputAttribute("Input") { OptionsFrom = typeof(Category) }); + + act.Should().Throw().WithMessage("*not 'Choice'*"); + } + + [Fact] + public void Default_outside_the_options_from_enum_throws() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") + { + Type = GitHubActionsInputType.Choice, OptionsFrom = typeof(Category), Default = "Missing" + }); + + act.Should().Throw().WithMessage("*not one of its options*"); + } + + [Fact] + public void Default_inside_the_options_from_enum_does_not_throw() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") + { + Type = GitHubActionsInputType.Choice, OptionsFrom = typeof(Category), Default = nameof(Category.ReadOnly) + }); + + act.Should().NotThrow(); + } + [Fact] public void Unknown_workflow_name_throws() { @@ -66,6 +119,8 @@ public void Blank_input_name_throws(string name) act.Should().Throw(); } + private enum Category { All, ReadOnly, Write } + private static void GetConfiguration(params GitHubActionsInputAttribute[] inputs) { var build = new ConfigurationGenerationSpecs.TestBuild();