smart_todo 1.0.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,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