Commit 55d29020 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '228893-add-gitpod-button-to-open-project-in-gitpod' into 'master'

Add Gitpod Integration – Add Gitpod buttons to start cloud dev environments for GitLab projects

Closes #228893

See merge request gitlab-org/gitlab!37985
parents dbb87f73 69124d38
<script>
import TreeActionLink from './tree_action_link.vue';
import { __ } from '~/locale';
import { webIDEUrl } from '~/lib/utils/url_utility';
export default {
components: {
TreeActionLink,
},
props: {
projectPath: {
type: String,
required: true,
},
refSha: {
type: String,
required: true,
},
canPushCode: {
type: Boolean,
required: false,
default: true,
},
forkPath: {
type: String,
required: false,
default: '',
},
},
computed: {
showLinkToFork() {
return !this.canPushCode && this.forkPath;
},
text() {
return this.showLinkToFork ? __('Edit fork in Web IDE') : __('Web IDE');
},
path() {
const path = this.showLinkToFork ? this.forkPath : this.projectPath;
return webIDEUrl(`/${path}/edit/${this.refSha}/-/${this.$route.params.path || ''}`);
},
},
};
</script>
<template>
<tree-action-link :path="path" :text="text" data-qa-selector="web_ide_button" />
</template>
import Vue from 'vue';
import { escapeFileUrl } from '../lib/utils/url_utility';
import { escapeFileUrl, joinPaths, webIDEUrl } from '../lib/utils/url_utility';
import createRouter from './router';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
import TreeActionLink from './components/tree_action_link.vue';
import WebIdeLink from './components/web_ide_link.vue';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
import { updateFormAction } from './utils/dom';
import { parseBoolean } from '../lib/utils/common_utils';
import { convertObjectPropsToCamelCase, parseBoolean } from '../lib/utils/common_utils';
import { __ } from '../locale';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
const {
canPushCode,
projectPath,
projectShortPath,
forkPath,
ref,
escapedRef,
fullName,
} = dataset;
const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeData({
......@@ -121,6 +113,10 @@ export default function setupVueRepositoryList() {
const webIdeLinkEl = document.getElementById('js-tree-web-ide-link');
if (webIdeLinkEl) {
const { ideBasePath, ...options } = convertObjectPropsToCamelCase(
JSON.parse(webIdeLinkEl.dataset.options),
);
// eslint-disable-next-line no-new
new Vue({
el: webIdeLinkEl,
......@@ -128,10 +124,10 @@ export default function setupVueRepositoryList() {
render(h) {
return h(WebIdeLink, {
props: {
projectPath,
refSha: ref,
forkPath,
canPushCode: parseBoolean(canPushCode),
webIdeUrl: webIDEUrl(
joinPaths('/', ideBasePath, 'edit', ref, '-', this.$route.params.path || '', '/'),
),
...options,
},
});
},
......
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlLink,
GlTooltipDirective,
} from '@gitlab/ui';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
actions: {
type: Array,
required: true,
},
selectedKey: {
type: String,
required: false,
default: '',
},
},
computed: {
hasMultipleActions() {
return this.actions.length > 1;
},
selectedAction() {
return this.actions.find(x => x.key === this.selectedKey) || this.actions[0];
},
},
methods: {
handleItemClick(action) {
this.$emit('select', action.key);
},
handleClick(action, evt) {
return action.handle?.(evt);
},
},
};
</script>
<template>
<gl-dropdown
v-if="hasMultipleActions"
v-gl-tooltip="selectedAction.tooltip"
class="gl-button-deprecated-adapter"
:text="selectedAction.text"
:split-href="selectedAction.href"
split
@click="handleClick(selectedAction, $event)"
>
<template slot="button-content">
<span class="gl-new-dropdown-button-text" v-bind="selectedAction.attrs">
{{ selectedAction.text }}
</span>
</template>
<template v-for="(action, index) in actions">
<gl-dropdown-item
:key="action.key"
class="gl-dropdown-item-deprecated-adapter"
:is-check-item="true"
:is-checked="action.key === selectedAction.key"
:secondary-text="action.secondaryText"
:data-testid="`action_${action.key}`"
@click="handleItemClick(action)"
>
{{ action.text }}
</gl-dropdown-item>
<gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" />
</template>
</gl-dropdown>
<gl-link
v-else-if="selectedAction"
v-gl-tooltip="selectedAction.tooltip"
v-bind="selectedAction.attrs"
class="btn"
:href="selectedAction.href"
@click="handleClick(selectedAction, $event)"
>
{{ selectedAction.text }}
</gl-link>
</template>
<script>
import $ from 'jquery';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
export default {
components: {
ActionsButton,
LocalStorageSync,
},
props: {
webIdeUrl: {
type: String,
required: true,
},
needsToFork: {
type: Boolean,
required: false,
default: false,
},
showWebIdeButton: {
type: Boolean,
required: false,
default: true,
},
showGitpodButton: {
type: Boolean,
required: false,
default: false,
},
gitpodUrl: {
type: String,
required: false,
default: '',
},
gitpodEnabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
selection: KEY_WEB_IDE,
};
},
computed: {
actions() {
return [this.webIdeAction, this.gitpodAction].filter(x => x);
},
webIdeAction() {
if (!this.showWebIdeButton) {
return null;
}
const handleOptions = this.needsToFork
? { href: '#modal-confirm-fork', handle: () => this.showModal('#modal-confirm-fork') }
: { href: this.webIdeUrl };
return {
key: KEY_WEB_IDE,
text: __('Web IDE'),
secondaryText: __('Quickly and easily edit multiple files in your project.'),
tooltip: '',
attrs: {
'data-qa-selector': 'web_ide_button',
},
...handleOptions,
};
},
gitpodAction() {
if (!this.showGitpodButton) {
return null;
}
const handleOptions = this.gitpodEnabled
? { href: this.gitpodUrl }
: { href: '#modal-enable-gitpod', handle: () => this.showModal('#modal-enable-gitpod') };
const secondaryText = __('Launch a ready-to-code development environment for your project.');
return {
key: KEY_GITPOD,
text: __('Gitpod'),
secondaryText,
tooltip: secondaryText,
attrs: {
'data-qa-selector': 'gitpod_button',
},
...handleOptions,
};
},
},
methods: {
select(key) {
this.selection = key;
},
showModal(id) {
$(id).modal('show');
},
},
};
</script>
<template>
<div>
<actions-button :actions="actions" :selected-key="selection" @select="select" />
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"
@input="select"
/>
</div>
</template>
......@@ -542,3 +542,13 @@ fieldset[disabled] .btn,
.btn-no-padding {
padding: 0;
}
// This class helps convert `.gl-button` children so that they consistently
// match the style of `.btn` elements which might be around them. Ideally we
// wouldn't need this class.
//
// Remove by upgrading all buttons in a container to use the new `.gl-button` style.
.gl-button-deprecated-adapter .gl-button {
box-shadow: none;
border-width: 1px;
}
......@@ -1135,3 +1135,17 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: $gl-dropdown-width-wide;
}
}
.gl-dropdown-item-deprecated-adapter {
.dropdown-item {
align-items: flex-start;
.gl-new-dropdown-item-text-primary {
@include gl-font-weight-bold;
}
.gl-new-dropdown-item-text-secondary {
color: inherit;
}
}
}
......@@ -51,6 +51,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:view_diffs_file_by_file,
:tab_width,
:sourcegraph_enabled,
:gitpod_enabled,
:render_whitespace_in_code
]
end
......
......@@ -104,6 +104,7 @@ class ProfilesController < Profiles::ApplicationController
:bio,
:email,
:role,
:gitpod_enabled,
:hide_no_password,
:hide_no_ssh_key,
:hide_project_limit,
......
......@@ -222,6 +222,8 @@ module ApplicationSettingsHelper
:gitaly_timeout_default,
:gitaly_timeout_medium,
:gitaly_timeout_fast,
:gitpod_enabled,
:gitpod_url,
:grafana_enabled,
:grafana_url,
:gravatar_enabled,
......
......@@ -80,6 +80,13 @@ module PreferencesHelper
)
end
def integration_views
[].tap do |views|
views << 'gitpod' if Gitlab::Gitpod.feature_and_settings_enabled?
views << 'sourcegraph' if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
end
end
private
# Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too
......
......@@ -191,16 +191,46 @@ module TreeHelper
def vue_file_list_data(project, ref)
{
can_push_code: current_user&.can?(:push_code, project) && "true",
project_path: project.full_path,
project_short_path: project.path,
fork_path: current_user&.fork_of(project)&.full_path,
ref: ref,
escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref),
full_name: project.name_with_namespace
}
end
def ide_base_path(project)
can_push_code = current_user&.can?(:push_code, project)
fork_path = current_user&.fork_of(project)&.full_path
if can_push_code
project.full_path
else
fork_path || project.full_path
end
end
def vue_ide_link_data(project, ref)
can_collaborate = can_collaborate_with_project?(project)
can_create_mr_from_fork = can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
show_web_ide_button = (can_collaborate || current_user&.already_forked?(project) || can_create_mr_from_fork)
{
ide_base_path: ide_base_path(project),
needs_to_fork: !can_collaborate && !current_user&.already_forked?(project),
show_web_ide_button: show_web_ide_button,
show_gitpod_button: show_web_ide_button && Gitlab::Gitpod.feature_and_settings_enabled?(project),
gitpod_url: full_gitpod_url(project, ref),
gitpod_enabled: current_user&.gitpod_enabled
}
end
def full_gitpod_url(project, ref)
return "" unless Gitlab::Gitpod.feature_and_settings_enabled?(project)
"#{Gitlab::CurrentSettings.gitpod_url}##{project_tree_url(project, tree_join(ref, @path || ''))}"
end
def directory_download_links(project, ref, archive_prefix)
Gitlab::Workhorse::ARCHIVE_FORMATS.map do |fmt|
{
......
......@@ -132,6 +132,11 @@ class ApplicationSetting < ApplicationRecord
presence: true,
if: :sourcegraph_enabled
validates :gitpod_url,
presence: true,
addressable_url: { enforce_sanitization: true },
if: :gitpod_enabled
validates :snowplow_collector_hostname,
presence: true,
hostname: true,
......
......@@ -74,6 +74,8 @@ module ApplicationSettingImplementation
gitaly_timeout_default: 55,
gitaly_timeout_fast: 10,
gitaly_timeout_medium: 30,
gitpod_enabled: false,
gitpod_url: 'https://gitpod.io/',
gravatar_enabled: Settings.gravatar['enabled'],
group_download_export_limit: 1,
group_export_limit: 6,
......
......@@ -279,6 +279,7 @@ class User < ApplicationRecord
:view_diffs_file_by_file, :view_diffs_file_by_file=,
:tab_width, :tab_width=,
:sourcegraph_enabled, :sourcegraph_enabled=,
:gitpod_enabled, :gitpod_enabled=,
:setup_for_company, :setup_for_company=,
:render_whitespace_in_code, :render_whitespace_in_code=,
:experience_level, :experience_level=,
......
- return unless Gitlab::Gitpod.feature_available?
- expanded = integration_expanded?('gitpod_')
- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer')
%section.settings.no-animate#js-gitpod-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Gitpod')
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link }
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
.settings-content
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
.form-check
= f.check_box :gitpod_enabled, class: 'form-check-input'
= f.label :gitpod_enabled, s_('Gitpod|Enable Gitpod integration'), class: 'form-check-label'
.form-group
= f.label :gitpod_url, s_('Gitpod|Gitpod URL'), class: 'label-bold'
= f.text_field :gitpod_url, class: 'form-control', placeholder: s_('Gitpod|e.g. https://gitpod.example.com')
.form-text.text-muted
= s_('Gitpod|Add the URL to your Gitpod instance configured to read your GitLab projects.')
= f.submit s_('Save changes'), class: 'btn btn-success'
......@@ -117,6 +117,7 @@
#js-maintenance-mode-settings
= render_if_exists 'admin/application_settings/elasticsearch_form'
= render 'admin/application_settings/gitpod'
= render 'admin/application_settings/plantuml'
= render 'admin/application_settings/sourcegraph'
= render_if_exists 'admin/application_settings/slack'
......
- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer')
%label.label-bold#gitpod
= s_('Gitpod')
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
.form-group.form-check
= f.check_box :gitpod_enabled, class: 'form-check-input'
= f.label :gitpod_enabled, class: 'form-check-label' do
= s_('Gitpod|Enable Gitpod integration').html_safe
.form-text.text-muted
= s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link }
- views = integration_views
- return unless views.any?
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#integrations
%h4.gl-mt-0
= s_('Preferences|Integrations')
%p
= s_('Preferences|Customize integrations with third party services.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences.md', anchor: 'integrations'), target: '_blank'
.col-lg-8
- views.each do |view|
= render view, f: f
- return unless Gitlab::Sourcegraph::feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
- sourcegraph_url = Gitlab::CurrentSettings.sourcegraph_url
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#integrations
%h4.gl-mt-0
= s_('Preferences|Integrations')
%p
= s_('Preferences|Customize integrations with third party services.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences.md', anchor: 'integrations'), target: '_blank'
.col-lg-8
%label.label-bold
%label.label-bold
= s_('Preferences|Sourcegraph')
= link_to sprite_icon('question-o'), help_page_path('user/profile/preferences.md', anchor: 'sourcegraph'), target: '_blank', class: 'has-tooltip', title: _('More information')
.form-group.form-check
= link_to sprite_icon('question-o'), help_page_path('user/profile/preferences.md', anchor: 'sourcegraph'), target: '_blank', class: 'has-tooltip', title: _('More information')
.form-group.form-check
= f.check_box :sourcegraph_enabled, class: 'form-check-input'
= f.label :sourcegraph_enabled, class: 'form-check-label' do
- link_start = '<a href="%{url}">'.html_safe % { url: sourcegraph_url }
- link_end = '</a>'.html_safe
= s_('Preferences|Enable integrated code intelligence on code views').html_safe % { link_start: link_start, link_end: link_end }
= s_('Preferences|Enable integrated code intelligence on code views').html_safe
.form-text.text-muted
= sourcegraph_url_message
= sourcegraph_experimental_message
......@@ -138,7 +138,7 @@
.form-text.text-muted
= s_('Preferences|For example: 30 mins ago.')
= render 'sourcegraph', f: f
= render 'integrations', f: f
.col-lg-4.profile-settings-sidebar
.col-lg-8
......
- can_collaborate = can_collaborate_with_project?(@project)
- can_create_mr_from_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
- can_visit_ide = can_collaborate || current_user&.already_forked?(@project)
.tree-ref-container
.tree-ref-holder
......@@ -14,12 +15,12 @@
= render 'projects/find_file_link'
- if can_collaborate || current_user&.already_forked?(@project)
#js-tree-web-ide-link.d-inline-block
- elsif can_create_mr_from_fork
= link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do
= _('Web IDE')
- if can_visit_ide || can_create_mr_from_fork
#js-tree-web-ide-link.d-inline-block{ data: { options: vue_ide_link_data(@project, @ref).to_json } }
- if !can_visit_ide
= render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path)
- unless current_user&.gitpod_enabled
= render 'shared/gitpod/enable_gitpod_modal'
- if show_xcode_link?(@project)
.project-action-button.project-xcode.inline<
......
#modal-enable-gitpod.modal.qa-enable-gitpod-modal
.modal-dialog
.modal-content
.modal-header
%h3.page-title= _('Enable Gitpod?')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
.modal-body.p-3
%p= (_("To use Gitpod you must first enable the feature in the integrations section of your %{user_prefs}.") % { user_prefs: link_to(_('user preferences'), profile_preferences_path(anchor: 'gitpod')) }).html_safe
.modal-footer
= link_to _('Cancel'), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
= link_to _('Enable Gitpod'), profile_path(user: { gitpod_enabled: true}), class: 'btn btn-success', method: :put
---
title: Add Gitpod integration
merge_request: 37985
author: Cornelius Ludmann @corneliusludmann
type: added
---
name: gitpod
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37985
rollout_issue_url:
group: group::editor
type: development
default_enabled: false
\ No newline at end of file
# frozen_string_literal: true
class AddGitpodApplicationSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
# rubocop:disable Migration/AddLimitToTextColumns
# limit is added in 20200727154631_add_gitpod_application_settings_text_limit
def change
add_column :application_settings, :gitpod_enabled, :boolean, default: false, null: false
add_column :application_settings, :gitpod_url, :text, default: 'https://gitpod.io/', null: true
end
# rubocop:enable Migration/AddLimitToTextColumns
end
# frozen_string_literal: true
class AddGitpodApplicationSettingsTextLimit < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_text_limit :application_settings, :gitpod_url, 255
end
def down
remove_text_limit :application_settings, :gitpod_url
end
end
# frozen_string_literal: true
class AddGitpodUserPreferences < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :user_preferences, :gitpod_enabled, :boolean, default: false, null: false
end
end
c04fe7e1a56bdcd41b5e1af346f9bfcae170d601954c4a0bcfcc9aea19d55528
\ No newline at end of file
0ce17a8ad6c5ca5bba49ff522fede400fe6666490157af123ad98a7643f3ce01
\ No newline at end of file
523f200c635e37ee1ac52257ffd45443a3e17bfe993d22775a5377865e044a46
\ No newline at end of file
......@@ -9272,6 +9272,9 @@ CREATE TABLE public.application_settings (
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL,
elasticsearch_client_request_timeout integer DEFAULT 0 NOT NULL,
gitpod_enabled boolean DEFAULT false NOT NULL,
gitpod_url text DEFAULT 'https://gitpod.io/'::text,
CONSTRAINT check_2dba05b802 CHECK ((char_length(gitpod_url) <= 255)),
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)),
......@@ -16277,7 +16280,8 @@ CREATE TABLE public.user_preferences (
tab_width smallint,
feature_filter_type bigint,
experience_level smallint,
view_diffs_file_by_file boolean DEFAULT false NOT NULL
view_diffs_file_by_file boolean DEFAULT false NOT NULL,
gitpod_enabled boolean DEFAULT false NOT NULL
);
CREATE SEQUENCE public.user_preferences_id_seq
......
---
type: reference, how-to
stage: Create
group: Editor
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers"
---
# Gitpod Integration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/228893) in GitLab 13.4.
> - It's [deployed behind a feature flag](#enable-or-disable-the-gitpod-integration), disabled by default.
> - It's enabled on GitLab.com.
> - It's recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#configure-your-gitlab-instance-with-gitpod). **(CORE ONLY)**
CAUTION: **Warning:**
This feature might not be available to you. Check the **version history** note above for details.
With [Gitpod](https://gitpod.io/) you can describe your dev environment as code to get fully set
up, compiled, and tested dev environments for any GitLab project. The dev environments are not only
automated but also prebuilt which means that Gitpod continuously builds your Git branches like a CI
server. By that you don’t have to wait for dependencies to be downloaded and builds to finish, but
you can start coding immediately.
In short: With Gitpod you can start coding instantly on any project, branch, and merge request from
any device, at any time.
![Gitpod interface](img/gitpod_web_interface_v13_4.png)
You can launch Gitpod directly from GitLab by clicking the **Gitpod** button from the **Web IDE**
dropdown on the project page:
![Gitpod Button on Project Page](img/gitpod_button_project_page_v13_4.png)
To learn more about Gitpod, see their [features](https://www.gitpod.io/features/) and
[documentation](https://www.gitpod.io/docs/).
To use the GitLab-Gitpod integration, you need to enable it from your user preferences:
1. From the GitLab UI, click your avatar in the top-right corner, then click **Settings**.
1. On the left-hand nav, click **Preferences**.
1. Under **Integrations**, find the **Gitpod** section.
1. Check **Enable Gitpod**.
Users of GitLab.com can enable it and start using straightaway. Users of GitLab self-managed instances
can follow the same steps once the integration has been enabled and configured by a GitLab administrator.
## Configure your GitLab instance with Gitpod **(CORE ONLY)**
If you are new to Gitpod, head over to the [Gitpod documentation](https://www.gitpod.io/docs/self-hosted/latest/self-hosted/)
and get your instance up and running.
1. In GitLab, go to **Admin Area > Settings > Integrations**.
1. Expand the **Gitpod** configuration section.
1. Check **Enable Gitpod**.
1. Add your Gitpod instance URL (for example, `https://gitpod.example.com`).
## Enable or disable the Gitpod integration **(CORE ONLY)**
The Gitpod integration is under development and not ready for production use. It is deployed behind a
feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:gitpod)
```
To disable it:
```ruby
Feature.disable(:gitpod)
......@@ -182,6 +182,12 @@ Manage the availability of integrated code intelligence features powered by
Sourcegraph. View [the Sourcegraph feature documentation](../../integration/sourcegraph.md#enable-sourcegraph-in-user-preferences)
for more information.
### Gitpod
Enable and disable the [GitLab-Gitpod integration](../../integration/gitpod.md). This is only
visible after the integration is configured by a GitLab administrator. View
[the Gitpod feature documentation](../../integration/gitpod.md) for more information.
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
......
......@@ -61,6 +61,10 @@ module API
end
optional :email_author_in_body, type: Boolean, desc: 'Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.'
optional :enabled_git_access_protocol, type: String, values: %w[ssh http nil], desc: 'Allow only the selected protocols to be used for Git access.'
optional :gitpod_enabled, type: Boolean, desc: 'Enable Gitpod'
given gitpod_enabled: ->(val) { val } do
requires :gitpod_url, type: String, desc: 'The configured Gitpod instance URL'
end
optional :gitaly_timeout_default, type: Integer, desc: 'Default Gitaly timeout, in seconds. Set to 0 to disable timeouts.'
optional :gitaly_timeout_fast, type: Integer, desc: 'Gitaly fast operation timeout, in seconds. Set to 0 to disable timeouts.'
optional :gitaly_timeout_medium, type: Integer, desc: 'Medium Gitaly timeout, in seconds. Set to 0 to disable timeouts.'
......
# frozen_string_literal: true
module Gitlab
class Gitpod
class << self
def feature_conditional?
feature.conditional?
end
def feature_available?
# The gitpod_bundle feature could be conditionally applied, so check if `!off?`
!feature.off?
end
def feature_enabled?(actor = nil)
feature.enabled?(actor)
end
def feature_and_settings_enabled?(actor = nil)
feature_enabled?(actor) && Gitlab::CurrentSettings.gitpod_enabled
end
private
def feature
Feature.get(:gitpod) # rubocop:disable Gitlab/AvoidFeatureGet
end
end
end
end
......@@ -12,8 +12,8 @@ module Gitlab
!feature.off?
end
def feature_enabled?(thing = nil)
feature.enabled?(thing)
def feature_enabled?(actor = nil)
feature.enabled?(actor)
end
private
......
......@@ -9240,9 +9240,6 @@ msgstr ""
msgid "Edit files in the editor and commit changes here"
msgstr ""
msgid "Edit fork in Web IDE"
msgstr ""
msgid "Edit group: %{group_name}"
msgstr ""
......@@ -9420,9 +9417,18 @@ msgstr ""
msgid "Enable"
msgstr ""
msgid "Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab."
msgstr ""
msgid "Enable Auto DevOps"
msgstr ""
msgid "Enable Gitpod"
msgstr ""
msgid "Enable Gitpod?"
msgstr ""
msgid "Enable HTML emails"
msgstr ""
......@@ -11996,6 +12002,21 @@ msgstr ""
msgid "Gitlab Pages"
msgstr ""
msgid "Gitpod"
msgstr ""
msgid "Gitpod|Add the URL to your Gitpod instance configured to read your GitLab projects."
msgstr ""
msgid "Gitpod|Enable Gitpod integration"
msgstr ""
msgid "Gitpod|Gitpod URL"
msgstr ""
msgid "Gitpod|e.g. https://gitpod.example.com"
msgstr ""
msgid "Given access %{time_ago}"
msgstr ""
......@@ -14594,6 +14615,9 @@ msgstr ""
msgid "Latest pipeline for the most recent commit on this branch"
msgstr ""
msgid "Launch a ready-to-code development environment for your project."
msgstr ""
msgid "Lead"
msgstr ""
......@@ -20679,6 +20703,9 @@ msgstr ""
msgid "Quick range"
msgstr ""
msgid "Quickly and easily edit multiple files in your project."
msgstr ""
msgid "README"
msgstr ""
......@@ -26561,6 +26588,9 @@ msgstr ""
msgid "To update Snippets with multiple files, you must use the `files` parameter"
msgstr ""
msgid "To use Gitpod you must first enable the feature in the integrations section of your %{user_prefs}."
msgstr ""
msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file"
msgstr ""
......@@ -30892,6 +30922,9 @@ msgstr ""
msgid "user avatar"
msgstr ""
msgid "user preferences"
msgstr ""
msgid "username"
msgstr ""
......
......@@ -56,7 +56,7 @@ module QA
element :new_file_option
end
view 'app/assets/javascripts/repository/components/web_ide_link.vue' do
view 'app/assets/javascripts/vue_shared/components/web_ide_link.vue' do
element :web_ide_button
end
......
......@@ -17,7 +17,10 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
end
context 'General page' do
let(:gitpod_feature_enabled) { true }
before do
stub_feature_flags(gitpod: gitpod_feature_enabled)
visit general_admin_application_settings_path
end
......@@ -205,6 +208,32 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(page).to have_content "Application settings saved successfully"
expect(current_settings.terminal_max_session_time).to eq(15)
end
context 'Configure Gitpod' do
context 'with feature disabled' do
let(:gitpod_feature_enabled) { false }
it 'do not show settings' do
expect(page).not_to have_selector('#js-gitpod-settings')
end
end
context 'with feature enabled' do
let(:gitpod_feature_enabled) { true }
it 'changes gitpod settings' do
page.within('#js-gitpod-settings') do
check 'Enable Gitpod integration'
fill_in 'Gitpod URL', with: 'https://gitpod.test/'
click_button 'Save changes'
end
expect(page).to have_content 'Application settings saved successfully'
expect(current_settings.gitpod_url).to eq('https://gitpod.test/')
expect(current_settings.gitpod_enabled).to be(true)
end
end
end
end
context 'Integrations page' do
......
import { mount } from '@vue/test-utils';
import WebIdeLink from '~/repository/components/web_ide_link.vue';
describe('Web IDE link component', () => {
let wrapper;
function createComponent(props) {
wrapper = mount(WebIdeLink, {
propsData: { ...props },
mocks: {
$route: {
params: {},
},
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders link to the Web IDE for a project if only projectPath is given', () => {
createComponent({ projectPath: 'gitlab-org/gitlab', refSha: 'master' });
expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/');
expect(wrapper.text()).toBe('Web IDE');
});
it('renders link to the Web IDE for a project even if both projectPath and forkPath are given', () => {
createComponent({
projectPath: 'gitlab-org/gitlab',
refSha: 'master',
forkPath: 'my-namespace/gitlab',
});
expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/');
expect(wrapper.text()).toBe('Web IDE');
});
it('renders link to the forked project if it exists and cannot write to the repo', () => {
createComponent({
projectPath: 'gitlab-org/gitlab',
refSha: 'master',
forkPath: 'my-namespace/gitlab',
canPushCode: false,
});
expect(wrapper.attributes('href')).toBe('/-/ide/project/my-namespace/gitlab/edit/master/-/');
expect(wrapper.text()).toBe('Edit fork in Web IDE');
});
});
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlLink } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
const TEST_ACTION = {
key: 'action1',
text: 'Sample',
secondaryText: 'Lorem ipsum.',
tooltip: '',
href: '/sample',
attrs: { 'data-test': '123' },
};
const TEST_ACTION_2 = {
key: 'action2',
text: 'Sample 2',
secondaryText: 'Dolar sit amit.',
tooltip: 'Dolar sit amit.',
href: '#',
attrs: { 'data-test': '456' },
};
const TEST_TOOLTIP = 'Lorem ipsum dolar sit';
describe('Actions button component', () => {
let wrapper;
function createComponent(props) {
wrapper = shallowMount(ActionsButton, {
propsData: { ...props },
directives: { GlTooltip: createMockDirective() },
});
}
afterEach(() => {
wrapper.destroy();
});
const getTooltip = child => {
const directiveBinding = getBinding(child.element, 'gl-tooltip');
return directiveBinding.value;
};
const findLink = () => wrapper.find(GlLink);
const findLinkTooltip = () => getTooltip(findLink());
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownTooltip = () => getTooltip(findDropdown());
const parseDropdownItems = () =>
findDropdown()
.findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub')
.wrappers.map(x => {
if (x.is('gl-dropdown-divider-stub')) {
return { type: 'divider' };
}
const { isCheckItem, isChecked, secondaryText } = x.props();
return {
type: 'item',
isCheckItem,
isChecked,
secondaryText,
text: x.text(),
};
});
const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt);
const clickLink = (...args) => clickOn(findLink(), ...args);
const clickDropdown = (...args) => clickOn(findDropdown(), ...args);
describe('with 1 action', () => {
beforeEach(() => {
createComponent({ actions: [TEST_ACTION] });
});
it('should not render dropdown', () => {
expect(findDropdown().exists()).toBe(false);
});
it('should render single button', () => {
const link = findLink();
expect(link.attributes()).toEqual({
class: expect.any(String),
href: TEST_ACTION.href,
...TEST_ACTION.attrs,
});
expect(link.text()).toBe(TEST_ACTION.text);
});
it('should have tooltip', () => {
expect(findLinkTooltip()).toBe(TEST_ACTION.tooltip);
});
it('should have attrs', () => {
expect(findLink().attributes()).toMatchObject(TEST_ACTION.attrs);
});
it('can click', () => {
expect(clickLink).not.toThrow();
});
});
describe('with 1 action with tooltip', () => {
it('should have tooltip', () => {
createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] });
expect(findLinkTooltip()).toBe(TEST_TOOLTIP);
});
});
describe('with 1 action with handle', () => {
it('can click and trigger handle', () => {
const handleClick = jest.fn();
createComponent({ actions: [{ ...TEST_ACTION, handle: handleClick }] });
const event = new Event('click');
clickLink(event);
expect(handleClick).toHaveBeenCalledWith(event);
});
});
describe('with multiple actions', () => {
let handleAction;
beforeEach(() => {
handleAction = jest.fn();
createComponent({ actions: [{ ...TEST_ACTION, handle: handleAction }, TEST_ACTION_2] });
});
it('should default to selecting first action', () => {
expect(findDropdown().attributes()).toMatchObject({
text: TEST_ACTION.text,
'split-href': TEST_ACTION.href,
});
});
it('should handle first action click', () => {
const event = new Event('click');
clickDropdown(event);
expect(handleAction).toHaveBeenCalledWith(event);
});
it('should render dropdown items', () => {
expect(parseDropdownItems()).toEqual([
{
type: 'item',
isCheckItem: true,
isChecked: true,
secondaryText: TEST_ACTION.secondaryText,
text: TEST_ACTION.text,
},
{ type: 'divider' },
{
type: 'item',
isCheckItem: true,
isChecked: false,
secondaryText: TEST_ACTION_2.secondaryText,
text: TEST_ACTION_2.text,
},
]);
});
it('should select action 2 when clicked', () => {
expect(wrapper.emitted('select')).toBeUndefined();
const action2 = wrapper.find(`[data-testid="action_${TEST_ACTION_2.key}"]`);
action2.vm.$emit('click');
expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]);
});
it('should have tooltip value', () => {
expect(findDropdownTooltip()).toBe(TEST_ACTION.tooltip);
});
});
describe('with multiple actions and selectedKey', () => {
beforeEach(() => {
createComponent({ actions: [TEST_ACTION, TEST_ACTION_2], selectedKey: TEST_ACTION_2.key });
});
it('should show action 2 as selected', () => {
expect(parseDropdownItems()).toEqual([
expect.objectContaining({
type: 'item',
isChecked: false,
}),
{ type: 'divider' },
expect.objectContaining({
type: 'item',
isChecked: true,
}),
]);
});
it('should have tooltip value', () => {
expect(findDropdownTooltip()).toBe(TEST_ACTION_2.tooltip);
});
});
});
import { shallowMount } from '@vue/test-utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/';
const TEST_GITPOD_URL = 'https://gitpod.test/';
const ACTION_WEB_IDE = {
href: TEST_WEB_IDE_URL,
key: 'webide',
secondaryText: 'Quickly and easily edit multiple files in your project.',
tooltip: '',
text: 'Web IDE',
attrs: {
'data-qa-selector': 'web_ide_button',
},
};
const ACTION_WEB_IDE_FORK = {
...ACTION_WEB_IDE,
href: '#modal-confirm-fork',
handle: expect.any(Function),
};
const ACTION_GITPOD = {
href: TEST_GITPOD_URL,
key: 'gitpod',
secondaryText: 'Launch a ready-to-code development environment for your project.',
tooltip: 'Launch a ready-to-code development environment for your project.',
text: 'Gitpod',
attrs: {
'data-qa-selector': 'gitpod_button',
},
};
const ACTION_GITPOD_ENABLE = {
...ACTION_GITPOD,
href: '#modal-enable-gitpod',
handle: expect.any(Function),
};
describe('Web IDE link component', () => {
let wrapper;
function createComponent(props) {
wrapper = shallowMount(WebIdeLink, {
propsData: {
webIdeUrl: TEST_WEB_IDE_URL,
gitpodUrl: TEST_GITPOD_URL,
...props,
},
});
}
afterEach(() => {
wrapper.destroy();
});
const findActionsButton = () => wrapper.find(ActionsButton);
const findLocalStorageSync = () => wrapper.find(LocalStorageSync);
it.each`
props | expectedActions
${{}} | ${[ACTION_WEB_IDE]}
${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]}
${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]}
${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]}
${{ showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_WEB_IDE, ACTION_GITPOD_ENABLE]}
`('renders actions with props=$props', ({ props, expectedActions }) => {
createComponent(props);
expect(findActionsButton().props('actions')).toEqual(expectedActions);
});
describe('with multiple actions', () => {
beforeEach(() => {
createComponent({ showWebIdeButton: true, showGitpodButton: true, gitpodEnabled: true });
});
it('selected Web IDE by default', () => {
expect(findActionsButton().props()).toMatchObject({
actions: [ACTION_WEB_IDE, ACTION_GITPOD],
selectedKey: ACTION_WEB_IDE.key,
});
});
it('should set selection with local storage value', async () => {
expect(findActionsButton().props('selectedKey')).toBe(ACTION_WEB_IDE.key);
findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key);
await wrapper.vm.$nextTick();
expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key);
});
it('should update local storage when selection changes', async () => {
expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key);
findActionsButton().vm.$emit('select', ACTION_GITPOD.key);
await wrapper.vm.$nextTick();
expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key);
expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key);
});
});
});
......@@ -156,21 +156,36 @@ RSpec.describe TreeHelper do
end
describe '#vue_file_list_data' do
before do
allow(helper).to receive(:current_user).and_return(nil)
end
it 'returns a list of attributes related to the project' do
expect(helper.vue_file_list_data(project, sha)).to include(
can_push_code: nil,
fork_path: nil,
escaped_ref: sha,
ref: sha,
project_path: project.full_path,
project_short_path: project.path,
ref: sha,
escaped_ref: sha,
full_name: project.name_with_namespace
)
end
end
describe '#vue_ide_link_data' do
before do
allow(helper).to receive(:current_user).and_return(nil)
allow(helper).to receive(:can_collaborate_with_project?).and_return(true)
allow(helper).to receive(:can?).and_return(true)
end
subject { helper.vue_ide_link_data(project, sha) }
it 'returns a list of attributes related to the project' do
expect(subject).to include(
ide_base_path: project.full_path,
needs_to_fork: false,
show_web_ide_button: true,
show_gitpod_button: false,
gitpod_url: "",
gitpod_enabled: nil
)
end
context 'user does not have write access but a personal fork exists' do
include ProjectForksHelper
......@@ -185,9 +200,9 @@ RSpec.describe TreeHelper do
allow(helper).to receive(:current_user).and_return(user)
end
it 'includes fork_path too' do
expect(helper.vue_file_list_data(project, sha)).to include(
fork_path: forked_project.full_path
it 'includes ide_base_path: forked_project.full_path' do
expect(subject).to include(
ide_base_path: forked_project.full_path
)
end
end
......@@ -201,9 +216,54 @@ RSpec.describe TreeHelper do
allow(helper).to receive(:current_user).and_return(user)
end
it 'includes can_push_code: true' do
expect(helper.vue_file_list_data(project, sha)).to include(
can_push_code: "true"
it 'includes ide_base_path: project.full_path' do
expect(subject).to include(
ide_base_path: project.full_path
)
end
end
context 'gitpod feature is enabled' do
let_it_be(:user) { create(:user) }
before do
stub_feature_flags(gitpod: true)
allow(Gitlab::CurrentSettings)
.to receive(:gitpod_enabled)
.and_return(true)
allow(helper).to receive(:current_user).and_return(user)
end
it 'has show_gitpod_button: true' do
expect(subject).to include(
show_gitpod_button: true
)
end
it 'has gitpod_enabled: true when user has enabled gitpod' do
user.gitpod_enabled = true
expect(subject).to include(
gitpod_enabled: true
)
end
it 'has gitpod_enabled: false when user has not enabled gitpod' do
user.gitpod_enabled = false
expect(subject).to include(
gitpod_enabled: false
)
end
it 'has show_gitpod_button: false when web ide button is not shown' do
allow(helper).to receive(:can_collaborate_with_project?).and_return(false)
allow(helper).to receive(:can?).and_return(false)
expect(subject).to include(
show_web_ide_button: false,
show_gitpod_button: false
)
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Gitpod do
let_it_be(:user) { create(:user) }
let(:feature_scope) { true }
before do
stub_feature_flags(gitpod: feature_scope)
end
describe '.feature_conditional?' do
subject { described_class.feature_conditional? }
context 'when feature is enabled globally' do
it { is_expected.to be_falsey }
end
context 'when feature is enabled only to a resource' do
let(:feature_scope) { user }
it { is_expected.to be_truthy }
end
end
describe '.feature_available?' do
subject { described_class.feature_available? }
context 'when feature is enabled globally' do
it { is_expected.to be_truthy }
end
context 'when feature is enabled only to a resource' do
let(:feature_scope) { user }
it { is_expected.to be_truthy }
end
end
describe '.feature_enabled?' do
let(:current_user) { nil }
subject { described_class.feature_enabled?(current_user) }
context 'when feature is enabled globally' do
it { is_expected.to be_truthy }
end
context 'when feature is enabled only to a resource' do
let(:feature_scope) { user }
context 'for the same resource' do
let(:current_user) { user }
it { is_expected.to be_truthy }
end
context 'for a different resource' do
let(:current_user) { create(:user) }
it { is_expected.to be_falsey }
end
end
end
end
......@@ -638,6 +638,29 @@ RSpec.describe ApplicationSetting do
is_expected.to be_invalid
end
end
context 'gitpod settings' do
it 'is invalid if gitpod is enabled and no url is provided' do
allow(subject).to receive(:gitpod_enabled).and_return(true)
allow(subject).to receive(:gitpod_url).and_return(nil)
is_expected.to be_invalid
end
it 'is invalid if gitpod is enabled and an empty url is provided' do
allow(subject).to receive(:gitpod_enabled).and_return(true)
allow(subject).to receive(:gitpod_url).and_return('')
is_expected.to be_invalid
end
it 'is invalid if gitpod is enabled and an invalid url is provided' do
allow(subject).to receive(:gitpod_enabled).and_return(true)
allow(subject).to receive(:gitpod_url).and_return('javascript:alert("test")//')
is_expected.to be_invalid
end
end
end
context 'restrict creating duplicates' do
......
......@@ -47,6 +47,9 @@ RSpec.describe User do
it { is_expected.to delegate_method(:sourcegraph_enabled).to(:user_preference) }
it { is_expected.to delegate_method(:sourcegraph_enabled=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:gitpod_enabled).to(:user_preference) }
it { is_expected.to delegate_method(:gitpod_enabled=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:setup_for_company).to(:user_preference) }
it { is_expected.to delegate_method(:setup_for_company=).to(:user_preference).with_arguments(:args) }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment