lazylead 0.5.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +49 -1
  3. data/.simplecov +1 -1
  4. data/Guardfile +1 -1
  5. data/Rakefile +4 -3
  6. data/bin/lazylead +9 -5
  7. data/lazylead.gemspec +20 -15
  8. data/lib/lazylead/cc.rb +21 -20
  9. data/lib/lazylead/cli/app.rb +13 -5
  10. data/lib/lazylead/confluence.rb +8 -1
  11. data/lib/lazylead/email.rb +0 -20
  12. data/lib/lazylead/exchange.rb +16 -28
  13. data/lib/lazylead/log.rb +2 -1
  14. data/lib/lazylead/model.rb +31 -16
  15. data/lib/lazylead/opts.rb +65 -2
  16. data/lib/lazylead/postman.rb +18 -16
  17. data/lib/lazylead/salt.rb +1 -0
  18. data/lib/lazylead/smtp.rb +3 -1
  19. data/lib/lazylead/system/jira.rb +31 -6
  20. data/lib/lazylead/task/accuracy/accuracy.rb +8 -10
  21. data/lib/lazylead/task/accuracy/attachment.rb +0 -4
  22. data/lib/lazylead/task/accuracy/logs.rb +8 -4
  23. data/lib/lazylead/task/accuracy/onlyll.rb +148 -0
  24. data/lib/lazylead/task/accuracy/records.rb +1 -1
  25. data/lib/lazylead/task/accuracy/servers.rb +16 -7
  26. data/lib/lazylead/task/accuracy/stacktrace.rb +50 -10
  27. data/lib/lazylead/task/accuracy/testcase.rb +23 -6
  28. data/lib/lazylead/task/assignment.rb +96 -0
  29. data/lib/lazylead/task/fix_version.rb +6 -0
  30. data/lib/lazylead/task/propagate_down.rb +1 -1
  31. data/lib/lazylead/task/svn/diff.rb +77 -0
  32. data/lib/lazylead/task/svn/grep.rb +139 -0
  33. data/lib/lazylead/task/svn/touch.rb +99 -0
  34. data/lib/lazylead/version.rb +1 -1
  35. data/lib/messages/illegal_assignee_change.erb +123 -0
  36. data/lib/messages/illegal_fixversion_change.erb +8 -0
  37. data/lib/messages/only_ll.erb +107 -0
  38. data/lib/messages/svn_diff.erb +110 -0
  39. data/lib/messages/{svn_log.erb → svn_diff_attachment.erb} +19 -9
  40. data/lib/messages/svn_grep.erb +114 -0
  41. data/test/lazylead/cc_test.rb +1 -0
  42. data/test/lazylead/model_test.rb +20 -0
  43. data/test/lazylead/opts_test.rb +47 -0
  44. data/test/lazylead/postman_test.rb +8 -5
  45. data/test/lazylead/smoke_test.rb +13 -0
  46. data/test/lazylead/system/jira_test.rb +6 -7
  47. data/test/lazylead/task/accuracy/logs_test.rb +62 -2
  48. data/test/lazylead/task/accuracy/onlyll_test.rb +138 -0
  49. data/test/lazylead/task/accuracy/servers_test.rb +2 -2
  50. data/test/lazylead/task/accuracy/stacktrace_test.rb +227 -0
  51. data/test/lazylead/task/accuracy/testcase_test.rb +49 -0
  52. data/test/lazylead/task/assignment_test.rb +53 -0
  53. data/test/lazylead/task/fix_version_test.rb +1 -0
  54. data/test/lazylead/task/propagate_down_test.rb +4 -3
  55. data/test/lazylead/task/savepoint_test.rb +7 -4
  56. data/test/lazylead/task/svn/diff_test.rb +97 -0
  57. data/test/lazylead/task/svn/grep_test.rb +103 -0
  58. data/test/lazylead/task/{touch_test.rb → svn/touch_test.rb} +7 -34
  59. data/test/test.rb +7 -6
  60. data/upgrades/sqlite/999.testdata.sql +3 -1
  61. metadata +120 -34
  62. data/lib/lazylead/task/touch.rb +0 -119
@@ -34,7 +34,7 @@ module Lazylead
34
34
  @ext = %w[.webm .mkv .flv .flv .vob .ogv .ogg .drc .gif .gifv .mng .avi
35
35
  .mts .m2ts .ts .mov .qt .wmv .yuv .rm .rmvb .viv .asf .amv .mp4
36
36
  .m4p .m4v .mpg .mp2 .mpeg .mpe .mpv .mpg .mpeg .m2v .m4v .svi
37
- .3gp .3g2 .mxf .roq .nsv .flv .f4v .f4p .f4a .f4b]
37
+ .3gp .3g2 .mxf .roq .nsv .flv .f4v .f4p .f4a .f4b .gif]
38
38
  end
39
39
 
40
40
  # Ensure that ticket has an attachment with video-file extension
@@ -38,13 +38,22 @@ module Lazylead
38
38
 
39
39
  def passed(issue)
40
40
  return true if @envs.empty?
41
- lines = issue["environment"].to_s + "\n" + issue.description
42
- lines.split("\n")
43
- .reject(&:blank?)
44
- .map(&:strip)
45
- .flat_map { |l| l.split(" ").map(&:strip) }
46
- .select { |w| w.start_with?("http://", "https://") }
47
- .any? { |u| @envs.any? { |e| u.match? e } }
41
+ "#{issue['environment']}\n#{issue.description}".split("\n")
42
+ .reject(&:blank?)
43
+ .map(&:strip)
44
+ .flat_map { |l| l.split.map(&:strip) }
45
+ .select(&method(:url?))
46
+ .any?(&method(:match?))
47
+ end
48
+
49
+ # Ensure that particular text contains web url
50
+ def url?(text)
51
+ text.include?("http://") || text.include?("https://")
52
+ end
53
+
54
+ # Ensure that particular url matches expected servers urls
55
+ def match?(url)
56
+ @envs.any? { |e| url.match? e }
48
57
  end
49
58
  end
50
59
  end
@@ -34,24 +34,64 @@ module Lazylead
34
34
 
35
35
  def passed(issue)
36
36
  return false if issue.description.nil?
37
- !frames(issue.description).select { |f| oracle?(f) || java?(f) }.empty?
37
+ frames(issue.description).any? { |f| oracle?(f) || java?(f) }
38
38
  end
39
39
 
40
- # Detect all {noformat} frames in description field
40
+ # Detect all {noformat}, {code} frames in ticket description
41
41
  def frames(description)
