diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 071bdd8..7bc0166 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,7 +45,7 @@ The GitHub Actions workflow `Build, Register and Deploy DMAPP to DMA on PR Merge - `Frequency` (`id=10`, string): `instant`, `daily`, `weekly`, `monthly`. - `ClientSecret` (`id=11`, string): Dataverse client secret. - The notify script does **not** enforce an internal cadence gate; every run processes subscriptions matching the passed `Frequency`. -- Activity detection includes both new and updated activities: a record is included if `createdon > since` OR `modifiedon > since` per subscription. +- New activity detection uses `createdon > LastSentAt` per subscription. - Sender/from behavior is controlled by DataMiner mail/SMTP configuration. --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..36167a7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,6 @@ +## Target branch + +- [ ] This PR creates, updates, or deletes Dataverse data and targets `release-candidate`. +- [ ] This PR is read-only and targets `main`. + +Use the **base** selector on the pull request page before opening the PR. Promote the complete release candidate with a separate `release-candidate` to `main` pull request. diff --git a/.github/workflows/deploy-dma-on-pr-merge.yml b/.github/workflows/deploy-dma-on-pr-merge.yml index 70be2e2..6ff0e85 100644 --- a/.github/workflows/deploy-dma-on-pr-merge.yml +++ b/.github/workflows/deploy-dma-on-pr-merge.yml @@ -9,15 +9,18 @@ # DATAMINER_TOKEN, VITE_CLIENT_ID, VITE_TENANT_ID, VITE_DATAVERSE_URL # Required vars: # VITE_REDIRECT_URI -# Optional vars for manual dev deploy target: -# VITE_REDIRECT_URI_DEV +# Optional vars: +# VITE_REDIRECT_URI_DEV — Manual development deploy target +# VITE_REDIRECT_URI_RC — Release candidate deploy target -name: Build, Register and Deploy DMAPP to DMA on PR Merge +name: Deploy DynamicsActivities DMAPP on: pull_request: types: [closed] - branches: [main] + branches: + - main + - release-candidate workflow_dispatch: inputs: deployment_target: @@ -29,9 +32,16 @@ on: - production - dev +permissions: + contents: read + +concurrency: + group: deploy-dynamicsactivities-${{ github.ref }} + cancel-in-progress: false + jobs: deploy_production: - if: (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch' && github.event.inputs.deployment_target == 'production' && github.ref == 'refs/heads/main') + if: (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.deployment_target == 'production' && github.ref == 'refs/heads/main') uses: ./.github/workflows/deploy-dmapp-reusable.yml with: catalog_id: "b6dc92d4-401e-42fd-b7bd-87d1ff562420" @@ -41,6 +51,17 @@ jobs: webapp_public_folder: "DynamicsActivities" secrets: inherit + deploy_release_candidate: + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'release-candidate' + uses: ./.github/workflows/deploy-dmapp-reusable.yml + with: + catalog_id: "b6dc92d4-401e-42fd-b7bd-87d1ff562420" + agent_destination_id: "e21dbc3e-7592-48f8-867b-d81d4ff06bee" + vite_redirect_uri: ${{ vars.VITE_REDIRECT_URI_RC }} + vite_app_base_path: "/public/DynamicsActivitiesRC/" + webapp_public_folder: "DynamicsActivitiesRC" + secrets: inherit + deploy_dev: if: github.event_name == 'workflow_dispatch' && github.event.inputs.deployment_target == 'dev' uses: ./.github/workflows/deploy-dmapp-reusable.yml diff --git a/.github/workflows/promote-main-to-release-candidate.yml b/.github/workflows/promote-main-to-release-candidate.yml new file mode 100644 index 0000000..7d5b39e --- /dev/null +++ b/.github/workflows/promote-main-to-release-candidate.yml @@ -0,0 +1,109 @@ +name: Promote main to release candidate + +on: + push: + branches: [main] + pull_request_target: + types: [synchronize] + branches: [release-candidate] + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: sync-main-to-release-candidate + cancel-in-progress: false + +jobs: + sync: + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request_target' && + github.event.pull_request.head.ref == 'automation/main-to-release-candidate' && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + steps: + - name: Create or reuse promotion pull request + id: promotion + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + promotion_branch="automation/main-to-release-candidate" + main_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + if ! gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f "ref=refs/heads/${promotion_branch}" \ + -f "sha=${main_sha}"; then + if ! gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${promotion_branch}" \ + -f "sha=${main_sha}" \ + -F force=false; then + echo "Promotion branch has diverged from main; leaving it unchanged for conflict resolution." + fi + fi + + pr_number="$(gh pr list \ + --base release-candidate \ + --head "$promotion_branch" \ + --state open \ + --json number \ + --jq '.[0].number // empty')" + + if [[ -z "$pr_number" ]]; then + pr_number="$(gh pr create \ + --base release-candidate \ + --head "$promotion_branch" \ + --title "Sync main into release-candidate" \ + --body $'Automated promotion of the latest main branch changes into release-candidate.\n\nThis pull request is maintained by the Promote main to release candidate workflow. Its dedicated branch allows Copilot to resolve conflicts without modifying the default branch.')" + fi + + echo "number=$pr_number" >> "$GITHUB_OUTPUT" + + - name: Try to enable auto-merge + id: merge + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.promotion.outputs.number }} + run: | + merge_state="$(gh pr view "$PR_NUMBER" --json mergeStateStatus --jq '.mergeStateStatus')" + + if [[ "$merge_state" == "CONFLICTING" ]]; then + echo "conflicting=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! gh pr merge "$PR_NUMBER" --auto --merge 2>/dev/null; then + # Auto-merge requires branch protection rules; fall back to direct merge + gh pr merge "$PR_NUMBER" --merge + fi + + - name: Ask Copilot to resolve merge conflicts + if: steps.merge.outputs.conflicting == 'true' || failure() + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.promotion.outputs.number }} + run: | + merge_state="$(gh pr view "$PR_NUMBER" --json mergeStateStatus --jq '.mergeStateStatus')" + if [[ "$merge_state" != "CONFLICTING" ]]; then + exit 1 + fi + + marker="" + if ! gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --paginate \ + --jq '.[].body' | grep -Fq "$marker"; then + gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF' + + @copilot Please resolve the merge conflicts in this pull request. The head branch contains the latest `main` changes and the base is `release-candidate`. + + Preserve the intended changes from both branches, run the repository's relevant checks, and push the conflict resolution to this pull request's dedicated head branch. Do not merge the pull request yourself; leave it ready for this workflow to auto-merge once it is clean. + EOF + )" + fi + + exit 0 diff --git a/.github/workflows/sync-main-to-release-candidate.yml b/.github/workflows/sync-main-to-release-candidate.yml deleted file mode 100644 index 0d5be09..0000000 --- a/.github/workflows/sync-main-to-release-candidate.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Sync main to release candidate - -on: - push: - branches: [main] - pull_request: - types: [opened, reopened, synchronize] - branches: [release-candidate] - workflow_dispatch: {} - -permissions: - contents: write - issues: write - pull-requests: write - -concurrency: - group: sync-main-to-release-candidate - cancel-in-progress: false - -jobs: - sync: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.ref == 'main' }} - runs-on: ubuntu-latest - steps: - - name: Create or reuse promotion pull request - id: promotion - env: - GH_TOKEN: ${{ github.token }} - run: | - pr_number="$(gh pr list \ - --base release-candidate \ - --head main \ - --state open \ - --json number \ - --jq '.[0].number // empty')" - - if [[ -z "$pr_number" ]]; then - pr_number="$(gh pr create \ - --base release-candidate \ - --head main \ - --title "Sync main into release-candidate" \ - --body $'Automated promotion of the latest main branch changes into release-candidate.\n\nThis pull request is maintained by the Sync main to release candidate workflow.')" - fi - - echo "number=$pr_number" >> "$GITHUB_OUTPUT" - - - name: Try to enable auto-merge - id: merge - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ steps.promotion.outputs.number }} - run: | - merge_state="$(gh pr view "$PR_NUMBER" --json mergeStateStatus --jq '.mergeStateStatus')" - - if [[ "$merge_state" == "CONFLICTING" ]]; then - echo "conflicting=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - gh pr merge "$PR_NUMBER" --auto --merge - - - name: Ask Copilot to resolve merge conflicts - if: steps.merge.outputs.conflicting == 'true' || failure() - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ steps.promotion.outputs.number }} - run: | - merge_state="$(gh pr view "$PR_NUMBER" --json mergeStateStatus --jq '.mergeStateStatus')" - if [[ "$merge_state" != "CONFLICTING" ]]; then - exit 1 - fi - - marker="" - if ! gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --paginate \ - --jq '.[].body' | grep -Fq "$marker"; then - gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF' - -@copilot Please resolve the merge conflicts in this pull request between `main` and `release-candidate`. - -Preserve the intended changes from both branches, run the repository's relevant checks, and push the conflict resolution to this pull request's head branch. Do not merge the pull request yourself; leave it ready for this workflow to auto-merge once it is clean. -EOF - )" - fi - - exit 0 diff --git a/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj b/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj index a5bff37..8a50cf2 100644 --- a/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj +++ b/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj @@ -8,8 +8,8 @@ True 10.3.0.0 - 12752 - 1.8.86 - Include new and updated activities in email digest (createdon or modifiedon since last sent) + 1.8.91 + Use a dedicated branch for promotion conflict resolution skyline:sdk:dataminertoken skyline:sdk:dataminertoken diff --git a/DynamicsActivitiesPackage/DynamicsActivities_NotifySubscribers/DynamicsActivities_NotifySubscribers.cs b/DynamicsActivitiesPackage/DynamicsActivities_NotifySubscribers/DynamicsActivities_NotifySubscribers.cs index 4e78275..38b9b0e 100644 --- a/DynamicsActivitiesPackage/DynamicsActivities_NotifySubscribers/DynamicsActivities_NotifySubscribers.cs +++ b/DynamicsActivitiesPackage/DynamicsActivities_NotifySubscribers/DynamicsActivities_NotifySubscribers.cs @@ -132,7 +132,7 @@ private void RunSafe(IEngine engine) var activities = FetchActivities(scopeType, scopeValue, activityTypesJson, since, now); if (activities.Count == 0) { - engine.GenerateInformation($"[NotifySubscribers] No new or updated activities for sub {instance.ID.Id} ({scopeType}:{scopeValue}) since {since:o}. LastSentAt={lastSentAt:o}, CreatedAt={(createdAt.HasValue ? createdAt.Value.ToString("o") : "n/a")}."); + engine.GenerateInformation($"[NotifySubscribers] No new activities for sub {instance.ID.Id} ({scopeType}:{scopeValue}) since {since:o}. LastSentAt={lastSentAt:o}, CreatedAt={(createdAt.HasValue ? createdAt.Value.ToString("o") : "n/a")}."); continue; } @@ -191,7 +191,7 @@ private List FetchActivities(string scopeType, string scopeValue, var requestedTypes = ParseActivityTypes(activityTypesJson); var includeAllTypes = requestedTypes == null || requestedTypes.Count == 0; var activities = new List(); - var fromIso = BuildActivityChangeLowerBound(since); + var fromIso = BuildCreatedOnLowerBound(since); var escalationScope = String.Equals((scopeType ?? String.Empty).Trim(), "escalation", StringComparison.OrdinalIgnoreCase); if (escalationScope) @@ -212,8 +212,8 @@ private List FetchActivities(string scopeType, string scopeValue, return activities .Where(a => !string.IsNullOrEmpty(a.Id)) .GroupBy(a => a.Id) - .Select(g => g.OrderByDescending(GetActivityTimestamp).First()) - .OrderByDescending(GetActivityTimestamp) + .Select(g => g.OrderByDescending(i => i.CreatedOn).First()) + .OrderByDescending(i => i.CreatedOn) .ToList(); } @@ -248,8 +248,8 @@ private List FetchEscalationScopedActivities(string fromIso, bool return activities .Where(a => !string.IsNullOrEmpty(a.Id)) .GroupBy(a => a.Id) - .Select(g => g.OrderByDescending(GetActivityTimestamp).First()) - .OrderByDescending(GetActivityTimestamp) + .Select(g => g.OrderByDescending(i => i.CreatedOn).First()) + .OrderByDescending(i => i.CreatedOn) .ToList(); } @@ -386,7 +386,7 @@ private static string BuildOrFilter(string fieldName, List ids) return "(" + string.Join(" or ", valid.Select(id => $"{fieldName} eq {id}")) + ")"; } - private static string BuildActivityChangeLowerBound(DateTime since) + private static string BuildCreatedOnLowerBound(DateTime since) { // Dataverse does not reliably handle DateTime.MinValue in OData filters. // First-run should not enforce a lower bound. @@ -395,20 +395,15 @@ private static string BuildActivityChangeLowerBound(DateTime since) return since.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); } - private static void AddNewOrUpdatedFilter(List filters, string fromIso) + private List FetchStandardActivities(string entitySet, string typeLabel, string lookupField, List accountIds, string fromIso) { + var filters = BuildLookupFilters(lookupField, accountIds); if (!string.IsNullOrEmpty(fromIso)) { - filters.Add($"(createdon gt {fromIso} or modifiedon gt {fromIso})"); + filters.Add($"createdon gt {fromIso}"); } - } - - private List FetchStandardActivities(string entitySet, string typeLabel, string lookupField, List accountIds, string fromIso) - { - var filters = BuildLookupFilters(lookupField, accountIds); - AddNewOrUpdatedFilter(filters, fromIso); var filter = filters.Count > 0 ? "&$filter=" + string.Join(" and ", filters) : string.Empty; - var json = DataverseGet($"/{entitySet}?$select=activityid,subject,description,createdon,modifiedon,_regardingobjectid_value{filter}&$orderby=modifiedon desc&$top=100"); + var json = DataverseGet($"/{entitySet}?$select=activityid,subject,description,createdon,_regardingobjectid_value{filter}&$orderby=createdon desc&$top=100"); return json["value"]?.Select(v => new ActivityItem { @@ -418,7 +413,6 @@ private List FetchStandardActivities(string entitySet, string type Subject = v["subject"]?.Value(), Description = v["description"]?.Value(), CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, RegardingId = v["_regardingobjectid_value"]?.Value(), Regarding = v["_regardingobjectid_value@OData.Community.Display.V1.FormattedValue"]?.Value(), }).ToList() ?? new List(); @@ -427,10 +421,13 @@ private List FetchStandardActivities(string entitySet, string type private List FetchStandardActivitiesLinkedToEscalations(string entitySet, string typeLabel, List escalationIds, string fromIso, Dictionary escalationAccountById) { var filters = new List(); - AddNewOrUpdatedFilter(filters, fromIso); + if (!string.IsNullOrEmpty(fromIso)) + { + filters.Add($"createdon gt {fromIso}"); + } var filter = filters.Count > 0 ? "&$filter=" + string.Join(" and ", filters) : string.Empty; - var values = DataverseGetAllValues($"/{entitySet}?$select=activityid,subject,description,createdon,modifiedon,_regardingobjectid_value{filter}&$orderby=modifiedon desc", 20); + var values = DataverseGetAllValues($"/{entitySet}?$select=activityid,subject,description,createdon,_regardingobjectid_value{filter}&$orderby=createdon desc", 20); var result = new List(); foreach (var v in values) @@ -454,7 +451,6 @@ private List FetchStandardActivitiesLinkedToEscalations(string ent Subject = v["subject"]?.Value(), Description = description, CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, RegardingId = regardingId, Regarding = v["_regardingobjectid_value@OData.Community.Display.V1.FormattedValue"]?.Value(), }; @@ -475,9 +471,12 @@ private List FetchStandardActivitiesLinkedToEscalations(string ent private List FetchEscalations(List accountIds, string fromIso) { var filters = BuildLookupFilters(EscalationAccountLookupField, accountIds); - AddNewOrUpdatedFilter(filters, fromIso); + if (!string.IsNullOrEmpty(fromIso)) + { + filters.Add($"createdon gt {fromIso}"); + } var filter = filters.Count > 0 ? "&$filter=" + string.Join(" and ", filters) : string.Empty; - var json = DataverseGet($"/slc_escalations?$select=slc_escalationid,slc_name,createdon,modifiedon,{EscalationAccountLookupField},slc_startdate,statecode,statuscode{filter}&$orderby=modifiedon desc&$top=100"); + var json = DataverseGet($"/slc_escalations?$select=slc_escalationid,slc_name,createdon,{EscalationAccountLookupField},slc_startdate,statecode,statuscode{filter}&$orderby=createdon desc&$top=100"); return json["value"]?.Select(v => new ActivityItem { @@ -487,7 +486,6 @@ private List FetchEscalations(List accountIds, string from Subject = v["slc_name"]?.Value() ?? "Escalation", Description = String.Empty, CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, RegardingId = NormalizeDataverseId(v[EscalationAccountLookupField]?.Value()), Regarding = v[$"{EscalationAccountLookupField}@OData.Community.Display.V1.FormattedValue"]?.Value(), }).ToList() ?? new List(); @@ -497,8 +495,8 @@ private List FetchLeads(List accountIds, string fromIso) { if (accountIds.Count == 0) return new List(); var parentFilter = "(" + string.Join(" or ", accountIds.Select(id => $"_parentaccountid_value eq {id}")) + ")"; - var lowerBound = !string.IsNullOrEmpty(fromIso) ? $" and (createdon gt {fromIso} or modifiedon gt {fromIso})" : string.Empty; - var json = DataverseGet($"/leads?$select=leadid,subject,description,createdon,modifiedon,_parentaccountid_value&$filter={parentFilter}{lowerBound}&$orderby=modifiedon desc&$top=100"); + var lowerBound = !string.IsNullOrEmpty(fromIso) ? $" and createdon gt {fromIso}" : string.Empty; + var json = DataverseGet($"/leads?$select=leadid,subject,description,createdon,_parentaccountid_value&$filter={parentFilter}{lowerBound}&$orderby=createdon desc&$top=100"); return json["value"]?.Select(v => new ActivityItem { @@ -508,7 +506,6 @@ private List FetchLeads(List accountIds, string fromIso) Subject = v["subject"]?.Value(), Description = v["description"]?.Value(), CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, Regarding = v["_parentaccountid_value@OData.Community.Display.V1.FormattedValue"]?.Value(), }).ToList() ?? new List(); } @@ -517,8 +514,8 @@ private List FetchOpportunities(List accountIds, string fr { if (accountIds.Count == 0) return new List(); var parentFilter = "(" + string.Join(" or ", accountIds.Select(id => $"_parentaccountid_value eq {id}")) + ")"; - var lowerBound = !string.IsNullOrEmpty(fromIso) ? $" and (createdon gt {fromIso} or modifiedon gt {fromIso})" : string.Empty; - var json = DataverseGet($"/opportunities?$select=opportunityid,name,description,createdon,modifiedon,_parentaccountid_value,slc_opportunitytype&$filter={parentFilter}{lowerBound}&$orderby=modifiedon desc&$top=100"); + var lowerBound = !string.IsNullOrEmpty(fromIso) ? $" and createdon gt {fromIso}" : string.Empty; + var json = DataverseGet($"/opportunities?$select=opportunityid,name,description,createdon,_parentaccountid_value,slc_opportunitytype&$filter={parentFilter}{lowerBound}&$orderby=createdon desc&$top=100"); var result = new List(); foreach (var v in json["value"] ?? new JArray()) { @@ -534,7 +531,6 @@ private List FetchOpportunities(List accountIds, string fr Subject = v["name"]?.Value(), Description = v["description"]?.Value(), CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, Regarding = v["_parentaccountid_value@OData.Community.Display.V1.FormattedValue"]?.Value(), }); } @@ -557,10 +553,13 @@ private List FetchAnnotations(ScopeContext scope, string fromIso) } var filters = new List(); - AddNewOrUpdatedFilter(filters, fromIso); + if (!string.IsNullOrEmpty(fromIso)) + { + filters.Add($"createdon gt {fromIso}"); + } var filter = filters.Count > 0 ? "&$filter=" + string.Join(" and ", filters) : string.Empty; - var values = DataverseGetAllValues($"/annotations?$select=annotationid,subject,notetext,createdon,modifiedon,_objectid_value{filter}&$orderby=modifiedon desc", 20); + var values = DataverseGetAllValues($"/annotations?$select=annotationid,subject,notetext,createdon,_objectid_value{filter}&$orderby=createdon desc", 20); var result = new List(); foreach (var v in values) { @@ -597,7 +596,6 @@ private List FetchAnnotations(ScopeContext scope, string fromIso) Subject = v["subject"]?.Value(), Description = v["notetext"]?.Value(), CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, RegardingId = regardingId, Regarding = v["_objectid_value@OData.Community.Display.V1.FormattedValue"]?.Value(), }); @@ -698,10 +696,13 @@ private static bool ValueMatchesScope(string value, string scopeValue) private List FetchAnnotationsLinkedToEscalations(List escalationIds, string fromIso, Dictionary escalationAccountById) { var filters = new List(); - AddNewOrUpdatedFilter(filters, fromIso); + if (!string.IsNullOrEmpty(fromIso)) + { + filters.Add($"createdon gt {fromIso}"); + } var filter = filters.Count > 0 ? "&$filter=" + string.Join(" and ", filters) : string.Empty; - var values = DataverseGetAllValues($"/annotations?$select=annotationid,subject,notetext,createdon,modifiedon,_objectid_value{filter}&$orderby=modifiedon desc", 20); + var values = DataverseGetAllValues($"/annotations?$select=annotationid,subject,notetext,createdon,_objectid_value{filter}&$orderby=createdon desc", 20); var result = new List(); foreach (var v in values) @@ -723,7 +724,6 @@ private List FetchAnnotationsLinkedToEscalations(List esca Subject = v["subject"]?.Value(), Description = v["notetext"]?.Value(), CreatedOn = ParseDateTime(v["createdon"]?.Value()) ?? DateTime.MinValue, - ModifiedOn = ParseDateTime(v["modifiedon"]?.Value()) ?? DateTime.MinValue, RegardingId = regardingId, Regarding = v["_objectid_value@OData.Community.Display.V1.FormattedValue"]?.Value(), }; @@ -864,8 +864,6 @@ private static string BuildSummarizePayload(string scopeLabel, DateTime since, D .Select(activity => new JObject { ["createdOnUtc"] = activity.CreatedOn == DateTime.MinValue ? null : activity.CreatedOn.ToString("o", CultureInfo.InvariantCulture), - ["modifiedOnUtc"] = activity.ModifiedOn == DateTime.MinValue ? null : activity.ModifiedOn.ToString("o", CultureInfo.InvariantCulture), - ["changedOnUtc"] = GetActivityTimestamp(activity) == DateTime.MinValue ? null : GetActivityTimestamp(activity).ToString("o", CultureInfo.InvariantCulture), ["type"] = GetTypeBadgeLabel(activity), ["subject"] = String.IsNullOrWhiteSpace(activity.Subject) ? "(No subject)" : activity.Subject.Trim(), ["regarding"] = String.IsNullOrWhiteSpace(activity.Regarding) ? "-" : activity.Regarding.Trim(), @@ -1090,7 +1088,7 @@ private static string BuildFallbackDigestSummary(string scopeLabel, List $"{GetActivityTimestamp(activity):yyyy-MM-dd HH:mm} UTC — {GetTypeBadgeLabel(activity)} — {activity.Subject ?? "(No subject)"}") + .Select(activity => $"{activity.CreatedOn:yyyy-MM-dd HH:mm} UTC — {GetTypeBadgeLabel(activity)} — {activity.Subject ?? "(No subject)"}") .ToList(); var summary = new StringBuilder(); @@ -1132,7 +1130,7 @@ private static string BuildEmailBody(string userName, string scopeType, string s sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine($"
Scope: {HtmlEncode(scopeLabel ?? scopeValue)}
"); - sb.AppendLine($"
New/updated activities: {activities.Count} since {since:yyyy-MM-dd HH:mm} UTC
"); + sb.AppendLine($"
New activities: {activities.Count} since {since:yyyy-MM-dd HH:mm} UTC
"); sb.AppendLine(activityTypes != null && activityTypes.Count > 0 ? $"
Activity types: {HtmlEncode(string.Join(", ", activityTypes))}
" : "
Activity types: All
"); @@ -1157,7 +1155,7 @@ private static string BuildEmailBody(string userName, string scopeType, string s sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine($"{HtmlEncode(typeLabel)}"); - sb.AppendLine($"{GetActivityTimeLabel(item)}"); + sb.AppendLine($"{item.CreatedOn:yyyy-MM-dd HH:mm} UTC"); sb.AppendLine("
"); sb.AppendLine($"
{HtmlEncode(item.Subject ?? "(No subject)")}
"); sb.AppendLine($"
Regarding: {HtmlEncode(item.Regarding)}
"); @@ -1176,7 +1174,7 @@ private static string BuildEmailBody(string userName, string scopeType, string s sb.AppendLine("
"); } - sb.AppendLine("
You only receive entries created or updated since your previous digest for this subscription.
"); + sb.AppendLine("
You only receive entries newer than your previous digest for this subscription.
"); sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine("You are receiving this because you subscribed to activity notifications in Dynamics Activities.
"); @@ -1398,29 +1396,6 @@ private static string GetTypeBadgeLabel(ActivityItem item) } } - private static DateTime GetActivityTimestamp(ActivityItem item) - { - if (item == null) return DateTime.MinValue; - if (item.ModifiedOn > DateTime.MinValue && item.ModifiedOn > item.CreatedOn) return item.ModifiedOn; - return item.CreatedOn; - } - - private static string GetActivityTimeLabel(ActivityItem item) - { - var timestamp = GetActivityTimestamp(item); - if (timestamp <= DateTime.MinValue) - { - return String.Empty; - } - - if (item?.ModifiedOn > DateTime.MinValue && item.ModifiedOn > item.CreatedOn) - { - return $"Updated {timestamp:yyyy-MM-dd HH:mm} UTC"; - } - - return $"{timestamp:yyyy-MM-dd HH:mm} UTC"; - } - private static string GetDynamicsUrl(string entityType, string activityId) { var etn = "activitypointer"; @@ -1463,7 +1438,6 @@ private sealed class ActivityItem public string RegardingId { get; set; } public string Regarding { get; set; } public DateTime CreatedOn { get; set; } - public DateTime ModifiedOn { get; set; } } private sealed class ScopeContext diff --git a/DynamicsActivitiesPackage/DynamicsActivities_Summarize/DynamicsActivities_Summarize.cs b/DynamicsActivitiesPackage/DynamicsActivities_Summarize/DynamicsActivities_Summarize.cs index 5f20587..b87759a 100644 --- a/DynamicsActivitiesPackage/DynamicsActivities_Summarize/DynamicsActivities_Summarize.cs +++ b/DynamicsActivitiesPackage/DynamicsActivities_Summarize/DynamicsActivities_Summarize.cs @@ -145,11 +145,11 @@ private static string BuildPrompt(SummaryRequest request) foreach (var activity in request.Activities.Take(50)) { var type = SafeValue(activity.Type); - var changedOn = SafeValue(GetActivityTimelineTimestampUtc(activity)); + var createdOn = SafeValue(activity.CreatedOnUtc); var regarding = SafeValue(activity.Regarding); var subject = SafeValue(activity.Subject); var description = TrimValue(SafeValue(activity.Description), 260); - lines.Add("- [" + changedOn + "] " + type + " | Regarding: " + regarding + " | Subject: " + subject + " | Note: " + description); + lines.Add("- [" + createdOn + "] " + type + " | Regarding: " + regarding + " | Subject: " + subject + " | Note: " + description); } return String.Join(Environment.NewLine, lines); @@ -309,8 +309,6 @@ private static SummaryRequest ParseSummaryRequest(string payload) request.Activities.Add(new ActivityInput { CreatedOnUtc = item["createdOnUtc"]?.Value(), - ModifiedOnUtc = item["modifiedOnUtc"]?.Value(), - ChangedOnUtc = item["changedOnUtc"]?.Value(), Type = item["type"]?.Value(), Subject = item["subject"]?.Value(), Regarding = item["regarding"]?.Value(), @@ -318,10 +316,6 @@ private static SummaryRequest ParseSummaryRequest(string payload) }); } - request.Activities = request.Activities - .OrderByDescending(GetActivityTimelineTimestampSortKey) - .ToList(); - return request; } @@ -382,7 +376,7 @@ private static string BuildFallbackSummary(SummaryRequest request) var topItems = activities .Take(3) - .Select(a => "[" + SafeValue(GetActivityTimelineTimestampUtc(a)) + "] " + SafeValue(a.Type) + ": " + SafeValue(a.Subject)) + .Select(a => "[" + SafeValue(a.CreatedOnUtc) + "] " + SafeValue(a.Type) + ": " + SafeValue(a.Subject)) .ToList(); var sb = new StringBuilder(); @@ -436,15 +430,15 @@ private static string BuildSummaryHtml(string summary, SummaryRequest request) sb.Append("'>"); foreach (var activity in topActivities) { - var changedOn = SafeValue(GetActivityTimelineTimestampUtc(activity)); - var hasKnownTimestamp = !String.Equals(changedOn, "-", StringComparison.Ordinal); + var createdOn = SafeValue(activity.CreatedOnUtc); + var hasKnownTimestamp = !String.Equals(createdOn, "-", StringComparison.Ordinal); sb.Append("
  • "); if (hasKnownTimestamp) { sb.Append("["); - sb.Append(HtmlEncode(changedOn)); + sb.Append(HtmlEncode(createdOn)); sb.Append("] "); } sb.Append(HtmlEncode(SafeValue(activity.Type))); @@ -574,42 +568,6 @@ private static string TrimValue(string value, int maxLength) return value.Substring(0, maxLength) + "..."; } - private static string GetActivityTimelineTimestampUtc(ActivityInput activity) - { - if (activity == null) - { - return null; - } - - if (!String.IsNullOrWhiteSpace(activity.ChangedOnUtc)) - { - return activity.ChangedOnUtc; - } - - if (!String.IsNullOrWhiteSpace(activity.ModifiedOnUtc)) - { - return activity.ModifiedOnUtc; - } - - return activity.CreatedOnUtc; - } - - private static DateTime GetActivityTimelineTimestampSortKey(ActivityInput activity) - { - var raw = GetActivityTimelineTimestampUtc(activity); - if (String.IsNullOrWhiteSpace(raw)) - { - return DateTime.MinValue; - } - - if (DateTime.TryParseExact(raw, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) - { - return parsed; - } - - return DateTime.MinValue; - } - private sealed class SummaryRequest { [JsonProperty("scopeLabel")] @@ -630,12 +588,6 @@ private sealed class ActivityInput [JsonProperty("createdOnUtc")] public string CreatedOnUtc { get; set; } - [JsonProperty("modifiedOnUtc")] - public string ModifiedOnUtc { get; set; } - - [JsonProperty("changedOnUtc")] - public string ChangedOnUtc { get; set; } - [JsonProperty("type")] public string Type { get; set; } diff --git a/README.md b/README.md index 6462636..0329a1f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Subscriptions are fully **DataMiner-native** in the current implementation. Behavior to be aware of: - The notify script has **no internal cadence gate**; each run processes subscriptions matching the requested frequency -- Activity detection includes both new and updated activities: a record is included if `createdon > since` OR `modifiedon > since` +- New activity detection uses `createdon > LastSentAt` - Digest emails can include Assistant-generated HTML timeline summaries, with deterministic fallback when Assistant integration is unavailable - Sender/from behavior is controlled by DataMiner mail / SMTP configuration @@ -219,7 +219,23 @@ and the frontend build base path is set to `/public/DynamicsActivitiesDev/` so s | `.github/workflows/deploy-dma-on-pr-merge.yml` | Caller workflow for production-on-merge, manual `production` from `main` only, and manual `dev` deploys | | `.github/workflows/deploy-dmapp-reusable.yml` | Reusable build/register/deploy workflow for DMAPP packaging and Catalog deployment | | `.github/workflows/copilot-bug-triage.yml` | Auto-comments on `bug`-labeled issues to ask `@copilot` for investigation | -| `.github/workflows/sync-main-to-release-candidate.yml` | Opens or updates the `main` → `release-candidate` promotion PR, enables auto-merge, asks `@copilot` to resolve conflicts, and retries when the PR is updated | +| `.github/workflows/promote-main-to-release-candidate.yml` | Opens or updates the `main` → `release-candidate` promotion PR after changes reach `main`, using a dedicated branch so `@copilot` can resolve conflicts without modifying `main` | + +### Release candidate deployment + +`release-candidate` is the integration branch for all changes that create, update, or delete Dataverse data. Every push to it deploys the app to: + +```text +https://solutionsdma-skyline.on.dataminer.services/auth/?url=%2Fpublic%2FDynamicsActivitiesRC%2Findex.html +``` + +Open write-capable PRs with `release-candidate` selected as their base branch. When the candidate is approved, open a promotion PR from `release-candidate` into `main`; production remains deployed only when that PR merges. + +The release-candidate deployment uses the repository variable `VITE_REDIRECT_URI_RC`, which must match the redirect URI registered on the deployed Entra application: + +```text +https://solutionsdma-skyline.on.dataminer.services/public/DynamicsActivitiesRC/ +``` ### Deployment URLs