Allow to create a change instead of updating project config directly.

In the [1] the new config is added for disabling project update without
creating a change. This change makes required UI changes to support this
functionality:

1. "Save for review" button is added to the project config page.
2. The "Save" buttons are automatically disabled if updating project
   config without creating a change is not allowed.

[1] https://gerrit-review.git.corp.google.com/c/gerrit/+/425807

Release-Notes: "Save for review" button was added into the project config page.
Google-Bug-Id: b/330832714
Change-Id: I63d02112999c91a66d3e8fdce52724161385aeff
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 615528c..ff87849 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -74,6 +74,8 @@
   // private but used in test
   @state() canUpload?: boolean = false; // restAPI can return undefined
 
+  @state() disableSaveWithoutReview = true;
+
   // private but used in test
   @state() inheritFromFilter?: RepoName;
 
@@ -238,7 +240,7 @@
                 ? 'invisible'
                 : ''}
               primary
-              ?disabled=${!this.modified}
+              ?disabled=${!this.modified || this.disableSaveWithoutReview}
               @click=${this.handleSave}
               >Save</gr-button
             >
@@ -343,6 +345,7 @@
         this.groups = res.groups;
         this.weblinks = res.config_web_links || [];
         this.canUpload = res.can_upload;
+        this.disableSaveWithoutReview = !!res.require_change_for_config_update;
         this.ownerOf = res.owner_of || [];
         return toSortedPermissionsArray(this.local);
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 467857d..495de52 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -157,12 +157,13 @@
                 Edit
               </gr-button>
               <gr-button
-                aria-disabled="false"
+                aria-disabled="true"
+                disabled=""
                 class="invisible"
                 id="saveBtn"
                 primary=""
                 role="button"
-                tabindex="0"
+                tabindex="-1"
               >
                 Save
               </gr-button>
@@ -1445,9 +1446,18 @@
       );
 
       element.repo = 'test-repo' as RepoName;
+      await element.updateComplete;
       sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
-
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
       element.modified = true;
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        false
+      );
       queryAndAssert<GrButton>(element, '#saveBtn').click();
       await element.updateComplete;
       assert.equal(
@@ -1460,6 +1470,58 @@
       assert.isTrue(setUrlStub.notCalled);
     });
 
+    test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(
+          JSON.parse(
+            JSON.stringify({
+              ...accessRes,
+              require_change_for_config_update: true,
+            })
+          )
+        )
+      );
+
+      element.repo = 'test-repo' as RepoName;
+      await element.updateComplete;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
+      element.modified = true;
+      await element.updateComplete;
+      assert.equal(element.disableSaveWithoutReview, true);
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
+    });
+
     test('handleSaveForReview', async () => {
       const repoAccessInput = {
         add: {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 2251321..6e1aba7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -11,6 +11,7 @@
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   ConfigInfo,
   RepoName,
@@ -39,11 +40,14 @@
 import {deepClone} from '../../../utils/deep-util';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {createChangeUrl} from '../../../models/views/change';
 import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
 import {userModelToken} from '../../../models/user/user-model';
 import {resolve} from '../../../models/dependency';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const STATES = {
   active: {value: RepoState.ACTIVE, label: 'Active'},
@@ -101,6 +105,11 @@
   // private but used in test
   @state() readOnly = true;
 
+  @state() showSaveForReviewButton = false;
+
+  // private but used in test
+  @state() disableSaveWithoutReview = true;
+
   @state() private states = Object.values(STATES);
 
   @state() private originalConfig?: ConfigInfo;
@@ -114,10 +123,14 @@
 
   @state() private pluginConfigChanged = false;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     subscribe(
@@ -195,10 +208,20 @@
                 ${this.renderDescription()} ${this.renderRepoOptions()}
                 ${this.renderPluginConfig()}
                 <gr-button
-                  ?disabled=${this.readOnly || !configChanged}
+                  id="saveBtn"
+                  ?disabled=${this.readOnly ||
+                  this.disableSaveWithoutReview ||
+                  !configChanged}
                   @click=${this.handleSaveRepoConfig}
                   >Save changes</gr-button
                 >
+                <gr-button
+                  id="saveReviewBtn"
+                  ?disabled=${this.readOnly || !configChanged}
+                  ?hidden=${!this.showSaveForReviewButton}
+                  @click=${this.handleSaveRepoConfigForReview}
+                  >Save for review</gr-button
+                >
               </fieldset>
               <gr-endpoint-decorator name="repo-config">
                 <gr-endpoint-param
@@ -781,6 +804,11 @@
 
             // If the user is not an owner, is_owner is not a property.
             this.readOnly = !access[repo]?.is_owner;
+            this.showSaveForReviewButton = this.flagsService.isEnabled(
+              KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+            );
+            this.disableSaveWithoutReview =
+              !!access[repo]?.require_change_for_config_update;
           });
         }
       })
@@ -925,6 +953,32 @@
     return;
   }
 
