Repository: featbit/featbit (language: python)

Issue:
✨ feat: webhooks
Allow users to subscribe to flag and segment changes
✨ feat: add instruction desctiptor
Use instruction descriptor to describe instruction change


Discussion / hints:
There will have some other webhook features be done in other PRs

- ~~Test webhook~~
- add human-readable changes description to our webhook payload
- view webhook logs
- add feature flag/segment url to our webhook payload
- (**optional**) handlebars auto completion, ref: https://github.com/microsoft/monaco-editor/issues/1731

@cosmic-flood @cosmos-explorer 


releated to #533 

Fix patch:
diff --git a/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs b/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs
index edb12ff43..2ad01b591 100644
--- a/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs
+++ b/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs
@@ -1,7 +1,9 @@
+using System.Text.Json;
 using Domain.AuditLogs;
 using Domain.FeatureFlags;
 using Domain.Segments;
 using Domain.SemanticPatch;
+using Domain.Utils;
 using Domain.Webhooks;
 
 namespace Application.Webhooks;
@@ -37,22 +39,34 @@ public WebhookHandler(
     public async Task HandleAsync(FeatureFlag flag, DataChange dataChange, Guid operatorId)
     {
         string[] events;
+        string[] changes;
 
         if (dataChange.IsCreation())
         {
             events = FlagCreatedEvents;
+            changes = new[] { $"Created Flag: ${flag.Name}" };
         }
         else if (dataChange.IsDeletion())
         {
             events = FlagDeletedEvents;
+            changes = new[] { $"Deleted Flag: ${flag.Name}" };
         }
         else
         {
-            var instructions = FlagComparer.Compare(dataChange).ToArray();
+            var original =
+                JsonSerializer.Deserialize<FeatureFlag>(dataChange.Previous, ReusableJsonSerializerOptions.Web);
+            var current =
+                JsonSerializer.Deserialize<FeatureFlag>(dataChange.Current, ReusableJsonSerializerOptions.Web);
+
+            var instructions = FlagComparer.Compare(original, current).ToArray();
             events = instructions
                 .Select(x => WebhookEvents.FlagEvents.FromInstructionKind(x.Kind))
                 .Distinct()
                 .ToArray();
+            changes = instructions
+                .Select(x => InstructionDescriptor.Describe(x, original, current))
+                .Distinct()
+                .ToArray();
         }
 
         var resourceDescriptor = await _environmentService.GetResourceDescriptorAsync(flag.EnvId);
@@ -62,7 +76,8 @@ public async Task HandleAsync(FeatureFlag flag, DataChange dataChange, Guid oper
         var dataObject = DataObjectBuilder
             .New(events, @operator, flag.UpdatedAt)
             .AddResourceDescriptor(resourceDescriptor)
-            .AddFeatureFlag(flag);
+            .AddFeatureFlag(flag)
+            .AddChanges(changes);
 
         foreach (var webhook in webhooks)
         {
@@ -76,22 +91,34 @@ public async Task HandleAsync(FeatureFlag flag, DataChange dataChange, Guid oper
     public async Task HandleAsync(Segment segment, DataChange dataChange, Guid operatorId)
     {
         string[] events;
+        string[] changes;
 
         if (dataChange.IsCreation())
         {
             events = SegmentCreatedEvents;
+            changes = new[] { $"Created Segment: ${segment.Name}" };
         }
         else if (dataChange.IsDeletion())
         {
             events = SegmentDeletedEvents;
+            changes = new[] { $"Deleted Segment: ${segment.Name}" };
         }
         else
         {
-            var instructions = SegmentComparer.Compare(dataChange).ToArray();
+            var original =
+                JsonSerializer.Deserialize<Segment>(dataChange.Previous, ReusableJsonSerializerOptions.Web);
+            var current =
+                JsonSerializer.Deserialize<Segment>(dataChange.Current, ReusableJsonSerializerOptions.Web);
+
+            var instructions = SegmentComparer.Compare(original, current).ToArray();
             events = instructions
                 .Select(x => WebhookEvents.SegmentEvents.FromInstructionKind(x.Kind))
                 .Distinct()
                 .ToArray();
+            changes = instructions
+                .Select(x => InstructionDescriptor.Describe(x, original, current))
+                .Distinct()
+                .ToArray();
         }
 
         var resourceDescriptor = await _environmentService.GetResourceDescriptorAsync(segment.EnvId);
@@ -102,7 +129,8 @@ public async Task HandleAsync(Segment segment, DataChange dataChange, Guid opera
         var dataObject = DataObjectBuilder
             .New(events, @operator, segment.UpdatedAt)
             .AddResourceDescriptor(resourceDescriptor)
-            .AddSegment(segment, flagReferences);
+            .AddSegment(segment, flagReferences)
+            .AddChanges(changes);
 
         foreach (var webhook in webhooks)
         {
diff --git a/modules/back-end/src/Domain/Webhooks/DataObjectBuilder.cs b/modules/back-end/src/Domain/Webhooks/DataObjectBuilder.cs
index 1b6978426..8e43b3e5f 100644
--- a/modules/back-end/src/Domain/Webhooks/DataObjectBuilder.cs
+++ b/modules/back-end/src/Domain/Webhooks/DataObjectBuilder.cs
@@ -96,5 +96,12 @@ public static Dictionary<string, object> AddSegment(
         return dataObject;
     }
 
+    public static Dictionary<string, object> AddChanges(this Dictionary<string, object> dataObject, string[] changes)
+    {
+        dataObject["changes"] = changes;
+
+        return dataObject;
+    }
+
     private static string BooleanToString(this bool value) => value ? "true" : "false";
 }
\ No newline at end of file
diff --git a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html
index 2464755ad..9808c30e0 100644
--- a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html
+++ b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html
@@ -4,7 +4,7 @@
   [nzVisible]="visible"
   nzPlacement="right"
   [nzTitle]="title"
-  [nzWidth]="650"
+  [nzWidth]="800"
   (nzOnClose)="onClose()">
   <ng-container *nzDrawerContent>
     <form nz-form [formGroup]="form" nzLayout="vertical" (ngSubmit)="doSubmit()">
diff --git a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less
index 67814a364..715847909 100644
--- a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less
+++ b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less
@@ -105,7 +105,7 @@
 
   nz-code-editor {
     border-radius: 6px;
-    height: 250px;
+    height: 350px;
   }
 }
 
diff --git a/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts b/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts
index 3052526bb..eb417001e 100644
--- a/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts
+++ b/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts
@@ -20,6 +20,7 @@ export const WebhookDefaultPayloadTemplate: string = `{
   "event": "{{events}}",
   "operator": "{{operator}}",
   "happenedAt": "{{happenedAt}}",
+  "changes": {{json changes}},
   "organization": {
     "id": "{{organization.id}}",
     "name": "{{organization.name}}"


Test patch:
diff --git a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts
index eb77141fd..dc4017107 100644
--- a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts
+++ b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts
@@ -24,7 +24,15 @@ export function getTestPayload(event: string, payloadTemplate: string): string {
 
   const data = Object.assign(
     {},
-    { events: event, operator: 'webhook-tester', happenedAt: new Date().toISOString() },
+    {
+      events: event,
+      operator: 'webhook-tester',
+      happenedAt: new Date().toISOString(),
+      changes: [
+        'test change description 1',
+        'test change description 2'
+      ]
+    },
     { ...resourceDescriptor }
   );