Merge branch '20137-starrers' into 'master'

Add possibilty to view starrers ("stargazers") of a repository & any user's starred repositories

Closes #20137

See merge request gitlab-org/gitlab-ce!24690
This commit is contained in:
Stan Hu 2019-08-08 04:40:55 +00:00
commit 3ad34c3a24
39 changed files with 999 additions and 96 deletions

View File

@ -143,7 +143,7 @@ export default class UserTabs {
this.loadOverviewTab();
}
const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets'];
if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint);
}

View File

@ -18,7 +18,7 @@ export default class Star {
const isStarred = $starSpan.hasClass('starred');
$this
.parent()
.find('.star-count')
.find('.count')
.text(data.star_count);
if (isStarred) {

View File

@ -9,10 +9,6 @@
}
}
.member-sort-dropdown {
margin-left: $gl-padding-8;
}
.member {
&.is-overridden {
.btn-ldap-override {
@ -62,36 +58,9 @@
}
}
.member-search-form {
position: relative;
@include media-breakpoint-up(sm) {
float: right;
}
.dropdown {
width: 100%;
margin-top: 5px;
.dropdown-menu-toggle {
vertical-align: middle;
width: 100%;
}
@include media-breakpoint-up(sm) {
margin-top: 0;
width: 155px;
}
}
.form-control {
width: 100%;
padding-right: 35px;
@include media-breakpoint-up(sm) {
width: 250px;
}
}
.member-access-text {
margin-left: auto;
line-height: 43px;
}
.member-search-btn {
@ -177,7 +146,7 @@
padding-bottom: 1px;
}
.flex-project-members-form {
.flex-users-form {
flex-wrap: nowrap;
white-space: nowrap;
margin-left: auto;

View File

@ -0,0 +1,105 @@
.user-sort-dropdown {
margin-left: $gl-padding-8;
}
.user-search-form {
position: relative;
@include media-breakpoint-up(sm) {
float: right;
}
.dropdown {
width: 100%;
margin-top: 5px;
.dropdown-menu-toggle {
vertical-align: middle;
width: 100%;
}
@include media-breakpoint-up(sm) {
margin-top: 0;
width: 155px;
}
}
.form-control {
width: 100%;
padding-right: 35px;
@include media-breakpoint-up(sm) {
width: 250px;
}
}
}
.user-search-btn {
position: absolute;
right: 4px;
top: 0;
height: 35px;
padding-left: 10px;
padding-right: 10px;
color: $gray-darkest;
background: transparent;
border: 0;
outline: 0;
}
.flex-users-panel {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
@include media-breakpoint-down(sm) {
display: block;
.flex-project-title {
vertical-align: top;
display: inline-block;
max-width: 90%;
}
}
.flex-project-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.badge.badge-pill {
height: 17px;
line-height: 16px;
margin-right: 5px;
padding-top: 1px;
padding-bottom: 1px;
}
.flex-users-form {
flex-wrap: nowrap;
white-space: nowrap;
margin-left: auto;
}
}
.content-list.members-list li {
display: flex;
justify-content: space-between;
.list-item-name {
float: none;
display: flex;
flex: 1;
}
}
.card-body .user-info {
float: left;
.user {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
}
}

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Projects::StarrersController < Projects::ApplicationController
include SortingHelper
def index
@starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute
# Normally the number of public starrers is equal to the number of visible
# starrers. We need to fix the counts in two cases: when the current user
# is an admin (and can see everything) and when the current user has a
# private profile and has starred the project (and can see itself).
@public_count =
if @current_user&.admin?
@starrers.with_public_profile.count
elsif @current_user&.private_profile && has_starred_project?(@starrers)
@starrers.size - 1
else
@starrers.size
end
@total_count = @project.starrers.size
@private_count = @total_count - @public_count
@sort = params[:sort].presence || sort_value_name
@starrers = @starrers.sort_by_attribute(@sort).page(params[:page])
end
private
def has_starred_project?(starrers)
starrers.first { |starrer| starrer.user_id == current_user.id }
end
end

View File

@ -17,7 +17,7 @@ class UsersController < ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists]
before_action :authorize_read_user_profile!,
only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :snippets]
only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :starred_projects, :snippets]
def show
respond_to do |format|
@ -57,6 +57,22 @@ class UsersController < ApplicationController
def projects
load_projects
present_projects(@projects)
end
def contributed
load_contributed_projects
present_projects(@contributed_projects)
end
def starred
load_starred_projects
present_projects(@starred_projects)
end
def present_projects(projects)
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
@ -64,20 +80,7 @@ class UsersController < ApplicationController
respond_to do |format|
format.html { render 'show' }
format.json do
pager_json("shared/projects/_list", @projects.count, projects: @projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
end
end
end
def contributed
load_contributed_projects
respond_to do |format|
format.html { render 'show' }
format.json do
render json: {
html: view_to_html_string("shared/projects/_list", projects: @contributed_projects)
}
pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
end
end
end
@ -120,6 +123,10 @@ class UsersController < ApplicationController
ContributedProjectsFinder.new(user).execute(current_user)
end
def starred_projects
StarredProjectsFinder.new(user, current_user: current_user).execute
end
def contributions_calendar
@contributions_calendar ||= Gitlab::ContributionsCalendar.new(user, current_user)
end
@ -145,6 +152,12 @@ class UsersController < ApplicationController
prepare_projects_for_rendering(@contributed_projects)
end
def load_starred_projects
@starred_projects = starred_projects
prepare_projects_for_rendering(@starred_projects)
end
def load_groups
@groups = JoinedGroupsFinder.new(user).execute(current_user)

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class StarredProjectsFinder < ProjectsFinder
def initialize(user, params: {}, current_user: nil)
super(
params: params,
current_user: current_user,
project_ids_relation: user.starred_projects.select(:id)
)
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class UsersStarProjectsFinder
include CustomAttributesFilter
attr_accessor :params
def initialize(project, params = {}, current_user: nil)
@params = params
@project = project
@current_user = current_user
end
def execute
stars = UsersStarProject.all
stars = by_project(stars)
stars = by_search(stars)
stars = filter_visible_profiles(stars)
stars
end
private
def by_search(items)
params[:search].present? ? items.search(params[:search]) : items
end
def by_project(items)
items.by_project(@project)
end
def filter_visible_profiles(items)
items.with_visible_profile(@current_user)
end
end

View File

@ -601,6 +601,11 @@ module ProjectsHelper
end
end
def filter_starrer_path(options = {})
options = params.slice(:sort).merge(options).permit!
"#{request.path}?#{options.to_param}"
end
def sidebar_projects_paths
%w[
projects#show

View File

@ -167,6 +167,15 @@ module SortingHelper
}
end
def starrers_sort_options_hash
{
sort_value_name => sort_title_name,
sort_value_name_desc => sort_title_name_desc,
sort_value_recently_created => sort_title_recently_starred,
sort_value_oldest_created => sort_title_oldest_starred
}
end
def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
@ -327,6 +336,10 @@ module SortingHelper
s_('SortOptions|Oldest sign in')
end
def sort_title_oldest_starred
s_('SortOptions|Oldest starred')
end
def sort_title_oldest_updated
s_('SortOptions|Oldest updated')
end
@ -347,6 +360,10 @@ module SortingHelper
s_('SortOptions|Recent sign in')
end
def sort_title_recently_starred
s_('SortOptions|Recently starred')
end
def sort_title_recently_updated
s_('SortOptions|Last updated')
end

View File

@ -89,7 +89,7 @@ module UsersHelper
tabs = []
if can?(current_user, :read_user_profile, @user)
tabs += [:overview, :activity, :groups, :contributed, :projects, :snippets]
tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets]
end
tabs

View File

@ -282,6 +282,17 @@ class User < ApplicationRecord
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
def self.with_visible_profile(user)
return with_public_profile if user.nil?
if user.admin?
all
else
with_public_profile.or(where(id: user.id))
end
end
# Limits the users to those that have TODOs, optionally in the given state.
#

View File

@ -1,10 +1,37 @@
# frozen_string_literal: true
class UsersStarProject < ApplicationRecord
include Sortable
belongs_to :project, counter_cache: :star_count, touch: true
belongs_to :user
validates :user, presence: true
validates :user_id, uniqueness: { scope: [:project_id] }
validates :project, presence: true
alias_attribute :starred_since, :created_at
scope :order_user_name_asc, -> { joins(:user).merge(User.order_name_asc) }
scope :order_user_name_desc, -> { joins(:user).merge(User.order_name_desc) }
scope :by_project, -> (project) { where(project_id: project.id) }
scope :with_visible_profile, -> (user) { joins(:user).merge(User.with_visible_profile(user)) }
scope :with_public_profile, -> { joins(:user).merge(User.with_public_profile) }
class << self
def sort_by_attribute(method)
order_method = method || 'id_desc'
case order_method.to_s
when 'name_asc' then order_user_name_asc
when 'name_desc' then order_user_name_desc
else
order_by(order_method)
end
end
def search(query)
joins(:user).merge(User.search(query))
end
end
end

View File

@ -25,11 +25,11 @@
Members with access to
%strong= @group.name
%span.badge.badge-pill= @members.total_count
= form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
= form_tag group_group_members_path(@group), method: :get, class: 'form-inline user-search-form flex-users-form' do
.form-group
.position-relative.append-right-8
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
%button.member-search-btn{ type: "submit", "aria-label" => "Submit search" }
%button.user-search-btn{ type: "submit", "aria-label" => "Submit search" }
= icon("search")
- if can_manage_members
= render 'shared/members/filter_2fa_dropdown'

View File

@ -8,7 +8,8 @@
= sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
= @project.star_count
= link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
= @project.star_count
- else
.count-badge.d-inline-flex.align-item-stretch.append-right-8
@ -16,4 +17,5 @@
= sprite_icon('star-o', { css_class: 'icon' })
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
= @project.star_count
= link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
= @project.star_count

View File

@ -6,11 +6,11 @@
%span.flex-project-title
= _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) }
%span.badge.badge-pill= members.total_count
= form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
= form_tag project_project_members_path(project), method: :get, class: 'form-inline user-search-form flex-users-form' do
.form-group
.position-relative
= search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false }
%button.member-search-btn{ type: "submit", "aria-label" => _("Submit search") }
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= icon("search")
= render 'shared/members/sort_dropdown'
%ul.content-list.members-list.qa-members-list

