smart_todo 1.6.0 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c17d1471d893e5a764dda2fa9adcfc73049b5c5a45d6678feca40104879065fc
4
- data.tar.gz: 85411f13bc189f060372e90a72fc1a545cd0030b6f67cf0fd75f1ff97dbeb755
3
+ metadata.gz: 7bbca316fba93242bc5ea22479579407ed9ce3ed93a6bd618978223f96f0626d
4
+ data.tar.gz: c91cf35475f5a32da647f6ed649ac7d50d85a929908aa29f1768a9b1d6db75da
5
5
  SHA512:
6
- metadata.gz: bfe190ce26c9b3e65aea86f3d23e40a400c14edcac8c465b361341ac00a7ab7d59498c9c3edda92d7e180659cad96240148bce26dc3a1db77a92ee2e03575204
7
- data.tar.gz: f1c0386fbb5346a74c3f9c0c3b6228a9af29a810241793a5f44db5cbe7d7873c24e9dda987b65aff0d4984193be3652bec472431a48407badb2eb7610da506d6
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.6.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,22 @@ 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
68
158
  end
69
159
 
70
160
  # Check if the installed ruby version meets requirements.
@@ -72,7 +162,61 @@ module SmartTodo
72
162
  # @param requirements [Array<String>] a list of version specifiers
73
163
  # @return [false, String]
74
164
  def ruby_version(*requirements)
75
- RubyVersion.new(requirements).met?
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)
76
220
  end
77
221
  end
78
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.6.0"
4
+ VERSION = "1.7.0"
5
5
  end
data/lib/smart_todo.rb CHANGED
@@ -1,25 +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
- autoload :RubyVersion, "smart_todo/events/ruby_version"
22
- end
10
+ autoload :Todo, "smart_todo/todo"
11
+ autoload :CommentParser, "smart_todo/comment_parser"
12
+ autoload :HttpClientBuilder, "smart_todo/http_client_builder"
23
13
 
24
14
  module Dispatchers
25
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.6.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-07-18 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,25 +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/events/ruby_version.rb
122
- - lib/smart_todo/parser/comment_parser.rb
123
- - lib/smart_todo/parser/metadata_parser.rb
124
- - lib/smart_todo/parser/todo_node.rb
119
+ - lib/smart_todo/http_client_builder.rb
125
120
  - lib/smart_todo/slack_client.rb
121
+ - lib/smart_todo/todo.rb
126
122
  - lib/smart_todo/version.rb
127
123
  - lib/smart_todo_cop.rb
128
124
  - service.yml
@@ -151,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
151
147
  - !ruby/object:Gem::Version
152
148
  version: '0'
153
149
  requirements: []
154
- rubygems_version: 3.4.16
150
+ rubygems_version: 3.4.22
155
151
  signing_key:
156
152
  specification_version: 4
157
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,113 +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
- #
15
- # You can also set a per-org or per-repo token by exporting more specific environment variables:
16
- # +SMART_TODO_GITHUB_TOKEN__<ORG>+ and +SMART_TODO_GITHUB_TOKEN__<ORG>__<REPO>+
17
- # The +<ORG>+ and +<REPO>+ parts should be uppercased and use underscores.
18
- # For example, +Shopify/my-repo+ would become +SMART_TODO_GITHUB_TOKEN__SHOPIFY__MY_REPO=...+.
19
- class IssueClose
20
- TOKEN_ENV = "SMART_TODO_GITHUB_TOKEN"
21
-
22
- # @param organization [String]
23
- # @param repo [String]
24
- # @param pr_number [String, Integer]
25
- def initialize(organization, repo, pr_number, type:)
26
- @url = "/repos/#{organization}/#{repo}/#{type}/#{pr_number}"
27
- @organization = organization
28
- @repo = repo
29
- @pr_number = pr_number
30
- end
31
-
32
- # @return [String, false]
33
- def met?
34
- response = client.get(@url, default_headers)
35
-
36
- if response.code_type < Net::HTTPClientError
37
- error_message
38
- elsif pull_request_closed?(response.body)
39
- message
40
- else
41
- false
42
- end
43
- end
44
-
45
- # Error message send to Slack in case the Pull Request or Issue couldn't be found.
46
- #
47
- # @return [String]
48
- def error_message
49
- <<~EOM
50
- I can't retrieve the information from the PR or Issue *#{@pr_number}* in the
51
- *#{@organization}/#{@repo}* repository.
52
-
53
- If the repository is a private one, make sure to export the `#{TOKEN_ENV}`
54
- environment variable with a correct GitHub token.
55
- EOM
56
- end
57
-
58
- # @return [String]
59
- def message
60
- <<~EOM
61
- The Pull Request or Issue https://github.com/#{@organization}/#{@repo}/pull/#{@pr_number}
62
- is now closed, your TODO is ready to be addressed.
63
- EOM
64
- end
65
-
66
- private
67
-
68
- # @return [Net::HTTP] an instance of Net::HTTP
69
- def client
70
- @client ||= Net::HTTP.new("api.github.com", Net::HTTP.https_default_port).tap do |client|
71
- client.use_ssl = true
72
- end
73
- end
74
-
75
- # @param pull_request [String] the Pull Request or Issue
76
- # detail sent back from the GitHub API
77
- #
78
- # @return [true, false]
79
- def pull_request_closed?(pull_request)
80
- JSON.parse(pull_request)["state"] == "closed"
81
- end
82
-
83
- # @return [Hash]
84
- def default_headers
85
- { "Accept" => "application/vnd.github.v3+json" }.tap do |headers|
86
- token = authorization_token
87
- headers["Authorization"] = "token #{token}" if token
88
- end
89
- end
90
-
91
- # @return [String, nil]
92
- def authorization_token
93
- # Will look in order for:
94
- # SMART_TODO_GITHUB_TOKEN__ORG__REPO
95
- # SMART_TODO_GITHUB_TOKEN__ORG
96
- # SMART_TODO_GITHUB_TOKEN
97
- parts = [
98
- TOKEN_ENV,
99
- @organization.upcase.gsub(/[^A-Z0-9]/, "_"),
100
- @repo.upcase.gsub(/[^A-Z0-9]/, "_"),
101
- ]
102
-
103
- (parts.size - 1).downto(0).each do |i|
104
- key = parts[0..i].join("__")
105
- token = ENV[key]
106
- return token unless token.nil? || token.empty?
107
- end
108
-
109
- nil
110
- end
111
- end
112
- end
113
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SmartTodo
4
- module Events
5
- # An event that checks the currently installed ruby version.
6
- # @example
7
- # RubyVersion.new(['>= 2.3', '< 3'])
8
- class RubyVersion
9
- def initialize(requirements)
10
- @requirements = Gem::Requirement.new(requirements)
11
- end
12
-
13
- # @param requirements [Array<String>] a list of version specifiers
14
- # @return [String, false]
15
- def met?
16
- if @requirements.satisfied_by?(Gem::Version.new(installed_ruby_version))
17
- message(installed_ruby_version)
18
- else
19
- false
20
- end
21
- end
22
-
23
- # @param installed_ruby_version [String], requirements [String]
24
- # @return [String]
25
- def message(installed_ruby_version)
26
- "The currently installed version of Ruby #{installed_ruby_version} is #{@requirements}."
27
- end
28
-
29
- private
30
-
31
- def installed_ruby_version
32
- RUBY_VERSION
33
- end
34
- end
35
- end
36
- 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.start_with?(/TODO\W?/)
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