Merge "Add the rest api endpoint for creating project config change."
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index ab78ff0..a21ed8e 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -984,6 +984,68 @@
   }
 ----
 
+[[create-config-change]]
+=== Create Config Change for review.
+--
+'PUT /projects/link:#project-name[\{project-name\}]/config:review'
+--
+
+Sets the configuration of a project.
+
+This takes the same input as link:#set-config[Set Config], but creates a pending
+change for review. Like link:#create-change[Create Change], it returns
+a link:#change-info[ChangeInfo] entity describing the resulting change.
+
+.Request
+----
+  PUT /projects/myproject/config:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "description": "demo project",
+    "use_contributor_agreements": "FALSE",
+    "use_content_merge": "INHERIT",
+    "use_signed_off_by": "INHERIT",
+    "create_new_change_for_all_not_in_target": "INHERIT",
+    "enable_signed_push": "INHERIT",
+    "require_signed_push": "INHERIT",
+    "reject_implicit_merges": "INHERIT",
+    "require_change_id": "TRUE",
+    "max_object_size_limit": "10m",
+    "submit_type": "REBASE_IF_NECESSARY",
+    "state": "ACTIVE"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
 [[run-gc]]
 === Run GC
 --
@@ -4171,11 +4233,13 @@
 Whether empty commits should be rejected when a change is merged.
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
-|commentlinks                              |optional|
+|`commentlinks`                              |optional|
 Map of commentlink names to link:#commentlink-input[CommentLinkInput]
 entities to add or update on the project. If the given commentlink
 already exists, it will be updated with the given values, otherwise
 it will be created. If the value is null, that entry is deleted.
+|`message`           |optional|
+A commit message for this change.
 |======================================================
 
 [[config-parameter-info]]
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 906fc4c..805e769 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -40,4 +40,5 @@
   public ProjectState state;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
   public Map<String, CommentLinkInput> commentLinks;
+  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 0b1b6b0..697561b 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -59,6 +59,9 @@
   @CanIgnoreReturnValue
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
+  ChangeInfo configReview(ConfigInput in) throws RestApiException;
+
   Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException;
 
@@ -345,6 +348,11 @@
     }
 
     @Override
+    public ChangeInfo configReview(ConfigInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
         throws RestApiException {
       throw new NotImplementedException();
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5c24ddc..38b2d46 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -88,6 +88,7 @@
 import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
+import com.google.gerrit.server.restapi.project.PutConfigReview;
 import com.google.gerrit.server.restapi.project.PutDescription;
 import com.google.gerrit.server.restapi.project.SetAccess;
 import com.google.gerrit.server.restapi.project.SetHead;
@@ -127,6 +128,7 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
+  private final PutConfigReview putConfigReview;
   private final CommitsIncludedInRefs commitsIncludedInRefs;
   private final Provider<ListBranches> listBranches;
   private final Provider<ListTags> listTags;
@@ -168,6 +170,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -208,6 +211,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        putConfigReview,
         commitsIncludedInRefs,
         listBranches,
         listTags,
@@ -252,6 +256,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -292,6 +297,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        putConfigReview,
         commitsIncludedInRefs,
         listBranches,
         listTags,
@@ -335,6 +341,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -375,6 +382,7 @@
     this.setAccess = setAccess;
     this.getConfig = getConfig;
     this.putConfig = putConfig;
+    this.putConfigReview = putConfigReview;
     this.commitsIncludedInRefs = commitsIncludedInRefs;
     this.listBranches = listBranches;
     this.listTags = listTags;
@@ -519,6 +527,15 @@
   }
 
   @Override
+  public ChangeInfo configReview(ConfigInput p) throws RestApiException {
+    try {
+      return putConfigReview.apply(checkExists(), p).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put config change", e);
+    }
+  }
+
+  @Override
   public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException {
     try {
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index a7e7894..c7b905e1d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -84,6 +84,7 @@
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
+    put(PROJECT_KIND, "config:review").to(PutConfigReview.class);
 
     post(PROJECT_KIND, "create.change").to(CreateChange.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index d5f61ce..a0f174d 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -47,15 +48,14 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -65,7 +65,6 @@
 import java.util.Map;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -76,8 +75,6 @@
       Pattern.compile("^[a-zA-Z0-9]+[a-zA-Z0-9-]*$");
 
   private final boolean serverEnableSignedPush;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
@@ -88,11 +85,11 @@
   private final PermissionBackend permissionBackend;
   private final ProjectConfig.Factory projectConfigFactory;
 
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+
   @Inject
   PutConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
@@ -101,10 +98,9 @@
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user,
       PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
+      ProjectConfig.Factory projectConfigFactory,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
@@ -114,6 +110,7 @@
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -127,72 +124,74 @@
   }
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, AuthException {
     Project.NameKey projectName = projectState.getNameKey();
     if (input == null) {
       throw new BadRequestException("config is required");
     }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.updateProject(
-          p -> {
-            p.setDescription(Strings.emptyToNull(input.description));
-            for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
-              InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
-              if (val != null) {
-                p.setBooleanConfig(cfg, val);
-              }
-            }
-            if (input.maxObjectSizeLimit != null) {
-              p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
-            }
-            if (input.submitType != null) {
-              p.setSubmitType(input.submitType);
-            }
-            if (input.state != null) {
-              p.setState(input.state);
-            }
-          });
-
-      if (input.pluginConfigValues != null) {
-        setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
-      }
-
-      if (input.commentLinks != null) {
-        updateCommentLinks(projectConfig, input.commentLinks);
-      }
-
-      md.setMessage("Modified project settings\n");
-      try {
-        projectConfig.commit(md);
-        projectCache.evictAndReindex(projectConfig.getProject());
-        md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
-      } catch (IOException e) {
-        if (e.getCause() instanceof ConfigInvalidException) {
-          throw new ResourceConflictException(
-              "Cannot update " + projectName + ": " + e.getCause().getMessage());
-        }
-        logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
-        throw new ResourceConflictException("Cannot update " + projectName);
-      }
-
-      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
+    try (ConfigUpdater updater =
+        repoMetaDataUpdater.configUpdater(
+            projectName, input.commitMessage, "Modified project settings")) {
+      updateConfig(projectState, updater.getConfig(), input);
+      updater.commitConfigUpdate();
+      updater
+          .getRepository()
+          .setGitwebDescription(updater.getConfig().getProject().getDescription());
+      ProjectState newProjectState =
+          projectStateFactory.create(
+              projectConfigFactory.read(updater.getRepository(), projectName).getCacheable());
       return ConfigInfoCreator.constructInfo(
           serverEnableSignedPush,
-          state,
+          newProjectState,
           user.get(),
           pluginConfigEntries,
           cfgFactory,
           allProjects,
           uiActions,
           views);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(projectName.get(), notFound);
+
+    } catch (IOException e) {
+      if (e.getCause() instanceof ConfigInvalidException) {
+        throw new ResourceConflictException(
+            "Cannot update " + projectName + ": " + e.getCause().getMessage());
+      }
+      logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
+      throw new ResourceConflictException("Cannot update " + projectName);
     } catch (ConfigInvalidException err) {
       throw new ResourceConflictException("Cannot read project " + projectName, err);
-    } catch (IOException err) {
-      throw new ResourceConflictException("Cannot update project " + projectName, err);
+    }
+  }
+
+  public void updateConfig(
+      ProjectState projectState, ProjectConfig projectConfig, ConfigInput input)
+      throws BadRequestException {
+    projectConfig.updateProject(
+        p -> {
+          p.setDescription(Strings.emptyToNull(input.description));
+          for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+            InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+            if (val != null) {
+              p.setBooleanConfig(cfg, val);
+            }
+          }
+          if (input.maxObjectSizeLimit != null) {
+            p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+          }
+          if (input.submitType != null) {
+            p.setSubmitType(input.submitType);
+          }
+          if (input.state != null) {
+            p.setState(input.state);
+          }
+        });
+
+    if (input.pluginConfigValues != null) {
+      setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
+    }
+
+    if (input.commentLinks != null) {
+      updateCommentLinks(projectConfig, input.commentLinks);
     }
   }
 
@@ -302,7 +301,7 @@
     }
   }
 
