Skip to content

Commit 6868637

Browse files
authored
Use shared tsp-client for regenerate func (#12066)
1 parent 7a8f637 commit 6868637

5 files changed

Lines changed: 134 additions & 43 deletions

File tree

tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/TypeSpec/TspClientUpdateToolTests.cs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
using Azure.Sdk.Tools.Cli.Tools;
44
using Microsoft.Extensions.Logging.Abstractions;
55
using Azure.Sdk.Tools.Cli.Helpers;
6-
using System;
7-
using System.IO;
6+
using Azure.Sdk.Tools.Cli.Models.Responses;
87

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

@@ -63,9 +62,10 @@ public async Task Auto_NoChanges_TerminatesAtValidation()
6362
{
6463
var svc = new MockNoChangeLanguageService();
6564
var resolver = new SingleResolver(svc);
66-
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver);
65+
var tsp = new MockTspHelper();
66+
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver, tsp);
6767
var pkg = CreateTempPackageDir();
68-
var run = await tool.UpdateAsync("placeholder.tsp", packagePath: pkg, ct: CancellationToken.None);
68+
var run = await tool.UpdateAsync("0123456789abcdef0123456789abcdef01234567", packagePath: pkg, ct: CancellationToken.None);
6969
Assert.That(run.Session, Is.Not.Null, "Session should be created");
7070
Assert.That(run.Session!.LastStage, Is.EqualTo(UpdateStage.Validated), "No changes now proceed through validation");
7171
// Slim model: no stored API change count; reaching Validated implies no changes or all handled.
@@ -76,21 +76,23 @@ public async Task Auto_WithChanges_Validated()
7676
{
7777
var svc = new MockChangeLanguageService();
7878
var resolver = new SingleResolver(svc);
79-
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver);
79+
var tsp = new MockTspHelper();
80+
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), resolver, tsp);
8081
var pkg = CreateTempPackageDir();
81-
var first = await tool.UpdateAsync("placeholder-change.tsp", packagePath: pkg, ct: CancellationToken.None);
82+
var first = await tool.UpdateAsync("89abcdef0123456789abcdef0123456789abcdef", packagePath: pkg, ct: CancellationToken.None);
8283
Assert.That(first.Session, Is.Not.Null);
8384
Assert.That(first.Session.LastStage, Is.EqualTo(UpdateStage.Validated), "Single-pass should reach validated");
8485
}
8586

