From 0b12a5312c9701fbfed25fbb334d47900ced736b Mon Sep 17 00:00:00 2001
From: GitLab Bot <gitlab-bot@gitlab.com>
Date: Tue, 14 Jan 2020 21:07:45 +0000
Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master

---
 Gemfile.lock                                  |   2 +-
 .../deploy_keys/components/key.vue            |   6 +-
 .../javascripts/diffs/components/app.vue      |   8 +-
 .../diffs/components/compare_versions.vue     |   6 +-
 app/assets/javascripts/diffs/store/actions.js |   8 +-
 app/assets/javascripts/diffs/store/getters.js |   2 -
 .../javascripts/diffs/store/mutations.js      |  11 +-
 .../ide/components/new_dropdown/modal.vue     |  25 +-
 app/assets/javascripts/ide/stores/actions.js  | 101 +++----
 .../javascripts/ide/stores/actions/project.js |   5 +-
 .../pages/admin/application_settings/index.js |   4 +
 .../components/self_monitor_form.vue          | 160 +++++++++++
 app/assets/javascripts/self_monitor/index.js  |  23 ++
 .../javascripts/self_monitor/store/actions.js | 126 +++++++++
 .../javascripts/self_monitor/store/index.js   |  21 ++
 .../self_monitor/store/mutation_types.js      |   6 +
 .../self_monitor/store/mutations.js           |  22 ++
 .../javascripts/self_monitor/store/state.js   |  15 ++
 app/helpers/markup_helper.rb                  |   2 +-
 .../metrics_and_profiling.html.haml           |   3 +
 .../195776-fix-discard-rename-web-ide.yml     |   5 +
 .../unreleased/34522-webide-empty-repos.yml   |   5 +
 ...e-count-increments-while-batch-loading.yml |   5 +
 ...user-to-a-group-or-project-through-api.yml |   5 +
 .../ab-projects-api-more-indexes.yml          |   5 +
 changelogs/unreleased/add-geo-node-api.yml    |   5 +
 .../dblessing_update_net_ldap_gem.yml         |   5 +
 .../split-up-relativelinkfilter.yml           |   5 +
 db/fixtures/development/17_cycle_analytics.rb |   2 +-
 ...0110144316_add_indexes_for_projects_api.rb |  31 +++
 db/schema.rb                                  |  14 +-
 .../geo/replication/troubleshooting.md        |   2 +-
 doc/api/geo_nodes.md                          |  48 ++++
 doc/development/packages.md                   |   1 +
 doc/user/application_security/sast/index.md   |  51 ++++
 doc/user/packages/npm_registry/index.md       |  40 +--
 lib/api/helpers/members_helpers.rb            |   4 +
 lib/api/members.rb                            |   4 +-
 .../filter/base_relative_link_filter.rb       |  45 ++++
 ...nk_filter.rb => repository_link_filter.rb} |  83 +-----
 lib/banzai/filter/upload_link_filter.rb       |  61 +++++
 lib/banzai/pipeline/post_process_pipeline.rb  |   5 +-
 lib/banzai/pipeline/relative_link_pipeline.rb |  13 -
 .../migrate_fingerprint_sha256_within_keys.rb |  16 +-
 locale/gitlab.pot                             |  38 ++-
 qa/qa/page/project/settings/deploy_keys.rb    |  16 +-
 .../project/settings/protected_branches.rb    |   2 +-
 qa/qa/resource/deploy_key.rb                  |   4 +-
 qa/qa/resource/ssh_key.rb                     |   2 +-
 qa/qa/runtime/key/base.rb                     |   4 +-
 .../3_create/repository/add_ssh_key_spec.rb   |   4 +-
 .../deploy_key/add_deploy_key_spec.rb         |   4 +-
 spec/features/markdown/markdown_spec.rb       |  18 +-
 spec/fixtures/markdown.md.erb                 |   8 +-
 .../diffs/components/compare_versions_spec.js |   1 +
 .../__snapshots__/self_monitor_spec.js.snap   |  72 +++++
 .../components/self_monitor_spec.js           |  83 ++++++
 .../self_monitor/store/actions_spec.js        | 255 ++++++++++++++++++
 .../self_monitor/store/mutations_spec.js      |  64 +++++
 spec/javascripts/diffs/components/app_spec.js |   8 +-
 spec/javascripts/diffs/store/actions_spec.js  |   7 +
 spec/javascripts/diffs/store/getters_spec.js  |   8 -
 .../ide/components/new_dropdown/modal_spec.js |  43 +--
 .../ide/stores/actions/project_spec.js        |  37 ++-
 spec/javascripts/ide/stores/actions_spec.js   | 245 ++++++++++++++---
 ...spec.rb => repository_link_filter_spec.rb} | 174 +-----------
 .../banzai/filter/upload_link_filter_spec.rb  | 221 +++++++++++++++
 .../pipeline/post_process_pipeline_spec.rb    |  26 ++
 ...ate_fingerprint_sha256_within_keys_spec.rb |  19 ++
 spec/support/matchers/markdown_matchers.rb    |  17 +-
 70 files changed, 1885 insertions(+), 511 deletions(-)
 create mode 100644 app/assets/javascripts/self_monitor/components/self_monitor_form.vue
 create mode 100644 app/assets/javascripts/self_monitor/index.js
 create mode 100644 app/assets/javascripts/self_monitor/store/actions.js
 create mode 100644 app/assets/javascripts/self_monitor/store/index.js
 create mode 100644 app/assets/javascripts/self_monitor/store/mutation_types.js
 create mode 100644 app/assets/javascripts/self_monitor/store/mutations.js
 create mode 100644 app/assets/javascripts/self_monitor/store/state.js
 create mode 100644 changelogs/unreleased/195776-fix-discard-rename-web-ide.yml
 create mode 100644 changelogs/unreleased/34522-webide-empty-repos.yml
 create mode 100644 changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml
 create mode 100644 changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml
 create mode 100644 changelogs/unreleased/ab-projects-api-more-indexes.yml
 create mode 100644 changelogs/unreleased/add-geo-node-api.yml
 create mode 100644 changelogs/unreleased/dblessing_update_net_ldap_gem.yml
 create mode 100644 changelogs/unreleased/split-up-relativelinkfilter.yml
 create mode 100644 db/migrate/20200110144316_add_indexes_for_projects_api.rb
 create mode 100644 lib/banzai/filter/base_relative_link_filter.rb
 rename lib/banzai/filter/{relative_link_filter.rb => repository_link_filter.rb} (72%)
 create mode 100644 lib/banzai/filter/upload_link_filter.rb
 delete mode 100644 lib/banzai/pipeline/relative_link_pipeline.rb
 create mode 100644 spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap
 create mode 100644 spec/frontend/self_monitor/components/self_monitor_spec.js
 create mode 100644 spec/frontend/self_monitor/store/actions_spec.js
 create mode 100644 spec/frontend/self_monitor/store/mutations_spec.js
 rename spec/lib/banzai/filter/{relative_link_filter_spec.rb => repository_link_filter_spec.rb} (66%)
 create mode 100644 spec/lib/banzai/filter/upload_link_filter_spec.rb
 create mode 100644 spec/lib/banzai/pipeline/post_process_pipeline_spec.rb

diff --git a/Gemfile.lock b/Gemfile.lock
index f55fb554c4a..2a94b907417 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -629,7 +629,7 @@ GEM
     nakayoshi_fork (0.0.4)
     nap (1.1.0)
     nenv (0.3.0)