+  async handleSaveRepoConfigForReview(e: Event) {
+    if (!this.repoConfig || !this.repo)
+      return Promise.reject(new Error('undefined repoConfig or repo'));
+    const button = e && (e.target as GrButton);
+    if (button) {
+      button.loading = true;
+    }
+
+    return this.restApiService
+      .saveRepoConfigForReview(
+        this.repo,
+        this.formatRepoConfigForSave(this.repoConfig)
+      )
+      .then(change => {
+        // Don't navigate on server error.
+        if (change) {
+          this.getNavigation().setUrl(createChangeUrl({change}));
+        }
+      })
+      .finally(() => {
+        if (button) {
+          button.loading = false;
+        }
+      });
+  }
+
   private isEdited(
     original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
     repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 0d30933..a4e0f09 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -6,6 +6,8 @@
 import '../../../test/common-test-setup';
 import './gr-repo';
 import {GrRepo} from './gr-repo';
+import {createChange} from '../../../test/test-data-generators';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {mockPromise} from '../../../test/test-utils';
 import {
   addListenerForTest,
@@ -17,6 +19,7 @@
   createInheritedBoolean,
   createServerInfo,
 } from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
 import {
   ConfigInfo,
   GitRef,
@@ -25,6 +28,7 @@
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
+  ProjectAccessInfo,
   RepoAccessGroups,
   RepoAccessInfoMap,
   RepoName,
@@ -32,6 +36,7 @@
 import {
   ConfigParameterInfoType,
   InheritedBooleanInfoConfiguredValue,
+  PermissionAction,
   RepoState,
   SubmitType,
 } from '../../../constants/constants';
@@ -45,6 +50,9 @@
 import {GrSuggestionTextarea} from '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
 import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {ChangeInfo} from '../../../api/rest-api';
 
 suite('gr-repo tests', () => {
   let element: GrRepo;
@@ -375,6 +383,7 @@
                 </section>
               </fieldset>
               <gr-button
+                id="saveBtn"
                 aria-disabled="true"
                 disabled=""
                 role="button"
@@ -382,6 +391,16 @@
               >
                 Save changes
               </gr-button>
+              <gr-button
+                id="saveReviewBtn"
+                aria-disabled="true"
+                disabled=""
+                hidden=""
+                role="button"
+                tabindex="-1"
+              >
+                Save for review
+              </gr-button>
             </fieldset>
             <gr-endpoint-decorator name="repo-config">
               <gr-endpoint-param name="repoName"> </gr-endpoint-param>
@@ -633,31 +652,35 @@
   });
 
   suite('admin', () => {
+    const testRepoAccess: ProjectAccessInfo = {
+      revision: 'xxxx',
+      local: {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {xxx: {action: PermissionAction.ALLOW, force: false}},
+            },
+          },
+        },
+      },
+      is_owner: true,
+      owner_of: ['refs/*'] as GitRef[],
+      groups: {
+        xxxx: {
+          id: 'xxxx' as GroupId,
+          url: 'test',
+          name: 'test' as GroupName,
+        },
+      } as RepoAccessGroups,
+      config_web_links: [{name: 'gitiles', url: 'test'}],
+    };
+    let getRepoAccessStub: sinon.SinonStub;
     setup(() => {
       element.repo = REPO as RepoName;
       loggedInStub.returns(Promise.resolve(true));
-      stubRestApi('getRepoAccess').callsFake(() =>
+      getRepoAccessStub = stubRestApi('getRepoAccess').callsFake(() =>
         Promise.resolve({
-          'test-repo': {
-            revision: 'xxxx',
-            local: {
-              'refs/*': {
-                permissions: {
-                  owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
-                },
-              },
-            },
-            is_owner: true,
-            owner_of: ['refs/*'] as GitRef[],
-            groups: {
-              xxxx: {
-                id: 'xxxx' as GroupId,
-                url: 'test',
-                name: 'test' as GroupName,
-              },
-            } as RepoAccessGroups,
-            config_web_links: [{name: 'gitiles', url: 'test'}],
-          },
+          'test-repo': testRepoAccess,
         } as RepoAccessInfoMap)
       );
     });
@@ -720,7 +743,7 @@
 
       await element.loadRepo();
 
-      const button = queryAll<GrButton>(element, 'gr-button')[2];
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
@@ -800,5 +823,76 @@
         saveStub.lastCall.calledWithExactly(REPO as RepoName, configInputObj)
       );
     });
