Commit f6e1b8cb authored by Etienne Baqué's avatar Etienne Baqué

Merge branch '335378-move-code-owners-to-ee' into 'master'

Display code owners info (move code to EE)

See merge request gitlab-org/gitlab!75407
parents 113dc497 97bb4e86
...@@ -111,7 +111,7 @@ export default { ...@@ -111,7 +111,7 @@ export default {
</script> </script>
<template> <template>
<div class="info-well d-none d-sm-flex project-last-commit commit p-3"> <div class="well-segment commit gl-p-5 gl-w-full">
<gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" />
<template v-else-if="commit"> <template v-else-if="commit">
<user-avatar-link <user-avatar-link
......
...@@ -188,5 +188,5 @@ export default function setupVueRepositoryList() { ...@@ -188,5 +188,5 @@ export default function setupVueRepositoryList() {
}, },
}); });
return { router, data: dataset }; return { router, data: dataset, apolloProvider, projectPath };
} }
...@@ -71,6 +71,10 @@ module Types ...@@ -71,6 +71,10 @@ module Types
field :pipeline_editor_path, GraphQL::Types::String, null: true, field :pipeline_editor_path, GraphQL::Types::String, null: true,
description: 'Web path to edit .gitlab-ci.yml file.' description: 'Web path to edit .gitlab-ci.yml file.'
field :code_owners, [Types::UserType], null: true,
description: 'List of code owners for the blob.',
calls_gitaly: true
field :file_type, GraphQL::Types::String, null: true, field :file_type, GraphQL::Types::String, null: true,
description: 'Expected format of the blob based on the extension.' description: 'Expected format of the blob based on the extension.'
...@@ -104,3 +108,5 @@ module Types ...@@ -104,3 +108,5 @@ module Types
end end
end end
end end
Types::Repository::BlobType.prepend_mod_with('Types::Repository::BlobType')
...@@ -66,6 +66,11 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ...@@ -66,6 +66,11 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default
end end
# Will be overridden in EE
def code_owners
[]
end
def fork_and_edit_path def fork_and_edit_path
fork_path_for_current_user(project, edit_blob_path) fork_path_for_current_user(project, edit_blob_path)
end end
...@@ -147,3 +152,5 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ...@@ -147,3 +152,5 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
blob.data blob.data
end end
end end
BlobPresenter.prepend_mod_with('BlobPresenter')
...@@ -10,10 +10,11 @@ ...@@ -10,10 +10,11 @@
.nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch
= render 'projects/tree/tree_header', tree: @tree = render 'projects/tree/tree_header', tree: @tree
#js-last-commit .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column
.info-well.gl-display-none.gl-sm-display-flex.project-last-commit #js-last-commit.gl-m-auto
.gl-spinner-container.m-auto .gl-spinner-container.m-auto
= loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom')
#js-code-owners
- if is_project_overview - if is_project_overview
.project-buttons.gl-mb-3.js-show-on-project-root .project-buttons.gl-mb-3.js-show-on-project-root
......
...@@ -14142,6 +14142,7 @@ Returns [`Tree`](#tree). ...@@ -14142,6 +14142,7 @@ Returns [`Tree`](#tree).
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="repositoryblobcancurrentuserpushtobranch"></a>`canCurrentUserPushToBranch` | [`Boolean`](#boolean) | Whether the current user can push to the branch. | | <a id="repositoryblobcancurrentuserpushtobranch"></a>`canCurrentUserPushToBranch` | [`Boolean`](#boolean) | Whether the current user can push to the branch. |
| <a id="repositoryblobcanmodifyblob"></a>`canModifyBlob` | [`Boolean`](#boolean) | Whether the current user can modify the blob. | | <a id="repositoryblobcanmodifyblob"></a>`canModifyBlob` | [`Boolean`](#boolean) | Whether the current user can modify the blob. |
| <a id="repositoryblobcodeowners"></a>`codeOwners` | [`[UserCore!]`](#usercore) | List of code owners for the blob. |
| <a id="repositoryblobeditblobpath"></a>`editBlobPath` | [`String`](#string) | Web path to edit the blob in the old-style editor. | | <a id="repositoryblobeditblobpath"></a>`editBlobPath` | [`String`](#string) | Web path to edit the blob in the old-style editor. |
| <a id="repositoryblobexternalstorageurl"></a>`externalStorageUrl` | [`String`](#string) | Web path to download the raw blob via external storage, if enabled. | | <a id="repositoryblobexternalstorageurl"></a>`externalStorageUrl` | [`String`](#string) | Web path to download the raw blob via external storage, if enabled. |
| <a id="repositoryblobfiletype"></a>`fileType` | [`String`](#string) | Expected format of the blob based on the extension. | | <a id="repositoryblobfiletype"></a>`fileType` | [`String`](#string) | Expected format of the blob based on the extension. |
import Vue from 'vue';
import CodeOwners from './code_owners.vue';
export default (projectPath, router, apolloProvider) =>
new Vue({
el: document.getElementById('js-code-owners'),
router,
apolloProvider,
render(h) {
return h(CodeOwners, {
props: {
filePath: this.$route.params.path,
projectPath,
},
});
},
});
<script>
import { GlIcon, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { helpPagePath } from '~/helpers/help_page_helper';
import getRefMixin from '~/repository/mixins/get_ref';
import codeOwnersInfoQuery from '../queries/code_owners_info.query.graphql';
export default {
i18n: {
title: __('Code owners'),
about: __('About this feature'),
andSeparator: __('and'),
errorMessage: __('An error occurred while loading code owners.'),
},
codeOwnersHelpPath: helpPagePath('user/project/code_owners'),
components: {
GlIcon,
GlLink,
},
mixins: [getRefMixin],
apollo: {
project: {
query: codeOwnersInfoQuery,
variables() {
return {
projectPath: this.projectPath,
filePath: this.filePath,
ref: this.ref,
};
},
skip() {
return !this.filePath;
},
result() {
this.isFetching = false;
},
error() {
createFlash({ message: this.$options.i18n.errorMessage });
},
},
},
props: {
projectPath: {
type: String,
required: true,
},
filePath: {
type: String,
required: false,
default: null,
},
},
data() {
return {
isFetching: false,
project: {
repository: {
blobs: {
nodes: [
{
codeOwners: [],
},
],
},
},
},
};
},
computed: {
blobInfo() {
return this.project?.repository?.blobs?.nodes[0];
},
codeOwners() {
return this.blobInfo?.codeOwners || [];
},
hasCodeOwners() {
return this.filePath && Boolean(this.codeOwners.length);
},
commaSeparateList() {
return this.codeOwners.length > 2;
},
showAndSeparator() {
return this.codeOwners.length > 1;
},
lastListItem() {
return this.codeOwners.length - 1;
},
},
watch: {
filePath() {
this.isFetching = true;
this.$apollo.queries.project.refetch();
},
},
};
</script>
<template>
<div
v-if="hasCodeOwners && !isFetching"
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon name="users" data-testid="users-icon" />
<strong>{{ $options.i18n.title }}</strong>
<gl-link :href="$options.codeOwnersHelpPath" target="_blank" :title="$options.i18n.about">
<gl-icon name="question-o" data-testid="help-icon" />
</gl-link>
:
<div
v-for="(owner, index) in codeOwners"
:key="index"
:class="[
{ 'gl-display-inline-block': commaSeparateList, 'gl-display-inline': !commaSeparateList },
]"
data-testid="code-owners"
>
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
<span v-if="showAndSeparator && index === lastListItem" data-testid="and-separator">{{
$options.i18n.andSeparator
}}</span>
<gl-link :href="owner.webPath" target="_blank" :title="$options.i18n.about">
{{ owner.name }}
</gl-link>
</div>
</div>
</template>
import Vue from 'vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import initTree from '~/repository'; import initTree from '~/repository';
import CodeOwners from './components/code_owners.vue';
export default () => { export default () => {
const { router, data } = initTree(); const { router, data, apolloProvider, projectPath } = initTree();
if (data.pathLocksAvailable) { if (data.pathLocksAvailable) {
const toggleBtn = document.querySelector('a.js-path-lock'); const toggleBtn = document.querySelector('a.js-path-lock');
...@@ -40,4 +42,21 @@ export default () => { ...@@ -40,4 +42,21 @@ export default () => {
}); });
}); });
} }
const initCodeOwnersApp = () =>
new Vue({
el: document.getElementById('js-code-owners'),
router,
apolloProvider,
render(h) {
return h(CodeOwners, {
props: {
filePath: this.$route.params.path,
projectPath,
},
});
},
});
initCodeOwnersApp();
}; };
query getCodeOwnersInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
project(fullPath: $projectPath) {
id
repository {
blobs(paths: [$filePath], ref: $ref) {
nodes {
id
codeOwners {
id
name
webPath
}
}
}
}
}
}
# frozen_string_literal: true
module EE
module Types
module Repository
module BlobType
extend ActiveSupport::Concern
prepended do
field :code_owners, [::Types::UserType], null: true,
description: 'List of code owners for the blob.',
calls_gitaly: true
end
end
end
end
end
# frozen_string_literal: true
module EE
module BlobPresenter
def code_owners
::Gitlab::CodeOwners.for_blob(project, blob)
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Code owners component matches the snapshot 1`] = `<!---->`;
exports[`Code owners component matches the snapshot 2`] = `
<div
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon-stub
data-testid="users-icon"
name="users"
size="16"
/>
<strong>
Code owners
</strong>
<gl-link-stub
href="/help/user/project/code_owners"
target="_blank"
title="About this feature"
>
<gl-icon-stub
data-testid="help-icon"
name="question-o"
size="16"
/>
</gl-link-stub>
:
<div
class="gl-display-inline"
data-testid="code-owners"
>
<!---->
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
</div>
`;
exports[`Code owners component matches the snapshot 3`] = `
<div
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon-stub
data-testid="users-icon"
name="users"
size="16"
/>
<strong>
Code owners
</strong>
<gl-link-stub
href="/help/user/project/code_owners"
target="_blank"
title="About this feature"
>
<gl-icon-stub
data-testid="help-icon"
name="question-o"
size="16"
/>
</gl-link-stub>
:
<div
class="gl-display-inline"
data-testid="code-owners"
>
<!---->
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
<div
class="gl-display-inline"
data-testid="code-owners"
>
<!---->
<span
data-testid="and-separator"
>
and
</span>
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
</div>
`;
exports[`Code owners component matches the snapshot 4`] = `
<div
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon-stub
data-testid="users-icon"
name="users"
size="16"
/>
<strong>
Code owners
</strong>
<gl-link-stub
href="/help/user/project/code_owners"
target="_blank"
title="About this feature"
>
<gl-icon-stub
data-testid="help-icon"
name="question-o"
size="16"
/>
</gl-link-stub>
:
<div
class="gl-display-inline-block"
data-testid="code-owners"
>
<!---->
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
<div
class="gl-display-inline-block"
data-testid="code-owners"
>
<span
data-testid="comma-separator"
>
,
</span>
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
<div
class="gl-display-inline-block"
data-testid="code-owners"
>
<span
data-testid="comma-separator"
>
,
</span>
<span
data-testid="and-separator"
>
and
</span>
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
</div>
`;
import { GlLink } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CodeOwners from 'ee_component/repository/components/code_owners.vue';
import codeOwnersInfoQuery from 'ee/repository/queries/code_owners_info.query.graphql';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { codeOwnerMock, codeOwnersDataMock, refMock } from '../mock_data';
let wrapper;
let mockResolver;
const localVue = createLocalVue();
const createComponent = async (codeOwners = [codeOwnerMock]) => {
localVue.use(VueApollo);
const project = {
...codeOwnersDataMock,
repository: {
blobs: {
nodes: [{ id: '345', codeOwners }],
},
},
};
mockResolver = jest.fn().mockResolvedValue({ data: { project } });
wrapper = extendedWrapper(
shallowMount(CodeOwners, {
localVue,
apolloProvider: createMockApollo([[codeOwnersInfoQuery, mockResolver]]),
propsData: { projectPath: 'some/project', filePath: 'some/file' },
mixins: [{ data: () => ({ ref: refMock }) }],
}),
);
wrapper.setData({ isFetching: false });
await waitForPromises();
};
describe('Code owners component', () => {
const findHelpIcon = () => wrapper.findByTestId('help-icon');
const findUsersIcon = () => wrapper.findByTestId('users-icon');
const findCodeOwners = () => wrapper.findAllByTestId('code-owners');
const findCommaSeparators = () => wrapper.findAllByTestId('comma-separator');
const findAndSeparator = () => wrapper.findAllByTestId('and-separator');
const findLink = () => wrapper.findComponent(GlLink);
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
describe('help link', () => {
it('renders a GlLink component', () => {
expect(findLink().exists()).toBe(true);
expect(findLink().attributes('href')).toBe('/help/user/project/code_owners');
expect(findLink().attributes('target')).toBe('_blank');
expect(findLink().attributes('title')).toBe('About this feature');
});
it('renders a Help icon', () => {
expect(findHelpIcon().exists()).toBe(true);
expect(findHelpIcon().props('name')).toBe('question-o');
});
});
it('renders a Users icon', () => {
expect(findUsersIcon().exists()).toBe(true);
expect(findUsersIcon().props('name')).toBe('users');
});
it.each`
codeOwners | commaSeparators | hasAndSeparator
${[]} | ${0} | ${false}
${[codeOwnerMock]} | ${0} | ${false}
${[codeOwnerMock, codeOwnerMock]} | ${0} | ${true}
${[codeOwnerMock, codeOwnerMock, codeOwnerMock]} | ${2} | ${true}
`('matches the snapshot', async ({ codeOwners, commaSeparators, hasAndSeparator }) => {
await createComponent(codeOwners);
expect(findCommaSeparators().length).toBe(commaSeparators);
expect(findAndSeparator().exists()).toBe(hasAndSeparator);
expect(findCodeOwners().length).toBe(codeOwners.length);
expect(wrapper.element).toMatchSnapshot();
});
});
export const refMock = 'default-ref';
export const codeOwnerMock = { id: '8765', name: 'John Doe', webPath: 'path/to/@johnDoe' };
export const codeOwnersDataMock = {
id: '1234',
repository: {
blobs: {
nodes: [
{
codeOwners: [],
},
],
},
},
};
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BlobPresenter do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.owner }
let(:blob) { project.repository.blob_at('HEAD', 'files/ruby/regex.rb') }
subject(:presenter) { described_class.new(blob, current_user: user) }
describe '#code_owners' do
before do
allow(Gitlab::CodeOwners).to receive(:for_blob).with(project, blob).and_return([user])
end
it { expect(presenter.code_owners).to match_array([user]) }
end
end
...@@ -3758,6 +3758,9 @@ msgstr "" ...@@ -3758,6 +3758,9 @@ msgstr ""
msgid "An error occurred while loading chart data" msgid "An error occurred while loading chart data"
msgstr "" msgstr ""
msgid "An error occurred while loading code owners."
msgstr ""
msgid "An error occurred while loading commit signatures" msgid "An error occurred while loading commit signatures"
msgstr "" msgstr ""
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
exports[`Repository last commit component renders commit widget 1`] = ` exports[`Repository last commit component renders commit widget 1`] = `
<div <div
class="info-well d-none d-sm-flex project-last-commit commit p-3" class="well-segment commit gl-p-5 gl-w-full"
> >
<user-avatar-link-stub <user-avatar-link-stub
class="avatar-cell" class="avatar-cell"
...@@ -108,7 +108,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` ...@@ -108,7 +108,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = ` exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = `
<div <div
class="info-well d-none d-sm-flex project-last-commit commit p-3" class="well-segment commit gl-p-5 gl-w-full"
> >
<user-avatar-link-stub <user-avatar-link-stub
class="avatar-cell" class="avatar-cell"
......
...@@ -24,6 +24,7 @@ RSpec.describe Types::Repository::BlobType do ...@@ -24,6 +24,7 @@ RSpec.describe Types::Repository::BlobType do
:raw_path, :raw_path,
:replace_path, :replace_path,
:pipeline_editor_path, :pipeline_editor_path,
:code_owners,
:simple_viewer, :simple_viewer,
:rich_viewer, :rich_viewer,
:plain_data, :plain_data,
......
...@@ -67,6 +67,10 @@ RSpec.describe BlobPresenter do ...@@ -67,6 +67,10 @@ RSpec.describe BlobPresenter do
end end
end end
describe '#code_owners' do
it { expect(presenter.code_owners).to match_array([]) }
end
describe '#ide_edit_path' do describe '#ide_edit_path' do
it { expect(presenter.ide_edit_path).to eq("/-/ide/project/#{project.full_path}/edit/HEAD/-/files/ruby/regex.rb") } it { expect(presenter.ide_edit_path).to eq("/-/ide/project/#{project.full_path}/edit/HEAD/-/files/ruby/regex.rb") }
end end
......
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