diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 524903c9..1cc0e016 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,35 +1,107 @@ name: 'Build and Test' on: + workflow_call: workflow_dispatch: inputs: reason: description: 'The reason for running the workflow' required: false default: 'Manual build and run tests' - workflow_run: - workflows: [ 'Build' ] - types: - - completed + push: + tags-ignore: + - '[0-9]+.[0-9]+.[0-9]+*' + paths: + - '**.cs' + - '**.csproj' + - '**.sln' + pull_request: + branches: [ master ] + paths: + - '**.cs' + - '**.csproj' + - '**.sln' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + # Disable the .NET logo in the console output. + DOTNET_NOLOGO: true + + # Stop wasting time caching packages + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + + # Disable sending usage data to Microsoft + DOTNET_CLI_TELEMETRY_OPTOUT: true + + DOTNET_ADD_GLOBAL_TOOLS_TO_PATH: false + DOTNET_MULTILEVEL_LOOKUP: 0 + + PROJECT_PATH: . + + CONFIGURATION: Release + jobs: build: - uses: ./.github/workflows/build.yml - test: - name: Test - ${{matrix.os}} - needs: [build] + needs: before + name: Build ${{ matrix.os }} - dotnet ${{ matrix.dotnet }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ ubuntu-latest, windows-latest, macOS-latest ] + os: [ ubuntu-latest, windows-latest, macos-latest ] + steps: - - name: Test - # if: ${{ github.event.workflow_run.conclusion == 'success' }} - timeout-minutes: 60 - run: >- - dotnet test "${{ env.PROJECT_PATH }}" - --no-restore - --no-build - --verbosity normal - --logger "trx;LogFileName=test-results.trx" || true - \ No newline at end of file + - name: Print manual run reason + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + echo 'Reason: ${{ github.event.inputs.reason }}' + + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + + - name: Setup .NET (global.json) + uses: actions/setup-dotnet@v4 + + - name: Display dotnet version + run: dotnet --version + + - uses: actions/cache@v4 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-nuget + + - name: Restore + run: dotnet restore "${{ env.PROJECT_PATH }}" + + - name: Build + run: >- + dotnet build "${{ env.PROJECT_PATH }}" + --configuration "${{ env.CONFIGURATION }}" + --no-restore + + - name: Test + timeout-minutes: 60 + run: >- + dotnet test "${{ env.PROJECT_PATH }}" + --no-restore + --no-build + --verbosity normal + --logger trx + --results-directory "TestResults-${{ matrix.os }}" || true + + - name: Upload test results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: TestResults-${{ matrix.os }} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index d40f5180..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: 'Build' - -on: - workflow_dispatch: - inputs: - reason: - description: 'The reason for running the workflow' - required: false - default: 'Manual run' - workflow_call: - push: - paths: - - '**.cs' - - '**.csproj' - pull_request: - branches: [ master ] - paths: - - '**.cs' - - '**.csproj' - -env: - # Disable the .NET logo in the console output. - DOTNET_NOLOGO: true - - # Stop wasting time caching packages - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - - # Disable sending usage data to Microsoft - DOTNET_CLI_TELEMETRY_OPTOUT: true - - DOTNET_ADD_GLOBAL_TOOLS_TO_PATH: false - - DOTNET_MULTILEVEL_LOOKUP: 0 - - PROJECT_PATH: . - - CONFIGURATION: Release - - # Set the build number in MinVer. - MINVERBUILDMETADATA: build.${{github.run_number}} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build ${{ matrix.os }} - dotnet ${{ matrix.dotnet }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest, windows-latest, macos-latest ] - - steps: - - name: Print manual run reason - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - echo 'Reason: ${{ github.event.inputs.reason }}' - - - name: Checkout - uses: actions/checkout@v4 - with: - lfs: true - fetch-depth: 0 - - - name: Setup .NET (global.json) - uses: actions/setup-dotnet@v4 - - - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - # Look to see if there is a cache hit for the corresponding requirements file - key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} - restore-keys: | - ${{ runner.os }}-nuget - - - name: Restore - run: dotnet restore "${{ env.PROJECT_PATH }}" - - - name: Build - run: >- - dotnet build "${{ env.PROJECT_PATH }}" - --configuration "${{ env.CONFIGURATION }}" - --no-restore - -p:ContinuousIntegrationBuild=true \ No newline at end of file diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml deleted file mode 100644 index eb967b1d..00000000 --- a/.github/workflows/pack.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: 'Pack' - -on: - workflow_run: - workflows: [ 'Build and Test' ] - types: [ requested ] - branches: [ master ] - - workflow_call: - - workflow_dispatch: - inputs: - reason: - description: 'The reason for running the workflow' - required: false - default: 'Manual pack' - version: - description: 'Version' - required: true - -env: - CONFIGURATION: 'Release' - - PROJECT_PATH: "." - - PROJECT_NAME: redmine-net-api - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - pack: - name: Pack - if: github.ref == 'refs/heads/master' - runs-on: ubuntu-latest - steps: - - name: Determine Version - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV - else - echo "VERSION=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV - fi - echo "$GITHUB_ENV" - - - name: Print Version - run: | - echo "$VERSION" - - - name: Validate Version matches SemVer format - run: | - if [[ ! "$VERSION" =~ ^([0-9]+\.){2}[0-9]+(-[\w.]+)?$ ]]; then - echo "The version does not match the SemVer format (X.Y.Z). Please provide a valid version." - exit 1 - fi - - - name: Checkout - uses: actions/checkout@v4 - with: - lfs: true - fetch-depth: 0 - - - name: Setup .NET Core (global.json) - uses: actions/setup-dotnet@v4 - - - name: Install dependencies - run: dotnet restore "${{ env.PROJECT_PATH }}" - - - name: Pack - run: >- - dotnet pack ./src/redmine-net-api/redmine-net-api.csproj - --output ./artifacts - --configuration "${{ env.CONFIGURATION }}" - -p:Version=$VERSION - -p:PackageVersion=${{ env.VERSION }} - -p:SymbolPackageFormat=snupkg - - - name: Pack Signed - run: >- - dotnet pack ./src/redmine-net-api/redmine-net-api.csproj - --output ./artifacts - --configuration "${{ env.CONFIGURATION }}" - --include-symbols - --include-source - -p:Version=$VERSION - -p:PackageVersion=${{ env.VERSION }} - -p:SymbolPackageFormat=snupkg - -p:Sign=true - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: artifacts - path: ./artifacts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8f8c8a8f..91fdaed1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,52 +1,178 @@ name: 'Publish to NuGet' -on: +on: workflow_dispatch: inputs: reason: description: 'The reason for running the workflow' required: false - default: 'Manual publish to nuget' - - workflow_run: - workflows: [ 'Pack' ] - types: - - completed + default: 'Manual publish' + version: + description: 'Version' + required: true push: tags: - - '[0-9]+.[0-9]+.[0-9]+(-[\w.]+)?' - + - '[0-9]+.[0-9]+.[0-9]+*' + +env: + # Disable the .NET logo in the console output. + DOTNET_NOLOGO: true + + # Disable sending usage data to Microsoft + DOTNET_CLI_TELEMETRY_OPTOUT: true + + # Set working directory + PROJECT_PATH: ./src/redmine-net-api/redmine-net-api.csproj + + # Configuration + CONFIGURATION: Release + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - -jobs: + +jobs: + check-tag-branch: + name: Check Tag and Master Branch hashes + # This job is based on replies in https://github.community/t/how-to-create-filter-on-both-tag-and-branch/16936/6 + runs-on: ubuntu-latest + outputs: + ver: ${{ steps.set-version.outputs.VERSION }} + steps: + - name: Get tag commit hash + id: tag-commit-hash + run: | + hash=${{ github.sha }} + echo "{name}=tag-hash::${hash}" >> $GITHUB_OUTPUT + echo $hash + + - name: Checkout master + uses: actions/checkout@v4 + with: + ref: master + + - name: Get latest master commit hash + id: master-commit-hash + run: | + hash=$(git log -n1 --format=format:"%H") + echo "{name}=master-hash::${hash}" >> $GITHUB_OUTPUT + echo $hash + + - name: Verify tag commit matches master commit - exit if they don't match + if: steps.tag-commit-hash.outputs.tag-hash != steps.master-commit-hash.outputs.master-hash + run: | + echo "Tag was not on the master branch. Exiting." + exit 1 + + - name: Get Dispatched Version + if: github.event_name == 'workflow_dispatch' + run: | + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + + - name: Get Tag Version + if: github.event_name == 'push' + run: | + echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Set Version + id: set-version + run: | + echo "VERSION=${{ env.VERSION }}" >> "$GITHUB_OUTPUT" + + validate-version: + name: Validate Version + needs: check-tag-branch + runs-on: ubuntu-latest + steps: + - name: Get Version + run: echo "VERSION=${{ needs.check-tag-branch.outputs.ver }}" >> $GITHUB_ENV + + - name: Display Version + run: echo "$VERSION" + + - name: Check Version Is Declared + run: | + if [[ -z "$VERSION" ]]; then + echo "Version is not declared." + exit 1 + fi + + - name: Validate Version matches SemVer format + run: | + if [[ ! "$VERSION" =~ ^([0-9]+\.){2,3}[0-9]+(-[a-zA-Z0-9.-]+)*$ ]]; then + echo "The version does not match the SemVer format (X.Y.Z). Please provide a valid version." + exit 1 + fi + + call-build-and-test: + name: Call Build and Test + needs: validate-version + uses: ./.github/workflows/build-and-test.yml + + pack: + name: Pack + needs: [check-tag-branch, validate-version, call-build-and-test] + runs-on: ubuntu-latest + steps: + - name: Get Version + run: echo "VERSION=${{ needs.check-tag-branch.outputs.ver }}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + + - name: Setup .NET Core (global.json) + uses: actions/setup-dotnet@v4 + + - name: Display dotnet version + run: dotnet --version + + - name: Install dependencies + run: dotnet restore "${{ env.PROJECT_PATH }}" + + - name: Create the package + run: >- + dotnet pack "${{ env.PROJECT_PATH }}" + --output ./artifacts + --configuration "${{ env.CONFIGURATION }}" + -p:Version=$VERSION + -p:PackageVersion=$VERSION + -p:SymbolPackageFormat=snupkg + + - name: Create the package - Signed + run: >- + dotnet pack "${{ env.PROJECT_PATH }}" + --output ./artifacts + --configuration "${{ env.CONFIGURATION }}" + --include-symbols + --include-source + -p:Version=$VERSION + -p:PackageVersion=$VERSION + -p:SymbolPackageFormat=snupkg + -p:Sign=true + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: artifacts + path: ./artifacts + publish: name: Publish to Nuget - if: github.ref == 'refs/heads/master' + needs: pack runs-on: ubuntu-latest steps: - - name: Print manual run reason - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - echo 'Reason: ${{ github.event.inputs.reason }}' - - name: Download artifacts uses: actions/download-artifact@v4 with: name: artifacts - path: ./artifacts - + path: ./artifacts + - name: Publish packages run: >- dotnet nuget push ./artifacts/**.nupkg --source 'https://api.nuget.org/v3/index.json' --api-key ${{secrets.NUGET_TOKEN}} - --skip-duplicate - - - name: Upload artifacts to the GitHub release - uses: Roang-zero1/github-upload-release-artifacts-action@v3.0.0 - with: - args: ./artifacts - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + --skip-duplicate \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml index fcb1ad07..8fc91681 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,6 @@ version: '{build}' image: - - Visual Studio 2019 + - Visual Studio 2022 - Ubuntu environment: diff --git a/global.json b/global.json index 88e2f39a..ab747bba 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.101", + "version": "8.0.303", "allowPrerelease": false, "rollForward": "latestMajor" } diff --git a/logo.png b/logo.png old mode 100755 new mode 100644 index 0e88a5f4..83433adf Binary files a/logo.png and b/logo.png differ diff --git a/redmine-net-api.sln b/redmine-net-api.sln index 2715270a..7cbed138 100644 --- a/redmine-net-api.sln +++ b/redmine-net-api.sln @@ -23,8 +23,6 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitActions", "GitActions", "{79119F8B-C468-4DC8-BE6F-6E7102BD2079}" ProjectSection(SolutionItems) = preProject .github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml - .github\workflows\pack.yml = .github\workflows\pack.yml - .github\workflows\build.yml = .github\workflows\build.yml .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml .github\workflows\publish.yml = .github\workflows\publish.yml EndProjectSection diff --git a/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs b/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs new file mode 100644 index 00000000..a47dbb9b --- /dev/null +++ b/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs @@ -0,0 +1,35 @@ +/* +Copyright 2011 - 2023 Adrian Popescu + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#if !(NET20) +using System.Threading; +using System.Threading.Tasks; + +namespace Redmine.Net.Api.Extensions; +#if !(NET45_OR_GREATER || NETCOREAPP) +internal static class SemaphoreSlimExtensions +{ + + public static Task WaitAsync(this SemaphoreSlim semaphore, CancellationToken cancellationToken = default) + { + return Task.Factory.StartNew(() => semaphore.Wait(cancellationToken) + , CancellationToken.None + , TaskCreationOptions.None + , TaskScheduler.Default); + } +} +#endif +#endif diff --git a/src/redmine-net-api/Extensions/TaskExtensions.cs b/src/redmine-net-api/Extensions/TaskExtensions.cs new file mode 100644 index 00000000..c74b6d4d --- /dev/null +++ b/src/redmine-net-api/Extensions/TaskExtensions.cs @@ -0,0 +1,60 @@ +/* +Copyright 2011 - 2023 Adrian Popescu + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#if !(NET20) +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Redmine.Net.Api.Extensions; + +internal static class TaskExtensions +{ + public static T GetAwaiterResult(this Task task) + { + return task.GetAwaiter().GetResult(); + } + + public static TResult Synchronize(Func> function) + { + return Task.Factory.StartNew(function, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default) + .Unwrap().GetAwaiter().GetResult(); + } + + public static void Synchronize(Func function) + { + Task.Factory.StartNew(function, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default) + .Unwrap().GetAwaiter().GetResult(); + } + + #if !(NET45_OR_GREATER || NETCOREAPP) + public static Task WhenAll(IEnumerable> tasks) + { + var clone = tasks.ToArray(); + + var x = Task.Factory.StartNew(() => + { + Task.WaitAll(clone); + return clone.Select(t => t.Result).ToArray(); + }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default); + + return default; + } + #endif +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/RedmineManagerAsync.cs b/src/redmine-net-api/RedmineManagerAsync.cs index 50192c5a..20485248 100644 --- a/src/redmine-net-api/RedmineManagerAsync.cs +++ b/src/redmine-net-api/RedmineManagerAsync.cs @@ -15,31 +15,33 @@ limitations under the License. */ #if !(NET20) +using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.Globalization; -using System.Net; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Net; using Redmine.Net.Api.Serialization; using Redmine.Net.Api.Types; +using TaskExtensions = Redmine.Net.Api.Extensions.TaskExtensions; namespace Redmine.Net.Api; public partial class RedmineManager: IRedmineManagerAsync { + private const string CRLR = "\r\n"; + /// - public async Task CountAsync(RequestOptions requestOptions, CancellationToken cancellationToken = default) where T : class, new() + public async Task CountAsync(RequestOptions requestOptions, CancellationToken cancellationToken = default) + where T : class, new() { - const int PAGE_SIZE = 1; - const int OFFSET = 0; var totalCount = 0; requestOptions ??= new RequestOptions(); - requestOptions.QueryString.AddPagingParameters(PAGE_SIZE, OFFSET); + requestOptions.QueryString.AddPagingParameters(pageSize: 1, offset: 0); var tempResult = await GetPagedAsync(requestOptions, cancellationToken).ConfigureAwait(false); if (tempResult != null) @@ -60,7 +62,8 @@ public async Task> GetPagedAsync(RequestOptions requestOption return response.DeserializeToPagedResults(Serializer); } - + + /// public async Task> GetAsync(RequestOptions requestOptions = null, CancellationToken cancellationToken = default) where T : class, new() @@ -86,52 +89,83 @@ public async Task> GetAsync(RequestOptions requestOptions = null, Can pageSize = PageSize > 0 ? PageSize : RedmineConstants.DEFAULT_PAGE_SIZE_VALUE; - requestOptions.QueryString.Set(RedmineKeys.LIMIT, pageSize.ToString(CultureInfo.InvariantCulture)); + requestOptions.QueryString.Set(RedmineKeys.LIMIT, pageSize.ToInvariantString()); } - - try + + var hasOffset = RedmineManager.TypesWithOffset.ContainsKey(typeof(T)); + if (hasOffset) { - var hasOffset = RedmineManager.TypesWithOffset.ContainsKey(typeof(T)); - if (hasOffset) + requestOptions.QueryString.Set(RedmineKeys.OFFSET, offset.ToInvariantString()); + + var tempResult = await GetPagedAsync(requestOptions, cancellationToken).ConfigureAwait(false); + + var totalCount = isLimitSet ? pageSize : tempResult.TotalItems; + + if (tempResult?.Items != null) { - int totalCount; - do - { - requestOptions.QueryString.Set(RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture)); + resultList = new List(tempResult.Items); + } + + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); - var tempResult = await GetPagedAsync(requestOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + var remainingPages = totalPages - offset / pageSize; - totalCount = isLimitSet ? pageSize : tempResult.TotalItems; + if (remainingPages <= 0) + { + return resultList; + } + + using (var semaphore = new SemaphoreSlim(MAX_CONCURRENT_TASKS)) + { + var pageFetchTasks = new List>>(); - if (tempResult?.Items != null) + for (int page = 0; page < remainingPages; page++) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + var innerOffset = (page * pageSize) + offset; + + pageFetchTasks.Add(GetPagedInternalAsync(semaphore, new RequestOptions() { - if (resultList == null) + QueryString = new NameValueCollection() { - resultList = new List(tempResult.Items); + {RedmineKeys.OFFSET, innerOffset.ToInvariantString()}, + {RedmineKeys.LIMIT, pageSize.ToInvariantString()} } - else - { - resultList.AddRange(tempResult.Items); - } - } + }, cancellationToken)); + } - offset += pageSize; - } while (offset < totalCount); - } - else - { - var result = await GetPagedAsync(requestOptions, cancellationToken: cancellationToken).ConfigureAwait(false); - if (result?.Items != null) + var pageResults = await + #if(NET45_OR_GREATER || NETCOREAPP) + Task.WhenAll(pageFetchTasks) + #else + TaskExtensions.WhenAll(pageFetchTasks) + #endif + .ConfigureAwait(false); + + foreach (var pageResult in pageResults) { - return new List(result.Items); + if (pageResult?.Items == null) + { + continue; + } + + resultList ??= new List(); + + resultList.AddRange(pageResult.Items); } } } - catch (WebException wex) + else { - wex.HandleWebException(Serializer); + var result = await GetPagedAsync(requestOptions, cancellationToken: cancellationToken) + .ConfigureAwait(false); + if (result?.Items != null) + { + return new List(result.Items); + } } - + return resultList; } @@ -145,8 +179,7 @@ public async Task GetAsync(string id, RequestOptions requestOptions = null return response.DeserializeTo(Serializer); } - - + /// public async Task CreateAsync(T entity, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) where T : class, new() @@ -174,8 +207,12 @@ public async Task UpdateAsync(string id, T entity, RequestOptions requestOpti var url = RedmineApiUrls.UpdateFragment(id); var payload = Serializer.Serialize(entity); - - // payload = Regex.Replace(payload, @"\r\n|\r|\n", "\r\n"); + + #if NET7_0_OR_GREATER + payload = ReplaceEndingsRegex().Replace(payload, CRLR); + #else + payload = Regex.Replace(payload, "\r\n|\r|\n",CRLR); + #endif await ApiClient.UpdateAsync(url, payload, requestOptions, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -205,5 +242,29 @@ public async Task DownloadFileAsync(string address, RequestOptions reque var response = await ApiClient.DownloadAsync(address, requestOptions,cancellationToken: cancellationToken).ConfigureAwait(false); return response.Content; } + + #if NET7_0_OR_GREATER + [GeneratedRegex(@"\r\n|\r|\n")] + private static partial Regex ReplaceEndingsRegex(); + #endif + + private const int MAX_CONCURRENT_TASKS = 3; + + private async Task> GetPagedInternalAsync(SemaphoreSlim semaphore, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + where T : class, new() + { + try + { + var url = RedmineApiUrls.GetListFragment(); + + var response = await ApiClient.GetAsync(url, requestOptions, cancellationToken).ConfigureAwait(false); + + return response.DeserializeToPagedResults(Serializer); + } + finally + { + semaphore.Release(); + } + } } #endif \ No newline at end of file diff --git a/src/redmine-net-api/Types/CustomFieldValue.cs b/src/redmine-net-api/Types/CustomFieldValue.cs index 0a0f1033..a345acdf 100644 --- a/src/redmine-net-api/Types/CustomFieldValue.cs +++ b/src/redmine-net-api/Types/CustomFieldValue.cs @@ -35,9 +35,7 @@ public class CustomFieldValue : IXmlSerializable, IJsonSerializable, IEquatable< /// /// /// - public CustomFieldValue() - { - } + public CustomFieldValue() { } /// /// @@ -47,7 +45,7 @@ public CustomFieldValue(string value) { Info = value; } - + #region Properties /// @@ -144,7 +142,7 @@ public void WriteJson(JsonWriter writer) public bool Equals(CustomFieldValue other) { if (other == null) return false; - return string.Equals(Info,other.Info,StringComparison.OrdinalIgnoreCase); + return string.Equals(Info, other.Info, StringComparison.OrdinalIgnoreCase); } /// @@ -195,6 +193,5 @@ public object Clone() /// /// private string DebuggerDisplay => $"[{nameof(CustomFieldValue)}: {Info}]"; - } } \ No newline at end of file diff --git a/src/redmine-net-api/Types/IssueCustomField.cs b/src/redmine-net-api/Types/IssueCustomField.cs index d278d5fc..65ad1049 100644 --- a/src/redmine-net-api/Types/IssueCustomField.cs +++ b/src/redmine-net-api/Types/IssueCustomField.cs @@ -112,7 +112,9 @@ public override void WriteXml(XmlWriter writer) writer.WriteAttributeString(RedmineKeys.ID, Id.ToString(CultureInfo.InvariantCulture)); - if (itemsCount > 1) + Multiple = itemsCount > 1; + + if (Multiple) { writer.WriteArrayStringElement(RedmineKeys.VALUE, Values, GetValue); } @@ -120,6 +122,8 @@ public override void WriteXml(XmlWriter writer) { writer.WriteElementString(RedmineKeys.VALUE, itemsCount > 0 ? Values[0].Info : null); } + + writer.WriteBoolean(RedmineKeys.MULTIPLE, Multiple); } #endregion @@ -136,12 +140,14 @@ public override void WriteJson(JsonWriter writer) } var itemsCount = Values.Count; + Multiple = itemsCount > 1; writer.WriteStartObject(); writer.WriteProperty(RedmineKeys.ID, Id); writer.WriteProperty(RedmineKeys.NAME, Name); - - if (itemsCount > 1) + writer.WriteBoolean(RedmineKeys.MULTIPLE, Multiple); + + if (Multiple) { writer.WritePropertyName(RedmineKeys.VALUE); writer.WriteStartArray(); @@ -150,8 +156,6 @@ public override void WriteJson(JsonWriter writer) writer.WriteValue(cfv.Info); } writer.WriteEndArray(); - - writer.WriteBoolean(RedmineKeys.MULTIPLE, Multiple); } else { diff --git a/src/redmine-net-api/redmine-net-api.csproj b/src/redmine-net-api/redmine-net-api.csproj index df2e9234..3d19d477 100644 --- a/src/redmine-net-api/redmine-net-api.csproj +++ b/src/redmine-net-api/redmine-net-api.csproj @@ -64,6 +64,12 @@ git https://github.com/zapadi/redmine-net-api Redmine .NET API Client + + true + + true + snupkg + true