schubert-minglr 1.2.0 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Rakefile +16 -3
- data/VERSION.yml +2 -2
- data/bin/mtx +3 -3
- data/cucumber.yml +2 -0
- data/features/cards.feature +27 -0
- data/features/step_definitions/minglr_steps.rb +34 -0
- data/features/step_definitions/shared_steps.rb +69 -0
- data/features/users.feature +6 -0
- data/lib/minglr.rb +6 -2
- data/lib/minglr/action.rb +2 -2
- data/lib/minglr/mtx/input_cache.rb +24 -0
- data/lib/minglr/mtx/options_parser.rb +58 -0
- data/lib/minglr/options_parser.rb +13 -10
- data/lib/minglr/resources/attachment.rb +6 -1
- data/lib/minglr/resources/card.rb +26 -16
- data/lib/minglr/resources/user.rb +0 -6
- data/minglr.gemspec +31 -9
- data/tasks/commit.sample.rake +3 -3
- data/test/action_test.rb +75 -0
- data/test/commands_test.rb +111 -0
- data/test/config_parser_test.rb +113 -0
- data/test/options_parser_test.rb +74 -0
- data/test/resources/attachment_test.rb +53 -3
- data/test/resources/base_test.rb +15 -2
- data/test/resources/card_test.rb +119 -9
- data/test/resources/project_test.rb +18 -0
- data/test/resources/user_test.rb +0 -7
- data/test/test_helper.rb +2 -0
- metadata +21 -7
- data/lib/minglr/input_cache.rb +0 -22
- data/lib/minglr/mtx_options_parser.rb +0 -53
data/.gitignore
CHANGED
data/Rakefile
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'rake'
|
3
3
|
|
4
|
+
task :default => [:rcov, :features]
|
5
|
+
|
4
6
|
begin
|
5
7
|
require 'jeweler'
|
6
8
|
Jeweler::Tasks.new do |gem|
|
@@ -36,6 +38,7 @@ begin
|
|
36
38
|
require 'rcov/rcovtask'
|
37
39
|
Rcov::RcovTask.new do |test|
|
38
40
|
test.libs << 'test'
|
41
|
+
test.rcov_opts = ['--exclude', 'gems', "--text-report", "--only-uncovered"]
|
39
42
|
test.pattern = 'test/**/*_test.rb'
|
40
43
|
test.verbose = true
|
41
44
|
end
|
@@ -46,8 +49,6 @@ rescue LoadError
|
|
46
49
|
end
|
47
50
|
|
48
51
|
|
49
|
-
task :default => :test
|
50
|
-
|
51
52
|
require 'rake/rdoctask'
|
52
53
|
Rake::RDocTask.new do |rdoc|
|
53
54
|
if File.exist?('VERSION.yml')
|
@@ -63,5 +64,17 @@ Rake::RDocTask.new do |rdoc|
|
|
63
64
|
rdoc.rdoc_files.include('lib/**/*.rb')
|
64
65
|
end
|
65
66
|
|
67
|
+
begin
|
68
|
+
require 'cucumber'
|
69
|
+
require 'cucumber/rake/task'
|
70
|
+
|
71
|
+
Cucumber::Rake::Task.new(:features) do |t|
|
72
|
+
t.cucumber_opts = "features --format pretty"
|
73
|
+
end
|
74
|
+
rescue LoadError
|
75
|
+
task :features do
|
76
|
+
abort "Cucumber is not available. In order to run features, you must: sudo gem install cucumber"
|
77
|
+
end
|
78
|
+
end
|
66
79
|
|
67
|
-
task "
|
80
|
+
task "ci" => ["rcov", "features", "gemspec", "build", "install"]
|
data/VERSION.yml
CHANGED
data/bin/mtx
CHANGED
@@ -2,12 +2,12 @@
|
|
2
2
|
|
3
3
|
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib', 'minglr'))
|
4
4
|
|
5
|
-
uri_options, execution_options =
|
5
|
+
uri_options, execution_options = MTX::OptionsParser.parse(ARGV, :card, :transition)
|
6
6
|
|
7
7
|
Resources::Base.configure uri_options
|
8
8
|
|
9
|
-
if card = Card.find(execution_options[:card])
|
10
|
-
execution = TransitionExecution.create(execution_options)
|
9
|
+
if card = Resources::Card.find(execution_options[:card])
|
10
|
+
execution = Resources::TransitionExecution.create(execution_options)
|
11
11
|
raise "Transition aborted. Errors: #{execution.errors.full_messages}" if execution.errors.any?
|
12
12
|
else
|
13
13
|
raise "Card '#{execution_options[:card]}' cannot be found."
|
data/cucumber.yml
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
Feature: Cards
|
2
|
+
|
3
|
+
Scenario: Print list of cards in the project
|
4
|
+
Given the project "xp"
|
5
|
+
When I issue the "cards" action
|
6
|
+
Then the result should have "119 - Task - Build PDF Export Widget on UI" in it
|
7
|
+
And the result should have "105 - Epic - Find people, projects and documents" in it
|
8
|
+
|
9
|
+
Scenario: Print list of cards in the project with a filter
|
10
|
+
Given the project "xp"
|
11
|
+
And a keyword of "Project"
|
12
|
+
When I issue the "cards" action
|
13
|
+
Then the result should not have "119 - Task - Build PDF Export Widget on UI" in it
|
14
|
+
Then the result should have "113 - Feature - Update Project" in it
|
15
|
+
And the result should have "112 - Feature - Create Project" in it
|
16
|
+
And the result should have "105 - Epic - Find people, projects and documents" in it
|
17
|
+
|
18
|
+
Scenario: Print the details of a card in the project
|
19
|
+
Given the project "xp"
|
20
|
+
And the card number "112"
|
21
|
+
When I issue the "card" action
|
22
|
+
Then the result should have "Number: 112" in it
|
23
|
+
And the result should have "Name: Create Project" in it
|
24
|
+
And the result should have "Type: Feature" in it
|
25
|
+
And the result should have "Status:" in it
|
26
|
+
And the result should have "Description: N/A" in it
|
27
|
+
And the result should have "Attachments:" in it
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), "shared_steps")
|
2
|
+
|
3
|
+
Given /^the project "([^\"]*)"$/ do |project|
|
4
|
+
@project = project
|
5
|
+
end
|
6
|
+
|
7
|
+
Given /^the card number "([^\"]*)"$/ do |card_number|
|
8
|
+
if @options
|
9
|
+
@options << [card_number]
|
10
|
+
else
|
11
|
+
@options = [card_number]
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
Given /^a keyword of "([^\"]*)"$/ do |keyword|
|
16
|
+
if @options
|
17
|
+
@options << [keyword]
|
18
|
+
else
|
19
|
+
@options = [keyword]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
When /^I issue the "([^\"]*)" action$/ do |action|
|
24
|
+
@action = action
|
25
|
+
@response = execute_minglr_command(@project, @action, @options)
|
26
|
+
end
|
27
|
+
|
28
|
+
Then /^the result should have "([^\"]*)" in it$/ do |string|
|
29
|
+
assert @response.split("\n").join(" ").include?(string), "Expected #{@response.inspect} to contain '#{string}'"
|
30
|
+
end
|
31
|
+
|
32
|
+
Then /^the result should not have "([^\"]*)" in it$/ do |string|
|
33
|
+
assert !@response.split("\n").join(" ").include?(string), "Expected #{@response.inspect} to not contain '#{string}'"
|
34
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require "test/unit/assertions"
|
2
|
+
require File.join(File.dirname(__FILE__), "..", "..", "lib", "minglr")
|
3
|
+
World(Test::Unit::Assertions)
|
4
|
+
|
5
|
+
module Kernel
|
6
|
+
|
7
|
+
def capture_stdout
|
8
|
+
$stdout = $cucumberout
|
9
|
+
yield
|
10
|
+
return $cucumberout
|
11
|
+
ensure
|
12
|
+
$stdout = STDOUT
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
def rc_config
|
18
|
+
{
|
19
|
+
:scrum => {
|
20
|
+
:url => "http://localhost:9090/projects/scrum",
|
21
|
+
:password => "mingle",
|
22
|
+
:status_property => "cp_status",
|
23
|
+
:username => "schubert"
|
24
|
+
},
|
25
|
+
:global => {
|
26
|
+
:default => "blank",
|
27
|
+
:username => "schubert"
|
28
|
+
},
|
29
|
+
:storytracker => {
|
30
|
+
:url => "http://localhost:9090/projects/storytracker",
|
31
|
+
:password => "mingle",
|
32
|
+
:status_property => "cp_status",
|
33
|
+
:username => "schubert"
|
34
|
+
},
|
35
|
+
:xp => {
|
36
|
+
:url => "http://localhost:9090/projects/xp",
|
37
|
+
:password => "mingle",
|
38
|
+
:username => "schubert"
|
39
|
+
},
|
40
|
+
:blank => {
|
41
|
+
:url => "http://localhost:9090/projects/blank",
|
42
|
+
:password => "mingle",
|
43
|
+
:username => "schubert"
|
44
|
+
},
|
45
|
+
:agilehybrid => {
|
46
|
+
:url => "http://localhost:9090/projects/agilehybrid",
|
47
|
+
:password => "mingle",
|
48
|
+
:status_property => "cp_status",
|
49
|
+
:username=>"schubert"
|
50
|
+
}
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
def execute_minglr_command(project, action, extra_arguments = [])
|
55
|
+
$cucumberout = StringIO.new
|
56
|
+
extra_arguments = [] if extra_arguments.nil?
|
57
|
+
uri_options = rc_config[:global] || {}
|
58
|
+
original_arguments = ([project, action] + extra_arguments)
|
59
|
+
|
60
|
+
uri_options.merge! rc_config[project.to_sym]
|
61
|
+
Resources::Base.configure uri_options
|
62
|
+
Resources::Attachment.configure
|
63
|
+
extra_options = Minglr::OptionsParser.parse(original_arguments)
|
64
|
+
|
65
|
+
output = capture_stdout do
|
66
|
+
Minglr::Action.execute(action, original_arguments, extra_options, rc_config[project.to_sym])
|
67
|
+
end
|
68
|
+
output.string.strip
|
69
|
+
end
|
data/lib/minglr.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
MINGLR_ENV = "normal" unless defined?(MINGLR_ENV)
|
2
|
+
|
1
3
|
require 'rubygems'
|
2
4
|
require 'activesupport'
|
3
5
|
require 'activeresource'
|
@@ -8,8 +10,10 @@ require File.join(prefix, "action")
|
|
8
10
|
require File.join(prefix, "options_parser")
|
9
11
|
require File.join(prefix, "config_parser")
|
10
12
|
|
11
|
-
|
12
|
-
|
13
|
+
mtx = File.join(prefix, "mtx", "*")
|
14
|
+
Dir[mtx].each do |file_name|
|
15
|
+
load file_name
|
16
|
+
end
|
13
17
|
|
14
18
|
require File.join(prefix, "resources", "base")
|
15
19
|
resources = File.join(prefix, "resources", "*")
|
data/lib/minglr/action.rb
CHANGED
@@ -12,7 +12,7 @@ module Minglr
|
|
12
12
|
begin
|
13
13
|
Commands.send(action, options, flag_options, config)
|
14
14
|
rescue ActiveResource::ResourceNotFound => error
|
15
|
-
puts error.message + " for URL #{Resources::Base.site}..."
|
15
|
+
puts error.message + " for URL '#{Resources::Base.site}' ..."
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
@@ -34,7 +34,7 @@ module Minglr
|
|
34
34
|
|
35
35
|
def self.card(options, flag_options, config)
|
36
36
|
card_number = options.first
|
37
|
-
Resources::Card.print_card(
|
37
|
+
Resources::Card.print_card(card_number, config[:status_property])
|
38
38
|
end
|
39
39
|
|
40
40
|
def self.cards(options, flag_options, config)
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module MTX
|
2
|
+
class InputCache
|
3
|
+
class << self
|
4
|
+
def put(key, content)
|
5
|
+
File.open(file_pathname(key), File::CREAT | File::WRONLY | File::TRUNC) { |file| file.write content }
|
6
|
+
end
|
7
|
+
|
8
|
+
def get(key)
|
9
|
+
if content = File.read(file_pathname(key))
|
10
|
+
return nil if content.blank?
|
11
|
+
content
|
12
|
+
end
|
13
|
+
rescue
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
|
17
|
+
protected
|
18
|
+
|
19
|
+
def file_pathname(key)
|
20
|
+
File.join(Dir::tmpdir, "#{key.to_s.gsub(/[^\w]/, '')}.entry")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module MTX
|
2
|
+
|
3
|
+
class OptionsParser
|
4
|
+
|
5
|
+
def self.parse(args, *required_by_command)
|
6
|
+
uri_options = {}
|
7
|
+
command_options = {}
|
8
|
+
|
9
|
+
parser = OptionParser.new do |opts|
|
10
|
+
opts.banner = "Usage: mtx [options]"
|
11
|
+
opts.on("--transition TRANSITION", "Transition name.") do |transition|
|
12
|
+
command_options[:transition] = transition
|
13
|
+
end
|
14
|
+
|
15
|
+
opts.on("--card CARD", "Card number.") do |card|
|
16
|
+
command_options[:card] = card
|
17
|
+
end
|
18
|
+
|
19
|
+
opts.on("--properties ARGS", Array, "User-entered properties and values for the transition in array format. Must be an even number of comma-delimited values, like \"A,B,'C with spaces','D with spaces'\".") do |args|
|
20
|
+
command_options[:properties] = args.in_groups_of(2).map { |key, value| {'name' => key, 'value' => value} }
|
21
|
+
end
|
22
|
+
|
23
|
+
opts.on("--comment COMMENT", "Transition comment. This may be required depending on your transition settings.") do |comment|
|
24
|
+
command_options[:comment] = comment
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.on("--username USERNAME", "Mingle username.") do |username|
|
28
|
+
uri_options[:username] = username
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on("--password PASSWORD", "Mingle password.") do |password|
|
32
|
+
uri_options[:password] = password
|
33
|
+
end
|
34
|
+
|
35
|
+
opts.on("--host_port HOST_PORT", "Host and port.") do |host_and_port|
|
36
|
+
uri_options[:host_and_port] = host_and_port
|
37
|
+
end
|
38
|
+
|
39
|
+
opts.on("--project PROJECT", "Project name.") do |project|
|
40
|
+
uri_options[:project] = project
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
parser.parse! args
|
45
|
+
|
46
|
+
([:project, :host_and_port] | required_by_command).each do |arg|
|
47
|
+
unless command_options[arg] || uri_options[arg]
|
48
|
+
# TODO: let commands handle their own errors
|
49
|
+
$stderr.puts "Missing command-line argument --#{arg.to_s}, use --help for command-line options."
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
[uri_options, command_options]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
1
3
|
module Minglr
|
2
4
|
class OptionsParser
|
3
5
|
def self.parse(args, *required_by_command)
|
@@ -9,7 +11,7 @@ module Minglr
|
|
9
11
|
rescue ActiveResource::UnauthorizedAccess => exception
|
10
12
|
puts "Connection #{exception.message} to #{Resources::Base.site.to_s}"
|
11
13
|
puts "Did you set 'basic_authentication_enabled: true' in your auth_config.yml file?"
|
12
|
-
exit 1
|
14
|
+
exit 1 unless MINGLR_ENV == "test"
|
13
15
|
end
|
14
16
|
end
|
15
17
|
|
@@ -21,23 +23,23 @@ module Minglr
|
|
21
23
|
opts.separator "Valid Commands Are: #{Minglr::Action.valid_actions.join(", ")}"
|
22
24
|
|
23
25
|
opts.on("-n NAME", String, "Short name of card") do |card_name|
|
24
|
-
command_options[:name] = card_name
|
26
|
+
command_options[:name] = card_name.strip
|
25
27
|
end
|
26
28
|
|
27
29
|
opts.on("-d DESCRIPTION", String, "Description of card") do |card_description|
|
28
|
-
command_options[:description] = card_description
|
30
|
+
command_options[:description] = card_description.strip
|
29
31
|
end
|
30
32
|
|
31
33
|
opts.on("-t TYPE", String, "Type of card") do |card_type|
|
32
|
-
command_options[:card_type_name] = card_type
|
34
|
+
command_options[:card_type_name] = card_type.strip
|
33
35
|
end
|
34
36
|
|
35
37
|
opts.on("-c COMMENT", String, "Comment") do |comment|
|
36
|
-
command_options[:comment] = comment
|
38
|
+
command_options[:comment] = comment.strip
|
37
39
|
end
|
38
40
|
|
39
41
|
opts.on("-f FILE", String, "File to attach") do |file|
|
40
|
-
command_options[:file_attachment] = file
|
42
|
+
command_options[:file_attachment] = file.strip
|
41
43
|
end
|
42
44
|
|
43
45
|
unless project_options.empty?
|
@@ -56,17 +58,18 @@ module Minglr
|
|
56
58
|
|
57
59
|
opts.on_tail("-h", "--help", "Show this help message.") do |help|
|
58
60
|
puts opts
|
59
|
-
exit
|
61
|
+
exit 0 unless MINGLR_ENV == "test"
|
60
62
|
end
|
61
63
|
|
62
64
|
opts.on_tail("--version", "Show version") do
|
63
|
-
|
64
|
-
|
65
|
+
version = YAML.load(File.read(File.join(File.dirname(__FILE__), "..", "..", "VERSION.yml")))
|
66
|
+
puts "#{version[:major]}.#{version[:minor]}.#{version[:patch]}"
|
67
|
+
exit 0 unless MINGLR_ENV == "test"
|
65
68
|
end
|
66
69
|
|
67
70
|
if args.empty?
|
68
71
|
puts opts
|
69
|
-
exit
|
72
|
+
exit 0 unless MINGLR_ENV == "test"
|
70
73
|
end
|
71
74
|
end
|
72
75
|
|
@@ -8,6 +8,10 @@ module Resources
|
|
8
8
|
self.prefix += "cards/:card_number/"
|
9
9
|
end
|
10
10
|
|
11
|
+
def self.curl(command)
|
12
|
+
`#{command}`
|
13
|
+
end
|
14
|
+
|
11
15
|
def self.fetch(card_number, username, password)
|
12
16
|
if card_to_update = Card.find(card_number)
|
13
17
|
attachments = find(:all, :params => { :card_number => card_number })
|
@@ -15,7 +19,8 @@ module Resources
|
|
15
19
|
url = self.site + attachment.url
|
16
20
|
url.userinfo = nil, nil
|
17
21
|
puts "Downloading #{url.to_s}:"
|
18
|
-
|
22
|
+
command = "curl --insecure --progress-bar --output #{attachment.file_name} --user #{username}:#{password} #{url}"
|
23
|
+
curl(command)
|
19
24
|
end
|
20
25
|
end
|
21
26
|
end
|
@@ -7,7 +7,7 @@ module Resources
|
|
7
7
|
card = self.new(options)
|
8
8
|
if card.save
|
9
9
|
card.reload
|
10
|
-
puts "Card
|
10
|
+
puts "Card ##{card.number} created"
|
11
11
|
else
|
12
12
|
warn "Unable to create card"
|
13
13
|
end
|
@@ -17,7 +17,12 @@ module Resources
|
|
17
17
|
if card_to_move = find(card_number)
|
18
18
|
transition_options = { :card => card_number }
|
19
19
|
transition_options.merge!({ :comment => options[:comment]}) if options[:comment]
|
20
|
-
|
20
|
+
if config[:status_property]
|
21
|
+
current_status = card_to_move.send(config[:status_property])
|
22
|
+
else
|
23
|
+
warn "No known status of card ##{card_number}, cannot move!"
|
24
|
+
return
|
25
|
+
end
|
21
26
|
next_transition = nil
|
22
27
|
|
23
28
|
card_type = card_to_move.card_type_name.downcase
|
@@ -35,7 +40,8 @@ module Resources
|
|
35
40
|
key.to_s =~ /^story_state/
|
36
41
|
end
|
37
42
|
else
|
38
|
-
|
43
|
+
warn "No transitions defined for card of type #{card_to_move.card_type_name}"
|
44
|
+
return
|
39
45
|
end
|
40
46
|
status_states = status_states.collect {|state| state.last }.collect {|state| state.split(">").collect { |value| value.strip } }
|
41
47
|
next_transition = status_states.select {|state| state.first.downcase == current_status.downcase }.first.last
|
@@ -47,13 +53,13 @@ module Resources
|
|
47
53
|
end
|
48
54
|
end
|
49
55
|
else
|
50
|
-
warn "No card
|
56
|
+
warn "No card ##{card_number} found to move"
|
51
57
|
end
|
52
58
|
end
|
53
59
|
|
54
60
|
def self.print_all(options = [], status_property = nil)
|
55
61
|
attributes = [:number, :card_type_name, status_property, :name].compact
|
56
|
-
cards =
|
62
|
+
cards = find(:all)
|
57
63
|
cards.send(:extend, Minglr::Extensions::Array)
|
58
64
|
cards = cards.filter(attributes, options)
|
59
65
|
if cards.any?
|
@@ -64,8 +70,7 @@ module Resources
|
|
64
70
|
end
|
65
71
|
|
66
72
|
def self.print_card(card_number, status_property = nil)
|
67
|
-
|
68
|
-
if card = find(card_number)
|
73
|
+
if card = find(card_number.to_i)
|
69
74
|
puts card.to_s(status_property)
|
70
75
|
else
|
71
76
|
warn "No card ##{card_number} found"
|
@@ -78,24 +83,29 @@ module Resources
|
|
78
83
|
card_to_update.send("#{attribute.to_s}=".to_sym, value)
|
79
84
|
end
|
80
85
|
card_to_update.save
|
81
|
-
puts "Card
|
82
|
-
puts
|
86
|
+
puts "Card ##{card_to_update.number} updated\n\n"
|
87
|
+
puts card_to_update.to_s
|
83
88
|
else
|
84
|
-
warn "Unable to update card
|
89
|
+
warn "Unable to update card ##{card_number}"
|
85
90
|
end
|
86
91
|
end
|
87
92
|
|
88
93
|
def to_s(status_property = nil)
|
89
|
-
attachments =
|
90
|
-
|
91
|
-
|
94
|
+
attachments = []
|
95
|
+
begin
|
96
|
+
attachments = Attachment.find(:all, :params => { :card_number => number })
|
97
|
+
attachments = attachments.collect do |attachment|
|
98
|
+
"* #{attachment.file_name}: #{Resources::Base.site + attachment.url}"
|
99
|
+
end
|
100
|
+
rescue ActiveResource::ResourceNotFound => error
|
101
|
+
attachments = ["N/A"]
|
92
102
|
end
|
93
103
|
output = <<-EOS
|
94
104
|
Number: #{number}
|
95
105
|
Name: #{name}
|
96
|
-
Type: #{card_type_name}
|
97
|
-
Status: #{send(status_property) if status_property}
|
98
|
-
Description: #{description}
|
106
|
+
Type: #{card_type_name.nil? ? "N/A" : card_type_name}
|
107
|
+
Status: #{send(status_property) if status_property && self.respond_to?(status_property)}
|
108
|
+
Description: #{description.nil? ? "N/A" : description}
|
99
109
|
|
100
110
|
Attachments:
|
101
111
|
#{attachments.join("\n")}
|