Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/smart_todo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module SmartTodo
autoload :Todo, "smart_todo/todo"
autoload :CommentParser, "smart_todo/comment_parser"
autoload :HttpClientBuilder, "smart_todo/http_client_builder"
autoload :GitUtils, "smart_todo/git_utils"

module Dispatchers
autoload :Base, "smart_todo/dispatchers/base"
Expand Down
3 changes: 2 additions & 1 deletion lib/smart_todo/comment_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def parse_comments(comments, filepath)

if source.match?(TAG_PATTERN)
todos << current_todo if current_todo
current_todo = Todo.new(source, filepath)
line_number = comment.location.start_line
current_todo = Todo.new(source, filepath, line_number)
elsif current_todo && (indent = source[/^#(\s*)/, 1].length) && (indent - current_todo.indent == 2)
current_todo << "#{source[(indent + 1)..]}\n"
else
Expand Down
31 changes: 30 additions & 1 deletion lib/smart_todo/dispatchers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ def slack_message(user, assignee)
existing_user
end

file_reference = generate_file_reference

<<~EOM
#{header}

You have an assigned TODO in the `#{@file}` file#{repo}.
You have an assigned TODO#{file_reference}#{repo}.
#{@event_message}

Here is the associated comment on your TODO:
Expand All @@ -79,6 +81,33 @@ def slack_message(user, assignee)
EOM
end

# Generates a file reference with link (if in a GitHub repo) or readable line reference
#
# @return [String]
def generate_file_reference
# Find the git repository root from the file's path, not from Dir.pwd
# This ensures we detect the correct repo even when smart_todo is run
# from a different directory than the repo root
git_root = GitUtils.find_git_root(@file)

# Try to generate a GitHub link if we have a line number and found a git repo
if @todo_node.line_number && git_root
github_link = GitUtils.generate_github_link(@file, @todo_node.line_number, git_root)

if github_link
" at <#{github_link}|#{@file}:#{@todo_node.line_number}>"
else
" in the `#{@file}` file on line #{@todo_node.line_number}"
end
elsif @todo_node.line_number
# Have line number but no git repo
" in the `#{@file}` file on line #{@todo_node.line_number}"
else
# Fallback to just the file name if no line number
" in the `#{@file}` file"
end
end

# Message in case a TODO's assignee doesn't exist in the Slack organization
#
# @param user [Hash]
Expand Down
145 changes: 145 additions & 0 deletions lib/smart_todo/git_utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# frozen_string_literal: true

module SmartTodo
module GitUtils
class << self
# Finds the git repository root by walking up the directory tree from the given path
# Returns the path to the directory containing .git, or nil if not found
#
# @param start_path [String] the starting directory path (usually from a file)
# @return [String, nil] the git repository root path, or nil if not in a git repo
def find_git_root(start_path)
# Normalize to absolute path and use directory if it's a file
path = File.expand_path(start_path)
path = File.dirname(path) if File.file?(path)

# Check cache first
return git_root_cache[path] if git_root_cache.key?(path)

# Walk up the directory tree looking for .git
current_path = path
loop do
git_dir = File.join(current_path, ".git")
if File.exist?(git_dir)
git_root_cache[path] = current_path
return current_path
end

parent = File.dirname(current_path)
# Reached filesystem root without finding .git
if parent == current_path
git_root_cache[path] = nil
return
end

current_path = parent
end
end

# Detects if the current directory is a git repository
# and extracts the GitHub organization and repository name
# Returns: { org: "Shopify", repo: "smart_todo", url: "https://github.com/Shopify/smart_todo" }
# Returns nil if not a git repository or cannot parse GitHub URL
#
# @param base_path [String] the directory path to check (defaults to Dir.pwd)
# @return [Hash, nil] GitHub info hash or nil
def github_info(base_path = Dir.pwd)
# Normalize path
base_path = File.expand_path(base_path)

# Check cache first
return git_info_cache[base_path] if git_info_cache.key?(base_path)

git_config = File.join(base_path, ".git", "config")
unless File.exist?(git_config)
git_info_cache[base_path] = nil
return
end

config_content = File.read(git_config)
remote_url = parse_remote_url(config_content)
unless remote_url
git_info_cache[base_path] = nil
return
end

result = parse_github_url(remote_url)
git_info_cache[base_path] = result
result
end

# Generates a GitHub file link with line number
# e.g., https://github.com/Shopify/smart_todo/blob/main/lib/todo.rb#L42
def generate_github_link(filepath, line_number, base_path = Dir.pwd)
github_info = github_info(base_path)
return unless github_info

# Convert absolute path to relative path from repo root
relative_path = if filepath.start_with?(base_path)
filepath.sub("#{base_path}/", "")
else
filepath
end

# Get the default branch (typically main)
branch = default_branch(base_path) || "main"

"#{github_info[:url]}/blob/#{branch}/#{relative_path}#L#{line_number}"
end

private

# Cache accessors - use methods instead of instance variables for thread safety
def git_root_cache
@git_root_cache ||= {}
end

def git_info_cache
@git_info_cache ||= {}
end

# Parses the git config file to extract the remote origin URL
def parse_remote_url(config_content)
# Match both HTTPS and SSH formats:
# - https://github.com/Shopify/smart_todo.git
# - git@github.com:Shopify/smart_todo.git
if config_content =~ %r{url\s*=\s*(https://github\.com/[^\s]+|git@github\.com:[^\s]+)}
::Regexp.last_match(1)
end
end

# Parses a GitHub URL (HTTPS or SSH) and extracts org/repo info
def parse_github_url(remote_url)
case remote_url
when %r{https://github\.com/([^/]+)/([^/\s]+?)(?:\.git)?$}
org = ::Regexp.last_match(1)
repo = ::Regexp.last_match(2)
{
org: org,
repo: repo,
url: "https://github.com/#{org}/#{repo}",
}
when %r{git@github\.com:([^/]+)/([^/\s]+?)(?:\.git)?$}
org = ::Regexp.last_match(1)
repo = ::Regexp.last_match(2)
{
org: org,
repo: repo,
url: "https://github.com/#{org}/#{repo}",
}
end
end

# Gets the default branch name from HEAD reference
def default_branch(base_path)
head_file = File.join(base_path, ".git", "HEAD")
return unless File.exist?(head_file)

head_content = File.read(head_file).strip
if head_content =~ %r{ref: refs/heads/(.+)}
::Regexp.last_match(1)
end
end
end
end
end
5 changes: 3 additions & 2 deletions lib/smart_todo/todo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

module SmartTodo
class Todo
attr_reader :filepath, :comment, :indent
attr_reader :filepath, :comment, :indent, :line_number
attr_reader :events, :assignees, :errors
attr_accessor :context

def initialize(source, filepath = "-e")
def initialize(source, filepath = "-e", line_number = nil)
@filepath = filepath
@line_number = line_number
@comment = +""
@indent = source[/^#(\s+)/, 1].length

Expand Down
35 changes: 25 additions & 10 deletions test/smart_todo/dispatchers/output_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,37 @@ def setup
@options = { fallback_channel: "#general", slack_token: "123", repo: "example" }
end

def test_dispatch
def test_dispatch_with_github_link
dispatcher = Output.new("Foo", todo_node, "file.rb", @options)
expected_text = <<~HEREDOC
Hello :wave:,

You have an assigned TODO in the `file.rb` file in repository `example`.
Foo
output = capture_io { dispatcher.dispatch }[0]

Here is the associated comment on your TODO:
# Verify it includes a GitHub link (we're in the smart_todo repo)
assert_match(%r{<https://github.com/Shopify/smart_todo/blob/[^/]+/file\.rb#L1\|file\.rb:1>}, output)
assert_match(/Foo/, output)
assert_match(/Hello :wave:,/, output)
end

def test_dispatch_without_github_link
# Use a file path in a temporary directory without a git repo
Dir.mktmpdir do |tmpdir|
filepath = File.join(tmpdir, "file.rb")
dispatcher = Output.new("Foo", todo_node, filepath, @options)
expected_text = <<~HEREDOC
Hello :wave:,

You have an assigned TODO in the `#{filepath}` file on line 1 in repository `example`.
Foo

Here is the associated comment on your TODO:

```
```

```
HEREDOC
```
HEREDOC

assert_output(expected_text) { dispatcher.dispatch }
assert_output(expected_text) { dispatcher.dispatch }
end
end

private
Expand Down
Loading