Skip to content

Commit 575f5f5

Browse files
Matthieu Ciapparaclaude
andcommitted
fix: resolve all rubocop offenses
- Add documentation comments on all modules - Split Config.load into merge_yaml!/merge_env! private helpers - Split UrlParser.parse into validate_scheme!/extract_path_and_iid - Extract build_option_parser/define_info_options from parse_args - Extract run() from main to reduce method length - Use Regexp.new for IMAGE_PATTERN to avoid %r{} parser conflict - Replace $stderr.puts with warn Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7b5bf20 commit 575f5f5

1 file changed

Lines changed: 87 additions & 44 deletions

File tree

bin/issue-md

Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class IssueMdError < StandardError; end
4040

4141
# ── Configuration ─────────────────────────────────────────────────────────────
4242

43+
# 4-layer config: defaults < config.yml < env vars < CLI flags.
4344
module Config
4445
CONFIG_DIR = File.expand_path('~/.issue-md')
4546
CONFIG_PATH = File.join(CONFIG_DIR, 'config.yml')
@@ -54,45 +55,67 @@ module Config
5455

5556
def self.load(cli_overrides = {})
5657
config = DEFAULTS.dup
58+
merge_yaml!(config)
59+
merge_env!(config)
60+
cli_overrides.each { |k, v| config[k] = v unless v.nil? }
61+
config
62+
end
5763

58-
if File.exist?(CONFIG_PATH)
59-
yaml = YAML.safe_load(File.read(CONFIG_PATH)) || {}
60-
yaml.each { |k, v| config[k] = v unless v.nil? }
61-
end
64+
def self.merge_yaml!(config)
65+
return unless File.exist?(CONFIG_PATH)
6266

67+
yaml = YAML.safe_load(File.read(CONFIG_PATH)) || {}
68+
yaml.each { |k, v| config[k] = v unless v.nil? }
69+
end
70+
71+
def self.merge_env!(config)
6372
ENV_MAPPING.each do |env_key, config_key|
6473
config[config_key] = ENV[env_key] if ENV.key?(env_key)
6574
end
66-
67-
cli_overrides.each { |k, v| config[k] = v unless v.nil? }
68-
config
6975
end
76+
77+
private_class_method :merge_yaml!, :merge_env!
7078
end
7179

7280
# ── URL parsing ───────────────────────────────────────────────────────────────
7381

82+
# Extracts base_url, project_path, and issue_iid from a GitLab issue URL.
7483
module UrlParser
7584
def self.parse(url)
7685
uri = URI.parse(url)
77-
raise IssueMdError, "Expected an http(s) URL, got: #{url}" unless %w[http https].include?(uri.scheme)
86+
validate_scheme!(uri, url)
87+
project_path, iid = extract_path_and_iid(uri, url)
88+
host = uri.port && ![80, 443].include?(uri.port) ? "#{uri.host}:#{uri.port}" : uri.host
89+
90+
{ base_url: "#{uri.scheme}://#{host}", project_path: project_path, issue_iid: iid }
91+
end
7892

93+
def self.validate_scheme!(uri, url)
94+
return if %w[http https].include?(uri.scheme)
95+
96+
raise IssueMdError, "Expected an http(s) URL, got: #{url}"
97+
end
98+
99+
def self.extract_path_and_iid(uri, url)
79100
path_before, _, issue_segment = uri.path.partition('/-/issues/')
80101
raise IssueMdError, "URL does not look like a GitLab issue: #{url}" if issue_segment.empty?
81102

82103
iid = issue_segment.split('/').first.to_i
83104
raise IssueMdError, "Could not extract issue IID from: #{url}" if iid.zero?
84105

85-
host = uri.port && ![80, 443].include?(uri.port) ? "#{uri.host}:#{uri.port}" : uri.host
86-
87-
{ base_url: "#{uri.scheme}://#{host}",
88-
project_path: path_before.delete_prefix('/'),
89-
issue_iid: iid }
106+
[path_before.delete_prefix('/'), iid]
90107
end
108+
109+
private_class_method :validate_scheme!, :extract_path_and_iid
91110
end
92111

93112
# ── Image downloading ─────────────────────────────────────────────────────────
94113

114+
# Downloads GitLab /uploads/ images and rewrites markdown references to local paths.
95115
module ImageDownloader
116+
# Matches GitLab markdown image references: ![alt](/uploads/path){optional-attrs}
117+
IMAGE_PATTERN = Regexp.new('!\[([^\]]*)\]\((/uploads/[^)]+)\)(\{[^}]*\})?').freeze
118+
96119
module_function
97120

98121
def build_opts(base_url:, token:, project_path:, dest_dir:)
@@ -109,7 +132,7 @@ module ImageDownloader
109132
state = { image_dir: File.join(dest_dir, '.issue-md-images'), downloaded: false }
110133
opts = { gitlab_url: gitlab_url, project_path: project_path, token: token }
111134

