dri 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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