smart_todo 1.9.2 → 1.11.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: beb68e6ca2807c8c2fb782fb34109d5abe45f3e306cab7ad2f34b211f6a250aa
4
- data.tar.gz: cc9978ba39c42bf194de59836c945197d066537187e34285857758bfcaf63ed5
3
+ metadata.gz: cd76c16d18b12ed3caada5a661031dd5f14c60aa1b0b227c0eb720116755c2cc
4
+ data.tar.gz: 91dadbb687dcfd22c4152f2b38054873f6d64fce3b43d8d96b8ac6891947e92a
5
5
  SHA512:
6
- metadata.gz: 580080fabbbcc83ad8f372e56b5d4c2238688fc4617edd1501ac3032ddd61497db9e9c2a648f7a7b32acdda27790fa63fe0eb5c5c08d8b97c2e72116f2868f92
7
- data.tar.gz: 6593132b64a848caa0205518b8c2124d04e6099b225af92e18ff997b5d204c1518c70c556280b766f3d6ea639bf9d4b44460152230f37baeda08f49f707bc889
6
+ metadata.gz: cede44f989ddbc2a4174c5c273e1e7e08521bbaaddc2a18fe5afc88d04fb84cdb6644e9d6c6839f6e7257dc1271f7f188bbdc6edec21a19debb8e0a438c20130
7
+ data.tar.gz: fc3d770921d2c2d7a275236ea068c9aef5f2202de990c644afeb4cfb974581e62e8ba77ae91bc6d6af265b438dde88b62052a9046f57301808a1a695d12e3c1c
@@ -15,7 +15,7 @@ jobs:
15
15
  version: [3.0, 3.1, 3.2, 3.3]
16
16
 
17
17
  steps:
18
- - uses: actions/checkout@v4
18
+ - uses: actions/checkout@v5
19
19
  - name: Set up Ruby ${{ matrix.version }}
20
20
  uses: ruby/setup-ruby@v1
21
21
  with:
@@ -7,7 +7,7 @@ jobs:
7
7
  runs-on: ubuntu-latest
8
8
 
9
9
  steps:
10
- - uses: actions/checkout@v4
10
+ - uses: actions/checkout@v5
11
11
  - name: Set up Ruby
12
12
  uses: ruby/setup-ruby@v1
13
13
  with:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- smart_todo (1.9.2)
4
+ smart_todo (1.11.0)
5
5
  prism (~> 1.0)
6
6
 
7
7
  GEM
@@ -10,26 +10,26 @@ GEM
10
10
  addressable (2.8.7)
11
11
  public_suffix (>= 2.0.2, < 7.0)
12
12
  ast (2.4.3)
13
- bigdecimal (3.1.9)
14
- crack (1.0.0)
13
+ bigdecimal (3.3.1)
14
+ crack (1.0.1)
15
15
  bigdecimal
16
16
  rexml
17
- hashdiff (1.1.2)
17
+ hashdiff (1.2.1)
18
18
  json (2.10.2)
19
19
  language_server-protocol (3.17.0.4)
20
20
  lint_roller (1.1.0)
21
- minitest (5.25.5)
21
+ minitest (5.26.0)
22
22
  parallel (1.26.3)
23
23
  parser (3.3.7.3)
24
24
  ast (~> 2.4.1)
25
25
  racc
26
- prism (1.4.0)
27
- public_suffix (6.0.1)
26
+ prism (1.6.0)
27
+ public_suffix (6.0.2)
28
28
  racc (1.8.1)
29
29
  rainbow (3.1.1)
30
- rake (13.2.1)
30
+ rake (13.3.0)
31
31
  regexp_parser (2.10.0)
32
- rexml (3.4.1)
32
+ rexml (3.4.4)
33
33
  rubocop (1.74.0)
34
34
  json (~> 2.3)
35
35
  language_server-protocol (~> 3.17.0.2)
@@ -49,7 +49,7 @@ GEM
49
49
  unicode-display_width (3.1.4)
