Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -496,28 +496,45 @@ private IEnumerable<GitHubActionsWorkflowDispatchInput> 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 _),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];

/// <summary>
/// Takes the choice options from the names of this enum, in declaration order, so the list is declared once.
/// Requires <see cref="Type"/> = <see cref="GitHubActionsInputType.Choice"/>. Cannot be combined with <see cref="Options"/>.
/// </summary>
public Type OptionsFrom { get; set; }
public string Description { get; set; }
public string[] Workflows { get; set; } = new string[0];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentException>().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<ArgumentException>().WithMessage("*both*");
}

[Fact]
public void Options_from_without_choice_type_throws()
{
var act = () => GetConfiguration(new GitHubActionsInputAttribute("Input") { OptionsFrom = typeof(Category) });

act.Should().Throw<ArgumentException>().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<ArgumentException>().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()
{
Expand Down Expand Up @@ -66,6 +119,8 @@ public void Blank_input_name_throws(string name)
act.Should().Throw<ArgumentException>();
}

private enum Category { All, ReadOnly, Write }

private static void GetConfiguration(params GitHubActionsInputAttribute[] inputs)
{
var build = new ConfigurationGenerationSpecs.TestBuild();
Expand Down
Loading