Skip to content

Commit 236b2b0

Browse files
couimetclaude
andcommitted
Add issue_pin event to link TODOs with GitHub issues
Introduces a new `issue_pin` event that allows developers to reference GitHub issues in TODO comments without requiring the issue to be closed. This enables capturing "notes to future self" and maintaining context between code and GitHub issues for tracking purposes. Key features: - Links `TODO`s directly to GitHub issues (org/repo/issue_number format) - Always returns issue information regardless of state (open/closed) - Shows issue title, state, and assignee when smart_todo runs - Works without the `to:` parameter (no notifications required) - Optional `to:` parameter still supported for email notifications - Uses existing GitHub authentication infrastructure The event name `issue_pin` was chosen to convey the concept of pinning a note or reminder to an issue, similar to a sticky note. Unlike `issue_close` which only triggers when an issue is closed, `issue_pin` always displays the current issue status, making it perfect for tracking work items and maintaining links between implementation decisions and their corresponding GitHub issues. Implementation includes comprehensive test coverage and RuboCop cop updates to allow the event to work without assignees. 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]>
1 parent 70d74b6 commit 236b2b0

File tree

6 files changed

+300
-3
lines changed

6 files changed

+300
-3
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ When the TODO's event is met (i.e. a certain date is reached), the TODO's assign
4040
end
4141
```
4242

43+
**Pin TODOs to GitHub Issues**
44+
```ruby
45+
# TODO(on: issue_pin('shopify', 'smart_todo', '123'))
46+
# Remember to handle the edge cases discussed in the issue
47+
def process_order
48+
end
49+
```
50+
51+
The `issue_pin` event allows you to link TODO comments to GitHub issues without requiring the issue to be closed. This is perfect for tracking work items and maintaining context between your code and GitHub issues.
52+
4353
Documentation
4454
----------------
4555
Please check out the GitHub [wiki](https://github.com/Shopify/smart_todo/wiki) for documentation and example on how to setup SmartTodo in your project.

lib/smart_todo/events.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,35 @@ def issue_close(organization, repo, issue_number)
132132
end
133133
end
134134

135+
# Pin a TODO to a GitHub issue for tracking purposes
136+
# Unlike issue_close, this will always return issue information regardless of state
137+
#
138+
# @param organization [String] the GitHub organization name
139+
# @param repo [String] the GitHub repo name
140+
# @param issue_number [String, Integer]
141+
# @return [String] Always returns a message with issue information
142+
def issue_pin(organization, repo, issue_number)
143+
headers = github_headers(organization, repo)
144+
response = github_client.get("/repos/#{organization}/#{repo}/issues/#{issue_number}", headers)
145+
146+
if response.code_type < Net::HTTPClientError
147+
<<~EOM
148+
I can't retrieve the information from the issue *#{issue_number}* in the *#{organization}/#{repo}* repository.
149+
150+
If the repository is a private one, make sure to export the `#{GITHUB_TOKEN}`
151+
environment variable with a correct GitHub token.
152+
EOM
153+
else
154+
issue = JSON.parse(response.body)
155+
state = issue["state"]
156+
title = issue["title"]
157+
assignee = issue["assignee"] ? "@#{issue["assignee"]["login"]}" : "unassigned"
158+
159+
"📌 Pinned to issue ##{issue_number}: \"#{title}\" [#{state}] (#{assignee}) - " \
160+
"https://github.com/#{organization}/#{repo}/issues/#{issue_number}"
161+
end
162+
end
163+
135164
# Check if the pull request +pr_number+ is closed
136165
#
137166
# @param organization [String] the GitHub organization name

lib/smart_todo_cop.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,15 @@ def metadata(comment)
5353
# @param metadata [SmartTodo::Parser::Visitor]
5454
# @return [true, false]
5555
def smart_todo?(metadata)
56-
metadata.events.any? &&
57-
metadata.events.all? { |event| event.is_a?(::SmartTodo::Todo::CallNode) } &&
58-
metadata.assignees.any?
56+
has_valid_events = metadata.events.any? &&
57+
metadata.events.all? { |event| event.is_a?(::SmartTodo::Todo::CallNode) }
58+
59+
# Allow issue_pin without assignees (to: parameter)
60+
if has_valid_events && metadata.events.any? { |e| e.method_name == :issue_pin }
61+
true
62+
else
63+
has_valid_events && metadata.assignees.any?
64+
end
5965
end
6066

6167
# @param assignees [Array]
@@ -97,6 +103,12 @@ def validate_issue_close_args(args)
97103
validate_fixed_arity_args(args, 3, "issue_close", ["organization", "repo", "issue_number"])
98104
end
99105

