diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0fed9b8..2b54fbe 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -52,7 +52,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 index bcd3cbe..36167a7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,6 @@ ## Target branch -- [ ] This PR targets `release-candidate` because the work is based on or must be validated in RC first. -- [ ] This PR targets `main` because the work can go directly to production. +- [ ] 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. - -Keep the PR base aligned with the branch the work started from and its intended release path. Do not rebase RC-based work onto `main` merely to change the PR target. 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 852a2a8..0000000 --- a/.github/workflows/sync-main-to-release-candidate.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Sync main to release candidate - -on: - push: - branches: [main] - workflow_dispatch: {} - -permissions: - contents: write - issues: write - pull-requests: write - -concurrency: - group: sync-main-to-release-candidate - cancel-in-progress: false - -jobs: - sync: - 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/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 ea72e0b..1e5b119 100644 --- a/DynamicsActivitiesPackage/DynamicsActivities_Summarize/DynamicsActivities_Summarize.cs +++ b/DynamicsActivitiesPackage/DynamicsActivities_Summarize/DynamicsActivities_Summarize.cs @@ -146,11 +146,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); @@ -310,8 +310,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(), @@ -319,10 +317,6 @@ private static SummaryRequest ParseSummaryRequest(string payload) }); } - request.Activities = request.Activities - .OrderByDescending(GetActivityTimelineTimestampSortKey) - .ToList(); - return request; } @@ -383,7 +377,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(); @@ -437,15 +431,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))); @@ -636,12 +630,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 73703c6..996de32 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,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 @@ -236,7 +236,7 @@ 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 after changes reach `main`, enables auto-merge, and asks `@copilot` to resolve conflicts | +| `.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 @@ -246,7 +246,7 @@ and the frontend build base path is set to `/public/DynamicsActivitiesDev/` so s https://solutionsdma-skyline.on.dataminer.services/auth/?url=%2Fpublic%2FDynamicsActivitiesRC%2Findex.html ``` -Choose the PR base according to the intended release path. Features that can go directly to production may target `main`; features that need RC validation or are started from `release-candidate` must target `release-candidate`. A branch based on `origin/release-candidate` should not be rebased onto `main` merely to change the PR base, because that can introduce unrelated history. Approved RC work can later be promoted to `main`. +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: