Skip to content

Commit 7b5bf20

Browse files
Matthieu Ciapparaclaude
andcommitted
feat: initial release of issue-md v0.1.0
Single-file Ruby CLI that exports a GitLab issue as clean Markdown. Fetches title, description, comments, images, and related issues via the GitLab API. Supports image download mode and file output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0 parents  commit 7b5bf20

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Changelog
2+
3+
## [Unreleased]
4+
5+
## [0.1.0] - 2026-04-10
6+
7+
### Added
8+
9+
- Initial release: fetch a GitLab issue as clean Markdown (title, description, comments, related issues).
10+
- Image download mode (`-d DIR`) to save GitLab-hosted images locally and rewrite references.
11+
- Output to file (`-o FILE`) or stdout (default).
12+
- 4-layer configuration: defaults, `~/.issue-md/config.yml`, environment variables, CLI flags.

CLAUDE.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# CLAUDE.md
2+
3+
## What This Is
4+
5+
A single-file Ruby CLI tool (`bin/issue-md`) distributed via Homebrew (`modulotech/tap`) that exports a GitLab issue as clean Markdown. Takes an issue URL, fetches the issue via the GitLab API, and outputs the title, description, all non-system comments, image references, and related issues as a Markdown document.
6+
7+
## Running
8+
9+
```bash
10+
# Print markdown to stdout
11+
./bin/issue-md https://gitlab.example.com/group/project/-/issues/123
12+
13+
# Write to file
14+
./bin/issue-md -o issue.md https://gitlab.example.com/group/project/-/issues/123
15+
16+
# Download images locally and rewrite references
17+
./bin/issue-md -d ./images https://gitlab.example.com/group/project/-/issues/123
18+
19+
# Override token
20+
./bin/issue-md -t glpat-xxxx https://gitlab.example.com/group/project/-/issues/123
21+
```
22+
23+
Dependencies are installed automatically via `bundler/inline`.
24+
25+
## Configuration
26+
27+
Settings resolved in 4 layers (highest priority wins):
28+
29+
1. **Defaults** — none required
30+
2. **Config file**`~/.issue-md/config.yml`
31+
3. **Environment variables**`GITLAB_API_TOKEN`
32+
4. **CLI flags**`-t`
33+
34+
### Config file format
35+
36+
```yaml
37+
gitlab_api_token: glpat-xxxxxxxxxxxxxxxxxxxx
38+
```
39+
40+
## Architecture
41+
42+
Single-file CLI with these internal modules:
43+
44+
- **Config** — 4-layer configuration loading
45+
- **UrlParser** — extracts `base_url`, `project_path`, `issue_iid` from a GitLab issue URL
46+
- **ImageDownloader** — downloads GitLab `/uploads/` images, rewrites markdown references
47+
- **IssueFormatter** — formats issue header, comments, and related links as markdown

bin/issue-md

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# =============================================================================
5+
# issue-md
6+
#
7+
# Export a GitLab issue as clean Markdown (title, description, comments,
8+
# related issues, images).
9+
#
10+
# Usage:
11+
# issue-md <ISSUE_URL> # Print markdown to stdout
12+
# issue-md -o issue.md <ISSUE_URL> # Write to file
13+
# issue-md -d ./images <ISSUE_URL> # Download images locally
14+
# issue-md -t glpat-xxx <ISSUE_URL> # Override token
15+
#
16+
# Configuration (priority: defaults < config.yml < env vars < CLI flags):
17+
# ~/.issue-md/config.yml Central config file
18+
# GITLAB_API_TOKEN Personal Access Token with api scope
19+
# =============================================================================
20+
21+
VERSION = '0.1.0'
22+
23+
require 'bundler/inline'
24+
25+
gemfile(true) do
26+
source 'https://rubygems.org'
27+
gem 'gitlab', '~> 5.0'
28+
end
29+
30+
require 'fileutils'
31+
require 'net/http'
32+
require 'net/https'
33+
require 'optparse'
34+
require 'uri'
35+
require 'yaml'
36+
37+
# ── Exceptions ────────────────────────────────────────────────────────────────
38+
39+
class IssueMdError < StandardError; end
40+
41+
# ── Configuration ─────────────────────────────────────────────────────────────
42+
43+
module Config
44+
CONFIG_DIR = File.expand_path('~/.issue-md')
45+
CONFIG_PATH = File.join(CONFIG_DIR, 'config.yml')
46+
47+
DEFAULTS = {
48+
'gitlab_api_token' => nil
49+
}.freeze
50+
51+
ENV_MAPPING = {
52+
'GITLAB_API_TOKEN' => 'gitlab_api_token'
53+
}.freeze
54+
55+
def self.load(cli_overrides = {})
56+
config = DEFAULTS.dup
57+
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
62+
63+
ENV_MAPPING.each do |env_key, config_key|
64+
config[config_key] = ENV[env_key] if ENV.key?(env_key)
65+
end
66+
67+
cli_overrides.each { |k, v| config[k] = v unless v.nil? }
68+
config
69+
end
70+
end
71+
72+
# ── URL parsing ───────────────────────────────────────────────────────────────
73+
74+
module UrlParser
75+
def self.parse(url)
76+
uri = URI.parse(url)
77+
raise IssueMdError, "Expected an http(s) URL, got: #{url}" unless %w[http https].include?(uri.scheme)
78+
79+
path_before, _, issue_segment = uri.path.partition('/-/issues/')
80+
raise IssueMdError, "URL does not look like a GitLab issue: #{url}" if issue_segment.empty?
81+
82+
iid = issue_segment.split('/').first.to_i
83+
raise IssueMdError, "Could not extract issue IID from: #{url}" if iid.zero?
84+
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 }
90+
end
91+
end
92+
93+
# ── Image downloading ─────────────────────────────────────────────────────────
94+
95+
module ImageDownloader
96+
module_function
97+
98+
def build_opts(base_url:, token:, project_path:, dest_dir:)
99+
{ gitlab_url: base_url, token: token, project_path: project_path, dest_dir: dest_dir }
100+
end
101+
102+
def maybe_download(text, img_opts)
103+
return text unless img_opts
104+
105+
download_images(text, **img_opts)
106+
end
107+
108+
def download_images(text, gitlab_url:, project_path:, token:, dest_dir:)
109+
state = { image_dir: File.join(dest_dir, '.issue-md-images'), downloaded: false }
110+
opts = { gitlab_url: gitlab_url, project_path: project_path, token: token }
111+
112+
text.gsub(%r{!\[([^\]]*)\]\((/uploads/[^)]+)\)(\{[^}]*\})?}) do
113+
replace_reference(::Regexp.last_match(1), ::Regexp.last_match(2), opts, state)
114+
end
115+
end
116+
117+
def replace_reference(alt, upload_path, opts, state)
118+
url = "#{opts[:gitlab_url]}/#{opts[:project_path]}#{upload_path}"
119+
filename = File.basename(upload_path)
120+
local_path = File.join(state[:image_dir], filename)
121+
122+
ensure_dir(state)
123+
download_and_save(url, opts[:token], local_path, alt, filename)
124+
rescue StandardError => e
125+
"[Image: #{filename} -- download failed: #{e.class}: #{e.message}]"
126+
end
127+
128+
def ensure_dir(state)
129+
return if state[:downloaded]
130+
131+
FileUtils.mkdir_p(state[:image_dir])
132+
state[:downloaded] = true
133+
end
134+
135+
def download_and_save(url, token, local_path, alt, filename)
136+
response = http_get_with_redirects(url, token)
137+
return "[Image: #{filename} -- download failed (#{response.code})]" unless response.is_a?(Net::HTTPSuccess)
138+
139+
validate_and_write(response, local_path, alt, filename)
140+
end
141+
142+
def http_get_with_redirects(url, token)
143+
uri = URI.parse(url)
144+
response = nil
145+
3.times do
146+
response = single_get(uri, token)
147+
break unless response.is_a?(Net::HTTPRedirection) && response['location']
148+
149+
uri = URI.parse(response['location'])
150+
end
151+
response
152+
end
153+
154+
def single_get(uri, token)
155+
http = Net::HTTP.new(uri.host, uri.port)
156+
http.use_ssl = (uri.scheme == 'https')
157+
request = Net::HTTP::Get.new(uri.request_uri)
158+
request['PRIVATE-TOKEN'] = token
159+
http.request(request)
160+
end
161+
162+
def validate_and_write(response, local_path, alt, filename)
163+
body = response.body
164+
content_type = response['content-type'].to_s
165+
if body.nil? || body.empty? || !content_type.start_with?('image/')
166+
return "[Image: #{filename} -- unsupported format (#{content_type})]"
167+
end
168+
169+
File.binwrite(local_path, body)
170+
"![#{alt}](#{local_path})"
171+
end
172+
end
173+
174+
# ── Issue formatting ──────────────────────────────────────────────────────────
175+
176+
module IssueFormatter
177+
module_function
178+
179+
def build_header(issue, img_opts)
180+
lines = ["# Issue ##{issue.iid}: #{issue.title}", '']
181+
if issue.description && !issue.description.empty?
182+
lines << ImageDownloader.maybe_download(issue.description.to_s, img_opts)
183+
end
184+
lines << ''
185+
lines
186+
end
187+
188+
def append_comments(lines, client, project_path, issue_iid, img_opts)
189+
notes = client.issue_notes(project_path, issue_iid, per_page: 100)
190+
user_notes = notes.reject(&:system)
191+
return unless user_notes.any?
192+
193+
lines << '## Comments'
194+
lines << ''
195+
user_notes.each { |note| append_single_comment(lines, note, img_opts) }
196+
rescue Gitlab::Error::ResponseError
197+
# Non-fatal: proceed without comments
198+
end
199+
200+
def append_single_comment(lines, note, img_opts)
201+
lines << "### #{note.author&.name || 'Unknown'} (#{note.created_at})"
202+
lines << ImageDownloader.maybe_download(note.body.to_s, img_opts)
203+
lines << ''
204+
end
205+
206+
def append_links(lines, client, project_path, issue_iid)
207+
links = client.issue_links(project_path, issue_iid)
208+
return unless links.any?
209+
210+
lines << '## Related issues'
211+
lines << ''
212+
links.each { |link| lines << "- ##{link.iid}: #{link.title} (#{link.state})" }
213+
lines << ''
214+
rescue Gitlab::Error::ResponseError, NoMethodError
215+
# Non-fatal: some GitLab versions don't support this
216+
end
217+
end
218+
219+
# ── Core ──────────────────────────────────────────────────────────────────────
220+
221+
def fetch_issue(client, project_path, issue_iid, img_opts)
222+
issue = client.issue(project_path, issue_iid)
223+
lines = IssueFormatter.build_header(issue, img_opts)
224+
IssueFormatter.append_comments(lines, client, project_path, issue_iid, img_opts)
225+
IssueFormatter.append_links(lines, client, project_path, issue_iid)
226+
lines.join("\n")
227+
end
228+
229+
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
231+
232+
Gitlab.client(endpoint: "#{base_url}/api/v4", private_token: token)
233+
end
234+
235+
# ── Argument parsing ──────────────────────────────────────────────────────────
236+
237+
def parse_args(argv)
238+
cli_overrides = {}
239+
opts = {}
240+
241+
parser = OptionParser.new do |o|
242+
o.banner = 'Usage: issue-md [options] <ISSUE_URL>'
243+
o.on('-t', '--token TOKEN', 'GitLab API token') { |v| cli_overrides['gitlab_api_token'] = v }
244+
o.on('-o', '--output FILE', 'Write markdown to FILE instead of stdout') { |v| opts[:output_file] = v }
245+
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 }
248+
end
249+
250+
parser.parse!(argv)
251+
raise IssueMdError, "Expected exactly one issue URL argument.\n\n#{parser}" if argv.size != 1
252+
253+
config = Config.load(cli_overrides)
254+
parsed = UrlParser.parse(argv.first)
255+
256+
opts.merge(config: config, **parsed)
257+
end
258+
259+
# ── Main ──────────────────────────────────────────────────────────────────────
260+
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
273+
274+
markdown = fetch_issue(client, args[:project_path], args[:issue_iid], img_opts)
275+
276+
if args[:output_file]
277+
File.write(args[:output_file], markdown)
278+
$stderr.puts "Written to #{args[:output_file]}"
279+
else
280+
puts markdown
281+
end
282+
rescue IssueMdError => e
283+
$stderr.puts "error: #{e.message}"
284+
exit 1
285+
rescue Gitlab::Error::ResponseError => e
286+
$stderr.puts "GitLab API error: #{e.message}"
287+
exit 1
288+
rescue Interrupt
289+
exit 130
290+
end
291+
292+
main

0 commit comments

Comments
 (0)