-  private void updateCommentLinks(
+  private static void updateCommentLinks(
       ProjectConfig projectConfig, Map<String, CommentLinkInput> input) {
     for (Map.Entry<String, CommentLinkInput> e : input.entrySet()) {
       String name = e.getKey();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
new file mode 100644
index 0000000..5c51003
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 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.project;
+
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutConfigReview implements RestModifyView<ProjectResource, ConfigInput> {
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PutConfig putConfig;
+
+  @Inject
+  PutConfigReview(RepoMetaDataUpdater repoMetaDataUpdater, PutConfig putConfig) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.putConfig = putConfig;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ConfigInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review config change")) {
+      putConfig.updateConfig(rsrc.getProjectState(), creator.getConfig(), input);
+      return creator.createChange();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
new file mode 100644
index 0000000..694cfc9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2024 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ConfigReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private Project.NameKey defaultMessageProject;
+  private Project.NameKey customMessageProject;
+
+  @Before
+  public void setUp() throws Exception {
+    defaultMessageProject = projectOperations.newProject().create();
+    customMessageProject = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void createConfigChangeWithDefaultMessage() throws Exception {
+    ConfigInput in = new ConfigInput();
+    in.description = "Test project description";
+
+    ChangeInfo changeInfo = gApi.projects().name(defaultMessageProject.get()).configReview(in);
+
+    assertThat(changeInfo.subject).isEqualTo("Review config change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("project", null, "description"))
+        .isEqualTo("Test project description");
+  }
+
+  @Test
+  public void createConfigChangeWithCustomMessage() throws Exception {
+    ConfigInput in = new ConfigInput();
+    in.description = "Test project description";
+    String customMessage = "test custom message";
+    in.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(customMessageProject.get()).configReview(in);
+
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("project", null, "description"))
+        .isEqualTo("Test project description");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index db9c1e7..f00804d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -70,6 +70,7 @@
           RestCall.get("/projects/%s/commits:in"),
           RestCall.get("/projects/%s/config"),
           RestCall.put("/projects/%s/config"),
+          RestCall.put("/projects/%s/config:review"),
           RestCall.post("/projects/%s/create.change"),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.get("/projects/%s/description"),