marlens-jira-api 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 782848ce0d3f51aed25b94ade7e4cabda09106be46fd3803307450f42a9d249c
4
+ data.tar.gz: ce6672958668fcf08a45a3dbdceb66ba7a99daf5b2d95e5085b07efc8741581b
5
+ SHA512:
6
+ metadata.gz: b31245df26fe026493a556d6e3b1ab1d9e45be5281705672b631d65b86f85ac91c8ce392064068b913c1c5e0c2096332c4f13533aeffdd59f382f6fd76cf4472
7
+ data.tar.gz: 5591a8de6a89de181ef02d0d5e4d7427bb20a6fc36c7d8c888c164f9966f029d30277516bf69b17dcc2bea8444dbbcc4bdb9e7d011fe79d422a2827f90cf9521
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marlen Brunner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Marlens Jira API
2
+
3
+ Small Ruby helper for Jira Cloud comments. It provides basic Jira comment CRUD, converts Markdown to Atlassian Document Format, and can upload allowed remote images as Jira issue attachments.
4
+
5
+ [RubyGems: `marlens-jira-api`](https://rubygems.org/gems/marlens-jira-api)
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ gem "marlens-jira-api", "~> 0.5"
11
+ ```
12
+
13
+ ## Ruby API
14
+
15
+ ```ruby
16
+ client = Marlens::JiraApi::Client.new(
17
+ base_url: ENV.fetch("JIRA_BASE_URL"),
18
+ email: ENV.fetch("JIRA_EMAIL"),
19
+ api_token: ENV.fetch("JIRA_API_TOKEN")
20
+ )
21
+
22
+ comments = client.list_comments(issue_key: "WRAPX-123")
23
+ comment = client.get_comment(issue_key: "WRAPX-123", comment_id: "10001")
24
+
25
+ created = client.create_markdown_comment(
26
+ issue_key: "WRAPX-123",
27
+ markdown: "# Release notes\n\n1. Verify `code` and **bold** text."
28
+ )
29
+
30
+ client.update_markdown_comment(
31
+ issue_key: "WRAPX-123",
32
+ comment_id: created.fetch("id"),
33
+ markdown: "Updated comment body."
34
+ )
35
+
36
+ client.delete_comment(issue_key: "WRAPX-123", comment_id: created.fetch("id"))
37
+ ```
38
+
39
+ ## CLI
40
+
41
+ Credentials are read from `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN`.
42
+
43
+ ```bash
44
+ jira-comment list --issue-key WRAPX-123
45
+ jira-comment get --issue-key WRAPX-123 --comment-id 10001
46
+ jira-comment create --issue-key WRAPX-123 --markdown-file pr-body.md
47
+ jira-comment update --issue-key WRAPX-123 --comment-id 10001 --markdown-file edited.md
48
+ jira-comment delete --issue-key WRAPX-123 --comment-id 10001
49
+ ```
50
+
51
+ Remote Markdown image URLs are advanced opt-in behavior. Pass `allowed_image_hosts:` in Ruby or repeat `--image-host` in the CLI only when you intentionally want the gem to fetch images from trusted hosts, upload them to Jira, and convert them into Jira media attachments.
52
+
53
+ ## Feature and Issue Workflow
54
+
55
+ Preferred flow for issue and feature work:
56
+
57
+ 1. Create or identify the GitHub issue with the user story and acceptance criteria.
58
+ 2. Branch from current `main` using the issue number and a short slug before editing when possible. If scoped work already exists locally, create the issue-named branch before committing.
59
+ 3. Make the smallest change that resolves the request, including tests and README updates that must stay in sync.
60
+ 4. For releasable changes, bump `Marlens::JiraApi::VERSION` before opening the release PR.
61
+ 5. Open a draft PR, link the issue, include the checks run, and mark it ready only after verification.
62
+ 6. Merge the PR to `main` so GitHub records the review path.
63
+ 7. After merge, build and publish the gem version to RubyGems with `gem build marlens-jira-api.gemspec` and `gem push marlens-jira-api-<version>.gem`.
64
+ 8. Verify RubyGems lists the released version with `gem list --remote marlens-jira-api --exact --all`, then run an install or CLI smoke check.
data/bin/jira-comment ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/marlens/jira_api"
5
+
6
+ exit(Marlens::JiraApi::CLI.run(ARGV))
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module Marlens
7
+ module JiraApi
8
+ class CLI
9
+ COMMANDS = {
10
+ "list" => { required: %i[issue_key], action: :list_comments },
11
+ "get" => { required: %i[issue_key comment_id], action: :get_comment },
12
+ "create" => { required: %i[issue_key markdown_file], action: :create_comment },
13
+ "update" => { required: %i[issue_key comment_id markdown_file], action: :update_comment },
14
+ "delete" => { required: %i[issue_key comment_id], action: :delete_comment },
15
+ }.freeze
16
+
17
+ def self.run(argv = ARGV, env: ENV, out: $stdout, err: $stderr, client_factory: nil)
18
+ new(argv, env:, out:, err:, client_factory:).run
19
+ end
20
+
21
+ def initialize(argv, env:, out:, err:, client_factory: nil)
22
+ @argv = argv.dup
23
+ @env = env
24
+ @out = out
25
+ @err = err
26
+ @client_factory = client_factory || method(:build_client)
27
+ end
28
+
29
+ def run
30
+ command = @argv.shift
31
+ config = COMMANDS[command]
32
+ return fail_with("Unknown command #{command.inspect}. Expected one of: #{COMMANDS.keys.join(", ")}") unless config
33
+
34
+ options = parse_options
35
+ missing = config.fetch(:required).select { |key| options[key].to_s.empty? }
36
+ return fail_with("Missing required option(s): #{option_names(missing)}") unless missing.empty?
37
+
38
+ @out.puts JSON.pretty_generate(send(config.fetch(:action), options))
39
+ 0
40
+ rescue OptionParser::ParseError, KeyError => error
41
+ fail_with(error.message)
42
+ end
43
+
44
+ private
45
+
46
+ def parse_options
47
+ options = { allowed_image_hosts: [] }
48
+ parser = OptionParser.new do |parser|
49
+ parser.banner = "Usage: jira-comment [#{COMMANDS.keys.join("|")}] [options]"
50
+ parser.on("--issue-key ISSUE", "Jira issue key") { |value| options[:issue_key] = value }
51
+ parser.on("--comment-id ID", "Jira comment ID") { |value| options[:comment_id] = value }
52
+ parser.on("--markdown-file FILE", "Markdown file for create/update") { |value| options[:markdown_file] = value }
53
+ parser.on("--image-host HOST", "Allowed remote image host; repeatable") do |value|
54
+ options[:allowed_image_hosts] << value
55
+ end
56
+ end
57
+ parser.parse!(@argv)
58
+ options
59
+ end
60
+
61
+ def list_comments(options)
62
+ client.list_comments(issue_key: options.fetch(:issue_key))
63
+ end
64
+
65
+ def get_comment(options)
66
+ client.get_comment(issue_key: options.fetch(:issue_key), comment_id: options.fetch(:comment_id))
67
+ end
68
+
69
+ def create_comment(options)
70
+ client.create_markdown_comment(
71
+ issue_key: options.fetch(:issue_key),
72
+ markdown: File.read(options.fetch(:markdown_file)),
73
+ allowed_image_hosts: options.fetch(:allowed_image_hosts)
74
+ )
75
+ end
76
+
77
+ def update_comment(options)
78
+ client.update_markdown_comment(
79
+ issue_key: options.fetch(:issue_key),
80
+ comment_id: options.fetch(:comment_id),
81
+ markdown: File.read(options.fetch(:markdown_file)),
82
+ allowed_image_hosts: options.fetch(:allowed_image_hosts)
83
+ )
84
+ end
85
+
86
+ def delete_comment(options)
87
+ client.delete_comment(issue_key: options.fetch(:issue_key), comment_id: options.fetch(:comment_id))
88
+ { deleted: true }
89
+ end
90
+
91
+ def client
92
+ @client ||= @client_factory.call(env: @env)
93
+ end
94
+
95
+ def build_client(env:)
96
+ Client.new(
97
+ base_url: env.fetch("JIRA_BASE_URL"),
98
+ email: env.fetch("JIRA_EMAIL"),
99
+ api_token: env.fetch("JIRA_API_TOKEN")
100
+ )
101
+ end
102
+
103
+ def fail_with(message)
104
+ @err.puts(message)
105
+ 1
106
+ end
107
+
108
+ def option_names(keys)
109
+ keys.map { |key| "--#{key.to_s.tr("_", "-")}" }.join(", ")
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "multipart/post"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ module Marlens
10
+ module JiraApi
11
+ class Client
12
+ def initialize(base_url:, email:, api_token:)
13
+ @base_url = base_url.to_s.delete_suffix("/")
14
+ @email = email
15
+ @api_token = api_token
16
+ end
17
+
18
+ def list_comments(issue_key:, start_at: nil, max_results: nil)
19
+ uri = api_uri("/issue/#{issue_key}/comment")
20
+ query = {}
21
+ query["startAt"] = start_at unless start_at.nil?
22
+ query["maxResults"] = max_results unless max_results.nil?
23
+ uri.query = URI.encode_www_form(query) unless query.empty?
24
+
25
+ json_request(Net::HTTP::Get.new(uri), uri)
26
+ end
27
+
28
+ def get_comment(issue_key:, comment_id:)
29
+ uri = api_uri("/issue/#{issue_key}/comment/#{comment_id}")
30
+ json_request(Net::HTTP::Get.new(uri), uri)
31
+ end
32
+
33
+ def create_comment(issue_key:, document:)
34
+ uri = api_uri("/issue/#{issue_key}/comment")
35
+ request = Net::HTTP::Post.new(uri)
36
+ request.body = JSON.dump({ "body" => document })
37
+
38
+ json_request(request, uri)
39
+ end
40
+
41
+ def update_comment(issue_key:, comment_id:, document:)
42
+ uri = api_uri("/issue/#{issue_key}/comment/#{comment_id}")
43
+ request = Net::HTTP::Put.new(uri)
44
+ request.body = JSON.dump({ "body" => document })
45
+
46
+ json_request(request, uri)
47
+ end
48
+
49
+ def delete_comment(issue_key:, comment_id:)
50
+ uri = api_uri("/issue/#{issue_key}/comment/#{comment_id}")
51
+ response = authenticated_request(Net::HTTP::Delete.new(uri), uri)
52
+ return true if response.code.to_i == 204
53
+
54
+ raise "Failed to delete Jira comment #{comment_id} for #{issue_key}: #{response.code} #{response.body}"
55
+ end
56
+
57
+ def create_markdown_comment(issue_key:, markdown:, allowed_image_hosts: [])
58
+ document = markdown_document(
59
+ issue_key: issue_key,
60
+ markdown: markdown,
61
+ allowed_image_hosts: allowed_image_hosts
62
+ )
63
+ create_comment(issue_key: issue_key, document: document)
64
+ end
65
+
66
+ def update_markdown_comment(issue_key:, comment_id:, markdown:, allowed_image_hosts: [])
67
+ document = markdown_document(
68
+ issue_key: issue_key,
69
+ markdown: markdown,
70
+ allowed_image_hosts: allowed_image_hosts
71
+ )
72
+ update_comment(issue_key: issue_key, comment_id: comment_id, document: document)
73
+ end
74
+
75
+ alias post_comment create_comment
76
+ alias post_markdown_comment create_markdown_comment
77
+
78
+ def upload_attachment(issue_key:, io:, filename:, content_type:)
79
+ uri = api_uri("/issue/#{issue_key}/attachments")
80
+ request = Net::HTTP::Post::Multipart.new(
81
+ uri.request_uri,
82
+ "file" => UploadIO.new(io, content_type, filename)
83
+ )
84
+ request.basic_auth(@email, @api_token)
85
+ request["X-Atlassian-Token"] = "no-check"
86
+
87
+ response = http_request(uri, request)
88
+ unless response.code.to_i.between?(200, 299)
89
+ raise "Failed to upload Jira attachment for #{issue_key}: #{response.code} #{response.body}"
90
+ end
91
+
92
+ JSON.parse(response.body).first
93
+ end
94
+
95
+ private
96
+
97
+ def markdown_document(issue_key:, markdown:, allowed_image_hosts:)
98
+ image_uploader = RemoteImageAttachmentUploader.new(
99
+ client: self,
100
+ issue_key: issue_key,
101
+ allowed_hosts: allowed_image_hosts
102
+ )
103
+ MarkdownToAdf.call(markdown) do |image|
104
+ image_uploader.media_node_for(image)
105
+ end
106
+ end
107
+
108
+ def json_request(request, uri)
109
+ response = authenticated_request(request, uri)
110
+ return JSON.parse(response.body) if response.code.to_i.between?(200, 299)
111
+
112
+ raise "Jira API request failed: #{request.method} #{uri.request_uri}: #{response.code} #{response.body}"
113
+ end
114
+
115
+ def authenticated_request(request, uri)
116
+ request["Content-Type"] = "application/json"
117
+ request["Authorization"] = "Basic #{auth_token}"
118
+ http_request(uri, request)
119
+ end
120
+
121
+ def api_uri(path)
122
+ URI("#{@base_url}/rest/api/3#{path}")
123
+ end
124
+
125
+ def http_request(uri, request)
126
+ http = Net::HTTP.new(uri.host, uri.port)
127
+ http.use_ssl = uri.scheme == "https"
128
+ http.request(request)
129
+ end
130
+
131
+ def auth_token
132
+ Base64.strict_encode64("#{@email}:#{@api_token}")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "commonmarker"
4
+
5
+ module Marlens
6
+ module JiraApi
7
+ class MarkdownToAdf
8
+ IMAGE_TAG_PATTERN = /<img\b(?<attributes>[^>]+)>/i
9
+
10
+ def self.call(markdown, &image_resolver)
11
+ new(markdown, image_resolver: image_resolver).to_document
12
+ end
13
+
14
+ def self.media_single(id:, alt:, width:, height:)
15
+ {
16
+ "type" => "mediaSingle",
17
+ "attrs" => { "layout" => "center" },
18
+ "content" => [
19
+ {
20
+ "type" => "media",
21
+ "attrs" => {
22
+ "id" => id,
23
+ "type" => "file",
24
+ "collection" => "jira-issue-attachments",
25
+ "alt" => alt,
26
+ "width" => width,
27
+ "height" => height,
28
+ },
29
+ },
30
+ ],
31
+ }
32
+ end
33
+
34
+ def self.paragraph(text)
35
+ new("").send(:paragraph_from_text, text)
36
+ end
37
+
38
+ def initialize(markdown, image_resolver: nil)
39
+ @markdown = markdown.to_s
40
+ @image_resolver = image_resolver
41
+ end
42
+
43
+ def to_document
44
+ {
45
+ "type" => "doc",
46
+ "version" => 1,
47
+ "content" => document_content,
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def document_content
54
+ blocks = render_blocks(markdown_document)
55
+ blocks.empty? ? [paragraph_from_text("(No PR description provided.)")] : blocks
56
+ end
57
+
58
+ def markdown_document
59
+ Commonmarker.parse(
60
+ @markdown,
61
+ options: {
62
+ parse: { smart: true },
63
+ extension: {
64
+ autolink: true,
65
+ strikethrough: true,
66
+ table: true,
67
+ tagfilter: false,
68
+ },
69
+ }
70
+ )
71
+ end
72
+
73
+ def render_blocks(parent)
74
+ children(parent).flat_map { |node| render_block(node) }.compact
75
+ end
76
+
77
+ def render_block(node)
78
+ case node.type
79
+ when :heading
80
+ heading(node)
81
+ when :paragraph
82
+ paragraph(node)
83
+ when :code_block
84
+ code_block(node)
85
+ when :list
86
+ list(node)
87
+ when :item
88
+ list_item(node)
89
+ when :block_quote
90
+ blockquote(node)
91
+ when :thematic_break
92
+ { "type" => "rule" }
93
+ when :html_block
94
+ html_image_block(node) || paragraph_from_text(node.to_commonmark.strip)
95
+ else
96
+ render_blocks(node)
97
+ end
98
+ end
99
+
100
+ def heading(node)
101
+ {
102
+ "type" => "heading",
103
+ "attrs" => { "level" => node.header_level },
104
+ "content" => inline_content(node),
105
+ }
106
+ end
107
+
108
+ def paragraph(node)
109
+ return image_block_for(children(node).first) if single_image_node?(node)
110
+
111
+ {
112
+ "type" => "paragraph",
113
+ "content" => inline_content(node),
114
+ }
115
+ end
116
+
117
+ def paragraph_from_text(text)
118
+ {
119
+ "type" => "paragraph",
120
+ "content" => [text_node(text)],
121
+ }
122
+ end
123
+
124
+ def code_block(node)
125
+ block = {
126
+ "type" => "codeBlock",
127
+ "content" => [{ "type" => "text", "text" => node.string_content.chomp }],
128
+ }
129
+ block["attrs"] = { "language" => node.fence_info } unless node.fence_info.to_s.empty?
130
+ block
131
+ end
132
+
133
+ def list(node)
134
+ content = children(node).map { |child| list_item(child) }
135
+
136
+ if node.list_type == :ordered
137
+ {
138
+ "type" => "orderedList",
139
+ "attrs" => { "order" => node.list_start },
140
+ "content" => content,
141
+ }
142
+ else
143
+ {
144
+ "type" => "bulletList",
145
+ "content" => content,
146
+ }
147
+ end
148
+ end
149
+
150
+ def list_item(node)
151
+ content = render_blocks(node)
152
+ content = [paragraph_from_text("")] if content.empty?
153
+
154
+ {
155
+ "type" => "listItem",
156
+ "content" => content,
157
+ }
158
+ end
159
+
160
+ def blockquote(node)
161
+ {
162
+ "type" => "blockquote",
163
+ "content" => render_blocks(node),
164
+ }
165
+ end
166
+
167
+ def inline_content(parent, marks = [])
168
+ children(parent).flat_map { |node| render_inline(node, marks) }.compact
169
+ end
170
+
171
+ def render_inline(node, marks)
172
+ case node.type
173
+ when :text
174
+ text_node(node.string_content, marks)
175
+ when :softbreak, :linebreak
176
+ text_node("\n", marks)
177
+ when :code
178
+ text_node(node.string_content, marks + [{ "type" => "code" }])
179
+ when :strong
180
+ inline_content(node, marks + [{ "type" => "strong" }])
181
+ when :emph
182
+ inline_content(node, marks + [{ "type" => "em" }])
183
+ when :strikethrough
184
+ inline_content(node, marks + [{ "type" => "strike" }])
185
+ when :link
186
+ inline_content(node, marks + [link_mark(node.url)])
187
+ when :image
188
+ text_node(image_alt(node), marks + [link_mark(node.url)])
189
+ when :html_inline
190
+ text_node(node.to_commonmark, marks)
191
+ else
192
+ inline_content(node, marks)
193
+ end
194
+ end
195
+
196
+ def image_block_for(node)
197
+ resolved_image_block(url: node.url, alt: image_alt(node))
198
+ end
199
+
200
+ def html_image_block(node)
201
+ match = node.to_commonmark.strip.match(IMAGE_TAG_PATTERN)
202
+ return nil if match.nil?
203
+
204
+ attributes = parse_html_attributes(match[:attributes])
205
+ resolved_image_block(
206
+ url: attributes["src"],
207
+ alt: attributes["alt"],
208
+ width: attributes["width"],
209
+ height: attributes["height"]
210
+ )
211
+ end
212
+
213
+ def resolved_image_block(url:, alt: nil, width: nil, height: nil)
214
+ return nil if url.nil? || url.strip.empty?
215
+ return fallback_image_paragraph(url, alt) if @image_resolver.nil?
216
+
217
+ @image_resolver.call(url: url, alt: alt, width: width, height: height)
218
+ end
219
+
220
+ def fallback_image_paragraph(url, alt)
221
+ paragraph_from_text("#{alt.to_s.empty? ? "Image" : alt}: #{url}")
222
+ end
223
+
224
+ def single_image_node?(node)
225
+ meaningful_children = children(node).reject do |child|
226
+ child.type == :text && child.string_content.strip.empty?
227
+ end
228
+
229
+ meaningful_children.length == 1 && meaningful_children.first.type == :image
230
+ end
231
+
232
+ def image_alt(node)
233
+ children(node)
234
+ .filter_map { |child| child.string_content if child.type == :text }
235
+ .join
236
+ end
237
+
238
+ def text_node(text, marks = [])
239
+ return nil if text.nil? || text.empty?
240
+
241
+ node = { "type" => "text", "text" => text }
242
+ node["marks"] = marks unless marks.empty?
243
+ node
244
+ end
245
+
246
+ def link_mark(url)
247
+ {
248
+ "type" => "link",
249
+ "attrs" => { "href" => url },
250
+ }
251
+ end
252
+
253
+ def parse_html_attributes(attributes)
254
+ attributes.scan(/([a-zA-Z:-]+)=["']([^"']+)["']/).to_h
255
+ end
256
+
257
+ def children(node)
258
+ node.each.to_a
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fastimage"
4
+ require "net/http"
5
+ require "tempfile"
6
+ require "uri"
7
+
8
+ module Marlens
9
+ module JiraApi
10
+ class RemoteImageAttachmentUploader
11
+ MAX_REDIRECTS = 5
12
+
13
+ def initialize(client:, issue_key:, allowed_hosts:)
14
+ @allowed_hosts = allowed_hosts
15
+ @client = client
16
+ @issue_key = issue_key
17
+ end
18
+
19
+ def media_node_for(image)
20
+ attachment = upload_image_attachment(image.fetch(:url))
21
+ dimensions = attachment.fetch("dimensions")
22
+
23
+ MarkdownToAdf.media_single(
24
+ id: attachment.fetch("id"),
25
+ alt: image[:alt].to_s.strip.empty? ? attachment.fetch("filename") : image[:alt],
26
+ width: image_dimension_value(image[:width], dimensions.fetch("width")),
27
+ height: image_dimension_value(image[:height], dimensions.fetch("height"))
28
+ )
29
+ rescue StandardError => error
30
+ warn "Failed to upload image #{image[:url]}: #{error.class}: #{error.message}"
31
+ MarkdownToAdf.paragraph("#{image[:alt] || "Image"}: #{image[:url]}")
32
+ end
33
+
34
+ private
35
+
36
+ def upload_image_attachment(url)
37
+ uri = allowed_image_uri(url)
38
+
39
+ Tempfile.create(["jira-comment-image", File.extname(uri.path)]) do |file|
40
+ response = fetch_image(uri)
41
+ content_type = response_content_type(response) || content_type_for(file.path)
42
+ raise "Expected image content type, got #{content_type}" unless content_type.start_with?("image/")
43
+
44
+ file.binmode
45
+ file.write(response.body)
46
+ file.rewind
47
+
48
+ filename = filename_for(uri, content_type)
49
+ dimensions = image_dimensions(file.path)
50
+ attachment = @client.upload_attachment(
51
+ issue_key: @issue_key,
52
+ io: file,
53
+ filename: filename,
54
+ content_type: content_type
55
+ )
56
+
57
+ return {
58
+ "id" => attachment.fetch("id").to_s,
59
+ "filename" => attachment.fetch("filename", filename),
60
+ "dimensions" => dimensions,
61
+ }
62
+ end
63
+ end
64
+
65
+ def allowed_image_uri(url)
66
+ uri = URI(url)
67
+ return uri if @allowed_hosts.include?(uri.host)
68
+
69
+ raise "Refusing to upload image URL from disallowed host #{uri.host.inspect}"
70
+ end
71
+
72
+ def fetch_image(uri, redirects_remaining: MAX_REDIRECTS)
73
+ response = Net::HTTP.get_response(uri)
74
+
75
+ if response.is_a?(Net::HTTPRedirection)
76
+ raise "Too many image redirects" if redirects_remaining.zero?
77
+
78
+ redirected_uri = allowed_image_uri(URI.join(uri, response["location"]).to_s)
79
+ return fetch_image(redirected_uri, redirects_remaining: redirects_remaining - 1)
80
+ end
81
+
82
+ return response if response.is_a?(Net::HTTPSuccess)
83
+
84
+ raise "Failed to fetch image: #{response.code} #{response.message}"
85
+ end
86
+
87
+ def response_content_type(response)
88
+ content_type = response["content-type"].to_s.split(";").first.to_s.strip
89
+ return nil if content_type.empty?
90
+
91
+ content_type
92
+ end
93
+
94
+ def image_dimension_value(value, fallback)
95
+ parsed_value = value.to_s.match?(/\A\d+\z/) ? value.to_i : nil
96
+ return parsed_value if parsed_value&.positive?
97
+
98
+ fallback
99
+ end
100
+
101
+ def filename_for(uri, content_type)
102
+ filename = File.basename(uri.path)
103
+ filename = "jira-comment-image" if filename.empty?
104
+
105
+ extension = File.extname(filename)
106
+ return filename unless extension.empty?
107
+
108
+ "#{filename}#{extension_for(content_type)}"
109
+ end
110
+
111
+ def extension_for(content_type)
112
+ case content_type
113
+ when "image/jpeg" then ".jpg"
114
+ when "image/png" then ".png"
115
+ when "image/gif" then ".gif"
116
+ when "image/webp" then ".webp"
117
+ else ".img"
118
+ end
119
+ end
120
+
121
+ def content_type_for(path)
122
+ case File.extname(path).downcase
123
+ when ".jpg", ".jpeg" then "image/jpeg"
124
+ when ".png" then "image/png"
125
+ when ".gif" then "image/gif"
126
+ when ".webp" then "image/webp"
127
+ else "application/octet-stream"
128
+ end
129
+ end
130
+
131
+ def image_dimensions(path)
132
+ width, height = FastImage.size(path)
133
+ return { "width" => width, "height" => height } if width && height
134
+
135
+ { "width" => 1200, "height" => 800 }
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marlens
4
+ module JiraApi
5
+ VERSION = "0.5.0"
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jira_api/version"
4
+ require_relative "jira_api/client"
5
+ require_relative "jira_api/markdown_to_adf"
6
+ require_relative "jira_api/remote_image_attachment_uploader"
7
+ require_relative "jira_api/cli"
8
+
9
+ module Marlens
10
+ module JiraApi
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: marlens-jira-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Marlen Brunner
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: commonmarker
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: fastimage
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.4'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.4'
54
+ - !ruby/object:Gem::Dependency
55
+ name: multipart-post
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.13'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.13'
82
+ description: Provides Jira Cloud comment CRUD, converts Markdown to Atlassian Document
83
+ Format, and optionally uploads allowed remote images as Jira issue attachments.
84
+ email:
85
+ - klondikemarlen@gmail.com
86
+ executables:
87
+ - jira-comment
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - LICENSE.txt
92
+ - README.md
93
+ - bin/jira-comment
94
+ - lib/marlens/jira_api.rb
95
+ - lib/marlens/jira_api/cli.rb
96
+ - lib/marlens/jira_api/client.rb
97
+ - lib/marlens/jira_api/markdown_to_adf.rb
98
+ - lib/marlens/jira_api/remote_image_attachment_uploader.rb
99
+ - lib/marlens/jira_api/version.rb
100
+ homepage: https://github.com/klondikemarlen/jira-api
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ homepage_uri: https://github.com/klondikemarlen/jira-api
105
+ source_code_uri: https://github.com/klondikemarlen/jira-api
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.2'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 4.0.10
121
+ specification_version: 4
122
+ summary: Small Jira Cloud API helper for comment CRUD and rich Markdown comments.
123
+ test_files: []