@@ -40,6 +40,7 @@ class IssueMdError < StandardError; end
4040
4141# ── Configuration ─────────────────────────────────────────────────────────────
4242
43+ # 4-layer config: defaults < config.yml < env vars < CLI flags.
4344module 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!
7078end
7179
7280# ── URL parsing ───────────────────────────────────────────────────────────────
7381
82+ # Extracts base_url, project_path, and issue_iid from a GitLab issue URL.
7483module 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
91110end
92111
93112# ── Image downloading ─────────────────────────────────────────────────────────
94113
114+ # Downloads GitLab /uploads/ images and rewrites markdown references to local paths.
95115module ImageDownloader
116+ # Matches GitLab markdown image references: {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
173196
174197# ── Issue formatting ──────────────────────────────────────────────────────────
175198
199+ # Formats a GitLab issue as markdown with header, comments, and related links.
176200module IssueFormatter
177201 module_function
178202
@@ -227,63 +251,82 @@ def fetch_issue(client, project_path, issue_iid, img_opts)
227251end
228252
229253def 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 )
233260end
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 ) )
257293end
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 ) )
282325rescue IssueMdError => e
283- $stderr . puts "error: #{ e . message } "
326+ warn "error: #{ e . message } "
284327 exit 1
285328rescue Gitlab ::Error ::ResponseError => e
286- $stderr . puts "GitLab API error: #{ e . message } "
329+ warn "GitLab API error: #{ e . message } "
287330 exit 1
288331rescue Interrupt
289332 exit 130
0 commit comments