From b7e8c43d1750c536acd11556f7864d99af984503 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 8 Sep 2026 16:43:07 -0700 Subject: [PATCH 1/8] Add reviews support (read and translate) Adds `msstore reviews list` and `msstore reviews get`, backed by the Microsoft Store analytics API (/v1.0/my/analytics/reviews). This reuses the existing DevCenter credential path in StorePackagedAPI, so no new authentication is needed. Responding to reviews is not implemented. Microsoft documents the Store reviews API as "currently not in a working state" and points to Partner Center instead, so a reply command would fail on every invocation. The Store returns no translated text and no language metadata for reviews, so `--translate` uses Azure AI Translator with a key the user supplies through MSSTORE_TRANSLATOR_KEY or `msstore settings set-translator-key`. The api-version and wire DTOs sit behind ITranslationService so moving off v3.0 stays contained. Every AppReview field is optional because the analytics API omits fields entirely rather than returning them as null, and `date` is kept as a string because the service returns a US-format value rather than ISO-8601. IEnvironmentInformationService gained GetEnvironmentVariable so the translator service can be tested without mutating process-wide environment state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- MSStore.API/Packaged/IStorePackagedAPI.cs | 2 + MSStore.API/Packaged/Models/AppReview.cs | 82 ++++ MSStore.API/Packaged/StorePackagedAPI.cs | 60 +++ MSStore.API/SourceGenerationContext.cs | 2 + .../AzureAITranslatorServiceUnitTests.cs | 352 ++++++++++++++++ MSStore.CLI.UnitTests/BaseCommandLineTest.cs | 113 +++++- .../ReviewsCommandUnitTests.cs | 324 +++++++++++++++ MSStore.CLI/Commands/Reviews/GetCommand.cs | 145 +++++++ MSStore.CLI/Commands/Reviews/ListCommand.cs | 270 +++++++++++++ .../Commands/Reviews/ReviewTranslator.cs | 53 +++ MSStore.CLI/Commands/ReviewsCommand.cs | 59 +++ .../Settings/SetTranslatorKeyCommand.cs | 118 ++++++ MSStore.CLI/Commands/SettingsCommand.cs | 3 +- MSStore.CLI/Helpers/ParseResultExtensions.cs | 19 + MSStore.CLI/MicrosoftStoreCLI.cs | 3 +- MSStore.CLI/Program.cs | 18 + MSStore.CLI/Services/Configurations.cs | 8 + .../Services/EnvironmentInformationService.cs | 2 + .../IEnvironmentInformationService.cs | 9 + .../Translation/AzureAITranslatorService.cs | 376 ++++++++++++++++++ .../Translation/ITranslationService.cs | 43 ++ .../Models/DetectedLanguageInfo.cs | 16 + .../Translation/Models/TranslateInput.cs | 13 + .../Translation/Models/TranslateResultItem.cs | 22 + .../Translation/Models/TranslationInfo.cs | 11 + .../Translation/Models/TranslatorError.cs | 17 + .../Models/TranslatorErrorResponse.cs | 10 + .../Translation/Models/TranslatorLanguage.cs | 12 + .../Models/TranslatorLanguagesResponse.cs | 19 + .../Translation/TranslationException.cs | 28 ++ .../Services/Translation/TranslationResult.cs | 22 + .../TranslationSourceGenerationContext.cs | 36 ++ MSStore.CLI/StoreHostBuilderExtensions.cs | 7 + README.md | 38 ++ 34 files changed, 2309 insertions(+), 3 deletions(-) create mode 100644 MSStore.API/Packaged/Models/AppReview.cs create mode 100644 MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs create mode 100644 MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs create mode 100644 MSStore.CLI/Commands/Reviews/GetCommand.cs create mode 100644 MSStore.CLI/Commands/Reviews/ListCommand.cs create mode 100644 MSStore.CLI/Commands/Reviews/ReviewTranslator.cs create mode 100644 MSStore.CLI/Commands/ReviewsCommand.cs create mode 100644 MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs create mode 100644 MSStore.CLI/Services/Translation/AzureAITranslatorService.cs create mode 100644 MSStore.CLI/Services/Translation/ITranslationService.cs create mode 100644 MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslateInput.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslationInfo.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslatorError.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs create mode 100644 MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs create mode 100644 MSStore.CLI/Services/Translation/TranslationException.cs create mode 100644 MSStore.CLI/Services/Translation/TranslationResult.cs create mode 100644 MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs diff --git a/MSStore.API/Packaged/IStorePackagedAPI.cs b/MSStore.API/Packaged/IStorePackagedAPI.cs index 443a23c..9b624d9 100644 --- a/MSStore.API/Packaged/IStorePackagedAPI.cs +++ b/MSStore.API/Packaged/IStorePackagedAPI.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -32,5 +33,6 @@ public interface IStorePackagedAPI Task UpdatePackageRolloutPercentageAsync(string productId, string submissionId, string? flightId, float percentage, CancellationToken ct = default); Task HaltPackageRolloutAsync(string productId, string submissionId, string? flightId, CancellationToken ct = default); Task FinalizePackageRolloutAsync(string productId, string submissionId, string? flightId, CancellationToken ct = default); + Task> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default); } } \ No newline at end of file diff --git a/MSStore.API/Packaged/Models/AppReview.cs b/MSStore.API/Packaged/Models/AppReview.cs new file mode 100644 index 0000000..79e2cea --- /dev/null +++ b/MSStore.API/Packaged/Models/AppReview.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace MSStore.API.Packaged.Models +{ + /// + /// A single customer review of an application, as returned by the Microsoft Store + /// analytics API (/v1.0/my/analytics/reviews). + /// + /// + /// Every property is optional. The service omits fields entirely (rather than + /// returning them as null) whenever the data was not captured for a given review. + /// + public class AppReview + { + /// + /// Gets or sets the unique identifier of the review. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the date the review was submitted. The service returns this as a + /// string in US format (for example 3/5/2021 12:48:33 PM) rather than ISO-8601, + /// so it is kept as a string to round-trip exactly. + /// + public string? Date { get; set; } + + public string? ApplicationId { get; set; } + public string? ApplicationName { get; set; } + + /// + /// Gets or sets the ISO 3166 country code of the market the review was submitted in. + /// This is a country, not a language. + /// + public string? Market { get; set; } + + public string? OsVersion { get; set; } + public string? DeviceType { get; set; } + public bool? IsRevised { get; set; } + public string? PackageVersion { get; set; } + public string? DeviceModel { get; set; } + public string? ProductFamily { get; set; } + public long? DeviceRAM { get; set; } + public string? DeviceScreenResolution { get; set; } + public double? DeviceStorageCapacity { get; set; } + public bool? IsTouchEnabled { get; set; } + public string? ReviewerName { get; set; } + public double? Rating { get; set; } + public string? ReviewTitle { get; set; } + public string? ReviewText { get; set; } + public long? HelpfulCount { get; set; } + public long? NotHelpfulCount { get; set; } + public string? ResponseDate { get; set; } + public string? ResponseText { get; set; } + + /// + /// Gets or sets the review title translated into the requested language. This is + /// never returned by the Store, which offers no translation; it is populated by the + /// CLI when translation is requested, and omitted from output otherwise. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TranslatedReviewTitle { get; set; } + + /// + /// Gets or sets the review text translated into the requested language. This is + /// never returned by the Store, which offers no translation; it is populated by the + /// CLI when translation is requested, and omitted from output otherwise. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TranslatedReviewText { get; set; } + + /// + /// Gets or sets the language detected in the original review text. The Store returns + /// no language information, so this comes from the translation service and is only + /// present when translation was requested. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DetectedLanguage { get; set; } + } +} diff --git a/MSStore.API/Packaged/StorePackagedAPI.cs b/MSStore.API/Packaged/StorePackagedAPI.cs index 585870c..e71bdbf 100644 --- a/MSStore.API/Packaged/StorePackagedAPI.cs +++ b/MSStore.API/Packaged/StorePackagedAPI.cs @@ -42,6 +42,7 @@ public class StorePackagedAPI : IStorePackagedAPI, IDisposable private static readonly CompositeFormat DevCenterUpdatePackageRolloutPercentageTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/updatepackagerolloutpercentage?percentage={4}"); private static readonly CompositeFormat DevCenterHaltPackageRolloutTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/haltpackagerollout"); private static readonly CompositeFormat DevCenterFinalizePackageRolloutTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/finalizepackagerollout"); + private static readonly CompositeFormat DevCenterAnalyticsReviewsTemplate = CompositeFormat.Parse("/v{0}/my/analytics/reviews?applicationId={1}"); private SubmissionClient? _devCenterClient; @@ -663,5 +664,64 @@ public async Task FinalizePackageRolloutAsync(string productId, SourceGenerationContext.GetCustom().PackageRollout, ct); } + + /// + /// The maximum number of reviews the analytics API accepts for the top parameter. + /// Larger values are rejected with InvalidQueryParameters. + /// + public const int MaxReviewsPerRequest = 10000; + + public async Task> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default) + { + AssertClientInitialized(); + + if (top is > MaxReviewsPerRequest) + { + throw new ArgumentOutOfRangeException(nameof(top), $"The Microsoft Store analytics API accepts at most {MaxReviewsPerRequest} reviews per request."); + } + + var url = new StringBuilder(string.Format( + CultureInfo.InvariantCulture, + DevCenterAnalyticsReviewsTemplate, + DevCenterVersion, + Uri.EscapeDataString(productId))); + + if (startDate.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&startDate={startDate.Value:yyyy-MM-dd}"); + } + + if (endDate.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&endDate={endDate.Value:yyyy-MM-dd}"); + } + + if (top.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&top={top.Value}"); + } + + if (skip.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&skip={skip.Value}"); + } + + if (!string.IsNullOrEmpty(filter)) + { + url.Append(CultureInfo.InvariantCulture, $"&filter={Uri.EscapeDataString(filter)}"); + } + + if (!string.IsNullOrEmpty(orderby)) + { + url.Append(CultureInfo.InvariantCulture, $"&orderby={Uri.EscapeDataString(orderby)}"); + } + + return await _devCenterClient.InvokeAsync>( + HttpMethod.Get, + url.ToString(), + null, + SourceGenerationContext.GetCustom().PagedResponseAppReview, + ct); + } } } diff --git a/MSStore.API/SourceGenerationContext.cs b/MSStore.API/SourceGenerationContext.cs index 2c36262..991f8a2 100644 --- a/MSStore.API/SourceGenerationContext.cs +++ b/MSStore.API/SourceGenerationContext.cs @@ -50,6 +50,8 @@ namespace MSStore.API.Models [JsonSerializable(typeof(PagedResponse))] [JsonSerializable(typeof(DevCenterFlightSubmission))] [JsonSerializable(typeof(DevCenterFlightSubmissionUpdate))] + [JsonSerializable(typeof(PagedResponse))] + [JsonSerializable(typeof(AppReview))] public partial class SourceGenerationContext : JsonSerializerContext { private static SourceGenerationContext? _default; diff --git a/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs new file mode 100644 index 0000000..04e1416 --- /dev/null +++ b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using MSStore.CLI.Services; +using MSStore.CLI.Services.CredentialManager; +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class AzureAITranslatorServiceUnitTests + { + private const string FakeKey = "fake-translator-key"; + + public TestContext TestContext { get; set; } = null!; + + private List _requests = null!; + private Queue _responses = null!; + private Mock _credentialManager = null!; + private Mock> _configurationManager = null!; + private Mock _environmentInformationService = null!; + private Configurations _configurations = null!; + + [TestInitialize] + public void Init() + { + _requests = []; + _responses = new Queue(); + + _credentialManager = new Mock(); + _credentialManager + .Setup(x => x.ReadCredential(AzureAITranslatorService.CredentialKeyName)) + .Returns(FakeKey); + + _configurations = new Configurations(); + _configurationManager = new Mock>(); + _configurationManager + .Setup(x => x.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => _configurations); + + // Environment variables are read through this service rather than + // System.Environment, so these tests never mutate process-wide state and cannot + // be affected by whatever the developer happens to have set in their shell. + _environmentInformationService = new Mock(); + } + + [TestMethod] + public async Task TranslateAsyncShouldReturnTranslatedTextAndDetectedLanguage() + { + EnqueueJson(HttpStatusCode.OK, """ + [{"detectedLanguage":{"language":"pt","score":1.0},"translations":[{"text":"A fantastic game","to":"en"}]}] + """); + + var results = await CreateService().TranslateAsync(["Um jogo fantástico"], "en", TestContext.CancellationToken); + + results.Should().HaveCount(1); + results[0]!.Text.Should().Be("A fantastic game"); + results[0]!.DetectedLanguage.Should().Be("pt"); + } + + [TestMethod] + public async Task TranslateAsyncShouldOmitFromParameterSoTheServiceAutoDetects() + { + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + var uri = _requests.Single().RequestUri!.ToString(); + uri.Should().Contain("api-version=3.0"); + uri.Should().Contain("to=en"); + uri.Should().NotContain("from="); + } + + [TestMethod] + public async Task TranslateAsyncShouldNotSendRegionHeaderWhenNoRegionIsConfigured() + { + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.Contains("Ocp-Apim-Subscription-Region").Should().BeFalse(); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be(FakeKey); + } + + [TestMethod] + public async Task TranslateAsyncShouldSendRegionHeaderWhenConfigured() + { + _configurations.TranslatorRegion = "westus2"; + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("westus2"); + } + + [TestMethod] + public async Task TranslateAsyncShouldPreferEnvironmentVariablesOverStoredValues() + { + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.KeyEnvironmentVariable)) + .Returns("env-key"); + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.RegionEnvironmentVariable)) + .Returns("eastus"); + _configurations.TranslatorRegion = "westus2"; + + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be("env-key"); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("eastus"); + } + + [TestMethod] + public async Task TranslateAsyncShouldSkipEmptyEntriesButKeepPositions() + { + EnqueueJson(HttpStatusCode.OK, """ + [{"translations":[{"text":"one","to":"en"}]},{"translations":[{"text":"two","to":"en"}]}] + """); + + var results = await CreateService().TranslateAsync(["um", null, " ", "dois"], "en", TestContext.CancellationToken); + + results.Should().HaveCount(4); + results[0]!.Text.Should().Be("one"); + results[1].Should().BeNull(); + results[2].Should().BeNull(); + results[3]!.Text.Should().Be("two"); + } + + [TestMethod] + public async Task TranslateAsyncShouldNotCallTheServiceWhenEverythingIsEmpty() + { + var results = await CreateService().TranslateAsync([null, " "], "en", TestContext.CancellationToken); + + results.Should().HaveCount(2); + _requests.Should().BeEmpty(); + } + + [TestMethod] + public async Task TranslateAsyncShouldSplitBatchesThatExceedTheElementLimit() + { + var texts = new List(); + for (var i = 0; i < AzureAITranslatorService.MaxElementsPerRequest + 5; i++) + { + texts.Add($"t{i}"); + } + + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(AzureAITranslatorService.MaxElementsPerRequest)); + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(5)); + + var results = await CreateService().TranslateAsync(texts, "en", TestContext.CancellationToken); + + _requests.Should().HaveCount(2); + results.Should().HaveCount(AzureAITranslatorService.MaxElementsPerRequest + 5); + results.Should().AllSatisfy(r => r.Should().NotBeNull()); + } + + [TestMethod] + public async Task TranslateAsyncShouldSplitBatchesThatExceedTheCharacterLimit() + { + var big = new string('a', (AzureAITranslatorService.MaxCharactersPerRequest / 2) + 1); + + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(1)); + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(1)); + + await CreateService().TranslateAsync([big, big], "en", TestContext.CancellationToken); + + _requests.Should().HaveCount(2); + } + + [TestMethod] + public async Task TranslateAsyncShouldThrowWhenNoKeyIsConfigured() + { + _credentialManager + .Setup(x => x.ReadCredential(It.IsAny())) + .Returns(string.Empty); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*MSSTORE_TRANSLATOR_KEY*set-translator-key*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldMapAuthenticationErrors() + { + // The service returns 'code' as a JSON number, not a string. + EnqueueJson(HttpStatusCode.Unauthorized, """ + {"error":{"code":401001,"message":"The request is not authorized because credentials are missing or invalid."}} + """); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*rejected the credentials*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldMapSpeechKeyError() + { + EnqueueJson(HttpStatusCode.Unauthorized, """ + {"error":{"code":401015,"message":"The credentials provided are for the Speech API."}} + """); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*Speech API*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldMapQuotaExceededError() + { + EnqueueJson(HttpStatusCode.Forbidden, """ + {"error":{"code":403001,"message":"The operation isn't allowed because the subscription exceeded its free quota."}} + """); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*exceeded its free quota*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldRetryThrottledRequests() + { + EnqueueJson(HttpStatusCode.TooManyRequests, """{"error":{"code":429001,"message":"Too many requests."}}"""); + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + var results = await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Should().HaveCount(2); + results[0]!.Text.Should().Be("hi"); + } + + [TestMethod] + public async Task TranslateAsyncShouldGiveUpAfterRepeatedThrottling() + { + for (var i = 0; i < 3; i++) + { + EnqueueJson(HttpStatusCode.TooManyRequests, """{"error":{"code":429001,"message":"Too many requests."}}"""); + } + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*throttling*"); + _requests.Should().HaveCount(3); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldReturnTheCanonicalCasing() + { + EnqueueJson(HttpStatusCode.OK, """ + {"translation":{"en":{"name":"English"},"pt-PT":{"name":"Portuguese (Portugal)"}}} + """); + + var resolved = await CreateService().ResolveLanguageAsync("PT-pt", TestContext.CancellationToken); + + resolved.Should().Be("pt-PT"); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldNotAuthenticateTheLanguagesCall() + { + EnqueueJson(HttpStatusCode.OK, """{"translation":{"en":{"name":"English"}}}"""); + + await CreateService().ResolveLanguageAsync("en", TestContext.CancellationToken); + + _requests.Single().Headers.Contains("Ocp-Apim-Subscription-Key").Should().BeFalse(); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldRejectUnsupportedLanguages() + { + EnqueueJson(HttpStatusCode.OK, """{"translation":{"en":{"name":"English"}}}"""); + + var act = async () => await CreateService().ResolveLanguageAsync("zz", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*not a language supported*"); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldFallBackWhenTheLanguageListIsUnavailable() + { + EnqueueJson(HttpStatusCode.ServiceUnavailable, "{}"); + + var resolved = await CreateService().ResolveLanguageAsync("pt", TestContext.CancellationToken); + + resolved.Should().Be("pt"); + } + + private static string BuildTranslationsJson(int count) + { + var items = Enumerable.Repeat("""{"translations":[{"text":"x","to":"en"}]}""", count); + return $"[{string.Join(',', items)}]"; + } + + private void EnqueueJson(HttpStatusCode statusCode, string json) + { + _responses.Enqueue(new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }); + } + + private AzureAITranslatorService CreateService() + { + var handler = new StubHttpMessageHandler(_requests, _responses); + + var httpClientFactory = new Mock(); + httpClientFactory + .Setup(x => x.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(handler, disposeHandler: false) + { + BaseAddress = new Uri("https://api.cognitive.microsofttranslator.com") + }); + + return new AzureAITranslatorService( + httpClientFactory.Object, + _credentialManager.Object, + _configurationManager.Object, + _environmentInformationService.Object, + NullLogger.Instance); + } + + private sealed class StubHttpMessageHandler(List requests, Queue responses) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // The body has to be buffered before the request is disposed by the caller. + if (request.Content != null) + { + await request.Content.LoadIntoBufferAsync(cancellationToken); + } + + requests.Add(request); + + return responses.Count > 0 + ? responses.Dequeue() + : new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }; + } + } + } +} diff --git a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs index 8a13f5c..afb96e4 100644 --- a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs +++ b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Invocation; +using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -25,6 +26,7 @@ using MSStore.CLI.Services.PWABuilder; using MSStore.CLI.Services.Telemetry; using MSStore.CLI.Services.TokenManager; +using MSStore.CLI.Services.Translation; using Spectre.Console; namespace MSStore.CLI.UnitTests @@ -46,6 +48,7 @@ public class BaseCommandLineTest internal Mock NuGetPackageManager { get; private set; } = null!; internal Mock ZipFileManager { get; private set; } = null!; internal Mock EnvironmentInformationService { get; private set; } = null!; + internal Mock FakeTranslationService { get; private set; } = null!; internal List UserNames { get; } = []; internal List Secrets { get; } = []; @@ -87,6 +90,43 @@ public class BaseCommandLineTest } ]; + protected List FakeReviews { get; } = + [ + new AppReview + { + Id = "6BE543FF-1C9C-4534-ACED-AF8B4FBE0316", + Date = "3/5/2021 12:48:33 PM", + Market = "US", + Rating = 5, + ReviewerName = "FakeReviewer1", + ReviewTitle = "Great app", + ReviewText = "This app is great", + HelpfulCount = 3, + NotHelpfulCount = 0 + }, + new AppReview + { + Id = "7CF654AA-2D8D-4645-BDFE-B09C5FCA1427", + Date = "3/6/2021 09:12:01 AM", + Market = "BR", + Rating = 4, + ReviewerName = "FakeReviewer2", + ReviewTitle = "Um jogo fantástico", + ReviewText = "Gostei muito", + ResponseDate = "3/7/2021 10:00:00 AM", + ResponseText = "Obrigado!" + }, + + // The analytics API omits fields entirely rather than returning them as null, + // so at least one fixture has to be sparse. + new AppReview + { + Id = "8DA765BB-3E7E-4756-CEAF-C1AD6FDB2538", + Date = "3/7/2021 08:00:00 PM", + Rating = 1 + } + ]; + internal static Organization DefaultOrganization { get; } = new Organization { Id = new Guid("F3C1CCB6-09C0-4BAB-BABA-C034BFB60EF9") @@ -271,6 +311,11 @@ public void Initialize() TokenManager = new Mock(); + FakeTranslationService = new Mock(); + FakeTranslationService + .Setup(x => x.ResolveLanguageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string language, CancellationToken ct) => language); + _hostBuilder = Host.CreateDefaultBuilder(null) .UseEnvironment("CLI") .ConfigureServices((hostContext, services) => @@ -310,7 +355,8 @@ public void Initialize() .AddScoped(sp => PWAAppInfoManager.Object) .AddScoped(sp => ElectronManifestManager.Object) .AddScoped(sp => NuGetPackageManager.Object) - .AddScoped(sp => AppXManifestManager.Object); + .AddScoped(sp => AppXManifestManager.Object) + .AddScoped(sp => FakeTranslationService.Object); services.AddLogging(builder => { @@ -510,6 +556,71 @@ protected void AddFakeApps() .ReturnsAsync((string productId, CancellationToken ct) => FakeApps.First(a => a.Id == productId)); } + protected void AddFakeReviews() + { + FakeStorePackagedAPI + .Setup(x => x.GetAppReviewsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string productId, DateOnly? startDate, DateOnly? endDate, int? top, int? skip, string? filter, string? orderby, CancellationToken ct) => + { + var reviews = FakeReviews.AsEnumerable(); + + // Mirrors the subset of the analytics API's filter syntax that the CLI emits. + if (filter != null) + { + var idMatch = System.Text.RegularExpressions.Regex.Match(filter, @"id eq '([^']*)'"); + if (idMatch.Success) + { + reviews = reviews.Where(r => string.Equals(r.Id, idMatch.Groups[1].Value, StringComparison.OrdinalIgnoreCase)); + } + + var ratingMatch = System.Text.RegularExpressions.Regex.Match(filter, @"rating eq (\d+)"); + if (ratingMatch.Success) + { + var rating = double.Parse(ratingMatch.Groups[1].Value, CultureInfo.InvariantCulture); + reviews = reviews.Where(r => r.Rating == rating); + } + + var marketMatch = System.Text.RegularExpressions.Regex.Match(filter, @"market eq '([^']*)'"); + if (marketMatch.Success) + { + reviews = reviews.Where(r => string.Equals(r.Market, marketMatch.Groups[1].Value, StringComparison.OrdinalIgnoreCase)); + } + } + + var value = reviews.Skip(skip ?? 0).Take(top ?? int.MaxValue).ToList(); + + return new PagedResponse + { + Value = value, + TotalCount = value.Count + }; + }); + } + + /// + /// Makes the fake translation service echo each text back prefixed with the target + /// language, so tests can assert that translated content reached the output. + /// + protected void AddFakeTranslations(string detectedLanguage = "pt") + { + FakeTranslationService + .Setup(x => x.TranslateAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IReadOnlyList texts, string targetLanguage, CancellationToken ct) => + texts + .Select(t => string.IsNullOrWhiteSpace(t) + ? null + : new TranslationResult($"[{targetLanguage}] {t}", detectedLanguage)) + .ToList()); + } + protected void AddFakeFlights() { FakeStorePackagedAPI diff --git a/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs new file mode 100644 index 0000000..220d173 --- /dev/null +++ b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class ReviewsCommandUnitTests : BaseCommandLineTest + { + [TestInitialize] + public void Init() + { + FakeLogin(); + AddDefaultFakeAccount(); + AddFakeApps(); + AddFakeReviews(); + } + + [TestMethod] + public async Task ReviewsListCommandShouldReturnZero() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA" + ]); + + // The table is human-facing output, so it goes to the injected console (stderr). + result.Error.Should().Contain("FakeReviewer1"); + result.Error.Should().Contain("Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRenderReviewsMissingOptionalFields() + { + // The analytics API omits fields entirely rather than returning them as null, + // so a sparse review must not break rendering. + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA" + ]); + + result.Error.Should().Contain("Um jogo fantástico"); + result.Error.Should().Contain("*---- (1)"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldFilterByMarket() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--market", + "BR" + ]); + + result.Error.Should().Contain("Um jogo fantástico"); + result.Error.Should().NotContain("Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldFilterByRating() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--rating", + "5" + ]); + + result.Error.Should().Contain("Great app"); + result.Error.Should().NotContain("Um jogo fantástico"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRejectInvalidRating() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--rating", + "9" + ], + -1); + + result.Error.Should().Contain("--rating must be between 1 and 5."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRejectTooLargeTop() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--top", + "10001" + ], + -1); + + result.Error.Should().Contain("--top cannot be greater than 10000."); + } + + [TestMethod] + public async Task ReviewsListCommandIsNotSupportedForUnpackagedApps() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + Guid.Empty.ToString() + ], + -1); + + result.Error.Should().Contain("This command is not supported for unpackaged applications."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldReportWhenThereAreNoReviews() + { + FakeReviews.Clear(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA" + ]); + + result.Error.Should().Contain("no"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldTranslateWhenRequested() + { + AddFakeTranslations(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--translate" + ]); + + result.Error.Should().Contain("[en] Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldTranslateIntoTheRequestedLanguage() + { + AddFakeTranslations(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--translate", + "pt" + ]); + + result.Error.Should().Contain("[pt] Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldReportMissingTranslatorKey() + { + FakeTranslationService + .Setup(x => x.TranslateAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new TranslationException("No Azure AI Translator key is configured.")); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--translate" + ], + -1); + + result.Error.Should().Contain("No Azure AI Translator key is configured."); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldReturnJsonForKnownReview() + { + var reviewId = FakeReviews[0].Id!; + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + reviewId + ]); + + result.Output.Should().Contain($"\"Id\": \"{reviewId}\""); + result.Output.Should().Contain("\"ReviewTitle\": \"Great app\""); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldNotEmitTranslatedFieldsWhenNotTranslating() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + FakeReviews[0].Id! + ]); + + result.Output.Should().NotContain("TranslatedReviewTitle"); + result.Output.Should().NotContain("DetectedLanguage"); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldEmitTranslatedFieldsWhenTranslating() + { + AddFakeTranslations(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + FakeReviews[1].Id!, + "--translate" + ]); + + // System.Text.Json escapes non-ASCII by default, so the accented characters are + // \u-escaped in the payload. That is valid JSON and decodes back correctly; the + // same encoder is used by every other command that emits JSON. + result.Output.Should().Contain("\"TranslatedReviewTitle\": \"[en] Um jogo fant\\u00E1stico\""); + result.Output.Should().Contain("\"DetectedLanguage\": \"pt\""); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldReturnErrorIfNonExistingReview() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + "00000000-0000-0000-0000-000000000000" + ], + -1); + + result.Error.Should().Contain("Could not find review with ID"); + + // No date option was passed, so no date parameters are sent and the service + // searches every review. Suggesting --startDate here would misdirect the user. + result.Error.Should().NotContain("--startDate"); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldSuggestWideningAnExplicitDateRange() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + "00000000-0000-0000-0000-000000000000", + "--startDate", + "2024-01-01" + ], + -1); + + result.Error.Should().Contain("within the requested date range"); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldNotClaimTheReviewIsMissingWhenTranslationFails() + { + FakeTranslationService + .Setup(x => x.TranslateAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new TranslationException("No Azure AI Translator key is configured.")); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + FakeReviews[0].Id!, + "--translate" + ], + -1); + + result.Error.Should().Contain("No Azure AI Translator key is configured."); + + // The review was found; only the translation failed, so pointing the user at + // --startDate would misdirect them. + result.Error.Should().NotContain("Could not find review with ID"); + } + + [TestMethod] + public async Task ReviewsGetCommandIsNotSupportedForUnpackagedApps() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + Guid.Empty.ToString(), + FakeReviews[0].Id! + ], + -1); + + result.Error.Should().Contain("This command is not supported for unpackaged applications."); + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/GetCommand.cs b/MSStore.CLI/Commands/Reviews/GetCommand.cs new file mode 100644 index 0000000..cc64d95 --- /dev/null +++ b/MSStore.CLI/Commands/Reviews/GetCommand.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using MSStore.API; +using MSStore.API.Models; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Helpers; +using MSStore.CLI.Services; +using MSStore.CLI.Services.Translation; +using Spectre.Console; + +namespace MSStore.CLI.Commands.Reviews +{ + internal class GetCommand : Command + { + public GetCommand() + : base("get", "Retrieves the details of a single review.") + { + Arguments.Add(ReviewsCommand.ProductIdArgument); + Arguments.Add(ReviewsCommand.ReviewIdArgument); + Options.Add(ReviewsCommand.StartDateOption); + Options.Add(ReviewsCommand.EndDateOption); + Options.Add(ReviewsCommand.TranslateOption); + } + + public class Handler( + ILogger logger, + IStoreAPIFactory storeAPIFactory, + ITranslationService translationService, + IAnsiConsole ansiConsole, + TelemetryClient telemetryClient) : AsynchronousCommandLineAction + { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IStoreAPIFactory _storeAPIFactory = storeAPIFactory ?? throw new ArgumentNullException(nameof(storeAPIFactory)); + private readonly ITranslationService _translationService = translationService ?? throw new ArgumentNullException(nameof(translationService)); + private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); + private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct = default) + { + string productId = parseResult.GetRequiredValue(ReviewsCommand.ProductIdArgument); + string reviewId = parseResult.GetRequiredValue(ReviewsCommand.ReviewIdArgument); + + if (ProductTypeHelper.Solve(productId) == ProductType.Unpackaged) + { + _ansiConsole.WriteLine("This command is not supported for unpackaged applications."); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var translateLanguage = parseResult.GetTranslateLanguage(); + + AppReview? review = null; + + // A failed call and a review that genuinely is not in the result set both + // leave 'review' null, so the outcome is tracked separately to avoid telling + // the user the review does not exist when the call never succeeded. + var success = await _ansiConsole.Status().StartAsync("Retrieving Review", async ctx => + { + try + { + var storePackagedAPI = await _storeAPIFactory.CreatePackagedAsync(ct: ct); + + var response = await storePackagedAPI.GetAppReviewsAsync( + productId, + parseResult.GetValue(ReviewsCommand.StartDateOption), + parseResult.GetValue(ReviewsCommand.EndDateOption), + filter: $"id eq '{reviewId.Replace("'", "''", StringComparison.Ordinal)}'", + ct: ct); + + review = response.Value?.Find(r => string.Equals(r.Id, reviewId, StringComparison.OrdinalIgnoreCase)); + + if (review != null && translateLanguage != null) + { + ctx.Status("Translating Review"); + await ReviewTranslator.TranslateAsync(_translationService, [review], translateLanguage, ct); + } + + ctx.SuccessStatus(_ansiConsole, "[bold green]Retrieved Review[/]"); + + return true; + } + catch (TranslationException err) + { + _logger.LogError(err, "Error while translating Review."); + ctx.ErrorStatus(_ansiConsole, err.Message); + return false; + } + catch (MSStoreHttpException err) + { + _logger.LogError(err, "Error while retrieving Review."); + + if (err.Response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + ctx.ErrorStatus(_ansiConsole, "Could not find the Application. Please check the ProductId."); + } + else + { + ctx.ErrorStatus(_ansiConsole, "Error while retrieving Review."); + } + + return false; + } + catch (Exception err) + { + _logger.LogError(err, "Error while retrieving Review."); + ctx.ErrorStatus(_ansiConsole, err); + return false; + } + }); + + if (!success) + { + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + if (review == null) + { + // Only mention the date range when the user actually narrowed it. Leaving + // both options off sends no date parameters at all, and the service then + // searches every review, so suggesting --startDate would be misleading. + var narrowedByDate = parseResult.GetResult(ReviewsCommand.StartDateOption) != null + || parseResult.GetResult(ReviewsCommand.EndDateOption) != null; + + _ansiConsole.MarkupLine(narrowedByDate + ? $"Could not find review with ID '{reviewId.EscapeMarkup()}' within the requested date range. Try widening it with [bold]--startDate[/] and [bold]--endDate[/]." + : $"Could not find review with ID '{reviewId.EscapeMarkup()}'."); + + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + StandardOutput.WriteLine(JsonSerializer.Serialize(review, SourceGenerationContext.GetCustom(true).AppReview)); + + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); + } + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/ListCommand.cs b/MSStore.CLI/Commands/Reviews/ListCommand.cs new file mode 100644 index 0000000..590a587 --- /dev/null +++ b/MSStore.CLI/Commands/Reviews/ListCommand.cs @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using MSStore.API; +using MSStore.API.Packaged; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Helpers; +using MSStore.CLI.Services; +using MSStore.CLI.Services.Translation; +using Spectre.Console; + +namespace MSStore.CLI.Commands.Reviews +{ + internal class ListCommand : Command + { + internal static readonly Option TopOption; + internal static readonly Option SkipOption; + internal static readonly Option RatingOption; + internal static readonly Option MarketOption; + + private const int MaxTextLengthInTable = 120; + + static ListCommand() + { + TopOption = new Option("--top", "-t") + { + Description = $"The maximum number of reviews to return. The Microsoft Store accepts at most {StorePackagedAPI.MaxReviewsPerRequest}." + }; + + SkipOption = new Option("--skip", "-s") + { + Description = "The number of reviews to skip, for paging through large result sets." + }; + + RatingOption = new Option("--rating") + { + Description = "Only return reviews with this star rating (1-5)." + }; + + MarketOption = new Option("--market", "-m") + { + Description = "Only return reviews from this market, as an ISO 3166 country code (for example 'US')." + }; + } + + public ListCommand() + : base("list", "List the reviews of an application.") + { + Arguments.Add(ReviewsCommand.ProductIdArgument); + Options.Add(ReviewsCommand.StartDateOption); + Options.Add(ReviewsCommand.EndDateOption); + Options.Add(TopOption); + Options.Add(SkipOption); + Options.Add(RatingOption); + Options.Add(MarketOption); + Options.Add(ReviewsCommand.TranslateOption); + } + + public class Handler( + ILogger logger, + IStoreAPIFactory storeAPIFactory, + ITranslationService translationService, + IAnsiConsole ansiConsole, + TelemetryClient telemetryClient) : AsynchronousCommandLineAction + { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IStoreAPIFactory _storeAPIFactory = storeAPIFactory ?? throw new ArgumentNullException(nameof(storeAPIFactory)); + private readonly ITranslationService _translationService = translationService ?? throw new ArgumentNullException(nameof(translationService)); + private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); + private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct = default) + { + string productId = parseResult.GetRequiredValue(ReviewsCommand.ProductIdArgument); + + if (ProductTypeHelper.Solve(productId) == ProductType.Unpackaged) + { + _ansiConsole.WriteLine("This command is not supported for unpackaged applications."); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var top = parseResult.GetValue(TopOption); + if (top is > StorePackagedAPI.MaxReviewsPerRequest) + { + _ansiConsole.MarkupLine($"[bold red]--top cannot be greater than {StorePackagedAPI.MaxReviewsPerRequest}.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var rating = parseResult.GetValue(RatingOption); + if (rating is < 1 or > 5) + { + _ansiConsole.MarkupLine("[bold red]--rating must be between 1 and 5.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var translateLanguage = parseResult.GetTranslateLanguage(); + + var reviews = await _ansiConsole.Status().StartAsync("Retrieving Reviews", async ctx => + { + try + { + var storePackagedAPI = await _storeAPIFactory.CreatePackagedAsync(ct: ct); + + var response = await storePackagedAPI.GetAppReviewsAsync( + productId, + parseResult.GetValue(ReviewsCommand.StartDateOption), + parseResult.GetValue(ReviewsCommand.EndDateOption), + top, + parseResult.GetValue(SkipOption), + BuildFilter(rating, parseResult.GetValue(MarketOption)), + "date desc", + ct); + + var reviews = response.Value ?? []; + + if (translateLanguage != null && reviews.Count > 0) + { + ctx.Status("Translating Reviews"); + await ReviewTranslator.TranslateAsync(_translationService, reviews, translateLanguage, ct); + } + + ctx.SuccessStatus(_ansiConsole, "[bold green]Retrieved Reviews[/]"); + + return reviews; + } + catch (TranslationException err) + { + _logger.LogError(err, "Error while translating Reviews."); + ctx.ErrorStatus(_ansiConsole, err.Message); + return null; + } + catch (MSStoreHttpException err) + { + _logger.LogError(err, "Error while retrieving Reviews."); + + if (err.Response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + ctx.ErrorStatus(_ansiConsole, "Could not find the Application. Please check the ProductId."); + } + else + { + ctx.ErrorStatus(_ansiConsole, "Error while retrieving Reviews."); + } + + return null; + } + catch (Exception err) + { + _logger.LogError(err, "Error while retrieving Reviews."); + ctx.ErrorStatus(_ansiConsole, err); + return null; + } + }); + + if (reviews == null) + { + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + if (reviews.Count == 0) + { + _ansiConsole.MarkupLine("This application has [bold][u]no[/] reviews[/] for the requested period."); + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); + } + + _ansiConsole.Write(BuildTable(reviews, translateLanguage)); + + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); + } + + /// + /// Builds the OData-style filter accepted by the analytics API. String values are + /// single-quoted, and single quotes inside them are doubled. + /// + private static string? BuildFilter(int? rating, string? market) + { + var clauses = new List(); + + if (rating.HasValue) + { + clauses.Add($"rating eq {rating.Value.ToString(CultureInfo.InvariantCulture)}"); + } + + if (!string.IsNullOrWhiteSpace(market)) + { + clauses.Add($"market eq '{market.Replace("'", "''", StringComparison.Ordinal)}'"); + } + + return clauses.Count == 0 ? null : string.Join(" and ", clauses); + } + + private static Table BuildTable(IReadOnlyList reviews, string? translateLanguage) + { + var translated = translateLanguage != null; + + // The Id is shown rather than a running index because it is the value + // 'reviews get' takes, and there is no other way to discover it. + var table = new Table(); + if (translated) + { + table.AddColumns("Id", "Date", "Rating", "Market", "Lang", "Reviewer", "Title", "Review", "Reply"); + } + else + { + table.AddColumns("Id", "Date", "Rating", "Market", "Reviewer", "Title", "Review", "Reply"); + } + + foreach (var review in reviews) + { + var title = (translated ? review.TranslatedReviewTitle ?? review.ReviewTitle : review.ReviewTitle) ?? string.Empty; + var text = (translated ? review.TranslatedReviewText ?? review.ReviewText : review.ReviewText) ?? string.Empty; + + var cells = new List + { + (review.Id ?? string.Empty).EscapeMarkup(), + (review.Date ?? string.Empty).EscapeMarkup(), + FormatRating(review.Rating), + (review.Market ?? string.Empty).EscapeMarkup(), + }; + + if (translated) + { + cells.Add((review.DetectedLanguage ?? string.Empty).EscapeMarkup()); + } + + cells.Add((review.ReviewerName ?? string.Empty).EscapeMarkup()); + cells.Add(Truncate(title).EscapeMarkup()); + cells.Add(Truncate(text).EscapeMarkup()); + cells.Add(string.IsNullOrEmpty(review.ResponseText) ? string.Empty : "yes"); + + table.AddRow([.. cells]); + } + + return table; + } + + private static string FormatRating(double? rating) + { + if (!rating.HasValue) + { + return string.Empty; + } + + var stars = (int)Math.Round(rating.Value, MidpointRounding.AwayFromZero); + stars = Math.Clamp(stars, 0, 5); + + return $"{new string('*', stars)}{new string('-', 5 - stars)} ({rating.Value.ToString("0.#", CultureInfo.InvariantCulture)})"; + } + + private static string Truncate(string value) + { + // Reviews are free-form and can contain newlines, which would break the row layout. + value = value.ReplaceLineEndings(" "); + + return value.Length <= MaxTextLengthInTable + ? value + : string.Concat(value.AsSpan(0, MaxTextLengthInTable), "..."); + } + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/ReviewTranslator.cs b/MSStore.CLI/Commands/Reviews/ReviewTranslator.cs new file mode 100644 index 0000000..dc2b89e --- /dev/null +++ b/MSStore.CLI/Commands/Reviews/ReviewTranslator.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.Commands.Reviews +{ + internal static class ReviewTranslator + { + /// + /// Populates the translated fields of each review in place. + /// + /// + /// Titles and texts are sent in a single call so the service batches them together + /// rather than paying the per-request overhead twice. + /// + /// The translation service to use. + /// The reviews to translate, modified in place. + /// The language to translate into. + /// Cancellation token. + /// A task that completes once every review has been updated. + public static async Task TranslateAsync(ITranslationService translationService, IReadOnlyList reviews, string targetLanguage, CancellationToken ct) + { + var language = await translationService.ResolveLanguageAsync(targetLanguage, ct); + + var texts = new List(reviews.Count * 2); + foreach (var review in reviews) + { + texts.Add(review.ReviewTitle); + texts.Add(review.ReviewText); + } + + var translations = await translationService.TranslateAsync(texts, language, ct); + + for (var i = 0; i < reviews.Count; i++) + { + var title = translations[i * 2]; + var text = translations[(i * 2) + 1]; + + reviews[i].TranslatedReviewTitle = title?.Text; + reviews[i].TranslatedReviewText = text?.Text; + + // The body is the better signal for the review's language; fall back to the + // title when the body was empty. + reviews[i].DetectedLanguage = text?.DetectedLanguage ?? title?.DetectedLanguage; + } + } + } +} diff --git a/MSStore.CLI/Commands/ReviewsCommand.cs b/MSStore.CLI/Commands/ReviewsCommand.cs new file mode 100644 index 0000000..b788aca --- /dev/null +++ b/MSStore.CLI/Commands/ReviewsCommand.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.CommandLine; +using MSStore.CLI.Commands.Reviews; + +namespace MSStore.CLI.Commands +{ + internal class ReviewsCommand : Command + { + /// + /// The language used when --translate is passed without a value. + /// + internal const string DefaultTranslateLanguage = "en"; + + internal static readonly Argument ProductIdArgument; + internal static readonly Argument ReviewIdArgument; + internal static readonly Option StartDateOption; + internal static readonly Option EndDateOption; + internal static readonly Option TranslateOption; + + static ReviewsCommand() + { + ProductIdArgument = new Argument("productId") + { + Description = "The product ID." + }; + + ReviewIdArgument = new Argument("reviewId") + { + Description = "The review ID." + }; + + StartDateOption = new Option("--startDate") + { + Description = "Only return reviews submitted on or after this date (yyyy-MM-dd). If omitted, reviews from all dates are returned." + }; + + EndDateOption = new Option("--endDate") + { + Description = "Only return reviews submitted on or before this date (yyyy-MM-dd). If omitted, reviews from all dates are returned." + }; + + TranslateOption = new Option("--translate") + { + Description = $"Translate the review title and text into this language, using Azure AI Translator. Defaults to '{DefaultTranslateLanguage}' when no language is given. The Microsoft Store provides no translation of its own, so this requires your own Translator key.", + Arity = ArgumentArity.ZeroOrOne + }; + } + + public ReviewsCommand(ListCommand listCommand, GetCommand getCommand) + : base("reviews", "Execute reviews related tasks.") + { + Subcommands.Add(listCommand); + Subcommands.Add(getCommand); + } + } +} diff --git a/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs b/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs new file mode 100644 index 0000000..089d087 --- /dev/null +++ b/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using MSStore.CLI.Helpers; +using MSStore.CLI.Services; +using MSStore.CLI.Services.CredentialManager; +using MSStore.CLI.Services.Translation; +using Spectre.Console; + +namespace MSStore.CLI.Commands.Settings +{ + internal class SetTranslatorKeyCommand : Command + { + internal static readonly Argument KeyArgument; + internal static readonly Option RegionOption; + internal static readonly Option ClearOption; + + static SetTranslatorKeyCommand() + { + KeyArgument = new Argument("key") + { + Description = "The Azure AI Translator resource key. Used by the --translate option of the reviews commands.", + Arity = ArgumentArity.ZeroOrOne + }; + + RegionOption = new Option("--region", "-r") + { + Description = "The region of the Azure AI Translator resource. Required for regional and multi-service resources, and unnecessary for a global one." + }; + + ClearOption = new Option("--clear") + { + DefaultValueFactory = _ => false, + Description = "Remove the stored Azure AI Translator key and region." + }; + } + + public SetTranslatorKeyCommand() + : base("set-translator-key", "Store the Azure AI Translator key used by the reviews '--translate' option.") + { + Arguments.Add(KeyArgument); + Options.Add(RegionOption); + Options.Add(ClearOption); + } + + public class Handler( + ILogger logger, + ICredentialManager credentialManager, + IConfigurationManager configurationManager, + IAnsiConsole ansiConsole, + TelemetryClient telemetryClient) : AsynchronousCommandLineAction + { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly ICredentialManager _credentialManager = credentialManager ?? throw new ArgumentNullException(nameof(credentialManager)); + private readonly IConfigurationManager _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); + private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); + private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct = default) + { + var key = parseResult.GetValue(KeyArgument); + var region = parseResult.GetValue(RegionOption); + var clear = parseResult.GetValue(ClearOption); + + try + { + var config = await _configurationManager.LoadAsync(ct: ct); + + if (clear) + { + _credentialManager.ClearCredentials(AzureAITranslatorService.CredentialKeyName); + config.TranslatorRegion = null; + await _configurationManager.SaveAsync(config, ct); + + _ansiConsole.MarkupLine("Azure AI Translator key and region [bold green]cleared[/]."); + return await _telemetryClient.TrackCommandEventAsync(0, ct); + } + + if (string.IsNullOrWhiteSpace(key)) + { + _ansiConsole.MarkupLine("[bold red]A key is required.[/] Pass the Azure AI Translator resource key, or use [bold]--clear[/] to remove the stored one."); + return await _telemetryClient.TrackCommandEventAsync(-1, ct); + } + + _credentialManager.WriteCredential(AzureAITranslatorService.CredentialKeyName, key); + + if (!string.IsNullOrWhiteSpace(region)) + { + config.TranslatorRegion = region; + await _configurationManager.SaveAsync(config, ct); + } + + _ansiConsole.MarkupLine("Azure AI Translator key [bold green]stored[/]."); + + if (string.IsNullOrWhiteSpace(region) && string.IsNullOrWhiteSpace(config.TranslatorRegion)) + { + _ansiConsole.MarkupLine("No region is set. That is correct for a [bold]global[/] Translator resource, but regional and multi-service resources need one - re-run with [bold]--region[/]."); + } + + return await _telemetryClient.TrackCommandEventAsync(0, ct); + } + catch (Exception err) + { + _logger.LogError(err, "Error while storing the Azure AI Translator key."); + _ansiConsole.MarkupLine("[bold red]Could not store the Azure AI Translator key.[/]"); + return await _telemetryClient.TrackCommandEventAsync(-1, ct); + } + } + } + } +} diff --git a/MSStore.CLI/Commands/SettingsCommand.cs b/MSStore.CLI/Commands/SettingsCommand.cs index 7d22054..a9effa3 100644 --- a/MSStore.CLI/Commands/SettingsCommand.cs +++ b/MSStore.CLI/Commands/SettingsCommand.cs @@ -28,12 +28,13 @@ static SettingsCommand() }; } - public SettingsCommand(SetPublisherDisplayNameCommand setPublisherDisplayNameCommand) + public SettingsCommand(SetPublisherDisplayNameCommand setPublisherDisplayNameCommand, SetTranslatorKeyCommand setTranslatorKeyCommand) : base("settings", "Change settings of the Microsoft Store Developer CLI.") { Options.Add(EnableTelemetryOption); Subcommands.Add(setPublisherDisplayNameCommand); + Subcommands.Add(setTranslatorKeyCommand); } public class Handler(TelemetryClient telemetryClient, IConfigurationManager telemetryConfigurationManager, IConfigurationManager configurationManager, ILogger logger) : AsynchronousCommandLineAction diff --git a/MSStore.CLI/Helpers/ParseResultExtensions.cs b/MSStore.CLI/Helpers/ParseResultExtensions.cs index 70b2635..ae0a9a3 100644 --- a/MSStore.CLI/Helpers/ParseResultExtensions.cs +++ b/MSStore.CLI/Helpers/ParseResultExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.CommandLine; +using MSStore.CLI.Commands; namespace MSStore.CLI.Helpers { @@ -12,5 +13,23 @@ public static bool IsVerbose(this ParseResult parseResult) return parseResult.RootCommandResult.Command is MicrosoftStoreCLI storeCLI && parseResult.GetValue(MicrosoftStoreCLI.VerboseOption); } + + /// + /// Resolves the language requested through --translate, or null when the option was + /// not supplied. Passing the option without a value selects the default language. + /// + /// The parsed command line. + /// The requested language, or null when --translate was not supplied. + public static string? GetTranslateLanguage(this ParseResult parseResult) + { + if (parseResult.GetResult(ReviewsCommand.TranslateOption) == null) + { + return null; + } + + var language = parseResult.GetValue(ReviewsCommand.TranslateOption); + + return string.IsNullOrWhiteSpace(language) ? ReviewsCommand.DefaultTranslateLanguage : language; + } } } diff --git a/MSStore.CLI/MicrosoftStoreCLI.cs b/MSStore.CLI/MicrosoftStoreCLI.cs index 1efbdbf..60f9039 100644 --- a/MSStore.CLI/MicrosoftStoreCLI.cs +++ b/MSStore.CLI/MicrosoftStoreCLI.cs @@ -39,7 +39,7 @@ internal static void WelcomeMessage(IAnsiConsole ansiConsole) ansiConsole.WriteLine(); } - public MicrosoftStoreCLI(InfoCommand infoCommand, ReconfigureCommand reconfigureCommand, SettingsCommand settingsCommand, AppsCommand appsCommand, SubmissionCommand submissionCommand, FlightsCommand flightsCommand, InitCommand initCommand, PackageCommand packageCommand, PublishCommand publishCommand, Handler handler) + public MicrosoftStoreCLI(InfoCommand infoCommand, ReconfigureCommand reconfigureCommand, SettingsCommand settingsCommand, AppsCommand appsCommand, SubmissionCommand submissionCommand, FlightsCommand flightsCommand, ReviewsCommand reviewsCommand, InitCommand initCommand, PackageCommand packageCommand, PublishCommand publishCommand, Handler handler) : base(description: "CLI tool to automate Microsoft Store Developer tasks.") { Subcommands.Add(infoCommand); @@ -48,6 +48,7 @@ public MicrosoftStoreCLI(InfoCommand infoCommand, ReconfigureCommand reconfigure Subcommands.Add(appsCommand); Subcommands.Add(submissionCommand); Subcommands.Add(flightsCommand); + Subcommands.Add(reviewsCommand); Subcommands.Add(initCommand); Subcommands.Add(packageCommand); Subcommands.Add(publishCommand); diff --git a/MSStore.CLI/Program.cs b/MSStore.CLI/Program.cs index d969440..a087d07 100644 --- a/MSStore.CLI/Program.cs +++ b/MSStore.CLI/Program.cs @@ -28,6 +28,7 @@ using MSStore.CLI.Services.PWABuilder; using MSStore.CLI.Services.Telemetry; using MSStore.CLI.Services.TokenManager; +using MSStore.CLI.Services.Translation; using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Resources; @@ -110,6 +111,7 @@ public static async Task Main(params string[] args) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddSingleton() .AddSingleton(telemetryClient); @@ -167,6 +169,22 @@ public static async Task Main(params string[] args) }; }); + services + .AddHttpClient(nameof(AzureAITranslatorService), client => + { + client.BaseAddress = new Uri("https://api.cognitive.microsofttranslator.com"); + }) + .ConfigurePrimaryHttpMessageHandler(() => + { + // Deliberately not RetryAfterHttpHandler: it retries 429s forever + // with no attempt cap, and Translator does not document a + // Retry-After header. AzureAITranslatorService backs off itself. + return new HttpClientHandler + { + CheckCertificateRevocationList = true + }; + }); + void AddPWABuilderDefaultHeaders(HttpRequestHeaders defaultRequestHeaders) { defaultRequestHeaders.Add("Platform-Identifier", "MSStoreCLI"); diff --git a/MSStore.CLI/Services/Configurations.cs b/MSStore.CLI/Services/Configurations.cs index 5975449..945945e 100644 --- a/MSStore.CLI/Services/Configurations.cs +++ b/MSStore.CLI/Services/Configurations.cs @@ -29,6 +29,14 @@ internal class Configurations [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? PublisherDisplayName { get; set; } + /// + /// Gets or sets the region of the Azure AI Translator resource. Required for regional + /// and multi-service resources, and unnecessary for a global one. This is not a + /// secret; the key itself lives in the OS secure store. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TranslatorRegion { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool ClientAssertion { get; set; } diff --git a/MSStore.CLI/Services/EnvironmentInformationService.cs b/MSStore.CLI/Services/EnvironmentInformationService.cs index 791bcfc..8ef85ca 100644 --- a/MSStore.CLI/Services/EnvironmentInformationService.cs +++ b/MSStore.CLI/Services/EnvironmentInformationService.cs @@ -39,5 +39,7 @@ public EnvironmentInformationService(ILogger logg } public bool IsRunningOnCI => _runningOnCI; + + public string? GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); } } diff --git a/MSStore.CLI/Services/IEnvironmentInformationService.cs b/MSStore.CLI/Services/IEnvironmentInformationService.cs index 5c91c15..bf486ef 100644 --- a/MSStore.CLI/Services/IEnvironmentInformationService.cs +++ b/MSStore.CLI/Services/IEnvironmentInformationService.cs @@ -6,5 +6,14 @@ namespace MSStore.CLI.Services internal interface IEnvironmentInformationService { bool IsRunningOnCI { get; } + + /// + /// Reads an environment variable. Going through this service rather than + /// directly keeps consumers testable without + /// mutating process-wide state. + /// + /// The name of the environment variable. + /// The value, or null when the variable is not set. + string? GetEnvironmentVariable(string name); } } diff --git a/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs new file mode 100644 index 0000000..d83e22d --- /dev/null +++ b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MSStore.CLI.Services.CredentialManager; +using MSStore.CLI.Services.Translation.Models; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Translates text with the Azure AI Translator Text API. + /// + /// + /// This targets API version 3.0. Version 2026-06-06 is the newer GA release but is not + /// backward compatible: it nests the request under inputs, returns results under + /// value, moves target languages into the body, and its headline features expect + /// a Microsoft Foundry resource. Version 3.0 works with a plain Translator resource key, + /// which is what users supply here. The version and the wire DTOs are deliberately + /// isolated so moving to a newer version stays contained to this class and its models. + /// + internal class AzureAITranslatorService( + IHttpClientFactory httpClientFactory, + ICredentialManager credentialManager, + IConfigurationManager configurationManager, + IEnvironmentInformationService environmentInformationService, + ILogger logger) : ITranslationService + { + /// + /// The name the Translator key is stored under in the OS secure store. + /// + internal const string CredentialKeyName = "AzureAITranslator"; + + internal const string KeyEnvironmentVariable = "MSSTORE_TRANSLATOR_KEY"; + internal const string RegionEnvironmentVariable = "MSSTORE_TRANSLATOR_REGION"; + + internal const string ApiVersion = "3.0"; + + /// + /// The documented maximum number of elements in one translate request. + /// + internal const int MaxElementsPerRequest = 1000; + + /// + /// The documented maximum number of characters in one translate request, counted + /// across all target languages. Only one target is ever requested here. + /// + internal const int MaxCharactersPerRequest = 50000; + + private const int MaxRetryAttempts = 3; + + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + private readonly ICredentialManager _credentialManager = credentialManager ?? throw new ArgumentNullException(nameof(credentialManager)); + private readonly IConfigurationManager _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); + private readonly IEnvironmentInformationService _environmentInformationService = environmentInformationService ?? throw new ArgumentNullException(nameof(environmentInformationService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + private Dictionary? _cachedLanguages; + + public async Task ResolveLanguageAsync(string language, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(language)) + { + throw new TranslationException("No target language was provided for translation."); + } + + language = language.Trim(); + + var languages = await GetSupportedLanguagesAsync(ct); + if (languages == null) + { + // The language list is only used to canonicalize and validate. If it is + // unavailable, let the service itself reject an invalid code. + return language; + } + + var match = languages.Keys.FirstOrDefault(k => string.Equals(k, language, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + return match; + } + + throw new TranslationException($"'{language}' is not a language supported by Azure AI Translator. See https://learn.microsoft.com/azure/ai-services/translator/language-support for the list of supported codes."); + } + + public async Task> TranslateAsync(IReadOnlyList texts, string targetLanguage, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(texts); + + var results = new TranslationResult?[texts.Count]; + + // Only non-empty entries are billable and worth sending. Track the original + // index of each so results line up with the caller's list. + var pending = new List(); + for (var i = 0; i < texts.Count; i++) + { + if (!string.IsNullOrWhiteSpace(texts[i])) + { + pending.Add(i); + } + } + + if (pending.Count == 0) + { + return results; + } + + var key = GetKey(); + if (string.IsNullOrEmpty(key)) + { + throw new TranslationException( + $"No Azure AI Translator key is configured. Set the {KeyEnvironmentVariable} environment variable, or store one with 'msstore settings set-translator-key'."); + } + + var region = await GetRegionAsync(ct); + + foreach (var batch in CreateBatches(texts, pending)) + { + var translated = await TranslateBatchAsync([.. batch.Select(i => texts[i]!)], targetLanguage, key, region, ct); + + for (var i = 0; i < batch.Count; i++) + { + results[batch[i]] = i < translated.Count ? translated[i] : null; + } + } + + return results; + } + + /// + /// Splits the indexes to translate into batches that respect both documented limits. + /// A single item longer than the per-request character limit is sent on its own so + /// the service can report the specific error rather than the batch failing opaquely. + /// + /// The full list of texts being translated. + /// The indexes of the entries that need translating. + /// The indexes grouped into batches. + private static List> CreateBatches(IReadOnlyList texts, List indexes) + { + var batches = new List>(); + var current = new List(); + var currentLength = 0; + + foreach (var index in indexes) + { + var length = texts[index]!.Length; + + if (current.Count > 0 && + (current.Count >= MaxElementsPerRequest || currentLength + length > MaxCharactersPerRequest)) + { + batches.Add(current); + current = []; + currentLength = 0; + } + + current.Add(index); + currentLength += length; + } + + if (current.Count > 0) + { + batches.Add(current); + } + + return batches; + } + + private static bool IsTransient(HttpStatusCode statusCode) => + statusCode == HttpStatusCode.TooManyRequests || + statusCode == HttpStatusCode.RequestTimeout || + statusCode >= HttpStatusCode.InternalServerError; + + /// + /// Translator does not document a Retry-After header on 429, so exponential backoff + /// with jitter is the primary strategy and the header is only honoured if present. + /// + /// The throttled or failed response. + /// The 1-based attempt number that just failed. + /// How long to wait before retrying. + private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) + { + var retryAfter = response.Headers.RetryAfter; + if (retryAfter?.Delta is TimeSpan delta && delta > TimeSpan.Zero) + { + return delta; + } + + if (retryAfter?.Date is DateTimeOffset date) + { + var until = date - DateTimeOffset.UtcNow; + if (until > TimeSpan.Zero) + { + return until; + } + } + + var backoff = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + return backoff + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)); + } + + private string? GetKey() + { + var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(KeyEnvironmentVariable); + if (!string.IsNullOrEmpty(fromEnvironment)) + { + return fromEnvironment; + } + + var stored = _credentialManager.ReadCredential(CredentialKeyName); + return string.IsNullOrEmpty(stored) ? null : stored; + } + + private async Task GetRegionAsync(CancellationToken ct) + { + var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(RegionEnvironmentVariable); + if (!string.IsNullOrEmpty(fromEnvironment)) + { + return fromEnvironment; + } + + var config = await _configurationManager.LoadAsync(ct: ct); + return string.IsNullOrEmpty(config.TranslatorRegion) ? null : config.TranslatorRegion; + } + + private async Task?> GetSupportedLanguagesAsync(CancellationToken ct) + { + if (_cachedLanguages != null) + { + return _cachedLanguages; + } + + try + { + using var httpClient = _httpClientFactory.CreateClient(nameof(AzureAITranslatorService)); + + // Listing languages requires no authentication. + using var response = await httpClient.GetAsync($"/languages?api-version={ApiVersion}&scope=translation", ct); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Could not retrieve the list of supported languages: {StatusCode}.", response.StatusCode); + return null; + } + + var languages = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(ct), + TranslationSourceGenerationContext.GetCustom().TranslatorLanguagesResponse); + + _cachedLanguages = languages?.Translation; + return _cachedLanguages; + } + catch (Exception err) when (err is not OperationCanceledException) + { + _logger.LogWarning(err, "Could not retrieve the list of supported languages."); + return null; + } + } + + private async Task> TranslateBatchAsync(IReadOnlyList texts, string targetLanguage, string key, string? region, CancellationToken ct) + { + var body = JsonSerializer.Serialize( + texts.Select(t => new TranslateInput { Text = t }).ToList(), + TranslationSourceGenerationContext.GetCustom().ListTranslateInput); + + // The 'from' parameter is deliberately omitted so the service auto-detects the + // source language inline. A separate /detect call would be metered separately. + var route = $"/translate?api-version={ApiVersion}&to={Uri.EscapeDataString(targetLanguage)}"; + + using var httpClient = _httpClientFactory.CreateClient(nameof(AzureAITranslatorService)); + + for (var attempt = 1; ; attempt++) + { + using var request = new HttpRequestMessage(HttpMethod.Post, route) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Ocp-Apim-Subscription-Key", key); + + // Required for regional and multi-service resources, optional for a global + // resource. Sending it when present is harmless; omitting it when needed + // produces a 401. + if (!string.IsNullOrEmpty(region)) + { + request.Headers.Add("Ocp-Apim-Subscription-Region", region); + } + + using var response = await httpClient.SendAsync(request, ct); + + if (response.IsSuccessStatusCode) + { + if (response.Headers.TryGetValues("X-metered-usage", out var usage)) + { + _logger.LogInformation("Translator billed characters for this request: {MeteredUsage}.", string.Join(',', usage)); + } + + var items = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(ct), + TranslationSourceGenerationContext.GetCustom().ListTranslateResultItem); + + if (items == null) + { + throw new TranslationException("The translation service returned an unreadable response."); + } + + return [.. items.Select(item => + { + var text = item.Translations?.FirstOrDefault()?.Text; + return text == null ? null : new TranslationResult(text, item.DetectedLanguage?.Language); + })]; + } + + if (IsTransient(response.StatusCode) && attempt < MaxRetryAttempts) + { + await Task.Delay(GetRetryDelay(response, attempt), ct); + continue; + } + + throw await CreateExceptionAsync(response, ct); + } + } + + private async Task CreateExceptionAsync(HttpResponseMessage response, CancellationToken ct) + { + var content = await response.Content.ReadAsStringAsync(ct); + + TranslatorError? error = null; + try + { + error = JsonSerializer.Deserialize(content, TranslationSourceGenerationContext.GetCustom().TranslatorErrorResponse)?.Error; + } + catch (JsonException) + { + // Fall through to the generic message below. + } + + if (response.Headers.TryGetValues("X-RequestId", out var requestIds)) + { + _logger.LogError("Translator request failed. X-RequestId: {RequestId}. Body: {Body}", string.Join(',', requestIds), content); + } + else + { + _logger.LogError("Translator request failed with {StatusCode}. Body: {Body}", response.StatusCode, content); + } + + var message = error?.Code switch + { + 401015 => "The key provided is for the Speech API, but the Text Translation API is required. Check that you copied the key from a Translator resource.", + >= 401000 and < 402000 => $"Azure AI Translator rejected the credentials. Check the {KeyEnvironmentVariable} value, and set {RegionEnvironmentVariable} if your Translator resource is regional or multi-service rather than global.", + 403001 => "The Azure AI Translator subscription has exceeded its free quota.", + >= 403000 and < 404000 => "Azure AI Translator refused the operation. This usually means the resource region is wrong or missing.", + >= 429000 and < 430000 => "Azure AI Translator is throttling this request. The free tier allows 2 million characters per hour, consumed evenly, so a large burst of reviews can be rejected. Try a smaller --top value.", + 400019 or 400036 => "Azure AI Translator does not support the requested target language.", + 400050 => "A review is longer than the maximum length Azure AI Translator accepts.", + _ => null + }; + + if (message != null) + { + return new TranslationException(message); + } + + return new TranslationException( + error?.Message is { Length: > 0 } serviceMessage + ? $"Azure AI Translator returned an error: {serviceMessage}" + : $"Azure AI Translator returned {(int)response.StatusCode} {response.ReasonPhrase}."); + } + } +} diff --git a/MSStore.CLI/Services/Translation/ITranslationService.cs b/MSStore.CLI/Services/Translation/ITranslationService.cs new file mode 100644 index 0000000..f87230c --- /dev/null +++ b/MSStore.CLI/Services/Translation/ITranslationService.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Translates text into a target language. + /// + /// + /// The Microsoft Store APIs return no translated text and no language metadata for + /// reviews, so translation has to come from a separate service. This interface keeps + /// the provider and its wire format out of the commands. + /// + internal interface ITranslationService + { + /// + /// Resolves a user-supplied language code to the canonical code used by the service. + /// + /// The language code supplied by the user. + /// Cancellation token. + /// + /// The canonical language code, or unchanged when the + /// supported-language list could not be retrieved. + /// + Task ResolveLanguageAsync(string language, CancellationToken ct = default); + + /// + /// Translates each entry of into . + /// + /// The texts to translate. + /// The canonical target language code. + /// Cancellation token. + /// + /// One entry per input, in the same order. Entries whose input was null or + /// whitespace are null, and were never sent to the service. + /// + Task> TranslateAsync(IReadOnlyList texts, string targetLanguage, CancellationToken ct = default); + } +} diff --git a/MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs b/MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs new file mode 100644 index 0000000..9573655 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class DetectedLanguageInfo + { + public string? Language { get; set; } + + /// + /// Gets or sets the confidence of the detection, between 0.0 and 1.0. The published + /// Swagger types this as an integer, which is wrong; the service returns a float. + /// + public double Score { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslateInput.cs b/MSStore.CLI/Services/Translation/Models/TranslateInput.cs new file mode 100644 index 0000000..8c4514d --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslateInput.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + /// + /// A single element of the Translator v3.0 request body, which is a bare JSON array. + /// + internal class TranslateInput + { + public string? Text { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs b/MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs new file mode 100644 index 0000000..5cb0a55 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace MSStore.CLI.Services.Translation.Models +{ + /// + /// A single element of the Translator v3.0 response body, which is a bare JSON array + /// with one element per input, in the same order. + /// + internal class TranslateResultItem + { + /// + /// Gets or sets the detected source language. Only present when the request omitted + /// the from parameter, which is how automatic detection is requested. + /// + public DetectedLanguageInfo? DetectedLanguage { get; set; } + + public List? Translations { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslationInfo.cs b/MSStore.CLI/Services/Translation/Models/TranslationInfo.cs new file mode 100644 index 0000000..afc23a3 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslationInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslationInfo + { + public string? Text { get; set; } + public string? To { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorError.cs b/MSStore.CLI/Services/Translation/Models/TranslatorError.cs new file mode 100644 index 0000000..7d27f6f --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorError.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslatorError + { + /// + /// Gets or sets the six-digit error code, made of the three-digit HTTP status + /// followed by a three-digit sub-code. The service returns this as a JSON number, + /// not a string, despite what the published Swagger says. + /// + public int Code { get; set; } + + public string? Message { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs b/MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs new file mode 100644 index 0000000..6a4d6ac --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslatorErrorResponse + { + public TranslatorError? Error { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs b/MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs new file mode 100644 index 0000000..17a9877 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslatorLanguage + { + public string? Name { get; set; } + public string? NativeName { get; set; } + public string? Dir { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs b/MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs new file mode 100644 index 0000000..253a4e1 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace MSStore.CLI.Services.Translation.Models +{ + /// + /// The response of GET /languages?api-version=3.0&scope=translation, which + /// requires no authentication. + /// + internal class TranslatorLanguagesResponse + { + /// + /// Gets or sets the supported languages, keyed by BCP-47 tag. + /// + public Dictionary? Translation { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/TranslationException.cs b/MSStore.CLI/Services/Translation/TranslationException.cs new file mode 100644 index 0000000..d589f2f --- /dev/null +++ b/MSStore.CLI/Services/Translation/TranslationException.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Raised when the translation service cannot fulfil a request. The message is intended + /// to be shown to the user directly. + /// + internal class TranslationException : Exception + { + public TranslationException() + { + } + + public TranslationException(string message) + : base(message) + { + } + + public TranslationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/MSStore.CLI/Services/Translation/TranslationResult.cs b/MSStore.CLI/Services/Translation/TranslationResult.cs new file mode 100644 index 0000000..2c7ad8c --- /dev/null +++ b/MSStore.CLI/Services/Translation/TranslationResult.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation +{ + /// + /// The translation of a single piece of text. + /// + internal class TranslationResult(string text, string? detectedLanguage) + { + /// + /// Gets the translated text. + /// + public string Text { get; } = text; + + /// + /// Gets the language detected in the source text, or null when the service did not + /// report one. + /// + public string? DetectedLanguage { get; } = detectedLanguage; + } +} diff --git a/MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs b/MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs new file mode 100644 index 0000000..2a3fcb7 --- /dev/null +++ b/MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using MSStore.CLI.Services.Translation.Models; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Source Generator Configuration for JSON Serialization/Deserialization of + /// Azure AI Translator calls. + /// + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(TranslatorErrorResponse))] + [JsonSerializable(typeof(TranslatorLanguagesResponse))] + internal partial class TranslationSourceGenerationContext : JsonSerializerContext + { + private static TranslationSourceGenerationContext? _default; + + public static TranslationSourceGenerationContext GetCustom() + { + return _default ??= new TranslationSourceGenerationContext(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + PropertyNameCaseInsensitive = true, + IgnoreReadOnlyFields = false, + IgnoreReadOnlyProperties = false, + IncludeFields = false + }); + } + } +} diff --git a/MSStore.CLI/StoreHostBuilderExtensions.cs b/MSStore.CLI/StoreHostBuilderExtensions.cs index 8670b3f..8972c71 100644 --- a/MSStore.CLI/StoreHostBuilderExtensions.cs +++ b/MSStore.CLI/StoreHostBuilderExtensions.cs @@ -17,6 +17,7 @@ internal static class StoreHostBuilderExtensions [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ReconfigureCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SettingsCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Settings.SetPublisherDisplayNameCommand.Handler))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Settings.SetTranslatorKeyCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Apps.ListCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Apps.GetCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Submission.StatusCommand.Handler))] @@ -45,6 +46,8 @@ internal static class StoreHostBuilderExtensions [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Flights.Submission.Rollout.UpdateCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Flights.Submission.Rollout.HaltCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Flights.Submission.Rollout.FinalizeCommand.Handler))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Reviews.ListCommand.Handler))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Reviews.GetCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PackageCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PublishCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MicrosoftStoreCLI.Handler))] @@ -59,6 +62,7 @@ public static IHostBuilder ConfigureStoreCLICommands(this IHostBuilder builder) .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() .ConfigureCommand() @@ -95,6 +99,9 @@ public static IHostBuilder ConfigureStoreCLICommands(this IHostBuilder builder) .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .ConfigureCommand() + .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler(); }); } diff --git a/README.md b/README.md index f01df15..861acd1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,44 @@ The Microsoft Store Developer Command Line Interface is a cross-platform (Window ## Helpful links * [Documentation](https://aka.ms/msstoredevcli/docs) - Microsoft's official documentation on regards to available commands, installation steps, how to properly setup CI/CD environments, and general guidance. +## Reviews + +Read the Store reviews of a managed (MSIX) application: + +``` +msstore reviews list +msstore reviews get +``` + +`list` renders a table and supports `--startDate`, `--endDate`, `--top`, `--skip`, `--rating` and `--market`. With no date options every review is returned; pass `--startDate`/`--endDate` to narrow the range. The `Id` column is the value `reviews get` takes. + +> Responding to reviews is not supported. Microsoft documents its [Store reviews API](https://learn.microsoft.com/windows/uwp/monetize/submit-responses-to-app-reviews) as "currently not in a working state", and points to [Partner Center](https://learn.microsoft.com/windows/apps/publish/analyze-msi-exe/ratings-reviews-performance) instead. + +### Translating reviews + +The Microsoft Store returns no translated text and no language information for reviews, so `--translate` uses [Azure AI Translator](https://learn.microsoft.com/azure/ai-services/translator/) with a key you supply: + +``` +msstore reviews list --translate # translates into English +msstore reviews list --translate pt # translates into Portuguese +``` + +Provide the key through environment variables: + +| Variable | Required | Description | +| --- | --- | --- | +| `MSSTORE_TRANSLATOR_KEY` | Yes | The Azure AI Translator resource key. | +| `MSSTORE_TRANSLATOR_REGION` | Only for regional and multi-service resources | The resource region. Not needed for a global resource. | + +Or store them once in the OS secure store: + +``` +msstore settings set-translator-key --region +msstore settings set-translator-key --clear +``` + +Translation is billed per source character, per target language, against your own Azure subscription. + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a From b897b9753b56b9c32d81698e2558e94288ff5113 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 13:49:34 -0700 Subject: [PATCH 2/8] Tailor the reviews list empty-result message to the query The message always claimed "for the requested period", even when no date option was passed. With no dates the CLI sends no date parameters and the service returns reviews from every date, so the wording implied a range that was never applied. It now reflects what actually narrowed the query, covering the --rating and --market filters as well as the dates. The date-narrowing check that `reviews get` already used moves to a shared ParseResultExtensions helper so both commands stay consistent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- .../ReviewsCommandUnitTests.cs | 56 ++++++++++++++++++- MSStore.CLI/Commands/Reviews/GetCommand.cs | 8 +-- MSStore.CLI/Commands/Reviews/ListCommand.cs | 15 ++++- MSStore.CLI/Helpers/ParseResultExtensions.cs | 16 ++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs index 220d173..9e8a3c9 100644 --- a/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs +++ b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs @@ -138,7 +138,61 @@ public async Task ReviewsListCommandShouldReportWhenThereAreNoReviews() "9PN3ABCDEFGA" ]); - result.Error.Should().Contain("no"); + result.Error.Should().Contain("This application has no reviews."); + + // No date or filter option was passed, so the service returns reviews from every + // date. Referring to a period or filters here would misdirect the user. + result.Error.Should().NotContain("period"); + result.Error.Should().NotContain("filters"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldMentionThePeriodWhenNarrowedByDate() + { + FakeReviews.Clear(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--startDate", + "2024-01-01" + ]); + + result.Error.Should().Contain("for the requested period."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldMentionFiltersWhenNarrowedByFilter() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--market", + "ZZ" + ]); + + result.Error.Should().Contain("matching the requested filters."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldMentionBothWhenNarrowedByDateAndFilter() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--startDate", + "2024-01-01", + "--rating", + "2" + ]); + + result.Error.Should().Contain("matching the requested period and filters."); } [TestMethod] diff --git a/MSStore.CLI/Commands/Reviews/GetCommand.cs b/MSStore.CLI/Commands/Reviews/GetCommand.cs index cc64d95..5c8eb4d 100644 --- a/MSStore.CLI/Commands/Reviews/GetCommand.cs +++ b/MSStore.CLI/Commands/Reviews/GetCommand.cs @@ -123,13 +123,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (review == null) { - // Only mention the date range when the user actually narrowed it. Leaving - // both options off sends no date parameters at all, and the service then - // searches every review, so suggesting --startDate would be misleading. - var narrowedByDate = parseResult.GetResult(ReviewsCommand.StartDateOption) != null - || parseResult.GetResult(ReviewsCommand.EndDateOption) != null; - - _ansiConsole.MarkupLine(narrowedByDate + _ansiConsole.MarkupLine(parseResult.NarrowedReviewsByDate() ? $"Could not find review with ID '{reviewId.EscapeMarkup()}' within the requested date range. Try widening it with [bold]--startDate[/] and [bold]--endDate[/]." : $"Could not find review with ID '{reviewId.EscapeMarkup()}'."); diff --git a/MSStore.CLI/Commands/Reviews/ListCommand.cs b/MSStore.CLI/Commands/Reviews/ListCommand.cs index 590a587..1e32ed9 100644 --- a/MSStore.CLI/Commands/Reviews/ListCommand.cs +++ b/MSStore.CLI/Commands/Reviews/ListCommand.cs @@ -168,7 +168,20 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (reviews.Count == 0) { - _ansiConsole.MarkupLine("This application has [bold][u]no[/] reviews[/] for the requested period."); + // Only refer to a period or filters when the caller actually narrowed the + // query. With no options the service returns reviews from every date, so + // implying a range was applied would be misleading. + var narrowedByDate = parseResult.NarrowedReviewsByDate(); + var narrowedByFilter = rating.HasValue || !string.IsNullOrWhiteSpace(parseResult.GetValue(MarketOption)); + + _ansiConsole.MarkupLine((narrowedByDate, narrowedByFilter) switch + { + (true, true) => "This application has [bold][u]no[/] reviews[/] matching the requested period and filters.", + (true, false) => "This application has [bold][u]no[/] reviews[/] for the requested period.", + (false, true) => "This application has [bold][u]no[/] reviews[/] matching the requested filters.", + (false, false) => "This application has [bold][u]no[/] reviews[/]." + }); + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); } diff --git a/MSStore.CLI/Helpers/ParseResultExtensions.cs b/MSStore.CLI/Helpers/ParseResultExtensions.cs index ae0a9a3..c8ac097 100644 --- a/MSStore.CLI/Helpers/ParseResultExtensions.cs +++ b/MSStore.CLI/Helpers/ParseResultExtensions.cs @@ -31,5 +31,21 @@ public static bool IsVerbose(this ParseResult parseResult) return string.IsNullOrWhiteSpace(language) ? ReviewsCommand.DefaultTranslateLanguage : language; } + + /// + /// Indicates whether the caller narrowed the reviews query by date. + /// + /// The parsed command line. + /// True when --startDate or --endDate was supplied. + /// + /// Leaving both options off sends no date parameters at all, and the service then + /// returns reviews from every date, so messages must not imply a date range was + /// applied in that case. + /// + public static bool NarrowedReviewsByDate(this ParseResult parseResult) + { + return parseResult.GetResult(ReviewsCommand.StartDateOption) != null + || parseResult.GetResult(ReviewsCommand.EndDateOption) != null; + } } } From e456d0f766f3f1388380f68218f8f67b1e278640 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 14:01:00 -0700 Subject: [PATCH 3/8] Validate negative and zero paging values for reviews --top -5 and --skip -3 were passed straight through to the analytics API, which rejected them with an opaque "Error!" and no indication of which argument was wrong. Both are now checked in the command with a clear message, and guarded in GetAppReviewsAsync so any consumer of MSStore.API gets a deterministic ArgumentOutOfRangeException. The argument checks run before AssertClientInitialized so a bad value is always reported as such rather than depending on initialization order. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- MSStore.API/Packaged/StorePackagedAPI.cs | 14 +++- .../ReviewsCommandUnitTests.cs | 34 +++++++++ .../StorePackagedAPIReviewsUnitTests.cs | 69 +++++++++++++++++++ MSStore.CLI/Commands/Reviews/ListCommand.cs | 12 ++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs diff --git a/MSStore.API/Packaged/StorePackagedAPI.cs b/MSStore.API/Packaged/StorePackagedAPI.cs index e71bdbf..7beef03 100644 --- a/MSStore.API/Packaged/StorePackagedAPI.cs +++ b/MSStore.API/Packaged/StorePackagedAPI.cs @@ -673,13 +673,25 @@ public async Task FinalizePackageRolloutAsync(string productId, public async Task> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default) { - AssertClientInitialized(); + // Arguments are validated before the client state so that a bad value is always + // reported as such, rather than depending on initialization order. + if (top is < 1) + { + throw new ArgumentOutOfRangeException(nameof(top), "The number of reviews to retrieve must be at least 1."); + } if (top is > MaxReviewsPerRequest) { throw new ArgumentOutOfRangeException(nameof(top), $"The Microsoft Store analytics API accepts at most {MaxReviewsPerRequest} reviews per request."); } + if (skip is < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip), "The number of reviews to skip cannot be negative."); + } + + AssertClientInitialized(); + var url = new StringBuilder(string.Format( CultureInfo.InvariantCulture, DevCenterAnalyticsReviewsTemplate, diff --git a/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs index 9e8a3c9..b599ff9 100644 --- a/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs +++ b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs @@ -112,6 +112,40 @@ public async Task ReviewsListCommandShouldRejectTooLargeTop() result.Error.Should().Contain("--top cannot be greater than 10000."); } + [TestMethod] + [DataRow("0")] + [DataRow("-5")] + public async Task ReviewsListCommandShouldRejectNonPositiveTop(string top) + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--top", + top + ], + -1); + + result.Error.Should().Contain("--top must be at least 1."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRejectNegativeSkip() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--skip", + "-3" + ], + -1); + + result.Error.Should().Contain("--skip cannot be negative."); + } + [TestMethod] public async Task ReviewsListCommandIsNotSupportedForUnpackagedApps() { diff --git a/MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs b/MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs new file mode 100644 index 0000000..eac5a19 --- /dev/null +++ b/MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using MSStore.API.Models; +using MSStore.API.Packaged; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class StorePackagedAPIReviewsUnitTests + { + public TestContext TestContext { get; set; } = null!; + + /// + /// Paging arguments are validated before the client is used, so an invalid value is + /// always reported as such rather than reaching the service, which rejects it with an + /// opaque error. + /// + /// The API instance under test. + private static StorePackagedAPI CreateApi() => new( + new StoreConfigurations + { + SellerId = 1, + TenantId = new Guid("41261775-DB6D-4B44-9A36-7EB8565C7D22"), + ClientId = new Guid("3F0BCAEF-6334-48CF-837F-81CB0F1F2C45") + }, + "fakeSecret", + null, + null); + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + public async Task GetAppReviewsAsyncShouldRejectNonPositiveTop(int top) + { + using var api = CreateApi(); + + var act = async () => await api.GetAppReviewsAsync("9PN3ABCDEFGA", top: top, ct: TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithParameterName(nameof(top)); + } + + [TestMethod] + public async Task GetAppReviewsAsyncShouldRejectTopAboveTheServiceMaximum() + { + using var api = CreateApi(); + + var act = async () => await api.GetAppReviewsAsync( + "9PN3ABCDEFGA", + top: StorePackagedAPI.MaxReviewsPerRequest + 1, + ct: TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithParameterName("top"); + } + + [TestMethod] + public async Task GetAppReviewsAsyncShouldRejectNegativeSkip() + { + using var api = CreateApi(); + + var act = async () => await api.GetAppReviewsAsync("9PN3ABCDEFGA", skip: -1, ct: TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithParameterName("skip"); + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/ListCommand.cs b/MSStore.CLI/Commands/Reviews/ListCommand.cs index 1e32ed9..8a6905b 100644 --- a/MSStore.CLI/Commands/Reviews/ListCommand.cs +++ b/MSStore.CLI/Commands/Reviews/ListCommand.cs @@ -89,12 +89,24 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio } var top = parseResult.GetValue(TopOption); + if (top is < 1) + { + _ansiConsole.MarkupLine("[bold red]--top must be at least 1.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + if (top is > StorePackagedAPI.MaxReviewsPerRequest) { _ansiConsole.MarkupLine($"[bold red]--top cannot be greater than {StorePackagedAPI.MaxReviewsPerRequest}.[/]"); return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); } + if (parseResult.GetValue(SkipOption) is < 0) + { + _ansiConsole.MarkupLine("[bold red]--skip cannot be negative.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + var rating = parseResult.GetValue(RatingOption); if (rating is < 1 or > 5) { From d977153251384d8eeb43586b1675d80c037297c6 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 14:22:28 -0700 Subject: [PATCH 4/8] Strip ANSI escape sequences from captured test output ReviewsListCommandShouldReportWhenThereAreNoReviews passed locally but failed on all three CI platforms. Spectre only emits colour and style codes when the underlying stream negotiates ANSI support, which differs between a developer machine and CI, so on CI the rendered message was This application has \e[1;4mno\e[0m\e[1m reviews\e[0m. and the assertion on "This application has no reviews." could not match across the markup boundary. The other message variants passed only because their text happens to fall outside the markup. ParseAndInvokeAsync now strips escape sequences so assertions match the visible text regardless of how the console negotiates ANSI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- MSStore.CLI.UnitTests/BaseCommandLineTest.cs | 26 ++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs index afb96e4..25dc7bb 100644 --- a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs +++ b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs @@ -31,7 +31,10 @@ namespace MSStore.CLI.UnitTests { - public class BaseCommandLineTest + /// + /// Shared host, mocks and console capture for command-line tests. + /// + public partial class BaseCommandLineTest { internal Mock FakeConsole { get; private set; } = null!; internal Mock> FakeConfigurationManager { get; private set; } = null!; @@ -973,9 +976,28 @@ protected void SetupBasedOnTestDataProjectSubPath(DirectoryInfo dirInfo, string[ outputCapture.Captured.ToString().Should().NotContain("💥"); } - return (Output: outputCapture.Captured.ToString() ?? string.Empty, Error: errorCapture.Captured.ToString() ?? string.Empty); + return (Output: StripAnsi(outputCapture.Captured.ToString()), Error: StripAnsi(errorCapture.Captured.ToString())); } + /// + /// Removes ANSI escape sequences so assertions can match the visible text. + /// + /// + /// Spectre emits colour and style codes only when the underlying stream negotiates + /// ANSI support, which differs between a developer machine and CI. Without stripping, + /// an assertion on a string that spans a markup boundary (for example the "no reviews" + /// in "This application has [bold][u]no[/] reviews[/].") passes locally and fails on CI. + /// + /// The captured console output. + /// The output with escape sequences removed. + private static string StripAnsi(string? value) + { + return value == null ? string.Empty : AnsiEscapeSequence().Replace(value, string.Empty); + } + + [System.Text.RegularExpressions.GeneratedRegex("\u001b\\[[0-9;]*[A-Za-z]")] + private static partial System.Text.RegularExpressions.Regex AnsiEscapeSequence(); + private OutputCapture RefreshAnsiConsole() { var errorCapture = new OutputCapture(Console.Error); From 9fe662f3102d804416e58b407a82ea53101a22c7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 14:33:58 -0700 Subject: [PATCH 5/8] Trim translator credentials and correct where the region is stored A key with a trailing newline, which is common when the value is pasted or piped in, is not valid in an HTTP header. The request threw before it was sent, so the user saw a bare "Error!" with no indication of the cause. Keys and regions are now trimmed both when read and before being persisted, and a whitespace-only key is treated as missing. The README also claimed both values go to the OS secure store. Only the key does; the region is not a secret and is saved in settings.json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- .../AzureAITranslatorServiceUnitTests.cs | 49 ++++++++++ .../SetTranslatorKeyCommandUnitTests.cs | 94 +++++++++++++++++++ .../Settings/SetTranslatorKeyCommand.cs | 6 +- .../Translation/AzureAITranslatorService.cs | 19 +++- README.md | 4 +- 5 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs diff --git a/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs index 04e1416..ff1c088 100644 --- a/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs +++ b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs @@ -114,6 +114,55 @@ public async Task TranslateAsyncShouldPreferEnvironmentVariablesOverStoredValues _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("eastus"); } + [TestMethod] + public async Task TranslateAsyncShouldTrimEnvironmentSuppliedCredentials() + { + // A key pasted or piped in often carries a trailing newline, which is not valid + // in a header value and would otherwise throw before the request is sent. + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.KeyEnvironmentVariable)) + .Returns(" env-key\n"); + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.RegionEnvironmentVariable)) + .Returns(" eastus \r\n"); + + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be("env-key"); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("eastus"); + } + + [TestMethod] + public async Task TranslateAsyncShouldTrimStoredCredentials() + { + _credentialManager + .Setup(x => x.ReadCredential(AzureAITranslatorService.CredentialKeyName)) + .Returns($"{FakeKey}\n"); + _configurations.TranslatorRegion = " westus2 "; + + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be(FakeKey); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("westus2"); + } + + [TestMethod] + public async Task TranslateAsyncShouldTreatWhitespaceOnlyKeyAsMissing() + { + _credentialManager + .Setup(x => x.ReadCredential(It.IsAny())) + .Returns(" "); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*MSSTORE_TRANSLATOR_KEY*set-translator-key*"); + } + [TestMethod] public async Task TranslateAsyncShouldSkipEmptyEntriesButKeepPositions() { diff --git a/MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs b/MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs new file mode 100644 index 0000000..0aa9c63 --- /dev/null +++ b/MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using MSStore.CLI.Services; +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class SetTranslatorKeyCommandUnitTests : BaseCommandLineTest + { + [TestInitialize] + public void Init() + { + FakeLogin(); + AddDefaultFakeAccount(); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldStoreTheKeyInTheSecureStore() + { + var result = await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key", + "my-translator-key" + ]); + + result.Error.Should().Contain("stored"); + + CredentialManager.Verify( + x => x.WriteCredential(AzureAITranslatorService.CredentialKeyName, "my-translator-key"), + Times.Once); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldTrimTheKeyAndRegionBeforePersisting() + { + // Whitespace around a pasted value is not valid in a request header, so it must + // never reach the secure store or settings.json. + Configurations? saved = null; + FakeConfigurationManager + .Setup(x => x.SaveAsync(It.IsAny(), It.IsAny())) + .Callback((Configurations c, CancellationToken ct) => saved = c) + .Returns(Task.CompletedTask); + + await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key", + " my-translator-key\t", + "--region", + " westus2 " + ]); + + CredentialManager.Verify( + x => x.WriteCredential(AzureAITranslatorService.CredentialKeyName, "my-translator-key"), + Times.Once); + + saved.Should().NotBeNull(); + saved!.TranslatorRegion.Should().Be("westus2"); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldRequireAKey() + { + var result = await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key" + ], + -1); + + result.Error.Should().Contain("A key is required."); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldClearStoredValues() + { + var result = await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key", + "--clear" + ]); + + result.Error.Should().Contain("cleared"); + + CredentialManager.Verify( + x => x.ClearCredentials(AzureAITranslatorService.CredentialKeyName), + Times.Once); + } + } +} diff --git a/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs b/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs index 089d087..a71f604 100644 --- a/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs +++ b/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs @@ -89,11 +89,13 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return await _telemetryClient.TrackCommandEventAsync(-1, ct); } - _credentialManager.WriteCredential(AzureAITranslatorService.CredentialKeyName, key); + // Keys and regions are frequently pasted or piped in with surrounding + // whitespace, which is not valid in a request header. + _credentialManager.WriteCredential(AzureAITranslatorService.CredentialKeyName, key.Trim()); if (!string.IsNullOrWhiteSpace(region)) { - config.TranslatorRegion = region; + config.TranslatorRegion = region.Trim(); await _configurationManager.SaveAsync(config, ct); } diff --git a/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs index d83e22d..a22634f 100644 --- a/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs +++ b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs @@ -206,28 +206,39 @@ private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) return backoff + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)); } + /// + /// Reads the Translator key, preferring the environment variable over the stored one. + /// + /// + /// Values are trimmed because a key pasted or piped in often carries trailing + /// whitespace or a newline, and header values cannot contain either. An untrimmed + /// newline makes the request throw before it is ever sent, which surfaces as an + /// opaque failure rather than an authentication message. + /// + /// The key, or null when none is configured. private string? GetKey() { - var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(KeyEnvironmentVariable); + var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(KeyEnvironmentVariable)?.Trim(); if (!string.IsNullOrEmpty(fromEnvironment)) { return fromEnvironment; } - var stored = _credentialManager.ReadCredential(CredentialKeyName); + var stored = _credentialManager.ReadCredential(CredentialKeyName)?.Trim(); return string.IsNullOrEmpty(stored) ? null : stored; } private async Task GetRegionAsync(CancellationToken ct) { - var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(RegionEnvironmentVariable); + var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(RegionEnvironmentVariable)?.Trim(); if (!string.IsNullOrEmpty(fromEnvironment)) { return fromEnvironment; } var config = await _configurationManager.LoadAsync(ct: ct); - return string.IsNullOrEmpty(config.TranslatorRegion) ? null : config.TranslatorRegion; + var region = config.TranslatorRegion?.Trim(); + return string.IsNullOrEmpty(region) ? null : region; } private async Task?> GetSupportedLanguagesAsync(CancellationToken ct) diff --git a/README.md b/README.md index 861acd1..4d53e6d 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,15 @@ Provide the key through environment variables: | `MSSTORE_TRANSLATOR_KEY` | Yes | The Azure AI Translator resource key. | | `MSSTORE_TRANSLATOR_REGION` | Only for regional and multi-service resources | The resource region. Not needed for a global resource. | -Or store them once in the OS secure store: +Or store them once, so they persist between runs: ``` msstore settings set-translator-key --region msstore settings set-translator-key --clear ``` +The key is held in the OS secure store; the region is not a secret and is saved in `settings.json`. + Translation is billed per source character, per target language, against your own Azure subscription. ## Contributing From 479ba53b9479f0685517d80f86ec2210f2e0dc9d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 14:41:49 -0700 Subject: [PATCH 6/8] Keep Translator response bodies out of error-level logs The error log included the full Translator response body. A failed request can echo back the submitted review text, so collected CI logs could end up holding customer review content. Error level now carries only the status, the service error code and the X-RequestId, which is what a support case needs. The body moves to debug level. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- .../Translation/AzureAITranslatorService.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs index a22634f..1dee936 100644 --- a/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs +++ b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs @@ -352,14 +352,21 @@ private async Task CreateExceptionAsync(HttpResponseMessag // Fall through to the generic message below. } - if (response.Headers.TryGetValues("X-RequestId", out var requestIds)) - { - _logger.LogError("Translator request failed. X-RequestId: {RequestId}. Body: {Body}", string.Join(',', requestIds), content); - } - else - { - _logger.LogError("Translator request failed with {StatusCode}. Body: {Body}", response.StatusCode, content); - } + var requestId = response.Headers.TryGetValues("X-RequestId", out var requestIds) + ? string.Join(',', requestIds) + : "(none)"; + + // The status, service error code and request id are enough to diagnose a failure + // and to raise a support case. The body is logged only at debug level because a + // failed request can echo back the submitted review text, which should not end up + // in CI logs collected at the default level. + _logger.LogError( + "Translator request failed with {StatusCode}. Service error code: {ErrorCode}. X-RequestId: {RequestId}.", + (int)response.StatusCode, + error?.Code, + requestId); + + _logger.LogDebug("Translator error response body: {Body}", content); var message = error?.Code switch { From b3d4e3e0b0c5d10df95909d4b3ef1a26b4fea097 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 14:50:17 -0700 Subject: [PATCH 7/8] Broaden ANSI stripping to private-mode and OSC sequences The regex only matched CSI sequences made of digits and semicolons, so two kinds of escape Spectre actually emits survived: the cursor hide/show pair a status spinner writes (ESC[?25l and ESC[?25h), whose private parameter byte is not a digit, and the OSC hyperlinks produced by link markup. Both were verified to pass through unchanged. The pattern now follows the ECMA-48 grammar for CSI and OSC, and StripAnsi is covered by its own tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- MSStore.CLI.UnitTests/BaseCommandLineTest.cs | 17 ++++- MSStore.CLI.UnitTests/StripAnsiUnitTests.cs | 65 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 MSStore.CLI.UnitTests/StripAnsiUnitTests.cs diff --git a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs index 25dc7bb..7937f14 100644 --- a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs +++ b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs @@ -990,12 +990,25 @@ protected void SetupBasedOnTestDataProjectSubPath(DirectoryInfo dirInfo, string[ /// /// The captured console output. /// The output with escape sequences removed. - private static string StripAnsi(string? value) + internal static string StripAnsi(string? value) { return value == null ? string.Empty : AnsiEscapeSequence().Replace(value, string.Empty); } - [System.Text.RegularExpressions.GeneratedRegex("\u001b\\[[0-9;]*[A-Za-z]")] + /// + /// Matches ANSI escape sequences per ECMA-48. + /// + /// + /// Covers more than the colour codes: CSI sequences may carry private parameters, as + /// in the cursor hide/show pair a status spinner emits, and OSC sequences carry the + /// hyperlinks produced by Spectre's link markup. Matching only digits and semicolons + /// would leave both in the captured output. + /// + /// The compiled regular expression. + [System.Text.RegularExpressions.GeneratedRegex( + """ + \u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_] + """)] private static partial System.Text.RegularExpressions.Regex AnsiEscapeSequence(); private OutputCapture RefreshAnsiConsole() diff --git a/MSStore.CLI.UnitTests/StripAnsiUnitTests.cs b/MSStore.CLI.UnitTests/StripAnsiUnitTests.cs new file mode 100644 index 0000000..98cfdfc --- /dev/null +++ b/MSStore.CLI.UnitTests/StripAnsiUnitTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.UnitTests +{ + /// + /// Guards the escape-sequence stripping that keeps console assertions from depending on + /// whether the host negotiated ANSI support. + /// + [TestClass] + public class StripAnsiUnitTests + { + private const string Esc = "\u001b"; + + [TestMethod] + public void ShouldRemoveColourAndStyleCodes() + { + BaseCommandLineTest.StripAnsi($"This application has {Esc}[1;4mno{Esc}[0m{Esc}[1m reviews{Esc}[0m.") + .Should().Be("This application has no reviews."); + } + + [TestMethod] + public void ShouldRemovePrivateModeCodes() + { + // A status spinner hides and shows the cursor with private parameter bytes. + BaseCommandLineTest.StripAnsi($"{Esc}[?25lRetrieving Reviews{Esc}[?25h") + .Should().Be("Retrieving Reviews"); + } + + [TestMethod] + public void ShouldRemoveHyperlinks() + { + // Spectre's link markup emits OSC 8 sequences terminated by a string terminator. + BaseCommandLineTest.StripAnsi($"see {Esc}]8;;https://aka.ms/privacy{Esc}\\the terms{Esc}]8;;{Esc}\\ here") + .Should().Be("see the terms here"); + } + + [TestMethod] + public void ShouldRemoveBellTerminatedHyperlinks() + { + BaseCommandLineTest.StripAnsi($"{Esc}]8;;https://example.com\u0007link{Esc}]8;;\u0007") + .Should().Be("link"); + } + + [TestMethod] + public void ShouldLeavePlainTextUntouched() + { + BaseCommandLineTest.StripAnsi("This application has no reviews.") + .Should().Be("This application has no reviews."); + } + + [TestMethod] + public void ShouldTreatNullAsEmpty() + { + BaseCommandLineTest.StripAnsi(null).Should().BeEmpty(); + } + + [TestMethod] + public void ShouldPreserveNonAsciiContent() + { + BaseCommandLineTest.StripAnsi($"{Esc}[1mUm jogo fantástico{Esc}[0m") + .Should().Be("Um jogo fantástico"); + } + } +} From d9a6cf4200f88f0af56f6f5a78c0c826010366c5 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 9 Sep 2026 15:00:14 -0700 Subject: [PATCH 8/8] Pin the Translator request property name instead of relying on the naming policy The text property was serialized by the shared camelCase policy, so the wire format depended on a setting shared with the response DTOs. The Translator reference is inconsistent about the name: the request body section states it is "Text" and the curl examples and official C# quickstart send that, while the JSON sample in the same section and the published Swagger use "text". The service accepts either. The name is now set explicitly on the property, matching the normative prose, and a test pins the serialized body so a future change to the naming policy cannot silently alter what is sent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 12fc293b-ef9f-4c54-aeec-be90ae079251 --- .../AzureAITranslatorServiceUnitTests.cs | 33 +++++++++++++++---- .../Translation/Models/TranslateInput.cs | 14 ++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs index ff1c088..cbebb54 100644 --- a/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs +++ b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs @@ -17,6 +17,7 @@ public class AzureAITranslatorServiceUnitTests public TestContext TestContext { get; set; } = null!; private List _requests = null!; + private List _requestBodies = null!; private Queue _responses = null!; private Mock _credentialManager = null!; private Mock> _configurationManager = null!; @@ -27,6 +28,7 @@ public class AzureAITranslatorServiceUnitTests public void Init() { _requests = []; + _requestBodies = []; _responses = new Queue(); _credentialManager = new Mock(); @@ -73,6 +75,23 @@ public async Task TranslateAsyncShouldOmitFromParameterSoTheServiceAutoDetects() uri.Should().NotContain("from="); } + [TestMethod] + public async Task TranslateAsyncShouldSendTheDocumentedRequestBodyShape() + { + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + // A bare JSON array whose elements carry the text under the name the Translator + // reference documents. Pinned so a change to the shared naming policy cannot + // silently alter the wire format. + var body = _requestBodies.Single(); + + body.Should().StartWith("[").And.EndWith("]"); + body.Should().Contain("\"Text\":"); + body.Should().Contain("ol\\u00E1"); + } + [TestMethod] public async Task TranslateAsyncShouldNotSendRegionHeaderWhenNoRegionIsConfigured() { @@ -359,7 +378,7 @@ private void EnqueueJson(HttpStatusCode statusCode, string json) private AzureAITranslatorService CreateService() { - var handler = new StubHttpMessageHandler(_requests, _responses); + var handler = new StubHttpMessageHandler(_requests, _requestBodies, _responses); var httpClientFactory = new Mock(); httpClientFactory @@ -377,15 +396,15 @@ private AzureAITranslatorService CreateService() NullLogger.Instance); } - private sealed class StubHttpMessageHandler(List requests, Queue responses) : HttpMessageHandler + private sealed class StubHttpMessageHandler(List requests, List requestBodies, Queue responses) : HttpMessageHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - // The body has to be buffered before the request is disposed by the caller. - if (request.Content != null) - { - await request.Content.LoadIntoBufferAsync(cancellationToken); - } + // The body has to be read here: the caller disposes the request, and with it + // the content, as soon as the send completes. + requestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); requests.Add(request); diff --git a/MSStore.CLI/Services/Translation/Models/TranslateInput.cs b/MSStore.CLI/Services/Translation/Models/TranslateInput.cs index 8c4514d..15eadea 100644 --- a/MSStore.CLI/Services/Translation/Models/TranslateInput.cs +++ b/MSStore.CLI/Services/Translation/Models/TranslateInput.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Text.Json.Serialization; + namespace MSStore.CLI.Services.Translation.Models { /// @@ -8,6 +10,18 @@ namespace MSStore.CLI.Services.Translation.Models /// internal class TranslateInput { + /// + /// Gets or sets the text to translate. + /// + /// + /// The wire name is pinned rather than left to the serializer's naming policy. The + /// reference documentation is inconsistent: the request body section states the + /// property is named Text and the curl examples and official C# quickstart + /// send that, while the JSON sample in the same section and the published Swagger use + /// text. The service accepts either, so this follows the normative prose and, + /// more importantly, no longer changes if the shared naming policy is ever altered. + /// + [JsonPropertyName("Text")] public string? Text { get; set; } } }