Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4022374
Add support for Issue Properties
d-hansen Mar 30, 2020
61d430c
Rev version and remove 'byebug'
d-hansen Apr 28, 2020
0ba2855
Updates based on testing
d-hansen Apr 28, 2020
ce94a86
Add check for required 'key' during build call.
d-hansen Apr 28, 2020
aa3002c
Allow shortcut 'key', 'value' assumptions (when not explicitly provided)
d-hansen Apr 28, 2020
ce30e9e
Update examples to include Issue properties
d-hansen Apr 28, 2020
3006384
Move new examples to the end (also not sure why a bunch of the exampl…
d-hansen Apr 28, 2020
53904c7
Update Issue.jql options to be compliant with Jira V2 (though previou…
d-hansen Apr 29, 2020
604688b
Update attachment resource to separate out the filename from the file…
d-hansen Apr 29, 2020
7dc79e2
Allow for either string or sym inputs on saving attachments
d-hansen Apr 29, 2020
08dd90d
Move automatic string->{key: string} conversion to initialize instead…
d-hansen Apr 30, 2020
ac27b52
Merge branch 'master' of https://github.com/sumoheavy/jira-ruby into …
d-hansen Oct 15, 2020
efb7a22
Merge branch 'sumoheavy-master'
d-hansen Oct 15, 2020
b253ae9
Add method to download an attachment
d-hansen Oct 22, 2020
102b710
Change to return the actual HTTP response so content-type can be veri…
d-hansen Oct 22, 2020
b8e9633
Rename download to http_download to make it clearer it returns an htt…
d-hansen Oct 23, 2020
8bd17c0
Merge remote-tracking branch 'upstream/master'
d-hansen Jan 20, 2021
4944c71
Add output of response when http_debug enabled
d-hansen Jan 20, 2021
ce056b8
Don't raise error on redirect
d-hansen Jun 24, 2022
72fd6f1
Merge branch 'master' of github.com:sumoheavy/jira-ruby into upstream
d-hansen Jun 24, 2022
c3c5db4
Use proper reference to UploadIO to avoid deprecated message
d-hansen Jun 24, 2022
7c81ccb
Merge remote-tracking branch 'upstream/master'
d-hansen Sep 15, 2025
0fa3727
Clean-up some elements that I didn't end up fleshing out.
d-hansen Sep 15, 2025
9db80e3
Add 'properties' check to the Jira Issue spec.
d-hansen Sep 15, 2025
baf033a
[MU-45] Final fixups to support Jira Issue Properties endpoints. Add…
d-hansen Sep 18, 2025
61373a9
[MU-45] Forgot to convert value to_json in properties.save!
d-hansen Sep 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/jira-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
require 'jira/resource/status_category'
require 'jira/resource/transition'
require 'jira/resource/project'
require 'jira/resource/properties'
require 'jira/resource/priority'
require 'jira/resource/comment'
require 'jira/resource/worklog'
Expand Down
12 changes: 10 additions & 2 deletions lib/jira/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ def Agile
JIRA::Resource::AgileFactory.new(self)
end

def Properties
JIRA::Resource::PropertiesFactory.new(self)
end

# HTTP methods without a body

# Make an HTTP DELETE request
Expand Down Expand Up @@ -351,7 +355,9 @@ def post(path, body = '', headers = {})
# @raise [JIRA::HTTPError] If the response is not an HTTP success code
def post_multipart(path, file, headers = {})
puts "post multipart: #{path} - [#{file}]" if @http_debug
@request_client.request_multipart(path, file, merge_default_headers(headers))
res = @request_client.request_multipart(path, file, merge_default_headers(headers))
puts "response: #{res}" if @http_debug
res
end

# Make an HTTP PUT request
Expand All @@ -375,7 +381,9 @@ def put(path, body = '', headers = {})
# @raise [JIRA::HTTPError] If the response is not an HTTP success code
def request(http_method, path, body = '', headers = {})
puts "#{http_method}: #{path} - [#{body}]" if @http_debug
@request_client.request(http_method, path, body, headers)
res = @request_client.request(http_method, path, body, headers)
puts "response: #{res}" if @http_debug
res
end

# @private
Expand Down
19 changes: 17 additions & 2 deletions lib/jira/resource/attachment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,14 @@ def download_contents(headers = {})
# @raise [JIRA::HTTPError] if failed
def save!(attrs, path = url)
file = attrs['file'] || attrs[:file] # Keep supporting 'file' parameter as a string for backward compatibility
mime_type = attrs[:mimeType] || 'application/binary'
# If :filename does not exist or is nil, that is fine as it will force
# Upload to determine the filename automatically from file.
# Breaking the filename out allows this to support any IO-based file parameter.
fname = attrs['filename'] || attrs[:filename]
mime_type = attrs['mimeType'] || attrs[:mimeType] || 'application/binary'

headers = { 'X-Atlassian-Token' => 'nocheck' }
data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, file) }
data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, fname) }

