Merge "Document updating changes, rather than replacing them"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index adb0c2d..c895a40 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -5714,11 +5714,11 @@
 +
 * `USER`
 +
-Gerrit will set the From header to use the current user's
-Full Name and Preferred Email.  This may cause messages to be
-classified as spam if the user's domain has SPF or DKIM enabled
-and <<sendemail.smtpServer,sendemail.smtpServer>> is not a trusted
-relay for that domain. You can specify
+Gerrit will set the From header name and address, and the SMTP `MAIL FROM`
+(envelope sender) address, to use the current user's Full Name and Preferred
+Email. This may cause messages to be classified as spam if the user's domain has
+SPF or DKIM enabled and <<sendemail.smtpServer,sendemail.smtpServer>> is not a
+trusted relay for that domain. You can specify
 <<sendemail.allowedDomain,sendemail.allowedDomain>> to instruct Gerrit to only
 send as USER if USER is from those domains.
 +
@@ -5730,16 +5730,17 @@
 +
 * `SERVER`
 +
-Gerrit will set the From header to the same name and address
-it records in any commits Gerrit creates.  This is set by
-<<user.name,user.name>> and <<user.email,user.email>>, or guessed
-from the local operating system.
+Gerrit will set the From header name and address, and the SMTP `MAIL FROM`
+(envelope sender) address, to the same values it records in any commits Gerrit
+creates. This is set by <<user.name,user.name>> and <<user.email,user.email>>,
+or guessed from the local operating system.
 +
 * `Code Review <review@example.com>`
 +
 If set to a name and email address in brackets, Gerrit will use
 this name and email address for any messages, overriding the name
 that may have been selected for commits by user.name and user.email.
+This address is also used in the SMTP `MAIL FROM` (envelope sender).
 Optionally, the name portion may contain the placeholder `${user}`,
 which is replaced by the Full Name of the current user.
 
@@ -6708,6 +6709,9 @@
 +
 Email address that Gerrit refers to itself as when it creates a
 new Git commit, such as a merge commit during change submission.
+When <<sendemail.from,sendemail.from>> is `SERVER` or `MIXED`,
+this is the email address used in the SMTP `MAIL FROM` field
+(also known as envelope sender or return-path) when Gerrit sends email.
 +
 If not set, Gerrit generates this as "gerrit@``hostname``", where
 `hostname` is the hostname of the system Gerrit is running on.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4fcb18f..b9d2ef0 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3847,6 +3847,35 @@
 If no flow service is bound (i.e. if no plugin that provides a flow service is
 installed) `405 Method Not Allowed` is returned.
 
+[[flows-actions]]
+=== List Flows Actions
+--
+'GET /changes/link:#change-id[{change-id}]/flows-actions/'
+--
+
+Lists the flows actions that are configured for the given change.
+
+As result a list of link:#flow-action-type-info[FlowActionTypeInfo] entries is returned.
+
+.Request
+----
+  GET /changes/myProject~65178/flows-actions/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "add-reviewer"
+    }
+  ]
+----
+
 [[create-flow]]
 === Create Flow
 --
@@ -8672,6 +8701,17 @@
 are supported and their format depends on the flow service implementation.
 |=========================
 
+[[flow-action-type]]
+=== FlowActionTypeInfo
+The `FlowActionTypeInfo` entity describes an action that may be used in a flow.
+
+[options="header",cols="1,6"]
+|=========================
+|Field Name   |Description
+|`name`       |The name of the action.
+|=========================
+
+
 [[flow-expression-info]]
 === FlowExpressionInfo
 The `FlowExpressionInfo` entity contains information about a flow expression. A
diff --git a/java/com/google/gerrit/acceptance/TestExtensions.java b/java/com/google/gerrit/acceptance/TestExtensions.java
index 97fcf0e..7c97b31 100644
--- a/java/com/google/gerrit/acceptance/TestExtensions.java
+++ b/java/com/google/gerrit/acceptance/TestExtensions.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.flow.Flow;
+import com.google.gerrit.server.flow.FlowActionType;
 import com.google.gerrit.server.flow.FlowCreation;
 import com.google.gerrit.server.flow.FlowExpression;
 import com.google.gerrit.server.flow.FlowKey;
@@ -190,6 +191,8 @@
      */
     private boolean rejectFlowDeletion;
 
+    private ImmutableList<FlowActionType> actions = ImmutableList.of();
+
     /** Makes the flow service reject all flow creations. */
     public void rejectFlowCreation() {
       this.rejectFlowCreation = true;
@@ -200,6 +203,10 @@
       this.rejectFlowDeletion = true;
     }
 
+    public void setActions(ImmutableList<FlowActionType> actions) {
+      this.actions = actions;
+    }
+
     @Override
     public Flow createFlow(FlowCreation flowCreation)
         throws FlowPermissionDeniedException, InvalidFlowException, StorageException {
@@ -242,6 +249,12 @@
     }
 
     @Override
+    public ImmutableList<FlowActionType> listActions(
+        Project.NameKey projectName, Change.Id changeId) throws StorageException {
+      return actions;
+    }
+
+    @Override
     public Optional<Flow> getFlow(FlowKey flowKey) throws StorageException {
       return Optional.ofNullable(flows.get(flowKey));
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 7af343a..02fe189 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.common.CommitMessageInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.EvaluateChangeQueryExpressionResultInfo;
+import com.google.gerrit.extensions.common.FlowActionTypeInfo;
 import com.google.gerrit.extensions.common.FlowInfo;
 import com.google.gerrit.extensions.common.FlowInput;
 import com.google.gerrit.extensions.common.IsFlowsEnabledInfo;
@@ -109,6 +110,9 @@
   /** Get the flows of this change/ */
   List<FlowInfo> flows() throws RestApiException;
 
+  /** Get the actions of this change/ */
+  List<FlowActionTypeInfo> flowsActions() throws RestApiException;
+
   EvaluateChangeQueryExpressionRequest evaluateChangeQueryExpression();
 
   default void abandon() throws RestApiException {
diff --git a/java/com/google/gerrit/extensions/common/FlowActionTypeInfo.java b/java/com/google/gerrit/extensions/common/FlowActionTypeInfo.java
new file mode 100644
index 0000000..e767a0d
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/FlowActionTypeInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+/**
+ * Representation of a flow action type in the REST API.
+ *
+ * <p>This class determines the JSON format of flow action types in the REST API.
+ *
+ * <p>An action type to be triggered when the condition of a flow expression becomes satisfied.
+ */
+public class FlowActionTypeInfo {
+  /**
+   * The name of the action type.
+   *
+   * <p>Which action types are supported depends on the flow service implementation.
+   */
+  public String name;
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 9ce3d79..060a8a6 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.extensions.common.CommitMessageInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.EvaluateChangeQueryExpressionResultInfo;
+import com.google.gerrit.extensions.common.FlowActionTypeInfo;
 import com.google.gerrit.extensions.common.FlowInfo;
 import com.google.gerrit.extensions.common.FlowInput;
 import com.google.gerrit.extensions.common.Input;
@@ -121,6 +122,7 @@
 import com.google.gerrit.server.restapi.flow.CreateFlow;
 import com.google.gerrit.server.restapi.flow.FlowCollection;
 import com.google.gerrit.server.restapi.flow.IsFlowsEnabled;
+import com.google.gerrit.server.restapi.flow.ListActions;
 import com.google.gerrit.server.restapi.flow.ListFlows;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
@@ -190,6 +192,7 @@
   private final PutMessage putMessage;
   private final CreateFlow createFlow;
   private final ListFlows listFlows;
+  private final ListActions listActions;
   private final IsFlowsEnabled isFlowsEnabled;
   private final Provider<EvaluateChangeQueryExpression> evaluateChangeQueryExpressionProvider;
   private final Provider<GetPureRevert> getPureRevertProvider;
@@ -249,6 +252,7 @@
       PutMessage putMessage,
       CreateFlow createFlow,
       ListFlows listFlows,
+      ListActions listActions,
       IsFlowsEnabled isFlowsEnabled,
       Provider<EvaluateChangeQueryExpression> evaluateChangeQueryExpressionProvider,
       Provider<GetPureRevert> getPureRevertProvider,
@@ -306,6 +310,7 @@
     this.putMessage = putMessage;
     this.createFlow = createFlow;
     this.listFlows = listFlows;
+    this.listActions = listActions;
     this.isFlowsEnabled = isFlowsEnabled;
     this.evaluateChangeQueryExpressionProvider = evaluateChangeQueryExpressionProvider;
     this.getPureRevertProvider = getPureRevertProvider;
@@ -375,6 +380,15 @@
   }
 
   @Override
+  public List<FlowActionTypeInfo> flowsActions() throws RestApiException {
+    try {
+      return listActions.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list actions", e);
+    }
+  }
+
+  @Override
   public EvaluateChangeQueryExpressionRequest evaluateChangeQueryExpression() {
     return new EvaluateChangeQueryExpressionRequest() {
       @Override
diff --git a/java/com/google/gerrit/server/flow/FlowActionType.java b/java/com/google/gerrit/server/flow/FlowActionType.java
new file mode 100644
index 0000000..1e657d1
--- /dev/null
+++ b/java/com/google/gerrit/server/flow/FlowActionType.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.flow;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * An action type to be triggered when the condition of a flow expression becomes satisfied.
+ *
+ * <p>Which action types are supported depends on the flow service implementation.
+ */
+@AutoValue
+public abstract class FlowActionType {
+  /**
+   * The name of the action type.
+   *
+   * <p>Which action types are supported depends on the flow service implementation.
+   */
+  public abstract String name();
+
+  /** Creates a {@link Builder} for this flow action type instance. */
+  public abstract Builder toBuilder();
+
+  /**
+   * Creates a builder for building a flow action type.
+   *
+   * @param name The name of the action type.
+   * @return the builder for building the flow action type
+   */
+  public static FlowActionType.Builder builder(String name) {
+    return new AutoValue_FlowActionType.Builder().name(name);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /** Sets the name of the action type. */
+    public abstract Builder name(String name);
+
+    /** Builds the {@link FlowActionType}. */
+    public abstract FlowActionType build();
+  }
+}
diff --git a/java/com/google/gerrit/server/flow/FlowService.java b/java/com/google/gerrit/server/flow/FlowService.java
index 25fd799..22cae39 100644
--- a/java/com/google/gerrit/server/flow/FlowService.java
+++ b/java/com/google/gerrit/server/flow/FlowService.java
@@ -98,4 +98,20 @@
    */
   ImmutableList<Flow> listFlows(Project.NameKey projectName, Change.Id changeId)
       throws StorageException;
+
+  /**
+   * Lists the actions for one change. When configuring flows, the user specifies a condition and
+   * the actions that can be performed. Return the list of possible actions that have been
+   * configured for that instance. This allows building an action autocomplete in the UI.
+   *
+   * <p>The order of the returned actions is stable, but depends on the flow service implementation.
+   *
+   * @param projectName The name of the project that contains the change.
+   * @param changeId The ID of the change for which the actions should be listed.
+   * @return The actions of the change. The service may filter out actions that are not visible to
+   *     the current user.
+   * @throws StorageException thrown if accessing the flow storage has failed
+   */
+  ImmutableList<FlowActionType> listActions(Project.NameKey projectName, Change.Id changeId)
+      throws StorageException;
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index 703e340..e2fe752 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -141,7 +141,7 @@
               ? performanceLogRecord
                   .metadata()
                   .map(Metadata::className)
-                  .map(clazz -> " (" + clazz + ")")
+                  .map(clazz -> clazz.isPresent() ? " (" + clazz.get() + ")" : "")
                   .orElse("")
               : "";
       PerformanceInfo info =
diff --git a/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java b/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java
index 442e6b1..9abf521 100644
--- a/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java
@@ -35,5 +35,6 @@
     delete(FLOW_KIND).to(DeleteFlow.class);
 
     get(CHANGE_KIND, "is-flows-enabled").to(IsFlowsEnabled.class);
+    get(CHANGE_KIND, "flows-actions").to(ListActions.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/flow/ListActions.java b/java/com/google/gerrit/server/restapi/flow/ListActions.java
new file mode 100644
index 0000000..5f0e0a2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/flow/ListActions.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.flow;
+
+import com.google.gerrit.extensions.common.FlowActionTypeInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.flow.FlowServiceUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * REST endpoint to list actions of a change.
+ *
+ * <p>This REST endpoint handles {@code GET /change/<change-id>/actions} requests.
+ */
+@Singleton
+public class ListActions implements RestReadView<ChangeResource> {
+  private final FlowServiceUtil flowServiceUtil;
+
+  @Inject
+  ListActions(FlowServiceUtil flowServiceUtil) {
+    this.flowServiceUtil = flowServiceUtil;
+  }
+
+  @Override
+  public Response<List<FlowActionTypeInfo>> apply(ChangeResource changeResource)
+      throws MethodNotAllowedException {
+    return Response.ok(
+        flowServiceUtil
+            .getFlowServiceOrThrow()
+            .listActions(changeResource.getProject(), changeResource.getId())
+            .stream()
+            .map(
+                flowAction -> {
+                  FlowActionTypeInfo flowActionTypeInfo = new FlowActionTypeInfo();
+                  flowActionTypeInfo.name = flowAction.name();
+                  return flowActionTypeInfo;
+                })
+            .collect(Collectors.toList()));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/flow/ListActionsIT.java b/javatests/com/google/gerrit/acceptance/api/flow/ListActionsIT.java
new file mode 100644
index 0000000..db59193
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/flow/ListActionsIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.flow;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestExtensions;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.extensions.api.changes.ChangeIdentifier;
+import com.google.gerrit.extensions.common.FlowActionTypeInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.server.flow.FlowActionType;
+import com.google.gerrit.server.flow.FlowService;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+/**
+ * Integration tests for the {@link com.google.gerrit.server.restapi.flow.ListActions} REST
+ * endpoint.
+ */
+public class ListActionsIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void listActionsIfNoFlowServiceIsBound_methodNotAllowed() throws Exception {
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().create();
+    MethodNotAllowedException exception =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.changes().id(changeIdentifier).flowsActions());
+    assertThat(exception).hasMessageThat().isEqualTo("No FlowService bound.");
+  }
+
+  @Test
+  public void listActions_noActionsExist() throws Exception {
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().create();
+    FlowService flowService = new TestExtensions.TestFlowService();
+    try (Registration registration = extensionRegistry.newRegistration().set(flowService)) {
+      List<FlowActionTypeInfo> actions = gApi.changes().id(changeIdentifier).flowsActions();
+      assertThat(actions).isEmpty();
+    }
+  }
+
+  @Test
+  public void listActions() throws Exception {
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().create();
+
+    TestExtensions.TestFlowService testFlowService = new TestExtensions.TestFlowService();
+    testFlowService.setActions(ImmutableList.of(action("action1"), action("action2")));
+
+    try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) {
+      List<FlowActionTypeInfo> actions = gApi.changes().id(changeIdentifier).flowsActions();
+      assertThat(actions).hasSize(2);
+      assertThat(actions.get(0).name).isEqualTo("action1");
+      assertThat(actions.get(1).name).isEqualTo("action2");
+    }
+  }
+
+  private static FlowActionType action(String name) {
+    return FlowActionType.builder(name).build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java
index f5953d2..eb76385 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java
@@ -43,6 +43,7 @@
       ImmutableList.of(
           RestCall.get("/changes/%s/flows"),
           RestCall.get("/changes/%s/is-flows-enabled"),
+          RestCall.get("/changes/%s/flows-actions"),
           RestCall.post("/changes/%s/flows"));
 
   private static final ImmutableList<RestCall> FLOW_ENDPOINTS =
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 1e31feb..60aa37f 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -236,6 +236,7 @@
   status?: string; // status message of the account
   inactive?: boolean; // not set if false
   tags?: string[];
+  deleted?: boolean; // not set if false
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
index 60b996f..866a297 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
@@ -32,6 +32,7 @@
 import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field.js';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
+const STAGE_SEPARATOR = ';';
 
 @customElement('gr-create-flow')
 export class GrCreateFlow extends LitElement {
@@ -202,13 +203,40 @@
     }) => {
       if (stage.action) {
         if (stage.parameterStr) {
-          return `${stage.condition} -> ${stage.action}(${stage.parameterStr})`;
+          return `${stage.condition} -> ${stage.action} ${stage.parameterStr}`;
         }
         return `${stage.condition} -> ${stage.action}`;
       }
       return stage.condition;
     };
-    this.flowString = this.stages.map(stageToString).join(', ');
+    this.flowString = this.stages.map(stageToString).join(STAGE_SEPARATOR);
+  }
+
+  private parseStagesFromRawFlow(rawFlow: string) {
+    if (!rawFlow) {
+      this.stages = [];
+      return;
+    }
+    const stageStrings = rawFlow.split(STAGE_SEPARATOR);
+    this.stages = stageStrings.map(stageStr => {
+      const stage = {
+        condition: '',
+        action: '',
+        parameterStr: '',
+      };
+      if (stageStr.includes('->')) {
+        const [condition, actionStr] = stageStr.split('->').map(s => s.trim());
+        stage.condition = condition;
+        const actionParts = actionStr.split(' ').filter(part => part);
+        stage.action = actionParts[0] ?? '';
+        if (actionParts.length > 1) {
+          stage.parameterStr = actionParts.slice(1).join(' ');
+        }
+      } else {
+        stage.condition = stageStr.trim();
+      }
+      return stage;
+    });
   }
 
   override render() {
@@ -218,6 +246,10 @@
           placeholder="raw flow"
           label="Raw Flow"
           .value=${this.flowString}
+          @input=${(e: InputEvent) => {
+            this.flowString = (e.target as HTMLTextAreaElement).value;
+            this.parseStagesFromRawFlow(this.flowString);
+          }}
         ></gr-autogrow-textarea>
         <gr-copy-clipboard
           .text=${this.flowString}
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
index 6aea78f..6e88d11 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
@@ -63,7 +63,7 @@
 
     searchAutocomplete.value = 'cond 1';
     await element.updateComplete;
-    actionInput.value = 'act 1';
+    actionInput.value = 'act-1';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     addButton.click();
@@ -73,7 +73,7 @@
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1',
-        action: 'act 1',
+        action: 'act-1',
         parameterStr: '',
       },
     ]);
@@ -82,7 +82,7 @@
 
     searchAutocomplete.value = 'cond 2';
     await element.updateComplete;
-    actionInput.value = 'act 2';
+    actionInput.value = 'act-2';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     addButton.click();
@@ -92,13 +92,13 @@
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1',
-        action: 'act 1',
+        action: 'act-1',
         parameterStr: '',
       },
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2',
-        action: 'act 2',
+        action: 'act-2',
         parameterStr: '',
       },
     ]);
@@ -113,7 +113,7 @@
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2',
-        action: 'act 2',
+        action: 'act-2',
         parameterStr: '',
       },
     ]);
@@ -216,7 +216,7 @@
 
     searchAutocomplete.value = 'cond 1';
     await element.updateComplete;
-    actionInput.value = 'act 1';
+    actionInput.value = 'act-1';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     addButton.click();
@@ -224,7 +224,7 @@
 
     searchAutocomplete.value = 'cond 2';
     await element.updateComplete;
-    actionInput.value = 'act 2';
+    actionInput.value = 'act-2';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     addButton.click();
@@ -243,12 +243,12 @@
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1',
-        action: {name: 'act 1'},
+        action: {name: 'act-1'},
       },
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2',
-        action: {name: 'act 2'},
+        action: {name: 'act-2'},
       },
     ]);
   });
@@ -271,14 +271,14 @@
 
     searchAutocomplete.value = 'cond 1';
     await element.updateComplete;
-    actionInput.value = 'act 1';
+    actionInput.value = 'act-1';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     addButton.click();
     await element.updateComplete;
     searchAutocomplete.value = 'cond 2';
     await element.updateComplete;
-    actionInput.value = 'act 2';
+    actionInput.value = 'act-2';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
 
@@ -295,12 +295,12 @@
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1',
-        action: {name: 'act 1'},
+        action: {name: 'act-1'},
       },
       {
         condition:
           'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2',
-        action: {name: 'act 2'},
+        action: {name: 'act-2'},
       },
     ]);
   });
