smart_todo 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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