lazylead 0.6.0 → 0.7.2

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +7 -2
  3. data/.docker/Dockerfile +17 -6
  4. data/.rubocop.yml +102 -1
  5. data/.simplecov +1 -1
  6. data/Guardfile +1 -1
  7. data/Rakefile +6 -3
  8. data/bin/lazylead +3 -1
  9. data/lazylead.gemspec +31 -27
  10. data/lib/lazylead/cc.rb +21 -20
  11. data/lib/lazylead/cli/app.rb +8 -3
  12. data/lib/lazylead/confluence.rb +9 -2
  13. data/lib/lazylead/email.rb +0 -20
  14. data/lib/lazylead/exchange.rb +16 -28
  15. data/lib/lazylead/model.rb +31 -16
  16. data/lib/lazylead/opts.rb +64 -2
  17. data/lib/lazylead/postman.rb +13 -18
  18. data/lib/lazylead/salt.rb +1 -0
  19. data/lib/lazylead/system/jira.rb +9 -2
  20. data/lib/lazylead/task/accuracy/accuracy.rb +10 -14
  21. data/lib/lazylead/task/accuracy/attachment.rb +2 -6
  22. data/lib/lazylead/task/accuracy/logs.rb +9 -4
  23. data/lib/lazylead/task/accuracy/onlyll.rb +147 -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 +1 -1
  27. data/lib/lazylead/task/accuracy/testcase.rb +8 -7
  28. data/lib/lazylead/task/fix_version.rb +4 -2
  29. data/lib/lazylead/task/propagate_down.rb +1 -1
  30. data/lib/lazylead/task/svn/diff.rb +13 -12
  31. data/lib/lazylead/task/svn/grep.rb +40 -12
  32. data/lib/lazylead/task/svn/touch.rb +2 -4
  33. data/lib/lazylead/version.rb +1 -1
  34. data/lib/messages/illegal_fixversion_change.erb +8 -2
  35. data/lib/messages/only_ll.erb +107 -0
  36. data/lib/messages/svn_diff.erb +9 -9
  37. data/lib/messages/svn_diff_attachment.erb +19 -9
  38. data/lib/messages/svn_grep.erb +1 -1
  39. data/test/lazylead/cc_test.rb +1 -0
  40. data/test/lazylead/model_test.rb +20 -0
  41. data/test/lazylead/opts_test.rb +47 -0
  42. data/test/lazylead/postman_test.rb +8 -5
  43. data/test/lazylead/smoke_test.rb +13 -0
  44. data/test/lazylead/smtp_test.rb +1 -4
  45. data/test/lazylead/task/accuracy/attachment_test.rb +1 -1
  46. data/test/lazylead/task/accuracy/logs_test.rb +12 -0
  47. data/test/lazylead/task/accuracy/onlyll_test.rb +138 -0
  48. data/test/lazylead/task/accuracy/servers_test.rb +2 -2
  49. data/test/lazylead/task/accuracy/testcase_test.rb +56 -0
  50. data/test/lazylead/task/duedate_test.rb +3 -10
  51. data/test/lazylead/task/fix_version_test.rb +1 -0
  52. data/test/lazylead/task/propagate_down_test.rb +4 -3
  53. data/test/lazylead/task/savepoint_test.rb +9 -6
  54. data/test/lazylead/task/svn/grep_test.rb +43 -1
  55. data/test/test.rb +7 -8
  56. data/upgrades/sqlite/999.testdata.sql +3 -1
  57. metadata +117 -57
