diff --git a/CHANGELOG b/CHANGELOG index 999b21f758a..8914068570e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -29,7 +29,7 @@ v 7.8.0 - - - - - + - Add a commit calendar to the user profile (Hannes Rosenögger) - - - diff --git a/Gemfile b/Gemfile index 96a1097d6d8..6e4e20f5e1f 100644 --- a/Gemfile +++ b/Gemfile @@ -154,6 +154,9 @@ gem "slack-notifier", "~> 1.0.0" # d3 gem "d3_rails", "~> 3.1.4" +#cal-heatmap +gem "cal-heatmap-rails", "~> 0.0.1" + # underscore-rails gem "underscore-rails", "~> 1.4.4" diff --git a/Gemfile.lock b/Gemfile.lock index 18fae9b7001..5c70541a735 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -52,6 +52,7 @@ GEM sass (~> 3.2) browser (0.7.2) builder (3.2.2) + cal-heatmap-rails (0.0.1) capybara (2.2.1) mime-types (>= 1.16) nokogiri (>= 1.3.3) @@ -627,6 +628,7 @@ DEPENDENCIES binding_of_caller bootstrap-sass (~> 3.0) browser + cal-heatmap-rails (~> 0.0.1) capybara (~> 2.2.1) carrierwave coffee-rails diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 337170605dc..4912c534b0e 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -39,6 +39,7 @@ #= require shortcuts_dashboard_navigation #= require shortcuts_issueable #= require shortcuts_network +#= require cal-heatmap #= require_tree . window.slugify = (text) -> diff --git a/app/assets/javascripts/calendar.js.coffee b/app/assets/javascripts/calendar.js.coffee new file mode 100644 index 00000000000..e3bb420a278 --- /dev/null +++ b/app/assets/javascripts/calendar.js.coffee @@ -0,0 +1,71 @@ +class @calendar + options = + month: "short" + day: "numeric" + year: "numeric" + + constructor: (timestamps,starting_year,starting_month,activities_path) -> + cal = new CalHeatMap() + cal.init + itemName: ["commit"] + data: timestamps + start: new Date(starting_year, starting_month) + domainLabelFormat: "%b" + id: "cal-heatmap" + domain: "month" + subDomain: "day" + range: 12 + tooltip: true + domainDynamicDimension: false + colLimit: 4 + label: + position: "top" + domainMargin: 1 + legend: [ + 0 + 1 + 4 + 7 + ] + legendCellPadding: 3 + onClick: (date, count) -> + $.ajax + url: activities_path + data: + date: date + + dataType: "json" + success: (data) -> + $("#loading_commits").fadeIn() + calendar.calendarOnClick data, date, count + setTimeout (-> + $("#calendar_onclick_placeholder").fadeIn 500 + return + ), 400 + setTimeout (-> + $("#loading_commits").hide() + return + ), 400 + return + return + return + + @calendarOnClick: (data, date, nb)-> + $("#calendar_onclick_placeholder").hide() + $("#calendar_onclick_placeholder").html -> + "" + + ((if nb is null then "no" else nb)) + + " commit" + + ((if (nb isnt 1) then "s" else "")) + " " + + date.toLocaleDateString("en-US", options) + + "
" + $.each data, (key, data) -> + $.each data, (index, data) -> + $("#calendar_onclick_placeholder").append -> + "Pushed " + ((if data is null then "no" else data)) + " commit" + + ((if (data isnt 1) then "s" else "")) + + " to " + + index + "
" + return + return + return diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 3cf08782c3c..8f63a7fee64 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -8,6 +8,7 @@ *= require select2 *= require_self *= require dropzone/basic + *= require cal-heatmap */ @import "main/*"; diff --git a/app/assets/stylesheets/generic/calendar.scss b/app/assets/stylesheets/generic/calendar.scss new file mode 100644 index 00000000000..9483b26164e --- /dev/null +++ b/app/assets/stylesheets/generic/calendar.scss @@ -0,0 +1,95 @@ +.calendar_onclick_placeholder { + padding: 0 0 2px 0; +} + +.calendar_commit_activity { + padding: 5px 0 0; +} + +.calendar_onclick_second { + font-size: 14px; + display: block; +} + +.calendar_onclick_hr { + padding: 0; + margin: 10px 0; +} + +.calendar_commit_date { + color: #999; +} + +.calendar_activity_summary { + font-size: 14px; +} + +/** +* This overwrites the default values of the cal-heatmap gem +*/ +.calendar { + .qi { + background-color: #999; + fill: #fff; + } + + .q1 { + background-color: #dae289; + fill: #ededed; + } + + .q2 { + background-color: #cedb9c; + fill: #ACD5F2; + } + + .q3 { + background-color: #b5cf6b; + fill: #7FA8D1; + } + + .q4 { + background-color: #637939; + fill: #49729B; + } + + .q5 { + background-color: #3b6427; + fill: #254E77; + } + + .domain-background { + fill: none; + shape-rendering: crispedges; + } + + .ch-tooltip { + position: absolute; + display: none; + margin-top: 22px; + margin-left: 1px; + font-size: 13px; + padding: 3px; + font-weight: 550; + background-color: #222; + span { + position: absolute; + width: 200px; + text-align: center; + visibility: hidden; + border-radius: 10px; + &:after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -8px; + width: 0; + height: 0; + border-top: 8px solid #000000; + border-right: 8px solid transparent; + border-left: 8px solid transparent; + } + } + } +} diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 67af1801bda..a5e80f7e008 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,5 +1,5 @@ class UsersController < ApplicationController - skip_before_filter :authenticate_user!, only: [:show] + skip_before_filter :authenticate_user!, only: [:show, :activities] layout :determine_layout def show @@ -10,7 +10,8 @@ class UsersController < ApplicationController end # Projects user can view - authorized_projects_ids = ProjectsFinder.new.execute(current_user).pluck(:id) + visible_projects = ProjectsFinder.new.execute(current_user) + authorized_projects_ids = visible_projects.pluck(:id) @projects = @user.personal_projects. where(id: authorized_projects_ids) @@ -24,12 +25,32 @@ class UsersController < ApplicationController @title = @user.name + user_repositories = visible_projects.map(&:repository) + @timestamps = Gitlab::CommitsCalendar.create_timestamp(user_repositories, + @user, false) + @starting_year = Gitlab::CommitsCalendar.starting_year(@timestamps) + @starting_month = Gitlab::CommitsCalendar.starting_month(@timestamps) + @last_commit_date = Gitlab::CommitsCalendar.last_commit_date(@timestamps) + respond_to do |format| format.html format.atom { render layout: false } end end + def activities + user = User.find_by_username!(params[:username]) + # Projects user can view + visible_projects = ProjectsFinder.new.execute(current_user) + + user_repositories = visible_projects.map(&:repository) + user_activities = Gitlab::CommitsCalendar.create_timestamp(user_repositories, + user, true) + user_activities = Gitlab::CommitsCalendar.commit_activity_match( + user_activities, params[:date]) + render json: user_activities.to_json + end + def determine_layout if current_user 'navless' diff --git a/app/models/repository.rb b/app/models/repository.rb index e93c76790c7..e44ecca865c 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -139,21 +139,46 @@ class Repository def graph_log Rails.cache.fetch(cache_key(:graph_log)) do - commits = raw_repository.log(limit: 6000, skip_merges: true, - ref: root_ref) - commits.map do |rugged_commit| - commit = Gitlab::Git::Commit.new(rugged_commit) + # handle empty repos that don't have a root_ref set yet + unless raw_repository.root_ref.present? + raw_repository.root_ref = 'refs/heads/master' + end + + commits = raw_repository.log(limit: 6000, skip_merges: true, + ref: raw_repository.root_ref) + + commits.map do |rugged_commit| + + commit = Gitlab::Git::Commit.new(rugged_commit) { author_name: commit.author_name.force_encoding('UTF-8'), author_email: commit.author_email.force_encoding('UTF-8'), additions: commit.stats.additions, - deletions: commit.stats.deletions + deletions: commit.stats.deletions, + date: commit.committed_date } end end end + def graph_logs_by_user_email(user) + graph_log.select { |u_email| u_email[:author_email] == user.email } + end + + def timestamps_by_user_from_graph_log(user) + graph_logs_by_user_email(user).map { |graph_log| graph_log[:date].to_time.to_i } + end + + def commits_log_of_user_by_date(user) + timestamps_by_user_from_graph_log(user). + group_by { |commit_date| commit_date }. + inject({}) do |hash, (timestamp_date, commits)| + hash[timestamp_date] = commits.count + hash + end + end + def cache_key(type) "#{type}:#{path_with_namespace}" end diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 61383315373..c7976ba564f 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -11,5 +11,4 @@ - elsif event.note? = render "events/event/note", event: event - else - = render "events/event/common", event: event - + = render "events/event/common", event: event \ No newline at end of file diff --git a/app/views/users/_calendar.html.haml b/app/views/users/_calendar.html.haml new file mode 100644 index 00000000000..70d5cca854d --- /dev/null +++ b/app/views/users/_calendar.html.haml @@ -0,0 +1,9 @@ +#cal-heatmap.calendar + :javascript + new calendar( + #{@timestamps.to_json}, + #{@starting_year}, + #{@starting_month}, + '#{user_activities_path}' + ); += render "calendar_onclick" diff --git a/app/views/users/_calendar_onclick.html.haml b/app/views/users/_calendar_onclick.html.haml new file mode 100644 index 00000000000..1514b56bb23 --- /dev/null +++ b/app/views/users/_calendar_onclick.html.haml @@ -0,0 +1,25 @@ +#calendar_commit_activity.calendar_commit_activity + %h4.activity_title Commit Activity: + + #loading_commits + %section.text-center + %h3 + %i.icon-spinner.icon-spin + + #calendar_onclick_placeholder.calendar_onclick_placeholder + %span.calendar_onclick_second.calendar_onclick_second + - if @timestamps.empty? + %span.calendar_activity_summary + %strong> #{@user.username} +   has no activity + - else + %span.calendar_activity_summary + %strong> #{@user.username} + 's last commit was on + %span.commit_date #{@last_commit_date} + + %hr.calendar_onclick_hr + +:javascript + $("#loading_commits").hide(); + diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 54f2666ce5d..0d214d31607 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -18,6 +18,8 @@ %h4 Groups: = render 'groups', groups: @groups %hr + %h4 Calendar: + = render 'calendar' %h4 User Activity: diff --git a/config/routes.rb b/config/routes.rb index f29b620e079..5d61de29b9a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -157,6 +157,10 @@ Gitlab::Application.routes.draw do end end + # route for commits used by the cal-heatmap + get 'u/:username/activities' => 'users#activities', as: :user_activities, + constraints: { username: /(?:[^.]|\.(?!atom$))+/, format: /atom/ }, + via: :get get '/u/:username' => 'users#show', as: :user, constraints: { username: /(?:[^.]|\.(?!atom$))+/, format: /atom/ } diff --git a/lib/gitlab/commits_calendar.rb b/lib/gitlab/commits_calendar.rb new file mode 100644 index 00000000000..a862e67a598 --- /dev/null +++ b/lib/gitlab/commits_calendar.rb @@ -0,0 +1,79 @@ +module Gitlab + class CommitsCalendar + def self.create_timestamp(repositories, user, show_activity) + timestamps = {} + repositories.each do |raw_repository| + if raw_repository.exists? + commits_log = raw_repository.commits_log_of_user_by_date(user) + + populated_timestamps = + if show_activity + populate_timestamps_by_project( + commits_log, + timestamps, + raw_repository + ) + else + populate_timestamps(commits_log, timestamps) + end + timestamps.merge!(populated_timestamps) + end + end + timestamps + end + + def self.populate_timestamps(commits_log, timestamps) + commits_log.each do |timestamp_date, commits_count| + hash = { "#{timestamp_date}" => commits_count } + if timestamps.has_key?("#{timestamp_date}") + timestamps.merge!(hash) do |timestamp_date, commits_count, + new_commits_count| commits_count = commits_count.to_i + + new_commits_count + end + else + timestamps.merge!(hash) + end + end + timestamps + end + + def self.populate_timestamps_by_project(commits_log, timestamps, + project) + commits_log.each do |timestamp_date, commits_count| + if timestamps.has_key?("#{timestamp_date}") + timestamps["#{timestamp_date}"]. + merge!(project.path_with_namespace => commits_count) + else + hash = { "#{timestamp_date}" => { project.path_with_namespace => + commits_count } } + timestamps.merge!(hash) + end + end + timestamps + end + + def self.latest_commit_date(timestamps) + if timestamps.nil? || timestamps.empty? + DateTime.now.to_date + else + Time.at(timestamps.keys.first.to_i).to_date + end + end + + def self.starting_year(timestamps) + DateTime.now.to_date - 1 + end + + def self.starting_month(timestamps) + Date.today.strftime("%m").to_i + end + + def self.last_commit_date(timestamps) + latest_commit_date(timestamps).to_formatted_s(:long).to_s + end + + def self.commit_activity_match(user_activities, date) + user_activities.select { |x| Time.at(x.to_i) == Time.parse(date) } + end + end +end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb new file mode 100644 index 00000000000..bfbe5254bbe --- /dev/null +++ b/spec/controllers/users_controller_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe UsersController do + let(:user) { create(:user, username: "user1", name: "User 1", email: "user1@gitlab.com") } + + before do + sign_in(user) + end + + describe "GET #show" do + render_views + before do + get :show, username: user.username + end + + it "renders the show template" do + expect(response.status).to eq(200) + expect(response).to render_template("show") + end + + it "renders calendar" do + controller.prepend_view_path 'app/views/users' + expect(response).to render_template("_calendar") + end + end +end +