require 'curb' require 'sinatra/base' require 'sinatra/partial' require 'rack-flash' require 'json' require 'jira' require_relative 'metadata' module OpsAsk class App < Sinatra::Base set :root, OpsAsk::ROOT # Add flash support enable :sessions use Rack::Flash # Add partials support register Sinatra::Partial set :partial_template_engine, :erb enable :partial_underscores # Serve up our form get '/' do erb :index, locals: { jiras_for_today: issues_for(today), jiras_for_tomorrow: issues_for(tomorrow), untracked_jiras: untracked_issues, stragglers: straggling_issues } end get '/glance' do erb :glance, locals: { jiras_for_today: issues_for(today), jiras_for_tomorrow: issues_for(tomorrow), untracked_jiras: untracked_issues, stragglers: straggling_issues } end get '/untracked' do erb :untracked, locals: { untracked_jiras: untracked_issues, stragglers: straggling_issues } end get '/stragglers' do erb :stragglers, locals: { untracked_jiras: untracked_issues, stragglers: straggling_issues } end get '/sprint/:sprint_num' do num = params[:sprint_num] sprint = get_sprint(num) id = sprint['id'] erb :stats, locals: { sprint: sprint, ask_stats: stats_for( asks_in_sprint(num), "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("labels in (Sprint#{num}) and resolution is not empty", nil)}", "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("labels in (Sprint#{num}) and resolution is empty", nil)}" ), sprint_stats: stats_for( items_in_sprint(num), "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("sprint = #{id} and resolution is not empty", nil)}", "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("sprint = #{id} and resolution is empty", nil)}" ), untracked_jiras: untracked_issues, stragglers: straggling_issues } end get '/sprint' do num = current_sprint_num id = current_sprint_id erb :stats, locals: { sprint: current_sprint, ask_stats: stats_for( asks_in_current_sprint, "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("labels in (Sprint#{num}) and resolution is not empty", nil)}", "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("labels in (Sprint#{num}) and resolution is empty", nil)}" ), sprint_stats: stats_for( items_in_current_sprint, "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("sprint = #{id} and resolution is not empty", nil)}", "#{settings.config[:jira_url]}/issues/?jql=#{URI::escape normalized_jql("sprint = #{id} and resolution is empty", nil)}" ), untracked_jiras: untracked_issues, stragglers: straggling_issues } end # I think everyone should do this get '/version' do content_type :txt "opsask #{settings.config[:app_version]}" end # Try to create a JIRA post '/' do duedate = validate_room_for_new_jiras component, summary, description, assign_to_me, epic, ops_only = validate_jira_params jira = create_jira duedate, component, summary, description, assign_to_me, epic, ops_only if jira.nil? or !jira.has_key?('key') flash[:error] = [ %Q| Failure! JIRA had an issue processing your request. Try again? | ] else flash[:notice] = [ %Q| Success! #{jira['key']} has been created on your behalf. | ] end redirect '/' end # Public assets %w[ css img js fonts ].each do |asset| get "/#{asset}/:file" do send_file "public/#{asset}/#{params[:file]}", :disposition => 'inline' end end get '/favicon.ico' do send_file 'public/favicon.ico', :disposition => 'inline' end # This section gets called before every request. Here, we set up the # OAuth consumer details including the consumer key, private key, # site uri, and the request token, access token, and authorize paths before do options = { :site => settings.config[:jira_url], :context_path => '', :signature_method => 'RSA-SHA1', :request_token_path => "#{settings.config[:jira_url]}/plugins/servlet/oauth/request-token", :authorize_url => "#{settings.config[:jira_url]}/plugins/servlet/oauth/authorize", :access_token_path => "#{settings.config[:jira_url]}/plugins/servlet/oauth/access-token", :private_key_file => settings.config[:jira_private_key], :rest_base_path => "#{settings.config[:jira_url]}/rest/api/latest", :consumer_key => settings.config[:jira_consumer_key] } @jira_client = JIRA::Client.new(options) # @jira_client.consumer.http.set_debug_output($stderr) # Add AccessToken if authorised previously. if session[:jira_auth] @jira_client.set_access_token( session[:jira_auth][:access_token], session[:jira_auth][:access_key] ) if @project.nil? @project = @jira_client.Project.find(settings.config[:project_key]) end end # Keep a pointer to myself begin response = @jira_client.get( @jira_client.options[:rest_base_path] + '/myself?expand=groups' ) @myself = JSON::parse response.body @me = @myself['name'] rescue JIRA::OauthClient::UninitializedAccessTokenError end end # Retrieves the @access_token then stores it inside a session cookie. In a real app, # you'll want to persist the token in a datastore associated with the user. get '/callback/' do request_token = @jira_client.set_request_token( session[:request_token], session[:request_secret] ) access_token = @jira_client.init_access_token( :oauth_verifier => params[:oauth_verifier] ) session[:jira_auth] = { :access_token => access_token.token, :access_key => access_token.secret } session.delete(:request_token) session.delete(:request_secret) redirect '/' end # Initialize the JIRA session get '/login' do if logged_in? session.clear redirect '/logout' end request_token = @jira_client.request_token session[:request_token] = request_token.token session[:request_secret] = request_token.secret redirect request_token.authorize_url end # Expire the JIRA session get '/logout' do session.delete(:jira_auth) redirect '/' end helpers do def pluralize n, item case n when 0 ; '%s %ss' when 1 ; '%s %s' else ; '%s %ss' end % [ n, item ] end end private def logged_in? !!session[:jira_auth] end def ops? return false unless logged_in? @myself['groups']['items'].each do |i| return true if i['name'] == settings.config[:ops_group] end return false end def one_day 1 * 24 * 60 * 60 # Day * Hour * Minute * Second = Seconds / Day end def now Time.now # + 3 * one_day # DEBUG end def todays_date offset=0 date = now + offset date += one_day if date.saturday? date += one_day if date.sunday? return date end def stats_for issues, resolved_link, unresolved_link return {} unless logged_in? return {} unless issues resolved_issues, unresolved_issues = [], [] issues.map! do |i| key = i['key'] status = i['fields']['status']['name'] resolution = i['fields']['resolution']['name'] rescue nil points = i['fields']['customfield_10002'].to_i issue = { key: key, status: status, resolution: resolution, points: points } if resolution.nil? unresolved_issues << issue else resolved_issues << issue end end { resolved: { number: resolved_issues.size, points: resolved_issues.map { |i| i[:points] }.reduce(0, :+), link: resolved_link }, unresolved: { number: unresolved_issues.size, points: unresolved_issues.map { |i| i[:points] }.reduce(0, :+), link: unresolved_link } } end def items_in_current_sprint items_in_sprint current_sprint_num end def items_in_sprint num return [] unless logged_in? issues = [] id = get_sprint(num)['id'] query = normalized_jql("sprint = #{id}", nil) @jira_client.Issue.jql(query, max_results: 500).each do |i| issues << i.attrs end return issues end def asks_in_current_sprint asks_in_sprint current_sprint_num end def asks_in_sprint num return [] unless logged_in? issues = [] query = normalized_jql("labels in (Sprint#{num})", nil) @jira_client.Issue.jql(query, max_results: 500).each do |i| issues << i.attrs end return issues end def sprints url = "#{settings.config[:jira_url]}/rest/greenhopper/1.0/sprintquery/#{settings.config[:agile_board]}" curl_request = Curl::Easy.http_get(url) do |curl| curl.headers['Accept'] = 'application/json' curl.headers['Content-Type'] = 'application/json' curl.http_auth_types = :basic curl.username = settings.config[:jira_user] curl.password = settings.config[:jira_pass] curl.verbose = true end raw_response = curl_request.body_str begin data = JSON::parse(raw_response) return data['sprints'] rescue $stderr.puts "Failed to parse response from JIRA: #{raw_response}" end return nil end def get_sprint num sprint = sprints.select { |s| s['name'] == "Sprint #{num}" } sprint_id = sprint.first['id'] url = "#{settings.config[:jira_url]}/rest/greenhopper/1.0/rapid/charts/sprintreport?rapidViewId=#{settings.config[:agile_board]}&sprintId=#{sprint_id}" curl_request = Curl::Easy.http_get(url) do |curl| curl.headers['Accept'] = 'application/json' curl.headers['Content-Type'] = 'application/json' curl.http_auth_types = :basic curl.username = settings.config[:jira_user] curl.password = settings.config[:jira_pass] curl.verbose = true end raw_response = curl_request.body_str begin data = JSON::parse(raw_response) contents = data.delete('contents') data = data.delete('sprint') return data.merge(contents) rescue $stderr.puts "Failed to parse response from JIRA: #{raw_response}" end return {} end def current_sprint_name sprint=current_sprint sprint.nil? ? nil : sprint['name'].gsub(/\s+/, '') end def current_sprint_num sprint=current_sprint sprint.nil? ? nil : sprint['name'].gsub(/\D+/, '') end def current_sprint_id sprint=current_sprint sprint.nil? ? nil : sprint['id'] end def current_sprint keys=[ 'sprintsData', 'sprints', 0 ] url = "#{settings.config[:jira_url]}/rest/greenhopper/1.0/xboard/work/allData.json?rapidViewId=#{settings.config[:agile_board]}" curl_request = Curl::Easy.http_get(url) do |curl| curl.headers['Accept'] = 'application/json' curl.headers['Content-Type'] = 'application/json' curl.http_auth_types = :basic curl.username = settings.config[:jira_user] curl.password = settings.config[:jira_pass] curl.verbose = true end raw_response = curl_request.body_str begin data = JSON::parse(raw_response) keys.each { |k| data = data[k] } return data unless data.nil? rescue $stderr.puts "Failed to parse response from JIRA: #{raw_response}" end return sprints.last end def today offset=0 todays_date(offset).strftime '%Y-%m-%d' end def tomorrow today(one_day) end def name_for_today offset=0 todays_date(offset).strftime '%A %-d %b' end def name_for_tomorrow name_for_today(one_day) end def name_for_coming_week todays_date.strftime 'Week of %-d %b' end def jiras_for date return [] unless logged_in? unless ops? return @jira_client.Issue.jql normalized_jql("due = #{date} and type != Change and labels in (OpsAsk) and labels not in (OpsOnly)"), max_results: 100 end return @jira_client.Issue.jql normalized_jql("due = #{date} and labels in (OpsAsk) and type != Change"), max_results: 100 end def jira_count_for date jiras_for(date).length end def jira_count_for_today ; jira_count_for(today) end def jira_count_for_tomorrow ; jira_count_for(tomorrow) end def raw_classes_for jira classes = [ jira.fields['resolution'].nil? ? 'open' : 'closed' ] classes << jira.fields['assignee']['name'].downcase.gsub(/\W+/, '') end def classes_for jira raw_classes_for(jira).join(' ') end def sorting_key_for jira rcs = raw_classes_for(jira) idx = 1 idx = 2 if rcs.include? 'denimcores' idx = 0 if rcs.include? 'closed' return "#{idx}-#{jira.key}" end def issues_for date jiras_for(date).sort_by do |jira| sorting_key_for(jira) end.reverse end def its_the_weekend? now.saturday? || now.sunday? end def room_for_new_jiras_for? date return true if ops? jira_count_for(date) < settings.config[:queue_size] end def date_for_new_jiras if now.hour < settings.config[:cutoff_hour] || its_the_weekend? return today if room_for_new_jiras_for? today return tomorrow if room_for_new_jiras_for? tomorrow else return tomorrow if room_for_new_jiras_for? tomorrow end return nil end def room_for_new_jiras? return true if ops? !date_for_new_jiras.nil? end def validate_room_for_new_jiras duedate = date_for_new_jiras return duedate unless duedate.nil? flash[:error] = [ "Sorry, there's is no room for new JIRAs" ] redirect '/' end def validate_jira_params flash[:error] = [] flash[:error] << 'Summary is required' if params['jira-summary'].empty? redirect '/' unless flash[:error].empty? return [ params['jira-component'], params['jira-summary'], params['jira-description'], !!params['jira-assign_to_me'], params['jira-epic'], !!params['jira-ops_only'] ] end def create_jira duedate, component, summary, description, assign_to_me, epic, ops_only epic = 'INF-3091' if epic.nil? # OpsAsk default epic assignee = assign_to_me ? @me : settings.config[:assignee] components = [] components = [ { name: component } ] unless component labels = [ 'OpsAsk', current_sprint_name ].compact labels << 'OpsOnly' if ops_only labels << settings.config[:require_label] if settings.config[:require_label] data = { fields: { project: { key: settings.config[:project_key] }, issuetype: { name: settings.config[:issue_type] }, versions: [ { name: settings.config[:version] } ], duedate: duedate, summary: summary, description: description, components: components, assignee: { name: assignee }, reporter: { name: @me }, labels: labels, customfield_10002: 1, # Story Points = 1 # customfield_10350: epic, customfield_10040: { id: '-1' } # Release Priority = None } } url = "#{settings.config[:jira_url]}/rest/api/latest/issue" curl_request = Curl::Easy.http_post(url, data.to_json) do |curl| curl.headers['Accept'] = 'application/json' curl.headers['Content-Type'] = 'application/json' curl.http_auth_types = :basic curl.username = settings.config[:jira_user] curl.password = settings.config[:jira_pass] curl.verbose = true end raw_response = curl_request.body_str begin response = JSON::parse raw_response rescue $stderr.puts "Failed to parse response from JIRA: #{raw_response}" return nil end return response end def components return @project.components.map(&:name).select { |c| c =~ /^Ops/ } end def untracked_issues return [] unless logged_in? constraints = [ "due < #{today}", "resolution = unresolved", "assignee = denimcores" ].join(' and ') @jira_client.Issue.jql(normalized_jql(constraints), max_results: 100).sort_by do |jira| sorting_key_for(jira) end.reverse end def straggling_issues return [] unless logged_in? constraints = [ "due < #{today}", "labels in (OpsAsk)", "resolution = unresolved", "assignee != denimcores" ].join(' and ') @jira_client.Issue.jql(normalized_jql(constraints), max_results: 100).sort_by do |jira| sorting_key_for(jira) end.reverse end def epics data = { jql: normalized_jql("type = Epic"), startAt: 0, maxResults: 1000 } url = "#{settings.config[:jira_url]}/rest/api/latest/search" curl_request = Curl::Easy.http_post(url, data.to_json) do |curl| curl.headers['Accept'] = 'application/json' curl.headers['Content-Type'] = 'application/json' curl.http_auth_types = :basic curl.username = settings.config[:jira_user] curl.password = settings.config[:jira_pass] curl.verbose = true end raw_response = curl_request.body_str begin response = JSON::parse raw_response rescue $stderr.puts "Failed to parse response from JIRA: #{raw_response}" return nil end return response['issues'].map do |epic| { 'key' => epic['key'], 'name' => epic['fields']['customfield_10351'] || epic['fields']['summary'] } end end def epic key url = "#{settings.config[:jira_url]}/rest/api/latest/issue/#{key}" curl_request = Curl::Easy.http_get(url) do |curl| curl.headers['Accept'] = 'application/json' curl.headers['Content-Type'] = 'application/json' curl.http_auth_types = :basic curl.username = settings.config[:jira_user] curl.password = settings.config[:jira_pass] curl.verbose = true end raw_response = curl_request.body_str begin response = JSON::parse raw_response rescue $stderr.puts "Failed to parse response from JIRA: #{raw_response}" return nil end return { 'key' => response['key'], 'name' => response['fields']['customfield_10351'] || response['fields']['summary'] } end def normalized_jql query, \ project=settings.config[:project_name], \ require_label=settings.config[:require_label], ignore_label=settings.config[:ignore_label] # ... query += %Q| and project = #{project}| if project query += %Q| and labels = #{require_label}| if require_label query += %Q| and (labels != #{ignore_label} OR labels is empty)| if ignore_label return query end end end