response = client.post_multipart(path, data, headers)

Expand All @@ -159,6 +163,17 @@ def save!(attrs, path = url)
true
end

def http_download
# Actually fetch the attachment
# Note: Jira handles attachment's weird!
# Typically, they respond with a redirect location that should not have the same authentication
client.get(attrs['content'])
rescue JIRA::HTTPError => e
raise e unless e.response.code =~ /\A3\d\d\z/ && e.response['location'].present?

Net::HTTP.get_response(URI(e.response['location']))
end

private

def set_attributes(attributes, response)
Expand Down
12 changes: 12 additions & 0 deletions lib/jira/resource/comment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ class Comment < JIRA::Base
belongs_to :issue

nested_collections true

def self.all(client, options = {})
issue = options[:issue]
raise ArgumentError, 'parent issue is required' unless issue

response = client.get("#{issue.url}/#{endpoint_name}")
json = parse_json(response.body)
json = json[endpoint_name.pluralize]
json.map do |attrs|
new(client, { attrs: }.merge(options))
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/jira/resource/issue.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class Issue < JIRA::Base
has_many :issuelinks, nested_under: 'fields'
has_many :remotelink, class: JIRA::Resource::Remotelink
has_many :watchers, attribute_key: 'watches', nested_under: %w[fields watches]
has_many :properties, class: JIRA::Resource::Properties

# Get collection of issues.
# @param client [JIRA::Client]
Expand Down
56 changes: 56 additions & 0 deletions lib/jira/resource/properties.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

require 'active_support/inflector'

module JIRA
module Resource
class PropertiesFactory < JIRA::BaseFactory # :nodoc:
end

class Properties < JIRA::Base
belongs_to :issue

def self.key_attribute
:key
end

def self.all(client, options = {})
issue = options[:issue]
raise ArgumentError, 'parent issue is required' unless issue

response = client.get("#{issue.url}/#{endpoint_name}")
json = parse_json(response.body)
json[key_attribute.to_s.pluralize].map do |attrs|
## Net get the individual property
self_response = client.get(attrs['self'])
property = parse_json(self_response.body)
## Make sure to build the new resource via the issue.properties in order to support the has_many proxy
issue.properties.build(property)
end
end

## Override save so we can handle the required attrs (and default 'value' when appropriate)
def save!(attrs = {}, path = nil)
if attrs.is_a?(Hash) && attrs.key?(self.class.key_attribute.to_s)
raise ArgumentError, "Use of 'value' is required when '#{self.class.key_attribute}' is provided" \
unless attrs.key?('value')

set_attrs(self.class.key_attribute.to_s => attrs[self.class.key_attribute.to_s])
end

raise ArgumentError, "'key' is required on a new record" if new_record?

path ||= patched_url
## We can take either the 'value' element from the hash, OR use the entire attrs as the value
value = attrs.is_a?(Hash) && attrs.key?('value') ? attrs['value'] : attrs
value = '' if value.nil?
## Note: this API endpoint requires a non-empty JSON body for the value of the property
## Note2: this API endpoint does not return a body, so no need to call set_attrs_from_response
client.send(:put, path, value.to_json)
set_attrs({ 'value' => value }, false)
@expanded = false
true
end
end
end
end
12 changes: 12 additions & 0 deletions lib/jira/resource/worklog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ class Worklog < JIRA::Base
has_one :update_author, class: JIRA::Resource::User, attribute_key: 'updateAuthor'
belongs_to :issue
nested_collections true

def self.all(client, options = {})
issue = options[:issue]
raise ArgumentError, 'parent issue is required' unless issue

response = client.get("#{issue.url}/#{endpoint_name}")
json = parse_json(response.body)
json = json[endpoint_name.pluralize]
json.map do |attrs|
new(client, { attrs: }.merge(options))
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/jira/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module JIRA
VERSION = '3.0.0'
VERSION = '3.0.1-dh1'
end
45 changes: 45 additions & 0 deletions spec/integration/properties_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require 'spec_helper'

describe JIRA::Resource::Properties do
with_each_client do |site_url, client|
context 'when accessing singular resource' do
let(:client) { client }
let(:site_url) { site_url }
let(:key) { 'xyz' }
let(:target) { described_class.new(client, attrs: { 'key' => 'xyz' }, issue_id: '10002') }
let(:belongs_to) { JIRA::Resource::Issue.new(client, attrs: { 'id' => '10002' }) }
let(:expected_attributes) { { 'key' => key, 'value' => 'supercalifragilistic' } }
let(:attributes_for_put) { { 'value' => 'expialidocious' } }
let(:expected_attributes_from_put) { { 'key' => key, 'value' => 'expialidocious' } }

it_behaves_like 'a resource'
it_behaves_like 'a resource with a singular GET endpoint'
it_behaves_like 'a resource with a DELETE endpoint'
it_behaves_like 'a resource with a PUT endpoint'
end

context 'when accessing collection' do
let(:client) { client }
let(:site_url) { site_url }
let(:key) { 'xyz' }
let(:belongs_to) { JIRA::Resource::Issue.new(client, attrs: { 'id' => '10002' }) }
let(:expected_collection_length) { 2 }
let(:expected_attributes) { { 'key' => key, 'value' => 'supercalifragilistic' } }

before do
## Since properties collections do subsequent queries on each individual properties records,
## we need to additionally define stub requests for each of the individual records
additional_targets = [
described_class.new(client, attrs: { 'key' => 'foo' }, issue_id: '10002'),
described_class.new(client, attrs: { 'key' => 'xyz' }, issue_id: '10002')
]
additional_targets.each do |target|
req_url = site_url + target.url
stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url))
end
end

it_behaves_like 'a resource with a collection GET endpoint'
end
end
end
5 changes: 3 additions & 2 deletions spec/integration/transition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@

describe 'POST endpoint' do
it 'saves a new resource' do
stub_request(:post, /#{described_class.collection_path(client, prefix)}$/)
req_url = build_url
stub_request(:post, req_url)
.with(body: attributes_for_post.to_json)
.to_return(status: 200, body: get_mock_from_path(:post))
.to_return(status: 200, body: get_mock_from_url(:post, req_url))
subject = build_receiver.build
expect(subject.save(attributes_for_post)).to be_truthy
end
Expand Down
6 changes: 4 additions & 2 deletions spec/integration/webhook_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
require 'spec_helper'

describe JIRA::Resource::Webhook do
with_each_client do |site_url, client|
## This endpoint uses a different base path, so override this client's rest_base_path option
## so this test can still use the SharedExampleGroups
with_each_client(rest_base_path: described_class.const_get(:REST_BASE_PATH)) do |site_url, client|
let(:client) { client }
let(:site_url) { site_url }

Expand All @@ -20,7 +22,7 @@

it 'returns a collection of components' do
stub_request(:get, site_url + described_class.singular_path(client, key))
.to_return(status: 200, body: get_mock_response('webhook/webhook.json'))
.to_return(status: 200, body: get_mock_response('webhook/2.json'))

webhook = client.Webhook.find(key)

Expand Down
6 changes: 5 additions & 1 deletion spec/jira/resource/issue_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc:
'comment' => { 'comments' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }] },
'attachment' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }],
'worklog' => { 'worklogs' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }] }
}
},
'properties' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }]
})
end

Expand Down Expand Up @@ -250,6 +251,9 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc:

expect(subject).to have_many(:worklogs, JIRA::Resource::Worklog)
expect(subject.worklogs.length).to eq(2)

expect(subject).to have_many(:properties, JIRA::Resource::Properties)
expect(subject.properties.length).to eq(2)
end
end
end
12 changes: 12 additions & 0 deletions spec/mock_responses/issue/10002/properties.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"keys": [
{
"key": "xyz",
"self": "http://localhost:2990/jira/rest/api/2/issue/10002/properties/xyz"
},
{
"key": "foo",
"self": "http://localhost:2990/jira/rest/api/2/issue/10002/properties/foo"
}
]
}
4 changes: 4 additions & 0 deletions spec/mock_responses/issue/10002/properties/foo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"key": "foo",
"value": "bar"
}
4 changes: 4 additions & 0 deletions spec/mock_responses/issue/10002/properties/xyz.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"key": "xyz",
"value": "supercalifragilistic"
}
4 changes: 4 additions & 0 deletions spec/mock_responses/issue/10002/properties/xyz.put.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"key": "xyz",
"value": "expialidocious"
}
11 changes: 0 additions & 11 deletions spec/mock_responses/jira/rest/webhooks/1.0/webhook.json

This file was deleted.

11 changes: 0 additions & 11 deletions spec/mock_responses/webhook/webhook.json

This file was deleted.

6 changes: 3 additions & 3 deletions spec/support/clients_helper.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
module ClientsHelper
def with_each_client(&)
def with_each_client(opts = {}, &)
clients = {}

oauth_client = JIRA::Client.new(consumer_key: 'foo', consumer_secret: 'bar')
oauth_client = JIRA::Client.new({ consumer_key: 'foo', consumer_secret: 'bar' }.merge(opts))
oauth_client.set_access_token('abc', '123')
clients['http://localhost:2990'] = oauth_client

basic_client = JIRA::Client.new(username: 'foo', password: 'bar', auth_type: :basic, use_ssl: false)
basic_client = JIRA::Client.new({ username: 'foo', password: 'bar', auth_type: :basic, use_ssl: false }.merge(opts))
clients['http://localhost:2990'] = basic_client

clients.each(&)
Expand Down
Loading