diff --git a/src/GitHubActionsVS.csproj b/src/GitHubActionsVS.csproj index c734762..af97c03 100644 --- a/src/GitHubActionsVS.csproj +++ b/src/GitHubActionsVS.csproj @@ -55,10 +55,14 @@ + + + + @@ -82,6 +86,9 @@ AddEditSecret.xaml + + WorkflowInputsDialog.xaml + True True @@ -136,6 +143,9 @@ Designer MSBuild:Compile + + MSBuild:Compile + @@ -186,6 +196,9 @@ 1.3.3 + + 16.3.0 + diff --git a/src/Helpers/StringHelpers.cs b/src/Helpers/StringHelpers.cs new file mode 100644 index 0000000..f3ca89f --- /dev/null +++ b/src/Helpers/StringHelpers.cs @@ -0,0 +1,27 @@ +namespace GitHubActionsVS.Helpers +{ + internal class StringHelpers + { + public static bool IsBase64(string input) + { + if (string.IsNullOrWhiteSpace(input) || input.Length % 4 != 0) + return false; + + foreach (char c in input) + { + if (!(char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '=')) + return false; + } + + try + { + _ = Convert.FromBase64String(input); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/src/Helpers/YamlHelpers.cs b/src/Helpers/YamlHelpers.cs new file mode 100644 index 0000000..0a7f064 --- /dev/null +++ b/src/Helpers/YamlHelpers.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using YamlDotNet.RepresentationModel; + +namespace GitHubActionsVS.Helpers +{ + internal static class YamlHelpers + { + /// + /// Follows YamlDotNet alias nodes (&anchor / *alias) to the real node. + /// Returns the resolved node if an alias chain exists. + /// + public static YamlNode Unalias(YamlNode node) + { + // Use reflection to check for YamlAliasNode and access RealNode + var aliasType = node.GetType().FullName == "YamlDotNet.RepresentationModel.YamlAliasNode" + ? node.GetType() + : null; + + while (aliasType != null) + { + var realNodeProp = aliasType.GetProperty("RealNode", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (realNodeProp?.GetValue(node) is not YamlNode realNode) + break; + node = realNode; + aliasType = node.GetType().FullName == "YamlDotNet.RepresentationModel.YamlAliasNode" + ? node.GetType() + : null; + } + + return node; + } + + /// + /// Attempts to retrieve a child value from a mapping node by key name. + /// Uses string comparison on scalar node values rather than allocating new nodes. + /// + /// The mapping node to search. + /// The scalar key string to match. + /// The value node if found. + /// True if a matching key was found, otherwise false. + public static bool TryGetScalarKey(YamlMappingNode map, string key, out YamlNode value) + { + foreach (var kv in map.Children) + { + if (kv.Key is YamlScalarNode sk && + string.Equals(sk.Value, key, StringComparison.Ordinal)) + { + value = kv.Value; + return true; + } + } + + value = null!; + return false; + } + + } +} diff --git a/src/Models/InputMetadata.cs b/src/Models/InputMetadata.cs new file mode 100644 index 0000000..0c143b0 --- /dev/null +++ b/src/Models/InputMetadata.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace GitHubActionsVS.Models +{ + public sealed class InputMetadata + { + public string Name { get; } + public string Type { get; } + public string Description { get; } + public bool Required { get; } + public string Default { get; } + public string[] Options { get; } + + public InputMetadata(string name, string type, string description, bool required, string @default, string[] options) + { + Name = name; + Type = type; + Description = description; + Required = required; + Default = @default; + Options = options; + } + + public static List ToInputMeta(IReadOnlyDictionary> inputs) + { + var list = new List(); + + try + { + foreach (var kv in inputs) + { + var name = kv.Key; + var dict = kv.Value; + + dict.TryGetValue("type", out var typeObj); + dict.TryGetValue("description", out var descObj); + dict.TryGetValue("required", out var reqObj); + dict.TryGetValue("default", out var defObj); + dict.TryGetValue("options", out var optsObj); + + var type = (typeObj?.ToString()?.Trim().ToLowerInvariant()) switch + { + "boolean" => "boolean", + "choice" => "choice", + "environment" => "environment", + _ => "string" + }; + + string[] options = null; + + if (optsObj is IEnumerable seq) + { + options = [.. seq.Select(o => o?.ToString() ?? string.Empty)]; + } + else if (optsObj is string s) + { + options = [s]; + } + + var required = reqObj is bool b ? b : bool.TryParse(reqObj?.ToString(), out var br) && br; + + list.Add(new InputMetadata( + name, + type, + descObj?.ToString(), + required, + defObj?.ToString(), + options + )); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to create InputMetadata. Ex: {ex.Message}"); + } + + return list; + } + } +} diff --git a/src/Models/Workflow.cs b/src/Models/Workflow.cs new file mode 100644 index 0000000..40c5f2c --- /dev/null +++ b/src/Models/Workflow.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace GitHubActionsVS.Models +{ + sealed class WorkflowRoot + { + [YamlMember(Alias = "on")] + public OnSection On { get; init; } + } + + sealed class OnSection + { + [YamlMember(Alias = "workflow_dispatch")] + public WorkflowDispatch WorkflowDispatch { get; init; } + } + + sealed class WorkflowDispatch + { + public Dictionary> Inputs { get; init; } + } +} diff --git a/src/ToolWindows/GHActionsToolWindow.xaml b/src/ToolWindows/GHActionsToolWindow.xaml index 5208384..ce1faa5 100644 --- a/src/ToolWindows/GHActionsToolWindow.xaml +++ b/src/ToolWindows/GHActionsToolWindow.xaml @@ -132,7 +132,7 @@ - @@ -147,7 +147,7 @@ - diff --git a/src/ToolWindows/GHActionsToolWindow.xaml.cs b/src/ToolWindows/GHActionsToolWindow.xaml.cs index 9a58404..c76dbea 100644 --- a/src/ToolWindows/GHActionsToolWindow.xaml.cs +++ b/src/ToolWindows/GHActionsToolWindow.xaml.cs @@ -1,19 +1,22 @@ using GitHubActionsVS.Helpers; using GitHubActionsVS.Models; using GitHubActionsVS.ToolWindows; +using GitHubActionsVS.UserControls; +using Humanizer; using Octokit; using System.Collections.Generic; using System.Diagnostics; -using System.Threading.Tasks; +using System.IO; +using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using GitHubActionsVS.UserControls; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; using Application = System.Windows.Application; -using System.Windows.Media; -using MessageBox = Community.VisualStudio.Toolkit.MessageBox; using resx = GitHubActionsVS.Resources.UIStrings; -using Humanizer; namespace GitHubActionsVS; @@ -59,7 +62,7 @@ private void OnMessageReceived(object sender, MessagePayload payload) private Task ReportFeedbackAsync(string text) { Process.Start($"https://github.com/timheuer/GitHubActionsVS/issues/new?assignees=timheuer&labels=bug&projects=&template=bug_report.yaml&title=%5BBUG%5D%3A+&vsversion={text}"); - + return Task.CompletedTask; } @@ -212,7 +215,7 @@ private async Task LoadDataAsync() if (refreshPending) { - var timer = new System.Timers.Timer(refreshInterval*1000); + var timer = new System.Timers.Timer(refreshInterval * 1000); timer.Elapsed += async (sender, e) => { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); @@ -296,7 +299,7 @@ private async Task RefreshEnvironmentsAsync(GitHubClient client) try { var repoEnvs = await client.Repository?.Environment?.GetAll(_repoInfo.RepoOwner, _repoInfo.RepoName); - + if (repoEnvs.TotalCount > 0) { tvEnvironments.Header = $"{resx.HEADER_ENVIRONMENTS} ({repoEnvs.TotalCount})"; @@ -352,6 +355,7 @@ private async Task RefreshWorkflowsAsync(GitHubClient client) } } + private async Task RefreshSecretsAsync(GitHubClient client) { List secretList = new(); @@ -539,23 +543,81 @@ private void ViewLog_Click(object sender, RoutedEventArgs e) } private void RunWorkflow_Click(object sender, RoutedEventArgs e) + { + _ = RunWorkflowInternalAsync(sender, e); + } + + private async Task RunWorkflowInternalAsync(object sender, RoutedEventArgs e) { MenuItem menuItem = (MenuItem)sender; TextBlock tvi = GetParentTreeViewItem(menuItem); - // check the tag value to ensure it isn't null if (tvi is not null && tvi.Tag is not null) { GitHubClient client = GetGitHubClient(); - CreateWorkflowDispatch cwd = new CreateWorkflowDispatch(_repoInfo.CurrentBranch); + long workflowId = (long)tvi.Tag; try { - _ = client.Actions.Workflows.CreateDispatch(_repoInfo.RepoOwner, _repoInfo.RepoName, (long)tvi.Tag, cwd); + await _pane.WriteLineAsync($"[{DateTime.UtcNow:o}] Fetching workflow details..."); + var workflow = await client.Actions.Workflows.Get(_repoInfo.RepoOwner, _repoInfo.RepoName, workflowId); + + var contents = await client.Repository.Content.GetAllContents(_repoInfo.RepoOwner, _repoInfo.RepoName, workflow.Path); + var workflowContent = contents.FirstOrDefault()?.Content; + + if (!string.IsNullOrEmpty(workflowContent) && StringHelpers.IsBase64(workflowContent)) + workflowContent = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(workflowContent)); + + if (string.IsNullOrEmpty(workflowContent)) + { + await _pane.WriteLineAsync($"[{DateTime.UtcNow:o}] Could not retrieve workflow file content"); + var cwdNoContent = new CreateWorkflowDispatch(_repoInfo.CurrentBranch); + await client.Actions.Workflows.CreateDispatch(_repoInfo.RepoOwner, _repoInfo.RepoName, workflowId, cwdNoContent); + VS.StatusBar.ShowMessageAsync("Workflow run requested...").FireAndForget(); + return; + } + + var workflowInputs = ParseWorkflowInputs(workflowContent); + + if (workflowInputs.Count == 0) + { + var cwdNoInputs = new CreateWorkflowDispatch(_repoInfo.CurrentBranch); + await _pane.WriteLineAsync($"[{DateTime.UtcNow:o}] No inputs found; dispatching..."); + await client.Actions.Workflows.CreateDispatch(_repoInfo.RepoOwner, _repoInfo.RepoName, workflowId, cwdNoInputs); + VS.StatusBar.ShowMessageAsync("Workflow run requested...").FireAndForget(); + return; + } + + var metas = InputMetadata.ToInputMeta(workflowInputs); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var inputsDialog = new WorkflowInputsDialog(workflow.Name, metas) + { + Owner = Application.Current.MainWindow + }; + + bool? result = inputsDialog.ShowDialog(); + if (result != true) + { + await _pane.WriteLineAsync($"[{DateTime.UtcNow:o}] Workflow dispatch canceled by user"); + return; + } + + var values = inputsDialog.GetInputValues(); + + var cwd = new CreateWorkflowDispatch(_repoInfo.CurrentBranch) + { + Inputs = values.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value, StringComparer.Ordinal) + }; + + await _pane.WriteLineAsync($"[{DateTime.UtcNow:o}] Dispatching workflow..."); + await client.Actions.Workflows.CreateDispatch(_repoInfo.RepoOwner, _repoInfo.RepoName, workflowId, cwd); VS.StatusBar.ShowMessageAsync("Workflow run requested...").FireAndForget(); } catch (Exception ex) { + await _pane.WriteLineAsync($"[{DateTime.UtcNow:o}] Error running workflow: {ex.Message}"); Debug.WriteLine($"Failed to start workflow: {ex.Message}"); } } @@ -593,5 +655,135 @@ private void CancelRun_Click(object sender, RoutedEventArgs e) } } } -} + public static IReadOnlyDictionary> ParseWorkflowInputs(string workflowContent) + { + if (string.IsNullOrWhiteSpace(workflowContent)) + { + Debug.WriteLine("ParseWorkflowInputs: empty workflow content."); + return new Dictionary>(StringComparer.Ordinal); + } + + try + { + var deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + + var root = deserializer.Deserialize(workflowContent); + + var inputs = root?.On?.WorkflowDispatch?.Inputs; + if (inputs is { Count: > 0 }) + { + var result = inputs.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyDictionary)(kvp.Value ?? []), + StringComparer.Ordinal + ); + + Debug.WriteLine($"ParseWorkflowInputs: found {result.Count} input(s) via POCO."); + return result; + } + + var fallback = TryExtractInputsWithNodes(workflowContent); + if (fallback.Count > 0) + { + Debug.WriteLine($"ParseWorkflowInputs: found {fallback.Count} input(s) via node-walk."); + return fallback; + } + + Debug.WriteLine("ParseWorkflowInputs: no workflow_dispatch.inputs found."); + } + catch (YamlException yex) + { + Debug.WriteLine($"YAML parse error: {yex.Message}"); + } + catch (Exception ex) + { + Debug.WriteLine($"Unexpected error parsing workflow YAML: {ex.Message}"); + } + + return new Dictionary>(StringComparer.Ordinal); + } + + private static IReadOnlyDictionary> TryExtractInputsWithNodes(string yamlText) + { + var outDict = new Dictionary>(StringComparer.Ordinal); + + using var reader = new StringReader(yamlText); + var ys = new YamlStream(); + ys.Load(reader); + + if (ys.Documents.Count == 0 || ys.Documents[0].RootNode is not YamlMappingNode root) return outDict; + + if (!root.Children.TryGetValue(new YamlScalarNode("on"), out var onNode)) + return outDict; + + static void ReadInputsFromMapping(YamlMappingNode mapping, Dictionary> target) + { + if (!mapping.Children.TryGetValue(new YamlScalarNode("inputs"), out var inputsNode) || + inputsNode is not YamlMappingNode inputsMap) + return; + + foreach (var kv in inputsMap.Children) + { + var name = kv.Key.ToString(); + var details = new Dictionary(StringComparer.Ordinal); + + if (kv.Value is YamlMappingNode dets) + { + foreach (var d in dets.Children) + { + var key = d.Key.ToString(); + object? val = d.Value switch + { + YamlScalarNode s => s.Value, + YamlSequenceNode seq => seq.Children.Select(c => (c as YamlScalarNode)?.Value).ToArray(), + YamlMappingNode m => m.Children.ToDictionary( + p => p.Key.ToString(), + p => (object?)(p.Value as YamlScalarNode)?.Value, + StringComparer.Ordinal), + _ => d.Value?.ToString() + }; + details[key] = val; + } + } + + target[name] = details; + } + } + + onNode = YamlHelpers.Unalias(onNode); + switch (onNode) + { + case YamlScalarNode s when string.Equals(s.Value, "workflow_dispatch", StringComparison.Ordinal): + break; + + case YamlSequenceNode seq: + foreach (var child in seq.Children) + { + if (child is YamlScalarNode sc && + string.Equals(sc.Value, "workflow_dispatch", StringComparison.Ordinal)) + continue; + + if (child is YamlMappingNode mm && + YamlHelpers.TryGetScalarKey(mm, "workflow_dispatch", out var wfNode)) + { + wfNode = YamlHelpers.Unalias(wfNode); + if (wfNode is YamlMappingNode wfMap) + ReadInputsFromMapping(wfMap, outDict); + } + } + break; + + case YamlMappingNode map when YamlHelpers.TryGetScalarKey(map, "workflow_dispatch", out var wfdNode): + wfdNode = YamlHelpers.Unalias(wfdNode); + if (wfdNode is YamlMappingNode wfdMap2) + ReadInputsFromMapping(wfdMap2, outDict); + break; + } + + return outDict; + } +} diff --git a/src/UserControls/WorkflowInputsDialog.xaml b/src/UserControls/WorkflowInputsDialog.xaml new file mode 100644 index 0000000..7bb8ec7 --- /dev/null +++ b/src/UserControls/WorkflowInputsDialog.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +