Skip to content

Commit 7f0ade3

Browse files
author
Calin Lupas
authored
Merge pull request #37 from DevExcelerate/feature/updates
Feature/updates
2 parents cbb919b + ac531af commit 7f0ade3

12 files changed

Lines changed: 354 additions & 70 deletions

src/DevExcelerateApi/Core/Extensions/ServiceExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ internal static IServiceCollection AddAppServices(this IServiceCollection servic
9797
services.AddSingleton<IRepositoryService, RepositoryService>();
9898
services.AddSingleton<ITeamService, TeamService>();
9999
services.AddSingleton<IIssueService, IssueService>();
100+
services.AddSingleton<IPolicyService, PolicyService>();
101+
102+
services.AddMemoryCache();
103+
services.AddSingleton<ICacheService, CacheService>();
100104

101105
return services;
102106
}

src/DevExcelerateApi/Models/Constants.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public static class Constants
1919
public const string ERROR_REPO_EXISTS = "ERROR_REPO_EXISTS";
2020
public const string ERROR_REPO_TEAM_DOES_NOT_EXISTS = "ERROR_REPO_TEAM_DOES_NOT_EXISTS";
2121
public const string ERROR_REPO_RULESET_DOES_NOT_EXISTS = "ERROR_REPO_RULESET_DOES_NOT_EXISTS";
22+
public const string ERROR_INVAILD_REPO_NAME = "ERROR_INVAILD_REPO_NAME";
2223

2324
// Repository PR related constants
2425
public const string ERROR_PR_CREATION = "ERROR_REPO_PR_CREATION";
@@ -29,7 +30,8 @@ public static class Constants
2930
public const string ERROR_TEAM_EXISTS = "ERROR_TEAM_EXISTS";
3031
public const string ERROR_PARENT_TEAM_DOES_NOT_EXISTS = "ERROR_PARENT_TEAM_DOES_NOT_EXISTS";
3132
public const string ERROR_TEAM_MEMBER_DOES_NOT_EXISTS = "ERROR_TEAM_MEMBER_DOES_NOT_EXISTS";
32-
33+
public const string ERROR_INVAILD_TEAM_NAME = "ERROR_INVAILD_TEAM_NAME";
34+
3335
public const string VALIDATION_PASSED = "VALIDATION_PASSED";
3436
public const string ISSUE_APPROVED = "ISSUE_APPROVED";
3537
public const string ISSUE_COMPLETED = "ISSUE_COMPLETED";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using DevExcelerateApi.Helpers;
2+
using YamlDotNet.Serialization;
3+
4+
namespace DevExcelerateApi.Models
5+
{
6+
public class DevExOrganizationPolicies
7+
{
8+
[YamlMember(Alias = "name", Order = 1)]
9+
public string? Name { get; set; }
10+
11+
[YamlMember(Alias = "description", Order = 2)]
12+
public string? Description { get; set; }
13+
14+
[YamlMember(Alias = "resource", Order = 3)]
15+
public string? Resource { get; set; }
16+
17+
/// <summary>
18+
/// The naming convention (RegEx) for repositories in the organization.
19+
/// </summary>
20+
/// <remarks>Example: "^([a-zA-Z0-9]{2,5})(?:-([a-zA-Z0-9]{2,5}))?-([a-zA-Z0-9\-_.]+)$"</remarks>
21+
[YamlMember(Alias = "repository_naming_convention", Order = 4)]
22+
public string? RepositoryNamingConvention { get; set; }
23+
24+
/// <summary>
25+
/// The naming convention (RegEx) for teams in the organization.
26+
/// </summary>
27+
[YamlMember(Alias = "team_naming_convention", Order = 5)]
28+
public string? TeamNamingConvention { get; set; }
29+
30+
public static DevExOrganizationPolicies DeserializeFromYaml(string yamlContent)
31+
{
32+
ArgumentNullException.ThrowIfNull(yamlContent);
33+
return YamlHelpers.Deserialize<DevExOrganizationPolicies>(yamlContent);
34+
}
35+
}
36+
}