View File

@ -0,0 +1,19 @@
- starrer = local_assigns.fetch(:starrer)
.col-lg-3.col-md-4.col-sm-12
.card
.card-body
= image_tag avatar_icon_for_user(starrer.user, 40), class: "avatar s40", alt: ''
.user-info
.block-truncated
= link_to starrer.user.name, user_path(starrer.user), class: 'user js-user-link', data: { user_id: starrer.user.id }
.block-truncated
%span.cgray= starrer.user.to_reference
- if starrer.user == current_user
%span.badge.badge-success.prepend-left-5= _("It's you")
.block-truncated
= time_ago_with_tooltip(starrer.starred_since)

View File

@ -0,0 +1,32 @@
- page_title _("Starrers")
.top-area.adjust
.nav-text
- full_count_title = "#{@public_count} public and #{@private_count} private"
#{pluralize(@total_count, 'starrer')}: #{full_count_title}
- if @starrers.size > 0 || params[:search].present?
.nav-controls
= form_tag request.original_url, method: :get, class: 'form-inline user-search-form flex-users-form' do
.form-group
.position-relative
= search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= icon("search")
.dropdown.inline.user-sort-dropdown
= dropdown_toggle(starrers_sort_options_hash[@sort], { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
= _("Sort by")
- starrers_sort_options_hash.each do |value, title|
%li
= link_to filter_starrer_path(sort: value), class: ("is-active" if @sort == value) do
= title
- if @starrers.size > 0
.row.prepend-top-10
= render partial: 'starrer', collection: @starrers, as: :starrer
= paginate @starrers, theme: 'gitlab'
- else
- if params[:search].present?
.nothing-here-block= _('No starrers matched your search')
- else
.nothing-here-block= _('Nobody has starred this repository yet')

View File

@ -1,4 +1,4 @@
.dropdown.inline.member-sort-dropdown
.dropdown.inline.user-sort-dropdown
= dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header

View File

@ -17,15 +17,20 @@
- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
- starred_projects_illustration_path = 'illustrations/starred_empty.svg'
- starred_projects_current_user_empty_message_header = s_('UserProfile|Star projects to track their progress and show your appreciation.')
- starred_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t starred any projects')
- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
- explore_page_empty_message = s_('UserProfile|Explore public groups to find projects to contribute to.')
- primary_button_label = _('New project')
- primary_button_link = new_project_path
- secondary_button_label = _('Explore groups')
- secondary_button_link = explore_groups_path
- new_project_button_label = _('New project')
- new_project_button_link = new_project_path
- explore_projects_button_label = _('Explore projects')
- explore_projects_button_link = explore_projects_path
- explore_groups_button_label = _('Explore groups')
- explore_groups_button_link = explore_groups_path
.js-projects-list-holder
- if any_projects?(projects)
@ -48,15 +53,21 @@
- if @contributed_projects
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
primary_button_label: primary_button_label,
primary_button_link: primary_button_link,
secondary_button_label: secondary_button_label,
secondary_button_link: secondary_button_link,
primary_button_label: new_project_button_label,
primary_button_link: new_project_button_link,
secondary_button_label: explore_groups_button_label,
secondary_button_link: explore_groups_button_link,
visitor_empty_message: contributed_projects_visitor_empty_message }
- elsif @starred_projects
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: starred_projects_illustration_path,
current_user_empty_message_header: starred_projects_current_user_empty_message_header,
primary_button_label: explore_projects_button_label,
primary_button_link: explore_projects_button_link,
visitor_empty_message: starred_projects_visitor_empty_message }
- else
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
current_user_empty_message_header: own_projects_current_user_empty_message_header,
current_user_empty_message_description: own_projects_current_user_empty_message_description,
primary_button_label: primary_button_label,
primary_button_link: primary_button_link,
primary_button_label: new_project_button_label,
primary_button_link: new_project_button_link,
visitor_empty_message: defined?(explore_page) && explore_page ? explore_page_empty_message : own_projects_visitor_empty_message }

View File

@ -63,7 +63,9 @@
- if project.archived
%span.d-flex.icon-wrapper.badge.badge-warning archived
- if stars
%span.d-flex.align-items-center.icon-wrapper.stars.has-tooltip{ data: { container: 'body', placement: 'top' }, title: _('Stars') }
= link_to project_starrers_path(project),
class: "d-flex align-items-center icon-wrapper stars has-tooltip",
title: _('Stars'), data: { container: 'body', placement: 'top' } do
= sprite_icon('star', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.star_count)
- if forks

View File

@ -111,6 +111,10 @@
%li.js-projects-tab
= link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
= s_('UserProfile|Personal projects')
- if profile_tab?(:starred)
%li.js-starred-tab
= link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
= s_('UserProfile|Starred projects')
- if profile_tab?(:snippets)
%li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
@ -142,6 +146,10 @@
#projects.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:starred)
#starred.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:snippets)
#snippets.tab-pane
-# This tab is always loaded via AJAX

View File

@ -0,0 +1,5 @@
---
title: Make starred projects and starrers of a project publicly visible
merge_request: 24690
author:
type: added

View File

@ -170,7 +170,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :recent
end
end
resources :releases, only: [:index]
resources :starrers, only: [:index]
resources :forks, only: [:index, :new, :create]
resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ }

View File

@ -59,6 +59,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :groups
get :projects
get :contributed, as: :contributed_projects
get :starred, as: :starred_projects
get :snippets
get :exists
get :activity

View File

@ -465,6 +465,194 @@ GET /users/:user_id/projects
]
```
## List projects starred by a user
Get a list of visible projects owned by the given user. When accessed without authentication, only public projects are returned.
```
GET /users/:user_id/starred_projects
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `user_id` | string | yes | The ID or username of the user. |
| `archived` | boolean | no | Limit by archived status. |
| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private`. |
| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at`. |
| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc`. |
| `search` | string | no | Return list of projects matching the search criteria. |
| `simple` | boolean | no | Return only limited fields for each project. This is a no-op without authentication as then _only_ simple fields are returned.. |
| `owned` | boolean | no | Limit by projects explicitly owned by the current user. |
| `membership` | boolean | no | Limit by projects that the current user is a member of. |
| `starred` | boolean | no | Limit by projects starred by the current user. |
| `statistics` | boolean | no | Include project statistics. |
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only). |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature. |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature. |
| `min_access_level` | integer | no | Limit by current user minimal [access level](members.md). |
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/users/5/starred_projects"
```
Example response:
```json
[
{
"id": 4,
"description": null,
"default_branch": "master",
"visibility": "private",
"ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git",
"http_url_to_repo": "http://example.com/diaspora/diaspora-client.git",
"web_url": "http://example.com/diaspora/diaspora-client",
"readme_url": "http://example.com/diaspora/diaspora-client/blob/master/README.md",
"tag_list": [
"example",
"disapora client"
],
"owner": {
"id": 3,
"name": "Diaspora",
"created_at": "2013-09-30T13:46:02Z"
},
"name": "Diaspora Client",
"name_with_namespace": "Diaspora / Diaspora Client",
"path": "diaspora-client",
"path_with_namespace": "diaspora/diaspora-client",
"issues_enabled": true,
"open_issues_count": 1,
"merge_requests_enabled": true,
"jobs_enabled": true,
"wiki_enabled": true,
"snippets_enabled": false,
"resolve_outdated_diff_discussions": false,
"container_registry_enabled": false,
"created_at": "2013-09-30T13:46:02Z",
"last_activity_at": "2013-09-30T13:46:02Z",
"creator_id": 3,
"namespace": {
"id": 3,
"name": "Diaspora",
"path": "diaspora",
"kind": "group",
"full_path": "diaspora"
},
"import_status": "none",
"archived": false,
"avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png",
"shared_runners_enabled": true,
"forks_count": 0,
"star_count": 0,
"runners_token": "b8547b1dc37721d05889db52fa2f02",
"public_jobs": true,
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
"merge_method": "merge",
"statistics": {
"commit_count": 37,
"storage_size": 1038090,
"repository_size": 1038090,
"lfs_objects_size": 0,
"job_artifacts_size": 0
},
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
"merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
"repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
"labels": "http://example.com/api/v4/projects/1/labels",
"events": "http://example.com/api/v4/projects/1/events",
"members": "http://example.com/api/v4/projects/1/members"
}
},
{
"id": 6,
"description": null,
"default_branch": "master",
"visibility": "private",
"ssh_url_to_repo": "git@example.com:brightbox/puppet.git",
"http_url_to_repo": "http://example.com/brightbox/puppet.git",
"web_url": "http://example.com/brightbox/puppet",
"readme_url": "http://example.com/brightbox/puppet/blob/master/README.md",
"tag_list": [
"example",
"puppet"
],
"owner": {
"id": 4,
"name": "Brightbox",
"created_at": "2013-09-30T13:46:02Z"
},
"name": "Puppet",
"name_with_namespace": "Brightbox / Puppet",
"path": "puppet",
"path_with_namespace": "brightbox/puppet",
"issues_enabled": true,
"open_issues_count": 1,
"merge_requests_enabled": true,
"jobs_enabled": true,
"wiki_enabled": true,
"snippets_enabled": false,
"resolve_outdated_diff_discussions": false,
"container_registry_enabled": false,
"created_at": "2013-09-30T13:46:02Z",
"last_activity_at": "2013-09-30T13:46:02Z",
"creator_id": 3,
"namespace": {
"id": 4,
"name": "Brightbox",
"path": "brightbox",
"kind": "group",
"full_path": "brightbox"
},
"import_status": "none",
"import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
"notification_level": 3
},
"group_access": {
"access_level": 50,
"notification_level": 3
}
},
"archived": false,
"avatar_url": null,
"shared_runners_enabled": true,
"forks_count": 0,
"star_count": 0,
"runners_token": "b8547b1dc37721d05889db52fa2f02",
"public_jobs": true,
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
"merge_method": "merge",
"statistics": {
"commit_count": 12,
"storage_size": 2066080,
"repository_size": 2066080,
"lfs_objects_size": 0,
"job_artifacts_size": 0
},
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
"merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
"repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
"labels": "http://example.com/api/v4/projects/1/labels",
"events": "http://example.com/api/v4/projects/1/events",
"members": "http://example.com/api/v4/projects/1/members"
}
}
]
```
## Get single project
Get a specific project. This endpoint can be accessed without authentication if
@ -1155,6 +1343,51 @@ Example response:
}
```
## List Starrers of a project
List the users who starred the specified project.
```
GET /projects/:id/starrers
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `search` | string | no | Search for specific users. |
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/starrers"
```
Example responses:
```json
[
{
"starred_since": "2019-01-28T14:47:30.642Z",
"user":
{
"id": 1,
"username": "jane_smith",
"name": "Jane Smith",
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg",
"web_url": "http://localhost:3000/jane_smith"
}
},
"starred_since": "2018-01-02T11:40:26.570Z",
"user":
{
"id": 2,
"username": "janine_smith",
"name": "Janine Smith",
"state": "blocked",
"avatar_url": "http://gravatar.com/../e32131cd8.jpeg",
"web_url": "http://localhost:3000/janine_smith"
}
]
```
## Languages
Get languages used in a project with percentage value.

View File

@ -27,6 +27,7 @@ On your profile page, you will see the following information:
- Groups: [groups](../group/index.md) you're a member of
- Contributed projects: [projects](../project/index.md) you contributed to
- Personal projects: your personal projects (respecting the project's visibility level)
- Starred projects: projects you starred
- Snippets: your personal code [snippets](../snippets.md#personal-snippets)
## Profile settings
@ -91,6 +92,7 @@ The following information will be hidden from the user profile page (`https://gi
- Groups tab
- Contributed projects tab
- Personal projects tab
- Starred projects tab
- Snippets tab
To enable private profile:

View File

@ -77,6 +77,11 @@ module API
expose :last_activity_on, as: :last_activity_at # Back-compat
end
class UserStarsProject < Grape::Entity
expose :starred_since
expose :user, using: Entities::UserBasic
end
class Identity < Grape::Entity
expose :provider, :extern_uid
end

View File

@ -115,6 +115,22 @@ module API
present_projects load_projects
end
desc 'Get projects starred by a user' do
success Entities::BasicProjectDetails
end
params do
requires :user_id, type: String, desc: 'The ID or username of the user'
use :collection_params
use :statistics_params
end
get ":user_id/starred_projects" do
user = find_user(params[:user_id])
not_found!('User') unless user
starred_projects = StarredProjectsFinder.new(user, params: project_finder_params, current_user: current_user).execute
present_projects starred_projects
end
end
resource :projects do
@ -358,6 +374,19 @@ module API
end
end
desc 'Get the users who starred a project' do
success Entities::UserBasic
end
params do
optional :search, type: String, desc: 'Return list of users matching the search criteria'
use :pagination
end
get ':id/starrers' do
starrers = UsersStarProjectsFinder.new(user_project, params, current_user: current_user).execute
present paginate(starrers), with: Entities::UserStarsProject
end
desc 'Get languages in project repository'
get ':id/languages' do
::Projects::RepositoryLanguagesService

View File

@ -7227,12 +7227,18 @@ msgstr ""
msgid "No schedules"
msgstr ""
msgid "No starrers matched your search"
msgstr ""
msgid "No template"
msgstr ""
msgid "No, directly import the existing email addresses and usernames."
msgstr ""
msgid "Nobody has starred this repository yet"
msgstr ""
msgid "None"
msgstr ""
@ -8543,6 +8549,12 @@ msgstr ""
msgid "ProjectOverview|Star"
msgstr ""
msgid "ProjectOverview|Starrer"
msgstr ""
msgid "ProjectOverview|Starrers"
msgstr ""
msgid "ProjectOverview|Unstar"
msgstr ""
@ -10268,6 +10280,9 @@ msgstr ""
msgid "SortOptions|Oldest sign in"
msgstr ""
msgid "SortOptions|Oldest starred"
msgstr ""
msgid "SortOptions|Oldest updated"
msgstr ""
@ -10283,6 +10298,9 @@ msgstr ""
msgid "SortOptions|Recent sign in"
msgstr ""
msgid "SortOptions|Recently starred"
msgstr ""
msgid "SortOptions|Sort direction"
msgstr ""
@ -10376,6 +10394,9 @@ msgstr ""
msgid "StarredProjectsEmptyState|You don't have starred projects yet."
msgstr ""
msgid "Starrers"
msgstr ""
msgid "Stars"
msgstr ""
@ -12218,6 +12239,12 @@ msgstr ""
msgid "UserProfile|Snippets in GitLab can either be private, internal, or public."
msgstr ""
msgid "UserProfile|Star projects to track their progress and show your appreciation."
msgstr ""
msgid "UserProfile|Starred projects"
msgstr ""
msgid "UserProfile|Subscribe"
msgstr ""
@ -12230,6 +12257,9 @@ msgstr ""
msgid "UserProfile|This user hasn't contributed to any projects"
msgstr ""
msgid "UserProfile|This user hasn't starred any projects"
msgstr ""
msgid "UserProfile|View all"
msgstr ""

View File

@ -0,0 +1,110 @@
# frozen_string_literal: true
require 'spec_helper'
describe Projects::StarrersController do
let(:user) { create(:user) }
let(:private_user) { create(:user, private_profile: true) }
let(:admin) { create(:user, admin: true) }
let(:project) { create(:project, :public, :repository) }
before do
user.toggle_star(project)
private_user.toggle_star(project)
end
describe 'GET index' do
def get_starrers
get :index,
params: {
namespace_id: project.namespace,
project_id: project
}
end
context 'when project is public' do
before do
project.update_attribute(:visibility_level, Project::PUBLIC)
end
context 'when no user is logged in' do
before do
get_starrers
end
it 'only public starrers are visible' do
user_ids = assigns[:starrers].map { |s| s['user_id'] }
expect(user_ids).to include(user.id)
expect(user_ids).not_to include(private_user.id)
end
it 'public/private starrers counts are correct' do
expect(assigns[:public_count]).to eq(1)
expect(assigns[:private_count]).to eq(1)
end
end
context 'when private user is logged in' do
before do
sign_in(private_user)
get_starrers
end
it 'their star is also visible' do
user_ids = assigns[:starrers].map { |s| s['user_id'] }
expect(user_ids).to include(user.id, private_user.id)
end
it 'public/private starrers counts are correct' do
expect(assigns[:public_count]).to eq(1)
expect(assigns[:private_count]).to eq(1)
end
end
context 'when admin is logged in' do
before do
sign_in(admin)
get_starrers
end
it 'all stars are visible' do
user_ids = assigns[:starrers].map { |s| s['user_id'] }
expect(user_ids).to include(user.id, private_user.id)
end
it 'public/private starrers counts are correct' do
expect(assigns[:public_count]).to eq(1)
expect(assigns[:private_count]).to eq(1)
end
end
end
context 'when project is private' do
before do
project.update(visibility_level: Project::PRIVATE)
end
it 'starrers are not visible for non logged in users' do
get_starrers
expect(assigns[:starrers]).to be_blank
end
context 'when user is logged in' do
before do
sign_in(project.creator)
end
it 'only public starrers are visible' do
get_starrers
user_ids = assigns[:starrers].map { |s| s['user_id'] }
expect(user_ids).to include(user.id)
expect(user_ids).not_to include(private_user.id)
end
end
end
end
end

View File

@ -19,9 +19,9 @@ describe 'Search group member' do
end
it 'renders member users' do
page.within '.member-search-form' do
page.within '.user-search-form' do
fill_in 'search', with: member.name
find('.member-search-btn').click
find('.user-search-btn').click
end
group_members_list = find(".card .content-list")

View File

