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
}
}
]