+
+    test('saveReviewBtn visible when experiment is enabled', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      await element.loadRepo();
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(
+        element,
+        'gr-button#saveReviewBtn'
+      );
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+
+    test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      getRepoAccessStub.callsFake(() =>
+        Promise.resolve({
+          'test-repo': {
+            ...testRepoAccess,
+            require_change_for_config_update: true,
+          },
+        } as RepoAccessInfoMap)
+      );
+      await element.loadRepo();
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+      assert.isTrue(button.hasAttribute('disabled'));
+    });
+
+    test('saveReviewBtn', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
+      const saveForReviewStub = stubRestApi('saveRepoConfigForReview').returns(
+        new Promise(r => (resolver = r))
+      );
+      resolver!(createChange());
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      await element.loadRepo();
+      await element.updateComplete;
+      const input = queryAndAssert<GrSuggestionTextarea>(
+        element,
+        '#descriptionInput'
+      );
+      input.text = 'New description';
+      await input.updateComplete;
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+      assert.isFalse(button.hasAttribute('disabled'));
+      queryAndAssert<GrButton>(element, 'gr-button#saveReviewBtn').click();
+      await element.updateComplete;
+      assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(
+        setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index ee1d813..7f3f9f5 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -24,4 +24,5 @@
   REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
   COMMENT_AUTOCOMPLETION = 'UiFeature__comment_autocompletion_enabled',
   GR_TEXTAREA = 'UiFeature__gr_textarea_enabled',
+  SAVE_PROJECT_CONFIG_FOR_REVIEW = 'UiFeature__save_project_config_for_review',
 }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 7a12e7a..b7eb7ac 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -348,6 +348,22 @@
     });
   }
 
+  saveRepoConfigForReview(
+    repo: RepoName,
+    config: ConfigInput
+  ): Promise<ChangeInfo | undefined> {
+    const url = `/projects/${encodeURIComponent(repo)}/config:review`;
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: config,
+      }),
+      url,
+      anonymizedUrl: '/projects/*/config',
+      reportServerError: true,
+    }) as unknown as Promise<ChangeInfo | undefined>;
+  }
+
   runRepoGC(repo: RepoName): Promise<Response> {
     const encodeName = encodeURIComponent(repo);
     return this._restApiHelper.fetch({
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 814e97a..66a60a6 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -645,6 +645,10 @@
   deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
   deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
   saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+  saveRepoConfigForReview(
+    repo: RepoName,
+    config: ConfigInput
+  ): Promise<ChangeInfo | undefined>;
 
   getRelatedChanges(
     changeNum: NumericChangeId,
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 570b50a..c1fe4ef 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -519,6 +519,9 @@
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  saveRepoConfigForReview(): Promise<ChangeInfo | undefined> {
+    throw new Error('saveRepoConfigForReview() not implemented by mock.');
+  },
   saveWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 072a872..720899f 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -986,6 +986,7 @@
   can_add?: boolean;
   can_add_tags?: boolean;
   config_visible?: boolean;
+  require_change_for_config_update?: boolean;
   groups: RepoAccessGroups;
   config_web_links: WebLinkInfo[];
 }