106+
# @param args [Array]
107+
# @return [String, nil] Returns error message if arguments are invalid, nil if valid
108+
def validate_issue_pin_args(args)
109+
validate_fixed_arity_args(args, 3, "issue_pin", ["organization", "repo", "issue_number"])
110+
end
111+
100112
# @param args [Array]
101113
# @return [String, nil] Returns error message if arguments are invalid, nil if valid
102114
def validate_pull_request_close_args(args)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module SmartTodo
6+
class Events
7+
class IssuePinTest < Minitest::Test
8+
def test_when_issue_is_open
9+
stub_request(:get, /api.github.com/)
10+
.to_return(body: JSON.dump(
11+
state: "open",
12+
title: "Add support for caching",
13+
number: 123,
14+
assignee: { login: "johndoe" },
15+
))
16+
17+
expected = "📌 Pinned to issue #123: \"Add support for caching\" [open] (@johndoe) - " \
18+
"https://github.com/rails/rails/issues/123"
19+
20+
assert_equal(expected, issue_pin("rails", "rails", "123"))
21+
end
22+
23+
def test_when_issue_is_closed
24+
stub_request(:get, /api.github.com/)
25+
.to_return(body: JSON.dump(
26+
state: "closed",
27+
title: "Fix memory leak",
28+
number: 456,
29+
assignee: { login: "janedoe" },
30+
))
31+
32+
expected = "📌 Pinned to issue #456: \"Fix memory leak\" [closed] (@janedoe) - " \
33+
"https://github.com/shopify/smart_todo/issues/456"
34+
35+
assert_equal(expected, issue_pin("shopify", "smart_todo", "456"))
36+
end
37+
38+
def test_when_issue_has_no_assignee
39+
stub_request(:get, /api.github.com/)
40+
.to_return(body: JSON.dump(
41+
state: "open",
42+
title: "Improve documentation",
43+
number: 789,
44+
assignee: nil,
45+
))
46+
47+
expected = "📌 Pinned to issue #789: \"Improve documentation\" [open] (unassigned) - " \
48+
"https://github.com/org/repo/issues/789"
49+
50+
assert_equal(expected, issue_pin("org", "repo", "789"))
51+
end
52+
53+
def test_when_issue_does_not_exist
54+
stub_request(:get, /api.github.com/)
55+
.to_return(status: 404)
56+
57+
expected = <<~EOM
58+
I can't retrieve the information from the issue *999* in the *rails/rails* repository.
59+
60+
If the repository is a private one, make sure to export the `#{GITHUB_TOKEN}`
61+
environment variable with a correct GitHub token.
62+
EOM
63+
64+
assert_equal(expected, issue_pin("rails", "rails", "999"))
65+
end
66+
67+
def test_when_token_env_is_not_present
68+
stub_request(:get, /api.github.com/)
69+
.to_return(body: JSON.dump(
70+
state: "open",
71+
title: "Test issue",
72+
number: 1,
73+
assignee: nil,
74+
))
75+
76+
result = issue_pin("rails", "rails", "1")
77+
assert(result.include?("📌 Pinned to issue #1"))
78+
79+
assert_requested(:get, /api.github.com/) do |request|
80+
assert(!request.headers.key?("Authorization"))
81+
end
82+
end
83+
84+
def test_when_token_env_is_present
85+
ENV[GITHUB_TOKEN] = "abc123"
86+
87+
stub_request(:get, /api.github.com/)
88+
.to_return(body: JSON.dump(
89+
state: "open",
90+
title: "Test issue",
91+
number: 2,
92+
assignee: nil,
93+
))
94+
95+
result = issue_pin("rails", "rails", "2")
96+
assert(result.include?("📌 Pinned to issue #2"))
97+
98+
assert_requested(:get, /api.github.com/) do |request|
99+
assert_equal("token abc123", request.headers["Authorization"])
100+
end
101+
ensure
102+
ENV.delete(GITHUB_TOKEN)
103+
end
104+
105+
def test_when_organization_specific_token_is_present
106+
ENV["#{GITHUB_TOKEN}__RAILS"] = "rails_token"
107+
108+
stub_request(:get, /api.github.com/)
109+
.to_return(body: JSON.dump(
110+
state: "open",
111+
title: "Test issue",
112+
number: 3,
113+
assignee: nil,
114+
))
115+
116+
result = issue_pin("rails", "rails", "3")
117+
assert(result.include?("📌 Pinned to issue #3"))
118+
119+
assert_requested(:get, /api.github.com/) do |request|
120+
assert_equal("token rails_token", request.headers["Authorization"])
121+
end
122+
ensure
123+
ENV.delete("#{GITHUB_TOKEN}__RAILS")
124+
end
125+
126+
def test_when_repo_specific_token_is_present
127+
ENV["#{GITHUB_TOKEN}__RAILS__RAILS"] = "rails_rails_token"
128+
129+
stub_request(:get, /api.github.com/)
130+
.to_return(body: JSON.dump(
131+
state: "open",
132+
title: "Test issue",
133+
number: 4,
134+
assignee: nil,
135+
))
136+
137+
result = issue_pin("rails", "rails", "4")
138+
assert(result.include?("📌 Pinned to issue #4"))
139+
140+
assert_requested(:get, /api.github.com/) do |request|
141+
assert_equal("token rails_rails_token", request.headers["Authorization"])
142+
end
143+
ensure
144+
ENV.delete("#{GITHUB_TOKEN}__RAILS__RAILS")
145+
end
146+
147+
private
148+
149+
def issue_pin(*args)
150+
Events.new.issue_pin(*args)
151+
end
152+
end
153+
end
154+
end

