Update from merge request

This commit is contained in:
root
2025-07-21 01:41:53 +00:00
parent 91763dcb69
commit 078ad48de0
12 changed files with 558 additions and 112 deletions

View File

@ -107,6 +107,13 @@ module Organizations
Gitlab::UrlBuilder.build(self, only_path: only_path)
end
# For example:
# scoped path - /o/my-org/my-group/my-project
# unscoped path - /my-group/my-project
def scoped_paths?
Feature.enabled?(:organization_scoped_paths, self) && !default?
end
private
# The visibility must be broader than the visibility of any contained root groups.

View File

@ -0,0 +1,10 @@
---
name: organization_scoped_paths
description: Derisk rollout of Organization scoped URL paths and helpers
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/553914
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/197425
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/556359
milestone: '18.3'
group: group::organizations
type: gitlab_com_derisk
default_enabled: false

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
# Provides organization-aware url helpers by automatically switching between
# organization-scoped routes (/o/:organization_path/...) and global routes
# based on the current organization context.
class OrganizationUrlHelpers
ORGANIZATION_PATH_PATTERN = '/o/:organization_path'
ORGANIZATION_PATH_REGEX = /(?<=^|_)organizations?_/
PATH_SUFFIX = '_path'
URL_SUFFIX = '_url'
mattr_accessor :already_installed, default: false
def self.install
return if already_installed
route_pairs = find_route_pairs
override_module = build_override_module(route_pairs)
Rails.application.routes.url_helpers.prepend(override_module)
self.already_installed = true
end
def self.find_route_pairs
all_routes = Rails.application.routes.routes
org_routes, global_routes = all_routes.partition { |route| organization_route?(route) }
build_route_pairs(org_routes, global_routes)
end
# Route name represents an Organization route.
def self.organization_route?(route)
route.path.spec.to_s.include?(ORGANIZATION_PATH_PATTERN)
end
# Build a Hash of global route => Organization route names.
def self.build_route_pairs(organization_routes, global_routes)
org_route_names = organization_routes.map(&:name)
global_route_names = global_routes.map(&:name)
# Global route => Organization route
route_pairs = {}
org_route_names.each do |org_route_name|
global_route_name = extract_global_route_name(org_route_name)
next unless global_route_names.include?(global_route_name)
route_pairs[global_route_name] = org_route_name
end
route_pairs
end
# Map organization named route to global route.
def self.extract_global_route_name(org_route_name)
return if org_route_name.nil?
# Handle organization patterns with proper underscore preservation
org_route_name.gsub(ORGANIZATION_PATH_REGEX, '')
end
# Build a module that overrides URL helpers with organization-aware versions
def self.build_override_module(route_pairs)
Module.new do
route_pairs.each do |global_route, org_route|
[PATH_SUFFIX, URL_SUFFIX].each do |suffix|
method_name = "#{global_route}#{suffix}"
org_method_name = "#{org_route}#{suffix}"
define_method(method_name) do |*args, **kwargs|
# rubocop:disable Gitlab/AvoidCurrentOrganization -- Current organization not available earlier.
org_scoped_path = Current.organization_assigned &&
!Current.organization.nil? &&
Current.organization.scoped_paths?
if org_scoped_path
# Call the Organization helper method
send(org_method_name, *args, organization_path: Current.organization.path, **kwargs)
else
# Call the original helper method
super(*args, **kwargs)
end
# rubocop:enable Gitlab/AvoidCurrentOrganization
end
end
end
end
end
end
Rails.application.config.after_routes_loaded do
OrganizationUrlHelpers.install
end

View File

@ -86,6 +86,8 @@ InitializerConnections.raise_if_new_database_connection do
# Terraform service discovery
get '.well-known/terraform.json' => 'terraform/services#index', as: :terraform_services
draw :organizations
# Begin of the /-/ scope.
# Use this scope for all new global routes.
scope path: '-' do
@ -170,7 +172,6 @@ InitializerConnections.raise_if_new_database_connection do
draw :operations
draw :jira_connect
draw :organizations
Gitlab.ee do
draw 'remote_development/resources'

View File