112-
text.gsub(%r{!\[([^\]]*)\]\((/uploads/[^)]+)\)(\{[^}]*\})?}) do
135+
text.gsub(IMAGE_PATTERN) do
113136
replace_reference(::Regexp.last_match(1), ::Regexp.last_match(2), opts, state)
114137
end
115138
end
@@ -173,6 +196,7 @@ end
173196

174197
# ── Issue formatting ──────────────────────────────────────────────────────────
175198

199+
# Formats a GitLab issue as markdown with header, comments, and related links.
176200
module IssueFormatter
177201
module_function
178202

@@ -227,63 +251,82 @@ def fetch_issue(client, project_path, issue_iid, img_opts)
227251
end
228252

229253
def build_client(base_url, token)
230-
raise IssueMdError, 'Missing GitLab API token (set GITLAB_API_TOKEN, use -t, or add to ~/.issue-md/config.yml)' unless token
254+
unless token
255+
raise IssueMdError,
256+
'Missing GitLab API token (set GITLAB_API_TOKEN, use -t, or add to ~/.issue-md/config.yml)'
257+
end
231258

232259
Gitlab.client(endpoint: "#{base_url}/api/v4", private_token: token)
233260
end
234261

235262
# ── Argument parsing ──────────────────────────────────────────────────────────
236263

237-
def parse_args(argv)
238-
cli_overrides = {}
239-
opts = {}
240-
241-
parser = OptionParser.new do |o|
264+
def build_option_parser(cli_overrides, opts)
265+
OptionParser.new do |o|
242266
o.banner = 'Usage: issue-md [options] <ISSUE_URL>'
243267
o.on('-t', '--token TOKEN', 'GitLab API token') { |v| cli_overrides['gitlab_api_token'] = v }
244268
o.on('-o', '--output FILE', 'Write markdown to FILE instead of stdout') { |v| opts[:output_file] = v }
245269
o.on('-d', '--download-images DIR', 'Download images to DIR') { |v| opts[:download_dir] = v }
246-
o.on('-v', '--version', 'Show version and exit') { puts "issue-md #{VERSION}"; exit 0 }
247-
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
270+
define_info_options(o)
248271
end
272+
end
273+
274+
def define_info_options(opts)
275+
opts.on('-v', '--version', 'Show version and exit') do
276+
puts "issue-md #{VERSION}"
277+
exit 0
278+
end
279+
opts.on('-h', '--help', 'Show this help') do
280+
puts opts
281+
exit 0
282+
end
283+
end
249284

285+
def parse_args(argv)
286+
cli_overrides = {}
287+
opts = {}
288+
parser = build_option_parser(cli_overrides, opts)
250289
parser.parse!(argv)
251290
raise IssueMdError, "Expected exactly one issue URL argument.\n\n#{parser}" if argv.size != 1
252291

253-
config = Config.load(cli_overrides)
254-
parsed = UrlParser.parse(argv.first)
255-
256-
opts.merge(config: config, **parsed)
292+
opts.merge(config: Config.load(cli_overrides), **UrlParser.parse(argv.first))
257293
end
258294

259295
# ── Main ──────────────────────────────────────────────────────────────────────
260296

261-
def main
262-
args = parse_args(ARGV)
263-
token = args[:config]['gitlab_api_token']
264-
client = build_client(args[:base_url], token)
265-
266-
img_opts = nil
267-
if args[:download_dir]
268-
img_opts = ImageDownloader.build_opts(
269-
base_url: args[:base_url], token: token,
270-
project_path: args[:project_path], dest_dir: args[:download_dir]
271-
)
272-
end
297+
def build_image_opts(args, token)
298+
return nil unless args[:download_dir]
273299

274-
markdown = fetch_issue(client, args[:project_path], args[:issue_iid], img_opts)
300+
ImageDownloader.build_opts(
301+
base_url: args[:base_url], token: token,
302+
project_path: args[:project_path], dest_dir: args[:download_dir]
303+
)
304+
end
275305

276-
if args[:output_file]
277-
File.write(args[:output_file], markdown)
278-
$stderr.puts "Written to #{args[:output_file]}"
306+
def write_output(markdown, output_file)
307+
if output_file
308+
File.write(output_file, markdown)
309+
warn "Written to #{output_file}"
279310
else
280311
puts markdown
281312
end
313+
end
314+
315+
def run(args)
316+
token = args[:config]['gitlab_api_token']
317+
client = build_client(args[:base_url], token)
318+
img_opts = build_image_opts(args, token)
319+
markdown = fetch_issue(client, args[:project_path], args[:issue_iid], img_opts)
320+
write_output(markdown, args[:output_file])
321+
end
322+
323+
def main
324+
run(parse_args(ARGV))
282325
rescue IssueMdError => e
283-
$stderr.puts "error: #{e.message}"
326+
warn "error: #{e.message}"
284327
exit 1
285328
rescue Gitlab::Error::ResponseError => e
286-
$stderr.puts "GitLab API error: #{e.message}"
329+
warn "GitLab API error: #{e.message}"
287330
exit 1
288331
rescue Interrupt
289332
exit 130

0 commit comments

Comments
 (0)