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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
using Azure.Sdk.Tools.Cli.Tools;
using Microsoft.Extensions.Logging.Abstractions;
using Azure.Sdk.Tools.Cli.Helpers;
using System;
using System.IO;
using Azure.Sdk.Tools.Cli.Models.Responses;

namespace Azure.Sdk.Tools.Cli.Tests.Tools.CustomizedCodeUpdateTool;

Expand Down Expand Up @@ -63,9 +62,10 @@ public async Task Auto_NoChanges_TerminatesAtValidation()
{
var svc = new MockNoChangeLanguageService();
var resolver = new SingleResolver(svc);
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver);
var tsp = new MockTspHelper();
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver, tsp);
var pkg = CreateTempPackageDir();
var run = await tool.UpdateAsync("placeholder.tsp", packagePath: pkg, ct: CancellationToken.None);
var run = await tool.UpdateAsync("0123456789abcdef0123456789abcdef01234567", packagePath: pkg, ct: CancellationToken.None);
Assert.That(run.Session, Is.Not.Null, "Session should be created");
Assert.That(run.Session!.LastStage, Is.EqualTo(UpdateStage.Validated), "No changes now proceed through validation");
// Slim model: no stored API change count; reaching Validated implies no changes or all handled.
Expand All @@ -76,21 +76,23 @@ public async Task Auto_WithChanges_Validated()
{
var svc = new MockChangeLanguageService();
var resolver = new SingleResolver(svc);
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver);
var tsp = new MockTspHelper();
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver, tsp);
var pkg = CreateTempPackageDir();
var first = await tool.UpdateAsync("placeholder-change.tsp", packagePath: pkg, ct: CancellationToken.None);
var first = await tool.UpdateAsync("89abcdef0123456789abcdef0123456789abcdef", packagePath: pkg, ct: CancellationToken.None);
Assert.That(first.Session, Is.Not.Null);
Assert.That(first.Session.LastStage, Is.EqualTo(UpdateStage.Validated), "Single-pass should reach validated");
}

[Test]
public async Task Validation_Failure_Then_AutoFixes_Applied()
{
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(new MockNoChangeLanguageService()));
var tsp = new MockTspHelper();
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(new MockNoChangeLanguageService()), tsp);
int calls = 0; var svc = new TestLanguageServiceFailThenFix(() => calls++);
tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(svc));
tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(svc), tsp);
var pkg = CreateTempPackageDir();
var resp = await tool.UpdateAsync("spec.tsp", packagePath: pkg, ct: CancellationToken.None);
var resp = await tool.UpdateAsync("fedcba9876543210fedcba9876543210fedcba98", packagePath: pkg, ct: CancellationToken.None);
Assert.That(resp.Session, Is.Not.Null);
Assert.That(resp.Session!.LastStage, Is.EqualTo(UpdateStage.Validated));
Assert.That(resp.Session.RequiresManualIntervention, Is.False);
Expand Down Expand Up @@ -129,3 +131,11 @@ private class SingleResolver : IClientUpdateLanguageServiceResolver
public Task<IClientUpdateLanguageService?> ResolveAsync(string? packagePath, CancellationToken ct = default) => Task.FromResult<IClientUpdateLanguageService?>(_svc);
}
}

internal class MockTspHelper : ITspClientHelper
{
public Task<TspToolResponse> ConvertSwaggerAsync(string swaggerReadmePath, string outputDirectory, bool isArm, bool fullyCompatible, bool isCli, CancellationToken ct)
=> Task.FromResult(new TspToolResponse { IsSuccessful = true, TypeSpecProjectPath = outputDirectory });
public Task<TspToolResponse> UpdateGenerationAsync(string tspLocationPath, string outputDirectory, bool isCli, CancellationToken ct)
=> Task.FromResult(new TspToolResponse { IsSuccessful = true, TypeSpecProjectPath = outputDirectory });
}
12 changes: 10 additions & 2 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/ITspClientHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
namespace Azure.Sdk.Tools.Cli.Helpers;

/// <summary>
/// Abstraction for running common tsp-client commands (convert, update, diff, map, etc.).
/// Abstraction for running common tsp-client commands (convert, update, generate, init).
/// This centralizes npx invocation logic so multiple tools (Generate, Build, Update workflows)
/// can share a single implementation without instantiating each other.
/// can share a single implementation without duplicating code.
/// </summary>
public interface ITspClientHelper
{
/// <summary>
/// Runs `tsp-client convert --swagger-readme <readme> --output-dir <out>` with optional flags.
/// </summary>
Task<TspToolResponse> ConvertSwaggerAsync(string swaggerReadmePath, string outputDirectory, bool isArm, bool fullyCompatible, bool isCli, CancellationToken ct);

/// <summary>
/// Runs `tsp-client update` to regenerate a TypeSpec client into the specified output directory.
/// </summary>
/// <param name="tspLocationPath">Path to the tsp-location.yaml file.</param>
/// <param name="outputDirectory">Directory to place regenerated output (created if missing, must be empty or created new).</param>
/// <param name="isCli">True when invoked from CLI flow (suppresses duplicate streamed output in error text).</param>
Task<TspToolResponse> UpdateGenerationAsync(string tspLocationPath, string outputDirectory, bool isCli, CancellationToken ct);
}
33 changes: 33 additions & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TspClientHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,37 @@ public async Task<TspToolResponse> ConvertSwaggerAsync(string swaggerReadmePath,
TypeSpecProjectPath = outputDirectory
};
}

public async Task<TspToolResponse> UpdateGenerationAsync(string tspLocationPath, string outputDirectory, bool isCli, CancellationToken ct)
{
logger.LogInformation("tsp-client update (tsp-location): {loc} -> {out}", tspLocationPath, outputDirectory);
if (!File.Exists(tspLocationPath))
{
return new TspToolResponse { ResponseError = $"tsp-location.yaml not found at path: {tspLocationPath}" };
}
var workingDir = Path.GetDirectoryName(Path.GetFullPath(tspLocationPath))!;
var npxOptions = new NpxOptions(
"@azure-tools/typespec-client-generator-cli",
["tsp-client", "update"],
Comment thread
samvaity marked this conversation as resolved.
logOutputStream: true,
workingDirectory: workingDir
);

var result = await npxHelper.Run(npxOptions, ct);
if (result.ExitCode != 0)
{
return new TspToolResponse
{
ResponseError = isCli
? "Failed to regenerate TypeSpec client, see details in the above logs."
: "Failed to regenerate TypeSpec client, see generator output below" + Environment.NewLine + result.Output
};
}

return new TspToolResponse
{
IsSuccessful = true,
TypeSpecProjectPath = outputDirectory
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ public enum UpdateStage
/// <summary>
/// Post-apply validation (build / tests / lint) executed and results captured.
/// </summary>
Validated
Validated,

/// <summary>
/// Update failed due to an unknown error.
/// </summary>
Failed
}

public class ClientUpdateSessionState
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,24 @@ public class TspClientUpdateTool : MCPTool
private readonly ILogger<TspClientUpdateTool> logger;
private readonly IOutputHelper output;
private readonly IClientUpdateLanguageServiceResolver languageServiceResolver;
private readonly Argument<string> specPathArg = new(name: "spec-path", description: "Path to the .tsp specification file") { Arity = ArgumentArity.ExactlyOne };
private readonly ITspClientHelper tspClientHelper;
private readonly Argument<string> updateCommitSha = new(name: "update-commit-sha", description: "SHA of the commit to apply update changes for") { Arity = ArgumentArity.ExactlyOne };
private readonly Option<string?> newGenOpt = new(["--new-gen"], () => "./tmpgen", "Directory for regenerated TypeSpec output (optional)");

public TspClientUpdateTool(ILogger<TspClientUpdateTool> logger, IOutputHelper output, IClientUpdateLanguageServiceResolver languageServiceResolver)
public TspClientUpdateTool(ILogger<TspClientUpdateTool> logger, IOutputHelper output, IClientUpdateLanguageServiceResolver languageServiceResolver, ITspClientHelper tspClientHelper)
{
this.logger = logger;
this.output = output;
this.languageServiceResolver = languageServiceResolver;
this.tspClientHelper = tspClientHelper;
CommandHierarchy = [ SharedCommandGroups.TypeSpec ];
}

public override Command GetCommand()
{
var cmd = new Command("customized-update",
description: "Update customized TypeSpec-generated client code. Runs the full pipeline by default: regenerate -> diff -> map -> propose -> apply");
cmd.AddArgument(specPathArg);
cmd.AddArgument(updateCommitSha);
cmd.AddOption(SharedOptions.PackagePath);
cmd.AddOption(newGenOpt);
cmd.SetHandler(async ctx => await HandleUpdate(ctx, ctx.GetCancellationToken()));
Expand All @@ -43,43 +45,31 @@ public override Command GetCommand()
public override Task HandleCommand(InvocationContext ctx, CancellationToken ct) => Task.CompletedTask;

[McpServerTool(Name = "azsdk_tsp_update"), Description("Update customized TypeSpec-generated client code")]
public async Task<TspClientUpdateResponse> UpdateAsync(string specPath, string packagePath, CancellationToken ct = default)
public Task<TspClientUpdateResponse> UpdateAsync(string commitSha, string packagePath, CancellationToken ct = default)
=> RunUpdateAsync(commitSha, packagePath, newGenPath: null, ct);

private async Task<TspClientUpdateResponse> RunUpdateAsync(string commitSha, string packagePath, string? newGenPath, CancellationToken ct)
{
try
{
logger.LogInformation($"Starting client update for package at: {packagePath}");
logger.LogInformation("Starting client update for package at: {packagePath} (regenDir: {regenDir})", packagePath, newGenPath);
if (!Directory.Exists(packagePath))
{
SetFailure(1);
return new TspClientUpdateResponse
{
ErrorCode = "1",
ResponseError = $"Package path does not exist: {packagePath}",
Message = ""
};
return new TspClientUpdateResponse { ErrorCode = "1", ResponseError = $"Package path does not exist: {packagePath}" };
}
if (string.IsNullOrWhiteSpace(specPath))
if (string.IsNullOrWhiteSpace(commitSha))
{
SetFailure(1);
return new TspClientUpdateResponse
{
ErrorCode = "1",
ResponseError = $"Spec path is required.",
Message = ""
};
return new TspClientUpdateResponse { ErrorCode = "1", ResponseError = "Commit SHA is required." };
}
var resolved = await languageServiceResolver.ResolveAsync(packagePath, ct);
if (resolved == null)
{
SetFailure(1);
return new TspClientUpdateResponse
{
ErrorCode = "NoLanguageService",
ResponseError = "Could not resolve a client update language service.",
Message = ""
};
return new TspClientUpdateResponse { ErrorCode = "NoLanguageService", ResponseError = "Could not resolve a client update language service." };
}
return await UpdateCoreAsync(specPath, packagePath, resolved, ct);
return await UpdateCoreAsync(commitSha, packagePath, resolved, ct, newGenPath);
}
catch (Exception ex)
{
Expand All @@ -88,12 +78,42 @@ public async Task<TspClientUpdateResponse> UpdateAsync(string specPath, string p
}
}

private async Task<TspClientUpdateResponse> UpdateCoreAsync(string specPath, string packagePath, IClientUpdateLanguageService languageService, CancellationToken ct)
private async Task<TspClientUpdateResponse> UpdateCoreAsync(string commitSha, string packagePath, IClientUpdateLanguageService languageService, CancellationToken ct, string? newGenPath)
{
var session = new ClientUpdateSessionState { SpecPath = specPath };
var session = new ClientUpdateSessionState { SpecPath = commitSha };

// Determine output directory for new generation: use provided newGenPath (CLI option) or fallback.
var regenDir = ResolveRegenDirectory(packagePath, newGenPath);
if (!Directory.Exists(regenDir))
{
Directory.CreateDirectory(regenDir);
}
session.NewGeneratedPath = regenDir;

// Locate the existing tsp-location.yaml file within the provided packagePath and overwrite the commit: value with the new sha
var tspLocationPath = Path.Combine(packagePath, "tsp-location.yaml");
if (File.Exists(tspLocationPath))
{
var tspLocationContent = await File.ReadAllTextAsync(tspLocationPath, ct);
tspLocationContent = tspLocationContent.Replace("commit: ", $"commit: {commitSha}");
Comment thread
samvaity marked this conversation as resolved.
await File.WriteAllTextAsync(tspLocationPath, tspLocationContent, ct);
}

// Regenerate (placeholder)
// Invoke tsp-client update
var regenResult = await tspClientHelper.UpdateGenerationAsync(tspLocationPath, regenDir, isCli: false, ct);
Comment thread
samvaity marked this conversation as resolved.
if (!regenResult.IsSuccessful)
{
SetFailure(1);
session.LastStage = UpdateStage.Failed;
return new TspClientUpdateResponse
{
Session = session,
ErrorCode = "RegenerateFailed",
ResponseError = regenResult.ResponseError
};
}
session.LastStage = UpdateStage.Regenerated;
// Now after regeneration, we have old generated at packagePath, new generation at regenDir to perform a diff

var apiChanges = await languageService.DiffAsync(packagePath, session.NewGeneratedPath);
session.LastStage = UpdateStage.Diffed;
Expand Down Expand Up @@ -154,12 +174,13 @@ private static async Task<TspClientUpdateResponse> ValidateWithAutoFixAsync(Clie

private async Task HandleUpdate(InvocationContext ctx, CancellationToken ct)
{
var spec = ctx.ParseResult.GetValueForArgument(specPathArg);
var spec = ctx.ParseResult.GetValueForArgument(updateCommitSha);
var packagePath = ctx.ParseResult.GetValueForOption(SharedOptions.PackagePath);
var newGenPath = ctx.ParseResult.GetValueForOption(newGenOpt);
try
{
logger.LogInformation($"Starting client update for package at: {packagePath}");
var resp = await UpdateAsync(spec, packagePath, ct);
logger.LogInformation("Starting client update (CLI) for package at: {packagePath} with new-gen: {newGenPath}", packagePath, newGenPath);
var resp = await RunUpdateAsync(spec, packagePath, newGenPath, ct);
output.Output(resp);
}
catch (Exception ex)
Expand All @@ -168,4 +189,18 @@ private async Task HandleUpdate(InvocationContext ctx, CancellationToken ct)
output.OutputError(new TspClientUpdateResponse { ResponseError = ex.Message, ErrorCode = "ClientUpdateFailed" });
}
}

private static string ResolveRegenDirectory(string packagePath, string? newGenPath)
{
if (string.IsNullOrWhiteSpace(newGenPath))
{
return Path.Combine(packagePath, "_generated-new");
}
// If user supplied a relative path, place it under the package path for isolation.
if (!Path.IsPathRooted(newGenPath))
{
return Path.GetFullPath(Path.Combine(packagePath, newGenPath));
}
return Path.GetFullPath(newGenPath);
}
}
Loading