diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..facb2b13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM mcr.microsoft.com/dotnet/aspnet:7.0 +WORKDIR /app +COPY publish ./ +ENTRYPOINT ["dotnet", "CrystallineSociety.Server.Api.dll"] diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..ef1d2d16 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,92 @@ +name: $(version).$(Build.BuildId) + +variables: + WEB_APP_DEPLOYMENT_TYPE: 'DefaultDeploymentType' + +jobs: + +- job: build_blazor_api_wasm + displayName: 'build blazor api + web assembly' + + pool: + vmImage: 'ubuntu-latest' + + steps: + + - task: Assembly-Info-NetCore@3 + displayName: 'Set Assembly Manifest Data' + inputs: + Company: Cs Internship + Product: Cs System + VersionNumber: '$(Build.BuildNumber)' + FileVersionNumber: '$(Build.BuildNumber)' + InformationalVersion: '$(Build.BuildNumber)' + PackageVersion: '$(Build.BuildNumber)' + + - task: UseDotNet@2 + displayName: 'Setup .NET' + inputs: + useGlobalJson: true + workingDirectory: 'src' + + - task: UseDotNet@2 + displayName: 'Use dotnet sdk 6.x for LibSassBuilder' + inputs: + version: 6.x + + - task: Bash@3 + displayName: 'Restore workloads' + inputs: + targetType: 'inline' + script: 'dotnet workload restore src/CrystallineSociety/Client/Web/CrystallineSociety.Client.Web.csproj -p:BlazorMode=BlazorWebAssembly' + + - task: Bash@3 + displayName: 'Switch to blazor web assembly' + inputs: + targetType: 'inline' + script: sed -i 's/Microsoft.NET.Sdk.Web/Microsoft.NET.Sdk.BlazorWebAssembly/g' src/CrystallineSociety/Client/Web/CrystallineSociety.Client.Web.csproj + - task: Bash@3 + displayName: 'Build migrations bundle' + inputs: + targetType: 'inline' + script: | + dotnet tool install --global dotnet-ef --version 7.0.0 + dotnet ef migrations bundle --self-contained -r linux-x64 --project src/CrystallineSociety/Server/Api/CrystallineSociety.Server.Api.csproj + failOnStderr: true + - task: Bash@3 + displayName: 'Install wasm-tools' + inputs: + targetType: 'inline' + script: dotnet workload install wasm-tools + + - task: Bash@3 + displayName: 'Build (To generate CSS/JS files)' + inputs: + targetType: 'inline' + script: 'dotnet build src/CrystallineSociety/Client/Web/CrystallineSociety.Client.Web.csproj -p:Configuration=Release -p:BlazorMode=BlazorWebAssembly -p:WebAppDeploymentType="${{ variables.WEB_APP_DEPLOYMENT_TYPE }}"' + + - task: Bash@3 + displayName: 'Publish' + inputs: + targetType: 'inline' + script: 'dotnet publish src/CrystallineSociety/Server/Api/CrystallineSociety.Server.Api.csproj -p:BlazorMode=BlazorWebAssembly -p:WebAppDeploymentType="${{ variables.WEB_APP_DEPLOYMENT_TYPE }}" -p:Configuration=Release -o api-web' + + - task: PublishPipelineArtifact@1 + displayName: Upload api-web artifact + inputs: + targetPath: 'api-web' + artifact: 'api-web-bundle' + publishLocation: 'pipeline' + + - task: PublishPipelineArtifact@1 + displayName: Upload ef migrations bundle + inputs: + targetPath: 'efbundle' + artifact: 'migrations-bundle' + publishLocation: 'pipeline' + - task: PublishPipelineArtifact@1 + inputs: + targetPath: 'Dockerfile' + artifact: 'Docker' + publishLocation: 'pipeline' + \ No newline at end of file diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor b/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor new file mode 100644 index 00000000..ae0508f8 --- /dev/null +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor @@ -0,0 +1,15 @@ +@using CrystallineSociety.Shared.Dtos.BadgeSystem +@inherits AppComponentBase + +@if (Badge != null) +{ +
+

Badge Content

+

Badge Code : @Badge.Code

+
+

Badge Level : @Badge.Level

+
+

Badge Description : @Badge.Description

+
+} + diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor.cs b/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor.cs new file mode 100644 index 00000000..53b21121 --- /dev/null +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor.cs @@ -0,0 +1,14 @@ +using CrystallineSociety.Shared.Dtos.BadgeSystem; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CrystallineSociety.Client.Shared.Components +{ + public partial class BadgeContent + { + [Parameter] public BadgeDto? Badge { get; set; } + } +} diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor.scss b/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor.scss new file mode 100644 index 00000000..e02abfc9 --- /dev/null +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeContent.razor.scss @@ -0,0 +1 @@ + diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor b/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor index fa897fab..e537cb12 100644 --- a/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor @@ -1,6 +1,7 @@ -
- Tada from Badge System +@using CrystallineSociety.Shared.Dtos.BadgeSystem +@inherits AppComponentBase + +
+ +
-
-    @GetBundleText(Bundle)
-
diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor.cs b/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor.cs index 8f406408..1ab98806 100644 --- a/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor.cs +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeSystem.razor.cs @@ -15,13 +15,9 @@ public partial class BadgeSystem [AutoInject] private IBadgeSystemService BadgeSystemService { get; set; } = default!; [AutoInject] private IBadgeUtilService BadgeUtilService { get; set; } = default!; [Parameter] public BadgeBundleDto? Bundle { get; set; } + private BadgeDto? BadgeDto {get; set;} - protected override Task OnInitializedAsync() - { - return base.OnInitializedAsync(); - } - private string? GetBundleText(BadgeBundleDto bundle) { var builder = new StringBuilder(); @@ -35,5 +31,10 @@ protected override Task OnInitializedAsync() return builder.ToString(); } + + private void GetBadge(BadgeDto badgeDto) + { + BadgeDto = badgeDto; + } } } diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor b/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor new file mode 100644 index 00000000..4886aabb --- /dev/null +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor @@ -0,0 +1,16 @@ +@using CrystallineSociety.Shared.Dtos.BadgeSystem +@inherits AppComponentBase + +@if (Badges != null) +{ + + +
+
Name: @badge.Code
+
+
+
+} diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor.cs b/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor.cs new file mode 100644 index 00000000..9ef42581 --- /dev/null +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using CrystallineSociety.Shared.Dtos.BadgeSystem; + +namespace CrystallineSociety.Client.Shared.Components +{ + public partial class BadgeTree + { + [Parameter] public BadgeBundleDto? BadgeBundleDto { get; set; } + [Parameter] public EventCallback BadgeDtoCallBack { get; set; } + + private List? Badges { get; set; } + + protected override Task OnParamsSetAsync() + { + if (BadgeBundleDto != null) + { + Badges = BadgeBundleDto.Badges.ToList(); + } + return base.OnParamsSetAsync(); + } + + private async Task OnBadgeClick(BadgeDto badgeDto) + { + await BadgeDtoCallBack.InvokeAsync(badgeDto); + } + } +} diff --git a/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor.scss b/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor.scss new file mode 100644 index 00000000..41353eb4 --- /dev/null +++ b/src/CrystallineSociety/Client/Shared/Components/BadgeTree.razor.scss @@ -0,0 +1,21 @@ +.list { + border: 1px #a19f9d solid; + border-radius: 3px; + height: auto; +} + +.badge-row { + + &:hover { + cursor: pointer; + background-color: lightskyblue; + + div { + color: black; + } + } + + .badge-name-color { + color: white; + } +} diff --git a/src/CrystallineSociety/Client/Shared/Pages/GitHubBadgeSystemExplorerPage.razor b/src/CrystallineSociety/Client/Shared/Pages/GitHubBadgeSystemExplorerPage.razor index 2e580ff8..35878f5a 100644 --- a/src/CrystallineSociety/Client/Shared/Pages/GitHubBadgeSystemExplorerPage.razor +++ b/src/CrystallineSociety/Client/Shared/Pages/GitHubBadgeSystemExplorerPage.razor @@ -2,10 +2,14 @@ @using CrystallineSociety.Shared.Dtos.BadgeSystem @inherits AppComponentBase -
- GitHub URL: - - Load Badge System - @GitHubUrl +
+
+ GitHub URL: +
+ + +
+
+ @GitHubUrl
diff --git a/src/CrystallineSociety/CrystallineSociety.Server.Api.Test/GitHubBadgeServiceTests.cs b/src/CrystallineSociety/CrystallineSociety.Server.Api.Test/GitHubBadgeServiceTests.cs index 57958e2f..4a086948 100644 --- a/src/CrystallineSociety/CrystallineSociety.Server.Api.Test/GitHubBadgeServiceTests.cs +++ b/src/CrystallineSociety/CrystallineSociety.Server.Api.Test/GitHubBadgeServiceTests.cs @@ -1,6 +1,7 @@ -using CrystallineSociety.Shared.Dtos.BadgeSystem; using CrystallineSociety.Shared.Services.Implementations.BadgeSystem; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Octokit; namespace CrystallineSociety.Server.Api.Test { @@ -9,59 +10,260 @@ public class GitHubBadgeServiceTests { public TestContext TestContext { get; set; } = default!; + [TestMethod] public async Task GitHubBadge_LoadSimple() { var testHost = Host.CreateDefaultBuilder() - .ConfigureServices((_, services) => - { - services.AddSharedServices(); - services.AddServerServices(); - } - ).Build(); + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); var githubService = testHost.Services.GetRequiredService(); - var factory = testHost.Services.GetRequiredService(); var badgeUrl = - "https://github.com/cs-internship/cs-system/tree/main/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample"; + "https://github.com/hootanht/cs-system/blob/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-badge.json"; + var badge = await githubService.GetBadgeAsync(badgeUrl); Assert.IsNotNull(badge); + } + + [TestMethod] + public async Task GitHubBadge_LoadByFolderAddress_FileNotFoundException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + var factory = testHost.Services.GetRequiredService(); + + var badgesFolderUrl = + "https://github.com/hootanht/cs-system/blob/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample"; + + + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetBadgeAsync(badgesFolderUrl); + }); + + Assert.IsNotNull(exception); + Assert.AreEqual($"Badge file not found in: {badgesFolderUrl}", exception.Message); + } + + [TestMethod] + public async Task GetBadgeAsync_IncorrectBranchName_ThrowResourceNotFoundException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + + var badgeUrl = + "https://github.com/hootanht/cs-system/tree/feature-initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample"; + + var ex = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetBadgeAsync(badgeUrl); + }); + } + + [TestMethod] + public async Task GetBadgeAsync_IncorrectOrganizationOrRepositoryName_ThrowOctokitNotFoundException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + + var badgeUrl = + "https://github.com/hootanhtbug/cs-system/tree/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample"; + + var ex = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetBadgeAsync(badgeUrl); + }); + } + + [TestMethod] + public async Task GetBadgeAsync_InvalidBadgeFileName_ThrowFileNotFoundException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + + var badgeUrl = + "https://github.com/hootanht/cs-system/tree/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec.json"; - var bundle = new BadgeBundleDto(); - bundle.Badges.Add(badge); + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetBadgeAsync(badgeUrl); + }); + } + + [TestMethod] + public async Task GetBadgeAsync_UnparsableBadgeFileFormat_ThrowFormatException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); - var badgeSystem = factory.CreateNew(bundle); + var githubService = testHost.Services.GetRequiredService(); - Assert.IsNotNull(badgeSystem.Validations); - Assert.IsFalse(badgeSystem.Validations.Any()); + var badgeUrl = + "https://github.com/hootanht/cs-system/tree/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-empty-badge.json"; + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetBadgeAsync(badgeUrl); + }); + } + + + [TestMethod] + public async Task GetBadgeAsync_InvalidRepoIdOrSha_ThrowNotFoundException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + + long repoId = 619299373; //correct: 619299373 + string invalidSha = + "cf74dc41bb1a474fe7ff3e2624aae055ee5338ec"; //correct: cf74dc41bb1a474fe7ff3e2624aae055ee5338eb + + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetBadgeAsync(repoId, invalidSha); + }); + } + + [TestMethod] + public async Task GetBadgeAsync_ValidRepoIdAndSha_AreEqual() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + + long repoId = 619299373; + string sha = "cf74dc41bb1a474fe7ff3e2624aae055ee5338eb"; + + var result = await githubService.GetBadgeAsync(repoId, sha); + Assert.AreEqual("doc-beginner", result.Code); + } + + [TestMethod] + public async Task GetLightBadgesAsync_ValidBadgeFileUrl_ThrowInvalidOperationException() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + var badgesFolderUrl = + "https://github.com/hootanht/cs-system/blob/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-badge.json"; + + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await githubService.GetLightBadgesAsync(badgesFolderUrl); + }); + } + + [TestMethod] + public async Task GetLightBadgesAsync_ValidBadgesFolderUrl_AreEqualShaValues() + { + var testHost = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); + + var githubService = testHost.Services.GetRequiredService(); + var badgesFolderUrl = + "https://github.com/hootanht/cs-system/tree/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/github-sample-folder"; + + var result = await githubService.GetLightBadgesAsync(badgesFolderUrl); + Assert.AreEqual(result[0].Sha, + "cf74dc41bb1a474fe7ff3e2624aae055ee5338eb"); + Assert.AreEqual(result[1].Sha, + "7399b9c26213221edf619a9ae5558f7138b970a2"); + Assert.AreEqual(result[2].Sha, + "1c0ba797e86cd01c5ef35673f5951d41de4b69ce"); + Assert.AreEqual(result[3].Sha, + "31b0dabb38f40a631aae96ae4066c828bfd21611"); + Assert.AreEqual(result[4].Sha, + "05ac8473cee780f202ad1f77df18428db5631e85"); + Assert.AreEqual(result[5].Sha, + "ce329c0bcc7307c53b8a695c59e62768c30e84e8"); } [TestMethod] public async Task GitHubBadgesList_LoadSimple() { var testHost = Host.CreateDefaultBuilder() - .ConfigureServices((_, services) => - { - services.AddSharedServices(); - services.AddServerServices(); - } - ).Build(); + .ConfigureServices((_, services) => + { + services.AddSharedServices(); + services.AddServerServices(); + } + ).Build(); var githubService = testHost.Services.GetRequiredService(); var factory = testHost.Services.GetRequiredService(); var badgesFolderUrl = - "https://github.com/cs-internship/cs-system/tree/main/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/github-sample-folder"; + "https://github.com/hootanht/cs-system/tree/feature/initial-get-badge-system/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/github-sample-folder"; var badges = await githubService.GetBadgesAsync(badgesFolderUrl); Assert.IsNotNull(badges); Assert.AreEqual(6, badges.Count); var badgeSystem = factory.CreateNew(badges); - } } } \ No newline at end of file diff --git a/src/CrystallineSociety/Server/Api/Extensions/IServiceCollectionExtensions.cs b/src/CrystallineSociety/Server/Api/Extensions/IServiceCollectionExtensions.cs index 4d3036d4..02ffd905 100644 --- a/src/CrystallineSociety/Server/Api/Extensions/IServiceCollectionExtensions.cs +++ b/src/CrystallineSociety/Server/Api/Extensions/IServiceCollectionExtensions.cs @@ -9,6 +9,9 @@ using CrystallineSociety.Server.Api.AppHooks; using CrystallineSociety.Server.Api.Models.Account; using CrystallineSociety.Server.Api.Services.Implementations; +using Octokit; +using User = CrystallineSociety.Server.Api.Models.Account.User; + namespace Microsoft.Extensions.DependencyInjection; @@ -16,9 +19,11 @@ public static class IServiceCollectionExtensions { public static void AddServerServices(this IServiceCollection services) { - services.AddTransient(); + services.AddTransient(); services.AddAppHook(); services.AddTransient(); + // ToDo: Complete. + services.AddTransient(CreateGitHubClient); } public static void AddIdentity(this IServiceCollection services, IConfiguration configuration) @@ -176,4 +181,16 @@ public static void AddHealthChecks(this IServiceCollection services, IWebHostEnv }); } } + + private static GitHubClient CreateGitHubClient(IServiceProvider serviceProvider) + { + var productHeaderValue = new ProductHeaderValue("CS-System"); + var gitHubToken = "ghp_" + serviceProvider.GetRequiredService().GetSection("GitHub")["GitHubAccessToken"]; + var tokenAuth = new Credentials(gitHubToken); + var client = new GitHubClient(productHeaderValue) + { + Credentials = tokenAuth + }; + return client; + } } diff --git a/src/CrystallineSociety/Server/Api/Services/Implementations/GitHubBadgeService.cs b/src/CrystallineSociety/Server/Api/Services/Implementations/GitHubBadgeService.cs new file mode 100644 index 00000000..821176d1 --- /dev/null +++ b/src/CrystallineSociety/Server/Api/Services/Implementations/GitHubBadgeService.cs @@ -0,0 +1,205 @@ +using System.Reflection.Metadata; +using System.Text; +using System.Text.RegularExpressions; + +using CrystallineSociety.Shared.Dtos.BadgeSystem; + +using Octokit; + +namespace CrystallineSociety.Server.Api.Services.Implementations +{ + public partial class GitHubBadgeService : IGitHubBadgeService + { + [AutoInject] public IBadgeUtilService BadgeUtilService { get; set; } + + [AutoInject] public GitHubClient GitHubClient { get; set; } + + /// + /// Asynchronously retrieves objects from badge files located at the GitHub URL provided. + /// This method performs a recursive search for files and selects those whose filename ends with `-badge.json`. + /// + /// GitHub folder URL containing badge files. + /// A task that represents a list of parsed badge files. + /// When the RepoId of light badge is null. + /// When the Sha of light badge is null. + public async Task> GetBadgesAsync(string folderUrl) + { + var lightBadges = await GetLightBadgesAsync(folderUrl); + + var badges = new List(); + + foreach (var lightBadge in lightBadges) + { + if (lightBadge.Url is null) + continue; + + //todo Replace Exception with NullReferenceException or another relevant one + var badgeDto = await GetBadgeAsync( + lightBadge.RepoId ?? throw new Exception("RepoId of light badge is null."), + lightBadge.Sha ?? throw new Exception("Sha of light badge is null.") + ); + + badges.Add(badgeDto); + } + + return badges; + } + + /// + /// Asynchronously loads and parses a lightweight version of badges specifications from a GitHub URL pointing to a folder recursively. + /// + /// The GitHub URL pointing to a folder containing badge file(s). All badge files will load recursively. Badge filename must ends with `-badge.json`. + /// A task that represents a list of lightweight version of s. + public async Task> GetLightBadgesAsync(string folderUrl) + { + var (orgName, repoName) = GetRepoAndOrgNameFromUrl(folderUrl); + var repo = await GitHubClient.Repository.Get(orgName, repoName); + var refs = await GitHubClient.Git.Reference.GetAll(repo.Id); + + var lastSegment = GetLastSegmentFromUrl(folderUrl,refs,out var parentFolderPath); + var repositoryId = repo.Id; + + var folderContents = await GitHubClient.Repository.Content.GetAllContents(repositoryId, parentFolderPath); + var folderSha = folderContents?.First(f => f.Name == lastSegment).Sha; + var allContents = await GitHubClient.Git.Tree.GetRecursive(repositoryId, folderSha); + + return allContents.Tree + .Where(t=>t.Type == TreeType.Blob ) + .Select(t => new BadgeDto { RepoId = repositoryId, Sha = t.Sha, Url = t.Url }) + .ToList(); + } + + /// + /// Asynchronously loads a badge specification from the given and parses it. + /// + /// The badge file GitHub URL. Badge filename must ends with `-badge.json`. + /// A task that represents the parsed badge object. + /// When unable to locate the GitHub repo branchName. + /// When the given is not a valid badge file URL. + /// When the loaded badge file has an incorrect format and cannot be parsed. + public async Task GetBadgeAsync(string badgeUrl) + { + var (orgName, repoName) = GetRepoAndOrgNameFromUrl(badgeUrl); + var repo = await GitHubClient.Repository.Get(orgName, repoName); + var refs = await GitHubClient.Git.Reference.GetAll(repo.Id); + var branchName = GetBranchNameFromUrl(badgeUrl, refs) ?? + throw new ResourceNotFoundException($"Unable to locate branchName: {badgeUrl}"); + + var branchRef = refs.First(r => r.Ref.Contains($"refs/heads/{branchName}")); + var badgeFilePath = GetRelativePath(badgeUrl.EndsWith("-badge.json") + ? badgeUrl + : throw new FileNotFoundException($"Badge file not found in: {badgeUrl}"), refs); + + var badgeFileContentByte = + await GitHubClient.Repository.Content.GetRawContentByRef(orgName, repoName, badgeFilePath, branchRef.Ref); + + var badgeFileContent = Encoding.UTF8.GetString(badgeFileContentByte); + + try + { + var badge = BadgeUtilService.ParseBadge(badgeFileContent); + return badge; + } + catch (Exception exception) + { + throw new FormatException($"Can not parse badge with badgeUrl: '{badgeUrl}'", exception); + } + } + + /// + /// Asynchronously loads and parses a badge specification from a badge file on GitHub identified by the and parameters. + /// + /// The Id of the repository on GtiHub. + /// The SHA Id of the file in the repository on GtiHub. + /// A task that represents the parsed badge object. + public async Task GetBadgeAsync(long repositoryId, string sha) + { + var badgeBlob = await GitHubClient.Git.Blob.Get(repositoryId, sha); + + var bytes = Convert.FromBase64String(badgeBlob.Content); + var badgeContent = Encoding.UTF8.GetString(bytes); + var badgeDto = BadgeUtilService.ParseBadge(badgeContent); + return badgeDto; + } + + /// + /// The relative path is created by eliminating the URL segments from the left up to the branch name. Branch name is exclude. + /// + /// A GitHub URL pointing to a file/folder. + /// Octokit references to the GitHub repo. + /// The relative path. + private static string? GetRelativePath(string url, IEnumerable refs) + { + var uri = new Uri(url); + var afterTreeSegments = string.Join("", uri.Segments[4..]); + foreach (var reference in refs) + { + var branchInRefWithEndingSlash = $"{Regex.Replace(reference.Ref, @"^[^/]+/[^/]+/", "")}/"; + + if (!afterTreeSegments.StartsWith(branchInRefWithEndingSlash)) + continue; + + var path = afterTreeSegments.Replace(branchInRefWithEndingSlash, string.Empty).TrimEnd('/'); + return path; + } + + return null; + } + + /// + /// Get the branch name from a GitHub URL. + /// + /// A GitHub URL. + /// Octokit references to the GitHub repo. + /// The branch name. + private static string? GetBranchNameFromUrl(string url, IEnumerable refs) + { + var uri = new Uri(url); + var afterTreeSegments = string.Join("", uri.Segments[4..]); + foreach (var reference in refs) + { + var branchInRefWithEndingSlash = $"{Regex.Replace(reference.Ref, @"^[^/]+/[^/]+/", "")}/"; + if (afterTreeSegments.StartsWith(branchInRefWithEndingSlash)) + { + return branchInRefWithEndingSlash.TrimEnd('/'); + } + } + + return null; + } + + /// + /// Extract the last segment of a GitHub URL pointing to a folder. + /// + /// A GitHub URL pointing to a folder. + /// Octokit references to the GitHub repo. + /// Extracted parent folder of the given GitHub URL. + /// The last segment of a GitHub URL. + private static string GetLastSegmentFromUrl(string url, IEnumerable refs, out string? parentFolderPath) + { + var uri = new Uri(url); + var lastSegment = uri.Segments.Last().TrimEnd('/'); + var parentFolderUrl = uri.GetLeftPart(UriPartial.Authority) + + string.Join("", uri.Segments.Take(uri.Segments.Length - 1)); + + parentFolderPath = GetRelativePath(parentFolderUrl, refs); + + return lastSegment; + } + + /// + /// Retrieves a repository and organization/owner name from GitHub URL. + /// + /// A GitHub URL + /// The repository and organization/owner name. + private static (string org, string repo) GetRepoAndOrgNameFromUrl(string url) + { + var uri = new Uri(url); + var segments = uri.Segments; + var org = segments[1].TrimEnd('/'); + var repo = segments[2].TrimEnd('/'); + + return (org, repo); + } + } +} \ No newline at end of file diff --git a/src/CrystallineSociety/Server/Api/Services/Implementations/ServerGitHubBadgeService.cs b/src/CrystallineSociety/Server/Api/Services/Implementations/ServerGitHubBadgeService.cs deleted file mode 100644 index 55be8194..00000000 --- a/src/CrystallineSociety/Server/Api/Services/Implementations/ServerGitHubBadgeService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using CrystallineSociety.Shared.Dtos.BadgeSystem; -using Octokit; - -namespace CrystallineSociety.Server.Api.Services.Implementations -{ - public partial class ServerGitHubBadgeService : IGitHubBadgeService - { - [AutoInject] - public IBadgeUtilService BadgeUtilService { get; set; } - - public async Task> GetBadgesAsync(string url) - { - // Todo: return the real list. - return new List() - { - BadgeUtilService.ParseBadge($$"""{"code": "github-test-badge-a", "description": "from: {{url}}"}"""), - BadgeUtilService.ParseBadge($$"""{"code": "github-test-badge-b", "description": "from: {{url}}"}"""), - }; - } - - public async Task GetBadgeAsync(string url) - { - throw new NotImplementedException(); - //var client = new GitHubClient(new ProductHeaderValue("CS-System")); - //var repos = await client.Repository.GetAllForOrg("cs-internship"); - //var repo = repos.First(r => r.Name == "cs-system"); - - //var refs= await client.Git.Reference.GetAll(repo.Id); - - //var main = refs.First(r => r.Ref.Contains("refs/heads/main")); - ////var main = refs.First(r => r.Ref.Contains("refs/heads/main")); - //var refx = - // "refs/heads/main/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample"; - //var xx = await client.Git.Tree.GetRecursive(repo.Id, main.Ref); - - //return default; - } - } -} diff --git a/src/CrystallineSociety/Server/Api/appsettings.json b/src/CrystallineSociety/Server/Api/appsettings.json index d7422a3f..d9a28fc0 100644 --- a/src/CrystallineSociety/Server/Api/appsettings.json +++ b/src/CrystallineSociety/Server/Api/appsettings.json @@ -1,44 +1,47 @@ { - "ConnectionStrings": { - "SqlServerConnectionString": "Data Source=.\\sqlexpress; Initial Catalog=CrystallineSocietyDb;Integrated Security=true;Application Name=Todo;TrustServerCertificate=True;" + "ConnectionStrings": { + "SqlServerConnectionString": "Data Source=.\\sqlexpress; Initial Catalog=CrystallineSocietyDb;Integrated Security=true;Application Name=Todo;TrustServerCertificate=True;" + }, + "GitHub": { + "GitHubAccessToken": "5MttcI7TZ8cijBPklSxwUE5zKgOjKo1bnaTr" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AppSettings": { + "JwtSettings": { + "IdentityCertificatePassword": "P@ssw0rdP@ssw0rd", + "Issuer": "CrystallineSociety", + "Audience": "CrystallineSociety", + "NotBeforeMinutes": "0", + "ExpirationMinutes": "1440" }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } + "IdentitySettings": { + "PasswordRequireDigit": "false", + "PasswordRequiredLength": "6", + "PasswordRequireNonAlphanumeric": "false", + "PasswordRequireUppercase": "false", + "PasswordRequireLowercase": "false", + "RequireUniqueEmail": "true", + "ConfirmationEmailResendDelay": "0.00:02:00", //Format: D.HH:mm:nn + "ResetPasswordEmailResendDelay": "0.00:02:00" //Format: D.HH:mm:nn }, - "AppSettings": { - "JwtSettings": { - "IdentityCertificatePassword": "P@ssw0rdP@ssw0rd", - "Issuer": "CrystallineSociety", - "Audience": "CrystallineSociety", - "NotBeforeMinutes": "0", - "ExpirationMinutes": "1440" - }, - "IdentitySettings": { - "PasswordRequireDigit": "false", - "PasswordRequiredLength": "6", - "PasswordRequireNonAlphanumeric": "false", - "PasswordRequireUppercase": "false", - "PasswordRequireLowercase": "false", - "RequireUniqueEmail": "true", - "ConfirmationEmailResendDelay": "0.00:02:00", //Format: D.HH:mm:nn - "ResetPasswordEmailResendDelay": "0.00:02:00" //Format: D.HH:mm:nn - }, - "EmailSettings": { - "Host": "LocalFolder", // Local folder means storing emails in bin\sent-emails folder (Recommended for testing purposes only) instead of sending them using smtp server. - "Port": "25", - "DefaulFromEmail": "info@CrystallineSociety.com", - "DefaultFromName": "CrystallineSociety", - "UserName": null, - "Password": null - }, - "HealthCheckSettings": { - "EnableHealthChecks": false - }, - "UserProfileImagePath": "Attachments/Profiles/" + "EmailSettings": { + "Host": "LocalFolder", // Local folder means storing emails in bin\sent-emails folder (Recommended for testing purposes only) instead of sending them using smtp server. + "Port": "25", + "DefaulFromEmail": "info@CrystallineSociety.com", + "DefaultFromName": "CrystallineSociety", + "UserName": null, + "Password": null }, - "AllowedHosts": "*" + "HealthCheckSettings": { + "EnableHealthChecks": false + }, + "UserProfileImagePath": "Attachments/Profiles/" + }, + "AllowedHosts": "*" } diff --git a/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-badge.json b/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-badge.json new file mode 100644 index 00000000..6d89aadf --- /dev/null +++ b/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-badge.json @@ -0,0 +1,59 @@ +{ + "code": "doc-guru-badge-sample", + "description": "Description for doc-guru-badge-sample", + "level": "Gold", + "info": { + "en": "info_en.md", + "fa": "info_fa.md" + }, + "appraisal-methods": [ + { + "title": "Main Method", + "badge-requirements": [ + "requirement-badge-code-A*2", + "requirement-badge-code-B", + "requirement-badge-code-C*1|requirement-badge-code-D*2" + ], + "activity-requirements": [ + "requirement-activity-code-A", + "requirement-activity-code-B*1", + "requirement-activity-code-C|requirement-activity-code-D*2" + ], + "approving-steps": [ + { + "step": 1, + "title": "Initial Approval", + "approver-required-badges": [ + "requirement-badge-code-A*2", + "requirement-badge-code-B", + "requirement-badge-code-C*2|requirement-badge-code-D" + ], + "required-approval-count": 2 + }, + { + "step": 2, + "title": "Final Approval", + "approver-required-badges": [ + "requirement-badge-code-D*2" + ], + "required-approval-count": 2 + } + ] + }, + { + "title": "Superpower Method", + "badge-requirements": [], + "activity-requirements": [], + "approving-steps": [ + { + "step": 1, + "title": "Superpower Admin", + "approver-required-badges": [ + "superpower" + ], + "required-approval-count": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-empty-badge.json b/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/BadgeSystem/SampleBadgeDocs/serialization-badge-sample/spec-empty-badge.json new file mode 100644 index 00000000..e69de29b diff --git a/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/CrystallineSociety.Shared.Test.csproj b/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/CrystallineSociety.Shared.Test.csproj index 62c9f522..53878c5c 100644 --- a/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/CrystallineSociety.Shared.Test.csproj +++ b/src/CrystallineSociety/Shared/CrystallineSociety.Shared.Test/CrystallineSociety.Shared.Test.csproj @@ -18,6 +18,7 @@ + @@ -55,6 +56,12 @@ Always + + Always + + + Always + Always diff --git a/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeBundleDto.cs b/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeBundleDto.cs index 9cd1253b..7753d3e1 100644 --- a/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeBundleDto.cs +++ b/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeBundleDto.cs @@ -8,6 +8,9 @@ namespace CrystallineSociety.Shared.Dtos.BadgeSystem { public class BadgeBundleDto { + public List Badges { get; set; } = new(); + public List? Validations { get; set; } + public BadgeBundleDto() { @@ -18,9 +21,6 @@ public BadgeBundleDto(List badges) Badges = badges; } - public List Badges { get; set; } = new(); - public List? Validations { get; set; } - public bool BadgeExists(string badgeCode) { return Badges.Any(b => b.Code == badgeCode); diff --git a/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeDto.cs b/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeDto.cs index 5952581b..01468015 100644 --- a/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeDto.cs +++ b/src/CrystallineSociety/Shared/Shared/Dtos/BadgeSystem/BadgeDto.cs @@ -9,15 +9,18 @@ namespace CrystallineSociety.Shared.Dtos.BadgeSystem { public class BadgeDto { - public string Code { get; set; } = default!; + public string? Code { get; set; } = default!; public string? Description { get; set; } - public BadgeLevel Level { get; set; } - public Dictionary Info { get; set; } = new(); - public List AppraisalMethods { get; set; } = new(); - + public BadgeLevel? Level { get; set; } + public Dictionary? Info { get; set; } = new(); + public List? AppraisalMethods { get; set; } = new(); + + public string? Sha { get; set; } + public string? Url { get; set; } + public long? RepoId { get; set; } public override string ToString() { - return Code; + return Code ?? ""; } } diff --git a/src/CrystallineSociety/Shared/Shared/Exceptions/FileContentIsNullException.cs b/src/CrystallineSociety/Shared/Shared/Exceptions/FileContentIsNullException.cs new file mode 100644 index 00000000..06c4d1e9 --- /dev/null +++ b/src/CrystallineSociety/Shared/Shared/Exceptions/FileContentIsNullException.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; + +namespace CrystallineSociety.Shared.Exceptions; + +public class FileContentIsNullException : IOException +{ + public FileContentIsNullException() + : base(nameof(AppStrings.FileContentIsNullException)) + { + } + + public FileContentIsNullException(string message) + : base(message) + { + } + + public FileContentIsNullException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.Designer.cs b/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.Designer.cs index 040c053d..5c501970 100644 --- a/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.Designer.cs +++ b/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.Designer.cs @@ -502,6 +502,15 @@ public static string Error { } } + /// + /// Looks up a localized string similar to File content is null.. + /// + public static string FileContentIsNullException { + get { + return ResourceManager.GetString("FileContentIsNullException", resourceCulture); + } + } + /// /// Looks up a localized string similar to The {0} field only accepts files with the following extensions: {1}. /// diff --git a/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.resx b/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.resx index 086eca07..4a33cd58 100644 --- a/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.resx +++ b/src/CrystallineSociety/Shared/Shared/Resources/AppStrings.resx @@ -207,8 +207,7 @@ Error when passwords do not have an uppercase letter - {0} must be at least {1} characters. - Error message for passwords that are too short + The field {0} must be a string or array type with a minimum length of '{1}'. Role {0} does not exist. @@ -535,9 +534,6 @@ Please confirm your email by clicking on the link. MinLengthAttribute must have a Length value that is zero or greater. - - The field {0} must be a string or array type with a minimum length of '{1}'. - The {0} field is not a valid phone number. @@ -649,4 +645,7 @@ Please confirm your email by clicking on the link. Title + + File content is null. + \ No newline at end of file diff --git a/src/CrystallineSociety/Shared/Shared/Services/Contracts/IGitHubBadgeService.cs b/src/CrystallineSociety/Shared/Shared/Services/Contracts/IGitHubBadgeService.cs index 2f1a100b..f0893ea2 100644 --- a/src/CrystallineSociety/Shared/Shared/Services/Contracts/IGitHubBadgeService.cs +++ b/src/CrystallineSociety/Shared/Shared/Services/Contracts/IGitHubBadgeService.cs @@ -9,7 +9,39 @@ namespace CrystallineSociety.Shared.Services.Contracts { public interface IGitHubBadgeService { - Task> GetBadgesAsync(string url); - Task GetBadgeAsync(string url); + /// + /// Asynchronously retrieves objects from badge files located at the GitHub URL provided. + /// This method performs a recursive search for files and selects those whose filename ends with `-badge.json`. + /// + /// GitHub folder URL containing badge files. + /// A task that represents a list of parsed badge files. + /// When the RepoId of light badge is null. + /// When the Sha of light badge is null. + Task> GetBadgesAsync(string folderUrl); + + /// + /// Asynchronously loads a badge specification from the given and parses it. + /// + /// The badge file GitHub URL. Badge filename must ends with `-badge.json`. + /// A task that represents the parsed badge object. + /// When unable to locate the GitHub repo branchName. + /// When the given is not a valid badge file URL. + /// When the loaded badge file has an incorrect format and cannot be parsed. + Task GetBadgeAsync(string badgeUrl); + + /// + /// Asynchronously loads and parses a badge specification from a badge file on GitHub identified by the and parameters. + /// + /// The Id of the repository on GtiHub. + /// The SHA Id of the file in the repository on GtiHub. + /// A task that represents the parsed badge object. + Task GetBadgeAsync(long repositoryId, string sha); + + /// + /// Asynchronously loads and parses a lightweight version of badges specifications from a GitHub URL pointing to a folder recursively. + /// + /// The GitHub URL pointing to a folder containing badge file(s). All badge files will load recursively. Badge filename must ends with `-badge.json`. + /// A task that represents a list of lightweight version of s. + Task> GetLightBadgesAsync(string folderUrl); } -} +} \ No newline at end of file