smart_todo 1.5.0 → 1.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4daea874da20235bc9b20d315058644bafb73e78b14c6c266f6252f4b346bcf1
4
- data.tar.gz: feb305070b73a03e2481515a914cca2fcaad264f3871cd65e242bf58ccb3612f
3
+ metadata.gz: 7bbca316fba93242bc5ea22479579407ed9ce3ed93a6bd618978223f96f0626d
4
+ data.tar.gz: c91cf35475f5a32da647f6ed649ac7d50d85a929908aa29f1768a9b1d6db75da
5
5
  SHA512:
6
- metadata.gz: 7fcba36662ce881114aafa3f5bba320d767c3e299a1d9d91f06a505107815d631c008ed87a1e853d1bb2766c1e9bdce756125ccc9c5ed65917264f962da62814
7
- data.tar.gz: 981c7785a6c5e4d2805d004f6ec1411a953ec9d68005314bc8fe52bf1deb5f6c036a73ea4a08cf3346a120f765971c4fbb0049bdc4ebf97c867bf7e14f7690b8
6
+ metadata.gz: 1668111d5578d6c64f2ff7df6071f9ede6ccc4aeb01bf1c5cdccea8d514cf29caad0b2b576bb3bb60eaaaa309f44b723e066c62b39b50ab17e418372a29c4dad
7
+ data.tar.gz: 25636e700c032b2bc0509791f8884f9e9f6cb78ae09103db503d69739e48507c07c8a8fc6579f4f441c1f08caf09f19274ad19061305b9ad9b82efc5c41e9711
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- smart_todo (1.5.0)
5
- rexml
4
+ smart_todo (1.7.0)
5
+ prism (~> 0.15)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
@@ -19,6 +19,7 @@ GEM
19
19
  parser (3.2.2.3)
20
20
  ast (~> 2.4.1)
21
21
  racc
22
+ prism (0.17.0)
22
23
  public_suffix (4.0.6)
23
24
  racc (1.7.0)
24
25
  rainbow (3.1.1)
data/bin/profile ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "smart_todo"
6
+
7
+ class NullDispatcher < SmartTodo::Dispatchers::Base
8
+ class << self
9
+ def validate_options!(_); end
10
+ end
11
+
12
+ def dispatch
13
+ end
14
+ end
15
+
16
+ exit SmartTodo::CLI.new(NullDispatcher).run
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optionparser"
4
+ require "etc"
4
5
 
5
6
  module SmartTodo
6
7
  # This class is the entrypoint of the SmartTodo library and is responsible
7
8
  # to retrieve the command line options as well as iterating over each files/directories
8
9
  # to run the +CommentParser+ on.
9
10
  class CLI
10
- def initialize
11
+ def initialize(dispatcher = nil)
11
12
  @options = {}
12
13
  @errors = []
14
+ @dispatcher = dispatcher
13
15
  end
14
16
 
15
17
  # @param args [Array<String>]
@@ -19,15 +21,18 @@ module SmartTodo
19
21
 
20
22
  paths << "." if paths.empty?
21
23
 
24
+ comment_parser = CommentParser.new
22
25
  paths.each do |path|
23
- normalize_path(path).each do |file|
24
- parse_file(file)
26
+ normalize_path(path).each do |filepath|
27
+ comment_parser.parse_file(filepath)
25
28
 
26
29
  $stdout.print(".")
27
30
  $stdout.flush
28
31
  end
29
32
  end
30
33
 
34
+ process_dispatches(process_todos(comment_parser.todos))
35
+
31
36
  if @errors.empty?
32
37
  0
33
38
  else
@@ -79,14 +84,17 @@ module SmartTodo
79
84
  end
80
85
  end
81
86
 
82
- # @param file [String] a path to a file
83
- def parse_file(file)
84
- Parser::CommentParser.new(File.read(file, encoding: "UTF-8")).parse.each do |todo_node|
87
+ def process_todos(todos)
88
+ events = Events.new
89
+ dispatches = []
90
+
91
+ todos.each do |todo|
85
92
  event_message = nil
