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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +64 -0
- data/bin/jira-comment +6 -0
- data/lib/marlens/jira_api/cli.rb +113 -0
- data/lib/marlens/jira_api/client.rb +136 -0
- data/lib/marlens/jira_api/markdown_to_adf.rb +262 -0
- data/lib/marlens/jira_api/remote_image_attachment_uploader.rb +139 -0
- data/lib/marlens/jira_api/version.rb +7 -0
- data/lib/marlens/jira_api.rb +12 -0
- metadata +123 -0
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,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,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: []
|