|
| 1 | +using DevExcelerateApi.Core.Extensions; |
| 2 | +using DevExcelerateApi.Core.Options; |
| 3 | +using DevExcelerateApi.Helpers; |
| 4 | +using DevExcelerateApi.Models; |
| 5 | +using Microsoft.Extensions.Options; |
| 6 | +using Octokit.Webhooks; |
| 7 | +using System.ComponentModel.DataAnnotations; |
| 8 | +using System.Text.RegularExpressions; |
| 9 | + |
| 10 | +namespace DevExcelerateApi.Services |
| 11 | +{ |
| 12 | + public class PolicyService( |
| 13 | + ILogger<PolicyService> logger, |
| 14 | + IGitHubClientFactory gitHubClientFactory, |
| 15 | + IOptions<GitHubOptions> gitHubOptions, |
| 16 | + ILocalizationService localizationService, |
| 17 | + ICacheService cacheService) |
| 18 | + : IPolicyService |
| 19 | + { |
| 20 | + private readonly ILogger<PolicyService> _logger = logger; |
| 21 | + private readonly IGitHubClientFactory _gitHubClientFactory = gitHubClientFactory; |
| 22 | + private readonly GitHubOptions _gitHubOptions = gitHubOptions.Value; |
| 23 | + private readonly ILocalizationService _localizationService = localizationService; |
| 24 | + private readonly ICacheService _cacheService = cacheService; |
| 25 | + |
| 26 | + public async Task<ValidationResult> ValidateRepositoryNameAsync(string name, WebhookEvent webhookEvent) |
| 27 | + { |
| 28 | + return await ValidateNameAgainstPolicyAsync( |
| 29 | + name, |
| 30 | + webhookEvent, |
| 31 | + policies => policies.RepositoryNamingConvention, |
| 32 | + Constants.ERROR_INVAILD_REPO_NAME, |
| 33 | + "Repository"); |
| 34 | + } |
| 35 | + |
| 36 | + public async Task<ValidationResult> ValidateTeamNameAsync(string name, WebhookEvent webhookEvent) |
| 37 | + { |
| 38 | + return await ValidateNameAgainstPolicyAsync( |
| 39 | + name, |
| 40 | + webhookEvent, |
| 41 | + policies => policies.TeamNamingConvention, |
| 42 | + Constants.ERROR_INVAILD_TEAM_NAME, |
| 43 | + "Team"); |
| 44 | + } |
| 45 | + |
| 46 | + private async Task<ValidationResult> ValidateNameAgainstPolicyAsync( |
| 47 | + string name, |
| 48 | + WebhookEvent webhookEvent, |
| 49 | + Func<DevExOrganizationPolicies, string?> namingConventionSelector, |
| 50 | + string errorKey, |
| 51 | + string entityTypeNameForLog) |
| 52 | + { |
| 53 | + if (string.IsNullOrWhiteSpace(name)) |
| 54 | + { |
| 55 | + var errorMessage = string.Format(_localizationService.GetLocalizedString(errorKey), name ?? "<null>"); |
| 56 | + return new ValidationResult(errorMessage, [nameof(name)]); |
| 57 | + } |
| 58 | + |
| 59 | + var policies = await GetDevExOrganizationPolicies(webhookEvent?.Installation?.Id, webhookEvent?.Organization?.Login!); |
| 60 | + |
| 61 | + if (policies != null) |
| 62 | + { |
| 63 | + var namingConvention = namingConventionSelector(policies); |
| 64 | + if (!string.IsNullOrWhiteSpace(namingConvention)) |
| 65 | + { |
| 66 | + if (!Regex.IsMatch(name, namingConvention)) |
| 67 | + { |
| 68 | + var errorMessage = string.Format(_localizationService.GetLocalizedString(errorKey), name); |
| 69 | + return new ValidationResult(errorMessage, [nameof(name)]); |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + // If no policy is found or no naming convention is specified, consider it valid. |
| 75 | + _logger.LogInformation("{EntityType} name '{Name}' validated successfully.", entityTypeNameForLog, name); |
| 76 | + return ValidationResult.Success!; |
| 77 | + } |
| 78 | + |
| 79 | + private async Task<DevExOrganizationPolicies> GetDevExOrganizationPolicies(long? installationId, string owner) |
| 80 | + { |
| 81 | + string cacheKey = $"org_policies_{owner}_{_gitHubOptions.RepoPolicy}_{DevExPolicies.Organization_Policies.GetPolicyYamlFileName()}"; |
| 82 | + DevExOrganizationPolicies? orgPolicies = null; |
| 83 | + |
| 84 | + try |
| 85 | + { |
| 86 | + // Try to get from cache first |
| 87 | + string? policyString = await _cacheService.GetAsync<string>(cacheKey); |
| 88 | + |
| 89 | + if (!string.IsNullOrEmpty(policyString)) |
| 90 | + { |
| 91 | + _logger.LogDebug("Organization policies for '{Owner}' found in cache.", owner); |
| 92 | + orgPolicies = DevExOrganizationPolicies.DeserializeFromYaml(policyString); |
| 93 | + } |
| 94 | + else |
| 95 | + { |
| 96 | + _logger.LogDebug("Organization policies for '{Owner}' not found in cache. Fetching from GitHub.", owner); |
| 97 | + var installationClient = await _gitHubClientFactory.GetInstallationAsync(installationId); |
| 98 | + string policyFilePath = $"{Constants.GitHubPoliciesFolderPath}{DevExPolicies.Organization_Policies.GetPolicyYamlFileName()}"; |
| 99 | + |
| 100 | + try |
| 101 | + { |
| 102 | + // Get the policy metadata from GitHub |
| 103 | + policyString = await installationClient.GetFileContentAsync( |
| 104 | + owner, |
| 105 | + _gitHubOptions.RepoPolicy, |
| 106 | + policyFilePath); |
| 107 | + |
| 108 | + if (!string.IsNullOrEmpty(policyString)) |
| 109 | + { |
| 110 | + orgPolicies = DevExOrganizationPolicies.DeserializeFromYaml(policyString); |
| 111 | + // Store in cache - consider adding an expiration time via ICacheService options if needed |
| 112 | + await _cacheService.SetAsync(cacheKey, policyString); |
| 113 | + _logger.LogInformation("Successfully fetched and cached organization policies for '{Owner}'.", owner); |
| 114 | + } |
| 115 | + else |
| 116 | + { |
| 117 | + _logger.LogWarning("Fetched empty or null policy string for organization '{Owner}' from '{PolicyRepo}/{PolicyPath}'. Returning default policies.", owner, _gitHubOptions.RepoPolicy, policyFilePath); |
| 118 | + // Cache the empty string to indicate the file is empty, preventing repeated fetches for empty files |
| 119 | + await _cacheService.SetAsync(cacheKey, string.Empty); // Cache empty string |
| 120 | + } |
| 121 | + } |
| 122 | + catch (Octokit.NotFoundException) |
| 123 | + { |
| 124 | + _logger.LogWarning("Policy file '{PolicyPath}' not found for organization '{Owner}' in repo '{PolicyRepo}'. Returning default policies and caching 'not found'.", policyFilePath, owner, _gitHubOptions.RepoPolicy); |
| 125 | + // Cache the fact that it's not found (e.g., cache empty string) to avoid repeated lookups for a non-existent file |
| 126 | + // Consider a specific expiration for "not found" cache entries if desired |
| 127 | + await _cacheService.SetAsync(cacheKey, string.Empty); // Cache empty string to represent "not found" |
| 128 | + } |
| 129 | + } |
| 130 | + } |
| 131 | + catch (Exception ex) // Catch potential exceptions during cache access or GitHub fetch |
| 132 | + { |
| 133 | + _logger.LogError(ex, "Error retrieving or processing organization policies for '{Owner}'. Returning default policies.", owner); |
| 134 | + } |
| 135 | + |
| 136 | + // Ensure we always return a non-null object |
| 137 | + return orgPolicies ?? new DevExOrganizationPolicies(); |
| 138 | + } |
| 139 | + } |
| 140 | +} |
0 commit comments