@@ -333,7 +333,7 @@
     // Add first stage
     searchAutocomplete.value = 'cond 1';
     await element.updateComplete;
-    actionInput.value = 'act 1';
+    actionInput.value = 'act-1';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     addButton.click();
@@ -341,13 +341,13 @@
 
     assert.equal(
       element.flowString,
-      'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1 -> act 1'
+      'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1 -> act-1'
     );
 
     // Add second stage with parameters
     searchAutocomplete.value = 'cond 2';
     await element.updateComplete;
-    actionInput.value = 'act 2';
+    actionInput.value = 'act-2';
     actionInput.dispatchEvent(new Event('input'));
     await element.updateComplete;
     paramsInput.value = 'param';
@@ -358,7 +358,7 @@
 
     assert.equal(
       element.flowString,
-      'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1 -> act 1, https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2 -> act 2(param)'
+      'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1 -> act-1;https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2 -> act-2 param'
     );
 
     // Remove first stage
@@ -368,7 +368,96 @@
 
     assert.equal(
       element.flowString,
-      'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2 -> act 2(param)'
+      'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2 -> act-2 param'
     );
   });
+
+  suite('parseStagesFromRawFlow tests', () => {
+    test('parses a single condition', async () => {
+      const rawFlow = 'cond 1';
+      element['parseStagesFromRawFlow'](rawFlow);
+      await element.updateComplete;
+      assert.deepEqual(element['stages'], [
+        {
+          condition: 'cond 1',
+          action: '',
+          parameterStr: '',
+        },
+      ]);
+    });
+
+    test('parses a single condition with action', async () => {
+      const rawFlow = 'cond 1 -> act-1';
+      element['parseStagesFromRawFlow'](rawFlow);
+      await element.updateComplete;
+      assert.deepEqual(element['stages'], [
+        {
+          condition: 'cond 1',
+          action: 'act-1',
+          parameterStr: '',
+        },
+      ]);
+    });
+
+    test('parses a single condition with action and params', async () => {
+      const rawFlow = 'cond 1 -> act-1 param1 param2';
+      element['parseStagesFromRawFlow'](rawFlow);
+      await element.updateComplete;
+      assert.deepEqual(element['stages'], [
+        {
+          condition: 'cond 1',
+          action: 'act-1',
+          parameterStr: 'param1 param2',
+        },
+      ]);
+    });
+
+    test('parses multiple stages', async () => {
+      const rawFlow = 'cond 1 -> act-1; cond 2 -> act-2 p2; cond 3';
+      element['parseStagesFromRawFlow'](rawFlow);
+      await element.updateComplete;
+      assert.deepEqual(element['stages'], [
+        {
+          condition: 'cond 1',
+          action: 'act-1',
+          parameterStr: '',
+        },
+        {
+          condition: 'cond 2',
+          action: 'act-2',
+          parameterStr: 'p2',
+        },
+        {
+          condition: 'cond 3',
+          action: '',
+          parameterStr: '',
+        },
+      ]);
+    });
+
+    test('parses an empty string', async () => {
+      const rawFlow = '';
+      element['parseStagesFromRawFlow'](rawFlow);
+      await element.updateComplete;
+      assert.deepEqual(element['stages'], []);
+    });
+
+    test('parses with extra spacing', async () => {
+      const rawFlow = '  cond 1   ->  act-1  p1 ;  cond 2  ';
+      element['parseStagesFromRawFlow'](rawFlow);
+      await element.updateComplete;
+      assert.deepEqual(element['stages'], [
+        {
+          condition: 'cond 1',
+          action: 'act-1',
+          parameterStr: 'p1',
+        },
+        {
+          condition: 'cond 2',
+          action: '',
+          parameterStr: '',
+        },
+      ]);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 5f56e51..6c2a690 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -18,6 +18,8 @@
     return account.username;
   } else if (account?.email) {
     return account.email;
+  } else if (account?.deleted) {
+    return 'Deleted User';
   } else if (
     config &&
     config.user &&
diff --git a/polygerrit-ui/app/utils/display-name-util_test.ts b/polygerrit-ui/app/utils/display-name-util_test.ts
index 143e21c..f3fa89f 100644
--- a/polygerrit-ui/app/utils/display-name-util_test.ts
+++ b/polygerrit-ui/app/utils/display-name-util_test.ts
@@ -133,6 +133,13 @@
     assert.deepEqual(getUserName(config, account), 'test-user@test-url.com');
   });
 
+  test('getUserName deleted account', () => {
+    const account: AccountInfo = {
+      deleted: true,
+    };
+    assert.deepEqual(getUserName(config, account), 'Deleted User');
+  });
+
   test('getUserName returns not Anonymous Coward as the anon name', () => {
     assert.deepEqual(getUserName(config, undefined), 'Anonymous');
   });