8687
[Test]
8788
public async Task Validation_Failure_Then_AutoFixes_Applied()
8889
{
89-
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(new MockNoChangeLanguageService()));
90+
var tsp = new MockTspHelper();
91+
var tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(new MockNoChangeLanguageService()), tsp);
9092
int calls = 0; var svc = new TestLanguageServiceFailThenFix(() => calls++);
91-
tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(svc));
93+
tool = new TspClientUpdateTool(new NullLogger<TspClientUpdateTool>(), new NullOutputService(), new SingleResolver(svc), tsp);
9294
var pkg = CreateTempPackageDir();
93-
var resp = await tool.UpdateAsync("spec.tsp", packagePath: pkg, ct: CancellationToken.None);
95+
var resp = await tool.UpdateAsync("fedcba9876543210fedcba9876543210fedcba98", packagePath: pkg, ct: CancellationToken.None);
9496
Assert.That(resp.Session, Is.Not.Null);
9597
Assert.That(resp.Session!.LastStage, Is.EqualTo(UpdateStage.Validated));
9698
Assert.That(resp.Session.RequiresManualIntervention, Is.False);
@@ -129,3 +131,11 @@ private class SingleResolver : IClientUpdateLanguageServiceResolver
129131
public Task<IClientUpdateLanguageService?> ResolveAsync(string? packagePath, CancellationToken ct = default) => Task.FromResult<IClientUpdateLanguageService?>(_svc);
130132
}
131133
}
134+
135+
internal class MockTspHelper : ITspClientHelper
136+
{
137+
public Task<TspToolResponse> ConvertSwaggerAsync(string swaggerReadmePath, string outputDirectory, bool isArm, bool fullyCompatible, bool isCli, CancellationToken ct)
138+
=> Task.FromResult(new TspToolResponse { IsSuccessful = true, TypeSpecProjectPath = outputDirectory });
139+
public Task<TspToolResponse> UpdateGenerationAsync(string tspLocationPath, string outputDirectory, bool isCli, CancellationToken ct)
140+
=> Task.FromResult(new TspToolResponse { IsSuccessful = true, TypeSpecProjectPath = outputDirectory });
141+
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/ITspClientHelper.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
namespace Azure.Sdk.Tools.Cli.Helpers;
66

77
/// <summary>
8-
/// Abstraction for running common tsp-client commands (convert, update, diff, map, etc.).
8+
/// Abstraction for running common tsp-client commands (convert, update, generate, init).
99
/// This centralizes npx invocation logic so multiple tools (Generate, Build, Update workflows)
10-
/// can share a single implementation without instantiating each other.
10+
/// can share a single implementation without duplicating code.
1111
/// </summary>
1212
public interface ITspClientHelper
1313
{
1414
/// <summary>
1515
/// Runs `tsp-client convert --swagger-readme <readme> --output-dir <out>` with optional flags.
1616
/// </summary>
1717
Task<TspToolResponse> ConvertSwaggerAsync(string swaggerReadmePath, string outputDirectory, bool isArm, bool fullyCompatible, bool isCli, CancellationToken ct);
18+
19+
/// <summary>
20+
/// Runs `tsp-client update` to regenerate a TypeSpec client into the specified output directory.
21+
/// </summary>
22+
/// <param name="tspLocationPath">Path to the tsp-location.yaml file.</param>
23+
/// <param name="outputDirectory">Directory to place regenerated output (created if missing, must be empty or created new).</param>
24+
/// <param name="isCli">True when invoked from CLI flow (suppresses duplicate streamed output in error text).</param>
25+
Task<TspToolResponse> UpdateGenerationAsync(string tspLocationPath, string outputDirectory, bool isCli, CancellationToken ct);
1826
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TspClientHelper.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,37 @@ public async Task<TspToolResponse> ConvertSwaggerAsync(string swaggerReadmePath,
5454
TypeSpecProjectPath = outputDirectory
5555
};
5656
}
57+
58+
public async Task<TspToolResponse> UpdateGenerationAsync(string tspLocationPath, string outputDirectory, bool isCli, CancellationToken ct)
59+
{
60+
logger.LogInformation("tsp-client update (tsp-location): {loc} -> {out}", tspLocationPath, outputDirectory);
61+
if (!File.Exists(tspLocationPath))
62+
{
63+
return new TspToolResponse { ResponseError = $"tsp-location.yaml not found at path: {tspLocationPath}" };
64+
}
65+
var workingDir = Path.GetDirectoryName(Path.GetFullPath(tspLocationPath))!;
66+
var npxOptions = new NpxOptions(
67+
"@azure-tools/typespec-client-generator-cli",
68+
["tsp-client", "update"],
69+
logOutputStream: true,
70+
workingDirectory: workingDir
71+
);
72+
73+
var result = await npxHelper.Run(npxOptions, ct);
74+
if (result.ExitCode != 0)
75+
{
76+
return new TspToolResponse
77+
{
78+
ResponseError = isCli
79+
? "Failed to regenerate TypeSpec client, see details in the above logs."
80+
: "Failed to regenerate TypeSpec client, see generator output below" + Environment.NewLine + result.Output
81+
};
82+
}
83+
84+
return new TspToolResponse
85+
{
86+
IsSuccessful = true,
87+
TypeSpecProjectPath = outputDirectory
88+
};
89+
}
5790
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/Responses/TspClientUpdateResponse.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,12 @@ public enum UpdateStage
7070
/// <summary>
7171
/// Post-apply validation (build / tests / lint) executed and results captured.
7272
/// </summary>
73-
Validated
73+
Validated,
74+
75+
/// <summary>
76+
/// Update failed due to an unknown error.
77+
/// </summary>
78+
Failed
7479
}
7580

