smart_todo 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop-http---shopify-github-io-ruby-style-guide-rubocop-yml +1027 -0
- data/.rubocop.yml +5 -0
- data/.shopify-build/VERSION +1 -0
- data/.shopify-build/smart-todo.yml +37 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +49 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/dev.yml +11 -0
- data/exe/smart_todo +14 -0
- data/lib/smart_todo/cli.rb +74 -0
- data/lib/smart_todo/dispatcher.rb +99 -0
- data/lib/smart_todo/events/date.rb +26 -0
- data/lib/smart_todo/events/gem_release.rb +69 -0
- data/lib/smart_todo/events/pull_request_close.rb +86 -0
- data/lib/smart_todo/events.rb +52 -0
- data/lib/smart_todo/parser/comment_parser.rb +71 -0
- data/lib/smart_todo/parser/metadata_parser.rb +116 -0
- data/lib/smart_todo/parser/todo_node.rb +43 -0
- data/lib/smart_todo/slack_client.rb +113 -0
- data/lib/smart_todo/version.rb +5 -0
- data/lib/smart_todo.rb +22 -0
- data/lib/smart_todo_cop.rb +37 -0
- data/shipit.rubygems.yml +1 -0
- data/smart_todo.gemspec +38 -0
- metadata +152 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module SmartTodo
|
6
|
+
module Events
|
7
|
+
# An event that check if the passed date is passed
|
8
|
+
class Date
|
9
|
+
# @param on_date [String] a string parsable by Time.parse
|
10
|
+
# @return [String, false]
|
11
|
+
def self.met?(on_date)
|
12
|
+
if Time.now >= Time.parse(on_date)
|
13
|
+
message(on_date)
|
14
|
+
else
|
15
|
+
false
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# @param on_date [String]
|
20
|
+
# @return [String]
|
21
|
+
def self.message(on_date)
|
22
|
+
"We are past the *#{on_date}* due date and your TODO is now ready to be addressed."
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module SmartTodo
|
7
|
+
module Events
|
8
|
+
# An event that check if a new version of gem has been released on RubyGem
|
9
|
+
# with the expected version specifiers.
|
10
|
+
# This event will make an API call to the RubyGem API
|
11
|
+
class GemRelease
|
12
|
+
# @param gem_name [String]
|
13
|
+
# @param requirements [Array] a list of version specifiers.
|
14
|
+
# The specifiers are the same as the one used in Gemfiles or Gemspecs
|
15
|
+
#
|
16
|
+
# @example Expecting a specific version
|
17
|
+
# GemRelease.new('rails', ['6.0'])
|
18
|
+
#
|
19
|
+
# @example Expecting a version in the 5.x.x series
|
20
|
+
# GemRelease.new('rails', ['> 5.2', '< 6'])
|
21
|
+
def initialize(gem_name, requirements)
|
22
|
+
@gem_name = gem_name
|
23
|
+
@requirements = Gem::Requirement.new(requirements)
|
24
|
+
end
|
25
|
+
|
26
|
+
# @return [String, false]
|
27
|
+
def met?
|
28
|
+
response = client.get("/api/v1/versions/#{@gem_name}.json")
|
29
|
+
|
30
|
+
if response.code_type < Net::HTTPClientError
|
31
|
+
error_message
|
32
|
+
elsif (gem = version_released?(response.body))
|
33
|
+
message(gem['number'])
|
34
|
+
else
|
35
|
+
false
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Error message send to Slack in case a gem couldn't be found
|
40
|
+
#
|
41
|
+
# @return [String]
|
42
|
+
def error_message
|
43
|
+
"The gem *#{@gem_name}* doesn't seem to exist, I can't determine if your TODO is ready to be addressed."
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [String]
|
47
|
+
def message(version_number)
|
48
|
+
"The gem *#{@gem_name}* was released to version *#{version_number}* and your TODO is now ready to be addressed."
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
# @param gem_versions [String] the response sent from RubyGems
|
54
|
+
# @return [true, false]
|
55
|
+
def version_released?(gem_versions)
|
56
|
+
JSON.parse(gem_versions).find do |gem|
|
57
|
+
@requirements.satisfied_by?(Gem::Version.new(gem['number']))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [Net::HTTP] an instance of Net::HTTP
|
62
|
+
def client
|
63
|
+
@client ||= Net::HTTP.new('rubygems.org', Net::HTTP.https_default_port).tap do |client|
|
64
|
+
client.use_ssl = true
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module SmartTodo
|
7
|
+
module Events
|
8
|
+
# An event that check if a GitHun Pull Request or Issue is closed.
|
9
|
+
# This event will make an API call to the GitHub API.
|
10
|
+
#
|
11
|
+
# If the Pull Request or Issue is on a private repository, exporting a token
|
12
|
+
# with the `repos` scope in the +SMART_TODO_GITHUB_TOKEN+ environment variable
|
13
|
+
# is required.
|
14
|
+
class PullRequestClose
|
15
|
+
TOKEN_ENV = 'SMART_TODO_GITHUB_TOKEN'
|
16
|
+
|
17
|
+
# @param organization [String]
|
18
|
+
# @param repo [String]
|
19
|
+
# @param pr_number [String, Integer]
|
20
|
+
def initialize(organization, repo, pr_number)
|
21
|
+
@url = "/repos/#{organization}/#{repo}/pulls/#{pr_number}"
|
22
|
+
@organization = organization
|
23
|
+
@repo = repo
|
24
|
+
@pr_number = pr_number
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [String, false]
|
28
|
+
def met?
|
29
|
+
response = client.get(@url, default_headers)
|
30
|
+
|
31
|
+
if response.code_type < Net::HTTPClientError
|
32
|
+
error_message
|
33
|
+
elsif pull_request_closed?(response.body)
|
34
|
+
message
|
35
|
+
else
|
36
|
+
false
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Error message send to Slack in case the Pull Request or Issue couldn't be found.
|
41
|
+
#
|
42
|
+
# @return [String]
|
43
|
+
def error_message
|
44
|
+
<<~EOM
|
45
|
+
I can't retrieve the information from the PR or Issue *#{@pr_number}* in the
|
46
|
+
*#{@organization}/#{@repo}* repository.
|
47
|
+
|
48
|
+
If the repository is a private one, make sure to export the `#{TOKEN_ENV}`
|
49
|
+
environment variable with a correct GitHub token.
|
50
|
+
EOM
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [String]
|
54
|
+
def message
|
55
|
+
<<~EOM
|
56
|
+
The Pull Request or Issue https://github.com/#{@organization}/#{@repo}/pull/#{@pr_number}
|
57
|
+
is now closed, your TODO is ready to be addressed.
|
58
|
+
EOM
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# @return [Net::HTTP] an instance of Net::HTTP
|
64
|
+
def client
|
65
|
+
@client ||= Net::HTTP.new('api.github.com', Net::HTTP.https_default_port).tap do |client|
|
66
|
+
client.use_ssl = true
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# @param pull_request [String] the Pull Request or Issue
|
71
|
+
# detail sent back from the GitHub API
|
72
|
+
#
|
73
|
+
# @return [true, false]
|
74
|
+
def pull_request_closed?(pull_request)
|
75
|
+
JSON.parse(pull_request)['state'] == 'closed'
|
76
|
+
end
|
77
|
+
|
78
|
+
# @return [Hash]
|
79
|
+
def default_headers
|
80
|
+
{ 'Accept' => 'application/vnd.github.v3+json' }.tap do |headers|
|
81
|
+
headers['Authorization'] = "token #{ENV[TOKEN_ENV]}" if ENV[TOKEN_ENV]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SmartTodo
|
4
|
+
# This module contains all the methods accessible for SmartTodo comments.
|
5
|
+
# It is meant to be reopened by the host application in order to define
|
6
|
+
# its own events.
|
7
|
+
#
|
8
|
+
# An event needs to return a +String+ containing the message that will be
|
9
|
+
# sent to the TODO assignee or +false+ in case the event hasn't been met.
|
10
|
+
#
|
11
|
+
# @example Adding a custom event
|
12
|
+
# module SmartTodo
|
13
|
+
# module Events
|
14
|
+
# def trello_card_close(card)
|
15
|
+
# ...
|
16
|
+
# end
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# TODO(on: trello_card_close(381), to: 'john@example.com')
|
21
|
+
module Events
|
22
|
+
extend self
|
23
|
+
|
24
|
+
# Check if the +date+ is in the past
|
25
|
+
#
|
26
|
+
# @param date [String] a correctly formatted date
|
27
|
+
# @return [false, String]
|
28
|
+
def date(date)
|
29
|
+
Date.met?(date)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Check if a new version of +gem_name+ was released with the +requirements+ expected
|
33
|
+
#
|
34
|
+
# @param gem_name [String]
|
35
|
+
# @param requirements [Array<String>] a list of version specifiers
|
36
|
+
# @return [false, String]
|
37
|
+
def gem_release(gem_name, *requirements)
|
38
|
+
GemRelease.new(gem_name, requirements).met?
|
39
|
+
end
|
40
|
+
|
41
|
+
# Check if the Pull Request or issue +pr_number+ is closed
|
42
|
+
#
|
43
|
+
# @param organization [String] the GitHub organization name
|
44
|
+
# @param repo [String] the GitHub repo name
|
45
|
+
# @param pr_number [String, Integer]
|
46
|
+
# @return [false, String]
|
47
|
+
def pull_request_close(organization, repo, pr_number)
|
48
|
+
PullRequestClose.new(organization, repo, pr_number).met?
|
49
|
+
end
|
50
|
+
alias_method :issue_close, :pull_request_close
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ripper'
|
4
|
+
|
5
|
+
module SmartTodo
|
6
|
+
module Parser
|
7
|
+
# This class is used to parse Ruby code and will stop each time
|
8
|
+
# a Ruby comment is encountered. It will detect if a TODO comment
|
9
|
+
# is a Smart Todo and will gather the comments associated to the TODO.
|
10
|
+
class CommentParser < Ripper::Filter
|
11
|
+
def initialize(*)
|
12
|
+
super
|
13
|
+
@node = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
# @param comment [String] the actual Ruby comment
|
17
|
+
# @param data [Array<TodoNode>]
|
18
|
+
# @return [Array<TodoNode>]
|
19
|
+
def on_comment(comment, data)
|
20
|
+
if todo_metadata?(comment)
|
21
|
+
append_existing_node(data)
|
22
|
+
@node = TodoNode.new(comment)
|
23
|
+
elsif todo_comment?(comment)
|
24
|
+
@node << comment
|
25
|
+
else
|
26
|
+
append_existing_node(data)
|
27
|
+
@node = nil
|
28
|
+
end
|
29
|
+
|
30
|
+
data
|
31
|
+
end
|
32
|
+
|
33
|
+
# @param init [Array]
|
34
|
+
# @return [Array<TodoNode>]
|
35
|
+
def parse(init = [])
|
36
|
+
super(init)
|
37
|
+
|
38
|
+
init.tap { append_existing_node(init) }
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
# @param comment [String] the actual Ruby comment
|
44
|
+
# @return [nil, Integer]
|
45
|
+
def todo_metadata?(comment)
|
46
|
+
/^#\sTODO\(/ =~ comment
|
47
|
+
end
|
48
|
+
|
49
|
+
# Check if the comment is associated with the Smart Todo
|
50
|
+
# @param comment [String] the actual Ruby comment
|
51
|
+
# @return [true, false]
|
52
|
+
#
|
53
|
+
# @example When a comment is associated to a SmartTodo
|
54
|
+
# TODO(on_date(...), to: '...')
|
55
|
+
# This is an associated comment
|
56
|
+
#
|
57
|
+
# @example When a comment is not associated to a SmartTodo
|
58
|
+
# TODO(on_date(...), to: '...')
|
59
|
+
# This is an associated comment (Note the indentation)
|
60
|
+
def todo_comment?(comment)
|
61
|
+
@node&.indented_comment?(comment)
|
62
|
+
end
|
63
|
+
|
64
|
+
# @param data [Array<TodoNode>]
|
65
|
+
# @return [Array<TodoNode>]
|
66
|
+
def append_existing_node(data)
|
67
|
+
data << @node if @node
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ripper'
|
4
|
+
|
5
|
+
module SmartTodo
|
6
|
+
module Parser
|
7
|
+
# A MethodNode represent an event associated to a TODO.
|
8
|
+
class MethodNode
|
9
|
+
attr_reader :method_name, :arguments
|
10
|
+
|
11
|
+
# @param method_name [Symbol]
|
12
|
+
# @param arguments [Array<String>]
|
13
|
+
def initialize(method_name, arguments)
|
14
|
+
@arguments = arguments
|
15
|
+
@method_name = method_name
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# This class is used to parse the ruby TODO() comment.
|
20
|
+
class MetadataParser < Ripper
|
21
|
+
# @param source [String] the actual Ruby code
|
22
|
+
def self.parse(source)
|
23
|
+
sexp = new(source).parse
|
24
|
+
Visitor.new.tap { |v| v.process(sexp) }
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [Array] an Array of Array
|
28
|
+
# the first element from each inner array is a token
|
29
|
+
def on_stmts_add(_, data)
|
30
|
+
data
|
31
|
+
end
|
32
|
+
|
33
|
+
# @param method [String] the name of the method
|
34
|
+
# when the parser hits one.
|
35
|
+
# @param args [Array]
|
36
|
+
# @return [Array, MethodNode]
|
37
|
+
def on_method_add_arg(method, args)
|
38
|
+
if method == 'TODO'
|
39
|
+
args
|
40
|
+
else
|
41
|
+
MethodNode.new(method, args)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @param list [nil, Array]
|
46
|
+
# @param arg [String]
|
47
|
+
# @return [Array]
|
48
|
+
def on_args_add(list, arg)
|
49
|
+
Array(list) << arg
|
50
|
+
end
|
51
|
+
|
52
|
+
# @param string_content [String]
|
53
|
+
# @return [String]
|
54
|
+
def on_string_add(_, string_content)
|
55
|
+
string_content
|
56
|
+
end
|
57
|
+
|
58
|
+
# @param key [String]
|
59
|
+
# @param value [String, Integer, MethodNode]
|
60
|
+
def on_assoc_new(key, value)
|
61
|
+
key.tr!(':', '')
|
62
|
+
|
63
|
+
case key
|
64
|
+
when 'on'
|
65
|
+
[:on_todo_event, value]
|
66
|
+
when 'to'
|
67
|
+
[:on_todo_assignee, value]
|
68
|
+
else
|
69
|
+
[:unknown, value]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# @param data [Hash]
|
74
|
+
# @return [Hash]
|
75
|
+
def on_bare_assoc_hash(data)
|
76
|
+
data
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class Visitor
|
81
|
+
attr_reader :events, :assignee
|
82
|
+
|
83
|
+
def initialize
|
84
|
+
@events = []
|
85
|
+
end
|
86
|
+
|
87
|
+
# Iterate over each tokens returned from the parser and call
|
88
|
+
# the corresponding method
|
89
|
+
#
|
90
|
+
# @param sexp [Array]
|
91
|
+
# @return [void]
|
92
|
+
def process(sexp)
|
93
|
+
return unless sexp
|
94
|
+
|
95
|
+
if sexp[0].is_a?(Array)
|
96
|
+
sexp.each { |node| process(node) }
|
97
|
+
else
|
98
|
+
method, *args = sexp
|
99
|
+
send(method, *args) if method.is_a?(Symbol) && respond_to?(method)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# @param method_node [MethodNode]
|
104
|
+
# @return [void]
|
105
|
+
def on_todo_event(method_node)
|
106
|
+
events << method_node
|
107
|
+
end
|
108
|
+
|
109
|
+
# @param assignee [String]
|
110
|
+
# @return [void]
|
111
|
+
def on_todo_assignee(assignee)
|
112
|
+
@assignee = assignee
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SmartTodo
|
4
|
+
module Parser
|
5
|
+
# Represents a SmartTodo which includes the associated events
|
6
|
+
# as well as the assignee.
|
7
|
+
class TodoNode
|
8
|
+
DEFAULT_RUBY_INDENTATION = 2
|
9
|
+
|
10
|
+
attr_reader :metadata
|
11
|
+
|
12
|
+
# @param todo [String] the actual Ruby comment
|
13
|
+
def initialize(todo)
|
14
|
+
@metadata = MetadataParser.parse(todo.gsub(/^#/, ''))
|
15
|
+
@comments = []
|
16
|
+
@start = todo.match(/^#(\s+)/)[1].size
|
17
|
+
end
|
18
|
+
|
19
|
+
# Return the associated comment for this TODO
|
20
|
+
#
|
21
|
+
# @return [String]
|
22
|
+
def comment
|
23
|
+
@comments.join
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param comment [String]
|
27
|
+
# @return [void]
|
28
|
+
def <<(comment)
|
29
|
+
@comments << comment.gsub(/^#(\s+)/, '')
|
30
|
+
end
|
31
|
+
|
32
|
+
# Check if the +comment+ is indented two spaces below the
|
33
|
+
# TODO declaration. If yes the comment is considered to be part
|
34
|
+
# of the TODO itself. Otherwise it's just a regular comment.
|
35
|
+
#
|
36
|
+
# @param comment [String]
|
37
|
+
# @return [true, false]
|
38
|
+
def indented_comment?(comment)
|
39
|
+
comment.match(/^#(\s+)/)[1].size - @start == DEFAULT_RUBY_INDENTATION
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|