Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-03-28 03:14:08 +00:00
parent d87faae128
commit 85435943cd
45 changed files with 1041 additions and 142 deletions

View File

@ -630,7 +630,6 @@ Layout/LineLength:
- 'config/application.rb'
- 'config/initializers/01_secret_token.rb'
- 'config/initializers/1_settings.rb'
- 'config/initializers/5_backend.rb'
- 'config/initializers/7_prometheus_metrics.rb'
- 'config/initializers/8_devise.rb'
- 'config/initializers/active_record_force_reconnects.rb'

View File

@ -1,11 +1,12 @@
<script>
import { GlAvatar, GlLoadingIcon } from '@gitlab/ui';
import { GlAvatar, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
export default {
components: {
GlAvatar,
GlLoadingIcon,
GlIcon,
},
directives: {
@ -98,6 +99,10 @@ export default {
return this.isReference && this.nodeProps.referenceType === 'milestone';
},
isWiki() {
return this.nodeProps.referenceType === 'wiki';
},
isEmoji() {
return this.nodeType === 'emoji';
},
@ -146,6 +151,8 @@ export default {
return item.reference;
case 'vulnerability':
return `[vulnerability:${item.id}]`;
case 'wiki':
return item.title;
default:
return '';
}
@ -178,6 +185,16 @@ export default {
});
}
if (this.isWiki) {
Object.assign(props, {
text: item.title,
href: item.path,
isGollumLink: true,
isWikiPage: true,
canonicalSrc: item.slug,
});
}
Object.assign(props, this.nodeProps);
return props;
@ -312,6 +329,15 @@ export default {
<span v-safe-html:safeHtmlConfig="highlight(item.title)"></span>
<span v-if="item.expired">{{ __('(expired)') }}</span>
</span>
<span v-if="isWiki">
<gl-icon class="gl-mr-2" name="document" />
<span v-safe-html:[$options.safeHtmlConfig]="highlight(item.title)"></span>
<small
v-if="item.title.toLowerCase() !== item.slug.toLowerCase()"
v-safe-html:[$options.safeHtmlConfig]="highlight(`(${item.slug})`)"
class="gl-text-gray-500"
></small>
</span>
<span v-if="isLabel" class="gl-display-flex">
<span
data-testid="label-color-box"

View File

@ -52,6 +52,17 @@ export default Link.extend({
title: null,
parseHTML: (element) => element.getAttribute('title'),
},
// only for gollum links (wikis)
isGollumLink: {
default: false,
parseHTML: (element) => Boolean(element.dataset.gollum),
renderHTML: () => '',
},
isWikiPage: {
default: false,
parseHTML: (element) => Boolean(element.classList.contains('gfm-gollum-wiki-page')),
renderHTML: ({ isWikiPage }) => (isWikiPage ? { class: 'gfm-gollum-wiki-page' } : {}),
},
canonicalSrc: {
default: null,
parseHTML: (element) => element.dataset.canonicalSrc,

View File

@ -23,14 +23,24 @@ function createSuggestionPlugin({
pluginKey: new PluginKey(uniqueId('suggestions')),
command: ({ editor: tiptapEditor, range, props }) => {
tiptapEditor
.chain()
.focus()
.insertContentAt(range, [
let content;
if (nodeType === 'link') {
content = [
{
type: 'text',
text: props.text,
marks: [{ type: 'link', attrs: props }],
},
];
} else {
content = [
{ type: nodeType, attrs: props },
{ type: 'text', text: ` ${insertionMap[props.text] || ''}` },
])
.run();
];
}
tiptapEditor.chain().focus().insertContentAt(range, content).run();
},
async items({ query, editor: tiptapEditor }) {
@ -153,6 +163,7 @@ export default Node.create({
createPlugin('[vulnerability:', 'reference', 'vulnerability'),
createPlugin('%', 'reference', 'milestone'),
createPlugin(':', 'emoji', 'emoji'),
createPlugin('[[', 'link', 'wiki'),
createPlugin('/', 'reference', 'command', {
cache: false,
limit: 100,

View File

@ -139,6 +139,7 @@ export default class AutocompleteHelper {
merge_request: this.dataSourceUrls.mergeRequests,
vulnerability: this.dataSourceUrls.vulnerabilities,
command: this.dataSourceUrls.commands,
wiki: this.dataSourceUrls.wikis,
};
const searchFields = {
@ -151,6 +152,7 @@ export default class AutocompleteHelper {
merge_request: ['iid', 'title'],
milestone: ['title', 'iid'],
command: ['name'],
wiki: ['title'],
emoji: [],
};

View File

@ -586,6 +586,10 @@ export const italic = {
expelEnclosingWhitespace: true,
};
function getMarkText(mark, parent) {
return findChildWithMark(mark, parent).child?.text || '';
}
const generateCodeTag = (wrapTagName = openTag) => {
const isOpen = wrapTagName === openTag;
@ -596,7 +600,7 @@ const generateCodeTag = (wrapTagName = openTag) => {
return wrapTagName(type.substring(1));
}
const childText = findChildWithMark(mark, parent).child?.text || '';
const childText = getMarkText(mark, parent);
if (childText.includes('`')) {
let tag = '``';
if (childText.startsWith('`') || childText.endsWith('`'))
@ -694,12 +698,13 @@ export const link = {
return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : '';
}
const { href, title, sourceMarkdown } = mark.attrs;
const { href, title, sourceMarkdown, isGollumLink } = mark.attrs;
// eslint-disable-next-line @gitlab/require-i18n-strings
if (href.startsWith('data:') || href.startsWith('blob:')) return '';
if (linkType(sourceMarkdown) === LINK_MARKDOWN) {
if (isGollumLink) return '[[';
return '[';
}
@ -718,7 +723,14 @@ export const link = {
return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
const { href = '', title, sourceMarkdown, isReference } = mark.attrs;
const {
href = '',
title,
sourceMarkdown,
isReference,
isGollumLink,
canonicalSrc,
} = mark.attrs;
// eslint-disable-next-line @gitlab/require-i18n-strings
if (href.startsWith('data:') || href.startsWith('blob:')) return '';
@ -731,6 +743,17 @@ export const link = {
return closeTag('a');
}
if (isGollumLink) {
const text = getMarkText(mark, parent);
const escapedCanonicalSrc = state.esc(canonicalSrc);
if (text.toLowerCase() === escapedCanonicalSrc.toLowerCase()) {
return ']]';
}
return `|${escapedCanonicalSrc}]]`;
}
return `](${state.esc(getLinkHref(mark, state.options.useCanonicalSrc))}${
title ? ` ${state.quote(title)}` : ''
})`;

View File

@ -21,6 +21,7 @@ const MERGEREQUESTS_ALIAS = 'mergerequests';
const LABELS_ALIAS = 'labels';
const SNIPPETS_ALIAS = 'snippets';
const CONTACTS_ALIAS = 'contacts';
const WIKIS_ALIAS = 'wikis';
const CADENCE_REFERENCE_PREFIX = '[cadence:';
const ITERATION_REFERENCE_PREFIX = '*iteration:';
@ -130,6 +131,7 @@ export const defaultAutocompleteConfig = {
snippets: true,
vulnerabilities: true,
contacts: true,
wikis: true,
};
class GfmAutoComplete {
@ -172,6 +174,7 @@ class GfmAutoComplete {
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
if (this.enableMap.contacts) this.setupContacts($input);
if (this.enableMap.wikis) this.setupWikis($input);
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
@ -741,6 +744,47 @@ class GfmAutoComplete {
showAndHideHelper($input, SNIPPETS_ALIAS);
}
setupWikis($input) {
$input.atwho({
at: '[[',
suffix: ']]',
alias: WIKIS_ALIAS,
searchKey: 'title',
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
tmpl = GfmAutoComplete.Wikis.templateFunction(value);
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}|${slug}',
callbacks: {
...this.getDefaultCallbacks(),
beforeInsert(value) {
const [title, slug] = value.substr(2).split('|');
if (title.toLowerCase() === slug.toLowerCase()) {
return `[[${slug}`;
}
return `[[${title}|${slug}`;
},
beforeSave(wikis) {
return $.map(wikis, (m) => {
if (m.title == null) {
return m;
}
return {
title: m.title,
slug: m.slug,
};
});
},
},
});
showAndHideHelper($input, WIKIS_ALIAS);
}
setupContacts($input) {
const fetchData = this.fetchData.bind(this);
let command = '';
@ -1011,6 +1055,7 @@ GfmAutoComplete.atTypeMap = {
'[vulnerability:': 'vulnerabilities',
$: 'snippets',
'[contact:': 'contacts',
'[[': 'wikis',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
@ -1116,6 +1161,14 @@ GfmAutoComplete.Contacts = {
return `<li><small>${escape(firstName)} ${escape(lastName)}</small> ${escape(email)}</li>`;
},
};
GfmAutoComplete.Wikis = {
templateFunction({ title, slug }) {
const icon = spriteIcon('document', 's16 vertical-align-middle gl-mr-2');
const slugSpan =
title.toLowerCase() !== slug.toLowerCase() ? ` <small>(${escape(slug)})</small>` : '';
return `<li>${icon} ${escape(title)} ${slugSpan}</li>`;
},
};
const loadingSpinner = loadingIconForLegacyJS({
inline: true,

View File

@ -484,20 +484,29 @@
}
}
a.gfm-gollum-wiki-page {
&::before {
margin-right: 4px;
vertical-align: -2px;
@include gl-dark-invert-keep-hue;
content: url('sprite_icons/document.svg');
}
}
a.with-attachment-icon,
a[href*='/uploads/'],
a[href*='storage.googleapis.com/google-code-attachments/'] {
&::before {
margin-right: 4px;
vertical-align: -2px;
font-style: normal;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
content: '📎';
@include gl-dark-invert-keep-hue;
content: url('sprite_icons/paperclip.svg');
}
}
a[href*='/wikis/'],
a[href*='/uploads/'],
a[href*='storage.googleapis.com/google-code-attachments/'] {
&.no-attachment-icon {

View File

@ -6,7 +6,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :authorize_read_milestone!, only: :milestones
before_action :authorize_read_crm_contact!, only: :contacts
feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts]
feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts, :wikis]
feature_category :code_review_workflow, [:merge_requests]
feature_category :groups_and_projects, [:members]
feature_category :source_code_management, [:snippets]
@ -46,6 +46,10 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
render json: autocomplete_service.contacts(target)
end
def wikis
render json: autocomplete_service.wikis
end
private
def autocomplete_service

View File

@ -456,7 +456,8 @@ module ApplicationHelper
milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
snippets: snippets_project_autocomplete_sources_path(object),
contacts: contacts_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id])
contacts: contacts_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
wikis: object.feature_available?(:wiki, current_user) ? wikis_project_autocomplete_sources_path(object) : nil
}
end
end

View File

@ -3,6 +3,8 @@
module Projects
class AutocompleteService < BaseService
include LabelsAsHash
include Routing::WikiHelper
def issues
IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end
@ -33,6 +35,16 @@ module Projects
SnippetsFinder.new(current_user, project: project).execute.select([:id, :title])
end
def wikis
wiki = Wiki.for_container(project, current_user)
return [] unless can?(current_user, :read_wiki, wiki.container)
wiki
.list_pages(limit: 5000)
.reject { |page| page.slug.start_with?('templates/') }
.map { |page| { path: wiki_page_path(page.wiki, page), slug: page.slug, title: page.title } }
end
def contacts(target)
available_contacts = Crm::ContactsFinder.new(current_user, group: project.group).execute
.select([:id, :email, :first_name, :last_name, :state])

View File

@ -1,10 +0,0 @@
# frozen_string_literal: true
unless Rails.env.test?
required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required)
current_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version)
unless current_version.valid? && required_version <= current_version
warn "WARNING: This version of GitLab depends on gitlab-shell #{required_version}, but you're running #{current_version}. Please update gitlab-shell."
end
end

View File

@ -172,6 +172,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get 'commands'
get 'snippets'
get 'contacts'
get 'wikis'
end
end

View File

@ -26,7 +26,7 @@ Some features are still in development. View details about [support for each sta
| Automates repetitive tasks and helps catch bugs early. | [Test generation](gitlab_duo_chat.md#write-tests-in-the-ide) | **Tier:** Ultimate <br>**Offering:** GitLab.com, Self-managed, GitLab Dedicated <br>**Status:** Beta |
| Generates a description for the merge request based on the contents of the template. | [Merge request template population](project/merge_requests/ai_in_merge_requests.md#fill-in-merge-request-templates) | **Tier:** Ultimate<br>**Offering:** GitLab.com <br>**Status:** Experiment |
| Assists in creating faster and higher-quality reviews by automatically suggesting reviewers for your merge request. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=ivwZQgh4Rxw) | [Suggested Reviewers](project/merge_requests/reviews/index.md#gitlab-duo-suggested-reviewers) | **Tier:** Ultimate <br>**Offering:** GitLab.com<br>**Status:** Generally Available |
| Efficiently communicates the impact of your merge request changes. | [Merge request summary](project/merge_requests/ai_in_merge_requests.md#summarize-merge-request-changes) | **Tier:** Ultimate <br>**Offering:** GitLab.com <br>**Status:** Experiment |
| Efficiently communicates the impact of your merge request changes. | [Merge request summary](project/merge_requests/ai_in_merge_requests.md#summarize-merge-request-changes) | **Tier:** Ultimate <br>**Offering:** GitLab.com <br>**Status:** Beta |
| Helps ease merge request handoff between authors and reviewers and help reviewers efficiently understand suggestions. | [Code review summary](project/merge_requests/ai_in_merge_requests.md#summarize-my-merge-request-review) | **Tier:** Ultimate <br>**Offering:** GitLab.com <br>**Status:** Experiment |
| Helps you remediate vulnerabilities more efficiently, boost your skills, and write more secure code. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=6sDf73QOav8) | [Vulnerability explanation](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | **Tier:** Ultimate <br>**Offering:** GitLab.com <br>**Status:** Beta |
| Generates a merge request containing the changes required to mitigate a vulnerability. | [Vulnerability resolution](application_security/vulnerabilities/index.md#vulnerability-resolution) | **Tier:** Ultimate <br>**Offering:** GitLab.com <br>**Status:** Experiment |

View File

@ -682,6 +682,8 @@ The selected diagram is replaced with an updated version.
## GitLab-specific references
> - Autocomplete for wiki pages [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/442229) in GitLab 16.11.
GitLab Flavored Markdown renders GitLab-specific references. For example, you can reference
an issue, a commit, a team member, or even an entire project team. GitLab Flavored Markdown turns
that reference into a link so you can navigate between them. All references to projects should use the
@ -692,35 +694,37 @@ version to reference other projects from the same namespace.
GitLab Flavored Markdown recognizes the following:
| References | Input | Cross-project reference | Shortcut inside the same namespace |
| -------------------------------------------------------------- | ------------------------------ | --------------------------------------- | ---------------------------------- |
| Specific user | `@user_name` | | |
| Specific group | `@group_name` | | |
| Entire team | [`@all`](discussions/index.md#mentioning-all-members) | | |
| Project | `namespace/project>` | | |
| Issue | ``#123`` | `namespace/project#123` | `project#123` |
| Merge request | `!123` | `namespace/project!123` | `project!123` |
| Snippet | `$123` | `namespace/project$123` | `project$123` |
| [Epic](group/epics/index.md) | `&123` | `group1/subgroup&123` | |
| [Iteration](group/iterations/index.md) | `*iteration:"iteration title"` | | |
| [Iteration cadence](group/iterations/index.md) by ID<sup>1</sup> | `[cadence:123]` | | |
| [Iteration cadence](group/iterations/index.md) by title (one word)<sup>1</sup> | `[cadence:plan]` | | |
| [Iteration cadence](group/iterations/index.md) by title (multiple words)<sup>1</sup> | `[cadence:"plan a"]` | | |
| [Vulnerability](application_security/vulnerabilities/index.md) | `[vulnerability:123]` | `[vulnerability:namespace/project/123]` | `[vulnerability:project/123]` |
| Feature flag | `[feature_flag:123]` | `[feature_flag:namespace/project/123]` | `[feature_flag:project/123]` |
| Label by ID | `~123` | `namespace/project~123` | `project~123` |
| Label by name (one word) | `~bug` | `namespace/project~bug` | `project~bug` |
| Label by name (multiple words) | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |
| Label by name (scoped) | `~"priority::high"` | `namespace/project~"priority::high"` | `project~"priority::high"` |
| Project milestone by ID <sup>2</sup> | `%123` | `namespace/project%123` | `project%123` |
| Milestone by name (one word) <sup>2</sup> | `%v1.23` | `namespace/project%v1.23` | `project%v1.23` |
| Milestone by name (multiple words) <sup>2</sup> | `%"release candidate"` | `namespace/project%"release candidate"` | `project%"release candidate"` |
| Commit (specific) | `9ba12248` | `namespace/project@9ba12248` | `project@9ba12248` |
| Commit range comparison | `9ba12248...b19a04f5` | `namespace/project@9ba12248...b19a04f5` | `project@9ba12248...b19a04f5` |
| Repository file reference | `[README](doc/README.md)` | | |
| Repository file reference (specific line) | `[README](doc/README.md#L13)` | | |
| [Alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
| [Contact](crm/index.md#contacts) | `[contact:test@example.com]` | | |
| References | Input | Cross-project reference | Shortcut inside the same namespace |
|--------------------------------------------------------------------------------------|-------------------------------------------------------|-----------------------------------------|------------------------------------|
| Specific user | `@user_name` | | |
| Specific group | `@group_name` | | |
| Entire team | [`@all`](discussions/index.md#mentioning-all-members) | | |
| Project | `namespace/project>` | | |
| Issue | ``#123`` | `namespace/project#123` | `project#123` |
| Merge request | `!123` | `namespace/project!123` | `project!123` |
| Snippet | `$123` | `namespace/project$123` | `project$123` |
| [Epic](group/epics/index.md) | `&123` | `group1/subgroup&123` | |
| [Iteration](group/iterations/index.md) | `*iteration:"iteration title"` | | |
| [Iteration cadence](group/iterations/index.md) by ID<sup>1</sup> | `[cadence:123]` | | |
| [Iteration cadence](group/iterations/index.md) by title (one word)<sup>1</sup> | `[cadence:plan]` | | |
| [Iteration cadence](group/iterations/index.md) by title (multiple words)<sup>1</sup> | `[cadence:"plan a"]` | | |
| [Vulnerability](application_security/vulnerabilities/index.md) | `[vulnerability:123]` | `[vulnerability:namespace/project/123]` | `[vulnerability:project/123]` |
| Feature flag | `[feature_flag:123]` | `[feature_flag:namespace/project/123]` | `[feature_flag:project/123]` |
| Label by ID | `~123` | `namespace/project~123` | `project~123` |
| Label by name (one word) | `~bug` | `namespace/project~bug` | `project~bug` |
| Label by name (multiple words) | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |
| Label by name (scoped) | `~"priority::high"` | `namespace/project~"priority::high"` | `project~"priority::high"` |
| Project milestone by ID <sup>2</sup> | `%123` | `namespace/project%123` | `project%123` |
| Milestone by name (one word) <sup>2</sup> | `%v1.23` | `namespace/project%v1.23` | `project%v1.23` |
| Milestone by name (multiple words) <sup>2</sup> | `%"release candidate"` | `namespace/project%"release candidate"` | `project%"release candidate"` |
| Commit (specific) | `9ba12248` | `namespace/project@9ba12248` | `project@9ba12248` |
| Commit range comparison | `9ba12248...b19a04f5` | `namespace/project@9ba12248...b19a04f5` | `project@9ba12248...b19a04f5` |
| Repository file reference | `[README](doc/README.md)` | | |
| Repository file reference (specific line) | `[README](doc/README.md#L13)` | | |
| [Alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
| [Contact](crm/index.md#contacts) | `[contact:test@example.com]` | | |
| [Wiki page](project/wiki/index.md) (if the page slug is the same as the title) | `[[Home]]` | | |
| [Wiki page](project/wiki/index.md) (if the page slug is different from the title) | `[[How to use GitLab\|how-to-use-gitlab]]` | | |
**Footnotes:**

View File

@ -27,7 +27,8 @@ This process is different than [importing from Bitbucket Cloud](bitbucket.md).
must be enabled. If not enabled, ask your GitLab administrator to enable it. The Bitbucket Server import source is enabled
by default on GitLab.com.
- At least the Maintainer role on the destination group to import to.
- Bitbucket Server authentication token with administrator access.
- Bitbucket Server authentication token with administrator access. Without administrator access, some data is
[not imported](https://gitlab.com/gitlab-org/gitlab/-/issues/446218).
## Import repositories

View File

@ -15,45 +15,17 @@ AI-assisted features in merge requests are designed to provide contextually rele
Additional information on enabling these features and maturity can be found in our [GitLab Duo overview](../../ai_features.md).
## Fill in merge request templates
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/10591) in GitLab 16.3 as an [Experiment](../../../policy/experiment-beta-support.md#experiment).
This feature is an [Experiment](../../../policy/experiment-beta-support.md) on GitLab.com.
Merge requests in projects often have [templates](../description_templates.md#create-a-merge-request-template) defined that need to be filled out. This helps reviewers and other users understand the purpose and changes a merge request might propose.
When creating a merge request, GitLab Duo can generate a description for the merge request based on the contents of the template. This fills in the template and replaces the current contents of the description.
To generate the description:
1. [Create a new merge request](creating_merge_requests.md), and go to the **Description** field.
1. Select **AI Actions** (**{tanuki}**).
1. Select **Fill in merge request template**.
The updated description is applied to the box. You can edit or revise this description before you finish creating your merge request.
Provide feedback on this experimental feature in [issue 416537](https://gitlab.com/gitlab-org/gitlab/-/issues/416537).
**Data usage**: When you use this feature, the following data is sent to the large language model referenced above:
- Title of the merge request
- Contents of the description
- Diff of changes between the source branch's head and the target branch
## Summarize merge request changes
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/10401) in GitLab 16.2 as an [Experiment](../../../policy/experiment-beta-support.md#experiment).
This feature is an [Experiment](../../../policy/experiment-beta-support.md) on GitLab.com.
This feature is in [Beta](../../../policy/experiment-beta-support.md) on GitLab.com.
GitLab Duo Merge request summaries are available on the merge request page in:
GitLab Duo Merge request summaries can be added to your merge request description when creating or editing a merge request. To add a summary, select **Summarize code changes**. The generated summary is added to the merge request description where your cursor is.
- The **Merge request summaries** dialog.
- The To-Do list.
- Email notifications.
![merge_request_ai_summary_v16_11](img/merge_request_ai_summary_v16_11.png)
Provide feedback on this experimental feature in [issue 408726](https://gitlab.com/gitlab-org/gitlab/-/issues/408726).
Provide feedback on this feature in [issue 443236](https://gitlab.com/gitlab-org/gitlab/-/issues/443236).
**Data usage**: The diff of changes between the source branch's head and the target branch is sent to the large language model.
@ -80,6 +52,33 @@ Provide feedback on this experimental feature in [issue 408991](https://gitlab.c
- Draft comment's text
## Fill in merge request templates
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/10591) in GitLab 16.3 as an [Experiment](../../../policy/experiment-beta-support.md#experiment).
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/429882) to Beta in GitLab 16.10
This feature is in [Beta](../../../policy/experiment-beta-support.md) on GitLab.com.
Merge requests in projects often have [templates](../description_templates.md#create-a-merge-request-template) defined that need to be filled out. This helps reviewers and other users understand the purpose and changes a merge request might propose.
When creating a merge request, GitLab Duo can generate a description for the merge request based on the contents of the template. This fills in the template and replaces the current contents of the description.
To generate the description:
1. [Create a new merge request](creating_merge_requests.md), and go to the **Description** field.
1. Select **AI Actions** (**{tanuki}**).
1. Select **Fill in merge request template**.
The updated description is applied to the box. You can edit or revise this description before you finish creating your merge request.
Provide feedback on this experimental feature in [issue 416537](https://gitlab.com/gitlab-org/gitlab/-/issues/416537).
**Data usage**: When you use this feature, the following data is sent to the large language model referenced above:
- Title of the merge request
- Contents of the description
- Diff of changes between the source branch's head and the target branch
## Generate messages for merge or squash commits
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/10453) in GitLab 16.2 as an [Experiment](../../../policy/experiment-beta-support.md#experiment).

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -64,13 +64,11 @@ module Banzai
next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS)
next unless node.content =~ TAGS_PATTERN
html = process_tag(Regexp.last_match(1))
html = node.content.gsub(TAGS_PATTERN) do
process_tag(Regexp.last_match(1)) || Regexp.last_match(0)
end
next unless html && html != node.content
new_node = Banzai::Filter::SanitizationFilter.new(html).call
new_node = new_node&.children&.first&.add_class('gfm')
node.replace(new_node.to_html) if new_node
node.replace(html)
end
doc
@ -105,12 +103,12 @@ module Banzai
path =
if url?(content)
content
elsif file = wiki.find_file(content, load_content: false)
elsif wiki && file = wiki.find_file(content, load_content: false)
file.path
end
if path
content_tag(:img, nil, src: path, class: 'gfm')
sanitized_content_tag(:img, nil, src: path, class: 'gfm')
end
end
@ -135,29 +133,48 @@ module Banzai
name, reference = *parts.compact.map(&:strip)
end
href =
if url?(reference)
reference
else
::File.join(wiki_base_path, reference)
end
class_list = 'gfm'
additional_data = {
'canonical-src': reference,
link: true,
gollum: true
}
content_tag(:a, name || reference, href: href, class: 'gfm')
if url?(reference)
href = reference
elsif wiki
href = ::File.join(wiki_base_path, reference)
class_list += " gfm-gollum-wiki-page"
additional_data['reference-type'] = 'wiki_page'
additional_data[:project] = context[:project].id if context[:project]
additional_data[:group] = context[:group]&.id if context[:group]
end
if href
sanitized_content_tag(:a, name || reference, href: href, class: class_list, data: additional_data)
end
end
def wiki
context[:wiki]
context[:wiki] || context[:project]&.wiki || context[:group]&.wiki
end
def wiki_base_path
wiki&.wiki_base_path
end
# Ensure that a :wiki key exists in context
#
# Note that while the key might exist, its value could be nil!
def validate
needs :wiki
def sanitized_content_tag(name, content, options = {})
html = content_tag(name, content, options)
node = Banzai::Filter::SanitizationFilter.new(html).call
link_node = node&.children&.first
link_node.add_class(options[:class])
options[:data]&.each do |key, value|
link_node.set_attribute("data-#{key}", value)
end
link_node
end
end
end

View File

@ -7,6 +7,7 @@ module Banzai
FilterArray[
Filter::AsciiDocSanitizationFilter,
Filter::CodeLanguageFilter,
Filter::GollumTagsFilter,
Filter::AssetProxyFilter,
Filter::ExternalLinkFilter,
Filter::PlantumlFilter,

View File

@ -17,6 +17,7 @@ module Banzai
Filter::SpacedLinkFilter,
Filter::SanitizationFilter,
Filter::KrokiFilter,
Filter::GollumTagsFilter,
Filter::AssetProxyFilter,
Filter::MathFilter,
Filter::ColorFilter,

View File

@ -4,8 +4,7 @@ module Banzai
module Pipeline
class WikiPipeline < FullPipeline
def self.filters
@filters ||= super.insert_before(Filter::ImageLazyLoadFilter, Filter::GollumTagsFilter)
.insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter)
@filters ||= super.insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter)
end
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Banzai
module ReferenceParser
class WikiPageParser < BaseParser
self.reference_type = :wiki_page
def nodes_visible_to_user(user, nodes)
project_attr = 'data-project'
group_attr = 'data-group'
projects = lazy { projects_for_nodes(nodes) }
groups = lazy { grouped_objects_for_nodes(nodes, Group, group_attr) }
preload_associations(projects, user)
nodes.select do |node|
if node.has_attribute?(project_attr)
can_read_reference?(user, projects[node], node)
elsif node.has_attribute?(group_attr)
can_read_reference?(user, groups[node], node)
else
true
end
end
end
private
def can_read_reference?(user, project_or_group, _node)
can?(user, :read_wiki, project_or_group)
end
end
end
end

View File

@ -67,6 +67,9 @@ module Banzai
original_content = node.attr('data-original')
original_content = CGI.escape_html(original_content) if original_content
# Redact gollum wiki links completely
redacted_content = _('[redacted]') if node.attr('data-reference-type') == 'wiki_page'
# Build the raw <a> tag just with a link as href and content if
# it's originally a link pattern. We shouldn't return a plain text href.
original_link =
@ -78,7 +81,7 @@ module Banzai
# The reference should be replaced by the original link's content,
# which is not always the same as the rendered one.
original_link || original_content || node.inner_html
redacted_content || original_link || original_content || node.inner_html
end
def redact_cross_project_references(documents)

View File

@ -44737,7 +44737,7 @@ msgstr ""
msgid "ScanResultPolicy|When %{scanType} in an open merge request targeting %{branches} %{branchExceptions} and the licenses match all of the following criteria:"
msgstr ""
msgid "ScanResultPolicy|When %{scanType} in an open that targets %{branches} %{branchExceptions} with %{commitType}"
msgid "ScanResultPolicy|When %{scanType} that targets %{branches} %{branchExceptions} with %{commitType}"
msgstr ""
msgid "ScanResultPolicy|When %{scanners} find scanner specified conditions in an open merge request targeting the %{branches} %{branchExceptions} and match %{boldDescription} of the following criteria"
@ -59295,6 +59295,9 @@ msgstr ""
msgid "[Supports GitLab-flavored markdown, including quick actions]"
msgstr ""
msgid "[redacted]"
msgstr ""
msgid "`.campfirenow.com` subdomain when you're signed in."
msgstr ""

View File

@ -11,7 +11,7 @@ gem 'capybara', '~> 3.40.0'
gem 'capybara-screenshot', '~> 1.0.26'
gem 'rake', '~> 13', '>= 13.1.0'
gem 'rspec', '~> 3.13'
gem 'selenium-webdriver', '= 4.18.1'
gem 'selenium-webdriver', '= 4.19.0'
gem 'airborne', '~> 0.3.7', require: false # airborne is messing with rspec sandboxed mode so not requiring by default
gem 'rest-client', '~> 2.1.0'
gem 'rspec-retry', '~> 0.6.2', require: 'rspec/retry'

View File

@ -304,7 +304,7 @@ GEM
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
selenium-webdriver (4.18.1)
selenium-webdriver (4.19.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
@ -379,7 +379,7 @@ DEPENDENCIES
rspec-retry (~> 0.6.2)
rspec_junit_formatter (~> 0.6.0)
ruby-debug-ide (~> 0.7.3)
selenium-webdriver (= 4.18.1)
selenium-webdriver (= 4.19.0)
slack-notifier (~> 2.4)
terminal-table (~> 3.0.2)
warning (~> 1.3)

View File

@ -48,7 +48,7 @@ module QA
end
it(
'publishes a composer package and deletes it',
'publishes a composer package and deletes it', :blocking,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348016'
) do
Page::Project::Menu.perform(&:go_to_package_registry)

View File

@ -287,6 +287,44 @@ RSpec.describe Projects::AutocompleteSourcesController do
end
end
describe 'GET wikis' do
before do
create(:wiki_page, project: project, title: 'foo')
create(:wiki_page, project: project, title: 'templates/template1')
end
context 'when user can read wiki pages' do
before do
group.add_owner(user)
sign_in(user)
end
it 'lists wiki pages (except templates)' do
get :wikis, format: :json, params: { namespace_id: group.path, project_id: project.path }
expect(json_response.pluck('title')).to eq(['foo'])
end
end
context 'when user cannot read wiki pages' do
let_it_be(:group2) { create(:group, :public) }
let_it_be(:project2) { create(:project, :public, namespace: group2) }
before do
create(:wiki_page, project: project2, title: 'foo')
# set wikis feature to members only
project2.project_feature.update!(wiki_access_level: ProjectFeature::PRIVATE)
end
it 'returns an empty list' do
get :wikis, format: :json, params: { namespace_id: group2.path, project_id: project2.path }
expect(json_response).to eq([])
end
end
end
describe 'GET contacts' do
let_it_be(:contact_1) { create(:contact, group: group) }
let_it_be(:contact_2) { create(:contact, group: group) }

View File

@ -254,6 +254,24 @@ RSpec.describe 'GFM autocomplete', :js, feature_category: :team_planning do
it_behaves_like 'autocomplete user mentions'
context 'autocomplete wiki pages' do
let_it_be(:wiki_page1) { create(:wiki_page, project: project, title: 'Home') }
let_it_be(:wiki_page2) { create(:wiki_page, project: project, title: 'How to use GitLab') }
it 'shows wiki pages in the autocomplete menu' do
fill_in 'Comment', with: '[[ho'
wait_for_requests
expect(find_autocomplete_menu).to have_text('Home')
expect(find_autocomplete_menu).to have_text('How to use GitLab (How-to-use-GitLab)')
send_keys [:arrow_down, :enter]
expect(find_field('Comment').value).to have_text('[[How to use GitLab|How-to-use-GitLab]]')
end
end
context 'when mention_autocomplete_backend_filtering is disabled' do
before do
stub_feature_flags(mention_autocomplete_backend_filtering: false)

View File

@ -78,6 +78,16 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
},
fieldValue: 'smiley',
};
const exampleWiki = {
title: 'Home',
slug: 'home',
path: '/path/to/project/-/wikis/home',
};
const exampleWiki2 = {
title: 'Changelog',
slug: 'docs/changelog',
path: '/path/to/project/-/wikis/docs/changelog',
};
const insertedEmojiProps = {
name: 'smiley',
@ -147,6 +157,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'project'} | ${'<strong class="gl-text-body!">Project</strong> creation QueryRecorder logs'}
${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'242'} | ${'<strong class="gl-text-body!">242</strong>0859'}
${'emoji'} | ${'emoji'} | ${exampleEmoji} | ${'sm'} | ${'<strong class="gl-text-body!">sm</strong>iley'}
${'wiki'} | ${'wiki'} | ${exampleWiki} | ${'home'} | ${'<strong class="gl-text-body!">Home</strong>'}
`(
'highlights query as bolded in $referenceType text',
({ nodeType, referenceType, reference, query, expectedHTML }) => {
@ -185,6 +196,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
${'reference'} | ${'vulnerability'} | ${'[vulnerability:'} | ${exampleVulnerability} | ${`[vulnerability:60850147]`} | ${{}}
${'reference'} | ${'snippet'} | ${'$'} | ${exampleSnippet} | ${`$2420859`} | ${{}}
${'emoji'} | ${'emoji'} | ${':'} | ${exampleEmoji} | ${`😃`} | ${insertedEmojiProps}
${'link'} | ${'wiki'} | ${'[['} | ${exampleWiki} | ${`Home`} | ${{ canonicalSrc: 'home', href: '/path/to/project/-/wikis/home', isGollumLink: true, isWikiPage: true }}
`(
'runs a command to insert the selected $referenceType',
async ({ char, nodeType, referenceType, reference, insertedText, insertedProps }) => {
@ -308,6 +320,40 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
});
});
describe('rendering wiki references', () => {
it('displays wiki title', () => {
buildWrapper({
propsData: {
char: '[[',
nodeType: 'link',
nodeProps: {
referenceType: 'wiki',
},
items: [exampleWiki],
},
});
expect(wrapper.text()).toContain(exampleWiki.title);
expect(wrapper.text()).not.toContain(exampleWiki.slug);
});
it('displays wiki slug if title is not the same as the slug', () => {
buildWrapper({
propsData: {
char: '[[',
nodeType: 'link',
nodeProps: {
referenceType: 'wiki',
},
items: [exampleWiki2],
},
});
expect(wrapper.text()).toContain(exampleWiki2.title);
expect(wrapper.text()).toContain(exampleWiki2.slug);
});
});
describe('rendering epic references', () => {
it('displays epic title and reference', () => {
buildWrapper({

View File

@ -254,3 +254,10 @@ Array [
"Cross Site Scripting (Persistent)",
]
`;
exports[`AutocompleteHelper for reference type "wiki", searches for "ho" correctly 1`] = `
Array [
"Home",
"How to use GitLab",
]
`;

View File

@ -17,6 +17,7 @@ import {
MOCK_MERGE_REQUESTS,
MOCK_ASSIGNEES,
MOCK_REVIEWERS,
MOCK_WIKIS,
} from './autocomplete_mock_data';
jest.mock('~/emoji', () => ({
@ -122,6 +123,7 @@ describe('AutocompleteHelper', () => {
mergeRequests: '/mergeRequests',
vulnerabilities: '/vulnerabilities',
commands: '/commands',
wikis: '/wikis',
};
mock.onGet('/members').reply(200, MOCK_MEMBERS);
@ -133,6 +135,7 @@ describe('AutocompleteHelper', () => {
mock.onGet('/mergeRequests').reply(200, MOCK_MERGE_REQUESTS);
mock.onGet('/vulnerabilities').reply(200, MOCK_VULNERABILITIES);
mock.onGet('/commands').reply(200, MOCK_COMMANDS);
mock.onGet('/wikis').reply(200, MOCK_WIKIS);
const sidebarMediator = {
store: {
@ -170,6 +173,7 @@ describe('AutocompleteHelper', () => {
${'merge_request'} | ${'n'}
${'vulnerability'} | ${'cross'}
${'command'} | ${'re'}
${'wiki'} | ${'ho'}
`(
'for reference type "$referenceType", searches for "$query" correctly',
async ({ referenceType, query }) => {

View File

@ -965,3 +965,21 @@ export const MOCK_COMMANDS = [
params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
},
];
export const MOCK_WIKIS = [
{
title: 'Home',
slug: 'home',
path: '/gitlab-org/gitlab-test/-/wikis/home',
},
{
title: 'How to use GitLab',
slug: 'how-to-use-gitlab',
path: '/gitlab-org/gitlab-test/-/wikis/how-to-use-gitlab',
},
{
title: 'Changelog',
slug: 'changelog',
path: '/gitlab-org/gitlab-test/-/wikis/changelog',
},
];

View File

@ -272,6 +272,31 @@ describe('markdownSerializer', () => {
).toBe('[GitLab][gitlab-url]');
});
it.each`
title | canonicalSrc | serialized
${'Usage'} | ${'usage'} | ${'[[Usage]]'}
${'Changelog'} | ${'docs/changelog'} | ${'[[Changelog|docs/changelog]]'}
`(
'correctly serializes a gollum (wiki) link: $serialized',
({ title, canonicalSrc, serialized }) => {
expect(
serialize(
paragraph(
link(
{
isGollumLink: true,
isWikiPage: true,
href: '/gitlab-org/gitlab-test/-/wikis/link/to/some/wiki/page',
canonicalSrc,
},
title,
),
),
),
).toBe(serialized);
},
);
it('correctly serializes image references', () => {
expect(
serialize(

View File

@ -23,6 +23,8 @@ import {
crmContactsMock,
} from 'ee_else_ce_jest/gfm_auto_complete/mock_data';
const mockSpriteIcons = '/icons.svg';
describe('escape', () => {
it.each`
xssPayload | escapedPayload
@ -55,6 +57,10 @@ describe('GfmAutoComplete', () => {
jest.runOnlyPendingTimers();
};
beforeEach(() => {
window.gon = { sprite_icons: mockSpriteIcons };
});
describe('DefaultOptions.filter', () => {
let items;
@ -577,7 +583,7 @@ describe('GfmAutoComplete', () => {
title: 'My Group',
search: 'MyGroup my-group',
icon:
'<svg class="s16 vertical-align-middle gl-ml-2"><use xlink:href="undefined#notifications-off" /></svg>',
'<svg class="s16 vertical-align-middle gl-ml-2"><use xlink:href="/icons.svg#notifications-off" /></svg>',
},
]);
});
@ -772,6 +778,54 @@ describe('GfmAutoComplete', () => {
});
});
describe('GfmAutoComplete.Wikis', () => {
const wikiPage1 = {
title: 'My Wiki Page',
slug: 'my-wiki-page',
path: '/path/to/project/-/wikis/my-wiki-page',
};
const wikiPage2 = {
title: 'Home',
slug: 'home',
path: '/path/to/project/-/wikis/home',
};
describe('templateFunction', () => {
it('shows both title and slug, if they are different', () => {
expect(GfmAutoComplete.Wikis.templateFunction(wikiPage1)).toMatchInlineSnapshot(`
<li>
<svg
class="gl-mr-2 s16 vertical-align-middle"
>
<use
xlink:href="/icons.svg#document"
/>
</svg>
My Wiki Page
<small>
(my-wiki-page)
</small>
</li>
`);
});
it('shows only title, if title and slug are the same', () => {
expect(GfmAutoComplete.Wikis.templateFunction(wikiPage2)).toMatchInlineSnapshot(`
<li>
<svg
class="gl-mr-2 s16 vertical-align-middle"
>
<use
xlink:href="/icons.svg#document"
/>
</svg>
Home
</li>
`);
});
});
});
describe('labels', () => {
const dataSources = {
labels: `${TEST_HOST}/autocomplete_sources/labels`,

View File

@ -452,7 +452,7 @@ RSpec.describe ApplicationHelper do
it 'returns paths for autocomplete_sources_controller' do
sources = helper.autocomplete_data_sources(project, noteable_type)
expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :contacts])
expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :contacts, :wikis])
sources.keys.each do |key|
expect(sources[key]).not_to be_nil
end

View File

@ -5,14 +5,9 @@ require 'spec_helper'
RSpec.describe Banzai::Filter::GollumTagsFilter, feature_category: :wiki do
include FilterSpecHelper
let(:project) { create(:project) }
let(:wiki) { ProjectWiki.new(project, nil) }
describe 'validation' do
it 'ensure that a :wiki key exists in context' do
expect { filter("See [[images/image.jpg]]", {}) }.to raise_error ArgumentError, "Missing context keys for Banzai::Filter::GollumTagsFilter: :wiki"
end
end
let_it_be(:project) { create(:project) }
let_it_be(:wiki) { create(:project_wiki, project: project) }
let_it_be(:group) { create(:group) }
context 'linking internal images' do
it 'creates img tag if image exists' do
@ -68,6 +63,13 @@ RSpec.describe Banzai::Filter::GollumTagsFilter, feature_category: :wiki do
expect(doc.at_css('a').text).to eq 'link-text'
expect(doc.at_css('a')['href']).to eq 'http://example.com/pdfs/gollum.pdf'
end
it 'does not add `gfm-gollum-wiki-page` class to the link' do
tag = '[[http://example.com]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('a')['class']).to eq 'gfm'
end
end
context 'linking internal resources' do
@ -78,6 +80,12 @@ RSpec.describe Banzai::Filter::GollumTagsFilter, feature_category: :wiki do
expect(doc.at_css('a').text).to eq 'wiki-slug'
expect(doc.at_css('a')['href']).to eq expected_path
expect(doc.at_css('a')['data-reference-type']).to eq 'wiki_page'
expect(doc.at_css('a')['data-canonical-src']).to eq 'wiki-slug'
expect(doc.at_css('a')['data-gollum']).to eq 'true'
expect(doc.at_css('a')['data-project']).to eq project.id.to_s
expect(doc.at_css('a')['data-group']).to be_nil
expect(doc.at_css('a')['class']).to eq 'gfm gfm-gollum-wiki-page'
end
it "the created link's text will be link-text" do
@ -89,24 +97,46 @@ RSpec.describe Banzai::Filter::GollumTagsFilter, feature_category: :wiki do
expect(doc.at_css('a')['href']).to eq expected_path
end
it "inside back ticks will be exempt from linkification" do
it 'inside back ticks will be exempt from linkification' do
doc = filter('<code>[[link-in-backticks]]</code>', wiki: wiki)
expect(doc.at_css('code').text).to eq '[[link-in-backticks]]'
end
it "sanitizes the href attribute (case 1)" do
tag = '[[a|http:\'"injected=attribute&gt;&lt;img/src="0"onerror="alert(0)"&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1]]'
doc = filter("See #{tag}", wiki: wiki)
it 'handles group wiki links' do
tag = '[[wiki-slug]]'
doc = filter("See #{tag}", project: nil, group: group, wiki: wiki)
expected_path = ::File.join(wiki.wiki_base_path, 'wiki-slug')
expect(doc.at_css('a').to_html).to eq '<a href="http:\'%22injected=attribute&gt;&lt;img/src=%220%22onerror=%22alert(0)%22&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1" class="gfm">a</a>'
end
it "sanitizes the href attribute (case 2)" do
tag = '<i>[[a|\'"&gt;&lt;svg&gt;&lt;i/class=gl-show-field-errors&gt;&lt;input/title="&lt;script&gt;alert(0)&lt;/script&gt;"/&gt;&lt;/svg&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('i a').to_html).to eq "<a href=\"#{wiki.wiki_base_path}/\'%22&gt;&lt;svg&gt;&lt;i/class=gl-show-field-errors&gt;&lt;input/title=%22&lt;script&gt;alert(0)&lt;/script&gt;%22/&gt;&lt;/svg&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1\" class=\"gfm\">a</a>"
expect(doc.at_css('a').text).to eq 'wiki-slug'
expect(doc.at_css('a')['href']).to eq expected_path
expect(doc.at_css('a')['data-reference-type']).to eq 'wiki_page'
expect(doc.at_css('a')['data-canonical-src']).to eq 'wiki-slug'
expect(doc.at_css('a')['data-gollum']).to eq 'true'
expect(doc.at_css('a')['data-project']).to be_nil
expect(doc.at_css('a')['data-group']).to eq group.id.to_s
expect(doc.at_css('a')['class']).to eq 'gfm gfm-gollum-wiki-page'
end
end
it 'adds `gfm-gollum-wiki-page` classes to the link' do
tag = '[[wiki-slug]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('a')['class']).to eq 'gfm gfm-gollum-wiki-page'
end
it 'sanitizes the href attribute (case 1)' do
tag = '[[a|http:\'"injected=attribute&gt;&lt;img/src="0"onerror="alert(0)"&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('a').to_html).to eq '<a href="http:\'%22injected=attribute&gt;&lt;img/src=%220%22onerror=%22alert(0)%22&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1" class="gfm" data-canonical-src="http:\'&quot;injected=attribute&gt;&lt;img/src=&quot;0&quot;onerror=&quot;alert(0)&quot;&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1" data-link="true" data-gollum="true">a</a>'
end
it 'sanitizes the href attribute (case 2)' do
tag = '<i>[[a|\'"&gt;&lt;svg&gt;&lt;i/class=gl-show-field-errors&gt;&lt;input/title="&lt;script&gt;alert(0)&lt;/script&gt;"/&gt;&lt;/svg&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('i a').to_html).to eq "<a href=\"#{wiki.wiki_base_path}/'%22&gt;&lt;svg&gt;&lt;i/class=gl-show-field-errors&gt;&lt;input/title=%22&lt;script&gt;alert(0)&lt;/script&gt;%22/&gt;&lt;/svg&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1\" class=\"gfm gfm-gollum-wiki-page\" data-canonical-src=\"'&quot;&gt;&lt;svg&gt;&lt;i/class=gl-show-field-errors&gt;&lt;input/title=&quot;&lt;script&gt;alert(0)&lt;/script&gt;&quot;/&gt;&lt;/svg&gt;https://gitlab.com/gitlab-org/gitlab/-/issues/1\" data-link=\"true\" data-gollum=\"true\" data-reference-type=\"wiki_page\" data-project=\"#{project.id}\">a</a>"
end
end

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Banzai::ReferenceParser::WikiPageParser, feature_category: :team_planning do
include ReferenceParserHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let(:link) { empty_html_link }
describe '#nodes_visible_to_user' do
before do
link['data-reference-type'] = 'wiki_page'
end
context 'when the link has a data-project attribute' do
before do
link['data-project'] = project.id
end
it 'redacts the link if the user cannot read the project' do
nodes = described_class
.new(Banzai::RenderContext.new(project, user))
.nodes_visible_to_user(user, [link])
expect(nodes).to be_empty
end
end
context 'when the link has a data-group attribute' do
before do
link['data-group'] = group.id
end
it 'redacts the link if the user cannot read the group' do
nodes = described_class
.new(Banzai::RenderContext.new(group, user))
.nodes_visible_to_user(user, [link])
expect(nodes).to be_empty
end
end
context 'if no data-project or data-group attribute is present' do
it 'returns the link' do
nodes = described_class
.new(Banzai::RenderContext.new(project, user))
.nodes_visible_to_user(user, [link])
expect(nodes).to eq([link])
end
end
end
end

View File

@ -103,6 +103,18 @@ RSpec.describe Banzai::ReferenceRedactor, feature_category: :team_planning do
expect(doc2.to_html).to eq(doc2_html)
end
end
context 'when reference is a gollum wiki page link that is not visible to user' do
it 'redacts the wiki page title and href' do
doc = Nokogiri::HTML.fragment('<a class="gfm" href="https://gitlab.com/path/to/project/-/wikis/foo" data-reference-type="wiki_page" data-gollum="true">foo</a>')
expect(redactor).to receive(:nodes_visible_to_user).and_return([])
redactor.redact([doc])
expect(doc.to_html).to eq('[redacted]')
end
end
end
context 'when the user cannot read cross project' do

View File

@ -821,6 +821,8 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team
"uploading": false,
"href": "/uploads/groups-test-file",
"title": null,
"isGollumLink": false,
"isWikiPage": false,
"canonicalSrc": "/uploads/groups-test-file",
"isReference": false
}
@ -848,6 +850,8 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team
"uploading": false,
"href": "projects-test-file",
"title": null,
"isGollumLink": false,
"isWikiPage": false,
"canonicalSrc": "projects-test-file",
"isReference": false
}
@ -905,6 +909,8 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team
"uploading": false,
"href": "project-wikis-test-file",
"title": null,
"isGollumLink": false,
"isWikiPage": false,
"canonicalSrc": "project-wikis-test-file",
"isReference": false
}

View File

@ -153,6 +153,39 @@ RSpec.describe Projects::AutocompleteService, feature_category: :groups_and_proj
end
end
describe '#wikis' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:wiki) { create(:project_wiki, project: project) }
before do
create(:wiki_page, wiki: wiki, title: 'page1', content: 'content1')
create(:wiki_page, wiki: wiki, title: 'templates/page2', content: 'content2')
end
context 'when user can read wiki' do
it 'returns wiki pages (except templates)' do
service = described_class.new(project, user)
results = service.wikis
expect(results.size).to eq(1)
expect(results.first).to include(path: "/#{project.full_path}/-/wikis/page1", slug: 'page1', title: 'page1')
end
end
context 'when user cannot read wiki' do
it 'returns empty array' do
project.project_feature.update!(wiki_access_level: ProjectFeature::PRIVATE)
service = described_class.new(project, nil)
results = service.wikis
expect(results).to be_empty
end
end
end
describe '#contacts' do
let_it_be(:user) { create(:user) }
let_it_be(:contact_1) { create(:contact, group: group) }

View File

@ -17,6 +17,7 @@ RSpec.shared_examples 'rich text editor - autocomplete' do |params = {
create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request')
create(:label, project: project, title: 'My Cool Label')
create(:milestone, project: project, title: 'My Cool Milestone')
create(:wiki_page, wiki: project.wiki, title: 'My Cool Wiki Page', content: 'Example')
project.add_maintainer(create(:user, name: 'abc123', username: 'abc123'))
else # group wikis
@ -26,6 +27,7 @@ RSpec.shared_examples 'rich text editor - autocomplete' do |params = {
create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request')
create(:group_label, group: group, title: 'My Cool Label')
create(:milestone, group: group, title: 'My Cool Milestone')
create(:wiki_page, wiki: group.wiki, title: 'My Cool Wiki Page', content: 'Example')
project.add_maintainer(create(:user, name: 'abc123', username: 'abc123'))
end
@ -232,6 +234,23 @@ RSpec.shared_examples 'rich text editor - autocomplete' do |params = {
expect(page).to have_text('😄')
end
it 'shows suggestions for wiki pages' do
type_in_content_editor '[[My'
expect(find(suggestions_dropdown)).to have_text('My Cool Wiki Page')
send_keys :enter
expect(page).not_to have_css(suggestions_dropdown)
expect(page).to have_text('My Cool Wiki Page')
click_button 'Switch to plain text editing'
wait_for_requests
# ensure serialized markdown is in correct format (gollum/wikilinks)
expect(page.find('textarea').value).to include('[[My Cool Wiki Page|My-Cool-Wiki-Page]]')
end
it 'doesn\'t show suggestions dropdown if there are no suggestions to show' do
type_in_content_editor '%'

View File

@ -7,6 +7,7 @@ RSpec.shared_examples 'autocompletes items' do
create(:merge_request, source_project: project, title: 'My Cool Merge Request')
create(:label, project: project, title: 'My Cool Label')
create(:milestone, project: project, title: 'My Cool Milestone')
create(:wiki_page, wiki: project.wiki, title: 'My Cool Wiki Page', content: 'Example')
project.add_maintainer(create(:user, name: 'JohnDoe123'))
project.add_maintainer(create(:user, name: 'ReallyLongUsername1234567890'))
@ -17,6 +18,7 @@ RSpec.shared_examples 'autocompletes items' do
create(:merge_request, source_project: project, title: 'My Cool Merge Request')
create(:group_label, group: group, title: 'My Cool Label')
create(:milestone, group: group, title: 'My Cool Milestone')
create(:wiki_page, wiki: group.wiki, title: 'My Cool Wiki Page', content: 'Example')
project.add_maintainer(create(:user, name: 'JohnDoe123'))
project.add_maintainer(create(:user, name: 'ReallyLongUsername1234567890'))
@ -41,6 +43,9 @@ RSpec.shared_examples 'autocompletes items' do
fill_in :wiki_content, with: ':smil'
expect(page).to have_text 'smile_cat'
fill_in :wiki_content, with: '[[My'
expect(page).to have_text 'My Cool Wiki Page'
end
it 'autocompletes items with long names' do