Repository: visivo-io/visivo
Pull request #205: Writer for Modified Objects
Author: login: jpjsonic
Created: 2025-03-22T03:38:25Z | Merged: 2025-03-31T21:09:45Z



Diff:
diff --git a/Not Found b/Not Found
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/test_project_writer.py b/tests/server/test_project_writer.py
index 0bd90d563..03acfdcaf 100644
--- a/tests/server/test_project_writer.py
+++ b/tests/server/test_project_writer.py
@@ -2,7 +2,7 @@
 import yaml
 import os
 import json
-from visivo.server.project_writer import ProjectNamedChildrenWriter
+from visivo.server.project_writer import ProjectWriter, diff, apply_diff, SubDiff, DELETE
 from tests.support.utils import temp_folder, temp_yml_file
 
 @pytest.fixture
@@ -26,7 +26,32 @@ def writer(sample_named_children, temp_project_dir):
         project_data = json.load(f)
 
     project_file = temp_yml_file(project_data, name="project.yml", output_dir=temp_project_dir)
-    return ProjectNamedChildrenWriter(sample_named_children, project_file)
+
+    return ProjectWriter(sample_named_children)
+
+# New fixtures for individual method tests
+@pytest.fixture
+def simple_project_file(temp_project_dir):
+    """Create a simple project file for testing"""
+    initial_content = {
+        "components": [
+            {"name": "Existing Component", "value": "original"}
+        ]
+    }
+    project_file = temp_yml_file(initial_content, name="simple_project.yml", output_dir=temp_project_dir)
+    return str(project_file)
+
+@pytest.fixture
+def simple_writer(temp_project_dir, simple_project_file):
+    """Create a ProjectWriter instance with minimal configuration"""
+    named_children = {
+        "Existing Component": {
+            "status": "Modified",
+            "file_path": simple_project_file,
+            "config": {"name": "Existing Component", "value": "original"}
+        }
+    }
+    return ProjectWriter(named_children)
 
 def test_initial_files_to_write_map(writer, sample_named_children):
     """Test that the initial files to write map is created correctly"""
@@ -37,11 +62,176 @@ def test__reconstruct_named_child_config(writer, sample_named_children):
     """Test that the named child config is reconstructed correctly"""
 
     # Test replacing an inline defined named child with the original named child config
-    named_child_config = writer.named_children["Double Simple Line"].get("config")
-    reconstructed_config = writer.reconstruct_named_child_config(named_child_config)
+    reconstructed_config = writer._get_named_child_config("Double Simple Line")
     assert  reconstructed_config.get("model").get("name") is not None
 
     # Test a larger object that has both inline defined and reference values
-    named_child_config = writer.named_children["Simple Dashboard"].get("config")
-    reconstructed_config = writer.reconstruct_named_child_config(named_child_config)
+    reconstructed_config = writer._get_named_child_config("Simple Dashboard")
     assert  "is_inline_defined" not in str(reconstructed_config)
+
+def test_diff_basic():
+    """Test the diff function with basic dictionary changes"""
+    old_dict = {"name": "test", "value": 1}
+    new_dict = {"name": "test", "value": 2}
+    
+    result = diff(old_dict, new_dict)
+    assert result == {"value": 2}
+
+def test_diff_nested():
+    """Test the diff function with nested dictionary changes"""
+    old_dict = {"config": {"name": "test", "value": 1}}
+    new_dict = {"config": {"name": "test", "value": 2}}
+    
+    result = diff(old_dict, new_dict)
+    assert isinstance(result["config"], SubDiff)
+    assert result["config"]["value"] == 2
+
+def test_diff_list_changes():
+    """Test the diff function with list changes"""
+    old_dict = {"items": [1, 2, 3]}
+    new_dict = {"items": [1, 2, 4]}
+    
+    result = diff(old_dict, new_dict)
+    assert result["items"] == [1, 2, 4]
+
+def test_diff_deletions():
+    """Test the diff function with key deletions"""
+    old_dict = {"name": "test", "value": 1}
+    new_dict = {"name": "test"}
+    
+    result = diff(old_dict, new_dict)
+    assert result["value"] is DELETE
+
+def test_apply_diff_basic():
+    """Test applying basic diffs to a dictionary"""
+    target = {"name": "test", "value": 1}
+    diff_changes = {"value": 2}
+    
+    apply_diff(target, diff_changes)
+    assert target == {"name": "test", "value": 2}
+
+def test_apply_diff_nested():
+    """Test applying nested diffs to a dictionary"""
+    target = {"config": {"name": "test", "value": 1}}
+    diff_changes = {"config": SubDiff({"value": 2})}
+    
+    apply_diff(target, diff_changes)
+    assert target["config"]["value"] == 2
+
+def test_update_named_child(simple_writer, simple_project_file):
+    """Test updating an existing named child"""
+    # Add the component to be updated
+    simple_writer.named_children["Existing Component"] = {
+        "status": "Modified",
+        "file_path": simple_project_file,
+        "config": {"name": "Existing Component", "value": "updated"}
+    }
+    simple_writer._update("Existing Component")
+    
+    # Verify the update was applied
+    updated_component = simple_writer.files_to_write[simple_project_file]["components"][0]
+    assert updated_component["value"] == "updated"
+
+def test_new_named_child(simple_writer, simple_project_file):
+    """Test adding a new named child"""
+    # Add new component configuration
+    simple_writer.named_children["New Component"] = {
+        "status": "New",
+        "new_file_path": simple_project_file,
+        "type_key": "components",
+        "config": {"name": "New Component", "value": "new"}
+    }
+    
+    simple_writer._new("New Component")
+    
+    # Verify the new component was added
+    components = simple_writer.files_to_write[simple_project_file]["components"]
+    assert len(components) == 2  # Original + new component
+    assert any(c["name"] == "New Component" and c["value"] == "new" for c in components)
+
+def test_delete_named_child(simple_writer, simple_project_file):
+    """Test deleting a named child"""
+    # Update the existing component to be deleted
+    simple_writer.named_children["Existing Component"]["status"] = "Deleted"
+    
+    simple_writer._delete("Existing Component")
+    
+    # Verify the component was deleted
+    components = simple_writer.files_to_write[simple_project_file]["components"]
+    assert len(components) == 0
+
+def test_delete_named_child_with_reference(simple_writer, simple_project_file):
+    """Test deleting a named child with reference replacement"""
+    # Update the existing component to be deleted
+    simple_writer.named_children["Existing Component"]["status"] = "Deleted"
+    
+    simple_writer._delete("Existing Component", replace_with_reference=True)
+    
+    # Verify the component was replaced with a reference
+    components = simple_writer.files_to_write[simple_project_file]["components"]
+    assert len(components) == 1
+    assert components[0] == "${ref(Existing Component)}"
+
+def test_move_named_child(simple_writer, simple_project_file, temp_project_dir):
+    """Test moving a named child"""
+    new_file_path = os.path.join(temp_project_dir, "new_project.yml")
+    
+    # Set up the component to be moved
+    simple_writer.named_children["Existing Component"].update({
+        "status": "Moved",
+        "new_file_path": new_file_path,
+        "type_key": "components"
+    })
+    
+    # Initialize the new file in files_to_write
+    simple_writer.files_to_write[new_file_path] = {"components": []}
+    
+    simple_writer._move("Existing Component")
+    
+    # Verify the component was moved
+    old_components = simple_writer.files_to_write[simple_project_file]["components"]
+    new_components = simple_writer.files_to_write[new_file_path]["components"]
+    
+    # Check that old location has reference
+    assert len(old_components) == 1
+    assert old_components[0] == "${ref(Existing Component)}"
+    
+    # Check that new location has actual component
+    assert len(new_components) == 1
+    assert new_components[0]["name"] == "Existing Component"
+    assert new_components[0]["value"] == "original"
+
+def test_update_file_contents(simple_writer, simple_project_file, temp_project_dir):
+    """Test the update_file_contents method handles all operations correctly"""
+    new_file_path = str(os.path.join(temp_project_dir, "new_project.yml"))
+    
+    # Set up multiple operations
+    simple_writer.named_children.update({
+        "New Component": {
+            "status": "New",
+            "new_file_path": simple_project_file,
+            "type_key": "components",
+            "config": {"name": "New Component", "value": "new"}
+        },
+        "Modified Component": {
+            "status": "Modified",
+            "file_path": simple_project_file,
+            "config": {"name": "Modified Component", "value": "modified"}
+        },
+        "Moved Component": {
+            "status": "Moved",
+            "file_path":   simple_project_file,
+            "new_file_path": new_file_path,
+            "type_key": "components",
+            "config": {"name": "Moved Component", "value": "moved"}
+        }
+    })
+    
+    # Initialize the new file
+    simple_writer.files_to_write[new_file_path] = {"components": []}
+    
+    simple_writer.update_file_contents()
+    
+    # Verify all operations were applied correctly
+    assert len(simple_writer.files_to_write[simple_project_file]["components"]) > 0
+    assert len(simple_writer.files_to_write[new_file_path]["components"]) > 0
\ No newline at end of file
diff --git a/visivo/parsers/core_parser.py b/visivo/parsers/core_parser.py
index f8b6ea85a..8c8ee6434 100644
--- a/visivo/parsers/core_parser.py
+++ b/visivo/parsers/core_parser.py
@@ -47,18 +47,25 @@ def __merged_project_data(self):
         )
 
     def __merge_data_into_project(self, project_data: dict, data_files: dict):
-        for file_path, data_file in data_files.items():
+        for index, (file_path, data_file) in enumerate(data_files.items()):
             for key_to_merge in PROJECT_CHILDREN.copy():
                 if key_to_merge in data_file:
                     base_merge = []
                     if key_to_merge in project_data:
                         base_merge = project_data[key_to_merge]
-                        self.__recursively_add_file_path(
-                            base_merge, 
-                            file_path
-                        )
+                        if index == 0:
+                            self.__recursively_add_file_path(
+                                base_merge, 
+                                self.project_file
+                            )
+                    file_to_merge = data_file[key_to_merge]
+                    self.__recursively_add_file_path(
+                        file_to_merge, 
+                        file_path
+                    )
                     project_data[key_to_merge] = always_merger.merge(
-                        base_merge, data_file[key_to_merge]
+                        base_merge, 
+                        file_to_merge
                     )
         return project_data
     
diff --git a/visivo/server/flask_app.py b/visivo/server/flask_app.py
index b620067d8..1cc798daf 100644
--- a/visivo/server/flask_app.py
+++ b/visivo/server/flask_app.py
@@ -6,7 +6,7 @@
 from visivo.parsers.serializer import Serializer
 from visivo.utils import VIEWER_PATH, SCHEMA_FILE
 from visivo.logging.logger import Logger
-
+from visivo.server.project_writer import ProjectWriter
 from visivo.server.repositories.worksheet_repository import WorksheetRepository
 
 
diff --git a/visivo/server/project_writer.py b/visivo/server/project_writer.py
index 88ae66043..4dd6f76f8 100644
--- a/visivo/server/project_writer.py
+++ b/visivo/server/project_writer.py
@@ -1,32 +1,183 @@
 import json
+import os
+import ruamel.yaml 
 from copy import deepcopy
 
-class ProjectNamedChildrenWriter:
-    def __init__(self, named_children, project_file_path):
+DELETE = object()
+
+class SubDiff(dict):
+    pass
+
+def diff(old_dict, new_dict):
+    updates = {}
+    for key in new_dict:
+        if key not in old_dict:
+            updates[key] = new_dict[key]
+        elif isinstance(new_dict[key], dict) and isinstance(old_dict[key], dict):
+            sub_diff = diff(old_dict[key], new_dict[key])
+            if sub_diff:
+                updates[key] = SubDiff(sub_diff)
+        elif isinstance(new_dict[key], list) and isinstance(old_dict[key], list):
+            if old_dict[key] != new_dict[key]:
+                updates[key] = new_dict[key]
+        else:
+            if old_dict[key] != new_dict[key]:
+                updates[key] = new_dict[key]
+    for key in old_dict:
+        if key not in new_dict:
+            updates[key] = DELETE
+    return updates
+
+# Apply diff
+def apply_diff(target, diff):
+    for key, value in diff.items():
+        if value is DELETE:
+            if key in target:
+                del target[key]
+        elif isinstance(value, SubDiff):
+            if key in target and isinstance(target[key], dict):
+                apply_diff(target[key], value)
+            else:
+                target[key] = value
+        else:
+            target[key] = value
+class ProjectWriter:
+    """
+    This class is responsible for parsing the front end data store and writing changes to the project files.
+
+    It's a three step process to write changes to the project files:
+
+        1. Re-build the named child to reflect how the information sits in the project files.
+        2. Make changes to project files using a special round trip yaml parser that preserves comments and spacing.
+        3. Write the changes to the project files. 
+    """
+    def __init__(self, named_children: dict):
         self.named_children = named_children
-        self.project_file_path = project_file_path
+        self.yaml = ruamel.yaml.YAML(typ='rt') 
         self.files_to_write = self.__set_initial_files_to_write_map(named_children)
     
-    def update(self, named_child: str):
-        pass
+    def update_file_contents(self):
+        for child_name, child_info in self.named_children.items():
+            match child_info["status"]:
+                case "Unchanged":
+                    continue
+                case "New":
+                    self._new(child_name)
+                case "Deleted":
+                    self._delete(child_name)
+                case "Moved":
+                    self._move(child_name)
+                case "Modified":
+                    self._update(child_name)
+                case "Renamed":
+                    self._rename(child_name, child_name)
 
-    def new(self, named_child: str):
-        child_dict = self.named_children[named_child]
-        file_path = child_dict["file_path"]
+    def write(self):
+        """ 
+        Writes changes to all files that have been updated. Setting "typ='rt'" on 
+        init allows us to keep the original spacing, comments, etc.
+        """
+        for file, contents in self.files_to_write.items(): 
+            with open(file, 'w') as file:
+                self.yaml.dump(contents, file)
+    
+    def _update(self, child_name: str):
+        new_object = self._get_named_child_config(child_name)
+        
+        def recurse(current):
+            if isinstance(current, dict):
+                for key, value in current.items():
+                    if isinstance(value, dict) and value.get("name") == child_name:
+                        diff_result = diff(value, new_object)
+                        apply_diff(value, diff_result)
+                        return True
+                    elif isinstance(value, (dict, list)):
+                        if recurse(value):
+                            return True
+            elif isinstance(current, list):
+                for item in current:
+                    if isinstance(item, dict) and item.get("name") == child_name:
+                        diff_result = diff(item, new_object)
+                        apply_diff(item, diff_result)
+                        return True
+                    elif isinstance(item, (dict, list)):
+                        if recurse(item):
+                            return True
+            return False
+        
+        file_path = self.named_children[child_name]["file_path"]
+        # Modify the file contents in place to reflect the changes for the named child
+        recurse(self.files_to_write[file_path])
+        
+        
+    def _new(self, child_name: str):
+        child_dict = self._get_named_child_config(child_name)
+        file_path = self.named_children[child_name]["new_file_path"]
+        type_key = self.named_children[child_name]["type_key"]
 
-        pass
+        if self.files_to_write[file_path].get(type_key) is None:
+            self.files_to_write[file_path][type_key] = []
+        # Inplace append the new child to the type key list
+        self.files_to_write[file_path][type_key].append(child_dict)
+        
 
-    def delete(self, named_child: str):
-        pass
+    def _delete(self, child_name: str, replace_with_reference: bool = False):
+        
+        def recurse(current):
+            # Handle dictionaries
+            if isinstance(current, dict):
+                for key, value in list(current.items()):  # Use list to safely iterate
+                    if isinstance(value, dict) and value.get("name") == child_name:
+                        if replace_with_reference: 
+                            current[key] =  "${ref(" + f"{child_name}" + ")}"
+                        else: 
+                            del current[key]
+                        return True
+                    elif isinstance(value, (dict, list)):
+                        if recurse(value):
+                            return True
+            # Handle lists
+            elif isinstance(current, list):
+                for i, item in enumerate(current):
+                    if isinstance(item, dict) and item.get("name") == child_name:
+                        if replace_with_reference: 
+                            current[i] =  "${ref(" + f"{child_name}" + ")}"
+                        else: 
+                            del current[i]
+                        return True
+                    elif isinstance(item, (dict, list)):
+                        if recurse(item):
+                            return True
+            return False
+        file_path = self.named_children[child_name]["file_path"]
+        # Modify the file contents in place to reflect the deletion of the named child
+        recurse(self.files_to_write[file_path])
 
-    def move(self, named_child: str):
-        self.delete(named_child)
-        self.new(named_child)
+    def _move(self, child_name: str):
+        """
+        Moving files is just a combination of deleting a file and then creating a new one. 
+        If the new file path is the same as the old file path, it will move the named child to 
+        a flat list in the project file.
+        """
+        self._delete(child_name, replace_with_reference=True)
+        self._new(child_name)
 
-    def __write(self):
-        pass
+    def _rename(self, old_child_name: str, new_child_name: str):
+        """Find and replace the old child name with the new child name in all file refs & in the named_child"""
+        raise NotImplementedError("Rename is not implemented yet.")
+    
 
-    def reconstruct_named_child_config(self, named_child_config: dict) -> dict:
+    def _get_named_child_config(self, named_child_name: str) -> dict:
+        """
+        Returns the reconstructed config for a named child.
+        """
+        if named_child_name not in self.named_children:
+            raise ValueError(f"Named child {named_child_name} not found in named_children dictionary.")
+        return self.__reconstruct_named_child_config(
+            self.named_children[named_child_name].get("config")
+            )
+
+    def __reconstruct_named_child_config(self, named_child_config: dict) -> dict:
         """"
         This function will recursivly reconstruct a named child and return a config dictionary with the 
         references and inline children returned to the original form found in the file. 
@@ -64,10 +215,7 @@ def reconstruct_config(obj) -> dict:
                     
             
 
-    @staticmethod
-    def __set_initial_files_to_write_map(named_children: dict) -> dict:
-        import yaml
-        import os 
+    def __set_initial_files_to_write_map(self, named_children: dict) -> dict:
 
         relevant_files = []
         for value in named_children.values():
@@ -76,18 +224,18 @@ def __set_initial_files_to_write_map(named_children: dict) -> dict:
             else: 
                 relevant_files.append(value.get("file_path"))
                 relevant_files.append(value.get("new_file_path"))
-        relevant_files = list(set(relevant_files))
-        
+        relevant_files = [x for x in list(set(relevant_files)) if x is not None]
         files_to_write = {}
+
         for file_path in relevant_files:
-            if os.path.isfile(file_path):
+            if file_path is None: 
+                continue 
+            elif os.path.isfile(file_path):
                 with open(file_path, "r") as file:
-                    file_contents = yaml.safe_load(file)
+                    file_contents = self.yaml.load(file)
                     files_to_write[file_path] = file_contents
             else: 
-                files_to_write[file_path] = ""
-        return files_to_write
-
-    def __merge_dictionary_updates(self, base_dict, updates_dict):
-        """"""
-        pass     
+                with open (file_path, "w") as file: 
+                    file.write("")
+                files_to_write[file_path] = {}
+        return files_to_write
\ No newline at end of file


Reviews:
user: login: tgsoverly
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T13:07:29Z
user: login: jpjsonic
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T15:52:57Z
user: login: jpjsonic
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T15:53:56Z

Review comments:
diff_hunk: @@ -72,12 +72,22 @@ def named_children():
         
     @app.route("/api/project/write_changes", methods = ["POST"])
     def write_changes():
-        from time import sleep
+        
         data = request.get_json()
         if not data:
             return jsonify({"message": "No data provided"}), 400
-        sleep(3)
-        return jsonify({"message": "Changes written successfully"}), 200
+        project_writer = ProjectWriter(data)
+        try:
+            project_writer.update_file_contents()
+            if os.environ.get("STACKTRACE"):
path: visivo/server/flask_app.py
user: login: tgsoverly
body: ```suggestion
            if os.environ.get("DEBUG"):
```
created_at: 2025-03-29T12:58:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,12 +72,22 @@ def named_children():
         
     @app.route("/api/project/write_changes", methods = ["POST"])
     def write_changes():
-        from time import sleep
+        
         data = request.get_json()
         if not data:
             return jsonify({"message": "No data provided"}), 400
-        sleep(3)
-        return jsonify({"message": "Changes written successfully"}), 200
+        project_writer = ProjectWriter(data)
+        try:
+            project_writer.update_file_contents()
+            if os.environ.get("STACKTRACE"):
+                Logger.instance().info(f"Made Updates to File Contents")
+            project_writer.write()
+            if os.environ.get("STACKTRACE"):
path: visivo/server/flask_app.py
user: login: tgsoverly
body: ```suggestion
            if os.environ.get("DEBUG"):
```
created_at: 2025-03-29T12:58:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,12 +72,22 @@ def named_children():
         
     @app.route("/api/project/write_changes", methods = ["POST"])
     def write_changes():
-        from time import sleep
+        
         data = request.get_json()
         if not data:
             return jsonify({"message": "No data provided"}), 400
-        sleep(3)
-        return jsonify({"message": "Changes written successfully"}), 200
+        project_writer = ProjectWriter(data)
+        try:
+            project_writer.update_file_contents()
+            if os.environ.get("STACKTRACE"):
+                Logger.instance().info(f"Made Updates to File Contents")
+            project_writer.write()
+            if os.environ.get("STACKTRACE"):
path: visivo/server/flask_app.py
user: login: tgsoverly
body: Let's reserve STACKTRACE for printing errors.
created_at: 2025-03-29T12:59:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,32 +1,183 @@
 import json
+import os
+import ruamel.yaml 
 from copy import deepcopy
 
-class ProjectNamedChildrenWriter:
-    def __init__(self, named_children, project_file_path):
+DELETE = object()
+
+class SubDiff(dict):
+    pass
path: visivo/server/project_writer.py
user: login: tgsoverly
body: Only pass?  Seems like that isn't used internally.
created_at: 2025-03-29T12:59:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,32 +1,183 @@
 import json
+import os
+import ruamel.yaml 
 from copy import deepcopy
 
-class ProjectNamedChildrenWriter:
-    def __init__(self, named_children, project_file_path):
+DELETE = object()
+
+class SubDiff(dict):
+    pass
+
+def diff(old_dict, new_dict):
+    updates = {}
+    for key in new_dict:
path: visivo/server/project_writer.py
user: login: tgsoverly
body: I think for these to be maintainable, it is probably best to break out these logically pieces into private methods with a name.  I think other than that it is going to be hard to track down bugs in these code. Then it can read more like:

```
for key in new_dict:
  __record_updates_not_in_old_dict(key, new_dict)
  __record_updates...
  ...
```
created_at: 2025-03-29T13:03:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,32 +1,183 @@
 import json
+import os
+import ruamel.yaml 
 from copy import deepcopy
 
-class ProjectNamedChildrenWriter:
-    def __init__(self, named_children, project_file_path):
+DELETE = object()
+
+class SubDiff(dict):
+    pass
path: visivo/server/project_writer.py
user: login: jpjsonic
body: It's used to tell when we modify a dict within the file
created_at: 2025-03-31T15:52:57Z
author_association: COLLABORATOR
diff_hunk: @@ -1,32 +1,183 @@
 import json
+import os
+import ruamel.yaml 
 from copy import deepcopy
 
-class ProjectNamedChildrenWriter:
-    def __init__(self, named_children, project_file_path):
+DELETE = object()
+
+class SubDiff(dict):
+    pass
+
+def diff(old_dict, new_dict):
+    updates = {}
+    for key in new_dict:
path: visivo/server/project_writer.py
user: login: jpjsonic
body: Ah yeah that makes sense. So then you get a stacktrace back to the one that was the issue 
created_at: 2025-03-31T15:53:56Z
author_association: COLLABORATOR

PR comments:
user: login: linear[bot]
created_at: 2025-03-22T03:38:28Z
body: <p><a href="https://linear.app/visivo/issue/VIS-615/gui-writes-back-to-project">VIS-615 GUI Writes Back to Project</a></p>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-22T03:39:42Z
body: Visivo Dashboard:
 Deployed to: 'https://app.visivo.io/visivo/jared_vis_615_gui_writes_back_to_project-actions/project'
<!-- Sticky Pull Request Comment -->
author_association: NONE
user: login: mint-automation-visivo[bot]
created_at: 2025-03-22T03:40:46Z
body: Deployed to: 'https://app.visivo.io/visivo/jared_vis_615_gui_writes_back_to_project-mint/project'

author_association: NONE
user: login: mint-automation-visivo[bot]
created_at: 2025-03-31T21:10:24Z
body: Archived stage associated with PR
author_association: NONE