diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 62066973ee64d5da019257ed83ee894c2afa41b7..43e114a91d320abb305ef71d84b9e412c002b224 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -111,7 +111,7 @@ export default { </script> <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" /> <template v-else-if="commit"> <user-avatar-link diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 45e026ad69560dd674a09e5b2722183791ce2d78..197b19387cf52402cfc95a916b0708f752c8ea31 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -188,5 +188,5 @@ export default function setupVueRepositoryList() { }, }); - return { router, data: dataset }; + return { router, data: dataset, apolloProvider, projectPath }; } diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index cd4993ea86ddd6bf980c7c6d9b9fc782b006d6e7..3265c14bdca9134a6edccfe5dfd6dd66e4eda2cb 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -71,6 +71,10 @@ class BlobType < BaseObject field :pipeline_editor_path, GraphQL::Types::String, null: true, 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, description: 'Expected format of the blob based on the extension.' @@ -104,3 +108,5 @@ def lfs_oid end end end + +Types::Repository::BlobType.prepend_mod_with('Types::Repository::BlobType') diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 3555c6c3d0cbf80b2c55ce667a2d459a47bd6a34..b310f8fff156cd15a170dac369df8d59fdbf2d21 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -66,6 +66,11 @@ def pipeline_editor_path 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 + # Will be overridden in EE + def code_owners + [] + end + def fork_and_edit_path fork_path_for_current_user(project, edit_blob_path) end @@ -147,3 +152,5 @@ def transformed_blob_data blob.data end end + +BlobPresenter.prepend_mod_with('BlobPresenter') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index cdcc98552f90b93eeedb7311324373aebc31dde1..2f4a61865f85e7f9646c8a58a5af57244725ed3b 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -10,10 +10,11 @@ .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch = render 'projects/tree/tree_header', tree: @tree - #js-last-commit - .info-well.gl-display-none.gl-sm-display-flex.project-last-commit + .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column + #js-last-commit.gl-m-auto .gl-spinner-container.m-auto = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') + #js-code-owners - if is_project_overview .project-buttons.gl-mb-3.js-show-on-project-root diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 92a37a800a77fd6e2a81d7f7adb102f1895c2c4e..bf8cf7dcd735df356c56d9ea6053dde2e722f360 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -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="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="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. | diff --git a/ee/app/assets/javascripts/repository/components/code_owners.js b/ee/app/assets/javascripts/repository/components/code_owners.js new file mode 100644 index 0000000000000000000000000000000000000000..cc3032a6062920ad794e83ebf98054ef35a8aee5 --- /dev/null +++ b/ee/app/assets/javascripts/repository/components/code_owners.js @@ -0,0 +1,17 @@ +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, + }, + }); + }, + }); diff --git a/ee/app/assets/javascripts/repository/components/code_owners.vue b/ee/app/assets/javascripts/repository/components/code_owners.vue new file mode 100644 index 0000000000000000000000000000000000000000..095ee84bbf844f58d53391d689158017c5a6517f --- /dev/null +++ b/ee/app/assets/javascripts/repository/components/code_owners.vue @@ -0,0 +1,127 @@ +<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> diff --git a/ee/app/assets/javascripts/repository/index.js b/ee/app/assets/javascripts/repository/index.js index cad74fbf927c53f506883d0f459fccc578b597ac..a0fe6294f0417662a3077141c866614816cd7df4 100644 --- a/ee/app/assets/javascripts/repository/index.js +++ b/ee/app/assets/javascripts/repository/index.js @@ -1,10 +1,12 @@ +import Vue from 'vue'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import initTree from '~/repository'; +import CodeOwners from './components/code_owners.vue'; export default () => { - const { router, data } = initTree(); + const { router, data, apolloProvider, projectPath } = initTree(); if (data.pathLocksAvailable) { const toggleBtn = document.querySelector('a.js-path-lock'); @@ -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(); }; diff --git a/ee/app/assets/javascripts/repository/queries/code_owners_info.query.graphql b/ee/app/assets/javascripts/repository/queries/code_owners_info.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..86349eea98718e004cac9b7e18b54e3b37fcd19e --- /dev/null +++ b/ee/app/assets/javascripts/repository/queries/code_owners_info.query.graphql @@ -0,0 +1,17 @@ +query getCodeOwnersInfo($projectPath: ID!, $filePath: String!, $ref: String!) { + project(fullPath: $projectPath) { + id + repository { + blobs(paths: [$filePath], ref: $ref) { + nodes { + id + codeOwners { + id + name + webPath + } + } + } + } + } +} diff --git a/ee/app/graphql/ee/types/repository/blob_type.rb b/ee/app/graphql/ee/types/repository/blob_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..5a03e6a0d35cafda80f2f0a4cd6ab8c44cbf9e79 --- /dev/null +++ b/ee/app/graphql/ee/types/repository/blob_type.rb @@ -0,0 +1,16 @@ +# 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 diff --git a/ee/app/presenters/ee/blob_presenter.rb b/ee/app/presenters/ee/blob_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..e9976c07c8e391ad2216788521ab231b2f868692 --- /dev/null +++ b/ee/app/presenters/ee/blob_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module EE + module BlobPresenter + def code_owners + ::Gitlab::CodeOwners.for_blob(project, blob) + end + end +end diff --git a/ee/spec/frontend/repository/components/__snapshots__/code_owners_spec.js.snap b/ee/spec/frontend/repository/components/__snapshots__/code_owners_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..06c01970afcb2b24f94cebeccd53d0f04dc385db --- /dev/null +++ b/ee/spec/frontend/repository/components/__snapshots__/code_owners_spec.js.snap @@ -0,0 +1,220 @@ +// 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> +`; diff --git a/ee/spec/frontend/repository/components/code_owners_spec.js b/ee/spec/frontend/repository/components/code_owners_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8184e41d9df374ea5d1d8d7080f88ddd5abeb9d1 --- /dev/null +++ b/ee/spec/frontend/repository/components/code_owners_spec.js @@ -0,0 +1,89 @@ +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(); + }); +}); diff --git a/ee/spec/frontend/repository/mock_data.js b/ee/spec/frontend/repository/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..a54a7e22e1b83da85c1a1e58669f6b70e7db00a2 --- /dev/null +++ b/ee/spec/frontend/repository/mock_data.js @@ -0,0 +1,16 @@ +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: [], + }, + ], + }, + }, +}; diff --git a/ee/spec/presenters/ee/blob_presenter_spec.rb b/ee/spec/presenters/ee/blob_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..399cb569f1bb69080c8f20a708eb1165f5319a94 --- /dev/null +++ b/ee/spec/presenters/ee/blob_presenter_spec.rb @@ -0,0 +1,20 @@ +# 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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cb3d94c246d9d095c9b3b6095afe6103873ec1a8..67e2b2ce671b1aadb05b9d7f759a50f68393ccff 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3758,6 +3758,9 @@ msgstr "" msgid "An error occurred while loading chart data" msgstr "" +msgid "An error occurred while loading code owners." +msgstr "" + msgid "An error occurred while loading commit signatures" msgstr "" diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index be4f8a688e0b86525587310eb385fd01d196729d..fe2247420ed920ac98cd3c9e20ab4f5da1ab8c41 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -2,7 +2,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` <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 class="avatar-cell" @@ -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`] = ` <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 class="avatar-cell" diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb index 968cb026401a1b8697046050ba52b673cf485fdc..21bc88e34c0f34f8ca8ad367555e71979a820324 100644 --- a/spec/graphql/types/repository/blob_type_spec.rb +++ b/spec/graphql/types/repository/blob_type_spec.rb @@ -24,6 +24,7 @@ :raw_path, :replace_path, :pipeline_editor_path, + :code_owners, :simple_viewer, :rich_viewer, :plain_data, diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb index cd38e74e0eaf339d71929056937f5d3437677af2..6dbcb5cace74b202140acf42476b555fa52f153d 100644 --- a/spec/presenters/blob_presenter_spec.rb +++ b/spec/presenters/blob_presenter_spec.rb @@ -67,6 +67,10 @@ end end + describe '#code_owners' do + it { expect(presenter.code_owners).to match_array([]) } + end + describe '#ide_edit_path' do it { expect(presenter.ide_edit_path).to eq("/-/ide/project/#{project.full_path}/edit/HEAD/-/files/ruby/regex.rb") } end