7681
public class ClientUpdateSessionState

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/TypeSpec/TspClientUpdateTool.cs

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,24 @@ public class TspClientUpdateTool : MCPTool
1818
private readonly ILogger<TspClientUpdateTool> logger;
1919
private readonly IOutputHelper output;
2020
private readonly IClientUpdateLanguageServiceResolver languageServiceResolver;
21-
private readonly Argument<string> specPathArg = new(name: "spec-path", description: "Path to the .tsp specification file") { Arity = ArgumentArity.ExactlyOne };
21+
private readonly ITspClientHelper tspClientHelper;
22+
private readonly Argument<string> updateCommitSha = new(name: "update-commit-sha", description: "SHA of the commit to apply update changes for") { Arity = ArgumentArity.ExactlyOne };
2223
private readonly Option<string?> newGenOpt = new(["--new-gen"], () => "./tmpgen", "Directory for regenerated TypeSpec output (optional)");
2324

24-
public TspClientUpdateTool(ILogger<TspClientUpdateTool> logger, IOutputHelper output, IClientUpdateLanguageServiceResolver languageServiceResolver)
25+
public TspClientUpdateTool(ILogger<TspClientUpdateTool> logger, IOutputHelper output, IClientUpdateLanguageServiceResolver languageServiceResolver, ITspClientHelper tspClientHelper)
2526
{
2627
this.logger = logger;
2728
this.output = output;
2829
this.languageServiceResolver = languageServiceResolver;
30+
this.tspClientHelper = tspClientHelper;
2931
CommandHierarchy = [ SharedCommandGroups.TypeSpec ];
3032
}
3133

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

4547
[McpServerTool(Name = "azsdk_tsp_update"), Description("Update customized TypeSpec-generated client code")]
46-
public async Task<TspClientUpdateResponse> UpdateAsync(string specPath, string packagePath, CancellationToken ct = default)
48+
public Task<TspClientUpdateResponse> UpdateAsync(string commitSha, string packagePath, CancellationToken ct = default)
49+
=> RunUpdateAsync(commitSha, packagePath, newGenPath: null, ct);
50+
51+
private async Task<TspClientUpdateResponse> RunUpdateAsync(string commitSha, string packagePath, string? newGenPath, CancellationToken ct)
4752
{
4853
try
4954
{
50-
logger.LogInformation($"Starting client update for package at: {packagePath}");
55+
logger.LogInformation("Starting client update for package at: {packagePath} (regenDir: {regenDir})", packagePath, newGenPath);
5156
if (!Directory.Exists(packagePath))
5257
{
5358
SetFailure(1);
54-
return new TspClientUpdateResponse
55-
{
56-
ErrorCode = "1",
57-
ResponseError = $"Package path does not exist: {packagePath}",
58-
Message = ""
59-
};
59+
return new TspClientUpdateResponse { ErrorCode = "1", ResponseError = $"Package path does not exist: {packagePath}" };
6060
}
61-
if (string.IsNullOrWhiteSpace(specPath))
61+
if (string.IsNullOrWhiteSpace(commitSha))
6262
{
6363
SetFailure(1);
64-
return new TspClientUpdateResponse
65-
{
66-
ErrorCode = "1",
67-
ResponseError = $"Spec path is required.",
68-
Message = ""
69-
};
64+
return new TspClientUpdateResponse { ErrorCode = "1", ResponseError = "Commit SHA is required." };
7065
}
7166
var resolved = await languageServiceResolver.ResolveAsync(packagePath, ct);
7267
if (resolved == null)
7368
{
7469
SetFailure(1);
75-
return new TspClientUpdateResponse
76-
{
77-
ErrorCode = "NoLanguageService",
78-
ResponseError = "Could not resolve a client update language service.",
79-
Message = ""
80-
};
70+
return new TspClientUpdateResponse { ErrorCode = "NoLanguageService", ResponseError = "Could not resolve a client update language service." };
8171
}
82-
return await UpdateCoreAsync(specPath, packagePath, resolved, ct);
72+
return await UpdateCoreAsync(commitSha, packagePath, resolved, ct, newGenPath);
8373
}
8474
catch (Exception ex)
8575
{
@@ -88,12 +78,42 @@ public async Task<TspClientUpdateResponse> UpdateAsync(string specPath, string p
8878
}
8979
}
9080

91-
private async Task<TspClientUpdateResponse> UpdateCoreAsync(string specPath, string packagePath, IClientUpdateLanguageService languageService, CancellationToken ct)
81+
private async Task<TspClientUpdateResponse> UpdateCoreAsync(string commitSha, string packagePath, IClientUpdateLanguageService languageService, CancellationToken ct, string? newGenPath)
9282
{
93-
var session = new ClientUpdateSessionState { SpecPath = specPath };
83+
var session = new ClientUpdateSessionState { SpecPath = commitSha };
84+
85+
// Determine output directory for new generation: use provided newGenPath (CLI option) or fallback.
86+
var regenDir = ResolveRegenDirectory(packagePath, newGenPath);
87+
if (!Directory.Exists(regenDir))
88+
{
89+
Directory.CreateDirectory(regenDir);
90+
}
91+
session.NewGeneratedPath = regenDir;
92+
93+
// Locate the existing tsp-location.yaml file within the provided packagePath and overwrite the commit: value with the new sha
94+
var tspLocationPath = Path.Combine(packagePath, "tsp-location.yaml");
95+
if (File.Exists(tspLocationPath))
96+
{
97+
var tspLocationContent = await File.ReadAllTextAsync(tspLocationPath, ct);
98+
tspLocationContent = tspLocationContent.Replace("commit: ", $"commit: {commitSha}");
99+
await File.WriteAllTextAsync(tspLocationPath, tspLocationContent, ct);
100+
}
94101

95-
// Regenerate (placeholder)
102+
// Invoke tsp-client update
103+
var regenResult = await tspClientHelper.UpdateGenerationAsync(tspLocationPath, regenDir, isCli: false, ct);
104+
if (!regenResult.IsSuccessful)
105+
{
106+
SetFailure(1);
107+
session.LastStage = UpdateStage.Failed;
108+
return new TspClientUpdateResponse
109+
{
110+
Session = session,
111+
ErrorCode = "RegenerateFailed",
112+
ResponseError = regenResult.ResponseError
113+
};
114+
}
96115
session.LastStage = UpdateStage.Regenerated;
116+
// Now after regeneration, we have old generated at packagePath, new generation at regenDir to perform a diff
97117

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

155175
private async Task HandleUpdate(InvocationContext ctx, CancellationToken ct)
156176
{
157-
var spec = ctx.ParseResult.GetValueForArgument(specPathArg);
177+
var spec = ctx.ParseResult.GetValueForArgument(updateCommitSha);
158178
var packagePath = ctx.ParseResult.GetValueForOption(SharedOptions.PackagePath);
179+
var newGenPath = ctx.ParseResult.GetValueForOption(newGenOpt);
159180
try
160181
{
161-
logger.LogInformation($"Starting client update for package at: {packagePath}");
162-
var resp = await UpdateAsync(spec, packagePath, ct);
182+
logger.LogInformation("Starting client update (CLI) for package at: {packagePath} with new-gen: {newGenPath}", packagePath, newGenPath);
183+
var resp = await RunUpdateAsync(spec, packagePath, newGenPath, ct);
163184
output.Output(resp);
164185
}
165186
catch (Exception ex)
@@ -168,4 +189,18 @@ private async Task HandleUpdate(InvocationContext ctx, CancellationToken ct)
168189
output.OutputError(new TspClientUpdateResponse { ResponseError = ex.Message, ErrorCode = "ClientUpdateFailed" });
169190
}
170191
}
192+
193+
private static string ResolveRegenDirectory(string packagePath, string? newGenPath)
194+
{
195+
if (string.IsNullOrWhiteSpace(newGenPath))
196+
{
197+
return Path.Combine(packagePath, "_generated-new");
198+
}
199+
// If user supplied a relative path, place it under the package path for isolation.
200+
if (!Path.IsPathRooted(newGenPath))
201+
{
202+
return Path.GetFullPath(Path.Combine(packagePath, newGenPath));
203+
}
204+
return Path.GetFullPath(newGenPath);
205+
}
171206
}

0 commit comments

Comments
 (0)