diff --git a/src/dotnet/APIView/APIViewUnitTests/AutoReviewControllerTests.cs b/src/dotnet/APIView/APIViewUnitTests/AutoReviewControllerTests.cs new file mode 100644 index 00000000000..a57f66d6a74 --- /dev/null +++ b/src/dotnet/APIView/APIViewUnitTests/AutoReviewControllerTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Claims; +using System.Threading.Tasks; +using ApiView; +using APIViewWeb; +using APIViewWeb.Controllers; +using APIViewWeb.Helpers; +using APIViewWeb.LeanModels; +using APIViewWeb.Managers; +using APIViewWeb.Managers.Interfaces; +using APIViewWeb.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace APIViewUnitTests +{ + public class AutoReviewControllerTests + { + private readonly Mock _mockAuthorizationService; + private readonly Mock _mockCodeFileManager; + private readonly Mock _mockReviewManager; + private readonly Mock _mockApiRevisionsManager; + private readonly Mock _mockCommentsManager; + private readonly Mock _mockConfiguration; + private readonly List _languageServices; + private readonly AutoReviewController _controller; + + public AutoReviewControllerTests() + { + _mockAuthorizationService = new Mock(); + _mockCodeFileManager = new Mock(); + _mockReviewManager = new Mock(); + _mockApiRevisionsManager = new Mock(); + _mockCommentsManager = new Mock(); + _mockConfiguration = new Mock(); + _languageServices = new List + { + new MockLanguageService("C#", false) + }; + + _controller = new AutoReviewController( + _mockAuthorizationService.Object, + _mockCodeFileManager.Object, + _mockReviewManager.Object, + _mockApiRevisionsManager.Object, + _mockCommentsManager.Object, + _mockConfiguration.Object, + _languageServices); + + // Set up the HTTP context with a mock user principal + SetupControllerContext(); + } + + [Theory] + [InlineData("client")] + [InlineData("mgmt")] + [InlineData("CLIENT")] + [InlineData("MGMT")] + public async Task UploadAutoReview_WithValidPackageType_CreatesNewReviewWithCorrectPackageType(string packageTypeValue) + { + // Arrange + var mockFile = CreateMockFormFile("test.json", "dummy content"); + var mockCodeFile = new CodeFile() + { + Name = "test", + Language = "C#", + PackageName = "TestPackage", + PackageVersion = "1.0.0" + }; + + var mockApiRevision = new APIRevisionListItemModel() + { + Id = "test-revision-id", + ReviewId = "test-review-id", + Language = "C#", + IsApproved = false + }; + + var expectedPackageType = Enum.Parse(packageTypeValue, true); + + // Setup mocks for new review creation scenario + _mockCodeFileManager.Setup(m => m.CreateCodeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockCodeFile); + + _mockReviewManager.Setup(m => m.GetReviewAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ReviewListItemModel)null); // No existing review + + _mockReviewManager.Setup(m => m.CreateReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ReviewListItemModel() + { + Id = "test-review-id", + PackageName = "TestPackage", + Language = "C#", + IsApproved = false, + PackageType = expectedPackageType + }); + + _mockApiRevisionsManager.Setup(m => m.CreateAPIRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockApiRevisionsManager.Setup(m => m.UpdateRevisionMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockConfiguration.Setup(c => c["ReviewUrl"]).Returns("https://test.com"); + + // Act + var result = await _controller.UploadAutoReview(mockFile.Object, "test-label", packageType: packageTypeValue); + + // Assert + result.Should().NotBeNull(); + + // UploadAutoReview returns ObjectResult with 202 status and review URL + var objectResult = result.Should().BeOfType().Which; + objectResult.StatusCode.Should().Be(StatusCodes.Status202Accepted); + objectResult.Value.Should().NotBeNull(); + objectResult.Value.Should().BeOfType().Which.Should().Contain("/Assemblies/Review/"); + + // Verify that CreateReviewAsync was called with the correct parsed PackageType + _mockReviewManager.Verify(m => m.CreateReviewAsync( + "TestPackage", + "C#", + false, + It.Is(pt => pt.HasValue && pt.Value == expectedPackageType)), + Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid")] + public async Task UploadAutoReview_WithInvalidPackageType_CreatesReviewWithNullPackageType(string packageTypeValue) + { + // Arrange + var mockFile = CreateMockFormFile("test.json", "dummy content"); + var mockCodeFile = new CodeFile() + { + Name = "test", + Language = "C#", + PackageName = "TestPackage", + PackageVersion = "1.0.0" + }; + + var mockApiRevision = new APIRevisionListItemModel() + { + Id = "test-revision-id", + ReviewId = "test-review-id", + Language = "C#", + IsApproved = false + }; + + // Setup mocks for new review creation scenario + _mockCodeFileManager.Setup(m => m.CreateCodeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockCodeFile); + + _mockReviewManager.Setup(m => m.GetReviewAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ReviewListItemModel)null); // No existing review + + _mockReviewManager.Setup(m => m.CreateReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ReviewListItemModel() + { + Id = "test-review-id", + PackageName = "TestPackage", + Language = "C#", + IsApproved = false, + PackageType = null + }); + + _mockApiRevisionsManager.Setup(m => m.CreateAPIRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockApiRevisionsManager.Setup(m => m.UpdateRevisionMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockConfiguration.Setup(c => c["ReviewUrl"]).Returns("https://test.com"); + + // Act + var result = await _controller.UploadAutoReview(mockFile.Object, "test-label", packageType: packageTypeValue); + + // Assert + result.Should().NotBeNull(); + + // UploadAutoReview returns ObjectResult with 202 status and review URL + var objectResult = result.Should().BeOfType().Which; + objectResult.StatusCode.Should().Be(StatusCodes.Status202Accepted); + objectResult.Value.Should().NotBeNull(); + objectResult.Value.Should().BeOfType(); + + // Verify that CreateReviewAsync was called with null PackageType for invalid values + _mockReviewManager.Verify(m => m.CreateReviewAsync( + "TestPackage", + "C#", + false, + It.Is(pt => !pt.HasValue)), + Times.Once); + } + + [Fact] + public async Task UploadAutoReview_WithExistingReviewWithoutPackageType_UpdatesReviewPackageType() + { + // Arrange + var mockFile = CreateMockFormFile("test.json", "dummy content"); + var mockCodeFile = new CodeFile() + { + Name = "test", + Language = "C#", + PackageName = "TestPackage", + PackageVersion = "1.0.0" + }; + + var existingReview = new ReviewListItemModel() + { + Id = "existing-review-id", + PackageName = "TestPackage", + Language = "C#", + IsApproved = false, + PackageType = null // No package type set initially + }; + + var mockApiRevision = new APIRevisionListItemModel() + { + Id = "test-revision-id", + ReviewId = "existing-review-id", + Language = "C#", + IsApproved = false + }; + + // Setup mocks for existing review scenario + _mockCodeFileManager.Setup(m => m.CreateCodeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockCodeFile); + + _mockReviewManager.Setup(m => m.GetReviewAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(existingReview); + + _mockReviewManager.Setup(m => m.UpdateReviewAsync(It.IsAny())) + .ReturnsAsync(existingReview); + + _mockApiRevisionsManager.Setup(m => m.GetAPIRevisionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + _mockApiRevisionsManager.Setup(m => m.CreateAPIRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockApiRevisionsManager.Setup(m => m.UpdateRevisionMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockConfiguration.Setup(c => c["ReviewUrl"]).Returns("https://test.com"); + + // Act + var result = await _controller.UploadAutoReview(mockFile.Object, "test-label", packageType: "mgmt"); + + // Assert + result.Should().NotBeNull(); + + // UploadAutoReview returns ObjectResult with 202 status and review URL + var objectResult = result.Should().BeOfType().Which; + objectResult.StatusCode.Should().Be(StatusCodes.Status202Accepted); + objectResult.Value.Should().NotBeNull(); + objectResult.Value.Should().BeOfType(); + + // Verify that UpdateReviewAsync was called (indicating the review was updated) + _mockReviewManager.Verify(m => m.UpdateReviewAsync( + It.Is(r => r.PackageType == PackageType.mgmt)), + Times.Once); + } + + [Fact] + public async Task UploadAutoReview_WithExistingReviewWithPackageType_DoesNotOverridePackageType() + { + // Arrange + var mockFile = CreateMockFormFile("test.json", "dummy content"); + var mockCodeFile = new CodeFile() + { + Name = "test", + Language = "C#", + PackageName = "TestPackage", + PackageVersion = "1.0.0" + }; + + var existingReview = new ReviewListItemModel() + { + Id = "existing-review-id", + PackageName = "TestPackage", + Language = "C#", + IsApproved = false, + PackageType = PackageType.client // Already has package type set + }; + + var mockApiRevision = new APIRevisionListItemModel() + { + Id = "test-revision-id", + ReviewId = "existing-review-id", + Language = "C#", + IsApproved = false + }; + + // Setup mocks for existing review scenario + _mockCodeFileManager.Setup(m => m.CreateCodeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockCodeFile); + + _mockReviewManager.Setup(m => m.GetReviewAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(existingReview); + + _mockApiRevisionsManager.Setup(m => m.GetAPIRevisionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + _mockApiRevisionsManager.Setup(m => m.CreateAPIRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockApiRevisionsManager.Setup(m => m.UpdateRevisionMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockApiRevision); + + _mockConfiguration.Setup(c => c["ReviewUrl"]).Returns("https://test.com"); + + // Act + var result = await _controller.UploadAutoReview(mockFile.Object, "test-label", packageType: "mgmt"); + + // Assert + result.Should().NotBeNull(); + + // UploadAutoReview returns ObjectResult with 202 status and review URL + var objectResult = result.Should().BeOfType().Which; + objectResult.StatusCode.Should().Be(StatusCodes.Status202Accepted); + objectResult.Value.Should().NotBeNull(); + objectResult.Value.Should().BeOfType(); + + // Verify that UpdateReviewAsync was NOT called since PackageType was already set + _mockReviewManager.Verify(m => m.UpdateReviewAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadAutoReview_WithNullFile_ReturnsInternalServerError() + { + // Act + var result = await _controller.UploadAutoReview(null, "test-label", packageType: "client"); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + + // Verify no manager methods were called + _mockCodeFileManager.Verify(m => m.CreateCodeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _mockReviewManager.Verify(m => m.CreateReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private Mock CreateMockFormFile(string fileName, string content) + { + var mockFile = new Mock(); + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(content); + writer.Flush(); + stream.Position = 0; + + mockFile.Setup(f => f.OpenReadStream()).Returns(stream); + mockFile.Setup(f => f.FileName).Returns(fileName); + mockFile.Setup(f => f.Length).Returns(stream.Length); + mockFile.Setup(f => f.ContentType).Returns("application/json"); + + return mockFile; + } + + private void SetupControllerContext() + { + // Create a claims principal with the GitHub login claim + var claims = new List + { + new Claim("urn:github:login", "testuser") + }; + var identity = new ClaimsIdentity(claims, "TestAuthType"); + var claimsPrincipal = new ClaimsPrincipal(identity); + + // Set up the HTTP context + var httpContext = new DefaultHttpContext(); + httpContext.User = claimsPrincipal; + + _controller.ControllerContext = new ControllerContext() + { + HttpContext = httpContext + }; + } + + private class MockLanguageService : LanguageService + { + private readonly string _name; + private readonly bool _usesTreeStyleParser; + + public MockLanguageService(string name, bool usesTreeStyleParser) + { + _name = name; + _usesTreeStyleParser = usesTreeStyleParser; + } + + public override string Name => _name; + public override string[] Extensions => new[] { ".json" }; + public override string VersionString => "1.0"; + public override bool CanUpdate(string versionString) => false; + public override Task GetCodeFileAsync(string originalName, Stream stream, bool runAnalysis) => Task.FromResult(null); + public override bool UsesTreeStyleParser => _usesTreeStyleParser; + public override CodeFile GetReviewGenPendingCodeFile(string fileName) => null; + public override bool GeneratePipelineRunParams(APIRevisionGenerationPipelineParamModel param) => false; + public override bool CanConvert(string versionString) => false; + } + } +} diff --git a/src/dotnet/APIView/APIViewUnitTests/PullRequestsControllerTests.cs b/src/dotnet/APIView/APIViewUnitTests/PullRequestsControllerTests.cs new file mode 100644 index 00000000000..6888a2ffba2 --- /dev/null +++ b/src/dotnet/APIView/APIViewUnitTests/PullRequestsControllerTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using APIViewWeb; +using APIViewWeb.DTOs; +using APIViewWeb.Helpers; +using APIViewWeb.LeanControllers; +using APIViewWeb.Managers; +using APIViewWeb.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace APIViewUnitTests +{ + public class PullRequestsControllerTests + { + private readonly Mock> _mockLogger; + private readonly Mock _mockPullRequestManager; + private readonly Mock _mockConfiguration; + private readonly List _languageServices; + private readonly PullRequestsController _controller; + + public PullRequestsControllerTests() + { + _mockLogger = new Mock>(); + _mockPullRequestManager = new Mock(); + _mockConfiguration = new Mock(); + _languageServices = new List(); + + _controller = new PullRequestsController( + _mockLogger.Object, + _mockPullRequestManager.Object, + _mockConfiguration.Object, + _languageServices); + } + + [Theory] + [InlineData("client")] + [InlineData("mgmt")] + [InlineData("CLIENT")] + [InlineData("MGMT")] + public async Task CreateAPIRevisionIfAPIHasChanges_WithValidPackageType_PassesCorrectValueToManager(string packageTypeValue) + { + // Arrange + _mockPullRequestManager.Setup(m => m.CreateAPIRevisionIfAPIHasChanges( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("https://test.com/review/test-id"); + + // Setup HTTP context for Request.Host + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("test.com"); + _controller.ControllerContext = new ControllerContext() + { + HttpContext = httpContext + }; + + // Act + var result = await _controller.CreateAPIRevisionIfAPIHasChanges( + buildId: "test-build-id", + artifactName: "test-artifact", + filePath: "test/path", + commitSha: "abc123", + repoName: "test-repo", + packageName: "test-package", + pullRequestNumber: 123, + packageType: packageTypeValue); + + // Assert + result.Should().NotBeNull(); + + // Verify that the manager was called with the exact packageType value passed from controller + _mockPullRequestManager.Verify(m => m.CreateAPIRevisionIfAPIHasChanges( + "test-build-id", + "test-artifact", + "test/path", + "abc123", + "test-repo", + "test-package", + 123, + "test.com", + It.IsAny(), + null, // codeFile + null, // baselineCodeFile + null, // language - actual value from controller + "internal", // default project + packageTypeValue), // packageType should be passed exactly as received + Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid")] + [InlineData("unknown")] + public async Task CreateAPIRevisionIfAPIHasChanges_WithInvalidPackageType_PassesValueToManager(string packageTypeValue) + { + // Arrange + _mockPullRequestManager.Setup(m => m.CreateAPIRevisionIfAPIHasChanges( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("https://test.com/review/test-id"); + + // Setup HTTP context for Request.Host + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("test.com"); + _controller.ControllerContext = new ControllerContext() + { + HttpContext = httpContext + }; + + // Act + var result = await _controller.CreateAPIRevisionIfAPIHasChanges( + buildId: "test-build-id", + artifactName: "test-artifact", + filePath: "test/path", + commitSha: "abc123", + repoName: "test-repo", + packageName: "test-package", + pullRequestNumber: 123, + packageType: packageTypeValue); + + // Assert + result.Should().NotBeNull(); + + // Verify that the manager was called with the exact packageType value (even if invalid) + _mockPullRequestManager.Verify(m => m.CreateAPIRevisionIfAPIHasChanges( + "test-build-id", + "test-artifact", + "test/path", + "abc123", + "test-repo", + "test-package", + 123, + "test.com", + It.IsAny(), + null, // codeFile + null, // baselineCodeFile + null, // language - actual value from controller + "internal", // default project + packageTypeValue), // packageType should be passed exactly as received (even invalid values) + Times.Once); + } + + [Fact] + public async Task CreateAPIRevisionIfAPIHasChanges_WhenPackageTypeOmitted_PassesNullToManager() + { + // Arrange + _mockPullRequestManager.Setup(m => m.CreateAPIRevisionIfAPIHasChanges( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("https://test.com/review/test-id"); + + // Setup HTTP context for Request.Host + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("test.com"); + _controller.ControllerContext = new ControllerContext() + { + HttpContext = httpContext + }; + + // Act - Not providing packageType parameter to test default behavior + var result = await _controller.CreateAPIRevisionIfAPIHasChanges( + buildId: "test-build-id", + artifactName: "test-artifact", + filePath: "test/path", + commitSha: "abc123", + repoName: "test-repo", + packageName: "test-package", + pullRequestNumber: 123); + // packageType parameter omitted + + // Assert + result.Should().NotBeNull(); + + // Verify that the manager was called with null packageType when omitted + _mockPullRequestManager.Verify(m => m.CreateAPIRevisionIfAPIHasChanges( + "test-build-id", + "test-artifact", + "test/path", + "abc123", + "test-repo", + "test-package", + 123, + "test.com", + It.IsAny(), + null, // codeFile + null, // baselineCodeFile + null, // language - actual value from controller + "internal", // default project + null), // packageType should be null when omitted + Times.Once); + } + + [Fact] + public async Task CreateAPIRevisionIfAPIHasChanges_WhenNoAPIRevisionUrlReturned_ReturnsAlreadyReported() + { + // Arrange - Manager returns null/empty URL indicating no changes + _mockPullRequestManager.Setup(m => m.CreateAPIRevisionIfAPIHasChanges( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string)null); // No API revision URL returned + + // Setup HTTP context for Request.Host + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("test.com"); + _controller.ControllerContext = new ControllerContext() + { + HttpContext = httpContext + }; + + // Act + var result = await _controller.CreateAPIRevisionIfAPIHasChanges( + buildId: "test-build-id", + artifactName: "test-artifact", + filePath: "test/path", + commitSha: "abc123", + repoName: "test-repo", + packageName: "test-package", + pullRequestNumber: 123, + packageType: "client"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GetAssociatedPullRequestsAsync_ReturnsExpectedResult() + { + // Arrange + var reviewId = "test-review-id"; + var apiRevisionId = "test-revision-id"; + var expectedPullRequests = new List + { + new PullRequestModel { ReviewId = reviewId, PullRequestNumber = 123 }, + new PullRequestModel { ReviewId = reviewId, PullRequestNumber = 456 } + }; + + _mockPullRequestManager.Setup(m => m.GetPullRequestsModelAsync(reviewId, apiRevisionId)) + .ReturnsAsync(expectedPullRequests); + + // Act + var result = await _controller.GetAssociatedPullRequestsAsync(reviewId, apiRevisionId); + + // Assert + result.Should().NotBeNull(); + + _mockPullRequestManager.Verify(m => m.GetPullRequestsModelAsync(reviewId, apiRevisionId), Times.Once); + } + } +} diff --git a/src/dotnet/APIView/APIViewWeb/Controllers/AutoReviewController.cs b/src/dotnet/APIView/APIViewWeb/Controllers/AutoReviewController.cs index f7c68cf3fe0..cf7fa2f188f 100644 --- a/src/dotnet/APIView/APIViewWeb/Controllers/AutoReviewController.cs +++ b/src/dotnet/APIView/APIViewWeb/Controllers/AutoReviewController.cs @@ -44,7 +44,7 @@ public AutoReviewController(IAuthorizationService authorizationService, ICodeFil // regular CI pipeline will not send this flag in request [TypeFilter(typeof(ApiKeyAuthorizeAsyncFilter))] [HttpPost] - public async Task UploadAutoReview([FromForm] IFormFile file, string label, bool compareAllRevisions = false, string packageVersion = null, bool setReleaseTag = false) + public async Task UploadAutoReview([FromForm] IFormFile file, string label, bool compareAllRevisions = false, string packageVersion = null, bool setReleaseTag = false, string packageType = null) { if (file != null) { @@ -54,7 +54,7 @@ public async Task UploadAutoReview([FromForm] IFormFile file, stri var codeFile = await _codeFileManager.CreateCodeFileAsync(originalName: file.FileName, fileStream: openReadStream, runAnalysis: false, memoryStream: memoryStream); - (var review, var apiRevision) = await CreateAutomaticRevisionAsync(codeFile: codeFile, label: label, originalName: file.FileName, memoryStream: memoryStream, compareAllRevisions); + (var review, var apiRevision) = await CreateAutomaticRevisionAsync(codeFile: codeFile, label: label, originalName: file.FileName, memoryStream: memoryStream, packageType: packageType, compareAllRevisions: compareAllRevisions); if (apiRevision != null) { apiRevision = await _apiRevisionsManager.UpdateRevisionMetadataAsync(apiRevision, packageVersion ?? codeFile.PackageVersion, label, setReleaseTag); @@ -142,7 +142,8 @@ public async Task CreateApiReview( bool compareAllRevisions, string project, string packageVersion = null, - bool setReleaseTag = false + bool setReleaseTag = false, + string packageType = null ) { using var memoryStream = new MemoryStream(); @@ -154,7 +155,7 @@ public async Task CreateApiReview( { return StatusCode(statusCode: StatusCodes.Status204NoContent, $"API review code file for package {packageName} is not found in DevOps pipeline artifacts."); } - (var review, var apiRevision) = await CreateAutomaticRevisionAsync(codeFile: codeFile, label: label, originalName: originalFilePath, memoryStream: memoryStream, compareAllRevisions); + (var review, var apiRevision) = await CreateAutomaticRevisionAsync(codeFile: codeFile, label: label, originalName: originalFilePath, memoryStream: memoryStream, packageType: packageType, compareAllRevisions: compareAllRevisions); if (apiRevision != null) { apiRevision = await _apiRevisionsManager.UpdateRevisionMetadataAsync(apiRevision, packageVersion ?? codeFile.PackageVersion, label, setReleaseTag); @@ -177,8 +178,11 @@ public async Task CreateApiReview( return StatusCode(statusCode: StatusCodes.Status500InternalServerError); } - private async Task<(ReviewListItemModel review, APIRevisionListItemModel apiRevision)> CreateAutomaticRevisionAsync(CodeFile codeFile, string label, string originalName, MemoryStream memoryStream, bool compareAllRevisions = false) + private async Task<(ReviewListItemModel review, APIRevisionListItemModel apiRevision)> CreateAutomaticRevisionAsync(CodeFile codeFile, string label, string originalName, MemoryStream memoryStream, string packageType, bool compareAllRevisions = false) { + // Parse package type once at the beginning + var parsedPackageType = !string.IsNullOrEmpty(packageType) && Enum.TryParse(packageType, true, out var result) ? (PackageType?)result : null; + var createNewRevision = true; var review = await _reviewManager.GetReviewAsync(packageName: codeFile.PackageName, language: codeFile.Language, isClosed: null); var apiRevision = default(APIRevisionListItemModel); @@ -187,6 +191,13 @@ public async Task CreateApiReview( if (review != null) { + // Update package type if provided from controller parameter and not already set + if (parsedPackageType.HasValue && !review.PackageType.HasValue) + { + review.PackageType = parsedPackageType; + review = await _reviewManager.UpdateReviewAsync(review); + } + apiRevisions = await _apiRevisionsManager.GetAPIRevisionsAsync(review.Id); if (apiRevisions.Any()) { @@ -239,7 +250,7 @@ public async Task CreateApiReview( } else { - review = await _reviewManager.CreateReviewAsync(packageName: codeFile.PackageName, language: codeFile.Language, isClosed: false); + review = await _reviewManager.CreateReviewAsync(packageName: codeFile.PackageName, language: codeFile.Language, isClosed: false, packageType: parsedPackageType); } if (createNewRevision) diff --git a/src/dotnet/APIView/APIViewWeb/Helpers/CommonUtilities.cs b/src/dotnet/APIView/APIViewWeb/Helpers/CommonUtilities.cs index cf081fdc18e..cbce960a0aa 100644 --- a/src/dotnet/APIView/APIViewWeb/Helpers/CommonUtilities.cs +++ b/src/dotnet/APIView/APIViewWeb/Helpers/CommonUtilities.cs @@ -3,7 +3,8 @@ using System; using System.Linq; -using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace APIViewWeb.Helpers { @@ -68,26 +69,4 @@ public static bool IsSDKLanguageOrTypeSpec(string language) return ApiViewConstants.AllSupportedLanguages.Contains(language); } } - - /* - /// - /// Backward compatibility alias for existing code - /// TODO: Auto-approval feature is currently disabled - commenting out for future use - /// - [Obsolete("Use DateTimeHelper instead for better organization")] - public static class BusinessDayCalculator - { - /// - /// Calculate business days from a start date, excluding weekends - /// TODO: Auto-approval feature is currently disabled - commenting out for future use - /// - /// The starting date - /// Number of business days to add - /// The calculated date after adding the specified business days - public static DateTime CalculateBusinessDays(DateTime startDate, int businessDays) - { - return DateTimeHelper.CalculateBusinessDays(startDate, businessDays); - } - } - */ } diff --git a/src/dotnet/APIView/APIViewWeb/LeanControllers/PullRequestsController.cs b/src/dotnet/APIView/APIViewWeb/LeanControllers/PullRequestsController.cs index 577e1218024..f72cadaf5f5 100644 --- a/src/dotnet/APIView/APIViewWeb/LeanControllers/PullRequestsController.cs +++ b/src/dotnet/APIView/APIViewWeb/LeanControllers/PullRequestsController.cs @@ -113,13 +113,14 @@ public async Task>> GetPullRequestRev /// /// /// + /// /// [AllowAnonymous] [HttpGet("CreateAPIRevisionIfAPIHasChanges", Name = "CreateAPIRevisionIfAPIHasChanges")] public async Task>> CreateAPIRevisionIfAPIHasChanges( string buildId, string artifactName, string filePath, string commitSha,string repoName, string packageName, int pullRequestNumber = 0, string codeFile = null, string baselineCodeFile = null, string language = null, - string project = "internal") + string project = "internal", string packageType = null) { var responseContent = new CreateAPIRevisionAPIResponse(); if (!ValidateInputParams()) @@ -135,7 +136,7 @@ public async Task>> Creat artifactName: artifactName, originalFileName: filePath, commitSha: commitSha, repoName: repoName, packageName: packageName, prNumber: pullRequestNumber, hostName: this.Request.Host.ToUriComponent(), responseContent: responseContent, codeFileName: codeFile, baselineCodeFileName: baselineCodeFile, - language: language, project: project); + language: language, project: project, packageType: packageType); responseContent.APIRevisionUrl = apiRevisionUrl; diff --git a/src/dotnet/APIView/APIViewWeb/LeanModels/ReviewListModels.cs b/src/dotnet/APIView/APIViewWeb/LeanModels/ReviewListModels.cs index 3de0a59824c..d5e4318a683 100644 --- a/src/dotnet/APIView/APIViewWeb/LeanModels/ReviewListModels.cs +++ b/src/dotnet/APIView/APIViewWeb/LeanModels/ReviewListModels.cs @@ -113,6 +113,7 @@ public class ReviewListItemModel : BaseListitemModel public List AssignedReviewers { get; set; } = new List(); public bool IsClosed { get; set; } public bool IsApproved { get; set; } // TODO: Deprecate in the future - redundant with NamespaceReviewStatus + public PackageType? PackageType { get; set; } // Nullable - null means not yet classified public NamespaceReviewStatus NamespaceReviewStatus { get; set; } = NamespaceReviewStatus.NotStarted; public string CreatedBy { get; set; } public DateTime CreatedOn { get; set; } diff --git a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IPullRequestManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IPullRequestManager.cs index ea0e374cbea..ba62c91ca24 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IPullRequestManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IPullRequestManager.cs @@ -15,6 +15,6 @@ public interface IPullRequestManager public Task CreateAPIRevisionIfAPIHasChanges( string buildId, string artifactName, string originalFileName, string commitSha, string repoName, string packageName, int prNumber, string hostName, CreateAPIRevisionAPIResponse responseContent, - string codeFileName = null, string baselineCodeFileName = null, string language = null, string project = "internal"); + string codeFileName = null, string baselineCodeFileName = null, string language = null, string project = "internal", string packageType = null); } } diff --git a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IReviewManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IReviewManager.cs index 7e1e18729c8..b08c710ae0a 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IReviewManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IReviewManager.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using APIViewWeb.Helpers; using APIViewWeb.LeanModels; +using APIViewWeb.Models; using Microsoft.AspNetCore.Http; @@ -19,7 +20,8 @@ public interface IReviewManager public Task> GetReviewsAsync(IEnumerable reviewIds, bool? isClosed = null); public Task GetLegacyReviewAsync(ClaimsPrincipal user, string id); public Task GetOrCreateReview(IFormFile file, string filePath, string language, bool runAnalysis = false); - public Task CreateReviewAsync(string packageName, string language, bool isClosed = true); + public Task CreateReviewAsync(string packageName, string language, bool isClosed = true, PackageType? packageType = null); + public Task UpdateReviewAsync(ReviewListItemModel review); public Task SoftDeleteReviewAsync(ClaimsPrincipal user, string id); public Task ToggleReviewIsClosedAsync(ClaimsPrincipal user, string id); public Task ToggleReviewApprovalAsync(ClaimsPrincipal user, string id, string revisionId, string notes=""); diff --git a/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs index e9a55cc9bf0..f164b65e891 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs @@ -134,7 +134,7 @@ public async Task CreateAPIRevisionIfAPIHasChanges( string buildId, string artifactName, string originalFileName, string commitSha, string repoName, string packageName, int prNumber, string hostName, CreateAPIRevisionAPIResponse responseContent, string codeFileName = null, string baselineCodeFileName = null, string language = null, - string project = "internal") + string project = "internal", string packageType = null) { language = LanguageServiceHelpers.MapLanguageAlias(language: language); originalFileName = originalFileName ?? codeFileName; @@ -150,6 +150,12 @@ public async Task CreateAPIRevisionIfAPIHasChanges( baselineCodeFileName: baselineCodeFileName, baselineStream: baselineStream, project: project, language: language); + if (codeFile == null) + { + responseContent.ActionsTaken.Add($"Failed to process code file. Language processor for '{language}' may not be available or file format is unsupported."); + return ""; + } + if (codeFile.PackageName != null && (packageName == null || packageName != codeFile.PackageName)) { packageName = codeFile.PackageName; @@ -182,7 +188,7 @@ public async Task CreateAPIRevisionIfAPIHasChanges( if (codeFile != null) { await CreateAPIRevisionIfRequired(codeFile: codeFile, originalFileName: originalFileName, memoryStream: memoryStream, pullRequestModel: pullRequestModel, - baselineCodeFile: baseLineCodeFile, baseLineStream: baselineStream, baselineFileName: baselineCodeFileName, responseContent: responseContent); + baselineCodeFile: baseLineCodeFile, baseLineStream: baselineStream, baselineFileName: baselineCodeFileName, responseContent: responseContent, packageType: packageType); } else { @@ -248,15 +254,28 @@ private async Task ClosePullRequestAPIRevision(PullRequestModel pullRequestModel } private async Task CreateAPIRevisionIfRequired(CodeFile codeFile, string originalFileName, MemoryStream memoryStream, - PullRequestModel pullRequestModel, CodeFile baselineCodeFile, MemoryStream baseLineStream, string baselineFileName, CreateAPIRevisionAPIResponse responseContent) + PullRequestModel pullRequestModel, CodeFile baselineCodeFile, MemoryStream baseLineStream, string baselineFileName, CreateAPIRevisionAPIResponse responseContent, string packageType = null) { + var validPackageType = !string.IsNullOrEmpty(packageType) && Enum.TryParse(packageType, true, out var result) ? (Models.PackageType?)result : null; + // fetch review for the package or create brand new review var review = await _reviewManager.GetReviewAsync(language: codeFile.Language, packageName: codeFile.PackageName); if (review == null) { - review = await _reviewManager.CreateReviewAsync(language: codeFile.Language, packageName: codeFile.PackageName, isClosed: false); + review = await _reviewManager.CreateReviewAsync(language: codeFile.Language, packageName: codeFile.PackageName, isClosed: false, packageType: validPackageType); responseContent.ActionsTaken.Add($"No existing review with packageName: '{codeFile.PackageName}' and language: '{codeFile.Language}'."); responseContent.ActionsTaken.Add($"Created a new Review with Id: '{review.Id}'."); + responseContent.ActionsTaken.Add($"Review created with packageType: '{validPackageType}'."); + } + else + { + // Update existing review with packageType if provided and different from current value + if (validPackageType.HasValue && (!review.PackageType.HasValue || review.PackageType.Value != validPackageType.Value)) + { + review.PackageType = validPackageType; + review = await _reviewManager.UpdateReviewAsync(review); + responseContent.ActionsTaken.Add($"Updated existing review '{review.Id}' with PackageType: '{validPackageType}'."); + } } pullRequestModel.ReviewId = review.Id; responseContent.ActionsTaken.Add($"Pull Request data ReviewId set to '{pullRequestModel.ReviewId}' in memory - not yet saved to database."); diff --git a/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs index dc1bcfff8a4..252b8840b9f 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs @@ -238,8 +238,9 @@ public async Task GetOrCreateReview(IFormFile file, string /// /// /// + /// Optional package type. If not provided, will be automatically classified. /// - public async Task CreateReviewAsync(string packageName, string language, bool isClosed=true) + public async Task CreateReviewAsync(string packageName, string language, bool isClosed = true, PackageType? packageType = null) { if (string.IsNullOrEmpty(packageName) || string.IsNullOrEmpty(language)) { @@ -250,6 +251,7 @@ public async Task CreateReviewAsync(string packageName, str { PackageName = packageName, Language = language, + PackageType = packageType, CreatedOn = DateTime.UtcNow, CreatedBy = ApiViewConstants.AzureSdkBotName, IsClosed = isClosed, @@ -268,6 +270,22 @@ public async Task CreateReviewAsync(string packageName, str return review; } + /// + /// Update an existing review + /// + /// The review to update + /// + public async Task UpdateReviewAsync(ReviewListItemModel review) + { + if (review == null) + { + throw new ArgumentNullException(nameof(review)); + } + + await _reviewsRepository.UpsertReviewAsync(review); + return review; + } + /// /// SoftDeleteReviewAsync /// diff --git a/src/dotnet/APIView/APIViewWeb/Models/PackageModel.cs b/src/dotnet/APIView/APIViewWeb/Models/PackageModel.cs index d3a7f584352..ccecdb9ad1d 100644 --- a/src/dotnet/APIView/APIViewWeb/Models/PackageModel.cs +++ b/src/dotnet/APIView/APIViewWeb/Models/PackageModel.cs @@ -1,24 +1,43 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using CsvHelper.Configuration.Attributes; - -namespace APIViewWeb.Models -{ - public class PackageModel - { - [Name("Package")] - public string Name { get; set; } - - [Name("DisplayName")] - public string DisplayName { get; set; } - - [Name("ServiceName")] - public string ServiceName { get; set; } - - [Name("New")] - public bool IsNew { get; set; } - - [Name("GroupId")] - public string GroupId { get; set; } - } -} +using CsvHelper.Configuration.Attributes; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace APIViewWeb.Models +{ + public class PackageModel + { + [Name("Package")] + public string Name { get; set; } + + [Name("DisplayName")] + public string DisplayName { get; set; } + + [Name("ServiceName")] + public string ServiceName { get; set; } + + [Name("New")] + public bool IsNew { get; set; } + + [Name("GroupId")] + public string GroupId { get; set; } + } + + /// + /// Represents the plane classification of a package + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum PackageType + { + /// + /// Data plane package (client libraries for Azure services) + /// + client = 0, + + /// + /// Management plane package (resource management libraries) + /// + mgmt = 1, + } +} diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts index 5c5cc8e53f5..a404c1d4dd1 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts @@ -5,7 +5,7 @@ import { MenuItem, TreeNode } from 'primeng/api'; import { concatMap, EMPTY, from, Observable, Subject, take, takeUntil, tap } from 'rxjs'; import { CodeLineRowNavigationDirection, getLanguageCssSafeName } from 'src/app/_helpers/common-helpers'; import { getQueryParams } from 'src/app/_helpers/router-helpers'; -import { Review } from 'src/app/_models/review'; +import { Review, PackageType } from 'src/app/_models/review'; import { APIRevision, APIRevisionGroupedByLanguage, ApiTreeBuilderData } from 'src/app/_models/revision'; import { ReviewsService } from 'src/app/_services/reviews/reviews.service'; import { APIRevisionsService } from 'src/app/_services/revisions/revisions.service'; @@ -280,6 +280,10 @@ export class ReviewPageComponent implements OnInit { this.review = review; this.updateLoadingStateBasedOnReviewDeletionStatus(); this.updatePageTitle(); + }, + error: (error) => { + this.loadFailed = true; + this.loadFailedMessage = "Failed to load review. Please refresh the page or try again later."; } }); } @@ -371,7 +375,6 @@ export class ReviewPageComponent implements OnInit { private processEmbeddedComments() { if (!this.codePanelData || !this.comments) return; - Object.values(this.codePanelData.nodeMetaData).forEach(nodeData => { if (nodeData.commentThread) { Object.values(nodeData.commentThread).forEach(commentThreadRow => { diff --git a/src/dotnet/APIView/ClientSPA/src/app/_models/review.ts b/src/dotnet/APIView/ClientSPA/src/app/_models/review.ts index 6174bd18ba7..142270fc06e 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_models/review.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_models/review.ts @@ -6,6 +6,11 @@ export enum FirstReleaseApproval { All } +export enum PackageType { + client = 'client', + mgmt = 'mgmt' +} + export class Review { id: string packageName: string @@ -16,6 +21,7 @@ export class Review { changeHistory: ChangeHistory[] subscribers: string[] namespaceReviewStatus: string + packageType?: PackageType | null // Optional - undefined or null if not yet classified constructor() { this.id = ''