Commit 124cece3 authored by George Tsiolis's avatar George Tsiolis Committed by Nick Thomas

Include private contributions in user contribution graph

parent 272281e4
......@@ -101,6 +101,7 @@ class ProfilesController < Profiles::ApplicationController
:organization,
:preferred_language,
:private_profile,
:include_private_contributions,
status: [:emoji, :message]
)
end
......
......@@ -48,20 +48,6 @@ class UserRecentEventsFinder
end
def projects
# Compile a list of projects `current_user` interacted with
# and `target_user` is allowed to see.
authorized = target_user
.project_interactions
.joins(:project_authorizations)
.where(project_authorizations: { user: current_user })
.select(:id)
visible = target_user
.project_interactions
.where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(current_user))
.select(:id)
Gitlab::SQL::Union.new([authorized, visible]).to_sql
target_user.project_interactions.to_sql
end
end
......@@ -19,7 +19,7 @@ module EventsHelper
name = self_added ? 'You' : author.name
link_to name, user_path(author.username), title: name
else
event.author_name
escape_once(event.author_name)
end
end
......
......@@ -151,15 +151,17 @@ class Event < ActiveRecord::Base
if push? || commit_note?
Ability.allowed?(user, :download_code, project)
elsif membership_changed?
true
Ability.allowed?(user, :read_project, project)
elsif created_project?
true
Ability.allowed?(user, :read_project, project)
elsif issue? || issue_note?
Ability.allowed?(user, :read_issue, note? ? note_target : target)
elsif merge_request? || merge_request_note?
Ability.allowed?(user, :read_merge_request, note? ? note_target : target)
elsif milestone?
Ability.allowed?(user, :read_project, project)
else
milestone?
false # No other event types are visible
end
end
......
......@@ -11,3 +11,5 @@
= render "events/event/note", event: event
- else
= render "events/event/common", event: event
- elsif @user.include_private_contributions?
= render "events/event/private", event: event
%span.event-scope
= event_preposition(event)
- if event.project
= link_to_project event.project
= link_to_project(event.project)
- else
= event.project_name
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
%span{ class: event.action_name }
- if event.target
= event.action_name
......
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
%span{ class: event.action_name }
= event_action_name(event)
- if event.project
= link_to_project event.project
= link_to_project(event.project)
- else
= event.project_name
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
= event.action_name
= event_note_title_html(event)
......
.event-inline.event-item
.event-item-timestamp
= time_ago_with_tooltip(event.created_at)
.system-note-image= sprite_icon('eye-slash', size: 16, css_class: 'icon')
.event-title
- author_name = capture do
%span.author_name= link_to_author(event)
= s_('Profiles|%{author_name} made a private contribution').html_safe % { author_name: author_name }
......@@ -3,7 +3,7 @@
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
%span.pushed #{event.action_name} #{event.ref_type}
%strong
- commits_link = project_commits_path(project, event.ref_name)
......
- breadcrumb_title "Edit Profile"
- breadcrumb_title s_("Profiles|Edit Profile")
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
= form_errors(@user)
......@@ -7,34 +8,36 @@
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Public Avatar
= s_("Profiles|Public Avatar")
%p
- if @user.avatar?
You can change your avatar here
- if gravatar_enabled?
or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host}
= s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- else
= s_("Profiles|You can change your avatar here")
- else
You can upload an avatar here
- if gravatar_enabled?
or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host}
= s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- else
= s_("Profiles|You can upload your avatar here")
.col-lg-8
.clearfix.avatar-image.append-bottom-default
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
%h5.prepend-top-0= _("Upload new avatar")
%h5.prepend-top-0= s_("Profiles|Upload new avatar")
.prepend-top-5.append-bottom-10
%button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...")
%span.avatar-file-name.prepend-left-default.js-avatar-filename= _("No file chosen")
%button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
%span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.text-muted= _("The maximum file size allowed is 200KB.")
.form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.")
- if @user.avatar?
%hr
= link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted'
= link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'btn btn-danger btn-inverted'
%hr
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0= s_("User|Current status")
%h4.prepend-top-0= s_("Profiles|Current status")
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
.col-lg-8
= f.fields_for :status, @user.status do |status_form|
......@@ -66,62 +69,66 @@
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Main settings
= s_("Profiles|Main settings")
%p
This information will appear on your profile.
= s_("Profiles|This information will appear on your profile.")
- if current_user.ldap_user?
Some options are unavailable for LDAP accounts
= s_("Profiles|Some options are unavailable for LDAP accounts")
.col-lg-8
.row
- if @user.read_only_attribute?(:name)
= f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
help: "Your name was automatically set based on your #{ attribute_provider_label(:name) } account, so people you know can recognize you."
help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) }
- else
= f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you."
= f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- if @user.read_only_attribute?(:email)
= f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{ attribute_provider_label(:email) } account."
= f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:email) }
- else
= f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?),
help: user_email_help_text(@user)
= f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
{ help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' },
{ help: s_("Profiles|This email will be displayed on your public profile."), include_blank: s_("Profiles|Do not show on profile") },
control_class: 'select2'
= f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
{ help: 'This feature is experimental and translations are not complete yet.' },
{ help: s_("Profiles|This feature is experimental and translations are not complete yet.") },
control_class: 'select2'
= f.text_field :skype
= f.text_field :linkedin
= f.text_field :twitter
= f.text_field :website_url, label: 'Website'
= f.text_field :website_url, label: s_("Profiles|Website")
- if @user.read_only_attribute?(:location)
= f.text_field :location, readonly: true, help: "Your location was automatically set based on your #{ attribute_provider_label(:location) } account."
= f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:location) }
- else
= f.text_field :location
= f.text_field :organization
= f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.'
= f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.")
%hr
%h5 Private profile
%h5= ("Private profile")
- private_profile_label = capture do
Don't display activity-related personal information on your profile
= s_("Profiles|Don't display activity-related personal information on your profiles")
= link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile')
= f.check_box :private_profile, label: private_profile_label
%h5= s_("Profiles|Private contributions")
= f.check_box :include_private_contributions, label: 'Include private contributions on my profile'
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
.prepend-top-default.append-bottom-default
= f.submit 'Update profile settings', class: 'btn btn-success'
= link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel'
= f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success'
= link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel'
.modal.modal-profile-crop
.modal-dialog
.modal-content
.modal-header
%h4.modal-title
Position and size your new avatar
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
= s_("Profiles|Position and size your new avatar")
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") }
%span{ "aria-hidden": true } &times;
.modal-body
.profile-crop-image-container
%img.modal-profile-crop-image{ alt: 'Avatar cropper' }
%img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }
.crop-controls
.btn-group
%button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } }
......@@ -130,4 +137,4 @@
%span.fa.fa-search-minus
.modal-footer
%button.btn.btn-primary.js-upload-user-avatar{ type: 'button' }
Set new profile picture
= s_("Profiles|Set new profile picture")
%h4.prepend-top-20
Contributions for
%strong= @calendar_date.to_s(:medium)
= _("Contributions for <strong>%{calendar_date}</strong>").html_safe % { calendar_date: @calendar_date.to_s(:medium) }
- if @events.any?
%ul.bordered-list
......@@ -9,25 +8,28 @@
%span.light
%i.fa.fa-clock-o
= event.created_at.strftime('%-I:%M%P')
- if event.push?
#{event.action_name} #{event.ref_type}
- if event.visible_to_user?(current_user)
- if event.push?
#{event.action_name} #{event.ref_type}
%strong
- commits_path = project_commits_path(event.project, event.ref_name)
= link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path
- else
= event_action_name(event)
%strong
- if event.note?
= link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title
- elsif event.target
= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title
at
%strong
- commits_path = project_commits_path(event.project, event.ref_name)
= link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path
- if event.project
= link_to_project(event.project)
- else
= event.project_name
- else
= event_action_name(event)
%strong
- if event.note?
= link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title
- elsif event.target
= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title
at
%strong
- if event.project
= link_to_project event.project
- else
= event.project_name
made a private contribution
- else
%p
No contributions found for #{@calendar_date.to_s(:medium)}
= _('No contributions were found')
---
title: Include private contributions to contributions calendar
merge_request: 17296
author: George Tsiolis
type: added
class AddIncludePrivateContributionsToUsers < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :users, :include_private_contributions, :boolean
end
end
......@@ -2205,6 +2205,7 @@ ActiveRecord::Schema.define(version: 20180906101639) do
t.integer "accepted_term_id"
t.string "feed_token"
t.boolean "private_profile"
t.boolean "include_private_contributions"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
......
......@@ -91,6 +91,18 @@ To enable private profile:
NOTE: **Note:**
You and GitLab admins can see your the abovementioned information on your profile even if it is private.
## Private contributions
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/14078) in GitLab 11.3.
Enabling private contributions will include contributions to private projects, in the user contribution calendar graph and user recent activity.
To enable private contributions:
1. Navigate to your personal [profile settings](#profile-settings).
2. Check the "Private contributions" option.
3. Hit **Update profile settings**.
## Current status
> Introduced in GitLab 11.2.
......
......@@ -7,7 +7,11 @@ module Gitlab
def initialize(contributor, current_user = nil)
@contributor = contributor
@current_user = current_user
@projects = ContributedProjectsFinder.new(contributor).execute(current_user)
@projects = if @contributor.include_private_contributions?
ContributedProjectsFinder.new(@contributor).execute(@contributor)
else
ContributedProjectsFinder.new(contributor).execute(current_user)
end
end
def activity_dates
......@@ -36,13 +40,9 @@ module Gitlab
def events_by_date(date)
return Event.none unless can_read_cross_project?
events = Event.contributions.where(author_id: contributor.id)
Event.contributions.where(author_id: contributor.id)
.where(created_at: date.beginning_of_day..date.end_of_day)
.where(project_id: projects)
# Use visible_to_user? instead of the complicated logic in activity_dates
# because we're only viewing the events for a single day.
events.select { |event| event.visible_to_user?(current_user) }
end
def starting_year
......
......@@ -1894,6 +1894,9 @@ msgstr ""
msgid "Contribution guide"
msgstr ""
msgid "Contributions for <strong>%{calendar_date}</strong>"
msgstr ""
msgid "Contributors"
msgstr ""
......@@ -3932,6 +3935,9 @@ msgstr ""
msgid "No container images stored for this project. Add one by following the instructions above."
msgstr ""
msgid "No contributions were found"
msgstr ""
msgid "No due date"
msgstr ""
......@@ -4471,6 +4477,9 @@ msgstr ""
msgid "Profiles| You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. Please update your Git repository remotes as soon as possible."
msgstr ""
msgid "Profiles|%{author_name} made a private contribution"
msgstr ""
msgid "Profiles|Account scheduled for removal."
msgstr ""
......@@ -4480,15 +4489,30 @@ msgstr ""
msgid "Profiles|Add status emoji"
msgstr ""
msgid "Profiles|Avatar cropper"
msgstr ""
msgid "Profiles|Avatar will be removed. Are you sure?"
msgstr ""
msgid "Profiles|Change username"
msgstr ""
msgid "Profiles|Choose file..."
msgstr ""
msgid "Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information."
msgstr ""
msgid "Profiles|Clear status"
msgstr ""
msgid "Profiles|Current path: %{path}"
msgstr ""
msgid "Profiles|Current status"
msgstr ""
msgid "Profiles|Delete Account"
msgstr ""
......@@ -4501,39 +4525,108 @@ msgstr ""
msgid "Profiles|Deleting an account has the following effects:"
msgstr ""
msgid "Profiles|Do not show on profile"
msgstr ""
msgid "Profiles|Don't display activity-related personal information on your profiles"
msgstr ""
msgid "Profiles|Edit Profile"
msgstr ""
msgid "Profiles|Invalid password"
msgstr ""
msgid "Profiles|Invalid username"
msgstr ""
msgid "Profiles|Main settings"
msgstr ""
msgid "Profiles|No file chosen"
msgstr ""
msgid "Profiles|Path"
msgstr ""
msgid "Profiles|Position and size your new avatar"
msgstr ""
msgid "Profiles|Private contributions"
msgstr ""
msgid "Profiles|Public Avatar"
msgstr ""
msgid "Profiles|Remove avatar"
msgstr ""
msgid "Profiles|Set new profile picture"
msgstr ""
msgid "Profiles|Some options are unavailable for LDAP accounts"
msgstr ""
msgid "Profiles|Tell us about yourself in fewer than 250 characters."
msgstr ""
msgid "Profiles|The maximum file size allowed is 200KB."
msgstr ""
msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?"
msgstr ""
msgid "Profiles|This email will be displayed on your public profile."
msgstr ""
msgid "Profiles|This emoji and message will appear on your profile and throughout the interface."
msgstr ""
msgid "Profiles|This feature is experimental and translations are not complete yet."
msgstr ""
msgid "Profiles|This information will appear on your profile."
msgstr ""
msgid "Profiles|Type your %{confirmationValue} to confirm:"
msgstr ""
msgid "Profiles|Typically starts with \"ssh-rsa …\""
msgstr ""
msgid "Profiles|Update profile settings"
msgstr ""
msgid "Profiles|Update username"
msgstr ""
msgid "Profiles|Upload new avatar"
msgstr ""
msgid "Profiles|Username change failed - %{message}"
msgstr ""
msgid "Profiles|Username successfully changed"
msgstr ""
msgid "Profiles|Website"
msgstr ""
msgid "Profiles|What's your status?"
msgstr ""
msgid "Profiles|You can change your avatar here"
msgstr ""
msgid "Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}"
msgstr ""
msgid "Profiles|You can upload your avatar here"
msgstr ""
msgid "Profiles|You can upload your avatar here or change it at %{gravatar_link}"
msgstr ""
msgid "Profiles|You don't have access to delete this user."
msgstr ""
......@@ -4543,6 +4636,15 @@ msgstr ""
msgid "Profiles|Your account is currently an owner in these groups:"
msgstr ""
msgid "Profiles|Your email address was automatically set based on your %{provider_label} account."
msgstr ""
msgid "Profiles|Your location was automatically set based on your %{provider_label} account."
msgstr ""
msgid "Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you."
msgstr ""
msgid "Profiles|Your status"
msgstr ""
......@@ -6310,9 +6412,6 @@ msgstr ""
msgid "Upload file"
msgstr ""
msgid "Upload new avatar"
msgstr ""
msgid "UploadLink|click to upload"
msgstr ""
......@@ -6358,9 +6457,6 @@ msgstr ""
msgid "Users"
msgstr ""
msgid "User|Current status"
msgstr ""
msgid "Variables"
msgstr ""
......
......@@ -95,6 +95,7 @@ describe 'bin/changelog' do
it 'shows error message and exits the program' do
allow($stdin).to receive(:getc).and_return(type)
expect do
expect { described_class.read_type }.to raise_error(
ChangelogHelpers::Abort,
......
......@@ -8,6 +8,7 @@ describe ContributedProjectsFinder do
let!(:public_project) { create(:project, :public) }
let!(:private_project) { create(:project, :private) }
let!(:internal_project) { create(:project, :internal) }
before do
private_project.add_maintainer(source_user)
......@@ -16,17 +17,18 @@ describe ContributedProjectsFinder do
create(:push_event, project: public_project, author: source_user)
create(:push_event, project: private_project, author: source_user)
create(:push_event, project: internal_project, author: source_user)
end
describe 'without a current user' do
describe 'activity without a current user' do
subject { finder.execute }
it { is_expected.to eq([public_project]) }
it { is_expected.to match_array([public_project]) }
end
describe 'with a current user' do
describe 'activity with a current user' do
subject { finder.execute(current_user) }
it { is_expected.to eq([private_project, public_project]) }
it { is_expected.to match_array([private_project, internal_project, public_project]) }
end
end
......@@ -13,49 +13,25 @@ describe UserRecentEventsFinder do
subject(:finder) { described_class.new(current_user, project_owner) }
describe '#execute' do
context 'current user does not have access to projects' do
it 'returns public and internal events' do
records = finder.execute
expect(records).to include(public_event, internal_event)
expect(records).not_to include(private_event)
context 'when profile is public' do
it 'returns all the events' do
expect(finder.execute).to include(private_event, internal_event, public_event)
end
end
context 'when current user has access to the projects' do
before do
private_project.add_developer(current_user)
internal_project.add_developer(current_user)
public_project.add_developer(current_user)
end
context 'when profile is public' do
it 'returns all the events' do
expect(finder.execute).to include(private_event, internal_event, public_event)
end
end
context 'when profile is private' do
it 'returns no event' do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
expect(finder.execute).to be_empty
end
end
context 'when profile is private' do
it 'returns no event' do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
it 'does not include the events if the user cannot read cross project' do
expect(Ability).to receive(:allowed?).and_call_original
expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
expect(finder.execute).to be_empty
end
end
context 'when current user is anonymous' do
let(:current_user) { nil }
it 'returns public events only' do
expect(finder.execute).to eq([public_event])
end
it 'does not include the events if the user cannot read cross project' do
expect(Ability).to receive(:allowed?).and_call_original
expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
expect(finder.execute).to be_empty
end
end
end
......@@ -189,9 +189,9 @@ describe API::Helpers::Pagination do
it 'it returns the right link to the next page' do
allow(subject).to receive(:params)
.and_return({ pagination: 'keyset', ks_prev_id: projects[3].id, ks_prev_name: projects[3].name, per_page: 2 })
expect_header('X-Per-Page', '2')
expect_header('X-Next-Page', "#{value}?ks_prev_id=#{projects[6].id}&ks_prev_name=#{projects[6].name}&pagination=keyset&per_page=2")
expect_header('Link', anything) do |_key, val|
expect(val).to include('rel="next"')
end
......