summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-03-06 18:08:12 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-03-06 18:08:12 +0000
commite22c3819ad2321a0cf825877fe3b60e41268c5b3 (patch)
treefcd143b30bdd7b42d439cd0b2fc5c6c4268d8d97 /app
parent49b16b71778148e9f9c579bf7bf69853c780c827 (diff)
downloadgitlab-ce-e22c3819ad2321a0cf825877fe3b60e41268c5b3.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/blame/streaming/index.js56
-rw-r--r--app/assets/javascripts/pages/projects/blame/show/index.js7
-rw-r--r--app/assets/javascripts/streaming/chunk_writer.js144
-rw-r--r--app/assets/javascripts/streaming/constants.js9
-rw-r--r--app/assets/javascripts/streaming/handle_streamed_anchor_link.js26
-rw-r--r--app/assets/javascripts/streaming/html_stream.js33
-rw-r--r--app/assets/javascripts/streaming/polyfills.js5
-rw-r--r--app/assets/javascripts/streaming/rate_limit_stream_requests.js87
-rw-r--r--app/assets/javascripts/streaming/render_balancer.js36
-rw-r--r--app/assets/javascripts/streaming/render_html_streams.js40
-rw-r--r--app/assets/stylesheets/framework/files.scss28
-rw-r--r--app/controllers/admin/applications_controller.rb2
-rw-r--r--app/controllers/admin/identities_controller.rb2
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb2
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/keys_controller.rb2
-rw-r--r--app/controllers/admin/sessions_controller.rb2
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/groups/settings/access_tokens_controller.rb2
-rw-r--r--app/controllers/groups/settings/applications_controller.rb2
-rw-r--r--app/controllers/import/github_controller.rb6
-rw-r--r--app/controllers/invites_controller.rb2
-rw-r--r--app/controllers/jwt_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/accounts_controller.rb2
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb2
-rw-r--r--app/controllers/profiles/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles/webauthn_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb2
-rw-r--r--app/controllers/projects/blame_controller.rb38
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb2
-rw-r--r--app/controllers/registrations/welcome_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb62
-rw-r--r--app/graphql/types/analytics/cycle_analytics/flow_metrics.rb5
-rw-r--r--app/policies/group_policy.rb2
-rw-r--r--app/policies/project_policy.rb2
-rw-r--r--app/services/projects/blame_service.rb42
-rw-r--r--app/services/protected_branches/base_service.rb2
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/component_preview.html.haml4
-rw-r--r--app/views/projects/blame/show.html.haml27
-rw-r--r--app/views/users/_profile_basic_info.html.haml8
-rw-r--r--app/views/users/show.html.haml146
-rw-r--r--app/workers/all_queues.yml28
-rw-r--r--app/workers/authorized_project_update/project_recalculate_per_user_worker.rb2
-rw-r--r--app/workers/authorized_project_update/project_recalculate_worker.rb2
-rw-r--r--app/workers/authorized_project_update/user_refresh_from_replica_worker.rb2
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb2
-rw-r--r--app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb2
-rw-r--r--app/workers/authorized_projects_worker.rb2
-rw-r--r--app/workers/delete_user_worker.rb2
-rw-r--r--app/workers/groups/update_two_factor_requirement_for_members_worker.rb2
-rw-r--r--app/workers/members_destroyer/unassign_issuables_worker.rb2
-rw-r--r--app/workers/personal_access_tokens/expired_notification_worker.rb2
-rw-r--r--app/workers/personal_access_tokens/expiring_worker.rb2
-rw-r--r--app/workers/remove_expired_group_links_worker.rb2
-rw-r--r--app/workers/remove_expired_members_worker.rb2
-rw-r--r--app/workers/remove_unaccepted_member_invites_worker.rb2
64 files changed, 772 insertions, 150 deletions
diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js
new file mode 100644
index 00000000000..a74e01b6423
--- /dev/null
+++ b/app/assets/javascripts/blame/streaming/index.js
@@ -0,0 +1,56 @@
+import { renderHtmlStreams } from '~/streaming/render_html_streams';
+import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { createAlert } from '~/flash';
+import { __ } from '~/locale';
+import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
+import { toPolyfillReadable } from '~/streaming/polyfills';
+
+export async function renderBlamePageStreams(firstStreamPromise) {
+ const element = document.querySelector('#blame-stream-container');
+
+ if (!element || !firstStreamPromise) return;
+
+ const stopAnchorObserver = handleStreamedAnchorLink(element);
+ const { dataset } = document.querySelector('#blob-content-holder');
+ const totalExtraPages = parseInt(dataset.totalExtraPages, 10);
+ const { pagesUrl } = dataset;
+
+ const remainingStreams = rateLimitStreamRequests({
+ factory: (index) => {
+ const url = new URL(pagesUrl);
+ // page numbers start with 1
+ // the first page is already rendered in the document
+ // the second page is passed with the 'firstStreamPromise'
+ url.searchParams.set('page', index + 3);
+ return fetch(url).then((response) => toPolyfillReadable(response.body));
+ },
+ // we don't want to overload gitaly with concurrent requests
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/391842#note_1281695095
+ // using 5 as a good starting point
+ maxConcurrentRequests: 5,
+ total: totalExtraPages,
+ });
+
+ try {
+ await renderHtmlStreams(
+ [firstStreamPromise.then(toPolyfillReadable), ...remainingStreams],
+ element,
+ );
+ } catch (error) {
+ createAlert({
+ message: __('Blame could not be loaded as a single page.'),
+ primaryButton: {
+ text: __('View blame as separate pages'),
+ clickHandler() {
+ const newUrl = new URL(window.location);
+ newUrl.searchParams.delete('streaming');
+ window.location.href = newUrl;
+ },
+ },
+ });
+ throw error;
+ } finally {
+ stopAnchorObserver();
+ document.querySelector('#blame-stream-loading').remove();
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js
index 1e4b9de90f2..f0fdd18c828 100644
--- a/app/assets/javascripts/pages/projects/blame/show/index.js
+++ b/app/assets/javascripts/pages/projects/blame/show/index.js
@@ -1,5 +1,10 @@
import initBlob from '~/pages/projects/init_blob';
import redirectToCorrectPage from '~/blame/blame_redirect';
+import { renderBlamePageStreams } from '~/blame/streaming';
-redirectToCorrectPage();
+if (new URLSearchParams(window.location.search).get('streaming')) {
+ renderBlamePageStreams(window.blamePageStream);
+} else {
+ redirectToCorrectPage();
+}
initBlob();
diff --git a/app/assets/javascripts/streaming/chunk_writer.js b/app/assets/javascripts/streaming/chunk_writer.js
new file mode 100644
index 00000000000..4bbd0a5f843
--- /dev/null
+++ b/app/assets/javascripts/streaming/chunk_writer.js
@@ -0,0 +1,144 @@
+import { throttle } from 'lodash';
+import { RenderBalancer } from '~/streaming/render_balancer';
+import {
+ BALANCE_RATE,
+ HIGH_FRAME_TIME,
+ LOW_FRAME_TIME,
+ MAX_CHUNK_SIZE,
+ MIN_CHUNK_SIZE,
+ TIMEOUT,
+} from '~/streaming/constants';
+
+const defaultConfig = {
+ balanceRate: BALANCE_RATE,
+ minChunkSize: MIN_CHUNK_SIZE,
+ maxChunkSize: MAX_CHUNK_SIZE,
+ lowFrameTime: LOW_FRAME_TIME,
+ highFrameTime: HIGH_FRAME_TIME,
+ timeout: TIMEOUT,
+};
+
+function concatUint8Arrays(a, b) {
+ const array = new Uint8Array(a.length + b.length);
+ array.set(a, 0);
+ array.set(b, a.length);
+ return array;
+}
+
+// This class is used to write chunks with a balanced size
+// to avoid blocking main thread for too long.
+//
+// A chunk can be:
+// 1. Too small
+// 2. Too large
+// 3. Delayed in time
+//
+// This class resolves all these problems by
+// 1. Splitting or concatenating chunks to met the size criteria
+// 2. Rendering current chunk buffer immediately if enough time has passed
+//
+// The size of the chunk is determined by RenderBalancer,
+// It measures execution time for each chunk write and adjusts next chunk size.
+export class ChunkWriter {
+ buffer = null;
+ decoder = new TextDecoder('utf-8');
+ timeout = null;
+
+ constructor(htmlStream, config) {
+ this.htmlStream = htmlStream;
+
+ const { balanceRate, minChunkSize, maxChunkSize, lowFrameTime, highFrameTime, timeout } = {
+ ...defaultConfig,
+ ...config,
+ };
+
+ // ensure we still render chunks over time if the size criteria is not met
+ this.scheduleAccumulatorFlush = throttle(this.flushAccumulator.bind(this), timeout);
+
+ const averageSize = Math.round((maxChunkSize + minChunkSize) / 2);
+ this.size = Math.max(averageSize, minChunkSize);
+
+ this.balancer = new RenderBalancer({
+ lowFrameTime,
+ highFrameTime,
+ decrease: () => {
+ this.size = Math.round(Math.max(this.size / balanceRate, minChunkSize));
+ },
+ increase: () => {
+ this.size = Math.round(Math.min(this.size * balanceRate, maxChunkSize));
+ },
+ });
+ }
+
+ write(chunk) {
+ this.scheduleAccumulatorFlush.cancel();
+
+ if (this.buffer) {
+ this.buffer = concatUint8Arrays(this.buffer, chunk);
+ } else {
+ this.buffer = chunk;
+ }
+
+ // accumulate chunks until the size is fulfilled
+ if (this.size > this.buffer.length) {
+ this.scheduleAccumulatorFlush();
+ return Promise.resolve();
+ }
+
+ return this.balancedWrite();
+ }
+
+ balancedWrite() {
+ let cursor = 0;
+
+ return this.balancer.render(() => {
+ const chunkPart = this.buffer.subarray(cursor, cursor + this.size);
+ // accumulate chunks until the size is fulfilled
+ // this is a hot path for the last chunkPart of the chunk
+ if (chunkPart.length < this.size) {
+ this.buffer = chunkPart;
+ this.scheduleAccumulatorFlush();
+ return false;
+ }
+
+ this.writeToDom(chunkPart);
+
+ cursor += this.size;
+ if (cursor >= this.buffer.length) {
+ this.buffer = null;
+ return false;
+ }
+ // continue render
+ return true;
+ });
+ }
+
+ writeToDom(chunk, stream = true) {
+ // stream: true allows us to split chunks with multi-part words
+ const decoded = this.decoder.decode(chunk, { stream });
+ this.htmlStream.write(decoded);
+ }
+
+ flushAccumulator() {
+ if (this.buffer) {
+ this.writeToDom(this.buffer);
+ this.buffer = null;
+ }
+ }
+
+ close() {
+ this.scheduleAccumulatorFlush.cancel();
+ if (this.buffer) {
+ // last chunk should have stream: false to indicate the end of the stream
+ this.writeToDom(this.buffer, false);
+ this.buffer = null;
+ }
+ this.htmlStream.close();
+ }
+
+ abort() {
+ this.scheduleAccumulatorFlush.cancel();
+ this.buffer = null;
+ this.htmlStream.abort();
+ }
+}
diff --git a/app/assets/javascripts/streaming/constants.js b/app/assets/javascripts/streaming/constants.js
new file mode 100644
index 00000000000..224d93a7ac1
--- /dev/null
+++ b/app/assets/javascripts/streaming/constants.js
@@ -0,0 +1,9 @@
+// Lower min chunk numbers can make the page loading take incredibly long
+export const MIN_CHUNK_SIZE = 128 * 1024;
+export const MAX_CHUNK_SIZE = 2048 * 1024;
+export const LOW_FRAME_TIME = 32;
+// Tasks that take more than 50ms are considered Long
+// https://web.dev/optimize-long-tasks/
+export const HIGH_FRAME_TIME = 64;
+export const BALANCE_RATE = 1.2;
+export const TIMEOUT = 500;
diff --git a/app/assets/javascripts/streaming/handle_streamed_anchor_link.js b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js
new file mode 100644
index 00000000000..315dc9bb0a0
--- /dev/null
+++ b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js
@@ -0,0 +1,26 @@
+import { throttle } from 'lodash';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import LineHighlighter from '~/blob/line_highlighter';
+
+const noop = () => {};
+
+export function handleStreamedAnchorLink(rootElement) {
+ // "#L100-200" → ['L100', 'L200']
+ const [anchorStart, end] = window.location.hash.substring(1).split('-');
+ const anchorEnd = end ? `L${end}` : anchorStart;
+ if (!anchorStart || document.getElementById(anchorEnd)) return noop;
+
+ const handler = throttle((mutationList, instance) => {
+ if (!document.getElementById(anchorEnd)) return;
+ scrollToElement(document.getElementById(anchorStart));
+ // eslint-disable-next-line no-new
+ new LineHighlighter();
+ instance.disconnect();
+ }, 300);
+
+ const observer = new MutationObserver(handler);
+
+ observer.observe(rootElement, { childList: true, subtree: true });
+
+ return () => observer.disconnect();
+}
diff --git a/app/assets/javascripts/streaming/html_stream.js b/app/assets/javascripts/streaming/html_stream.js
new file mode 100644
index 00000000000..8182f69a607
--- /dev/null
+++ b/app/assets/javascripts/streaming/html_stream.js
@@ -0,0 +1,33 @@
+import { ChunkWriter } from '~/streaming/chunk_writer';
+
+export class HtmlStream {
+ constructor(element) {
+ const streamDocument = document.implementation.createHTMLDocument('stream');
+
+ streamDocument.open();
+ streamDocument.write('<streaming-element>');
+
+ const virtualStreamingElement = streamDocument.querySelector('streaming-element');
+ element.appendChild(document.adoptNode(virtualStreamingElement));
+
+ this.streamDocument = streamDocument;
+ }
+
+ withChunkWriter(config) {
+ return new ChunkWriter(this, config);
+ }
+
+ write(chunk) {
+ // eslint-disable-next-line no-unsanitized/method
+ this.streamDocument.write(chunk);
+ }
+
+ close() {
+ this.streamDocument.write('</streaming-element>');
+ this.streamDocument.close();
+ }
+
+ abort() {
+ this.streamDocument.close();
+ }
+}
diff --git a/app/assets/javascripts/streaming/polyfills.js b/app/assets/javascripts/streaming/polyfills.js
new file mode 100644
index 00000000000..a9a044a3e99
--- /dev/null
+++ b/app/assets/javascripts/streaming/polyfills.js
@@ -0,0 +1,5 @@
+import { createReadableStreamWrapper } from '@mattiasbuelens/web-streams-adapter';
+import { ReadableStream as PolyfillReadableStream } from 'web-streams-polyfill';
+
+// TODO: remove this when our WebStreams API reaches 100% support
+export const toPolyfillReadable = createReadableStreamWrapper(PolyfillReadableStream);
diff --git a/app/assets/javascripts/streaming/rate_limit_stream_requests.js b/app/assets/javascripts/streaming/rate_limit_stream_requests.js
new file mode 100644
index 00000000000..04a592baa16
--- /dev/null
+++ b/app/assets/javascripts/streaming/rate_limit_stream_requests.js
@@ -0,0 +1,87 @@
+const consumeReadableStream = (stream) => {
+ return new Promise((resolve, reject) => {
+ stream.pipeTo(
+ new WritableStream({
+ close: resolve,
+ abort: reject,
+ }),
+ );
+ });
+};
+
+const wait = (timeout) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, timeout);
+ });
+
+// this rate-limiting approach is specific to Web Streams
+// because streams only resolve when they're fully consumed
+// so we need to split each stream into two pieces:
+// one for the rate-limiter (wait for all the bytes to be sent)
+// another for the original consumer
+export const rateLimitStreamRequests = ({
+ factory,
+ total,
+ maxConcurrentRequests,
+ immediateCount = maxConcurrentRequests,
+ timeout = 0,
+}) => {
+ if (total === 0) return [];
+
+ const unsettled = [];
+
+ const pushUnsettled = (promise) => {
+ let res;
+ let rej;
+ const consume = new Promise((resolve, reject) => {
+ res = resolve;
+ rej = reject;
+ });
+ unsettled.push(consume);
+ return promise.then((stream) => {
+ const [first, second] = stream.tee();
+ // eslint-disable-next-line promise/no-nesting
+ consumeReadableStream(first)
+ .then(() => {
+ unsettled.splice(unsettled.indexOf(consume), 1);
+ res();
+ })
+ .catch(rej);
+ return second;
+ }, rej);
+ };
+
+ const immediate = Array.from({ length: Math.min(immediateCount, total) }, (_, i) =>
+ pushUnsettled(factory(i)),
+ );
+
+ const queue = [];
+ const flushQueue = () => {
+ const promises =
+ unsettled.length > maxConcurrentRequests ? unsettled : [...unsettled, wait(timeout)];
+ // errors are handled by the caller
+ // eslint-disable-next-line promise/catch-or-return
+ Promise.race(promises).then(() => {
+ const cb = queue.shift();
+ cb?.();
+ if (queue.length !== 0) {
+ // wait for stream consumer promise to be removed from unsettled
+ queueMicrotask(flushQueue);
+ }
+ });
+ };
+
+ const throttled = Array.from({ length: total - immediateCount }, (_, i) => {
+ return new Promise((resolve, reject) => {
+ queue.push(() => {
+ pushUnsettled(factory(i + immediateCount))
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+ });
+
+ flushQueue();
+
+ return [...immediate, ...throttled];
+};
diff --git a/app/assets/javascripts/streaming/render_balancer.js b/app/assets/javascripts/streaming/render_balancer.js
new file mode 100644
index 00000000000..66929ff3a54
--- /dev/null
+++ b/app/assets/javascripts/streaming/render_balancer.js
@@ -0,0 +1,36 @@
+export class RenderBalancer {
+ previousTimestamp = undefined;
+
+ constructor({ increase, decrease, highFrameTime, lowFrameTime }) {
+ this.increase = increase;
+ this.decrease = decrease;
+ this.highFrameTime = highFrameTime;
+ this.lowFrameTime = lowFrameTime;
+ }
+
+ render(fn) {
+ return new Promise((resolve) => {
+ const callback = (timestamp) => {
+ this.throttle(timestamp);
+ if (fn()) requestAnimationFrame(callback);
+ else resolve();
+ };
+ requestAnimationFrame(callback);
+ });
+ }
+
+ throttle(timestamp) {
+ const { previousTimestamp } = this;
+ this.previousTimestamp = timestamp;
+ if (previousTimestamp === undefined) return;
+
+ const duration = Math.round(timestamp - previousTimestamp);
+ if (!duration) return;
+
+ if (duration >= this.highFrameTime) {
+ this.decrease();
+ } else if (duration < this.lowFrameTime) {
+ this.increase();
+ }
+ }
+}
diff --git a/app/assets/javascripts/streaming/render_html_streams.js b/app/assets/javascripts/streaming/render_html_streams.js
new file mode 100644
index 00000000000..7201e541777
--- /dev/null
+++ b/app/assets/javascripts/streaming/render_html_streams.js
@@ -0,0 +1,40 @@
+import { HtmlStream } from '~/streaming/html_stream';
+
+async function pipeStreams(domWriter, streamPromises) {
+ try {
+ for await (const stream of streamPromises.slice(0, -1)) {
+ await stream.pipeTo(domWriter, { preventClose: true });
+ }
+ const stream = await streamPromises[streamPromises.length - 1];
+ await stream.pipeTo(domWriter);
+ } catch (error) {
+ domWriter.abort(error);
+ }
+}
+
+// this function (and the rest of the pipeline) expects polyfilled streams
+// do not pass native streams here unless our browser support allows for it
+// TODO: remove this notice when our WebStreams API support reaches 100%
+export function renderHtmlStreams(streamPromises, element, config) {
+ if (streamPromises.length === 0) return Promise.resolve();
+
+ const chunkedHtmlStream = new HtmlStream(element).withChunkWriter(config);
+
+ return new Promise((resolve, reject) => {
+ const domWriter = new WritableStream({
+ write(chunk) {
+ return chunkedHtmlStream.write(chunk);
+ },
+ close() {
+ chunkedHtmlStream.close();
+ resolve();
+ },
+ abort(error) {
+ chunkedHtmlStream.abort();
+ reject(error);
+ },
+ });
+
+ pipeStreams(domWriter, streamPromises);
+ });
+}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 9ea5a66b3bc..b292adf9eac 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -580,3 +580,31 @@ span.idiff {
padding: 0;
border-radius: 0 0 $border-radius-default $border-radius-default;
}
+
+.blame-stream-container {
+ border-top: 1px solid $border-color;
+}
+
+.blame-stream-loading {
+ $gradient-size: 16px;
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: -$gradient-size;
+ height: $gl-spacing-scale-10;
+ border-top: $gradient-size solid transparent;
+ background-color: $white;
+ box-sizing: content-box;
+ background-clip: content-box;
+
+ .gradient {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: -$gradient-size;
+ height: $gradient-size;
+ background: linear-gradient(to top, $white, transparentize($white, 1));
+ }
+}
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 91fc1bf489d..53d302f60ee 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -6,7 +6,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
before_action :set_application, only: [:show, :edit, :update, :renew, :destroy]
before_action :load_scopes, only: [:new, :create, :edit, :update]
- feature_category :authentication_and_authorization
+ feature_category :system_access
def index
applications = ApplicationsFinder.new.execute
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index dcec50e882d..0745ba328c6 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -4,7 +4,7 @@ class Admin::IdentitiesController < Admin::ApplicationController
before_action :user
before_action :identity, except: [:index, :new, :create]
- feature_category :authentication_and_authorization
+ feature_category :system_access
def new
@identity = Identity.new
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index ddc555add5c..dae3337d19b 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -4,7 +4,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
before_action :user
before_action :verify_impersonation_enabled!
- feature_category :authentication_and_authorization
+ feature_category :user_management
def index
set_index_vars
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 6c45b03455e..c1a6cb350ec 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -4,7 +4,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
skip_before_action :authenticate_admin!
before_action :authenticate_impersonator!
- feature_category :authentication_and_authorization
+ feature_category :user_management
def destroy
original_user = stop_impersonation
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 03383604e30..e4a756ec12d 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -3,7 +3,7 @@
class Admin::KeysController < Admin::ApplicationController
before_action :user, only: [:show, :destroy]
- feature_category :authentication_and_authorization
+ feature_category :user_management
def show
@key = user.keys.find(params[:id])
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 63579421573..bb275532170 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -7,7 +7,7 @@ class Admin::SessionsController < ApplicationController
before_action :user_is_admin!
- feature_category :authentication_and_authorization
+ feature_category :system_access
def new
if current_user_mode.admin_mode?
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index a0ba5b9c8a4..4d1cbd8becc 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -10,7 +10,7 @@ class ConfirmationsController < Devise::ConfirmationsController
prepend_before_action :check_recaptcha, only: :create
before_action :load_recaptcha, only: :new
- feature_category :authentication_and_authorization
+ feature_category :user_management
def almost_there
flash[:notice] = nil
diff --git a/app/controllers/groups/settings/access_tokens_controller.rb b/app/controllers/groups/settings/access_tokens_controller.rb
index d86ddcfe2d0..ff07e881bfa 100644
--- a/app/controllers/groups/settings/access_tokens_controller.rb
+++ b/app/controllers/groups/settings/access_tokens_controller.rb
@@ -7,7 +7,7 @@ module Groups
include AccessTokensActions
layout 'group_settings'
- feature_category :authentication_and_authorization
+ feature_category :system_access
alias_method :resource, :group
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
index 6fb2b65feb8..b174ba9a6ad 100644
--- a/app/controllers/groups/settings/applications_controller.rb
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -9,7 +9,7 @@ module Groups
before_action :set_application, only: [:show, :edit, :update, :renew, :destroy]
before_action :load_scopes, only: [:index, :create, :edit, :update]
- feature_category :authentication_and_authorization
+ feature_category :system_access
def index
set_index_vars
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 0bee1faccf5..2729b11fcff 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -245,11 +245,7 @@ class Import::GithubController < Import::BaseController
{
before: params[:before].presence,
after: params[:after].presence,
- first: PAGE_LENGTH,
- # TODO: remove after rollout FF github_client_fetch_repos_via_graphql
- # https://gitlab.com/gitlab-org/gitlab/-/issues/385649
- page: [1, params[:page].to_i].max,
- per_page: PAGE_LENGTH
+ first: PAGE_LENGTH
}
end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 2a7f2d42e2a..0a2c98af8ec 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -13,7 +13,7 @@ class InvitesController < ApplicationController
respond_to :html
- feature_category :authentication_and_authorization
+ feature_category :system_access
def show
accept if skip_invitation_prompt?
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 7211eebdb4b..d299613f498 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -8,7 +8,7 @@ class JwtController < ApplicationController
# Add this before other actions, since we want to have the user or project
prepend_before_action :auth_user, :authenticate_project_or_user
- feature_category :authentication_and_authorization
+ feature_category :system_access
# https://gitlab.com/gitlab-org/gitlab/-/issues/357037
urgency :low
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 4046433f8ea..e450151fd82 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -12,7 +12,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
protect_from_forgery except: [:cas3, :failure] + AuthHelper.saml_providers, with: :exception, prepend: true
- feature_category :authentication_and_authorization
+ feature_category :system_access
def handle_omniauth
omniauth_flow(Gitlab::Auth::OAuth)
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 38cdb16c350..38839497fb6 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -12,7 +12,7 @@ class PasswordsController < Devise::PasswordsController
before_action :check_password_authentication_available, only: [:create]
before_action :throttle_reset, only: [:create]
- feature_category :authentication_and_authorization
+ feature_category :system_access
# rubocop: disable CodeReuse/ActiveRecord
def edit
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index cb8b2783000..eb64016379d 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -3,7 +3,7 @@
class Profiles::AccountsController < Profiles::ApplicationController
include AuthHelper
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low, [:show]
def show
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
index 2607ba7d404..5a86179b89f 100644
--- a/app/controllers/profiles/active_sessions_controller.rb
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Profiles::ActiveSessionsController < Profiles::ApplicationController
- feature_category :authentication_and_authorization
+ feature_category :system_access
def index
@sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index 738c41207d5..7a0dfbbba0d 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -11,7 +11,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
layout :determine_layout
- feature_category :authentication_and_authorization
+ feature_category :system_access
def new
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 1663aa61f62..8d5c690fbfe 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -3,7 +3,7 @@
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
include RenderAccessTokens
- feature_category :authentication_and_authorization
+ feature_category :system_access
before_action :check_personal_access_tokens_enabled
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index aded295bfab..89151068696 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -12,7 +12,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
push_frontend_feature_flag(:webauthn)
end
- feature_category :authentication_and_authorization
+ feature_category :system_access
def show
setup_show_page
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index 32ca303e722..2ee0e9fe960 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Profiles::U2fRegistrationsController < Profiles::ApplicationController
- feature_category :authentication_and_authorization
+ feature_category :system_access
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
diff --git a/app/controllers/profiles/webauthn_registrations_controller.rb b/app/controllers/profiles/webauthn_registrations_controller.rb
index a4a6d84f1ae..345d7bdbca8 100644
--- a/app/controllers/profiles/webauthn_registrations_controller.rb
+++ b/app/controllers/profiles/webauthn_registrations_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Profiles::WebauthnRegistrationsController < Profiles::ApplicationController
- feature_category :authentication_and_authorization
+ feature_category :system_access
def destroy
webauthn_registration = current_user.webauthn_registrations.find(params[:id])
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 45b274fc920..70487915707 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -17,7 +17,7 @@ class ProfilesController < Profiles::ApplicationController
feature_category :user_profile, [:show, :update, :reset_incoming_email_token, :reset_feed_token,
:reset_static_object_token, :update_username]
- feature_category :authentication_and_authorization, [:audit_log]
+ feature_category :system_access, [:audit_log]
urgency :low, [:show, :update]
def show
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index cfff281604e..d41b347dc5a 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -23,13 +23,47 @@ class Projects::BlameController < Projects::ApplicationController
environment_params[:find_latest] = true
@environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
- blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :no_pagination))
+ permitted_params = params.permit(:page, :no_pagination, :streaming)
+ blame_service = Projects::BlameService.new(@blob, @commit, permitted_params)
@blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
- @blame_pagination = blame_service.pagination
+ @entire_blame_path = full_blame_path(no_pagination: true)
+ @blame_pages_url = blame_pages_url(permitted_params)
+ if blame_service.streaming_possible
+ @entire_blame_path = full_blame_path(streaming: true)
+ end
+
+ @streaming_enabled = blame_service.streaming_enabled
+ @blame_pagination = blame_service.pagination unless @streaming_enabled
@blame_per_page = blame_service.per_page
+
+ render locals: { total_extra_pages: blame_service.total_extra_pages }
+ end
+
+ def page
+ @blob = @repository.blob_at(@commit.id, @path)
+
+ environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
+ environment_params[:find_latest] = true
+ @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
+
+ blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :streaming))
+
+ @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
+
+ render partial: 'page'
+ end
+
+ private
+
+ def full_blame_path(params)
+ namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, **params)
+ end
+
+ def blame_pages_url(params)
+ namespace_project_blame_page_url(namespace_id: @project.namespace, project_id: @project, id: @id, **params)
end
end
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index 0884816ef62..af1527ba6a3 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -7,7 +7,7 @@ module Projects
include AccessTokensActions
layout 'project_settings'
- feature_category :authentication_and_authorization
+ feature_category :system_access
alias_method :resource, :project
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index cfb4e939b35..78c00a81a93 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -10,7 +10,7 @@ module Registrations
skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
before_action :require_current_user
- feature_category :authentication_and_authorization
+ feature_category :user_management
def show
return redirect_to path_for_signed_in_user(current_user) if completed_welcome_step?
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 420ca6a2286..edc74dd71fc 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -27,7 +27,7 @@ class RegistrationsController < Devise::RegistrationsController
push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)
end
- feature_category :authentication_and_authorization
+ feature_category :user_management
def new
@resource = build_resource
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index b6aba04c877..83034e3faa6 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -56,7 +56,7 @@ class SessionsController < Devise::SessionsController
# token mismatch.
protect_from_forgery with: :exception, prepend: true, except: :destroy
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
CAPTCHA_HEADER = 'X-GitLab-Show-Login-Captcha'
diff --git a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb
new file mode 100644
index 00000000000..be17601e7a2
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Analytics
+ module CycleAnalytics
+ class DeploymentCountResolver < BaseResolver
+ type Types::Analytics::CycleAnalytics::MetricType, null: true
+
+ argument :from, Types::TimeType,
+ required: true,
+ description: 'Deployments finished after the date.'
+
+ argument :to, Types::TimeType,
+ required: true,
+ description: 'Deployments finished before the date.'
+
+ def resolve(**args)
+ value = count(args)
+ {
+ value: value,
+ title: n_('Deploy', 'Deploys', value.to_i),
+ identifier: 'deploys',
+ links: []
+ }
+ end
+
+ private
+
+ def count(args)
+ finder = DeploymentsFinder.new({
+ finished_after: args[:from],
+ finished_before: args[:to],
+ project: object.project,
+ status: :success,
+ order_by: :finished_at
+ })
+
+ finder.execute.count
+ end
+
+ # :project level: no customization, returning the original resolver
+ # :group level: add the project_ids argument
+ def self.[](context = :project)
+ case context
+ when :project
+ self
+ when :group
+ Class.new(self) do
+ argument :project_ids, [GraphQL::Types::ID],
+ required: false,
+ description: 'Project IDs within the group hierarchy.'
+ end
+
+ end
+ end
+ end
+ end
+ end
+end
+
+mod = Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver
+mod.prepend_mod_with('Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver')
diff --git a/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
index d320cd6cfc6..2645a86a9f8 100644
--- a/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
+++ b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
@@ -14,6 +14,11 @@ module Types
null: true,
description: 'Number of issues opened in the given period.',
resolver: Resolvers::Analytics::CycleAnalytics::IssueCountResolver[context]
+ field :deployment_count,
+ Types::Analytics::CycleAnalytics::MetricType,
+ null: true,
+ description: 'Number of production deployments in the given period.',
+ resolver: Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver[context]
end
end
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index a11e2c00cf8..ba6e760fd2b 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -85,7 +85,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? }
condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow_for_admin)
+ Feature.enabled?(:create_runner_workflow_for_namespace, group)
end
condition(:achievements_enabled, scope: :subject) do
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index cab3dff0400..fbe0f9bfeea 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -235,7 +235,7 @@ class ProjectPolicy < BasePolicy
end
condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow_for_admin)
+ Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace)
end
# `:read_project` may be prevented in EE, but `:read_project_for_iids` should
diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb
index 58e146e5a32..1ea16040655 100644
--- a/app/services/projects/blame_service.rb
+++ b/app/services/projects/blame_service.rb
@@ -5,15 +5,19 @@
module Projects
class BlameService
PER_PAGE = 1000
+ STREAMING_FIRST_PAGE_SIZE = 200
+ STREAMING_PER_PAGE = 2000
def initialize(blob, commit, params)
@blob = blob
@commit = commit
- @page = extract_page(params)
+ @streaming_enabled = streaming_state(params)
@pagination_enabled = pagination_state(params)
+ @page = extract_page(params)
+ @params = params
end
- attr_reader :page
+ attr_reader :page, :streaming_enabled
def blame
Gitlab::Blame.new(blob, commit, range: blame_range)
@@ -28,7 +32,22 @@ module Projects
end
def per_page
- PER_PAGE
+ streaming_enabled ? STREAMING_PER_PAGE : PER_PAGE
+ end
+
+ def total_pages
+ total = (blob_lines_count.to_f / per_page).ceil
+ return total unless streaming_enabled
+
+ ([blob_lines_count - STREAMING_FIRST_PAGE_SIZE, 0].max.to_f / per_page).ceil + 1
+ end
+
+ def total_extra_pages
+ [total_pages - 1, 0].max
+ end
+
+ def streaming_possible
+ Feature.enabled?(:blame_page_streaming, commit.project)
end
private
@@ -36,9 +55,16 @@ module Projects
attr_reader :blob, :commit, :pagination_enabled
def blame_range
- return unless pagination_enabled
+ return unless pagination_enabled || streaming_enabled
first_line = (page - 1) * per_page + 1
+
+ if streaming_enabled
+ return 1..STREAMING_FIRST_PAGE_SIZE if page == 1
+
+ first_line = STREAMING_FIRST_PAGE_SIZE + (page - 2) * per_page + 1
+ end
+
last_line = (first_line + per_page).to_i - 1
first_line..last_line
@@ -52,6 +78,12 @@ module Projects
page
end
+ def streaming_state(params)
+ return false unless streaming_possible
+
+ Gitlab::Utils.to_boolean(params[:streaming], default: false)
+ end
+
def pagination_state(params)
return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false)
@@ -59,7 +91,7 @@ module Projects
end
def overlimit?(page)
- page * per_page >= blob_lines_count + per_page
+ page > total_pages
end
def blob_lines_count
diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb
index 951017b2d01..6906ab2b642 100644
--- a/app/services/protected_branches/base_service.rb
+++ b/app/services/protected_branches/base_service.rb
@@ -21,3 +21,5 @@ module ProtectedBranches
end
end
end
+
+ProtectedBranches::BaseService.prepend_mod
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index dd441d0d155..f0c1b090140 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -10,6 +10,7 @@
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
= render 'layouts/startup_js'
+ = yield :startup_js
- if page_canonical_link
%link{ rel: 'canonical', href: page_canonical_link }
diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml
index a1b1304f994..8217ac13c52 100644
--- a/app/views/layouts/component_preview.html.haml
+++ b/app/views/layouts/component_preview.html.haml
@@ -1,12 +1,12 @@
%head
- - if params[:lookbook][:display][:theme] == 'light'
+ - if params[:lookbook][:display][:theme] == "light"
= stylesheet_link_tag "application"
= stylesheet_link_tag "application_utilities"
- else
= stylesheet_link_tag "application_dark"
= stylesheet_link_tag "application_utilities_dark"
%body
- .container.gl-mt-6
+ .gl-mt-6{ class: (params[:lookbook][:display][:layout] == "fluid" ? "container-fluid" : "container") }
- if params[:lookbook][:display][:bg_dark]
.bg-dark.rounded.shadow.p-4
= yield
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 74b85a93c8e..ee7ca9cd351 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,6 +1,15 @@
- page_title _("Blame"), @blob.path, @ref
- add_page_specific_style 'page_bundles/tree'
-- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page }
+- if @streaming_enabled && total_extra_pages > 0
+ - content_for :startup_js do
+ = javascript_tag do
+ :plain
+ window.blamePageStream = (() => {
+ const url = new URL("#{@blame_pages_url}");
+ url.searchParams.set('page', 2);
+ return fetch(url).then(response => response.body);
+ })();
+- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page, total_extra_pages: total_extra_pages - 1, pages_url: @blame_pages_url }
#blob-content-holder.tree-holder.js-per-page{ data: dataset }
= render "projects/blob/breadcrumb", blob: @blob, blame: true
@@ -26,11 +35,21 @@
.blame-table-wrapper
= render partial: 'page'
+ - if @streaming_enabled
+ #blame-stream-container.blame-stream-container
+
- if @blame_pagination && @blame_pagination.total_pages > 1
.gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100
- = _('For faster browsing, not all history is shown.')
- = render Pajamas::ButtonComponent.new(href: namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, no_pagination: true), size: :small, button_options: { class: 'gl-mt-3' }) do |c|
- = _('View entire blame')
+ = render Pajamas::ButtonComponent.new(href: @entire_blame_path, size: :small, button_options: { class: 'gl-mt-3' }) do |c|
+ = _('Show full blame')
+
+ - if @streaming_enabled
+ #blame-stream-loading.blame-stream-loading
+ .gradient
+ = gl_loading_icon(size: 'sm')
+ %span.gl-mx-2
+ = _('Loading full blame...')
- if @blame_pagination
= paginate(@blame_pagination, theme: "gitlab")
+
diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
index b62440fcbde..c916b6c3d45 100644
--- a/app/views/users/_profile_basic_info.html.haml
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -1,9 +1,9 @@
-.gl-text-gray-900.gl-mt-4
- = render 'middle_dot_divider' do
+.gl-text-gray-900
+ = render 'middle_dot_divider', stacking: true do
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
- = render 'middle_dot_divider' do
+ = render 'middle_dot_divider', stacking: true do
= s_('UserProfile|User ID: %{id}') % { id: @user.id }
= clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id)
- = render 'middle_dot_divider' do
+ = render 'middle_dot_divider', stacking: true do
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index dd84b303655..29fd894cebf 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -43,80 +43,82 @@
= _('Follow')
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
- .avatar-holder
- = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
- = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" })
-
- - if @user.blocked? || !@user.confirmed?
- .user-info
- %h1.cover-title
- = user_display_name(@user)
- = render "users/profile_basic_info"
- - else
- .user-info
- %h1.cover-title{ itemprop: 'name' }
- = @user.name
- - if @user.pronouns.present?
- %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
- = "(#{@user.pronouns})"
- - if @user.status&.busy?
- %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)")
-
- - if @user.pronunciation.present?
- .gl-align-items-center
- %p.gl-mb-4.gl-text-gray-500.gl-max-w-80.gl-mx-auto= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation }
-
- - if @user.status&.customized?
- .cover-status.gl-display-inline-flex.gl-align-items-center
- = emoji_icon(@user.status.emoji, class: 'gl-mr-2')
- = markdown_field(@user.status, :message)
+ .gl-display-inline-block.gl-mx-8.gl-vertical-align-top
+ .avatar-holder
+ = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
+ = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" })
+ .gl-display-inline-block.gl-vertical-align-top.gl-text-left
+ - if @user.blocked? || !@user.confirmed?
+ .user-info
+ %h1.cover-title.gl-my-0
+ = user_display_name(@user)
= render "users/profile_basic_info"
- .gl-text-gray-900.mb-1.mb-sm-2
- - unless @user.location.blank?
- = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do
- = sprite_icon('location', css_class: 'fgray')
- %span{ itemprop: 'addressLocality' }
- = @user.location
+ - else
+ .user-info
+ %h1.cover-title.gl-my-0{ itemprop: 'name' }
+ = @user.name
+ - if @user.pronouns.present?
+ %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
+ = "(#{@user.pronouns})"
+ - if @user.status&.busy?
+ %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)")
+
+ - if @user.pronunciation.present?
+ .gl-align-items-center
+ %p.gl-mb-4.gl-text-gray-500.gl-max-w-80.gl-mx-auto= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation }
+
+ - if @user.status&.customized?
+ .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3
+ = emoji_icon(@user.status.emoji, class: 'gl-mr-2')
+ = markdown_field(@user.status, :message)
+ = render "users/profile_basic_info"
- user_local_time = local_time(@user.timezone)
- - unless user_local_time.nil?
- = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
- = sprite_icon('clock', css_class: 'fgray')
- %span
- = user_local_time
- - unless work_information(@user).blank?
- = render 'middle_dot_divider', stacking: true do
- = sprite_icon('work', css_class: 'fgray')
- %span
- = work_information(@user, with_schema_markup: true)
- .gl-text-gray-900
- - unless @user.skype.blank?
- = render 'middle_dot_divider' do
- = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do
- = sprite_icon('skype', css_class: 'skype-icon')
- - unless @user.linkedin.blank?
- = render 'middle_dot_divider' do
- = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('linkedin', css_class: 'linkedin-icon')
- - unless @user.twitter.blank?
- = render 'middle_dot_divider', breakpoint: 'sm' do
- = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('twitter', css_class: 'twitter-icon')
- - unless @user.discord.blank?
- = render 'middle_dot_divider', breakpoint: 'sm' do
- = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('discord', css_class: 'discord-icon')
- - unless @user.website_url.blank?
- = render 'middle_dot_divider', stacking: true do
- - if Feature.enabled?(:security_auto_fix) && @user.bot?
- = sprite_icon('question', css_class: 'gl-text-blue-600')
- = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
- - if display_public_email?(@user)
- = render 'middle_dot_divider', stacking: true do
- = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
- - if @user.bio.present?
- .gl-text-gray-900
- .profile-user-bio
- = @user.bio
+ - if @user.location.present? || user_local_time.present? || work_information(@user).present?
+ .gl-text-gray-900
+ - if @user.location.present?
+ = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do
+ = sprite_icon('location', css_class: 'fgray')
+ %span{ itemprop: 'addressLocality' }
+ = @user.location
+ - if user_local_time.present?
+ = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
+ = sprite_icon('clock', css_class: 'fgray')
+ %span
+ = user_local_time
+ - if work_information(@user).present?
+ = render 'middle_dot_divider', stacking: true do
+ = sprite_icon('work', css_class: 'fgray')
+ %span
+ = work_information(@user, with_schema_markup: true)
+ .gl-text-gray-900
+ - if @user.skype.present?
+ = render 'middle_dot_divider' do
+ = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do
+ = sprite_icon('skype', css_class: 'skype-icon')
+ - if @user.linkedin.present?
+ = render 'middle_dot_divider' do
+ = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = sprite_icon('linkedin', css_class: 'linkedin-icon')
+ - if @user.twitter.present?
+ = render 'middle_dot_divider', breakpoint: 'sm' do
+ = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = sprite_icon('twitter', css_class: 'twitter-icon')
+ - if @user.discord.present?
+ = render 'middle_dot_divider', breakpoint: 'sm' do
+ = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = sprite_icon('discord', css_class: 'discord-icon')
+ - if @user.website_url.present?
+ = render 'middle_dot_divider', stacking: true do
+ - if Feature.enabled?(:security_auto_fix) && @user.bot?
+ = sprite_icon('question', css_class: 'gl-text-blue-600')
+ = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
+ - if display_public_email?(@user)
+ = render 'middle_dot_divider', stacking: true do
+ = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
+ - if @user.bio.present? && @user.confirmed? && !@user.blocked?
+ .gl-text-gray-900.gl-mx-5
+ .profile-user-bio.gl-text-left
+ = @user.bio
- if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user)
.scrolling-tabs-container
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index fbb348811e0..16930548124 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -5,7 +5,7 @@
---
- :name: authorized_project_update:authorized_project_update_project_recalculate
:worker_name: AuthorizedProjectUpdate::ProjectRecalculateWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -14,7 +14,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_project_recalculate_per_user
:worker_name: AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -23,7 +23,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_from_replica
:worker_name: AuthorizedProjectUpdate::UserRefreshFromReplicaWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -32,7 +32,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range
:worker_name: AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -41,7 +41,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
:worker_name: AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -617,7 +617,7 @@
:tags: []
- :name: cronjob:personal_access_tokens_expired_notification
:worker_name: PersonalAccessTokens::ExpiredNotificationWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -626,7 +626,7 @@
:tags: []
- :name: cronjob:personal_access_tokens_expiring
:worker_name: PersonalAccessTokens::ExpiringWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -680,7 +680,7 @@
:tags: []
- :name: cronjob:remove_expired_group_links
:worker_name: RemoveExpiredGroupLinksWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -689,7 +689,7 @@
:tags: []
- :name: cronjob:remove_expired_members
:worker_name: RemoveExpiredMembersWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :cpu
@@ -698,7 +698,7 @@
:tags: []
- :name: cronjob:remove_unaccepted_member_invites
:worker_name: RemoveUnacceptedMemberInvitesWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2165,7 +2165,7 @@
:tags: []
- :name: unassign_issuables:members_destroyer_unassign_issuables
:worker_name: MembersDestroyer::UnassignIssuablesWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :user_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2219,7 +2219,7 @@
:tags: []
- :name: authorized_projects
:worker_name: AuthorizedProjectsWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -2417,7 +2417,7 @@
:tags: []
- :name: delete_user
:worker_name: DeleteUserWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :user_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2642,7 +2642,7 @@
:tags: []
- :name: groups_update_two_factor_requirement_for_members
:worker_name: Groups::UpdateTwoFactorRequirementForMembersWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
diff --git a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
index 352c82e5021..96647cc671c 100644
--- a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
+++ b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
@@ -4,7 +4,7 @@ module AuthorizedProjectUpdate
class ProjectRecalculatePerUserWorker < ProjectRecalculateWorker
data_consistency :always
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :high
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb
index 1b5faee0b6f..cbf068f0b85 100644
--- a/app/workers/authorized_project_update/project_recalculate_worker.rb
+++ b/app/workers/authorized_project_update/project_recalculate_worker.rb
@@ -9,7 +9,7 @@ module AuthorizedProjectUpdate
prepend WaitableWorker
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :high
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
index daebb23baae..cdc0a097c92 100644
--- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
@@ -5,7 +5,7 @@ module AuthorizedProjectUpdate
include ApplicationWorker
sidekiq_options retry: 3
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
data_consistency :always
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index 8452f2a7821..ae243a94d3d 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -16,7 +16,7 @@ module AuthorizedProjectUpdate
sidekiq_options retry: 3
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
index 7ca59a72adf..d6b41ba949c 100644
--- a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
@@ -2,7 +2,7 @@
module AuthorizedProjectUpdate
class UserRefreshWithLowUrgencyWorker < ::AuthorizedProjectsWorker
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
queue_namespace :authorized_project_update
deduplicate :until_executing, including_scheduled: true
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index 4312ba41367..b553a2cd14e 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -8,7 +8,7 @@ class AuthorizedProjectsWorker
sidekiq_options retry: 3
prepend WaitableWorker
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :high
weight 2
idempotent!
diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb
index 0af084caf86..bca156ff84c 100644
--- a/app/workers/delete_user_worker.rb
+++ b/app/workers/delete_user_worker.rb
@@ -7,7 +7,7 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category :authentication_and_authorization
+ feature_category :user_management
loggable_arguments 2
def perform(current_user_id, delete_user_id, options = {})
diff --git a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
index ac1d3589516..ca68a82ec66 100644
--- a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
+++ b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
@@ -9,7 +9,7 @@ module Groups
idempotent!
- feature_category :authentication_and_authorization
+ feature_category :system_access
def perform(group_id)
group = Group.find_by_id(group_id)
diff --git a/app/workers/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb
index 915551d6e30..2e6ce0005fc 100644
--- a/app/workers/members_destroyer/unassign_issuables_worker.rb
+++ b/app/workers/members_destroyer/unassign_issuables_worker.rb
@@ -11,7 +11,7 @@ module MembersDestroyer
ENTITY_TYPES = %w(Group Project).freeze
queue_namespace :unassign_issuables
- feature_category :authentication_and_authorization
+ feature_category :user_management
idempotent!
diff --git a/app/workers/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb
index b119957fa2c..f86bd604cdf 100644
--- a/app/workers/personal_access_tokens/expired_notification_worker.rb
+++ b/app/workers/personal_access_tokens/expired_notification_worker.rb
@@ -8,7 +8,7 @@ module PersonalAccessTokens
include CronjobQueue
- feature_category :authentication_and_authorization
+ feature_category :system_access
MAX_TOKENS = 100
diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb
index f4afa9f8994..de0bda82573 100644
--- a/app/workers/personal_access_tokens/expiring_worker.rb
+++ b/app/workers/personal_access_tokens/expiring_worker.rb
@@ -8,7 +8,7 @@ module PersonalAccessTokens
include CronjobQueue
- feature_category :authentication_and_authorization
+ feature_category :system_access
MAX_TOKENS = 100
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
index 37298c53a5c..f1da5f37945 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -7,7 +7,7 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWork
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :authentication_and_authorization
+ feature_category :system_access
def perform
ProjectGroupLink.expired.find_each do |link|
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index c9eb715a522..b5031f4cda6 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -7,7 +7,7 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker
include CronjobQueue
- feature_category :authentication_and_authorization
+ feature_category :system_access
worker_resource_boundary :cpu
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/remove_unaccepted_member_invites_worker.rb b/app/workers/remove_unaccepted_member_invites_worker.rb
index 7fe45b26094..96f60b5fa12 100644
--- a/app/workers/remove_unaccepted_member_invites_worker.rb
+++ b/app/workers/remove_unaccepted_member_invites_worker.rb
@@ -7,7 +7,7 @@ class RemoveUnacceptedMemberInvitesWorker # rubocop:disable Scalability/Idempote
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
idempotent!