-    net-ldap (0.16.0)
+    net-ldap (0.16.2)
     net-ntp (2.1.3)
     net-ssh (5.2.0)
     netrc (0.11.0)
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 74f1373f144..c856e380c41 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -115,12 +115,10 @@ export default {
       <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
       <div class="table-mobile-content qa-key">
         <strong class="title qa-key-title"> {{ deployKey.title }} </strong>
-        <div class="fingerprint qa-key-fingerprint">
+        <div class="fingerprint" data-qa-selector="key_md5_fingerprint">
           {{ __('MD5') }}:{{ deployKey.fingerprint }}
         </div>
-        <div class="fingerprint qa-key-fingerprint">
-          {{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}
-        </div>
+        <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div>
       </div>
     </div>
     <div class="table-section section-30 section-wrap">
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index c6d32ffef34..23b8458aa6b 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -95,6 +95,7 @@ export default {
 
     return {
       treeWidth,
+      diffFilesLength: 0,
     };
   },
   computed: {
@@ -241,7 +242,8 @@ export default {
     fetchData(toggleTree = true) {
       if (this.glFeatures.diffsBatchLoad) {
         this.fetchDiffFilesMeta()
-          .then(() => {
+          .then(({ real_size }) => {
+            this.diffFilesLength = parseInt(real_size, 10);
             if (toggleTree) this.hideTreeListIfJustOneFile();
 
             this.startDiffRendering();
@@ -264,7 +266,8 @@ export default {
           });
       } else {
         this.fetchDiffFiles()
-          .then(() => {
+          .then(({ real_size }) => {
+            this.diffFilesLength = parseInt(real_size, 10);
             if (toggleTree) {
               this.hideTreeListIfJustOneFile();
             }
@@ -351,6 +354,7 @@ export default {
         :merge-request-diff="mergeRequestDiff"
         :target-branch="targetBranch"
         :is-limited-container="isLimitedContainer"
+        :diff-files-length="diffFilesLength"
       />
 
       <hidden-files-warning
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 2e57a47f2f7..24542126b07 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -42,9 +42,13 @@ export default {
       required: false,
       default: false,
     },
+    diffFilesLength: {
+      type: Number,
+      required: true,
+    },
   },
   computed: {
-    ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']),
+    ...mapGetters('diffs', ['hasCollapsedFile']),
     ...mapState('diffs', [
       'commit',
       'showTreeList',
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 6714f4e62b8..b920e041135 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -64,6 +64,7 @@ export const fetchDiffFiles = ({ state, commit }) => {
   const urlParams = {
     w: state.showWhitespace ? '0' : '1',
   };
+  let returnData;
 
   if (state.useSingleDiffStyle) {
     urlParams.view = state.diffViewType;
@@ -87,9 +88,13 @@ export const fetchDiffFiles = ({ state, commit }) => {
 
       worker.postMessage(state.diffFiles);
 
+      returnData = res.data;
       return Vue.nextTick();
     })
-    .then(handleLocationHash)
+    .then(() => {
+      handleLocationHash();
+      return returnData;
+    })
     .catch(() => worker.terminate());
 };
 
@@ -147,6 +152,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
 
       prepareDiffData(data);
       worker.postMessage(data.diff_files);
+      return data;
     })
     .catch(() => worker.terminate());
 };
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index bc27e263bff..c4737090a70 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -95,8 +95,6 @@ export const allBlobs = (state, getters) =>
     return acc;
   }, []);
 
-export const diffFilesLength = state => state.diffFiles.length;
-
 export const getCommentFormForDiffFile = state => fileHash =>
   state.commentForms.find(form => form.fileHash === fileHash);
 
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 8cfdded1f9b..1505be1a0b2 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -179,16 +179,19 @@ export default {
     const mapDiscussions = (line, extraCheck = () => true) => ({
       ...line,
       discussions: extraCheck()
-        ? line.discussions
+        ? line.discussions &&
+          line.discussions
             .filter(() => !line.discussions.some(({ id }) => discussion.id === id))
             .concat(lineCheck(line) ? discussion : line.discussions)
         : [],
     });
 
     const setDiscussionsExpanded = line => {
-      const isLineNoteTargeted = line.discussions.some(
-        disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
-      );
+      const isLineNoteTargeted =
+        line.discussions &&
+        line.discussions.some(
+          disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
+        );
 
       return {
         ...line,
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index ecafb4e81c4..bf3d736ddf3 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -67,8 +67,8 @@ export default {
       if (this.entryModal.type === modalTypes.rename) {
         if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
           flash(
-            sprintf(s__('The name %{entryName} is already taken in this directory.'), {
-              entryName: this.entryName,
+            sprintf(s__('The name "%{name}" is already taken in this directory.'), {
+              name: this.entryName,
             }),
             'alert',
             document,
@@ -81,22 +81,11 @@ export default {
           const entryName = parentPath.pop();
           parentPath = parentPath.join('/');
 
-          const createPromise =
-            parentPath && !this.entries[parentPath]
-              ? this.createTempEntry({ name: parentPath, type: 'tree' })
-              : Promise.resolve();
-
-          createPromise
-            .then(() =>
-              this.renameEntry({
-                path: this.entryModal.entry.path,
-                name: entryName,
-                parentPath,
-              }),
-            )
-            .catch(() =>
-              flash(__('Error creating a new path'), 'alert', document, null, false, true),
-            );
+          this.renameEntry({
+            path: this.entryModal.entry.path,
+            name: entryName,
+            parentPath,
+          });
         }
       } else {
         this.createTempEntry({
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 7ffb430296b..3445ef7a75f 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -53,60 +53,55 @@ export const setResizingStatus = ({ commit }, resizing) => {
 export const createTempEntry = (
   { state, commit, dispatch },
   { name, type, content = '', base64 = false, binary = false, rawPath = '' },
-) =>
-  new Promise(resolve => {
-    const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
-
-    if (state.entries[name] && !state.entries[name].deleted) {
-      flash(
-        `The name "${name.split('/').pop()}" is already taken in this directory.`,
-        'alert',
-        document,
-        null,
-        false,
-        true,
-      );
-
-      resolve();
-
-      return null;
-    }
-
-    const data = decorateFiles({
-      data: [fullName],
-      projectId: state.currentProjectId,
-      branchId: state.currentBranchId,
-      type,
-      tempFile: true,
-      content,
-      base64,
-      binary,
-      rawPath,
-    });
-    const { file, parentPath } = data;
+) => {
+  const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
+
+  if (state.entries[name] && !state.entries[name].deleted) {
+    flash(
+      sprintf(__('The name "%{name}" is already taken in this directory.'), {
+        name: name.split('/').pop(),
+      }),
+      'alert',
+      document,
+      null,
+      false,
+      true,
+    );
 
-    commit(types.CREATE_TMP_ENTRY, {
-      data,
-      projectId: state.currentProjectId,
-      branchId: state.currentBranchId,
-    });
+    return;
+  }
 
-    if (type === 'blob') {
-      commit(types.TOGGLE_FILE_OPEN, file.path);
-      commit(types.ADD_FILE_TO_CHANGED, file.path);
-      dispatch('setFileActive', file.path);
-      dispatch('triggerFilesChange');
-      dispatch('burstUnusedSeal');
-    }
+  const data = decorateFiles({
+    data: [fullName],
+    projectId: state.currentProjectId,
+    branchId: state.currentBranchId,
+    type,
+    tempFile: true,
+    content,
+    base64,
+    binary,
+    rawPath,
+  });
+  const { file, parentPath } = data;
 
-    if (parentPath && !state.entries[parentPath].opened) {
-      commit(types.TOGGLE_TREE_OPEN, parentPath);
-    }
+  commit(types.CREATE_TMP_ENTRY, {
+    data,
+    projectId: state.currentProjectId,
+    branchId: state.currentBranchId,
+  });
 
-    resolve(file);
+  if (type === 'blob') {
+    commit(types.TOGGLE_FILE_OPEN, file.path);
+    commit(types.ADD_FILE_TO_CHANGED, file.path);
+    dispatch('setFileActive', file.path);
+    dispatch('triggerFilesChange');
+    dispatch('burstUnusedSeal');
+  }
 
-    return null;
-  });
+  if (parentPath && !state.entries[parentPath].opened) {
+    commit(types.TOGGLE_TREE_OPEN, parentPath);
+  }
+};
 
 export const scrollToTab = () => {
   Vue.nextTick(() => {
@@ -211,8 +206,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
   const entry = state.entries[path];
   const { prevPath, prevName, prevParentPath } = entry;
   const isTree = entry.type === 'tree';
+  const prevEntry = prevPath && state.entries[prevPath];
 
-  if (prevPath) {
+  if (prevPath && (!prevEntry || prevEntry.deleted)) {
     dispatch('renameEntry', {
       path,
       name: prevName,
@@ -245,6 +241,11 @@ export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
 export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => {
   const entry = state.entries[path];
   const newPath = parentPath ? `${parentPath}/${name}` : name;
+  const existingParent = parentPath && state.entries[parentPath];
+
+  if (parentPath && (!existingParent || existingParent.deleted)) {
+    dispatch('createTempEntry', { name: parentPath, type: 'tree' });
+  }
 
   commit(types.RENAME_ENTRY, { path, name, parentPath });
 
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 52bf9becd0f..e206f9bee9e 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -83,8 +83,11 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => {
   });
 };
 
-export const showEmptyState = ({ commit, state }, { projectId, branchId }) => {
+export const showEmptyState = ({ commit, state, dispatch }, { projectId, branchId }) => {
   const treePath = `${projectId}/${branchId}`;
+
+  dispatch('setCurrentBranchId', branchId);
+
   commit(types.CREATE_TREE, { treePath });
   commit(types.TOGGLE_LOADING, {
     entry: state.trees[treePath],
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 47bd70537f1..089dedd14cb 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,7 +1,11 @@
 import initSettingsPanels from '~/settings_panels';
 import projectSelect from '~/project_select';
+import selfMonitor from '~/self_monitor';
 
 document.addEventListener('DOMContentLoaded', () => {
+  if (gon.features && gon.features.selfMonitoringProject) {
+    selfMonitor();
+  }
   // Initialize expandable settings panels
   initSettingsPanels();
   projectSelect();
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
new file mode 100644
index 00000000000..2f364eae67f
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -0,0 +1,160 @@
+<script>
+import Vue from 'vue';
+import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { __, s__, sprintf } from '~/locale';
+import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
+
+Vue.use(GlToast);
+
+export default {
+  components: {
+    GlFormGroup,
+    GlButton,
+    GlModal,
+    GlToggle,
+  },
+  formLabels: {
+    createProject: __('Create Project'),
+  },
+  data() {
+    return {
+      modalId: 'delete-self-monitor-modal',
+    };
+  },
+  computed: {
+    ...mapState('selfMonitoring', [
+      'projectEnabled',
+      'projectCreated',
+      'showAlert',
+      'projectPath',
+      'loading',
+      'alertContent',
+    ]),
+    selfMonitorEnabled: {
+      get() {
+        return this.projectEnabled;
+      },
+      set(projectEnabled) {
+        this.setSelfMonitor(projectEnabled);
+      },
+    },
+    selfMonitorProjectFullUrl() {
+      return `${getBaseURL()}/${this.projectPath}`;
+    },
+    selfMonitoringFormText() {
+      if (this.projectCreated) {
+        return sprintf(
+          s__(
+            'SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance.',
+          ),
+          {
+            projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`,
+            projectLinkEnd: '</a>',
+          },
+          false,
+        );
+      }
+
+      return s__(
+        'SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance.',
+      );
+    },
+  },
+  watch: {
+    selfMonitorEnabled() {
+      this.saveChangesSelfMonitorProject();
+    },
+    showAlert() {
+      let toastOptions = {
+        onComplete: () => {
+          this.resetAlert();
+        },
+      };
+
+      if (this.showAlert) {
+        if (this.alertContent.actionName && this.alertContent.actionName.length > 0) {
+          toastOptions = {
+            ...toastOptions,
+            action: {
+              text: this.alertContent.actionText,
+              onClick: (_, toastObject) => {
+                this[this.alertContent.actionName]();
+                toastObject.goAway(0);
+              },
+            },
+          };
+        }
+        this.$toast.show(this.alertContent.message, toastOptions);
+      }
+    },
+  },
+  methods: {
+    ...mapActions('selfMonitoring', [
+      'setSelfMonitor',
+      'createProject',
+      'deleteProject',
+      'resetAlert',
+    ]),
+    hideSelfMonitorModal() {
+      this.$root.$emit('bv::hide::modal', this.modalId);
+      this.setSelfMonitor(true);
+    },
+    showSelfMonitorModal() {
+      this.$root.$emit('bv::show::modal', this.modalId);
+    },
+    saveChangesSelfMonitorProject() {
+      if (this.projectCreated && !this.projectEnabled) {
+        this.showSelfMonitorModal();
+      } else {
+        this.createProject();
+      }
+    },
+    viewSelfMonitorProject() {
+      visitUrl(this.selfMonitorProjectFullUrl);
+    },
+  },
+};
+</script>
+<template>
+  <section class="settings no-animate js-self-monitoring-settings">
+    <div class="settings-header">
+      <h4 class="js-section-header">
+        {{ s__('SelfMonitoring|Self monitoring') }}
+      </h4>
+      <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
+      <p class="js-section-sub-header">
+        {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }}
+      </p>
+    </div>
+    <div class="settings-content">
+      <form name="self-monitoring-form">
+        <p v-html="selfMonitoringFormText"></p>
+        <gl-form-group :label="$options.formLabels.createProject" label-for="self-monitor-toggle">
+          <gl-toggle
+            v-model="selfMonitorEnabled"
+            :is-loading="loading"
+            name="self-monitor-toggle"
+          />
+        </gl-form-group>
+      </form>
+    </div>
+    <gl-modal
+      :title="s__('SelfMonitoring|Disable self monitoring?')"
+      :modal-id="modalId"
+      :ok-title="__('Delete project')"
+      :cancel-title="__('Cancel')"
+      ok-variant="danger"
+      @ok="deleteProject"
+      @cancel="hideSelfMonitorModal"
+    >
+      <div>
+        {{
+          s__(
+            'SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?',
+          )
+        }}
+      </div>
+    </gl-modal>
+  </section>
+</template>
diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js
new file mode 100644
index 00000000000..42c94e11989
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import store from './store';
+import SelfMonitorForm from './components/self_monitor_form.vue';
+
+export default () => {
+  const el = document.querySelector('.js-self-monitoring-settings');
+  let selfMonitorProjectCreated;
+
+  if (el) {
+    selfMonitorProjectCreated = el.dataset.selfMonitoringProjectExists;
+    // eslint-disable-next-line no-new
+    new Vue({
+      el,
+      store: store({
+        projectEnabled: selfMonitorProjectCreated,
+        ...el.dataset,
+      }),
+      render(createElement) {
+        return createElement(SelfMonitorForm);
+      },
+    });
+  }
+};
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
new file mode 100644
index 00000000000..f8430a9b136
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/actions.js
@@ -0,0 +1,126 @@
+import { __, s__ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+
+const TWO_MINUTES = 120000;
+
+function backOffRequest(makeRequestCallback) {
+  return backOff((next, stop) => {
+    makeRequestCallback()
+      .then(resp => {
+        if (resp.status === statusCodes.ACCEPTED) {
+          next();
+        } else {
+          stop(resp);
+        }
+      })
+      .catch(stop);
+  }, TWO_MINUTES);
+}
+
+export const setSelfMonitor = ({ commit }, enabled) => commit(types.SET_ENABLED, enabled);
+
+export const createProject = ({ dispatch }) => dispatch('requestCreateProject');
+
+export const resetAlert = ({ commit }) => commit(types.SET_SHOW_ALERT, false);
+
+export const requestCreateProject = ({ dispatch, state, commit }) => {
+  commit(types.SET_LOADING, true);
+  axios
+    .post(state.createProjectEndpoint)
+    .then(resp => {
+      if (resp.status === statusCodes.ACCEPTED) {
+        dispatch('requestCreateProjectStatus', resp.data.job_id);
+      }
+    })
+    .catch(error => {
+      dispatch('requestCreateProjectError', error);
+    });
+};
+
+export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => {
+  backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } }))
+    .then(resp => {
+      if (resp.status === statusCodes.OK) {
+        dispatch('requestCreateProjectSuccess', resp.data);
+      }
+    })
+    .catch(error => {
+      dispatch('requestCreateProjectError', error);
+    });
+};
+
+export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => {
+  commit(types.SET_LOADING, false);
+  commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path);
+  commit(types.SET_ALERT_CONTENT, {
+    message: s__('SelfMonitoring|Self monitoring project has been successfully created.'),
+    actionText: __('View project'),
+    actionName: 'viewSelfMonitorProject',
+  });
+  commit(types.SET_SHOW_ALERT, true);
+  commit(types.SET_PROJECT_CREATED, true);
+};
+
+export const requestCreateProjectError = ({ commit }, error) => {
+  const { response } = error;
+  const message = response.data && response.data.message ? response.data.message : '';
+
+  commit(types.SET_ALERT_CONTENT, {
+    message: `${__('There was an error saving your changes.')} ${message}`,
+  });
+  commit(types.SET_SHOW_ALERT, true);
+  commit(types.SET_LOADING, false);
+};
+
+export const deleteProject = ({ dispatch }) => dispatch('requestDeleteProject');
+
+export const requestDeleteProject = ({ dispatch, state, commit }) => {
+  commit(types.SET_LOADING, true);
+  axios
+    .delete(state.deleteProjectEndpoint)
+    .then(resp => {
+      if (resp.status === statusCodes.ACCEPTED) {
+        dispatch('requestDeleteProjectStatus', resp.data.job_id);
+      }
+    })
+    .catch(error => {
+      dispatch('requestDeleteProjectError', error);
+    });
+};
+
+export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => {
+  backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } }))
+    .then(resp => {
+      if (resp.status === statusCodes.OK) {
+        dispatch('requestDeleteProjectSuccess', resp.data);
+      }
+    })
+    .catch(error => {
+      dispatch('requestDeleteProjectError', error);
+    });
+};
+
+export const requestDeleteProjectSuccess = ({ commit }) => {
+  commit(types.SET_PROJECT_URL, '');
+  commit(types.SET_PROJECT_CREATED, false);
+  commit(types.SET_ALERT_CONTENT, {
+    message: s__('SelfMonitoring|Self monitoring project has been successfully deleted.'),
+    actionText: __('Undo'),
+    actionName: 'createProject',
+  });
+  commit(types.SET_SHOW_ALERT, true);
+  commit(types.SET_LOADING, false);
+};
+
+export const requestDeleteProjectError = ({ commit }, error) => {
+  const { response } = error;
+  const message = response.data && response.data.message ? response.data.message : '';
+
+  commit(types.SET_ALERT_CONTENT, {
+    message: `${__('There was an error saving your changes.')} ${message}`,
+  });
+  commit(types.SET_LOADING, false);
+};
diff --git a/app/assets/javascripts/self_monitor/store/index.js b/app/assets/javascripts/self_monitor/store/index.js
new file mode 100644
index 00000000000..a222e9c87b8
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export const createStore = initialState =>
+  new Vuex.Store({
+    modules: {
+      selfMonitoring: {
+        namespaced: true,
+        state: createState(initialState),
+        actions,
+        mutations,
+      },
+    },
+  });
+
+export default createStore;
diff --git a/app/assets/javascripts/self_monitor/store/mutation_types.js b/app/assets/javascripts/self_monitor/store/mutation_types.js
new file mode 100644
index 00000000000..c5952b66144
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_ENABLED = 'SET_ENABLED';
+export const SET_PROJECT_CREATED = 'SET_PROJECT_CREATED';
+export const SET_SHOW_ALERT = 'SET_SHOW_ALERT';
+export const SET_PROJECT_URL = 'SET_PROJECT_URL';
+export const SET_LOADING = 'SET_LOADING';
+export const SET_ALERT_CONTENT = 'SET_ALERT_CONTENT';
diff --git a/app/assets/javascripts/self_monitor/store/mutations.js b/app/assets/javascripts/self_monitor/store/mutations.js
new file mode 100644
index 00000000000..7dca8bcdc4d
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/mutations.js
@@ -0,0 +1,22 @@
+import * as types from './mutation_types';
+
+export default {
+  [types.SET_ENABLED](state, enabled) {
+    state.projectEnabled = enabled;
+  },
+  [types.SET_PROJECT_CREATED](state, created) {
+    state.projectCreated = created;
+  },
+  [types.SET_SHOW_ALERT](state, show) {
+    state.showAlert = show;
+  },
+  [types.SET_PROJECT_URL](state, url) {
+    state.projectPath = url;
+  },
+  [types.SET_LOADING](state, loading) {
+    state.loading = loading;
+  },
+  [types.SET_ALERT_CONTENT](state, content) {
+    state.alertContent = content;
+  },
+};
diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js
new file mode 100644
index 00000000000..b8b4a4af614
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/state.js
@@ -0,0 +1,15 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default (initialState = {}) => ({
+  projectEnabled: parseBoolean(initialState.projectEnabled) || false,
+  projectCreated: parseBoolean(initialState.selfMonitorProjectCreated) || false,
+  createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '',
+  deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '',
+  createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '',
+  deleteProjectStatusEndpoint: initialState.statusDeleteSelfMonitoringProjectPath || '',
+  selfMonitorProjectPath: initialState.selfMonitoringProjectFullPath || '',
+  showAlert: false,
+  projectPath: '',
+  loading: false,
+  alertContent: {},
+});
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 657e5accdab..719de095faf 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -281,7 +281,7 @@ module MarkupHelper
     context.reverse_merge!(
       current_user: (current_user if defined?(current_user)),
 
-      # RelativeLinkFilter
+      # RepositoryLinkFilter and UploadLinkFilter
       commit:         @commit,
       project_wiki:   @project_wiki,
       ref:            @ref,
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index 55a48da8342..ff40d7da892 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -47,6 +47,9 @@
   .settings-content
     = render 'performance_bar'
 
+- if Feature.enabled?(:self_monitoring_project)
+  .js-self-monitoring-settings{ data: self_monitoring_project_data }
+
 %section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) }
   .settings-header#usage-statistics
     %h4
diff --git a/changelogs/unreleased/195776-fix-discard-rename-web-ide.yml b/changelogs/unreleased/195776-fix-discard-rename-web-ide.yml
new file mode 100644
index 00000000000..5780a07a047
--- /dev/null
+++ b/changelogs/unreleased/195776-fix-discard-rename-web-ide.yml
@@ -0,0 +1,5 @@
+---
+title: Fix discarding renamed directories in Web IDE
+merge_request: 22943
+author:
+type: fixed
diff --git a/changelogs/unreleased/34522-webide-empty-repos.yml b/changelogs/unreleased/34522-webide-empty-repos.yml
new file mode 100644
index 00000000000..3fbd097dba8
--- /dev/null
+++ b/changelogs/unreleased/34522-webide-empty-repos.yml
@@ -0,0 +1,5 @@
+---
+title: 'Fix: WebIDE doesn''t work on empty repositories again'
+merge_request: 22950
+author:
+type: fixed
diff --git a/changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml b/changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml
new file mode 100644
index 00000000000..3058624a4d8
--- /dev/null
+++ b/changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml
@@ -0,0 +1,5 @@
+---
+title: Fix MR diffs file count increments while batch loading
+merge_request: 21764
+author:
+type: fixed
diff --git a/changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml b/changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml
new file mode 100644
index 00000000000..c139cea868a
--- /dev/null
+++ b/changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add audit events to the adding members to project or group API endpoint
+merge_request: 21633
+author:
+type: changed
diff --git a/changelogs/unreleased/ab-projects-api-more-indexes.yml b/changelogs/unreleased/ab-projects-api-more-indexes.yml
new file mode 100644
index 00000000000..1567e78cba3
--- /dev/null
+++ b/changelogs/unreleased/ab-projects-api-more-indexes.yml
@@ -0,0 +1,5 @@
+---
+title: Add more indexes for other order_by options (Projects API)
+merge_request: 22784
+author:
+type: performance
diff --git a/changelogs/unreleased/add-geo-node-api.yml b/changelogs/unreleased/add-geo-node-api.yml
new file mode 100644
index 00000000000..9db482531cc
--- /dev/null
+++ b/changelogs/unreleased/add-geo-node-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add API endpoint for creating a Geo node
+merge_request: 22392
+author: Rajendra Kadam
+type: added
diff --git a/changelogs/unreleased/dblessing_update_net_ldap_gem.yml b/changelogs/unreleased/dblessing_update_net_ldap_gem.yml
new file mode 100644
index 00000000000..52cced0ef6c
--- /dev/null
+++ b/changelogs/unreleased/dblessing_update_net_ldap_gem.yml
@@ -0,0 +1,5 @@
+---
+title: Update the Net-LDAP gem to 0.16.2
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/split-up-relativelinkfilter.yml b/changelogs/unreleased/split-up-relativelinkfilter.yml
new file mode 100644
index 00000000000..feaa9f290ab
--- /dev/null
+++ b/changelogs/unreleased/split-up-relativelinkfilter.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid making Gitaly calls when some Markdown text links to an uploaded file
+merge_request: 22631
+author:
+type: performance
diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb
index 3e017b810b6..4698a42c8b6 100644
--- a/db/fixtures/development/17_cycle_analytics.rb
+++ b/db/fixtures/development/17_cycle_analytics.rb
@@ -208,7 +208,7 @@ class Gitlab::Seeder::CycleAnalytics
       job = merge_request.head_pipeline.builds.where.not(environment: nil).last
 
       job.success!
-      pipeline.update_status
+      job.pipeline.update_status
     end
   end
 end
diff --git a/db/migrate/20200110144316_add_indexes_for_projects_api.rb b/db/migrate/20200110144316_add_indexes_for_projects_api.rb
new file mode 100644
index 00000000000..6b0ca252456
--- /dev/null
+++ b/db/migrate/20200110144316_add_indexes_for_projects_api.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class AddIndexesForProjectsApi < ActiveRecord::Migration[5.2]
+  include Gitlab::Database::MigrationHelpers
+
+  DOWNTIME = false
+
+  disable_ddl_transaction!
+
+  COLUMNS = %i(created_at last_activity_at updated_at name path)
+
+  def up
+    COLUMNS.each do |column|
+      add_concurrent_index :projects, [column, :id], where: 'visibility_level = 20', order: { id: :desc }, name: "index_projects_api_vis20_#{column}_id_desc"
+      add_concurrent_index :projects, [column, :id], where: 'visibility_level = 20', name: "index_projects_api_vis20_#{column}"
+    end
+
+    remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_created_at_id_desc'
+    remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_created_at_desc_id_desc'
+  end
+
+  def down
+    add_concurrent_index :projects, %i(visibility_level created_at id), order: { id: :desc }, name: 'index_projects_on_visibility_level_created_at_id_desc'
+    add_concurrent_index :projects, %i(visibility_level created_at id), order: { created_at: :desc, id: :desc }, name: 'index_projects_on_visibility_level_created_at_desc_id_desc'
+
+    COLUMNS.each do |column|
+      remove_concurrent_index_by_name :projects, "index_projects_api_vis20_#{column}_id_desc"
+      remove_concurrent_index_by_name :projects, "index_projects_api_vis20_#{column}"
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7f1a7ac0ff1..c2c5bb43c5e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_01_08_233040) do
+ActiveRecord::Schema.define(version: 2020_01_10_144316) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "pg_trgm"
@@ -3353,6 +3353,8 @@ ActiveRecord::Schema.define(version: 2020_01_08_233040) do
     t.boolean "autoclose_referenced_issues"
     t.string "suggestion_commit_message", limit: 255
     t.index "lower((name)::text)", name: "index_projects_on_lower_name"
+    t.index ["created_at", "id"], name: "index_projects_api_vis20_created_at", where: "(visibility_level = 20)"
+    t.index ["created_at", "id"], name: "index_projects_api_vis20_created_at_id_desc", order: { id: :desc }, where: "(visibility_level = 20)"
     t.index ["created_at", "id"], name: "index_projects_on_created_at_and_id"
     t.index ["creator_id"], name: "index_projects_on_creator_id"
     t.index ["description"], name: "index_projects_on_description_trigram", opclass: :gin_trgm_ops, using: :gin
@@ -3360,6 +3362,8 @@ ActiveRecord::Schema.define(version: 2020_01_08_233040) do
     t.index ["id"], name: "index_on_id_partial_with_legacy_storage", where: "((storage_version < 2) OR (storage_version IS NULL))"
     t.index ["id"], name: "index_projects_on_id_partial_for_visibility", unique: true, where: "(visibility_level = ANY (ARRAY[10, 20]))"
     t.index ["id"], name: "index_projects_on_mirror_and_mirror_trigger_builds_both_true", where: "((mirror IS TRUE) AND (mirror_trigger_builds IS TRUE))"
+    t.index ["last_activity_at", "id"], name: "index_projects_api_vis20_last_activity_at", where: "(visibility_level = 20)"
+    t.index ["last_activity_at", "id"], name: "index_projects_api_vis20_last_activity_at_id_desc", order: { id: :desc }, where: "(visibility_level = 20)"
     t.index ["last_activity_at"], name: "index_projects_on_last_activity_at"
     t.index ["last_repository_check_at"], name: "index_projects_on_last_repository_check_at", where: "(last_repository_check_at IS NOT NULL)"
     t.index ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed"
@@ -3368,8 +3372,12 @@ ActiveRecord::Schema.define(version: 2020_01_08_233040) do
     t.index ["marked_for_deletion_by_user_id"], name: "index_projects_on_marked_for_deletion_by_user_id", where: "(marked_for_deletion_by_user_id IS NOT NULL)"
     t.index ["mirror_last_successful_update_at"], name: "index_projects_on_mirror_last_successful_update_at"
     t.index ["mirror_user_id"], name: "index_projects_on_mirror_user_id"
+    t.index ["name", "id"], name: "index_projects_api_vis20_name", where: "(visibility_level = 20)"
+    t.index ["name", "id"], name: "index_projects_api_vis20_name_id_desc", order: { id: :desc }, where: "(visibility_level = 20)"
     t.index ["name"], name: "index_projects_on_name_trigram", opclass: :gin_trgm_ops, using: :gin
     t.index ["namespace_id"], name: "index_projects_on_namespace_id"
+    t.index ["path", "id"], name: "index_projects_api_vis20_path", where: "(visibility_level = 20)"
+    t.index ["path", "id"], name: "index_projects_api_vis20_path_id_desc", order: { id: :desc }, where: "(visibility_level = 20)"
     t.index ["path"], name: "index_projects_on_path"
     t.index ["path"], name: "index_projects_on_path_trigram", opclass: :gin_trgm_ops, using: :gin
     t.index ["pending_delete"], name: "index_projects_on_pending_delete"
@@ -3379,8 +3387,8 @@ ActiveRecord::Schema.define(version: 2020_01_08_233040) do
     t.index ["runners_token"], name: "index_projects_on_runners_token"
     t.index ["runners_token_encrypted"], name: "index_projects_on_runners_token_encrypted"
     t.index ["star_count"], name: "index_projects_on_star_count"
-    t.index ["visibility_level", "created_at", "id"], name: "index_projects_on_visibility_level_created_at_desc_id_desc", order: { created_at: :desc, id: :desc }
-    t.index ["visibility_level", "created_at", "id"], name: "index_projects_on_visibility_level_created_at_id_desc", order: { id: :desc }
+    t.index ["updated_at", "id"], name: "index_projects_api_vis20_updated_at", where: "(visibility_level = 20)"
+    t.index ["updated_at", "id"], name: "index_projects_api_vis20_updated_at_id_desc", order: { id: :desc }, where: "(visibility_level = 20)"
   end
 
   create_table "prometheus_alert_events", force: :cascade do |t|
diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md
index 0fbdaa942e8..771efc2c8c6 100644
--- a/doc/administration/geo/replication/troubleshooting.md
+++ b/doc/administration/geo/replication/troubleshooting.md
@@ -298,7 +298,7 @@ log data to build up in `pg_xlog`. Removing the unused slots can reduce the amou
 1. Start a PostgreSQL console session:
 
    ```sh
-   sudo gitlab-psql gitlabhq_production
+   sudo gitlab-psql
    ```
 
    Note: **Note:** Using `gitlab-rails dbconsole` will not work, because managing replication slots requires superuser permissions.
diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md
index e44d69f1419..2786d00ebbd 100644
--- a/doc/api/geo_nodes.md
+++ b/doc/api/geo_nodes.md
@@ -3,6 +3,54 @@
 In order to interact with Geo node endpoints, you need to authenticate yourself
 as an admin.
 
+## Create a new Geo node
+
+Creates a new Geo node.
+
+```
+POST /geo_nodes
+```
+
+| Attribute                   | Type    | Required | Description                                                      |
+| ----------------------------| ------- | -------- | -----------------------------------------------------------------|
+| `primary`                   | boolean | no       | Specifying whether this node will be primary. Defaults to false. |
+| `enabled`                   | boolean | no       | Flag indicating if the Geo node is enabled. Defaults to true.    |
+| `name`                      | string  | yes      | The unique identifier for the Geo node. Must match `geo_node_name` if it is set in `gitlab.rb`, otherwise it must match `external_url` |
+| `url`                       | string  | yes      | The user-facing URL for the Geo node. |
+| `internal_url`              | string  | no       | The URL defined on the primary node that secondary nodes should use to contact it. Returns `url` if not set. |
+| `files_max_capacity`        | integer | no       | Control the maximum concurrency of LFS/attachment backfill for this secondary node. Defaults to 10. |
+| `repos_max_capacity`        | integer | no       | Control the maximum concurrency of repository backfill for this secondary node. Defaults to 25. |
+| `verification_max_capacity` | integer | no       | Control the maximum concurrency of repository verification for this node. Defaults to 100. |
+| `container_repositories_max_capacity` | integer  | no | Control the maximum concurrency of container repository sync for this node. Defaults to 10. |
+| `sync_object_storage`       | boolean | no       | Flag indicating if the secondary Geo node will replicate blobs in Object Storage. Defaults to false. |
+
+Example response:
+
+```json
+{
+  "id": 3,
+  "name": "Test Node 1",
+  "url": "https://secondary.example.com/",
+  "internal_url": "https://secondary.example.com/",
+  "primary": false,
+  "enabled": true,
+  "current": false,
+  "files_max_capacity": 10,
+  "repos_max_capacity": 25,
+  "verification_max_capacity": 100,
+  "container_repositories_max_capacity": 10,
+  "sync_object_storage": false,
+  "clone_protocol": "http",
+  "web_edit_url": "https://primary.example.com/admin/geo/nodes/3/edit",
+  "web_geo_projects_url": "http://secondary.example.com/admin/geo/projects",
+  "_links": {
+     "self": "https://primary.example.com/api/v4/geo_nodes/3",
+     "status": "https://primary.example.com/api/v4/geo_nodes/3/status",
+     "repair": "https://primary.example.com/api/v4/geo_nodes/3/repair"
+  }
+}
+```
+
 ## Retrieve configuration about all Geo nodes
 
 ```
diff --git a/doc/development/packages.md b/doc/development/packages.md
index 4ea71afac80..980c1869a0a 100644
--- a/doc/development/packages.md
+++ b/doc/development/packages.md
@@ -21,6 +21,7 @@ The goal of the Package group is to build a set of features that, within three y
 | Format | Use case |
 | ------ | ------ |
 | [Bower](https://gitlab.com/gitlab-org/gitlab/issues/36888) | Boost your front end development by hosting your own Bower components.  |
+| [Cargo](https://gitlab.com/gitlab-org/gitlab/issues/33060) | Cargo is the Rust package manager. Build, publish and share Rust packages  |
 | [Chef](https://gitlab.com/gitlab-org/gitlab/issues/36889) | Configuration management with Chef using all the benefits of a repository manager. |
 | [CocoaPods](https://gitlab.com/gitlab-org/gitlab/issues/36890) | Speed up development with Xcode and CocoaPods. |
 | [Conda](https://gitlab.com/gitlab-org/gitlab/issues/36891) | Secure and private local Conda repositories. |
diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md
index 5693c6c50ec..2672b0f3461 100644
--- a/doc/user/application_security/sast/index.md
+++ b/doc/user/application_security/sast/index.md
@@ -199,9 +199,60 @@ include:
   - template: SAST.gitlab-ci.yml
 
 variables:
+  SAST_DISABLE_DIND: "true"
   SCAN_KUBERNETES_MANIFESTS: "true"
 ```
 
+#### Pre-compilation
+
+If your project requires custom build configurations, it can be preferable to avoid
+compilation during your SAST execution and instead pass all job artifacts from an
+earlier stage within the pipeline.
+
+To pass your project's dependencies as artifacts, the dependencies must be included
+in the project's working directory and specified using the `artifacts:path` configuration.
+If all dependencies are present, the `-compile=false` flag can be provided to the
+analyzer and compilation will be skipped:
+
+```yaml
+image: maven:3.6-jdk-8-alpine
+
+stages:
+ - build
+ - test
+
+include:
+  template: SAST.gitlab-ci.yml
+
+variables:
+  SAST_DISABLE_DIND: "true"
+
+build:
+  stage: build
+  script:
+    - mvn package -Dmaven.repo.local=./.m2/repository
+  artifacts:
+    paths:
+      - .m2/
+      - target/
+
+spotbugs-sast:
+  dependencies: build
+  script:
+    - /analyzer run -compile=false
+  variables:
+    MAVEN_REPO_PATH: ./.m2/repository
+  artifacts:
+    reports:
+      sast: gl-sast-report.json
+```
+
+NOTE: **Note:**
+The path to the vendored directory must be specified explicitly to allow
+the analyzer to recognize the compiled artifacts. This configuration can vary per
+analyzer but in the case of Java above, `MAVEN_REPO_PATH` can be used.
+See [Analyzer settings](#analyzer-settings) for the complete list of available options.
+
 ### Available variables
 
 SAST can be [configured](#customizing-the-sast-settings) using environment variables.
diff --git a/doc/user/packages/npm_registry/index.md b/doc/user/packages/npm_registry/index.md
index 6b65aaba2c5..5fdbbcedfc9 100644
--- a/doc/user/packages/npm_registry/index.md
+++ b/doc/user/packages/npm_registry/index.md
@@ -73,20 +73,20 @@ If you have 2FA enabled, you need to use a [personal access token](../../profile
 ### Authenticating with an OAuth token
 
 To authenticate with an [OAuth token](../../../api/oauth2.md#resource-owner-password-credentials-flow)
-or [personal access token](../../profile/personal_access_tokens.md), add a corresponding section to your `.npmrc` file:
+or [personal access token](../../profile/personal_access_tokens.md), set your NPM configuration:
 
-```ini
-; Set URL for your scoped packages.
-; For example package with name `@foo/bar` will use this URL for download
-@foo:registry=https://gitlab.com/api/v4/packages/npm/
+```bash
+# Set URL for your scoped packages.
+# For example package with name `@foo/bar` will use this URL for download
+npm config set @foo:registry https://gitlab.com/api/v4/packages/npm/
 
-; Add the token for the scoped packages URL. This will allow you to download
-; `@foo/` packages from private projects.
-//gitlab.com/api/v4/packages/npm/:_authToken=<your_token>
+# Add the token for the scoped packages URL. This will allow you to download
+# `@foo/` packages from private projects.
+npm config set '//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken' "<your_token>"
 
-; Add token for uploading to the registry. Replace <your_project_id>
-; with the project you want your package to be uploaded to.
-//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken=<your_token>
+# Add token for uploading to the registry. Replace <your_project_id>
+# with the project you want your package to be uploaded to.
+npm config set '//gitlab.com/api/v4/packages/npm/:_authToken' "<your_token>"
 ```
 
 Replace `<your_project_id>` with your project ID which can be found on the home page
@@ -103,13 +103,11 @@ If you encounter an error message with [Yarn](https://yarnpkg.com/en/), see the
 
 ### Using variables to avoid hard-coding auth token values
 
-To avoid hard-coding the `authToken` value, you may use a variables in its place.
-In your `.npmrc` file, you would add:
+To avoid hard-coding the `authToken` value, you may use a variables in its place:
 
-```ini
-@foo:registry=https://gitlab.com/api/v4/packages/npm/
-//gitlab.com/api/v4/packages/npm/:_authToken=${NPM_TOKEN}
-//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken=${NPM_TOKEN}
+```bash
+npm config set '//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken' "${NPM_TOKEN}"
+npm config set '//gitlab.com/api/v4/packages/npm/:_authToken' "${NPM_TOKEN}"
 ```
 
 Then, you could run `npm publish` either locally or via GitLab CI/CD:
@@ -227,6 +225,14 @@ And the `.npmrc` file should look like:
 @foo:registry=https://gitlab.com/api/v4/packages/npm/
 ```
 
+### `npm install` returns `Error: Failed to replace env in config: ${NPM_TOKEN}`
+
+You do not need a token to run `npm install` unless your project is private (the token is only required to publish). If the `.npmrc` file was checked in with a reference to `$NPM_TOKEN`, you can remove it. If you prefer to leave the reference in, you'll need to set a value prior to running `npm install` or set the value using [GitLab environment variables](./../../../ci/variables/README.md):
+
+```bash
+NPM_TOKEN=<your_token> npm install
+```
+
 ## NPM dependencies metadata
 
 > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11867) in GitLab Premium 12.6.
diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb
index 3ccae8d85cc..d06c59907b4 100644
--- a/lib/api/helpers/members_helpers.rb
+++ b/lib/api/helpers/members_helpers.rb
@@ -41,6 +41,10 @@ module API
         GroupMembersFinder.new(group).execute
       end
 
+      def create_member(current_user, user, source, params)
+        source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at])
+      end
+
       def present_members(members)
         present members, with: Entities::Member, current_user: current_user
       end
diff --git a/lib/api/members.rb b/lib/api/members.rb
index e745bd0d4a9..e4df2f341c6 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -101,12 +101,12 @@ module API
           user = User.find_by_id(params[:user_id])
           not_found!('User') unless user
 
-          member = source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at])
+          member = create_member(current_user, user, source, params)
 
           if !member
             not_allowed! # This currently can only be reached in EE
           elsif member.persisted? && member.valid?
-            present_members member
+            present_members(member)
           else
             render_validation_error!(member)
           end
diff --git a/lib/banzai/filter/base_relative_link_filter.rb b/lib/banzai/filter/base_relative_link_filter.rb
new file mode 100644
index 00000000000..eca105ce9d9
--- /dev/null
+++ b/lib/banzai/filter/base_relative_link_filter.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'uri'
+
+module Banzai
+  module Filter
+    class BaseRelativeLinkFilter < HTML::Pipeline::Filter
+      include Gitlab::Utils::StrongMemoize
+
+      protected
+
+      def linkable_attributes
+        strong_memoize(:linkable_attributes) do
+          attrs = []
+
+          attrs += doc.search('a:not(.gfm)').map do |el|
+            el.attribute('href')
+          end
+
+          attrs += doc.search('img:not(.gfm), video:not(.gfm), audio:not(.gfm)').flat_map do |el|
+            [el.attribute('src'), el.attribute('data-src')]
+          end
+
+          attrs.reject do |attr|
+            attr.blank? || attr.value.start_with?('//')
+          end
+        end
+      end
+
+      def relative_url_root
+        Gitlab.config.gitlab.relative_url_root.presence || '/'
+      end
+
+      def project
+        context[:project]
+      end
+
+      private
+
+      def unescape_and_scrub_uri(uri)
+        Addressable::URI.unescape(uri).scrub
+      end
+    end
+  end
+end
diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/repository_link_filter.rb
similarity index 72%
rename from lib/banzai/filter/relative_link_filter.rb
rename to lib/banzai/filter/repository_link_filter.rb
index 4f257189f8e..14cd607cc50 100644
--- a/lib/banzai/filter/relative_link_filter.rb
+++ b/lib/banzai/filter/repository_link_filter.rb
@@ -4,19 +4,17 @@ require 'uri'
 
 module Banzai
   module Filter
-    # HTML filter that "fixes" relative links to uploads or files in a repository.
+    # HTML filter that "fixes" relative links to files in a repository.
     #
     # Context options:
     #   :commit
-    #   :group
     #   :current_user
     #   :project
     #   :project_wiki
     #   :ref
     #   :requested_path
-    class RelativeLinkFilter < HTML::Pipeline::Filter
-      include Gitlab::Utils::StrongMemoize
-
+    #   :system_note
+    class RepositoryLinkFilter < BaseRelativeLinkFilter
       def call
         return doc if context[:system_note]
 
@@ -26,7 +24,9 @@ module Banzai
         load_uri_types
 
         linkable_attributes.each do |attr|
-          process_link_attr(attr)
+          if linkable_files? && repo_visible_to_user?
+            process_link_to_repository_attr(attr)
+          end
         end
 
         doc
@@ -35,8 +35,8 @@ module Banzai
       protected
 
       def load_uri_types
-        return unless linkable_files?
         return unless linkable_attributes.present?
+        return unless linkable_files?
         return {} unless repository
 
         @uri_types = request_path.present? ? get_uri_types([request_path]) : {}
@@ -57,24 +57,6 @@ module Banzai
         end
       end
 
-      def linkable_attributes
-        strong_memoize(:linkable_attributes) do
-          attrs = []
-
-          attrs += doc.search('a:not(.gfm)').map do |el|
-            el.attribute('href')
-          end
-
-          attrs += doc.search('img, video, audio').flat_map do |el|
-            [el.attribute('src'), el.attribute('data-src')]
-          end
-
-          attrs.reject do |attr|
-            attr.blank? || attr.value.start_with?('//')
-          end
-        end
-      end
-
       def get_uri_types(paths)
         return {} if paths.empty?
 
@@ -107,39 +89,6 @@ module Banzai
       rescue URI::Error, Addressable::URI::InvalidURIError
       end
 
-      def process_link_attr(html_attr)
-        if html_attr.value.start_with?('/uploads/')
-          process_link_to_upload_attr(html_attr)
-        elsif linkable_files? && repo_visible_to_user?
-          process_link_to_repository_attr(html_attr)
-        end
-      end
-
-      def process_link_to_upload_attr(html_attr)
-        path_parts = [unescape_and_scrub_uri(html_attr.value)]
-
-        if project
-          path_parts.unshift(relative_url_root, project.full_path)
-        elsif group
-          path_parts.unshift(relative_url_root, 'groups', group.full_path, '-')
-        else
-          path_parts.unshift(relative_url_root)
-        end
-
-        begin
-          path = Addressable::URI.escape(File.join(*path_parts))
-        rescue Addressable::URI::InvalidURIError
-          return
-        end
-
-        html_attr.value =
-          if context[:only_path]
-            path
-          else
-            Addressable::URI.join(Gitlab.config.gitlab.base_url, path).to_s
-          end
-      end
-
       def process_link_to_repository_attr(html_attr)
         uri = URI(html_attr.value)
 
@@ -239,10 +188,6 @@ module Banzai
         @current_commit ||= context[:commit] || repository.commit(ref)
       end
 
-      def relative_url_root
-        Gitlab.config.gitlab.relative_url_root.presence || '/'
-      end
-
       def repo_visible_to_user?
         project && Ability.allowed?(current_user, :download_code, project)
       end
@@ -251,14 +196,6 @@ module Banzai
         context[:ref] || project.default_branch
       end
 
-      def group
-        context[:group]
-      end
-
-      def project
-        context[:project]
-      end
-
       def current_user
         context[:current_user]
       end
@@ -266,12 +203,6 @@ module Banzai
       def repository
         @repository ||= project&.repository
       end
-
-      private
-
-      def unescape_and_scrub_uri(uri)
-        Addressable::URI.unescape(uri).scrub
-      end
     end
   end
 end
diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb
new file mode 100644
index 00000000000..023c1288367
--- /dev/null
+++ b/lib/banzai/filter/upload_link_filter.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'uri'
+
+module Banzai
+  module Filter
+    # HTML filter that "fixes" links to uploads.
+    #
+    # Context options:
+    #   :group
+    #   :only_path
+    #   :project
+    #   :system_note
+    class UploadLinkFilter < BaseRelativeLinkFilter
+      def call
+        return doc if context[:system_note]
+
+        linkable_attributes.each do |attr|
+          process_link_to_upload_attr(attr)
+        end
+
+        doc
+      end
+
+      protected
+
+      def process_link_to_upload_attr(html_attr)
+        return unless html_attr.value.start_with?('/uploads/')
+
+        path_parts = [unescape_and_scrub_uri(html_attr.value)]
+
+        if project
+          path_parts.unshift(relative_url_root, project.full_path)
+        elsif group
+          path_parts.unshift(relative_url_root, 'groups', group.full_path, '-')
+        else
+          path_parts.unshift(relative_url_root)
+        end
+
+        begin
+          path = Addressable::URI.escape(File.join(*path_parts))
+        rescue Addressable::URI::InvalidURIError
+          return
+        end
+
+        html_attr.value =
+          if context[:only_path]
+            path
+          else
+            Addressable::URI.join(Gitlab.config.gitlab.base_url, path).to_s
+          end
+
+        html_attr.parent.add_class('gfm')
+      end
+
+      def group
+        context[:group]
+      end
+    end
+  end
+end
diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb
index fe629a23ff1..5e02d972614 100644
--- a/lib/banzai/pipeline/post_process_pipeline.rb
+++ b/lib/banzai/pipeline/post_process_pipeline.rb
@@ -16,7 +16,10 @@ module Banzai
         [
           Filter::ReferenceRedactorFilter,
           Filter::InlineMetricsRedactorFilter,
-          Filter::RelativeLinkFilter,
+          # UploadLinkFilter must come before RepositoryLinkFilter to
+          # prevent unnecessary Gitaly calls from being made.
+          Filter::UploadLinkFilter,
+          Filter::RepositoryLinkFilter,
           Filter::IssuableStateFilter,
           Filter::SuggestionFilter
         ]
diff --git a/lib/banzai/pipeline/relative_link_pipeline.rb b/lib/banzai/pipeline/relative_link_pipeline.rb
deleted file mode 100644
index 88651892acc..00000000000
--- a/lib/banzai/pipeline/relative_link_pipeline.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module Banzai
-  module Pipeline
-    class RelativeLinkPipeline < BasePipeline
-      def self.filters
-        FilterArray[
-          Filter::RelativeLinkFilter
-        ]
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb b/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb
index 40c109207a9..899f381e911 100644
--- a/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb
+++ b/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb
@@ -24,12 +24,14 @@ module Gitlab
 
           fingerprints = []
           Key.where(id: start_id..stop_id, fingerprint_sha256: nil).find_each do |regular_key|
-            fingerprint = Base64.decode64(generate_ssh_public_key(regular_key.key))
-
-            fingerprints << {
-              id: regular_key.id,
-              fingerprint_sha256: ActiveRecord::Base.connection.escape_bytea(fingerprint)
-            }
+            if fingerprint = generate_ssh_public_key(regular_key.key)
+              bytea = ActiveRecord::Base.connection.escape_bytea(Base64.decode64(fingerprint))
+
+              fingerprints << {
+                id: regular_key.id,
+                fingerprint_sha256: bytea
+              }
+            end
           end
 
           Gitlab::Database.bulk_insert(TEMP_TABLE, fingerprints)
@@ -48,7 +50,7 @@ module Gitlab
       private
 
       def generate_ssh_public_key(regular_key)
-        Gitlab::SSHPublicKey.new(regular_key).fingerprint("SHA256").gsub("SHA256:", "")
+        Gitlab::SSHPublicKey.new(regular_key).fingerprint("SHA256")&.gsub("SHA256:", "")
       end
 
       def execute(query)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 955a3cc306c..8d731ef8bd5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -5217,6 +5217,9 @@ msgstr ""
 msgid "Create New Domain"
 msgstr ""
 
+msgid "Create Project"
+msgstr ""
+
 msgid "Create a GitLab account first, and then connect it to your %{label} account."
 msgstr ""
 
@@ -5849,6 +5852,9 @@ msgstr ""
 msgid "Delete pipeline"
 msgstr ""
 
+msgid "Delete project"
+msgstr ""
+
 msgid "Delete snippet"
 msgstr ""
 
@@ -7209,9 +7215,6 @@ msgstr ""
 msgid "Error Tracking"
 msgstr ""
 
-msgid "Error creating a new path"
-msgstr ""
-
 msgid "Error creating epic"
 msgstr ""
 
@@ -16412,6 +16415,30 @@ msgstr ""
 msgid "Self-monitoring project was not deleted. Please check logs for any error messages"
 msgstr ""
 
+msgid "SelfMonitoring|Disable self monitoring?"
+msgstr ""
+
+msgid "SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?"
+msgstr ""
+
+msgid "SelfMonitoring|Enable or disable instance self monitoring"
+msgstr ""
+
+msgid "SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance."
+msgstr ""
+
+msgid "SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance."
+msgstr ""
+
+msgid "SelfMonitoring|Self monitoring"
+msgstr ""
+
+msgid "SelfMonitoring|Self monitoring project has been successfully created."
+msgstr ""
+
+msgid "SelfMonitoring|Self monitoring project has been successfully deleted."
+msgstr ""
+
 msgid "Send a separate email notification to Developers."
 msgstr ""
 
@@ -18263,7 +18290,7 @@ msgstr ""
 msgid "The merge request can now be merged."
 msgstr ""
 
-msgid "The name %{entryName} is already taken in this directory."
+msgid "The name \"%{name}\" is already taken in this directory."
 msgstr ""
 
 msgid "The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time."
@@ -20444,6 +20471,9 @@ msgstr ""
 msgid "View previous app"
 msgstr ""
 
+msgid "View project"
+msgstr ""
+
 msgid "View project labels"
 msgstr ""
 
diff --git a/qa/qa/page/project/settings/deploy_keys.rb b/qa/qa/page/project/settings/deploy_keys.rb
index 602bfc64710..12d7c0a396e 100644
--- a/qa/qa/page/project/settings/deploy_keys.rb
+++ b/qa/qa/page/project/settings/deploy_keys.rb
@@ -18,7 +18,7 @@ module QA
           view 'app/assets/javascripts/deploy_keys/components/key.vue' do
             element :key
             element :key_title
-            element :key_fingerprint
+            element :key_md5_fingerprint
           end
 
           def add_key
@@ -33,17 +33,17 @@ module QA
             fill_in 'deploy_key_key', with: key
           end
 
-          def find_fingerprint(title)
+          def find_md5_fingerprint(title)
             within_project_deploy_keys do
               find_element(:key, text: title)
-                .find(element_selector_css(:key_fingerprint)).text
+                .find(element_selector_css(:key_md5_fingerprint)).text.delete_prefix('MD5:')
             end
           end
 
-          def has_key?(title, fingerprint)
+          def has_key?(title, md5_fingerprint)
             within_project_deploy_keys do
               find_element(:key, text: title)
-                .has_css?(element_selector_css(:key_fingerprint), text: fingerprint)
+                .has_css?(element_selector_css(:key_md5_fingerprint), text: "MD5:#{md5_fingerprint}")
             end
           end
 
@@ -53,12 +53,6 @@ module QA
             end
           end
 
-          def key_fingerprint
-            within_project_deploy_keys do
-              find_element(:key_fingerprint).text
-            end
-          end
-
           private
 
           def within_project_deploy_keys
diff --git a/qa/qa/page/project/settings/protected_branches.rb b/qa/qa/page/project/settings/protected_branches.rb
index d1d2f302013..3f8aba78f44 100644
--- a/qa/qa/page/project/settings/protected_branches.rb
+++ b/qa/qa/page/project/settings/protected_branches.rb
@@ -61,7 +61,7 @@ module QA
             end
 
             # Click the select element again to close the dropdown
-            click_element :protected_branch_select
+            click_element :"allowed_to_#{action}_select"
           end
         end
       end
diff --git a/qa/qa/resource/deploy_key.rb b/qa/qa/resource/deploy_key.rb
index 869e2a71e47..26355729dab 100644
--- a/qa/qa/resource/deploy_key.rb
+++ b/qa/qa/resource/deploy_key.rb
@@ -5,10 +5,10 @@ module QA
     class DeployKey < Base
       attr_accessor :title, :key
 
-      attribute :fingerprint do
+      attribute :md5_fingerprint do
         Page::Project::Settings::Repository.perform do |setting|
           setting.expand_deploy_keys do |key|
-            key.find_fingerprint(title)
+            key.find_md5_fingerprint(title)
           end
         end
       end
diff --git a/qa/qa/resource/ssh_key.rb b/qa/qa/resource/ssh_key.rb
index c140cb9ca62..22bdea424ca 100644
--- a/qa/qa/resource/ssh_key.rb
+++ b/qa/qa/resource/ssh_key.rb
@@ -7,7 +7,7 @@ module QA
 
       attr_accessor :title
 
-      def_delegators :key, :private_key, :public_key, :fingerprint
+      def_delegators :key, :private_key, :public_key, :md5_fingerprint
 
       def key
         @key ||= Runtime::Key::RSA.new
diff --git a/qa/qa/runtime/key/base.rb b/qa/qa/runtime/key/base.rb
index 1281eceaff0..72d1673438a 100644
--- a/qa/qa/runtime/key/base.rb
+++ b/qa/qa/runtime/key/base.rb
@@ -4,7 +4,7 @@ module QA
   module Runtime
     module Key
       class Base
-        attr_reader :name, :bits, :private_key, :public_key, :fingerprint
+        attr_reader :name, :bits, :private_key, :public_key, :md5_fingerprint
 
         def initialize(name, bits)
           @name = name
@@ -29,7 +29,7 @@ module QA
         def populate_key_data(path)
           @private_key = ::File.binread(path)
           @public_key = ::File.binread("#{path}.pub")
-          @fingerprint =
+          @md5_fingerprint =
             `ssh-keygen -l -E md5 -f #{path} | cut -d' ' -f2 | cut -d: -f2-`.chomp
         end
       end
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb
index 474a7904fea..c3379d41ff2 100644
--- a/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb
@@ -13,7 +13,7 @@ module QA
         end
 
         expect(page).to have_content("Title: #{key_title}")
-        expect(page).to have_content(key.fingerprint)
+        expect(page).to have_content(key.md5_fingerprint)
 
         Page::Main::Menu.perform(&:click_settings_link)
         Page::Profile::Menu.perform(&:click_ssh_keys)
@@ -23,7 +23,7 @@ module QA
         end
 
         expect(page).not_to have_content("Title: #{key_title}")
-        expect(page).not_to have_content(key.fingerprint)
+        expect(page).not_to have_content(key.md5_fingerprint)
       end
     end
   end
diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb
index 9c964c726f1..89aba112407 100644
--- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb
@@ -15,11 +15,11 @@ module QA
           resource.key = deploy_key_value
         end
 
-        expect(deploy_key.fingerprint).to eq key.fingerprint
+        expect(deploy_key.md5_fingerprint).to eq key.md5_fingerprint
 
         Page::Project::Settings::Repository.perform do |setting|
           setting.expand_deploy_keys do |keys|
-            expect(keys).to have_key(deploy_key_title, key.fingerprint)
+            expect(keys).to have_key(deploy_key_title, key.md5_fingerprint)
           end
         end
       end
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
index a45fa67ce9e..9ebd85acb81 100644
--- a/spec/features/markdown/markdown_spec.rb
+++ b/spec/features/markdown/markdown_spec.rb
@@ -208,6 +208,8 @@ describe 'GitLab Markdown', :aggregate_failures do
     @group = @feat.group
   end
 
+  let(:project) { @feat.project } # Shadow this so matchers can use it
+
   context 'default pipeline' do
     before do
       @html = markdown(@feat.raw_markdown)
@@ -216,8 +218,12 @@ describe 'GitLab Markdown', :aggregate_failures do
     it_behaves_like 'all pipelines'
 
     it 'includes custom filters' do
-      aggregate_failures 'RelativeLinkFilter' do
-        expect(doc).to parse_relative_links
+      aggregate_failures 'UploadLinkFilter' do
+        expect(doc).to parse_upload_links
+      end
+
+      aggregate_failures 'RepositoryLinkFilter' do
+        expect(doc).to parse_repository_links
       end
 
       aggregate_failures 'EmojiFilter' do
@@ -277,8 +283,12 @@ describe 'GitLab Markdown', :aggregate_failures do
     it_behaves_like 'all pipelines'
 
     it 'includes custom filters' do
-      aggregate_failures 'RelativeLinkFilter' do
-        expect(doc).not_to parse_relative_links
+      aggregate_failures 'UploadLinkFilter' do
+        expect(doc).to parse_upload_links
+      end
+
+      aggregate_failures 'RepositoryLinkFilter' do
+        expect(doc).not_to parse_repository_links
       end
 
       aggregate_failures 'EmojiFilter' do
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index b19b45928d9..59795c835a2 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -111,7 +111,13 @@ Markdown should be usable inside a link. Let's try!
 - [**text**](#link-strong)
 - [`text`](#link-code)
 
-### RelativeLinkFilter
+### UploadLinkFilter
+
+Linking to an upload in this project should work:
+[Relative Upload Link](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg)
+![Relative Upload Image](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg)
+
+### RepositoryLinkFilter
 
 Linking to a file relative to this project's repository should work.
 
diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js
index 82088182a06..7f47677f56c 100644
--- a/spec/frontend/diffs/components/compare_versions_spec.js
+++ b/spec/frontend/diffs/components/compare_versions_spec.js
@@ -28,6 +28,7 @@ describe('CompareVersions', () => {
       propsData: {
         mergeRequestDiffs: diffsMockData,
         mergeRequestDiff: diffsMockData[0],
+        diffFilesLength: 0,
         targetBranch,
         ...props,
       },
diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap
new file mode 100644
index 00000000000..1d0f0c024d6
--- /dev/null
+++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap
@@ -0,0 +1,72 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`self monitor component When the self monitor project has not been created default state to match the default snapshot 1`] = `
+<section
+  class="settings no-animate js-self-monitoring-settings"
+>
+  <div
+    class="settings-header"
+  >
+    <h4
+      class="js-section-header"
+    >
+      
+      Self monitoring
+    
+    </h4>
+     
+    <gl-button-stub
+      class="js-settings-toggle"
+    >
+      Expand
+    </gl-button-stub>
+     
+    <p
+      class="js-section-sub-header"
+    >
+      
+      Enable or disable instance self monitoring
+    
+    </p>
+  </div>
+   
+  <div
+    class="settings-content"
+  >
+    <form
+      name="self-monitoring-form"
+    >
+      <p>
+        Enabling this feature creates a project that can be used to monitor the health of your instance.
+      </p>
+       
+      <gl-form-group-stub
+        label="Create Project"
+        label-for="self-monitor-toggle"
+      >
+        <gl-toggle-stub
+          labeloff="Toggle Status: OFF"
+          labelon="Toggle Status: ON"
+          name="self-monitor-toggle"
+        />
+      </gl-form-group-stub>
+    </form>
+  </div>
+   
+  <gl-modal-stub
+    cancel-title="Cancel"
+    modalclass=""
+    modalid="delete-self-monitor-modal"
+    ok-title="Delete project"
+    ok-variant="danger"
+    title="Disable self monitoring?"
+    titletag="h4"
+  >
+    <div>
+      
+      Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?
+    
+    </div>
+  </gl-modal-stub>
+</section>
+`;
diff --git a/spec/frontend/self_monitor/components/self_monitor_spec.js b/spec/frontend/self_monitor/components/self_monitor_spec.js
new file mode 100644
index 00000000000..b95c7514047
--- /dev/null
+++ b/spec/frontend/self_monitor/components/self_monitor_spec.js
@@ -0,0 +1,83 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue';
+import { createStore } from '~/self_monitor/store';
+
+describe('self monitor component', () => {
+  let wrapper;
+  let store;
+
+  describe('When the self monitor project has not been created', () => {
+    beforeEach(() => {
+      store = createStore({
+        projectEnabled: false,
+        selfMonitorProjectCreated: false,
+        createSelfMonitoringProjectPath: '/create',
+        deleteSelfMonitoringProjectPath: '/delete',
+      });
+    });
+
+    afterEach(() => {
+      if (wrapper.destroy) {
+        wrapper.destroy();
+      }
+    });
+
+    describe('default state', () => {
+      it('to match the default snapshot', () => {
+        wrapper = shallowMount(SelfMonitor, { store });
+
+        expect(wrapper.element).toMatchSnapshot();
+      });
+    });
+
+    it('renders header text', () => {
+      wrapper = shallowMount(SelfMonitor, { store });
+
+      expect(wrapper.find('.js-section-header').text()).toBe('Self monitoring');
+    });
+
+    describe('expand/collapse button', () => {
+      it('renders as an expand button by default', () => {
+        wrapper = shallowMount(SelfMonitor, { store });
+
+        const button = wrapper.find(GlButton);
+
+        expect(button.text()).toBe('Expand');
+      });
+    });
+
+    describe('sub-header', () => {
+      it('renders descriptive text', () => {
+        wrapper = shallowMount(SelfMonitor, { store });
+
+        expect(wrapper.find('.js-section-sub-header').text()).toContain(
+          'Enable or disable instance self monitoring',
+        );
+      });
+    });
+
+    describe('settings-content', () => {
+      it('renders the form description without a link', () => {
+        wrapper = shallowMount(SelfMonitor, { store });
+
+        expect(wrapper.vm.selfMonitoringFormText).toContain(
+          'Enabling this feature creates a project that can be used to monitor the health of your instance.',
+        );
+      });
+
+      it('renders the form description with a link', () => {
+        store = createStore({
+          projectEnabled: true,
+          selfMonitorProjectCreated: true,
+          createSelfMonitoringProjectPath: '/create',
+          deleteSelfMonitoringProjectPath: '/delete',
+        });
+
+        wrapper = shallowMount(SelfMonitor, { store });
+
+        expect(wrapper.vm.selfMonitoringFormText).toContain('<a href="http://localhost/">');
+      });
+    });
+  });
+});
diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js
new file mode 100644
index 00000000000..344dbf11954
--- /dev/null
+++ b/spec/frontend/self_monitor/store/actions_spec.js
@@ -0,0 +1,255 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import statusCodes from '~/lib/utils/http_status';
+import * as actions from '~/self_monitor/store/actions';
+import * as types from '~/self_monitor/store/mutation_types';
+import createState from '~/self_monitor/store/state';
+
+describe('self monitor actions', () => {
+  let state;
+  let mock;
+
+  beforeEach(() => {
+    state = createState();
+    mock = new MockAdapter(axios);
+  });
+
+  describe('setSelfMonitor', () => {
+    it('commits the SET_ENABLED mutation', done => {
+      testAction(
+        actions.setSelfMonitor,
+        null,
+        state,
+        [{ type: types.SET_ENABLED, payload: null }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('resetAlert', () => {
+    it('commits the SET_ENABLED mutation', done => {
+      testAction(
+        actions.resetAlert,
+        null,
+        state,
+        [{ type: types.SET_SHOW_ALERT, payload: false }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('requestCreateProject', () => {
+    describe('success', () => {
+      beforeEach(() => {
+        state.createProjectEndpoint = '/create';
+        state.createProjectStatusEndpoint = '/create_status';
+        mock.onPost(state.createProjectEndpoint).reply(statusCodes.ACCEPTED, {
+          job_id: '123',
+        });
+        mock.onGet(state.createProjectStatusEndpoint).reply(statusCodes.OK, {
+          project_full_path: '/self-monitor-url',
+        });
+      });
+
+      it('dispatches status request with job data', done => {
+        testAction(
+          actions.requestCreateProject,
+          null,
+          state,
+          [
+            {
+              type: types.SET_LOADING,
+              payload: true,
+            },
+          ],
+          [
+            {
+              type: 'requestCreateProjectStatus',
+              payload: '123',
+            },
+          ],
+          done,
+        );
+      });
+
+      it('dispatches success with project path', done => {
+        testAction(
+          actions.requestCreateProjectStatus,
+          null,
+          state,
+          [],
+          [
+            {
+              type: 'requestCreateProjectSuccess',
+              payload: { project_full_path: '/self-monitor-url' },
+            },
+          ],
+          done,
+        );
+      });
+    });
+
+    describe('error', () => {
+      beforeEach(() => {
+        state.createProjectEndpoint = '/create';
+        mock.onPost(state.createProjectEndpoint).reply(500);
+      });
+
+      it('dispatches error', done => {
+        testAction(
+          actions.requestCreateProject,
+          null,
+          state,
+          [
+            {
+              type: types.SET_LOADING,
+              payload: true,
+            },
+          ],
+          [
+            {
+              type: 'requestCreateProjectError',
+              payload: new Error('Request failed with status code 500'),
+            },
+          ],
+          done,
+        );
+      });
+    });
+
+    describe('requestCreateProjectSuccess', () => {
+      it('should commit the received data', done => {
+        testAction(
+          actions.requestCreateProjectSuccess,
+          { project_full_path: '/self-monitor-url' },
+          state,
+          [
+            { type: types.SET_LOADING, payload: false },
+            { type: types.SET_PROJECT_URL, payload: '/self-monitor-url' },
+            {
+              type: types.SET_ALERT_CONTENT,
+              payload: {
+                actionName: 'viewSelfMonitorProject',
+                actionText: 'View project',
+                message: 'Self monitoring project has been successfully created.',
+              },
+            },
+            { type: types.SET_SHOW_ALERT, payload: true },
+            { type: types.SET_PROJECT_CREATED, payload: true },
+          ],
+          [],
+          done,
+        );
+      });
+    });
+  });
+
+  describe('deleteSelfMonitorProject', () => {
+    describe('success', () => {
+      beforeEach(() => {
+        state.deleteProjectEndpoint = '/delete';
+        state.deleteProjectStatusEndpoint = '/delete-status';
+        mock.onDelete(state.deleteProjectEndpoint).reply(statusCodes.ACCEPTED, {
+          job_id: '456',
+        });
+        mock.onGet(state.deleteProjectStatusEndpoint).reply(statusCodes.OK, {
+          status: 'success',
+        });
+      });
+
+      it('dispatches status request with job data', done => {
+        testAction(
+          actions.requestDeleteProject,
+          null,
+          state,
+          [
+            {
+              type: types.SET_LOADING,
+              payload: true,
+            },
+          ],
+          [
+            {
+              type: 'requestDeleteProjectStatus',
+              payload: '456',
+            },
+          ],
+          done,
+        );
+      });
+
+      it('dispatches success with status', done => {
+        testAction(
+          actions.requestDeleteProjectStatus,
+          null,
+          state,
+          [],
+          [
+            {
+              type: 'requestDeleteProjectSuccess',
+              payload: { status: 'success' },
+            },
+          ],
+          done,
+        );
+      });
+    });
+
+    describe('error', () => {
+      beforeEach(() => {
+        state.deleteProjectEndpoint = '/delete';
+        mock.onDelete(state.deleteProjectEndpoint).reply(500);
+      });
+
+      it('dispatches error', done => {
+        testAction(
+          actions.requestDeleteProject,
+          null,
+          state,
+          [
+            {
+              type: types.SET_LOADING,
+              payload: true,
+            },
+          ],
+          [
+            {
+              type: 'requestDeleteProjectError',
+              payload: new Error('Request failed with status code 500'),
+            },
+          ],
+          done,
+        );
+      });
+    });
+
+    describe('requestDeleteProjectSuccess', () => {
+      it('should commit mutations to remove previously set data', done => {
+        testAction(
+          actions.requestDeleteProjectSuccess,
+          null,
+          state,
+          [
+            { type: types.SET_PROJECT_URL, payload: '' },
+            { type: types.SET_PROJECT_CREATED, payload: false },
+            {
+              type: types.SET_ALERT_CONTENT,
+              payload: {
+                actionName: 'createProject',
+                actionText: 'Undo',
+                message: 'Self monitoring project has been successfully deleted.',
+              },
+            },
+            { type: types.SET_SHOW_ALERT, payload: true },
+            { type: types.SET_LOADING, payload: false },
+          ],
+          [],
+          done,
+        );
+      });
+    });
+  });
+});
diff --git a/spec/frontend/self_monitor/store/mutations_spec.js b/spec/frontend/self_monitor/store/mutations_spec.js
new file mode 100644
index 00000000000..5282ae3b2f5
--- /dev/null
+++ b/spec/frontend/self_monitor/store/mutations_spec.js
@@ -0,0 +1,64 @@
+import mutations from '~/self_monitor/store/mutations';
+import createState from '~/self_monitor/store/state';
+
+describe('self monitoring mutations', () => {
+  let localState;
+
+  beforeEach(() => {
+    localState = createState();
+  });
+
+  describe('SET_ENABLED', () => {
+    it('sets selfMonitor', () => {
+      mutations.SET_ENABLED(localState, true);
+
+      expect(localState.projectEnabled).toBe(true);
+    });
+  });
+
+  describe('SET_PROJECT_CREATED', () => {
+    it('sets projectCreated', () => {
+      mutations.SET_PROJECT_CREATED(localState, true);
+
+      expect(localState.projectCreated).toBe(true);
+    });
+  });
+
+  describe('SET_SHOW_ALERT', () => {
+    it('sets showAlert', () => {
+      mutations.SET_SHOW_ALERT(localState, true);
+
+      expect(localState.showAlert).toBe(true);
+    });
+  });
+
+  describe('SET_PROJECT_URL', () => {
+    it('sets projectPath', () => {
+      mutations.SET_PROJECT_URL(localState, '/url/');
+
+      expect(localState.projectPath).toBe('/url/');
+    });
+  });
+
+  describe('SET_LOADING', () => {
+    it('sets loading', () => {
+      mutations.SET_LOADING(localState, true);
+
+      expect(localState.loading).toBe(true);
+    });
+  });
+
+  describe('SET_ALERT_CONTENT', () => {
+    it('set alertContent', () => {
+      const alertContent = {
+        message: 'success',
+        actionText: 'undo',
+        actionName: 'createProject',
+      };
+
+      mutations.SET_ALERT_CONTENT(localState, alertContent);
+
+      expect(localState.alertContent).toBe(alertContent);
+    });
+  });
+});
diff --git a/spec/javascripts/diffs/components/app_spec.js b/spec/javascripts/diffs/components/app_spec.js
index 4b4a710df2d..2411fe8ad89 100644
--- a/spec/javascripts/diffs/components/app_spec.js
+++ b/spec/javascripts/diffs/components/app_spec.js
@@ -77,7 +77,7 @@ describe('diffs/components/app', () => {
     beforeEach(done => {
       const fetchResolver = () => {
         store.state.diffs.retrievingBatches = false;
-        return Promise.resolve();
+        return Promise.resolve({ real_size: 100 });
       };
       spyOn(window, 'requestIdleCallback').and.callFake(fn => fn());
       createComponent();
@@ -229,6 +229,7 @@ describe('diffs/components/app', () => {
     });
 
     it('calls fetchDiffFiles if diffsBatchLoad is not enabled', done => {
+      expect(wrapper.vm.diffFilesLength).toEqual(0);
       wrapper.vm.glFeatures.diffsBatchLoad = false;
       wrapper.vm.fetchData(false);
 
@@ -238,12 +239,14 @@ describe('diffs/components/app', () => {
         expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled();
         expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
         expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
+        expect(wrapper.vm.diffFilesLength).toEqual(100);
 
         done();
       });
     });
 
     it('calls batch methods if diffsBatchLoad is enabled, and not latest version', done => {
+      expect(wrapper.vm.diffFilesLength).toEqual(0);
       wrapper.vm.glFeatures.diffsBatchLoad = true;
       wrapper.vm.isLatestVersion = () => false;
       wrapper.vm.fetchData(false);
@@ -254,11 +257,13 @@ describe('diffs/components/app', () => {
         expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
         expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled();
         expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
+        expect(wrapper.vm.diffFilesLength).toEqual(100);
         done();
       });
     });
 
     it('calls batch methods if diffsBatchLoad is enabled, and latest version', done => {
+      expect(wrapper.vm.diffFilesLength).toEqual(0);
       wrapper.vm.glFeatures.diffsBatchLoad = true;
       wrapper.vm.fetchData(false);
 
@@ -268,6 +273,7 @@ describe('diffs/components/app', () => {
         expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
         expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled();
         expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
+        expect(wrapper.vm.diffFilesLength).toEqual(100);
         done();
       });
     });
diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js
index 436d7338361..af2dd7b4f93 100644
--- a/spec/javascripts/diffs/store/actions_spec.js
+++ b/spec/javascripts/diffs/store/actions_spec.js
@@ -141,6 +141,13 @@ describe('DiffsStoreActions', () => {
           done();
         },
       );
+
+      fetchDiffFiles({ state: { endpoint }, commit: () => null })
+        .then(data => {
+          expect(data).toEqual(res);
+          done();
+        })
+        .catch(done.fail);
     });
   });
 
diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js
index eab5703dfb2..9e628fdd540 100644
--- a/spec/javascripts/diffs/store/getters_spec.js
+++ b/spec/javascripts/diffs/store/getters_spec.js
@@ -263,14 +263,6 @@ describe('Diffs Module Getters', () => {
     });
   });
 
-  describe('diffFilesLength', () => {
-    it('returns length of diff files', () => {
-      localState.diffFiles.push('test', 'test 2');
-
-      expect(getters.diffFilesLength(localState)).toBe(2);
-    });
-  });
-
   describe('currentDiffIndex', () => {
     it('returns index of currently selected diff in diffList', () => {
       localState.diffFiles = [{ file_hash: '111' }, { file_hash: '222' }, { file_hash: '333' }];
diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
index a1c00e99927..0ea767e087d 100644
--- a/spec/javascripts/ide/components/new_dropdown/modal_spec.js
+++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
@@ -52,19 +52,6 @@ describe('new file modal component', () => {
           expect(templateFilesEl instanceof Element).toBeTruthy();
         }
       });
-
-      describe('createEntryInStore', () => {
-        it('$emits create', () => {
-          spyOn(vm, 'createTempEntry');
-
-          vm.submitForm();
-
-          expect(vm.createTempEntry).toHaveBeenCalledWith({
-            name: 'testing',
-            type,
-          });
-        });
-      });
     });
   });
 
@@ -145,31 +132,19 @@ describe('new file modal component', () => {
 
       vm = createComponentWithStore(Component, store).$mount();
       const flashSpy = spyOnDependency(modal, 'flash');
-      vm.submitForm();
 
-      expect(flashSpy).toHaveBeenCalled();
-    });
+      expect(flashSpy).not.toHaveBeenCalled();
 
-    it('calls createTempEntry when target path does not exist', () => {
-      const store = createStore();
-      store.state.entryModal = {
-        type: 'rename',
-        path: 'test-path/test',
-        entry: {
-          name: 'test',
-          type: 'blob',
-          path: 'test-path1/test',
-        },
-      };
-
-      vm = createComponentWithStore(Component, store).$mount();
-      spyOn(vm, 'createTempEntry').and.callFake(() => Promise.resolve());
       vm.submitForm();
 
-      expect(vm.createTempEntry).toHaveBeenCalledWith({
-        name: 'test-path1',
-        type: 'tree',
-      });
+      expect(flashSpy).toHaveBeenCalledWith(
+        'The name "test-path/test" is already taken in this directory.',
+        'alert',
+        jasmine.anything(),
+        null,
+        false,
+        true,
+      );
     });
   });
 });
diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js
index 6a7116d87f2..bd51222ac3c 100644
--- a/spec/javascripts/ide/stores/actions/project_spec.js
+++ b/spec/javascripts/ide/stores/actions/project_spec.js
@@ -201,35 +201,30 @@ describe('IDE store project actions', () => {
   });
 
   describe('showEmptyState', () => {
-    it('commits proper mutations when supplied error is 404', done => {
+    it('creates a blank tree and sets loading state to false', done => {
       testAction(
         showEmptyState,
-        {
-          err: {
-            response: {
-              status: 404,
-            },
-          },
-          projectId: 'abc/def',
-          branchId: 'master',
-        },
+        { projectId: 'abc/def', branchId: 'master' },
         store.state,
         [
-          {
-            type: 'CREATE_TREE',
-            payload: {
-              treePath: 'abc/def/master',
-            },
-          },
+          { type: 'CREATE_TREE', payload: { treePath: 'abc/def/master' } },
           {
             type: 'TOGGLE_LOADING',
-            payload: {
-              entry: store.state.trees['abc/def/master'],
-              forceValue: false,
-            },
+            payload: { entry: store.state.trees['abc/def/master'], forceValue: false },
           },
         ],
-        [],
+        jasmine.any(Object),
+        done,
+      );
+    });
+
+    it('sets the currentBranchId to the branchId that was passed', done => {
+      testAction(
+        showEmptyState,
+        { projectId: 'abc/def', branchId: 'master' },
+        store.state,
+        jasmine.any(Object),
+        [{ type: 'setCurrentBranchId', payload: 'master' }],
         done,
       );
     });
diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js
index 8abd9c38514..9c24f20ca9c 100644
--- a/spec/javascripts/ide/stores/actions_spec.js
+++ b/spec/javascripts/ide/stores/actions_spec.js
@@ -206,13 +206,17 @@ describe('Multi-file store actions', () => {
 
     describe('blob', () => {
       it('creates temp file', done => {
+        const name = 'test';
+
         store
           .dispatch('createTempEntry', {
-            name: 'test',
+            name,
             branchId: 'mybranch',
             type: 'blob',
           })
-          .then(f => {
+          .then(() => {
+            const f = store.state.entries[name];
+
             expect(f.tempFile).toBeTruthy();
             expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1);
 
@@ -222,13 +226,17 @@ describe('Multi-file store actions', () => {
       });
 
       it('adds tmp file to open files', done => {
+        const name = 'test';
+
         store
           .dispatch('createTempEntry', {
-            name: 'test',
+            name,
             branchId: 'mybranch',
             type: 'blob',
           })
-          .then(f => {
+          .then(() => {
+            const f = store.state.entries[name];
+
             expect(store.state.openFiles.length).toBe(1);
             expect(store.state.openFiles[0].name).toBe(f.name);
 
@@ -238,13 +246,17 @@ describe('Multi-file store actions', () => {
       });
 
       it('adds tmp file to changed files', done => {
+        const name = 'test';
+
         store
           .dispatch('createTempEntry', {
-            name: 'test',
+            name,
             branchId: 'mybranch',
             type: 'blob',
           })
-          .then(f => {
+          .then(() => {
+            const f = store.state.entries[name];
+
             expect(store.state.changedFiles.length).toBe(1);
             expect(store.state.changedFiles[0].name).toBe(f.name);
 
@@ -292,7 +304,9 @@ describe('Multi-file store actions', () => {
             type: 'blob',
           })
           .then(() => {
-            expect(document.querySelector('.flash-alert')).not.toBeNull();
+            expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual(
+              `The name "${f.name}" is already taken in this directory.`,
+            );
 
             done();
           })
@@ -604,36 +618,98 @@ describe('Multi-file store actions', () => {
       );
     });
 
-    it('if renamed, reverts the rename before deleting', () => {
-      const testEntry = {
-        path: 'test',
-        name: 'test',
-        prevPath: 'lorem/ipsum',
-        prevName: 'ipsum',
-        prevParentPath: 'lorem',
-      };
+    describe('when renamed', () => {
+      let testEntry;
 
-      store.state.entries = { test: testEntry };
-      testAction(
-        deleteEntry,
-        testEntry.path,
-        store.state,
-        [],
-        [
-          {
-            type: 'renameEntry',
-            payload: {
-              path: testEntry.path,
-              name: testEntry.prevName,
-              parentPath: testEntry.prevParentPath,
-            },
-          },
-          {
-            type: 'deleteEntry',
-            payload: testEntry.prevPath,
-          },
-        ],
-      );
+      beforeEach(() => {
+        testEntry = {
+          path: 'test',
+          name: 'test',
+          prevPath: 'test_old',
+          prevName: 'test_old',
+          prevParentPath: '',
+        };
+
+        store.state.entries = { test: testEntry };
+      });
+
+      describe('and previous does not exist', () => {
+        it('reverts the rename before deleting', done => {
+          testAction(
+            deleteEntry,
+            testEntry.path,
+            store.state,
+            [],
+            [
+              {
+                type: 'renameEntry',
+                payload: {
+                  path: testEntry.path,
+                  name: testEntry.prevName,
+                  parentPath: testEntry.prevParentPath,
+                },
+              },
+              {
+                type: 'deleteEntry',
+                payload: testEntry.prevPath,
+              },
+            ],
+            done,
+          );
+        });
+      });
+
+      describe('and previous exists', () => {
+        beforeEach(() => {
+          const oldEntry = {
+            path: testEntry.prevPath,
+            name: testEntry.prevName,
+          };
+
+          store.state.entries[oldEntry.path] = oldEntry;
+        });
+
+        it('does not revert rename before deleting', done => {
+          testAction(
+            deleteEntry,
+            testEntry.path,
+            store.state,
+            [{ type: types.DELETE_ENTRY, payload: testEntry.path }],
+            [
+              { type: 'burstUnusedSeal' },
+              { type: 'stageChange', payload: testEntry.path },
+              { type: 'triggerFilesChange' },
+            ],
+            done,
+          );
+        });
+
+        it('when previous is deleted, it reverts rename before deleting', done => {
+          store.state.entries[testEntry.prevPath].deleted = true;
+
+          testAction(
+            deleteEntry,
+            testEntry.path,
+            store.state,
+            [],
+            [
+              {
+                type: 'renameEntry',
+                payload: {
+                  path: testEntry.path,
+                  name: testEntry.prevName,
+                  parentPath: testEntry.prevParentPath,
+                },
+              },
+              {
+                type: 'deleteEntry',
+                payload: testEntry.prevPath,
+              },
+            ],
+            done,
+          );
+        });
+      });
     });
 
     it('bursts unused seal', done => {
@@ -918,6 +994,103 @@ describe('Multi-file store actions', () => {
           .then(done)
           .catch(done.fail);
       });
+
+      describe('with file in directory', () => {
+        const parentPath = 'original-dir';
+        const newParentPath = 'new-dir';
+        const fileName = 'test.md';
+        const filePath = `${parentPath}/${fileName}`;
+
+        let rootDir;
+
+        beforeEach(() => {
+          const parentEntry = file(parentPath, parentPath, 'tree');
+          const fileEntry = file(filePath, filePath, 'blob', parentEntry);
+          rootDir = {
+            tree: [],
+          };
+
+          Object.assign(store.state, {
+            entries: {
+              [parentPath]: {
+                ...parentEntry,
+                tree: [fileEntry],
+              },
+              [filePath]: fileEntry,
+            },
+            trees: {
+              '/': rootDir,
+            },
+          });
+        });
+
+        it('creates new directory', done => {
+          expect(store.state.entries[newParentPath]).toBeUndefined();
+
+          store
+            .dispatch('renameEntry', { path: filePath, name: fileName, parentPath: newParentPath })
+            .then(() => {
+              expect(store.state.entries[newParentPath]).toEqual(
+                jasmine.objectContaining({
+                  path: newParentPath,
+                  type: 'tree',
+                  tree: jasmine.arrayContaining([
+                    store.state.entries[`${newParentPath}/${fileName}`],
+                  ]),
+                }),
+              );
+            })
+            .then(done)
+            .catch(done.fail);
+        });
+
+        describe('when new directory exists', () => {
+          let newDir;
+
+          beforeEach(() => {
+            newDir = file(newParentPath, newParentPath, 'tree');
+
+            store.state.entries[newDir.path] = newDir;
+            rootDir.tree.push(newDir);
+          });
+
+          it('inserts in new directory', done => {
+            expect(newDir.tree).toEqual([]);
+
+            store
+              .dispatch('renameEntry', {
+                path: filePath,
+                name: fileName,
+                parentPath: newParentPath,
+              })
+              .then(() => {
+                expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]);
+              })
+              .then(done)
+              .catch(done.fail);
+          });
+
+          it('when new directory is deleted, it undeletes it', done => {
+            store.dispatch('deleteEntry', newParentPath);
+
+            expect(store.state.entries[newParentPath].deleted).toBe(true);
+            expect(rootDir.tree.some(x => x.path === newParentPath)).toBe(false);
+
+            store
+              .dispatch('renameEntry', {
+                path: filePath,
+                name: fileName,
+                parentPath: newParentPath,
+              })
+              .then(() => {
+                expect(store.state.entries[newParentPath].deleted).toBe(false);
+                expect(rootDir.tree.some(x => x.path === newParentPath)).toBe(true);
+              })
+              .then(done)
+              .catch(done.fail);
+          });
+        });
+      });
     });
   });
 
diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/repository_link_filter_spec.rb
similarity index 66%
rename from spec/lib/banzai/filter/relative_link_filter_spec.rb
rename to spec/lib/banzai/filter/repository_link_filter_spec.rb
index 9f467d7a6fd..c87f452a3df 100644
--- a/spec/lib/banzai/filter/relative_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/repository_link_filter_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-describe Banzai::Filter::RelativeLinkFilter do
+describe Banzai::Filter::RepositoryLinkFilter do
   include GitHelpers
   include RepoHelpers
 
@@ -128,11 +128,6 @@ describe Banzai::Filter::RelativeLinkFilter do
     expect { filter(act) }.not_to raise_error
   end
 
-  it 'does not raise an exception on URIs containing invalid utf-8 byte sequences in uploads' do
-    act = link("/uploads/%FF")
-    expect { filter(act) }.not_to raise_error
-  end
-
   it 'does not raise an exception on URIs containing invalid utf-8 byte sequences in context requested path' do
     expect { filter(link("files/test.md"), requested_path: '%FF') }.not_to raise_error
   end
@@ -147,11 +142,6 @@ describe Banzai::Filter::RelativeLinkFilter do
     expect { filter(act) }.not_to raise_error
   end
 
-  it 'does not raise an exception with a space in the path' do
-    act = link("/uploads/d18213acd3732630991986120e167e3d/Landscape_8.jpg  \nBut here's some more unexpected text :smile:)")
-    expect { filter(act) }.not_to raise_error
-  end
-
   it 'ignores ref if commit is passed' do
     doc = filter(link('non/existent.file'), commit: project.commit('empty-branch') )
     expect(doc.at_css('a')['href'])
@@ -350,166 +340,4 @@ describe Banzai::Filter::RelativeLinkFilter do
 
     include_examples :valid_repository
   end
-
-  context 'with a /upload/ URL' do
-    # not needed
-    let(:commit) { nil }
-    let(:ref) { nil }
-    let(:requested_path) { nil }
-    let(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' }
-    let(:relative_path) { "/#{project.full_path}#{upload_path}" }
-
-    context 'to a project upload' do
-      shared_examples 'rewrite project uploads' do
-        context 'with an absolute URL' do
-          let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
-          let(:only_path) { false }
-
-          it 'rewrites the link correctly' do
-            doc = filter(link(upload_path))
-
-            expect(doc.at_css('a')['href']).to eq(absolute_path)
-          end
-        end
-
-        it 'rebuilds relative URL for a link' do
-          doc = filter(link(upload_path))
-          expect(doc.at_css('a')['href']).to eq(relative_path)
-
-          doc = filter(nested(link(upload_path)))
-          expect(doc.at_css('a')['href']).to eq(relative_path)
-        end
-
-        it 'rebuilds relative URL for an image' do
-          doc = filter(image(upload_path))
-          expect(doc.at_css('img')['src']).to eq(relative_path)
-
-          doc = filter(nested(image(upload_path)))
-          expect(doc.at_css('img')['src']).to eq(relative_path)
-        end
-
-        it 'does not modify absolute URL' do
-          doc = filter(link('http://example.com'))
-          expect(doc.at_css('a')['href']).to eq 'http://example.com'
-        end
-
-        it 'supports unescaped Unicode filenames' do
-          path = '/uploads/한글.png'
-          doc = filter(link(path))
-
-          expect(doc.at_css('a')['href']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
-        end
-
-        it 'supports escaped Unicode filenames' do
-          path = '/uploads/한글.png'
-          escaped = Addressable::URI.escape(path)
-          doc = filter(image(escaped))
-
-          expect(doc.at_css('img')['src']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
-        end
-      end
-
-      context 'without project repository access' do
-        let(:project) { create(:project, :repository, repository_access_level: ProjectFeature::PRIVATE) }
-
-        it_behaves_like 'rewrite project uploads'
-      end
-
-      context 'with project repository access' do
-        it_behaves_like 'rewrite project uploads'
-      end
-    end
-
-    context 'to a group upload' do
-      let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') }
-      let(:group) { create(:group) }
-      let(:project) { nil }
-      let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" }
-
-      context 'with an absolute URL' do
-        let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
-        let(:only_path) { false }
-
-        it 'rewrites the link correctly' do
-          doc = filter(upload_link)
-
-          expect(doc.at_css('a')['href']).to eq(absolute_path)
-        end
-      end
-
-      it 'rewrites the link correctly' do
-        doc = filter(upload_link)
-
-        expect(doc.at_css('a')['href']).to eq(relative_path)
-      end
-
-      it 'rewrites the link correctly for subgroup' do
-        group.update!(parent: create(:group))
-
-        doc = filter(upload_link)
-
-        expect(doc.at_css('a')['href']).to eq(relative_path)
-      end
-
-      it 'does not modify absolute URL' do
-        doc = filter(link('http://example.com'))
-
-        expect(doc.at_css('a')['href']).to eq 'http://example.com'
-      end
-    end
-
-    context 'to a personal snippet' do
-      let(:group) { nil }
-      let(:project) { nil }
-      let(:relative_path) { '/uploads/-/system/personal_snippet/6/674e4f07fbf0a7736c3439212896e51a/example.tar.gz' }
-
-      context 'with an absolute URL' do
-        let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
-        let(:only_path) { false }
-
-        it 'rewrites the link correctly' do
-          doc = filter(link(relative_path))
-
-          expect(doc.at_css('a')['href']).to eq(absolute_path)
-        end
-      end
-
-      context 'with a relative URL root' do
-        let(:gitlab_root) { '/gitlab' }
-        let(:absolute_path) { Gitlab.config.gitlab.url + gitlab_root + relative_path }
-
-        before do
-          stub_config_setting(relative_url_root: gitlab_root)
-        end
-
-        context 'with an absolute URL' do
-          let(:only_path) { false }
-
-          it 'rewrites the link correctly' do
-            doc = filter(link(relative_path))
-
-            expect(doc.at_css('a')['href']).to eq(absolute_path)
-          end
-        end
-
-        it 'rewrites the link correctly' do
-          doc = filter(link(relative_path))
-
-          expect(doc.at_css('a')['href']).to eq(gitlab_root + relative_path)
-        end
-      end
-
-      it 'rewrites the link correctly' do
-        doc = filter(link(relative_path))
-
-        expect(doc.at_css('a')['href']).to eq(relative_path)
-      end
-
-      it 'does not modify absolute URL' do
-        doc = filter(link('http://example.com'))
-
-        expect(doc.at_css('a')['href']).to eq 'http://example.com'
-      end
-    end
-  end
 end
diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb
new file mode 100644
index 00000000000..3f181dce7bc
--- /dev/null
+++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb
@@ -0,0 +1,221 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Banzai::Filter::UploadLinkFilter do
+  def filter(doc, contexts = {})
+    contexts.reverse_merge!(
+      project: project,
+      group: group,
+      only_path: only_path
+    )
+
+    described_class.call(doc, contexts)
+  end
+
+  def image(path)
+    %(<img src="#{path}" />)
+  end
+
+  def video(path)
+    %(<video src="#{path}"></video>)
+  end
+
+  def audio(path)
+    %(<audio src="#{path}"></audio>)
+  end
+
+  def link(path)
+    %(<a href="#{path}">#{path}</a>)
+  end
+
+  def nested(element)
+    %(<div>#{element}</div>)
+  end
+
+  let_it_be(:project) { create(:project, :public) }
+  let_it_be(:user) { create(:user) }
+  let(:group) { nil }
+  let(:project_path) { project.full_path }
+  let(:only_path) { true }
+  let(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' }
+  let(:relative_path) { "/#{project.full_path}#{upload_path}" }
+
+  context 'to a project upload' do
+    context 'with an absolute URL' do
+      let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
+      let(:only_path) { false }
+
+      it 'rewrites the link correctly' do
+        doc = filter(link(upload_path))
+
+        expect(doc.at_css('a')['href']).to eq(absolute_path)
+        expect(doc.at_css('a').classes).to include('gfm')
+      end
+    end
+
+    it 'rebuilds relative URL for a link' do
+      doc = filter(link(upload_path))
+
+      expect(doc.at_css('a')['href']).to eq(relative_path)
+      expect(doc.at_css('a').classes).to include('gfm')
+
+      doc = filter(nested(link(upload_path)))
+
+      expect(doc.at_css('a')['href']).to eq(relative_path)
+      expect(doc.at_css('a').classes).to include('gfm')
+    end
+
+    it 'rebuilds relative URL for an image' do
+      doc = filter(image(upload_path))
+
+      expect(doc.at_css('img')['src']).to eq(relative_path)
+      expect(doc.at_css('img').classes).to include('gfm')
+
+      doc = filter(nested(image(upload_path)))
+
+      expect(doc.at_css('img')['src']).to eq(relative_path)
+      expect(doc.at_css('img').classes).to include('gfm')
+    end
+
+    it 'does not modify absolute URL' do
+      doc = filter(link('http://example.com'))
+
+      expect(doc.at_css('a')['href']).to eq 'http://example.com'
+      expect(doc.at_css('a').classes).not_to include('gfm')
+    end
+
+    it 'supports unescaped Unicode filenames' do
+      path = '/uploads/한글.png'
+      doc = filter(link(path))
+
+      expect(doc.at_css('a')['href']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
+      expect(doc.at_css('a').classes).to include('gfm')
+    end
+
+    it 'supports escaped Unicode filenames' do
+      path = '/uploads/한글.png'
+      escaped = Addressable::URI.escape(path)
+      doc = filter(image(escaped))
+
+      expect(doc.at_css('img')['src']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
+      expect(doc.at_css('img').classes).to include('gfm')
+    end
+  end
+
+  context 'to a group upload' do
+    let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') }
+    let_it_be(:group) { create(:group) }
+    let(:project) { nil }
+    let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" }
+
+    context 'with an absolute URL' do
+      let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
+      let(:only_path) { false }
+
+      it 'rewrites the link correctly' do
+        doc = filter(upload_link)
+
+        expect(doc.at_css('a')['href']).to eq(absolute_path)
+        expect(doc.at_css('a').classes).to include('gfm')
+      end
+    end
+
+    it 'rewrites the link correctly' do
+      doc = filter(upload_link)
+
+      expect(doc.at_css('a')['href']).to eq(relative_path)
+      expect(doc.at_css('a').classes).to include('gfm')
+    end
+
+    it 'rewrites the link correctly for subgroup' do
+      group.update!(parent: create(:group))
+
+      doc = filter(upload_link)
+
+      expect(doc.at_css('a')['href']).to eq(relative_path)
+      expect(doc.at_css('a').classes).to include('gfm')
+    end
+
+    it 'does not modify absolute URL' do
+      doc = filter(link('http://example.com'))
+
+      expect(doc.at_css('a')['href']).to eq 'http://example.com'
+      expect(doc.at_css('a').classes).not_to include('gfm')
+    end
+  end
+
+  context 'to a personal snippet' do
+    let(:group) { nil }
+    let(:project) { nil }
+    let(:relative_path) { '/uploads/-/system/personal_snippet/6/674e4f07fbf0a7736c3439212896e51a/example.tar.gz' }
+
+    context 'with an absolute URL' do
+      let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
+      let(:only_path) { false }
+
+      it 'rewrites the link correctly' do
+        doc = filter(link(relative_path))
+
+        expect(doc.at_css('a')['href']).to eq(absolute_path)
+        expect(doc.at_css('a').classes).to include('gfm')
+      end
+    end
+
+    context 'with a relative URL root' do
+      let(:gitlab_root) { '/gitlab' }
+      let(:absolute_path) { Gitlab.config.gitlab.url + gitlab_root + relative_path }
+
+      before do
+        stub_config_setting(relative_url_root: gitlab_root)
+      end
+
+      context 'with an absolute URL' do
+        let(:only_path) { false }
+
+        it 'rewrites the link correctly' do
+          doc = filter(link(relative_path))
+
+          expect(doc.at_css('a')['href']).to eq(absolute_path)
+          expect(doc.at_css('a').classes).to include('gfm')
+        end
+      end
+
+      it 'rewrites the link correctly' do
+        doc = filter(link(relative_path))
+
+        expect(doc.at_css('a')['href']).to eq(gitlab_root + relative_path)
+        expect(doc.at_css('a').classes).to include('gfm')
+      end
+    end
+
+    it 'rewrites the link correctly' do
+      doc = filter(link(relative_path))
+
+      expect(doc.at_css('a')['href']).to eq(relative_path)
+      expect(doc.at_css('a').classes).to include('gfm')
+    end
+
+    it 'does not modify absolute URL' do
+      doc = filter(link('http://example.com'))
+
+      expect(doc.at_css('a')['href']).to eq 'http://example.com'
+      expect(doc.at_css('a').classes).not_to include('gfm')
+    end
+  end
+
+  context 'invalid input' do
+    using RSpec::Parameterized::TableSyntax
+
+    where(:name, :href) do
+      'invalid URI'                  | '://foo'
+      'invalid UTF-8 byte sequences' | '%FF'
+      'garbled path'                 | 'open(/var/tmp/):%20/location%0Afrom:%20/test'
+      'whitespace'                   | "d18213acd3732630991986120e167e3d/Landscape_8.jpg\nand more"
+    end
+
+    with_them do
+      it { expect { filter(link("/uploads/#{href}")) }.not_to raise_error }
+    end
+  end
+end
diff --git a/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb b/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb
new file mode 100644
index 00000000000..ab72354edcf
--- /dev/null
+++ b/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Banzai::Pipeline::PostProcessPipeline do
+  context 'when a document only has upload links' do
+    it 'does not make any Gitaly calls', :request_store do
+      markdown = <<-MARKDOWN.strip_heredoc
+        [Relative Upload Link](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg)
+
+        ![Relative Upload Image](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg)
+      MARKDOWN
+
+      context = {
+        project: create(:project, :public, :repository),
+        ref: 'master'
+      }
+
+      Gitlab::GitalyClient.reset_counts
+
+      described_class.call(markdown, context)
+
+      expect(Gitlab::GitalyClient.get_request_count).to eq(0)
+    end
+  end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb b/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb
index 3101e782b8f..3ccb2379936 100644
--- a/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb
+++ b/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb
@@ -37,6 +37,25 @@ describe Gitlab::BackgroundMigration::MigrateFingerprintSha256WithinKeys, :migra
     expect(key_2.fingerprint_sha256).to eq('zMNbLekgdjtcgDv8VSC0z5lpdACMG3Q4PUoIz5+H2jM')
   end
 
+  context 'with invalid keys' do
+    before do
+      key = Key.find(1017)
+      # double space after "ssh-rsa" leads to a
+      # OpenSSL::PKey::PKeyError in Net::SSH::KeyFactory.load_data_public_key
+      key.update_column(:key, key.key.gsub('ssh-rsa ', 'ssh-rsa  '))
+    end
+
+    it 'ignores errors and does not set the fingerprint' do
+      fingerprint_migrator.perform(1, 10000)
+
+      key_1 = Key.find(1017)
+      key_2 = Key.find(1027)
+
+      expect(key_1.fingerprint_sha256).to be_nil
+      expect(key_2.fingerprint_sha256).not_to be_nil
+    end
+  end
+
   it 'migrates all keys' do
     expect(Key.where(fingerprint_sha256: nil).count).to eq(Key.all.count)
 
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index 35b2993443f..103019d8dd8 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -10,8 +10,21 @@ module MarkdownMatchers
   extend RSpec::Matchers::DSL
   include Capybara::Node::Matchers
 
-  # RelativeLinkFilter
-  matcher :parse_relative_links do
+  # UploadLinkFilter
+  matcher :parse_upload_links do
+    set_default_markdown_messages
+
+    match do |actual|
+      link = actual.at_css('a:contains("Relative Upload Link")')
+      image = actual.at_css('img[alt="Relative Upload Image"]')
+
+      expect(link['href']).to eq("/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg")
+      expect(image['data-src']).to eq("/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg")
+    end
+  end
+
+  # RepositoryLinkFilter
+  matcher :parse_repository_links do
     set_default_markdown_messages
 
     match do |actual|
-- 
2.30.9