@ -19,7 +19,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by access level ascending' do
@ -27,7 +27,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
end
it 'sorts by access level descending' do
@ -35,7 +35,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
end
it 'sorts by last joined' do
@ -43,7 +43,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
end
it 'sorts by oldest joined' do
@ -51,7 +51,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
end
it 'sorts by name ascending' do
@ -59,7 +59,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by name descending' do
@ -67,7 +67,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
@ -75,7 +75,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(owner.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
@ -83,7 +83,7 @@ describe 'Groups > Members > Sort members' do
expect(first_member).to include(developer.name)
expect(second_member).to include(owner.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
end
def visit_members_list(sort:)

View File

@ -52,18 +52,18 @@ describe 'Projects > Members > Groups with access list', :js do
context 'search in existing members (yes, this filters the groups list as well)' do
it 'finds no results' do
page.within '.member-search-form' do
page.within '.user-search-form' do
fill_in 'search', with: 'testing 123'
find('.member-search-btn').click
find('.user-search-btn').click
end
expect(page).not_to have_selector('.group_member')
end
it 'finds results' do
page.within '.member-search-form' do
page.within '.user-search-form' do
fill_in 'search', with: group.name
find('.member-search-btn').click
find('.user-search-btn').click
end
expect(page).to have_selector('.group_member', count: 1)

View File

@ -18,7 +18,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by access level ascending' do
@ -26,7 +26,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
end
it 'sorts by access level descending' do
@ -34,7 +34,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
end
it 'sorts by last joined' do
@ -42,7 +42,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
end
it 'sorts by oldest joined' do
@ -50,7 +50,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
end
it 'sorts by name ascending' do
@ -58,7 +58,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
end
it 'sorts by name descending' do
@ -66,7 +66,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
@ -74,7 +74,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(maintainer.name)
expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
@ -82,7 +82,7 @@ describe 'Projects > Members > Sorting' do
expect(first_member).to include(developer.name)
expect(second_member).to include(maintainer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
expect(page).to have_css('.user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
end
def visit_members_list(sort:)

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'spec_helper'
describe StarredProjectsFinder do
let(:project1) { create(:project, :public, :empty_repo) }
let(:project2) { create(:project, :public, :empty_repo) }
let(:other_project) { create(:project, :public, :empty_repo) }
let(:user) { create(:user) }
let(:other_user) { create(:user) }
before do
user.toggle_star(project1)
user.toggle_star(project2)
end
describe '#execute' do
let(:finder) { described_class.new(user, params: {}, current_user: current_user) }
subject { finder.execute }
describe 'as same user' do
let(:current_user) { user }
it { is_expected.to contain_exactly(project1, project2) }
end
describe 'as other user' do
let(:current_user) { other_user }
it { is_expected.to contain_exactly(project1, project2) }
end
describe 'as no user' do
let(:current_user) { nil }
it { is_expected.to contain_exactly(project1, project2) }
end
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
require 'spec_helper'
describe UsersStarProjectsFinder do
let(:project) { create(:project, :public, :empty_repo) }
let(:user) { create(:user) }
let(:private_user) { create(:user, private_profile: true) }
let(:other_user) { create(:user) }
before do
user.toggle_star(project)
private_user.toggle_star(project)
end
describe '#execute' do
let(:finder) { described_class.new(project, {}, current_user: current_user) }
let(:public_stars) { user.users_star_projects }
let(:private_stars) { private_user.users_star_projects }
subject { finder.execute }
describe 'as same user' do
let(:current_user) { private_user }
it { is_expected.to match_array(private_stars + public_stars) }
end
describe 'as other user' do
let(:current_user) { other_user }
it { is_expected.to match_array(public_stars) }
end
describe 'as no user' do
let(:current_user) { nil }
it { is_expected.to match_array(public_stars) }
end
end
end

View File

@ -27,7 +27,7 @@ describe UsersHelper do
context 'with public profile' do
it 'includes all the expected tabs' do
expect(tabs).to include(:activity, :groups, :contributed, :projects, :snippets)
expect(tabs).to include(:activity, :groups, :contributed, :projects, :starred, :snippets)
end
end

View File

@ -838,6 +838,28 @@ describe API::Projects do
end
end
describe 'GET /users/:user_id/starred_projects/' do
before do
user3.update(starred_projects: [project, project2, project3])
end
it 'returns error when user not found' do
get api('/users/9999/starred_projects/')
expect(response).to have_gitlab_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns projects filtered by user' do
get api("/users/#{user3.id}/starred_projects/", user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.map { |project| project['id'] }).to contain_exactly(project.id, project2.id, project3.id)
end
end
describe 'POST /projects/user/:id' do
it 'creates new project without path but with name and return 201' do
expect { post api("/projects/user/#{user.id}", admin), params: { name: 'Foo Project' } }.to change { Project.count }.by(1)
@ -2148,6 +2170,85 @@ describe API::Projects do
end
end
describe 'GET /projects/:id/starrers' do
shared_examples_for 'project starrers response' do
it 'returns an array of starrers' do
get api("/projects/#{public_project.id}/starrers", current_user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response[0]['starred_since']).to be_present
expect(json_response[0]['user']).to be_present
end
it 'returns the proper security headers' do
get api('/projects/1/starrers', current_user)
expect(response).to include_security_headers
end
end
let(:public_project) { create(:project, :public) }
let(:private_user) { create(:user, private_profile: true) }
before do
user.update(starred_projects: [public_project])
private_user.update(starred_projects: [public_project])
end
it 'returns not_found(404) for not existing project' do
get api("/projects/9999999999/starrers", user)
expect(response).to have_gitlab_http_status(:not_found)
end
context 'public project without user' do
it_behaves_like 'project starrers response' do
let(:current_user) { nil }
end
it 'returns only starrers with a public profile' do
get api("/projects/#{public_project.id}/starrers", nil)
user_ids = json_response.map { |s| s['user']['id'] }
expect(user_ids).to include(user.id)
expect(user_ids).not_to include(private_user.id)
end
end
context 'public project with user with private profile' do
it_behaves_like 'project starrers response' do
let(:current_user) { private_user }
end
it 'returns current user with a private profile' do
get api("/projects/#{public_project.id}/starrers", private_user)
user_ids = json_response.map { |s| s['user']['id'] }
expect(user_ids).to include(user.id, private_user.id)
end
end
context 'private project' do
context 'with unauthorized user' do
it 'returns not_found for existing but unauthorized project' do
get api("/projects/#{project3.id}/starrers", user3)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'without user' do
it 'returns not_found for existing but unauthorized project' do
get api("/projects/#{project3.id}/starrers", nil)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
describe 'GET /projects/:id/languages' do
context 'with an authorized user' do
it_behaves_like 'languages and percentages JSON response' do