Skip to content

Velocity chart (BE)

Summary

This is the backend implementation issue for Velocity chart.

Proposal

See the proposal in the implementation issue.

Proof of concept

velocity_by_month.rb
require 'date'
require 'json'
require 'net/http'

# Purpose
# Get data on velocity (weight of closed issues).

# Instructions
# Customize access_token and query_params in the initialize method.

class VelocityByMonth

    attr_accessor(
      :access_token,
      :details,
      :query_params,
    )

    GITLAB_BOT_USER_ID = 1786152
    GITLAB_ORG_GROUP_ID = 9970 # gitlab-org

    def initialize(details = false)
      self.access_token = 'PAT_GOES_HERE'
      self.details = details
      self.query_params = {
        # Documentation at https://docs.gitlab.com/ee/api/issues.html#list-issues
        # assignee_username: 'djensen', # weight is divided by assignee count on issues with multiple assignees
        # created_after: '2020-01-01', # for testing
        labels: 'group::analytics,frontend', # Separate multiple with commas
        not: {
          labels: 'triage%20report',
        }
      }
    end

    def data
      {
        average: average_velocity_per_month,
        by_yearmonth: metrics_by_yearmonth.map do |yearmonth, metrics|
          [ yearmonth, metrics[:closed_weight] ]
        end.to_h
      }
    end

    def metrics_by_yearmonth
      return @metrics_by_yearmonth if @metrics_by_yearmonth
      
      @metrics_by_yearmonth = {}
      closed_issues.each do |issue|
        increment_metrics_for_issue(issue: issue, metrics_by_yearmonth: metrics_by_yearmonth)
      end
      @metrics_by_yearmonth = @metrics_by_yearmonth.sort.to_h
    end

    def missing_weight_issue_references
      get_all_of_metric(:missing_weight_issue_references)
    end

    def multiple_assignee_issue_references
      get_all_of_metric(:multiple_assignee_issue_references)
    end

    private

      def assignee_specified?
        query_params.has_key?(:assignee_id) || query_params.has_key?(:assignee_username)
      end

      def average_velocity_per_month
        velocities = metrics_by_yearmonth.map { |yearmonth, metrics| metrics[:closed_weight] }
        total_velocity = velocities.sum
        month_count = metrics_by_yearmonth.keys.size
        (total_velocity.to_f / month_count).round(1)
      end

      def build_query_string(params)
        query_string_default_params.merge(params).map do |key, value|
          if value.kind_of?(Hash)
            value.map do |subkey, subvalue|
              "#{key}[#{subkey}]=#{subvalue}" # see https://docs.gitlab.com/ee/api/#hash
            end.join('&')
          else
            "#{key}=#{value}"
          end
        end.join('&')
      end

      def closed_issues
        params = query_params.merge({ state: 'closed' })
        get_all_response_results(group_issues_url, params)
      end

      def current_month_start_date
        @current_month_start_date ||= (Date.today - Date.today.mday + 1)
      end

      def datetime_from_string(string)
        DateTime.parse(string)
      end

      def earliest_allowed_closed_at
        @earliest_allowed_closed_at ||= current_month_start_date - 365 # days
      end

      def get_all_of_metric(metric)
        metrics_by_yearmonth.map { |yearmonth, metrics| metrics[metric] }.flatten.sort
      end

      def get_all_response_results(url, params)
        results = []
        target_page = 1

        loop do
          params[:page] = target_page
          response = get_response(url, params)
          results.concat(JSON.parse(response.body))
          break if target_page >= response.header['X-Total-Pages'].to_i # Documented at https://docs.gitlab.com/ee/api/README.html#other-pagination-headers

          target_page += 1
        end

        return results
      end

      def get_response(url, params)
        sleep(0.1)
        query_string = build_query_string(params)
        uri = URI("#{url}?#{query_string}")
        request = Net::HTTP::Get.new(uri)
        Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
          http.request(request)
        end
      end

      def group_issues_url
        @group_issues_url ||= "https://gitlab.com/api/v4/groups/#{GITLAB_ORG_GROUP_ID}/issues"
      end

      def increment_metrics_for_issue(issue:, metrics_by_yearmonth:)
        return unless issue_countable?(issue)

        closed_at = datetime_from_string(issue['closed_at'])
        return if closed_at < earliest_allowed_closed_at
        # return if closed_at > current_month_start_date

        assignee_count = issue['assignees'].size
        closed_yearmonth = yearmonth(closed_at)
        weight = (issue['weight'] || 0).to_f
        weight = (weight / assignee_count) if assignee_count > 1 && assignee_specified?
puts "##{issue['id']}: #{weight}"

        metrics_by_yearmonth[closed_yearmonth] = metrics_default unless metrics_by_yearmonth.has_key?(closed_yearmonth)
        metrics_by_yearmonth[closed_yearmonth][:closed_weight] += weight
        if details
          metrics_by_yearmonth[closed_yearmonth][:closed_issues] += 1
          metrics_by_yearmonth[closed_yearmonth][:issue_references] << issue_reference(issue)
          metrics_by_yearmonth[closed_yearmonth][:missing_weight_issue_references] << issue_reference(issue) if weight.zero?
          metrics_by_yearmonth[closed_yearmonth][:multiple_assignee_issue_references] << issue_reference(issue) if assignee_count > 1
        end
      end

      def issue_countable?(issue)
        return false if issue['assignee'].nil? # Un-assigned means closed without execution
        return false if issue['author_id'] == GITLAB_BOT_USER_ID # Triage report
        return false if security_issue?(issue) # security issues duplicate gitlab issues

        return true
      end

      def issue_reference(issue)
        issue['references']['relative']
      end

      def last_complete_yearmonth
        @last_complete_yearmonth ||= team_metrics_by_yearmonth.sort.to_h.keys.last
      end

      def metrics_default
        hash = {
          # assigned_issues: 0,
          # assigned_weight: 0,
          closed_weight: 0.0,
        }
        if details
          hash.merge!({
            closed_issues: 0,
            issue_references: [],
            missing_weight_issue_references: [],
            multiple_assignee_issue_references: [],
          })
        end
        return hash
      end

      def query_string_default_params
        {
          per_page: 50, # Max is 100
          private_token: access_token,
        }
      end

      def security_issue?(issue)
        issue_reference(issue).match?(/^gitlab-org\/security\/gitlab/)
      end

      def yearmonth(datetime)
        date = datetime.to_date
        date.strftime('%Y%m').to_i
      end

end

velocity = VelocityByMonth.new(true)
puts velocity.data

puts "metrics_by_yearmonth:"
puts velocity.metrics_by_yearmonth

if velocity.details
  puts "missing_weight_issue_references (#{velocity.missing_weight_issue_references.size})"
  puts velocity.missing_weight_issue_references

  puts "multiple_assignee_issue_references (#{velocity.multiple_assignee_issue_references.size})"
  puts velocity.multiple_assignee_issue_references
end
Edited by Dan Jensen