42
- description.enum_for(:scan, /(?={noformat})/)
43
- .map { Regexp.last_match.offset(0).first }
44
- .each_slice(2).map do |f|
45
- description[f.first, f.last - f.first + "{noformat}".size]
42
+ noformat(description).concat(code(description))
43
+ end
44
+
45
+ # Detect all noformat blocks and give all text snippets in array
46
+ # @param desc The jira ticket description
47
+ def noformat(desc)
48
+ return [] unless desc.match?(/{(n|N)(o|O)(f|F)(o|O)(r|R)(m|M)(a|A)(t|T)}/)
49
+ desc.enum_for(:scan, /(?=\{(n|N)(o|O)(f|F)(o|O)(r|R)(m|M)(a|A)(t|T)})/)
50
+ .map { Regexp.last_match.offset(0).first }
51
+ .each_slice(2).map do |f|
52
+ desc[f.first, f.last - f.first + "{noformat}".size]
53
+ end
54
+ end
55
+
56
+ # Detect all {code:*} blocks and give all text snippets in array
57
+ # @param desc The jira ticket description
58
+ def code(desc)
59
+ return [] unless desc.match?(/{(c|C)(o|O)(d|D)(e|E)(:\S+)?}/)
60
+ words = desc.gsub(/{(c|C)(o|O)(d|D)(e|E)/, " {code")
61
+ .gsub("}", "} ")
62
+ .gsub("Caused by:", "Caused_by:")
63
+ .split
64
+ .map(&:strip)
65
+ .reject(&:blank?)
66
+ pairs(words, "{code").map { |s| words[s.first..s.last].join("\n") }
67
+ end
68
+
69
+ # Detect indexes of pairs which are starting from particular text
70
+ # @param words is array of words
71
+ # @param text is a label for pairs
72
+ #
73
+ # paris([aa,tag,bb,cc,tag,dd], "tag") => [[1, 4]]
74
+ # paris([aa,tag,bb,cc,tag,dd,tag,ee], "tag") => [[1, 4]] # non closed
75
+ #
76
+ def pairs(words, text)
77
+ snippets = [[]]
78
+ words.each_with_index do |e, i|
79
+ next unless e.start_with? text
80
+ pair = snippets.last
81
+ pair << i if pair.size.zero? || pair.size == 1
82
+ snippets[-1] = pair
83
+ snippets << [] if pair.size == 2
46
84
  end
85
+ snippets.select { |s| s.size == 2 }
47
86
  end
48
87
 
49
88
  # @return true if frame has few lines with java stack frames
50
89
  def java?(frame)
51
- allowed = ["at ", "Caused by:"]
52
- frame.split("\n")
53
- .map(&:strip)
54
- .count { |l| allowed.any? { |a| l.start_with? a } } > 3
90
+ allowed = ["at", "Caused by:", "Caused_by:"]
91
+ frame.match?(/\s\S+\.\S+Exception:\s/) ||
92
+ frame.split("\n")
93
+ .map(&:strip)
94
+ .count { |l| allowed.any? { |a| l.start_with? a } } > 3
55
95
  end
56
96
 
57
97
  # @return true if frame has Oracle error
@@ -38,7 +38,7 @@ module Lazylead
38
38
  return false if issue.description.nil?
39
39
  @tc = @ar = @er = -1
40
40
  issue.description.split("\n").reject(&:blank?).each_with_index do |l, i|
41
- line = l.gsub(/[^a-zA-Z(:|=)]/, "").downcase
41
+ line = escape l.downcase.gsub(/(\s+|\*)/, "")
42
42
  detect_tc line, i
43
43
  detect_ar line, i
44
44
  detect_er line, i
@@ -47,6 +47,16 @@ module Lazylead
47
47
  with_tc_ar_er?
48
48
  end
49
49
 
50
+ def escape(line)
51
+ if line.include?("{color")
52
+ line.gsub(
53
+ /({color:(#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|[A-Za-z]+)})|{color}/, ""
54
+ )
55
+ else
56
+ line.gsub(/[^a-zA-Z(:|=)]/, "")
57
+ end
58
+ end
59
+
50
60
  # @return true if description has test case, AR and ER
51
61
  def with_tc_ar_er?
52
62
  (@tc.zero? || @tc.positive?) && @ar.positive? && @er.positive?
@@ -55,21 +65,28 @@ module Lazylead
55
65
  # Detect index of line with test case
56
66
  def detect_tc(line, index)
57
67
  return unless @tc.negative?
58
- @tc = index if %w[testcase: tc: teststeps: teststeps steps:].any? do |e|
59
- e.eql? line
60
- end
68
+ @tc = index if eql? line,
69
+ %w[testcase: tc: teststeps: teststeps steps: tcsteps:]
61
70
  end
62
71
 
63
72
  # Detect index of line with actual result
64
73
  def detect_ar(line, index)
65
74
  return unless @ar.negative? && index > @tc
66
- @ar = index if %w[ar: actualresult: ar=].any? { |e| line.start_with? e }
75
+ @ar = index if starts? line, %w[ar: actualresult: ar= [ar]]
67
76
  end
68
77
 
69
78
  # Detect index of line with expected result
70
79
  def detect_er(line, index)
71
80
  return unless @er.negative? && index > @tc
72
- @er = index if %w[er: expectedresult: er=].any? { |e| line.start_with? e }
81
+ @er = index if starts? line, %w[er: expectedresult: er= [er]]
82
+ end
83
+
84
+ def starts?(line, text)
85
+ text.any? { |t| line.start_with? t }
86
+ end
87
+
88
+ def eql?(line, text)
89
+ text.any? { |t| t.eql? line }
73
90
  end
74
91
  end
75
92
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The MIT License
4
+ #
5
+ # Copyright (c) 2019-2020 Yurii Dubinka
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"),
9
+ # to deal in the Software without restriction, including without limitation
10
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
11
+ # and/or sell copies of the Software, and to permit persons to whom
12
+ # the Software is furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included
15
+ # in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
22
+ # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
23
+ # OR OTHER DEALINGS IN THE SOFTWARE.
24
+
25
+ require "date"
26
+ require_relative "../log"
27
+ require_relative "../opts"
28
+ require_relative "../system/jira"
29
+
30
+ module Lazylead
31
+ module Task
32
+ #
33
+ # Email alerts about illegal issue assignment(s).
34
+ #
35
+ # Author:: Yurii Dubinka (yurii.dubinka@gmail.com)
36
+ # Copyright:: Copyright (c) 2019-2020 Yurii Dubinka
37
+ # License:: MIT
38
+ class Assignment
39
+ def initialize(log = Log.new)
40
+ @log = log
41
+ end
42
+
43
+ def run(sys, postman, opts)
44
+ allowed = opts.slice "allowed", ","
45
+ silent = opts.key? "silent"
46
+ issues = sys.issues opts["jql"],
47
+ opts.jira_defaults.merge(expand: "changelog")
48
+ return if issues.empty?
49
+ postman.send opts.merge(
50
+ assignees: issues.map { |i| Assignee.new(i, allowed, silent) }
51
+ .select(&:illegal?)
52
+ .each(&:add_label)
53
+ )
54
+ end
55
+ end
56
+
57
+ # Instance of "Assignee" history item for the particular ticket.
58
+ class Assignee
59
+ attr_reader :issue
60
+
61
+ def initialize(issue, allowed, silent)
62
+ @issue = issue
63
+ @allowed = allowed
64
+ @silent = silent
65
+ end
66
+
67
+ # Gives true when last change of "Assignee" field was done
68
+ # by not authorized person.
69
+ def illegal?
70
+ @allowed.none? do |a|
71
+ return false if last.nil?
72
+ a == last["author"]["name"]
73
+ end
74
+ end
75
+
76
+ # Detect details about last change of "Assignee" to non-null value
77
+ def last
78
+ @last ||= issue.history.reverse.find do |h|
79
+ h["items"].any? do |i|
80
+ i["field"] == "assignee"
81
+ end
82
+ end
83
+ end
84
+
85
+ # Mark ticket with particular label
86
+ def add_label(name = "LL.IllegalChangeOfAssignee")
87
+ @issue.add_label(name) unless @silent
88
+ end
89
+
90
+ # The name of current assignee for ticket
91
+ def to
92
+ @issue.fields["assignee"]["name"]
93
+ end
94
+ end
95
+ end
96
+ end
@@ -85,6 +85,12 @@ module Lazylead
85
85
  def add_label
86
86
  @issue.add_label("LL.IllegalChangeOfFixVersion") unless @silent
87
87
  end
88
+
89
+ def to
90
+ versions = @issue.fields["fixVersions"]
91
+ return "" if versions.nil? || versions.empty?
92
+ versions.map { |x| x["name"] }.join(",")
93
+ end
88
94
  end
89
95
  end
90
96
  end
@@ -106,7 +106,7 @@ module Lazylead
106
106
  v = a.last
107
107
  d[k] = expected[k] if v.nil? || v.blank?
108
108
  next if v.nil?
109
- d[k] = v + "," + expected[k] unless v.to_s.include? expected[k]
109
+ d[k] = "#{v},#{expected[k]}" unless v.to_s.include? expected[k]
110
110
  end
111
111
  end
112
112
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The MIT License
4
+ #
5
+ # Copyright (c) 2019-2020 Yurii Dubinka
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"),
9
+ # to deal in the Software without restriction, including without limitation
10
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
11
+ # and/or sell copies of the Software, and to permit persons to whom
12
+ # the Software is furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included
15
+ # in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
22
+ # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
23
+ # OR OTHER DEALINGS IN THE SOFTWARE.
24
+
25
+ require "tempfile"
26
+ require "nokogiri"
27
+ require "backtrace"
28
+ require "active_support/core_ext/hash/conversions"
29
+ require_relative "../../salt"
30
+ require_relative "../../opts"
31
+
32
+ module Lazylead
33
+ module Task
34
+ module Svn
35
+ #
36
+ # Send notification about modification of svn files since particular
37
+ # revision.
38
+ #
39
+ class Diff
40
+ def initialize(log = Log.new)
41
+ @log = log
42
+ end
43
+
44
+ def run(_, postman, opts)
45
+ cmd = [
46
+ "svn log --diff --no-auth-cache",
47
+ "--username #{opts.decrypt('svn_user', 'svn_salt')}",
48
+ "--password #{opts.decrypt('svn_password', 'svn_salt')}",
49
+ "-r#{opts['since_rev']}:HEAD #{opts['svn_url']}"
50
+ ]
51
+ stdout = `#{cmd.join(" ")}`
52
+ send_email postman, opts.merge(stdout: stdout) unless stdout.blank?
53
+ end
54
+
55
+ # Send email with svn log as an attachment.
56
+ # The attachment won't be stored locally and we'll be removed once
57
+ # mail sent.
58
+ def send_email(postman, opts)
59
+ Dir.mktmpdir do |dir|
60
+ name = "svn-log-#{Date.today.strftime('%d-%b-%Y')}.html"
61
+ f = File.open(File.join(dir, name), "w")
62
+ begin
63
+ f.write opts.msg_body("template-attachment")
64
+ f.close
65
+ postman.send opts.merge(attachments: [f.path])
66
+ ensure
67
+ File.delete(f)
68
+ end
69
+ rescue StandardError => e
70
+ @log.error "ll-010: Can't send an email for #{opts} due to " \
71
+ "#{Backtrace.new(e)}'"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The MIT License
4
+ #
5
+ # Copyright (c) 2019-2020 Yurii Dubinka
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"),
9
+ # to deal in the Software without restriction, including without limitation
10
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
11
+ # and/or sell copies of the Software, and to permit persons to whom
12
+ # the Software is furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included
15
+ # in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
22
+ # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
23
+ # OR OTHER DEALINGS IN THE SOFTWARE.
24
+
25
+ require "tmpdir"
26
+ require "nokogiri"
27
+ require "active_support/core_ext/hash/conversions"
28
+ require_relative "../../salt"
29
+ require_relative "../../opts"
30
+
31
+ module Lazylead
32
+ module Task
33
+ module Svn
34
+ #
35
+ # Detect particular text in diff commit.
36
+ #
37
+ class Grep
38
+ def initialize(log = Log.new)
39
+ @log = log
40
+ end
41
+
42
+ def run(_, postman, opts)
43
+ text = opts.slice("text", ",")
44
+ commits = svn_log(opts).select { |c| c.includes? text }
45
+ postman.send(opts.merge(entries: commits)) unless commits.empty?
46
+ end
47
+
48
+ # Return all svn commits for particular date range in repo
49
+ def svn_log(opts)
50
+ cmd = [
51
+ "svn log --diff --no-auth-cache",
52
+ "--username #{opts.decrypt('svn_user', 'svn_salt')}",
53
+ "--password #{opts.decrypt('svn_password', 'svn_salt')}",
54
+ "-r {#{from(opts)}}:{#{now(opts)}} #{opts['svn_url']}"
55
+ ]
56
+ stdout = `#{cmd.join(" ")}`
57
+ stdout.split("-" * 72).reject(&:blank?).reverse.map { |e| Entry.new(e) }
58
+ end
59
+
60
+ # The start date & time for search range
61
+ def from(opts)
62
+ (now(opts).to_time - opts["period"].to_i).to_datetime
63
+ end
64
+
65
+ # The current date & time for search range
66
+ def now(opts)
67
+ if opts.key? "now"
68
+ DateTime.parse(opts["now"])
69
+ else
70
+ DateTime.now
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ # Single SVN commit details
78
+ class Entry
79
+ def initialize(commit)
80
+ @commit = commit
81
+ end
82
+
83
+ def to_s
84
+ "#{rev} #{msg}"
85
+ end
86
+
87
+ def rev
88
+ header.first
89
+ end
90
+
91
+ def author
92
+ header[1]
93
+ end
94
+
95
+ def time
96
+ header[2]
97
+ end
98
+
99
+ def msg
100
+ lines[1]
101
+ end
102
+
103
+ # The modified lines contains expected text
104
+ def includes?(text)
105
+ text = [text] unless text.respond_to? :each
106
+ lines[4..].select { |l| l.start_with? "+" }
107
+ .any? { |l| text.any? { |t| l.include? t } }
108
+ end
109
+
110
+ def lines
111
+ @lines ||= @commit.split("\n").reject(&:blank?)
112
+ end
113
+
114
+ def header
115
+ @header ||= lines.first.split(" | ").reject(&:blank?)
116
+ end
117
+
118
+ # Detect SVN diff lines with particular text
119
+ def diff(text)
120
+ @diff ||= begin
121
+ files = affected(text).uniq
122
+ @commit.split("Index: ")
123
+ .select { |i| files.any? { |f| i.start_with? f } }
124
+ .map { |i| i.split "\n" }
125
+ .flatten
126
+ end
127
+ end
128
+
129
+ # Detect affected files with particular text
130
+ def affected(text)
131
+ occurrences = lines.each_index.select do |i|
132
+ lines[i].start_with?("+") && text.any? { |t| lines[i].include? t }
133
+ end
134
+ occurrences.map do |occ|
135
+ lines[2..occ].reverse.find { |l| l.start_with? "Index: " }[7..]
136
+ end
137
+ end
138
+ end
139
+ end