@ -1,45 +1,55 @@
# frozen_string_literal: true
resources(:organizations, only: [:show, :index, :new], param: :organization_path, module: :organizations) do
collection do
post :preview_markdown
end
scope(
path: '/o/:organization_path',
constraints: { organization_path: Gitlab::PathRegex.organization_route_regex },
as: :organization
) do
resources :projects, only: [:new, :create]
end
member do
get :activity
get :groups_and_projects
get :users
resource :settings, only: [], as: :settings_organization do
get :general
scope path: '-' do
resources(:organizations, only: [:show, :index, :new], param: :organization_path, module: :organizations) do
collection do
post :preview_markdown
end
resource :groups, only: [:new, :create, :destroy], as: :groups_organization
member do
get :activity
get :groups_and_projects
get :users
scope(
path: 'groups/*id',
constraints: { id: Gitlab::PathRegex.full_namespace_route_regex }
) do
resource(
:groups,
path: '/',
only: [:edit],
as: :groups_organization
)
end
resource :settings, only: [], as: :settings_organization do
get :general
end
scope(
path: 'projects/*namespace_id',
as: :namespace,
constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }
) do
resources(
:projects,
path: '/',
constraints: { id: Gitlab::PathRegex.project_route_regex },
only: [:edit],
as: :projects_organization
)
resource :groups, only: [:new, :create, :destroy], as: :groups_organization
scope(
path: 'groups/*id',
constraints: { id: Gitlab::PathRegex.full_namespace_route_regex }
) do
resource(
:groups,
path: '/',
only: [:edit],
as: :groups_organization
)
end
scope(
path: 'projects/*namespace_id',
as: :namespace,
constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }
) do
resources(
:projects,
path: '/',
constraints: { id: Gitlab::PathRegex.project_route_regex },
only: [:edit],
as: :projects_organization
)
end
end
end
end

View File

@ -36,6 +36,7 @@ module Gitlab
import
jwt
login
o
oauth
profile
projects

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Group show page', feature_category: :groups_and_projects do
RSpec.describe 'Group show page', :with_current_organization, feature_category: :groups_and_projects do
include Features::InviteMembersModalHelpers
let_it_be(:user) { create(:user) }
@ -91,18 +91,43 @@ RSpec.describe 'Group show page', feature_category: :groups_and_projects do
before do
group.add_owner(user)
sign_in(user)
end
subject(:page_content) do
visit path
page
end
it 'shows `Create subgroup` link' do
link = new_group_path(parent_id: group.id, anchor: 'create-group-pane')
expect(page).to have_link(s_('GroupsEmptyState|Create subgroup'), href: link)
expect(page_content).to have_link(s_('GroupsEmptyState|Create subgroup'), href: link)
end
it 'shows `Create project` link' do
expect(page)
.to have_link(s_('GroupsEmptyState|Create project'), href: new_project_path(namespace_id: group.id))
context 'when current Organization does not have scoped paths' do
before do
allow(current_organization).to receive(:scoped_paths?).and_return(false)
end
it 'shows `Create project` link' do
expect(page_content)
.to have_link(s_('GroupsEmptyState|Create project'), href: new_project_path(namespace_id: group.id))
end
end
context 'when current Organization has scoped paths' do
before do
allow(current_organization).to receive(:scoped_paths?).and_return(true)
end
it 'shows `Create project` link' do
expected_path = new_organization_project_path(
namespace_id: group.id,
organization_path: current_organization.path
)
expect(page_content)
.to have_link(s_('GroupsEmptyState|Create project'), href: expected_path)
end
end
end
end

View File

@ -383,82 +383,220 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
end
end
it 'returns "Create new" menu groups without headers', :use_clean_rails_memory_store_caching do
extra_attrs = ->(id) {
describe '"Create new" menu groups', :use_clean_rails_memory_store_caching do
def menu_item(href:, text:, id:, component: nil)
{
"data-track-label": id,
"data-track-action": "click_link",
"data-track-property": "nav_create_menu",
"data-testid": 'create_menu_item',
"data-qa-create-menu-item": id
href: href,
text: text,
component: component,
extraAttrs: {
"data-track-label": id,
"data-track-action": "click_link",
"data-track-property": "nav_create_menu",
"data-testid": 'create_menu_item',
"data-qa-create-menu-item": id
}
}
}
end
expect(subject[:create_new_menu_groups]).to eq([
{
name: "",
items: [
{ href: "/projects/new", text: "New project/repository",
component: nil,
extraAttrs: extra_attrs.call("general_new_project") },
{ href: "/groups/new", text: "New group",
component: nil,
extraAttrs: extra_attrs.call("general_new_group") },
{ href: "/-/organizations/new", text: s_('Organization|New organization'),
component: nil,
extraAttrs: extra_attrs.call("general_new_organization") },
{ href: "/-/snippets/new", text: "New snippet",
component: nil,
extraAttrs: extra_attrs.call("general_new_snippet") }
]
}
])
end
context 'without headers' do
shared_examples '"Create new" menu groups without headers' do
it 'returns "Create new" menu groups without headers' do
expect(subject[:create_new_menu_groups]).to eq([
{
name: "",
items: expected_menu_item_groups
}
])
end
end
it 'returns "Create new" menu groups with headers', :use_clean_rails_memory_store_caching do
extra_attrs = ->(id) {
{
"data-track-label": id,
"data-track-action": "click_link",
"data-track-property": "nav_create_menu",
"data-testid": 'create_menu_item',
"data-qa-create-menu-item": id
}
}
context 'when current Organization has scoped paths' do
let(:expected_menu_item_groups) do
[
menu_item(
href: "/o/#{current_organization.path}/projects/new",
text: "New project/repository",
id: "general_new_project"
),
menu_item(
href: "/groups/new",
text: "New group",
id: "general_new_group"
),
menu_item(
href: "/-/organizations/new",
text: s_('Organization|New organization'),
id: "general_new_organization"
),
menu_item(
href: "/-/snippets/new",
text: "New snippet",
id: "general_new_snippet"
)
]
end
allow(group).to receive(:persisted?).and_return(true)
allow(helper).to receive(:can?).and_return(true)
before do
allow(current_organization).to receive(:scoped_paths?).and_return(true)
end
expect(subject[:create_new_menu_groups]).to contain_exactly(
a_hash_including(
name: "In this group",
items: array_including(
{ href: "/projects/new", text: "New project/repository",
component: nil,
extraAttrs: extra_attrs.call("new_project") },
{ href: "/groups/new#create-group-pane", text: "New subgroup",
component: nil,
extraAttrs: extra_attrs.call("new_subgroup") },
{ href: nil, text: "Invite members",
component: 'invite_members',
extraAttrs: extra_attrs.call("invite") }
)
),
a_hash_including(
name: "In GitLab",
items: array_including(
{ href: "/projects/new", text: "New project/repository",
component: nil,
extraAttrs: extra_attrs.call("general_new_project") },
{ href: "/groups/new", text: "New group",
component: nil,
extraAttrs: extra_attrs.call("general_new_group") },
{ href: "/-/snippets/new", text: "New snippet",
component: nil,
extraAttrs: extra_attrs.call("general_new_snippet") }
)
)
)
include_examples '"Create new" menu groups without headers'
end
context 'when current Organization does not have scoped paths' do
let(:expected_menu_item_groups) do
[
menu_item(
href: "/projects/new",
text: "New project/repository",
id: "general_new_project"
),
menu_item(
href: "/groups/new",
text: "New group",
id: "general_new_group"
),
menu_item(
href: "/-/organizations/new",
text: s_('Organization|New organization'),
id: "general_new_organization"
),
menu_item(
href: "/-/snippets/new",
text: "New snippet",
id: "general_new_snippet"
)
]
end
before do
allow(current_organization).to receive(:scoped_paths?).and_return(false)
end
include_examples '"Create new" menu groups without headers'
end
end
context 'with headers' do
before do
allow(group).to receive(:persisted?).and_return(true)
allow(helper).to receive(:can?).and_return(true)
end
shared_examples '"Create new" menu groups with headers' do
it 'returns "Create new" menu groups with headers' do
expect(subject[:create_new_menu_groups]).to contain_exactly(
a_hash_including(
name: "In this group",
items: array_including(*in_this_group_menu_items)
),
a_hash_including(
name: "In GitLab",
items: array_including(*in_gitlab_menu_items)
)
)
end
end
context 'when current Organization has scoped paths' do
let(:in_this_group_menu_items) do
[
menu_item(
href: "/o/#{current_organization.path}/projects/new",
text: "New project/repository",
id: "new_project"
),
menu_item(
href: "/groups/new#create-group-pane",
text: "New subgroup",
id: "new_subgroup"
),
menu_item(
href: nil,
text: "Invite members",
component: 'invite_members',
id: "invite"
)
]
end
let(:in_gitlab_menu_items) do
[
menu_item(
href: "/o/#{current_organization.path}/projects/new",
text: "New project/repository",
id: "general_new_project"
),
menu_item(
href: "/groups/new",
text: "New group",
id: "general_new_group"
),
menu_item(
href: "/-/snippets/new",
text: "New snippet",
id: "general_new_snippet"
)
]
end
before do
allow(current_organization).to receive(:scoped_paths?).and_return(true)
end
include_examples '"Create new" menu groups with headers'
end
context 'when current Organization does not have scoped paths' do
let(:in_this_group_menu_items) do
[
menu_item(
href: "/projects/new",
text: "New project/repository",
id: "new_project"
),
menu_item(
href: "/groups/new#create-group-pane",
text: "New subgroup",
id: "new_subgroup"
),
menu_item(
href: nil,
text: "Invite members",
component: 'invite_members',
id: "invite"
)
]
end
let(:in_gitlab_menu_items) do
[
menu_item(
href: "/projects/new",
text: "New project/repository",
id: "general_new_project"
),
menu_item(
href: "/groups/new",
text: "New group",
id: "general_new_group"
),
menu_item(
href: "/-/snippets/new",
text: "New snippet",
id: "general_new_snippet"
)
]
end
before do
allow(current_organization).to receive(:scoped_paths?).and_return(false)
end
include_examples '"Create new" menu groups with headers'
end
end
end
describe 'current context' do

View File

@ -0,0 +1,124 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe OrganizationUrlHelpers, feature_category: :organization do
shared_examples 'organization aware route helper' do
include Rails.application.routes.url_helpers
let(:helper_url) { public_send :"#{helper}_url" }
let(:helper_path) { public_send :"#{helper}_path" }
let(:expected_global_path) do
# Call the method on a fresh url_helpers instance to get the original behavior
original_helpers = Rails.application.routes.url_helpers.dup
original_helpers.public_send(:"#{helper}_path")
end
let(:expected_global_url) { "http://localhost#{expected_global_path}" }
context 'when organization context is not present' do
before do
allow(Current).to receive_messages(
organization_assigned: false,
organization: nil
)
end
it 'automatically routes to global path' do
expect(helper_path).to eq(expected_global_path)
end
it 'automatically routes to global url' do
expect(helper_url).to eq(expected_global_url)
end
end
context 'when organization has path scopes' do
let(:organization) { create(:organization) }
let(:organization_helper_url) do
public_send :"#{organization_helper}_url", organization_path: organization.path
end
let(:organization_helper_path) do
public_send :"#{organization_helper}_path", organization_path: organization.path
end
before do
allow(Current).to receive_messages(
organization_assigned: true,
organization: organization
)
end
context 'and they are enabled' do
before do
allow(organization).to receive(:scoped_paths?).and_return(true)
end
it 'automatically routes to organization scoped path' do
expect(helper_path).to eq(organization_helper_path)
end
it 'automatically routes to organization scoped URL' do
expect(helper_url).to eq(organization_helper_url)
end
end
context 'and they are disabled' do
before do
allow(organization).to receive(:scoped_paths?).and_return(false)
end
it 'automatically routes to global path' do
expect(helper_path).to eq(expected_global_path)
end
it 'automatically routes to global url' do
expect(helper_url).to eq(expected_global_url)
end
end
end
context 'when organization context is nil' do
before do
allow(Current).to receive_messages(
organization_assigned: true,
organization: nil
)
end
it 'automatically routes to global path' do
expect(helper_path).to eq(expected_global_path)
end
it 'automatically routes to global URL' do
expect(helper_url).to eq(expected_global_url)
end
end
end
describe '#new_project_path' do
let(:helper) { :new_project }
let(:organization_helper) { :new_organization_project }
it_behaves_like 'organization aware route helper'
end
describe '#projects_path' do
let(:helper) { :projects }
let(:organization_helper) { :organization_projects }
it_behaves_like 'organization aware route helper'
end
describe '.install' do
it 'only installs once' do
# Has already been installed as part of Rails initialization.
# Second call should not reinstall
expect(Rails.application.routes.url_helpers).not_to receive(:prepend)
described_class.install
end
end
end

View File

@ -194,7 +194,7 @@ RSpec.describe Gitlab::PathRegex do
# We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
it 'does not allow expansion' do
expect(described_class::TOP_LEVEL_ROUTES.size).to eq(38)
expect(described_class::TOP_LEVEL_ROUTES.size).to eq(39)
end
end

View File

@ -315,6 +315,10 @@ RSpec.describe Organizations::Organization, type: :model, feature_category: :org
end
end
describe '#scoped_paths?' do
it { expect(organization.scoped_paths?).to eq(true) }
end
describe '.search' do
let_it_be(:other_organization) { create(:organization, name: 'Other') }
@ -461,5 +465,20 @@ RSpec.describe Organizations::Organization, type: :model, feature_category: :org
end
end
end
describe '#scoped_paths?' do
it { expect(default_organization.scoped_paths?).to eq(false) }
end
end
describe 'Feature flagged #scoped_paths?' do
# The FF enabled cases are above.
context 'when organization_scoped_paths feature flag is disabled' do
before do
stub_feature_flags(organization_scoped_paths: false)
end
it { expect(organization.scoped_paths?).to eq(false) }
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Organization routes', feature_category: :organization do
it 'ensures organization_path is constrained' do
expect(get('/o/admin/projects/new')).to route_to_route_not_found
end
describe 'projects' do
it "to #new" do
expect(get("/o/my-org/projects/new")).to route_to('projects#new', organization_path: 'my-org')
end
it "to #create" do
expect(post("/o/my-org/projects")).to route_to('projects#create', organization_path: 'my-org')
end
end
end