@@ -0,0 +1,147 @@
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_relative "../../log"
26
+ require_relative "../../opts"
27
+
28
+ module Lazylead
29
+ module Task
30
+ #
31
+ # Ensure that ticket accuracy evaluation labels are set only by LL, not by
32
+ # some person.
33
+ #
34
+ # The task supports the following features:
35
+ # - fetch issues from remote ticketing system by query
36
+ # - check the history of labels modification
37
+ # - remove evaluation labels if they set not by LL
38
+ class OnlyLL
39
+ def initialize(log = Log.new)
40
+ @log = log
41
+ end
42
+
43
+ def run(sys, postman, opts)
44
+ found = sys.issues(opts["jql"],
45
+ opts.jira_defaults.merge(expand: "changelog"))
46
+ .map { |i| Labels.new(i, opts) }
47
+ .select(&:exists?)
48
+ .reject(&:valid?)
49
+ .each(&:remove)
50
+ postman.send opts.merge(tickets: found) unless found.empty?
51
+ end
52
+ end
53
+ end
54
+
55
+ # The ticket with grid labels
56
+ class Labels
57
+ attr_reader :issue
58
+
59
+ def initialize(issue, opts)
60
+ @issue = issue
61
+ @opts = opts
62
+ end
63
+
64
+ # Ensure that issue has evaluation labels for accuracy rules
65
+ def exists?
66
+ return false if @issue.labels.nil? || @issue.labels.empty?
67
+ return false unless @issue.labels.is_a? Array
68
+ grid.any? { |g| @issue.labels.any? { |l| g.eql? l } }
69
+ end
70
+
71
+ # Compare the score evaluated by LL and current ticket score.
72
+ # @return true if current score equal to LL evaluation
73
+ def valid?
74
+ score.eql?(@issue.labels.sort.find { |l| grid.any? { |g| l.eql? g } })
75
+ end
76
+
77
+ # Find expected ticket score evaluated by LL.
78
+ # If LL evaluated the same ticket several times (no matter why),
79
+ # then the last score would be returned.
80
+ def score
81
+ to_l(
82
+ @issue.history
83
+ .select { |h| h["author"]["key"].eql? @opts["author"] }
84
+ .reverse
85
+ .find { |h| to_l(h) }
86
+ ).fetch("toString", "").split.find { |l| grid.any? { |g| l.eql? g } }
87
+ end
88
+
89
+ # Find history record with labels changes.
90
+ def to_l(history)
91
+ return {} if history.nil? || history.empty?
92
+ history["items"].find { |f| f["field"].eql? "labels" }
93
+ end
94
+
95
+ # Detect the percentage grid for tickets, by default its 0%, 10%, 20%, etc.
96
+ def grid
97
+ @grid ||= begin
98
+ if @opts.key? "grid"
99
+ @opts.slice("grid", ",")
100
+ else
101
+ %w[0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%]
102
+ end
103
+ end
104
+ end
105
+
106
+ # Remove score labels from the ticket.
107
+ def remove
108
+ @issue.labels!(@issue.labels - grid)
109
+ end
110
+
111
+ # Detect the violators with their changes.
112
+ # .violators => ["Tom Hhhh set 40%", "Bob Mmmm set 50%"]
113
+ def violators
114
+ @issue.history
115
+ .reject { |h| h["author"]["key"].eql? @opts["author"] }
116
+ .select { |h| grid?(h) }
117
+ .group_by { |h| h["author"]["key"] }
118
+ .map do |a|
119
+ "#{a.last.first['author']['displayName']} set #{hacked(a.last)}"
120
+ end
121
+ end
122
+
123
+ # Ensure that history record has label change related to LL grid labels.
124
+ # @return true if LL grid labels added
125
+ def grid?(record)
126
+ diff(record).any? { |d| d.find { |l| grid.any? { |g| l.eql? g } } }
127
+ end
128
+
129
+ # Detect label diff in single history record from ticket's history.
130
+ def diff(record)
131
+ record["items"].select { |f| f["field"].eql? "labels" }
132
+ .reject { |f| f["toString"].nil? || f["toString"].blank? }
133
+ .map do |f|
134
+ from = []
135
+ from = f["fromString"].split unless f["fromString"].nil?
136
+ f["toString"].split - from
137
+ end
138
+ end
139
+
140
+ # Hacked score by violator in ticket's history.
141
+ def hacked(record)
142
+ diff(record.first)
143
+ .first
144
+ .find { |l| grid.any? { |g| l.eql? g } }
145
+ end
146
+ end
147
+ end
@@ -38,7 +38,7 @@ module Lazylead
38
38
  end
39
39
 
40
40
  # Ensure that ticket has an attachment with video-file extension
