using System; using System.Collections.Generic; using System.Text.RegularExpressions; using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.DistributedTask.Pipelines; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap.StepCommands { /// /// Interface for creating ActionStep and IActionRunner objects at runtime. /// Used by step commands to dynamically add steps during debug sessions. /// [ServiceLocator(Default = typeof(StepFactory))] public interface IStepFactory : IRunnerService { /// /// Creates a new run step (script step). /// /// The script to execute /// Optional step ID for referencing in expressions (e.g., steps.<id>.outputs) /// Optional display name for the step /// Optional shell (bash, sh, pwsh, python, etc.) /// Optional working directory /// Optional environment variables /// Optional condition expression (defaults to "success()") /// Whether to continue on error (defaults to false) /// Optional timeout in minutes /// A configured ActionStep with ScriptReference ActionStep CreateRunStep( string script, string id = null, string name = null, string shell = null, string workingDirectory = null, Dictionary env = null, string condition = null, bool continueOnError = false, int? timeoutMinutes = null); /// /// Creates a new uses step (action step). /// /// The action reference (e.g., "actions/checkout@v4", "owner/repo@ref", "./local-action") /// Optional step ID for referencing in expressions (e.g., steps.<id>.outputs) /// Optional display name for the step /// Optional input parameters for the action /// Optional environment variables /// Optional condition expression (defaults to "success()") /// Whether to continue on error (defaults to false) /// Optional timeout in minutes /// A configured ActionStep with RepositoryPathReference or ContainerRegistryReference ActionStep CreateUsesStep( string actionReference, string id = null, string name = null, Dictionary with = null, Dictionary env = null, string condition = null, bool continueOnError = false, int? timeoutMinutes = null); /// /// Wraps an ActionStep in an IActionRunner for execution. /// /// The ActionStep to wrap /// The job execution context /// The execution stage (Main, Pre, or Post) /// An IActionRunner ready for execution IActionRunner WrapInRunner( ActionStep step, IExecutionContext jobContext, ActionRunStage stage = ActionRunStage.Main); } /// /// Parsed components of an action reference string. /// public class ParsedActionReference { /// /// The type of action reference. /// public ActionReferenceType Type { get; set; } /// /// For GitHub actions: "owner/repo". For local: null. For docker: null. /// public string Name { get; set; } /// /// For GitHub actions: the git ref (tag/branch/commit). For local/docker: null. /// public string Ref { get; set; } /// /// For actions in subdirectories: the path within the repo. For local: the full path. /// public string Path { get; set; } /// /// For docker actions: the image reference. /// public string Image { get; set; } } /// /// Types of action references. /// public enum ActionReferenceType { /// GitHub repository action (e.g., "actions/checkout@v4") Repository, /// Local action (e.g., "./.github/actions/my-action") Local, /// Docker container action (e.g., "docker://alpine:latest") Docker } /// /// Factory for creating ActionStep and IActionRunner objects at runtime. /// public sealed class StepFactory : RunnerService, IStepFactory { // Constants for script step inputs (matching PipelineConstants.ScriptStepInputs) private const string ScriptInputKey = "script"; private const string ShellInputKey = "shell"; private const string WorkingDirectoryInputKey = "workingDirectory"; // Regex for parsing action references // Matches: owner/repo@ref, owner/repo/path@ref, owner/repo@ref/path (unusual but valid) private static readonly Regex ActionRefRegex = new Regex( @"^(?[^/@]+/[^/@]+)(?:/(?[^@]+))?@(?.+)$", RegexOptions.Compiled); /// public ActionStep CreateRunStep( string script, string id = null, string name = null, string shell = null, string workingDirectory = null, Dictionary env = null, string condition = null, bool continueOnError = false, int? timeoutMinutes = null) { if (string.IsNullOrEmpty(script)) { throw new ArgumentException("Script cannot be null or empty", nameof(script)); } var stepId = Guid.NewGuid(); var step = new ActionStep { Id = stepId, Name = id ?? $"_dynamic_{stepId:N}", DisplayName = name ?? "Run script", Reference = new ScriptReference(), Condition = condition ?? "success()", Enabled = true }; // Build Inputs mapping with script, shell, working-directory step.Inputs = CreateRunInputs(script, shell, workingDirectory); // Build Environment mapping if (env?.Count > 0) { step.Environment = CreateEnvToken(env); } // Set continue-on-error if (continueOnError) { step.ContinueOnError = new BooleanToken(null, null, null, true); } // Set timeout if (timeoutMinutes.HasValue) { step.TimeoutInMinutes = new NumberToken(null, null, null, timeoutMinutes.Value); } return step; } /// public ActionStep CreateUsesStep( string actionReference, string id = null, string name = null, Dictionary with = null, Dictionary env = null, string condition = null, bool continueOnError = false, int? timeoutMinutes = null) { if (string.IsNullOrEmpty(actionReference)) { throw new ArgumentException("Action reference cannot be null or empty", nameof(actionReference)); } var parsed = ParseActionReference(actionReference); var stepId = Guid.NewGuid(); var step = new ActionStep { Id = stepId, Name = id ?? $"_dynamic_{stepId:N}", DisplayName = name ?? actionReference, Condition = condition ?? "success()", Enabled = true }; // Set reference based on action type switch (parsed.Type) { case ActionReferenceType.Repository: step.Reference = new RepositoryPathReference { Name = parsed.Name, Ref = parsed.Ref, Path = parsed.Path, RepositoryType = "GitHub" }; break; case ActionReferenceType.Local: step.Reference = new RepositoryPathReference { RepositoryType = PipelineConstants.SelfAlias, Path = parsed.Path }; break; case ActionReferenceType.Docker: step.Reference = new ContainerRegistryReference { Image = parsed.Image }; break; } // Build with inputs if (with?.Count > 0) { step.Inputs = CreateWithInputs(with); } // Build Environment mapping if (env?.Count > 0) { step.Environment = CreateEnvToken(env); } // Set continue-on-error if (continueOnError) { step.ContinueOnError = new BooleanToken(null, null, null, true); } // Set timeout if (timeoutMinutes.HasValue) { step.TimeoutInMinutes = new NumberToken(null, null, null, timeoutMinutes.Value); } return step; } /// public IActionRunner WrapInRunner( ActionStep step, IExecutionContext jobContext, ActionRunStage stage = ActionRunStage.Main) { if (step == null) { throw new ArgumentNullException(nameof(step)); } if (jobContext == null) { throw new ArgumentNullException(nameof(jobContext)); } var runner = HostContext.CreateService(); runner.Action = step; runner.Stage = stage; runner.Condition = step.Condition; // Create a child execution context for this step // The child context gets its own scope for outputs, logging, etc. // Following the pattern from JobExtension.cs line ~401 runner.ExecutionContext = jobContext.CreateChild( recordId: step.Id, displayName: step.DisplayName, refName: step.Name, scopeName: null, contextName: step.ContextName, stage: stage ); return runner; } #region Helper Methods /// /// Parses an action reference string into its components. /// /// The action reference (e.g., "actions/checkout@v4") /// Parsed action reference components public static ParsedActionReference ParseActionReference(string actionReference) { if (string.IsNullOrWhiteSpace(actionReference)) { throw new ArgumentException("Action reference cannot be null or empty", nameof(actionReference)); } var trimmed = actionReference.Trim(); // Check for docker action: docker://image:tag if (trimmed.StartsWith("docker://", StringComparison.OrdinalIgnoreCase)) { return new ParsedActionReference { Type = ActionReferenceType.Docker, Image = trimmed.Substring("docker://".Length) }; } // Check for local action: ./ or ../ prefix if (trimmed.StartsWith("./") || trimmed.StartsWith("../")) { return new ParsedActionReference { Type = ActionReferenceType.Local, Path = trimmed }; } // Parse as GitHub repository action: owner/repo@ref or owner/repo/path@ref var match = ActionRefRegex.Match(trimmed); if (match.Success) { var result = new ParsedActionReference { Type = ActionReferenceType.Repository, Name = match.Groups["name"].Value, Ref = match.Groups["ref"].Value }; if (match.Groups["path"].Success && !string.IsNullOrEmpty(match.Groups["path"].Value)) { result.Path = match.Groups["path"].Value; } return result; } // If no @ sign, assume it's a local action path if (!trimmed.Contains("@")) { return new ParsedActionReference { Type = ActionReferenceType.Local, Path = trimmed }; } // Invalid format throw new StepCommandException( StepCommandErrors.ParseError, $"Invalid action reference format: '{actionReference}'. Expected: 'owner/repo@ref', './local-path', or 'docker://image:tag'"); } /// /// Creates a MappingToken for run step inputs (script, shell, working-directory). /// private MappingToken CreateRunInputs(string script, string shell, string workingDirectory) { var inputs = new MappingToken(null, null, null); // Script is always required inputs.Add( new StringToken(null, null, null, ScriptInputKey), new StringToken(null, null, null, script) ); // Shell is optional if (!string.IsNullOrEmpty(shell)) { inputs.Add( new StringToken(null, null, null, ShellInputKey), new StringToken(null, null, null, shell) ); } // Working directory is optional if (!string.IsNullOrEmpty(workingDirectory)) { inputs.Add( new StringToken(null, null, null, WorkingDirectoryInputKey), new StringToken(null, null, null, workingDirectory) ); } return inputs; } /// /// Creates a MappingToken for action "with" inputs. /// private MappingToken CreateWithInputs(Dictionary with) { var inputs = new MappingToken(null, null, null); foreach (var kvp in with) { inputs.Add( new StringToken(null, null, null, kvp.Key), new StringToken(null, null, null, kvp.Value ?? "") ); } return inputs; } /// /// Creates a MappingToken for environment variables. /// private MappingToken CreateEnvToken(Dictionary env) { var envMapping = new MappingToken(null, null, null); foreach (var kvp in env) { envMapping.Add( new StringToken(null, null, null, kvp.Key), new StringToken(null, null, null, kvp.Value ?? "") ); } return envMapping; } #endregion } }