mirror of
https://github.com/actions/runner.git
synced 2026-01-23 13:01:14 +08:00
editing jobs
This commit is contained in:
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
@@ -282,9 +283,21 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||
private int _nextCompletedFrameId = CompletedFrameIdBase;
|
||||
|
||||
// Track completed IStep objects for the step manipulator
|
||||
private readonly List<IStep> _completedStepsTracker = new List<IStep>();
|
||||
|
||||
// Variable provider for converting contexts to DAP variables
|
||||
private DapVariableProvider _variableProvider;
|
||||
|
||||
// Step command parser for !step REPL commands
|
||||
private IStepCommandParser _stepCommandParser;
|
||||
|
||||
// Step command handler for executing step commands
|
||||
private IStepCommandHandler _stepCommandHandler;
|
||||
|
||||
// Step manipulator for queue operations
|
||||
private IStepManipulator _stepManipulator;
|
||||
|
||||
// Checkpoint storage for step-back (time-travel) debugging
|
||||
private readonly List<StepCheckpoint> _checkpoints = new List<StepCheckpoint>();
|
||||
private const int MaxCheckpoints = 50;
|
||||
@@ -319,6 +332,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_variableProvider = new DapVariableProvider(hostContext);
|
||||
_stepCommandParser = hostContext.GetService<IStepCommandParser>();
|
||||
_stepCommandHandler = hostContext.GetService<IStepCommandHandler>();
|
||||
_stepManipulator = hostContext.GetService<IStepManipulator>();
|
||||
Trace.Info("DapDebugSession initialized");
|
||||
}
|
||||
|
||||
@@ -661,6 +677,12 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return HandleDebugCommand(expression);
|
||||
}
|
||||
|
||||
// Check for !step command (step manipulation commands)
|
||||
if (_stepCommandParser.IsStepCommand(expression))
|
||||
{
|
||||
return await HandleStepCommandAsync(expression);
|
||||
}
|
||||
|
||||
// Get the current execution context
|
||||
var executionContext = _currentStep?.ExecutionContext ?? _jobContext;
|
||||
if (executionContext == null)
|
||||
@@ -1096,6 +1118,81 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles step manipulation commands (!step ...).
|
||||
/// Parses and executes commands using the StepCommandHandler.
|
||||
/// </summary>
|
||||
private async Task<Response> HandleStepCommandAsync(string expression)
|
||||
{
|
||||
Trace.Info($"Handling step command: {expression}");
|
||||
|
||||
try
|
||||
{
|
||||
var command = _stepCommandParser.Parse(expression);
|
||||
|
||||
// Ensure manipulator is initialized with current context
|
||||
EnsureManipulatorInitialized();
|
||||
|
||||
// Execute the command
|
||||
var result = await _stepCommandHandler.HandleAsync(command, _jobContext);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Return appropriate response format based on input type
|
||||
if (command.WasJsonInput)
|
||||
{
|
||||
return CreateSuccessResponse(new EvaluateResponseBody
|
||||
{
|
||||
Result = Newtonsoft.Json.JsonConvert.SerializeObject(result),
|
||||
Type = "json",
|
||||
VariablesReference = 0
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return CreateSuccessResponse(new EvaluateResponseBody
|
||||
{
|
||||
Result = result.Message,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return CreateErrorResponse($"[{result.Error}] {result.Message}");
|
||||
}
|
||||
}
|
||||
catch (StepCommandException ex)
|
||||
{
|
||||
Trace.Warning($"Step command error: {ex.ErrorCode} - {ex.Message}");
|
||||
|
||||
return CreateErrorResponse($"[{ex.ErrorCode}] {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Step command failed: {ex}");
|
||||
|
||||
return CreateErrorResponse($"Step command failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the step manipulator is initialized with the current job context.
|
||||
/// </summary>
|
||||
private void EnsureManipulatorInitialized()
|
||||
{
|
||||
if (_jobContext == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"No job context available. Wait for the first step to start.");
|
||||
}
|
||||
|
||||
// The manipulator should already be initialized from OnStepStartingAsync
|
||||
// but just ensure the handler has the reference
|
||||
_stepCommandHandler.SetManipulator(_stepManipulator);
|
||||
}
|
||||
|
||||
private Response HandleSetBreakpoints(Request request)
|
||||
{
|
||||
// Stub - breakpoints not implemented in demo
|
||||
@@ -1187,6 +1284,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_jobContext = jobContext;
|
||||
_jobCancellationToken = cancellationToken; // Store for REPL commands
|
||||
|
||||
// Initialize or update the step manipulator
|
||||
InitializeStepManipulator(step, isFirstStep);
|
||||
|
||||
// Hook up StepsContext debug logging (do this once when we first get jobContext)
|
||||
if (jobContext.Global.StepsContext.OnDebugLog == null)
|
||||
{
|
||||
@@ -1233,6 +1333,28 @@ namespace GitHub.Runner.Worker.Dap
|
||||
await WaitForCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes or updates the step manipulator with the current state.
|
||||
/// </summary>
|
||||
private void InitializeStepManipulator(IStep currentStep, bool isFirstStep)
|
||||
{
|
||||
if (isFirstStep)
|
||||
{
|
||||
// First step - initialize fresh
|
||||
_stepManipulator.Initialize(_jobContext, 0);
|
||||
(_stepManipulator as StepManipulator)?.SetCurrentStep(currentStep);
|
||||
_stepCommandHandler.SetManipulator(_stepManipulator);
|
||||
_stepManipulator.RecordOriginalState();
|
||||
Trace.Info("Step manipulator initialized for debug session");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update current step reference
|
||||
(_stepManipulator as StepManipulator)?.SetCurrentStep(currentStep);
|
||||
_stepManipulator.UpdateCurrentIndex(_completedStepsTracker.Count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnStepCompleted(IStep step)
|
||||
{
|
||||
if (!IsActive)
|
||||
@@ -1269,6 +1391,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
FrameId = _nextCompletedFrameId++
|
||||
});
|
||||
|
||||
// Track IStep for the step manipulator
|
||||
_completedStepsTracker.Add(step);
|
||||
_stepManipulator?.AddCompletedStep(step);
|
||||
|
||||
// Clear current step reference since it's done
|
||||
// (will be set again when next step starts)
|
||||
}
|
||||
@@ -1604,6 +1730,16 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_completedSteps.RemoveAt(_completedSteps.Count - 1);
|
||||
}
|
||||
|
||||
// Also clear the step tracker for manipulator sync
|
||||
while (_completedStepsTracker.Count > checkpointIndex)
|
||||
{
|
||||
_completedStepsTracker.RemoveAt(_completedStepsTracker.Count - 1);
|
||||
}
|
||||
|
||||
// Reset the step manipulator to match the restored state
|
||||
// It will be re-initialized when the restored step starts
|
||||
_stepManipulator?.ClearChanges();
|
||||
|
||||
// Store restored checkpoint for StepsRunner to consume
|
||||
_restoredCheckpoint = checkpoint;
|
||||
|
||||
|
||||
132
src/Runner.Worker/Dap/StepCommands/StepChange.cs
Normal file
132
src/Runner.Worker/Dap/StepCommands/StepChange.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a modification made to a step during a debug session.
|
||||
/// Used for change tracking and export diff generation.
|
||||
/// </summary>
|
||||
public class StepChange
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of change made.
|
||||
/// </summary>
|
||||
public ChangeType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The original 1-based index of the step (before any modifications).
|
||||
/// For Added steps, this is -1.
|
||||
/// </summary>
|
||||
public int OriginalIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The current 1-based index of the step (after all modifications).
|
||||
/// For Removed steps, this is -1.
|
||||
/// </summary>
|
||||
public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the step before modification (for Modified/Moved/Removed).
|
||||
/// </summary>
|
||||
public StepInfo OriginalStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The step after modification (for Added/Modified/Moved).
|
||||
/// For Removed steps, this is null.
|
||||
/// </summary>
|
||||
public StepInfo ModifiedStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the change was made.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the change for display purposes.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for an added step.
|
||||
/// </summary>
|
||||
public static StepChange Added(StepInfo step, int index)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Added,
|
||||
OriginalIndex = -1,
|
||||
CurrentIndex = index,
|
||||
ModifiedStep = step,
|
||||
Description = $"Added step '{step.Name}' at position {index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a modified step.
|
||||
/// </summary>
|
||||
public static StepChange Modified(StepInfo original, StepInfo modified, string changeDescription = null)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Modified,
|
||||
OriginalIndex = original.Index,
|
||||
CurrentIndex = modified.Index,
|
||||
OriginalStep = original,
|
||||
ModifiedStep = modified,
|
||||
Description = changeDescription ?? $"Modified step '{original.Name}' at position {original.Index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a removed step.
|
||||
/// </summary>
|
||||
public static StepChange Removed(StepInfo step)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Removed,
|
||||
OriginalIndex = step.Index,
|
||||
CurrentIndex = -1,
|
||||
OriginalStep = step,
|
||||
Description = $"Removed step '{step.Name}' from position {step.Index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a moved step.
|
||||
/// </summary>
|
||||
public static StepChange Moved(StepInfo original, int newIndex)
|
||||
{
|
||||
var modified = new StepInfo
|
||||
{
|
||||
Index = newIndex,
|
||||
Name = original.Name,
|
||||
Type = original.Type,
|
||||
TypeDetail = original.TypeDetail,
|
||||
Status = original.Status,
|
||||
Action = original.Action,
|
||||
Step = original.Step,
|
||||
OriginalIndex = original.Index,
|
||||
Change = ChangeType.Moved
|
||||
};
|
||||
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Moved,
|
||||
OriginalIndex = original.Index,
|
||||
CurrentIndex = newIndex,
|
||||
OriginalStep = original,
|
||||
ModifiedStep = modified,
|
||||
Description = $"Moved step '{original.Name}' from position {original.Index} to {newIndex}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable summary of this change.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return Description ?? $"{Type} step at index {OriginalIndex} -> {CurrentIndex}";
|
||||
}
|
||||
}
|
||||
}
|
||||
782
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs
Normal file
782
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs
Normal file
@@ -0,0 +1,782 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
using DistributedTask = GitHub.DistributedTask;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for handling step manipulation commands.
|
||||
/// Executes parsed commands using the StepManipulator, StepFactory, and StepSerializer.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepCommandHandler))]
|
||||
public interface IStepCommandHandler : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles a parsed step command and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="command">The parsed command to execute</param>
|
||||
/// <param name="jobContext">The job execution context</param>
|
||||
/// <returns>Result of the command execution</returns>
|
||||
Task<StepCommandResult> HandleAsync(StepCommand command, IExecutionContext jobContext);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the handler with required services.
|
||||
/// Called when the debug session starts.
|
||||
/// </summary>
|
||||
/// <param name="manipulator">The step manipulator to use</param>
|
||||
void SetManipulator(IStepManipulator manipulator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles step manipulation commands (list, add, edit, remove, move, export).
|
||||
/// </summary>
|
||||
public sealed class StepCommandHandler : RunnerService, IStepCommandHandler
|
||||
{
|
||||
private IStepManipulator _manipulator;
|
||||
private IStepFactory _factory;
|
||||
private IStepSerializer _serializer;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_factory = hostContext.GetService<IStepFactory>();
|
||||
_serializer = hostContext.GetService<IStepSerializer>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetManipulator(IStepManipulator manipulator)
|
||||
{
|
||||
_manipulator = manipulator;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<StepCommandResult> HandleAsync(StepCommand command, IExecutionContext jobContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateContext(jobContext);
|
||||
|
||||
var result = command switch
|
||||
{
|
||||
ListCommand list => HandleList(list),
|
||||
AddRunCommand addRun => HandleAddRun(addRun, jobContext),
|
||||
AddUsesCommand addUses => await HandleAddUsesAsync(addUses, jobContext),
|
||||
EditCommand edit => HandleEdit(edit),
|
||||
RemoveCommand remove => HandleRemove(remove),
|
||||
MoveCommand move => HandleMove(move),
|
||||
ExportCommand export => HandleExport(export),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand,
|
||||
$"Unknown command type: {command.GetType().Name}")
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (StepCommandException ex)
|
||||
{
|
||||
return ex.ToResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Step command failed: {ex}");
|
||||
return StepCommandResult.Fail(StepCommandErrors.InvalidCommand, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
#region Command Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step list command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleList(ListCommand command)
|
||||
{
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var output = FormatStepList(steps, command.Verbose);
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = output,
|
||||
Result = new
|
||||
{
|
||||
steps = steps.Select(s => new
|
||||
{
|
||||
index = s.Index,
|
||||
name = s.Name,
|
||||
type = s.Type,
|
||||
typeDetail = s.TypeDetail,
|
||||
status = s.Status.ToString().ToLower(),
|
||||
change = s.Change?.ToString().ToUpper()
|
||||
}).ToList(),
|
||||
totalCount = steps.Count,
|
||||
completedCount = steps.Count(s => s.Status == StepStatus.Completed),
|
||||
pendingCount = steps.Count(s => s.Status == StepStatus.Pending)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the step list for REPL display.
|
||||
/// </summary>
|
||||
private string FormatStepList(IReadOnlyList<StepInfo> steps, bool verbose)
|
||||
{
|
||||
if (steps.Count == 0)
|
||||
{
|
||||
return "No steps in the job.";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Steps:");
|
||||
|
||||
var maxNameLength = steps.Max(s => s.Name?.Length ?? 0);
|
||||
maxNameLength = Math.Min(maxNameLength, 40); // Cap at 40 chars
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
// Status indicator
|
||||
var statusIcon = step.Status switch
|
||||
{
|
||||
StepStatus.Completed => "\u2713", // checkmark
|
||||
StepStatus.Current => "\u25B6", // play arrow
|
||||
StepStatus.Pending => " ",
|
||||
_ => "?"
|
||||
};
|
||||
|
||||
// Change indicator
|
||||
var changeTag = step.Change.HasValue
|
||||
? $" [{step.Change.Value.ToString().ToUpper()}]"
|
||||
: "";
|
||||
|
||||
// Truncate name if needed
|
||||
var displayName = step.Name ?? "";
|
||||
if (displayName.Length > maxNameLength)
|
||||
{
|
||||
displayName = displayName.Substring(0, maxNameLength - 3) + "...";
|
||||
}
|
||||
|
||||
// Build the line
|
||||
var line = $" {statusIcon} {step.Index,2}. {displayName.PadRight(maxNameLength)}{changeTag,-12} {step.Type,-5} {step.TypeDetail}";
|
||||
|
||||
if (verbose && step.Action != null)
|
||||
{
|
||||
// Add verbose details
|
||||
sb.AppendLine(line);
|
||||
if (!string.IsNullOrEmpty(step.Action.Condition) && step.Action.Condition != "success()")
|
||||
{
|
||||
sb.AppendLine($" if: {step.Action.Condition}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Legend
|
||||
sb.AppendLine();
|
||||
sb.Append("Legend: \u2713 = completed, \u25B6 = current/paused");
|
||||
if (steps.Any(s => s.Change.HasValue))
|
||||
{
|
||||
sb.Append(", [ADDED] = new, [MODIFIED] = edited, [MOVED] = reordered");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step add run command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleAddRun(AddRunCommand command, IExecutionContext jobContext)
|
||||
{
|
||||
// Create the ActionStep
|
||||
var actionStep = _factory.CreateRunStep(
|
||||
script: command.Script,
|
||||
name: command.Name,
|
||||
shell: command.Shell,
|
||||
workingDirectory: command.WorkingDirectory,
|
||||
env: command.Env,
|
||||
condition: command.Condition,
|
||||
continueOnError: command.ContinueOnError,
|
||||
timeoutMinutes: command.Timeout);
|
||||
|
||||
// Wrap in IActionRunner
|
||||
var runner = _factory.WrapInRunner(actionStep, jobContext);
|
||||
|
||||
// Insert into the queue
|
||||
var index = _manipulator.InsertStep(runner, command.Position);
|
||||
|
||||
var stepInfo = StepInfo.FromStep(runner, index, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
Trace.Info($"Added run step '{actionStep.DisplayName}' at position {index}");
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step added at position {index}: {actionStep.DisplayName}",
|
||||
Result = new
|
||||
{
|
||||
index,
|
||||
step = new
|
||||
{
|
||||
name = stepInfo.Name,
|
||||
type = stepInfo.Type,
|
||||
typeDetail = stepInfo.TypeDetail,
|
||||
status = stepInfo.Status.ToString().ToLower()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step add uses command with action download integration.
|
||||
/// Downloads the action via IActionManager and handles pre/post steps.
|
||||
/// </summary>
|
||||
private async Task<StepCommandResult> HandleAddUsesAsync(AddUsesCommand command, IExecutionContext jobContext)
|
||||
{
|
||||
// Create the ActionStep
|
||||
var actionStep = _factory.CreateUsesStep(
|
||||
actionReference: command.Action,
|
||||
name: command.Name,
|
||||
with: command.With,
|
||||
env: command.Env,
|
||||
condition: command.Condition,
|
||||
continueOnError: command.ContinueOnError,
|
||||
timeoutMinutes: command.Timeout);
|
||||
|
||||
// For local actions (starting with ./ or ..) and docker actions, skip download
|
||||
var isLocalOrDocker = command.Action.StartsWith("./") ||
|
||||
command.Action.StartsWith("../") ||
|
||||
command.Action.StartsWith("docker://", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
IActionRunner preStepRunner = null;
|
||||
string downloadMessage = null;
|
||||
|
||||
if (!isLocalOrDocker)
|
||||
{
|
||||
// Download the action via IActionManager
|
||||
try
|
||||
{
|
||||
var actionManager = HostContext.GetService<IActionManager>();
|
||||
|
||||
Trace.Info($"Preparing action '{command.Action}' for download...");
|
||||
|
||||
// PrepareActionsAsync downloads the action and returns info about pre/post steps
|
||||
var prepareResult = await actionManager.PrepareActionsAsync(
|
||||
jobContext,
|
||||
new[] { actionStep }
|
||||
);
|
||||
|
||||
// Check if this action has a pre-step
|
||||
if (prepareResult.PreStepTracker != null &&
|
||||
prepareResult.PreStepTracker.TryGetValue(actionStep.Id, out var preRunner))
|
||||
{
|
||||
preStepRunner = preRunner;
|
||||
Trace.Info($"Action '{command.Action}' has a pre-step that will be inserted");
|
||||
}
|
||||
|
||||
// Note: Post-steps are handled automatically by the job infrastructure
|
||||
// when the action definition declares a post step. The ActionRunner
|
||||
// will add it to PostJobSteps during execution.
|
||||
|
||||
downloadMessage = $"Action '{command.Action}' downloaded successfully";
|
||||
Trace.Info(downloadMessage);
|
||||
}
|
||||
catch (DistributedTask.WebApi.UnresolvableActionDownloadInfoException ex)
|
||||
{
|
||||
// Action not found or not accessible
|
||||
Trace.Error($"Failed to resolve action: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Failed to resolve action '{command.Action}': {ex.Message}");
|
||||
}
|
||||
catch (DistributedTask.WebApi.FailedToResolveActionDownloadInfoException ex)
|
||||
{
|
||||
// Network or other transient error
|
||||
Trace.Error($"Failed to download action: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Failed to download action '{command.Action}': {ex.Message}. Try again.");
|
||||
}
|
||||
catch (DistributedTask.WebApi.InvalidActionArchiveException ex)
|
||||
{
|
||||
// Corrupted archive
|
||||
Trace.Error($"Invalid action archive: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Action '{command.Action}' has an invalid archive: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex.Message.Contains("action.yml") ||
|
||||
ex.Message.Contains("action.yaml") ||
|
||||
ex.Message.Contains("Dockerfile"))
|
||||
{
|
||||
// Action exists but has no valid entry point
|
||||
Trace.Error($"Invalid action format: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Action '{command.Action}' is invalid: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
downloadMessage = isLocalOrDocker && command.Action.StartsWith("docker://")
|
||||
? "Docker action - container will be pulled when step executes"
|
||||
: "Local action - no download needed";
|
||||
}
|
||||
|
||||
// Calculate insertion position
|
||||
// If there's a pre-step, we need to insert it before the main step
|
||||
var insertPosition = command.Position;
|
||||
int mainStepIndex;
|
||||
int? preStepIndex = null;
|
||||
|
||||
if (preStepRunner != null)
|
||||
{
|
||||
// Insert pre-step first
|
||||
preStepIndex = _manipulator.InsertStep(preStepRunner, insertPosition);
|
||||
|
||||
// Then insert main step after the pre-step
|
||||
mainStepIndex = _manipulator.InsertStep(
|
||||
_factory.WrapInRunner(actionStep, jobContext),
|
||||
StepPosition.After(preStepIndex.Value));
|
||||
|
||||
Trace.Info($"Added pre-step at position {preStepIndex} and main step at position {mainStepIndex}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pre-step, just insert the main step
|
||||
var runner = _factory.WrapInRunner(actionStep, jobContext);
|
||||
mainStepIndex = _manipulator.InsertStep(runner, insertPosition);
|
||||
}
|
||||
|
||||
var stepInfo = StepInfo.FromStep(_factory.WrapInRunner(actionStep, jobContext), mainStepIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
Trace.Info($"Added uses step '{actionStep.DisplayName}' at position {mainStepIndex}");
|
||||
|
||||
// Build result message
|
||||
var messageBuilder = new StringBuilder();
|
||||
messageBuilder.Append($"Step added at position {mainStepIndex}: {actionStep.DisplayName}");
|
||||
|
||||
if (preStepIndex.HasValue)
|
||||
{
|
||||
messageBuilder.Append($"\n Pre-step added at position {preStepIndex.Value}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(downloadMessage))
|
||||
{
|
||||
messageBuilder.Append($"\n ({downloadMessage})");
|
||||
}
|
||||
|
||||
// TODO: Before production release, add action restriction checks:
|
||||
// - Verify action is in organization's allowed list
|
||||
// - Check verified creator requirements
|
||||
// - Enforce enterprise policies
|
||||
// For now, allow all actions in prototype
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = messageBuilder.ToString(),
|
||||
Result = new
|
||||
{
|
||||
index = mainStepIndex,
|
||||
preStepIndex,
|
||||
hasPreStep = preStepIndex.HasValue,
|
||||
step = new
|
||||
{
|
||||
name = stepInfo.Name,
|
||||
type = stepInfo.Type,
|
||||
typeDetail = stepInfo.TypeDetail,
|
||||
status = stepInfo.Status.ToString().ToLower()
|
||||
},
|
||||
actionDownloaded = !isLocalOrDocker
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step edit command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleEdit(EditCommand command)
|
||||
{
|
||||
// Get the step info for validation
|
||||
var stepInfo = _manipulator.GetStep(command.Index);
|
||||
if (stepInfo == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"No step at index {command.Index}.");
|
||||
}
|
||||
|
||||
if (stepInfo.Status == StepStatus.Completed)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {command.Index} has already completed and cannot be modified.");
|
||||
}
|
||||
|
||||
if (stepInfo.Status == StepStatus.Current)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {command.Index} is currently executing. Use step-back first to modify it.");
|
||||
}
|
||||
|
||||
if (stepInfo.Action == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {command.Index} is not an action step and cannot be edited.");
|
||||
}
|
||||
|
||||
// Track what was changed for the message
|
||||
var changes = new List<string>();
|
||||
|
||||
// Apply edits
|
||||
_manipulator.EditStep(command.Index, action =>
|
||||
{
|
||||
// Name
|
||||
if (command.Name != null)
|
||||
{
|
||||
action.DisplayName = command.Name;
|
||||
changes.Add("name");
|
||||
}
|
||||
|
||||
// Condition
|
||||
if (command.Condition != null)
|
||||
{
|
||||
action.Condition = command.Condition;
|
||||
changes.Add("if");
|
||||
}
|
||||
|
||||
// Script (for run steps)
|
||||
if (command.Script != null)
|
||||
{
|
||||
UpdateScript(action, command.Script);
|
||||
changes.Add("script");
|
||||
}
|
||||
|
||||
// Shell (for run steps)
|
||||
if (command.Shell != null)
|
||||
{
|
||||
UpdateRunInput(action, "shell", command.Shell);
|
||||
changes.Add("shell");
|
||||
}
|
||||
|
||||
// Working directory
|
||||
if (command.WorkingDirectory != null)
|
||||
{
|
||||
UpdateRunInput(action, "workingDirectory", command.WorkingDirectory);
|
||||
changes.Add("working-directory");
|
||||
}
|
||||
|
||||
// With inputs (for uses steps)
|
||||
if (command.With != null)
|
||||
{
|
||||
foreach (var kvp in command.With)
|
||||
{
|
||||
UpdateWithInput(action, kvp.Key, kvp.Value);
|
||||
changes.Add($"with.{kvp.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove with inputs
|
||||
if (command.RemoveWith != null)
|
||||
{
|
||||
foreach (var key in command.RemoveWith)
|
||||
{
|
||||
RemoveInput(action, key);
|
||||
changes.Add($"remove with.{key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Env vars
|
||||
if (command.Env != null)
|
||||
{
|
||||
foreach (var kvp in command.Env)
|
||||
{
|
||||
UpdateEnvVar(action, kvp.Key, kvp.Value);
|
||||
changes.Add($"env.{kvp.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove env vars
|
||||
if (command.RemoveEnv != null)
|
||||
{
|
||||
foreach (var key in command.RemoveEnv)
|
||||
{
|
||||
RemoveEnvVar(action, key);
|
||||
changes.Add($"remove env.{key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Continue on error
|
||||
if (command.ContinueOnError.HasValue)
|
||||
{
|
||||
action.ContinueOnError = new BooleanToken(null, null, null, command.ContinueOnError.Value);
|
||||
changes.Add("continue-on-error");
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (command.Timeout.HasValue)
|
||||
{
|
||||
action.TimeoutInMinutes = new NumberToken(null, null, null, command.Timeout.Value);
|
||||
changes.Add("timeout-minutes");
|
||||
}
|
||||
});
|
||||
|
||||
var changesStr = changes.Count > 0 ? string.Join(", ", changes) : "no changes";
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step {command.Index} updated ({changesStr})",
|
||||
Result = new
|
||||
{
|
||||
index = command.Index,
|
||||
changes
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step remove command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleRemove(RemoveCommand command)
|
||||
{
|
||||
// Get step info for the message
|
||||
var stepInfo = _manipulator.GetStep(command.Index);
|
||||
var stepName = stepInfo?.Name ?? $"step {command.Index}";
|
||||
|
||||
// Remove the step
|
||||
_manipulator.RemoveStep(command.Index);
|
||||
|
||||
Trace.Info($"Removed step '{stepName}' from position {command.Index}");
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step {command.Index} removed: {stepName}",
|
||||
Result = new
|
||||
{
|
||||
index = command.Index,
|
||||
removedStep = stepName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step move command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleMove(MoveCommand command)
|
||||
{
|
||||
// Get step info for the message
|
||||
var stepInfo = _manipulator.GetStep(command.FromIndex);
|
||||
var stepName = stepInfo?.Name ?? $"step {command.FromIndex}";
|
||||
|
||||
// Move the step
|
||||
var newIndex = _manipulator.MoveStep(command.FromIndex, command.Position);
|
||||
|
||||
Trace.Info($"Moved step '{stepName}' from position {command.FromIndex} to {newIndex}");
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step moved from position {command.FromIndex} to {newIndex}: {stepName}",
|
||||
Result = new
|
||||
{
|
||||
fromIndex = command.FromIndex,
|
||||
toIndex = newIndex,
|
||||
stepName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step export command.
|
||||
/// Generates YAML output for modified steps with optional change comments.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleExport(ExportCommand command)
|
||||
{
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var changes = _manipulator.GetChanges();
|
||||
|
||||
IEnumerable<StepInfo> toExport;
|
||||
if (command.ChangesOnly)
|
||||
{
|
||||
toExport = steps.Where(s => s.Change.HasValue && s.Action != null);
|
||||
}
|
||||
else
|
||||
{
|
||||
toExport = steps.Where(s => s.Action != null);
|
||||
}
|
||||
|
||||
var yaml = _serializer.ToYaml(toExport, command.WithComments);
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = yaml,
|
||||
Result = new
|
||||
{
|
||||
yaml,
|
||||
totalSteps = steps.Count,
|
||||
exportedSteps = toExport.Count(),
|
||||
addedCount = changes.Count(c => c.Type == ChangeType.Added),
|
||||
modifiedCount = changes.Count(c => c.Type == ChangeType.Modified),
|
||||
movedCount = changes.Count(c => c.Type == ChangeType.Moved),
|
||||
removedCount = changes.Count(c => c.Type == ChangeType.Removed)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Validates that we have a valid context and manipulator.
|
||||
/// </summary>
|
||||
private void ValidateContext(IExecutionContext jobContext)
|
||||
{
|
||||
if (_manipulator == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"Step manipulator not initialized. Debug session may not be active.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the script in a run step's Inputs mapping.
|
||||
/// </summary>
|
||||
private void UpdateScript(ActionStep action, string script)
|
||||
{
|
||||
UpdateRunInput(action, "script", script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a value in a run step's Inputs mapping.
|
||||
/// </summary>
|
||||
private void UpdateRunInput(ActionStep action, string key, string value)
|
||||
{
|
||||
if (action.Reference is not ScriptReference)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
"Cannot update script/shell/working-directory on a non-run step.");
|
||||
}
|
||||
|
||||
if (action.Inputs == null)
|
||||
{
|
||||
action.Inputs = new MappingToken(null, null, null);
|
||||
}
|
||||
|
||||
UpdateMappingValue(action.Inputs as MappingToken, key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a with input for a uses step.
|
||||
/// </summary>
|
||||
private void UpdateWithInput(ActionStep action, string key, string value)
|
||||
{
|
||||
// For uses steps, Inputs contains the "with" values
|
||||
if (action.Reference is ScriptReference)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
"Cannot update 'with' on a run step. Use --script instead.");
|
||||
}
|
||||
|
||||
if (action.Inputs == null)
|
||||
{
|
||||
action.Inputs = new MappingToken(null, null, null);
|
||||
}
|
||||
|
||||
UpdateMappingValue(action.Inputs as MappingToken, key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an input from the step.
|
||||
/// </summary>
|
||||
private void RemoveInput(ActionStep action, string key)
|
||||
{
|
||||
RemoveMappingValue(action.Inputs as MappingToken, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an environment variable.
|
||||
/// </summary>
|
||||
private void UpdateEnvVar(ActionStep action, string key, string value)
|
||||
{
|
||||
if (action.Environment == null)
|
||||
{
|
||||
action.Environment = new MappingToken(null, null, null);
|
||||
}
|
||||
|
||||
UpdateMappingValue(action.Environment as MappingToken, key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an environment variable.
|
||||
/// </summary>
|
||||
private void RemoveEnvVar(ActionStep action, string key)
|
||||
{
|
||||
RemoveMappingValue(action.Environment as MappingToken, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates or adds a key-value pair in a MappingToken.
|
||||
/// </summary>
|
||||
private void UpdateMappingValue(MappingToken mapping, string key, string value)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and update existing key, or add new one
|
||||
for (int i = 0; i < mapping.Count; i++)
|
||||
{
|
||||
var pair = mapping[i];
|
||||
if (string.Equals(pair.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Found it - replace the value
|
||||
mapping.RemoveAt(i);
|
||||
mapping.Insert(i,
|
||||
new StringToken(null, null, null, key),
|
||||
new StringToken(null, null, null, value));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found - add new entry
|
||||
mapping.Add(
|
||||
new StringToken(null, null, null, key),
|
||||
new StringToken(null, null, null, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a key from a MappingToken.
|
||||
/// </summary>
|
||||
private void RemoveMappingValue(MappingToken mapping, string key)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mapping.Count; i++)
|
||||
{
|
||||
var pair = mapping[i];
|
||||
if (string.Equals(pair.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mapping.RemoveAt(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
930
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs
Normal file
930
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs
Normal file
@@ -0,0 +1,930 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for parsing step commands from REPL strings or JSON.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepCommandParser))]
|
||||
public interface IStepCommandParser : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a command string (REPL or JSON) into a structured StepCommand.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string (e.g., "!step list --verbose" or JSON)</param>
|
||||
/// <returns>Parsed StepCommand</returns>
|
||||
/// <exception cref="StepCommandException">If parsing fails</exception>
|
||||
StepCommand Parse(string input);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the input is a step command.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to check</param>
|
||||
/// <returns>True if this is a step command (REPL or JSON format)</returns>
|
||||
bool IsStepCommand(string input);
|
||||
}
|
||||
|
||||
#region Command Classes
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all step commands.
|
||||
/// </summary>
|
||||
public abstract class StepCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the original input was JSON (affects response format).
|
||||
/// </summary>
|
||||
public bool WasJsonInput { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step list [--verbose]
|
||||
/// </summary>
|
||||
public class ListCommand : StepCommand
|
||||
{
|
||||
public bool Verbose { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step add run "script" [options]
|
||||
/// </summary>
|
||||
public class AddRunCommand : StepCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
public StepPosition Position { get; set; } = StepPosition.Last();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step add uses "action@ref" [options]
|
||||
/// </summary>
|
||||
public class AddUsesCommand : StepCommand
|
||||
{
|
||||
public string Action { get; set; }
|
||||
public string Name { get; set; }
|
||||
public Dictionary<string, string> With { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
public StepPosition Position { get; set; } = StepPosition.Last();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step edit <index> [modifications]
|
||||
/// </summary>
|
||||
public class EditCommand : StepCommand
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Script { get; set; }
|
||||
public string Action { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public Dictionary<string, string> With { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public List<string> RemoveWith { get; set; }
|
||||
public List<string> RemoveEnv { get; set; }
|
||||
public bool? ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step remove <index>
|
||||
/// </summary>
|
||||
public class RemoveCommand : StepCommand
|
||||
{
|
||||
public int Index { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step move <from> [position options]
|
||||
/// </summary>
|
||||
public class MoveCommand : StepCommand
|
||||
{
|
||||
public int FromIndex { get; set; }
|
||||
public StepPosition Position { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step export [--changes-only] [--with-comments]
|
||||
/// </summary>
|
||||
public class ExportCommand : StepCommand
|
||||
{
|
||||
public bool ChangesOnly { get; set; }
|
||||
public bool WithComments { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Position Types
|
||||
|
||||
/// <summary>
|
||||
/// Types of position specifications for inserting/moving steps.
|
||||
/// </summary>
|
||||
public enum PositionType
|
||||
{
|
||||
/// <summary>Insert at specific index (1-based)</summary>
|
||||
At,
|
||||
/// <summary>Insert after specific index (1-based)</summary>
|
||||
After,
|
||||
/// <summary>Insert before specific index (1-based)</summary>
|
||||
Before,
|
||||
/// <summary>Insert at first pending position</summary>
|
||||
First,
|
||||
/// <summary>Insert at end (default)</summary>
|
||||
Last
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a position for inserting or moving steps.
|
||||
/// </summary>
|
||||
public class StepPosition
|
||||
{
|
||||
public PositionType Type { get; set; }
|
||||
public int? Index { get; set; }
|
||||
|
||||
public static StepPosition At(int index) => new StepPosition { Type = PositionType.At, Index = index };
|
||||
public static StepPosition After(int index) => new StepPosition { Type = PositionType.After, Index = index };
|
||||
public static StepPosition Before(int index) => new StepPosition { Type = PositionType.Before, Index = index };
|
||||
public static StepPosition First() => new StepPosition { Type = PositionType.First };
|
||||
public static StepPosition Last() => new StepPosition { Type = PositionType.Last };
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Type switch
|
||||
{
|
||||
PositionType.At => $"at {Index}",
|
||||
PositionType.After => $"after {Index}",
|
||||
PositionType.Before => $"before {Index}",
|
||||
PositionType.First => "first",
|
||||
PositionType.Last => "last",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parser implementation for step commands (REPL and JSON formats).
|
||||
/// </summary>
|
||||
public sealed class StepCommandParser : RunnerService, IStepCommandParser
|
||||
{
|
||||
// Regex to match quoted strings (handles escaped quotes)
|
||||
private static readonly Regex QuotedStringRegex = new Regex(
|
||||
@"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public bool IsStepCommand(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// REPL command format: !step ...
|
||||
if (trimmed.StartsWith("!step", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// JSON format: {"cmd": "step.*", ...}
|
||||
if (trimmed.StartsWith("{") && trimmed.Contains("\"cmd\"") && trimmed.Contains("\"step."))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public StepCommand Parse(string input)
|
||||
{
|
||||
var trimmed = input?.Trim() ?? "";
|
||||
|
||||
if (trimmed.StartsWith("{"))
|
||||
{
|
||||
return ParseJsonCommand(trimmed);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ParseReplCommand(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
#region JSON Parsing
|
||||
|
||||
private StepCommand ParseJsonCommand(string json)
|
||||
{
|
||||
JObject obj;
|
||||
try
|
||||
{
|
||||
obj = JObject.Parse(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, $"Invalid JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
var cmd = obj["cmd"]?.ToString();
|
||||
if (string.IsNullOrEmpty(cmd))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'cmd' field in JSON");
|
||||
}
|
||||
|
||||
StepCommand result = cmd switch
|
||||
{
|
||||
"step.list" => ParseJsonListCommand(obj),
|
||||
"step.add" => ParseJsonAddCommand(obj),
|
||||
"step.edit" => ParseJsonEditCommand(obj),
|
||||
"step.remove" => ParseJsonRemoveCommand(obj),
|
||||
"step.move" => ParseJsonMoveCommand(obj),
|
||||
"step.export" => ParseJsonExportCommand(obj),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown command: {cmd}")
|
||||
};
|
||||
|
||||
result.WasJsonInput = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
private ListCommand ParseJsonListCommand(JObject obj)
|
||||
{
|
||||
return new ListCommand
|
||||
{
|
||||
Verbose = obj["verbose"]?.Value<bool>() ?? false
|
||||
};
|
||||
}
|
||||
|
||||
private StepCommand ParseJsonAddCommand(JObject obj)
|
||||
{
|
||||
var type = obj["type"]?.ToString()?.ToLower();
|
||||
|
||||
if (type == "run")
|
||||
{
|
||||
var script = obj["script"]?.ToString();
|
||||
if (string.IsNullOrEmpty(script))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'script' field for run step");
|
||||
}
|
||||
|
||||
return new AddRunCommand
|
||||
{
|
||||
Script = script,
|
||||
Name = obj["name"]?.ToString(),
|
||||
Shell = obj["shell"]?.ToString(),
|
||||
WorkingDirectory = obj["workingDirectory"]?.ToString(),
|
||||
Env = ParseJsonDictionary(obj["env"]),
|
||||
Condition = obj["if"]?.ToString(),
|
||||
ContinueOnError = obj["continueOnError"]?.Value<bool>() ?? false,
|
||||
Timeout = obj["timeout"]?.Value<int>(),
|
||||
Position = ParseJsonPosition(obj["position"])
|
||||
};
|
||||
}
|
||||
else if (type == "uses")
|
||||
{
|
||||
var action = obj["action"]?.ToString();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'action' field for uses step");
|
||||
}
|
||||
|
||||
return new AddUsesCommand
|
||||
{
|
||||
Action = action,
|
||||
Name = obj["name"]?.ToString(),
|
||||
With = ParseJsonDictionary(obj["with"]),
|
||||
Env = ParseJsonDictionary(obj["env"]),
|
||||
Condition = obj["if"]?.ToString(),
|
||||
ContinueOnError = obj["continueOnError"]?.Value<bool>() ?? false,
|
||||
Timeout = obj["timeout"]?.Value<int>(),
|
||||
Position = ParseJsonPosition(obj["position"])
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
|
||||
}
|
||||
}
|
||||
|
||||
private EditCommand ParseJsonEditCommand(JObject obj)
|
||||
{
|
||||
var index = obj["index"]?.Value<int>();
|
||||
if (!index.HasValue)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for edit command");
|
||||
}
|
||||
|
||||
return new EditCommand
|
||||
{
|
||||
Index = index.Value,
|
||||
Name = obj["name"]?.ToString(),
|
||||
Script = obj["script"]?.ToString(),
|
||||
Action = obj["action"]?.ToString(),
|
||||
Shell = obj["shell"]?.ToString(),
|
||||
WorkingDirectory = obj["workingDirectory"]?.ToString(),
|
||||
Condition = obj["if"]?.ToString(),
|
||||
With = ParseJsonDictionary(obj["with"]),
|
||||
Env = ParseJsonDictionary(obj["env"]),
|
||||
RemoveWith = ParseJsonStringList(obj["removeWith"]),
|
||||
RemoveEnv = ParseJsonStringList(obj["removeEnv"]),
|
||||
ContinueOnError = obj["continueOnError"]?.Value<bool>(),
|
||||
Timeout = obj["timeout"]?.Value<int>()
|
||||
};
|
||||
}
|
||||
|
||||
private RemoveCommand ParseJsonRemoveCommand(JObject obj)
|
||||
{
|
||||
var index = obj["index"]?.Value<int>();
|
||||
if (!index.HasValue)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for remove command");
|
||||
}
|
||||
|
||||
return new RemoveCommand { Index = index.Value };
|
||||
}
|
||||
|
||||
private MoveCommand ParseJsonMoveCommand(JObject obj)
|
||||
{
|
||||
var from = obj["from"]?.Value<int>();
|
||||
if (!from.HasValue)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'from' field for move command");
|
||||
}
|
||||
|
||||
var position = ParseJsonPosition(obj["position"]);
|
||||
if (position.Type == PositionType.Last)
|
||||
{
|
||||
// Default 'last' is fine for add, but move needs explicit position
|
||||
// unless explicitly set
|
||||
var posObj = obj["position"];
|
||||
if (posObj == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'position' field for move command");
|
||||
}
|
||||
}
|
||||
|
||||
return new MoveCommand
|
||||
{
|
||||
FromIndex = from.Value,
|
||||
Position = position
|
||||
};
|
||||
}
|
||||
|
||||
private ExportCommand ParseJsonExportCommand(JObject obj)
|
||||
{
|
||||
return new ExportCommand
|
||||
{
|
||||
ChangesOnly = obj["changesOnly"]?.Value<bool>() ?? false,
|
||||
WithComments = obj["withComments"]?.Value<bool>() ?? false
|
||||
};
|
||||
}
|
||||
|
||||
private StepPosition ParseJsonPosition(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return StepPosition.Last();
|
||||
|
||||
if (token.Type == JTokenType.Object)
|
||||
{
|
||||
var obj = (JObject)token;
|
||||
|
||||
if (obj["at"] != null)
|
||||
return StepPosition.At(obj["at"].Value<int>());
|
||||
if (obj["after"] != null)
|
||||
return StepPosition.After(obj["after"].Value<int>());
|
||||
if (obj["before"] != null)
|
||||
return StepPosition.Before(obj["before"].Value<int>());
|
||||
if (obj["first"]?.Value<bool>() == true)
|
||||
return StepPosition.First();
|
||||
if (obj["last"]?.Value<bool>() == true)
|
||||
return StepPosition.Last();
|
||||
}
|
||||
|
||||
return StepPosition.Last();
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ParseJsonDictionary(JToken token)
|
||||
{
|
||||
if (token == null || token.Type != JTokenType.Object)
|
||||
return null;
|
||||
|
||||
var result = new Dictionary<string, string>();
|
||||
foreach (var prop in ((JObject)token).Properties())
|
||||
{
|
||||
result[prop.Name] = prop.Value?.ToString() ?? "";
|
||||
}
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
private List<string> ParseJsonStringList(JToken token)
|
||||
{
|
||||
if (token == null || token.Type != JTokenType.Array)
|
||||
return null;
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var item in (JArray)token)
|
||||
{
|
||||
var str = item?.ToString();
|
||||
if (!string.IsNullOrEmpty(str))
|
||||
result.Add(str);
|
||||
}
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region REPL Parsing
|
||||
|
||||
private StepCommand ParseReplCommand(string input)
|
||||
{
|
||||
// Tokenize the input, respecting quoted strings
|
||||
var tokens = Tokenize(input);
|
||||
|
||||
if (tokens.Count < 2 || !tokens[0].Equals("!step", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Invalid command format. Expected: !step <command> [args...]");
|
||||
}
|
||||
|
||||
var subCommand = tokens[1].ToLower();
|
||||
|
||||
return subCommand switch
|
||||
{
|
||||
"list" => ParseReplListCommand(tokens),
|
||||
"add" => ParseReplAddCommand(tokens),
|
||||
"edit" => ParseReplEditCommand(tokens),
|
||||
"remove" => ParseReplRemoveCommand(tokens),
|
||||
"move" => ParseReplMoveCommand(tokens),
|
||||
"export" => ParseReplExportCommand(tokens),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown sub-command: {subCommand}")
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> Tokenize(string input)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var remaining = input;
|
||||
|
||||
while (!string.IsNullOrEmpty(remaining))
|
||||
{
|
||||
remaining = remaining.TrimStart();
|
||||
if (string.IsNullOrEmpty(remaining))
|
||||
break;
|
||||
|
||||
// Check for quoted string
|
||||
var match = QuotedStringRegex.Match(remaining);
|
||||
if (match.Success && match.Index == 0)
|
||||
{
|
||||
// Extract the quoted content (without quotes)
|
||||
var quoted = match.Value;
|
||||
var content = quoted.Substring(1, quoted.Length - 2);
|
||||
// Unescape
|
||||
content = content.Replace("\\\"", "\"").Replace("\\'", "'").Replace("\\\\", "\\");
|
||||
tokens.Add(content);
|
||||
remaining = remaining.Substring(match.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-quoted token
|
||||
var spaceIndex = remaining.IndexOfAny(new[] { ' ', '\t' });
|
||||
if (spaceIndex == -1)
|
||||
{
|
||||
tokens.Add(remaining);
|
||||
break;
|
||||
}
|
||||
tokens.Add(remaining.Substring(0, spaceIndex));
|
||||
remaining = remaining.Substring(spaceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private ListCommand ParseReplListCommand(List<string> tokens)
|
||||
{
|
||||
var cmd = new ListCommand();
|
||||
|
||||
for (int i = 2; i < tokens.Count; i++)
|
||||
{
|
||||
var token = tokens[i].ToLower();
|
||||
if (token == "--verbose" || token == "-v")
|
||||
{
|
||||
cmd.Verbose = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option for list: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private StepCommand ParseReplAddCommand(List<string> tokens)
|
||||
{
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step add <run|uses> <script|action> [options]");
|
||||
}
|
||||
|
||||
var type = tokens[2].ToLower();
|
||||
|
||||
if (type == "run")
|
||||
{
|
||||
return ParseReplAddRunCommand(tokens);
|
||||
}
|
||||
else if (type == "uses")
|
||||
{
|
||||
return ParseReplAddUsesCommand(tokens);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
|
||||
}
|
||||
}
|
||||
|
||||
private AddRunCommand ParseReplAddRunCommand(List<string> tokens)
|
||||
{
|
||||
// !step add run "script" [options]
|
||||
if (tokens.Count < 4)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step add run \"<script>\" [--name \"...\"] [--shell <shell>] [--at|--after|--before <n>]");
|
||||
}
|
||||
|
||||
var cmd = new AddRunCommand
|
||||
{
|
||||
Script = tokens[3],
|
||||
Env = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 4; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--shell":
|
||||
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
|
||||
break;
|
||||
case "--working-directory":
|
||||
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private AddUsesCommand ParseReplAddUsesCommand(List<string> tokens)
|
||||
{
|
||||
// !step add uses "action@ref" [options]
|
||||
if (tokens.Count < 4)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step add uses <action@ref> [--name \"...\"] [--with key=value] [--at|--after|--before <n>]");
|
||||
}
|
||||
|
||||
var cmd = new AddUsesCommand
|
||||
{
|
||||
Action = tokens[3],
|
||||
With = new Dictionary<string, string>(),
|
||||
Env = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 4; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--with":
|
||||
ParseKeyValueArg(tokens, ref i, cmd.With);
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.With.Count == 0)
|
||||
cmd.With = null;
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private EditCommand ParseReplEditCommand(List<string> tokens)
|
||||
{
|
||||
// !step edit <index> [modifications]
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step edit <index> [--name \"...\"] [--script \"...\"] [--if \"...\"]");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var index))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
var cmd = new EditCommand
|
||||
{
|
||||
Index = index,
|
||||
With = new Dictionary<string, string>(),
|
||||
Env = new Dictionary<string, string>(),
|
||||
RemoveWith = new List<string>(),
|
||||
RemoveEnv = new List<string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 3; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--script":
|
||||
cmd.Script = GetNextArg(tokens, ref i, "--script");
|
||||
break;
|
||||
case "--action":
|
||||
cmd.Action = GetNextArg(tokens, ref i, "--action");
|
||||
break;
|
||||
case "--shell":
|
||||
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
|
||||
break;
|
||||
case "--working-directory":
|
||||
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--with":
|
||||
ParseKeyValueArg(tokens, ref i, cmd.With);
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--remove-with":
|
||||
cmd.RemoveWith.Add(GetNextArg(tokens, ref i, "--remove-with"));
|
||||
break;
|
||||
case "--remove-env":
|
||||
cmd.RemoveEnv.Add(GetNextArg(tokens, ref i, "--remove-env"));
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--no-continue-on-error":
|
||||
cmd.ContinueOnError = false;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty collections
|
||||
if (cmd.With.Count == 0)
|
||||
cmd.With = null;
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
if (cmd.RemoveWith.Count == 0)
|
||||
cmd.RemoveWith = null;
|
||||
if (cmd.RemoveEnv.Count == 0)
|
||||
cmd.RemoveEnv = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private RemoveCommand ParseReplRemoveCommand(List<string> tokens)
|
||||
{
|
||||
// !step remove <index>
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step remove <index>");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var index))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
return new RemoveCommand { Index = index };
|
||||
}
|
||||
|
||||
private MoveCommand ParseReplMoveCommand(List<string> tokens)
|
||||
{
|
||||
// !step move <from> --to|--after|--before <index>|--first|--last
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step move <from> --to|--after|--before <index>|--first|--last");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var fromIndex))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid from index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
var cmd = new MoveCommand { FromIndex = fromIndex };
|
||||
|
||||
// Parse position
|
||||
for (int i = 3; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--to":
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, opt));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.Position == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Move command requires a position (--to, --after, --before, --first, or --last)");
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private ExportCommand ParseReplExportCommand(List<string> tokens)
|
||||
{
|
||||
var cmd = new ExportCommand();
|
||||
|
||||
for (int i = 2; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--changes-only":
|
||||
cmd.ChangesOnly = true;
|
||||
break;
|
||||
case "--with-comments":
|
||||
cmd.WithComments = true;
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
#region Argument Helpers
|
||||
|
||||
private string GetNextArg(List<string> tokens, ref int index, string optName)
|
||||
{
|
||||
if (index + 1 >= tokens.Count)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Option {optName} requires a value");
|
||||
}
|
||||
return tokens[++index];
|
||||
}
|
||||
|
||||
private int GetNextArgInt(List<string> tokens, ref int index, string optName)
|
||||
{
|
||||
var value = GetNextArg(tokens, ref index, optName);
|
||||
if (!int.TryParse(value, out var result))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Option {optName} requires an integer value, got: {value}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ParseEnvArg(List<string> tokens, ref int index, Dictionary<string, string> env)
|
||||
{
|
||||
ParseKeyValueArg(tokens, ref index, env);
|
||||
}
|
||||
|
||||
private void ParseKeyValueArg(List<string> tokens, ref int index, Dictionary<string, string> dict)
|
||||
{
|
||||
var value = GetNextArg(tokens, ref index, "key=value");
|
||||
var eqIndex = value.IndexOf('=');
|
||||
if (eqIndex <= 0)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Expected key=value format, got: {value}");
|
||||
}
|
||||
var key = value.Substring(0, eqIndex);
|
||||
var val = value.Substring(eqIndex + 1);
|
||||
dict[key] = val;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
92
src/Runner.Worker/Dap/StepCommands/StepCommandResult.cs
Normal file
92
src/Runner.Worker/Dap/StepCommands/StepCommandResult.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Standardized result from step command execution.
|
||||
/// Used by both REPL and JSON API handlers to return consistent responses.
|
||||
/// </summary>
|
||||
public class StepCommandResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the command executed successfully.
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message describing the result (for REPL display).
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code for programmatic handling (e.g., "INVALID_INDEX", "PARSE_ERROR").
|
||||
/// Null if successful.
|
||||
/// </summary>
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Command-specific result data (e.g., list of steps, step info, YAML export).
|
||||
/// Type varies by command - consumers should check Success before using.
|
||||
/// </summary>
|
||||
public object Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static StepCommandResult Ok(string message, object result = null)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = message,
|
||||
Result = result
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static StepCommandResult Fail(string errorCode, string message)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = false,
|
||||
Error = errorCode,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for step commands.
|
||||
/// </summary>
|
||||
public static class StepCommandErrors
|
||||
{
|
||||
public const string InvalidIndex = "INVALID_INDEX";
|
||||
public const string InvalidCommand = "INVALID_COMMAND";
|
||||
public const string InvalidOption = "INVALID_OPTION";
|
||||
public const string InvalidType = "INVALID_TYPE";
|
||||
public const string ActionDownloadFailed = "ACTION_DOWNLOAD_FAILED";
|
||||
public const string ParseError = "PARSE_ERROR";
|
||||
public const string NotPaused = "NOT_PAUSED";
|
||||
public const string NoContext = "NO_CONTEXT";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown during step command parsing or execution.
|
||||
/// </summary>
|
||||
public class StepCommandException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public StepCommandException(string errorCode, string message) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public StepCommandResult ToResult()
|
||||
{
|
||||
return StepCommandResult.Fail(ErrorCode, Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
444
src/Runner.Worker/Dap/StepCommands/StepFactory.cs
Normal file
444
src/Runner.Worker/Dap/StepCommands/StepFactory.cs
Normal file
@@ -0,0 +1,444 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for creating ActionStep and IActionRunner objects at runtime.
|
||||
/// Used by step commands to dynamically add steps during debug sessions.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepFactory))]
|
||||
public interface IStepFactory : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new run step (script step).
|
||||
/// </summary>
|
||||
/// <param name="script">The script to execute</param>
|
||||
/// <param name="name">Optional display name for the step</param>
|
||||
/// <param name="shell">Optional shell (bash, sh, pwsh, python, etc.)</param>
|
||||
/// <param name="workingDirectory">Optional working directory</param>
|
||||
/// <param name="env">Optional environment variables</param>
|
||||
/// <param name="condition">Optional condition expression (defaults to "success()")</param>
|
||||
/// <param name="continueOnError">Whether to continue on error (defaults to false)</param>
|
||||
/// <param name="timeoutMinutes">Optional timeout in minutes</param>
|
||||
/// <returns>A configured ActionStep with ScriptReference</returns>
|
||||
ActionStep CreateRunStep(
|
||||
string script,
|
||||
string name = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new uses step (action step).
|
||||
/// </summary>
|
||||
/// <param name="actionReference">The action reference (e.g., "actions/checkout@v4", "owner/repo@ref", "./local-action")</param>
|
||||
/// <param name="name">Optional display name for the step</param>
|
||||
/// <param name="with">Optional input parameters for the action</param>
|
||||
/// <param name="env">Optional environment variables</param>
|
||||
/// <param name="condition">Optional condition expression (defaults to "success()")</param>
|
||||
/// <param name="continueOnError">Whether to continue on error (defaults to false)</param>
|
||||
/// <param name="timeoutMinutes">Optional timeout in minutes</param>
|
||||
/// <returns>A configured ActionStep with RepositoryPathReference or ContainerRegistryReference</returns>
|
||||
ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string name = null,
|
||||
Dictionary<string, string> with = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an ActionStep in an IActionRunner for execution.
|
||||
/// </summary>
|
||||
/// <param name="step">The ActionStep to wrap</param>
|
||||
/// <param name="jobContext">The job execution context</param>
|
||||
/// <param name="stage">The execution stage (Main, Pre, or Post)</param>
|
||||
/// <returns>An IActionRunner ready for execution</returns>
|
||||
IActionRunner WrapInRunner(
|
||||
ActionStep step,
|
||||
IExecutionContext jobContext,
|
||||
ActionRunStage stage = ActionRunStage.Main);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of an action reference string.
|
||||
/// </summary>
|
||||
public class ParsedActionReference
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of action reference.
|
||||
/// </summary>
|
||||
public ActionReferenceType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For GitHub actions: "owner/repo". For local: null. For docker: null.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For GitHub actions: the git ref (tag/branch/commit). For local/docker: null.
|
||||
/// </summary>
|
||||
public string Ref { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For actions in subdirectories: the path within the repo. For local: the full path.
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For docker actions: the image reference.
|
||||
/// </summary>
|
||||
public string Image { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of action references.
|
||||
/// </summary>
|
||||
public enum ActionReferenceType
|
||||
{
|
||||
/// <summary>GitHub repository action (e.g., "actions/checkout@v4")</summary>
|
||||
Repository,
|
||||
/// <summary>Local action (e.g., "./.github/actions/my-action")</summary>
|
||||
Local,
|
||||
/// <summary>Docker container action (e.g., "docker://alpine:latest")</summary>
|
||||
Docker
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating ActionStep and IActionRunner objects at runtime.
|
||||
/// </summary>
|
||||
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(
|
||||
@"^(?<name>[^/@]+/[^/@]+)(?:/(?<path>[^@]+))?@(?<ref>.+)$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ActionStep CreateRunStep(
|
||||
string script,
|
||||
string name = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null,
|
||||
Dictionary<string, string> 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 = $"_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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string name = null,
|
||||
Dictionary<string, string> with = null,
|
||||
Dictionary<string, string> 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 = $"_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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<IActionRunner>();
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Parses an action reference string into its components.
|
||||
/// </summary>
|
||||
/// <param name="actionReference">The action reference (e.g., "actions/checkout@v4")</param>
|
||||
/// <returns>Parsed action reference components</returns>
|
||||
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'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for run step inputs (script, shell, working-directory).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for action "with" inputs.
|
||||
/// </summary>
|
||||
private MappingToken CreateWithInputs(Dictionary<string, string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for environment variables.
|
||||
/// </summary>
|
||||
private MappingToken CreateEnvToken(Dictionary<string, string> 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
|
||||
}
|
||||
}
|
||||
215
src/Runner.Worker/Dap/StepCommands/StepInfo.cs
Normal file
215
src/Runner.Worker/Dap/StepCommands/StepInfo.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Step status for display and manipulation.
|
||||
/// </summary>
|
||||
public enum StepStatus
|
||||
{
|
||||
/// <summary>Step has completed execution.</summary>
|
||||
Completed,
|
||||
/// <summary>Step is currently executing or paused.</summary>
|
||||
Current,
|
||||
/// <summary>Step is pending execution.</summary>
|
||||
Pending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of change applied to a step.
|
||||
/// </summary>
|
||||
public enum ChangeType
|
||||
{
|
||||
/// <summary>Step was added during debug session.</summary>
|
||||
Added,
|
||||
/// <summary>Step was modified during debug session.</summary>
|
||||
Modified,
|
||||
/// <summary>Step was removed during debug session.</summary>
|
||||
Removed,
|
||||
/// <summary>Step was moved during debug session.</summary>
|
||||
Moved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified step information for display, manipulation, and serialization.
|
||||
/// Wraps both the underlying ActionStep and IStep with metadata about status and changes.
|
||||
/// </summary>
|
||||
public class StepInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 1-based index of the step in the combined list (completed + current + pending).
|
||||
/// </summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the step.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Step type: "run" or "uses"
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type detail: action reference for uses steps, script preview for run steps.
|
||||
/// </summary>
|
||||
public string TypeDetail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current execution status of the step.
|
||||
/// </summary>
|
||||
public StepStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Change type if the step was modified during this debug session, null otherwise.
|
||||
/// </summary>
|
||||
public ChangeType? Change { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying ActionStep (for serialization and modification).
|
||||
/// May be null for non-action steps (e.g., JobExtensionRunner).
|
||||
/// </summary>
|
||||
public ActionStep Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying IStep (for execution).
|
||||
/// </summary>
|
||||
public IStep Step { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original index before any moves (for change tracking).
|
||||
/// Only set when Change == Moved.
|
||||
/// </summary>
|
||||
public int? OriginalIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StepInfo from an IStep.
|
||||
/// </summary>
|
||||
public static StepInfo FromStep(IStep step, int index, StepStatus status)
|
||||
{
|
||||
var info = new StepInfo
|
||||
{
|
||||
Index = index,
|
||||
Name = step.DisplayName ?? "Unknown step",
|
||||
Status = status,
|
||||
Step = step
|
||||
};
|
||||
|
||||
// Try to extract ActionStep from IActionRunner
|
||||
if (step is IActionRunner actionRunner && actionRunner.Action != null)
|
||||
{
|
||||
info.Action = actionRunner.Action;
|
||||
PopulateFromActionStep(info, actionRunner.Action);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-action step (e.g., JobExtensionRunner)
|
||||
info.Type = "extension";
|
||||
info.TypeDetail = step.GetType().Name;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StepInfo from an ActionStep.
|
||||
/// </summary>
|
||||
public static StepInfo FromActionStep(ActionStep action, int index, StepStatus status)
|
||||
{
|
||||
var info = new StepInfo
|
||||
{
|
||||
Index = index,
|
||||
Name = action.DisplayName ?? "Unknown step",
|
||||
Status = status,
|
||||
Action = action
|
||||
};
|
||||
|
||||
PopulateFromActionStep(info, action);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates type information from an ActionStep.
|
||||
/// </summary>
|
||||
private static void PopulateFromActionStep(StepInfo info, ActionStep action)
|
||||
{
|
||||
switch (action.Reference)
|
||||
{
|
||||
case ScriptReference:
|
||||
info.Type = "run";
|
||||
info.TypeDetail = GetScriptPreview(action);
|
||||
break;
|
||||
|
||||
case RepositoryPathReference repoRef:
|
||||
info.Type = "uses";
|
||||
info.TypeDetail = BuildUsesReference(repoRef);
|
||||
break;
|
||||
|
||||
case ContainerRegistryReference containerRef:
|
||||
info.Type = "uses";
|
||||
info.TypeDetail = $"docker://{containerRef.Image}";
|
||||
break;
|
||||
|
||||
default:
|
||||
info.Type = "unknown";
|
||||
info.TypeDetail = action.Reference?.GetType().Name ?? "null";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a preview of the script (first line, truncated).
|
||||
/// </summary>
|
||||
private static string GetScriptPreview(ActionStep action)
|
||||
{
|
||||
if (action.Inputs is GitHub.DistributedTask.ObjectTemplating.Tokens.MappingToken mapping)
|
||||
{
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var key = pair.Key?.ToString();
|
||||
if (string.Equals(key, "script", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var script = pair.Value?.ToString() ?? "";
|
||||
// Get first line, truncate if too long
|
||||
var firstLine = script.Split('\n')[0].Trim();
|
||||
if (firstLine.Length > 40)
|
||||
{
|
||||
return firstLine.Substring(0, 37) + "...";
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "(script)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a uses reference string from a RepositoryPathReference.
|
||||
/// </summary>
|
||||
private static string BuildUsesReference(RepositoryPathReference repoRef)
|
||||
{
|
||||
// Local action
|
||||
if (string.Equals(repoRef.RepositoryType, "self", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return repoRef.Path ?? ".";
|
||||
}
|
||||
|
||||
// Remote action
|
||||
var name = repoRef.Name ?? "";
|
||||
var refValue = repoRef.Ref ?? "";
|
||||
var path = repoRef.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(path) && path != "/" && path != ".")
|
||||
{
|
||||
if (path.StartsWith("/"))
|
||||
{
|
||||
path = path.Substring(1);
|
||||
}
|
||||
return $"{name}/{path}@{refValue}";
|
||||
}
|
||||
|
||||
return $"{name}@{refValue}";
|
||||
}
|
||||
}
|
||||
}
|
||||
642
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs
Normal file
642
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs
Normal file
@@ -0,0 +1,642 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for manipulating job steps during a debug session.
|
||||
/// Provides query and mutation operations on the step queue with change tracking.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepManipulator))]
|
||||
public interface IStepManipulator : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialize the manipulator with job context and current execution state.
|
||||
/// Must be called before any other operations.
|
||||
/// </summary>
|
||||
/// <param name="jobContext">The job execution context containing the step queues.</param>
|
||||
/// <param name="currentStepIndex">The 1-based index of the currently executing step, or 0 if no step is executing.</param>
|
||||
void Initialize(IExecutionContext jobContext, int currentStepIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the current step index (called as steps complete).
|
||||
/// </summary>
|
||||
/// <param name="index">The new 1-based index of the current step.</param>
|
||||
void UpdateCurrentIndex(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a completed step to the history (for step-back support).
|
||||
/// </summary>
|
||||
/// <param name="step">The step that completed.</param>
|
||||
void AddCompletedStep(IStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current step that is executing/paused (if any).
|
||||
/// </summary>
|
||||
IStep CurrentStep { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all steps (completed + current + pending) as a unified list.
|
||||
/// </summary>
|
||||
/// <returns>List of StepInfo ordered by index (1-based).</returns>
|
||||
IReadOnlyList<StepInfo> GetAllSteps();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific step by 1-based index.
|
||||
/// </summary>
|
||||
/// <param name="index">1-based index of the step.</param>
|
||||
/// <returns>The StepInfo, or null if index is out of range.</returns>
|
||||
StepInfo GetStep(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of pending steps (not yet executed).
|
||||
/// </summary>
|
||||
int GetPendingCount();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the 1-based index of the first pending step.
|
||||
/// </summary>
|
||||
/// <returns>The first pending index, or -1 if no pending steps.</returns>
|
||||
int GetFirstPendingIndex();
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a step at the specified position.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to insert.</param>
|
||||
/// <param name="position">The position specification (At, After, Before, First, Last).</param>
|
||||
/// <returns>The 1-based index where the step was inserted.</returns>
|
||||
/// <exception cref="StepCommandException">If the position is invalid.</exception>
|
||||
int InsertStep(IStep step, StepPosition position);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a step at the specified 1-based index.
|
||||
/// </summary>
|
||||
/// <param name="index">The 1-based index of the step to remove.</param>
|
||||
/// <exception cref="StepCommandException">If the index is invalid or step cannot be removed.</exception>
|
||||
void RemoveStep(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a step from one position to another.
|
||||
/// </summary>
|
||||
/// <param name="fromIndex">The 1-based index of the step to move.</param>
|
||||
/// <param name="position">The target position specification.</param>
|
||||
/// <returns>The new 1-based index of the step.</returns>
|
||||
/// <exception cref="StepCommandException">If the move is invalid.</exception>
|
||||
int MoveStep(int fromIndex, StepPosition position);
|
||||
|
||||
/// <summary>
|
||||
/// Applies an edit to a step's ActionStep.
|
||||
/// </summary>
|
||||
/// <param name="index">The 1-based index of the step to edit.</param>
|
||||
/// <param name="edit">The edit action to apply.</param>
|
||||
/// <exception cref="StepCommandException">If the step cannot be edited.</exception>
|
||||
void EditStep(int index, Action<ActionStep> edit);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all changes made during this session.
|
||||
/// </summary>
|
||||
/// <returns>List of change records in chronological order.</returns>
|
||||
IReadOnlyList<StepChange> GetChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Records the original state for change tracking.
|
||||
/// Should be called when the debug session starts.
|
||||
/// </summary>
|
||||
void RecordOriginalState();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded changes (for testing or reset).
|
||||
/// </summary>
|
||||
void ClearChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of step manipulation operations.
|
||||
/// Manages the job step queue and tracks all modifications for export.
|
||||
/// </summary>
|
||||
public sealed class StepManipulator : RunnerService, IStepManipulator
|
||||
{
|
||||
private IExecutionContext _jobContext;
|
||||
private int _currentStepIndex;
|
||||
private IStep _currentStep;
|
||||
|
||||
// Completed steps (for display and step-back)
|
||||
private readonly List<IStep> _completedSteps = new List<IStep>();
|
||||
|
||||
// Original state for change tracking
|
||||
private List<StepInfo> _originalSteps;
|
||||
|
||||
// Change history
|
||||
private readonly List<StepChange> _changes = new List<StepChange>();
|
||||
|
||||
// Track which steps have been modified (by step Name/Id)
|
||||
private readonly HashSet<Guid> _modifiedStepIds = new HashSet<Guid>();
|
||||
private readonly HashSet<Guid> _addedStepIds = new HashSet<Guid>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStep CurrentStep => _currentStep;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialize(IExecutionContext jobContext, int currentStepIndex)
|
||||
{
|
||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||
|
||||
_jobContext = jobContext;
|
||||
_currentStepIndex = currentStepIndex;
|
||||
_currentStep = null;
|
||||
_completedSteps.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateCurrentIndex(int index)
|
||||
{
|
||||
_currentStepIndex = index;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddCompletedStep(IStep step)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
_completedSteps.Add(step);
|
||||
_currentStep = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current step (the one that is executing/paused).
|
||||
/// </summary>
|
||||
public void SetCurrentStep(IStep step)
|
||||
{
|
||||
_currentStep = step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<StepInfo> GetAllSteps()
|
||||
{
|
||||
var result = new List<StepInfo>();
|
||||
int index = 1;
|
||||
|
||||
// Add completed steps
|
||||
foreach (var step in _completedSteps)
|
||||
{
|
||||
var info = StepInfo.FromStep(step, index, StepStatus.Completed);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Add current step if present
|
||||
if (_currentStep != null)
|
||||
{
|
||||
var info = StepInfo.FromStep(_currentStep, index, StepStatus.Current);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Add pending steps from queue
|
||||
if (_jobContext?.JobSteps != null)
|
||||
{
|
||||
foreach (var step in _jobContext.JobSteps)
|
||||
{
|
||||
var info = StepInfo.FromStep(step, index, StepStatus.Pending);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StepInfo GetStep(int index)
|
||||
{
|
||||
if (index < 1)
|
||||
return null;
|
||||
|
||||
var allSteps = GetAllSteps();
|
||||
if (index > allSteps.Count)
|
||||
return null;
|
||||
|
||||
return allSteps[index - 1];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetPendingCount()
|
||||
{
|
||||
return _jobContext?.JobSteps?.Count ?? 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetFirstPendingIndex()
|
||||
{
|
||||
var completedCount = _completedSteps.Count;
|
||||
var currentCount = _currentStep != null ? 1 : 0;
|
||||
var pendingCount = GetPendingCount();
|
||||
|
||||
if (pendingCount == 0)
|
||||
return -1;
|
||||
|
||||
return completedCount + currentCount + 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int InsertStep(IStep step, StepPosition position)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
ValidateInitialized();
|
||||
|
||||
// Calculate the insertion index within the pending queue (0-based)
|
||||
int insertAt = CalculateInsertIndex(position);
|
||||
|
||||
// Convert queue to list for manipulation
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Insert the step
|
||||
pending.Insert(insertAt, step);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Calculate the 1-based index in the overall step list
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var newIndex = firstPendingIndex + insertAt;
|
||||
|
||||
// Track the change
|
||||
var stepInfo = StepInfo.FromStep(step, newIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_addedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
_changes.Add(StepChange.Added(stepInfo, newIndex));
|
||||
|
||||
Trace.Info($"Inserted step '{step.DisplayName}' at position {newIndex} (queue index {insertAt})");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RemoveStep(int index)
|
||||
{
|
||||
ValidateInitialized();
|
||||
var stepInfo = ValidatePendingIndex(index);
|
||||
|
||||
// Calculate queue index (0-based)
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var queueIndex = index - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove the step
|
||||
var removedStep = pending[queueIndex];
|
||||
pending.RemoveAt(queueIndex);
|
||||
|
||||
// Re-queue remaining steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Track the change
|
||||
var removedInfo = StepInfo.FromStep(removedStep, index, StepStatus.Pending);
|
||||
removedInfo.Change = ChangeType.Removed;
|
||||
_changes.Add(StepChange.Removed(removedInfo));
|
||||
|
||||
Trace.Info($"Removed step '{removedStep.DisplayName}' from position {index}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int MoveStep(int fromIndex, StepPosition position)
|
||||
{
|
||||
ValidateInitialized();
|
||||
var stepInfo = ValidatePendingIndex(fromIndex);
|
||||
|
||||
// Calculate queue indices - BEFORE modifying the queue
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var fromQueueIndex = fromIndex - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove from original position
|
||||
var step = pending[fromQueueIndex];
|
||||
pending.RemoveAt(fromQueueIndex);
|
||||
|
||||
// Calculate new position (within the now-smaller list)
|
||||
// Pass firstPendingIndex since the queue is now cleared
|
||||
var toQueueIndex = CalculateMoveTargetIndex(position, pending.Count, fromQueueIndex, firstPendingIndex);
|
||||
|
||||
// Insert at new position
|
||||
pending.Insert(toQueueIndex, step);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Calculate new 1-based index
|
||||
var newIndex = firstPendingIndex + toQueueIndex;
|
||||
|
||||
// Track the change
|
||||
var originalInfo = StepInfo.FromStep(step, fromIndex, StepStatus.Pending);
|
||||
_changes.Add(StepChange.Moved(originalInfo, newIndex));
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
Trace.Info($"Moved step '{step.DisplayName}' from position {fromIndex} to {newIndex}");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void EditStep(int index, Action<ActionStep> edit)
|
||||
{
|
||||
ArgUtil.NotNull(edit, nameof(edit));
|
||||
ValidateInitialized();
|
||||
|
||||
var stepInfo = ValidatePendingIndex(index);
|
||||
|
||||
// Get the IActionRunner to access the ActionStep
|
||||
if (stepInfo.Step is not IActionRunner runner || runner.Action == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step at index {index} is not an action step and cannot be edited.");
|
||||
}
|
||||
|
||||
// Capture original state for change tracking
|
||||
var originalInfo = StepInfo.FromStep(stepInfo.Step, index, StepStatus.Pending);
|
||||
|
||||
// Apply the edit
|
||||
edit(runner.Action);
|
||||
|
||||
// Update display name if it changed
|
||||
if (runner.Action.DisplayName != originalInfo.Name)
|
||||
{
|
||||
stepInfo.Name = runner.Action.DisplayName;
|
||||
}
|
||||
|
||||
// Track the change
|
||||
var modifiedInfo = StepInfo.FromStep(stepInfo.Step, index, StepStatus.Pending);
|
||||
modifiedInfo.Change = ChangeType.Modified;
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
|
||||
_changes.Add(StepChange.Modified(originalInfo, modifiedInfo));
|
||||
|
||||
Trace.Info($"Edited step '{runner.Action.DisplayName}' at position {index}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<StepChange> GetChanges()
|
||||
{
|
||||
return _changes.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RecordOriginalState()
|
||||
{
|
||||
_originalSteps = GetAllSteps().ToList();
|
||||
Trace.Info($"Recorded original state: {_originalSteps.Count} steps");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ClearChanges()
|
||||
{
|
||||
_changes.Clear();
|
||||
_modifiedStepIds.Clear();
|
||||
_addedStepIds.Clear();
|
||||
_originalSteps = null;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the manipulator has been initialized.
|
||||
/// </summary>
|
||||
private void ValidateInitialized()
|
||||
{
|
||||
if (_jobContext == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"StepManipulator has not been initialized. Call Initialize() first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the given index refers to a pending step that can be manipulated.
|
||||
/// </summary>
|
||||
/// <param name="index">1-based step index</param>
|
||||
/// <returns>The StepInfo at that index</returns>
|
||||
private StepInfo ValidatePendingIndex(int index)
|
||||
{
|
||||
var allSteps = GetAllSteps();
|
||||
var totalCount = allSteps.Count;
|
||||
var completedCount = _completedSteps.Count;
|
||||
var currentCount = _currentStep != null ? 1 : 0;
|
||||
var firstPendingIndex = completedCount + currentCount + 1;
|
||||
|
||||
if (index < 1 || index > totalCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Index {index} is out of range. Valid range: 1 to {totalCount}.");
|
||||
}
|
||||
|
||||
if (index <= completedCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {index} has already completed and cannot be modified.");
|
||||
}
|
||||
|
||||
if (index == completedCount + 1 && _currentStep != null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {index} is currently executing. Use step-back first to modify it.");
|
||||
}
|
||||
|
||||
return allSteps[index - 1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the 0-based index within the pending queue for insertion.
|
||||
/// </summary>
|
||||
private int CalculateInsertIndex(StepPosition position)
|
||||
{
|
||||
var pendingCount = GetPendingCount();
|
||||
|
||||
switch (position.Type)
|
||||
{
|
||||
case PositionType.Last:
|
||||
return pendingCount;
|
||||
|
||||
case PositionType.First:
|
||||
return 0;
|
||||
|
||||
case PositionType.At:
|
||||
{
|
||||
// Position.Index is 1-based overall index
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var queueIndex = position.Index.Value - firstPendingIndex;
|
||||
|
||||
if (queueIndex < 0)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert at position {position.Index} - that is before the first pending step.");
|
||||
}
|
||||
if (queueIndex > pendingCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert at position {position.Index} - only {pendingCount} pending steps.");
|
||||
}
|
||||
return queueIndex;
|
||||
}
|
||||
|
||||
case PositionType.After:
|
||||
{
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var afterOverallIndex = position.Index.Value;
|
||||
|
||||
// If "after" points to a completed/current step, insert at beginning of pending
|
||||
if (afterOverallIndex < firstPendingIndex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate queue index (after means +1)
|
||||
var queueIndex = afterOverallIndex - firstPendingIndex + 1;
|
||||
return Math.Min(queueIndex, pendingCount);
|
||||
}
|
||||
|
||||
case PositionType.Before:
|
||||
{
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var beforeOverallIndex = position.Index.Value;
|
||||
|
||||
// If "before" points to a completed/current step, that's an error
|
||||
if (beforeOverallIndex < firstPendingIndex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert before position {beforeOverallIndex} - it is not a pending step.");
|
||||
}
|
||||
|
||||
// Calculate queue index
|
||||
var queueIndex = beforeOverallIndex - firstPendingIndex;
|
||||
return Math.Max(0, queueIndex);
|
||||
}
|
||||
|
||||
default:
|
||||
return pendingCount; // Default to last
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the target index for a move operation.
|
||||
/// Note: This is called AFTER the item has been removed from the list,
|
||||
/// so we need to adjust indices that were after the removed item.
|
||||
/// </summary>
|
||||
/// <param name="position">The target position specification.</param>
|
||||
/// <param name="listCount">The count of items in the list after removal.</param>
|
||||
/// <param name="fromQueueIndex">The original queue index of the removed item.</param>
|
||||
/// <param name="firstPendingIndex">The first pending index (captured before queue was modified).</param>
|
||||
private int CalculateMoveTargetIndex(StepPosition position, int listCount, int fromQueueIndex, int firstPendingIndex)
|
||||
{
|
||||
switch (position.Type)
|
||||
{
|
||||
case PositionType.Last:
|
||||
return listCount;
|
||||
|
||||
case PositionType.First:
|
||||
return 0;
|
||||
|
||||
case PositionType.At:
|
||||
{
|
||||
var targetQueueIndex = position.Index.Value - firstPendingIndex;
|
||||
// Adjust for the fact that we removed the item first
|
||||
// Items that were after the removed item have shifted down by 1
|
||||
if (targetQueueIndex > fromQueueIndex)
|
||||
targetQueueIndex--;
|
||||
return Math.Max(0, Math.Min(targetQueueIndex, listCount));
|
||||
}
|
||||
|
||||
case PositionType.After:
|
||||
{
|
||||
var afterOverallIndex = position.Index.Value;
|
||||
if (afterOverallIndex < firstPendingIndex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// Convert to queue index
|
||||
var afterQueueIndex = afterOverallIndex - firstPendingIndex;
|
||||
// Adjust for removal: items that were after the removed item
|
||||
// have shifted down by 1 in the list
|
||||
if (afterQueueIndex > fromQueueIndex)
|
||||
afterQueueIndex--;
|
||||
// Insert after that position (so +1)
|
||||
var targetQueueIndex = afterQueueIndex + 1;
|
||||
return Math.Max(0, Math.Min(targetQueueIndex, listCount));
|
||||
}
|
||||
|
||||
case PositionType.Before:
|
||||
{
|
||||
var beforeOverallIndex = position.Index.Value;
|
||||
if (beforeOverallIndex < firstPendingIndex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot move before position {beforeOverallIndex} - it is not a pending step.");
|
||||
}
|
||||
var beforeQueueIndex = beforeOverallIndex - firstPendingIndex;
|
||||
// Adjust for removal: items that were after the removed item
|
||||
// have shifted down by 1 in the list
|
||||
if (beforeQueueIndex > fromQueueIndex)
|
||||
beforeQueueIndex--;
|
||||
return Math.Max(0, Math.Min(beforeQueueIndex, listCount));
|
||||
}
|
||||
|
||||
default:
|
||||
return listCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies change tracking info to a StepInfo based on recorded changes.
|
||||
/// </summary>
|
||||
private void ApplyChangeInfo(StepInfo info)
|
||||
{
|
||||
if (info.Step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
var actionId = runner.Action.Id;
|
||||
|
||||
if (_addedStepIds.Contains(actionId))
|
||||
{
|
||||
info.Change = ChangeType.Added;
|
||||
}
|
||||
else if (_modifiedStepIds.Contains(actionId))
|
||||
{
|
||||
// Check if it was moved or just modified
|
||||
var moveChange = _changes.LastOrDefault(c =>
|
||||
c.Type == ChangeType.Moved &&
|
||||
c.ModifiedStep?.Action?.Id == actionId);
|
||||
|
||||
info.Change = moveChange != null ? ChangeType.Moved : ChangeType.Modified;
|
||||
info.OriginalIndex = moveChange?.OriginalIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
557
src/Runner.Worker/Dap/StepCommands/StepSerializer.cs
Normal file
557
src/Runner.Worker/Dap/StepCommands/StepSerializer.cs
Normal file
@@ -0,0 +1,557 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
// Note: StepStatus, ChangeType, and StepInfo are now defined in StepInfo.cs
|
||||
|
||||
/// <summary>
|
||||
/// Interface for serializing ActionStep objects to YAML.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepSerializer))]
|
||||
public interface IStepSerializer : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a single ActionStep to YAML string.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to serialize</param>
|
||||
/// <returns>YAML representation of the step</returns>
|
||||
string ToYaml(ActionStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a collection of steps to YAML string.
|
||||
/// </summary>
|
||||
/// <param name="steps">The steps to serialize</param>
|
||||
/// <param name="withComments">Whether to include change comments (# ADDED, # MODIFIED)</param>
|
||||
/// <returns>YAML representation of the steps</returns>
|
||||
string ToYaml(IEnumerable<StepInfo> steps, bool withComments = false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes ActionStep objects to YAML string representation.
|
||||
/// Handles run steps (ScriptReference), uses steps (RepositoryPathReference),
|
||||
/// and docker steps (ContainerRegistryReference).
|
||||
/// </summary>
|
||||
public sealed class StepSerializer : RunnerService, IStepSerializer
|
||||
{
|
||||
// Input keys for script steps (from Inputs MappingToken)
|
||||
private const string ScriptInputKey = "script";
|
||||
private const string ShellInputKey = "shell";
|
||||
private const string WorkingDirectoryInputKey = "workingDirectory";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ToYaml(ActionStep step)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
WriteStep(sb, step, indent: 0, comment: null);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ToYaml(IEnumerable<StepInfo> steps, bool withComments = false)
|
||||
{
|
||||
if (steps == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("steps:");
|
||||
|
||||
foreach (var stepInfo in steps)
|
||||
{
|
||||
if (stepInfo.Action == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string comment = null;
|
||||
if (withComments && stepInfo.Change.HasValue)
|
||||
{
|
||||
comment = stepInfo.Change.Value switch
|
||||
{
|
||||
ChangeType.Added => "ADDED",
|
||||
ChangeType.Modified => "MODIFIED",
|
||||
ChangeType.Moved => "MOVED",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
WriteStep(sb, stepInfo.Action, indent: 2, comment: comment);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd() + Environment.NewLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a single step to the StringBuilder with proper YAML formatting.
|
||||
/// </summary>
|
||||
private void WriteStep(StringBuilder sb, ActionStep step, int indent, string comment)
|
||||
{
|
||||
var indentStr = new string(' ', indent);
|
||||
|
||||
// Determine step type and write accordingly
|
||||
switch (step.Reference)
|
||||
{
|
||||
case ScriptReference:
|
||||
WriteScriptStep(sb, step, indentStr, comment);
|
||||
break;
|
||||
|
||||
case RepositoryPathReference repoRef:
|
||||
WriteUsesStep(sb, step, repoRef, indentStr, comment);
|
||||
break;
|
||||
|
||||
case ContainerRegistryReference containerRef:
|
||||
WriteDockerStep(sb, step, containerRef, indentStr, comment);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown reference type - write minimal info
|
||||
sb.AppendLine($"{indentStr}- name: {EscapeYamlString(step.DisplayName ?? "Unknown step")}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a run step (ScriptReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteScriptStep(StringBuilder sb, ActionStep step, string indent, string comment)
|
||||
{
|
||||
// Extract script-specific inputs
|
||||
var script = GetInputValue(step.Inputs, ScriptInputKey);
|
||||
var shell = GetInputValue(step.Inputs, ShellInputKey);
|
||||
var workingDirectory = GetInputValue(step.Inputs, WorkingDirectoryInputKey);
|
||||
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// run: ...
|
||||
if (!string.IsNullOrEmpty(script))
|
||||
{
|
||||
WriteRunScript(sb, script, indent);
|
||||
}
|
||||
|
||||
// shell: ...
|
||||
if (!string.IsNullOrEmpty(shell))
|
||||
{
|
||||
sb.AppendLine($"{indent} shell: {shell}");
|
||||
}
|
||||
|
||||
// working-directory: ...
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
sb.AppendLine($"{indent} working-directory: {EscapeYamlString(workingDirectory)}");
|
||||
}
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a uses step (RepositoryPathReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteUsesStep(StringBuilder sb, ActionStep step, RepositoryPathReference repoRef, string indent, string comment)
|
||||
{
|
||||
// Build the uses value
|
||||
var usesValue = BuildUsesValue(repoRef);
|
||||
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// uses: ...
|
||||
sb.AppendLine($"{indent} uses: {usesValue}");
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// with: ...
|
||||
WriteMappingProperty(sb, "with", step.Inputs, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a docker step (ContainerRegistryReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteDockerStep(StringBuilder sb, ActionStep step, ContainerRegistryReference containerRef, string indent, string comment)
|
||||
{
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// uses: docker://...
|
||||
sb.AppendLine($"{indent} uses: docker://{containerRef.Image}");
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// with: ...
|
||||
WriteMappingProperty(sb, "with", step.Inputs, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the uses value from a RepositoryPathReference.
|
||||
/// </summary>
|
||||
private string BuildUsesValue(RepositoryPathReference repoRef)
|
||||
{
|
||||
// Local action: uses: ./path
|
||||
if (string.Equals(repoRef.RepositoryType, "self", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return repoRef.Path ?? ".";
|
||||
}
|
||||
|
||||
// Remote action: uses: owner/repo@ref or uses: owner/repo/path@ref
|
||||
var name = repoRef.Name ?? "";
|
||||
var refValue = repoRef.Ref ?? "";
|
||||
var path = repoRef.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(path) && path != "/" && path != ".")
|
||||
{
|
||||
// Normalize path - remove leading slash if present
|
||||
if (path.StartsWith("/"))
|
||||
{
|
||||
path = path.Substring(1);
|
||||
}
|
||||
return $"{name}/{path}@{refValue}";
|
||||
}
|
||||
|
||||
return $"{name}@{refValue}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a multi-line or single-line run script.
|
||||
/// </summary>
|
||||
private void WriteRunScript(StringBuilder sb, string script, string indent)
|
||||
{
|
||||
if (script.Contains("\n"))
|
||||
{
|
||||
// Multi-line script: use literal block scalar
|
||||
sb.AppendLine($"{indent} run: |");
|
||||
foreach (var line in script.Split('\n'))
|
||||
{
|
||||
// Trim trailing \r if present (Windows line endings)
|
||||
var cleanLine = line.TrimEnd('\r');
|
||||
sb.AppendLine($"{indent} {cleanLine}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-line script
|
||||
sb.AppendLine($"{indent} run: {EscapeYamlString(script)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the if condition if present and not default.
|
||||
/// </summary>
|
||||
private void WriteCondition(StringBuilder sb, string condition, string indent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't write default condition
|
||||
if (condition == "success()")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"{indent} if: {EscapeYamlString(condition)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a mapping property (env, with) if it has values.
|
||||
/// </summary>
|
||||
private void WriteMappingProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token is not MappingToken mapping || mapping.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"{indent} {propertyName}:");
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var key = pair.Key?.ToString() ?? "";
|
||||
var value = TokenToYamlValue(pair.Value);
|
||||
sb.AppendLine($"{indent} {key}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a boolean or expression property if not default.
|
||||
/// </summary>
|
||||
private void WriteBoolOrExprProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case BooleanToken boolToken:
|
||||
// Only write if true (false is default for continue-on-error)
|
||||
if (boolToken.Value)
|
||||
{
|
||||
sb.AppendLine($"{indent} {propertyName}: true");
|
||||
}
|
||||
break;
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {exprToken.ToString()}");
|
||||
break;
|
||||
|
||||
case StringToken strToken when strToken.Value == "true":
|
||||
sb.AppendLine($"{indent} {propertyName}: true");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a number or expression property if present.
|
||||
/// </summary>
|
||||
private void WriteNumberOrExprProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case NumberToken numToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {(int)numToken.Value}");
|
||||
break;
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {exprToken.ToString()}");
|
||||
break;
|
||||
|
||||
case StringToken strToken when int.TryParse(strToken.Value, out var intVal):
|
||||
sb.AppendLine($"{indent} {propertyName}: {intVal}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string value from a MappingToken by key.
|
||||
/// </summary>
|
||||
private string GetInputValue(TemplateToken inputs, string key)
|
||||
{
|
||||
if (inputs is not MappingToken mapping)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var keyStr = pair.Key?.ToString();
|
||||
if (string.Equals(keyStr, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return pair.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a TemplateToken to a YAML value string.
|
||||
/// </summary>
|
||||
private string TokenToYamlValue(TemplateToken token)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case NullToken:
|
||||
return "null";
|
||||
|
||||
case BooleanToken boolToken:
|
||||
return boolToken.Value ? "true" : "false";
|
||||
|
||||
case NumberToken numToken:
|
||||
// Use integer if possible, otherwise double
|
||||
if (numToken.Value == Math.Floor(numToken.Value))
|
||||
{
|
||||
return ((long)numToken.Value).ToString();
|
||||
}
|
||||
return numToken.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
case StringToken strToken:
|
||||
return EscapeYamlString(strToken.Value);
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
return exprToken.ToString();
|
||||
|
||||
default:
|
||||
// For complex types, just use ToString
|
||||
return EscapeYamlString(token.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a string for YAML output if necessary.
|
||||
/// </summary>
|
||||
private string EscapeYamlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "''";
|
||||
}
|
||||
|
||||
// Check if value needs quoting
|
||||
var needsQuoting = false;
|
||||
|
||||
// Quote if starts/ends with whitespace
|
||||
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[value.Length - 1]))
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
|
||||
// Quote if contains special characters that could be misinterpreted
|
||||
if (!needsQuoting)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == ':' || c == '#' || c == '\'' || c == '"' ||
|
||||
c == '{' || c == '}' || c == '[' || c == ']' ||
|
||||
c == ',' || c == '&' || c == '*' || c == '!' ||
|
||||
c == '|' || c == '>' || c == '%' || c == '@' ||
|
||||
c == '`' || c == '\n' || c == '\r')
|
||||
{
|
||||
needsQuoting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quote if it looks like a boolean, null, or number
|
||||
if (!needsQuoting)
|
||||
{
|
||||
var lower = value.ToLowerInvariant();
|
||||
if (lower == "true" || lower == "false" || lower == "null" ||
|
||||
lower == "yes" || lower == "no" || lower == "on" || lower == "off" ||
|
||||
lower == "~" || lower == "")
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
|
||||
// Check if it looks like a number
|
||||
if (double.TryParse(value, out _))
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsQuoting)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Use single quotes and escape single quotes by doubling them
|
||||
if (!value.Contains('\n') && !value.Contains('\r'))
|
||||
{
|
||||
return "'" + value.Replace("'", "''") + "'";
|
||||
}
|
||||
|
||||
// For multi-line strings, use double quotes with escapes
|
||||
return "\"" + value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t") + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
687
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs
Normal file
687
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs
Normal file
@@ -0,0 +1,687 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for StepCommandParser JSON API functionality.
|
||||
/// Tests the parsing of JSON commands for browser extension integration.
|
||||
/// </summary>
|
||||
public sealed class StepCommandParserJsonL0 : IDisposable
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private StepCommandParser _parser;
|
||||
|
||||
public StepCommandParserJsonL0()
|
||||
{
|
||||
_hc = new TestHostContext(this);
|
||||
_parser = new StepCommandParser();
|
||||
_parser.Initialize(_hc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
|
||||
#region IsStepCommand Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_DetectsJsonFormat()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.True(_parser.IsStepCommand("{\"cmd\":\"step.list\"}"));
|
||||
Assert.True(_parser.IsStepCommand("{\"cmd\": \"step.add\", \"type\": \"run\"}"));
|
||||
Assert.True(_parser.IsStepCommand(" { \"cmd\" : \"step.export\" } "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_RejectsInvalidJson()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.False(_parser.IsStepCommand("{\"cmd\":\"other.command\"}"));
|
||||
Assert.False(_parser.IsStepCommand("{\"action\":\"step.list\"}"));
|
||||
Assert.False(_parser.IsStepCommand("{\"type\":\"step\"}"));
|
||||
Assert.False(_parser.IsStepCommand("{}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_HandlesBothFormats()
|
||||
{
|
||||
// REPL format
|
||||
Assert.True(_parser.IsStepCommand("!step list"));
|
||||
Assert.True(_parser.IsStepCommand("!STEP ADD run \"test\""));
|
||||
|
||||
// JSON format
|
||||
Assert.True(_parser.IsStepCommand("{\"cmd\":\"step.list\"}"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON List Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ListCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.list\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ListCommand>(command);
|
||||
var listCmd = (ListCommand)command;
|
||||
Assert.False(listCmd.Verbose);
|
||||
Assert.True(listCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ListCommand_WithVerbose()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.list\",\"verbose\":true}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var listCmd = Assert.IsType<ListCommand>(command);
|
||||
Assert.True(listCmd.Verbose);
|
||||
Assert.True(listCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Add Run Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddRunCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"npm test\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal("npm test", addCmd.Script);
|
||||
Assert.True(addCmd.WasJsonInput);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddRunCommand_AllOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = @"{
|
||||
""cmd"": ""step.add"",
|
||||
""type"": ""run"",
|
||||
""script"": ""npm run build"",
|
||||
""name"": ""Build App"",
|
||||
""shell"": ""bash"",
|
||||
""workingDirectory"": ""./src"",
|
||||
""if"": ""success()"",
|
||||
""env"": {""NODE_ENV"": ""production"", ""CI"": ""true""},
|
||||
""continueOnError"": true,
|
||||
""timeout"": 30,
|
||||
""position"": {""after"": 3}
|
||||
}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal("npm run build", addCmd.Script);
|
||||
Assert.Equal("Build App", addCmd.Name);
|
||||
Assert.Equal("bash", addCmd.Shell);
|
||||
Assert.Equal("./src", addCmd.WorkingDirectory);
|
||||
Assert.Equal("success()", addCmd.Condition);
|
||||
Assert.NotNull(addCmd.Env);
|
||||
Assert.Equal("production", addCmd.Env["NODE_ENV"]);
|
||||
Assert.Equal("true", addCmd.Env["CI"]);
|
||||
Assert.True(addCmd.ContinueOnError);
|
||||
Assert.Equal(30, addCmd.Timeout);
|
||||
Assert.Equal(PositionType.After, addCmd.Position.Type);
|
||||
Assert.Equal(3, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddRunCommand_MissingScript_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("script", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Add Uses Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddUsesCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"uses\",\"action\":\"actions/checkout@v4\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(command);
|
||||
Assert.Equal("actions/checkout@v4", addCmd.Action);
|
||||
Assert.True(addCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddUsesCommand_AllOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = @"{
|
||||
""cmd"": ""step.add"",
|
||||
""type"": ""uses"",
|
||||
""action"": ""actions/setup-node@v4"",
|
||||
""name"": ""Setup Node.js"",
|
||||
""with"": {""node-version"": ""20"", ""cache"": ""npm""},
|
||||
""env"": {""NODE_OPTIONS"": ""--max-old-space-size=4096""},
|
||||
""if"": ""always()"",
|
||||
""continueOnError"": false,
|
||||
""timeout"": 10,
|
||||
""position"": {""at"": 2}
|
||||
}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(command);
|
||||
Assert.Equal("actions/setup-node@v4", addCmd.Action);
|
||||
Assert.Equal("Setup Node.js", addCmd.Name);
|
||||
Assert.NotNull(addCmd.With);
|
||||
Assert.Equal("20", addCmd.With["node-version"]);
|
||||
Assert.Equal("npm", addCmd.With["cache"]);
|
||||
Assert.NotNull(addCmd.Env);
|
||||
Assert.Equal("--max-old-space-size=4096", addCmd.Env["NODE_OPTIONS"]);
|
||||
Assert.Equal("always()", addCmd.Condition);
|
||||
Assert.False(addCmd.ContinueOnError);
|
||||
Assert.Equal(10, addCmd.Timeout);
|
||||
Assert.Equal(PositionType.At, addCmd.Position.Type);
|
||||
Assert.Equal(2, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddUsesCommand_MissingAction_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"uses\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("action", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddCommand_InvalidType_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"invalid\",\"script\":\"echo test\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.InvalidType, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Edit Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EditCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.edit\",\"index\":3,\"name\":\"New Name\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var editCmd = Assert.IsType<EditCommand>(command);
|
||||
Assert.Equal(3, editCmd.Index);
|
||||
Assert.Equal("New Name", editCmd.Name);
|
||||
Assert.True(editCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EditCommand_AllOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = @"{
|
||||
""cmd"": ""step.edit"",
|
||||
""index"": 4,
|
||||
""name"": ""Updated Step"",
|
||||
""script"": ""npm run test:ci"",
|
||||
""shell"": ""pwsh"",
|
||||
""workingDirectory"": ""./tests"",
|
||||
""if"": ""failure()"",
|
||||
""with"": {""key1"": ""value1""},
|
||||
""env"": {""DEBUG"": ""true""},
|
||||
""removeWith"": [""oldKey""],
|
||||
""removeEnv"": [""OBSOLETE""],
|
||||
""continueOnError"": true,
|
||||
""timeout"": 15
|
||||
}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var editCmd = Assert.IsType<EditCommand>(command);
|
||||
Assert.Equal(4, editCmd.Index);
|
||||
Assert.Equal("Updated Step", editCmd.Name);
|
||||
Assert.Equal("npm run test:ci", editCmd.Script);
|
||||
Assert.Equal("pwsh", editCmd.Shell);
|
||||
Assert.Equal("./tests", editCmd.WorkingDirectory);
|
||||
Assert.Equal("failure()", editCmd.Condition);
|
||||
Assert.NotNull(editCmd.With);
|
||||
Assert.Equal("value1", editCmd.With["key1"]);
|
||||
Assert.NotNull(editCmd.Env);
|
||||
Assert.Equal("true", editCmd.Env["DEBUG"]);
|
||||
Assert.NotNull(editCmd.RemoveWith);
|
||||
Assert.Contains("oldKey", editCmd.RemoveWith);
|
||||
Assert.NotNull(editCmd.RemoveEnv);
|
||||
Assert.Contains("OBSOLETE", editCmd.RemoveEnv);
|
||||
Assert.True(editCmd.ContinueOnError);
|
||||
Assert.Equal(15, editCmd.Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EditCommand_MissingIndex_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.edit\",\"name\":\"New Name\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("index", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Remove Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_RemoveCommand()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.remove\",\"index\":5}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var removeCmd = Assert.IsType<RemoveCommand>(command);
|
||||
Assert.Equal(5, removeCmd.Index);
|
||||
Assert.True(removeCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_RemoveCommand_MissingIndex_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.remove\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("index", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Move Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_After()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5,\"position\":{\"after\":2}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(5, moveCmd.FromIndex);
|
||||
Assert.Equal(PositionType.After, moveCmd.Position.Type);
|
||||
Assert.Equal(2, moveCmd.Position.Index);
|
||||
Assert.True(moveCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_Before()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":3,\"position\":{\"before\":5}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(3, moveCmd.FromIndex);
|
||||
Assert.Equal(PositionType.Before, moveCmd.Position.Type);
|
||||
Assert.Equal(5, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_First()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5,\"position\":{\"first\":true}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(PositionType.First, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_Last()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":2,\"position\":{\"last\":true}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(PositionType.Last, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_At()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5,\"position\":{\"at\":3}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(PositionType.At, moveCmd.Position.Type);
|
||||
Assert.Equal(3, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_MissingFrom_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"position\":{\"after\":2}}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("from", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_MissingPosition_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("position", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Export Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ExportCommand_Default()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.export\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var exportCmd = Assert.IsType<ExportCommand>(command);
|
||||
Assert.False(exportCmd.ChangesOnly);
|
||||
Assert.False(exportCmd.WithComments);
|
||||
Assert.True(exportCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ExportCommand_WithOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.export\",\"changesOnly\":true,\"withComments\":true}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var exportCmd = Assert.IsType<ExportCommand>(command);
|
||||
Assert.True(exportCmd.ChangesOnly);
|
||||
Assert.True(exportCmd.WithComments);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_InvalidJson_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{invalid json}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("Invalid JSON", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MissingCmd_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"action\":\"list\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("cmd", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_UnknownCommand_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.unknown\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.InvalidCommand, ex.ErrorCode);
|
||||
Assert.Contains("unknown", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EmptyJson_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Position Parsing Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_PositionDefaults_ToLast()
|
||||
{
|
||||
// Arrange - position is optional for add commands
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"test\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_NullPosition_DefaultsToLast()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"test\",\"position\":null}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EmptyPosition_DefaultsToLast()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"test\",\"position\":{}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WasJsonInput Flag Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_JsonInput_SetsWasJsonInputTrue()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.list\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(command.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ReplInput_SetsWasJsonInputFalse()
|
||||
{
|
||||
// Arrange
|
||||
var repl = "!step list";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(repl);
|
||||
|
||||
// Assert
|
||||
Assert.False(command.WasJsonInput);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
725
src/Test/L0/Worker/Dap/StepCommands/StepManipulatorL0.cs
Normal file
725
src/Test/L0/Worker/Dap/StepCommands/StepManipulatorL0.cs
Normal file
@@ -0,0 +1,725 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Dap.StepCommands
|
||||
{
|
||||
public sealed class StepManipulatorL0 : IDisposable
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private StepManipulator _manipulator;
|
||||
private Queue<IStep> _jobSteps;
|
||||
|
||||
public StepManipulatorL0()
|
||||
{
|
||||
_hc = new TestHostContext(this);
|
||||
_manipulator = new StepManipulator();
|
||||
_manipulator.Initialize(_hc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
|
||||
private void SetupJobContext(int pendingStepCount = 3)
|
||||
{
|
||||
_jobSteps = new Queue<IStep>();
|
||||
for (int i = 0; i < pendingStepCount; i++)
|
||||
{
|
||||
var step = CreateMockStep($"Step {i + 1}");
|
||||
_jobSteps.Enqueue(step);
|
||||
}
|
||||
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.Setup(x => x.JobSteps).Returns(_jobSteps);
|
||||
|
||||
_manipulator.Initialize(_ec.Object, 0);
|
||||
}
|
||||
|
||||
private IStep CreateMockStep(string displayName, bool isActionRunner = true)
|
||||
{
|
||||
if (isActionRunner)
|
||||
{
|
||||
var actionRunner = new Mock<IActionRunner>();
|
||||
var actionStep = new ActionStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"_step_{Guid.NewGuid():N}",
|
||||
DisplayName = displayName,
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = CreateScriptInputs("echo hello"),
|
||||
Condition = "success()"
|
||||
};
|
||||
actionRunner.Setup(x => x.DisplayName).Returns(displayName);
|
||||
actionRunner.Setup(x => x.Action).Returns(actionStep);
|
||||
actionRunner.Setup(x => x.Condition).Returns("success()");
|
||||
return actionRunner.Object;
|
||||
}
|
||||
else
|
||||
{
|
||||
var step = new Mock<IStep>();
|
||||
step.Setup(x => x.DisplayName).Returns(displayName);
|
||||
step.Setup(x => x.Condition).Returns("success()");
|
||||
return step.Object;
|
||||
}
|
||||
}
|
||||
|
||||
private MappingToken CreateScriptInputs(string script)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, "script"),
|
||||
new StringToken(null, null, null, script));
|
||||
return inputs;
|
||||
}
|
||||
|
||||
#region Initialization Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Initialize_SetsJobContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext();
|
||||
|
||||
// Act & Assert - no exception means success
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal(3, steps.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Initialize_ThrowsOnNullContext()
|
||||
{
|
||||
// Arrange
|
||||
var manipulator = new StepManipulator();
|
||||
manipulator.Initialize(_hc);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => manipulator.Initialize(null, 0));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllSteps Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_ReturnsPendingSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.All(steps, s => Assert.Equal(StepStatus.Pending, s.Status));
|
||||
Assert.Equal(1, steps[0].Index);
|
||||
Assert.Equal(2, steps[1].Index);
|
||||
Assert.Equal(3, steps[2].Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_IncludesCompletedSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var completedStep = CreateMockStep("Completed Step");
|
||||
_manipulator.AddCompletedStep(completedStep);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.Equal(StepStatus.Completed, steps[0].Status);
|
||||
Assert.Equal("Completed Step", steps[0].Name);
|
||||
Assert.Equal(StepStatus.Pending, steps[1].Status);
|
||||
Assert.Equal(StepStatus.Pending, steps[2].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_IncludesCurrentStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var currentStep = CreateMockStep("Current Step");
|
||||
_manipulator.SetCurrentStep(currentStep);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.Equal(StepStatus.Current, steps[0].Status);
|
||||
Assert.Equal("Current Step", steps[0].Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetStep_ReturnsCorrectStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var step = _manipulator.GetStep(2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(step);
|
||||
Assert.Equal(2, step.Index);
|
||||
Assert.Equal("Step 2", step.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetStep_ReturnsNullForInvalidIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(_manipulator.GetStep(0));
|
||||
Assert.Null(_manipulator.GetStep(4));
|
||||
Assert.Null(_manipulator.GetStep(-1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPendingCount Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetPendingCount_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(5);
|
||||
|
||||
// Act
|
||||
var count = _manipulator.GetPendingCount();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetFirstPendingIndex Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithNoPriorSteps_ReturnsOne()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithCompletedSteps_ReturnsCorrectIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed 1"));
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed 2"));
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithNoSteps_ReturnsNegativeOne()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(0);
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(-1, index);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InsertStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtLast_AppendsToQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
Assert.Equal(3, _jobSteps.Count);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("New Step", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtFirst_PrependsToQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("New Step", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtPosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.At(2));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("New Step", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
Assert.Equal("Step 3", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AfterPosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.After(1));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("New Step", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_BeforePosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.Before(3));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("Step 2", steps[1].Name);
|
||||
Assert.Equal("New Step", steps[2].Name);
|
||||
Assert.Equal("Step 3", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
_manipulator.InsertStep(newStep, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Added, changes[0].Type);
|
||||
Assert.Equal(3, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_RemovesFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RemoveStep(2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, _jobSteps.Count);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("Step 3", steps[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RemoveStep(2);
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Removed, changes[0].Type);
|
||||
Assert.Equal(2, changes[0].OriginalIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForCompletedStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(1));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForCurrentStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.SetCurrentStep(CreateMockStep("Current Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(1));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForInvalidIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(0));
|
||||
Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(4));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MoveStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToLast_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var newIndex = _manipulator.MoveStep(1, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, newIndex);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 2", steps[0].Name);
|
||||
Assert.Equal("Step 3", steps[1].Name);
|
||||
Assert.Equal("Step 1", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToFirst_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var newIndex = _manipulator.MoveStep(3, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, newIndex);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 3", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToMiddle_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(4);
|
||||
|
||||
// Act - move step 1 to after step 2 (which becomes position 2)
|
||||
var newIndex = _manipulator.MoveStep(1, StepPosition.After(2));
|
||||
|
||||
// Assert
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 2", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 3", steps[2].Name);
|
||||
Assert.Equal("Step 4", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.MoveStep(1, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Moved, changes[0].Type);
|
||||
Assert.Equal(1, changes[0].OriginalIndex);
|
||||
Assert.Equal(3, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EditStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_ModifiesActionStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.EditStep(2, step =>
|
||||
{
|
||||
step.DisplayName = "Modified Step";
|
||||
});
|
||||
|
||||
// Assert
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var actionRunner = steps[1].Step as IActionRunner;
|
||||
Assert.NotNull(actionRunner);
|
||||
Assert.Equal("Modified Step", actionRunner.Action.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.EditStep(2, step =>
|
||||
{
|
||||
step.DisplayName = "Modified Step";
|
||||
});
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Modified, changes[0].Type);
|
||||
Assert.Equal(2, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_ThrowsForCompletedStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() =>
|
||||
_manipulator.EditStep(1, step => { }));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Change Tracking Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RecordOriginalState_CapturesSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RecordOriginalState();
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
|
||||
// Assert - changes should be tracked
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ClearChanges_RemovesAllChanges()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
|
||||
// Act
|
||||
_manipulator.ClearChanges();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_manipulator.GetChanges());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MultipleOperations_TrackAllChanges()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
_manipulator.RemoveStep(1);
|
||||
_manipulator.MoveStep(2, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Equal(3, changes.Count);
|
||||
Assert.Equal(ChangeType.Added, changes[0].Type);
|
||||
Assert.Equal(ChangeType.Removed, changes[1].Type);
|
||||
Assert.Equal(ChangeType.Moved, changes[2].Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StepInfo Factory Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StepInfo_FromStep_ExtractsRunStepInfo()
|
||||
{
|
||||
// Arrange
|
||||
var step = CreateMockStep("Test Run Step");
|
||||
|
||||
// Act
|
||||
var info = StepInfo.FromStep(step, 1, StepStatus.Pending);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Run Step", info.Name);
|
||||
Assert.Equal("run", info.Type);
|
||||
Assert.Equal(StepStatus.Pending, info.Status);
|
||||
Assert.NotNull(info.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StepInfo_FromStep_HandlesNonActionRunner()
|
||||
{
|
||||
// Arrange
|
||||
var step = CreateMockStep("Extension Step", isActionRunner: false);
|
||||
|
||||
// Act
|
||||
var info = StepInfo.FromStep(step, 1, StepStatus.Pending);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Extension Step", info.Name);
|
||||
Assert.Equal("extension", info.Type);
|
||||
Assert.Null(info.Action);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user