86
- event_met = todo_node.metadata.events.find do |event|
87
- event_message = Events.public_send(event.method_name, *event.arguments)
93
+ event_met = todo.events.find do |event|
94
+ event_message = events.public_send(event.method_name, *event.arguments)
88
95
  rescue => e
89
- message = "Error while parsing #{file} on event `#{event.method_name}` with arguments #{event.arguments}: " \
96
+ message = "Error while parsing #{todo.filepath} on event `#{event.method_name}` " \
97
+ "with arguments #{event.arguments.map(&:inspect)}: " \
90
98
  "#{e.message}"
91
99
 
92
100
  @errors << message
@@ -94,10 +102,36 @@ module SmartTodo
94
102
  nil
95
103
  end
96
104
 
97
- @errors.concat(todo_node.metadata.errors)
98
-
99
- dispatcher.new(event_message, todo_node, file, @options).dispatch if event_met
105
+ @errors.concat(todo.errors)
106
+ dispatches << [event_message, todo] if event_met
100
107
  end
108
+
109
+ dispatches
110
+ end
111
+
112
+ def process_dispatches(dispatches)
113
+ queue = Queue.new
114
+ dispatches.each { |dispatch| queue << dispatch }
115
+
116
+ thread_count = Etc.nprocessors
117
+ thread_count.times { queue << nil }
118
+
119
+ threads =
120
+ thread_count.times.map do
121
+ Thread.new do
122
+ Thread.current.abort_on_exception = true
123
+
124
+ loop do
125
+ dispatch = queue.pop
126
+ break if dispatch.nil?
127
+
128
+ (event_message, todo) = dispatch
129
+ dispatcher.new(event_message, todo, todo.filepath, @options).dispatch
130
+ end
131
+ end
132
+ end
133
+
134
+ threads.each(&:join)
101
135
  end
102
136
  end