41
- def matching(attach)
41
+ def matches?(attach)
42
42
  @ext.any? { |e| e.eql? File.extname(attach.attrs["filename"]).downcase }
43
43
  end
44
44
  end
@@ -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.include?("http://") || w.include?("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 { |u| url?(u) }
46
+ .any? { |u| match?(u) }
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
@@ -60,7 +60,7 @@ module Lazylead
60
60
  words = desc.gsub(/{(c|C)(o|O)(d|D)(e|E)/, " {code")
61
61
  .gsub("}", "} ")
62
62
  .gsub("Caused by:", "Caused_by:")
63
- .split(" ")
63
+ .split
64
64
  .map(&:strip)
65
65
  .reject(&:blank?)
66
66
  pairs(words, "{code").map { |s| words[s.first..s.last].join("\n") }
@@ -38,10 +38,10 @@ 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 = escape(l.downcase.gsub(/\s+/, ""))
42
- detect_tc(line, i)
43
- detect_ar(line, i)
44
- detect_er(line, i)
41
+ line = escape l.downcase.gsub(/(\s+|\*|\+)/, "")
42
+ detect_tc line, i
43
+ detect_ar line, i
44
+ detect_er line, i
45
45
  break if with_tc_ar_er?
46
46
  end
47
47
  with_tc_ar_er?
@@ -65,19 +65,20 @@ module Lazylead
65
65
  # Detect index of line with test case
66
66
  def detect_tc(line, index)
67
67
  return unless @tc.negative?
68
- @tc = index if eql?(line, %w[testcase: tc: teststeps: teststeps steps:])
68
+ @tc = index if eql? line,
69
+ %w[testcase: tc: teststeps: teststeps steps: tcsteps: tc testcases steps]
69
70
  end
70
71
 
71
72
  # Detect index of line with actual result
72
73
  def detect_ar(line, index)
73
74
  return unless @ar.negative? && index > @tc
74
- @ar = index if starts?(line, %w[ar: actualresult: ar= *ar*= *ar*:])
75
+ @ar = index if starts? line, %w[ar: actualresult: ar= [ar]]
75
76
  end
76
77
 
77
78
  # Detect index of line with expected result
78
79
  def detect_er(line, index)
79
80
  return unless @er.negative? && index > @tc
80
- @er = index if starts?(line, %w[er: expectedresult: er= *er*= *er*:])
81
+ @er = index if starts? line, %w[er: expectedresult: er= [er]]
81
82
  end
82
83
 
83
84
  def starts?(line, text)
@@ -86,8 +86,10 @@ module Lazylead
86
86
  @issue.add_label("LL.IllegalChangeOfFixVersion") unless @silent
87
87
  end
88
88
 
89
- def current
90
- @issue.fields["fixVersions"].first["name"]
89
+ def to
90
+ versions = @issue.fields["fixVersions"]
91
+ return "" if versions.nil? || versions.empty?
92
+ versions.map { |x| x["name"] }.join(",")
91
93
  end
92
94
  end
93
95
  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
 
@@ -22,8 +22,9 @@
22
22
  # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
23
23
  # OR OTHER DEALINGS IN THE SOFTWARE.
24
24
 
25
- require "tmpdir"
25
+ require "tempfile"
26
26
  require "nokogiri"
27
+ require "backtrace"
27
28
  require "active_support/core_ext/hash/conversions"
28
29
  require_relative "../../salt"
29
30
  require_relative "../../opts"
@@ -48,26 +49,26 @@ module Lazylead
48
49
  "-r#{opts['since_rev']}:HEAD #{opts['svn_url']}"
49
50
  ]
50
51
  stdout = `#{cmd.join(" ")}`
51
- send_email stdout, postman, opts unless stdout.blank?
52
+ send_email postman, opts.merge(stdout: stdout) unless stdout.blank?
52
53
  end
53
54
 
54
55
  # Send email with svn log as an attachment.
55
56
  # The attachment won't be stored locally and we'll be removed once
56
57
  # mail sent.
57
- def send_email(stdout, postman, opts)
58
+ def send_email(postman, opts)
58
59
  Dir.mktmpdir do |dir|
59
60
  name = "svn-log-#{Date.today.strftime('%d-%b-%Y')}.html"
60
61
  f = File.open(File.join(dir, name), "w")
61
- f.write(
62
- Email.new(
63
- opts["template-attachment"],
64
- opts.merge(stdout: stdout, version: Lazylead::VERSION)
65
- ).render
66
- )
67
- postman.send opts.merge(stdout: stdout, attachments: [f.path])
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
68
69
  rescue StandardError => e
69
- @log.error "ll-010: Can't send an email for #{opts} based on "\
70
- "'#{stdout}'", e
70
+ @log.error "ll-010: Can't send an email for #{opts} due to " \
71
+ "#{Backtrace.new(e)}'"
71
72
  end
72
73
  end
73
74
  end
@@ -47,21 +47,28 @@ module Lazylead
47
47
 
48
48
  # Return all svn commits for particular date range in repo
49
49
  def svn_log(opts)
50
- now = if opts.key? "now"
51
- DateTime.parse(opts["now"])
52
- else
53
- DateTime.now
54
- end
55
- start = (now.to_time - opts["period"].to_i).to_datetime
56
50
  cmd = [
57
51
  "svn log --diff --no-auth-cache",
58
52
  "--username #{opts.decrypt('svn_user', 'svn_salt')}",
59
53
  "--password #{opts.decrypt('svn_password', 'svn_salt')}",
60
- "-r {#{start}}:{#{now}} #{opts['svn_url']}"
54
+ "-r {#{from(opts)}}:{#{now(opts)}} #{opts['svn_url']}"
61
55
  ]
62
56
  stdout = `#{cmd.join(" ")}`
63
- stdout.split("-" * 72).reject(&:blank?).reverse
64
- .map { |e| Entry.new(e) }
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
65
72
  end
66
73
  end
67
74
  end
@@ -78,7 +85,7 @@ module Lazylead
78
85
  end
79
86
 
80
87
  def rev
81
- header.first
88
+ header.first[1..]
82
89
  end
83
90
 
84
91
  def author
@@ -96,8 +103,8 @@ module Lazylead
96
103
  # The modified lines contains expected text
97
104
  def includes?(text)
98
105
  text = [text] unless text.respond_to? :each
99
- lines[4..-1].select { |l| l.start_with? "+" }
100
- .any? { |l| text.any? { |t| l.include? t } }
106
+ lines[4..].select { |l| l.start_with? "+" }
107
+ .any? { |l| text.any? { |t| l.include? t } }
101
108
  end
102
109
 
103
110
  def lines
@@ -107,5 +114,26 @@ module Lazylead
107
114
  def header
108
115
  @header ||= lines.first.split(" | ").reject(&:blank?)
109
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
110
138
  end
111
139
  end
@@ -50,11 +50,9 @@ module Lazylead
50
50
  def touch(files, opts)
51
51
  xpath = files.map { |f| "contains(text(),\"#{f}\")" }.join(" or ")
52
52
  svn_log(opts).xpath("//logentry[paths/path[#{xpath}]]")
53
- .map(&method(:to_entry))
53
+ .map { |xml| to_entry(xml) }
54
54
  .each do |e|
55
- if e.paths.path.respond_to? :delete_if
56
- e.paths.path.delete_if { |p| files.none? { |f| p.include? f } }
57
- end
55
+ e.paths.path.delete_if { |p| files.none? { |f| p.include? f } } if e.paths.path.respond_to? :delete_if
58
56
  end
59
57
  end
60
58
 
@@ -23,5 +23,5 @@
23
23
  # OR OTHER DEALINGS IN THE SOFTWARE.
24
24
 
25
25
  module Lazylead
26
- VERSION = "0.6.0"
26
+ VERSION = "0.7.2"
27
27
  end
@@ -92,7 +92,8 @@
92
92
  <tr>
93
93
  <th id="key">Key</th>
94
94
  <th id="priority">Priority</th>
95
- <th id="version">Fix Version</th>
95
+ <th id="from">From</th>
96
+ <th id="to">To</th>
96
97
  <th id="when">When</th>
97
98
  <th id="who">Who</th>
98
99
  <th id="reporter">Reporter</th>
@@ -102,7 +103,12 @@
102
103
  <tr>
103
104
  <td><a href='<%= v.issue.url %>'><%= v.issue.key %></a></td>
104
105
  <td><%= v.issue.priority %></td>
105
- <td><%= v.current %></td>
106
+ <td><%= v.last["items"]
107
+ .select { |h| h["field"] == "Fix Version" }
108
+ .map { |h| h["fromString"] }
109
+ .reject(&:blank?)
110
+ .join(",") %></td>
111
+ <td><%= v.to %></td>
106
112
  <td><%= DateTime.parse(v.last["created"])
107
113
  .strftime('%d-%b-%Y %I:%M:%S %p') %></td>
108
114
  <td><span style='color: red'><%= v.last["author"]["displayName"] %></span>
@@ -0,0 +1,107 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <style> /* CSS styles taken from https://github.com/yegor256/tacit */
5
+ th {
6
+ font-weight: 600
7
+ }
8
+
9
+ table tr {
10
+ border-bottom-width: 2.16px
11
+ }
12
+
13
+ table tr th {
14
+ border-bottom-width: 2.16px
15
+ }
16
+
17
+ table tr td, table tr th {
18
+ overflow: hidden;
19
+ padding: 5.4px 3.6px;
20
+ line-height: 14px;
21
+ }
22
+
23
+ #summary {
24
+ text-align: left;
25
+ }
26
+
27
+ .auto {
28
+ min-width: auto;
29
+ white-space: nowrap;
30
+ }
31
+
32
+ a {
33
+ color: #275a90;
34
+ text-decoration: none
35
+ }
36
+
37
+ a:hover {
38
+ text-decoration: underline
39
+ }
40
+
41
+ * {
42
+ border: 0;
43
+ border-collapse: separate;
44
+ border-spacing: 0;
45
+ box-sizing: border-box;
46
+ margin: 0;
47
+ max-width: 100%;
48
+ padding: 0;
49
+ vertical-align: baseline;
50
+ font-family: system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif;
51
+ font-size: 13px;
52
+ font-stretch: normal;
53
+ font-style: normal;
54
+ font-weight: 400;
55
+ line-height: 29.7px
56
+ }
57
+
58
+ html, body {
59
+ width: 100%
60
+ }
61
+
62
+ html {
63
+ height: 100%
64
+ }
65
+
66
+ body {
67
+ background: #fff;
68
+ color: #1a1919;
69
+ padding: 36px
70
+ }
71
+ </style>
72
+ <title>Only LL</title>
73
+ </head>
74
+ <body>
75
+ <p>Hi,</p>
76
+ <p>The LL accuracy labels are cleaned from the following tickets:</p>
77
+ <table summary="table with tickets triage score">
78
+ <tr>
79
+ <th id="key">Key</th>
80
+ <th id="priority">Priority</th>
81
+ <th id="reporter">Reporter</th>
82
+ <th id="violators">Violators</th>
83
+ <th id="summary">Summary</th>
84
+ <th id="labels">Labels</th>
85
+ </tr>
86
+ <% tickets.each do |t| %>
87
+ <tr>
88
+ <td>
89
+ <div class="auto">
90
+ <a href="<%= t.issue.url %>"><%= t.issue.key %></a>
91
+ </div>
92
+ </td>
93
+ <td><%= t.issue.priority %></td>
94
+ <td>
95
+ <div class="auto"><%= t.issue.reporter.name %></div>
96
+ </td>
97
+ <td><%= t.violators.join(', ') %></td>
98
+ <td><%= t.issue.summary %></td>
99
+ <td><%= t.issue.labels.join(', ') %></td>
100
+ </tr>
101
+ <% end %>
102
+ </table>
103
+ <p>Posted by
104
+ <a href="https://github.com/dgroup/lazylead">lazylead v<%= version %></a>.
105
+ </p>
106
+ </body>
107
+ </html>