src/DevExcelerateApi/Models/DevExPolicies.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ namespace DevExcelerateApi.Models
44
{
55
public enum DevExPolicies
66
{
7+
[Description("Organization Policies")]
8+
Organization_Policies = 10,
79
[Description("Repository Metadata")]
8-
Repository_Metadata = 1,
10+
Repository_Metadata = 20,
911
[Description("Repository Protection")]
10-
Repository_Protection,
12+
Repository_Protection = 21,
1113
[Description("Team Metadata")]
12-
Team_Metadata
14+
Team_Metadata = 30
1315
}
1416

1517
public static class DevExPoliciesExtensions
@@ -18,6 +20,7 @@ public static string GetPolicyYamlFileName(this DevExPolicies policy)
1820
{
1921
return policy switch
2022
{
23+
DevExPolicies.Organization_Policies => "organization-policies.yml",
2124
DevExPolicies.Repository_Metadata => "repository-metadata.yml",
2225
DevExPolicies.Repository_Protection => "repository-protection.yml",
2326
DevExPolicies.Team_Metadata => "team-metadata.yml",

src/DevExcelerateApi/Resources/Services.LocalizationService.fr.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,10 @@
189189
<data name="ERROR_TEAM_MEMBER_DOES_NOT_EXISTS" xml:space="preserve">
190190
<value>Le membre d'équipe {0} n'existe pas.</value>
191191
</data>
192+
<data name="ERROR_INVAILD_REPO_NAME" xml:space="preserve">
193+
<value>Le nom du dépôt '{0}' est invalide.</value>
194+
</data>
195+
<data name="ERROR_INVAILD_TEAM_NAME" xml:space="preserve">
196+
<value>Le nom de l'équipe '{0}' est invalide.</value>
197+
</data>
192198
</root>

src/DevExcelerateApi/Resources/Services.LocalizationService.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,10 @@
189189
<data name="ERROR_TEAM_MEMBER_DOES_NOT_EXISTS" xml:space="preserve">
190190
<value>The team member {0} does not exist.</value>
191191
</data>
192+
<data name="ERROR_INVAILD_REPO_NAME" xml:space="preserve">
193+
<value>The repository name '{0}' is invalid.</value>
194+
</data>
195+
<data name="ERROR_INVAILD_TEAM_NAME" xml:space="preserve">
196+
<value>The team name '{0}' is invalid.</value>
197+
</data>
192198
</root>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Microsoft.Extensions.Caching.Memory;
2+
3+
namespace DevExcelerateApi.Services
4+
{
5+
public class CacheService(IMemoryCache memoryCache) : ICacheService
6+
{
7+
private readonly IMemoryCache _memoryCache = memoryCache;
8+
9+
public Task<T?> GetAsync<T>(string key)
10+
{
11+
_memoryCache.TryGetValue(key, out T? value);
12+
return Task.FromResult(value);
13+
}
14+
15+
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpireTime = null)
16+
{
17+
var options = new MemoryCacheEntryOptions();
18+
19+
if (absoluteExpireTime.HasValue)
20+
{
21+
options.AbsoluteExpirationRelativeToNow = absoluteExpireTime;
22+
}
23+
else if (slidingExpireTime.HasValue)
24+
{
25+
options.SlidingExpiration = slidingExpireTime;
26+
}
27+
else
28+
{
29+
// Default expiration if none provided
30+
options.SlidingExpiration = TimeSpan.FromMinutes(5);
31+
}
32+
33+
_memoryCache.Set(key, value, options);
34+
return Task.CompletedTask;
35+
}
36+
37+
public Task RemoveAsync(string key)
38+
{
39+
_memoryCache.Remove(key);
40+
return Task.CompletedTask;
41+
}
42+
}
43+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace DevExcelerateApi.Services
2+
{
3+
public interface ICacheService
4+
{
5+
Task<T?> GetAsync<T>(string key);
6+
Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpireTime = null);
7+
Task RemoveAsync(string key);
8+
}
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Octokit.Webhooks;
2+
using System.ComponentModel.DataAnnotations;
3+
4+
namespace DevExcelerateApi.Services
5+
{
6+
public interface IPolicyService
7+
{
8+
Task<ValidationResult> ValidateRepositoryNameAsync(string name, WebhookEvent webhookEvent);
9+
Task<ValidationResult> ValidateTeamNameAsync(string name, WebhookEvent webhookEvent);
10+
}
11+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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

Comments
 (0)