50
50
  unicode-emoji (~> 4.0, >= 4.0.4)
51
51
  unicode-emoji (4.0.4)
52
- webmock (3.25.1)
52
+ webmock (3.26.0)
53
53
  addressable (>= 2.8.0)
54
54
  crack (>= 0.3.2)
55
55
  hashdiff (>= 0.4.0, < 2.0.0)
@@ -66,6 +66,9 @@ module SmartTodo
66
66
  opts.on("--dispatcher DISPATCHER") do |dispatcher|
67
67
  @options[:dispatcher] = dispatcher
68
68
  end
69
+ opts.on("--repo [REPO]", "Repository name to include in notifications") do |repo|
70
+ @options[:repo] = repo || File.basename(Dir.pwd)
71
+ end
69
72
  end
70
73
  end
71
74
 
@@ -103,12 +106,40 @@ module SmartTodo
103
106
  end
104
107
 
105
108
  @errors.concat(todo.errors)
106
- dispatches << [event_message, todo] if event_met
109
+
110
+ next unless event_met
111
+
112
+ event_message = append_context_if_applicable(event_message, todo, event_met, events)
113
+
114
+ dispatches << [event_message, todo]
107
115
  end
108
116
 
109
117
  dispatches
110
118
  end
111
119
 
120
+ private
121
+
122
+ # @param event_message [String] the original event message
123
+ # @param todo [Todo] the todo object that may contain context
124
+ # @param event [Event] the event that was met
125
+ # @param events [Events] the events instance for fetching issue context
126
+ # @return [String] the event message, potentially with context appended
127
+ def append_context_if_applicable(event_message, todo, event, events)
128
+ return event_message unless should_apply_context?(todo, event)
129
+
130
+ org, repo, issue_number = todo.context.arguments
131
+ context_message = events.issue_context(org, repo, issue_number)
132
+
133
+ context_message ? "#{event_message}\n\n#{context_message}" : event_message
134
+ end
135
+
136
+ # @param todo [Todo] the todo object to check for context
137
+ # @param event [Event] the event to check
138
+ # @return [Boolean] true if context should be applied, false otherwise
139
+ def should_apply_context?(todo, event)
140
+ !!todo.context
141
+ end
142
+
112
143
  def process_dispatches(dispatches)
113
144
  queue = Queue.new
114
145
  dispatches.each { |dispatch| queue << dispatch }
@@ -2,6 +2,9 @@
2
2
 
3
3
  module SmartTodo
4
4
  class CommentParser
5
+ SUPPORTED_TAGS = ["TODO", "FIXME", "OPTIMIZE"].freeze
6
+ TAG_PATTERN = /^#\s(#{SUPPORTED_TAGS.join("|")})\(/
7
+
5
8
  attr_reader :todos
6
9
 
7
10
  def initialize
@@ -54,7 +57,7 @@ module SmartTodo
54
57
 
55
58
  source = comment.location.slice
56
59
 
57
- if source.match?(/^#\sTODO\(/)
60
+ if source.match?(TAG_PATTERN)
58
61
  todos << current_todo if current_todo
59
62
  current_todo = Todo.new(source, filepath)
60
63
  elsif current_todo && (indent = source[/^#(\s*)/, 1].length) && (indent - current_todo.indent == 2)
@@ -68,7 +68,7 @@ module SmartTodo
68
68
  <<~EOM
69
69
  #{header}
70
70
 
71
- You have an assigned TODO in the `#{@file}` file.
71
+ You have an assigned TODO in the `#{@file}` file#{repo}.
72
72
  #{@event_message}
73
73
 
74
74
  Here is the associated comment on your TODO:
@@ -91,6 +91,15 @@ module SmartTodo
91
91
  def existing_user
92
92
  "Hello :wave:,"
93
93
  end
94
+
95
+ def repo
96
+ repo = @options[:repo]
97
+ return unless repo
98
+
99
+ unless repo.empty?
100
+ " in repository `#{repo}`"
101
+ end
102
+ end
94
103
  end
95
104
  end
96
105
  end
@@ -7,7 +7,8 @@ module SmartTodo
7
7
  class Slack < Base
8
8
  class << self
9
9
  def validate_options!(options)
10
- options[:slack_token] ||= ENV.fetch("SMART_TODO_SLACK_TOKEN") { raise(ArgumentError, "Missing :slack_token") }
10
+ options[:slack_token] ||= ENV.fetch("SMART_TODO_SLACK_TOKEN", "")
11
+ raise(ArgumentError, "Missing :slack_token") if options[:slack_token].empty?
11
12
 
12
13
  options.fetch(:fallback_channel) { raise(ArgumentError, "Missing :fallback_channel") }
13
14
  end
@@ -75,6 +75,8 @@ module SmartTodo
75
75
  false
76
76
  end
77
77
  end
78
+ rescue Net::HTTPError, JSON::ParserError
79
+ "Error retrieving gem information for *#{gem_name}*."
78
80
  end
79
81
 
80
82
  # Check if +gem_name+ was bumped to the +requirements+ expected
@@ -130,6 +132,8 @@ module SmartTodo
130
132
  else
131
133
  false
132
134
  end
135
+ rescue Net::HTTPError, JSON::ParserError
136
+ "Error retrieving issue information for #{organization}/#{repo}##{issue_number}."
133
137
  end
134
138
 
135
139
  # Check if the pull request +pr_number+ is closed
@@ -155,6 +159,34 @@ module SmartTodo
155
159
  else
156
160
  false
157
161
  end
162
+ rescue Net::HTTPError, JSON::ParserError
163
+ "Error retrieving pull request information for #{organization}/#{repo}##{pr_number}."
164
+ end
165
+
166
+ # Retrieve context information for an issue
167
+ # This is used when a TODO has a context: issue() attribute
168
+ #
169
+ # @param organization [String] the GitHub organization name
170
+ # @param repo [String] the GitHub repo name
171
+ # @param issue_number [String, Integer]
172
+ # @return [String, nil]
173
+ def issue_context(organization, repo, issue_number)
174
+ headers = github_headers(organization, repo)
175
+ response = github_client.get("/repos/#{organization}/#{repo}/issues/#{issue_number}", headers)
176
+
177
+ if response.code_type < Net::HTTPClientError
178
+ nil
179
+ else
180
+ issue = JSON.parse(response.body)
181
+ state = issue["state"]
182
+ title = issue["title"]
183
+ assignee = issue["assignee"] ? "@#{issue["assignee"]["login"]}" : "unassigned"
184
+
185
+ "📌 Context: Issue ##{issue_number} - \"#{title}\" [#{state}] (#{assignee}) - " \
186
+ "https://github.com/#{organization}/#{repo}/issues/#{issue_number}"
187
+ end
188
+ rescue Net::HTTPError, JSON::ParserError
189
+ nil
158
190
  end
159
191
 
160
192
  # Check if the installed ruby version meets requirements.
@@ -4,6 +4,7 @@ module SmartTodo
4
4
  class Todo
5
5
  attr_reader :filepath, :comment, :indent
6
6
  attr_reader :events, :assignees, :errors
7
+ attr_accessor :context
7
8
 
8
9
  def initialize(source, filepath = "-e")
9
10
  @filepath = filepath
@@ -12,6 +13,7 @@ module SmartTodo
12
13
 
13
14
  @events = []
14
15
  @assignees = []
16
+ @context = nil
15
17
  @errors = []
16
18
 
17
19
  parse(source[(indent + 1)..])
@@ -66,6 +68,28 @@ module SmartTodo
66
68
  end
67
69
  when :to
68
70
  metadata.assignees << visit(element.value)
71
+ when :context
72
+ value = visit(element.value)
73
+
74
+ unless value.is_a?(String)
75
+ metadata.errors << "Incorrect `:context` format: expected string value"
76
+ next
77
+ end
78
+
79
+ unless value =~ %r{^([^/]+)/([^#]+)#(\d+)$}
80
+ metadata.errors << "Incorrect `:context` format: expected \"org/repo#issue_number\""
81
+ next
82
+ end
83
+
84
+ org = ::Regexp.last_match(1)
85
+ repo = ::Regexp.last_match(2)
86
+ issue_number = ::Regexp.last_match(3)
87
+
88
+ if org.empty? || repo.empty?
89
+ metadata.errors << "Incorrect `:context` format: org and repo cannot be empty"
90
+ else
91
+ metadata.context = CallNode.new(:issue, [org, repo, issue_number], element.value.location)
92
+ end
69
93
  end
70
94
  end
71
95
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmartTodo
4
- VERSION = "1.9.2"
4
+ VERSION = "1.11.0"
5
5
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "smart_todo"
4
+ require "parser"
5
+
6
+ module RuboCop
7
+ module Cop
8
+ module SmartTodo
9
+ # A RuboCop cop to enforce proper formatting of SmartTodo comments.
10
+ # SmartTodo comments must have their description on separate lines, indented by 2 spaces.
11
+ #
12
+ # Bad:
13
+ # # TODO(on: date('2024-03-29'), to: 'john@example.com'): Remove this
14
+ # # TODO(on: date('2024-03-29'), to: 'john@example.com') Remove this
15
+ # # TODO(on: date('2024-03-29'), to: 'john@example.com')
16
+ # # Remove this (not indented)
17
+ #
18
+ # Good:
19
+ # # TODO(on: date('2024-03-29'), to: 'john@example.com')
20
+ # # Remove this (indented by 2 extra spaces)
21
+ #
22
+ class SmartTodoCommentFormatCop < Base
23
+ extend AutoCorrector
24
+
25
+ MSG_INLINE = "SmartTodo comment must not be on the same line as the TODO. " \
26
+ "For more info please look at https://github.com/Shopify/smart_todo/wiki/Syntax"
27
+ MSG_INDENT = "SmartTodo continuation line must be indented by 2 spaces. " \
28
+ "For more info please look at https://github.com/Shopify/smart_todo/wiki/Syntax"
29
+
30
+ SMART_TODO_PATTERN = /\A\s*#\s*(TODO|FIXME|OPTIMIZE)\(on:/
31
+ INLINE_TEXT_PATTERN = /\A(\s*#\s*(?:TODO|FIXME|OPTIMIZE)\(.+\))\s*:?\s+(.+)/
32
+
33
+ def on_new_investigation
34
+ processed_source.comments.each_with_index do |comment, index|
35
+ next unless smart_todo_comment?(comment)
36
+
37
+ check_inline_text(comment)
38
+ check_continuation_indent(comment, processed_source.comments[index + 1])
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def smart_todo_comment?(comment)
45
+ comment.text.match?(SMART_TODO_PATTERN)
46
+ end
47
+
48
+ def check_inline_text(comment)
49
+ match = comment.text.match(INLINE_TEXT_PATTERN)
50
+ return unless match
51
+
52
+ add_offense(comment.location.expression, message: MSG_INLINE) do |corrector|
53
+ todo_part = match[1]
54
+ text_part = match[2]
55
+ indentation = " " * comment.location.column
56
+
57
+ corrected = "#{todo_part}\n#{indentation}# #{text_part}"
58
+ corrector.replace(comment.location.expression, corrected)
59
+ end
60
+ end
61
+
62
+ def check_continuation_indent(comment, next_comment)
63
+ return unless next_comment
64
+ return unless next_comment.location.line == comment.location.line + 1
65
+ return if smart_todo_comment?(next_comment)
66
+ return if empty_comment?(next_comment)
67
+ return if properly_indented?(next_comment)
68
+
69
+ add_offense(next_comment.location.expression, message: MSG_INDENT) do |corrector|
70
+ corrected = fix_indentation(next_comment.text)
71
+ corrector.replace(next_comment.location.expression, corrected)
72
+ end
73
+ end
74
+
75
+ def empty_comment?(comment)
76
+ comment.text.match?(/\A\s*#\s*\z/)
77
+ end
78
+
79
+ def properly_indented?(comment)
80
+ # A properly indented continuation has exactly 2 spaces after the #
81
+ comment.text.match?(/\A\s*# \S/)
82
+ end
83
+
84
+ def fix_indentation(text)
85
+ # Extract leading whitespace and content
86
+ match = text.match(/\A(\s*)#\s*(\S.*)\z/)
87
+ return text unless match
88
+
89
+ leading_space = match[1]
90
+ content = match[2]
91
+ "#{leading_space}# #{content}"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "smart_todo"
4
+ require "date"
4
5
 
5
6
  module RuboCop
6
7
  module Cop
@@ -12,12 +13,20 @@ module RuboCop
12
13
  class SmartTodoCop < Base
13
14
  HELP = "For more info please look at https://github.com/Shopify/smart_todo/wiki/Syntax"
14
15
  MSG = "Don't write regular TODO comments. Write SmartTodo compatible syntax comments. #{HELP}"
16
+ INVESTIGATED_TAGS = ::SmartTodo::CommentParser::SUPPORTED_TAGS +
17
+ ::SmartTodo::CommentParser::SUPPORTED_TAGS.map(&:downcase)
18
+ TODO_PATTERN = /^#\s@?(#{INVESTIGATED_TAGS.join("|")})\b/
15
19
 
16
20
  # @param processed_source [RuboCop::ProcessedSource]
17
21
  # @return [void]
18
22
  def on_new_investigation
19
23
  processed_source.comments.each do |comment|
20
- next unless /^#\sTODO/.match?(comment.text)
24
+ next unless (match = TODO_PATTERN.match(comment.text))
25
+
26
+ if match[1] != match[1].upcase
27
+ add_offense(comment)
28
+ next
29
+ end
21
30
 
22
31
  metadata = metadata(comment.text)
23
32
 
@@ -25,10 +34,10 @@ module RuboCop
25
34
  add_offense(comment, message: "Invalid TODO format: #{metadata.errors.join(", ")}. #{HELP}")
26
35
  elsif !smart_todo?(metadata)
27
36
  add_offense(comment)
28
- elsif (methods = invalid_event_methods(metadata.events)).any?
29
- add_offense(comment, message: "Invalid event method(s): #{methods.join(", ")}. #{HELP}")
30
37
  elsif invalid_assignees(metadata.assignees).any?
31
38
  add_offense(comment, message: "Invalid event assignee. This method only accepts strings. #{HELP}")
39
+ elsif (invalid_events = validate_events(metadata.events)).any?
40
+ add_offense(comment, message: "#{invalid_events.join(". ")}. #{HELP}")
32
41
  end
33
42
  end
34
43
  end
@@ -49,17 +58,94 @@ module RuboCop
49
58
  metadata.assignees.any?
50
59
  end
51
60
 
52
- # @param metadata [Array<SmartTodo::Parser::MethodNode>]
53
- # @return [Array<String>]
54
- def invalid_event_methods(events)
55
- events.map(&:method_name).reject { |method| ::SmartTodo::Events.method_defined?(method) }
56
- end
57
-
58
61
  # @param assignees [Array]
59
62
  # @return [Array]
60
63
  def invalid_assignees(assignees)
61
64
  assignees.reject { |assignee| assignee.is_a?(String) }
62
65
  end
66
+
67
+ # @param events [Array<SmartTodo::Todo::CallNode>]
68
+ # @return [Array<String>]
69
+ def validate_events(events)
70
+ invalid_methods = events.map(&:method_name).reject { |method| ::SmartTodo::Events.method_defined?(method) }
71
+ return ["Invalid event method(s): #{invalid_methods.join(", ")}"] if invalid_methods.any?
72
+
73
+ events.map do |event|
74
+ send(validate_method(event.method_name), event.arguments)
75
+ end.compact
76
+ end
77
+
78
+ # @param event_type [Symbol]
79
+ # @return [String]
80
+ def validate_method(event_type)
81
+ "validate_#{event_type}_args"
82
+ end
83
+
84
+ # @param args [Array]
85
+ # @return [String, nil] Returns error message if date is invalid, nil if valid
86
+ def validate_date_args(args)
87
+ date = args.first
88
+ Date.parse(date)
89
+ nil
90
+ rescue ArgumentError, TypeError
91
+ "Invalid date format: #{date}"
92
+ end
93
+
94
+ # @param args [Array]
95
+ # @return [String, nil] Returns error message if arguments are invalid, nil if valid
96
+ def validate_issue_close_args(args)
97
+ validate_fixed_arity_args(args, 3, "issue_close", ["organization", "repo", "issue_number"])
98
+ end
99
+
100
+ # @param args [Array]
101
+ # @return [String, nil] Returns error message if arguments are invalid, nil if valid
102
+ def validate_pull_request_close_args(args)
103
+ validate_fixed_arity_args(args, 3, "pull_request_close", ["organization", "repo", "pr_number"])
104
+ end
105
+
106
+ # @param args [Array]
107
+ # @return [String, nil] Returns error message if arguments are invalid, nil if valid
108
+ def validate_gem_release_args(args)
109
+ validate_gem_args(args, "gem_release")
110
+ end
111
+
112
+ # @param args [Array]
113
+ # @return [String, nil] Returns error message if arguments are invalid, nil if valid
114
+ def validate_gem_bump_args(args)
115
+ validate_gem_args(args, "gem_bump")
116
+ end
117
+
118
+ # @param args [Array]
119
+ # @return [String, nil] Returns error message if arguments are invalid, nil if valid
120
+ def validate_ruby_version_args(args)
121
+ if args.empty?
122
+ "Invalid ruby_version event: Expected at least 1 argument (version requirement), got 0"
123
+ elsif !args.all? { |arg| arg.is_a?(String) }
124
+ "Invalid ruby_version event: Version requirements must be strings"
125
+ end
126
+ end
127
+
128
+ # Helper method for validating fixed arity events
129
+ def validate_fixed_arity_args(args, expected_count, event_name, arg_names)
130
+ if args.size != expected_count
131
+ message = "Invalid #{event_name} event: Expected #{expected_count} arguments "
132
+ message += "(#{arg_names.join(", ")}), got #{args.size}"
133
+ message
134
+ elsif !args.all? { |arg| arg.is_a?(String) }
135
+ "Invalid #{event_name} event: Arguments must be strings"
136
+ end
137
+ end
138
+
139
+ # Helper method for validating gem-related events
140
+ def validate_gem_args(args, event_name)
141
+ if args.empty?
142
+ "Invalid #{event_name} event: Expected at least 1 argument (gem_name), got 0"
143
+ elsif !args[0].is_a?(String)
144
+ "Invalid #{event_name} event: First argument (gem_name) must be a string"
145
+ elsif args.size > 1 && !args[1..].all? { |arg| arg.is_a?(String) }
146
+ "Invalid #{event_name} event: Version requirements must be strings"
147
+ end
148
+ end
63
149
  end
64
150
  end
65
151
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_todo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.2
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-03-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: prism
@@ -121,6 +121,7 @@ files:
121
121
  - lib/smart_todo/slack_client.rb
122
122
  - lib/smart_todo/todo.rb
123
123
  - lib/smart_todo/version.rb
124
+ - lib/smart_todo_comment_format_cop.rb
124
125
  - lib/smart_todo_cop.rb
125
126
  - service.yml
126
127
  - shipit.rubygems.yml
@@ -147,7 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
148
  - !ruby/object:Gem::Version
148
149
  version: '0'
149
150
  requirements: []
150
- rubygems_version: 3.6.6
151
+ rubygems_version: 3.7.2
151
152
  specification_version: 4
152
153
  summary: Enhance todo's comments in your codebase.
153
154
  test_files: []