dri 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'command'
3
+ require_relative 'api_client'
4
+
5
+ require "tty-config"
6
+ require "pastel"
7
+ require 'forwardable'
8
+
9
+ module Dri
10
+ class Command
11
+ extend Forwardable
12
+
13
+ def_delegators :command, :run
14
+ attr_reader :config, :emoji, :token, :username, :timezone, :profile
15
+
16
+ def pastel(**options)
17
+ Pastel.new(**options)
18
+ end
19
+
20
+ # Main configuration
21
+ def config
22
+ @config ||= begin
23
+ config = TTY::Config.new
24
+ config.filename = ".dri_profile"
25
+ config.extname = ".yml"
26
+ config.append_path Dir.pwd
27
+ config.append_path Dir.home
28
+ config
29
+ end
30
+ end
31
+
32
+ def api_client
33
+ ApiClient.new(config)
34
+ end
35
+
36
+ def profile
37
+ @profile ||= config.read
38
+ end
39
+
40
+ def emoji
41
+ @emoji ||= profile["settings"]["emoji"]
42
+ end
43
+
44
+ def username
45
+ @username ||= profile["settings"]["user"]
46
+ end
47
+
48
+ def token
49
+ @token ||= profile["settings"]["token"]
50
+ end
51
+
52
+ def timezone
53
+ @timezone ||= profile["settings"]["timezone"]
54
+ end
55
+
56
+ def verify_config_exists
57
+ if !config.exist?
58
+ logger.error "Oops, could not find a configuration. Try using #{add_color('dri init', :yellow)} first."
59
+ exit 1
60
+ end
61
+ end
62
+
63
+ def add_color(str, *color)
64
+ @options[:no_color] ? str : pastel.decorate(str, *color)
65
+ end
66
+
67
+ # Execute this command
68
+ #
69
+ # @api public
70
+ def execute(*)
71
+ raise(
72
+ NotImplementedError,
73
+ "#{self.class}##{__method__} must be implemented"
74
+ )
75
+ end
76
+
77
+ def logger
78
+ require 'tty-logger'
79
+ TTY::Logger.new
80
+ end
81
+
82
+ def spinner
83
+ require 'tty-spinner'
84
+ TTY::Spinner.new("[:spinner] ⏳", format: :classic)
85
+ end
86
+
87
+ # The external commands runner
88
+ #
89
+ # @see http://www.rubydoc.info/gems/tty-command
90
+ #
91
+ # @api public
92
+ def command(**options)
93
+ require 'tty-command'
94
+ TTY::Command.new(options)
95
+ end
96
+
97
+ # The cursor movement
98
+ #
99
+ # @see http://www.rubydoc.info/gems/tty-cursor
100
+ #
101
+ # @api public
102
+ def cursor
103
+ require 'tty-cursor'
104
+ TTY::Cursor
105
+ end
106
+
107
+ # Open a file or text in the user's preferred editor
108
+ #
109
+ # @see http://www.rubydoc.info/gems/tty-editor
110
+ #
111
+ # @api public
112
+ def editor
113
+ require 'tty-editor'
114
+ TTY::Editor
115
+ end
116
+
117
+ # The interactive prompt
118
+ #
119
+ # @see http://www.rubydoc.info/gems/tty-prompt
120
+ #
121
+ # @api public
122
+ def prompt(**options)
123
+ require 'tty-prompt'
124
+ TTY::Prompt.new(options)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../command'
4
+
5
+ require 'tty-table'
6
+
7
+ module Dri
8
+ module Commands
9
+ class Fetch
10
+ class Failures < Dri::Command
11
+ def initialize(options)
12
+ @options = options
13
+ @today_iso_format = Time.now.strftime('%Y-%m-%dT00:00:00Z')
14
+ end
15
+
16
+ def execute(input: $stdin, output: $stdout)
17
+ verify_config_exists
18
+
19
+ title = add_color('Title', :bright_yellow)
20
+ triaged = add_color('Triaged?', :bright_yellow)
21
+ author = add_color('Author', :bright_yellow)
22
+ url = add_color('URL', :bright_yellow)
23
+
24
+ failures = []
25
+ labels = [ title, triaged, author, url ]
26
+ triaged_counter = 0
27
+
28
+ logger.info "Fetching today\'s failures..."
29
+
30
+ spinner.run do
31
+
32
+ response = api_client.fetch_failures(date: @today_iso_format, state: 'opened')
33
+
34
+ if response.nil?
35
+ logger.info "Life is great, there are no new failures today!"
36
+ exit 0
37
+ end
38
+
39
+ response.each do |failure|
40
+ title = truncate(failure["title"], 60)
41
+ author = failure["author"]["username"]
42
+ url = failure["web_url"]
43
+ award_emoji_url = failure["_links"]["award_emoji"]
44
+ triaged = add_color('x', :red)
45
+
46
+ emoji_awards = api_client.fetch_awarded_emojis(award_emoji_url)
47
+
48
+ triaged = add_color('✓', :green) && triaged_counter += 1 if emoji_awards.find do |e|
49
+ e['name'] == emoji && e['user']['username'] == @username
50
+ end
51
+
52
+ failures << [title, triaged, author, url]
53
+ end
54
+ end
55
+
56
+ table = TTY::Table.new(labels, failures)
57
+ puts table.render(:ascii, resize: true, alignments: [:center, :center, :center, :center])
58
+ output.puts "\nFound: #{failures.size} failures, of these #{triaged_counter} have been triaged with a #{emoji}."
59
+ end
60
+
61
+ private
62
+
63
+ def truncate(string, max)
64
+ string.length > max ? "#{string[0...max]}..." : string
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../command'
4
+
5
+ require 'pastel'
6
+
7
+ module Dri
8
+ module Commands
9
+ class Fetch
10
+ class Testcases < Dri::Command
11
+
12
+ def initialize(options)
13
+ @options = options
14
+ @available_pipelines = %w(main canary master nightly production staging-canary staging-orchestrated staging-ref staging)
15
+ end
16
+
17
+ def execute(input: $stdin, output: $stdout)
18
+ verify_config_exists
19
+
20
+ if @options[:filter_pipelines]
21
+ filtered_pipelines = prompt.multi_select("Select pipelines:", @available_pipelines)
22
+ end
23
+
24
+ logger.info "Fetching currently failing testcases..."
25
+
26
+ title_label = add_color('Title:', :bright_yellow)
27
+ url_label = add_color('URL:', :bright_yellow)
28
+ divider = add_color('---', :cyan)
29
+
30
+ spinner.start
31
+
32
+ pipelines = @options[:filter_pipelines] ? filtered_pipelines : @available_pipelines
33
+
34
+ pipelines.each do |pipeline|
35
+ logger.info "Fetching failing testcases in #{pipeline}\n"
36
+ response = api_client.fetch_failing_testcases(pipeline, state: 'opened')
37
+
38
+ output.puts "♦♦♦♦♦ #{add_color(pipeline, :black, :on_white)}♦♦♦♦♦\n\n"
39
+
40
+ response.each do |pipeline|
41
+ output.puts "#{title_label} #{pipeline["title"]}\n#{url_label} #{pipeline["web_url"]}"
42
+ output.puts "#{divider}\n"
43
+ end
44
+ end
45
+
46
+ spinner.stop
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Dri
6
+ module Commands
7
+ class Fetch < Thor
8
+ namespace :fetch
9
+
10
+ desc 'testcases', 'Display failing testcases'
11
+ method_option :help, aliases: '-h', type: :boolean,
12
+ desc: 'Display usage information'
13
+ method_option :filter_pipelines, type: :boolean,
14
+ desc: 'Filter by pipeline'
15
+ def testcases(*)
16
+ if options[:help]
17
+ invoke :help, ['testcases']
18
+ else
19
+ require_relative 'fetch/testcases'
20
+ Dri::Commands::Fetch::Testcases.new(options).execute
21
+ end
22
+ end
23
+
24
+ desc 'failures', 'Display failures opened today'
25
+ method_option :help, aliases: '-h', type: :boolean,
26
+ desc: 'Display usage information'
27
+ def failures(*)
28
+ if options[:help]
29
+ invoke :help, ['failures']
30
+ else
31
+ require_relative 'fetch/failures'
32
+ Dri::Commands::Fetch::Failures.new(options).execute
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+
5
+ require "tty-font"
6
+ require "pastel"
7
+
8
+ module Dri
9
+ module Commands
10
+ class Init < Dri::Command
11
+ def initialize(options)
12
+ font = TTY::Font.new(:doom)
13
+ puts pastel.yellow(font.write("DRI"))
14
+ end
15
+
16
+ def execute(input: $stdin, output: $stdout)
17
+ output.puts "🤖 Welcome to DRI 🤖\n"
18
+
19
+ logger.info "🔎 Scanning for existing configurations...\n"
20
+
21
+ if config.exist?
22
+ overwrite = prompt.yes?("There is already a configuration initialized. Would you like to overwrite it?")
23
+ unless overwrite
24
+ output.puts "Using existing configuration. To view configuration in use try #{add_color('dri profile', :yellow)}."
25
+ exit 0
26
+ end
27
+ end
28
+
29
+ @username = prompt.ask("What is your GitLab username?")
30
+ @token = prompt.mask("Please provide your GitLab personal access token:")
31
+ @timezone = prompt.select("Choose your current timezone?", %w(EMEA AMER APAC))
32
+ @emoji = prompt.ask("Have a triage emoji?")
33
+
34
+ if (@emoji || @token || @username).nil?
35
+ logger.error "Please provide a username, token, timezone and emoji used for triage."
36
+ exit 1
37
+ end
38
+
39
+ config.set(:settings, :user, value: @username)
40
+ config.set(:settings, :token, value: @token)
41
+ config.set(:settings, :timezone, value: @timezone)
42
+ config.set(:settings, :emoji, value: @emoji)
43
+ config.write(force: true)
44
+
45
+ logger.success "✅ We're ready to go 🚀"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+
5
+ require 'tty-box'
6
+
7
+ module Dri
8
+ module Commands
9
+ class Profile < Dri::Command
10
+ def initialize(options)
11
+ @options = options
12
+ end
13
+
14
+ def execute(input: $stdin, output: $stdout)
15
+ if config.exist? && @options["edit"]
16
+ editor.open(config.source_file)
17
+ return
18
+ end
19
+
20
+ logger.info "🔎 Looking for profiles...\n"
21
+
22
+ if config.exist?
23
+ box = TTY::Box.frame(width: 30, height: 10, align: :center, padding: 1, title: {top_left: add_color('PROFILE:', :bright_cyan)}, border: :thick) do
24
+ pretty_print_profile
25
+ end
26
+ print box
27
+ output.puts "☝️ To modify this profile try passing #{add_color('dri profile --edit', :yellow)}.\n"
28
+ else
29
+ logger.error "Oops.. Profile not found. Try creating one using #{add_color('dri init', :yellow)}."
30
+ end
31
+ end
32
+
33
+ def pretty_print_profile
34
+ "#{add_color('User:', :bright_cyan)} #{username}\n #{add_color('Token:', :bright_cyan)} #{token}\n #{add_color('Timezone:', :bright_cyan)} #{timezone}\n #{add_color('Emoji:', :bright_cyan)} #{emoji}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,86 @@
1
+ require_relative '../../command'
2
+ require_relative '../../utils/markdown_lists'
3
+ require_relative "../../report"
4
+
5
+ require 'markdown-tables'
6
+ require 'fileutils'
7
+ require "uri"
8
+
9
+ module Dri
10
+ module Commands
11
+ class Publish
12
+ class Report < Dri::Command
13
+ def initialize(options)
14
+ @options = options
15
+
16
+ @date = Date.today
17
+ @time = Time.now.to_i
18
+ end
19
+
20
+ def execute(input: $stdin, output: $stdout)
21
+ verify_config_exists
22
+ report = Dri::Report.new(config)
23
+
24
+ logger.info "Fetching triaged failures with award emoji #{emoji}..."
25
+
26
+ spinner.start
27
+ issues = api_client.fetch_triaged_failures(emoji: emoji, state: 'opened')
28
+ spinner.stop
29
+
30
+ if issues.empty?
31
+ logger.warn "Found no issues associated with \"#{emoji}\" emoji. Will exit. Bye 👋"
32
+ exit 1
33
+ end
34
+
35
+ logger.info "Assembling the report... "
36
+ # sets each failure on the table
37
+ spinner.start
38
+ issues.each do |issue|
39
+ report.add_failure(issue)
40
+ end
41
+
42
+ if @options[:format] == 'list'
43
+ # generates markdown list with failures
44
+ format_style = Utils::MarkdownLists.make_list(report.labels, report.failures) unless report.failures.empty?
45
+ else
46
+ # generates markdown table with rows as failures
47
+ format_style = MarkdownTables.make_table(report.labels, report.failures, is_rows: true, align: %w[c l c l l]) unless report.failures.empty?
48
+ end
49
+
50
+ report.set_header(timezone, username)
51
+ note = "#{report.header}\n\n#{format_style}"
52
+
53
+ spinner.stop
54
+
55
+ # creates an .md file with the report locally in /handover_reports
56
+ if @options[:dry_run]
57
+ logger.info "Downloading the report... "
58
+
59
+ spinner.start
60
+
61
+ FileUtils.mkdir_p("#{Dir.pwd}/handover_reports")
62
+ report_path = "handover_reports/report-#{@date}-#{@time}.md"
63
+
64
+ File.open(report_path, 'a') do |out_file|
65
+ out_file.puts note
66
+ end
67
+
68
+ spinner.stop
69
+
70
+ output.puts "Done! ✅\n"
71
+ logger.success "Report is ready at: #{report_path}"
72
+ exit 0
73
+ end
74
+
75
+ # sends note to the weekly triage report
76
+ issues = api_client.fetch_current_triage_issue
77
+ current_issue_iid = issues[0]["iid"]
78
+ response = api_client.post_triage_report_note(iid: current_issue_iid, body: note)
79
+
80
+ output.puts "Done! ✅\n"
81
+ logger.success "Thanks @#{username}, your report was posted at https://gitlab.com/gitlab-org/quality/dri/-/issues/#{current_issue_iid} 🎉"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Dri
6
+ module Commands
7
+ class Publish < Thor
8
+
9
+ namespace :publish
10
+
11
+ desc 'report', 'Generate a report'
12
+ method_option :dry_run, type: :boolean,
13
+ desc: 'Generates a report locally'
14
+ method_option :format, aliases: '-f', type: :string, :default => "table",
15
+ desc: 'Formats the report'
16
+ def report(*)
17
+ if options[:help]
18
+ invoke :help, ['report']
19
+ else
20
+ require_relative 'publish/report'
21
+ Dri::Commands::Publish::Report.new(options).execute
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../command'
4
+
5
+ module Dri
6
+ module Commands
7
+ class Rm
8
+ class Emoji < Dri::Command
9
+ def initialize(options)
10
+ @options = options
11
+ end
12
+
13
+ def execute(input: $stdin, output: $stdout)
14
+ verify_config_exists
15
+
16
+ remove = prompt.yes? "Are you sure you want to remove all #{emoji} award emojis from issues?"
17
+
18
+ unless remove
19
+ logger.info "Emojis kept in place 👍"
20
+ exit 0
21
+ end
22
+
23
+ logger.info "Removing #{emoji} from issues..."
24
+
25
+ spinner.start
26
+
27
+ issues_with_award_emoji = api_client.fetch_triaged_failures(emoji: emoji, state: 'opened')
28
+
29
+ spinner.stop
30
+
31
+ issues_with_award_emoji.each do |issue|
32
+ logger.info "Removing #{emoji} from #{issue["web_url"]}..."
33
+
34
+ award_emoji_url = issue["_links"]["award_emoji"]
35
+
36
+ response = api_client.fetch_awarded_emojis(award_emoji_url)
37
+
38
+ emoji_found = response.find { |e| e['name'] == emoji && e['user']['username'] == username }
39
+
40
+ if !emoji_found.nil?
41
+ url = "#{award_emoji_url}/#{emoji_found["id"]}"
42
+ api_client.delete_award_emoji(url)
43
+ end
44
+ end
45
+ output.puts "Done! ✅"
46
+ logger.success "Removed #{emoji} from #{issues_with_award_emoji.size} issue(s)."
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Dri
6
+ module Commands
7
+ class Rm < Thor
8
+
9
+ namespace :rm
10
+
11
+ desc 'emoji', 'Remove triage emoji from all failures'
12
+ method_option :help, aliases: '-h', type: :boolean,
13
+ desc: 'Display usage information'
14
+ def emoji(*)
15
+ if options[:help]
16
+ invoke :help, ['emoji']
17
+ else
18
+ require_relative 'rm/emoji'
19
+ Dri::Commands::Rm::Emoji.new(options).execute
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/dri/report.rb ADDED
@@ -0,0 +1,121 @@
1
+ module Dri
2
+ class Report
3
+ attr_reader :header, :failures, :labels
4
+
5
+ def initialize(config)
6
+ @labels = ['Title', 'Issue', 'Pipelines', 'Stack Trace', 'Actions']
7
+ @failures = []
8
+ @date = Date.today
9
+ @today = Date.today.strftime("%Y-%m-%d")
10
+ @weekday = Date.today.strftime("%A")
11
+ @header = nil
12
+
13
+ @api_client = ApiClient.new(config)
14
+ end
15
+
16
+ def set_header(timezone, username)
17
+ @header = "# #{timezone}, #{@weekday} - #{@date}\n posted by: @#{username}"
18
+ end
19
+
20
+ def add_failure(failure)
21
+ iid = failure["iid"]
22
+ title = failure["title"]
23
+ link = failure["web_url"]
24
+ labels = failure["labels"]
25
+ created_at = failure["created_at"]
26
+ assignees = failure["assignees"]
27
+ award_emoji_url = failure["_links"]["award_emoji"]
28
+ description = failure["description"]
29
+
30
+ related_mrs = @api_client.fetch_related_mrs(issue_iid: iid)
31
+ emoji = classify_failure_emoji(created_at)
32
+ emojified_link = "#{emoji} #{link}"
33
+
34
+ stack_blob = description.empty? ? "No stack trace found" : description.split("### Stack trace").last.gsub(/\n|`|!|\[|\]/, '').squeeze(" ")[0...250]
35
+ stack_trace = ":link:[`#{stack_blob}...`](#{link + '#stack-trace'})"
36
+
37
+ failure_type = filter_failure_type_labels(labels)
38
+ assigned_status = assigned?(assignees)
39
+ pipelines = filter_pipeline_labels(labels)
40
+
41
+ linked_pipelines = link_pipelines(iid, pipelines)
42
+
43
+ actions = ""
44
+ actions.concat actions_status_template(failure_type, assigned_status)
45
+ actions.concat actions_fixes_template(related_mrs)
46
+
47
+ @failures << [title, emojified_link, linked_pipelines, stack_trace, actions]
48
+ end
49
+
50
+ private
51
+
52
+ def link_pipelines(iid, pipelines)
53
+ failure_notes = @api_client.fetch_failure_notes(issue_iid: iid)
54
+
55
+ linked = ""
56
+
57
+ pipelines.each do |pipeline|
58
+ failure_notes.each do |note|
59
+ if note["body"].include? pipeline
60
+ pipeline_link = URI.extract(note["body"], %w(https))
61
+ pipeline_link_sanitized = pipeline_link.join.strip
62
+ pipeline = "[#{pipeline}](#{pipeline_link_sanitized})"
63
+ break
64
+ end
65
+ end
66
+
67
+ linked << pipeline
68
+ end
69
+ linked
70
+ end
71
+
72
+ def actions_status_template(failure_type, assigned_status)
73
+ "<i>Status:</i><ul><li>#{failure_type}</li><li>#{assigned_status}</li><li>[ ] notified SET</li><li>[ ] quarantined</li></ul>"
74
+ end
75
+
76
+ def actions_fixes_template(related_mrs)
77
+ actions_fixes_template = '<ul><i>Potential fixes:</i><br>'
78
+ related_mrs.each do |mr|
79
+ actions_fixes_template.concat "<li>[#{mr["title"]}](#{mr["web_url"]})</li>"
80
+ end
81
+ actions_fixes_template.concat '</ul>'
82
+ actions_fixes_template
83
+ end
84
+
85
+ def assigned?(assignees)
86
+ assignees.empty? ? 'Assigned :x:' : 'Assigned :white_check_mark:'
87
+ end
88
+
89
+ def filter_pipeline_labels(labels)
90
+ pipelines = []
91
+
92
+ labels.each do |label|
93
+ matchers = { 'found:' => ' ', '.gitlab.com' => ' ' }
94
+
95
+ if label.include? "found:"
96
+ pipeline = label.gsub(/found:|.gitlab.com/) { |match| matchers[match] }
97
+ pipelines << pipeline.strip
98
+ end
99
+ end
100
+ pipelines
101
+ end
102
+
103
+ def filter_failure_type_labels(labels)
104
+ labels.each do |label|
105
+ @type = label.gsub!('failure::', ' ').to_s if label.include? "failure::"
106
+ end
107
+ @type
108
+ end
109
+
110
+ def classify_failure_emoji(created_at)
111
+ new_failure_emoji = ':boom:'
112
+ known_failure_emoji = ':fire_engine:'
113
+
114
+ if created_at.include? @today
115
+ new_failure_emoji
116
+ else
117
+ known_failure_emoji
118
+ end
119
+ end
120
+ end
121
+ end