test/smart_todo/integration_test.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,62 @@ def hello
104104
)
105105
end
106106

107+
def test_outputs_issue_pin_information_without_sending_notification
108+
ruby_code = <<~EOM
109+
# TODO(on: issue_pin('shopify', 'smart_todo', '123'))
110+
# Remember to update the caching strategy
111+
def hello
112+
end
113+
EOM
114+
115+
stub_request(:get, /api.github.com/)
116+
.to_return(body: JSON.dump(
117+
state: "open",
118+
title: "Improve caching",
119+
number: 123,
120+
assignee: { login: "developer" },
121+
))
122+
123+
generate_ruby_file(ruby_code) do |file|
124+
# Run CLI with output dispatcher to see results
125+
output = capture_subprocess_io do
126+
CLI.new.run([file.path, "--dispatcher", "output"])
127+
end.join
128+
129+
# Check that the issue information is in the output
130+
assert_match(/📌 Pinned to issue #123/, output)
131+
assert_match(/Improve caching/, output)
132+
end
133+
end
134+
135+
def test_sends_notification_when_issue_pin_has_assignee
136+
ruby_code = <<~EOM
137+
# TODO(on: issue_pin('shopify', 'smart_todo', '456'), to: '[email protected]')
138+
# Don't forget about the refactoring
139+
def hello
140+
end
141+
EOM
142+
143+
stub_request(:get, /api.github.com/)
144+
.to_return(body: JSON.dump(
145+
state: "closed",
146+
title: "Refactor authentication",
147+
number: 456,
148+
assignee: nil,
149+
))
150+
151+
generate_ruby_file(ruby_code) do |file|
152+
run_cli(file)
153+
end
154+
155+
assert_slack_message_sent(
156+
"Hello :wave:,",
157+
"📌 Pinned to issue #456",
158+
"Refactor authentication",
159+
"Don't forget about the refactoring",
160+
)
161+
end
162+
107163
private
108164

109165
def assert_slack_message_sent(*messages)

test/smart_todo/smart_todo_cop_test.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,42 @@ def hello
149149
RUBY
150150
end
151151

152+
def test_does_not_add_offense_when_using_issue_pin_without_to_parameter
153+
expect_no_offense(<<~RUBY)
154+
# TODO(on: issue_pin('shopify', 'smart_todo', '123'))
155+
# Remember to handle the edge cases
156+
def hello
157+
end
158+
RUBY
159+
end
160+
161+
def test_does_not_add_offense_when_using_issue_pin_with_to_parameter
162+
expect_no_offense(<<~RUBY)
163+
# TODO(on: issue_pin('shopify', 'smart_todo', '456'), to: '[email protected]')
164+
# Update the documentation
165+
def hello
166+
end
167+
RUBY
168+
end
169+
170+
def test_adds_offense_when_issue_pin_has_wrong_number_of_arguments
171+
expect_offense(<<~RUBY)
172+
# TODO(on: issue_pin('shopify', 'smart_todo'))
173+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ SmartTodo/SmartTodoCop: Invalid issue_pin event: Expected 3 arguments (organization, repo, issue_number), got 2. For more info please look at https://github.com/Shopify/smart_todo/wiki/Syntax
174+
def hello
175+
end
176+
RUBY
177+
end
178+
179+
def test_adds_offense_when_issue_pin_has_non_string_arguments
180+
expect_offense(<<~RUBY)
181+
# TODO(on: issue_pin('shopify', 'smart_todo', 123))
182+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ SmartTodo/SmartTodoCop: Invalid issue_pin event: Arguments must be strings. For more info please look at https://github.com/Shopify/smart_todo/wiki/Syntax
183+
def hello
184+
end
185+
RUBY
186+
end
187+
152188
def test_does_not_add_offense_when_comment_is_not_a_todo
153189
expect_no_offense(<<~RUBY)
154190
# @return [Void]

0 commit comments

Comments
 (0)