diff --git a/src/DependencyUpdated.Core/Config/AzureDevOpsConfig.cs b/src/DependencyUpdated.Core/Config/AzureDevOpsConfig.cs index 6b2b7f9..b13ed98 100644 --- a/src/DependencyUpdated.Core/Config/AzureDevOpsConfig.cs +++ b/src/DependencyUpdated.Core/Config/AzureDevOpsConfig.cs @@ -9,6 +9,10 @@ public record AzureDevOpsConfig : IValidatableObject public string? Email { get; set; } public string? PAT { get; set; } + + public string? ApproverPAT { get; set; } + + public string? AutoApproverId { get; set; } public string? Organization { get; set; } diff --git a/src/DependencyUpdated.Core/Updater.cs b/src/DependencyUpdated.Core/Updater.cs index 6243adc..3ad793a 100644 --- a/src/DependencyUpdated.Core/Updater.cs +++ b/src/DependencyUpdated.Core/Updater.cs @@ -26,10 +26,10 @@ public async Task DoUpdate() var projectFiles = updater.GetAllProjectFiles(directory); var projectName = ResolveProjectName(project, directory); var alreadyProcessed = new List(); + var allProjectDependencies = await updater.ExtractAllPackages(projectFiles); foreach (var group in project.Groups) { repositoryProvider.SwitchToUpdateBranch(repositoryPath, projectName, group); - var allProjectDependencies = await updater.ExtractAllPackages(projectFiles); var filteredPackages = FilterPackages(allProjectDependencies, alreadyProcessed, group, project); if (filteredPackages.Count == 0) { diff --git a/src/DependencyUpdated.Projects.DotNet/DotNetUpdater.cs b/src/DependencyUpdated.Projects.DotNet/DotNetUpdater.cs index 293685e..3218543 100644 --- a/src/DependencyUpdated.Projects.DotNet/DotNetUpdater.cs +++ b/src/DependencyUpdated.Projects.DotNet/DotNetUpdater.cs @@ -6,6 +6,7 @@ using NuGet.Configuration; using NuGet.Protocol.Core.Types; using NuGet.Versioning; +using System.Net; using System.Xml; using System.Xml.Linq; using ILogger = Serilog.ILogger; @@ -78,12 +79,20 @@ public async Task> GetVersions(Dependency foreach (var repository in repositories) { var findPackageByIdResource = await repository.GetResourceAsync(); - var versions = await findPackageByIdResource.GetAllVersionsAsync( - package.Name, - new SourceCacheContext(), - NullLogger.Instance, - CancellationToken.None); - allVersions.AddRange(versions.Where(x => !x.IsPrerelease)); + try + { + var versions = await findPackageByIdResource.GetAllVersionsAsync( + package.Name, + new SourceCacheContext(), + NullLogger.Instance, + CancellationToken.None); + allVersions.AddRange(versions.Where(x => !x.IsPrerelease)); + } + catch (FatalProtocolException ex) + when (ex.InnerException is HttpRequestException { StatusCode: HttpStatusCode.NotFound }) + { + // Package not found in source + } } var result = allVersions diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/AzureApiHeaderHandler.cs b/src/DependencyUpdated.Repositories.AzureDevOps/AzureApiHeaderHandler.cs new file mode 100644 index 0000000..7bb6997 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/AzureApiHeaderHandler.cs @@ -0,0 +1,30 @@ +using DependencyUpdated.Core.Config; +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; + +namespace DependencyUpdated.Repositories.AzureDevOps; + +internal sealed class AzureApiHeaderHandler(IOptions config) : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var approverPat = config.Value.AzureDevOps.ApproverPAT; + if (request.RequestUri?.AbsolutePath.Contains(IAzureDevOpsClient.ReviewersResource) == true && + !string.IsNullOrEmpty(approverPat)) + { + request.Headers.Authorization ??= CreateToken(approverPat); + } + + request.Headers.Authorization ??= CreateToken(config.Value.AzureDevOps.PAT!); + var response = await base.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + return response; + } + + private static AuthenticationHeaderValue CreateToken(string token) + { + return new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($":{token}"))); + } +} \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/AzureDevOps.cs b/src/DependencyUpdated.Repositories.AzureDevOps/AzureDevOps.cs index 58cb32e..a3273f8 100644 --- a/src/DependencyUpdated.Repositories.AzureDevOps/AzureDevOps.cs +++ b/src/DependencyUpdated.Repositories.AzureDevOps/AzureDevOps.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Options; using Serilog; using System.Diagnostics.CodeAnalysis; -using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -15,7 +14,7 @@ namespace DependencyUpdated.Repositories.AzureDevOps; [ExcludeFromCodeCoverage] -internal sealed class AzureDevOps(TimeProvider timeProvider, IOptions config, ILogger logger) +internal sealed class AzureDevOps(TimeProvider timeProvider, IOptions config, ILogger logger, IAzureDevOpsClient azureDevOpsClient) : IRepositoryProvider { private const string GitCommitMessage = "Bump dependencies"; @@ -76,58 +75,32 @@ public void CommitChanges(string repositoryPath, string projectName, string grou public async Task SubmitPullRequest(IReadOnlyCollection updates, string projectName, string group) { - using var client = new HttpClient(); var configValue = config.Value.AzureDevOps; - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes($":{configValue.PAT}"))); - var gitBranchName = CreateGitBranchName(projectName, config.Value.AzureDevOps.BranchName, group); var sourceBranch = $"refs/heads/{gitBranchName}"; var targetBranch = $"refs/heads/{configValue.TargetBranchName}"; - var baseUrl = - $"https://dev.azure.com/{configValue.Organization}/{configValue.Project}/_apis/git/repositories/{configValue.Repository}/pullrequests?api-version=6.0"; - if (await CheckIfPrExists(client, baseUrl, sourceBranch, targetBranch)) + if (await CheckIfPrExists(sourceBranch, targetBranch)) { - logger.Information("PR from {SourceBranch} to {TargetBranch} already exists. Skipping creating PR", gitBranchName, configValue.TargetBranchName); + logger.Information("PR from {SourceBranch} to {TargetBranch} already exists. Skipping creating PR", + gitBranchName, configValue.TargetBranchName); return; } - + var prTitile = $"[AutoUpdate] Update dependencies - {projectName}"; var prDescription = CreatePrDescription(updates); logger.Information("Creating new PR"); var pr = new PullRequest(sourceBranch, targetBranch, prTitile, prDescription); - - var jsonString = JsonSerializer.Serialize(pr); - var content = new StringContent(jsonString, Encoding.UTF8, "application/json"); - - var result = await client.PostAsync(baseUrl, content); - result.EnsureSuccessStatusCode(); - - if (result.StatusCode == HttpStatusCode.NonAuthoritativeInformation) - { - throw new InvalidOperationException("Invalid PAT token provided"); - } - - var response = await result.Content.ReadAsStringAsync(); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, }; - var responseObject = JsonSerializer.Deserialize(response, options) ?? - throw new InvalidOperationException("Missing response from API"); - + var responseObject = await azureDevOpsClient.CreatePullRequest(configValue.Repository!, pr); logger.Information("New PR created {Id}", responseObject.PullRequestId); if (configValue.AutoComplete) { logger.Information("Setting autocomplete for PR {Id}", responseObject.PullRequestId); - baseUrl = - $"https://dev.azure.com/{configValue.Organization}/{configValue.Project}/_apis/git/repositories/{configValue.Repository}/pullrequests/{responseObject.PullRequestId}?api-version=6.0"; var autoComplete = new PullRequestUpdate(responseObject.CreatedBy, new GitPullRequestCompletionOptions(true, false, GitPullRequestMergeStrategy.Squash)); - jsonString = JsonSerializer.Serialize(autoComplete); - content = new StringContent(jsonString, Encoding.UTF8, "application/json"); - result = await client.PatchAsync(baseUrl, content); - result.EnsureSuccessStatusCode(); + await azureDevOpsClient.UpdatePullRequest(configValue.Repository!, responseObject.PullRequestId, + autoComplete); } if (configValue.WorkItemId.HasValue) @@ -151,11 +124,22 @@ public async Task SubmitPullRequest(IReadOnlyCollection updates, s } }; - jsonString = JsonSerializer.Serialize(patchValue); - content = new StringContent(jsonString, Encoding.UTF8, "application/json-patch+json"); - result = await client.PatchAsync(workItemUpdateUrl, content); + var jsonString = JsonSerializer.Serialize(patchValue); + var content = new StringContent(jsonString, Encoding.UTF8, "application/json-patch+json"); + using var client = new HttpClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($":{configValue.PAT}"))); + var result = await client.PatchAsync(workItemUpdateUrl, content); result.EnsureSuccessStatusCode(); } + + if (!string.IsNullOrEmpty(configValue.AutoApproverId)) + { + logger.Information("Setting auto approver to {ApproverId}", configValue.AutoApproverId); + await azureDevOpsClient.Approve(configValue.Repository!, responseObject.PullRequestId, + configValue.AutoApproverId, ApproveBody.Approve()); + } } private static string CreateGitBranchName(string projectName, string branchName, string group) @@ -165,18 +149,10 @@ private static string CreateGitBranchName(string projectName, string branchName, return newBranchName; } - private async Task CheckIfPrExists(HttpClient client, string baseUrl, string sourceBranchName, - string targetBranchName) + private async Task CheckIfPrExists(string sourceBranchName, string targetBranchName) { - var response = await client.GetAsync(baseUrl); - response.EnsureSuccessStatusCode(); - - var jsonString = await response.Content.ReadAsStringAsync(); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, }; - var responseObject = JsonSerializer.Deserialize(jsonString, options) ?? - throw new InvalidOperationException("Missing response from API"); - - return responseObject.Value.Any(pr => pr.SourceRefName == sourceBranchName && pr.TargetRefName == targetBranchName); + var response = await azureDevOpsClient.GetPullRequests(config.Value.AzureDevOps.Repository!); + return response.Value.Any(pr => pr.SourceRefName == sourceBranchName && pr.TargetRefName == targetBranchName); } private Branch? GetGitBranch(Repository repo, string branchName) diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/ConfigureServices.cs b/src/DependencyUpdated.Repositories.AzureDevOps/ConfigureServices.cs index 9627b8a..90ba077 100644 --- a/src/DependencyUpdated.Repositories.AzureDevOps/ConfigureServices.cs +++ b/src/DependencyUpdated.Repositories.AzureDevOps/ConfigureServices.cs @@ -1,6 +1,11 @@ +using DependencyUpdated.Core.Config; using DependencyUpdated.Core.Interfaces; using DependencyUpdated.Core.Models.Enums; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Refit; +using System.Net.Http.Headers; +using System.Text.Json; namespace DependencyUpdated.Repositories.AzureDevOps; @@ -9,7 +14,21 @@ public static class ConfigureServices public static IServiceCollection RegisterAzureDevOps(this IServiceCollection serviceCollection) { serviceCollection.AddKeyedSingleton(RepositoryType.AzureDevOps); - + serviceCollection.AddSingleton(); + serviceCollection.AddRefitClient(_ => new RefitSettings() + { + HttpMessageHandlerFactory = () => new LoggingHandler(), + ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true + }) + }).ConfigureHttpClient((serviceProvider, x) => + { + var options = serviceProvider.GetRequiredService>().Value; + x.BaseAddress = + new Uri($"https://dev.azure.com/{options.AzureDevOps.Organization}/{options.AzureDevOps.Project}"); + x.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + }).AddHttpMessageHandler(); return serviceCollection; } } \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/DependencyUpdated.Repositories.AzureDevOps.csproj b/src/DependencyUpdated.Repositories.AzureDevOps/DependencyUpdated.Repositories.AzureDevOps.csproj index 815071d..9a8788b 100644 --- a/src/DependencyUpdated.Repositories.AzureDevOps/DependencyUpdated.Repositories.AzureDevOps.csproj +++ b/src/DependencyUpdated.Repositories.AzureDevOps/DependencyUpdated.Repositories.AzureDevOps.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/Dto/ApproveBody.cs b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/ApproveBody.cs new file mode 100644 index 0000000..e398d28 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/ApproveBody.cs @@ -0,0 +1,9 @@ +namespace DependencyUpdated.Repositories.AzureDevOps.Dto; + +public record ApproveBody(int Vote) +{ + public static ApproveBody Approve() + { + return new ApproveBody(10); + } +} \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdateAttributes.cs b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdateAttributes.cs new file mode 100644 index 0000000..1630532 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdateAttributes.cs @@ -0,0 +1,3 @@ +namespace DependencyUpdated.Repositories.AzureDevOps.Dto; + +public sealed record WorkItemUpdateAttributes(string Name); \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdatePatch.cs b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdatePatch.cs new file mode 100644 index 0000000..988e846 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdatePatch.cs @@ -0,0 +1,3 @@ +namespace DependencyUpdated.Repositories.AzureDevOps.Dto; + +public sealed record WorkItemUpdatePatch(string Op, string Path, WorkItemUpdateRelation Value); \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdateRelation.cs b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdateRelation.cs new file mode 100644 index 0000000..ee364b5 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/Dto/WorkItemUpdateRelation.cs @@ -0,0 +1,3 @@ +namespace DependencyUpdated.Repositories.AzureDevOps.Dto; + +public sealed record WorkItemUpdateRelation(string Rel, string Url, WorkItemUpdateAttributes Attributes); \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/IAzureDevOpsClient.cs b/src/DependencyUpdated.Repositories.AzureDevOps/IAzureDevOpsClient.cs new file mode 100644 index 0000000..905ff24 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/IAzureDevOpsClient.cs @@ -0,0 +1,25 @@ +using DependencyUpdated.Repositories.AzureDevOps.Dto; +using Refit; + +namespace DependencyUpdated.Repositories.AzureDevOps; + +public interface IAzureDevOpsClient +{ + const string ReviewersResource = "reviewers"; + + [Get("/_apis/git/repositories/{repository}/pullrequests?api-version=6.0")] + Task GetPullRequests(string repository); + + [Post("/_apis/git/repositories/{repository}/pullrequests?api-version=6.0")] + Task CreatePullRequest(string repository, [Body] PullRequest pullRequestInfo); + + [Patch("/_apis/git/repositories/{repository}/pullrequests/{pullRequestId}?api-version=6.0")] + Task UpdatePullRequest(string repository, int pullRequestId, [Body] PullRequestUpdate pullRequestUpdate); + + [Patch("/_apis/wit/workitems/{workItemId}?api-version=6.0")] + [Headers("Content-Type: application/json-patch+json")] + Task UpdateWorkItemRelation(int workItemId, [Body] WorkItemUpdatePatch[] patchValue); + + [Put("/_apis/git/repositories/{repository}/pullRequests/{pullRequestId}/" + ReviewersResource + "/{reviewerId}?api-version=6.0")] + Task Approve(string repository, int pullRequestId, string reviewerId, [Body] ApproveBody body); +} \ No newline at end of file diff --git a/src/DependencyUpdated.Repositories.AzureDevOps/LoggingHandler.cs b/src/DependencyUpdated.Repositories.AzureDevOps/LoggingHandler.cs new file mode 100644 index 0000000..0bb7437 --- /dev/null +++ b/src/DependencyUpdated.Repositories.AzureDevOps/LoggingHandler.cs @@ -0,0 +1,33 @@ +using Refit; +using System.Diagnostics; +using System.Net; + +namespace DependencyUpdated.Repositories.AzureDevOps; + +internal sealed class LoggingHandler : HttpClientHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Debug.WriteLine("Request:"); + Debug.WriteLine(request.ToString()); + if (request.Content != null) + { + Debug.WriteLine(await request.Content.ReadAsStringAsync(cancellationToken)); + } + + Debug.WriteLine(string.Empty); + + var response = await base.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NonAuthoritativeInformation) + { + throw await ApiException.Create("Invalid PAT token", request, request.Method, response, new RefitSettings()); + } + + Debug.WriteLine("Response:"); + Debug.WriteLine(response.ToString()); + Debug.WriteLine(await response.Content.ReadAsStringAsync(cancellationToken)); + + return response; + } +} \ No newline at end of file