103
137
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartTodo
4
+ class CommentParser
5
+ attr_reader :todos
6
+
7
+ def initialize
8
+ @todos = []
9
+ end
10
+
11
+ if Prism.respond_to?(:parse_comments)
12
+ def parse(source, filepath = "-e")
13
+ parse_comments(Prism.parse_comments(source), filepath)
14
+ end
15
+
16
+ def parse_file(filepath)
17
+ parse_comments(Prism.parse_file_comments(filepath), filepath)
18
+ end
19
+ else
20
+ def parse(source, filepath = "-e")
21
+ parse_comments(Prism.parse(source, filepath).comments, filepath)
22
+ end
23
+
24
+ def parse_file(filepath)
25
+ parse_comments(Prism.parse_file(filepath).comments, filepath)
26
+ end
27
+ end
28
+
29
+ class << self
30
+ def parse(source)
31
+ parser = new
32
+ parser.parse(source)
33
+ parser.todos
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ if defined?(Prism::InlineComment)
40
+ def inline?(comment)
41
+ comment.is_a?(Prism::InlineComment)
42
+ end
43
+ else
44
+ def inline?(comment)
45
+ comment.type == :inline
46
+ end
47
+ end
48
+
49
+ def parse_comments(comments, filepath)
50
+ current_todo = nil
51
+
52
+ comments.each do |comment|
53
+ next unless inline?(comment)
54
+
55
+ source = comment.location.slice
56
+
57
+ if source.match?(/^#\sTODO\(/)
58
+ todos << current_todo if current_todo
59
+ current_todo = Todo.new(source, filepath)
60
+ elsif current_todo && (indent = source[/^#(\s*)/, 1].length) && (indent - current_todo.indent == 2)
61
+ current_todo << "#{source[(indent + 1)..]}\n"
62
+ else
63
+ todos << current_todo if current_todo
64
+ current_todo = nil
65
+ end
66
+ end
67
+
68
+ todos << current_todo if current_todo
69
+ end
70
+ end
71
+ end
@@ -40,7 +40,7 @@ module SmartTodo
40
40
  @todo_node = todo_node
41
41
  @options = options
42
42
  @file = file
43
- @assignees = @todo_node.metadata.assignees
43
+ @assignees = @todo_node.assignees
44
44
  end
45
45
 
46
46
  # This method gets called when a TODO reminder is expired and needs to be delivered.
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ gem("bundler")
4
+ require "bundler"
5
+ require "net/http"
6
+ require "time"
7
+ require "json"
8
+
3
9
  module SmartTodo
4
10
  # This module contains all the methods accessible for SmartTodo comments.
5
11
  # It is meant to be reopened by the host application in order to define
@@ -10,7 +16,7 @@ module SmartTodo
10
16
  #
11
17
  # @example Adding a custom event
12
18
  # module SmartTodo
13
- # module Events
19
+ # class Events
14
20
  # def trello_card_close(card)
15
21
  # ...
16
22
  # end
@@ -18,33 +24,87 @@ module SmartTodo
18
24
  # end
19
25
  #
20
26
  # TODO(on: trello_card_close(381), to: 'john@example.com')
21
- module Events
22
- extend self
27
+ #
28
+ class Events
29
+ def initialize(now: nil, spec_set: nil, current_ruby_version: nil)
30
+ @now = now
31
+ @spec_set = spec_set
32
+ @rubygems_client = nil
33
+ @github_client = nil
34
+ @current_ruby_version = current_ruby_version
35
+ end
23
36
 
24
37
  # Check if the +date+ is in the past
25
38
  #
26
- # @param date [String] a correctly formatted date
39
+ # @param on_date [String] a string parsable by Time.parse
27
40
  # @return [false, String]
28
- def date(date)
29
- Date.met?(date)
41
+ def date(on_date)
42
+ if now >= Time.parse(on_date)
43
+ "We are past the *#{on_date}* due date and " \
44
+ "your TODO is now ready to be addressed."
45
+ else
46
+ false
47
+ end
30
48
  end
31
49
 
32
50
  # Check if a new version of +gem_name+ was released with the +requirements+ expected
33
51
  #
52
+ # @example Expecting a specific version
53
+ # gem_release('rails', '6.0')
54
+ #
55
+ # @example Expecting a version in the 5.x.x series
56
+ # gem_release('rails', '> 5.2', '< 6')
57
+ #
34
58
  # @param gem_name [String]
35
59
  # @param requirements [Array<String>] a list of version specifiers
36
60
  # @return [false, String]
37
61
  def gem_release(gem_name, *requirements)
38
- GemRelease.new(gem_name, requirements).met?
62
+ response = rubygems_client.get("/api/v1/versions/#{gem_name}.json")
63
+
64
+ if response.code_type < Net::HTTPClientError
65
+ "The gem *#{gem_name}* doesn't seem to exist, I can't determine if " \
66
+ "your TODO is ready to be addressed."
67
+ else
68
+ requirement = Gem::Requirement.new(requirements)
69
+ version = JSON.parse(response.body).find { |gem| requirement.satisfied_by?(Gem::Version.new(gem["number"])) }
70
+
71
+ if version
72
+ "The gem *#{gem_name}* was released to version *#{version["number"]}* and " \
73
+ "your TODO is now ready to be addressed."
74
+ else
75
+ false
76
+ end
77
+ end
39
78
  end
40
79
 
41
80
  # Check if +gem_name+ was bumped to the +requirements+ expected
42
81
  #
82
+ # @example Expecting a specific version
83
+ # gem_bump('rails', '6.0')
84
+ #
85
+ # @example Expecting a version in the 5.x.x series
86
+ # gem_bump('rails', '> 5.2', '< 6')
87
+ #
43
88
  # @param gem_name [String]
44
89
  # @param requirements [Array<String>] a list of version specifiers
45
90
  # @return [false, String]
46
91
  def gem_bump(gem_name, *requirements)
47
- GemBump.new(gem_name, requirements).met?
92
+ specs = spec_set[gem_name]
93
+
94
+ if specs.empty?
95
+ "The gem *#{gem_name}* is not in your dependencies, I can't determine if " \
96
+ "your TODO is ready to be addressed."
97
+ else
98
+ requirement = Gem::Requirement.new(requirements)
99
+ version = specs.first.version
100
+
101
+ if requirement.satisfied_by?(version)
102
+ "The gem *#{gem_name}* was updated to version *#{version}* and " \
103
+ "your TODO is now ready to be addressed."
104
+ else
105
+ false
106
+ end
107
+ end
48
108
  end
49
109
 
50
110
  # Check if the issue +issue_number+ is closed
@@ -54,7 +114,22 @@ module SmartTodo
54
114
  # @param issue_number [String, Integer]
55
115
  # @return [false, String]
56
116
  def issue_close(organization, repo, issue_number)
57
- IssueClose.new(organization, repo, issue_number, type: "issues").met?
117
+ headers = github_headers(organization, repo)
118
+ response = github_client.get("/repos/#{organization}/#{repo}/issues/#{issue_number}", headers)
119
+
120
+ if response.code_type < Net::HTTPClientError
121
+ <<~EOM
122
+ I can't retrieve the information from the issue *#{issue_number}* in the *#{organization}/#{repo}* repository.
123
+
124
+ If the repository is a private one, make sure to export the `#{GITHUB_TOKEN}`
125
+ environment variable with a correct GitHub token.
126
+ EOM
127
+ elsif JSON.parse(response.body)["state"] == "closed"
128
+ "The issue https://github.com/#{organization}/#{repo}/issues/#{issue_number} is now closed, " \
129
+ "your TODO is ready to be addressed."
130
+ else
131
+ false
132
+ end
58
133
  end
59
134
 
60
135
  # Check if the pull request +pr_number+ is closed
@@ -64,7 +139,84 @@ module SmartTodo
64
139
  # @param pr_number [String, Integer]
65
140
  # @return [false, String]
66
141
  def pull_request_close(organization, repo, pr_number)
67
- IssueClose.new(organization, repo, pr_number, type: "pulls").met?
142
+ headers = github_headers(organization, repo)
143
+ response = github_client.get("/repos/#{organization}/#{repo}/pulls/#{pr_number}", headers)
144
+
145
+ if response.code_type < Net::HTTPClientError
146
+ <<~EOM
147
+ I can't retrieve the information from the PR *#{pr_number}* in the *#{organization}/#{repo}* repository.
148
+
149
+ If the repository is a private one, make sure to export the `#{GITHUB_TOKEN}`
150
+ environment variable with a correct GitHub token.
151
+ EOM
152
+ elsif JSON.parse(response.body)["state"] == "closed"
153
+ "The pull request https://github.com/#{organization}/#{repo}/pull/#{pr_number} is now closed, " \
154
+ "your TODO is ready to be addressed."
155
+ else
156
+ false
157
+ end
158
+ end
159
+
160
+ # Check if the installed ruby version meets requirements.
161
+ #
162
+ # @param requirements [Array<String>] a list of version specifiers
163
+ # @return [false, String]
164
+ def ruby_version(*requirements)
165
+ requirement = Gem::Requirement.new(requirements)
166
+
167
+ if requirement.satisfied_by?(current_ruby_version)
168
+ "The currently installed version of Ruby #{current_ruby_version} is #{requirement}."
169
+ else
170
+ false
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def now
177
+ @now ||= Time.now
178
+ end
179
+
180
+ def spec_set
181
+ @spec_set ||= Bundler.load.specs
182
+ end
183
+
184
+ def rubygems_client
185
+ @rubygems_client ||= HttpClientBuilder.build("rubygems.org")
186
+ end
187
+
188
+ def github_client
189
+ @github_client ||= HttpClientBuilder.build("api.github.com")
190
+ end
191
+
192
+ def github_headers(organization, repo)
193
+ headers = { "Accept" => "application/vnd.github.v3+json" }
194
+
195
+ token = github_authorization_token(organization, repo)
196
+ headers["Authorization"] = "token #{token}" if token
197
+
198
+ headers
199
+ end
200
+
201
+ GITHUB_TOKEN = "SMART_TODO_GITHUB_TOKEN"
202
+
203
+ # @return [String, nil]
204
+ def github_authorization_token(organization, repo)
205
+ organization_name = organization.upcase.gsub(/[^A-Z0-9]/, "_")
206
+ repo_name = repo.upcase.gsub(/[^A-Z0-9]/, "_")
207
+
208
+ [
209
+ "#{GITHUB_TOKEN}__#{organization_name}__#{repo_name}",
210
+ "#{GITHUB_TOKEN}__#{organization_name}",
211
+ GITHUB_TOKEN,
212
+ ].find do |key|
213
+ token = ENV[key]
214
+ break token unless token.nil? || token.empty?
215
+ end
216
+ end
217
+
218
+ def current_ruby_version
219
+ @current_ruby_version ||= Gem::Version.new(RUBY_VERSION)
68
220
  end
69
221
  end
70
222
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module SmartTodo
6
+ # @api private
7
+ class HttpClientBuilder
8
+ class << self
9
+ def build(endpoint)
10
+ Net::HTTP.new(endpoint, Net::HTTP.https_default_port).tap do |client|
11
+ client.use_ssl = true
12
+ client.read_timeout = 30
13
+ client.ssl_timeout = 15
14
+ client.max_retries = 2
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -26,11 +26,7 @@ module SmartTodo
26
26
  # @param slack_token [String]
27
27
  def initialize(slack_token)
28
28
  @slack_token = slack_token
29
- @client = Net::HTTP.new("slack.com", Net::HTTP.https_default_port).tap do |client|
30
- client.use_ssl = true
31
- client.read_timeout = 30
32
- client.ssl_timeout = 15
33
- end
29
+ @client = HttpClientBuilder.build("slack.com")
34
30
  end
35
31
 
36
32
  # Retrieve the Slack ID of a user from his email
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartTodo
4
+ class Todo
5
+ attr_reader :filepath, :comment, :indent
6
+ attr_reader :events, :assignees, :errors
7
+
8
+ def initialize(source, filepath = "-e")
9
+ @filepath = filepath
10
+ @comment = +""
11
+ @indent = source[/^#(\s+)/, 1].length
12
+
13
+ @events = []
14
+ @assignees = []
15
+ @errors = []
16
+
17
+ parse(source[(indent + 1)..])
18
+ end
19
+
20
+ def <<(source)
21
+ comment << source
22
+ end
23
+
24
+ class CallNode
25
+ attr_reader :method_name, :arguments, :location
26
+
27
+ def initialize(method_name, arguments, location)
28
+ @arguments = arguments
29
+ @method_name = method_name
30
+ @location = location
31
+ end
32
+ end
33
+
34
+ class Compiler < Prism::Compiler
35
+ attr_reader :metadata
36
+
37
+ def initialize(metadata)
38
+ super()
39
+ @metadata = metadata
40
+ end
41
+
42
+ def visit_call_node(node)
43
+ CallNode.new(node.name, visit_all(node.arguments&.arguments || []), node.location)
44
+ end
45
+
46
+ def visit_integer_node(node)
47
+ node.value
48
+ end
49
+
50
+ def visit_keyword_hash_node(node)
51
+ node.elements.each do |element|
52
+ next unless (key = element.key).is_a?(Prism::SymbolNode)
53
+
54
+ case key.unescaped.to_sym
55
+ when :on
56
+ value = visit(element.value)
57
+
58
+ if value.is_a?(CallNode)
59
+ if value.arguments.all? { |arg| arg.is_a?(Integer) || arg.is_a?(String) }
60
+ metadata.events << value
61
+ else
62
+ metadata.errors << "Incorrect `:on` event format: #{value.location.slice}"
63
+ end
64
+ else
65
+ metadata.errors << "Incorrect `:on` event format: #{value.inspect}"
66
+ end
67
+ when :to
68
+ metadata.assignees << visit(element.value)
69
+ end
70
+ end
71
+ end
72
+
73
+ def visit_string_node(node)
74
+ node.unescaped
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def parse(source)
81
+ Prism.parse(source).value.statements.body.first.accept(Compiler.new(self))
82
+ end
83
+ end
84
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmartTodo
4
- VERSION = "1.5.0"
4
+ VERSION = "1.7.0"
5
5
  end
data/lib/smart_todo.rb CHANGED
@@ -1,24 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
3
4
  require "smart_todo/version"
4
5
  require "smart_todo/events"
5
6
 
6
7
  module SmartTodo
7
8
  autoload :SlackClient, "smart_todo/slack_client"
8
9
  autoload :CLI, "smart_todo/cli"
9
-
10
- module Parser
11
- autoload :CommentParser, "smart_todo/parser/comment_parser"
12
- autoload :TodoNode, "smart_todo/parser/todo_node"
13
- autoload :MetadataParser, "smart_todo/parser/metadata_parser"
14
- end
15
-
16
- module Events
17
- autoload :Date, "smart_todo/events/date"
18
- autoload :GemBump, "smart_todo/events/gem_bump"
19
- autoload :GemRelease, "smart_todo/events/gem_release"
20
- autoload :IssueClose, "smart_todo/events/issue_close"
21
- end
10
+ autoload :Todo, "smart_todo/todo"
11
+ autoload :CommentParser, "smart_todo/comment_parser"
12
+ autoload :HttpClientBuilder, "smart_todo/http_client_builder"
22
13
 
23
14
  module Dispatchers
24
15
  autoload :Base, "smart_todo/dispatchers/base"
@@ -17,7 +17,7 @@ module RuboCop
17
17
  # @return [void]
18
18
  def investigate(processed_source)
19
19
  processed_source.comments.each do |comment|
20
- next unless /^#\sTODO/ =~ comment.text
20
+ next unless /^#\sTODO/.match?(comment.text)
21
21
 
22
22
  metadata = metadata(comment.text)
23
23
 
@@ -36,21 +36,21 @@ module RuboCop
36
36
  # @param comment [String]
37
37
  # @return [SmartTodo::Parser::Visitor]
38
38
  def metadata(comment)
39
- ::SmartTodo::Parser::MetadataParser.parse(comment.gsub(/^#/, ""))
39
+ ::SmartTodo::Todo.new(comment)
40
40
  end
41
41
 
42
42
  # @param metadata [SmartTodo::Parser::Visitor]
43
43
  # @return [true, false]
44
44
  def smart_todo?(metadata)
45
45
  metadata.events.any? &&
46
- metadata.events.all? { |event| event.is_a?(::SmartTodo::Parser::MethodNode) } &&
46
+ metadata.events.all? { |event| event.is_a?(::SmartTodo::Todo::CallNode) } &&
47
47
  metadata.assignees.any?
48
48
  end
49
49
 
50
50
  # @param metadata [Array<SmartTodo::Parser::MethodNode>]
51
51
  # @return [Array<String>]
52
52
  def invalid_event_methods(events)
53
- events.map(&:method_name).reject { |method| ::SmartTodo::Events.respond_to?(method) }
53
+ events.map(&:method_name).reject { |method| ::SmartTodo::Events.method_defined?(method) }
54
54
  end
55
55
  end
56
56
  end
data/smart_todo.gemspec CHANGED
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.executables = ["smart_todo"]
34
34
  spec.require_paths = ["lib"]
35
35
 
36
- spec.add_runtime_dependency("rexml")
36
+ spec.add_runtime_dependency("prism", "~> 0.15")
37
37
  spec.add_development_dependency("bundler", ">= 1.17")
38
38
  spec.add_development_dependency("minitest", "~> 5.0")
39
39
  spec.add_development_dependency("rake", ">= 10.0")
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_todo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-06-13 00:00:00.000000000 Z
11
+ date: 2023-12-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rexml
14
+ name: prism
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '0.15'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '0.15'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -104,24 +104,21 @@ files:
104
104
  - README.md
105
105
  - Rakefile
106
106
  - bin/console
107
+ - bin/profile
107
108
  - bin/rubocop
108
109
  - bin/setup
109
110
  - dev.yml
110
111
  - exe/smart_todo
111
112
  - lib/smart_todo.rb
112
113
  - lib/smart_todo/cli.rb
114
+ - lib/smart_todo/comment_parser.rb
113
115
  - lib/smart_todo/dispatchers/base.rb
114
116
  - lib/smart_todo/dispatchers/output.rb
115
117
  - lib/smart_todo/dispatchers/slack.rb
116
118
  - lib/smart_todo/events.rb
117
- - lib/smart_todo/events/date.rb
118
- - lib/smart_todo/events/gem_bump.rb
119
- - lib/smart_todo/events/gem_release.rb
120
- - lib/smart_todo/events/issue_close.rb
121
- - lib/smart_todo/parser/comment_parser.rb
122
- - lib/smart_todo/parser/metadata_parser.rb
123
- - lib/smart_todo/parser/todo_node.rb
119
+ - lib/smart_todo/http_client_builder.rb
124
120
  - lib/smart_todo/slack_client.rb
121
+ - lib/smart_todo/todo.rb
125
122
  - lib/smart_todo/version.rb
126
123
  - lib/smart_todo_cop.rb
127
124
  - service.yml
@@ -150,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
147
  - !ruby/object:Gem::Version
151
148
  version: '0'
152
149
  requirements: []
153
- rubygems_version: 3.4.13
150
+ rubygems_version: 3.4.22
154
151
  signing_key:
155
152
  specification_version: 4
156
153
  summary: Enhance todo's comments in your codebase.
@@ -1,28 +0,0 @@
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
- class << self
10
- # @param on_date [String] a string parsable by Time.parse
11
- # @return [String, false]
12
- def met?(on_date)
13
- if Time.now >= Time.parse(on_date)
14
- message(on_date)
15
- else
16
- false
17
- end
18
- end
19
-
20
- # @param on_date [String]
21
- # @return [String]
22
- def message(on_date)
23
- "We are past the *#{on_date}* due date and your TODO is now ready to be addressed."
24
- end
25
- end
26
- end
27
- end
28
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem("bundler")
4
- require "bundler"
5
-
6
- module SmartTodo
7
- module Events
8
- # An event that compare the version of a gem specified in your Gemfile.lock
9
- # with the expected version specifiers.
10
- class GemBump
11
- # @param gem_name [String]
12
- # @param requirements [Array] a list of version specifiers.
13
- # The specifiers are the same as the one used in Gemfiles or Gemspecs
14
- #
15
- # @example Expecting a specific version
16
- # GemBump.new('rails', ['6.0'])
17
- #
18
- # @example Expecting a version in the 5.x.x series
19
- # GemBump.new('rails', ['> 5.2', '< 6'])
20
- def initialize(gem_name, requirements)
21
- @gem_name = gem_name
22
- @requirements = Gem::Requirement.new(requirements)
23
- end
24
-
25
- # @return [String, false]
26
- def met?
27
- return error_message if spec_set[@gem_name].empty?
28
-
29
- installed_version = spec_set[@gem_name].first.version
30
- if @requirements.satisfied_by?(installed_version)
31
- message(installed_version)
32
- else
33
- false
34
- end
35
- end
36
-
37
- # Error message send to Slack in case a gem couldn't be found
38
- #
39
- # @return [String]
40
- def error_message
41
- "The gem *#{@gem_name}* is not in your dependencies, I can't determine if your TODO is ready to be addressed."
42
- end
43
-
44
- # @return [String]
45
- def message(version_number)
46
- "The gem *#{@gem_name}* was updated to version *#{version_number}* and your TODO is now ready to be addressed."
47
- end
48
-
49
- private
50
-
51
- # @return [Bundler::SpecSet] an instance of Bundler::SpecSet
52
- def spec_set
53
- @spec_set ||= Bundler.load.specs
54
- end
55
- end
56
- end
57
- end
@@ -1,69 +0,0 @@
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
@@ -1,86 +0,0 @@
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 GitHub 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 IssueClose
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, type:)
21
- @url = "/repos/#{organization}/#{repo}/#{type}/#{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
@@ -1,71 +0,0 @@
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
@@ -1,124 +0,0 @@
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
- class << self
22
- # @param source [String] the actual Ruby code
23
- def parse(source)
24
- sexp = new(source).parse
25
- Visitor.new.tap { |v| v.process(sexp) }
26
- end
27
- end
28
-
29
- # @return [Array] an Array of Array
30
- # the first element from each inner array is a token
31
- def on_stmts_add(_, data)
32
- data
33
- end
34
-
35
- # @param method [String] the name of the method
36
- # when the parser hits one.
37
- # @param args [Array]
38
- # @return [Array, MethodNode]
39
- def on_method_add_arg(method, args)
40
- if method == "TODO"
41
- args
42
- else
43
- MethodNode.new(method, args)
44
- end
45
- end
46
-
47
- # @param list [nil, Array]
48
- # @param arg [String]
49
- # @return [Array]
50
- def on_args_add(list, arg)
51
- Array(list) << arg
52
- end
53
-
54
- # @param string_content [String]
55
- # @return [String]
56
- def on_string_add(_, string_content)
57
- string_content
58
- end
59
-
60
- # @param key [String]
61
- # @param value [String, Integer, MethodNode]
62
- def on_assoc_new(key, value)
63
- key.tr!(":", "")
64
-
65
- case key
66
- when "on"
67
- [:on_todo_event, value]
68
- when "to"
69
- [:on_todo_assignee, value]
70
- else
71
- [:unknown, value]
72
- end
73
- end
74
-
75
- # @param data [Hash]
76
- # @return [Hash]
77
- def on_bare_assoc_hash(data)
78
- data
79
- end
80
- end
81
-
82
- class Visitor
83
- attr_reader :events, :assignees, :errors
84
-
85
- def initialize
86
- @events = []
87
- @assignees = []
88
- @errors = []
89
- end
90
-
91
- # Iterate over each tokens returned from the parser and call
92
- # the corresponding method
93
- #
94
- # @param sexp [Array]
95
- # @return [void]
96
- def process(sexp)
97
- return unless sexp
98
-
99
- if sexp[0].is_a?(Array)
100
- sexp.each { |node| process(node) }
101
- else
102
- method, *args = sexp
103
- send(method, *args) if method.is_a?(Symbol) && respond_to?(method)
104
- end
105
- end
106
-
107
- # @param method_node [MethodNode]
108
- # @return [void]
109
- def on_todo_event(method_node)
110
- if method_node.is_a?(MethodNode)
111
- events << method_node
112
- else
113
- errors << "Incorrect `:on` event format: #{method_node}"
114
- end
115
- end
116
-
117
- # @param assignee [String]
118
- # @return [void]
119
- def on_todo_assignee(assignee)
120
- @assignees << assignee
121
- end
122
- end
123
- end
124
- end
@@ -1,43 +0,0 @@
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